You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Chat: Stop is ignored when pressed before the agent starts the turn #9448
If you press Stop while an attachment is still loading, or while the agent is still starting, the Stop is lost. The agent then runs the whole turn, including tools, and the turn ends as completed. This happens with Codex, ACP agents, and Claude.
Expected: the turn ends as interrupted and the agent does not run it.
Steps to reproduce
Open a chat with Codex or an ACP agent.
Send a prompt with a large attachment (or send the first prompt while the agent is still starting).
Press Stop at once.
See the agent run the turn to the end.
Logs, screenshots, or recordings
Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.
Implementation notes
Files
packages/chat-runtime/src/harness/codex/codexAdapter/codexAdapter.tscancelTurn: returns early when there is no thread yet, and does nothing while the attachment resolves.
packages/chat-runtime/src/harness/acp/acpAdapter.tsrunTurn: releases the held turn before await this.toAcpPrompt(content), so session/cancel is sent before session/prompt. cancelTurn returns when there is no session, but the prompt stays in queuedPrompts.
packages/chat-runtime/src/harness/claude/claudeAdapter/claudeAdapter.tscancelTurn: with no session yet it aborts the adapter-wide AbortController, so the query starts already aborted and the session stays in "starting".
Approach
Record the Stop whenever a prompt is anywhere in the pipeline, and apply it when the turn starts. ACP: build the prompt while the turn is still held; with no session, drop the queued prompt and report an interrupted turn. Claude: defer the Stop until the session exists instead of aborting.
Triage: Stop is lost when pressed before the agent starts the turn
Summary. If you press Stop after a prompt is sent but before the agent has started a turn for it, the Stop does nothing. This happens in two cases: an attachment is still loading, or the agent process or session is still starting. The prompt goes through later and the turn runs to the end. Reading the code supports this report. Neither the shared session layer nor the three adapters remember a Stop that comes before a running turn exists. Each adapter loses it in a different place.
Affected code
Session layer — packages/chat-runtime/src/sessions/liveSession/liveSession.ts:526-549 cancelTurn handles only one early case: the checkpoint snapshot (capturing, lines 527-539). After send() has given the prompt to the adapter (awaitingTurn, lines 826/846), it calls adapter.cancelTurn() (line 548) and records nothing. So each adapter must keep the Stop itself, and none does.
:186 returns at once when there is no client/threadId. The prompt sits in queuedInput (:179), and bootstrap starts it later (:300-301).
:192 keeps the Stop only when pendingTurnStart is set. That flag is set only inside startTurn (:312). While prompt() waits for an attachment (:167-171), pendingTurnStart is false and currentTurn is not running, so the Stop does nothing. When the attachment is ready, deliver → startTurn runs as normal.
The step that applies a kept Stop already exists (emitTurnState, :680). The Stop just never gets recorded.
runTurn clears heldTurnId at :1134, then waits for this.toAcpPrompt(content) inside the session/prompt call (:1141-1144). A Stop during that wait skips the held-turn branch (:587) and sends session/cancel (:597) before session/prompt has been sent. The agent then gets the prompt and runs it.
cancelTurn returns at :585 when there is no session, but the prompt stays in queuedPrompts (:536). bootstrap runs it later (:1110-1111).
Claude — packages/chat-runtime/src/harness/claude/claudeAdapter/claudeAdapter.ts
With no session yet (:337-341), Stop calls abort() on the adapter-wide abortController. That same controller is then passed to query() at :271. The session starts already aborted, so this is worse than a lost Stop: the whole session breaks, not only the one turn.
If the session exists but prompt() is still waiting for an attachment (:288-291), interrupt() runs while no turn is active, and markInterrupted marks nothing useful. The userMessage is pushed afterwards and runs in full.
Suspected root cause
A Stop counts only when a running turn exists. The time between "the prompt was given to the adapter" and "the agent has a running turn" has no record of a Stop request in any of these places:
the session layer (awaitingTurn)
Codex (attachment loading, no thread yet)
ACP (no session yet; toAcpPrompt after the hold is cleared)
Claude (no session yet; attachment loading)
Reproduction (sketch for bun:test)
The existing adapter test harnesses already provide a fake transport, resolveAttachment, and a controllable launch. For example, see codexAdapter.test.ts:297, acpAdapter.test.ts:3275, and claudeAdapter.test.ts:137.
Attachment case: set resolveAttachment to return a promise you resolve by hand. Call adapter.prompt([{ type: "attachment", ... }]), then adapter.cancelTurn(), then resolve the attachment.
Codex: assert that turn/start is not sent, or is followed by turn/interrupt.
ACP: assert that no session/prompt is sent after the session/cancel, or that the turn ends interrupted.
Claude: assert that no user message reaches the prompt stream.
Startup case: make launch (or the fake initialize/session/new/thread/start) wait on a promise you resolve by hand. Call prompt(...), then cancelTurn(), then let startup finish. Assert that the turn ends as interrupted and the agent never gets the prompt. For Claude, also assert that the abortController given to query() is not aborted.
Suggested fix direction
This matches the approach in the issue:
Codex: in cancelTurn, set cancelRequested = true whenever a prompt is waiting: queuedInput.length > 0, resolvingPrompt is set, or pendingTurnStart is true. Then either drop the waiting input or let emitTurnState (:680) interrupt the turn when it starts.
ACP: run toAcpPrompt before clearing heldTurnId, so a Stop in that wait uses the held-turn branch at :587. With no session, clear queuedPrompts and report an interrupted turn, or leave that to the session layer.
Claude: never abort the shared abortController to handle a Stop before the session exists. Keep a "cancel pending" flag and either drop the waiting or attachment-loading prompt, or interrupt once the first turn starts.
Alternative: handle this once in LiveSession.cancelTurn. When awaitingTurn is set and no turn is running, keep a "cancel on next turn start" flag, call adapter.cancelTurn() when the matching turn: running event arrives, and stop relying on each adapter to keep the Stop. The ACP session/cancel-before-session/prompt ordering problem still needs its own fix.
Activity
github-actions commentedon Nov 7, 2026
Triage: Stop is lost when pressed before the agent starts the turn
Summary. If you press Stop after a prompt is sent but before the agent has started a turn for it, the Stop does nothing. This happens in two cases: an attachment is still loading, or the agent process or session is still starting. The prompt goes through later and the turn runs to the end. Reading the code supports this report. Neither the shared session layer nor the three adapters remember a Stop that comes before a running turn exists. Each adapter loses it in a different place.
Affected code
Session layer —
packages/chat-runtime/src/sessions/liveSession/liveSession.ts:526-549cancelTurnhandles only one early case: the checkpoint snapshot (capturing, lines 527-539). Aftersend()has given the prompt to the adapter (awaitingTurn, lines 826/846), it callsadapter.cancelTurn()(line 548) and records nothing. So each adapter must keep the Stop itself, and none does.Codex —
packages/chat-runtime/src/harness/codex/codexAdapter/codexAdapter.ts:186returns at once when there is noclient/threadId. The prompt sits inqueuedInput(:179), andbootstrapstarts it later (:300-301).:192keeps the Stop only whenpendingTurnStartis set. That flag is set only insidestartTurn(:312). Whileprompt()waits for an attachment (:167-171),pendingTurnStartis false andcurrentTurnis not running, so the Stop does nothing. When the attachment is ready,deliver→startTurnruns as normal.emitTurnState,:680). The Stop just never gets recorded.ACP —
packages/chat-runtime/src/harness/acp/acpAdapter.tsrunTurnclearsheldTurnIdat:1134, then waits forthis.toAcpPrompt(content)inside thesession/promptcall (:1141-1144). A Stop during that wait skips the held-turn branch (:587) and sendssession/cancel(:597) beforesession/prompthas been sent. The agent then gets the prompt and runs it.cancelTurnreturns at:585when there is no session, but the prompt stays inqueuedPrompts(:536).bootstrapruns it later (:1110-1111).Claude —
packages/chat-runtime/src/harness/claude/claudeAdapter/claudeAdapter.ts:337-341), Stop callsabort()on the adapter-wideabortController. That same controller is then passed toquery()at:271. The session starts already aborted, so this is worse than a lost Stop: the whole session breaks, not only the one turn.prompt()is still waiting for an attachment (:288-291),interrupt()runs while no turn is active, andmarkInterruptedmarks nothing useful. TheuserMessageis pushed afterwards and runs in full.Suspected root cause
A Stop counts only when a running turn exists. The time between "the prompt was given to the adapter" and "the agent has a running turn" has no record of a Stop request in any of these places:
awaitingTurn)toAcpPromptafter the hold is cleared)Reproduction (sketch for
bun:test)The existing adapter test harnesses already provide a fake transport,
resolveAttachment, and a controllablelaunch. For example, seecodexAdapter.test.ts:297,acpAdapter.test.ts:3275, andclaudeAdapter.test.ts:137.resolveAttachmentto return a promise you resolve by hand. Calladapter.prompt([{ type: "attachment", ... }]), thenadapter.cancelTurn(), then resolve the attachment.turn/startis not sent, or is followed byturn/interrupt.session/promptis sent after thesession/cancel, or that the turn endsinterrupted.launch(or the fakeinitialize/session/new/thread/start) wait on a promise you resolve by hand. Callprompt(...), thencancelTurn(), then let startup finish. Assert that the turn ends asinterruptedand the agent never gets the prompt. For Claude, also assert that theabortControllergiven toquery()is not aborted.Suggested fix direction
This matches the approach in the issue:
cancelTurn, setcancelRequested = truewhenever a prompt is waiting:queuedInput.length > 0,resolvingPromptis set, orpendingTurnStartis true. Then either drop the waiting input or letemitTurnState(:680) interrupt the turn when it starts.toAcpPromptbefore clearingheldTurnId, so a Stop in that wait uses the held-turn branch at:587. With no session, clearqueuedPromptsand report an interrupted turn, or leave that to the session layer.abortControllerto handle a Stop before the session exists. Keep a "cancel pending" flag and either drop the waiting or attachment-loading prompt, or interrupt once the first turn starts.LiveSession.cancelTurn. WhenawaitingTurnis set and no turn is running, keep a "cancel on next turn start" flag, calladapter.cancelTurn()when the matchingturn: runningevent arrives, and stop relying on each adapter to keep the Stop. The ACPsession/cancel-before-session/promptordering problem still needs its own fix.