fix(acp): clear stale interrupted prompt when a new turn starts - #56624
fix(acp): clear stale interrupted prompt when a new turn starts#56624golldyck wants to merge 1 commit into
Conversation
interrupted_prompt_text is set only on a running-cancel (for the /steer salvage feature) and cleared only inside the /steer salvage path. If the user cancels a running turn, then sends an ordinary prompt that runs to completion, the interrupted prompt is never cleared. A later /steer on the now-idle session then sees the stale value as truthy and resurrects it, silently re-running a task the user cancelled two turns ago and moved on from. Clear interrupted_prompt_text at the turn-start transition so any real turn drops the stale salvage buffer. The /steer salvage path already consumes and clears it before reaching this point, so legitimate immediate-salvage (steer directly after a client cancel) is unaffected. Adds a regression test; existing salvage/idle tests still pass.
|
Thanks for the focused ACP regression fix. Current main still stores the cancelled prompt in The added regression test covers the stale-buffer sequence, and the affected files are unchanged from the PR base on current main, so the salvage should be clean. Automated hermes-sweeper review. |
|
We hit this in production last week, so I went to adopt this patch and found it doesn't fully close the hole. Sharing the repro and what we ended up shipping, in case it's useful. The incident. A turn was cancelled, arming The turn-start clear runs too lateThe clear lands at the This is verifiable with the test in this PR. On a clean The What we shippedThe clear is necessary but not sufficient. Three things together, each independently reverted and confirmed to turn a test red:
We also routed both salvage call sites through shared consume/merge helpers so the One scoping noteThis doesn't apply to Happy to open a PR with the window + |
A cancelled prompt could be replayed as the leading instruction of an
unrelated turn hours later. Live incident 2026-08-12: a turn was
cancelled after it had already delivered its answer, which armed
state.interrupted_prompt_text; four hours on, a different user's
one-line request ("give me editor access to both of these sheets") was
merged BEHIND that abandoned prompt. The agent read the stale request as
the directive and restarted building spreadsheets nobody had asked for.
Three independent defects, each sufficient to cause the derail:
1. The salvage buffer had no lifetime. It is meant for clients that
implement "stop and send" as cancel-then-submit, two protocol calls
milliseconds apart. Nothing bounded it, so it survived indefinitely.
Stamp it on arm and refuse it outside a 30s window, clearing the dead
buffer so it cannot ambush a later prompt. An unstamped buffer fails
closed (not salvageable).
2. cancel() armed the buffer from any running turn, including one that
had already produced and delivered its final response. The response
is delivered inside the post-turn tail while is_running is still True
(server.py), so a late cancel captured an already-fulfilled request.
Track response_delivered and refuse to arm once the turn has answered.
3. The merge put the interrupted prompt FIRST and demoted the user's
live message to "User correction/guidance after interrupt: ...".
Models correctly read the leading imperative as the task. Flip it:
the new message leads, the interrupted one follows as explicitly
labelled reference-only context so deictic follow-ups ("not that
file") still resolve.
Both salvage call sites now share _consume_interrupted_prompt (gating)
and _merge_interrupted_prompt (wording), so the two paths cannot drift.
Also adopts the turn-start clear from upstream PR
NousResearch#56624. That fix alone is insufficient here:
it clears the buffer at the is_running=True transition, which runs
AFTER the plain-text salvage branch has already consumed it, so it only
protects the turn after the offending one. Verified by test — see the
upstream PR comment accompanying this change.
Restores coverage for the salvage paths, which our fork's test-prune
(3997561) had dropped entirely.
The turn-start clear adopted from upstream PR NousResearch#56624 broke an existing invariant: tests/acp/test_server.py:: test_notification_does_not_resurrect_cancelled_prompt asserts that a SYNTHETIC notification delivery must leave a cancelled prompt intact for the next real user prompt. Notifications route through prompt(), so the unscoped clear wiped the buffer from a background event — the user never moved on, a timer did. Gate the clear on `not synthetic_notification`. This is a latent regression in the upstream PR as written, not just in our adaptation; reported on the PR. Also pins the clear with a test. Previously nothing failed when it was removed, because on text-only turns the salvage branches consume the buffer before it is reached. The case it genuinely covers is a turn that SKIPS those branches — a multimodal prompt — which would otherwise leave a live buffer for the next text prompt to pick up. Full mutation check now red for all four guards: salvage window, precedence order, response_delivered gate, and this turn-start clear.
What does this PR do?
Fixes a state-machine bug in the ACP adapter. A
/steeron an idle session can resurrect a prompt the user cancelled and moved on from.state.interrupted_prompt_textpowers the "Zed-interrupt salvage" feature, which replays an interrupted prompt when/steerarrives right after a client cancel (PR #18258). The problem is how the field is managed. It is set only incancel(), when a turn is actively running. It is cleared only inside the/steersalvage block. No other path clears it, so this sequence leaks a stale value:"refactor the auth module". User hits ESC, socancel()storesinterrupted_prompt_text = "refactor the auth module". Turn ends."what's the weather". It runs to completion.interrupted_prompt_textis still"refactor the auth module", because the normal turn never cleared it./steer be conciseon the idle session. The salvage block sees the stale value as truthy and runs:"refactor the auth module\n\nUser correction/guidance after interrupt: be concise"as a real LLM turn.The user expected to nudge the current (nonexistent) turn. Instead the agent re-executes an abandoned task from two turns ago.
Fix: clear
interrupted_prompt_textat the turn-start transition, whereis_runningflips toTrue, so starting any real turn drops the stale salvage buffer. The/steersalvage path already consumes and clears the field before this point, so legitimate immediate-salvage (steer directly after a client cancel, with no intervening turn) is unaffected.Related Issue
Fixes #
Type of Change
Changes Made
acp_adapter/server.py: inprompt(), setstate.interrupted_prompt_text = ""in theis_running = Trueturn-start transition.tests/acp_adapter/test_acp_commands.py:test_acp_normal_turn_clears_stale_interrupted_promptchecks that a running-cancel value is cleared by a completed normal turn, and that a subsequent/steerruns only the steer text.How to Test
scripts/run_tests.sh tests/acp_adapter/test_acp_commands.py -q. 7 tests pass, including the existing salvage testtest_acp_steer_after_zed_interrupt_replays_interrupted_prompt_with_guidance.acp_adapter/server.pyhunk and rerun-k clears_stale. The new test fails, sinceinterrupted_prompt_textstill holds the abandoned prompt and/steerresurrects it. Restore the hunk and it passes.Checklist
Code
fix(acp):)interrupted_prompt_text, none; open PR fix(acp): ignore idle cancel before next prompt #50461 touches idle-cancelcancel_eventpoisoning, a different field and path, and leaves this bug)Documentation & Housekeeping
docs/, docstrings): N/A (inline comment explains the clear)cli-config.yaml.example: N/ACONTRIBUTING.md/AGENTS.md: N/Ascripts/check-windows-footguns.pyclean)