diff --git a/server/src/services/native-runtime/native-question-bridge.test.ts b/server/src/services/native-runtime/native-question-bridge.test.ts index bc9551c295..976be6d578 100644 --- a/server/src/services/native-runtime/native-question-bridge.test.ts +++ b/server/src/services/native-runtime/native-question-bridge.test.ts @@ -318,6 +318,29 @@ describeEmbeddedPostgres("native question bridge", () => { } }); + it("accepts the Rust ACPX cancellation payload without a redundant action field", async () => { + await seed(); + const { interaction, cancelled } = await savedRuntimeQuestion(); + // Both Rust expire_runtime_requests and RuntimeRequestOutcome emit this + // envelope. Cancellation authority is its committed event type. + cancelled.payload = { provider: "acpx", requestId: "request-1", requestKind: "runtime", requestType: "input", + turnId: cancelled.turnId, itemId: cancelled.itemId, reason: "session_interrupted", replayAllowed: false, + adapter: "acpx-runtime-sidecar", request: { ...(runtimeRequestEvent().payload.request as Record), + turnId: cancelled.turnId, itemId: cancelled.itemId } }; + await projectNativeRuntimeRequest({ db, binding: binding(), event: cancelled }); + const [current] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id)); + expect(current).toMatchObject({ status: "expired", result: { cancelled: true, answers: [] } }); + }); + + it.each([{ action: "submit" }, { replayAllowed: true }, { response: { answers: {} } }])("rejects contradictory cancellation data %j", async (extra) => { + await seed(); + const { interaction, cancelled } = await savedRuntimeQuestion(); + Object.assign(cancelled.payload, extra); + await expect(projectNativeRuntimeRequest({ db, binding: binding(), event: cancelled })).rejects.toThrow("native_runtime_cancellation_invalid"); + const [current] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id)); + expect(current.status).toBe("pending"); + }); + it("commits and acknowledges an ACP permission, then queues only an admin's exact turn-bound decision", async () => { await seed(); const event = permissionRequestEvent(); diff --git a/server/src/services/native-runtime/native-question-bridge.ts b/server/src/services/native-runtime/native-question-bridge.ts index 309d3ba151..6edfc15add 100644 --- a/server/src/services/native-runtime/native-question-bridge.ts +++ b/server/src/services/native-runtime/native-question-bridge.ts @@ -131,7 +131,8 @@ export async function projectNativeRuntimeRequest(input: { // Permission cards have their own privileged resolver. A provider-loss // expiry is a durable handoff, not cancellation of a historical question. if (outcome.requestKind !== "runtime" || outcome.requestType !== "input") return null; - if (outcome.action !== "cancel" || typeof outcome.requestId !== "string" + if ((outcome.action !== undefined && outcome.action !== "cancel") || outcome.replayAllowed === true + || typeof outcome.requestId !== "string" || !REQUEST_ID_PATTERN.test(outcome.requestId) || typeof input.event.turnId !== "string" || outcome.turnId !== input.event.turnId || (outcome.itemId ?? null) !== (input.event.itemId ?? null) || ["response", "answers", "answer", "replay"].some((key) => Object.hasOwn(outcome, key))) {