From 99b938c65e759398dc66cb97bf54e3c453aaeb48 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 19:30:28 -0700 Subject: [PATCH] fix(resume): hydrate only the retained transcript tail A larger disk window then a host-side slice left the dropped-rows notice unpainted. Resume now reads the same tail the transcript keeps, and overflow still goes through retention eviction. --- src/tui/product-host.test.ts | 30 +++++++++++++++++++----------- src/tui/product-host.ts | 5 +---- src/tui/runner/wiring.ts | 7 ++++--- src/tui/turns-to-blocks.test.ts | 12 +++++++++++- src/tui/turns-to-blocks.ts | 5 ++++- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 122e51cfe..bf855b2a6 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -17,6 +17,7 @@ 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"; +import { transcriptMarker } from "./shell/transcript.js"; function makeFakeSessionPort(): { readonly sends: string[]; @@ -146,9 +147,9 @@ describe("mountProductHost", () => { 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); + const dropped = total - MAX_RETAINED_STREAM_ROWS; + expect(host.shell.streamLogBase).toBe(dropped); + expect(transcriptMarker(host.shell)).toBeDefined(); } finally { host.dispose(); } @@ -173,6 +174,7 @@ describe("mountProductHost", () => { text: `small-${total - 1}`, }); expect(host.shell.streamLogBase).toBe(0); + expect(transcriptMarker(host.shell)).toBeUndefined(); } finally { host.dispose(); } @@ -200,12 +202,12 @@ describe("mountProductHost", () => { }, ]; 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, - ); + // Folding merges the pair inside hydration, so retention evicts whole + // merged rows: 605 texts + 1 merged row → newest 600. A block-level + // slice would split the pair and paint 599 rows instead. + const allRows = hydrateHistoryRows(blocks); + const expected = allRows.slice(-MAX_RETAINED_STREAM_ROWS); + const dropped = allRows.length - expected.length; expect(expected.length).toBe(MAX_RETAINED_STREAM_ROWS); expect(host.shell.streamLog).toEqual(expected); expect(host.shell.streamLog[0]).toEqual({ @@ -215,7 +217,9 @@ describe("mountProductHost", () => { 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); + expect(dropped).toBeGreaterThan(0); + expect(host.shell.streamLogBase).toBe(dropped); + expect(transcriptMarker(host.shell)).toBeDefined(); } finally { host.dispose(); } @@ -246,7 +250,11 @@ describe("mountProductHost", () => { ); expect(host.shell.parentStreamLog).toEqual(expected); expect(host.shell.parentStreamLog?.length).toBe(MAX_RETAINED_STREAM_ROWS); - expect(host.shell.parentStreamLogBase).toBe(0); + expect(host.shell.parentStreamLogBase).toBe( + total - MAX_RETAINED_STREAM_ROWS, + ); + expect(host.shell.streamLogBase).toBe(0); + expect(transcriptMarker(host.shell)).toBeUndefined(); } finally { host.dispose(); } diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index aceec33cb..f29a8e312 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -65,7 +65,6 @@ 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"; @@ -542,9 +541,7 @@ export async function mountProductHost( function onHistory(blocks: unknown): void { if (disposed) return; - const rows = hydrateHistoryRows(blocks); - const tail = rows.slice(-MAX_RETAINED_STREAM_ROWS); - for (const row of tail) { + for (const row of hydrateHistoryRows(blocks)) { appendStreamRow(shell, row); } } diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 2ff3104dc..81adc5a34 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -501,9 +501,10 @@ export function wirePostStartup( // Hydrate a resumed session's transcript after first paint. Reading history and // mapping it to content blocks is pure I/O with no bearing on the shell, so the // App renders empty immediately and fills in the past turns once they are ready. - // Only the tail needed to fill RESUME_TRANSCRIPT_BLOCK_LIMIT blocks is read from - // disk — a long session's full history is not needed just to paint a transcript - // that itself caps how much it displays. + // Only the retained transcript tail is read from disk — a long session's + // full history is not needed just to paint a display that itself caps how + // much it keeps. Agent conversation state still loads in full via + // ContextStore.load(); this path is display-only. void loadRecentTurns(state.workdir, RESUME_TRANSCRIPT_BLOCK_LIMIT) .then((turns) => { const blocks = turnsToContentBlocks(turns, { diff --git a/src/tui/turns-to-blocks.test.ts b/src/tui/turns-to-blocks.test.ts index e40ef93e4..3fd5b0213 100644 --- a/src/tui/turns-to-blocks.test.ts +++ b/src/tui/turns-to-blocks.test.ts @@ -1,7 +1,11 @@ import { describe, test, expect } from "bun:test"; import type { ConversationTurn } from "@intx/types/runtime"; import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js"; -import { turnsToContentBlocks } from "./turns-to-blocks.js"; +import { + RESUME_TRANSCRIPT_BLOCK_LIMIT, + turnsToContentBlocks, +} from "./turns-to-blocks.js"; +import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; import { hydrateTasksFromTurns } from "../agent/director.js"; function manageTasksTurn( @@ -115,6 +119,12 @@ describe("turnsToContentBlocks marks occupancy wakes with system origin", () => }); }); +describe("RESUME_TRANSCRIPT_BLOCK_LIMIT", () => { + test("matches the retained stream tail, not a larger pre-slice", () => { + expect(RESUME_TRANSCRIPT_BLOCK_LIMIT).toBe(MAX_RETAINED_STREAM_ROWS); + }); +}); + describe("hydrateTasksFromTurns", () => { test("derives the task list from manage_tasks tool calls in a transcript", () => { const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", false)]; diff --git a/src/tui/turns-to-blocks.ts b/src/tui/turns-to-blocks.ts index b08a6471c..bd8b0c184 100644 --- a/src/tui/turns-to-blocks.ts +++ b/src/tui/turns-to-blocks.ts @@ -4,6 +4,7 @@ import type { } from "@intx/types/runtime"; import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js"; +import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; import { validateView } from "./view/validate.js"; import type { ViewNode } from "./view/spec.js"; @@ -209,7 +210,9 @@ function finalizeResumeToolBlocks( return out; } -export const RESUME_TRANSCRIPT_BLOCK_LIMIT = 2000; +// Resume paints into the same retained log live turns use, so the disk/block +// window is the retention cap rather than a larger pre-slice. +export const RESUME_TRANSCRIPT_BLOCK_LIMIT = MAX_RETAINED_STREAM_ROWS; interface TurnsToContentBlocksOptions { maxBlocks?: number;