diff --git a/benchmarks/child-transcript-repaint-demo.ts b/benchmarks/child-transcript-repaint-demo.ts new file mode 100644 index 00000000..71b80f0c --- /dev/null +++ b/benchmarks/child-transcript-repaint-demo.ts @@ -0,0 +1,203 @@ +/** + * Issue #185: a human-readable side-by-side of repaint cost, using realistic + * child-session content at the production transcript ceiling. + * + * Reports three regimes separately, because the change only helps one of them: + * + * cold open first paint of a page; both paths must measure every item + * to know the row total, so this is NOT improved + * warm repaint nothing changed (spinner tick, stream delta elsewhere); + * this is the hot path an operator hits continuously + * after invalidate theme change or resize drops every cached row; both paths + * must re-render, so this is NOT improved + * + * Wall time here is indicative only; benchmarks/child-transcript-viewport.ts + * reports the deterministic item-visit counts. + */ + +import { performance } from "node:perf_hooks"; +import { initTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import { + AgentTranscriptRenderer, + buildPairingIndex, + type AgentTranscriptDocument, + type AgentTranscriptItem, +} from "../extensions/shared/agent-transcript.ts"; + +initTheme("dark", false); + +const theme = new Proxy( + {}, + { + get: (_target, prop) => + prop === "fg" + ? (_color: string, text: string) => text + : (text: string) => text, + }, +) as Theme; + +function readOption(name: string) { + const index = process.argv.indexOf(name); + if (index >= 0) return process.argv[index + 1]; + return process.argv + .find((argument) => argument.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +/** MAX_TRANSCRIPT_ITEMS in the Direct subagent manager. */ +const CEILING = 512; +const SIZES = (readOption("--sizes") ?? `128,${CEILING}`) + .split(",") + .map((value) => Number.parseInt(value, 10)); +const WIDTH = Number.parseInt(readOption("--width") ?? "100", 10); +const VIEWPORT = Number.parseInt(readOption("--viewport") ?? "40", 10); + +/** Markdown-heavy turns, like a real coding child session. */ +const PROSE = `Looking at the failure, the root cause is that \`resolveConfig\` +reads the cached value before the watcher fires. Two options: + +1. Invalidate the cache in the watcher callback +2. Read through a getter that checks mtime + +Option 1 is smaller but races with concurrent readers.`; + +function realistic(count: number) { + const items: AgentTranscriptItem[] = []; + for (let turn = 0; items.length < count; turn++) { + items.push({ + kind: "user", + text: `Fix the failing test in module ${turn}`, + }); + items.push({ + kind: "assistant", + parts: [{ type: "thinking", text: `Considering approach ${turn}...` }], + }); + items.push({ kind: "assistant", parts: [{ type: "text", text: PROSE }] }); + items.push({ + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: `t${turn}`, + name: "read", + argsPreview: JSON.stringify({ path: `src/module-${turn}/config.ts` }), + }, + ], + }); + items.push({ + kind: "toolResult", + toolId: `t${turn}`, + name: "read", + isError: false, + outputPreview: `export const config = { retries: ${turn} };`, + }); + } + return items.slice(0, count); +} + +type Mode = "before" | "after"; + +function paint( + renderer: AgentTranscriptRenderer, + document: AgentTranscriptDocument, + mode: Mode, +) { + if (mode === "before") { + // The pre-change path: render every row, then clip to the viewport. + const all = renderer.render(document, WIDTH, theme, { now: 0 }); + return all.slice(Math.max(0, all.length - VIEWPORT)); + } + const frame = renderer.beginFrame(document, WIDTH, theme, { now: 0 }); + return frame.rows(Math.max(0, frame.rowCount - VIEWPORT), VIEWPORT); +} + +function averageMs(run: () => void, iterations: number) { + const started = performance.now(); + for (let index = 0; index < iterations; index++) run(); + return (performance.now() - started) / iterations; +} + +function bar(ms: number, scale: number) { + return "█".repeat(Math.max(1, Math.round((ms / scale) * 40))); +} + +for (const size of SIZES) { + const items = realistic(size); + const document: AgentTranscriptDocument = { + items, + pairing: buildPairingIndex(items), + }; + const rows = new AgentTranscriptRenderer().beginFrame( + document, + WIDTH, + theme, + { now: 0 }, + ).rowCount; + + const measured: Record> = { + before: {}, + after: {}, + }; + for (const mode of ["before", "after"] as Mode[]) { + measured[mode].coldOpen = averageMs( + () => paint(new AgentTranscriptRenderer(), document, mode), + 20, + ); + + const warm = new AgentTranscriptRenderer(); + paint(warm, document, mode); + measured[mode].warmRepaint = averageMs( + () => paint(warm, document, mode), + 500, + ); + + const dirty = new AgentTranscriptRenderer(); + paint(dirty, document, mode); + measured[mode].afterInvalidate = averageMs(() => { + dirty.invalidate(); + paint(dirty, document, mode); + }, 20); + } + + const label = size === CEILING ? ` (transcript ceiling)` : ""; + console.log( + `\n${size} items${label} -> ${rows} rows, showing ${VIEWPORT} at width ${WIDTH}`, + ); + console.log(` ${"-".repeat(64)}`); + for (const [key, title, improves] of [ + ["coldOpen", "cold open ", false], + ["warmRepaint", "warm repaint ", true], + ["afterInvalidate", "after invalidate", false], + ] as [string, string, boolean][]) { + const before = measured.before[key]!; + const after = measured.after[key]!; + const scale = Math.max(before, after); + const note = improves + ? `${(before / after).toFixed(0)}x faster` + : "unchanged by design"; + console.log( + ` ${title} before ${before.toFixed(3)} ms ${bar(before, scale)}`, + ); + console.log( + ` ${" ".repeat(16)} after ${after.toFixed(3)} ms ${bar(after, scale)}`, + ); + console.log(` ${" ".repeat(16)} ${note}`); + console.log(` ${"-".repeat(64)}`); + } +} + +console.log(` +How to read this: + + warm repaint is the hot path. An open child page repaints on every spinner + tick and every stream delta, so this cost is paid continuously for as long + as an operator watches a run. That is what this change removes. + + cold open and after-invalidate are unchanged on purpose: a scrollable view + must know its total row count, and knowing that means measuring every item + once. Both paths pay that, and the result is then cached. + + All figures are well under a 16 ms frame budget, so this is a scaling fix, + not a fix for visible lag today: before the change the warm cost grew with + history length, after it is flat. +`); diff --git a/benchmarks/child-transcript-viewport.ts b/benchmarks/child-transcript-viewport.ts new file mode 100644 index 00000000..88cee219 --- /dev/null +++ b/benchmarks/child-transcript-viewport.ts @@ -0,0 +1,199 @@ +/** + * Issue #185: prove child transcript repaint cost tracks viewport height, not + * transcript length. + * + * Counts numeric index reads on the items array, which is the exact quantity the + * issue objects to: "scans the full history before clipping to the viewport". + * Wall time is reported too, but the visit count is the load-bearing number + * because it is deterministic and machine-comparable. + * + * Shapes cover both cases the issue names: + * sequential - each call is followed by its own result + * separated - a fan of calls, then all their results (the pairing worst case) + */ + +import { performance } from "node:perf_hooks"; +import { initTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import { + AgentTranscriptRenderer, + buildPairingIndex, + type AgentTranscriptDocument, + type AgentTranscriptItem, +} from "../extensions/shared/agent-transcript.ts"; + +initTheme("dark", false); + +const theme = new Proxy( + {}, + { + get: (_target, prop) => + prop === "fg" + ? (_color: string, text: string) => text + : (text: string) => text, + }, +) as Theme; + +function readOption(name: string) { + const index = process.argv.indexOf(name); + if (index >= 0) return process.argv[index + 1]; + return process.argv + .find((argument) => argument.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function parseList(raw: string | undefined, fallback: number[]) { + if (!raw) return fallback; + const values = raw.split(",").map((value) => Number.parseInt(value, 10)); + if (values.some((value) => !Number.isSafeInteger(value) || value <= 0)) { + throw new Error("expected a comma-separated list of positive integers"); + } + return values; +} + +const SIZES = parseList(readOption("--sizes"), [32, 128, 512]); +const VIEWPORTS = parseList(readOption("--viewports"), [40]); +const REPAINTS = Number.parseInt(readOption("--repaints") ?? "100", 10); +const FAN = Number.parseInt(readOption("--fan") ?? "64", 10); +const WIDTH = Number.parseInt(readOption("--width") ?? "80", 10); +/** + * window: resolve the row total, then render only the viewport (this change). + * full: render every row, then slice the viewport (the behaviour #185 objects + * to, reproduced through the same public renderer for comparison). + */ +const MODE = readOption("--mode") ?? "window"; +if (MODE !== "window" && MODE !== "full") { + throw new Error("--mode must be 'window' or 'full'"); +} + +const ask = (index: number): AgentTranscriptItem => ({ + kind: "user", + text: `ask ${index}`, +}); +const say = (index: number): AgentTranscriptItem => ({ + kind: "assistant", + parts: [{ type: "text", text: `step ${index}` }], +}); +const call = (id: string): AgentTranscriptItem => ({ + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: id, + name: "read", + argsPreview: JSON.stringify({ path: `${id}.ts` }), + }, + ], +}); +const result = (id: string): AgentTranscriptItem => ({ + kind: "toolResult", + toolId: id, + name: "read", + isError: false, + outputPreview: `out-${id}`, +}); + +/** Calls immediately followed by their results. */ +function sequential(count: number) { + const items: AgentTranscriptItem[] = []; + for (let index = 0; items.length < count; index++) { + items.push(ask(index), say(index), call(`t${index}`), result(`t${index}`)); + } + return items.slice(0, count); +} + +/** A fan of parallel calls, then all of their results. */ +function separated(count: number, fan: number) { + const items: AgentTranscriptItem[] = []; + let base = 0; + while (items.length < count) { + const ids: string[] = []; + for (let index = 0; index < fan && items.length < count; index++) { + const id = `p${base + index}`; + ids.push(id); + items.push(call(id)); + } + for (const id of ids) { + if (items.length >= count) break; + items.push(result(id)); + } + base += fan; + } + return items.slice(0, count); +} + +/** Count numeric index reads: how much of the history each frame touches. */ +function instrument(items: AgentTranscriptItem[]) { + let visits = 0; + const proxy = new Proxy(items, { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) visits++; + return Reflect.get(target, property, receiver); + }, + }); + return { + items: proxy as ReadonlyArray, + visits: () => visits, + reset: () => { + visits = 0; + }, + }; +} + +for (const shape of ["sequential", "separated"] as const) { + for (const size of SIZES) { + for (const viewport of VIEWPORTS) { + const source = + shape === "sequential" + ? sequential(size) + : separated(size, Math.min(FAN, Math.max(1, size >> 1))); + const probe = instrument(source); + // Production documents carry the pairing index built by their producer. + const document: AgentTranscriptDocument = { + items: probe.items, + pairing: buildPairingIndex(source), + }; + const renderer = new AgentTranscriptRenderer(); + + /** One repaint: returns the rows the viewport would show. */ + const repaint = () => { + if (MODE === "full") { + // The pre-change path: render everything, then clip. + const all = renderer.render(document, WIDTH, theme, { now: 0 }); + const top = Math.max(0, all.length - viewport); + return { rowCount: all.length, rows: all.slice(top, top + viewport) }; + } + const frame = renderer.beginFrame(document, WIDTH, theme, { now: 0 }); + const top = Math.max(0, frame.rowCount - viewport); + return { rowCount: frame.rowCount, rows: frame.rows(top, viewport) }; + }; + + // Cold: the first frame measures the history once. + const coldStarted = performance.now(); + const cold = repaint(); + const coldMs = performance.now() - coldStarted; + const coldVisits = probe.visits(); + + // Hot: repaint the same unchanged transcript at the bottom. + probe.reset(); + const hotStarted = performance.now(); + for (let index = 0; index < REPAINTS; index++) repaint(); + const hotMs = performance.now() - hotStarted; + + console.log( + JSON.stringify({ + mode: MODE, + shape, + items: size, + viewport, + rowCount: cold.rowCount, + renderedRows: cold.rows.length, + coldVisits, + coldMs: Number(coldMs.toFixed(2)), + repaints: REPAINTS, + hotVisitsPerRepaint: probe.visits() / REPAINTS, + hotMsPerRepaint: Number((hotMs / REPAINTS).toFixed(4)), + }), + ); + } + } +} diff --git a/extensions/shared/agent-session-page.ts b/extensions/shared/agent-session-page.ts index 7fe7e007..5158cebd 100644 --- a/extensions/shared/agent-session-page.ts +++ b/extensions/shared/agent-session-page.ts @@ -240,13 +240,15 @@ export class AgentSessionPage implements Component, Focusable { const errorRows = state.errorText ? 1 : 0; const bodyHeight = Math.max(1, height - chromeRows); const transcriptCapacity = Math.max(1, bodyHeight - errorRows); - const transcript = this.renderer.render(state.document, width, this.theme, { + // Resolve the row count first so the viewport can settle its anchor, then + // render only the rows this frame actually shows. + const frame = this.renderer.beginFrame(state.document, width, this.theme, { now, expanded: this.toolsExpanded, }); - this.rowCount = transcript.length; + this.rowCount = frame.rowCount; this.viewportSize = transcriptCapacity; - this.viewport.reconcile(transcript.length, transcriptCapacity); + this.viewport.reconcile(frame.rowCount, transcriptCapacity); const lines = [ this.rule( @@ -267,10 +269,7 @@ export class AgentSessionPage implements Component, Focusable { ), ); } - const visible = transcript.slice( - this.viewport.scrollTop, - this.viewport.scrollTop + transcriptCapacity, - ); + const visible = frame.rows(this.viewport.scrollTop, transcriptCapacity); if (visible.length === 0) { body.push(this.theme.fg("dim", state.emptyText ?? "waiting for output…")); } else { @@ -287,7 +286,7 @@ export class AgentSessionPage implements Component, Focusable { ? "" : this.theme.fg( "dim", - `↓ ${this.viewport.linesBelow(transcript.length, transcriptCapacity)}`, + `↓ ${this.viewport.linesBelow(frame.rowCount, transcriptCapacity)}`, ), ), ); diff --git a/extensions/shared/agent-tool-renderer.ts b/extensions/shared/agent-tool-renderer.ts index 9b43e22c..4c346569 100644 --- a/extensions/shared/agent-tool-renderer.ts +++ b/extensions/shared/agent-tool-renderer.ts @@ -20,6 +20,14 @@ export interface AgentToolRenderer { request: AgentToolRenderRequest, width: number, ): string[] | undefined; + /** + * Monotonic revision of one tool's native output, and of the ledger as a + * whole. Row and height caches key on these instead of re-rendering settled + * history every frame. A renderer that cannot report them is treated as + * changing on every frame: correct, but uncached. + */ + revision?(toolId: string): number; + generation?(): number; invalidate?(): void; } @@ -37,6 +45,8 @@ interface ToolExecutionRecord { component?: ToolExecutionComponent; componentCwd?: string; componentExpanded?: boolean; + /** Ledger clock value of the last mutation that can change rendered output. */ + revision: number; } const inertTui = { @@ -89,6 +99,8 @@ function normalizeResult(value: unknown, isError: boolean): ToolResult { */ export class AgentToolRenderLedger implements AgentToolRenderer { private executions = new Map(); + /** One clock for all ids: a bump is both a per-tool and a ledger-wide fact. */ + private clock = 0; start( toolId: string, @@ -103,18 +115,26 @@ export class AgentToolRenderLedger implements AgentToolRenderer { current.component = undefined; current.componentCwd = undefined; current.componentExpanded = undefined; + current.revision = ++this.clock; } current.name = name; if (current.args !== args) { current.args = args; current.component?.updateArgs(args); + current.revision = ++this.clock; } const wasStarted = current.executionStarted; const wereArgsComplete = current.argsComplete; current.executionStarted = true; current.argsComplete = true; - if (!wasStarted) current.component?.markExecutionStarted(); - if (!wereArgsComplete) current.component?.setArgsComplete(); + if (!wasStarted) { + current.component?.markExecutionStarted(); + current.revision = ++this.clock; + } + if (!wereArgsComplete) { + current.component?.setArgsComplete(); + current.revision = ++this.clock; + } return; } this.executions.set(toolId, { @@ -124,6 +144,7 @@ export class AgentToolRenderLedger implements AgentToolRenderer { executionStarted: true, argsComplete: true, isPartial: true, + revision: ++this.clock, }); } @@ -138,18 +159,21 @@ export class AgentToolRenderLedger implements AgentToolRenderer { result: normalizeResult(result, false), resultSource: result, isPartial: true, + revision: ++this.clock, }); return; } if (current.args !== args) { current.args = args; current.component?.updateArgs(args); + current.revision = ++this.clock; } if (current.resultSource === result && current.isPartial) return; current.result = normalizeResult(result, false); current.resultSource = result; current.isPartial = true; current.component?.updateResult(current.result, true); + current.revision = ++this.clock; } end(toolId: string, name: string, result: unknown, isError: boolean) { @@ -163,6 +187,7 @@ export class AgentToolRenderLedger implements AgentToolRenderer { result: normalizeResult(result, isError), resultSource: result, isPartial: false, + revision: ++this.clock, }); return; } @@ -177,6 +202,19 @@ export class AgentToolRenderLedger implements AgentToolRenderer { current.resultSource = result; current.isPartial = false; current.component?.updateResult(current.result, false); + current.revision = ++this.clock; + } + + /** + * Zero means "this ledger has no native output for the id", which is itself a + * stable fact: renderTool then falls back to the bounded preview row. + */ + revision(toolId: string) { + return this.executions.get(toolId)?.revision ?? 0; + } + + generation() { + return this.clock; } renderTool(request: AgentToolRenderRequest, width: number) { @@ -213,6 +251,7 @@ export class AgentToolRenderLedger implements AgentToolRenderer { invalidate() { for (const execution of this.executions.values()) { execution.component?.invalidate(); + execution.revision = ++this.clock; } } } diff --git a/extensions/shared/agent-transcript.ts b/extensions/shared/agent-transcript.ts index 5c6dccba..a8cc4195 100644 --- a/extensions/shared/agent-transcript.ts +++ b/extensions/shared/agent-transcript.ts @@ -399,71 +399,613 @@ function findResult( ) { const indices = pairing.resultsById.get(toolId); if (!indices) return undefined; - // Ascending by construction, so the first entry past the call is the match. - for (const index of indices) { - if (index <= callIndex) continue; - const candidate = transcript[index]; - return candidate?.kind === "toolResult" ? candidate : undefined; + // Ascending by construction: binary search the first result past the call, so + // a wide fan of parallel calls whose results all land later cannot degrade + // into a quadratic pairing scan. + let low = 0; + let high = indices.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (indices[middle]! <= callIndex) low = middle + 1; + else high = middle; } - return undefined; + const index = indices[low]; + if (index === undefined) return undefined; + const candidate = transcript[index]; + return candidate?.kind === "toolResult" ? candidate : undefined; +} + +/** Rows pre-rendered around the window so a scroll step lands on warm rows. */ +const DEFAULT_OVERSCAN_ROWS = 6; + +/** A resolved frame: the row total, plus rows on demand by absolute position. */ +export interface AgentTranscriptFrame { + /** Total rows including the live tail. Drives the viewport's scroll math. */ + readonly rowCount: number; + /** Rows [top, top + height), rendering only the items those rows need. */ + rows(top: number, height: number, overscan?: number): string[]; +} + +/** Everything besides item identity that can change an item's rows. */ +interface RenderContext { + readonly width: number; + readonly now: number; + readonly expanded: boolean; + readonly liveIds: ReadonlySet; + readonly liveKey: string; + readonly pairing: PairingIndex; + /** Discriminates cached rows across cwd, expansion, and native tool output. */ + readonly keyPrefix: string; +} + +/** + * Row layout for one items array under one RenderContext. Heights change only + * when the items array, the live-tool set, the theme generation, the key + * prefix, or a referenced tool's native output changes, so a hot repaint + * validates this in O(1) instead of re-deriving every preceding row. + */ +interface TranscriptLayout { + readonly keyPrefix: string; + readonly liveKey: string; + readonly generation: number; + /** + * The pairing index this layout was measured against. Producers rebuild it on + * every transcript mutation, so identity here is an O(1) content check. + */ + readonly pairing: PairingIndex; + length: number; + last: AgentTranscriptItem | undefined; + /** offsets[i] is the first row of item i; offsets[length] is the row total. */ + offsets: number[]; + heights: number[]; + /** The exact items these heights were measured from. */ + measured: Array; + /** Indices of items whose rows come from a tool, with their revision token. */ + toolItems: number[]; + toolTokens: string[]; + /** Ledger clock at build time; native output moves without items moving. */ + toolGeneration: number; +} + +/** + * Resolve everything besides item identity that can change an item's rows. + * cwd and expansion both change tool rows, so they belong in the cache key; + * omitting them would serve a row rendered for a different child or view. + */ +function renderContext( + document: AgentTranscriptDocument, + width: number, + options?: { readonly now?: number; readonly expanded?: boolean }, +): RenderContext { + const liveIds = new Set((document.liveTools ?? []).map((t) => t.toolId)); + const expanded = options?.expanded === true; + return { + width, + now: options?.now ?? Date.now(), + expanded, + liveIds, + liveKey: [...liveIds].sort().join(","), + pairing: document.pairing ?? buildPairingIndex(document.items), + keyPrefix: `${width}|${expanded ? "x" : "c"}|${document.cwd ?? ""}`, + }; +} + +/** First item whose row range contains `row`. */ +function itemAtRow(offsets: ReadonlyArray, row: number) { + let low = 0; + let high = offsets.length - 1; + while (low < high) { + const middle = (low + high) >>> 1; + if (offsets[middle + 1]! <= row) low = middle + 1; + else high = middle; + } + return low; +} + +/** + * Native tool output changes without the transcript item changing, so cached + * rows and heights carry the renderer's revision for every tool id they show. + * A renderer that cannot report revisions yields no suffix and stays uncached. + */ +function toolRevisionToken( + item: AgentTranscriptItem, + toolRenderer?: AgentToolRenderer, +) { + if (!toolRenderer?.revision) return ""; + if (item.kind === "toolResult") { + return `#${toolRenderer.revision(item.toolId)}`; + } + if (item.kind !== "assistant") return ""; + let token = ""; + for (const part of item.parts) { + if (part.type !== "toolCall") continue; + token += `#${toolRenderer.revision(part.toolId)}`; + } + return token; } /** - * Caches finalized transcript items by identity and width. Live state remains - * uncached because it changes on every stream tick; callers clear this cache - * from their component's invalidate() when Pi changes theme. + * Caches finalized transcript items by identity, width, and render context. + * Live state remains uncached because it changes on every stream tick; callers + * clear this cache from their component's invalidate() when Pi changes theme. */ export class AgentTranscriptRenderer { private itemCache = new WeakMap>(); + private heightCache = new WeakMap>(); + private layoutCache = new WeakMap< + ReadonlyArray, + TranscriptLayout + >(); private toolRenderers = new Set(); + /** Bumped by invalidate() so cached rows and heights cannot outlive a theme. */ + private generation = 0; - render( + /** + * Resolve the row total first so the viewport can settle its anchor, then + * render only the rows it asks for. Callers that need the whole transcript + * use render(), which is this with a full-height window. + */ + beginFrame( document: AgentTranscriptDocument, width: number, theme: Theme, options?: { readonly now?: number; readonly expanded?: boolean }, - ) { - const out: string[] = []; - const now = options?.now ?? Date.now(); - const expanded = options?.expanded === true; - const liveTools = document.liveTools ?? []; + ): AgentTranscriptFrame { if (document.toolRenderer) this.toolRenderers.add(document.toolRenderer); - const liveIds = new Set(liveTools.map((tool) => tool.toolId)); - const pairing = document.pairing ?? buildPairingIndex(document.items); + const context = renderContext(document, width, options); + const layout = this.layout(document, theme, context); + // The live tail is re-rendered every frame by definition: it is the part of + // the transcript that changes on each stream tick. + const tail = this.renderTail(document, theme, context); + const itemRows = () => layout.offsets[layout.length] ?? 0; + const rowCount = itemRows() + tail.length; - for (let index = 0; index < document.items.length; index++) { - const item = document.items[index]; - const context = itemContext(document.items, index, liveIds, pairing); - const key = `${width}|${context.token}`; - const cacheable = !document.toolRenderer || !itemHasTool(item); - const cached = cacheable ? this.itemCache.get(item)?.get(key) : undefined; - const lines = - cached ?? - renderTranscriptItem( + return { + rowCount, + rows: (top: number, height: number, overscan?: number) => { + const from = Math.max(0, top); + const to = Math.min(rowCount, from + Math.max(0, height)); + if (to <= from) return []; + + // A visible item that no longer matches what was measured means the + // offsets this slice was cut against are wrong. Repair and retry within + // the same frame rather than emitting rows the operator would see move. + let out = this.collectRows( + document, + layout, theme, - item, - width, context, - now, - document.cwd, - document.toolRenderer, - expanded, + from, + Math.min(to, itemRows()), ); - if (!cached && cacheable) { - const widths = this.itemCache.get(item) ?? new Map(); - if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) { - const oldestWidth = widths.keys().next().value; - if (oldestWidth !== undefined) widths.delete(oldestWidth); + if (out === undefined) { + this.resum(layout); + out = + this.collectRows( + document, + layout, + theme, + context, + from, + Math.min(to, itemRows()), + ) ?? []; + } + + const tailStart = itemRows(); + for ( + let row = Math.max(0, from - tailStart); + row < to - tailStart; + row++ + ) { + const line = tail[row]; + if (line !== undefined) out.push(line); } - widths.set(key, lines); - this.itemCache.set(item, widths); + + this.warmOverscan( + document, + layout, + theme, + context, + from, + to, + overscan ?? DEFAULT_OVERSCAN_ROWS, + ); + return out; + }, + }; + } + + render( + document: AgentTranscriptDocument, + width: number, + theme: Theme, + options?: { readonly now?: number; readonly expanded?: boolean }, + ) { + const frame = this.beginFrame(document, width, theme, options); + return frame.rows(0, frame.rowCount, 0); + } + + /** + * Rows [from, to) of the items block, or undefined when a visible item no + * longer matches what was measured and the layout must be re-summed first. + * + * The two known ways a height can go stale are caught before the row total is + * published: in-place replacement by itemsUnmoved, and unrevisioned native + * output by remeasureUnrevisioned. No test reaches this check (verified by + * sentinel injection across every suite that renders a transcript), so it is + * a last-resort guard for a cache-key input nobody has enumerated yet. It is + * deliberately kept rather than deleted: the failure it prevents is a + * misaligned viewport, and repairing costs one extra pass over the window + * while removing it would make that misalignment permanent for the frame. + */ + private collectRows( + document: AgentTranscriptDocument, + layout: TranscriptLayout, + theme: Theme, + context: RenderContext, + from: number, + to: number, + ) { + const out: string[] = []; + if (to <= from) return out; + for ( + let index = itemAtRow(layout.offsets, from); + index < layout.length; + index++ + ) { + const start = layout.offsets[index] ?? 0; + if (start >= to) break; + const lines = this.itemLines(document, index, theme, context); + // Identity is the cause, height is the symptom; either one means these + // offsets no longer describe the document. + if ( + layout.measured[index] !== document.items[index] || + lines.length !== layout.heights[index] + ) { + layout.measured[index] = document.items[index]; + layout.heights[index] = lines.length; + return undefined; } - if (lines.length > 0) { - out.push(...lines); + const sliceFrom = Math.max(0, from - start); + const sliceTo = Math.min(lines.length, to - start); + for (let row = sliceFrom; row < sliceTo; row++) out.push(lines[row]!); + } + return out; + } + + /** Rebuild the prefix sums from recorded heights. No item is re-rendered. */ + private resum(layout: TranscriptLayout) { + for (let index = 0; index < layout.length; index++) { + layout.offsets[index + 1] = + (layout.offsets[index] ?? 0) + (layout.heights[index] ?? 0); + } + } + + /** + * Pre-render cacheable neighbours so a scroll step reuses rows instead of + * rendering them under the operator's keypress. Items drawn by a native + * renderer without revisions are skipped: they cannot be cached, so warming + * them would be pure overhead. + */ + private warmOverscan( + document: AgentTranscriptDocument, + layout: TranscriptLayout, + theme: Theme, + context: RenderContext, + from: number, + to: number, + overscan: number, + ) { + if (overscan <= 0 || layout.length === 0) return; + const start = Math.max(0, from - overscan); + const end = Math.min(layout.offsets[layout.length] ?? 0, to + overscan); + for ( + let index = itemAtRow(layout.offsets, start); + index < layout.length; + index++ + ) { + const itemStart = layout.offsets[index] ?? 0; + if (itemStart >= end) break; + if (itemStart >= from && itemStart < to) continue; + const item = document.items[index]; + if (!item) break; + if (!this.cacheable(document, item)) continue; + this.itemLines(document, index, theme, context); + } + } + + /** + * Without a revision the native output can change silently, so those items + * are re-rendered every frame exactly as they were before windowing. + */ + private cacheable( + document: AgentTranscriptDocument, + item: AgentTranscriptItem, + ) { + return ( + !document.toolRenderer || + !itemHasTool(item) || + document.toolRenderer.revision !== undefined + ); + } + + private itemKey( + document: AgentTranscriptDocument, + item: AgentTranscriptItem, + index: number, + context: RenderContext, + ) { + const itemContextValue = itemContext( + document.items, + index, + context.liveIds, + context.pairing, + ); + return { + context: itemContextValue, + key: `${context.keyPrefix}|${itemContextValue.token}${toolRevisionToken(item, document.toolRenderer)}`, + }; + } + + /** Rows for one item, reusing the identity+context cache where allowed. */ + private itemLines( + document: AgentTranscriptDocument, + index: number, + theme: Theme, + context: RenderContext, + ): string[] { + const item = document.items[index]; + if (!item) return []; + const { context: itemContextValue, key } = this.itemKey( + document, + item, + index, + context, + ); + const cacheable = this.cacheable(document, item); + const cached = cacheable ? this.itemCache.get(item)?.get(key) : undefined; + if (cached) return cached; + const lines = renderTranscriptItem( + theme, + item, + context.width, + itemContextValue, + context.now, + document.cwd, + document.toolRenderer, + context.expanded, + ); + if (cacheable) this.remember(this.itemCache, item, key, lines); + this.remember(this.heightCache, item, key, lines.length); + return lines; + } + + /** Bounded per-item cache: a child page is read at one or two widths. */ + private remember( + cache: WeakMap>, + item: AgentTranscriptItem, + key: string, + value: T, + ) { + const keyed = cache.get(item) ?? new Map(); + if (keyed.size >= MAX_CACHED_WIDTHS_PER_ITEM && !keyed.has(key)) { + const oldest = keyed.keys().next().value; + if (oldest !== undefined) keyed.delete(oldest); + } + keyed.set(key, value); + cache.set(item, keyed); + } + + /** + * Height of one item, measured once per identity+context and then reused. + * Measuring is a render, so this is only paid for genuinely new rows. + */ + private itemHeight( + document: AgentTranscriptDocument, + index: number, + theme: Theme, + context: RenderContext, + ) { + const item = document.items[index]; + if (!item) return 0; + const { key } = this.itemKey(document, item, index, context); + const cached = this.heightCache.get(item)?.get(key); + if (cached !== undefined) return cached; + return this.itemLines(document, index, theme, context).length; + } + + /** + * Row offsets for the items block. Appends extend the cached prefix sums, a + * native tool update re-measures only the items that reference it, and + * anything else (front trimming from compaction, width, expansion, or theme + * change) re-sums from cached heights without re-rendering settled rows. + */ + private layout( + document: AgentTranscriptDocument, + theme: Theme, + context: RenderContext, + ): TranscriptLayout { + const items = document.items; + const toolGeneration = document.toolRenderer?.generation?.() ?? 0; + const cached = this.layoutCache.get(items); + // A document that carries its own pairing index rebuilds it on mutation, so + // identity settles content in O(1). Without one, fall back to comparing the + // measured items, which is the same order of growth as building the index. + const trusted = document.pairing !== undefined; + const reusable = + cached !== undefined && + cached.keyPrefix === context.keyPrefix && + cached.liveKey === context.liveKey && + cached.generation === this.generation && + (trusted + ? cached.pairing === context.pairing + : this.itemsUnmoved(cached, items)); + + if (reusable) { + if ( + cached.length === items.length && + cached.last === items[items.length - 1] + ) { + this.settleToolRows(cached, document, theme, context, toolGeneration); + this.remeasureUnrevisioned(cached, document, theme, context); + return cached; + } + // Append-only growth keeps every preceding offset valid. + if ( + items.length > cached.length && + cached.last === items[cached.length - 1] + ) { + this.settleToolRows(cached, document, theme, context, toolGeneration); + for (let index = cached.length; index < items.length; index++) { + const item = items[index]; + if (!item) break; + const metrics = this.itemHeight(document, index, theme, context); + cached.heights[index] = metrics; + cached.measured[index] = item; + if (itemHasTool(item)) { + cached.toolItems.push(index); + cached.toolTokens.push( + toolRevisionToken(item, document.toolRenderer), + ); + } + } + cached.length = items.length; + cached.last = items[items.length - 1]; + this.remeasureUnrevisioned(cached, document, theme, context); + this.resum(cached); + return cached; + } + } + + const heights: number[] = []; + const measured: Array = []; + const offsets: number[] = [0]; + const toolItems: number[] = []; + const toolTokens: string[] = []; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + if (!item) break; + const height = this.itemHeight(document, index, theme, context); + heights.push(height); + measured.push(item); + offsets.push((offsets[index] ?? 0) + height); + if (itemHasTool(item)) { + toolItems.push(index); + toolTokens.push(toolRevisionToken(item, document.toolRenderer)); } } - while (out.length > 0 && out[out.length - 1] === "") out.pop(); + const layout: TranscriptLayout = { + keyPrefix: context.keyPrefix, + liveKey: context.liveKey, + generation: this.generation, + pairing: context.pairing, + length: items.length, + last: items[items.length - 1], + offsets, + heights, + measured, + toolItems, + toolTokens, + toolGeneration, + }; + this.layoutCache.set(items, layout); + return layout; + } + /** + * A renderer without revision() can change its native output with no cache + * key movement, so those heights cannot be trusted between frames. They are + * re-measured here, before the row total is published, rather than being + * discovered mid-slice once the total is already wrong. + * + * These items are also uncached, so this is the same per-frame work the + * pre-windowing renderer already did for them, restricted to tool items. + */ + private remeasureUnrevisioned( + layout: TranscriptLayout, + document: AgentTranscriptDocument, + theme: Theme, + context: RenderContext, + ) { + if (!document.toolRenderer || document.toolRenderer.revision) return; + let moved = false; + for (const index of layout.toolItems) { + if (index >= layout.length) continue; + const item = document.items[index]; + if (!item) continue; + // The recorded height is keyed on inputs that did not move, so it would + // just echo the stale value. Render to find the current height. + const lines = this.itemLines(document, index, theme, context); + if (lines.length === layout.heights[index]) continue; + layout.heights[index] = lines.length; + layout.measured[index] = item; + moved = true; + } + if (!moved) return; + this.resum(layout); + } + + /** + * Whether every already-measured slot still holds the item it was measured + * from. Endpoint checks catch appends and front trimming, but a same-length + * in-place replacement moves rows without moving either endpoint, and the row + * total is published before any slice is cut, so it has to be caught here. + * + * Only reached when the document omits a pairing index: producers that supply + * one rebuild it on every transcript mutation, which makes pairing identity an + * O(1) proxy for this walk. Callers without an index already pay O(n) to build + * one, so this walk adds no order of growth. + */ + private itemsUnmoved( + layout: TranscriptLayout, + items: ReadonlyArray, + ) { + const checked = Math.min(layout.length, items.length); + for (let index = 0; index < checked; index++) { + if (layout.measured[index] !== items[index]) return false; + } + return true; + } + + /** + * Re-measure only the items whose native tool output moved. Revision lookups + * are map reads, so a streaming tool costs its own rows plus prefix-sum + * arithmetic instead of a fresh pass over settled history. + */ + private settleToolRows( + layout: TranscriptLayout, + document: AgentTranscriptDocument, + theme: Theme, + context: RenderContext, + toolGeneration: number, + ) { + if (layout.toolGeneration === toolGeneration) return; + layout.toolGeneration = toolGeneration; + let moved = false; + for (let slot = 0; slot < layout.toolItems.length; slot++) { + const index = layout.toolItems[slot]; + if (index === undefined || index >= layout.length) continue; + const item = document.items[index]; + if (!item) continue; + const token = toolRevisionToken(item, document.toolRenderer); + if (token === layout.toolTokens[slot]) continue; + layout.toolTokens[slot] = token; + const metrics = this.itemHeight(document, index, theme, context); + layout.heights[index] = metrics; + layout.measured[index] = item; + moved = true; + } + if (!moved) return; + this.resum(layout); + } + + private renderTail( + document: AgentTranscriptDocument, + theme: Theme, + context: RenderContext, + ) { + const out: string[] = []; + const { width, now, expanded } = context; // Live streaming assistant buffers (cleared when the finalized message lands). if (document.liveAssistant) { const { thinking, text } = document.liveAssistant; @@ -476,7 +1018,7 @@ export class AgentTranscriptRenderer { // Live tool executions. The manager drops a live entry when its ToolEnd // lands, and the transcript's call line then takes over with the settled // glyph in the same column, so the block never reflows. - for (const tool of liveTools) { + for (const tool of document.liveTools ?? []) { const phase: ToolPhase = tool.done ? tool.isError ? "error" @@ -512,12 +1054,14 @@ export class AgentTranscriptRenderer { ).render(width), ); } - return out; } invalidate() { this.itemCache = new WeakMap(); + this.heightCache = new WeakMap(); + this.layoutCache = new WeakMap(); + this.generation++; for (const renderer of this.toolRenderers) renderer.invalidate?.(); this.toolRenderers.clear(); } diff --git a/tests/extensions/shared/agent-transcript-window.test.ts b/tests/extensions/shared/agent-transcript-window.test.ts new file mode 100644 index 00000000..6aca6fb7 --- /dev/null +++ b/tests/extensions/shared/agent-transcript-window.test.ts @@ -0,0 +1,515 @@ +import assert from "node:assert/strict"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { stripVTControlCharacters } from "node:util"; + +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { AgentToolRenderLedger } from "../../../extensions/shared/agent-tool-renderer.ts"; +import { + AgentTranscriptRenderer, + buildPairingIndex, +} from "../../../extensions/shared/agent-transcript.ts"; +import type { + AgentTranscriptDocument, + AgentTranscriptItem, +} from "../../../extensions/shared/agent-transcript.ts"; + +initTheme("dark", false); + +/** + * Viewport-first rendering for #185. + * + * `beginFrame().rows(top, height)` must return exactly the rows at those + * absolute positions while only rendering the window. These tests avoid + * comparing `rows()` against `render()` alone: both now share one path, so that + * comparison cannot detect a layout bug that shifts every row consistently. + * Instead each case pins an expected row count and expected row content. + * + * Mutation-checked (measured). Each of these fails at least one case here: + * - treating any cached layout as reusable (skipping the front-trim check); + * - ignoring native tool revisions in `settleToolRows`; + * - dropping the same-frame repair when a visible item changed identity; + * - starting the binary search in `findResult` at slot 0. + */ +const theme = new Proxy( + {}, + { + get: (_target, prop) => + prop === "fg" + ? (_color: string, text: string) => text + : (text: string) => text, + }, +) as never; + +const call = (toolId: string, name = "read"): AgentTranscriptItem => ({ + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId, + name, + argsPreview: JSON.stringify({ path: `${toolId}.ts` }), + } as never, + ], +}); + +const result = ( + toolId: string, + isError = false, + name = "read", +): AgentTranscriptItem => ({ + kind: "toolResult", + toolId, + name, + isError, + outputPreview: `out-${toolId}`, +}); + +const bash = (toolId: string, command: string): AgentTranscriptItem => ({ + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId, + name: "bash", + argsPreview: JSON.stringify({ command }), + } as never, + ], +}); + +const say = (text: string): AgentTranscriptItem => ({ + kind: "assistant", + parts: [{ type: "text", text }], +}); + +const ask = (text: string): AgentTranscriptItem => ({ kind: "user", text }); + +/** + * Strip colour and the Nerd Font tool icons so expectations read as structure. + * The icons live in the private-use area and carry no assertion value here. + */ +const plain = (rows: ReadonlyArray) => + rows.map((row) => + stripVTControlCharacters(row) + .replace(/[\uE000-\uF8FF]/g, "•") + .trimEnd(), + ); + +function frame( + document: AgentTranscriptDocument, + renderer = new AgentTranscriptRenderer(), + width = 60, +) { + return renderer.beginFrame(document, width, theme, { now: 0 }); +} + +/** Interleaved conversation with sequential call/result pairs. */ +function sequential(pairs: number) { + const items: AgentTranscriptItem[] = []; + for (let index = 0; index < pairs; index++) { + items.push(ask(`ask ${index}`)); + items.push(say(`step ${index}`)); + items.push(call(`t${index}`)); + items.push(result(`t${index}`)); + } + return items; +} + +/** All calls emitted before any result: the quadratic-pairing shape. */ +function separated(width: number) { + const items: AgentTranscriptItem[] = []; + for (let index = 0; index < width; index++) items.push(call(`p${index}`)); + for (let index = 0; index < width; index++) items.push(result(`p${index}`)); + return items; +} + +/** + * Every window must be the corresponding slice of one reference transcript + * assembled row by row from single-row windows. A single-row window shares no + * offset arithmetic with a wide one, so a broken layout shows up as a diff. + */ +function assertWindowsAgree( + document: AgentTranscriptDocument, + expectedRowCount: number, +) { + const rowByRow = frame(document); + assert.equal(rowByRow.rowCount, expectedRowCount, "row count"); + const reference: string[] = []; + for (let row = 0; row < expectedRowCount; row++) { + const single = rowByRow.rows(row, 1); + assert.equal(single.length, 1, `row ${row} must exist`); + reference.push(single[0]!); + } + + // One renderer across all windows, so a stale cache surfaces as a diff. + const renderer = new AgentTranscriptRenderer(); + for (const height of [2, 5, 13, expectedRowCount + 4]) { + for (let top = 0; top <= expectedRowCount; top++) { + const rows = frame(document, renderer).rows(top, height); + assert.deepEqual( + rows, + reference.slice(top, top + height), + `window ${top}+${height}`, + ); + } + } + return reference; +} + +test("a window is the transcript's rows at absolute positions", () => { + const items = [ask("hi"), say("## Result\n\nbody")]; + const rows = assertWindowsAgree({ items }, 7); + assert.deepEqual(plain(rows), ["", " hi", "", "", " Result", "", " body"]); + // A window past the end must stay empty rather than exposing untrimmed rows. + assert.deepEqual(frame({ items }).rows(7, 3), []); +}); + +test("a settled tool block keeps its leading separator row", () => { + // A settled tool block is ["", row]: the blank is a separator between the + // assistant text and the tool row, and both are addressable rows. + const items = [say("done"), call("z"), result("z")]; + const rows = assertWindowsAgree({ items }, 4); + assert.deepEqual(plain(rows), ["", " done", "", " • Read z.ts"]); +}); + +test("every window equals the row-by-row transcript for a long history", () => { + // 6 turns x (blank + user + blank + blank + assistant + blank + tool row). + assertWindowsAgree({ items: sequential(6) }, 42); +}); + +test("64-way separated calls and results keep window equivalence", () => { + const items = separated(64); + // Each of the 64 calls pairs with a result, so only the calls render, and + // each renders as a [blank, row] block. + const rows = assertWindowsAgree({ items }, 128); + const text = plain(rows).join("\n"); + assert.match(text, /• Read {5}p0\.ts/); + assert.doesNotMatch(text, /Reading/, "a paired call must not stay pending"); +}); + +test("a late result repaginates without leaving stale offsets", () => { + const items = [ask("go"), call("late")]; + const renderer = new AgentTranscriptRenderer(); + const pending = frame({ items }, renderer); + assert.match(plain(pending.rows(0, pending.rowCount)).join("\n"), /Reading/); + + // The manager appends in place, so the items array keeps its identity. + items.push(result("late")); + const settled = frame({ items }, renderer); + assert.equal(settled.rowCount, 5); + assert.deepEqual(plain(settled.rows(0, settled.rowCount)), [ + "", + " go", + "", + "", + " • Read late.ts", + ]); +}); + +test("legacy items with no matching call id keep their rows", () => { + // Workflow history predating call ids: orphan results must still render, and + // a result with no args preview falls back to the bare relative path. + const items = [ask("go"), result("orphan-1"), result("orphan-2")]; + const rows = assertWindowsAgree({ items }, 7); + assert.deepEqual(plain(rows), [ + "", + " go", + "", + "", + " • Read .", + "", + " • Read .", + ]); +}); + +test("front trimming from compaction shifts every window", () => { + const items = sequential(8); + const renderer = new AgentTranscriptRenderer(); + const before = frame({ items }, renderer); + assert.equal(before.rowCount, 56); + const tailBefore = before.rows(before.rowCount - 4, 4); + + // MAX_TRANSCRIPT_ITEMS trimming splices the front in place: the array + // identity survives while every cached offset shifts. Replace the trimmed + // items with the same COUNT of taller ones, so length alone cannot reveal + // the change and a layout that trusts its cache keeps the old offsets. + items.splice(0, 4, ask("replaced\n\nwith\n\nmore rows")); + items.splice(1, 0, say("a"), say("b"), say("c")); + const after = frame({ items }, renderer); + assert.equal(items.length, 32, "item count is unchanged by design"); + assert.equal(after.rowCount, 62, "taller replacement must repaginate"); + assert.deepEqual( + after.rows(after.rowCount - 4, 4), + tailBefore, + "surviving tail rows must be unchanged", + ); + assert.deepEqual(plain(after.rows(0, 3)), ["", " replaced", ""]); +}); + +test("a live call renders in the tail, not twice", () => { + const items = [call("live-1")]; + const live = frame({ + items, + liveTools: [ + { + toolId: "live-1", + name: "read", + argsPreview: JSON.stringify({ path: "live-1.ts" }), + }, + ], + }); + const rows = plain(live.rows(0, live.rowCount)); + assert.equal(live.rowCount, 2, "the live block replaces the call's rows"); + assert.equal( + rows.filter((row) => row.includes("live-1.ts")).length, + 1, + "a live call must not appear in both the items block and the tail", + ); + assert.match(rows.join("\n"), /Reading/); +}); + +test("streaming native tool output repaginates its own rows", () => { + const toolRenderer = new AgentToolRenderLedger(); + // bash output grows the native block, so a stale height shifts every row + // after it rather than only recolouring in place. + const items = [say("running"), bash("n1", "ls"), say("after the tool")]; + const document = { items, toolRenderer } satisfies AgentTranscriptDocument; + const renderer = new AgentTranscriptRenderer(); + + toolRenderer.start("n1", "bash", { command: "ls" }); + const pending = frame(document, renderer); + assert.equal(pending.rowCount, 8); + const pendingRows = pending.rows(0, pending.rowCount); + const trailing = plain(pendingRows).at(-1); + assert.equal(trailing, " after the tool"); + + toolRenderer.end( + "n1", + "bash", + { content: [{ type: "text", text: "a\nb\nc\nd\ne\nf" }] }, + false, + ); + const settled = frame(document, renderer); + assert.ok( + settled.rowCount > pending.rowCount, + `settled native output must add rows, got ${settled.rowCount}`, + ); + const settledRows = plain(settled.rows(0, settled.rowCount)); + assert.deepEqual( + settledRows.slice(0, 2), + ["", " running"], + "rows before the tool must not move", + ); + assert.equal( + settledRows.at(-1), + " after the tool", + "the item after the tool must still be the last row", + ); + // The grown output must be reachable by window, not just counted. + assert.match(settledRows.join("\n"), /\bf\b/); +}); + +test("queued and live tail rows are addressable by window", () => { + const items = [say("body")]; + const document = { + items, + liveAssistant: { text: "thinking out loud", thinking: "" }, + liveTools: [ + { toolId: "tail-1", name: "bash", argsPreview: '{"command":"ls"}' }, + ], + queued: [{ text: "next please", kind: "follow-up" as const }], + } satisfies AgentTranscriptDocument; + const rows = assertWindowsAgree(document, 8); + assert.deepEqual(plain(rows), [ + "", + " body", + "", + " thinking out loud", + "", + " ⠋ Running ls", + "", + " Follow-up: next please", + ]); +}); + +test("cwd participates in the cache key", () => { + // Absolute paths are relativized against cwd, so the same item renders + // differently per child. A key without cwd would serve one child's row to + // another. Reuse ONE renderer so a stale key would surface. + // + // Built with path.resolve so these are absolute on the host platform: + // a hardcoded "D:/..." is absolute on Windows but not on Linux, where + // relativization would then never happen and the test would pass vacuously. + const childCwd = resolve("child-cwd"); + const otherCwd = resolve("other-cwd"); + const items = [ + { + kind: "assistant" as const, + parts: [ + { + type: "toolCall", + toolId: "c1", + name: "read", + argsPreview: JSON.stringify({ + path: join(childCwd, "deep", "file.ts"), + }), + } as never, + ], + }, + ]; + const renderer = new AgentTranscriptRenderer(); + const inRepo = frame({ items, cwd: childCwd }, renderer); + const inRepoRows = plain(inRepo.rows(0, inRepo.rowCount)).join("\n"); + const outside = frame({ items, cwd: otherCwd }, renderer); + const outsideRows = plain(outside.rows(0, outside.rowCount)).join("\n"); + + assert.notEqual(inRepoRows, outsideRows, "cwd must change the rendered path"); + const cold = frame({ items, cwd: otherCwd }); + assert.equal( + outsideRows, + plain(cold.rows(0, cold.rowCount)).join("\n"), + "a warm cache must not serve another cwd's row", + ); +}); + +test("tool expansion participates in the cache key", () => { + // The native ledger reports revisions, so tool items are cacheable; the key + // must still separate collapsed from expanded evidence. + const toolRenderer = new AgentToolRenderLedger(); + toolRenderer.start("t1", "bash", { command: "ls" }); + toolRenderer.end( + "t1", + "bash", + { content: [{ type: "text", text: "a\nb\nc\nd\ne\nf" }] }, + false, + ); + const document = { + items: [bash("t1", "ls")], + toolRenderer, + } satisfies AgentTranscriptDocument; + const renderer = new AgentTranscriptRenderer(); + + const collapsed = renderer.beginFrame(document, 60, theme, { + now: 0, + expanded: false, + }); + const collapsedRows = plain(collapsed.rows(0, collapsed.rowCount)).join("\n"); + const expanded = renderer.beginFrame(document, 60, theme, { + now: 0, + expanded: true, + }); + const expandedRows = plain(expanded.rows(0, expanded.rowCount)).join("\n"); + + assert.match(collapsedRows, /earlier lines/, "collapsed elides output"); + assert.doesNotMatch(expandedRows, /earlier lines/, "expanded shows output"); + const cold = new AgentTranscriptRenderer().beginFrame(document, 60, theme, { + now: 0, + expanded: true, + }); + assert.equal( + expandedRows, + plain(cold.rows(0, cold.rowCount)).join("\n"), + "a warm collapsed cache must not survive into the expanded view", + ); +}); + +test("a same-length in-place replacement republishes the row total", () => { + // Endpoint checks catch appends and front trimming, but replacing a middle + // item keeps the length and both endpoints. rowCount is published before any + // slice is cut, so a layout that trusts its endpoints reports a stale total + // and the operator loses the rows past the replacement for one frame. + const items = [ask("a"), say("b"), say("c")]; + const renderer = new AgentTranscriptRenderer(); + const before = frame({ items }, renderer); + assert.equal(before.rowCount, 7); + before.rows(0, before.rowCount); + + items[1] = say("b1\n\nb2\n\nb3\n\nb4"); + const after = frame({ items }, renderer); + const cold = frame({ items }); + assert.equal( + after.rowCount, + cold.rowCount, + "the row total must reflect the replacement in the same frame", + ); + assert.deepEqual( + after.rows(0, after.rowCount), + cold.rows(0, cold.rowCount), + "every row must match a cold render of the replaced document", + ); + assert.equal(plain(after.rows(after.rowCount - 1, 1))[0], " c"); +}); + +test("a renderer without revisions republishes the row total every frame", () => { + // revision() is optional on AgentToolRenderer. Without it the native output + // can change with no cache-key movement at all, so those heights must be + // re-measured before the row total is published. + let tall = false; + const legacy = { + renderTool: () => (tall ? ["n1", "n2", "n3", "n4"] : ["n1"]), + }; + const items = [say("head"), call("t1"), say("tail")]; + const document = { + items, + toolRenderer: legacy, + } satisfies AgentTranscriptDocument; + const renderer = new AgentTranscriptRenderer(); + + const short = frame(document, renderer); + assert.equal(short.rowCount, 5); + assert.deepEqual(plain(short.rows(0, short.rowCount)).at(-1), " tail"); + + tall = true; + const grown = frame(document, renderer); + const cold = frame(document); + assert.equal( + grown.rowCount, + cold.rowCount, + "the row total must follow unrevisioned native output in the same frame", + ); + assert.deepEqual( + grown.rows(0, grown.rowCount), + cold.rows(0, cold.rowCount), + "every row must match a cold render", + ); + assert.deepEqual( + plain(grown.rows(0, grown.rowCount)).at(-1), + " tail", + "the item after the tool must not fall off the end", + ); +}); + +test("a rebuilt pairing index invalidates the cached layout", () => { + // Production documents carry a pairing index that their producer rebuilds on + // every transcript mutation, so layout reuse keys on its identity instead of + // walking the items. A mutation that keeps the length and both endpoints must + // still repaginate once the index is rebuilt. + const items = [ask("a"), say("b"), say("c")]; + const renderer = new AgentTranscriptRenderer(); + const before = frame({ items, pairing: buildPairingIndex(items) }, renderer); + assert.equal(before.rowCount, 7); + before.rows(0, before.rowCount); + + items[1] = say("b1\n\nb2\n\nb3\n\nb4"); + const document = { items, pairing: buildPairingIndex(items) }; + const after = frame(document, renderer); + const cold = frame(document); + assert.equal( + after.rowCount, + cold.rowCount, + "a rebuilt index must not reuse the previous layout", + ); + assert.deepEqual(after.rows(0, after.rowCount), cold.rows(0, cold.rowCount)); + assert.equal(plain(after.rows(after.rowCount - 1, 1))[0], " c"); +}); + +test("invalidate() drops cached rows and heights", () => { + const items = sequential(4); + const renderer = new AgentTranscriptRenderer(); + const before = frame({ items }, renderer); + const beforeRows = before.rows(0, before.rowCount); + renderer.invalidate(); + const after = frame({ items }, renderer); + assert.equal(after.rowCount, before.rowCount); + assert.deepEqual(after.rows(0, after.rowCount), beforeRows); +});