diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 442dbeb0390..9487cc0e2f7 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -575,6 +575,33 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(await db.select().from(heartbeatRuns)).toHaveLength(3); }); + // A composer snapshot taken before the card was recognised as a tool action + // must not steer or interrupt the active turn just to deliver its result. + it.each([ + { action: "steer", native: true }, + { action: "interrupt", native: false }, + ] as const)("refuses to $action with a tool action's outcome from a stale snapshot", async ({ action, native }) => { + const seeded = await seedResponseQueue(native); + const client = app(seeded.companyId); + const stale = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + expect(stale.body.entries).toHaveLength(1); + await db.update(issueThreadInteractions).set({ title: "Approve tool action", payload: { version: 1, prompt: "Create the page?", + toolAction: { toolName: "notebook_create_page", actionRequestId: randomUUID() } } as never }) + .where(eq(issueThreadInteractions.id, seeded.interactionId)); + const steerCalls = steerNativeSessionMock.mock.calls.length; + const body = { queueId: seeded.wakeId, targetRunId: seeded.runId, revision: stale.body.revision }; + const endpoint = action === "steer" + ? `/api/issues/${seeded.issueId}/queued-comments/${seeded.interactionId}/steer` + : `/api/issues/${seeded.issueId}/queued-comments/interrupt`; + const result = await request(client).post(endpoint).send(body).expect(409); + expect(result.body.details.code).toBe("queued_comment_not_pending"); + expect(steerNativeSessionMock.mock.calls).toHaveLength(steerCalls); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId)))[0].status).toBe("running"); + // The result is still owed to the agent after the current turn. + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId)))[0].status) + .toBe("deferred_issue_execution"); + }); + it("steers a saved approval only on click and acknowledges retries once", async () => { const seeded = await seedResponseQueue(true); await seedDispatchIdentity(seeded); @@ -791,6 +818,37 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(await hasQueuedInteractionResponse(db, seeded.companyId, seeded.issueId, seeded.agentId)).toBe(true); }); + // Approve = run: the tool already ran (or was declined) when its outcome wake + // is deferred behind a later turn. That wake hands the recorded result to the + // agent; it is not a message the person still has to send, so the composer + // queue must not show it as "Accepted: Approve tool action". + // The outcome delivery carries the tool action; a recovered continuation + // carries only the interaction id, so the saved card must decide. + it.each((["delivery", "recovery"] as const).flatMap(wakeShape => ([ + { status: "accepted", outcome: "executed", wakeShape }, + { status: "rejected", outcome: "rejected", wakeShape }, + ] as const)))("does not queue a $status tool action's outcome ($wakeShape wake) as a message", async ({ status, outcome, wakeShape }) => { + const toolActionCase = { + kind: "request_confirmation", status, title: "Approve tool action", label: `${status}`, + payload: { version: 1, prompt: "Create the page?", toolAction: { toolName: "notebook_create_page", actionRequestId: randomUUID() } }, + result: { version: 1, outcome: status, toolAction: { executionStatus: outcome, resultSummary: "Page created" } }, + } as unknown as (typeof deliveredReceiptCases)[number]; + const seeded = await seedDeliveredReceipt(toolActionCase, null); + const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.staleWakeId)); + if (wakeShape === "delivery") { + const toolAction = { toolName: "notebook_create_page", actionRequestId: randomUUID(), executionStatus: outcome }; + await db.update(agentWakeupRequests).set({ requestedByActorType: "user", requestedByActorId: "queue-owner", + payload: { ...wake.payload, interactionKind: "request_confirmation", toolAction, toolActions: [toolAction], + toolActionRequestIds: [toolAction.actionRequestId] } }).where(eq(agentWakeupRequests.id, seeded.staleWakeId)); + } + const queue = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + expect(queue.body.entries).toEqual([]); + // Hiding the row must not drop the outcome: the agent still receives it. + const [after] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.staleWakeId)); + expect(after.status).toBe("deferred_issue_execution"); + expect(await hasQueuedInteractionResponse(db, seeded.companyId, seeded.issueId, seeded.agentId)).toBe(true); + }); + it("shows a later undelivered receipt instead of an earlier delivered one", async () => { const seeded = await seedDeliveredReceipt(deliveredReceiptCases[3], "succeeded"); const pendingId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6167138a439..ba8255ea1f0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7602,7 +7602,8 @@ export function issueRoutes( if (wake.status === "deferred_issue_execution") { if (isReceipt) { const response = await readReceipt(); - if (response) receipt = { wake, state: "deferred", queueRun: null, response }; + // A tool action's outcome is delivered, never listed (approve = run). + if (response && !response.toolActionOutcome) receipt = { wake, state: "deferred", queueRun: null, response }; continue; } return { wake, state: "deferred", queueRun: null }; @@ -7624,7 +7625,7 @@ export function issueRoutes( .then((runRows) => runRows[0] ?? null); if (queueRun && isReceipt) { const response = await readReceipt(); - if (response) receipt = { wake, state: "queued", queueRun, response }; + if (response && !response.toolActionOutcome) receipt = { wake, state: "queued", queueRun, response }; } else if (queueRun) return { wake, state: "queued", queueRun }; } @@ -7790,7 +7791,11 @@ export function issueRoutes( if ( !wake || readObject(wake.payload).issueId !== input.issue.id || - (queuedCommentIdsFromWakePayload(wake.payload).length === 0 && !queuedInteractionId(wake.payload)) + (queuedCommentIdsFromWakePayload(wake.payload).length === 0 && !queuedInteractionId(wake.payload)) || + // A tool action's outcome is never a message to steer, interrupt or discard. + (queuedInteractionId(wake.payload) && + (await readQueuedInteractionResponse(input.tx as unknown as Db, input.issue.companyId, input.issue.id, wake.payload)) + ?.toolActionOutcome) ) { throw conflict("The queued message is no longer pending", { code: "queued_comment_not_pending", diff --git a/server/src/services/queued-interaction-response.ts b/server/src/services/queued-interaction-response.ts index 5486716ffc6..9d161623c36 100644 --- a/server/src/services/queued-interaction-response.ts +++ b/server/src/services/queued-interaction-response.ts @@ -16,6 +16,20 @@ export function queuedInteractionId(payload: unknown): string | null { ? p.interactionId : null; } +/** + * A governed tool action resolves as approve = run: by the time a receipt for + * it waits behind another turn, the tool already ran or was declined, and the + * approval card shows that outcome. The receipt only hands the recorded result + * to the agent. It still counts as owed input for delivery and recovery, but it + * is not a message the person can send, steer or interrupt with, so the + * composer queue never lists it. The saved interaction decides this, because a + * recovered wake does not carry the tool action fields of the original one. + */ +export function isToolActionConfirmation(interaction: { kind: string; payload?: unknown }): boolean { + const toolAction = record(record(interaction.payload).toolAction); + return interaction.kind === "request_confirmation" && Object.keys(toolAction).length > 0; +} + /** * One readable line for a queued response. The steering body also carries the * typed JSON receipt for the agent; people only ever see this line. @@ -115,7 +129,7 @@ export async function readQueuedInteractionResponse(db: Db, companyId: string, i requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true, summary: heading, }; - return { comment, source }; + return { comment, source, toolActionOutcome: isToolActionConfirmation(interaction) }; } /** A saved answer is a live continuation, even though its card is no longer pending. */