Skip to content

Commit 84d34cf

Browse files
committed
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.
1 parent 4a03fb5 commit 84d34cf

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type ProductHostConfig,
2020
} from "./product-host.js";
2121
import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js";
22+
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
2223

2324
function makeFakeSessionPort(): {
2425
readonly sends: string[];
@@ -136,6 +137,56 @@ describe("mountProductHost", () => {
136137
}
137138
});
138139

140+
test("history.hydrate caps oversized history at the newest retained rows (CL-9008)", async () => {
141+
const { host, emitter } = await mountHeadless();
142+
try {
143+
const total = MAX_RETAINED_STREAM_ROWS + 200;
144+
const blocks = Array.from({ length: total }, (_, i) => ({
145+
type: "text",
146+
content: `row-${i}`,
147+
}));
148+
emitter.emit("history.hydrate", blocks);
149+
expect(host.shell.streamLog.length).toBe(MAX_RETAINED_STREAM_ROWS);
150+
expect(host.shell.streamLog[0]).toEqual({
151+
role: "assistant",
152+
text: `row-${total - MAX_RETAINED_STREAM_ROWS}`,
153+
});
154+
expect(host.shell.streamLog[MAX_RETAINED_STREAM_ROWS - 1]).toEqual({
155+
role: "assistant",
156+
text: `row-${total - 1}`,
157+
});
158+
// Pre-sliced to the cap, so the append loop never trips retention
159+
// eviction: no churn, no dropped-rows marker on resume.
160+
expect(host.shell.streamLogBase).toBe(0);
161+
} finally {
162+
host.dispose();
163+
}
164+
});
165+
166+
test("history.hydrate below the cap paints every row (CL-9008)", async () => {
167+
const { host, emitter } = await mountHeadless();
168+
try {
169+
const total = MAX_RETAINED_STREAM_ROWS - 100;
170+
const blocks = Array.from({ length: total }, (_, i) => ({
171+
type: "text",
172+
content: `small-${i}`,
173+
}));
174+
emitter.emit("history.hydrate", blocks);
175+
expect(host.shell.streamLog.length).toBe(total);
176+
expect(host.shell.streamLog[0]).toEqual({
177+
role: "assistant",
178+
text: "small-0",
179+
});
180+
expect(host.shell.streamLog[total - 1]).toEqual({
181+
role: "assistant",
182+
text: `small-${total - 1}`,
183+
});
184+
expect(host.shell.streamLogBase).toBe(0);
185+
} finally {
186+
host.dispose();
187+
}
188+
});
189+
139190
test("session.title updates the shell header", async () => {
140191
const { host, emitter } = await mountHeadless();
141192
try {

‎src/tui/product-host.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
import { surfaceSystemNotice } from "./shell/prompt.js";
6868
import type { DeliverySettle, QueueKind } from "./delivery-queue.js";
6969
import { hydrateHistoryRows } from "./history-hydrate.js";
70+
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
7071
import type { StreamRow } from "./stream.js";
7172

7273
import type { PendingImageAttachment } from "./image-attachments.js";
@@ -557,7 +558,9 @@ export async function mountProductHost(
557558

558559
function onHistory(blocks: unknown): void {
559560
if (disposed) return;
560-
for (const row of hydrateHistoryRows(blocks)) {
561+
const rows = hydrateHistoryRows(blocks);
562+
const tail = rows.slice(-MAX_RETAINED_STREAM_ROWS);
563+
for (const row of tail) {
561564
appendStreamRow(shell, row);
562565
}
563566
}

0 commit comments

Comments
 (0)