From d557b26e04361d891f6fbf45797ae866a6839b55 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:02:51 -0700 Subject: [PATCH 1/4] Retract recovered inference errors on same-turn failover Committed retry already rolled back the failed attempt. Failover is a new inference.start, so the error row stayed. Treat that start as recovery too, so quota and credential chrome disappear when the turn continues. --- src/tui/runtime-bridge.test.ts | 192 +++++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 33 ++++++ src/tui/stream-event-map.test.ts | 29 ++++- src/tui/stream-event-map.ts | 60 ++++++---- 4 files changed, 291 insertions(+), 23 deletions(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index c77270e1..8eeae6b3 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -814,6 +814,198 @@ describe("committed inference retry", () => { }); }); +describe("same-turn failover after inference.error", () => { + const errorRows = (shell: { + streamLog: readonly { role: string; meta?: string; text: string }[]; + }) => shell.streamLog.filter((r) => r.meta === "error").map((r) => r.text); + + test("a recovered quota error does not stay in the transcript", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + for (const event of [ + { type: "inference.start", data: {} }, + { + type: "inference.error", + data: { + error: { + category: "quota_exhausted", + message: "The usage limit has been reached", + statusCode: 429, + }, + }, + }, + { type: "inference.start", data: {} }, + { type: "inference.text.delta", data: { token: "recovered" } }, + { type: "inference.done", data: {} }, + { type: "reactor.done", data: {} }, + ] as const) { + bridge.handle(event); + } + + expect(errorRows(shell)).toEqual([]); + expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a recovered credential error does not stay in the transcript", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + for (const event of [ + { type: "inference.start", data: {} }, + { + type: "inference.error", + data: { + error: { category: "credential_failure", message: "Forbidden", statusCode: 403 }, + }, + }, + { type: "inference.start", data: {} }, + { type: "inference.text.delta", data: { token: "recovered" } }, + { type: "inference.done", data: {} }, + { type: "reactor.done", data: {} }, + ] as const) { + bridge.handle(event); + } + + expect(errorRows(shell)).toEqual([]); + expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("an echoed auto-retry prompt does not expire recovery", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.error", + data: { + error: { + category: "quota_exhausted", + message: "The usage limit has been reached", + statusCode: 429, + }, + }, + }); + bridge.submit("retry this", "immediate"); + bridge.handle({ type: "message.received", data: { message: { content: "retry this" } } }); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } }); + bridge.handle({ type: "inference.done", data: {} }); + bridge.handle({ type: "reactor.done", data: {} }); + + expect(errorRows(shell)).toEqual([]); + expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a queued steer row survives failover rollback", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.error", + data: { + error: { category: "credential_failure", message: "Forbidden", statusCode: 403 }, + }, + }); + bridge.submit("steer this", "steer"); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } }); + bridge.handle({ type: "inference.done", data: {} }); + bridge.handle({ type: "reactor.done", data: {} }); + + const text = shell.streamLog.map((r) => r.text).join("\n"); + expect(errorRows(shell)).toEqual([]); + expect(text).toContain("recovered"); + expect(text).toContain("steer this"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a terminal inference.error with no recovery still surfaces", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + for (const event of [ + { type: "inference.start", data: {} }, + { + type: "inference.error", + data: { + error: { category: "credential_failure", message: "Forbidden", statusCode: 403 }, + }, + }, + { type: "reactor.error", data: { error: "failed" } }, + ] as const) { + bridge.handle(event); + } + + expect(errorRows(shell).length).toBeGreaterThan(0); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); + describe("parallel sub-agent dispatch on the live session bridge", () => { // The live main-session path tracks a call's row by callId in its own map // (applyToolCall/applyToolResult), independent of tool-rows.ts's name-based diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 6dd61305..4ce1c13d 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -417,6 +417,24 @@ function consumeEcho(bag: BridgeBag, text: string): boolean { return true; } +function messageReceivedContent(event: { readonly data?: unknown }): string | undefined { + const data = event.data; + if (data === null || typeof data !== "object" || Array.isArray(data)) return undefined; + const message = (data as { readonly message?: unknown }).message; + if (message === null || typeof message !== "object" || Array.isArray(message)) return undefined; + const content = (message as { readonly content?: unknown }).content; + return typeof content === "string" ? content : undefined; +} + +function consumePendingEchoEvent( + bag: BridgeBag, + event: { readonly type: string; readonly data?: unknown }, +): boolean { + if (event.type !== "message.received") return false; + const content = messageReceivedContent(event); + return content !== undefined && consumeEcho(bag, content); +} + function openRowContent( kind: OpenRowKind, text: string, @@ -691,11 +709,25 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { * bookkeeping that pointed into it — a rolled-back tool call has no row left * to resolve, and a rolled-back reasoning row is no longer there to fold into. */ +function isLocallyQueuedUserRow(row: StreamRow): boolean { + return ( + row.role === "user" && + (row.meta === "queue" || + row.meta === "steer" || + row.meta === "steering" || + row.meta === "following-up") + ); +} + function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { const boundary = bag.attemptRow; bag.attemptRow = null; if (boundary === null || boundary >= streamRowCount(shell)) return; + const localRows = Array.from({ length: streamRowCount(shell) - boundary }, (_, i) => + streamRowAt(shell, boundary + i), + ).filter((row): row is StreamRow => row !== undefined && isLocallyQueuedUserRow(row)); truncateStreamRows(shell, boundary); + for (const row of localRows) appendStreamRow(shell, row); for (const [callId, index] of [...bag.toolRows]) { if (index >= boundary) { bag.toolRows.delete(callId); @@ -1045,6 +1077,7 @@ export function attachSessionBridge( const settled = noteEvent(event); // Reactor-shaped types always map first (avoids tool.done name collision). if (PRODUCTION_REACTOR_TYPES.has(event.type)) { + if (consumePendingEchoEvent(bag, event)) return; for (const mapped of mapProductionEvent(event as ReactorLikeEvent, bag.mapCtx)) { applyInbound(shell, bag, mapped); } diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index e4e4a599..ae5df33d 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -258,6 +258,33 @@ describe("inference.retry", () => { expect(actions(out)).toEqual(["mark", "rollback"]); }); + test("a same-turn failover start consumes the boundary handed off by inference.error", () => { + const out = mapProductionSequence([ + { type: "inference.start" }, + { type: "inference.text.delta", data: { token: "partial" } }, + { + type: "inference.error", + data: { + error: { category: "quota_exhausted", message: "The usage limit has been reached" }, + }, + }, + { type: "inference.start" }, + ]); + expect(actions(out)).toEqual(["mark", "rollback", "mark"]); + }); + + test("a same-turn failover start after credential_failure also rolls back", () => { + const out = mapProductionSequence([ + { type: "inference.start" }, + { + type: "inference.error", + data: { error: { category: "credential_failure", message: "Forbidden" } }, + }, + { type: "inference.start" }, + ]); + expect(actions(out)).toEqual(["mark", "rollback", "mark"]); + }); + test("a settled cycle disarms, so the next cycle's pre-commit retry is inert", () => { const out = mapProductionSequence([ { type: "inference.start" }, @@ -268,7 +295,7 @@ describe("inference.retry", () => { expect(actions(out)).toEqual(["mark", "clear"]); }); - test("any event other than the retry expires the error handoff", () => { + test("any event other than retry or start expires the error handoff", () => { const out = mapProductionSequence([ { type: "inference.start" }, { type: "inference.text.delta", data: { token: "partial" } }, diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index c8c53e85..9ff63e9c 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -109,10 +109,11 @@ export interface StreamMapContext { attemptCallIds: Set; /** * A committed attempt can also end in `inference.error` with no - * `inference.done`, and the reactor's committed-retry follows that error - * immediately. The boundary must not stay armed across a terminal error, so - * the error hands it off here: the very next event either is the retry that - * consumes it, or expires it. + * `inference.done`. Recovery follows that error as either the reactor's + * committed-retry or a same-turn failover `inference.start`. The boundary + * must not stay armed across a terminal error, so the error hands it off + * here: the very next event either consumes it (retry or start) and + * retracts the failed attempt, or expires it and keeps the error row. */ errorRollbackArmed: boolean; /** @@ -151,6 +152,23 @@ function disarmAttempt(ctx: StreamMapContext | undefined): readonly BridgeInboun return [ATTEMPT_CLEAR]; } +/** Drop call bookkeeping and held deltas that belonged only to the failed attempt. */ +function forgetAttemptLocalState(ctx: StreamMapContext): void { + for (const callId of [...ctx.callIdToName.keys()]) { + if (ctx.attemptCallIds.has(callId)) continue; + ctx.callIdToName.delete(callId); + ctx.callIdToArgs.delete(callId); + ctx.emittedToolCalls.delete(callId); + } + ctx.hadTextDelta = false; + ctx.pendingDelta.assistant = ""; + ctx.pendingDelta.thinking = ""; +} + +function recoversErrorHandoff(type: string): boolean { + return type === "inference.retry" || type === "inference.start"; +} + type DeltaChannel = "assistant" | "thinking"; const DELTA_EVENT_TYPE: Record = { @@ -276,10 +294,11 @@ export function mapProductionEvent( flushed.push(...flushDelta(ctx, "thinking")); } // The error handoff only survives to the very next event; consume it here so - // anything other than the retry it was meant for expires the boundary. + // anything other than the retry or failover start it was meant for expires + // the boundary and keeps the error row. const handoff = ctx?.errorRollbackArmed === true; if (ctx) ctx.errorRollbackArmed = false; - const expired = handoff && event.type !== "inference.retry" ? [ATTEMPT_CLEAR] : []; + const expired = handoff && !recoversErrorHandoff(event.type) ? [ATTEMPT_CLEAR] : []; const mapped = mapEvent(event, ctx, handoff); return [...flushed, ...expired, ...mapped]; } @@ -311,13 +330,20 @@ function mapEvent( return [...disarmed, { type: "user", text: full }]; } - case "inference.start": + case "inference.start": { + const recovered = errorRollbackHandoff; if (ctx) { + if (recovered) forgetAttemptLocalState(ctx); ctx.hadTextDelta = false; ctx.attemptArmed = true; ctx.attemptCallIds = new Set(ctx.callIdToName.keys()); } - return [ATTEMPT_MARK, { type: "run", state: "busy" }]; + return [ + ...(recovered ? [ATTEMPT_ROLLBACK] : []), + ATTEMPT_MARK, + { type: "run", state: "busy" }, + ]; + } case "inference.done": // Cycle settled: disarm so a pre-commit retry belonging to the *next* @@ -330,17 +356,7 @@ function mapEvent( // A retry that arrives with nothing armed is the harness's pre-commit // kind: the failed attempt never streamed, so there is nothing to undo. if (!armed && !errorRollbackHandoff) return []; - if (ctx) { - for (const callId of [...ctx.callIdToName.keys()]) { - if (ctx.attemptCallIds.has(callId)) continue; - ctx.callIdToName.delete(callId); - ctx.callIdToArgs.delete(callId); - ctx.emittedToolCalls.delete(callId); - } - ctx.hadTextDelta = false; - ctx.pendingDelta.assistant = ""; - ctx.pendingDelta.thinking = ""; - } + if (ctx) forgetAttemptLocalState(ctx); return [ATTEMPT_ROLLBACK]; } @@ -484,9 +500,9 @@ function mapEvent( ...(typeof err.retryAfterMs === "number" ? { retryAfterMs: err.retryAfterMs } : {}), }) : rawMessage; - // Hand the armed boundary to the next event rather than disarming: the - // reactor's committed-retry follows this error and must still retract - // the failed attempt, including the error row painted here. + // Hand the armed boundary to the next event rather than disarming: a + // committed retry or same-turn failover start must still retract the + // failed attempt, including the error row painted here. if (ctx?.attemptArmed === true) { ctx.attemptArmed = false; ctx.errorRollbackArmed = true; From 6a1bb54fcbd5424c99da6702ebeb52bdba461c0c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 11:48:47 -0700 Subject: [PATCH 2/4] Expire error recovery when the operator interrupts --- src/tui/runtime-bridge.test.ts | 46 ++++++++++++++++++++++++++++++++-- src/tui/runtime-bridge.ts | 5 ++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 8eeae6b3..fc223ddc 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -905,7 +905,8 @@ describe("same-turn failover after inference.error", () => { wireKeys: false, run: "idle", }); - const bridge = attachSessionBridge(shell, createRecordingPort()); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); try { bridge.handle({ type: "inference.start", data: {} }); bridge.handle({ @@ -927,6 +928,47 @@ describe("same-turn failover after inference.error", () => { expect(errorRows(shell)).toEqual([]); expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered"); + // Same-turn failover, not an operator stop — recovery must not borrow interrupt. + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false); + expect(shell.streamLog.some((r) => r.meta === "stop")).toBe(false); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("interrupt then a new prompt keeps the prompt and the classified error", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.error", + data: { + error: { category: "credential_failure", message: "Forbidden", statusCode: 403 }, + }, + }); + bridge.interrupt(); + bridge.submit("next prompt", "immediate"); + bridge.handle({ + type: "message.received", + data: { message: { content: "next prompt" } }, + }); + bridge.handle({ type: "inference.start", data: {} }); + + const text = shell.streamLog.map((r) => r.text).join("\n"); + expect(text).toContain("next prompt"); + expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); } finally { bridge.dispose(); shell.dispose(); @@ -995,7 +1037,7 @@ describe("same-turn failover after inference.error", () => { bridge.handle(event); } - expect(errorRows(shell).length).toBeGreaterThan(0); + expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); } finally { bridge.dispose(); shell.dispose(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 4ce1c13d..bf1db34d 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -1195,6 +1195,11 @@ export function attachSessionBridge( if (bag.disposed) return; closeOpenRow(shell, bag); bag.pendingEchoes.length = 0; + // The stopped attempt is no longer in flight. Expire the error-recovery + // handoff so a later new-turn inference.start cannot roll back the + // classified error, the stop row, or the operator's next prompt. + bag.mapCtx.errorRollbackArmed = false; + bag.attemptRow = null; applyShellInterrupt(shell); bag.port.interrupt(); // The stop settles the turn without necessarily producing an idle event to From 22b8855a9067d20349a5e9c0ba27bfcf758e17af Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 11:52:14 -0700 Subject: [PATCH 3/4] Expire error recovery on reinject the same as interrupt Reinject still stops the run before sending. Clear the one-event handoff there too, and keep reinject rows if a rollback still fires. --- src/tui/runtime-bridge.test.ts | 36 ++++++++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 15 ++++++++------ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index fc223ddc..9b6b8848 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -1014,6 +1014,42 @@ describe("same-turn failover after inference.error", () => { ); }); + test("reinject interrupt keeps the prompt and the classified error", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.error", + data: { + error: { category: "credential_failure", message: "Forbidden", statusCode: 403 }, + }, + }); + bridge.submit("restart from here", "reinject"); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } }); + bridge.handle({ type: "inference.done", data: {} }); + bridge.handle({ type: "reactor.done", data: {} }); + + const text = shell.streamLog.map((r) => r.text).join("\n"); + expect(text).toContain("restart from here"); + expect(text).toContain("stop — restarting from your message"); + expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("a terminal inference.error with no recovery still surfaces", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index bf1db34d..8bd48195 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -704,21 +704,22 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { } } -/** - * Retract everything the failed attempt painted, then forget the row - * bookkeeping that pointed into it — a rolled-back tool call has no row left - * to resolve, and a rolled-back reasoning row is no longer there to fold into. - */ function isLocallyQueuedUserRow(row: StreamRow): boolean { return ( row.role === "user" && (row.meta === "queue" || row.meta === "steer" || row.meta === "steering" || - row.meta === "following-up") + row.meta === "following-up" || + row.meta === "reinject") ); } +/** + * Retract everything the failed attempt painted, then forget the row + * bookkeeping that pointed into it — a rolled-back tool call has no row left + * to resolve, and a rolled-back reasoning row is no longer there to fold into. + */ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { const boundary = bag.attemptRow; bag.attemptRow = null; @@ -1138,6 +1139,8 @@ export function attachSessionBridge( if (shell.session.run !== "busy") return; closeOpenRow(shell, bag); bag.pendingEchoes.length = 0; + bag.mapCtx.errorRollbackArmed = false; + bag.attemptRow = null; shell.session = interrupt(shell.session); appendStreamRow(shell, { role: "system", From aa031e9c6deffafb36d77c6fa03f10c8778cc836 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 12:15:05 -0700 Subject: [PATCH 4/4] Split the thinking row when a steer echo is swallowed Consuming an echoed message before the mapper kept the recovery handoff alive but skipped the turn bookkeeping, so reasoning after a steer folded into the row above the operator's message. Close the open row and reset the turn thinking on that path, and pin that the replayed quota prompt row is dropped by the rollback. --- src/tui/runtime-bridge.test.ts | 56 ++++++++++++++++++++++++++++++++++ src/tui/runtime-bridge.ts | 8 ++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 9b6b8848..47168686 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -908,6 +908,8 @@ describe("same-turn failover after inference.error", () => { const port = createRecordingPort(); const bridge = attachSessionBridge(shell, port); try { + bridge.submit("retry this", "immediate"); + bridge.handle({ type: "message.received", data: { message: { content: "retry this" } } }); bridge.handle({ type: "inference.start", data: {} }); bridge.handle({ type: "inference.error", @@ -928,6 +930,10 @@ describe("same-turn failover after inference.error", () => { expect(errorRows(shell)).toEqual([]); expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered"); + // The replay duplicates the operator's prompt; rollback drops the copy. + expect(shell.streamLog.filter((r) => r.role === "user").map((r) => r.text)).toEqual([ + "retry this", + ]); // Same-turn failover, not an operator stop — recovery must not borrow interrupt. expect(port.calls.some((c) => c.op === "interrupt")).toBe(false); expect(shell.streamLog.some((r) => r.meta === "stop")).toBe(false); @@ -940,6 +946,56 @@ describe("same-turn failover after inference.error", () => { ); }); + test("a steer echo at a tool boundary opens a new thinking row", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.submit("first prompt", "immediate"); + bridge.handle({ + type: "message.received", + data: { message: { content: "first prompt" } }, + }); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.thinking.delta", data: { token: "planning" } }); + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "run_shell", callId: "c1", arguments: "{}" }, + }); + bridge.handle({ type: "inference.done", data: {} }); + bridge.submit("steer this", "steer"); + bridge.handle({ + type: "tool.done", + data: { result: { callId: "c1", content: "ok", isError: false } }, + }); + bridge.handle({ type: "message.received", data: { message: { content: "steer this" } } }); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.thinking.delta", data: { token: "after steer" } }); + bridge.handle({ type: "inference.text.delta", data: { token: "done" } }); + + const rows = shell.streamLog.map((r) => `${r.meta ?? r.role}:${r.text}`); + expect(rows.indexOf("thinking:planning")).toBeGreaterThan(-1); + expect(rows.indexOf("steering:steer this")).toBeGreaterThan( + rows.indexOf("thinking:planning"), + ); + expect(rows.indexOf("thinking:after steer")).toBeGreaterThan( + rows.indexOf("steering:steer this"), + ); + expect(shell.streamLog.filter((r) => r.meta === "thinking")).toHaveLength(2); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("interrupt then a new prompt keeps the prompt and the classified error", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 8bd48195..fe9945f8 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -1078,7 +1078,13 @@ export function attachSessionBridge( const settled = noteEvent(event); // Reactor-shaped types always map first (avoids tool.done name collision). if (PRODUCTION_REACTOR_TYPES.has(event.type)) { - if (consumePendingEchoEvent(bag, event)) return; + if (consumePendingEchoEvent(bag, event)) { + // The echo skips the mapper so it cannot expire a recovery handoff, + // but it still starts a new turn: the next reasoning gets its own row. + closeOpenRow(shell, bag); + bag.turnThinking = null; + return; + } for (const mapped of mapProductionEvent(event as ReactorLikeEvent, bag.mapCtx)) { applyInbound(shell, bag, mapped); }