/** @jsxImportSource react */ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { DynamicToolUIPart, UIMessage } from "ai"; import { MessageList } from "../src/components/chat/message-list"; import { MessageListProvider } from "../src/components/chat/message-list-provider"; function bashPart(id: string): DynamicToolUIPart { return { type: "dynamic-tool", toolName: "bash", toolCallId: id, state: "output-available", input: { command: `echo ${id}`, description: "run" }, output: "ok", }; } function editPart(id: string, filePath: string): DynamicToolUIPart { return { type: "dynamic-tool", toolName: "edit", toolCallId: id, state: "output-available", input: { filePath, oldString: "a", newString: "b" }, output: "ok", }; } /** * Other test files stub `globalThis.window` and can leak it into a shared * bun test worker. Static SSR rendering must not see a partial window stub * (components probe it for addEventListener), so hide it for the render. */ function withoutWindow(run: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); if (descriptor?.configurable) { Reflect.deleteProperty(globalThis, "window"); } try { return run(); } finally { if (descriptor?.configurable) { Object.defineProperty(globalThis, "window", descriptor); } } } function renderList(messages: UIMessage[]) { return withoutWindow(() => renderToStaticMarkup( {}} setPrompt={() => {}} onRevertToUserMessage={() => {}} onForkAtMessage={() => {}} onEditUserMessage={() => {}} onMcpReconnect={() => Promise.reject(new Error("unused"))} onMcpReopenAuthorization={() => Promise.resolve()} onMcpRetry={() => {}} > )); } const userMessage: UIMessage = { id: "user-1", role: "user", metadata: { opencode: { created: 1_000 } }, parts: [{ type: "text", text: "do the thing", state: "done" }], }; describe("finished turn step fold (single OpenCode message per turn)", () => { test("folds interleaved steps into a 'Worked for …' line and keeps the answer", () => { const assistant: UIMessage = { id: "assistant-1", role: "assistant", metadata: { opencode: { created: 1_000, completed: 80_000 } }, parts: [ { type: "step-start" }, { type: "reasoning", text: "planning the change", state: "done" }, bashPart("c1"), editPart("c2", "/repo/src/a.ts"), { type: "text", text: "Now checking the result:", state: "done" }, bashPart("c3"), bashPart("c4"), bashPart("c5"), { type: "text", text: "Everything passed — the change is in.", state: "done" }, ], }; const markup = renderList([userMessage, assistant]); // 79 seconds of work between created and completed. expect(markup).toContain("Worked for 1m 19s"); // The answer stays visible outside the fold. expect(markup).toContain("Everything passed — the change is in."); }); test("a short turn stays inline with one aggregate line", () => { const assistant: UIMessage = { id: "assistant-2", role: "assistant", metadata: { opencode: { created: 1_000, completed: 5_000 } }, parts: [ { type: "step-start" }, bashPart("c1"), editPart("c2", "/repo/src/a.ts"), { type: "text", text: "Done.", state: "done" }, ], }; const markup = renderList([userMessage, assistant]); expect(markup).not.toContain("Worked for"); // Both calls merge into one aggregate summary line. expect(markup).toContain("Edited 1 file, ran command"); expect(markup).toContain("Done."); }); test("reasoning between calls stays one aggregate line that advertises its thought", () => { const assistant: UIMessage = { id: "assistant-3", role: "assistant", metadata: { opencode: { created: 1_000, completed: 4_000 } }, parts: [ { type: "step-start" }, { type: "reasoning", text: "first", state: "done" }, bashPart("c1"), { type: "reasoning", text: "second", state: "done" }, bashPart("c2"), { type: "text", text: "Done.", state: "done" }, ], }; const markup = renderList([userMessage, assistant]); // No thought/command ladder: the run is ONE aggregate line… expect(markup).toContain("Ran 2 commands"); // …that counts the thought it carries. expect(markup).toContain("1 thought"); // The turn-opening thought still renders as its own line above the run. const openingThought = markup.indexOf("Thought"); const run = markup.indexOf("Ran 2 commands"); expect(openingThought).toBeGreaterThan(-1); expect(run).toBeGreaterThan(openingThought); }); });