From b9fbbf8d30861646253c14d0f2c5746c8dc7ea59 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 4 Nov 2026 19:10:59 -0800 Subject: [PATCH] fix(native): continue runs woken by withdrawn or cancelled interactions (#19163) Withdrawing a card or cancelling a question wakes the assignee with that interaction id. The native interaction bridge required every wake interaction to be accepted, rejected or answered, so preparation failed with native_interaction_unresolved before the provider started. Project a cancelled interaction as a closed response with its stored result. Consumers grant authority only for accepted, answered or pending items, so the closed response carries context and no approval. Pending requests and governed tool reviews keep their existing checks. Co-Authored-By: Claude Opus 5.5 --- .../native-interaction-bridge-closed.test.ts | 177 ++++++++++++++++++ .../native-interaction-bridge.ts | 27 +++ 2 files changed, 204 insertions(+) create mode 100644 server/src/__tests__/native-interaction-bridge-closed.test.ts diff --git a/server/src/__tests__/native-interaction-bridge-closed.test.ts b/server/src/__tests__/native-interaction-bridge-closed.test.ts new file mode 100644 index 00000000000..9c179c16e01 --- /dev/null +++ b/server/src/__tests__/native-interaction-bridge-closed.test.ts @@ -0,0 +1,177 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { + agents, + companies, + createDb, + heartbeatRuns, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { + materializeLegacyQuestionResponseWakeProjection, + materializeNativeInteractionResponses, + NativeInteractionBridgeError, +} from "../services/native-runtime/native-interaction-bridge.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; + +describe("native interaction bridge with closed interactions", () => { + let temporary: Awaited> | null = null; + let db: ReturnType; + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-closed-interaction-"); + db = createDb(temporary.connectionString); + await db.insert(companies).values({ id: companyId, name: "Closed interaction", issuePrefix: "NCI" }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Closed interaction agent", + adapterType: "codex_local", + status: "running", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Continue after a withdrawn request", + status: "in_progress", + assigneeAgentId: agentId, + workMode: "standard", + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId }, + }); + }, 30_000); + + afterAll(async () => temporary?.cleanup()); + + it("projects withdrawn and cancelled interactions as closed without granting approval", async () => { + const withdrawnConfirmationId = randomUUID(); + const withdrawnSuggestionId = randomUUID(); + const cancelledQuestionsId = randomUUID(); + const pendingConfirmationId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: withdrawnConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + payload: { version: 1, prompt: "Restore the credential?" }, + }, + { + id: withdrawnSuggestionId, + companyId, + issueId, + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + payload: { version: 1, tasks: [{ clientKey: "follow-up", title: "Follow up" }] }, + }, + { + id: cancelledQuestionsId, + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [{ + id: "choice", + prompt: "Which path?", + selectionMode: "single", + options: [{ id: "safe", label: "Safe" }], + }], + }, + }, + { + id: pendingConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + payload: { version: 1, prompt: "Still waiting?" }, + }, + ]); + // Close them through the same service paths as the withdraw and cancel + // routes, which then wake the assignee with the closed interaction id. + const interactionSvc = issueThreadInteractionService(db); + const issue = { id: issueId, companyId, status: "in_progress" }; + const boardActor = { userId: "board-user" }; + const withdrawnConfirmation = await interactionSvc.withdrawInteraction( + issue, withdrawnConfirmationId, { reason: "No longer needed" }, boardActor); + const withdrawnSuggestion = await interactionSvc.withdrawInteraction( + issue, withdrawnSuggestionId, { reason: "No longer needed" }, boardActor); + const cancelledQuestions = await interactionSvc.cancelQuestions( + issue, cancelledQuestionsId, { reason: "Asked in another task" }, boardActor); + expect([withdrawnConfirmation.status, withdrawnSuggestion.status, cancelledQuestions.status]) + .toEqual(["cancelled", "cancelled", "cancelled"]); + + const responses = await materializeNativeInteractionResponses({ + db, + companyId, + issueId, + runId, + agentId, + interactionIds: [withdrawnConfirmationId, withdrawnSuggestionId, cancelledQuestionsId], + }); + expect(responses).toHaveLength(3); + expect(responses).toEqual(expect.arrayContaining([ + { + interactionId: withdrawnConfirmationId, + kind: "request_confirmation", + response: { + status: "cancelled", + result: { version: 1, outcome: "withdrawn", reason: "No longer needed" }, + }, + }, + { + interactionId: withdrawnSuggestionId, + kind: "suggest_tasks", + response: { + status: "cancelled", + result: { version: 1, outcome: "withdrawn", reason: "No longer needed" }, + }, + }, + { + interactionId: cancelledQuestionsId, + kind: "ask_user_questions", + response: { + status: "cancelled", + result: expect.objectContaining({ cancelled: true, answers: [] }), + }, + }, + ])); + // A cancelled question carries no answers for the legacy wake prompt. + await expect(materializeLegacyQuestionResponseWakeProjection({ + db, + companyId, + issueId, + runId, + agentId, + interactionId: cancelledQuestionsId, + })).resolves.toBeNull(); + // A still-open request remains unresolved; withdrawal does not relax that. + const error = await materializeNativeInteractionResponses({ + db, + companyId, + issueId, + runId, + agentId, + interactionIds: [pendingConfirmationId], + }).catch((caught) => caught); + expect(error).toBeInstanceOf(NativeInteractionBridgeError); + expect(error).toMatchObject({ code: "native_interaction_unresolved" }); + }); +}); diff --git a/server/src/services/native-runtime/native-interaction-bridge.ts b/server/src/services/native-runtime/native-interaction-bridge.ts index 4243bcfa6a3..aeae4fd2c84 100644 --- a/server/src/services/native-runtime/native-interaction-bridge.ts +++ b/server/src/services/native-runtime/native-interaction-bridge.ts @@ -43,6 +43,15 @@ function record(value: unknown): Record { : {}; } +const CLOSABLE_INTERACTION_KINDS: ReadonlySet = new Set([ + "connection_intent", + "suggest_tasks", + "ask_user_questions", + "request_confirmation", + "request_checkbox_confirmation", + "request_item_verdicts", +]); + export type NativeAttentionMaterializedTarget = { effectKind: string; targetType: string; @@ -254,6 +263,24 @@ export async function materializeNativeInteractionResponses(input: { }); continue; } + // Withdraw and cancel close a request without granting anything, yet + // they still wake the assignee to continue. Project the closed lifecycle + // as context; consumers act only on accepted, answered or pending items. + if ( + interaction.status === "cancelled" + && interaction.result + && CLOSABLE_INTERACTION_KINDS.has(interaction.kind) + ) { + responses.push({ + interactionId: interaction.id, + kind: interaction.kind as NativeInteractionResponseEnvelope["kind"], + response: { + status: interaction.status, + result: structuredClone(interaction.result) as unknown as Record, + }, + }); + continue; + } if ( interaction.resolvedByRunId === input.runId || (