import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { assistantCopyFlags, buildDisplayUnits, ThreadMessages, } from "@/components/thread/ThreadMessages"; import type { UIMessage } from "@/lib/types"; describe("ThreadMessages", () => { it("groups consecutive reasoning and tool rows into one cluster before the answer", () => { const messages: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: "thinking", reasoningStreaming: false, isStreaming: true, createdAt: Date.now(), }, { id: "t1", role: "tool", kind: "trace", content: "search()", traces: ["search()"], createdAt: Date.now(), }, { id: "r2", role: "assistant", content: "", reasoning: "more thinking", reasoningStreaming: false, isStreaming: true, createdAt: Date.now(), }, { id: "a1", role: "assistant", content: "final answer", createdAt: Date.now(), }, ]; const { container } = render( , ); const rows = Array.from(container.firstElementChild?.children ?? []); expect(rows).toHaveLength(2); expect(rows[0]).not.toHaveClass("mt-2", "mt-4", "mt-5"); expect(rows[1]).toHaveClass("mt-4"); }); it("starts a new activity cluster when the activity segment changes", () => { const messages: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: "first pass", activitySegmentId: "seg-1", createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "edit_file()", traces: ["edit_file()"], fileEdits: [{ call_id: "call-edit", tool: "edit_file", path: "foo.txt", phase: "end", added: 2, deleted: 1, status: "done", }], activitySegmentId: "seg-1", createdAt: 2, }, { id: "r2", role: "assistant", content: "", reasoning: "second pass", activitySegmentId: "seg-2", createdAt: 3, }, ]; const units = buildDisplayUnits(messages); expect(units).toHaveLength(2); expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ "r1", "t1", ]); expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([ "r2", ]); }); it("does not split ordinary tool activity just because segment ids changed", () => { const messages: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: "first pass", activitySegmentId: "seg-1", createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "read_file()", traces: ["read_file()"], activitySegmentId: "seg-1", createdAt: 2, }, { id: "r2", role: "assistant", content: "", reasoning: "second pass", activitySegmentId: "seg-2", createdAt: 3, }, { id: "t2", role: "tool", kind: "trace", content: "grep()", traces: ["grep()"], activitySegmentId: "seg-2", createdAt: 4, }, ]; const units = buildDisplayUnits(messages); expect(units).toHaveLength(1); expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ "r1", "t1", "r2", "t2", ]); }); it("only marks the current activity cluster as live while streaming", () => { const messages: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: "first pass", reasoningStreaming: true, activitySegmentId: "seg-1", createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "edit_file()", traces: ["edit_file()"], fileEdits: [{ call_id: "call-edit", tool: "edit_file", path: "foo.txt", phase: "start", added: 4, deleted: 1, approximate: true, status: "editing", }], activitySegmentId: "seg-1", createdAt: 2, }, { id: "r2", role: "assistant", content: "", reasoning: "second pass", reasoningStreaming: true, activitySegmentId: "seg-2", createdAt: 3, }, ]; render(); expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument(); }); it("folds final answer reasoning into the preceding activity cluster", () => { const messages: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: "search plan", reasoningStreaming: false, createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "web_search()", traces: ["web_search()"], createdAt: 2, }, { id: "a1", role: "assistant", content: "final answer", reasoning: "summarize results", reasoningStreaming: false, createdAt: 3, }, ]; const units = buildDisplayUnits(messages); expect(units).toHaveLength(2); expect(units[0]).toMatchObject({ type: "cluster" }); expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([ "r1", "t1", "a1-reasoning", ]); expect(units[1]).toMatchObject({ type: "single", message: { id: "a1", content: "final answer", }, }); if (units[1].type === "single") { expect(units[1].message).not.toHaveProperty("reasoning"); } render(); expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument(); expect(screen.getByText("final answer")).toBeInTheDocument(); }); it("shows copy only on the last assistant slice before the next user turn", () => { const messages: UIMessage[] = [ { id: "early", role: "assistant", content: "starting…", createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "search()", traces: ["search()"], createdAt: 2, }, { id: "late", role: "assistant", content: "final reply", createdAt: 3, }, ]; render(); expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1); expect(screen.getByText("final reply")).toBeInTheDocument(); }); it("shows copy only on the second assistant when two text slices appear before user", () => { const messages: UIMessage[] = [ { id: "a1", role: "assistant", content: "part one", createdAt: 1 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 }, ]; render(); expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1); }); it("computes final assistant copy flags with user-boundary semantics", () => { const units = buildDisplayUnits([ { id: "u1", role: "user", content: "one", createdAt: 1 }, { id: "a1", role: "assistant", content: "draft", createdAt: 2 }, { id: "t1", role: "tool", kind: "trace", content: "tool()", traces: ["tool()"], createdAt: 3, }, { id: "a2", role: "assistant", content: "final", createdAt: 4 }, { id: "u2", role: "user", content: "two", createdAt: 5 }, { id: "a3", role: "assistant", content: "next", createdAt: 6 }, ]); const flags = assistantCopyFlags(units); const assistantFlags = units .map((unit, index) => unit.type === "single" && unit.message.role === "assistant" ? [unit.message.id, flags[index]] : null, ) .filter(Boolean); expect(assistantFlags).toEqual([ ["a1", false], ["a2", true], ["a3", true], ]); }); });