Skip to content

Commit 99b938c

Browse files
committed
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.
1 parent e78c9be commit 99b938c

5 files changed

Lines changed: 39 additions & 20 deletions

File tree

‎src/tui/product-host.test.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js";
1717
import { hydrateHistoryRows } from "./history-hydrate.js";
1818
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
1919
import { enterSubagentObserve } from "./shell/observe.js";
20+
import { transcriptMarker } from "./shell/transcript.js";
2021

2122
function makeFakeSessionPort(): {
2223
readonly sends: string[];
@@ -146,9 +147,9 @@ describe("mountProductHost", () => {
146147
role: "assistant",
147148
text: `row-${total - 1}`,
148149
});
149-
// Pre-sliced to the cap, so the append loop never trips retention
150-
// eviction: no churn, no dropped-rows marker on resume.
151-
expect(host.shell.streamLogBase).toBe(0);
150+
const dropped = total - MAX_RETAINED_STREAM_ROWS;
151+
expect(host.shell.streamLogBase).toBe(dropped);
152+
expect(transcriptMarker(host.shell)).toBeDefined();
152153
} finally {
153154
host.dispose();
154155
}
@@ -173,6 +174,7 @@ describe("mountProductHost", () => {
173174
text: `small-${total - 1}`,
174175
});
175176
expect(host.shell.streamLogBase).toBe(0);
177+
expect(transcriptMarker(host.shell)).toBeUndefined();
176178
} finally {
177179
host.dispose();
178180
}
@@ -200,12 +202,12 @@ describe("mountProductHost", () => {
200202
},
201203
];
202204
emitter.emit("history.hydrate", blocks);
203-
// Folding merges the pair inside hydration, so the row-level slice keeps
204-
// and drops whole merged rows: 605 texts + 1 merged row → newest 600.
205-
// A block-level slice would split the pair and paint 599 rows instead.
206-
const expected = hydrateHistoryRows(blocks).slice(
207-
-MAX_RETAINED_STREAM_ROWS,
208-
);
205+
// Folding merges the pair inside hydration, so retention evicts whole
206+
// merged rows: 605 texts + 1 merged row → newest 600. A block-level
207+
// slice would split the pair and paint 599 rows instead.
208+
const allRows = hydrateHistoryRows(blocks);
209+
const expected = allRows.slice(-MAX_RETAINED_STREAM_ROWS);
210+
const dropped = allRows.length - expected.length;
209211
expect(expected.length).toBe(MAX_RETAINED_STREAM_ROWS);
210212
expect(host.shell.streamLog).toEqual(expected);
211213
expect(host.shell.streamLog[0]).toEqual({
@@ -215,7 +217,9 @@ describe("mountProductHost", () => {
215217
const last = host.shell.streamLog[host.shell.streamLog.length - 1];
216218
expect(last?.pending).not.toBe(true);
217219
expect(last?.text).toBe("done cut-1");
218-
expect(host.shell.streamLogBase).toBe(0);
220+
expect(dropped).toBeGreaterThan(0);
221+
expect(host.shell.streamLogBase).toBe(dropped);
222+
expect(transcriptMarker(host.shell)).toBeDefined();
219223
} finally {
220224
host.dispose();
221225
}
@@ -246,7 +250,11 @@ describe("mountProductHost", () => {
246250
);
247251
expect(host.shell.parentStreamLog).toEqual(expected);
248252
expect(host.shell.parentStreamLog?.length).toBe(MAX_RETAINED_STREAM_ROWS);
249-
expect(host.shell.parentStreamLogBase).toBe(0);
253+
expect(host.shell.parentStreamLogBase).toBe(
254+
total - MAX_RETAINED_STREAM_ROWS,
255+
);
256+
expect(host.shell.streamLogBase).toBe(0);
257+
expect(transcriptMarker(host.shell)).toBeUndefined();
250258
} finally {
251259
host.dispose();
252260
}

‎src/tui/product-host.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ import {
6565
import { surfaceSystemNotice } from "./shell/prompt.js";
6666
import type { DeliverySettle, QueueKind } from "./delivery-queue.js";
6767
import { hydrateHistoryRows } from "./history-hydrate.js";
68-
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
6968
import type { StreamRow } from "./stream.js";
7069

7170
import type { PendingImageAttachment } from "./image-attachments.js";
@@ -542,9 +541,7 @@ export async function mountProductHost(
542541

543542
function onHistory(blocks: unknown): void {
544543
if (disposed) return;
545-
const rows = hydrateHistoryRows(blocks);
546-
const tail = rows.slice(-MAX_RETAINED_STREAM_ROWS);
547-
for (const row of tail) {
544+
for (const row of hydrateHistoryRows(blocks)) {
548545
appendStreamRow(shell, row);
549546
}
550547
}

‎src/tui/runner/wiring.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,9 +501,10 @@ export function wirePostStartup(
501501
// Hydrate a resumed session's transcript after first paint. Reading history and
502502
// mapping it to content blocks is pure I/O with no bearing on the shell, so the
503503
// App renders empty immediately and fills in the past turns once they are ready.
504-
// Only the tail needed to fill RESUME_TRANSCRIPT_BLOCK_LIMIT blocks is read from
505-
// disk — a long session's full history is not needed just to paint a transcript
506-
// that itself caps how much it displays.
504+
// Only the retained transcript tail is read from disk — a long session's
505+
// full history is not needed just to paint a display that itself caps how
506+
// much it keeps. Agent conversation state still loads in full via
507+
// ContextStore.load(); this path is display-only.
507508
void loadRecentTurns(state.workdir, RESUME_TRANSCRIPT_BLOCK_LIMIT)
508509
.then((turns) => {
509510
const blocks = turnsToContentBlocks(turns, {

‎src/tui/turns-to-blocks.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, test, expect } from "bun:test";
22
import type { ConversationTurn } from "@intx/types/runtime";
33
import { buildMailboxMailPrompt } from "../subagent/mailbox-mail-drive.js";
4-
import { turnsToContentBlocks } from "./turns-to-blocks.js";
4+
import {
5+
RESUME_TRANSCRIPT_BLOCK_LIMIT,
6+
turnsToContentBlocks,
7+
} from "./turns-to-blocks.js";
8+
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
59
import { hydrateTasksFromTurns } from "../agent/director.js";
610

711
function manageTasksTurn(
@@ -115,6 +119,12 @@ describe("turnsToContentBlocks marks occupancy wakes with system origin", () =>
115119
});
116120
});
117121

122+
describe("RESUME_TRANSCRIPT_BLOCK_LIMIT", () => {
123+
test("matches the retained stream tail, not a larger pre-slice", () => {
124+
expect(RESUME_TRANSCRIPT_BLOCK_LIMIT).toBe(MAX_RETAINED_STREAM_ROWS);
125+
});
126+
});
127+
118128
describe("hydrateTasksFromTurns", () => {
119129
test("derives the task list from manage_tasks tool calls in a transcript", () => {
120130
const turns = [manageTasksTurn("m1", "doing"), toolResultTurn("m1", false)];

‎src/tui/turns-to-blocks.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from "@intx/types/runtime";
55

66
import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js";
7+
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
78
import { validateView } from "./view/validate.js";
89
import type { ViewNode } from "./view/spec.js";
910

@@ -209,7 +210,9 @@ function finalizeResumeToolBlocks(
209210
return out;
210211
}
211212

212-
export const RESUME_TRANSCRIPT_BLOCK_LIMIT = 2000;
213+
// Resume paints into the same retained log live turns use, so the disk/block
214+
// window is the retention cap rather than a larger pre-slice.
215+
export const RESUME_TRANSCRIPT_BLOCK_LIMIT = MAX_RETAINED_STREAM_ROWS;
213216

214217
interface TurnsToContentBlocksOptions {
215218
maxBlocks?: number;

0 commit comments

Comments
 (0)