// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { cleanup, fireEvent, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { api } from "@multica/core/api";
import type { AgentRuntime, AgentTask } from "@multica/core/types/agent";
import { useTranscriptViewStore } from "@multica/core/agents/stores";
import { renderWithI18n } from "../../test/i18n";
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
import type { TimelineItem } from "./build-timeline";
const copyTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(true));
vi.mock("@multica/core/api", () => ({
api: {
getAgent: vi.fn().mockResolvedValue(null),
listRuntimes: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("@multica/ui/lib/clipboard", () => ({
copyText: copyTextMock,
}));
// Real react-virtuoso renders no data rows under jsdom's zero-height viewport,
// so stub it with a flat render to make rows visible to these tests.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({
data,
itemContent,
computeItemKey,
}: {
data: TimelineItem[];
itemContent: (i: number, item: TimelineItem) => ReactNode;
computeItemKey: (i: number, item: TimelineItem) => number;
}) => (
{data.map((item, i) => (
{itemContent(i, item)}
))}
),
}));
vi.mock("../actor-avatar", () => ({
ActorAvatar: () => ,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <>{children}> : null,
DialogContent: ({ children }: { children: ReactNode }) => (
{children}
),
DialogTitle: ({ children }: { children: ReactNode }) => {children}
,
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", async () => {
const React = await import("react");
const RadioContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});
return {
DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
DropdownMenuTrigger: ({
children,
...props
}: ButtonHTMLAttributes) => (
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
{children}
),
DropdownMenuSeparator: () =>
,
DropdownMenuCheckboxItem: ({
checked,
onCheckedChange,
children,
}: {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
children: ReactNode;
}) => (
),
DropdownMenuItem: ({
children,
onClick,
className: _className,
}: ButtonHTMLAttributes) => (
),
DropdownMenuRadioGroup: ({
value,
onValueChange,
children,
}: {
value?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
}) => (
{children}
),
DropdownMenuRadioItem: ({
value,
children,
}: {
value: string;
children: ReactNode;
}) => {
const ctx = React.useContext(RadioContext);
return (
);
},
};
});
// The transcript body renders agent markdown through RichContent; stub it to
// keep these tests independent of the markdown pipeline.
vi.mock("../../rich-content", () => ({
RichContent: ({ content }: { content: string }) => (
{content}
),
}));
vi.mock("@multica/ui/components/ui/collapsible", async () => {
const React = await import("react");
const Context = React.createContext<{
open: boolean;
onOpenChange?: (open: boolean) => void;
}>({ open: false });
return {
Collapsible: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) => (
{children}
),
CollapsibleTrigger: ({
disabled,
children,
className: _className,
...props
}: ButtonHTMLAttributes) => {
const ctx = React.useContext(Context);
return (
);
},
CollapsibleContent: ({ children }: { children: ReactNode }) => {
const ctx = React.useContext(Context);
return ctx.open ? {children}
: null;
},
};
});
const baseTask: AgentTask = {
id: "task-1",
agent_id: "",
runtime_id: "",
issue_id: "issue-1",
status: "completed",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: "2026-06-08T08:01:00Z",
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
};
const liveTask: AgentTask = {
...baseTask,
runtime_id: "runtime-1",
status: "running",
completed_at: null,
};
function runtimeFor(provider: string): AgentRuntime {
return {
id: "runtime-1",
workspace_id: "workspace-1",
daemon_id: "daemon-1",
name: `${provider} runtime`,
runtime_mode: "local",
provider,
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: "owner-1",
visibility: "private",
last_seen_at: null,
created_at: "2026-06-08T08:00:00Z",
updated_at: "2026-06-08T08:00:00Z",
};
}
const items: TimelineItem[] = [
{
seq: 1,
type: "text",
content: "Agent summary\nAgent hidden detail",
},
{
seq: 2,
type: "thinking",
content: "Thinking summary\nThinking hidden detail",
},
{
seq: 3,
type: "tool_use",
tool: "terminal",
input: { command: "pnpm test" },
},
];
function renderDialog(
dialogItems: TimelineItem[] = items,
options: { task?: AgentTask; isLive?: boolean } = {},
) {
return renderWithI18n(
,
);
}
beforeEach(() => {
cleanup();
copyTextMock.mockClear();
vi.mocked(api.listRuntimes).mockResolvedValue([]);
useTranscriptViewStore.setState({
sortDirection: "chronological",
selectedFilterKeys: [],
// Legacy row assertions below expect one-line summaries; smart density is
// exercised by its own tests.
density: "collapsed",
});
});
afterEach(() => {
cleanup();
});
describe("AgentTranscriptDialog", () => {
it("explains unavailable live events for an empty Antigravity transcript", async () => {
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]);
renderDialog([], { task: liveTask, isLive: true });
expect(
await screen.findByText(
"Antigravity does not currently provide live execution events. The transcript will be available after the task completes.",
),
).toBeInTheDocument();
expect(screen.queryByText("Waiting for events...")).not.toBeInTheDocument();
});
it("keeps waiting for live events from other runtimes", async () => {
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("hermes")]);
renderDialog([], { task: liveTask, isLive: true });
// Runtime detail now lives in the ⓘ popover; its trigger appearing proves
// the runtime loaded. The non-antigravity live state still waits.
await screen.findByRole("button", { name: "Run details" });
expect(screen.getByText("Waiting for events...")).toBeInTheDocument();
});
it("preserves selected filters across dialog remounts unconditionally", () => {
const first = renderDialog();
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Thinking" }));
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["thinking"]);
first.unmount();
renderDialog();
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
});
it("ignores stale persisted filter keys that are not available in the current transcript", () => {
useTranscriptViewStore.setState({
selectedFilterKeys: ["thinking"],
});
renderDialog([
{
seq: 1,
type: "text",
content: "Only agent summary\nOnly agent hidden detail",
},
]);
expect(screen.getByText("Only agent summary")).toBeInTheDocument();
expect(screen.queryByText("No execution data recorded.")).not.toBeInTheDocument();
});
it("switches wholesale between expand-all and collapse-all via the density menu", () => {
renderDialog();
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitemradio", { name: /Expand all/ }));
expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument();
expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument();
expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitemradio", { name: /Collapse all/ }));
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
});
it("smart density opens agent text in place and keeps process noise folded", () => {
useTranscriptViewStore.setState({ density: "smart" });
renderDialog();
// Agent body reads without a click (through RichContent), tools stay folded.
expect(screen.getByTestId("rich-content")).toHaveTextContent("Agent hidden detail");
expect(screen.queryByText(/Thinking hidden detail/)).not.toBeInTheDocument();
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
});
it("row-level toggles override the density default until the mode changes", () => {
useTranscriptViewStore.setState({ density: "smart" });
renderDialog();
// Fold the default-open agent body back to one line. The `expanded`
// filter distinguishes the collapse trigger from the timeline segment,
// which also carries the "Agent" accessible name via its title.
fireEvent.click(screen.getByRole("button", { name: "Agent", expanded: true }));
expect(screen.queryByTestId("rich-content")).not.toBeInTheDocument();
expect(screen.getByText("Agent summary")).toBeInTheDocument();
// Open a default-folded thinking row.
fireEvent.click(screen.getByRole("button", { name: /Thinking summary/ }));
expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument();
});
it("copies RFC 3339 timestamps before event labels", () => {
renderDialog([
{
seq: 1,
type: "text",
content: "Agent summary\nAgent hidden detail",
created_at: "2026-06-08T08:00:00+08:00",
},
{
seq: 2,
type: "thinking",
content: "Thinking summary",
created_at: "2026-06-08T08:00:05.123Z",
},
]);
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
// Full body (not the truncated summary) with the RFC 3339 prefix, events
// separated by a blank line.
expect(copyTextMock).toHaveBeenCalledWith(
[
"[2026-06-08T00:00:00.000Z] [Agent] Agent summary\nAgent hidden detail",
"[2026-06-08T08:00:05.123Z] [Thinking] Thinking summary",
].join("\n\n"),
);
});
it("keeps older events without a valid timestamp copyable", () => {
renderDialog([
{
seq: 1,
type: "text",
content: "Missing timestamp",
},
{
seq: 2,
type: "error",
content: "Invalid timestamp",
created_at: "not-a-date",
},
]);
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
expect(copyTextMock).toHaveBeenCalledWith(
["[Agent] Missing timestamp", "[Error] Invalid timestamp"].join("\n\n"),
);
});
it("renders a file edit as a diff instead of escaped JSON strings", () => {
useTranscriptViewStore.setState({ density: "expanded" });
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: {
file_path: "/repo/src/counter.rs",
old_string: "pub count: u32,",
new_string: "pub count: u32,\npub total: u64,",
},
},
]);
// Changed lines read as diff rows. Text is asserted on the row, not on a
// leaf node: syntax highlighting splits a line across `hljs-*` spans.
const rowText = (selector: string) =>
Array.from(container.querySelectorAll(selector)).map((el) => el.textContent?.trim());
// The first line is unchanged, so it is context; only the second is added.
expect(rowText(".bg-success\\/10")).toEqual(["+ pub total: u64,"]);
expect(rowText(".bg-destructive\\/10")).toEqual([]);
expect(container.querySelector("pre")?.textContent).toContain("pub count: u32,");
// ...and the raw JSON keys are gone from the surface.
expect(screen.queryByText(/"new_string"/)).not.toBeInTheDocument();
expect(screen.queryByText(/\\n/)).not.toBeInTheDocument();
});
it("highlights diff rows using the grammar for the file extension", () => {
useTranscriptViewStore.setState({ density: "expanded" });
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: {
file_path: "/repo/src/lib.rs",
old_string: "let a = 1;",
new_string: "let b = 2;",
},
},
]);
// `let` is a Rust keyword, so the highlighter must have marked it up.
expect(container.querySelector(".hljs-keyword")?.textContent).toBe("let");
});
it("carries the scope class the hljs palette is defined under", () => {
// The palette lives in editor/styles/code.css, scoped to the editor surface
// and this class. Without it the spans render but stay uncoloured.
useTranscriptViewStore.setState({ density: "expanded" });
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: { file_path: "/repo/src/lib.rs", old_string: "let a = 1;", new_string: "let b = 2;" },
},
]);
expect(container.querySelector("pre")?.className).toContain("transcript-code");
const css = readFileSync("editor/styles/code.css", "utf8");
expect(css).toContain(".transcript-code");
});
it("leaves an unknown extension unhighlighted rather than guessing", () => {
useTranscriptViewStore.setState({ density: "expanded" });
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: { file_path: "/repo/NOTES", old_string: "let a = 1;", new_string: "let b = 2;" },
},
]);
expect(container.querySelector(".hljs-keyword")).toBeNull();
expect(container.textContent).toContain("let b = 2;");
});
it("unwraps a JSON-encoded tool result so it reads as terminal output", () => {
useTranscriptViewStore.setState({ density: "expanded" });
renderDialog([
{
seq: 1,
type: "tool_result",
tool: "Bash",
output: '"total 0\\ndrwxr-xr-x 2 user staff"',
},
]);
expect(
screen.getByText("total 0\ndrwxr-xr-x 2 user staff", {
selector: "pre",
// Keep the newline and column spacing the unwrap is meant to restore.
normalizer: (text) => text,
}),
).toBeInTheDocument();
});
it("shows a whole-file write as plain content with a line count", () => {
useTranscriptViewStore.setState({ density: "expanded" });
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Write",
input: { file_path: "/f.rs", content: "alpha\nbeta" },
},
]);
expect(screen.getByText("+2")).toBeInTheDocument();
// Plain content: no per-line + gutter and no diff tinting.
const pre = container.querySelector("pre");
expect(pre?.textContent).toBe("alpha\nbeta");
expect(container.querySelector(".bg-success\\/10")).toBeNull();
});
it("clamps a long body behind the show-all affordance", () => {
useTranscriptViewStore.setState({ density: "expanded" });
const content = Array.from({ length: 40 }, (_, i) => `line ${i}`).join("\n");
const { container } = renderDialog([
{ seq: 1, type: "tool_use", tool: "Write", input: { file_path: "/f.rs", content } },
]);
const pre = container.querySelector("pre");
expect(pre?.className).toContain("max-h-52");
expect(pre?.className).toContain("overflow-hidden");
expect(screen.getByRole("button", { name: "Show all" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
expect(container.querySelector("pre")?.className).not.toContain("max-h-52");
});
it("does not clamp a short diff", () => {
useTranscriptViewStore.setState({ density: "expanded" });
renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: { file_path: "/f.rs", old_string: "a", new_string: "b" },
},
]);
expect(screen.queryByRole("button", { name: "Show all" })).not.toBeInTheDocument();
});
});