fix(server): keep a Claude wake reply off the user's queued message - #13535
Conversation
|
Macroscope skipped reviewing this pull request. Per-review cost limit exceeded (workspace setting). This review would cost an estimated $15.76, which exceeds your per-review limit of $15.00. The top 3 files driving up this estimate:
Tip To get this pull request reviewed, you can:
|
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR makes substantial production changes to Claude turn attribution, buffering, continuation routing, tool callbacks, and subagent lifecycle handling, backed by extensive new fixtures and tests. The asynchronous state-machine behavior and broad runtime impact warrant human review. Not approved because:
Review your spending limits in Billing settings, or comment |
When a background task or subagent finishes during a turn, Claude queues
a wake turn and runs it before the next prompt's turn. The adapter took
whichever turn came next as the answer to the prompt, so a message sent
right after such a turn showed the wake reply ("Agent B's kill is
confirmed…") and its real answer landed in the next continuation run.
The adapter now sends each prompt with a uuid derived from its run
attempt. Claude Code echoes it as user_message_uuid on the first frame of
the turn that answers it; wake turns carry none. Once a CLI process has
echoed on a turn's first frame, root output arriving before a prompt's
echo is held: the echo releases it to the prompt's turn, and a
task-notification-origin result sends it to the wake buffer and so to a
continuation run. The prompt's own turn is never delayed, because its
echo is on its first frame. A CLI that echoes only on the result, or not
at all, is never held and behaves as before. Held output is released to
the turn if the stream ends first.
Replay maps recorded prompt uuids to the replayed ones and accepts the
command_lifecycle frames Claude Code 2.1.281 sends for uuid-carrying
prompts. The recorder sends uuids too and can offer the next prompt
without waiting out queued wakes.
- claude_background_wake_before_queued_prompt: re-recorded live on
2.1.281 with uuids; each prompt gets its own reply and both wake
replies land in continuation runs. Fails without the gate.
- claude_background_wake_before_queued_prompt_no_echo: the earlier
recording without uuids, pinning that a CLI without an echo streams
as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a prompt's echo is pending, a queued wake turn's root output is held and later goes to a continuation run. But its permission callbacks and the frames of subagents it launched were still attached to the pending prompt turn: an ExitPlanMode in a wake turn started its tool and plan in the user's run, the tool was failed when that run settled, and the drain moved the tool while the plan stayed behind. - A permission callback that needs no answer defers to the held tool_use frame: the frame starts the tool, and an ExitPlanMode plan projects when that frame is handled, in whichever run it lands in. - A callback that needs the user's answer (an approval, AskUserQuestion) cannot wait for an echo that only comes after it is answered, so it releases the held output to the prompt turn and asks there, as before output was held. - Subagent and task lifecycle frames tied to a held tool_use are held with it, in order. - A zero-turn task-notification result before the echo is lifecycle debris again: it is dropped as before instead of opening an empty continuation. - A replaced query's stream end no longer releases the live turn's held output. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7058060 to
b4b4009
Compare
In approval-required mode a held ExitPlanMode callback went through the interactive-approval fallback, which released the held frames, and then parked the plan for a tool_use frame that had already been handled, so the plan was never published while Claude was told it was captured. ExitPlanMode answers at once and needs no user input, so it no longer takes that fallback. Its plan is deferred only while its tool_use frame is still held, and published immediately otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1ee0577
into
t3code/codex-turn-mapping
…13535) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a background task or subagent finishes during a turn, Claude queues a wake turn and runs it before the next prompt's turn. The adapter took whichever turn came next as the answer to the prompt. So a message sent right after such a turn showed the wake reply, and its real answer landed in the following continuation run.
Before / after (same live recording, replayed)
RESUMEDRESUMEDWhat the live CLI shows (Claude Code 2.1.281)
SDKUserMessage.uuidis echoed back asuser_message_uuid(plususer_message_uuids). It appears on the first rootmessage_startof the turn that answers the prompt, on its first assistant frame, and on its result.command_lifecycleframe (queued/started/completed, keyed bycommand_uuid) for each prompt that carries a uuid.prompt.offer:3is followed by a wake turn (resultorigin: task-notification, no echo). Only then comes the prompt's turn, whose firstmessage_startechoes its uuid.message_start, and none of 89 wake turns carry one. 2.1.236 echoes only on the result.Fix
claudePromptUuid, SHA-256 shaped as a v4 uuid), so replays stay deterministic.ExitPlanModeplan is projected when the frame is handled, in whichever run it lands in. This holds in every runtime mode:ExitPlanModeanswers at once, so it never takes the approval path below. Its plan is deferred only while its tool_use frame is still held and is published immediately otherwise, so a plan Claude is told was captured is never dropped.AskUserQuestion) cannot wait for an echo that only comes after it is answered. It releases the held output to the pending prompt turn and asks there, which is where this output went before the hold existed. So the SDK is never left waiting.task-notification-origin result with at least one turn sends it to the wake buffer, which becomes a continuation run as before.task-notificationresult is lifecycle debris: it is dropped as before, and held output stays held for its real owner.peer,channel,coordinator,auto-continuation). Their attribution is unchanged from before; only their streaming is delayed until their result.Replay maps recorded prompt uuids to the replayed ones and accepts
command_lifecycleframes; older recordings without uuids still match. The recorder now sends uuids too, and can offer the next prompt without waiting out queued wakes.Tests
claude_background_wake_before_queued_prompt(fixture), re-recorded live on 2.1.281 with uuids:claude_background_wake_before_queued_prompt_no_echo(fixture): the earlier recording made without uuids. It pins that a CLI without an echo streams as before.ClaudeAdapterV2.test.ts:ExitPlanModecallback (the review's case), in both full-access and approval-required mode. Driven through continuation drain: the tool (every update), its proposed plan, and the plan artifact all land in the continuation run. The tool is not failed by the user's run, the user's run shows only its own reply, and the plan Claude is told was captured is projected.task-notificationresult before the echo. No continuation is opened.Verification
vp test run src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts -t claude: 26 passed.vp test runonClaudeAdapterV2.test.ts,ClaudeReplayFixtures.integration.test.ts, andOrchestratorReplayFixtures.contract.test.ts: 122 passed.RESUMED.ExitPlanModetest, the wake-subagent test, and the zero-turn test all fail.b4b4009d8f, the approval-requiredExitPlanModecase fails (its plan is never published); the full-access case passes.vp exec tsc --noEmit -p .inapps/server: 0error TS, 0warning TS.vp run knip:check: passes.vp linton the touched files: only the one warning already on the base.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code