From f975290eb1caadb94a8760dbf560ca8c60731874 Mon Sep 17 00:00:00 2001 From: liwen-aaa <2469865866@qq.com> Date: Tue, 1 Sep 2026 08:50:20 +0800 Subject: [PATCH 1/2] perf(tui): render child transcript by viewport window Repaint used to render every transcript row and clip afterwards, so a child page paid for its whole history on every tick. #232 stopped the pairing index from being rebuilt per frame; this stops the rows themselves from being rebuilt. - AgentTranscriptRenderer.beginFrame() resolves the row total, then renders only the rows a window asks for. render() stays the full-transcript contract and is now that same path with a full-height window, so WorkflowTranscript- Renderer and existing callers are unchanged. - A per-items layout caches item heights as prefix sums, so a window locates its first item by binary search instead of re-deriving preceding rows. Appends extend the sums; a rebuilt pairing index, a moved live-tool set, a theme generation bump, or a changed key prefix rebuilds them. - AgentToolRenderLedger reports monotonic per-tool and ledger-wide revisions, so streaming native output invalidates only the items that show that tool. revision() is optional: a renderer without it is re-measured every frame, which preserves today's behaviour for third-party renderers. - findResult() binary searches the ascending result indices, so a wide fan of parallel calls whose results all land later cannot degrade into a quadratic pairing scan. - cwd and the #292 tool expansion state join the cache key. Both change tool rows, and neither was previously discriminated; without them a warm cache could serve one child's or one view's row to another. Fixes two row-total bugs found while measuring, both cases where the total was published before the rows were known: a same-length in-place item replacement, and an unrevisioned renderer whose native output grew. Each dropped rows off the end of the viewport for one frame. This is a scaling fix, not a fix for visible lag today. Warm repaint cost used to grow with history length and is now flat, but at the 512-item transcript ceiling the old cost was already only 0.1 ms, well inside a frame budget. Cold open and post-invalidate repaint are deliberately unchanged: a scrollable view must know its total row count, and knowing that means measuring every item once. Measured (benchmarks/child-transcript-viewport.ts, viewport 40, 100 repaints, counting item-array index reads per repaint): items before after 32 102 89 128 414 89 512 1662 89 At 512 items the cost now follows viewport height (10/20/40/80 rows -> 34/53/89/164 reads) instead of history length. The 64-way separated call/result shape is flat too: 65/93/93 for 32/128/512 items. benchmarks/child-transcript-repaint-demo.ts prints the same comparison as wall time over realistic Markdown-heavy turns, separating cold open, warm repaint, and post-invalidate so the regimes this does not improve stay visible. Refs #185 --- benchmarks/child-transcript-repaint-demo.ts | 203 ++++++ benchmarks/child-transcript-viewport.ts | 199 ++++++ extensions/shared/agent-session-page.ts | 15 +- extensions/shared/agent-tool-renderer.ts | 43 +- extensions/shared/agent-transcript.ts | 632 ++++++++++++++++-- .../shared/agent-transcript-window.test.ts | 506 ++++++++++++++ 6 files changed, 1544 insertions(+), 54 deletions(-) create mode 100644 benchmarks/child-transcript-repaint-demo.ts create mode 100644 benchmarks/child-transcript-viewport.ts create mode 100644 tests/extensions/shared/agent-transcript-window.test.ts 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..65f82405 --- /dev/null +++ b/tests/extensions/shared/agent-transcript-window.test.ts @@ -0,0 +1,506 @@ +import assert from "node:assert/strict"; +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. + const items = [ + { + kind: "assistant" as const, + parts: [ + { + type: "toolCall", + toolId: "c1", + name: "read", + argsPreview: JSON.stringify({ path: "D:/works/openpi/deep/file.ts" }), + } as never, + ], + }, + ]; + const renderer = new AgentTranscriptRenderer(); + const inRepo = frame({ items, cwd: "D:/works/openpi" }, renderer); + const inRepoRows = plain(inRepo.rows(0, inRepo.rowCount)).join("\n"); + const outside = frame({ items, cwd: "C:/other" }, 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: "C:/other" }); + 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); +}); From 9691b006a9a96c786a9ed4cd6d5fd00ae8499e9f Mon Sep 17 00:00:00 2001 From: liwen-aaa <2469865866@qq.com> Date: Tue, 1 Sep 2026 09:30:22 +0800 Subject: [PATCH 2/2] fix(test): build cwd fixtures from the host platform's root The cwd cache-key test hardcoded "D:/works/openpi", which path.isAbsolute only accepts on Windows. On Linux CI the value was relative, displayPath returned it unchanged for both cwd values, and the assertion that cwd changes the rendered row failed. Build both cwd values and the tool argument with path.resolve/join so they are absolute on whatever platform runs the suite, and relativization actually happens. Verified under posix semantics: the old fixture renders identically for both cwds, the new one does not. --- .../shared/agent-transcript-window.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/extensions/shared/agent-transcript-window.test.ts b/tests/extensions/shared/agent-transcript-window.test.ts index 65f82405..6aca6fb7 100644 --- a/tests/extensions/shared/agent-transcript-window.test.ts +++ b/tests/extensions/shared/agent-transcript-window.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { join, resolve } from "node:path"; import test from "node:test"; import { stripVTControlCharacters } from "node:util"; @@ -335,6 +336,12 @@ 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, @@ -343,19 +350,21 @@ test("cwd participates in the cache key", () => { type: "toolCall", toolId: "c1", name: "read", - argsPreview: JSON.stringify({ path: "D:/works/openpi/deep/file.ts" }), + argsPreview: JSON.stringify({ + path: join(childCwd, "deep", "file.ts"), + }), } as never, ], }, ]; const renderer = new AgentTranscriptRenderer(); - const inRepo = frame({ items, cwd: "D:/works/openpi" }, renderer); + const inRepo = frame({ items, cwd: childCwd }, renderer); const inRepoRows = plain(inRepo.rows(0, inRepo.rowCount)).join("\n"); - const outside = frame({ items, cwd: "C:/other" }, renderer); + 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: "C:/other" }); + const cold = frame({ items, cwd: otherCwd }); assert.equal( outsideRows, plain(cold.rows(0, cold.rowCount)).join("\n"),