Files
oh-my-pi/packages/coding-agent/test/modes/components/transcript-container.test.ts
T
can1357 fd0dd40083 refactor(coding-agent): improved detection of reflowing markdown
- Replaced regex-based markdown detection with a stateful parser in `detectLiveReflowingMarkdown`.
- Correctly ignore table delimiters and mermaid markers when they appear inside fenced code blocks.
- Added tests to verify that code blocks containing markdown-like syntax do not prevent commit stability.
2026-06-19 16:11:40 +02:00

731 lines
30 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
import { type Component, Text } from "@oh-my-pi/pi-tui";
// Models a transcript block that re-lays-out (tool preview collapsing, assistant
// message finalizing, late async result) after newer blocks were appended below
// it — the window must always reflect its current content.
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];
}
}
// A block that can declare itself still-mutating (a foreground tool awaiting its
// result). The container must keep such a block in the repaintable live region —
// even with finalized blocks below it — until it finalizes.
class StreamingBlock implements Component {
#lines: string[];
#finalized: boolean;
constructor(lines: string[], finalized = false) {
this.#lines = lines;
this.#finalized = finalized;
}
set(lines: string[]): void {
this.#lines = lines;
}
finalize(lines?: string[]): void {
if (lines) this.#lines = lines;
this.#finalized = true;
}
isTranscriptBlockFinalized(): boolean {
return this.#finalized;
}
invalidate(): void {}
renderCount = 0;
render(_width: number): string[] {
this.renderCount++;
return [...this.#lines];
}
}
// A still-live block whose render is provisional (a tool call's tail-window
// streaming preview): the result render replaces it wholesale, so its settled
// rows must never be offered for native-scrollback commit.
class ProvisionalStreamingBlock extends StreamingBlock {
isTranscriptBlockCommitStable(): boolean {
return false;
}
}
class CountingFinalizedBlock implements Component {
renderCount = 0;
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
set(lines: string[]): void {
this.#lines = lines;
}
invalidate(): void {}
render(_width: number): string[] {
this.renderCount++;
return [...this.#lines];
}
}
// A finalized block that can still mutate afterwards (an assistant message whose
// suppressed inline error is restored at the next turn, late tool-result images)
// and reports each mutation through the transcript block version protocol.
class VersionedFinalizedBlock implements Component {
renderCount = 0;
#lines: string[];
#version = 0;
constructor(lines: string[]) {
this.#lines = lines;
}
mutate(lines: string[]): void {
this.#lines = lines;
this.#version++;
}
isTranscriptBlockFinalized(): boolean {
return true;
}
getTranscriptBlockVersion(): number {
return this.#version;
}
invalidate(): void {}
render(_width: number): string[] {
this.renderCount++;
return [...this.#lines];
}
}
beforeAll(() => {
initTheme();
});
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
afterEach(() => {
resetSettingsForTest();
});
function makeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: "Continuing." }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
...overrides,
};
}
function plain(lines: readonly string[]): string {
return stripVTControlCharacters(lines.join("\n"));
}
describe("TranscriptContainer", () => {
it("always renders a block's current content, even after newer blocks append below it", () => {
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
container.addChild(a);
expect(container.render(40)).toEqual(["a1"]);
a.set(["a2"]);
expect(container.render(40)).toEqual(["a2"]);
const b = new MutableBlock(["b1"]);
container.addChild(b);
expect(container.render(40)).toEqual(["a2", "", "b1"]);
// A late re-layout of `a` (collapse, late async result, expand toggle) is
// reflected immediately: committed history keeps its old bytes, but the
// visible window always shows the present state.
a.set(["a3-collapsed"]);
expect(container.render(40)).toEqual(["a3-collapsed", "", "b1"]);
b.set(["b2"]);
expect(container.render(40)).toEqual(["a3-collapsed", "", "b2"]);
// Width changes recompute like any other frame.
a.set(["a-reflowed"]);
expect(container.render(80)).toEqual(["a-reflowed", "", "b2"]);
});
it("reports the live block start that gates native scrollback commits", () => {
const container = new TranscriptContainer();
const a = new MutableBlock(["a1", "a2"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
expect(container.render(40)).toEqual(["a1", "a2", "", "b1"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
b.set(["b1", "b2"]);
expect(container.render(40)).toEqual(["a1", "a2", "", "b1", "b2"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
});
it("keeps an unfinalized block below the seam when a finalized block is appended below it", () => {
const container = new TranscriptContainer();
// A foreground tool whose args are still streaming (no result yet).
const tool = new StreamingBlock(["write (streaming)"]);
container.addChild(tool);
expect(container.render(40)).toEqual(["write (streaming)"]);
// An out-of-band card (TTSR/todo reminder) is appended below the in-flight
// tool while it is still streaming. The tool's rows must not commit here.
const card = new MutableBlock(["rule card"]);
container.addChild(card);
expect(container.render(40)).toEqual(["write (streaming)", "", "rule card"]);
// The live region begins at the unfinalized tool, not the bottom card.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(0);
// The tool's result lands after the card is already below it.
tool.finalize(["✔ write: 4 lines"]);
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
// The seam moves past the now-finalized tool.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// Even after finalizing, a late re-layout still repaints in the window.
tool.set(["collapsed"]);
expect(container.render(40)).toEqual(["collapsed", "", "rule card"]);
});
it("keeps a streaming assistant live so final interrupted content can land after status rows below it", () => {
const container = new TranscriptContainer();
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({
content: [{ type: "text", text: "The config file write went through." }],
}),
);
container.addChild(assistant);
expect(assistant.isTranscriptBlockFinalized()).toBe(false);
expect(plain(container.render(80))).toContain("The config file write went through.");
// Status/notice rows can arrive below the still-streaming assistant before
// message_end finalizes the interrupted message. The assistant must stay repaintable.
container.addChild(new Text("Copied raw SSE stream", 0, 0));
expect(plain(container.render(80))).toContain("Copied raw SSE stream");
expect(container.getNativeScrollbackLiveRegionStart()).toBe(0);
assistant.updateContent(
makeAssistantMessage({
content: [{ type: "text", text: "The config file write went through despite the interruption." }],
stopReason: "aborted",
errorMessage: USER_INTERRUPT_LABEL,
}),
);
assistant.markTranscriptBlockFinalized();
const rendered = plain(container.render(80));
expect(rendered).toContain("The config file write went through despite the interruption.");
expect(rendered).not.toContain(USER_INTERRUPT_LABEL);
expect(rendered).toContain("Copied raw SSE stream");
expect(container.getNativeScrollbackLiveRegionStart()).not.toBe(0);
});
it("starts the live region at the earliest of several unfinalized blocks", () => {
const container = new TranscriptContainer();
const sealed = new StreamingBlock(["done"], true);
const pending = new StreamingBlock(["pending"]);
const card = new MutableBlock(["card"]);
container.addChild(sealed);
container.addChild(pending);
container.addChild(card);
expect(container.render(40)).toEqual(["done", "", "pending", "", "card"]);
// Live region starts at the pending block (offset 1), so the already-sealed
// leading block can commit while pending + card stay repaintable.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// The sealed block's late re-layout still renders current content; the
// seam is unaffected (it keys off finalization, not row diffs).
sealed.set(["done-collapsed"]);
expect(container.render(40)).toEqual(["done-collapsed", "", "pending", "", "card"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// The pending block updates freely while live.
pending.finalize(["pending-final"]);
expect(container.render(40)).toEqual(["done-collapsed", "", "pending-final", "", "card"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(4);
});
it("never offers a commit-unstable live block's settled rows for native scrollback", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
// A pending collapsed tool preview: byte-static while the tool executes
// (the spinner stops once args complete), but replaced wholesale by the
// result render — committing any of it would strand a stale call-box
// fragment in terminal history above the final block.
const preview = new ProvisionalStreamingBlock([
"┌ Edit: foo.ts",
"… (2 more hunks above)",
"-old-a",
"+new-a",
"-old-b",
"+new-b",
"└ (streaming)",
]);
container.addChild(preview);
// Far past STABLE_PREFIX_COMMIT_FRAMES: a durable block's settled head
// would have been promoted long ago.
for (let frame = 0; frame < 40; frame++) container.render(40);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
expect(container.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
// The result render re-anchors the block top-first; nothing of the stale
// preview was committed, so nothing can be duplicated. Finalizing makes
// the full body commit-safe like any settled block.
preview.finalize(["✔ Edit: foo.ts (+2/-2)", "-old-a", "+new-a", "context"]);
container.render(40);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
expect(container.getNativeScrollbackCommitSafeEnd()).toBe(6);
});
it("still promotes a durable live block's settled head after the stability window", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
// Default contract (no isTranscriptBlockCommitStable): settled leading
// rows are durable — a streaming assistant message, a top-anchored
// expanded tool stream — and promote once they sit visibly unchanged for
// the whole stability window, holding back only the volatile tail.
const streaming = new StreamingBlock(["head-0", "head-1", "head-2", "head-3", "head-4", "head-5", "tail"]);
container.addChild(streaming);
for (let frame = 0; frame < 40; frame++) container.render(40);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// blockStart 2 + (7 rows - TAIL_VOLATILITY_ROWS holdback of 4) = 5.
expect(container.getNativeScrollbackCommitSafeEnd()).toBe(5);
});
it("withholds the durable snapshot commit for a streaming mermaid reply, then promotes it on finalize", () => {
// A fenced mermaid diagram re-lays-out its whole body every frame as the
// reply streams. If its scrolled-off rows were committed to immutable
// native scrollback (the durable-snapshot path) the later re-layout would
// strand a stale diagram fragment in history that only Ctrl+L clears, so
// the live mermaid reply must advertise no durable snapshot end.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "```mermaid\nflowchart TD\n A-->B\n```" }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined();
// Finalized: the final layout is permanent and commits like any block.
assistant.markTranscriptBlockFinalized();
container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("withholds the durable snapshot commit for a streaming GFM table, then promotes it on finalize", () => {
// A streaming table re-aligns its columns as rows arrive; its scrolled-off
// rows must not commit an intermediate alignment to immutable scrollback.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "| Name | Score |\n| --- | --- |\n| a | 1 |" }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined();
assistant.markTranscriptBlockFinalized();
container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("still commits the durable snapshot of a streaming reply without a mermaid fence", () => {
// Guard the narrow scope: an ordinary streaming reply stays commit-stable,
// so its settled rows still reach native scrollback while it streams.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "A normal streamed answer with **bold** text." }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("does not re-render finalized rows already committed to native scrollback", () => {
const container = new TranscriptContainer();
const committed = new CountingFinalizedBlock(["committed"]);
const liveTail = new CountingFinalizedBlock(["tail"]);
container.addChild(committed);
container.addChild(liveTail);
expect(container.render(40)).toEqual(["committed", "", "tail"]);
expect(committed.renderCount).toBe(1);
expect(liveTail.renderCount).toBe(1);
container.setNativeScrollbackCommittedRows(1);
expect(container.render(40)).toEqual(["committed", "", "tail"]);
expect(committed.renderCount).toBe(1);
expect(liveTail.renderCount).toBe(2);
container.invalidate();
expect(container.render(40)).toEqual(["committed", "", "tail"]);
expect(committed.renderCount).toBe(2);
});
it("re-renders a committed finalized block when its version changes", () => {
const container = new TranscriptContainer();
const block = new VersionedFinalizedBlock(["original"]);
container.addChild(block);
expect(container.render(40)).toEqual(["original"]);
container.setNativeScrollbackCommittedRows(1);
expect(container.render(40)).toEqual(["original"]);
expect(block.renderCount).toBe(1);
// Post-finalize mutation (e.g. setErrorPinned(false) restoring the inline
// error) must surface even though the rows sit in committed scrollback —
// the render is what lets the TUI's committed-prefix audit re-anchor.
block.mutate(["original", "Error: boom"]);
expect(container.render(40)).toEqual(["original", "Error: boom"]);
expect(block.renderCount).toBe(2);
// Once observed, the bypass re-engages at the new version.
container.setNativeScrollbackCommittedRows(2);
expect(container.render(40)).toEqual(["original", "Error: boom"]);
expect(block.renderCount).toBe(2);
});
it("renders once after a block finalizes with rows already inside committed scrollback", () => {
const container = new TranscriptContainer();
const block = new StreamingBlock(["streaming"]);
const counting = new CountingFinalizedBlock(["tail"]);
container.addChild(block);
container.addChild(counting);
expect(container.render(40)).toEqual(["streaming", "", "tail"]);
// The block's rows settle into committed scrollback while it is still
// live (append-only commit path), then it finalizes with different bytes.
// The first post-transition frame must render — the previous segment was
// produced by a non-finalized render and is not trustworthy history.
container.setNativeScrollbackCommittedRows(3);
block.finalize(["streaming", "done"]);
const rendersBeforeTransition = block.renderCount;
expect(container.render(40)).toEqual(["streaming", "done", "", "tail"]);
expect(block.renderCount).toBe(rendersBeforeTransition + 1);
// The post-finalize render is now trustworthy history: once its rows are
// committed, the bypass replays it without calling render().
container.setNativeScrollbackCommittedRows(2);
const rendersAfterTransition = block.renderCount;
expect(container.render(40)).toEqual(["streaming", "done", "", "tail"]);
expect(block.renderCount).toBe(rendersAfterTransition);
});
it("reports a new assistant block version after post-finalize error unpinning", () => {
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
stopReason: "error",
errorMessage: "boom",
} as AssistantMessage;
const component = new AssistantMessageComponent(message);
expect(component.isTranscriptBlockFinalized()).toBe(true);
component.setErrorPinned(true);
const pinnedVersion = component.getTranscriptBlockVersion();
// The restore path at the next turn's agent_start must be observable by
// the transcript container's committed-scrollback bypass.
component.setErrorPinned(false);
expect(component.getTranscriptBlockVersion()).toBeGreaterThan(pinnedVersion);
});
});
describe("TranscriptContainer spacing", () => {
it("inserts exactly one blank line between consecutive blocks", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
container.addChild(new MutableBlock(["b"]));
container.addChild(new MutableBlock(["c"]));
// One separator between each block; none above the first.
expect(container.render(40)).toEqual(["a", "", "b", "", "c"]);
});
it("strips a block's plain-blank top/bottom padding", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
// Leading Spacer rows + a trailing paddingY row collapse to just the body.
container.addChild(new MutableBlock(["", " ", "body", ""]));
expect(container.render(40)).toEqual(["a", "", "body"]);
});
it("preserves background-colored padding rows (block-internal design)", () => {
const bgPad = "\x1b[48;2;0;0;0m \x1b[0m";
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
// The ANSI-bearing padding row is not "plain blank", so it survives stripping.
container.addChild(new MutableBlock([bgPad, "x", bgPad]));
expect(container.render(40)).toEqual(["a", "", bgPad, "x", bgPad]);
});
it("does not double the gap when a block carries its own trailing blank", () => {
const container = new TranscriptContainer();
// The trailing blank is stripped, so only the container's separator remains.
container.addChild(new MutableBlock(["note", ""]));
container.addChild(new MutableBlock(["b"]));
expect(container.render(40)).toEqual(["note", "", "b"]);
});
it("does not inject separators within a single block's rows", () => {
const container = new TranscriptContainer();
// An IRC card / file-mention list wrapped as one block stays tight inside.
container.addChild(new MutableBlock(["header", " body1", " body2"]));
expect(container.render(40)).toEqual(["header", " body1", " body2"]);
});
it("drops a blank-only block without leaving a stray gap", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
container.addChild(new MutableBlock(["", " "]));
container.addChild(new MutableBlock(["b"]));
expect(container.render(40)).toEqual(["a", "", "b"]);
});
it("counts the separator into the committed prefix below the live region (ED3-risk)", () => {
const container = new TranscriptContainer();
// A finalized block, then a still-live block below it.
container.addChild(new MutableBlock(["a1", "a2"]));
container.addChild(new StreamingBlock(["b"]));
// Separator sits at index 2; the live block's content begins at index 3.
expect(container.render(40)).toEqual(["a1", "a2", "", "b"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
});
});
// The consumable stable-prefix floor (RenderStablePrefix): render() returns
// the SAME persistent array every call, mutated in place, so the engine relies
// on this report — not reference equality — to know which leading rows
// survived. Reading consumes the report (re-bases the baseline to the current
// array state); between reads the floor accumulates the MIN across renders.
// `Text` children are ref-stable per (text, width), so an unchanged block's
// segment is reused and counts toward the floor.
describe("TranscriptContainer getRenderStablePrefixRows", () => {
it("reports 0 until a second render proves the rows, then the full length", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
// First render only pushed rows; nothing is proven stable yet.
expect(container.render(40)).toHaveLength(3); // alpha, separator, beta
expect(container.getRenderStablePrefixRows()).toBe(0);
// Unchanged finalized blocks: the second render reuses every row.
const second = container.render(40);
expect(container.getRenderStablePrefixRows()).toBe(second.length);
});
it("keeps the previous rows stable when a finalized block is appended", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
const before = container.render(40).length;
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
container.addChild(new Text("gamma", 0, 0));
const grown = container.render(40);
expect(grown.length).toBeGreaterThan(before);
// Only the appended block's separator + body are new rows.
expect(container.getRenderStablePrefixRows()).toBe(before);
});
it("lowers the report to a mutated early block's start row", () => {
const container = new TranscriptContainer();
const beta = new Text("beta", 0, 0);
container.addChild(new Text("alpha", 0, 0));
container.addChild(beta);
container.addChild(new Text("gamma", 0, 0));
expect(container.render(40)).toHaveLength(5);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
beta.setText("beta-edited");
container.render(40);
// alpha's single row survives; beta's segment (separator + body, start
// row 1) and everything below it was re-pushed.
expect(container.getRenderStablePrefixRows()).toBe(1);
});
it("accumulates the minimum across renders between reads", () => {
const container = new TranscriptContainer();
const gamma = new Text("gamma", 0, 0);
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.addChild(gamma);
expect(container.render(40)).toHaveLength(5);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
// First render after the edit drops the floor to gamma's segment start
// (row 3); a second, fully stable render must NOT lift it back — an
// out-of-band render between engine frames can only lower the report.
gamma.setText("gamma-edited");
container.render(40);
container.render(40);
expect(container.getRenderStablePrefixRows()).toBe(3);
});
it("reports 0 after a width change", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.render(40);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
// A width change re-renders every block; no row carries over.
container.render(80);
expect(container.getRenderStablePrefixRows()).toBe(0);
});
it("consumes on read: an immediate second read re-bases to the current rows", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.render(40);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
const reflowed = container.render(80);
expect(container.getRenderStablePrefixRows()).toBe(0);
// The read above re-based the baseline to the just-returned state, so
// without any render in between the full array now counts as stable.
expect(container.getRenderStablePrefixRows()).toBe(reflowed.length);
});
});
describe("TranscriptContainer isBlockInLiveRegion", () => {
it("opens at the first still-mutating block and includes everything below it", () => {
const container = new TranscriptContainer();
const above = new StreamingBlock(["above"], true);
const live = new StreamingBlock(["live"], false);
const below = new StreamingBlock(["below"], true);
container.addChild(above);
container.addChild(live);
container.addChild(below);
expect(container.isBlockInLiveRegion(above)).toBe(false);
expect(container.isBlockInLiveRegion(live)).toBe(true);
expect(container.isBlockInLiveRegion(below)).toBe(true);
});
it("anchors on the tail block when every block has finalized", () => {
const container = new TranscriptContainer();
const first = new StreamingBlock(["first"], true);
const tail = new StreamingBlock(["tail"], false);
container.addChild(first);
container.addChild(tail);
expect(container.isBlockInLiveRegion(tail)).toBe(true);
tail.finalize();
// All finalized: only the tail anchors the live region — a finalized
// block above it is commit-eligible history (a detached task block
// stops animating exactly on this transition).
expect(container.isBlockInLiveRegion(first)).toBe(false);
expect(container.isBlockInLiveRegion(tail)).toBe(true);
});
it("returns false for a component that is not a child", () => {
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["a"], true));
expect(container.isBlockInLiveRegion(new StreamingBlock(["x"], false))).toBe(false);
});
});
describe("TranscriptContainer renderViewportTail", () => {
const W = 40;
// Four two-row blocks. A full render joins them with one blank separator:
// b0a b0b "" b1a b1b "" b2a b2b "" b3a b3b → 11 rows.
function fourBlocks(): { container: TranscriptContainer; blocks: CountingFinalizedBlock[] } {
const blocks = [0, 1, 2, 3].map(i => new CountingFinalizedBlock([`b${i}a`, `b${i}b`]));
const container = new TranscriptContainer();
for (const b of blocks) container.addChild(b);
return { container, blocks };
}
it("returns exactly the bottom rows of a full render", () => {
const { container } = fourBlocks();
const full = [...container.render(W)];
expect(full).toEqual(["b0a", "b0b", "", "b1a", "b1b", "", "b2a", "b2b", "", "b3a", "b3b"]);
// A clean block boundary (the separator before b2) lands at the fold.
expect([...container.renderViewportTail(W, 5)]).toEqual(full.slice(full.length - 5));
// A mid-block fold still yields the exact bottom rows.
expect([...container.renderViewportTail(W, 4)]).toEqual(full.slice(full.length - 4));
expect([...container.renderViewportTail(W, 1)]).toEqual(["b3b"]);
});
it("renders only the blocks needed to fill the request", () => {
const { container, blocks } = fourBlocks();
container.render(W);
for (const b of blocks) b.renderCount = 0;
// Bottom 4 rows span b3 (whole) and b2 (its last row visible): blocks
// above the fold must never be rendered.
container.renderViewportTail(W, 4);
expect(blocks.map(b => b.renderCount)).toEqual([0, 0, 1, 1]);
});
it("returns the whole transcript top-aligned when it is shorter than the request", () => {
const { container } = fourBlocks();
const full = [...container.render(W)];
expect([...container.renderViewportTail(W, 100)]).toEqual(full);
});
it("never mutates persistent full-compose state", () => {
const { container } = fourBlocks();
const before = [...container.render(W)];
const liveBefore = container.getNativeScrollbackLiveRegionStart();
// Interleave tail renders at other widths and sizes.
container.renderViewportTail(80, 3);
container.renderViewportTail(20, 7);
container.renderViewportTail(W, 2);
const after = [...container.render(W)];
expect(after).toEqual(before);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(liveBefore);
});
it("handles empty and zero-row requests", () => {
const empty = new TranscriptContainer();
expect([...empty.renderViewportTail(W, 10)]).toEqual([]);
const { container } = fourBlocks();
expect([...container.renderViewportTail(W, 0)]).toEqual([]);
});
});