nanobot/webui/src/tests/agent-activity-cluster.test.tsx
2026-05-24 19:43:20 +08:00

804 lines
24 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
const BLENDER_CLI_APP: CliAppInfo = {
name: "blender",
display_name: "Blender",
category: "3d",
description: "3D creation",
requires: "",
source: "harness",
entry_point: "cli-anything-blender",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/blender.svg",
brand_color: "#E87D0D",
skill_installed: true,
};
const BROWSERBASE_MCP: McpPresetInfo = {
name: "browserbase",
display_name: "Browserbase",
category: "browser",
description: "Cloud browser automation",
docs_url: "https://docs.browserbase.com",
transport: "streamableHttp",
requires: "Browserbase API key",
note: "",
install_supported: true,
installed: true,
configured: true,
available: true,
status: "configured",
logo_url: "https://example.invalid/browserbase.svg",
brand_color: "#111827",
required_fields: [],
connection_summary: "https://mcp.browserbase.com/mcp",
};
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: `thinking${extraReasoning}`,
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
createdAt: 2,
},
];
if (extraTool) rows.push(extraTool);
return rows;
}
function installAnimationFrameQueue() {
const originalRequest = window.requestAnimationFrame;
const originalCancel = window.cancelAnimationFrame;
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextId;
nextId += 1;
callbacks.set(id, callback);
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
callbacks.delete(id);
}) as typeof window.cancelAnimationFrame;
return {
flush() {
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) callback(0);
},
restore() {
window.requestAnimationFrame = originalRequest;
window.cancelAnimationFrame = originalCancel;
},
};
}
function setScrollGeometry(
element: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: geometry.scrollHeight },
clientHeight: { configurable: true, value: geometry.clientHeight },
scrollTop: {
configurable: true,
value: geometry.scrollTop ?? element.scrollTop,
writable: true,
},
});
}
function installReducedMotion() {
const original = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
addEventListener: () => {},
removeEventListener: () => {},
}),
});
return () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: original,
});
};
}
describe("AgentActivityCluster", () => {
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(880);
} finally {
raf.restore();
}
});
it("follows new reasoning and tool activity while the user is at the bottom", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
rerender(
<AgentActivityCluster
messages={activityMessages(" with more detail", {
id: "t2",
role: "tool",
kind: "trace",
content: "open_browser()",
traces: ["open_browser()"],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(1380);
} finally {
raf.restore();
}
});
it("does not pull the user down after they scroll up inside the activity pane", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
scrollport.scrollTop = 100;
fireEvent.scroll(scrollport);
rerender(
<AgentActivityCluster
messages={activityMessages(" still streaming")}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(100);
} finally {
raf.restore();
}
});
it("turns the live reasoning marker into an animated check when thinking completes", async () => {
const liveReasoning: UIMessage = {
id: "r-check",
role: "assistant",
content: "",
reasoning: "checking a source",
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
};
const { rerender } = render(
<AgentActivityCluster
messages={[liveReasoning]}
isTurnStreaming
hasBodyBelow
/>,
);
expect(screen.getByTestId("activity-reasoning-marker")).toHaveAttribute("data-state", "thinking");
rerender(
<AgentActivityCluster
messages={[{
...liveReasoning,
reasoningStreaming: false,
isStreaming: false,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
const marker = screen.getByTestId("activity-reasoning-marker");
expect(marker).toHaveAttribute("data-state", "done");
expect(marker.querySelector("svg")).toBeInTheDocument();
await waitFor(() => expect(marker).toHaveClass("animate-in"));
});
it("briefly shows completed activity, then auto-collapses before the answer", () => {
vi.useFakeTimers();
const liveReasoning: UIMessage = {
id: "r-collapse",
role: "assistant",
content: "",
reasoning: "checking files",
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
};
try {
const { rerender } = render(
<AgentActivityCluster
messages={[liveReasoning]}
isTurnStreaming
hasBodyBelow
/>,
);
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
rerender(
<AgentActivityCluster
messages={[{
...liveReasoning,
reasoningStreaming: false,
isStreaming: false,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(901);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /1 steps/i })).toHaveAttribute(
"aria-expanded",
"false",
);
} finally {
vi.useRealTimers();
}
});
it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute(
"aria-label",
"/Users/renxubin/project/src/app.tsx",
);
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx");
for (const diffPair of screen.getAllByTestId("activity-diff-pair")) {
expect(diffPair).toHaveClass("items-baseline");
expect(diffPair).toHaveClass("leading-[inherit]");
expect(diffPair.className).not.toContain("translate-y");
}
await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
<AgentActivityCluster
messages={[{
id: "t-cli",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
/>,
);
const cliRuns = screen.getByTestId("activity-cli-runs");
expect(cliRuns).toHaveTextContent("Using");
expect(cliRuns).toHaveTextContent("@blender");
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
});
it("keeps CLI rows in chronological trace order", () => {
const cliArgs = { name: "blender", args: ["project", "new"], json: true };
const cliLine = `run_cli_app(${JSON.stringify(cliArgs)})`;
render(
<AgentActivityCluster
messages={[
{
id: "t-search",
role: "tool",
kind: "trace",
content: 'web_search({"query":"nanobot architecture"})',
traces: ['web_search({"query":"nanobot architecture"})'],
createdAt: 1,
},
{
id: "t-cli",
role: "tool",
kind: "trace",
content: cliLine,
traces: [cliLine],
toolEvents: [{
phase: "end",
call_id: "call-blender",
name: "run_cli_app",
arguments: cliArgs,
}],
createdAt: 2,
},
{
id: "t-fetch",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://example.com/diagram"})',
traces: ['web_fetch({"url":"https://example.com/diagram"})'],
createdAt: 3,
},
]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
/>,
);
const searchRow = screen.getByText("Searching").closest("li");
const cliRow = screen.getByText("@blender").closest("li");
const fetchRow = screen.getByText("Reading").closest("li");
expect(searchRow).not.toBeNull();
expect(cliRow).not.toBeNull();
expect(fetchRow).not.toBeNull();
expect(searchRow!.compareDocumentPosition(cliRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(cliRow!.compareDocumentPosition(fetchRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("labels rejected CLI app calls as failed instead of ran", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-cli-fail",
role: "tool",
kind: "trace",
content: 'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
traces: ['run_cli_app({"name":"github","args":["repo","view"],"json":"true"})'],
toolEvents: [
{
phase: "error",
call_id: "call-github",
name: "run_cli_app",
arguments: { name: "github", args: ["repo", "view"], json: "true" },
error: "Error: CLI app 'github' not found",
},
],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed @github/i }));
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Failed");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found");
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
});
it("renders MCP preset tool calls as branded activity rows", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-mcp",
role: "tool",
kind: "trace",
content: "mcp_browserbase_browser_navigate()",
traces: ["mcp_browserbase_browser_navigate({\"url\":\"https://example.com\"})"],
toolEvents: [
{
phase: "start",
call_id: "call-browserbase",
name: "mcp_browserbase_browser_navigate",
arguments: { url: "https://example.com" },
},
],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
mcpPresets={[BROWSERBASE_MCP]}
/>,
);
const mcpRuns = screen.getByTestId("activity-mcp-runs");
expect(mcpRuns).toHaveTextContent("Using");
expect(mcpRuns).toHaveTextContent("Browserbase");
expect(mcpRuns).toHaveTextContent("browser_navigate");
expect(mcpRuns).toHaveTextContent("url: https://example.com");
expect(screen.getByTestId("activity-mcp-logo-browserbase")).toBeInTheDocument();
expect(screen.queryByText(/mcp_browserbase_browser_navigate/)).not.toBeInTheDocument();
});
it("renders public web fetch traces with the site favicon", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-web-fetch",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})',
traces: ['web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})'],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const favicon = screen.getByTestId("activity-web-favicon-auth0.com");
expect(favicon.querySelector("img")?.getAttribute("src")).toContain("auth0.com");
expect(screen.getByText("Reading")).toBeInTheDocument();
expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
});
it("renders plain-text fetch progress with the site favicon", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-web-fetch-text",
role: "tool",
kind: "trace",
content: "Fetching https://auth0.com/blog/jwt-security-best-practices",
traces: ["Fetching https://auth0.com/blog/jwt-security-best-practices"],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("activity-web-favicon-auth0.com")).toBeInTheDocument();
expect(screen.getByText("Reading")).toBeInTheDocument();
expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
});
it("does not request favicons for private web fetch targets", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-web-fetch-local",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"http://localhost:3000/dashboard"})',
traces: ['web_fetch({"url":"http://localhost:3000/dashboard"})'],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.queryByTestId("activity-web-favicon-localhost")).not.toBeInTheDocument();
expect(screen.getByText("url: http://localhost:3000/dashboard")).toBeInTheDocument();
});
it("summarizes long shell traces instead of dumping scripts", () => {
const command = [
"cat << 'EOF' | bash",
"SECRET_TOKEN=sk-test",
"for id in m1 m2 m3; do",
" echo done $id",
"done",
"EOF",
].join("\n");
const line = `exec(${JSON.stringify({ command })})`;
render(
<AgentActivityCluster
messages={[{
id: "t-shell",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow
/>,
);
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
expect(screen.getByText("Shell")).toBeInTheDocument();
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Done$/)).not.toBeInTheDocument();
});
it("does not render zero diff counters for completed edits", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 0,
deleted: 0,
approximate: false,
status: "done",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.queryByText("+0")).not.toBeInTheDocument();
expect(screen.queryByText("-0")).not.toBeInTheDocument();
});
it("drops stale pathless pending edits after the turn completes", () => {
render(
<AgentActivityCluster
messages={[{
id: "t1",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 98,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByRole("button", { name: /preparing edit/i })).not.toBeInTheDocument();
expect(screen.queryByText("+98")).not.toBeInTheDocument();
expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument();
});
it("renders pending file edit placeholders before the path is known", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 0,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [
{
call_id: "call-edit-1",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 2,
deleted: 1,
approximate: false,
status: "done",
},
{
call_id: "call-edit-2",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "patch failed",
},
{
call_id: "call-edit-3",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 6,
deleted: 6,
approximate: false,
status: "done",
},
],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(1);
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
});