import { afterEach, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface" import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme" import type { StreamCommit } from "@/cli/cmd/run/types" type ClaimedCommit = { snapshot: { height: number getRealCharBytes(addLineBreaks?: boolean): Uint8Array destroy(): void } trailingNewline: boolean } type QueueRenderer = { externalOutputQueue: { claim(): ClaimedCommit[] } } const decoder = new TextDecoder() const active: TestRenderer[] = [] afterEach(() => { for (const renderer of active.splice(0)) { renderer.destroy() } }) function claim(renderer: TestRenderer) { return (renderer as unknown as QueueRenderer).externalOutputQueue.claim() } function renderCommit(commit: ClaimedCommit) { return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n") } function render(commits: ClaimedCommit[]) { return commits.map(renderCommit).join("") } function renderRows(commit: ClaimedCommit, width = 80) { const raw = decoder.decode(commit.snapshot.getRealCharBytes(true)) return Array.from({ length: commit.snapshot.height }, (_, index) => raw.slice(index * width, (index + 1) * width).trimEnd(), ) } function destroy(commits: ClaimedCommit[]) { for (const commit of commits) { commit.snapshot.destroy() } } async function setup(input: { width?: number wrote?: boolean } = {}) { const out = await createTestRenderer({ width: input.width ?? 80, screenMode: "split-footer", footerHeight: 6, externalOutputMode: "capture-stdout", consoleMode: "disabled", }) active.push(out.renderer) const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) treeSitterClient.setMockResult({ highlights: [] }) return { renderer: out.renderer, scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, { treeSitterClient, wrote: input.wrote ?? false, }), } } function assistant(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit { return { kind: "assistant", text, phase, source: "assistant", messageID: "msg-1", partID: "part-1", } } function user(text: string): StreamCommit { return { kind: "user", text, phase: "start", source: "system", } } function toolPart(tool: string, state: Record, id: string, messageID: string): ToolPart { return { id, sessionID: "session-1", messageID, type: "tool", callID: `call-${id}`, tool, state, } as ToolPart } function toolCommit(input: { tool: string phase: StreamCommit["phase"] toolState?: StreamCommit["toolState"] text?: string state?: Record id?: string messageID?: string }): StreamCommit { const id = input.id ?? `${input.tool}-1` const messageID = input.messageID ?? `msg-${input.tool}` return { kind: "tool", text: input.text ?? "", phase: input.phase, source: "tool", partID: id, messageID, tool: input.tool, ...(input.toolState ? { toolState: input.toolState } : {}), ...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}), } } test("finalizes markdown tables for streamed and coalesced input", async () => { const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |" for (const chunks of [[text], [...text]]) { const out = await setup() try { for (const chunk of chunks) { await out.scrollback.append(assistant(chunk)) } await out.scrollback.complete() const commits = claim(out.renderer) try { const output = render(commits) expect(output).toContain("Column 1") expect(output).toContain("Row 2") expect(output).toContain("Value 4") } finally { destroy(commits) } } finally { out.scrollback.destroy() } } }) test("holds markdown code blocks until final commit and keeps newline ownership", async () => { const out = await setup() try { await out.scrollback.append( assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'), ) const progress = claim(out.renderer) try { expect(progress).toHaveLength(1) expect(render(progress)).toContain("Markdown Sample") expect(render(progress)).toContain("Item 2") expect(render(progress)).not.toContain("console.log(message)") } finally { destroy(progress) } await out.scrollback.complete() const final = claim(out.renderer) try { expect(final).toHaveLength(1) expect(final[0]!.trailingNewline).toBe(false) expect(render(final)).toContain('const message = "Hello, markdown"') expect(render(final)).toContain("console.log(message)") } finally { destroy(final) } } finally { out.scrollback.destroy() } }) test("renders todo and question summaries without boilerplate footer copy", async () => { const cases = [ { title: "# Todos", include: [ "[✓] List files under `run/`", "[•] Count functions in each `run/` file", "[ ] Mark each tracking item complete", ], exclude: ["Updating", "todos completed"], start: toolCommit({ tool: "todowrite", phase: "start", toolState: "running", state: { status: "running", input: { todos: [ { status: "completed", content: "List files under `run/`" }, { status: "in_progress", content: "Count functions in each `run/` file" }, { status: "pending", content: "Mark each tracking item complete" }, ], }, time: { start: 1 }, }, }), final: toolCommit({ tool: "todowrite", phase: "final", toolState: "completed", state: { status: "completed", input: { todos: [ { status: "completed", content: "List files under `run/`" }, { status: "in_progress", content: "Count functions in each `run/` file" }, { status: "pending", content: "Mark each tracking item complete" }, ], }, metadata: {}, time: { start: 1, end: 4 }, }, }), }, { title: "# Questions", include: ["What should I work on in the codebase next?", "Bug fix"], exclude: ["Asked", "questions completed"], start: toolCommit({ tool: "question", phase: "start", toolState: "running", state: { status: "running", input: { questions: [ { question: "What should I work on in the codebase next?", header: "Next work", options: [{ label: "bug", description: "Bug fix" }], multiple: false, }, ], }, time: { start: 1 }, }, }), final: toolCommit({ tool: "question", phase: "final", toolState: "completed", state: { status: "completed", input: { questions: [ { question: "What should I work on in the codebase next?", header: "Next work", options: [{ label: "bug", description: "Bug fix" }], multiple: false, }, ], }, metadata: { answers: [["Bug fix"]], }, time: { start: 1, end: 2100 }, }, }), }, ] for (const item of cases) { const out = await setup() try { await out.scrollback.append(item.start) expect(claim(out.renderer)).toHaveLength(0) await out.scrollback.append(item.final) const commits = claim(out.renderer) try { expect(commits).toHaveLength(1) const rows = renderRows(commits[0]!) const output = rows.join("\n") expect(output).toContain(item.title) for (const line of item.include) { expect(output).toContain(line) } for (const line of item.exclude) { expect(output).not.toContain(line) } } finally { destroy(commits) } } finally { out.scrollback.destroy() } } }) test("inserts spacers for new visible groups", async () => { const prior = await setup({ wrote: true }) try { await prior.scrollback.append(user("use subagent to explore run.ts")) const commits = claim(prior.renderer) try { expect(commits).toHaveLength(2) expect(renderCommit(commits[0]!).trim()).toBe("") expect(renderCommit(commits[1]!).trim()).toBe("› use subagent to explore run.ts") } finally { destroy(commits) } } finally { prior.scrollback.destroy() } const grouped = await setup() try { await grouped.scrollback.append(assistant("hello")) await grouped.scrollback.complete() destroy(claim(grouped.renderer)) await grouped.scrollback.append( toolCommit({ tool: "glob", phase: "start", text: "running glob", toolState: "running", state: { status: "running", input: { pattern: "**/run.ts", }, time: { start: 1 }, }, }), ) const commits = claim(grouped.renderer) try { expect(commits).toHaveLength(2) expect(renderCommit(commits[0]!).trim()).toBe("") expect(renderCommit(commits[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"') } finally { destroy(commits) } } finally { grouped.scrollback.destroy() } }) test("coalesces same-line tool progress into one snapshot", async () => { const out = await setup() try { await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" })) await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" })) await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" })) const commits = claim(out.renderer) try { expect(commits).toHaveLength(1) expect(render(commits)).toContain("abcdef") } finally { destroy(commits) } } finally { out.scrollback.destroy() } }) test("renders structured write finals once as code blocks", async () => { const out = await setup() try { await out.scrollback.append( toolCommit({ tool: "write", phase: "start", toolState: "running", id: "tool-2", messageID: "msg-2", state: { status: "running", input: { filePath: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, time: { start: 1 }, }, }), ) expect(claim(out.renderer)).toHaveLength(0) await out.scrollback.append( toolCommit({ tool: "write", phase: "final", toolState: "completed", id: "tool-2", messageID: "msg-2", state: { status: "completed", input: { filePath: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, metadata: {}, time: { start: 1, end: 2 }, }, }), ) const commits = claim(out.renderer) try { expect(commits).toHaveLength(1) const output = render(commits[0] ? [commits[0]] : []) expect(output).toContain("# Wrote src/a.ts") expect(output).toMatch(/1\s+const x = 1/) expect(output).toMatch(/2\s+const y = 2/) } finally { destroy(commits) } } finally { out.scrollback.destroy() } }) test("renders promoted task markdown without a leading blank row", async () => { const out = await setup() try { await out.scrollback.append( toolCommit({ tool: "task", phase: "final", toolState: "completed", state: { status: "completed", input: { description: "Explore run.ts", subagent_type: "explore", }, output: [ "task_id: child-1 (for resuming to continue this task if needed)", "", "", "Location: `/tmp/run.ts`", "", "Summary:", "- Local interactive mode", "- Attach mode", "", ].join("\n"), metadata: { sessionId: "child-1", }, time: { start: 1, end: 2 }, }, }), ) const commits = claim(out.renderer) try { const output = render(commits) expect(output.startsWith("\n")).toBe(false) expect(output).toContain("Summary:") expect(output).toContain("Local interactive mode") } finally { destroy(commits) } } finally { out.scrollback.destroy() } })