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 || (