feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>)",
|
||||
")",
|
||||
].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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user