From e8d1ab005e4d0e8d6a7b1000a24e55f01ac406c2 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Jul 2026 12:13:34 +0200 Subject: [PATCH] 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. --- .../src/modes/interactive-mode.ts | 18 +- .../assistant-message-mermaid.test.ts | 13 +- .../components/transcript-container.test.ts | 37 ++++ .../test/streaming-output-scrollback.test.ts | 195 ++++++++++++++++++ 4 files changed, 254 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 9ad4862a8..ecccf049b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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(); diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 6a5c1b27d..33582e8bd 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -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```", diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 6fb342557..73f6bbaa8 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -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: diff --git a/packages/coding-agent/test/streaming-output-scrollback.test.ts b/packages/coding-agent/test/streaming-output-scrollback.test.ts index a292086b6..3305cb413 100644 --- a/packages/coding-agent/test/streaming-output-scrollback.test.ts +++ b/packages/coding-agent/test/streaming-output-scrollback.test.ts @@ -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 { + // 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(); + 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); });