Skip to content

Chat (Claude): a late Stop marks the next turn interrupted, and an early Stop leaves the chat running forever #9449

Open

Description

@Bruno-rose

Where did this happen?

Desktop app (macOS)

Version

Source build, main at 19db630

What happened?

Two problems with Stop in Claude chats:

  1. 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.
  2. 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:

  1. Send a prompt and let it finish.
  2. Press Stop just as it finishes.
  3. Send another prompt and let it finish.
  4. See it marked interrupted.

Case 2:

  1. Send a prompt.
  2. Press Stop before any text streams.
  3. 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.ts cancelTurn: calls markInterrupted even when no prompt is in flight.
  • packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts result(): 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.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    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

    1. 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.
    2. 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 only
    const t = new ClaudeTranslator({ cwd: "/", mintId: seq(), now: () => 0 });
    t.translate(streamEvent("message_start", { id: "m1" }));
    t.translate(resultOk());                 // turn 1 completed
    t.markInterrupted("Turn canceled by user"); // late Stop, no turn open
    t.translate(streamEvent("message_start", { id: "m2" }));
    const events = t.translate(resultOk({ usage: {...}, total_cost_usd: 0.01 }));
    // actual: turn.status === "interrupted", no usage
    // expected: turn.status === "completed" with usage/costUsd
    
    // Case 2
    const t2 = new ClaudeTranslator(...);
    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions