Skip to content

Chat: Stop is ignored when pressed before the agent starts the turn #9448

Open

Description

@Bruno-rose

Where did this happen?

Desktop app (macOS)

Version

Source build, main at 19db630

What happened?

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

  1. Open a chat with Codex or an ACP agent.
  2. Send a prompt with a large attachment (or send the first prompt while the agent is still starting).
  3. Press Stop at once.
  4. 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.ts cancelTurn: returns early when there is no thread yet, and does nothing while the attachment resolves.
  • packages/chat-runtime/src/harness/acp/acpAdapter.ts runTurn: 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.ts cancelTurn: 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.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    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.

    Codex — packages/chat-runtime/src/harness/codex/codexAdapter/codexAdapter.ts

    • :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.

    ACP — packages/chat-runtime/src/harness/acp/acpAdapter.ts

    • 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.

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