Skip to content

Commit fbc8365

Browse files
committed
fix(orchestrator): Keep agent output attached after a mid-turn steer
Steering a Claude turn while a tool was running settled the run while the agent was still working. The reply to the steer then had no live turn to attach to, so it was buffered and, with nothing asking for a continuation, silently dropped: the thread went quiet and returned to Ready with the steer unanswered. Two independent defects produced that. The steer guard keyed on one exact string. `isClaudeActiveSteeringAbortResult` matched only `terminal_reason: "aborted_streaming"`, which the CLI reports when a steer interrupts a streaming assistant message. A steer that lands while a tool is running is queued instead and delivered when the tool result arrives, and the CLI ends that native turn with `aborted_tools`, answering the steer in the next one. That result did not match, so the turn finalized while the CLI kept working. A newly recorded fixture captures the shape from a real session: `subtype: success`, `terminal_reason: "aborted_tools"`, empty text, followed by a fresh native turn carrying the answer. `isClaudeSteeringHandoffResult` replaces it. While a steer is outstanding, a result hands off when the CLI cut the turn short (`aborted_streaming` or `aborted_tools`, including on the error result that is how the first of those arrives) or when the turn ended cleanly with nothing to say. A result carrying text has answered something and still terminalizes, so a steer the CLI absorbs into the running turn does not hold the run open; `max_turns`, `background_requested`, `tool_deferred` and the hook reasons are real terminals even as an empty success. Each accepted steer swallows exactly one result, so the turn still ends on the result that answers it, and the handoff is decided before anything else reads the result so it cannot seed the turn's fallback assistant text. Because a handoff makes the turn depend on a native turn that has not opened yet, it is bounded: if no frame reaches the turn within 60 seconds it settles as a failure rather than leaving the run running for the rest of the session. A failure, not a quiet completion, because an accepted steer that was never answered is exactly the silence this change exists to stop. Buffered assistant text could also be stranded. When a run terminalizes early while the query is still live, later frames go to the wake buffer, which only requested a continuation for a tracked task notification, a running subagent, or a result. Ordinary post-settle assistant text asked for nothing, so it sat in the buffer until the session recycled. Assistant text now requests a continuation on its own. Tool_use and tool_result frames stay gated: they are the model working rather than speaking, and the notification that follows them carries the wake detail. Also record replay `stream_event` frames, which every query receives since `includePartialMessages` landed, and allow a fixture to hold its steer until the target run reports a given turn item so a mid-tool steer can be replayed deterministically.
1 parent 71e5450 commit fbc8365

10 files changed

Lines changed: 832 additions & 18 deletions

File tree

apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { makeCheckpointWorkspace } from "../src/orchestration-v2/testkit/ReplayF
1818
import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
1919
import {
2020
MESSAGE_STEERING_INITIAL_PROMPT,
21+
MESSAGE_STEERING_MID_TOOL_PROMPT,
2122
MULTI_TURN_FIRST_PROMPT,
2223
MESSAGE_STEERING_STEER_PROMPT,
2324
READ_ONLY_NEVER_POLICY,
@@ -91,6 +92,12 @@ const CLAUDE_RECORDINGS = {
9192
queryMode: "active_steering",
9293
enableTools: true,
9394
},
95+
message_steering_mid_tool: {
96+
prompts: [MESSAGE_STEERING_MID_TOOL_PROMPT, MESSAGE_STEERING_STEER_PROMPT],
97+
defaultTranscriptFile: "fixtures/message_steering_mid_tool/claude_transcript.ndjson",
98+
queryMode: "active_steering_mid_tool",
99+
enableTools: true,
100+
},
94101
turn_interrupt_mid_tool: {
95102
prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT],
96103
defaultTranscriptFile: "fixtures/turn_interrupt_mid_tool/claude_transcript.ndjson",
@@ -240,6 +247,7 @@ type ClaudeRecordingQueryMode =
240247
| "fork_session_merge_back"
241248
| "fork_session_merge_back_siblings"
242249
| "active_steering"
250+
| "active_steering_mid_tool"
243251
| "interrupt"
244252
| "interrupt_restart";
245253

@@ -259,13 +267,14 @@ function selectedQueryMode(defaultMode: ClaudeRecordingQueryMode): ClaudeRecordi
259267
raw === "fork_session_merge_back" ||
260268
raw === "fork_session_merge_back_siblings" ||
261269
raw === "active_steering" ||
270+
raw === "active_steering_mid_tool" ||
262271
raw === "interrupt" ||
263272
raw === "interrupt_restart"
264273
) {
265274
return raw;
266275
}
267276
throw new Error(
268-
`Unsupported Claude replay query mode '${raw}'. Use 'streaming', 'restart', 'resume_at_cursor', 'fork_session', 'fork_session_prior_turn', 'fork_session_continue', 'fork_session_siblings', 'fork_session_merge_back', 'fork_session_merge_back_siblings', 'active_steering', 'interrupt', or 'interrupt_restart'.`,
277+
`Unsupported Claude replay query mode '${raw}'. Use 'streaming', 'restart', 'resume_at_cursor', 'fork_session', 'fork_session_prior_turn', 'fork_session_continue', 'fork_session_siblings', 'fork_session_merge_back', 'fork_session_merge_back_siblings', 'active_steering', 'active_steering_mid_tool', 'interrupt', or 'interrupt_restart'.`,
269278
);
270279
}
271280

0 commit comments

Comments
 (0)