feat(mcp): add preset setup and capability mentions
This commit is contained in:
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
const BLENDER_CLI_APP: CliAppInfo = {
|
||||
name: "blender",
|
||||
@@ -21,6 +21,26 @@ const BLENDER_CLI_APP: CliAppInfo = {
|
||||
skill_installed: true,
|
||||
};
|
||||
|
||||
const BROWSERBASE_MCP: McpPresetInfo = {
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
description: "Cloud browser automation",
|
||||
docs_url: "https://docs.browserbase.com",
|
||||
transport: "streamableHttp",
|
||||
requires: "Browserbase API key",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
required_fields: [],
|
||||
connection_summary: "https://mcp.browserbase.com/mcp",
|
||||
};
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
{
|
||||
@@ -120,7 +140,6 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
@@ -149,7 +168,6 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
@@ -201,7 +219,6 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
@@ -238,6 +255,44 @@ describe("AgentActivityCluster", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("turns the live reasoning marker into an animated check when thinking completes", async () => {
|
||||
const liveReasoning: UIMessage = {
|
||||
id: "r-check",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "checking a source",
|
||||
reasoningStreaming: true,
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={[liveReasoning]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("activity-reasoning-marker")).toHaveAttribute("data-state", "thinking");
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
...liveReasoning,
|
||||
reasoningStreaming: false,
|
||||
isStreaming: false,
|
||||
}]}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow
|
||||
/>,
|
||||
);
|
||||
|
||||
const marker = screen.getByTestId("activity-reasoning-marker");
|
||||
expect(marker).toHaveAttribute("data-state", "done");
|
||||
expect(marker.querySelector("svg")).toBeInTheDocument();
|
||||
await waitFor(() => expect(marker).toHaveClass("animate-in"));
|
||||
});
|
||||
|
||||
it("renders file edit totals and a compact expanded file list", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
try {
|
||||
@@ -306,16 +361,68 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /running cli @blender/i }));
|
||||
|
||||
const cliRuns = screen.getByTestId("activity-cli-runs");
|
||||
expect(cliRuns).toHaveTextContent("Running CLI");
|
||||
expect(cliRuns).toHaveTextContent("Using");
|
||||
expect(cliRuns).toHaveTextContent("@blender");
|
||||
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
|
||||
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps CLI rows in chronological trace order", () => {
|
||||
const cliArgs = { name: "blender", args: ["project", "new"], json: true };
|
||||
const cliLine = `run_cli_app(${JSON.stringify(cliArgs)})`;
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[
|
||||
{
|
||||
id: "t-search",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'web_search({"query":"nanobot architecture"})',
|
||||
traces: ['web_search({"query":"nanobot architecture"})'],
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t-cli",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: cliLine,
|
||||
traces: [cliLine],
|
||||
toolEvents: [{
|
||||
phase: "end",
|
||||
call_id: "call-blender",
|
||||
name: "run_cli_app",
|
||||
arguments: cliArgs,
|
||||
}],
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "t-fetch",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'web_fetch({"url":"https://example.com/diagram"})',
|
||||
traces: ['web_fetch({"url":"https://example.com/diagram"})'],
|
||||
createdAt: 3,
|
||||
},
|
||||
]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
cliApps={[BLENDER_CLI_APP]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchRow = screen.getByText("Searching").closest("li");
|
||||
const cliRow = screen.getByText("@blender").closest("li");
|
||||
const fetchRow = screen.getByText("Reading").closest("li");
|
||||
|
||||
expect(searchRow).not.toBeNull();
|
||||
expect(cliRow).not.toBeNull();
|
||||
expect(fetchRow).not.toBeNull();
|
||||
expect(searchRow!.compareDocumentPosition(cliRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(cliRow!.compareDocumentPosition(fetchRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("labels rejected CLI app calls as failed instead of ran", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
@@ -341,14 +448,143 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /cli failed @github/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /failed @github/i }));
|
||||
|
||||
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("CLI failed");
|
||||
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Failed");
|
||||
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github");
|
||||
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found");
|
||||
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders MCP preset tool calls as branded activity rows", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-mcp",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "mcp_browserbase_browser_navigate()",
|
||||
traces: ["mcp_browserbase_browser_navigate({\"url\":\"https://example.com\"})"],
|
||||
toolEvents: [
|
||||
{
|
||||
phase: "start",
|
||||
call_id: "call-browserbase",
|
||||
name: "mcp_browserbase_browser_navigate",
|
||||
arguments: { url: "https://example.com" },
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
mcpPresets={[BROWSERBASE_MCP]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const mcpRuns = screen.getByTestId("activity-mcp-runs");
|
||||
expect(mcpRuns).toHaveTextContent("Using");
|
||||
expect(mcpRuns).toHaveTextContent("Browserbase");
|
||||
expect(mcpRuns).toHaveTextContent("browser_navigate");
|
||||
expect(mcpRuns).toHaveTextContent("url: https://example.com");
|
||||
expect(screen.getByTestId("activity-mcp-logo-browserbase")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/mcp_browserbase_browser_navigate/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders public web fetch traces with the site favicon", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-web-fetch",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})',
|
||||
traces: ['web_fetch({"url":"https://auth0.com/blog/jwt-security-best-practices"})'],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const favicon = screen.getByTestId("activity-web-favicon-auth0.com");
|
||||
expect(favicon.querySelector("img")?.getAttribute("src")).toContain("auth0.com");
|
||||
expect(screen.getByText("Reading")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plain-text fetch progress with the site favicon", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-web-fetch-text",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Fetching https://auth0.com/blog/jwt-security-best-practices",
|
||||
traces: ["Fetching https://auth0.com/blog/jwt-security-best-practices"],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("activity-web-favicon-auth0.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reading")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not request favicons for private web fetch targets", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-web-fetch-local",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: 'web_fetch({"url":"http://localhost:3000/dashboard"})',
|
||||
traces: ['web_fetch({"url":"http://localhost:3000/dashboard"})'],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("activity-web-favicon-localhost")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("url: http://localhost:3000/dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("summarizes long shell traces instead of dumping scripts", () => {
|
||||
const command = [
|
||||
"cat << 'EOF' | bash",
|
||||
"SECRET_TOKEN=sk-test",
|
||||
"for id in m1 m2 m3; do",
|
||||
" echo done $id",
|
||||
"done",
|
||||
"EOF",
|
||||
].join("\n");
|
||||
const line = `exec(${JSON.stringify({ command })})`;
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[{
|
||||
id: "t-shell",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: line,
|
||||
traces: [line],
|
||||
createdAt: 1,
|
||||
}]}
|
||||
isTurnStreaming={false}
|
||||
hasBodyBelow
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Shell")).toBeInTheDocument();
|
||||
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Done$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render zero diff counters for completed edits", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
@@ -440,7 +676,6 @@ describe("AgentActivityCluster", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
|
||||
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchSidebarState,
|
||||
fetchWebuiThread,
|
||||
importMcpConfig,
|
||||
listSessions,
|
||||
listSlashCommands,
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
updateMcpServerTools,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateWebSearchSettings,
|
||||
@@ -68,6 +74,21 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model configuration creation", async () => {
|
||||
await createModelConfiguration("tok", {
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes provider settings updates without returning secrets", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
provider: "openrouter",
|
||||
@@ -145,6 +166,91 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads MCP presets and serializes actions", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
presets: [],
|
||||
installed_count: 0,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchMcpPresets("tok")).resolves.toMatchObject({ presets: [] });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await runMcpPresetAction("tok", "enable", "browserbase", {
|
||||
browserbase_api_key: "bb_live_test",
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/enable?name=browserbase",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
browserbase_api_key: "bb_live_test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||
await saveCustomMcpServer("tok", {
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/custom",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/import",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/tools",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
enabled_tools: ["search", "fetch"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads and writes persisted sidebar state", async () => {
|
||||
const state = {
|
||||
schema_version: 1,
|
||||
|
||||
@@ -713,6 +713,12 @@ describe("App layout", () => {
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(document.title).toBe("Settings · nanobot");
|
||||
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("overview-logo-nanobot-gateway")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
|
||||
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
||||
expect(settingsNav.className).toContain("overflow-x-auto");
|
||||
@@ -722,23 +728,41 @@ describe("App layout", () => {
|
||||
"page",
|
||||
);
|
||||
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Providers" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
|
||||
expect(screen.getByText("Brand logos")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
||||
expect(screen.getByText("AI")).toBeInTheDocument();
|
||||
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Current model")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /openai\/gpt-4o/ }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
||||
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
|
||||
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
||||
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
|
||||
target: { value: "Fast writing" },
|
||||
});
|
||||
fireEvent.change(within(modelDialog).getByPlaceholderText("openai/gpt-4.1"), {
|
||||
target: { value: "openai/gpt-4.1-mini" },
|
||||
});
|
||||
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
|
||||
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
|
||||
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
|
||||
"text-blue-600",
|
||||
);
|
||||
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
|
||||
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
||||
@@ -757,10 +781,11 @@ describe("App layout", () => {
|
||||
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Atomic Chat"));
|
||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
||||
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
|
||||
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Image generation" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Provider status")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Save directory")).toBeInTheDocument();
|
||||
@@ -768,7 +793,9 @@ describe("App layout", () => {
|
||||
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
|
||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Jina reader" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument();
|
||||
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
||||
@@ -783,7 +810,16 @@ describe("App layout", () => {
|
||||
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
|
||||
expect(screen.getByText("Bot name")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
|
||||
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
|
||||
target: { value: "Shanghai" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
|
||||
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("returns from settings to the blank start page when no session was active", async () => {
|
||||
@@ -969,6 +1005,7 @@ describe("App layout", () => {
|
||||
expect(dialog).toHaveClass("origin-center");
|
||||
expect(dialog.className).not.toContain("translate-x");
|
||||
expect(dialog.className).not.toContain("translate-y");
|
||||
expect(dialog.querySelector("kbd")).toBeNull();
|
||||
expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument();
|
||||
|
||||
@@ -12,7 +12,6 @@ const SETTINGS_NAV_KEYS = [
|
||||
"overview",
|
||||
"appearance",
|
||||
"models",
|
||||
"providers",
|
||||
"image",
|
||||
"web",
|
||||
"runtime",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
const CLI_APPS: CliAppInfo[] = [
|
||||
{
|
||||
@@ -39,6 +39,28 @@ const CLI_APPS: CliAppInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MCP_PRESETS: McpPresetInfo[] = [
|
||||
{
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
description: "Cloud browser automation",
|
||||
docs_url: "https://docs.browserbase.com",
|
||||
transport: "streamableHttp",
|
||||
requires: "Browserbase API key",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
required_fields: [],
|
||||
connection_summary: "https://mcp.browserbase.com/mcp",
|
||||
},
|
||||
];
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("renders user messages as right-aligned pills", () => {
|
||||
const message: UIMessage = {
|
||||
@@ -69,6 +91,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
const token = screen.getByTestId("message-cli-mention-zoom");
|
||||
expect(token).toHaveTextContent("@zoom");
|
||||
expect(token).toHaveAttribute("title", "CLI app: Zoom");
|
||||
expect(token.className).not.toContain("rounded");
|
||||
expect(token.className).not.toContain("px-");
|
||||
expect(token.getAttribute("style")).toContain("color: #0B5CFF");
|
||||
@@ -104,6 +127,23 @@ describe("MessageBubble", () => {
|
||||
expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders MCP preset mentions inside sent user messages", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-mcp",
|
||||
role: "user",
|
||||
content: "Use @browserbase to inspect the checkout flow",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} mcpPresets={MCP_PRESETS} />);
|
||||
|
||||
const token = screen.getByTestId("message-mcp-mention-browserbase");
|
||||
expect(token).toHaveTextContent("@browserbase");
|
||||
expect(token).toHaveAttribute("title", "MCP server: Browserbase");
|
||||
expect(token.getAttribute("style")).toContain("color: #111827");
|
||||
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies completed assistant replies from the action row", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
|
||||
@@ -375,6 +375,53 @@ describe("NanobotClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes MCP preset attachments in outbound messages", () => {
|
||||
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-mcp",
|
||||
"@browserbase check this page",
|
||||
undefined,
|
||||
{
|
||||
mcpPresets: [{
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
transport: "streamableHttp",
|
||||
status: "configured",
|
||||
configured: true,
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastSocket().sent).toContain(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
chat_id: "chat-mcp",
|
||||
content: "@browserbase check this page",
|
||||
mcp_presets: [{
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
transport: "streamableHttp",
|
||||
status: "configured",
|
||||
configured: true,
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
}],
|
||||
webui: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-attaches known chats after a reconnect", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { faviconUrls, logoFallbackUrls, providerBrand } from "@/lib/provider-brand";
|
||||
|
||||
describe("provider brand logos", () => {
|
||||
it("uses multiple favicon sources before falling back to initials", () => {
|
||||
expect(faviconUrls("z.ai")).toEqual([
|
||||
"https://z.ai/favicon.ico",
|
||||
"https://icons.duckduckgo.com/ip3/z.ai.ico",
|
||||
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",
|
||||
]);
|
||||
});
|
||||
|
||||
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",
|
||||
"https://browserbase.com/favicon.ico",
|
||||
"https://icons.duckduckgo.com/ip3/browserbase.com.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes path-like favicon domains for secondary fallbacks", () => {
|
||||
expect(logoFallbackUrls("https://www.google.com/s2/favicons?domain=github.com/HKUDS/CLI-Anything&sz=64")).toEqual([
|
||||
"https://www.google.com/s2/favicons?domain=github.com/HKUDS/CLI-Anything&sz=64",
|
||||
"https://github.com/favicon.ico",
|
||||
"https://icons.duckduckgo.com/ip3/github.com.ico",
|
||||
"https://www.google.com/s2/favicons?domain=github.com%2FHKUDS%2FCLI-Anything&sz=64",
|
||||
]);
|
||||
});
|
||||
|
||||
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");
|
||||
expect(providerBrand("zhipu")?.logoUrls).toContain("https://z.ai/favicon.ico");
|
||||
expect(providerBrand("zhipu")?.initials).toBe("Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(index: number): ChatSummary {
|
||||
return {
|
||||
key: `websocket:chat-${index}`,
|
||||
channel: "websocket",
|
||||
chatId: `chat-${index}`,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: `Chat ${index}`,
|
||||
preview: `Preview ${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionSearchDialog", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses a solid compact command palette surface", () => {
|
||||
render(
|
||||
<SessionSearchDialog
|
||||
open
|
||||
sessions={[session(1)]}
|
||||
activeKey={null}
|
||||
loading={false}
|
||||
onOpenChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveClass("bg-background");
|
||||
expect(dialog.className).not.toContain("bg-popover/");
|
||||
expect(dialog.className).not.toContain("backdrop-blur");
|
||||
expect(screen.getByTestId("session-search-scroll")).toHaveClass("overflow-y-auto");
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation scrollable through long result lists", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
|
||||
render(
|
||||
<SessionSearchDialog
|
||||
open
|
||||
sessions={Array.from({ length: 24 }, (_, index) => session(index + 1))}
|
||||
activeKey={null}
|
||||
loading={false}
|
||||
onOpenChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Search" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import type { CliAppInfo, SlashCommand } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
@@ -70,6 +70,47 @@ const CLI_APPS: CliAppInfo[] = [
|
||||
skill_installed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const MCP_PRESETS: McpPresetInfo[] = [
|
||||
{
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
description: "Cloud browser automation",
|
||||
docs_url: "https://docs.browserbase.com",
|
||||
transport: "streamableHttp",
|
||||
requires: "Browserbase API key",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
required_fields: [],
|
||||
connection_summary: "https://mcp.browserbase.com/mcp",
|
||||
},
|
||||
{
|
||||
name: "figma",
|
||||
display_name: "Figma",
|
||||
category: "design",
|
||||
description: "Design context",
|
||||
docs_url: "https://figma.com",
|
||||
transport: "streamableHttp",
|
||||
requires: "Figma desktop",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: false,
|
||||
available: false,
|
||||
status: "missing_credentials",
|
||||
logo_url: null,
|
||||
brand_color: "#F24E1E",
|
||||
required_fields: [],
|
||||
connection_summary: "",
|
||||
},
|
||||
];
|
||||
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -125,11 +166,14 @@ describe("ThreadComposer", () => {
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="gpt-4o"
|
||||
modelProvider="openai"
|
||||
modelProviderLabel="OpenAI"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
|
||||
const input = screen.getByPlaceholderText("Type your message...");
|
||||
expect(input.className).toContain("min-h-[50px]");
|
||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
|
||||
@@ -227,7 +271,7 @@ describe("ThreadComposer", () => {
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
const palette = screen.getByRole("listbox", { name: "CLI Apps" });
|
||||
const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
|
||||
expect(palette).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -246,7 +290,7 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
||||
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
@@ -282,6 +326,69 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
||||
});
|
||||
|
||||
it("shows configured MCP presets in the mention palette and submits metadata", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
mcpPresets={MCP_PRESETS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "use @bro", selectionStart: 8 },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("option", { name: /@browserbase/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /@figma/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input).toHaveValue("use @browserbase ");
|
||||
expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("use @browserbase", undefined, {
|
||||
mcpPresets: [{
|
||||
name: "browserbase",
|
||||
display_name: "Browserbase",
|
||||
category: "browser",
|
||||
transport: "streamableHttp",
|
||||
status: "configured",
|
||||
configured: true,
|
||||
logo_url: "https://example.invalid/browserbase.svg",
|
||||
brand_color: "#111827",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
mcpPresets={MCP_PRESETS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.queryByText("CLI Apps")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("MCP servers")).not.toBeInTheDocument();
|
||||
const gimp = screen.getByRole("option", { name: /GIMP @gimp .* CLI/i });
|
||||
const browserbase = screen.getByRole("option", { name: /Browserbase @browserbase .* MCP/i });
|
||||
expect(within(gimp).getByText("CLI")).toBeInTheDocument();
|
||||
expect(within(browserbase).getByText("MCP")).toBeInTheDocument();
|
||||
expect(within(gimp).getByText("@gimp")).toBeInTheDocument();
|
||||
expect(within(browserbase).getByText("@browserbase")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not duplicate the next word separator when completing a CLI app mention", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
|
||||
@@ -1010,7 +1010,7 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
|
||||
const payload: CliAppsPayload = {
|
||||
apps: [{
|
||||
@@ -1038,7 +1038,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.getByRole("listbox", { name: "CLI Apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user