Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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[];
Expand Down Expand Up @@ -136,6 +139,130 @@ 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("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 {
Expand Down
5 changes: 4 additions & 1 deletion src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading