From 596afd869ec85b6057fc4e35a8a7f624201b6e8e Mon Sep 17 00:00:00 2001 From: LiangChangtong <2787046556@qq.com> Date: Mon, 26 Oct 2026 12:24:58 +0800 Subject: [PATCH 1/3] fix(claude-code): steer a held Turn's continuation instead of stopping its background work A user message sent while a requested Turn is held on background work (root Segment finished, Subagents still running) currently takes the stop-then-start path: turn.cancel stops every background task the Turn owns, so steering into that window kills all running Subagents without confirmation. Extend the native steer admission to that continuation: while the idle stream is live, a pushed input runs as the held Turn's next Segment and the background work keeps running. The Session capability now reports steer during the hold, so Desktop delivers natively instead of replacing the Turn. Fixes #615 --- .../adapters/claude-code/src/sdk-transport.ts | 7 +- .../test/claude-code-adapter.test.ts | 69 ++++++++- .../claude-code/test/sdk-transport.test.ts | 62 ++++++++ .../test/app-server-host.claude.real.test.ts | 140 ++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) diff --git a/packages/adapters/claude-code/src/sdk-transport.ts b/packages/adapters/claude-code/src/sdk-transport.ts index b9bf70387..f7d20b7f8 100644 --- a/packages/adapters/claude-code/src/sdk-transport.ts +++ b/packages/adapters/claude-code/src/sdk-transport.ts @@ -629,9 +629,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 { 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..6e65a378c 100644 --- a/packages/adapters/claude-code/test/claude-code-adapter.test.ts +++ b/packages/adapters/claude-code/test/claude-code-adapter.test.ts @@ -203,11 +203,13 @@ 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()); + if (!this.canSteerActiveTurn()) return Promise.reject(new ClaudeSteerMissedTurnError()); this.steers.push({ text, messageId, priority: "next" }); return new Promise((resolve, reject) => { this.#pendingSteers.set(messageId, { resolve, reject }); @@ -3190,6 +3192,69 @@ 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.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(); + }); + 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..b3995d598 100644 --- a/packages/adapters/claude-code/test/sdk-transport.test.ts +++ b/packages/adapters/claude-code/test/sdk-transport.test.ts @@ -3263,6 +3263,68 @@ 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 steered = value.transport.steer("background status?", steerId); + 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; + + // 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(); 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..36453ee44 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,144 @@ 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"); + + // The reply ends and the Turn holds on the live background subagent; native + // steering must become 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, + ); }); From 658a72c82cf9b9d3ebca8be000edf074c0497c3d Mon Sep 17 00:00:00 2001 From: LiangChangtong <2787046556@qq.com> Date: Mon, 26 Oct 2026 14:41:13 +0800 Subject: [PATCH 2/3] fix(claude-code): harden continuation steer against cancellation and early quiescence Two lifecycle gaps found in review (#617): - A held Turn whose cancellation is in flight also reports idleLive, so the continuation admission let new steers in and the cancel path interrupted them right after acceptance. The capability getter and turn.steer now refuse while the target Turn's cancellation is requested. - A steer written while continuation quiescence counts down did not hold the Turn open: the timer could finish the Turn before the native Segment took over the written input, leaving the user message on a completed Turn. The adapter now marks the held Turn while a written steer awaits its native Segment and hands the keep-open over at the first root output. Also updates the steer documentation and transport contract comment to the widened admission, and tightens the real test to prove the held-continuation window (main reply completed, Turn not finished) before steering. --- .../claude-code/claude-code-steer.md | 14 ++-- .../claude-code/src/claude-code-adapter.ts | 30 +++++++- .../adapters/claude-code/src/transport.ts | 6 +- .../test/claude-code-adapter.test.ts | 73 +++++++++++++++++++ .../test/app-server-host.claude.real.test.ts | 20 ++++- 5 files changed, 130 insertions(+), 13 deletions(-) diff --git a/docs/harnesses/claude-code/claude-code-steer.md b/docs/harnesses/claude-code/claude-code-steer.md index ce94871ad..458f1994b 100644 --- a/docs/harnesses/claude-code/claude-code-steer.md +++ b/docs/harnesses/claude-code/claude-code-steer.md @@ -8,13 +8,17 @@ Host 在原生接受后自己发当前 Turn 的 `userMessage` Item。Adapter 不 `priority: "next"` 在**当前工具调用结束后**、下一次模型请求之前并入;工具结果之后一定还有一次模型请求,所以插入写入时只要有根级工具调用正在执行,它就一定并入当前 Turn。反过来,如果 Claude 正在输出最终文本、没有工具在执行,它可能直接结束本轮,把插入留到下一轮执行:2026-10-22 在 Claude Code 2.1.305 上实测,Host 把插入显示在原来那一轮,而 Claude 在随后的另一轮里回复。 -因此 `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 未声明支持查询时保持停止后重发。 + +续态写入还有一个额外保证:已写入、尚未被原生根段接管的插入会让 held Turn 保持打开,continuation 静默计时器不会在此之前把 Turn 提前收口;原生根输出一开始(`segment.started` 或根输出事件)保活即交接给正常的段生命周期。 ## 接受 @@ -24,7 +28,7 @@ CLI 先发 `command_lifecycle` `queued`,表示 Claude 已接受这条输入; ## 版本 -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..923fab04c 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; + /** A written steer is not yet taken over by a native Root Segment; the Turn must stay open. */ + steerAwaitingNative: boolean; 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, + steerAwaitingNative: false, 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,15 @@ class ClaudeHarnessSession implements HarnessSession { }, }; } + // The write happens synchronously inside steer(); claim the keep-open before it, so + // continuation quiescence cannot finish the held Turn between write and native takeover. + active.steerAwaitingNative = true; try { await transport.steer(text, this.#randomUUID()); } catch (error) { if (error instanceof ClaudeSteerMissedTurnError) { + // Nothing was written, or the Session died after writing and the Turn ends anyway. + active.steerAwaitingNative = false; return { ok: false, error: invalidState("Claude Code steer missed the active Turn") }; } throw error; @@ -1165,6 +1183,7 @@ class ClaudeHarnessSession implements HarnessSession { nativeTurnKey, nativeTurnRef: null, cancellationRequested: false, + steerAwaitingNative: false, usageRequestIds: new Set(), estimatedInputTokens: 0, estimatedOutputTokens: 0, @@ -2322,6 +2341,7 @@ class ClaudeHarnessSession implements HarnessSession { const active: ActiveTurn = { command: { type: "turn.start", turnId, input: [] }, compactionItem: null, + steerAwaitingNative: false, item, agentMessageOrdinal: 1, assistantMessageId: null, @@ -2809,6 +2829,7 @@ class ClaudeHarnessSession implements HarnessSession { this.#active !== active || !active.held || active.rootSegmentActive || + active.steerAwaitingNative || this.#phase !== "open" ) { return; @@ -2829,6 +2850,7 @@ class ClaudeHarnessSession implements HarnessSession { #observeRootOutput(active: ActiveTurn): void { active.rootSegmentActive = true; + active.steerAwaitingNative = false; this.#clearContinuationQuiescence(); } diff --git a/packages/adapters/claude-code/src/transport.ts b/packages/adapters/claude-code/src/transport.ts index 921a30e90..952946108 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; 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 6e65a378c..9c738a277 100644 --- a/packages/adapters/claude-code/test/claude-code-adapter.test.ts +++ b/packages/adapters/claude-code/test/claude-code-adapter.test.ts @@ -3255,6 +3255,79 @@ describe("Claude Code HarnessAdapter", () => { 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); + + // 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.waitFor(() => expect(transport.steers).toHaveLength(1)); + + // Quiescence passes without the written steer being taken over; the Turn must stay open. + await new Promise((resolve) => setTimeout(resolve, 300)); + await expect(session.execute(textTurn("next"))).resolves.toMatchObject({ + ok: false, + error: { code: "sessionBusy" }, + }); + + transport.recordSteer(transport.steers[0]?.messageId ?? ""); + await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); + transport.event({ type: "segment.started" }); + transport.delta("Still holding.", "quiescence-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", + outcome: { status: "succeeded" }, + }); + await session.close(); + }); + function agentApproval(transport: FakeClaudeTransport, requestId: string): void { transport.event({ type: "interaction.requested", 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 36453ee44..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 @@ -724,8 +724,24 @@ describe.skipIf(!RUN_REAL)("AppServerHost real Claude projection", () => { const turnId = ((turnResponse.result as JsonObject).turn as JsonObject).id; if (typeof turnId !== "string") throw new Error("Host returned no Turn ID"); - // The reply ends and the Turn holds on the live background subagent; native - // steering must become available for that continuation instead of a restart. + // 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) { From ee83f03a37622883ea941a97aa7ff2c676a79a78 Mon Sep 17 00:00:00 2001 From: LiangChangtong <2787046556@qq.com> Date: Mon, 26 Oct 2026 17:20:33 +0800 Subject: [PATCH 3/3] fix(claude-code): keep a Turn open until Claude drains each written steer A steer accepted while a held Turn's final continuation streamed text (no root tool call) runs after that Segment's result as the next native turn. The previous keep-open was released by any root output, so the continuation itself cleared it; once the Segment ended, quiescence finished the Turn and the steer's reply landed in a separate autonomous Turn. The adapter now tracks each written steer by message ID until Claude Code reports `command_lifecycle` `started` for it (fold or fresh turn). While any steer awaits that, the Turn stays held, so its native turn runs as the Turn's next Segment. A terminal without `started`, a missing `queued` receipt, or a transport close/fault releases it and the Turn settles as if it was never written. This also keeps a steer whose `queued` follows the Root result in the same Turn. Cancellation and faults respect the new state: - Cancelling a Turn held only for an undrained steer goes through the background-work cancellation instead of finishing at once; settling waits for the steer and falls back to the existing escalation that closes the native process. - A steer that starts while cancellation is in flight is interrupted like any started Segment. - A transport fault reports the fault before releasing pending steers, so the Turn fails instead of completing successfully. Updates the steer documentation (acceptance, cancellation window, receipt ordering) and switches timer-dependent tests to fake timers. Co-Authored-By: Claude Opus 5.5 --- .../claude-code/claude-code-steer.md | 8 +- .../claude-code/src/claude-code-adapter.ts | 67 +- .../adapters/claude-code/src/sdk-transport.ts | 42 +- .../adapters/claude-code/src/transport.ts | 6 +- .../test/claude-code-adapter.test.ts | 581 +++++++++++++++++- .../claude-code/test/sdk-transport.test.ts | 61 +- 6 files changed, 706 insertions(+), 59 deletions(-) diff --git a/docs/harnesses/claude-code/claude-code-steer.md b/docs/harnesses/claude-code/claude-code-steer.md index 458f1994b..88a118475 100644 --- a/docs/harnesses/claude-code/claude-code-steer.md +++ b/docs/harnesses/claude-code/claude-code-steer.md @@ -6,7 +6,7 @@ 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 改用停止后重发: @@ -18,13 +18,13 @@ Host 在原生接受后自己发当前 Turn 的 `userMessage` Item。Adapter 不 `turn.steer` 在写入输入流前同步复查上述条件,不依赖 Renderer 之前的查询结果;两个窗口都已关闭、或取消已在进行时拒绝且不写入。Renderer 已按同轮展示时,Host 报错而不停止后重发。Harness Broker 经公共 `readCapabilities()` 按需读取当前能力;旧 Broker 未声明支持查询时保持停止后重发。 -续态写入还有一个额外保证:已写入、尚未被原生根段接管的插入会让 held Turn 保持打开,continuation 静默计时器不会在此之前把 Turn 提前收口;原生根输出一开始(`segment.started` 或根输出事件)保活即交接给正常的段生命周期。 +已写入的插入还有一个额外保证:尚未被 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 的)则不立即结束,而是走停止后台工作并等待安静的取消流程:插入随后被接管时,它运行所在的原生段会被中断;以终态结束却未被接管时,取消随即收口;迟迟没有结果时沿用既有的取消升级,超时后关闭原生进程。续态窗口插入、尚未被接管时被停止就属于这种情况,窗口只剩插入写入到出现结果之间的短暂时间,且取消已开始后不再接受新的插入。 ## 版本 diff --git a/packages/adapters/claude-code/src/claude-code-adapter.ts b/packages/adapters/claude-code/src/claude-code-adapter.ts index 923fab04c..d9c13ba3b 100644 --- a/packages/adapters/claude-code/src/claude-code-adapter.ts +++ b/packages/adapters/claude-code/src/claude-code-adapter.ts @@ -229,8 +229,8 @@ interface ActiveTurn { cancellation: TurnCancellation | null; /** True while Claude can still produce a native result for the current Root Segment. */ rootSegmentActive: boolean; - /** A written steer is not yet taken over by a native Root Segment; the Turn must stay open. */ - steerAwaitingNative: boolean; + /** Written steers Claude has not yet drained into a native turn; the Turn must stay open. */ + steersAwaitingNative: Set; completion: Promise; resolveCompletion(): void; } @@ -981,7 +981,7 @@ class ClaudeHarnessSession implements HarnessSession { nativeTurnKey, nativeTurnRef: null, cancellationRequested: false, - steerAwaitingNative: false, + steersAwaitingNative: new Set(), usageRequestIds: new Set(), estimatedInputTokens: 0, estimatedOutputTokens: 0, @@ -1056,15 +1056,18 @@ class ClaudeHarnessSession implements HarnessSession { }, }; } - // The write happens synchronously inside steer(); claim the keep-open before it, so - // continuation quiescence cannot finish the held Turn between write and native takeover. - active.steerAwaitingNative = true; + // 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. - active.steerAwaitingNative = false; + this.#takeOverSteer(active, messageId, false); return { ok: false, error: invalidState("Claude Code steer missed the active Turn") }; } throw error; @@ -1183,7 +1186,7 @@ class ClaudeHarnessSession implements HarnessSession { nativeTurnKey, nativeTurnRef: null, cancellationRequested: false, - steerAwaitingNative: false, + steersAwaitingNative: new Set(), usageRequestIds: new Set(), estimatedInputTokens: 0, estimatedOutputTokens: 0, @@ -1597,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 } }; @@ -1677,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; @@ -2341,7 +2352,7 @@ class ClaudeHarnessSession implements HarnessSession { const active: ActiveTurn = { command: { type: "turn.start", turnId, input: [] }, compactionItem: null, - steerAwaitingNative: false, + steersAwaitingNative: new Set(), item, agentMessageOrdinal: 1, assistantMessageId: null, @@ -2829,7 +2840,7 @@ class ClaudeHarnessSession implements HarnessSession { this.#active !== active || !active.held || active.rootSegmentActive || - active.steerAwaitingNative || + active.steersAwaitingNative.size > 0 || this.#phase !== "open" ) { return; @@ -2850,10 +2861,36 @@ class ClaudeHarnessSession implements HarnessSession { #observeRootOutput(active: ActiveTurn): void { active.rootSegmentActive = true; - active.steerAwaitingNative = false; 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; @@ -2864,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 f7d20b7f8..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; @@ -858,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()); } @@ -879,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; @@ -895,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) { @@ -1260,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. @@ -1270,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 { @@ -1417,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 952946108..cc09fe2d7 100644 --- a/packages/adapters/claude-code/src/transport.ts +++ b/packages/adapters/claude-code/src/transport.ts @@ -330,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 9c738a277..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: | { @@ -208,14 +214,31 @@ class FakeClaudeTransport implements ClaudeTurnTransport { return this.idleLive && this.idleHandler !== null; } - steer(text: string, messageId: string): Promise { + 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}`); @@ -227,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 { @@ -1205,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); @@ -1253,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"), @@ -3114,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"), @@ -3221,6 +3312,7 @@ describe("Claude Code HarnessAdapter", () => { 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" }); @@ -3255,6 +3347,73 @@ describe("Claude Code HarnessAdapter", () => { 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" }); @@ -3287,45 +3446,407 @@ describe("Claude Code HarnessAdapter", () => { }); 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", + }); - // 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(); + }); - const steered = session.execute({ - type: "turn.steer", - turnId: hostTurnIdSchema.parse("delegate in background"), - input: [{ type: "text", text: "hold the Turn open" }], + it("keeps cancellation pending until an undrained steer is released", async () => { + const { iterator, session, transport } = await holdForBackgroundAgent({ + continuationQuiescenceMs: 10, }); - await vi.waitFor(() => expect(transport.steers).toHaveLength(1)); + 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 } }); - // Quiescence passes without the written steer being taken over; the Turn must stay open. - await new Promise((resolve) => setTimeout(resolve, 300)); - await expect(session.execute(textTurn("next"))).resolves.toMatchObject({ - ok: false, - error: { code: "sessionBusy" }, + 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(); + } + }); - transport.recordSteer(transport.steers[0]?.messageId ?? ""); - await expect(steered).resolves.toEqual({ ok: true, value: { accepted: true } }); - transport.event({ type: "segment.started" }); - transport.delta("Still holding.", "quiescence-answer"); - transport.finish({ status: "succeeded" }); - const outputs = eventOutputs( + 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 === "turn.completed", - ), - ); - expect(outputs.at(-1)).toMatchObject({ - type: "turn.completed", - outcome: { status: "succeeded" }, + (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 { diff --git a/packages/adapters/claude-code/test/sdk-transport.test.ts b/packages/adapters/claude-code/test/sdk-transport.test.ts index b3995d598..e26447786 100644 --- a/packages/adapters/claude-code/test/sdk-transport.test.ts +++ b/packages/adapters/claude-code/test/sdk-transport.test.ts @@ -3283,7 +3283,10 @@ describe("ClaudeSdkTransport steer", () => { expect(value.transport.canSteerActiveTurn()).toBe(true); const steerId = "00000000-0000-4000-8000-000000000060"; - const steered = value.transport.steer("background status?", steerId); + 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](); @@ -3305,6 +3308,19 @@ describe("ClaudeSdkTransport steer", () => { 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( @@ -3395,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(); @@ -3450,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(); @@ -3465,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); }); });