Files
multica/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx

1168 lines
42 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { api } from "@multica/core/api";
import type { SupportedLocale } from "@multica/core/i18n";
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";
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace" }));
vi.mock("./use-trace-issue-labels", () => ({
useTraceIssueLabels: () => (text: string) => text.replaceAll("01a07eca-8e82-775e-be06-e4a97ccfa299", "DEV-17"),
}));
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;
}) => (
<div>
{data.map((item, i) => (
<div key={computeItemKey(i, item)}>{itemContent(i, item)}</div>
))}
</div>
),
}));
vi.mock("../actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <>{children}</> : null,
DialogContent: ({ children }: { children: ReactNode }) => (
<div role="dialog">{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
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 }) => <div>{children}</div>,
DropdownMenuTrigger: ({
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuCheckboxItem: ({
checked,
onCheckedChange,
children,
}: {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
children: ReactNode;
}) => (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked === true}
onClick={() => onCheckedChange?.(checked !== true)}
>
{children}
</button>
),
DropdownMenuItem: ({
children,
onClick,
className: _className,
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuRadioGroup: ({
value,
onValueChange,
children,
}: {
value?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
}) => (
<RadioContext.Provider value={{ value, onValueChange }}>{children}</RadioContext.Provider>
),
DropdownMenuRadioItem: ({
value,
children,
}: {
value: string;
children: ReactNode;
}) => {
const ctx = React.useContext(RadioContext);
return (
<button
type="button"
role="menuitemradio"
aria-checked={ctx.value === value}
onClick={() => ctx.onValueChange?.(value)}
>
{children}
</button>
);
},
};
});
// 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 }) => (
<div data-testid="rich-content">{content}</div>
),
}));
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;
locale?: SupportedLocale;
} = {},
) {
return renderWithI18n(
<AgentTranscriptDialog
open
onOpenChange={vi.fn()}
task={options.task ?? baseTask}
items={dialogItems}
agentName="Codex"
isLive={options.isLive}
/>,
{ locale: options.locale },
);
}
beforeEach(() => {
cleanup();
copyTextMock.mockClear();
vi.mocked(api.listRuntimes).mockResolvedValue([]);
useTranscriptViewStore.setState({
sortDirection: "chronological",
selectedFilterKeys: [],
});
});
afterEach(() => {
cleanup();
});
describe("AgentTranscriptDialog", () => {
it.each([
{ type: "text", label: "Agent", icon: ".lucide-bot" },
{ type: "thinking", label: "Thinking", icon: ".lucide-brain" },
{ type: "error", label: "Error", icon: ".lucide-circle-alert" },
] as const)("labels a $type step correctly when opened from the timeline", ({ type, label, icon }) => {
renderDialog([
{ seq: 1, type, content: "Final delivered reply", created_at: baseTask.started_at! },
]);
fireEvent.click(screen.getByRole("button", { name: "Model 1m 00s" }));
const inspector = screen.getByRole("complementary");
expect(within(inspector).getByRole("heading", { name: label })).toBeInTheDocument();
expect(inspector.querySelector(icon)).not.toBeNull();
expect(inspector).toHaveTextContent("Final delivered reply");
if (type === "text") {
expect(within(inspector).queryByText("Error")).not.toBeInTheDocument();
expect(inspector.querySelector(".text-destructive, .bg-destructive")).toBeNull();
}
});
it("shows what was asked, whole, under the header", () => {
// The lists that open this dialog cut the ask to one line; here it wraps.
const ask = "Merge the three split-out sub-issues back into this PR &amp; ship conditions, history and runaway protection together";
renderDialog(items, { task: { ...baseTask, trigger_comment_id: "comment-1", trigger_summary: ask } });
expect(screen.getByText("Trigger")).toBeInTheDocument();
expect(
screen.getByText("Merge the three split-out sub-issues back into this PR & ship conditions, history and runaway protection together"),
).toBeInTheDocument();
});
it.each([
{ locale: "en", label: "Agent", model: "Model" },
{ locale: "zh-Hans", label: "智能体", model: "模型" },
{ locale: "ja", label: "エージェント", model: "モデル" },
{ locale: "ko", label: "에이전트", model: "모델" },
{ locale: "fr", label: "Agent", model: "Modèle" },
] as const)("localizes the text step inspector in $locale", ({ locale, label, model }) => {
renderDialog([
{ seq: 1, type: "text", content: "Final delivered reply", created_at: baseTask.started_at! },
], { locale });
fireEvent.click(screen.getByRole("button", { name: `${model} 1m 00s` }));
const inspector = screen.getByRole("complementary");
expect(within(inspector).getByRole("heading", { name: label })).toBeInTheDocument();
expect(inspector.firstElementChild).toHaveTextContent(label);
});
it("has no trigger row for a run with no snapshot", () => {
renderDialog(items, { task: { ...baseTask, trigger_summary: undefined } });
expect(screen.queryByText("Trigger")).not.toBeInTheDocument();
});
it("opens the matching result and duration for parallel same-tool calls", () => {
const at = (seconds: number) =>
new Date(Date.parse(baseTask.started_at!) + seconds * 1000).toISOString();
renderDialog([
{ seq: 1, type: "tool_use", tool: "Bash", callId: "A", input: { command: "slow-A" }, created_at: at(0) },
{ seq: 2, type: "tool_use", tool: "Bash", callId: "B", input: { command: "fast-B" }, created_at: at(1) },
{ seq: 3, type: "tool_result", tool: "Bash", callId: "B", output: "B finished", created_at: at(3) },
{ seq: 4, type: "tool_result", tool: "Bash", callId: "A", output: "A finished", created_at: at(10) },
]);
const slow = screen.getByRole("button", { name: /slow-A/ });
const fast = screen.getByRole("button", { name: /fast-B/ });
expect(slow).toHaveTextContent("10s");
expect(fast).toHaveTextContent("2.0s");
fireEvent.click(slow);
expect(screen.getByText("A finished", { selector: "pre" })).toBeInTheDocument();
expect(screen.queryByText("B finished", { selector: "pre" })).not.toBeInTheDocument();
fireEvent.click(fast);
expect(screen.getByText("B finished", { selector: "pre" })).toBeInTheDocument();
expect(screen.queryByText("A finished", { selector: "pre" })).not.toBeInTheDocument();
});
it("waits for live events from Antigravity", async () => {
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]);
renderDialog([], { task: liveTask, isLive: true });
await screen.findByRole("button", { name: "Run details" });
expect(screen.getByText("Waiting for events...")).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("shows live Antigravity tool events", async () => {
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]);
renderDialog([
{ seq: 1, type: "tool_use", tool: "run_command", input: { CommandLine: "echo hello" } },
{ seq: 2, type: "tool_result", tool: "run_command", output: "hello" },
], { task: liveTask, isLive: true });
await screen.findByRole("button", { name: "Run details" });
expect(screen.queryByText("Waiting for events...")).not.toBeInTheDocument();
expect(screen.getAllByText(/run_command/).length).toBeGreaterThan(0);
});
it("preserves selected filters across dialog remounts unconditionally", () => {
const first = renderDialog();
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Thinking" }));
expect(screen.queryByTestId("rich-content")).not.toBeInTheDocument();
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["thinking"]);
first.unmount();
renderDialog();
expect(screen.queryByTestId("rich-content")).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.getByTestId("rich-content")).toHaveTextContent("Only agent summary");
expect(screen.queryByText("No execution data recorded.")).not.toBeInTheDocument();
});
it("reads agent prose in place and keeps tool detail one click away", () => {
renderDialog();
// The report is the narrative and renders whole; a tool call is a line
// whose body waits in the inspector. This inversion is the redesign.
expect(screen.getByTestId("rich-content")).toHaveTextContent("Agent hidden detail");
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /pnpm test/ }));
expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument();
});
// Regression, #7125: a run of short prose steps under a long agent name put
// the same semibold name above every one-line body, so the row's heaviest
// element was the one value that never changes. Identity belongs to the run,
// and the header already carries it.
it("states the agent once in the header, not on every prose row", () => {
renderWithI18n(
<AgentTranscriptDialog
open
onOpenChange={vi.fn()}
task={{ ...baseTask, agent_id: "agent-1" }}
items={[
{ seq: 1, type: "text", content: "Cleanup done. Starting tests:" },
{ seq: 2, type: "text", content: "Now adding the Feishu row:" },
{ seq: 3, type: "text", content: "Now the version bump:" },
]}
agentName="【Chores|Opus5】Multica Helper"
/>,
);
expect(screen.getAllByTestId("rich-content")).toHaveLength(3);
expect(screen.getAllByText("【Chores|Opus5】Multica Helper")).toHaveLength(1);
expect(screen.getAllByTestId("actor-avatar")).toHaveLength(1);
});
// A facet is only readable if you can see what it selects. Tool facets always
// could — their rows print the tool name — but prose rows carried no kind
// mark at all, so "Agent" in the menu pointed at nothing.
it("anchors each filter facet to the glyph its rows carry", () => {
renderDialog([
{ seq: 1, type: "text", content: "Committing now:" },
{ seq: 2, type: "tool_use", tool: "Bash", input: { command: "git commit" } },
]);
const agentFacet = screen.getByRole("menuitemcheckbox", { name: "Agent" });
expect(agentFacet.querySelector(".lucide-bot")).not.toBeNull();
const proseRow = screen.getByTestId("rich-content").closest(".group");
expect(proseRow?.querySelector(".lucide-bot")).not.toBeNull();
});
it("names a tool facet the way its rows do, keeping the prefix in the key", () => {
renderDialog([{ seq: 1, type: "tool_use", tool: "Bash", input: { command: "ls" } }]);
expect(screen.getByRole("menuitemcheckbox", { name: "Bash" })).toBeInTheDocument();
expect(screen.queryByRole("menuitemcheckbox", { name: "tool:Bash" })).toBeNull();
// The label changed; the persisted facet key did not.
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Bash" }));
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["tool:Bash"]);
});
it("folds a call and its result into one step instead of two rows", () => {
renderDialog([
{ seq: 1, type: "tool_use", tool: "Bash", input: { command: "ls" } },
{ seq: 2, type: "tool_result", tool: "Bash", output: "total 0" },
]);
// One row for the call, and the result body is not in the list at all.
expect(screen.getAllByRole("button", { name: /^Bash/ })).toHaveLength(1);
expect(screen.queryByText("total 0")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^Bash/ }));
expect(screen.getByText("total 0")).toBeInTheDocument();
});
it("shows a meaningful bound instead of rounding a fast call to zero", () => {
renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Bash",
input: { command: "sed -n 1,20p README.md" },
created_at: "2026-06-08T08:00:00.000Z",
},
{
seq: 2,
type: "tool_result",
tool: "Bash",
output: "ok",
created_at: "2026-06-08T08:00:00.040Z",
},
]);
expect(screen.getByText("<0.1s")).toBeInTheDocument();
expect(screen.queryByText("0.0s")).not.toBeInTheDocument();
});
it("does not claim a duration for legacy calls whose batched timestamps are identical", () => {
renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Bash",
input: { command: "pwd" },
created_at: "2026-06-08T08:00:00.000Z",
},
{
seq: 2,
type: "tool_result",
tool: "Bash",
output: "/repo",
created_at: "2026-06-08T08:00:00.000Z",
},
]);
const unknownDuration = screen.getByText("—");
expect(unknownDuration).toHaveAttribute("aria-hidden", "true");
expect(unknownDuration.parentElement).toHaveAttribute("title", "Exact duration is unavailable.");
expect(unknownDuration.parentElement).not.toHaveAttribute("aria-label");
expect(screen.getByText("Exact duration is unavailable.")).toHaveClass("sr-only");
const row = screen.getByRole("button", { name: /Exact duration is unavailable\./ });
fireEvent.click(row);
expect(screen.getAllByText("Exact duration is unavailable.")).toHaveLength(2);
expect(screen.queryByText("0.0s")).not.toBeInTheDocument();
});
it("keeps a screenshot out of the list and renders it as an image", () => {
const output = JSON.stringify([
{ type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } },
]);
renderDialog([
{ seq: 1, type: "tool_use", tool: "Bash", input: { command: "screenshot" } },
{ seq: 2, type: "tool_result", tool: "Bash", output },
]);
// The old surface printed the whole base64 payload into the row.
expect(screen.queryByText(/iVBORw0KGgo/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /screenshot/ }));
expect(screen.getByRole("img", { name: "Image" })).toHaveAttribute(
"src",
"data:image/png;base64,iVBORw0KGgo=",
);
});
it("folds consecutive same-tool calls into one group that expands", () => {
renderDialog([
{ seq: 1, type: "tool_use", tool: "Read", input: { file_path: "/a.ts" } },
{ seq: 2, type: "tool_result", tool: "Read", output: "a" },
{ seq: 3, type: "tool_use", tool: "Read", input: { file_path: "/b.ts" } },
{ seq: 4, type: "tool_result", tool: "Read", output: "b" },
{ seq: 5, type: "tool_use", tool: "Read", input: { file_path: "/c.ts" } },
{ seq: 6, type: "tool_result", tool: "Read", output: "c" },
]);
// The folded row still names what it touched first — "Read · 3 calls"
// alone would hide the only detail that makes a group scannable.
const group = screen.getByRole("button", { name: /\/a\.ts.*3 calls/ });
expect(screen.queryByText("/c.ts")).not.toBeInTheDocument();
fireEvent.click(group);
expect(screen.getByText("/c.ts")).toBeInTheDocument();
});
it("narrows the list to steps matching the search", () => {
renderDialog();
fireEvent.change(screen.getByLabelText("Search this run…"), {
target: { value: "pnpm" },
});
expect(screen.getByRole("button", { name: /pnpm test/ })).toBeInTheDocument();
expect(screen.queryByTestId("rich-content")).not.toBeInTheDocument();
expect(screen.getByText("1 of 3 steps")).toBeInTheDocument();
});
it("hides the timeline when steps have no timestamps", () => {
renderDialog();
expect(screen.queryByText("Model")).not.toBeInTheDocument();
expect(screen.queryByText("Tools")).not.toBeInTheDocument();
});
it.each([{ count: 1, seconds: 20 }, { count: 8, seconds: 30 }, { count: 8, seconds: 320 }])("shows the timeline for $count steps over $seconds seconds", ({ count, seconds }) => {
const at = (seconds: number) =>
new Date(Date.parse("2026-06-08T08:00:00Z") + seconds * 1000).toISOString();
const longRun: TimelineItem[] = [];
for (let i = 0; i < count; i++) {
const start = (seconds / count) * i;
longRun.push(
{ seq: i * 2 + 1, type: "tool_use", tool: "Bash", input: { command: `step ${i}` }, created_at: at(start) },
{ seq: i * 2 + 2, type: "tool_result", tool: "Bash", output: "ok", created_at: at(start + seconds / count / 2) },
);
}
renderDialog(longRun, {
task: { ...baseTask, started_at: at(0), completed_at: at(seconds) },
});
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Tools")).toBeInTheDocument();
});
it("copies RFC 3339 timestamps before event labels", async () => {
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",
},
]);
await act(async () => {
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", async () => {
renderDialog([
{
seq: 1,
type: "text",
content: "Missing timestamp",
},
{
seq: 2,
type: "error",
content: "Invalid timestamp",
created_at: "not-a-date",
},
]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
});
expect(copyTextMock).toHaveBeenCalledWith(
["[Agent] Missing timestamp", "[Error] Invalid timestamp"].join("\n\n"),
);
});
it("cancels copy feedback timers when the dialog unmounts", async () => {
vi.useFakeTimers();
try {
const { unmount } = renderDialog();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy all" }));
await Promise.resolve();
});
expect(vi.getTimerCount()).toBeGreaterThan(0);
unmount();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
});
it("renders a file edit as a diff instead of escaped JSON strings", () => {
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,",
},
},
]);
fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
// 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", () => {
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;",
},
},
]);
fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
// `let` is a Rust keyword, so the highlighter must have marked it up.
expect(container.querySelector(".hljs-keyword")?.textContent).toBe("let");
});
it("leaves an unknown extension unhighlighted rather than guessing", () => {
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;" },
},
]);
fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
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", () => {
renderDialog([
{
seq: 1,
type: "tool_result",
tool: "Bash",
output: '"total 0\\ndrwxr-xr-x 2 user staff"',
},
]);
fireEvent.click(screen.getByRole("button", { name: /^Bash/ }));
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", () => {
const { container } = renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Write",
input: { file_path: "/f.rs", content: "alpha\nbeta" },
},
]);
fireEvent.click(screen.getByRole("button", { name: /Write/ }));
// The outcome row reports "+2" as well, so scope this to the inspector.
expect(within(screen.getByRole("complementary")).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", () => {
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 } },
]);
fireEvent.click(screen.getByRole("button", { name: /Write/ }));
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", () => {
renderDialog([
{
seq: 1,
type: "tool_use",
tool: "Edit",
input: { file_path: "/f.rs", old_string: "a", new_string: "b" },
},
]);
fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
expect(screen.queryByRole("button", { name: "Show all" })).not.toBeInTheDocument();
});
// The run-details provider row used to carry its own name map. Pointing it at
// the shared runtime formatter is right for every provider except Claude,
// whose runtime-list name is "Claude" while this diagnostic row has always
// said "Claude Code" — and whose legacy `claude-code` slug title-cases into
// "Claude-code". Both aliases are pinned here so the next cleanup keeps them.
it.each([
["claude", "Claude Code"],
["claude-code", "Claude Code"],
// Everything outside the alias table defers to the shared runtime
// formatter, so a new provider needs no edit here.
["omp", "Oh-My-Pi"],
])("names a %s run %s in the run details", async (provider, expected) => {
const user = userEvent.setup();
vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor(provider)]);
renderDialog(items, { task: { ...baseTask, runtime_id: "runtime-1" } });
await user.click(await screen.findByRole("button", { name: "Run details" }));
expect(await screen.findByText(expected)).toBeInTheDocument();
});
});
describe("AgentTranscriptDialog — work directory handoff", () => {
it("shows and copies the durable project directory after worktree cleanup", async () => {
renderDialog(items, {
task: {
...baseTask,
work_dir: "/managed/task/worktree",
relative_work_dir: "workspace/task/worktree",
durable_work_dir: "/Users/dev/project",
relative_durable_work_dir: "project",
},
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText("Project directory")).toBeInTheDocument();
expect(screen.getByText("project")).toBeInTheDocument();
await userEvent.click(screen.getByTitle("Copy project directory"));
expect(copyTextMock).toHaveBeenCalledWith("/Users/dev/project");
});
it("keeps a live task on its actual workdir", async () => {
renderDialog(items, {
task: {
...liveTask,
work_dir: "/managed/task/worktree",
relative_work_dir: "workspace/task/worktree",
durable_work_dir: "/Users/dev/project",
relative_durable_work_dir: "project",
},
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText("Workdir")).toBeInTheDocument();
expect(screen.getByText("workspace/task/worktree")).toBeInTheDocument();
await userEvent.click(screen.getByTitle("Copy working directory"));
expect(copyTextMock).toHaveBeenCalledWith("/managed/task/worktree");
});
it("keeps a failed-but-preserved task on its worktree", async () => {
renderDialog(items, {
task: {
...baseTask,
status: "failed",
work_dir: "/managed/preserved/worktree",
relative_work_dir: "workspace/task/worktree",
},
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText("Workdir")).toBeInTheDocument();
expect(screen.getByText("workspace/task/worktree")).toBeInTheDocument();
await userEvent.click(screen.getByTitle("Copy working directory"));
expect(copyTextMock).toHaveBeenCalledWith("/managed/preserved/worktree");
});
});
// A worktree-mode run never touches the user's working copy: the branch is the
// only pointer to what it produced. Showing it in Run details is what makes the
// result findable — including for a run that failed partway, which still
// commits whatever the agent had done.
describe("AgentTranscriptDialog — delivered branch", () => {
it("shows the branch and copies it", async () => {
renderDialog(items, {
task: { ...baseTask, branch_name: "agent/j/abc12345" },
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
// Also shown as a produced artifact in the outcome row, hence getAllByText.
expect(screen.getAllByText("agent/j/abc12345").length).toBeGreaterThan(0);
await userEvent.click(screen.getByTitle("Copy branch name"));
expect(copyTextMock).toHaveBeenCalledWith("agent/j/abc12345");
});
it("renders nothing for tasks that delivered no branch", async () => {
renderDialog(items, { task: { ...baseTask, branch_name: undefined } });
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.queryByTitle("Copy branch name")).not.toBeInTheDocument();
});
});
// A server-cancelled run (worktree claim gate, preserved-work delivery) must
// explain itself: the localized reason rides the status badge and heads the
// "Reason" row in Run details, while the raw persisted diagnostic sits under
// its own "Technical details" heading. A user's own cancel stays a plain
// "Cancelled" for historical rows that predate actor provenance.
describe("AgentTranscriptDialog — cancel reason", () => {
const gateError = "worktree mode needs daemon version 0.4.24 or newer on that machine";
it("labels a server-cancelled run and surfaces the persisted reason", async () => {
renderDialog(items, {
task: {
...baseTask,
status: "cancelled",
error: gateError,
failure_reason: "local_directory_error",
},
});
expect(screen.getByText(/Local directory error/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText(gateError)).toBeInTheDocument();
});
it("renders a server cancellation reason in the active locale", () => {
renderDialog(items, {
locale: "zh-Hans",
task: {
...baseTask,
status: "cancelled",
error: gateError,
failure_reason: "local_directory_error",
},
});
expect(screen.getByText(/本地目录出错/)).toBeInTheDocument();
expect(
screen.queryByText(/Local directory error/),
).not.toBeInTheDocument();
});
it("keeps a legacy user-initiated cancel a plain Cancelled", () => {
renderDialog(items, {
task: { ...baseTask, status: "cancelled", error: null },
});
expect(screen.getByText("Cancelled")).toBeInTheDocument();
expect(screen.queryByText(/Local directory error/)).not.toBeInTheDocument();
});
it("names the actor on a new user-initiated cancellation", () => {
renderDialog(items, {
task: {
...baseTask,
status: "cancelled",
error: null,
cancelled_by: { type: "member", id: "user-1", name: "Jiayuan" },
},
});
expect(screen.getByText("Cancelled by Jiayuan")).toBeInTheDocument();
});
// #7411: the status badge used to carry the raw English error as its
// `title`. Hovering a translated pill and getting English prose (with an
// absolute path in it) is exactly the leak this issue reported.
it("keeps the raw diagnostic off the status badge tooltip", () => {
renderDialog(items, {
locale: "zh-Hans",
task: {
...baseTask,
status: "cancelled",
error: gateError,
failure_reason: "local_directory_error",
},
});
expect(screen.queryByTitle(gateError)).not.toBeInTheDocument();
});
});
// The two audiences of a failure, kept apart in Run details: the localized
// reason answers "what happened" for the person who ran the task, the raw
// persisted text answers "what exactly did the runner say" for whoever
// debugs it. Merging them is what made #7411 unfixable by translation alone.
describe("AgentTranscriptDialog — reason vs raw diagnostics", () => {
const rawError =
"opencode stream ended on an empty step (no text, no tool call, no reported usage)";
it("heads the localized reason and the raw text with different labels", async () => {
renderDialog(items, {
task: {
...baseTask,
status: "failed",
error: rawError,
failure_reason: "agent_error.provider_network",
},
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText("Reason")).toBeInTheDocument();
expect(screen.getByText("Network error reaching provider")).toBeInTheDocument();
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(screen.getByText(rawError)).toBeInTheDocument();
});
it("translates both headings and the reason, leaving the raw text verbatim", async () => {
renderDialog(items, {
locale: "zh-Hans",
task: {
...baseTask,
status: "failed",
error: rawError,
failure_reason: "agent_error.provider_network",
},
});
await userEvent.click(screen.getByRole("button", { name: "运行详情" }));
expect(screen.getByText("原始诊断")).toBeInTheDocument();
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
// The diagnostic itself is not translated — it is the runner's own output.
// Keeping it readable is the point; presenting it as the reason is not.
expect(screen.getByText(rawError)).toBeInTheDocument();
});
it("shows no diagnostics section for a run that persisted no error", async () => {
renderDialog(items, {
task: { ...baseTask, status: "completed", error: null },
});
await userEvent.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
expect(screen.queryByText("Reason")).not.toBeInTheDocument();
});
});
describe("readable issue references", () => {
it("searches both the displayed identifier and original UUID", async () => {
const issueId = "01a07eca-8e82-775e-be06-e4a97ccfa299";
renderDialog([{ seq: 1, type: "tool_use", tool: "exec_command", input: { command: `multica issue get ${issueId} --output json` } }]);
expect(screen.getByText("multica issue get DEV-17 --output json")).toBeInTheDocument();
const search = screen.getByRole("textbox");
fireEvent.change(search, { target: { value: "DEV-17" } });
expect(screen.getByText("multica issue get DEV-17 --output json")).toBeInTheDocument();
fireEvent.change(search, { target: { value: issueId } });
expect(screen.getByText("multica issue get DEV-17 --output json")).toBeInTheDocument();
});
});
// The completeness indicator, #8182 / #8183. A truncation remark sits at the
// end of the output it describes and waits for the reader to reveal that end;
// unknown-ness belongs to the run, because one daemon produced all of it. The
// state matrix lives in build-timeline.test.ts.
describe("tool output completeness", () => {
const SHORT = "line one";
// Past ToolDetailSurface's fade threshold, so the body opens collapsed.
const LONG = Array.from({ length: 40 }, (_, i) => `line ${i}`).join("\n");
function run(output: string, output_truncated?: boolean): TimelineItem[] {
return [
{ seq: 1, type: "tool_use", tool: "exec_command", input: { command: "cat big.log" } },
{ seq: 2, type: "tool_result", tool: "exec_command", output, output_truncated },
];
}
function openStep(items: TimelineItem[]) {
renderDialog(items);
fireEvent.click(screen.getByRole("button", { name: /cat big\.log/ }));
}
it("remarks at the end of an output the record could not keep whole", () => {
openStep(run(SHORT, true));
expect(screen.getByText(/only the beginning was kept/i)).toBeInTheDocument();
});
it("says nothing about an output the daemon measured as complete", () => {
openStep(run(SHORT, false));
expect(screen.queryByText(/only the beginning was kept/i)).not.toBeInTheDocument();
});
// The remark answers "was that all of it?", which is a question the reader
// only has at the bottom. Showing it over a faded, half-shown body answers
// ahead of the question and puts the words nowhere near the end they describe.
it("waits for the body to be revealed before remarking", () => {
openStep(run(LONG, true));
expect(screen.queryByText(/only the beginning was kept/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
expect(screen.getByText(/only the beginning was kept/i)).toBeInTheDocument();
});
// Regression guard: a warning badge next to the tool name announced the
// caveat before the reader had asked the question.
it("keeps the remark out of the step header", () => {
openStep(run(SHORT, true));
const header = screen.getByRole("button", { name: "Copy this step" }).closest("div");
expect(header).toHaveTextContent("exec_command");
expect(header).not.toHaveTextContent(/only the beginning was kept/i);
});
// A stored tool result is capped at 8192 bytes upstream, so the render clip
// must never reach it: one remark, not a second reporting the rounding error.
it("leaves a stored tool result with exactly one remark", () => {
openStep(run("x".repeat(8192), true));
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
expect(screen.getByText(/only the beginning was kept/i)).toBeInTheDocument();
expect(screen.queryByText(/only the start is displayed/i)).not.toBeInTheDocument();
});
// Tool input has no server-side budget and keeps its existing render ceiling.
// Nothing about how long an input renders is this change's business.
it("still clips a tool input at its existing length", () => {
renderDialog([
{
seq: 1,
type: "tool_use",
tool: "exec_command",
input: { command: "echo", payload: "y".repeat(9000) },
},
]);
fireEvent.click(screen.getByRole("button", { name: /exec_command/ }));
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
const body = screen.getByText(/only the start is displayed/i);
expect(body.textContent).not.toMatch(/copy|whole record|everything that was saved/i);
});
// A record from before the flag existed says nothing at all. The viewer only
// speaks when a daemon measured the output and found bytes missing; it never
// volunteers that it cannot tell, in the transcript or above it.
it("stays silent on a record whose completeness was never measured", () => {
openStep(run(LONG, undefined));
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
expect(screen.queryByText(/only the beginning was kept/i)).not.toBeInTheDocument();
expect(screen.queryByText(/cannot be confirmed|completeness/i)).not.toBeInTheDocument();
});
});