Interrupting a tool mid-execution is stamped non_execution_kind: "user-rejected" — the model is told the tool did not run, though the engine enum has a dedicated "interrupted" value
Summary
When a tool call that is already executing is interrupted, the harness hands the model a tool_result whose text asserts the tool never ran:
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.
accompanied by tool_use_result: "User rejected tool use" and tool_result_meta: [{"non_execution_kind": "user-rejected"}].
This is the permission-denial message being reused for the mid-execution interrupt path. The two are not equivalent: on a permission denial the tool genuinely never executes; on a mid-execution interrupt it ran and was killed.
The engine is not missing the information. At the same instant it emits, for the same tool_use_id:
system/task_notification→"status": "stopped"(preceded bytask_startedfor that same id)- final
resultevent →"terminal_reason": "aborted_tools"
And its own non_execution_kind enum already contains a value for exactly this case:
["user-rejected","permission-rule","automode-blocked","automode-unavailable",
"automode-parsing-error","interrupted","cancelled"]
with the schema documenting it as:
non_execution_kindis the harness-stamped reason anis_error:trueresult did not carry the tool's own execution output (user-rejected / permission-rule / automode-* / interrupted / cancelled); absent means the tool ran to completion.
So the mid-execution-interrupt path stamps user-rejected when a documented interrupted value exists.
Reproduction (no GUI required)
A tick-probe makes execution duration observable: the log line count proves how long it actually ran, which distinguishes "never executed" from "executed then killed".
probe.sh:
#!/bin/bash
L="$1"; rm -f "$L"
for i in $(seq 1 40); do
printf '%s tick=%02d\n' "$(date '+%H:%M:%S')" "$i" >> "$L"
sleep 1
done
echo "COMPLETED_ALL_40_TICKS"
Drive the engine over the documented streaming-input channel:
claude -p --verbose --input-format stream-json --output-format stream-json --allowed-tools Bash
- On stdin, send a user message asking it to run
bash probe.sh <logpath>with the Bash tool. - Poll the log until it has accumulated several ticks — this confirms the tool is genuinely executing, not sitting at a permission prompt.
- Then send on stdin:
``json``
{"type":"control_request","request_id":"req_1","request":{"subtype":"interrupt"}}
- Keep polling the log for 10+ seconds afterwards.
Observed
Three independent runs (interrupt injected at tick 6, tick 6, and tick 15 — the last specifically to rule out a start-up race):
| run | ticks at interrupt | ticks at +2s / +5s / +10s / +15s | real execution before kill |
|---|---|---|---|
| 1 | 6 | 6 / 6 / 6 / 6 | ~6s |
| 2 | 6 | 6 / 6 / 6 / 6 | ~6s |
| 3 | 15 | 15 / 15 / 15 / 15 | ~15s |
COMPLETED_ALL_40_TICKS never printed; the probe process was gone (pgrep empty). So the tool executed and was killed mid-flight.
Event sequence from the stream (run 1, abridged):
system/task_started {task_id: ..., tool_use_id: toolu_01Ww..., task_type: local_bash}
control_response {subtype: success, request_id: req_int_1, response: {still_queued: []}}
system/task_notification {tool_use_id: toolu_01Ww..., status: "stopped"}
user tool_result "The user doesn't want to proceed with this tool use. The tool use
was rejected (eg. if it was a file edit, the new_string was NOT
written to the file). STOP what you are doing..." is_error: true
tool_use_result: "User rejected tool use"
tool_result_meta: [{non_execution_kind: "user-rejected"}]
user text "[Request interrupted by user for tool use]"
result/error_during_execution {terminal_reason: "aborted_tools", stop_reason: "tool_use"}
The tool_result text was byte-identical across all runs, in both the --output-format stream-json output and the persisted session JSONL.
Why this matters
The model is told, in the same breath, two contradictory things:
tool_result: the tool was rejected, and if it was an edit nothing was written- an adjacent text block:
[Request interrupted by user for tool use]
The interrupt marker is the weaker of the two and is directly contradicted by the tool_result next to it. The failure mode is a model that, after interrupting a long Edit, a git operation, or a migration script, concludes the target is untouched and proceeds on that assumption — skipping the state check it would have made had it known the tool ran.
Note the narrow version of this claim, which is the defensible one: the harness cannot know what side effects a killed command left behind, and it does not need to. It does know the tool started executing — it emitted task_started and then stopped for that tool_use_id. Asserting non-execution is wrong on that basis alone.
Also worth flagging: the field is literally named non_execution_kind, and its schema says "absent means the tool ran to completion". There is no stamp for "started, did not complete" other than the interrupted / cancelled values that this path declines to use.
Affected entry points
The misleading wording is confirmed on three entry points, all with the same probe method and byte-identical tool_result text:
| entry point | engine | real execution before kill | tool_result wording | toolUseResult |
|---|---|---|---|---|
| Agent SDK / stream-json, control_request {subtype: interrupt} | 2.1.227 | 6s, 6s, 15s (3 runs) | identical | User rejected tool use |
| Claude Desktop Code window, Interrupt Claude button | 2.1.227 | 19s | identical | — |
| CLI / TUI, ESC | 2.1.195 | 55s | identical | User rejected tool use |
The non_execution_kind: "user-rejected" mis-stamp is confirmed on 2.1.227 (SDK/stream-json and Desktop). On 2.1.195 the tool_result_meta / non_execution_kind field does not exist at all (zero occurrences in that binary), so the TUI run above cannot speak to it either way.
Worth noting what that implies: the wording problem is long-standing — #47282 reported it in April 2026 — while non_execution_kind is a newer, machine-readable field that arrived with a dedicated interrupted value already in its enum, and this path stamps user-rejected anyway. The structured field reproduced the old mistake rather than fixing it.
VS Code extension — not tested.
The permission-prompt-stage interrupt is a correct use of this wording and is a useful control: in that case the probe log is never created (zero execution) and rejected is accurate.
Environment
- Engine
2.1.227(the build Claude Desktop ships/downloads on macOS:~/Library/Application Support/Claude/claude-code/2.1.227/claude.app/Contents/MacOS/claude) — SDK/stream-json and Desktop runs - Engine
2.1.195(CLI install at~/.local/share/claude/versions/2.1.195) — TUI/ESC run - Desktop app
1.28929.0, macOS 26 (arm64) - Reproduced from clean working directories; five runs total across the three entry points, with two independently written harness scripts for the SDK path
Related
- #47282 — "ESC during tool execution is reported to the assistant as \"rejected\"" — the same defect, reported 2026-04-13, closed by the stale bot 2026-05-25 (
not_planned) and now locked, so it cannot be commented on. Filing fresh per the bot's own instruction. That report focused on the wording being untrue and onSTOP what you are doingover-halting the model; it did not cover thenon_execution_kindmis-stamp, the existence of aninterruptedenum value, or the SDK / Desktop entry points. - anthropics/claude-agent-sdk-typescript#405 — the same scenario (interrupt during an in-flight tool call) surfacing as a misclassified
error_during_executionto SDK consumers. Consistent with theresult/error_during_execution+terminal_reason: aborted_toolsseen here. - #84621 — the structurally identical problem for interrupted subagent dispatches: the parent cannot distinguish "never ran" from "ran and stopped mid-action".
Suggested fix
Stamp non_execution_kind: "interrupted" on this path and give it text that does not assert non-execution — something to the effect of the tool was interrupted while running; its effects, if any, are unknown; verify state before continuing — instead of reusing the permission-denial string.
3 Comments
Reproduced on v2.1.233 (macOS,
claude -p --input-format stream-json --output-format stream-json --allowed-tools Bash).Steps:
probe.sh(appends one line per second to a log file for 40s, then printsCOMPLETED_ALL_40_TICKS).claude -p --verbose --input-format stream-json --output-format stream-json --allowed-tools Bashand send a user message asking it to runbash probe.sh <log>with the Bash tool.{"type":"control_request","request_id":"req_1","request":{"subtype":"interrupt"}}on stdin.Observed: the log stops at 6 lines,
COMPLETED_ALL_40_TICKSnever prints, so the tool ran ~6s and was killed. The stream then emits, for the same tool_use_id:Expected: a result that says the tool was interrupted while running (effects unknown), stamped
non_execution_kind: "interrupted", rather than the permission-denial text anduser-rejectedstamp, which claim the tool never ran.Assessment: This looks like a genuine bug. Claude Code already has a distinct interrupted-tool result message and the documented
interruptedvalue fornon_execution_kind, and the non-streaming tool path uses them when a running tool is aborted. The streaming tool executor, however, replaces the tool's own interrupt result with the permission-rejection message and stampsuser-rejectedwhenever the turn is interrupted, without checking whether the tool was already executing versus still waiting at a permission prompt. Only the latter case is a real rejection. This is long-standing (the wording predates thenon_execution_kindfield, which inherited it), not a recent regression. The fix is to keep the rejection message for tools that had not started and use the interrupt message plusinterruptedfor tools that were killed mid-run.🤖 Generated with Claude Code
A concrete instance of the second-order harm, from today, in the VS Code extension with
permissions on Auto (so no permission decision was involved at any point).
The interrupted tool call was a shell chain:
The user sent a message while it was executing.
rmhad already completed; thehome-manager switchthat would have replaced the file with a symlink had not. So~/.claude/settings.json— 67 permission rules, model, theme, effort level — was gone.The model then read the
tool_resultverbatim and told the user *"nothing was executed,the file was not written"*, because that is what the message asserts. So the wrong
vocabulary did not just stop the work: it produced a confidently wrong report about the
state of the user's machine, and the user was told their file was intact while it was
deleted. It was recoverable only because the
cpon line 1 happened to have run.This occurred three times in one session. The other two were harmless, differing only in
where the chain happened to stop.
Two observations on top of the existing report:
STOP what you are doing and waitis the part that converts an interrupt into anabandoned task. The user's message is delivered and answered, but the in-flight work is
dropped silently, so from their side the agent appears to hang. They said "you're hung
again" — three times, because there was no way to tell that anything had been cancelled.
new_string was NOT written to the file)"* is accurate for edit tools and false for a
partially-executed shell command. A model that trusts it will report a clean state that
does not exist. Even without changing the interrupt semantics, dropping that clause for
Bash — or replacing it with "side effects up to the interruption may have been applied,
verify state before reporting" — would have prevented the data loss being described as a
no-op.
+1, I run into this issue frequently and the misleading system message confuses Claude.