Files
oh-my-pi/packages/coding-agent/test/modes/components/transcript-container.test.ts
T
can1357 cbdff129b6 feat(tui): enabled stable-prefix scrollback commits for streaming assistant output
- Added assistant-message completion state and stable-line count reporting.
- Added transcript stable-row aggregation and snapshot replay to keep unstable rows out of scrollback.
- Added TUI stable scrollback boundary APIs and stable-prefix commit path.
- Added regression tests proving unstable rows stay out of scrollback until stabilized.
2026-06-04 16:00:50 +02:00

129 lines
4.3 KiB
TypeScript

import { afterEach, describe, expect, it } from "bun:test";
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
import { TranscriptContainer } from "../../../src/modes/components/transcript-container";
// Models a transcript block that re-lays-out (tool preview collapsing, assistant
// message finalizing, late async result) after it has scrolled past the live
// region — the mutation that leaves a stale duplicate on ED3-risk terminals.
class MutableBlock implements Component {
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
set(lines: string[]): void {
this.#lines = lines;
}
invalidate(): void {}
render(_width: number): string[] {
return [...this.#lines];
}
}
class StableBlock extends MutableBlock {
#stableLineCount = 0;
setStableLineCount(count: number): void {
this.#stableLineCount = count;
}
getStableLineCount(): number {
return this.#stableLineCount;
}
}
const riskFlag = TERMINAL as unknown as { eagerEraseScrollbackRisk: boolean };
const original = riskFlag.eagerEraseScrollbackRisk;
afterEach(() => {
riskFlag.eagerEraseScrollbackRisk = original;
});
describe("TranscriptContainer", () => {
it("freezes a block at its last live render once a newer block is appended (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
container.addChild(a);
expect(container.render(40)).toEqual(["a1"]);
// While `a` is still the live (bottom-most) block its render tracks updates.
a.set(["a2"]);
expect(container.render(40)).toEqual(["a2"]);
// A newer block makes `a` non-live; it now replays its last live render.
const b = new MutableBlock(["b1"]);
container.addChild(b);
expect(container.render(40)).toEqual(["a2", "b1"]);
// A post-freeze mutation of `a` (its collapse/re-layout) is NOT reflected —
// the committed rows stay stable so no stale duplicate enters scrollback.
a.set(["a3-collapsed"]);
expect(container.render(40)).toEqual(["a2", "b1"]);
// The live block still updates freely.
b.set(["b2"]);
expect(container.render(40)).toEqual(["a2", "b2"]);
});
it("reports frozen blocks plus the live block's immutable prefix", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const frozen = new MutableBlock(["a1", "a2"]);
const live = new StableBlock(["b1", "b2", "b3"]);
live.setStableLineCount(1);
container.addChild(frozen);
container.addChild(live);
expect(container.render(40)).toEqual(["a1", "a2", "b1", "b2", "b3"]);
expect(container.getStableLineCount(40)).toBe(3);
frozen.set(["a-mutated"]);
live.setStableLineCount(3);
expect(container.render(40)).toEqual(["a1", "a2", "b1", "b2", "b3"]);
expect(container.getStableLineCount(40)).toBe(5);
});
it("thaw() reconciles frozen blocks to their current state", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
a.set(["a-final"]);
expect(container.render(40)).toEqual(["a1", "b1"]); // frozen
container.thaw();
expect(container.render(40)).toEqual(["a-final", "b1"]); // reconciled
});
it("recomputes a frozen block on a width change", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
a.set(["a-reflowed"]);
expect(container.render(40)).toEqual(["a1", "b1"]); // frozen at width 40
// A resize is an explicit rebuild that reconciles history, so recompute.
expect(container.render(80)).toEqual(["a-reflowed", "b1"]);
});
it("renders every block live on terminals that can rebuild history", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
// No freezing: a non-live block's mutation is reflected (the renderer can
// rebuild committed history on these terminals).
a.set(["a-updated"]);
expect(container.render(40)).toEqual(["a-updated", "b1"]);
});
});