Files
PaperClipAI/server/src/__tests__/native-interaction-bridge-closed.test.ts
T
Devin FoleyandClaude Opus 5.5 b9fbbf8d30 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 <noreply@anthropic.com>
2026-11-04 19:10:59 -08:00

178 lines
6.0 KiB
TypeScript

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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
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" });
});
});