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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user