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>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-11-04 19:10:59 -08:00
1 parent 5fdfd3f17a
commit b9fbbf8d30
2 files changed
+204

No files matched your search

@@ -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<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" });
});
});
@@ -43,6 +43,15 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
const CLOSABLE_INTERACTION_KINDS: ReadonlySet<string> = new Set<NativeInteractionResponseEnvelope["kind"]>([
"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<string, unknown>,
},
});
continue;
}
if (
interaction.resolvedByRunId === input.runId
|| (