Repository navigation
fix: v1 prompt cancellation targets its own turn, answers after the session's cancel, one turn at a time - #155
Open
EugeneTheDev wants to merge 5 commits into
Open
EugeneTheDev wants to merge 5 commits into
EugeneTheDev wants to merge 5 commits into
Conversation
…-up prompt SessionWrapper.prompt cleared the active-prompt slot unconditionally when it finished, wiping whichever turn was registered at that moment: - after session/cancel, cancel() frees the slot so the next prompt can start; when the cancelled turn then ends, it cleared the new turn's registration, so a second session/cancel never stopped the new turn; - a prompt rejected with "There is already active prompt execution" cleared the running turn's registration, so a later cancel never stopped it. In both cases the cancelled session/prompt never answered, while the spec requires it to respond with the `cancelled` stop reason. Clear the slot only if it still holds this prompt's own registration, and not at all for a rejected prompt. Claude-Session: https://claude.ai/code/session_01ViX9HtaVGkZASCLwEUDGaA
…ived SessionWrapper.cancel() awaited AgentSession.cancel() and only then took the turn out of the active-prompt slot. AgentSession.cancel() may suspend until the turn's activities are cancelled, and the cancelled turn can answer meanwhile. A client that then sends the next prompt registers a new turn, and cancel() cancelled that turn without the client asking. Take the turn to cancel before notifying the session, then clear the slot only if it still holds that turn. Cancelling a turn that already ended is a no-op. Also register the prompt before the try block, so the finally clears this turn's own registration without a nullable holder, and pin the rejection of an overlapping prompt to its error code and message in the test. Claude-Session: https://claude.ai/code/session_01ViX9HtaVGkZASCLwEUDGaA
…e turn at a time The v1 SessionWrapper answered a cancelled turn as soon as its prompt flow ended, even while AgentSession.cancel() was still suspended. Updates sent from cancel() could then reach the client after the session/prompt response, and the client's confirmation of the cancel came before the agent had finished cancelling. The spec requires the response after all ongoing operations are aborted and pending updates are sent. A turn now counts the cancels running for it and answers only after all of them have returned. A cancel that arrives after the turn started answering does not hold it. cancel() also no longer frees the active-prompt slot early. Only the turn frees it when it answers, so the SDK runs one turn at a time per session: a session/prompt sent before the previous turn has answered, including a cancelled turn that is still winding down, is rejected with -32603. The protocol does not define overlapping prompts, so AgentSession.prompt() documents this as an SDK guarantee. AgentSession.cancel() documents the response ordering, that it must not wait for the response, and that calls may overlap. The turn to cancel is still taken before AgentSession.cancel() is called, so a cancel that arrives just after a turn answered never cancels the next turn the client starts meanwhile.
EugeneTheDev
marked this pull request as ready for review
October 28, 2026 14:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
In the v1
Agent,SessionWrappertracks the running turn in an active-prompt slot. Its bookkeeping around that slot and aroundsession/cancellet a turn get lost, get cancelled by mistake, or answer too early. That happens in five scenarios:session/promptwhile turn 1 runs is correctly rejected with-32603 "There is already active prompt execution". But itsfinallystill ran and cleared turn 1's registration, so a latersession/cancelnever stopped turn 1.cancel()freed the slot as soon asAgentSession.cancel()returned, while turn 1 was still winding down. The client's nextsession/promptregistered turn 2, and turn 1'sfinallythen cleared turn 2's registration (_activePrompt.getAndSet(null)). A secondsession/cancelfound nothing to cancel, and turn 2 never answered.AgentSession.cancel().cancel()awaitedAgentSession.cancel()and only then took whatever was in the slot. If the client sends the next prompt meanwhile,cancel()cancelled that turn, which nosession/canceltargeted, and it answeredcancelled. The spec definescancelledas "The Client cancels the turn".session/promptas soon as the turn's flow ended, even whileAgentSession.cancel()was still suspended. Its KDoc says it returns after the agent has cancelled the turn's activities. Updates sent fromcancel()throughcurrentCoroutineContext().clientcould reach the client after the response, and the client got its confirmation before cancellation had finished.end_turn. The stop reason without an emitted response wascancelledonly if the turn's job was cancelled. AnAgentSession.cancel()that tells the turn to stop and waits for it, as its contract suggests, ends the flow normally before the SDK cancels the job, so the client gotend_turnfor a turn it cancelled.Scenarios 1, 2, and 5 violate "After all ongoing operations have been successfully aborted and pending updates have been sent, the Agent MUST respond to the original
session/promptrequest with thecancelledstop reason" .Scenario 4 violates the same sentence and "The Agent MAY send
session/updatenotifications … after receiving thesession/cancelnotification, but it MUST ensure that it does so before responding to thesession/promptrequest".Fix
promptregisters itsPromptSessionbefore itstryblock, so a rejected prompt throws before it can clear anything (scenario 1). Only the turn frees the slot, in itsfinally, when it answers:cancel()no longer frees it early. Asession/promptsent before the previous turn has answered, including a cancelled turn that is still winding down, is rejected with-32603(scenario 2). The protocol does not define overlapping prompts: v1 only says "Once a prompt turn completes, the Client may send anothersession/prompt", and the v2 prompt RFD leaves "whether agents insert new prompts while busy" unspecified.AgentSession.prompt()therefore documents this as an SDK guarantee.cancel()takes the turn to cancel from the slot before it callsAgentSession.cancel(), and after that call returns it cancels only that turn's job. A turn registered meanwhile is not touched (scenario 3).AgentSession.cancel()(scenario 4). A cancel that arrives after the turn started answering does not hold it.cancelled. The turn records that a cancel reached it before it started answering. Without a response emitted by the flow, it answerscancelledeven if the flow completed normally (scenario 5). A response the flow emits is still kept.AgentSession.cancel()now documents its contract:cancel()returns;cancel()must not wait for that response;session/canceltriggers one;cancelledunless its flow emitted a response.AGENTS.mddescribes the v1 turn rules.The v2
Agentis not affected: itscancel()only delegates toAgentSession.cancel()without picking or cancelling a turn, and the implementation itself sends theidle(cancelled)update that ends the turn.Verification
./gradlew checkpasses.AgentTest:a prompt sent before the cancelled turn answers is rejected;a rejected overlapping prompt leaves the running turn cancellable;a cancelled turn answers only after the session's cancel returns;a cancel that arrives after the turn answered leaves the next prompt running;a turn stopped cooperatively by the session's cancel answers cancelled;a turn stopped cooperatively keeps the response it emitted(control).