Claude Code Agent Loop Deep Dive (8): From Ctrl-C to a Synthetic tool_result
The previous article described how the loop recovers from infrastructure failures. There is one case it can never solve by itself: the user changes their mind.
Perhaps the agent is reading an irrelevant file, heading down the wrong path, or simply needs new information from the user. The user must be able to interrupt it.
The product interaction looks simple—press Ctrl-C. The loop consequences are not. At the time of interruption, the system may be streaming an LLM response, executing several tools in parallel, or waiting for an approval promise. Each can leave the message history structurally incomplete, especially when a tool_use has no matching tool_result.
This post explains how Claude Code stops work without corrupting its history, and why an interruption sometimes creates an error result while an interrupt-and-send-new-message action does not.
One AbortController spans the whole loop
Each loop run creates a fresh Node.js AbortController. It provides two things:
-
signal, passed to downstream work so it can observe cancellation. -
abort(), which marks the signal aborted.
Claude Code keeps that controller on QueryEngine and shares it across the active loop:
QueryEngine
├── this.abortController = new AbortController()
├── interrupt() → this.abortController.abort()
└── toolUseContext passes the controller signal through the loop
Ctrl-C in the UI calls QueryEngine.interrupt(). At once, every operation holding the shared signal can observe the cancellation.
Using one controller is important because a loop can have several concurrent operations: a streaming API request, parallel tool invocations, an approval promise, or hook subprocesses. One abort signal reaches all of them; multiple controllers would require manually cancelling each one and make omissions likely.
Three cancellation checkpoints
The loop checks signal.aborted at three important boundaries.
1. The HTTP request
The model request passes the signal to fetch:
fetch(url, { signal: toolUseContext.abortController.signal })
fetch understands abort signals. Once cancellation occurs, the request rejects and the streaming connection is physically stopped; no later SSE events arrive.
2. Between streaming and tool execution
After the response has produced a full assistant message, possibly with tool_use blocks, the loop checks the signal before it starts tools. If interrupted, it skips starting new tool work and moves to cleanup.
3. Between tool batches
Parallel tools run in batches. After one batch finishes and before the next starts, the loop checks again. A cancellation prevents the next batch from beginning.
This placement reflects cooperative cancellation. Signal-aware I/O such as fetch can stop immediately, but pure computation has to check the signal itself. The checkpoints sit at each moment where the loop is about to begin more work.
Synthesizing missing tool_result blocks
An interruption can leave history in this state:
user message
assistant message: tool_use A, B, C
user message: tool_result A, tool_result B
↑ C was interrupted before it got a result
That history is invalid. The orphaned tool_use C has no corresponding tool_result, so sending the conversation to the model again would produce a 400 error.
Claude Code repairs this by scanning in-flight tool uses after cancellation and creating a synthetic result:
{
type: 'tool_result',
tool_use_id: 'toolu_C',
content: 'Interrupted by user',
is_error: true
}
The synthetic result restores the structural pairing invariant. It also has useful semantics: the model can understand that the operation did not finish because the user stopped it.
The source calls this repair yieldMissingToolResultBlocks. It began as interruption handling and shares the same philosophy as the pairing repair discussed in Part 3: From Reading Files to Parallel Scheduling: pairing is a hard constraint, so any break must be repaired.
Two meanings of interruption
In practice, Ctrl-C can mean two different things.
A. Stop and think
The user wants the loop to stop so they can review the current progress. They may or may not send another message later.
B. Interrupt to submit a correction
The user wants to stop the agent and immediately provide a correction, such as “You are looking in the wrong place; inspect auth_v2.py instead.”
Both cases abort the same active work, but their desired follow-up is different:
- In case A, Claude Code appends the synthetic
Interrupted by userresult and ends the loop. - In case B, the typed user message becomes the next loop input immediately after the interruption.
Claude Code distinguishes them through signal.reason:
- A normal Ctrl-C-style reason follows the first path and synthesizes the interruption result.
-
signal.reason === 'interrupt'carries the “interrupt to submit” meaning and does not synthesize a redundant interruption message.
The second path does not need the extra text because the user's new message already explains why the prior work stopped. One cancellation mechanism therefore supports two different user experiences with a small semantic flag.
Interrupting while the response is streaming
What if Ctrl-C arrives before the model has finished streaming?
AbortController.abort() stops the underlying fetch, but any SSE deltas already received are kept. Claude Code appends the partial assistant message to history: it may contain the first few text fragments but no final stop reason or completed tool use.
The terminal outcome is aborted_streaming. On a later turn, the model can see the incomplete response it had begun.
Keeping partial output is intentional. Users often interrupt because they have seen the agent say something wrong and want to correct it. Preserving those words lets them refer to the mistake in the next message. Dropping them would make the conversation look cleaner, but would remove useful context.
MCP cleanup belongs only to a main-agent interruption
Some MCP servers, including Anthropic-internal services such as Chicago, need connection cleanup when a loop ends. Claude Code performs that cleanup only for an interrupted main-agent loop, not an interrupted sub-agent.
The reason is shared ownership. A sub-agent can share an MCP connection with its main loop; cancelling the sub-agent must not tear down a connection the main loop still needs. This is one of the main-thread-only branches guarded by if (!toolUseContext.agentId).
The second exception to unattended looping
Part 1: From Tool Declarations to Pre-Execution Approval described approval as the first exception to an otherwise unattended loop: the loop pauses and asks the user to decide about a risky action.
Interrupt is the second exception: the user can step into the loop at any time. Together with the maxTurns circuit breaker from Part 4: From a Finished Answer to the Seven Meanings of stop_reason, they cover complementary risks:
- Approval: the loop proactively waits for a decision at a sensitive point.
- Interrupt: the user proactively stops the loop whenever needed.
-
maxTurns: the system stops automatically even if nobody intervenes.
These three mechanisms ensure autonomous execution remains controllable under every condition.
Takeaways
- One
AbortControllercovers the entire loop, so a single cancellation reaches all concurrent operations. - The loop checks for cancellation at the HTTP request, before tool execution, and between tool batches.
- Missing results are repaired with a synthetic
is_error: truetool result to preserve thetool_use/tool_resultinvariant. -
signal.reasondistinguishes “stop and think” from “interrupt and submit a correction.” - A streaming interruption preserves already received partial output for later conversational context.
- Main-agent-only cleanup prevents a cancelled sub-agent from closing shared MCP connections.
- Approval, interrupt, and
maxTurnsare complementary controls over an otherwise unattended loop.
The next article examines Claude Code's final large mechanism: sub-agents. They reuse the same queryLoop implementation, while identity-based branches, isolated JSONL transcripts, and agentId routing keep their work distinct.
References
Primary source locations (v2.1.220):
-
src/QueryEngine.ts:abortControllerandinterrupt(). -
src/query.ts:yieldMissingToolResultBlocksand synthetic results for in-flight tool use. -
src/query.ts: routing onsignal.reason === 'interrupt'. -
src/hooks/useCancelRequest.ts: Ctrl-C and Esc event capture. -
src/hooks/useCancelRequest.ts:chat:cancelandapp:interruptpriority.
Related reading: