diff --git a/docs/harnesses/claude-code/claude-code-steer.md b/docs/harnesses/claude-code/claude-code-steer.md index ce94871ad..88a118475 100644 --- a/docs/harnesses/claude-code/claude-code-steer.md +++ b/docs/harnesses/claude-code/claude-code-steer.md @@ -6,25 +6,29 @@ Host 在原生接受后自己发当前 Turn 的 `userMessage` Item。Adapter 不 ## 何时同轮插入 -`priority: "next"` 在**当前工具调用结束后**、下一次模型请求之前并入;工具结果之后一定还有一次模型请求,所以插入写入时只要有根级工具调用正在执行,它就一定并入当前 Turn。反过来,如果 Claude 正在输出最终文本、没有工具在执行,它可能直接结束本轮,把插入留到下一轮执行:2026-10-22 在 Claude Code 2.1.305 上实测,Host 把插入显示在原来那一轮,而 Claude 在随后的另一轮里回复。 +`priority: "next"` 在**当前工具调用结束后**、下一次模型请求之前并入;工具结果之后一定还有一次模型请求,所以插入写入时只要有根级工具调用正在执行,它就一定并入当前 Turn。反过来,如果 Claude 正在输出最终文本、没有工具在执行,它可能直接结束本轮,把插入留到下一轮执行:2026-10-22 在 Claude Code 2.1.305 上实测,Host 把插入显示在原来那一轮,而 Claude 在随后的另一轮里回复;现在这类已写入、尚未被接管的插入由下述保活保证兜住,Turn 保持 held,插入作为该 Turn 的下一段运行,不再落入另一轮。 -因此 `capabilities.steer` 实时取以下三项的交集,任何一项不满足都为 `false`,Host 改用停止后重发: +因此 `capabilities.steer` 实时取以下条件的交集,任何一项不满足都为 `false`,Host 改用停止后重发: - CLI 在 `system/init` 的 `capabilities` 中报告 `msg_lifecycle_v1`,会发出确认插入所需的 `command_lifecycle` 回执; -- 当前是用户发起的 Turn,不是后台通知等触发的自主 Turn(transport 没有对应的活跃 Turn); -- 有根级工具调用已经开始、还没返回结果(子 Agent 内部的工具调用不计入)。 +- 当前是用户发起的 Turn,不是后台通知等触发的自主 Turn(transport 没有对应的活跃 Turn),且它的取消尚未开始; +- 处于以下两种窗口之一: + - 有根级工具调用已经开始、还没返回结果(子 Agent 内部的工具调用不计入),插入在当前工具调用结束后并入本轮; + - held Turn 的续态:主回复已结束、后台 Subagent 仍在运行、idle 流存活,插入作为该 Turn 的下一段运行,后台任务不被停止。 -`turn.steer` 在写入输入流前同步复查上述三项,不依赖 Renderer 之前的查询结果;工具已经结束时拒绝且不写入。Renderer 已按同轮展示时,Host 报错而不停止后重发。Harness Broker 经公共 `readCapabilities()` 按需读取当前能力;旧 Broker 未声明支持查询时保持停止后重发。 +`turn.steer` 在写入输入流前同步复查上述条件,不依赖 Renderer 之前的查询结果;两个窗口都已关闭、或取消已在进行时拒绝且不写入。Renderer 已按同轮展示时,Host 报错而不停止后重发。Harness Broker 经公共 `readCapabilities()` 按需读取当前能力;旧 Broker 未声明支持查询时保持停止后重发。 + +已写入的插入还有一个额外保证:尚未被 Claude Code 原生接管(CLI 对这条消息的 uuid 发出 `command_lifecycle` `started`,并入当前段或作为下一原生 turn 运行都会发出)之前,它会让该 Turn 保持 held,不会被 continuation 静默计时器或当前段的结束提前收口,两个窗口里同样成立。因此即使插入写入时 Claude 正在输出续态的最终文本(没有根级工具执行,插入要等这段输出结束后作为下一个原生 turn 运行),这个原生 turn 也作为该 Turn 的下一段运行,回复留在同一 Turn,不会落入另一个自主 Turn。保活是有界的:这条消息以 `completed`、`cancelled`、`discarded`、`refused` 等终态结束却没发出 `started`、`queued` 回执 5 秒内未到,或会话关闭、故障时,保活即解除,Turn 按没写过这条插入的状态重新收口;收到 `queued` 后则依赖 CLI 对每条命令恰好报告一次终态,不再另设上限,用户仍可停止该 Turn,停止后的处理见下文“接受”一节。 ## 接受 -CLI 先发 `command_lifecycle` `queued`,表示 Claude 已接受这条输入;工具调用结束后发 `started`,这时插入进入正在运行的 Turn。Adapter 在 `queued` 时返回 `{ accepted: true }`:此后由 Claude 负责送达,不等 `started`,否则调用方要被整段工具调用挂住(Desktop 提交约 30 秒超时)。输入一旦写入就无法撤回,所以本轮的 `result` 先于 `queued` 到达时不拒绝,仍按接受处理;`queued` 回执迟迟不到时,等待 5 秒后同样按接受处理,避免串行处理请求的调用方(如 Harness Broker)被无限期占住。只有目标不是当前 Turn、CLI 不支持、当前是自主 Turn、没有根级工具执行,或输入写入失败时,才返回失败。写入后会话关闭或出错时抛出回执丢失异常,不返回表示未收下的拒绝结果,Host 不自动重发;Session 故障仍经原有故障路径报告。空文本返回 `invalidRequest`。忙时 `turn.start` 仍是 `sessionBusy`。 +CLI 先发 `command_lifecycle` `queued`,表示 Claude 已接受这条输入;根级工具执行中插入时,工具调用结束后发 `started`,插入进入正在运行的 Turn;续态窗口插入时,`started` 在当前续态输出结束、插入作为下一原生 turn 运行之时发出。Adapter 在 `queued` 时返回 `{ accepted: true }`:此后由 Claude 负责送达,不等 `started`,否则调用方要被整段工具调用挂住(Desktop 提交约 30 秒超时)。输入一旦写入就无法撤回,所以本轮的 `result` 先于 `queued` 到达时不拒绝,仍按接受处理:此时 Turn 不结束,保持 held,插入作为该 Turn 的下一段运行,不再落入另一轮,期间 `turn.start` 返回 `sessionBusy`;`queued` 回执迟迟不到时,等待 5 秒后同样按接受处理,避免串行处理请求的调用方(如 Harness Broker)被无限期占住。只有目标不是当前 Turn、CLI 不支持、当前是自主 Turn、取消已在进行、两个可插入窗口(根级工具执行中与 held Turn 续态)都不成立,或输入写入失败时,才返回失败。写入后会话关闭或出错时抛出回执丢失异常,不返回表示未收下的拒绝结果,Host 不自动重发;Session 故障仍经原有故障路径报告。空文本返回 `invalidRequest`。忙时 `turn.start` 仍是 `sessionBusy`。 -插入被接受后、还没并入之前,如果用户停止了这一轮,按 SDK 的中断语义,仍在排队的插入会在下一轮执行。公开的 SDK 接口目前没有撤回单条排队消息的方法,Adapter 不处理这种情况;由于只在工具执行期间插入,这个窗口只剩当前那次工具调用的剩余时间。 +插入被接受后、还没并入之前,如果用户停止了这一轮,处理取决于 Turn 是否 held。根级工具执行中、非 held 的活跃 Turn 仍按 SDK 的中断语义,仍在排队的插入会在下一轮执行:公开的 SDK 接口目前没有撤回单条排队消息的方法,Adapter 不处理这种情况,这个窗口只剩当前那次工具调用的剩余时间。held Turn(包括因存在待接管的插入而保持 held 的)则不立即结束,而是走停止后台工作并等待安静的取消流程:插入随后被接管时,它运行所在的原生段会被中断;以终态结束却未被接管时,取消随即收口;迟迟没有结果时沿用既有的取消升级,超时后关闭原生进程。续态窗口插入、尚未被接管时被停止就属于这种情况,窗口只剩插入写入到出现结果之间的短暂时间,且取消已开始后不再接受新的插入。 ## 版本 -2026-09-24 在 Claude Code 2.1.280 上实机确认;2026-10-21 在 Claude Code 2.1.305(Agent SDK 0.3.273 与 0.3.305)上复核了相同的 `queued` → `started` → `completed` 顺序和单个 `result`,这两个版本都会报告 `msg_lifecycle_v1`。其他版本只依据这项运行时能力判断,没有逐个实机验证。 +2026-09-24 在 Claude Code 2.1.280 上实机确认;2026-10-21 在 Claude Code 2.1.305(Agent SDK 0.3.273 与 0.3.305)上复核了相同的 `queued` → `started` → `completed` 顺序和单个 `result`,这两个版本都会报告 `msg_lifecycle_v1`。2026-10-26 在 Claude Code 2.1.303 上实机确认了 held Turn 续态的同轮插入:主回复结束、后台 Subagent 运行中写入,插入作为该 Turn 下一段得到回复,后台 Subagent 不受影响并最终正常完成。其他版本只依据这项运行时能力判断,没有逐个实机验证。 ## 历史 diff --git a/packages/adapters/claude-code/src/claude-code-adapter.ts b/packages/adapters/claude-code/src/claude-code-adapter.ts index b9e3f691d..d9c13ba3b 100644 --- a/packages/adapters/claude-code/src/claude-code-adapter.ts +++ b/packages/adapters/claude-code/src/claude-code-adapter.ts @@ -229,6 +229,8 @@ interface ActiveTurn { cancellation: TurnCancellation | null; /** True while Claude can still produce a native result for the current Root Segment. */ rootSegmentActive: boolean; + /** Written steers Claude has not yet drained into a native turn; the Turn must stay open. */ + steersAwaitingNative: Set; completion: Promise; resolveCompletion(): void; } @@ -580,10 +582,15 @@ class ClaudeHarnessSession implements HarnessSession { }, history: { fork: true, forkAcrossCwd: false, rollbackLastTurn: true }, subagents: { observe: true, readTranscript: true }, - // Read live: native delivery joins this Turn only while a requested Turn runs a root tool call - // on a CLI that confirms steers (`msg_lifecycle_v1`). Otherwise the Host stops and restarts. + // Read live: native delivery joins this Turn while a root tool call runs, or while a held + // Turn's continuation waits on background work, on a CLI that confirms steers + // (`msg_lifecycle_v1`). A Turn whose cancellation is in flight must not take more input; + // every other case stops and restarts. get steer(): boolean { - return session.#transport?.canSteerActiveTurn?.() === true; + return ( + session.#transport?.canSteerActiveTurn?.() === true && + session.#active?.cancellationRequested !== true + ); }, }))(this); readonly commands: HarnessCommandCapability; @@ -974,6 +981,7 @@ class ClaudeHarnessSession implements HarnessSession { nativeTurnKey, nativeTurnRef: null, cancellationRequested: false, + steersAwaitingNative: new Set(), usageRequestIds: new Set(), estimatedInputTokens: 0, estimatedOutputTokens: 0, @@ -1015,9 +1023,14 @@ class ClaudeHarnessSession implements HarnessSession { } async #steer(command: TurnSteerCommand): Promise> { - if (!this.#active || this.#active.command.turnId !== command.turnId) { + const active = this.#active; + if (!active || active.command.turnId !== command.turnId) { return { ok: false, error: invalidState("Claude Code steer target is not the active Turn") }; } + if (active.cancellationRequested) { + // A cancelled Turn must not take more input; its Segments are being interrupted. + return { ok: false, error: invalidState("Claude Code steer target is being cancelled") }; + } const text = command.input.map((input) => input.text).join("\n"); if (text.length === 0) { return { @@ -1043,10 +1056,18 @@ class ClaudeHarnessSession implements HarnessSession { }, }; } + // The write happens synchronously inside steer(); claim the keep-open before it, so neither + // the Root result nor continuation quiescence finishes the Turn before Claude drains the steer. + const messageId = this.#randomUUID(); + active.steersAwaitingNative.add(messageId); try { - await transport.steer(text, this.#randomUUID()); + await transport.steer(text, messageId, (started) => + this.#takeOverSteer(active, messageId, started), + ); } catch (error) { if (error instanceof ClaudeSteerMissedTurnError) { + // Nothing was written, or the Session died after writing and the Turn ends anyway. + this.#takeOverSteer(active, messageId, false); return { ok: false, error: invalidState("Claude Code steer missed the active Turn") }; } throw error; @@ -1165,6 +1186,7 @@ class ClaudeHarnessSession implements HarnessSession { nativeTurnKey, nativeTurnRef: null, cancellationRequested: false, + steersAwaitingNative: new Set(), usageRequestIds: new Set(), estimatedInputTokens: 0, estimatedOutputTokens: 0, @@ -1578,7 +1600,10 @@ class ClaudeHarnessSession implements HarnessSession { if ( transport && (taskIds.length > 0 || - (active.held && (active.rootSegmentActive || this.#occupancy.awaitingContinuation))) + (active.held && + (active.rootSegmentActive || + this.#occupancy.awaitingContinuation || + active.steersAwaitingNative.size > 0))) ) { await this.#cancelBackgroundWork(active, transport, taskIds); return { ok: true, value: { cancellationRequested: true } }; @@ -1658,6 +1683,11 @@ class ClaudeHarnessSession implements HarnessSession { const cancellation = active.cancellation; if (this.#active !== active || !cancellation || this.#hardCancelTask) return; if (cancellation.stopping || cancellation.interrupting || active.rootSegmentActive) return; + if (active.steersAwaitingNative.size > 0) { + this.#armCancelEscalation(active); + return; + } + this.#clearCancelEscalation(); this.#clearCancellationQuiet(cancellation); const quiet = setTimeout(() => { cancellation.quiet = null; @@ -2322,6 +2352,7 @@ class ClaudeHarnessSession implements HarnessSession { const active: ActiveTurn = { command: { type: "turn.start", turnId, input: [] }, compactionItem: null, + steersAwaitingNative: new Set(), item, agentMessageOrdinal: 1, assistantMessageId: null, @@ -2809,6 +2840,7 @@ class ClaudeHarnessSession implements HarnessSession { this.#active !== active || !active.held || active.rootSegmentActive || + active.steersAwaitingNative.size > 0 || this.#phase !== "open" ) { return; @@ -2832,6 +2864,33 @@ class ClaudeHarnessSession implements HarnessSession { this.#clearContinuationQuiescence(); } + /** + * A drained steer runs in a native turn whose result still reaches this Turn, folded into the + * running Root Segment or as the held Turn's next one. A steer that ends without one releases + * its keep-open, so a Turn held only for it settles as if the steer had never been written. + */ + #takeOverSteer(active: ActiveTurn, messageId: string, started: boolean): void { + if (!active.steersAwaitingNative.delete(messageId) || this.#active !== active) return; + if (started) { + this.#handleTurnEvent(active, { type: "segment.started" }); + return; + } + if (active.cancellation) { + this.#settleCancellation(active); + return; + } + if ( + !active.held || + active.rootSegmentActive || + active.steersAwaitingNative.size > 0 || + this.#phase !== "open" + ) { + return; + } + if (this.#occupancy.awaitingContinuation) this.#armContinuationQuiescence(active); + else if (!this.#occupancy.unsettled) this.#finish(active, { status: "succeeded" }); + } + #finish(active: ActiveTurn, outcome: TurnOutcome): void { if (this.#active !== active) return; this.#requestUsageBoundary += 1; @@ -2842,7 +2901,9 @@ class ClaudeHarnessSession implements HarnessSession { } this.#clearContinuationQuiescence(); const hold = - outcome.status === "succeeded" && !active.cancellationRequested && this.#occupancy.unsettled; + outcome.status === "succeeded" && + !active.cancellationRequested && + (this.#occupancy.unsettled || active.steersAwaitingNative.size > 0); this.#closeActiveInteractions( active, outcome.status === "succeeded" ? "superseded" : "cancelled", diff --git a/packages/adapters/claude-code/src/sdk-transport.ts b/packages/adapters/claude-code/src/sdk-transport.ts index b9bf70387..cad990efe 100644 --- a/packages/adapters/claude-code/src/sdk-transport.ts +++ b/packages/adapters/claude-code/src/sdk-transport.ts @@ -493,6 +493,8 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { timer: ReturnType | undefined; } >(); + /** Written steers Claude has not yet drained into a native turn, by message ID. */ + readonly #steerTakeovers = new Map void>(); #autonomous: AutonomousSegment | null = null; #autonomousTurnHandler: ClaudeAutonomousTurnHandler | null = null; #idleHandler: ClaudeIdleTurnHandler | null = null; @@ -629,9 +631,14 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { * `priority: "next"` joins after the current tool call, and a tool result is always followed by * another model request. So a steer written while a root tool call runs folds into this Turn; * one written while Claude streams its final text may instead run as the next Turn. + * + * A held Turn's continuation steers as well: while its idle stream is live, the pushed input + * runs as that Turn's next Segment, and the background work the Turn waits on keeps running. */ canSteerActiveTurn(): boolean { - return this.#nativeSteer && this.#active !== null && this.#rootToolCalls.size > 0; + if (!this.#nativeSteer) return false; + if (this.#active !== null) return this.#rootToolCalls.size > 0; + return this.#idleLive && this.#idleHandler !== null; } #observeRootToolCalls(message: unknown): void { @@ -853,7 +860,7 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { * The Turn ending first therefore does not reject it, and a missing `queued` receipt resolves * after a bounded wait so callers that serialize requests are never held indefinitely. */ - steer(text: string, messageId: string): Promise { + steer(text: string, messageId: string, onTakeover?: (started: boolean) => void): Promise { if (this.#closePromise || !this.#started || !this.#query || !this.canSteerActiveTurn()) { return Promise.reject(new ClaudeSteerMissedTurnError()); } @@ -874,9 +881,14 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { this.#pendingSteers.delete(messageId); return Promise.reject(new ClaudeSteerMissedTurnError()); } + if (onTakeover) this.#steerTakeovers.set(messageId, onTakeover); const pending = this.#pendingSteers.get(messageId); if (pending) { - pending.timer = setTimeout(() => this.#acceptSteer(messageId), this.#steerQueuedTimeoutMs); + pending.timer = setTimeout(() => { + // Without a `queued` receipt there is no evidence Claude will report this steer's fate. + this.#acceptSteer(messageId); + this.#takeOverSteer(messageId, false); + }, this.#steerQueuedTimeoutMs); pending.timer.unref?.(); } return promise; @@ -890,6 +902,13 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { pending.resolve(); } + #takeOverSteer(messageId: string, started: boolean): void { + const onTakeover = this.#steerTakeovers.get(messageId); + if (!onTakeover) return; + this.#steerTakeovers.delete(messageId); + onTakeover(started); + } + respondToInteraction(response: ClaudeInteractionResponse): Promise { const pending = this.#interactions.get(response.requestId); if (!pending) { @@ -1255,6 +1274,9 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { } #rejectPendingSteers(): void { + for (const messageId of [...this.#steerTakeovers.keys()]) { + this.#takeOverSteer(messageId, false); + } const pending = [...this.#pendingSteers.values()]; this.#pendingSteers.clear(); // Written input may still be delivered. Only pre-write failures are ClaudeSteerMissedTurnError. @@ -1265,18 +1287,29 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { } } - /** `queued` is Claude's acceptance of this message into the running Session. */ + /** + * `queued` is Claude's acceptance of this message into the running Session. `started` is its + * drain into a native turn, folded into the running one or run as the next; a terminal state + * without `started` means no native turn takes it over. + */ #observeSteer(message: unknown): void { if ( - this.#pendingSteers.size === 0 || + (this.#pendingSteers.size === 0 && this.#steerTakeovers.size === 0) || !isRecord(message) || message.type !== "command_lifecycle" ) { return; } - if (message.state !== "queued") return; if (typeof message.command_uuid !== "string" || message.command_uuid.length === 0) return; - this.#acceptSteer(message.command_uuid); + if (message.state === "queued") { + this.#acceptSteer(message.command_uuid); + return; + } + if ( + ["started", "completed", "cancelled", "discarded", "refused"].includes(String(message.state)) + ) { + this.#takeOverSteer(message.command_uuid, message.state === "started"); + } } #observeBackgroundTasks(message: unknown): void { @@ -1412,12 +1445,12 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { if (!this.#closePromise) throw new Error("Claude SDK Query ended unexpectedly"); } catch (error) { const active = this.#active; + if (!this.#closePromise) this.#onFault(error); this.#closeInteractions("all", "cancelled"); this.#rejectPendingSteers(); this.#rootToolCalls.clear(); this.#active = null; active?.reject(error); - if (!this.#closePromise) this.#onFault(error); } } diff --git a/packages/adapters/claude-code/src/transport.ts b/packages/adapters/claude-code/src/transport.ts index 921a30e90..cc09fe2d7 100644 --- a/packages/adapters/claude-code/src/transport.ts +++ b/packages/adapters/claude-code/src/transport.ts @@ -290,8 +290,10 @@ export interface ClaudeTurnTransport { */ supportsNativeSteer?(): boolean; /** - * True while a requested Turn has a root tool call running on a CLI that confirms steers. A - * steer written now joins this Turn after that tool call; otherwise it may run as the next Turn. + * True while a requested Turn has a root tool call running on a CLI that confirms steers, or + * while a held Turn's continuation is idle-live waiting on background work. A steer written + * in the first case joins this Turn after that tool call; in the second it runs as the held + * Turn's next Segment without stopping the work it waits on. */ canSteerActiveTurn?(): boolean; getPermissionMode(): ClaudePermissionMode; @@ -328,8 +330,12 @@ export interface ClaudeTurnTransport { * the current tool call, or running it next. The Turn ending first does not reject a written * message. Rejects with {@link ClaudeSteerMissedTurnError} when no Turn is active, the message * cannot be written, or the transport closes or faults before acceptance. + * + * `onTakeover` runs once for a written message: `true` when Claude drains it into a native turn + * (`command_lifecycle` `started`), `false` when it ends without one, the `queued` receipt never + * arrives, or the transport closes or faults first. */ - steer(text: string, messageId: string): Promise; + steer(text: string, messageId: string, onTakeover?: (started: boolean) => void): Promise; respondToInteraction(response: ClaudeInteractionResponse): Promise; /** Interrupts the running requested or autonomous Turn; its Terminal still follows. */ abort(): Promise; diff --git a/packages/adapters/claude-code/test/claude-code-adapter.test.ts b/packages/adapters/claude-code/test/claude-code-adapter.test.ts index 6365e4302..996f8dc23 100644 --- a/packages/adapters/claude-code/test/claude-code-adapter.test.ts +++ b/packages/adapters/claude-code/test/claude-code-adapter.test.ts @@ -3,7 +3,8 @@ import type * as FsPromises from "node:fs/promises"; import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { harnessPermissionModeIdSchema, harnessThinkingOptionIdSchema, @@ -23,6 +24,7 @@ import { claudeBypassPermissionsAvailable, type ClaudePermissionMode, } from "../src/permission-modes.js"; +import { ClaudeSdkTransport } from "../src/sdk-transport.js"; import { ClaudeSteerMissedTurnError, type ClaudeAdapterDependencies, @@ -37,6 +39,9 @@ import { type ClaudeTurnEvent, type ClaudeTurnTransport, } from "../src/transport.js"; +import { FakeQuery } from "./fake-sdk-query.js"; + +afterEach(() => vi.useRealTimers()); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -122,6 +127,7 @@ class FakeClaudeTransport implements ClaudeTurnTransport { string, { resolve(): void; reject(error: ClaudeSteerMissedTurnError): void } >(); + readonly #steerTakeovers = new Map void>(); #assistantMessageId: string | null = null; #active: | { @@ -203,17 +209,36 @@ class FakeClaudeTransport implements ClaudeTurnTransport { } canSteerActiveTurn(): boolean { - return this.nativeSteer && this.#active !== undefined && this.rootToolRunning; + if (!this.nativeSteer) return false; + if (this.#active !== undefined) return this.rootToolRunning; + return this.idleLive && this.idleHandler !== null; } - steer(text: string, messageId: string): Promise { - if (!this.#active) return Promise.reject(new ClaudeSteerMissedTurnError()); + steer(text: string, messageId: string, onTakeover?: (started: boolean) => void): Promise { + if (!this.canSteerActiveTurn()) return Promise.reject(new ClaudeSteerMissedTurnError()); this.steers.push({ text, messageId, priority: "next" }); + if (onTakeover) this.#steerTakeovers.set(messageId, onTakeover); return new Promise((resolve, reject) => { this.#pendingSteers.set(messageId, { resolve, reject }); }); } + /** Claude drains the written steer into a native turn (`command_lifecycle` `started`). */ + startSteer(messageId: string): void { + this.#takeOverSteer(messageId, true); + } + + /** Claude ends the written steer without a native turn, or the transport stops awaiting it. */ + releaseSteer(messageId: string): void { + this.#takeOverSteer(messageId, false); + } + + #takeOverSteer(messageId: string, started: boolean): void { + const onTakeover = this.#steerTakeovers.get(messageId); + this.#steerTakeovers.delete(messageId); + onTakeover?.(started); + } + recordSteer(messageId: string): void { const pending = this.#pendingSteers.get(messageId); if (!pending) throw new Error(`No pending Claude steer ${messageId}`); @@ -225,6 +250,7 @@ class FakeClaudeTransport implements ClaudeTurnTransport { const error = new ClaudeSteerMissedTurnError(); for (const pending of this.#pendingSteers.values()) pending.reject(error); this.#pendingSteers.clear(); + for (const messageId of [...this.#steerTakeovers.keys()]) this.releaseSteer(messageId); } event(event: ClaudeTurnEvent): void { @@ -1203,6 +1229,41 @@ describe("Claude Code HarnessAdapter", () => { await adapter.close(); }); + it("completes a root tool window Turn at its result once the steer folded in", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + await session.execute(textTurn("turn-1")); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("turn-1"), + input: [{ type: "text", text: "fold this in" }], + }); + await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + const messageId = transport.steers[0]?.messageId ?? ""; + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + // Claude drains it after the tool call, before the Root result. + transport.startSteer(messageId); + transport.delta("Folded answer.", "folded-answer"); + transport.finish({ status: "succeeded" }); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "turn-1", + outcome: { status: "succeeded" }, + }); + expect(transport.idleLive).toBe(false); + await session.close(); + }); + it("inserts a steer into the active Turn and does not emit a userMessage item", async () => { const { adapter, transports } = fixture(); const session = await openSession(adapter); @@ -1251,10 +1312,21 @@ describe("Claude Code HarnessAdapter", () => { input: [{ type: "text", text: "late" }], }); await vi.waitFor(() => expect(transports[0]?.steers).toHaveLength(2)); + // The first steer folds in after the tool call, before the Root result. + transports[0]?.startSteer("claude-id-3"); transports[0]?.finish({ status: "succeeded" }); // Claude already holds the written input and runs it next, so it is still accepted. transports[0]?.recordSteer("claude-id-4"); await expect(late).resolves.toEqual({ ok: true, value: { accepted: true } }); + // The late steer is not drained yet: the Turn stays held and runs it as its next Segment. + expect( + outputs.some((output) => output.kind === "event" && output.event.type === "turn.completed"), + ).toBe(false); + expect(transports[0]?.idleLive).toBe(true); + transports[0]?.startSteer("claude-id-4"); + transports[0]?.event({ type: "segment.started" }); + transports[0]?.delta("Late answer.", "late-answer"); + transports[0]?.finish({ status: "succeeded" }); await vi.waitFor(() => expect( outputs.some((output) => output.kind === "event" && output.event.type === "turn.completed"), @@ -3112,6 +3184,27 @@ describe("Claude Code HarnessAdapter", () => { return { adapter, iterator, session, transport, transports }; } + async function holdForUndrainedSteer(options: ClaudeCodeAdapterOptions = {}) { + const { adapter, transports } = fixture(options); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + await session.execute(textTurn("pending-steer")); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("pending-steer"), + input: [{ type: "text", text: "then cancel" }], + }); + await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + const messageId = transport.steers[0]?.messageId ?? ""; + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + transport.finish({ status: "succeeded" }); + await vi.waitFor(() => expect(transport.idleLive).toBe(true)); + return { adapter, iterator, messageId, session, transport }; + } + const cancelDelegation = { type: "turn.cancel", turnId: hostTurnIdSchema.parse("delegate in background"), @@ -3190,6 +3283,572 @@ describe("Claude Code HarnessAdapter", () => { await session.close(); }); + it("steers a held Turn's continuation without stopping its background Subagent", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent(); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + // The Turn is held on the background Subagent; its continuation is idle-live. + expect(transport.idleLive).toBe(true); + expect(session.capabilities.steer).toBe(true); + + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "status update please" }], + }); + await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + expect(transport.steers).toEqual([ + { + text: "status update please", + messageId: expect.any(String), + priority: "next", + }, + ]); + transport.recordSteer(transport.steers[0]?.messageId ?? ""); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + // Message delivery must not take the cancellation path that stops background work. + expect(transport.stopTasks).not.toHaveBeenCalled(); + expect(transport.abort).not.toHaveBeenCalled(); + expect(transport.abortContinuation).not.toHaveBeenCalled(); + + // The answer arrives as the held Turn's next Segment; the Turn keeps waiting. + transport.startSteer(transport.steers[0]?.messageId ?? ""); + transport.event({ type: "segment.started" }); + transport.delta("The background Agent is still running.", "steer-answer"); + transport.finish({ status: "succeeded" }); + await nextOutputMatching( + iterator, + (output) => + output.kind === "event" && + output.event.type === "item.completed" && + output.event.snapshot.item.type === "agentMessage" && + output.event.snapshot.item.text.includes("still running"), + ); + expect(transport.idleLive).toBe(true); + expect(transport.stopTasks).not.toHaveBeenCalled(); + + // Once the background Subagent settles, the steered Turn completes successfully. + transport.threadEvent({ + type: "subagent.settled", + nativeSubagentId: "native-agent-1", + status: "completed", + resultSummary: "Agent finished", + }); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + outcome: { status: "succeeded" }, + }); + await session.close(); + }); + + it("runs a steer written during a held Turn's final continuation as that Turn's next Segment", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent({ + continuationQuiescenceMs: 200, + }); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + vi.useFakeTimers(); + try { + // Every background Subagent settles and Claude streams its final continuation text. + transport.threadEvent({ + type: "subagent.settled", + nativeSubagentId: "native-agent-1", + status: "completed", + resultSummary: "Agent finished", + }); + transport.event({ type: "segment.started" }); + transport.delta("All background work is done.", "final-summary"); + expect(session.capabilities.steer).toBe(true); + + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "one more thing" }], + }); + await vi.advanceTimersByTimeAsync(0); + const messageId = transport.steers[0]?.messageId ?? ""; + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + + // The continuation keeps streaming, and no tool call lets the steer fold in: it ends first, + // and Claude starts the steer as its next native turn after quiescence would have elapsed. + transport.delta(" Wrapping up.", "final-summary"); + transport.finish({ status: "succeeded" }); + await vi.advanceTimersByTimeAsync(1_000); + expect(transport.idleLive).toBe(true); + + transport.startSteer(messageId); + transport.event({ type: "segment.started" }); + transport.delta("Here is the one more thing.", "steer-answer"); + transport.finish({ status: "succeeded" }); + await vi.advanceTimersByTimeAsync(1_000); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.filter((event) => event.type === "turn.started")).toEqual([]); + expect( + outputs.some( + (event) => + event.type === "item.completed" && + event.snapshot.item.type === "agentMessage" && + event.snapshot.item.text.includes("one more thing"), + ), + ).toBe(true); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "delegate in background", + outcome: { status: "succeeded" }, + }); + } finally { + vi.useRealTimers(); + } + await session.close(); + }); + + it("refuses to steer a held Turn whose cancellation is in flight", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent(); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + expect(session.capabilities.steer).toBe(true); + + await expect( + session.execute({ + type: "turn.cancel", + turnId: hostTurnIdSchema.parse("delegate in background"), + }), + ).resolves.toEqual({ ok: true, value: { cancellationRequested: true } }); + + // The Turn is being cancelled and owes no further input. + expect(session.capabilities.steer).toBe(false); + await expect( + session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "too late" }], + }), + ).resolves.toMatchObject({ ok: false, error: { code: "invalidState" } }); + expect(transport.steers).toHaveLength(0); + await session.close(); + }); + + it("keeps a held Turn open while a written steer awaits its native Segment", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent({ + continuationQuiescenceMs: 200, + }); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + vi.useFakeTimers(); + try { + // The background Subagent settles and continuation quiescence starts counting down. + transport.threadEvent({ + type: "subagent.settled", + nativeSubagentId: "native-agent-1", + status: "completed", + resultSummary: "Agent finished", + }); + + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "hold the Turn open" }], + }); + await vi.advanceTimersByTimeAsync(0); + expect(transport.steers).toHaveLength(1); + + // Quiescence passes without the written steer being taken over; the Turn must stay open. + await vi.advanceTimersByTimeAsync(200); + await expect(session.execute(textTurn("next"))).resolves.toMatchObject({ + ok: false, + error: { code: "sessionBusy" }, + }); + + const messageId = transport.steers[0]?.messageId ?? ""; + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + transport.startSteer(messageId); + transport.event({ type: "segment.started" }); + transport.delta("Still holding.", "quiescence-answer"); + transport.finish({ status: "succeeded" }); + await vi.advanceTimersByTimeAsync(200); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + outcome: { status: "succeeded" }, + }); + } finally { + vi.useRealTimers(); + } + await session.close(); + }); + + it("settles a held Turn once a written steer ends without a native turn", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent({ + continuationQuiescenceMs: 200, + }); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + vi.useFakeTimers(); + try { + transport.threadEvent({ + type: "subagent.settled", + nativeSubagentId: "native-agent-1", + status: "completed", + resultSummary: "Agent finished", + }); + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "never drained" }], + }); + await vi.advanceTimersByTimeAsync(0); + const messageId = transport.steers[0]?.messageId ?? ""; + // The transport stops awaiting it: no `queued` receipt in time, or a terminal state. + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + await vi.advanceTimersByTimeAsync(1_000); + expect(transport.idleLive).toBe(true); + + transport.releaseSteer(messageId); + await vi.advanceTimersByTimeAsync(200); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + outcome: { status: "succeeded" }, + }); + expect(transport.idleLive).toBe(false); + } finally { + vi.useRealTimers(); + } + await session.close(); + }); + + it("keeps cancellation pending until an undrained steer is released", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent({ + continuationQuiescenceMs: 10, + }); + try { + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + transport.threadEvent({ + type: "subagent.settled", + nativeSubagentId: "native-agent-1", + status: "completed", + resultSummary: "Agent finished", + }); + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "then cancel" }], + }); + await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + const messageId = transport.steers[0]?.messageId ?? ""; + transport.recordSteer(messageId); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + + await expect( + session.execute({ + type: "turn.cancel", + turnId: hostTurnIdSchema.parse("delegate in background"), + }), + ).resolves.toEqual({ ok: true, value: { cancellationRequested: true } }); + transport.releaseSteer(messageId); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + outcome: { status: "cancelled" }, + }); + } finally { + await session.close(); + } + }); + + it("closes a cancellation that waits for a steer which never takes over", async () => { + const { adapter, iterator, session, transport } = await holdForUndrainedSteer({ + continuationQuiescenceMs: 10, + cancelTimeoutMs: 1_000, + }); + try { + vi.useFakeTimers(); + await expect(session.execute(cancelDelegationFor("pending-steer"))).resolves.toEqual({ + ok: true, + value: { cancellationRequested: true }, + }); + await vi.advanceTimersByTimeAsync(1_000); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(transport.close).toHaveBeenCalledOnce(); + expect(outputs.some((event) => event.type === "turn.autonomous.started")).toBe(false); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "pending-steer", + outcome: { status: "cancelled" }, + }); + } finally { + vi.useRealTimers(); + await session.close(); + await adapter.close(); + } + }); + + it("interrupts a pending steer that starts after cancellation begins", async () => { + const { adapter, iterator, messageId, session, transport } = await holdForUndrainedSteer({ + continuationQuiescenceMs: 10, + }); + try { + await expect(session.execute(cancelDelegationFor("pending-steer"))).resolves.toEqual({ + ok: true, + value: { cancellationRequested: true }, + }); + transport.startSteer(messageId); + await vi.waitFor(() => expect(transport.abortContinuation).toHaveBeenCalledOnce()); + transport.event({ type: "segment.started" }); + transport.finish({ status: "cancelled", reason: "aborted_streaming" }); + + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.filter((event) => event.type === "turn.started")).toHaveLength(1); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "pending-steer", + outcome: { status: "cancelled" }, + }); + } finally { + await session.close(); + await adapter.close(); + } + }); + + it("keeps the cancellation escalation when an undrained steer ends while a started steer owes its result", async () => { + const { adapter, iterator, messageId, session, transport } = await holdForUndrainedSteer({ + continuationQuiescenceMs: 10, + cancelTimeoutMs: 1_000, + }); + try { + vi.useFakeTimers(); + const secondSteer = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("pending-steer"), + input: [{ type: "text", text: "cancel instead" }], + }); + await vi.advanceTimersByTimeAsync(0); + expect(transport.steers).toHaveLength(2); + const secondMessageId = transport.steers[1]?.messageId ?? ""; + transport.recordSteer(secondMessageId); + await expect(secondSteer).resolves.toEqual({ ok: true, value: { accepted: true } }); + // The first steer is drained into a native turn that still owes its result. + transport.startSteer(messageId); + + await expect(session.execute(cancelDelegationFor("pending-steer"))).resolves.toEqual({ + ok: true, + value: { cancellationRequested: true }, + }); + // The undrained steer ends without a native turn; the started one is still unresolved. + transport.releaseSteer(secondMessageId); + await vi.advanceTimersByTimeAsync(999); + expect(transport.close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(transport.close).toHaveBeenCalledOnce(); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "pending-steer", + outcome: { status: "cancelled" }, + }); + } finally { + vi.useRealTimers(); + await session.close(); + await adapter.close(); + } + }); + + it("clears the cancellation escalation when its last undrained steer is released before the deadline", async () => { + const { adapter, iterator, messageId, session, transport } = await holdForUndrainedSteer({ + continuationQuiescenceMs: 200, + cancelTimeoutMs: 1_000, + }); + try { + vi.useFakeTimers(); + await expect(session.execute(cancelDelegationFor("pending-steer"))).resolves.toEqual({ + ok: true, + value: { cancellationRequested: true }, + }); + await vi.advanceTimersByTimeAsync(990); + transport.releaseSteer(messageId); + // Past the original escalation deadline the transport stays open. + await vi.advanceTimersByTimeAsync(20); + expect(transport.close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(transport.close).not.toHaveBeenCalled(); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "pending-steer", + outcome: { status: "cancelled" }, + }); + } finally { + vi.useRealTimers(); + await session.close(); + await adapter.close(); + } + }); + + it("fails a held Turn when its transport faults before releasing a pending steer", async () => { + const { adapter, dependencies } = fixture({ continuationQuiescenceMs: 10 }); + const fakeQuery = new FakeQuery(); + let transport: ClaudeSdkTransport | undefined; + dependencies.createTransport = (input) => { + transport = new ClaudeSdkTransport({ + ...input, + command: process.execPath, + closeTimeoutMs: 50, + steerQueuedTimeoutMs: 60_000, + queryFactory: () => fakeQuery as unknown as Query, + }); + return transport; + }; + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + try { + await session.execute(textTurn("faulted-steer")); + if (!transport) throw new Error("Claude SDK transport was not created"); + fakeQuery.push({ + type: "system", + subtype: "init", + capabilities: ["msg_lifecycle_v1"], + } as unknown as SDKMessage); + await vi.waitFor(() => expect(transport?.supportsNativeSteer()).toBe(true)); + fakeQuery.push({ + type: "assistant", + parent_tool_use_id: null, + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_root", name: "Bash", input: { command: "sleep 1" } }, + ], + }, + } as unknown as SDKMessage); + await vi.waitFor(() => expect(transport?.canSteerActiveTurn()).toBe(true)); + const steered = session + .execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("faulted-steer"), + input: [{ type: "text", text: "wait for me" }], + }) + .catch(() => undefined); + + fakeQuery.push({ + type: "user", + uuid: "00000000-0000-4000-8000-000000000072", + session_id: "00000000-0000-4000-8000-000000000001", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_root", content: "done" }], + }, + } as unknown as SDKMessage); + await vi.waitFor(() => expect(transport?.canSteerActiveTurn()).toBe(false)); + fakeQuery.push({ + type: "result", + subtype: "success", + is_error: false, + terminal_reason: "completed", + } as unknown as SDKMessage); + await nextOutputMatching( + iterator, + (output) => + output.kind === "event" && + output.event.type === "item.completed" && + output.event.snapshot.item.type === "agentMessage", + ); + + fakeQuery.close(); + await steered; + const outputs = eventOutputs( + await nextOutputMatching( + iterator, + (output) => output.kind === "event" && output.event.type === "turn.completed", + ), + ); + expect(outputs.at(-1)).toMatchObject({ + type: "turn.completed", + turnId: "faulted-steer", + outcome: { status: "failed" }, + }); + } finally { + await session.close(); + await adapter.close(); + } + }); + + function cancelDelegationFor(turnId: string) { + return { type: "turn.cancel" as const, turnId: hostTurnIdSchema.parse(turnId) }; + } + + it("does not leave a held Turn waiting on an undrained steer when the Session closes", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent(); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + const steered = session.execute({ + type: "turn.steer", + turnId: hostTurnIdSchema.parse("delegate in background"), + input: [{ type: "text", text: "then close" }], + }); + await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + transport.recordSteer(transport.steers[0]?.messageId ?? ""); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + + await session.close(); + const outputs: HarnessOutput[] = []; + for (;;) { + const output = await iterator.next(); + if (output.done) break; + outputs.push(output.value); + } + expect(eventOutputs(outputs).at(-1)).toMatchObject({ type: "turn.completed" }); + }); + function agentApproval(transport: FakeClaudeTransport, requestId: string): void { transport.event({ type: "interaction.requested", diff --git a/packages/adapters/claude-code/test/sdk-transport.test.ts b/packages/adapters/claude-code/test/sdk-transport.test.ts index 4abd092a6..e26447786 100644 --- a/packages/adapters/claude-code/test/sdk-transport.test.ts +++ b/packages/adapters/claude-code/test/sdk-transport.test.ts @@ -3263,6 +3263,84 @@ describe("ClaudeSdkTransport steer", () => { await expect(input.next()).resolves.toMatchObject({ done: true }); }); + it("steers a held Turn's continuation while its idle stream is live", async () => { + const value = fixture(); + const idleEvents: ClaudeTurnEvent[] = []; + const idleTerminals: ClaudeTransportTurnResult[] = []; + await value.transport.start(); + value.fakeQuery.push({ + type: "system", + subtype: "init", + capabilities: ["msg_lifecycle_v1"], + } as unknown as SDKMessage); + await vi.waitFor(() => expect(value.transport.supportsNativeSteer()).toBe(true)); + expect(value.transport.canSteerActiveTurn()).toBe(false); + value.transport.setIdleTurnHandler({ + onEvent: (event) => idleEvents.push(event), + onTerminal: (result) => idleTerminals.push(result), + }); + value.transport.setIdleLive(true); + expect(value.transport.canSteerActiveTurn()).toBe(true); + + const steerId = "00000000-0000-4000-8000-000000000060"; + const takeovers: boolean[] = []; + const steered = value.transport.steer("background status?", steerId, (started) => + takeovers.push(started), + ); + const prompt = value.queryInput().prompt; + if (typeof prompt === "string" || !prompt) throw new Error("expected streaming input"); + const input = prompt[Symbol.asyncIterator](); + await expect(input.next()).resolves.toMatchObject({ + done: false, + value: { + type: "user", + priority: "next", + uuid: steerId, + origin: { kind: "human" }, + message: { role: "user", content: "background status?" }, + }, + }); + value.fakeQuery.push({ + type: "command_lifecycle", + state: "queued", + command_uuid: steerId, + uuid: "00000000-0000-4000-8000-000000000061", + session_id: "00000000-0000-4000-8000-000000000001", + } as unknown as SDKMessage); + await steered; + expect(takeovers).toEqual([]); + + // Claude drains the steer into its next native turn, reported once. + for (const state of ["started", "completed"]) { + value.fakeQuery.push({ + type: "command_lifecycle", + state, + command_uuid: steerId, + uuid: "00000000-0000-4000-8000-000000000064", + session_id: "00000000-0000-4000-8000-000000000001", + } as unknown as SDKMessage); + } + await vi.waitFor(() => expect(takeovers).toEqual([true])); + + // The answer runs as the held Turn's next Segment through the idle handler. + pushAssistantText( + value.fakeQuery, + "The background Agent is still running.", + "00000000-0000-4000-8000-000000000062", + ); + completeTurn(value.fakeQuery); + await vi.waitFor(() => expect(idleTerminals).toEqual([{ status: "succeeded" }])); + expect(idleEvents.some(({ type }) => type === "text.delta")).toBe(true); + expect(value.transport.canSteerActiveTurn()).toBe(true); + + value.transport.setIdleLive(false); + expect(value.transport.canSteerActiveTurn()).toBe(false); + await expect( + value.transport.steer("too late", "00000000-0000-4000-8000-000000000063"), + ).rejects.toBeInstanceOf(ClaudeSteerMissedTurnError); + await value.transport.close(); + }); + it("supports native steering only after init reports msg_lifecycle_v1", async () => { const value = fixture(); await value.transport.start(); @@ -3333,6 +3411,34 @@ describe("ClaudeSdkTransport steer", () => { await value.transport.close(); }); + it("reports a queued steer that ends without a native turn", async () => { + const value = fixture(); + await value.transport.start(); + const turn = value.transport.runTurn( + "hello", + "00000000-0000-4000-8000-000000000042", + () => undefined, + ); + await enableNativeSteer(value); + const steerId = "00000000-0000-4000-8000-000000000043"; + const takeovers: boolean[] = []; + const steered = value.transport.steer("PIN", steerId, (started) => takeovers.push(started)); + for (const state of ["queued", "cancelled"]) { + value.fakeQuery.push({ + type: "command_lifecycle", + state, + command_uuid: steerId, + uuid: "00000000-0000-4000-8000-000000000044", + session_id: "00000000-0000-4000-8000-000000000001", + } as unknown as SDKMessage); + } + await steered; + await vi.waitFor(() => expect(takeovers).toEqual([false])); + completeTurn(value.fakeQuery); + await turn; + await value.transport.close(); + }); + it("keeps a written steer accepted when the Turn ends before Claude queues it", async () => { const value = fixture(); await value.transport.start(); @@ -3388,9 +3494,14 @@ describe("ClaudeSdkTransport steer", () => { () => undefined, ); await enableNativeSteer({ transport, fakeQuery: value.fakeQuery }); + const takeovers: boolean[] = []; await expect( - transport.steer("PIN", "00000000-0000-4000-8000-000000000039"), + transport.steer("PIN", "00000000-0000-4000-8000-000000000039", (started) => + takeovers.push(started), + ), ).resolves.toBeUndefined(); + // Without a receipt nothing reports its fate, so it is no longer awaited. + expect(takeovers).toEqual([false]); completeTurn(value.fakeQuery); await turn; await transport.close(); @@ -3403,10 +3514,16 @@ describe("ClaudeSdkTransport steer", () => { .runTurn("hello", "00000000-0000-4000-8000-000000000040", () => undefined) .catch(() => undefined); await enableNativeSteer(value); - const steered = value.transport.steer("PIN", "00000000-0000-4000-8000-000000000041"); + const takeovers: boolean[] = []; + const steered = value.transport.steer( + "PIN", + "00000000-0000-4000-8000-000000000041", + (started) => takeovers.push(started), + ); const lostReceipt = expect(steered).rejects.toThrow("receipt was lost after input was written"); await value.transport.close(); await lostReceipt; + expect(takeovers).toEqual([false]); await expect(steered).rejects.not.toBeInstanceOf(ClaudeSteerMissedTurnError); }); }); diff --git a/packages/host-runtime/test/app-server-host.claude.real.test.ts b/packages/host-runtime/test/app-server-host.claude.real.test.ts index 27e6fd2d6..3eb137dc0 100644 --- a/packages/host-runtime/test/app-server-host.claude.real.test.ts +++ b/packages/host-runtime/test/app-server-host.claude.real.test.ts @@ -654,4 +654,160 @@ describe.skipIf(!RUN_REAL)("AppServerHost real Claude projection", () => { }, REAL_TIMEOUT_MS, ); + + it( + "steers a held real Claude Turn's continuation without stopping its background Subagent", + async () => { + const workspace = path.resolve( + ".codexhost", + "claude-host-real", + "continuation-steer-workspace", + ); + await fs.mkdir(workspace, { recursive: true }); + const mappingStoreDirectory = await fs.mkdtemp(path.join(tmpdir(), "codexhost-cont-steer-")); + const desktopInput = new PassThrough(); + const desktopOutput = new PassThrough(); + // Claude Code runs the scripted `sleep` without an Approval in the default permission mode; + // decline any native action that does ask, so the test never grants extra permissions. + const collector = new JsonCollector(desktopOutput, (request) => { + if (request.method !== "mcpServer/elicitation/request") return; + writeRequest(desktopInput, { id: request.id ?? null, result: { action: "decline" } }); + }); + const official = new OfficialProcess(); + const claudeAdapter = new ClaudeCodeAdapter({ closeTimeoutMs: 10_000 }); + const host = new AppServerHost({ + stockCodexPath: "/synthetic/codex", + arguments: [], + desktopInput, + desktopOutput, + diagnosticOutput: new PassThrough(), + externalAdapters: new Map([ + ["claude-code", claudeAdapter], + ]), + mappingStore: new MappingStore({ directory: mappingStoreDirectory }), + spawnOfficial: (() => + official as unknown as ChildProcessWithoutNullStreams) as unknown as typeof spawn, + }); + const running = host.run(); + const delay = (milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + + try { + writeRequest(desktopInput, { + id: 1, + method: "thread/start", + params: { + model: encodeExternalTransportSelection("claude-code", { + permissionModeId: harnessPermissionModeIdSchema.parse("default"), + }), + cwd: workspace, + }, + }); + const started = await collector.waitFor((message) => requestId(message, 1)); + const threadId = ((started.result as JsonObject).thread as JsonObject).id; + if (typeof threadId !== "string") throw new Error("Host returned no Thread ID"); + + writeRequest(desktopInput, { + id: 2, + method: "turn/start", + params: { + threadId, + input: [ + { + type: "text", + text: "Use the Agent tool to launch exactly ONE background subagent (run_in_background: true, subagent_type: general-purpose, model: haiku). Its whole task: run the Bash command `sleep 20`, then reply done. Do NOT wait for the subagent, do not run anything else, and reply immediately with exactly DISPATCHED.", + }, + ], + }, + }); + const turnResponse = await collector.waitFor((message) => requestId(message, 2)); + const turnId = ((turnResponse.result as JsonObject).turn as JsonObject).id; + if (typeof turnId !== "string") throw new Error("Host returned no Turn ID"); + + // Wait for the main DISPATCHED reply to finish: with its root tool call returned and + // the Turn not completed, only the new continuation admission can report steer. + await expect( + collector.waitFor( + (message) => + method(message, "item/completed") && + (message.params as JsonObject).turnId === turnId && + ((message.params as JsonObject).item as JsonObject).type === "agentMessage" && + String(((message.params as JsonObject).item as JsonObject).text ?? "").includes( + "DISPATCHED", + ), + ), + ).resolves.toBeDefined(); + await delay(1_000); + expect(collector.messages.some((message) => method(message, "turn/completed"))).toBe(false); + + // The reply has ended and the Turn holds on the live background subagent; native + // steering must be available for that continuation instead of a restart. + let delivery = ""; + const inspectDeadline = Date.now() + 120_000; + for (let inspectId = 3; Date.now() < inspectDeadline; inspectId += 1) { + writeRequest(desktopInput, { + id: inspectId, + method: "codexhost/thread/steering/inspect", + params: { threadId }, + }); + const inspect = await collector.waitFor((message) => requestId(message, inspectId)); + delivery = String((inspect.result as JsonObject).delivery ?? ""); + if (delivery === "activeTurn") break; + await delay(500); + } + expect(delivery).toBe("activeTurn"); + + writeRequest(desktopInput, { + id: 1000, + method: "turn/steer", + params: { + threadId, + expectedTurnId: turnId, + clientUserMessageId: "continuation-steer-message", + input: [ + { + type: "text", + text: "Status check only: do not start or stop anything. Reply with exactly STILL_RUNNING.", + }, + ], + }, + }); + await expect( + collector.waitFor((message) => requestId(message, 1000)), + ).resolves.toMatchObject({ result: { turnId, delivery: "activeTurn" } }); + + // The steer's answer arrives as the held Turn's next Segment, in the same Turn. + await expect( + collector.waitFor( + (message) => + method(message, "item/completed") && + (message.params as JsonObject).turnId === turnId && + ((message.params as JsonObject).item as JsonObject).type === "agentMessage" && + String(((message.params as JsonObject).item as JsonObject).text ?? "").includes( + "STILL_RUNNING", + ), + ), + ).resolves.toBeDefined(); + + // The Turn finishes only once the background subagent settles; nothing restarted it. + await expect( + collector.waitFor( + (message) => + method(message, "turn/completed") && + ((message.params as JsonObject).turn as JsonObject).id === turnId, + ), + ).resolves.toMatchObject({ params: { turn: { status: "completed" } } }); + const turnsStarted = collector.messages.filter((message) => + method(message, "turn/started"), + ); + expect(turnsStarted).toHaveLength(1); + } finally { + desktopInput.end(); + await running; + await claudeAdapter.close(); + await fs.rm(mappingStoreDirectory, { recursive: true, force: true }); + } + }, + REAL_TIMEOUT_MS, + ); });