Skip to content

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
masterfrom
fix/active-prompt-cancellation
Open

EugeneTheDev wants to merge 5 commits into
masterfrom
fix/active-prompt-cancellation

Conversation

@EugeneTheDev

@EugeneTheDev EugeneTheDev commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

In the v1 Agent, SessionWrapper tracks the running turn in an active-prompt slot. Its bookkeeping around that slot and around session/cancel let a turn get lost, get cancelled by mistake, or answer too early. That happens in five scenarios:

  1. Overlapping prompt without a cancel. A second session/prompt while turn 1 runs is correctly rejected with -32603 "There is already active prompt execution". But its finally still ran and cleared turn 1's registration, so a later session/cancel never stopped turn 1.
  2. Cancel, then an immediate new prompt. cancel() freed the slot as soon as AgentSession.cancel() returned, while turn 1 was still winding down. The client's next session/prompt registered turn 2, and turn 1's finally then cleared turn 2's registration (_activePrompt.getAndSet(null)). A second session/cancel found nothing to cancel, and turn 2 never answered.
  3. A cancel that arrives just after a turn answered, with a slow AgentSession.cancel(). cancel() awaited AgentSession.cancel() and only then took whatever was in the slot. If the client sends the next prompt meanwhile, cancel() cancelled that turn, which no session/cancel targeted, and it answered cancelled. The spec defines cancelled as "The Client cancels the turn".
  4. The cancelled turn answered before the agent finished cancelling. The SDK answered session/prompt as soon as the turn's flow ended, even while AgentSession.cancel() was still suspended. Its KDoc says it returns after the agent has cancelled the turn's activities. Updates sent from cancel() through currentCoroutineContext().client could reach the client after the response, and the client got its confirmation before cancellation had finished.
  5. A turn stopped cooperatively answered end_turn. The stop reason without an emitted response was cancelled only if the turn's job was cancelled. An AgentSession.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 got end_turn for 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/prompt request with the cancelled stop reason" .

Scenario 4 violates the same sentence and "The Agent MAY send session/update notifications … after receiving the session/cancel notification, but it MUST ensure that it does so before responding to the session/prompt request".

Fix

  • One turn at a time. prompt registers its PromptSession before its try block, so a rejected prompt throws before it can clear anything (scenario 1). Only the turn frees the slot, in its finally, when it answers: cancel() no longer frees it early. A session/prompt sent 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 another session/prompt", and the v2 prompt RFD leaves "whether agents insert new prompts while busy" unspecified. AgentSession.prompt() therefore documents this as an SDK guarantee.
  • A cancel targets the turn that was running when it arrived. cancel() takes the turn to cancel from the slot before it calls AgentSession.cancel(), and after that call returns it cancels only that turn's job. A turn registered meanwhile is not touched (scenario 3).
  • A cancelled turn answers after the session's cancel. Each turn counts the cancels running for it. After its job completes, the turn waits until that count is zero and then marks itself as answering, so its response follows every update sent from AgentSession.cancel() (scenario 4). A cancel that arrives after the turn started answering does not hold it.
  • A cancelled turn answers cancelled. The turn records that a cancel reached it before it started answering. Without a response emitted by the flow, it answers cancelled even if the flow completed normally (scenario 5). A response the flow emits is still kept.

AgentSession.cancel() now documents its contract:

  • the cancelled turn is answered only after cancel() returns;
  • cancel() must not wait for that response;
  • it never cancels a turn that started after the call began;
  • calls may overlap, since each session/cancel triggers one;
  • the cancelled turn answers cancelled unless its flow emitted a response.

AGENTS.md describes the v1 turn rules.

The v2 Agent is not affected: its cancel() only delegates to AgentSession.cancel() without picking or cancelling a turn, and the implementation itself sends the idle(cancelled) update that ends the turn.

Verification

  • ./gradlew check passes.
  • New and updated tests in 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).

…-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 EugeneTheDev changed the title fix: keep a v1 prompt turn cancellable after an overlapping or follow-up prompt fix: v1 prompt cancellation targets its own turn, answers after the session's cancel, one turn at a time Oct 28, 2026
@EugeneTheDev
EugeneTheDev requested a review from Rizzen October 28, 2026 14:32
@EugeneTheDev
EugeneTheDev marked this pull request as ready for review October 28, 2026 14:32
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.

1 participant