From 84d34cfab4bfa8b8625da6772fb78440d267aecb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:02:38 -0700 Subject: [PATCH 1/2] fix(resume): cap history hydrate to the newest retained rows Resume paints only the newest MAX_RETAINED_STREAM_ROWS rows, so an oversized history never trips per-append retention eviction during the hydrate loop. streamLogBase stays 0: no dropped-rows marker on resume. --- src/tui/product-host.test.ts | 51 ++++++++++++++++++++++++++++++++++++ src/tui/product-host.ts | 5 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index bc28a665a..8abea9804 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -19,6 +19,7 @@ import { type ProductHostConfig, } from "./product-host.js"; import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js"; +import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; function makeFakeSessionPort(): { readonly sends: string[]; @@ -136,6 +137,56 @@ describe("mountProductHost", () => { } }); + test("history.hydrate caps oversized history at the newest retained rows (CL-9008)", async () => { + const { host, emitter } = await mountHeadless(); + try { + const total = MAX_RETAINED_STREAM_ROWS + 200; + const blocks = Array.from({ length: total }, (_, i) => ({ + type: "text", + content: `row-${i}`, + })); + emitter.emit("history.hydrate", blocks); + expect(host.shell.streamLog.length).toBe(MAX_RETAINED_STREAM_ROWS); + expect(host.shell.streamLog[0]).toEqual({ + role: "assistant", + text: `row-${total - MAX_RETAINED_STREAM_ROWS}`, + }); + expect(host.shell.streamLog[MAX_RETAINED_STREAM_ROWS - 1]).toEqual({ + role: "assistant", + text: `row-${total - 1}`, + }); + // Pre-sliced to the cap, so the append loop never trips retention + // eviction: no churn, no dropped-rows marker on resume. + expect(host.shell.streamLogBase).toBe(0); + } finally { + host.dispose(); + } + }); + + test("history.hydrate below the cap paints every row (CL-9008)", async () => { + const { host, emitter } = await mountHeadless(); + try { + const total = MAX_RETAINED_STREAM_ROWS - 100; + const blocks = Array.from({ length: total }, (_, i) => ({ + type: "text", + content: `small-${i}`, + })); + emitter.emit("history.hydrate", blocks); + expect(host.shell.streamLog.length).toBe(total); + expect(host.shell.streamLog[0]).toEqual({ + role: "assistant", + text: "small-0", + }); + expect(host.shell.streamLog[total - 1]).toEqual({ + role: "assistant", + text: `small-${total - 1}`, + }); + expect(host.shell.streamLogBase).toBe(0); + } finally { + host.dispose(); + } + }); + test("session.title updates the shell header", async () => { const { host, emitter } = await mountHeadless(); try { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 9a73e9700..139333192 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -67,6 +67,7 @@ import { import { surfaceSystemNotice } from "./shell/prompt.js"; import type { DeliverySettle, QueueKind } from "./delivery-queue.js"; import { hydrateHistoryRows } from "./history-hydrate.js"; +import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; import type { StreamRow } from "./stream.js"; import type { PendingImageAttachment } from "./image-attachments.js"; @@ -557,7 +558,9 @@ export async function mountProductHost( function onHistory(blocks: unknown): void { if (disposed) return; - for (const row of hydrateHistoryRows(blocks)) { + const rows = hydrateHistoryRows(blocks); + const tail = rows.slice(-MAX_RETAINED_STREAM_ROWS); + for (const row of tail) { appendStreamRow(shell, row); } } From dbe055df6bab5a595f042953bfc1b245a38292e4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:11:29 -0700 Subject: [PATCH 2/2] test(tui): add hydrate-cap keeper probes for folding and observe paths --- src/tui/product-host.test.ts | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 8abea9804..a30a2503d 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -19,7 +19,9 @@ import { type ProductHostConfig, } from "./product-host.js"; import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js"; +import { hydrateHistoryRows } from "./history-hydrate.js"; import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; +import { enterSubagentObserve } from "./shell/observe.js"; function makeFakeSessionPort(): { readonly sends: string[]; @@ -187,6 +189,80 @@ describe("mountProductHost", () => { } }); + test("history.hydrate keeps a tool pair merged atomically across the cap boundary (CL-9008)", async () => { + const { host, emitter } = await mountHeadless(); + try { + const blocks = [ + ...Array.from({ length: 605 }, (_, i) => ({ + type: "text", + content: `row-${i}`, + })), + { + type: "tool_call", + name: "spawn_agent", + arguments: '{"description":"Fix CL-9008"}', + callId: "cut-1", + }, + { + type: "tool_result", + name: "spawn_agent", + content: "done cut-1", + callId: "cut-1", + }, + ]; + emitter.emit("history.hydrate", blocks); + // Folding merges the pair inside hydration, so the row-level slice keeps + // and drops whole merged rows: 605 texts + 1 merged row → newest 600. + // A block-level slice would split the pair and paint 599 rows instead. + const expected = hydrateHistoryRows(blocks).slice( + -MAX_RETAINED_STREAM_ROWS, + ); + expect(expected.length).toBe(MAX_RETAINED_STREAM_ROWS); + expect(host.shell.streamLog).toEqual(expected); + expect(host.shell.streamLog[0]).toEqual({ + role: "assistant", + text: "row-6", + }); + const last = host.shell.streamLog[host.shell.streamLog.length - 1]; + expect(last?.pending).not.toBe(true); + expect(last?.text).toBe("done cut-1"); + expect(host.shell.streamLogBase).toBe(0); + } finally { + host.dispose(); + } + }); + + test("history.hydrate in observe mode lands the capped tail on the parent log (CL-9008)", async () => { + const { host, emitter } = await mountHeadless(); + try { + enterSubagentObserve(host.shell, { + sessionId: "child-keeper", + agentId: "explorer", + description: "observe-mode hydrate keeper", + lines: [], + }); + expect(host.shell.parentStreamLog).toEqual([]); + const visibleBefore = host.shell.streamLog.length; + const total = MAX_RETAINED_STREAM_ROWS + 200; + const blocks = Array.from({ length: total }, (_, i) => ({ + type: "text", + content: `obs-row-${i}`, + })); + emitter.emit("history.hydrate", blocks); + // Observe routes hydrate rows to the parent snapshot only; the child + // view on screen is untouched. + expect(host.shell.streamLog.length).toBe(visibleBefore); + const expected = hydrateHistoryRows(blocks).slice( + -MAX_RETAINED_STREAM_ROWS, + ); + expect(host.shell.parentStreamLog).toEqual(expected); + expect(host.shell.parentStreamLog?.length).toBe(MAX_RETAINED_STREAM_ROWS); + expect(host.shell.parentStreamLogBase).toBe(0); + } finally { + host.dispose(); + } + }); + test("session.title updates the shell header", async () => { const { host, emitter } = await mountHeadless(); try {