fix(esc): override cancelled tool_result with REJECT_MESSAGE in production path#150
Merged
Merged
Conversation
…ction path When ESC fires during a Bash command, the production tool-dispatch path (`_dispatch_single_tool` in `src/query/query.py`) was passing the bash tool's `<error>Command was aborted before completion</error>` payload through to the model. On the resume turn, the model read this as a generic failure and retried the bash command instead of honouring the user's cancel. The TS reference at `StreamingToolExecutor.ts:153-205` overrides the tool_result with REJECT_MESSAGE when the abort reason is `user_interrupted`, but the Python production REPL bypasses `StreamingToolExecutor` (it dispatches via `_run_tools_partitioned` → `_dispatch_single_tool`), so the override never fired in production. Add the override at four sites in `_dispatch_single_tool` — pre-tool gate, post-tool override, `AbortError` catch, and a late-abort tail in the generic exception handler — all funneling through two helpers (`_is_user_cancelled_abort`, `_build_user_cancelled_result`). The `sibling_error` reason is carved out so the streaming-executor's parallel-tool cascade doesn't get mislabelled as a user rejection. The `except AbortError` branch re-gates on the same user-cancel check so future tools that repurpose `AbortError` for their own internal cancellation aren't silently relabelled as "user rejected". Pinned by 13 tests in `tests/test_esc_reject_message_dispatch.py` covering each override site, the `sibling_error` carve-out, normal completion, and the defensive `AbortError`-without-signal-aborted case. 217/217 tests pass across the abort/ESC/query/bash domain. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced May 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<error>Command was aborted before completion</error>payload through to the model. On the resume turn, the model read this as a generic failure and retried the command instead of honouring the cancel.StreamingToolExecutor.ts:153-205overrides the tool_result withREJECT_MESSAGEwhen the abort reason isuser_interrupted, but the Python production REPL bypassesStreamingToolExecutor(dispatches via_run_tools_partitioned→_dispatch_single_tool), so that override never fired in production._dispatch_single_tool(pre-tool gate, post-tool override,AbortErrorcatch, late-abort tail) — all funneling through two helpers (_is_user_cancelled_abort,_build_user_cancelled_result). Thesibling_errorreason is carved out so the streaming-executor's parallel-tool cascade isn't mislabelled as a user rejection. Theexcept AbortErrorbranch re-gates on the same user-cancel check so future tools repurposingAbortErrorfor their own internal cancellation aren't silently relabelled.Reproduction (before the fix)
User runs a long Bash command, presses ESC, then types
please resume. The model sees the bash tool_result content as<error>Command was aborted before completion</error>and treats it as a transient failure — it retries the command instead of honouring the user's cancel.After the fix
The tool_result for the ESC-cancelled call now contains
REJECT_MESSAGE("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."). On the resume turn the model sees an unambiguous "user rejected" signal in the conversation history.Test plan
tests/test_esc_reject_message_dispatch.py— 13 tests covering each override site, thesibling_errorcarve-out, normal completion, and the defensiveAbortError-without-signal-aborted casetest_esc_reject_message_dispatch + test_esc_cancel_propagation + test_abort_controller* + test_streaming_executor_interruptible + test_tool_execution_integration + test_fast_path_dispatch + test_tool_result_budget + test_query_loop + test_query_engine + test_query_error_recovery + test_query_hook_stopped + test_query_terminal + test_streaming_query_loop + test_bash_parser + test_bash_securityexcept AbortErrorgate and reworked the sibling_error test to exercise a real failure payload)TS parity
Mirrors
typescript/src/services/tools/StreamingToolExecutor.ts:153-205(createSyntheticErrorMessageforuser_interrupted) and:278-292/:332-345(the initial-abort branch + per-iteration abort check).Divergence noted in
_is_user_cancelled_abortdocstring: TS distinguishes'interrupt'(mid-stream submit) from'user_interrupted'(ESC) via a per-toolinterruptBehavior() === 'cancel'gate. Python today emits neither'interrupt'nor any per-toolinterrupt_behavior, so the collapsed check is sound. Any future'interrupt'wire-up must land the per-tool gate first.Follow-ups (out of scope)
<error>Command was aborted before completion</error>payload but withsignal.aborted == False, so the post-tool override doesn't fire. The model may still retry on timeout — pre-existing behavior.ToolContextsnapshot; if ESC trips beforereset_abort_controller, a delayed wake-up could see the previously-aborted controller and trigger REJECT_MESSAGE spuriously.🤖 Generated with Claude Code