diff --git a/apps/server/src/jira/JiraIssueBridge.ts b/apps/server/src/jira/JiraIssueBridge.ts index 7a709172be4..34eeafe3aa2 100644 --- a/apps/server/src/jira/JiraIssueBridge.ts +++ b/apps/server/src/jira/JiraIssueBridge.ts @@ -54,7 +54,11 @@ import { postDiscordChannelMessage, resolveDiscordBotToken, } from "./jiraDiscordContext.ts"; -import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts"; +import { + buildJiraContextOnlyPrompt, + buildJiraTurnPrompt, + type JiraIssueInvocation, +} from "./JiraWebhookPayload.ts"; const NOT_LINKED_RESPONSE = "not yet linked. No T3 thread lists this issue, and auto-create could not pick a project (set T3CODE_JIRA_PROJECT_MAP for this Jira key, T3CODE_JIRA_DEFAULT_PROJECT_ID, or ensure exactly one T3 project exists)."; @@ -661,8 +665,8 @@ const make = Effect.gen(function* () { return; } - // Untrusted actors: optional chat context note only (no agent). Never auto-creates. - // Requires a unique chat-linked issue in links.json when filing context. + // Untrusted actors: Discord context note (required) + optional T3 transcript note. + // Never starts an agent turn. Never auto-creates links. if (trust.mode === "context-only") { const linksPath = config.discordLinksPath; if (linksPath === null || linksPath.length === 0) { @@ -715,12 +719,62 @@ const make = Effect.gen(function* () { return; } - yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", { + // Best-effort T3 transcript mirror when the Discord link carries a live T3 thread. + let t3MessageId: string | null = null; + if (discordLink.t3ThreadId !== null) { + const t3ThreadId = discordLink.t3ThreadId as ThreadId; + const snapshot = yield* projection + .getThreadDetailById(t3ThreadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(snapshot)) { + const commandId = CommandId.make(yield* crypto.randomUUIDv4); + const messageId = MessageId.make(yield* crypto.randomUUIDv4); + const mapPeople = yield* identity.listMapPeople(); + const source = jiraSourceRef(input.invocation, mapPeople); + const mirrored = yield* engine + .dispatch({ + type: "thread.message.append", + commandId, + threadId: t3ThreadId, + message: { + messageId, + role: "user", + text: buildJiraContextOnlyPrompt(input.invocation), + attachments: [], + }, + source, + createdAt: DateTime.formatIso(yield* DateTime.now), + }) + .pipe( + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("Failed to mirror Jira context note into T3 transcript", { + deliveryId: input.deliveryId, + threadId: t3ThreadId, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (mirrored) { + t3MessageId = messageId; + yield* workItems + .appendForThread({ + threadId: t3ThreadId, + jiraIssueKeys: [input.invocation.issueKey], + source: "jira-webhook", + }) + .pipe(Effect.ignore); + } + } + } + + yield* Effect.logInfo("Posted Jira context-only note (no agent run)", { deliveryId: input.deliveryId, issueKey: input.invocation.issueKey, discordThreadId: discordLink.discordThreadId, t3ThreadId: discordLink.t3ThreadId, discordMessageId: posted.message.id, + t3MessageId, }); const notedDelivery: StoredJiraDelivery = { ...acknowledged, @@ -728,6 +782,7 @@ const make = Effect.gen(function* () { discordLink.t3ThreadId !== null ? (discordLink.t3ThreadId as ThreadId) : acknowledged.threadId, + userMessageId: t3MessageId, }; yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed"); return; diff --git a/apps/server/src/jira/JiraWebhookPayload.ts b/apps/server/src/jira/JiraWebhookPayload.ts index d3950142063..7b5803856ea 100644 --- a/apps/server/src/jira/JiraWebhookPayload.ts +++ b/apps/server/src/jira/JiraWebhookPayload.ts @@ -433,6 +433,25 @@ export function buildJiraTurnPrompt(invocation: JiraIssueInvocation): string { return lines.join("\n"); } +/** + * Context-only transcript note for untrusted Jira actors (no agent turn). + */ +export function buildJiraContextOnlyPrompt(invocation: JiraIssueInvocation): string { + const requester = invocation.actorDisplayName ?? invocation.actorAccountId ?? "unknown"; + const lines = [ + ...jiraPromptHeaderLines(invocation), + "- Trust: context-only (Jira actor not in identity map; no agent run)", + "-->", + "", + `**Jira context note** (no agent run) from [${requester}] on [${invocation.issueKey}]${ + invocation.commentUrl ? `(${invocation.commentUrl})` : "" + }:`, + "", + invocation.prompt, + ].filter((line): line is string => line !== null); + return lines.join("\n"); +} + /** * Stable delivery id: creates dedupe on comment id; updates include updated-at / prompt * so redeliveries of the same edit collapse but new edits re-run. diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c6fe019e50a..eaed9f29936 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1661,6 +1661,72 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.append": { + // Context-only transcript note: message-sent without turn-start-requested. + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const messageSentEvent: PlannedOrchestrationEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: command.message.role, + text: command.message.text, + ...(command.message.attachments !== undefined + ? { attachments: command.message.attachments } + : {}), + turnId: null, + streaming: false, + ...(command.source !== undefined ? { source: command.source } : {}), + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + const lifecycleResetEvents: Array = []; + if (thread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + if (thread.snoozedUntil != null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, messageSentEvent]; + } + case "thread.activity.append": { const thread = yield* requireThread({ readModel, diff --git a/docs/user/jira-issue-conversations.md b/docs/user/jira-issue-conversations.md index 227edb39b5b..f3de58aafbc 100644 --- a/docs/user/jira-issue-conversations.md +++ b/docs/user/jira-issue-conversations.md @@ -174,8 +174,19 @@ are logged and never block the turn. - Require a shared secret on every delivery (`Authorization: Bearer …` or `X-T3-Webhook-Secret`). - Cap body size at 1 MiB. + <<<<<<< HEAD:docs/user/jira-issue-conversations.md - Ignore events that are not `comment_created`. -- Allowlist projects when configured. +- # Allowlist projects when configured. +- Ignore events that are not `comment_created` / `comment_updated`. +- Allowlist projects when configured (`T3CODE_JIRA_ALLOWED_PROJECTS`). +- **Identity map trust gate** (when `T3_IDENTITY_MAP_PATH` has people): + - **Trusted** — Jira `accountId` appears on a map person (`jira.accountId` / `jiraAccountId`) → full agent turn (same as today, including auto-create when enabled). + - **Untrusted** — map on but actor missing/unmapped → **context only** (no agent): + 1. **Required:** post a note into the unique Discord thread linked to the issue (`links.json` + `DISCORD_BOT_TOKEN`). + 2. **Best-effort:** when that link also has a live T3 thread id, append a transcript note via `thread.message.append` (no turn). + Requires exactly one active Discord link with that issue key; never auto-creates. + - Map **off** / empty → legacy full access for all mentioners (backward compatible). + > > > > > > > 9bc1befdc (feat(jira): mirror untrusted context notes into T3 transcript):docs/integrations/jira-issue-conversations.md - Do not put secrets in prompts, delivery logs, or git. - Prefer the free Atlassian **service account** for REST replies (see [atlassian-service-accounts](./atlassian-service-accounts.md) when present on the branch). diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 242cb7fafc2..942793c309c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1022,6 +1022,26 @@ const ThreadActivityAppendCommand = Schema.Struct({ createdAt: IsoDateTime, }); +/** + * Server-internal: append a transcript message without starting a turn. + * Used for untrusted integration context notes so operators (and later + * agent turns) can see platform context without granting host control. + */ +const ThreadMessageAppendCommand = Schema.Struct({ + type: Schema.Literal("thread.message.append"), + commandId: CommandId, + threadId: ThreadId, + message: Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "system"]), + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + }), + /** Server-authored only. */ + source: Schema.optional(SourceRef), + createdAt: IsoDateTime, +}); + /** * Server-internal: dispatch the queued-message head as a turn after a * natural (non-interrupted) turn completion. Rejected when the queue is @@ -1076,6 +1096,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, + ThreadMessageAppendCommand, ThreadQueueDrainCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand,