Skip to content

feat: make request cancellation spec-conformant and avoid leaving a turn hanging - #131

Merged
EugeneTheDev merged 2 commits into
masterfrom
eugenethedev/cancellation-v2
Sep 22, 2026
Merged

EugeneTheDev merged 2 commits into
masterfrom
eugenethedev/cancellation-v2

Conversation

@EugeneTheDev

Copy link
Copy Markdown
Collaborator

Summary

Audited cancellation against the spec ($/cancel_request, session/cancel, prompt lifecycle) and fixed seven defects. Two themes:

  1. $/cancel_request did not follow the spec — a cancelled request was left unanswered, a cancel arriving after the reply could kill work the peer never asked to stop, and the notification carried a field the schema does not define.
  2. A v2 prompt turn could end without its mandatory terminal state_update, leaving a client waiting forever. This was reachable from four different directions; all four are now closed by a single reporting path.

session/cancel was already conformant and is unchanged.

Problems found and fixes

# Problem Fix
A1 A cancelled incoming request got no reply at all, though the spec says the receiver MUST answer. A conformant peer waited forever; Kotlin↔Kotlin burned the full 1 s graceful timeout on every cancel. Always reply; a peer-driven cancel now answers with -32800. Removed the now-dead "omitted reply" plumbing. Round trip measured at 3–5 ms.
A2 A $/cancel_request for an already answered request still hard-cancelled its follow-up work — which, for a v2 prompt, is the whole turn. The turn died silently with no idle update. Pending requests now carry a cancellableByCounterpart flag that is disarmed (atomically) when the reply is collected, so a peer's cancel of a settled id is inert. Local cancellation is unaffected and still reaches settled requests, which close() needs.
A3 A -32800 response from a peer was converted into a CancellationException and thrown from an uncancelled coroutine, silently tearing down the caller's scope. An agent whose permission request was answered -32800 lost the turn with no error surfaced. New public AcpRequestCancelledException (a plain Exception, not a CancellationException), used on both the single-request and batch paths. The v2 agent catches it and ends the turn with cancelled. Only public API addition in this PR.
A4 CancelRequestNotification carried a message field the v2 schema does not define, and the SDK always populated it from the local CancellationException — so it was usually on the wire. Removed the field. There is nowhere conformant to put a human-readable reason, and it was only ever a log string; the cancelling side still logs its own, and the cancelled side reports a fixed "Cancelled by the counterpart". The -32800 response path still carries the cancelled side's message.
A5 If an implementation's prompt flow threw anything else, the turn produced no terminal update either — same dead end, reached from any ordinary bug or transport hiccup. Safety net: the agent's after-response handler now catches Throwable and emits a terminal idle with stopReason = null (the honest answer — there is no error stop reason, and refusal/notice would misinform the client). CancellationException is rethrown untouched so shutdown stays silent; the emit is wrapped so a failed send cannot mask the original error.
A6 cancelPendingIncomingRequest(s) are public and callable on a live protocol. Used there, they stranded a settled prompt: the client kept its successful response but never got the rest of the stream or the terminal update. The CancellationException branch now reports Idle(cancelled) before rethrowing. To avoid a second terminal update on the session/cancel path, all updates funnel through an idempotent reportIdle(). Nothing reaches the wire on shutdown, because sendFrame already refuses to send on a dead protocol.
A7 Between collecting the reply and invoking the after-response work there was a pre-emptive discard point, so a cancel landing in that window dropped the turn where no handler could observe it. In a batch the window is as long as the slowest sibling, so it was not just a narrow race. Made the discard cooperative: after-response work is now entered even when already cancelled and observes it at its first suspension point, which turns this into the A6 case that already reports. The wait for the reply frame is now NonCancellable, so follow-up work can never overtake the reply that announced it.

Testing

Every fix ships with tests that were confirmed to fail with that fix removed, at both the protocol level (ProtocolTest, BatchProtocolTest) and end-to-end on both drivers (V2ClientTest, AgentTest) — including the negative cases: close() must not synthesise an idle update, a cancel for an answered prompt must leave the turn running, and a turn cancelled before it started must not report an outcome it never reached. The A4 removal also updated the tests that expected the caller's cancellation reason to cross the wire; they now expect the fixed message. Full JVM suite green.

Compatibility

Two API changes, both in the cancellation surface:

  • Added AcpRequestCancelledException (A3). Callers that relied on catching a CancellationException for a peer's -32800 must switch to it — which is the point: that conversion was cancelling scopes silently.
  • Removed CancelRequestNotification.message (A4), along with its constructor parameter. apiDump refreshed.

Everything else is unchanged; the typed Agent, Client, and session APIs are untouched.

@EugeneTheDev
EugeneTheDev force-pushed the eugenethedev/cancellation-v2 branch from f0b1d1f to 5e322ab Compare September 16, 2026 17:28
@EugeneTheDev
EugeneTheDev requested a review from Rizzen September 16, 2026 17:30
@EugeneTheDev
EugeneTheDev force-pushed the eugenethedev/cancellation-v2 branch from 5e322ab to 7c38491 Compare September 18, 2026 13:18
@EugeneTheDev
EugeneTheDev merged commit c168109 into master Sep 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants