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 (Claude): a late Stop marks the next turn interrupted, and an early Stop leaves the chat running forever #9449
If Stop lands just after a turn ends, the flag stays set. The next turn runs in full but shows as interrupted ("Turn canceled by user"), and its usage and cost are dropped.
If Stop lands before the first token, the SDK ends the query with a bare result. No turn event is sent, so the chat stays "running" and every later prompt waits in the queue until the session is closed.
Expected: a Stop with no turn in flight does nothing, and a stopped turn always ends.
Steps to reproduce
Case 1:
Send a prompt and let it finish.
Press Stop just as it finishes.
Send another prompt and let it finish.
See it marked interrupted.
Case 2:
Send a prompt.
Press Stop before any text streams.
See the chat stay running; new prompts do not start.
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/claude/claudeAdapter/claudeAdapter.tscancelTurn: calls markInterrupted even when no prompt is in flight.
packages/chat-runtime/src/harness/claude/translateStream/translateStream.tsresult(): returns [] when no turn is open, so a pending interrupt is never turned into a turn event, and the interrupt flag is only cleared by a later result.
Approach
Count prompts sent to Claude that have no result yet, and ignore Stop when the count is zero. In result(), open the turn when an interrupt is pending so it ends as interrupted.
Gotchas
If the CLI merges queued prompts into one turn, the count can stay at one while idle. This was not checked against the real CLI.
I checked the code on main (19db630) and both reports hold up. Both bugs come from one cause: the interrupt flag in ClaudeTranslator is only cleared when a result arrives while a turn is open.
Summary
Pressing Stop in a Claude chat always records "this turn was interrupted" in ClaudeTranslator, even when no turn is open. That record is only cleared when a result comes in while a turn is open, and a result with no open turn is ignored.
Case 1 (Stop just after a turn ends): the record survives the idle gap. The next turn runs normally, but its result is shown as interrupted ("Turn canceled by user") and its usage and cost are dropped.
Case 2 (Stop before the first token): the SDK ends the query with a bare result before any turn has opened. No turn event is sent, so LiveSession never clears its in-flight prompt and stays busy. Every later prompt waits in the queue.
Affected code
packages/chat-runtime/src/harness/claude/claudeAdapter/claudeAdapter.ts:336-346 — cancelTurn() calls this.translator?.markInterrupted("Turn canceled by user") and session.interrupt() without checking whether a prompt is in flight.
packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:95-97 — markInterrupted sets interruptReason, with no guard.
packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:389-398 — result() returns [] at once if !this.turnId. When a turn is open it reads interruptReason, clears it, and returns an interrupted turn instead of the completed one, so usage and cost are lost.
packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:439-453 — ensureTurn() only opens a turn on message_start or assistant, so a turn stopped before any of those never opens.
packages/chat-runtime/src/sessions/liveSession/liveSession.ts:526-549 — LiveSession.cancelTurn checks this.capturing (a prompt whose snapshot is still being taken), not whether a turn is running. Once a prompt has been sent it always calls adapter.cancelTurn(), including when currentTurn has already completed.
packages/chat-runtime/src/sessions/liveSession/liveSession.ts:819-821 and :1063-1070 — inFlight() returns awaitingTurn, which is only cleared when a turn event arrives (handle → attributeAwaitingPrompt on running, :664-675). Without a turn event, isBusy() stays true and prompt() queues everything (:299).
Suspected root cause
Late Stop:LiveSession.cancelTurn → ClaudeAdapter.cancelTurn → markInterrupted runs after result has already set turnId = null. interrupt() on an idle query does nothing, so no result arrives to clear the flag. The next turn's result then reaches the interruptReason !== null branch at translateStream.ts:392-397, which drops usage/total_cost_usd.
Early Stop:awaitingTurn is set and markInterrupted runs, but the SDK sends only result, with no message_start or assistant. result() returns [] because turnId is null, so LiveSession gets no turn event and awaitingTurn stays set forever. The interruptReason also stays set, so if the session ever moves on, the next turn hits case 1 as well.
Reproduction (sketch for translateStream.test.ts / claudeAdapter.test.ts)
// Case 1 – translator onlyconstt=newClaudeTranslator({cwd: "/",mintId: seq(),now: ()=>0});t.translate(streamEvent("message_start",{id: "m1"}));t.translate(resultOk());// turn 1 completedt.markInterrupted("Turn canceled by user");// late Stop, no turn opent.translate(streamEvent("message_start",{id: "m2"}));constevents=t.translate(resultOk({usage: {...},total_cost_usd: 0.01}));// actual: turn.status === "interrupted", no usage// expected: turn.status === "completed" with usage/costUsd// Case 2constt2=newClaudeTranslator(...);t2.markInterrupted("Turn canceled by user");expect(t2.translate(resultOk())).toEqual([]);// actual: no turn event at all
For an end-to-end check, run ClaudeAdapter with a fake query whose interrupt() makes the stream yield a bare { type: "result", ... }. Then confirm LiveSession still reports busy and that a second prompt() comes back queued: true and is never sent.
Fix direction
Guard Stop in the adapter. Track prompts pushed to this.prompts that have no result yet: increment in prompt() when the message is actually pushed (including the async attachment path), decrement on each result. Make cancelTurn do nothing when the count is 0.
The issue's caveat applies: if the Claude CLI merges several queued prompts into one turn, the count can stay above 0 while idle. Check this against the real CLI, or reset the count to 0 on each result instead of decrementing.
Close a stopped turn in the translator. In result(), when turnId is null but interruptReason is set, call ensureTurn() first. It then emits running followed by interrupted + idle, which clears awaitingTurn in LiveSession and lets the queue continue.
Possible extra guard in LiveSession.cancelTurn: skip adapter.cancelTurn() when currentTurn?.status !== "running" and inFlight() is null. This only narrows the race. A result can still be in the SDK pipe when Stop lands, so the adapter and translator changes above are the real fix.
Keep usage on an interrupted turn. The interrupted branch could carry over usage/total_cost_usd from the result envelope, so real usage is not lost when a Stop arrives just as a turn completes.
Activity
github-actions commentedon Nov 7, 2026
I checked the code on
main(19db630) and both reports hold up. Both bugs come from one cause: the interrupt flag inClaudeTranslatoris only cleared when aresultarrives while a turn is open.Summary
Pressing Stop in a Claude chat always records "this turn was interrupted" in
ClaudeTranslator, even when no turn is open. That record is only cleared when aresultcomes in while a turn is open, and aresultwith no open turn is ignored.resultis shown as interrupted ("Turn canceled by user") and its usage and cost are dropped.resultbefore any turn has opened. No turn event is sent, soLiveSessionnever clears its in-flight prompt and stays busy. Every later prompt waits in the queue.Affected code
packages/chat-runtime/src/harness/claude/claudeAdapter/claudeAdapter.ts:336-346—cancelTurn()callsthis.translator?.markInterrupted("Turn canceled by user")andsession.interrupt()without checking whether a prompt is in flight.packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:95-97—markInterruptedsetsinterruptReason, with no guard.packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:389-398—result()returns[]at once if!this.turnId. When a turn is open it readsinterruptReason, clears it, and returns an interrupted turn instead of the completed one, so usage and cost are lost.packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:439-453—ensureTurn()only opens a turn onmessage_startorassistant, so a turn stopped before any of those never opens.packages/chat-runtime/src/sessions/liveSession/liveSession.ts:526-549—LiveSession.cancelTurnchecksthis.capturing(a prompt whose snapshot is still being taken), not whether a turn is running. Once a prompt has been sent it always callsadapter.cancelTurn(), including whencurrentTurnhas already completed.packages/chat-runtime/src/sessions/liveSession/liveSession.ts:819-821and:1063-1070—inFlight()returnsawaitingTurn, which is only cleared when a turn event arrives (handle→attributeAwaitingPromptonrunning,:664-675). Without a turn event,isBusy()staystrueandprompt()queues everything (:299).Suspected root cause
LiveSession.cancelTurn→ClaudeAdapter.cancelTurn→markInterruptedruns afterresulthas already setturnId = null.interrupt()on an idle query does nothing, so noresultarrives to clear the flag. The next turn'sresultthen reaches theinterruptReason !== nullbranch attranslateStream.ts:392-397, which dropsusage/total_cost_usd.awaitingTurnis set andmarkInterruptedruns, but the SDK sends onlyresult, with nomessage_startorassistant.result()returns[]becauseturnIdis null, soLiveSessiongets no turn event andawaitingTurnstays set forever. TheinterruptReasonalso stays set, so if the session ever moves on, the next turn hits case 1 as well.Reproduction (sketch for
translateStream.test.ts/claudeAdapter.test.ts)For an end-to-end check, run
ClaudeAdapterwith a fakequerywhoseinterrupt()makes the stream yield a bare{ type: "result", ... }. Then confirmLiveSessionstill reports busy and that a secondprompt()comes backqueued: trueand is never sent.Fix direction
this.promptsthat have noresultyet: increment inprompt()when the message is actually pushed (including the async attachment path), decrement on eachresult. MakecancelTurndo nothing when the count is 0.resultinstead of decrementing.result(), whenturnIdis null butinterruptReasonis set, callensureTurn()first. It then emitsrunningfollowed byinterrupted+idle, which clearsawaitingTurninLiveSessionand lets the queue continue.LiveSession.cancelTurn: skipadapter.cancelTurn()whencurrentTurn?.status !== "running"andinFlight()is null. This only narrows the race. Aresultcan still be in the SDK pipe when Stop lands, so the adapter and translator changes above are the real fix.usage/total_cost_usdfrom theresultenvelope, so real usage is not lost when a Stop arrives just as a turn completes.