CodeDocs Vault

14. Mid-Turn Messages, Interruption, and the Command Queue

How can the user send a message while the agent is mid-turn? The short answer: there is an always-open command queue, a synchronous query guard that prevents races, and a per-tool opt-in interrupt policy that controls whether the current turn can be cancelled or must be allowed to finish first.

14.1 The command queue — /workspaces/src/utils/messageQueueManager.ts

A module-level, non-React queue. All reads/writes mutate commandQueue[]; React components observe via a useSyncExternalStore snapshot.

Three priority tiers

Selection

dequeue() (lines 167-193) picks the highest-priority non-empty tier, then FIFO within that tier. Commands come out one at a time unless the processor opts to batch (slash commands are always individual; non-slash can batch if they share mode).

Enqueue

enqueueCommand() (line 129) is called from the text input handler when the user submits. It also pops the editable preview (popAllEditable at line 428) so the input buffer is cleared but the user can still see their queued message while the query runs.

Non-React readers

getCommandQueue() and getCommandQueueLength() are called from outside React (print.ts headless loop, SDK streaming callers). The queue is the single source of truth across UI and SDK paths.

14.2 The query guard — /workspaces/src/utils/QueryGuard.ts

A synchronous state machine with three states: 'idle' | 'dispatching' | 'running'.

The generation counter (line 103) makes stale cleanup safe:

end(generation: number): boolean {
  if (this._generation !== generation) return false  // stale
  if (this._status !== 'running') return false
  this._status = 'idle'
  this._notify()
  return true
}

If a cancellation increments the generation and then a finally block from the cancelled run tries to end(), it's a no-op — it can't accidentally re-enable the loop.

14.3 The queue processor — /workspaces/src/hooks/useQueueProcessor.ts

A React hook that subscribes to both the queue and the guard via useSyncExternalStore. The condition for dispatch (lines 28-68):

No query active AND queue has items AND no blocking local JSX UI.

When it fires, it calls processQueueIfReady() which:

  1. Dequeues a command (or a mode-compatible batch).
  2. Feeds it to executeQueuedInput()handlePromptSubmit()executeUserInput().
  3. Before the first await of that chain, queryGuard.reserve() runs synchronously, blocking re-entry.

That synchronous-before-await gap is the critical race-preventer: once the processor has picked an item, the guard is committed before React gets a chance to re-observe the queue.

14.4 How mid-turn submission actually works

Scenario: the user types a message while the agent is in a long Bash call.

  1. Text input submits → enqueueCommand({ priority: 'next', ... }) (messageQueueManager.ts:129).
  2. The message appears as a pill preview above the input so the user sees what will run next.
  3. useQueueProcessor wakes because the queue changed, but isQueryActive is true → it does nothing and waits for the guard to flip to idle.
  4. handlePromptSubmit inspects the running state. At /workspaces/src/utils/handlePromptSubmit.ts:321, it checks whether all executing tools declare interruptBehavior() === 'cancel':
// handlePromptSubmit.ts:321, approx
if (allExecutingToolsCancelOnInterrupt) {
  params.abortController?.abort('interrupt')
}
  1. Because Bash is 'block' (default), nothing is aborted. The current turn finishes.
  2. When the query loop's finally block runs queryGuard.end(...), the guard flips idle.
  3. useQueueProcessor re-fires → dequeues the user's message → new turn begins.

14.5 Interrupt behavior per tool — Tool.ts:411-416

interruptBehavior?(): 'cancel' | 'block'

Enforcement in StreamingToolExecutor.ts:219-226

if (this.toolUseContext.abortController.signal.aborted) {
  if (this.toolUseContext.abortController.signal.reason === 'interrupt') {
    return this.getToolInterruptBehavior(tool) === 'cancel'
      ? 'user_interrupted'
      : null  // 'block' tools: don't abort
  }
}

The executor inspects the signal's reason — if it's the literal string 'interrupt', the tool's declared policy decides the outcome. If the abort came from somewhere else (API error, parent cancel), all tools abort regardless.

In the query loop — query.ts:1046

if (toolUseContext.abortController.signal.reason !== 'interrupt') {
  yield createUserInterruptionMessage({ toolUse: false })
}

Non-interrupt aborts emit an explicit "interrupted by user" message. Interrupt aborts don't — because the queued user message is about to run and provides its own context.

14.6 'now'-priority bypass — /workspaces/src/screens/REPL.tsx:4100-4103

useEffect(() => {
  if (queuedCommands.some(cmd => cmd.priority === 'now')) {
    abortControllerRef.current?.abort('interrupt')
  }
}, [queuedCommands])

When a 'now'-priority message is enqueued (typically from a UDS / browser-remote client), the current turn is aborted regardless of interruptBehavior. This ensures responsive UI for external clients that have different latency contracts than a local terminal user.

14.7 Cancellation (Escape / Ctrl+C) — /workspaces/src/hooks/useCancelRequest.ts

Priority order:

  1. Running task exists → call onCancel() which fires the abort controller (line 100). This invokes queryGuard.forceEnd().
  2. Queue has items (no running task) → pop the topmost queued command (line 107).
  3. Fallback → call onCancel() anyway (line 115).

Escape is not a hard kill — it is an abort signal that the loop cooperatively reacts to, with per-tool policy deciding whether mid-flight work is preserved.

14.8 Remote-session message path — /workspaces/src/remote/

When a message originates in the browser at claude.ai/code/session_…:

Why HTTP for inbound, WebSocket for outbound? The WebSocket must be always-listening so the session can deliver model tokens, permission prompts, and progress. HTTP POST for inbound is stateless and supports retries cleanly. The asymmetry also means an expensive WebSocket reconnect doesn't affect the user's ability to submit.

Connection maintenance: ping every 30s (SessionsWebSocket.ts:19), reconnect up to 5 times (MAX_RECONNECT_ATTEMPTS, line 18).

14.9 Mailbox — async teammate-to-parent delivery — /workspaces/src/utils/mailbox.ts

In-process, non-persistent queue for async agents:

send(msg: Message): void
poll(fn?: filter): Message | undefined
receive(fn?: filter): Promise<Message>

useInboxPoller (hooks/useInboxPoller.ts:126-969) polls teammate inboxes every 1 second:

Mailbox messages do not bypass the query guard. They follow the same enqueue → dequeue → reserve path. A teammate can't interrupt a 'block' tool; it waits for a turn boundary.

This is how background agents' completion notifications reach the parent without disrupting mid-flight work.

14.10 Summary table — what actually happens

User action Turn running? Current tool Result
Type message + Enter No New turn starts immediately.
Type message + Enter Yes Bash (block) Message queues. When turn ends, it runs. User sees a pill preview.
Type message + Enter Yes Sleep (cancel) abort('interrupt'). Sleep cancels with synthetic result. Queued turn runs.
Type message + Enter Yes Agent (block) Message queues. Agent completes, then new turn.
Press Escape Yes Any forceEnd() + abort(). Turn cancels; no "interrupted" message if reason is 'interrupt'.
Press Escape No, queue has items Pops top of queue (removes pending message).
Browser POST (UDS 'now') Yes Any (block or cancel) Priority-'now' triggers unconditional abort.
Background agent finishes Any Any enqueueAgentNotification with 'later' priority. Delivered on next idle.
Teammate sends via SendMessage Any Any Queued in LocalAgentTask.pendingMessages or mailbox. Delivered on recipient's next turn boundary.

14.11 The core design principle

The UI enqueues immediately; the loop drains on boundaries; tool-level policy decides whether boundaries can be forced.