From 96a313077f7635815840647a46157ad416a1648e Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 20:06:20 +0900 Subject: [PATCH 1/3] test(acp): cover delayed cancel follow-up settlement --- .../test/acp/acp-cancel-settlement.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts index 5fcab0154f0..07d740ec4c6 100644 --- a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts +++ b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts @@ -3296,3 +3296,21 @@ test("successful remote session retirement releases an unresolved abort owner", fixture.dispose(); } }); + +test("cancel 100ms after background prompt start settles cancelled and follow-up end_turn", async () => { + const fixture = await createFixture({ cancelSettlementGraceMs: 25 }); + try { + const background = prompt(fixture, "sleep 5000"); + await bounded(fixture.promptDelivered, "background prompt delivery"); + await Bun.sleep(100); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "background cancel acknowledgement"); + fixture.sendStopped("cancelled"); + expect(await bounded(background, "background cancelled settlement")).toEqual({ stopReason: "cancelled" }); + const followUp = prompt(fixture, "follow-up"); + await waitFor(() => fixture.promptDeliveryCount() === 2, "follow-up prompt delivery"); + fixture.sendStopped("end_turn"); + expect(await bounded(followUp, "follow-up completion")).toEqual({ stopReason: "end_turn" }); + } finally { + fixture.dispose(); + } +}); From 5d4d0f358b2c3b88539f8df587851b410218abe0 Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 20:35:20 +0900 Subject: [PATCH 2/3] test(acp): exercise grace cancellation settlement Remove the injected cancelled terminal so the delayed background cancel regression covers the adapter's own settlement path. --- packages/coding-agent/test/acp/acp-cancel-settlement.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts index 07d740ec4c6..64e296032f6 100644 --- a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts +++ b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts @@ -3304,7 +3304,6 @@ test("cancel 100ms after background prompt start settles cancelled and follow-up await bounded(fixture.promptDelivered, "background prompt delivery"); await Bun.sleep(100); await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "background cancel acknowledgement"); - fixture.sendStopped("cancelled"); expect(await bounded(background, "background cancelled settlement")).toEqual({ stopReason: "cancelled" }); const followUp = prompt(fixture, "follow-up"); await waitFor(() => fixture.promptDeliveryCount() === 2, "follow-up prompt delivery"); From 83950d189eb0be3c8ef8b10fb244df139035c74c Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 4 Oct 2026 20:38:21 +0900 Subject: [PATCH 3/3] fix(acp): preserve cancelled stop reason after acknowledged abort A terminal stopped frame racing an acknowledged client cancellation must settle the owned prompt as cancelled, regardless of the stale terminal reason. --- packages/coding-agent/src/modes/acp/acp-agent.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 247147a8d79..f0e9acc3a6e 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -5274,7 +5274,9 @@ export class AcpAgent implements Agent { const { outcome } = waiter.terminal; this.#rememberSettledPromptCorrelation(id, record, waiter.correlation); if (outcome.kind === "stopped") { - waiter.resolve({ stopReason: outcome.reason }); + // Once the SDK has acknowledged a client cancel, the cancellation owns the + // turn even if a stale terminal frame races in with another stopped reason. + waiter.resolve({ stopReason: waiter.cancelAcknowledged ? "cancelled" : outcome.reason }); return; } // `phase` is the host's own claim, and a host that omits it leaves `terminalOutcome`