test(coding-agent): verified transcript component streaming and state management

- Added comprehensive unit tests for `TranscriptContainer` to verify uncommitted block tracking.
- Created integration tests ensuring `AssistantMessageComponent` correctly streams thinking and answer content into scrollback.
- Added tests verifying that expanded tool evaluation output records rows correctly without duplication after settling.
- Updated `AssistantMessageComponent` test suite to cover table streaming scenarios in the unsettled tail.
This commit is contained in:
can1357
2026-07-04 12:13:34 +02:00
parent a3e13cd5f2
commit e8d1ab005e
4 changed files with 254 additions and 9 deletions
@@ -930,6 +930,17 @@ export class InteractiveMode implements InteractiveModeContext {
pushTerminalTitle();
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
this.updateEditorBorderColor();
// Single side-effect point for title changes: every setSessionName caller
// (first-input titling, /rename, extension renames, plan seeding, replan
// refresh) gets the terminal title + accent updates from here. Registered
// before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
// all of which can reach setSessionName during init.
this.#eventBusUnsubscribers.push(
this.sessionManager.onSessionNameChanged(() => {
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
this.#handleSessionAccentInputsChanged();
}),
);
this.#syncEditorMaxHeight();
this.isInitialized = true;
this.ui.requestRender(true);
@@ -986,13 +997,6 @@ export class InteractiveMode implements InteractiveModeContext {
this.session.subscribe(event => {
void this.#handleGoalSessionEvent(event);
}),
this.sessionManager.onSessionNameChanged(() => {
// Single side-effect point for title changes: every setSessionName
// caller (first-input titling, /rename, plan seeding, replan refresh)
// gets the terminal title + accent updates from here.
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
this.#handleSessionAccentInputsChanged();
}),
onStatusLineSessionAccentChanged(() => {
this.#syncStatusLineSettings();
this.#handleSessionAccentInputsChanged();
@@ -114,11 +114,10 @@ describe("AssistantMessageComponent settled-row commit boundary", () => {
expect(component.getTranscriptBlockSettledRows()).toBeGreaterThan(0);
});
it("exposes zero settled rows for reflowing markdown while streaming", () => {
it("exposes zero settled rows for Mermaid while streaming", () => {
for (const markdown of [
"Here is the flow:\n\n```mermaid\nflowchart TD\n A-->B",
"```mermaid\nflowchart TD\n A-->B\n```",
"Results:\n\n| Name | Score |\n| --- | --- |\n| a | 1 |",
]) {
const component = renderStreamingMarkdown(markdown);
@@ -126,6 +125,16 @@ describe("AssistantMessageComponent settled-row commit boundary", () => {
}
});
it("keeps a streaming table in the unsettled tail", () => {
const component = renderStreamingMarkdown("Results:\n\n| Name | Score |\n| --- | --- |\n| a | 1 |");
const renderedRows = component.render(80);
const settledRows = component.getTranscriptBlockSettledRows();
expect(settledRows).toBeGreaterThan(0);
expect(settledRows).toBeLessThan(renderedRows.length);
expect(Bun.stripANSI(renderedRows.slice(settledRows).join("\n"))).toContain("Name");
});
it("exposes zero settled rows after a reflowing block finalizes", () => {
for (const markdown of [
"```mermaid\nflowchart TD\n A-->B\n```",
@@ -632,6 +632,43 @@ describe("TranscriptContainer isBlockInLiveRegion", () => {
});
});
describe("TranscriptContainer isBlockUncommitted", () => {
it("returns true for a block that has never rendered", () => {
const container = new TranscriptContainer();
const block = new MutableBlock(["not painted yet"]);
container.addChild(block);
expect(container.isBlockUncommitted(block)).toBe(true);
});
it("tracks whether committed rows have reached a rendered block", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
const block = new MutableBlock(["target-0", "target-1"]);
container.addChild(block);
expect(container.render(40)).toEqual(["history", "", "target-0", "target-1"]);
container.setNativeScrollbackCommittedRows(1);
expect(container.isBlockUncommitted(block)).toBe(true);
container.setNativeScrollbackCommittedRows(3);
expect(container.isBlockUncommitted(block)).toBe(false);
});
it("keeps empty-render blocks uncommitted after committed rows advance", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
const empty = new MutableBlock([]);
container.addChild(empty);
container.addChild(new MutableBlock(["tail"]));
expect(container.render(40)).toEqual(["history", "", "tail"]);
expect(container.isBlockUncommitted(empty)).toBe(true);
container.setNativeScrollbackCommittedRows(100);
expect(container.isBlockUncommitted(empty)).toBe(true);
});
});
describe("TranscriptContainer renderViewportTail", () => {
const W = 40;
// Four two-row blocks. A full render joins them with one blank separator:
@@ -1,4 +1,6 @@
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { theme as activeTheme, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
@@ -93,6 +95,70 @@ function stubStdoutRows(rows: number): void {
Object.defineProperty(process.stdout, "rows", { configurable: true, value: rows });
}
function makeAssistantMessage(content: AssistantMessage["content"], output = 0): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 0,
output,
cacheRead: 0,
cacheWrite: 0,
totalTokens: output,
reasoningTokens: output,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function streamingPrefixes(text: string, step: number): string[] {
const prefixes: string[] = [];
for (let end = Math.min(step, text.length); end < text.length; end += step) {
prefixes.push(text.slice(0, end));
}
prefixes.push(text);
return prefixes;
}
async function settleFrame(term: VirtualTerminal): Promise<void> {
// These integration tests use the production TUI scheduler rather than the
// drainable unit-test scheduler, so the frame timer must elapse for the real
// differential renderer to write to the Ghostty-backed terminal.
const nextTick = Promise.withResolvers<void>();
process.nextTick(nextTick.resolve);
await nextTick.promise;
await Bun.sleep(45);
await term.flush();
}
function plainScrollBuffer(term: VirtualTerminal): string[] {
return term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd());
}
function makeEvalProbeResult(output: string, status: "running" | "complete") {
return {
content: [{ type: "text" as const, text: output }],
details: {
language: "js" as const,
languages: ["js" as const],
cells: [
{
index: 0,
code: "for (const line of probeLines) console.log(line);",
language: "js" as const,
output,
status,
},
],
},
isError: false,
};
}
describe("streaming tool output never sprays duplicate scrollback banners", () => {
beforeAll(async () => {
await initTheme();
@@ -159,4 +225,133 @@ describe("streaming tool output never sprays duplicate scrollback banners", () =
expect(lines.length).toBeLessThanOrEqual(previewWindowRows() + 10);
expect(lines.map(line => Bun.stripANSI(line)).join("\n")).toContain("ctrl+o");
});
test("streams live assistant thinking and answer rows into native scrollback before finalize", async () => {
const rows = 8;
stubStdoutRows(rows);
const term = new VirtualTerminal(60, rows);
Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined });
const tui = new TUI(term);
const transcript = new TranscriptContainer();
const assistant = new AssistantMessageComponent(undefined, false);
transcript.addChild(assistant);
tui.addChild(transcript);
const thinking = Array.from(
{ length: 6 },
(_, i) => `Thinking paragraph ${i} streaming with plenty of words to wrap here.`,
).join("\n\n");
const text = Array.from(
{ length: 10 },
(_, i) => `Answer paragraph ${i} with enough content to occupy a full row or two.`,
).join("\n\n");
const fullContent: AssistantMessage["content"] = [
{ type: "thinking", thinking },
{ type: "text", text },
];
try {
tui.start();
await settleFrame(term);
for (const partialThinking of streamingPrefixes(thinking, 300)) {
assistant.updateContent(makeAssistantMessage([{ type: "thinking", thinking: partialThinking }]), {
transient: true,
});
tui.requestRender();
await settleFrame(term);
}
for (const partialText of streamingPrefixes(text, 300)) {
assistant.updateContent(
makeAssistantMessage([
{ type: "thinking", thinking },
{ type: "text", text: partialText },
]),
{ transient: true },
);
tui.requestRender();
await settleFrame(term);
}
const midStreamRows = plainScrollBuffer(term);
expect(midStreamRows.some(row => row.includes("Thinking paragraph 0"))).toBe(true);
expect(midStreamRows.some(row => row.includes("Answer paragraph 0 "))).toBe(true);
assistant.updateContent(makeAssistantMessage(fullContent), { transient: false });
assistant.markTranscriptBlockFinalized();
for (let i = 0; i < 2; i++) {
tui.requestRender();
await settleFrame(term);
}
const finalRows = plainScrollBuffer(term);
expect(finalRows.filter(row => row.includes("Thinking paragraph 0"))).toHaveLength(1);
expect(finalRows.filter(row => row.includes("Answer paragraph 0 "))).toHaveLength(1);
} finally {
assistant.dispose();
tui.stop();
await term.flush();
}
}, 30_000);
test("expanded live eval output records painted rows without spraying after settle", async () => {
const rows = 8;
stubStdoutRows(rows);
const term = new VirtualTerminal(60, rows);
const tui = new TUI(term);
const transcript = new TranscriptContainer();
const component = new ToolExecutionComponent(
"eval",
{ code: "probeLines.forEach(console.log)", language: "js" },
{},
undefined,
tui,
);
transcript.addChild(component);
tui.addChild(transcript);
const probeLines = Array.from({ length: 30 }, (_, i) => `probe-line-${i}`);
const output = probeLines.join("\n");
try {
tui.start();
await settleFrame(term);
component.updateResult(makeEvalProbeResult(output, "running"), true);
component.setExpanded(true);
for (let i = 0; i < 3; i++) {
tui.requestRender();
await settleFrame(term);
}
const midRunRows = plainScrollBuffer(term);
expect(midRunRows.some(row => row.includes("probe-line-0"))).toBe(true);
component.updateResult(makeEvalProbeResult(output, "complete"), false);
for (let i = 0; i < 2; i++) {
tui.requestRender();
await settleFrame(term);
}
const settledRows = plainScrollBuffer(term);
for (const line of probeLines) {
expect(settledRows.some(row => row.includes(line))).toBe(true);
}
const settledTape = settledRows.join("\n");
const settledLength = settledRows.length;
for (let i = 0; i < 2; i++) {
tui.requestRender();
await settleFrame(term);
}
const repeatedRows = plainScrollBuffer(term);
expect(repeatedRows).toHaveLength(settledLength);
expect(repeatedRows.join("\n")).toBe(settledTape);
} finally {
component.stopAnimation();
tui.stop();
await term.flush();
}
}, 30_000);
});