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

Status Open
Reported on v2.1.227
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 12, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 by task_started for that same id)
  • final result event → "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_kind is the harness-stamped reason an is_error:true result 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
  1. On stdin, send a user message asking it to run bash probe.sh <logpath> with the Bash tool.
  2. Poll the log until it has accumulated several ticks — this confirms the tool is genuinely executing, not sitting at a permission prompt.
  3. Then send on stdin:

``json
{"type":"control_request","request_id":"req_1","request":{"subtype":"interrupt"}}
``

  1. 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 on STOP what you are doing over-halting the model; it did not cover the non_execution_kind mis-stamp, the existence of an interrupted enum 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_execution to SDK consumers. Consistent with the result/error_during_execution + terminal_reason: aborted_tools seen 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.

View original on GitHub ↗

3 Comments

bcherny collaborator · 2 months ago

Reproduced on v2.1.233 (macOS, claude -p --input-format stream-json --output-format stream-json --allowed-tools Bash).

Steps:

  1. Create probe.sh (appends one line per second to a log file for 40s, then prints COMPLETED_ALL_40_TICKS).
  2. Start claude -p --verbose --input-format stream-json --output-format stream-json --allowed-tools Bash and send a user message asking it to run bash probe.sh <log> with the Bash tool.
  3. Wait until the log has 6 lines (tool is genuinely running), then send {"type":"control_request","request_id":"req_1","request":{"subtype":"interrupt"}} on stdin.
  4. Keep polling the log.

Observed: the log stops at 6 lines, COMPLETED_ALL_40_TICKS never prints, so the tool ran ~6s and was killed. The stream then emits, for the same tool_use_id:

system/task_started       task_type: local_bash
control_response          success
system/task_notification  status: "stopped"
user tool_result          is_error: true
  "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..."
  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                    subtype: error_during_execution, terminal_reason: aborted_tools

Expected: a result that says the tool was interrupted while running (effects unknown), stamped non_execution_kind: "interrupted", rather than the permission-denial text and user-rejected stamp, 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 interrupted value for non_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 stamps user-rejected whenever 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 the non_execution_kind field, 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 plus interrupted for tools that were killed mid-run.

🤖 Generated with Claude Code

Titus-von-Koeller · 1 month ago

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:

cp ~/.claude/settings.json dotfiles/home/claude/settings.json   # ran
... edit the nix module ...                                     # ran
rm ~/.claude/settings.json                                      # ran
home-manager switch                                             # never ran

The user sent a message while it was executing. rm had already completed; the
home-manager switch that 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_result verbatim 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 cp on 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:

  1. STOP what you are doing and wait is the part that converts an interrupt into an

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

  1. The parenthetical is actively misleading for Bash. *"(eg. if it was a file edit, the

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.

S-Luiten · 23 days ago

+1, I run into this issue frequently and the misleading system message confuses Claude.