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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗