import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; 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 { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { setTerminalImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; const originalImageProtocol = TERMINAL.imageProtocol; const RENDER_WIDTH = 120; function erroredMessage(errorMessage: string): AssistantMessage { return { role: "assistant", content: [], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "error", errorMessage, timestamp: Date.now(), }; } function renderLines(message: AssistantMessage, hideThinkingBlock = false): string[] { const component = new AssistantMessageComponent(message, hideThinkingBlock); return Bun.stripANSI(component.render(RENDER_WIDTH).join("\n")) .split("\n") .map(line => line.trimEnd()); } beforeAll(async () => { await initTheme(false); }); beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true }); setTerminalImageProtocol(null); }); afterEach(() => { resetSettingsForTest(); setTerminalImageProtocol(originalImageProtocol); }); describe("AssistantMessageComponent error rendering", () => { // A proxy 502 returns its own HTML page as the body; AnthropicApiError folds // that whole document into `errorMessage`. The inline transcript render must // not faithfully reprint every line, or the scrollback fills with the HTML // page's blank lines (the reported "weird terminal state"). const longLine = "x".repeat(300); const body = Array.from({ length: 25 }, (_, i) => `marker-${i}
content
`).join("\n\n"); const proxy502 = `${longLine}\n\n${body}`; it("drops the blank-line flood from a multi-line HTML error body", () => { const lines = renderLines(erroredMessage(proxy502)); // The body interleaves 25 markers with blank lines (~50 source lines). If // blanks leaked through, the rendered block would be dozens of lines tall. const blankRun = lines.reduce( (acc, line) => { const run = line === "" ? acc.run + 1 : 0; return { run, max: Math.max(acc.max, run) }; }, { run: 0, max: 0 }, ); expect(blankRun.max).toBeLessThanOrEqual(1); expect(lines.length).toBeLessThan(15); }); it("clamps the line count of a runaway error body", () => { const lines = renderLines(erroredMessage(proxy502)); const markerLines = lines.filter(line => line.includes("marker-")); // MAX_TRANSCRIPT_ERROR_LINES is 8; the first preview line is the long line, // so at most 7 markers survive — and the late ones are gone entirely. expect(markerLines.length).toBeLessThanOrEqual(8); expect(lines.some(line => line.includes("marker-0"))).toBe(true); expect(lines.some(line => line.includes("marker-24"))).toBe(false); }); it("width-truncates an overlong error line", () => { const lines = renderLines(erroredMessage(proxy502)); const head = lines.find(line => line.trim().startsWith("Error:")); expect(head).toBeDefined(); // 300 'x' chars must not survive the render width; the line is truncated // with an ellipsis well under the 120-col terminal width. expect(head?.includes("…")).toBe(true); expect(head?.length).toBeLessThan(RENDER_WIDTH); }); it("renders a short single-line error unchanged", () => { const lines = renderLines(erroredMessage("overloaded_error: Overloaded")); expect(lines.some(line => line.includes("Error: overloaded_error: Overloaded"))).toBe(true); }); }); describe("AssistantMessageComponent hidden thinking rendering", () => { function thinkingMessage(): AssistantMessage { return { role: "assistant", content: [ { type: "thinking", thinking: "private reasoning" }, { type: "text", text: "Visible answer" }, ], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; } it("omits hidden thinking instead of rendering a placeholder", () => { const lines = renderLines(thinkingMessage(), true); expect(lines.some(line => line.includes("Thinking..."))).toBe(false); expect(lines.some(line => line.includes("private reasoning"))).toBe(false); expect(lines.some(line => line.includes("Visible answer"))).toBe(true); }); it("still renders thinking when it is not hidden", () => { const lines = renderLines(thinkingMessage()); expect(lines.some(line => line.includes("private reasoning"))).toBe(true); }); }); describe("AssistantMessageComponent streaming thinking pulse", () => { // The in-flight streaming partial always carries stopReason "stop" (proxy.ts // seeds it), so "still streaming" is keyed off the block not yet being // finalized — a live component is constructed with no message. function streaming(content: AssistantMessage["content"]): AssistantMessage { return { role: "assistant", content, api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; } function liveLines(message: AssistantMessage, hideThinkingBlock = true): string[] { const component = new AssistantMessageComponent(undefined, hideThinkingBlock); component.updateContent(message); const lines = Bun.stripANSI(component.render(RENDER_WIDTH).join("\n")) .split("\n") .map(line => line.trimEnd()); component.dispose(); return lines; } // First frame of the breathing ▁▃▄▃ pulse; deterministic right after updateContent. const PULSE = "▁"; it("shows the pulse in place of hidden reasoning while thinking streams", () => { const lines = liveLines(streaming([{ type: "thinking", thinking: "private reasoning" }])); expect(lines.some(line => line.includes(PULSE))).toBe(true); expect(lines.some(line => line.includes("private reasoning"))).toBe(false); }); it("drops the pulse once visible text starts streaming", () => { const lines = liveLines( streaming([ { type: "thinking", thinking: "private reasoning" }, { type: "text", text: "Visible answer" }, ]), ); expect(lines.some(line => line.includes(PULSE))).toBe(false); expect(lines.some(line => line.includes("Visible answer"))).toBe(true); }); it("does not show the pulse when thinking is visible", () => { const lines = liveLines(streaming([{ type: "thinking", thinking: "private reasoning" }]), false); expect(lines.some(line => line.includes(PULSE))).toBe(false); expect(lines.some(line => line.includes("private reasoning"))).toBe(true); }); it("does not show the pulse once a tool call streams", () => { const lines = liveLines( streaming([ { type: "thinking", thinking: "private reasoning" }, { type: "toolCall", id: "t1", name: "read", arguments: { path: "x" } }, ]), ); expect(lines.some(line => line.includes(PULSE))).toBe(false); }); it("removes the pulse when the block is finalized", () => { const component = new AssistantMessageComponent(undefined, true); component.updateContent(streaming([{ type: "thinking", thinking: "private reasoning" }])); expect(Bun.stripANSI(component.render(RENDER_WIDTH).join("\n")).includes(PULSE)).toBe(true); component.markTranscriptBlockFinalized(); const afterFinalize = Bun.stripANSI(component.render(RENDER_WIDTH).join("\n")); expect(afterFinalize.includes(PULSE)).toBe(false); expect(afterFinalize.includes("private reasoning")).toBe(false); component.dispose(); }); it("keeps the pulse across thinking deltas on a reused component, then yields to text", () => { // Mirrors live streaming: one component reused across updateContent calls // (the fast path early-returns on a stable shape, so the placeholder must // persist) until visible text arrives and replaces it. const component = new AssistantMessageComponent(undefined, true); const rendered = () => Bun.stripANSI(component.render(RENDER_WIDTH).join("\n")); component.updateContent(streaming([{ type: "thinking", thinking: "a" }])); expect(rendered().includes(PULSE)).toBe(true); component.updateContent(streaming([{ type: "thinking", thinking: "ab" }])); expect(rendered().includes(PULSE)).toBe(true); component.updateContent( streaming([ { type: "thinking", thinking: "abc" }, { type: "text", text: "Answer" }, ]), ); expect(rendered().includes(PULSE)).toBe(false); expect(rendered().includes("Answer")).toBe(true); component.dispose(); }); });