feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
import type { UIMessage } from "@/lib/types";
const trace = 'web_search({"query":"same query"})';
function progressMessage(id: string, phase: "start" | "end" | "error"): UIMessage {
return {
id,
role: "tool",
kind: "trace",
content: trace,
traces: [trace],
toolEvents: [{ phase, name: "web_search", arguments: { query: "same query" } }],
createdAt: 1,
};
}
describe("activity message coalescing", () => {
it("folds persisted start and terminal progress into one activity", () => {
const result = coalesceActivityMessages([
progressMessage("start", "start"),
progressMessage("end", "end"),
]);
expect(result).toHaveLength(1);
expect(result[0].toolEvents?.[0]?.phase).toBe("end");
});
it("keeps repeated completed calls as separate activities", () => {
const result = coalesceActivityMessages([
progressMessage("first", "end"),
progressMessage("second", "end"),
]);
expect(result).toHaveLength(2);
});
});
+590 -135
View File
@@ -340,7 +340,7 @@ describe("AgentActivityCluster", () => {
vi.advanceTimersByTime(901);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /1 steps/i })).toHaveAttribute(
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
"aria-expanded",
"false",
);
@@ -401,7 +401,7 @@ describe("AgentActivityCluster", () => {
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
});
it("renders file edit totals and a compact expanded file list", async () => {
it("renders file edits as one-line activity rows", async () => {
const restoreMotion = installReducedMotion();
try {
render(
@@ -430,33 +430,25 @@ describe("AgentActivityCluster", () => {
/>,
);
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");
expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
expect(screen.queryByTestId("activity-header-file-reference")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
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);
});
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.getByText("-3")).toBeInTheDocument();
} finally {
restoreMotion();
}
});
it("renders GitHub-like file edit diffs when the local preference is enabled", () => {
it("keeps file edits flat even when the legacy diff preference is enabled", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
@@ -496,20 +488,17 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.queryByText("@@ -10,2 +10,2 @@")).not.toBeInTheDocument();
expect(screen.getByText("return <Old />;")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
expect(screen.getAllByText("11").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByTestId("activity-header-file-reference")).toHaveLength(1);
expect(screen.queryByTestId("activity-file-reference")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("return <Old />;")).not.toBeInTheDocument();
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("renders folded separators between separated file edit hunks", () => {
it("does not render diff hunks inside the activity list", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
@@ -555,17 +544,16 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent(
"21 unchanged lines hidden",
);
expect(screen.queryByTestId("file-edit-diff-hunk-gap")).not.toBeInTheDocument();
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
expect(screen.getByText("return newSecond;")).toBeInTheDocument();
expect(screen.queryByText("return newSecond;")).not.toBeInTheDocument();
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("keeps long file edit diffs collapsed until opened", () => {
it("summarizes long file edit diffs without an expansion control", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
@@ -604,40 +592,16 @@ describe("AgentActivityCluster", () => {
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(toggle).toHaveTextContent("165 lines");
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("line-160")).toBeInTheDocument();
expect(screen.queryByText("line-161")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(screen.getByTestId("file-edit-diff-expand-lines"));
expect(screen.getByText("line-165")).toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-collapse-lines")).toHaveTextContent("Show fewer lines");
fireEvent.click(screen.getByTestId("file-edit-diff-collapse-lines"));
expect(screen.queryByText("line-165")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.getByText("+165")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("does not mount collapsed file edit diff rows until opened", () => {
it("ignores the legacy collapsed diff mode in the activity list", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
@@ -677,24 +641,16 @@ describe("AgentActivityCluster", () => {
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View diff");
expect(toggle).toHaveTextContent("3 lines");
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("offers the file preview entry point when a diff payload is truncated", () => {
it("opens the edited file directly instead of expanding a truncated diff", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
@@ -735,15 +691,9 @@ describe("AgentActivityCluster", () => {
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(screen.queryByTestId("file-edit-diff-toggle")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByTestId("file-edit-diff-truncated")).toHaveTextContent("Diff truncated");
fireEvent.click(screen.getByTestId("file-edit-diff-open-file"));
fireEvent.click(screen.getByTestId("activity-file-reference"));
expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx");
} finally {
@@ -778,8 +728,8 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByRole("button", { name: /deleted angry-birds\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edited angry-birds\.html/i })).not.toBeInTheDocument();
expect(screen.getByText("Deleted")).toBeInTheDocument();
expect(screen.queryByText("Edited")).not.toBeInTheDocument();
});
it("renders file-only edits without a redundant disclosure", () => {
@@ -812,7 +762,8 @@ describe("AgentActivityCluster", () => {
expect(screen.queryByRole("button", { name: /edited app\.tsx/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByText("Edited")).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.queryByTestId("activity-header-file-reference")).not.toBeInTheDocument();
expect(screen.getByTestId("activity-file-reference")).toHaveTextContent("src/app.tsx");
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.getByText("-3")).toBeInTheDocument();
});
@@ -879,10 +830,7 @@ describe("AgentActivityCluster", () => {
/>,
);
const cliRuns = screen.getByTestId("activity-cli-runs");
expect(cliRuns).toHaveTextContent("Using");
expect(cliRuns).toHaveTextContent("@blender");
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
expect(screen.getByText("Using Blender · --json --background scene.blend")).toBeInTheDocument();
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
});
@@ -930,9 +878,9 @@ describe("AgentActivityCluster", () => {
/>,
);
const searchRow = screen.getByText("Searching").closest("li");
const cliRow = screen.getByText("@blender").closest("li");
const fetchRow = screen.getByText("Reading").closest("li");
const searchRow = screen.getByText("Searched nanobot architecture").closest('[data-testid="activity-step"]');
const cliRow = screen.getByText("Used Blender · --json project new").closest('[data-testid="activity-step"]');
const fetchRow = screen.getByText("example.com/diagram").closest('[data-testid="activity-step"]');
expect(searchRow).not.toBeNull();
expect(cliRow).not.toBeNull();
@@ -941,6 +889,181 @@ describe("AgentActivityCluster", () => {
expect(cliRow!.compareDocumentPosition(fetchRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("renders web search results as lightweight branded source rows", () => {
const line = 'web_search({"query":"agent frameworks"})';
render(
<AgentActivityCluster
messages={[{
id: "t-web-search-results",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "end",
call_id: "call-web-search",
name: "web_search",
arguments: { query: "agent frameworks" },
result: [
"Results for: agent frameworks",
"",
"1. OpenAI Agents SDK",
" https://openai.com/index/new-tools-for-building-agents/?utm_source=test",
" Build and deploy agentic applications.",
"2. Building effective agents",
" https://www.anthropic.com/engineering/building-effective-agents",
" Practical patterns for reliable agents.",
"3. Internal dashboard",
" http://localhost:3000/search",
].join("\n"),
}],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Searched agent frameworks")).toBeInTheDocument();
expect(screen.queryByText("2 sources")).not.toBeInTheDocument();
const openAiLink = screen.getByText("OpenAI Agents SDK").closest("a");
const anthropicLink = screen.getByText("Building effective agents").closest("a");
expect(openAiLink).toHaveAttribute(
"href",
"https://openai.com/index/new-tools-for-building-agents/",
);
expect(openAiLink).not.toHaveAttribute("title");
expect(anthropicLink).toHaveAttribute(
"href",
"https://www.anthropic.com/engineering/building-effective-agents",
);
expect(screen.getByText("openai.com/index/new-tools-for-building-agents")).toBeInTheDocument();
expect(screen.getByText("anthropic.com/engineering/building-effective-agents")).toBeInTheDocument();
expect(screen.getByTestId("activity-web-favicon-openai.com")).toBeInTheDocument();
expect(screen.getByTestId("activity-web-favicon-anthropic.com")).toBeInTheDocument();
expect(screen.queryByText("Internal dashboard")).not.toBeInTheDocument();
expect(screen.queryByText("Build and deploy agentic applications.")).not.toBeInTheDocument();
const searchStep = screen.getByText("Searched agent frameworks").closest(
'[data-testid="activity-step"]',
);
const openAiStep = openAiLink!.closest('[data-testid="activity-step"]');
const anthropicStep = anthropicLink!.closest('[data-testid="activity-step"]');
expect(openAiStep).toContainElement(
screen.getByText("openai.com/index/new-tools-for-building-agents"),
);
expect(anthropicStep).toContainElement(
screen.getByText("anthropic.com/engineering/building-effective-agents"),
);
expect(searchStep?.parentElement).toBe(openAiStep?.parentElement);
expect(searchStep?.parentElement).toBe(anthropicStep?.parentElement);
expect(searchStep?.parentElement?.querySelector("ul, li, section")).toBeNull();
expect(screen.getAllByTestId("activity-step")).toHaveLength(3);
});
it("redacts credentials from web search queries, titles, and links", () => {
const query = "release notes access_token=signed-secret";
const line = `web_search(${JSON.stringify({ query })})`;
render(
<AgentActivityCluster
messages={[{
id: "t-web-search-secret",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "end",
call_id: "call-web-search-secret",
name: "web_search",
arguments: { query },
result: [
"1. Release sk-proj-secret1234",
" https://example.com/release?api_key=url-secret#details",
].join("\n"),
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByText(/signed-secret|secret1234|url-secret/)).not.toBeInTheDocument();
expect(screen.getByText("Searched release notes access_token=<redacted>")).toBeInTheDocument();
expect(screen.getByText("Release <redacted>")).toBeInTheDocument();
expect(screen.getByText("Release <redacted>").closest("a")).toHaveAttribute(
"href",
"https://example.com/release",
);
});
it("renders persisted search progress as one human-readable action", () => {
const line = 'web_search({"query":"site:linkedin.com/company Evomap startup"})';
render(
<AgentActivityCluster
messages={[
{
id: "search-start",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "start",
name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" },
}],
createdAt: 1,
},
{
id: "search-end",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "error",
name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" },
error: "Search provider rate limited the request",
}],
createdAt: 2,
},
]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getAllByTestId("activity-step")).toHaveLength(1);
expect(screen.getByText("Could not search LinkedIn · Evomap startup")).toBeInTheDocument();
expect(screen.queryByText(/site:linkedin/i)).not.toBeInTheDocument();
expect(screen.queryByText("Web research")).not.toBeInTheDocument();
});
it("renders reasoning as a single flat activity row", () => {
render(
<AgentActivityCluster
messages={[{
id: "r-flat",
role: "assistant",
content: "",
reasoning: "**Planning** a focused search\nfor official sources",
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Planning a focused search for official sources")).toBeInTheDocument();
expect(screen.queryByText("Thinking…")).not.toBeInTheDocument();
expect(screen.queryByText("Thinking")).not.toBeInTheDocument();
});
it("labels rejected CLI app calls as failed instead of ran", () => {
render(
<AgentActivityCluster
@@ -966,11 +1089,14 @@ describe("AgentActivityCluster", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed @github/i }));
fireEvent.click(screen.getByRole("button", { name: "Worked" }));
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");
const row = screen.getByText("Could not use GitHub · --json repo view").closest(
'[data-testid="activity-step"]',
);
expect(row).toBeInTheDocument();
expect(row).not.toHaveAttribute("title");
expect(screen.queryByText("Error: CLI app 'github' not found")).not.toBeInTheDocument();
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
});
@@ -999,11 +1125,9 @@ describe("AgentActivityCluster", () => {
/>,
);
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.getByText("Opening example.com · Browserbase")).toBeInTheDocument();
expect(screen.queryByText("Using")).not.toBeInTheDocument();
expect(screen.queryByText(/browser_navigate/)).not.toBeInTheDocument();
expect(screen.getByTestId("activity-mcp-logo-browserbase")).toBeInTheDocument();
expect(screen.queryByText(/mcp_browserbase_browser_navigate/)).not.toBeInTheDocument();
});
@@ -1025,9 +1149,11 @@ describe("AgentActivityCluster", () => {
);
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();
expect(favicon).toHaveAttribute("src", expect.stringContaining("auth0.com"));
const row = screen.getByText("auth0.com/blog/jwt-security-best-practices").closest(
'[data-testid="activity-step"]',
);
expect(row).toHaveTextContent("Reading");
});
it("renders plain-text fetch progress with the site favicon", () => {
@@ -1047,8 +1173,42 @@ describe("AgentActivityCluster", () => {
);
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();
const row = screen.getByText("auth0.com/blog/jwt-security-best-practices").closest(
'[data-testid="activity-step"]',
);
expect(row).toHaveTextContent("Reading");
});
it("renders a completed fetch as one linked title and URL row", () => {
const line = 'web_fetch({"url":"https://example.com/docs"})';
render(
<AgentActivityCluster
messages={[{
id: "t-web-fetch-title",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "end",
call_id: "fetch-title",
name: "web_fetch",
arguments: { url: "https://example.com/docs" },
result: "# Example documentation\n\nPage body",
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const title = screen.getByText("Example documentation");
const url = screen.getByText("example.com/docs");
const row = title.closest('[data-testid="activity-step"]');
expect(row).toContainElement(url);
expect(title.closest("a")).toHaveAttribute("href", "https://example.com/docs");
expect(screen.getAllByTestId("activity-step")).toHaveLength(1);
});
it("does not request favicons for private web fetch targets", () => {
@@ -1068,10 +1228,11 @@ describe("AgentActivityCluster", () => {
);
expect(screen.queryByTestId("activity-web-favicon-localhost")).not.toBeInTheDocument();
expect(screen.getByText("url: http://localhost:3000/dashboard")).toBeInTheDocument();
expect(screen.getByText("Reading Private address")).toBeInTheDocument();
expect(screen.queryByText("http://localhost:3000/dashboard")).not.toBeInTheDocument();
});
it("shows readable argument previews for generic tool traces", () => {
it("presents generic tool traces as one-line semantic actions", () => {
render(
<AgentActivityCluster
messages={[{
@@ -1091,9 +1252,109 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("find_files query: thread · glob: *.tsx")).toBeInTheDocument();
expect(screen.getByText("list_dir path: memory")).toBeInTheDocument();
expect(screen.getByText("grep pattern: dream_cursor")).toBeInTheDocument();
expect(screen.getByText("Found files *.tsx")).toBeInTheDocument();
expect(screen.getByText("Listed files memory")).toBeInTheDocument();
expect(screen.getByText("Searching files “dream_cursor")).toBeInTheDocument();
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
expect(document.querySelector("details")).not.toBeInTheDocument();
});
it("groups repeated searches over internal tool results without exposing raw paths", () => {
const pattern = "Jul (1[0-7]), 2026|July (1[0-7]), 2026|2026-07-(1[0-7])";
const secondPattern = "Anthropic|OpenAI|DeepMind";
const firstPath = "/Users/test/.nanobot/workspace/.nanobot/tool-results/websocket_session/call_first-result.txt";
const secondPath = "/Users/test/.nanobot/workspace/.nanobot/tool-results/websocket_session/call_second-result.txt";
const traces = [
`grep(${JSON.stringify({ pattern, path: firstPath })})`,
`grep(${JSON.stringify({ pattern: secondPattern, path: secondPath })})`,
];
render(
<AgentActivityCluster
messages={[{
id: "t-grouped-grep",
role: "tool",
kind: "trace",
content: traces.join("\n"),
traces,
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const run = screen.getByText(/Reviewed sources.*2 files/).closest('[data-testid="activity-step"]');
expect(run).toBeInTheDocument();
expect(screen.queryByText(firstPath)).not.toBeInTheDocument();
expect(screen.queryByText(secondPath)).not.toBeInTheDocument();
expect(screen.queryByText(pattern)).not.toBeInTheDocument();
expect(screen.queryByText(secondPattern)).not.toBeInTheDocument();
expect(screen.queryByText("call_first-result.txt")).not.toBeInTheDocument();
expect(screen.queryByText("call_second-result.txt")).not.toBeInTheDocument();
});
it("surfaces generic tool failures without dumping their arguments", () => {
const args = { pattern: "needle", path: "workspace/file.txt" };
const line = `grep(${JSON.stringify(args)})`;
render(
<AgentActivityCluster
messages={[{
id: "t-grep-error",
role: "tool",
kind: "trace",
content: line,
traces: [line],
toolEvents: [{
phase: "error",
call_id: "call-grep-error",
name: "grep",
arguments: args,
error: JSON.stringify({
message: "Permission denied",
headers: { Authorization: "Bearer sk-live-secret" },
token: "super-secret",
}),
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const row = screen.getByText("Could not search files “needle”").closest(
'[data-testid="activity-step"]',
);
expect(row).toBeInTheDocument();
expect(row).not.toHaveAttribute("title");
expect(screen.queryByText(/Permission denied/)).not.toBeInTheDocument();
expect(screen.queryByText(/super-secret/)).not.toBeInTheDocument();
expect(screen.queryByText(/sk-live-secret/)).not.toBeInTheDocument();
expect(screen.queryByText(/Authorization/)).not.toBeInTheDocument();
});
it("redacts credentials from generic tool URL details", () => {
const line = 'download_asset({"url":"https://user:password@example.com/file?access_token=signed-secret&format=png"})';
render(
<AgentActivityCluster
messages={[{
id: "t-generic-url-secret",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByText(/password|signed-secret/)).not.toBeInTheDocument();
expect(screen.getByText("Completed Download asset")).toBeInTheDocument();
expect(screen.queryByText(/example\.com/)).not.toBeInTheDocument();
});
it("summarizes long shell traces instead of dumping scripts", () => {
@@ -1121,15 +1382,36 @@ describe("AgentActivityCluster", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
fireEvent.click(screen.getByRole("button", { name: "Worked" }));
expect(screen.getByText("Command")).toBeInTheDocument();
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
expect(screen.getByText("Ran command 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("presents time checks as an intent instead of a raw command", () => {
const line = `exec(${JSON.stringify({ command: "date '+%Y-%m-%d %H:%M:%S %Z'" })})`;
render(
<AgentActivityCluster
messages={[{
id: "t-date",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Checking current time")).toBeInTheDocument();
expect(screen.queryByText(/%Y-%m-%d/)).not.toBeInTheDocument();
expect(screen.queryByText("Web")).not.toBeInTheDocument();
});
it("does not render zero diff counters for completed edits", () => {
render(
<AgentActivityCluster
@@ -1156,7 +1438,7 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.getByText("Edited")).toBeInTheDocument();
expect(screen.queryByText("+0")).not.toBeInTheDocument();
expect(screen.queryByText("-0")).not.toBeInTheDocument();
});
@@ -1220,7 +1502,6 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
@@ -1251,9 +1532,10 @@ describe("AgentActivityCluster", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed angry-birds\.html/i }));
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
const row = screen.getByText("Could not edit").closest('[data-testid="activity-step"]');
expect(row).toBeInTheDocument();
expect(row).not.toHaveAttribute("title");
expect(screen.queryByText("Target text was not found in angry-birds.html.")).not.toBeInTheDocument();
});
it("keeps permission errors readable for failed file edits", () => {
@@ -1283,9 +1565,10 @@ describe("AgentActivityCluster", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed composition\.html/i }));
expect(screen.getByText("No permission to change this location.")).toBeInTheDocument();
const row = screen.getByText("Could not edit").closest('[data-testid="activity-step"]');
expect(row).toBeInTheDocument();
expect(row).not.toHaveAttribute("title");
expect(screen.queryByText("No permission to change this location.")).not.toBeInTheDocument();
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
});
@@ -1358,18 +1641,18 @@ describe("AgentActivityCluster", () => {
/>,
);
const toggle = screen.getByRole("button", { name: "Edited 3 changes" });
expect(toggle).toHaveTextContent("+8");
expect(toggle).toHaveTextContent("-7");
fireEvent.click(toggle);
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(3);
expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true);
expect(screen.getByText("patch failed")).toBeInTheDocument();
expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
expect(screen.getByText("<canvas />")).toBeInTheDocument();
expect(screen.getByText("const fps = 60;")).toBeInTheDocument();
const failedRow = screen.getByText("Could not edit").closest(
'[data-testid="activity-step"]',
);
expect(failedRow).toBeInTheDocument();
expect(failedRow).not.toHaveAttribute("title");
expect(screen.queryByText("patch failed")).not.toBeInTheDocument();
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("<canvas />")).not.toBeInTheDocument();
expect(screen.queryByText("const fps = 60;")).not.toBeInTheDocument();
expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
@@ -1379,7 +1662,7 @@ describe("AgentActivityCluster", () => {
}
});
it("renders tool event embeds as inline activity evidence", () => {
it("keeps tool event embeds out of the flat activity list", () => {
render(
<AgentActivityCluster
messages={[{
@@ -1406,15 +1689,91 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Web")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Homepage screenshot" })).toHaveAttribute(
"src",
"/api/media/signed/screenshot.png",
);
expect(screen.queryByText("Web")).not.toBeInTheDocument();
const row = screen.getByText("example.com").closest('[data-testid="activity-step"]');
expect(row).toHaveTextContent("Read");
expect(screen.queryByTestId("activity-evidence-preview")).not.toBeInTheDocument();
expect(screen.queryByText(/Found image/i)).not.toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Homepage screenshot" })).not.toBeInTheDocument();
});
it("shows missing evidence as a file-safe placeholder", () => {
it("keeps image generation status to one activity line", () => {
const message: UIMessage = {
id: "image-run",
role: "tool",
kind: "trace",
content: 'generate_image({"prompt":"an orange nanobot on a desk","aspect_ratio":"4:3"})',
traces: ['generate_image({"prompt":"an orange nanobot on a desk","aspect_ratio":"4:3"})'],
toolEvents: [{
phase: "start",
call_id: "image-call",
name: "generate_image",
arguments: { prompt: "an orange nanobot on a desk", aspect_ratio: "4:3" },
}],
createdAt: 1,
};
const { rerender } = render(
<AgentActivityCluster messages={[message]} isTurnStreaming hasBodyBelow={false} />,
);
expect(screen.getByText("Generating image")).toBeInTheDocument();
rerender(
<AgentActivityCluster
messages={[{
...message,
toolEvents: [{
...message.toolEvents![0],
phase: "end",
files: [{
url: "/api/media/signed/generated.png",
name: "generated.png",
type: "image/png",
}],
}],
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Generated image")).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "generated.png" })).not.toBeInTheDocument();
});
it("keeps image-generation failures visible and actionable", () => {
render(
<AgentActivityCluster
messages={[{
id: "image-error",
role: "tool",
kind: "trace",
content: 'generate_image({"prompt":"a launch poster"})',
traces: ['generate_image({"prompt":"a launch poster"})'],
toolEvents: [{
phase: "error",
call_id: "image-error-call",
name: "generate_image",
arguments: { prompt: "a launch poster" },
error: "Image provider quota exceeded",
}],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Could not generate image")).toBeInTheDocument();
const row = screen.getByText("Could not generate image").closest(
'[data-testid="activity-step"]',
);
expect(row).toBeInTheDocument();
expect(row).not.toHaveAttribute("title");
expect(screen.queryByText("Image provider quota exceeded")).not.toBeInTheDocument();
});
it("does not add a secondary evidence row when evidence is missing", () => {
render(
<AgentActivityCluster
messages={[{
@@ -1437,8 +1796,104 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Vision")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByText("missing.png")).toBeInTheDocument();
expect(screen.queryByText("Vision")).not.toBeInTheDocument();
expect(screen.getByText("Captured screenshot")).toBeInTheDocument();
expect(screen.queryByTestId("activity-evidence-preview")).not.toBeInTheDocument();
expect(screen.queryByText("missing.png")).not.toBeInTheDocument();
});
it("keeps every default activity action on one structural line", () => {
render(
<AgentActivityCluster
messages={[
{
id: "reasoning-line",
role: "assistant",
content: "",
reasoning: "**Planning** the next step\nwithout a nested title",
reasoningStreaming: false,
createdAt: 1,
},
{
id: "tool-line",
role: "tool",
kind: "trace",
content: 'grep({"pattern":"needle","path":"workspace/file.txt"})',
traces: ['grep({"pattern":"needle","path":"workspace/file.txt"})'],
createdAt: 2,
},
{
id: "fetch-line",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://example.com/docs"})',
traces: ['web_fetch({"url":"https://example.com/docs"})'],
createdAt: 3,
},
]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const steps = screen.getAllByTestId("activity-step");
expect(steps.length).toBeGreaterThanOrEqual(3);
for (const step of steps) {
expect(step).toHaveClass("grid-cols-[1.125rem_minmax(0,1fr)]");
const line = step.children[1]?.firstElementChild;
expect(line).toHaveClass("overflow-hidden");
expect(line).toHaveClass("whitespace-nowrap");
expect(step.querySelector("br")).not.toBeInTheDocument();
expect(step.querySelector('[data-testid="activity-evidence-preview"]')).not.toBeInTheDocument();
}
expect(document.querySelector("details")).not.toBeInTheDocument();
expect(document.querySelector("ul, li, section")).not.toBeInTheDocument();
});
it("does not expose tool inputs or credentials in the activity surface", () => {
const cliLine = 'run_cli_app({"name":"blender","args":["--token","xoxb-1234567890-secret","render"],"json":true})';
const mcpLine = 'mcp_browserbase_browser_fill({"element":"Password","text":"mcp-private-value"})';
const genericLine = 'third_party_sync({"token":"sk-proj-1234567890-secret","payload":"private-payload"})';
const { container } = render(
<AgentActivityCluster
messages={[
{
id: "private-cli",
role: "tool",
kind: "trace",
content: cliLine,
traces: [cliLine],
createdAt: 1,
},
{
id: "private-mcp",
role: "tool",
kind: "trace",
content: mcpLine,
traces: [mcpLine],
createdAt: 2,
},
{
id: "private-generic",
role: "tool",
kind: "trace",
content: genericLine,
traces: [genericLine],
createdAt: 3,
},
]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
mcpPresets={[BROWSERBASE_MCP]}
/>,
);
expect(container.innerHTML).not.toContain("xoxb-1234567890-secret");
expect(container.innerHTML).not.toContain("mcp-private-value");
expect(container.innerHTML).not.toContain("sk-proj-1234567890-secret");
expect(container.innerHTML).not.toContain("private-payload");
expect(container.textContent).not.toMatch(/run_cli_app\(|browser_fill\(|third_party_sync\(/);
expect(container.textContent).toMatch(/<redacted>|••••/);
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { redactActivityText } from "@/components/thread/activity/activity-text";
import {
describeGenericToolRun,
parseGenericToolTrace,
type GenericToolStatus,
} from "@/components/thread/activity/generic-tool-model";
function describeRun(line: string, status: GenericToolStatus = "done") {
const trace = parseGenericToolTrace(line);
expect(trace).not.toBeNull();
return describeGenericToolRun([{ trace: trace!, status }]);
}
describe("generic tool activity semantics", () => {
it.each([
['find_files({"glob":"*.tsx"})', "Found files", "*.tsx"],
['grep({"pattern":"dream_cursor"})', "Searched files", "“dream_cursor”"],
['list_dir({"path":"memory"})', "Listed files", "memory"],
['read_file({"path":"docs/guide.md"})', "Read file", "docs/guide.md"],
['memory_search({"query":"launch date"})', "Searched memory", "“launch date”"],
['generate_image({"prompt":"private launch art"})', "Generated image", ""],
['spawn({"label":"Research competitors","task":"private task"})', "Delegated task", "Research competitors"],
['message({"channel":"telegram","content":"private message"})', "Sent message", "telegram"],
['my({"action":"check","key":"context_window_tokens"})', "Checked agent settings", "context_window_tokens"],
['my({"action":"set","key":"model","value":"private-model"})', "Updated agent settings", "model"],
['cron({"action":"add","name":"Daily digest","message":"private prompt"})', "Scheduled automation", "Daily digest"],
['cron({"action":"remove","name":"Daily digest"})', "Removed automation", "Daily digest"],
['create_goal({"objective":"private objective","ui_summary":"Benchmark memory"})', "Started long task", "Benchmark memory"],
['update_goal({"action":"complete","recap":"private recap"})', "Updated long task", "complete"],
['write_stdin({"session_id":"session-1234567890-secret","chars":"private input"})', "Continued command", "session…ecret"],
['list_exec_sessions({})', "Checked running commands", ""],
['screenshot({"path":"artifacts/home.png"})', "Captured screenshot", ""],
['third_party_sync({"token":"secret","payload":"private payload"})', "Completed Third party sync", ""],
])("describes %s without exposing implementation syntax", (line, label, detail) => {
const presentation = describeRun(line);
expect(presentation.label).toBe(label);
expect(presentation.detail).toBe(detail);
expect(`${presentation.label} ${presentation.detail}`).not.toMatch(/[{}]|private|tool-results/);
});
it.each([
["running", "Generating image"],
["done", "Generated image"],
["error", "Could not generate image"],
] as const)("uses human status copy for %s tools", (status, label) => {
expect(describeRun('generate_image({"prompt":"private"})', status).label).toBe(label);
});
it("groups searches over collected sources without exposing absolute paths", () => {
const first = parseGenericToolTrace(
'grep({"pattern":"July","path":"/Users/test/.nanobot/tool-results/session/call_first.txt"})',
)!;
const second = parseGenericToolTrace(
'grep({"pattern":"OpenAI","path":"/Users/test/.nanobot/tool-results/session/call_second.txt"})',
)!;
const presentation = describeGenericToolRun([
{ trace: first, status: "done" },
{ trace: second, status: "done" },
]);
expect(presentation).toMatchObject({ label: "Reviewed sources", detail: "", aside: "2 files" });
expect(JSON.stringify(presentation)).not.toContain("/Users/test");
});
it("leaves specialized tools to their dedicated activity surfaces", () => {
for (const line of [
'web_search({"query":"nanobot"})',
'web_fetch({"url":"https://example.com"})',
'exec({"command":"date"})',
'write_file({"path":"README.md"})',
'edit_file({"path":"README.md"})',
'apply_patch({"patch":"private"})',
'run_cli_app({"name":"github"})',
'mcp_browser_click({"text":"private"})',
]) {
expect(parseGenericToolTrace(line)).toBeNull();
}
});
it.each([
["Authorization: Bearer top-secret-token", "Authorization: <redacted>"],
["API_KEY=sk-proj-1234567890abcdef", "API_KEY=<redacted>"],
["--token xoxb-1234567890-secret", "--token <redacted>"],
["https://user:password@example.com/file?access_token=signed-secret", "https://<redacted>@example.com/file?access_token=<redacted>"],
["github ghp_1234567890abcdefghijkl", "github <redacted>"],
["aws AKIA1234567890ABCDEF", "aws <redacted>"],
["telegram 123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd", "telegram <redacted>"],
])("redacts activity text before rendering: %s", (input, expected) => {
expect(redactActivityText(input)).toBe(expected);
});
});
+169 -3
View File
@@ -13,6 +13,52 @@ describe("MarkdownTextRenderer", () => {
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
});
it("does not render active URL protocols from untrusted markdown", () => {
const { container } = render(
<MarkdownTextRenderer>
{[
"[JavaScript](javascript:alert(1))",
"[Data](data:text/html,<script>alert(1)</script>)",
"![Unsafe image](javascript:alert(2))",
].join(" ")}
</MarkdownTextRenderer>,
);
expect(container).toHaveTextContent("JavaScript Data");
expect(container.querySelector("a")).toBeNull();
expect(container.querySelector("img")).toBeNull();
});
it("keeps safe external, mail, relative, and fragment links", () => {
render(
<MarkdownTextRenderer>
{[
"[HTTPS](https://example.com)",
"[Mail](mailto:hello@example.com)",
"[Relative](/docs/getting-started)",
"[Fragment](#install)",
].join(" ")}
</MarkdownTextRenderer>,
);
expect(screen.getByRole("link", { name: "HTTPS" })).toHaveAttribute(
"href",
"https://example.com",
);
expect(screen.getByRole("link", { name: "Mail" })).toHaveAttribute(
"href",
"mailto:hello@example.com",
);
expect(screen.getByRole("link", { name: "Relative" })).toHaveAttribute(
"href",
"/docs/getting-started",
);
expect(screen.getByRole("link", { name: "Fragment" })).toHaveAttribute(
"href",
"#install",
);
});
it("renders local file links as previewable file references", () => {
const onOpenFilePreview = vi.fn();
render(
@@ -264,7 +310,13 @@ describe("MarkdownTextRenderer", () => {
expect(favicon()).toHaveAttribute(
"src",
"https://www.savills.com.hk/favicon.ico",
"https://favicon.im/www.savills.com.hk?larger=true",
);
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64",
);
fireEvent.error(favicon()!);
@@ -276,7 +328,7 @@ describe("MarkdownTextRenderer", () => {
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64",
"https://www.savills.com.hk/favicon.ico",
);
fireEvent.error(favicon()!);
@@ -340,7 +392,7 @@ describe("MarkdownTextRenderer", () => {
expect(container).not.toHaveTextContent("</details>");
});
it("renders task list checkboxes as quiet status marks", () => {
it("renders task lists with compact static status markers", () => {
const { container } = render(
<MarkdownTextRenderer>
{"- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果"}
@@ -350,6 +402,120 @@ describe("MarkdownTextRenderer", () => {
expect(container.querySelectorAll("input[type='checkbox']")).toHaveLength(0);
expect(screen.getAllByTestId("markdown-task-checkbox")).toHaveLength(3);
expect(container.querySelectorAll(".task-list-item")).toHaveLength(3);
expect(screen.queryByRole("button", { name: /tasks/i })).not.toBeInTheDocument();
});
it("renders GFM tables in a responsive data surface", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"## Models\n\n| Model | Context | Price |\n| --- | ---: | ---: |\n| nanobot | 200k | $1 |\n\n## Notes"
}
</MarkdownTextRenderer>,
);
const surface = screen.getByTestId("markdown-data-table");
expect(surface).toHaveClass("overflow-x-auto", "rounded-lg", "mb-5");
expect(surface).toHaveAttribute("role", "region");
expect(surface).toHaveAttribute("tabindex", "0");
expect(surface).toHaveAccessibleName("Data table");
expect(screen.getByRole("table")).toHaveTextContent("nanobot");
expect(container.firstElementChild).toHaveClass("space-y-4");
expect(container.firstElementChild).not.toHaveClass("space-y-0");
});
it("uses Streamdown's incremental reveal while content is streaming", () => {
const { container } = render(
<MarkdownTextRenderer streaming></MarkdownTextRenderer>,
);
expect(container.firstElementChild).toHaveClass(
"[&>*:last-child]:after:content-[var(--streamdown-caret)]",
);
const animatedUnits = container.querySelectorAll<HTMLElement>("[data-sd-animate]");
expect(animatedUnits).toHaveLength(1);
expect(animatedUnits[0]).toHaveTextContent("春天");
expect(animatedUnits[0].getAttribute("style")).toContain("--sd-duration: 180ms");
});
it("removes animation markup when a streamed response completes", async () => {
const { container, rerender } = render(
<MarkdownTextRenderer streaming></MarkdownTextRenderer>,
);
expect(container.querySelector("[data-sd-animate]")).toBeInTheDocument();
rerender(<MarkdownTextRenderer></MarkdownTextRenderer>);
await waitFor(() => {
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
});
});
it("does not create one DOM node per CJK character for long responses", () => {
const { container } = render(
<MarkdownTextRenderer streaming>{"长".repeat(6_001)}</MarkdownTextRenderer>,
);
expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(1);
expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument();
});
it("repairs incomplete streaming markdown without exposing syntax fragments", () => {
const { container, rerender } = render(
<MarkdownTextRenderer streaming>{"**partial answer"}</MarkdownTextRenderer>,
);
expect(container).toHaveTextContent("partial answer");
expect(container).not.toHaveTextContent("**partial answer");
rerender(
<MarkdownTextRenderer streaming>
{"[OpenAI](https://openai.com"}
</MarkdownTextRenderer>,
);
expect(screen.queryByRole("link", { name: "OpenAI" })).not.toBeInTheDocument();
expect(container).toHaveTextContent("OpenAI");
rerender(
<MarkdownTextRenderer streaming>
{"[OpenAI](https://openai.com)"}
</MarkdownTextRenderer>,
);
expect(screen.getByRole("link", { name: "OpenAI" })).toHaveAttribute(
"href",
"https://openai.com",
);
rerender(
<MarkdownTextRenderer streaming highlightCode={false}>
{"```ts\nconst value = 1;"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
});
it("preserves semantic emphasis without leaking parser metadata into the DOM", () => {
render(
<MarkdownTextRenderer>
{"**Important** and *careful* with [links](https://example.com)."}
</MarkdownTextRenderer>,
);
expect(screen.getByText("Important").tagName).toBe("STRONG");
expect(screen.getByText("careful").tagName).toBe("EM");
expect(screen.getByRole("link", { name: "links" })).not.toHaveAttribute("node");
});
it("adds line numbers to multiline fenced code without changing inline code", () => {
render(
<MarkdownTextRenderer highlightCode={false}>
{"```ts\nconst one = 1;\nconst two = 2;\n```\n\nUse `one` next."}
</MarkdownTextRenderer>,
);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("one").tagName).toBe("CODE");
});
it("keeps dollar amounts from being parsed as inline math", () => {
+70 -44
View File
@@ -1,18 +1,29 @@
import { useEffect } from "react";
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarkdownText } from "@/components/MarkdownText";
const rendererSpy = vi.hoisted(() => vi.fn());
const rendererMountSpy = vi.hoisted(() => vi.fn());
const rendererControl = vi.hoisted(() => ({ failStreaming: false }));
vi.mock("@/components/MarkdownTextRenderer", () => ({
default: ({
default: function MockMarkdownTextRenderer({
children,
highlightCode,
streaming,
}: {
children: string;
highlightCode?: boolean;
}) => {
streaming?: boolean;
}) {
useEffect(() => {
rendererMountSpy();
}, []);
if (streaming && rendererControl.failStreaming) {
throw new Error("incomplete streaming markdown");
}
rendererSpy({ children, highlightCode });
return (
<div
@@ -26,61 +37,76 @@ vi.mock("@/components/MarkdownTextRenderer", () => ({
}));
describe("MarkdownText", () => {
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
rendererSpy.mockClear();
vi.useFakeTimers();
it("recovers markdown rendering when a failed streaming response completes", async () => {
rendererControl.failStreaming = true;
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const source = "## Final answer\n\nThis is **important**.";
try {
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
const { container, rerender } = render(
<MarkdownText streaming>{source}</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(container.querySelector(".streaming-text-fallback")?.textContent).toBe(source);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
rendererControl.failStreaming = false;
rerender(<MarkdownText>{source}</MarkdownText>);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(79);
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
expect(rendererSpy).toHaveBeenCalledTimes(2);
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
expect(screen.getByTestId("markdown-renderer").textContent).toBe(source);
} finally {
vi.useRealTimers();
rendererControl.failStreaming = false;
consoleError.mockRestore();
}
});
it("keeps very large streaming snippets plain until the final render", async () => {
it("forwards every provider update without an extra UI timer", async () => {
rendererSpy.mockClear();
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
});
it("keeps a healthy renderer mounted when streaming completes", async () => {
rendererMountSpy.mockClear();
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
rerender(<MarkdownText>hello world</MarkdownText>);
expect(rendererMountSpy).toHaveBeenCalledTimes(1);
});
it("defers syntax highlighting until the final render", async () => {
rendererSpy.mockClear();
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { describeMcpActivity } from "@/components/thread/activity/mcp-activity-model";
describe("describeMcpActivity", () => {
it.each([
["browser_navigate", { url: "https://example.com/docs" }, "done", "Opened", "example.com/docs"],
["browser_click", { element: "Submit" }, "running", "Clicking", "Submit"],
["browser_snapshot", {}, "done", "Inspected page", undefined],
["browser_screenshot", {}, "done", "Captured screenshot", undefined],
["browser_press_key", { key: "Enter" }, "error", "Could not press", "Enter"],
] as const)("turns %s into user-facing activity copy", (tool, args, status, action, target) => {
expect(describeMcpActivity(tool, args, status)).toEqual({ action, target });
});
it("does not expose entered text in the activity timeline", () => {
expect(describeMcpActivity(
"browser_fill",
{ element: "Password", text: "not-for-the-timeline" },
"done",
)).toEqual({ action: "Entered text", target: "in Password" });
});
it("drops URL credentials and query parameters from browser activity", () => {
expect(describeMcpActivity(
"browser_navigate",
{ url: "https://user:password@example.com/docs?token=private#section" },
"done",
)).toEqual({ action: "Opened", target: "example.com/docs" });
});
it("humanizes unknown tool names instead of exposing function syntax", () => {
expect(describeMcpActivity("browser_export_report", {}, "done")).toEqual({
action: "Export report completed",
});
});
});
+20 -21
View File
@@ -511,13 +511,13 @@ describe("MessageBubble", () => {
const video = screen.getByLabelText(/video attachment/i);
expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(video).toHaveAttribute("preload", "auto");
expect(video).toHaveAttribute("preload", "metadata");
expect(container.querySelector("video[controls]")).toBeInTheDocument();
expect(screen.queryByText("Preview")).not.toBeInTheDocument();
expect(screen.queryByText("Code")).not.toBeInTheDocument();
});
it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
it("renders streaming reasoning as one compact activity line", () => {
const message: UIMessage = {
id: "a-reasoning-streaming",
role: "assistant",
@@ -529,15 +529,19 @@ describe("MessageBubble", () => {
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
const preview = screen.getByText("Step 1: parse intent. Step 2: compute.");
expect(preview).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
expect(preview).toHaveClass("streaming-text-sheen");
expect(preview).toHaveAttribute(
"data-sheen-text",
"Step 1: parse intent. Step 2: compute.",
);
expect(screen.queryByText("Thinking…")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /thinking/i })).not.toBeInTheDocument();
});
it("collapses the reasoning section by default once streaming ends", () => {
it("keeps completed reasoning on one line above the answer", () => {
const message: UIMessage = {
id: "a-reasoning-done",
role: "assistant",
@@ -549,17 +553,15 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} />);
expect(screen.getByText("Thinking")).toBeInTheDocument();
const preview = screen.getByText("hidden until expanded");
expect(preview).toBeInTheDocument();
expect(screen.getByText("The answer is 42.")).toBeInTheDocument();
expect(screen.queryByText("hidden until expanded")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).toHaveClass("mb-2");
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
expect(screen.getByText("hidden until expanded")).toBeInTheDocument();
expect(preview.closest('[data-testid="activity-step"]')).toHaveClass("mb-2");
expect(screen.queryByText("Thinking")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /thinking/i })).not.toBeInTheDocument();
});
it("renders reasoning body as markdown so headings are not left as raw ###", async () => {
await import("@/components/MarkdownTextRenderer");
it("compacts reasoning markdown into plain single-line text", () => {
const message: UIMessage = {
id: "a-reasoning-md",
role: "assistant",
@@ -570,13 +572,10 @@ describe("MessageBubble", () => {
};
const { container } = render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
await waitFor(() => {
expect(container.querySelector("h3")?.textContent).toBe("Section title");
});
expect(screen.getByText("Section title Body line.")).toBeInTheDocument();
expect(container.textContent).not.toContain("###");
expect(screen.getByText("Body line.")).toBeInTheDocument();
expect(container.querySelector("h3")).not.toBeInTheDocument();
});
it("renders inline file paths as compact file references", async () => {
+22
View File
@@ -528,6 +528,28 @@ describe("NanobotClient", () => {
});
});
it("sends selected assistant text as separate quoted context", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "What does this mean?", undefined, {
quotedContext: " selected answer excerpt ",
});
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "message",
chat_id: "chat-x",
content: "What does this mean?",
quoted_context: "selected answer excerpt",
webui: true,
});
});
it("includes CLI app attachments in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
+27 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { faviconUrls, logoFallbackUrls, providerBrand } from "@/lib/provider-brand";
import {
browserSafeFaviconUrls,
faviconUrls,
isGenericRepositoryLogoUrl,
logoFallbackUrls,
providerBrand,
} from "@/lib/provider-brand";
describe("provider brand logos", () => {
it("uses multiple favicon sources before falling back to initials", () => {
@@ -11,6 +17,15 @@ describe("provider brand logos", () => {
]);
});
it("uses cross-origin-safe favicon sources first for arbitrary web pages", () => {
expect(browserSafeFaviconUrls("openai.com")).toEqual([
"https://favicon.im/openai.com?larger=true",
"https://www.google.com/s2/favicons?domain=openai.com&sz=64",
"https://icons.duckduckgo.com/ip3/openai.com.ico",
"https://openai.com/favicon.ico",
]);
});
it("keeps explicit Google favicon URLs first before trying fallbacks", () => {
expect(logoFallbackUrls("https://www.google.com/s2/favicons?domain=browserbase.com&sz=64")).toEqual([
"https://www.google.com/s2/favicons?domain=browserbase.com&sz=64",
@@ -28,6 +43,17 @@ describe("provider brand logos", () => {
]);
});
it("distinguishes repository host favicons from product identities", () => {
expect(
isGenericRepositoryLogoUrl(
"https://www.google.com/s2/favicons?domain=github.com/HKUDS/CLI-Anything&sz=64",
),
).toBe(true);
expect(isGenericRepositoryLogoUrl("https://github.com/favicon.ico")).toBe(true);
expect(isGenericRepositoryLogoUrl("https://raw.githubusercontent.com/org/repo/logo.svg")).toBe(false);
expect(isGenericRepositoryLogoUrl("https://blender.org/favicon.ico")).toBe(false);
});
it("keeps Zhipu on the current Z.ai brand domain", () => {
expect(providerBrand("zhipu")?.logoUrls[0]).toBe("https://z-cdn.chatglm.cn/z-ai/static/logo.svg");
expect(providerBrand("zhipu")?.logoUrls).toContain("https://www.google.com/s2/favicons?domain=z.ai&sz=64");
+7 -3
View File
@@ -485,7 +485,10 @@ describe("SettingsView Apps catalog", () => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
@@ -514,11 +517,12 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
expect(screen.queryByText("Api")).not.toBeInTheDocument();
expect(screen.getByText("AnyGen")).toBeInTheDocument();
expect(screen.getByText("0 ready")).toBeInTheDocument();
});
+65 -4
View File
@@ -292,6 +292,51 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
}
describe("ThreadComposer", () => {
it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn();
const onQuotedContextChange = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
quotedContext="selected answer excerpt"
focusRequest={1}
onQuotedContextChange={onQuotedContextChange}
/>,
);
const input = screen.getByLabelText("Message input");
await waitFor(() => expect(input).toHaveFocus());
expect(screen.getByLabelText("Quoted context")).toHaveTextContent("selected answer excerpt");
fireEvent.change(input, { target: { value: "What does this mean?" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("What does this mean?", undefined, {
quotedContext: "selected answer excerpt",
});
expect(onQuotedContextChange).toHaveBeenCalledWith(null);
});
it("removes quoted context without clearing the draft", () => {
const onQuotedContextChange = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
quotedContext="selected answer excerpt"
onQuotedContextChange={onQuotedContextChange}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "keep this draft" } });
fireEvent.click(screen.getByRole("button", { name: "Remove quoted context" }));
expect(onQuotedContextChange).toHaveBeenCalledWith(null);
expect(input).toHaveValue("keep this draft");
});
it("renders a readonly hero model composer when provided", () => {
render(
<ThreadComposer
@@ -1633,7 +1678,11 @@ describe("ThreadComposer", () => {
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("keep the UI minimal");
expect(onSend).toHaveBeenCalledWith(
"keep the UI minimal",
undefined,
{ continueActiveTurn: true },
);
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
});
@@ -1663,7 +1712,11 @@ describe("ThreadComposer", () => {
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("send this guidance now");
expect(onSend).toHaveBeenCalledWith(
"send this guidance now",
undefined,
{ continueActiveTurn: true },
);
expect(onSend).toHaveBeenCalledTimes(1);
expect(screen.queryByText("send this guidance now")).not.toBeInTheDocument();
});
@@ -1783,7 +1836,11 @@ describe("ThreadComposer", () => {
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("guide this one now");
expect(onSend).toHaveBeenCalledWith(
"guide this one now",
undefined,
{ continueActiveTurn: true },
);
expect(onSend).toHaveBeenCalledTimes(1);
expect(screen.getByText("older guidance")).toBeInTheDocument();
expect(screen.queryByText("guide this one now")).not.toBeInTheDocument();
@@ -2165,7 +2222,11 @@ describe("ThreadComposer", () => {
fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
expect(onSend).toHaveBeenCalledWith(
"remember this edited follow-up",
undefined,
{ continueActiveTurn: true },
);
remount.unmount();
render(
+49 -1
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -10,10 +10,58 @@ import {
import type { UIMessage } from "@/lib/types";
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("ThreadMessages", () => {
it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn();
render(
<ThreadMessages
messages={[{
id: "a1",
role: "assistant",
content: "The selected answer excerpt",
createdAt: 1,
}]}
isStreaming={false}
onQuoteSelection={onQuoteSelection}
/>,
);
const textNode = screen.getByText("The selected answer excerpt").firstChild!;
const range = document.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 19);
vi.spyOn(range, "getBoundingClientRect").mockReturnValue({
left: 100,
right: 240,
top: 100,
bottom: 120,
width: 140,
height: 20,
x: 100,
y: 100,
toJSON: () => ({}),
});
const removeAllRanges = vi.fn();
vi.spyOn(window, "getSelection").mockReturnValue({
isCollapsed: false,
rangeCount: 1,
getRangeAt: () => range,
toString: () => "selected answer",
removeAllRanges,
} as unknown as Selection);
document.dispatchEvent(new Event("selectionchange"));
const action = await screen.findByRole("button", { name: "Ask about this" });
fireEvent.click(action);
await waitFor(() => expect(onQuoteSelection).toHaveBeenCalledWith("selected answer"));
expect(removeAllRanges).toHaveBeenCalled();
});
it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
const messages: UIMessage[] = [
{
+2
View File
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
import { ClientProvider } from "@/providers/ClientProvider";
@@ -232,6 +233,7 @@ describe("ThreadShell", () => {
});
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
await preloadMarkdownText();
const client = makeClient();
let resolveProbe!: (value: Response) => void;
const probe = new Promise<Response>((resolve) => {
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
canonicalToolTrace,
mergeUniqueToolTraceLines,
} from "@/lib/tool-traces";
describe("tool trace identity", () => {
it("treats persisted and live JSON formatting as the same call", () => {
const persisted = 'web_search({"query": "site:linkedin.com/company Evomap startup", "count": 10})';
const live = 'web_search({"query":"site:linkedin.com/company Evomap startup","count":10})';
expect(canonicalToolTrace(persisted)).toBe(canonicalToolTrace(live));
expect(mergeUniqueToolTraceLines([persisted], [live])).toEqual({
traces: [persisted],
added: false,
});
});
it("keeps genuinely different calls separate", () => {
const first = 'web_search({"query":"nanobot"})';
const second = 'web_search({"query":"nanobot cloud"})';
expect(mergeUniqueToolTraceLines([first], [second])).toEqual({
traces: [first, second],
added: true,
});
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { describeTraceLine } from "@/components/thread/activity/trace-activity-model";
import type { GenericToolStatus } from "@/components/thread/activity/generic-tool-model";
function describeTrace(line: string, status: GenericToolStatus = "done") {
return describeTraceLine(line, status);
}
describe("trace activity semantics", () => {
it.each([
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],
['read_file({"path":"/Users/alice/project/README.md"})', "done", "Read", "~/project/README.md"],
['exec({"command":"date +%Y-%m-%d"})', "done", "Checked current time", ""],
['exec_command({"cmd":"API_KEY=secret npm test"})', "running", "Running command", "API_KEY=•••• npm test"],
['write_file({"path":"/home/alice/project/output.txt"})', "done", "Wrote file", "~/project/output.txt"],
['apply_patch({"file_path":"src/app.tsx","patch":"private"})', "error", "Could not edit file", "src/app.tsx"],
['third_party_sync({"token":"secret","payload":"private"})', "done", "Completed Third party sync", ""],
["Finished collecting results", "done", "Completed step", "Finished collecting results"],
] as const)("describes %s as one safe activity line", (line, status, label, detail) => {
const result = describeTrace(line, status);
expect(result).toMatchObject({ label, detail });
expect(`${result.label} ${result.detail}`).not.toMatch(/[{}]|private|\/Users\/alice|\/home\/alice/);
});
it.each([
["running", "Searching status test"],
["done", "Searched status test"],
["error", "Could not search status test"],
] as const)("uses status-aware search copy for %s", (status, label) => {
expect(describeTrace('web_search({"query":"status test"})', status).label).toBe(label);
});
it("never exposes URL credentials, query secrets, or private-network links", () => {
const publicResult = describeTrace(
'web_fetch({"url":"https://user:password@example.com/docs?api_key=secret#section"})',
);
expect(publicResult).toMatchObject({ detail: "example.com/docs", host: "example.com" });
expect(JSON.stringify(publicResult)).not.toMatch(/password|api_key|secret/);
const privateResult = describeTrace('web_fetch({"url":"http://127.0.0.1:8765/private"})');
expect(privateResult.url).toBeUndefined();
expect(privateResult.detail).not.toContain("127.0.0.1");
});
it("summarizes multi-line commands without exposing every script line", () => {
const result = describeTrace(
'exec({"command":"npm test\\necho second-secret-line\\necho third-line"})',
);
expect(result).toMatchObject({
label: "Ran command",
detail: "npm test · script, 3 lines",
});
expect(result.detail).not.toContain("second-secret-line");
});
});
+8 -7
View File
@@ -7,15 +7,13 @@ import {
} from "@/hooks/useLogoFallback";
function TestLogo({ urls }: { urls: string[] }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(urls);
const { logoUrl, logoLoaded, onLogoError, onLogoLoad } = useLogoFallback(urls);
if (!logoUrl) return <span>No logo</span>;
return (
<img
src={logoUrl}
alt="Logo"
onLoad={onLogoLoad}
onError={onLogoError}
/>
<>
<span>{logoLoaded ? "Loaded" : "Loading"}</span>
<img src={logoUrl} alt="Logo" onLoad={onLogoLoad} onError={onLogoError} />
</>
);
}
@@ -32,15 +30,18 @@ describe("useLogoFallback", () => {
const first = render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[0]);
expect(screen.getByText("Loading")).toBeInTheDocument();
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
fireEvent.load(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByText("Loaded")).toBeInTheDocument();
first.unmount();
render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
expect(screen.getByText("Loaded")).toBeInTheDocument();
});
it("returns no logo once every candidate failed", () => {
+131
View File
@@ -131,6 +131,55 @@ describe("useNanobotStream", () => {
requestFrame.mockRestore();
});
it("coalesces hidden-tab deltas without scheduling paint frames", () => {
vi.useFakeTimers();
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
try {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-background", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
act(() => {
fake.emit("chat-background", {
event: "delta",
chat_id: "chat-background",
text: "Quiet",
});
fake.emit("chat-background", {
event: "delta",
chat_id: "chat-background",
text: " background",
});
});
expect(requestFrame).not.toHaveBeenCalled();
expect(result.current.messages).toHaveLength(0);
act(() => vi.advanceTimersByTime(1_000));
expect(result.current.messages[0]).toMatchObject({
content: "Quiet background",
isStreaming: true,
});
} finally {
requestFrame.mockRestore();
if (visibilityDescriptor) {
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
} else {
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
}
vi.useRealTimers();
}
});
it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
@@ -1832,6 +1881,88 @@ describe("useNanobotStream", () => {
}
});
it("keeps guided output in place while the active turn resumes", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-guide", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
result.current.send("research this");
});
const activeTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
act(() => {
fake.emit("chat-guide", {
event: "delta",
chat_id: "chat-guide",
text: "Initial findings",
turn_id: activeTurnId,
});
});
await flushStreamFrame();
act(() => {
result.current.send("focus on primary sources", undefined, {
continueActiveTurn: true,
});
});
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
expect(result.current.messages.map((message) => message.content)).toEqual([
"research this",
"Initial findings",
"focus on primary sources",
]);
act(() => {
fake.emit("chat-guide", {
event: "stream_end",
chat_id: "chat-guide",
text: "Initial findings",
resuming: true,
turn_id: activeTurnId,
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[1]).toMatchObject({
content: "Initial findings",
isStreaming: false,
});
act(() => {
fake.emit("chat-guide", {
event: "delta",
chat_id: "chat-guide",
text: "Updated with primary sources",
turn_id: activeTurnId,
});
});
await flushStreamFrame();
expect(result.current.messages.map((message) => message.content)).toEqual([
"research this",
"Initial findings",
"focus on primary sources",
"Updated with primary sources",
]);
expect(result.current.messages[3]).toMatchObject({ isStreaming: true });
act(() => {
fake.emit("chat-guide", {
event: "turn_end",
chat_id: "chat-guide",
turn_id: activeTurnId,
});
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
});
it("keeps streaming alive across stream_end when tool activity follows", async () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
@@ -0,0 +1,35 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePageVisibility } from "@/hooks/usePageVisibility";
describe("usePageVisibility", () => {
it("tracks visibility changes so background work can pause and resume", () => {
const original = Object.getOwnPropertyDescriptor(document, "visibilityState");
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
const { result, unmount } = renderHook(usePageVisibility);
try {
expect(result.current).toBe(false);
act(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current).toBe(true);
} finally {
unmount();
if (original) {
Object.defineProperty(document, "visibilityState", original);
} else {
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
}
}
});
});
+18
View File
@@ -15,6 +15,24 @@ describe("webuiManualChunk", () => {
).toBe("markdown-vendor");
});
it("keeps Streamdown and its repair helper in the markdown chunk", () => {
expect(webuiManualChunk("/repo/node_modules/streamdown/dist/index.js")).toBe(
"markdown-vendor",
);
expect(webuiManualChunk("/repo/node_modules/remend/dist/index.js")).toBe(
"markdown-vendor",
);
});
it("leaves Streamdown's optional renderers as lazy chunks", () => {
expect(
webuiManualChunk("/repo/node_modules/streamdown/dist/mermaid-ABC.js"),
).toBeUndefined();
expect(
webuiManualChunk("/repo/node_modules/streamdown/dist/highlighted-body-ABC.js"),
).toBeUndefined();
});
it("leaves language grammars as independently loaded chunks", () => {
expect(webuiManualChunk("/repo/node_modules/refractor/lang/python.js")).toBeUndefined();
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
formatCompactWebUrl,
parsePublicHttpUrl,
parseSafeActivityHttpUrl,
} from "@/components/thread/activity/web-url";
describe("activity web URLs", () => {
it("keeps public HTTP URLs and removes query noise from their label", () => {
const url = parsePublicHttpUrl("https://www.example.com/docs/?token=private#section");
expect(url).not.toBeNull();
expect(formatCompactWebUrl(url!)).toBe("example.com/docs");
});
it.each([
"http://localhost:3000",
"http://service.internal",
"http://printer.lan",
"http://127.0.0.1",
"http://10.0.0.1",
"http://169.254.169.254/latest/meta-data",
"http://172.16.0.1",
"http://192.168.1.1",
"http://[::1]",
"http://[::ffff:127.0.0.1]",
"https://user:password@example.com",
])("rejects private or credential-bearing target %s", (value) => {
expect(parsePublicHttpUrl(value)).toBeNull();
});
it("normalizes credential-bearing public URLs for safe activity display", () => {
const url = parseSafeActivityHttpUrl(
"https://user:password@example.com/docs?access_token=private#section",
);
expect(url?.href).toBe("https://example.com/docs");
expect(formatCompactWebUrl(url!)).toBe("example.com/docs");
});
});