diff --git a/.agent/design.md b/.agent/design.md index 0f68d23bf..e8cef12fc 100644 --- a/.agent/design.md +++ b/.agent/design.md @@ -6,6 +6,8 @@ These rules govern architectural decisions. When adding a feature or fixing a bu New capabilities should be added via `channels/`, `tools/`, skills, or MCP servers. The files `agent/loop.py` and `agent/runner.py` form the critical core path; changes there should be minimal and justified. If a feature can live in a channel adapter, a tool, or an external MCP server, it should not be inlined into the agent loop. +Runtime state fan-out follows the same boundary. `AgentLoop` may publish generic runtime events from `nanobot.bus.runtime_events` for turn/run/model/goal state changes, but WebUI/WebSocket wire details such as `_turn_end`, `_goal_status`, title refreshes, and goal-state sync belong in `nanobot.session.webui_turns.WebuiTurnCoordinator` or the relevant channel adapter. + ## Less structure, more intelligence Prefer simple, readable code over new framework layers and indirection. Add structure only when it removes real complexity, protects an important boundary, or matches an established local pattern. The best fix is often a smaller prompt, a tighter tool contract, a channel-local change, or one focused regression test. diff --git a/.dockerignore b/.dockerignore index 020b9ec39..ca4bd300e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ __pycache__ *.egg-info dist/ build/ +nanobot/web/dist/ .git .env .assets diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d925f32c6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +This file provides guidance to AI coding agents working with this repository. + +## Project Overview + +nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory. + +## Development Commands + +```bash +# Python: run single test / lint +pytest tests/test_openai_api.py::test_function -v +ruff check nanobot/ + +# WebUI: dev server (proxies API/WS to gateway :8765), build, test +# Build outputs to ../nanobot/web/dist (bundled into the Python wheel) +cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev +cd webui && bun run build +cd webui && bun run test + +# Gateway +nanobot gateway +``` + +## High-Level Architecture + +### Core Data Flow + +Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core: + +1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus. +2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn. +3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses. +4. Responses are published as `OutboundMessage` events back to the appropriate channel. + +### Key Subsystems + +- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution. +- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery. +- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins. +- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins. +- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability. +- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`). +- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility. +- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`. +- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway. +- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access. +- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers. +- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed). +- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel. +- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context. +- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry. + +### Entry Points + +- **CLI**: `nanobot/cli/commands.py` +- **Python SDK**: `nanobot/nanobot.py` + +## Project-Specific Notes + +- Architecture constraints: [`.agent/design.md`](.agent/design.md) +- Security boundaries: [`.agent/security.md`](.agent/security.md) +- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md) + +## Branching Strategy + +See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines. + +## Code Style + +- Python 3.11+, asyncio throughout. +- Line length: 100. +- Linting: `ruff` with rules E, F, I, N, W (E501 ignored). +- pytest with `asyncio_mode = "auto"`. + +## Common File Locations + +- Config schema: `nanobot/config/schema.py` +- Provider base / new provider template: `nanobot/providers/base.py` +- Channel base / new channel template: `nanobot/channels/base.py` +- Tool registry: `nanobot/agent/tools/registry.py` +- WebUI dev proxy config: `webui/vite.config.ts` +- Tests mirror the `nanobot/` package structure. diff --git a/CLAUDE.md b/CLAUDE.md index 4408c18ce..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,84 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory. - -## Development Commands - -```bash -# Python: run single test / lint -pytest tests/test_openai_api.py::test_function -v -ruff check nanobot/ - -# WebUI: dev server (proxies API/WS to gateway :8765), build, test -# Build outputs to ../nanobot/web/dist (bundled into the Python wheel) -cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev -cd webui && bun run build -cd webui && bun run test - -# Gateway -nanobot gateway -``` - -## High-Level Architecture - -### Core Data Flow - -Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core: - -1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus. -2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn. -3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses. -4. Responses are published as `OutboundMessage` events back to the appropriate channel. - -### Key Subsystems - -- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution. -- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery. -- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins. -- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins. -- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability. -- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`). -- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility. -- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`. -- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway. -- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access. -- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers. -- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed). -- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel. -- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context. -- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry. - -### Entry Points - -- **CLI**: `nanobot/cli/commands.py` -- **Python SDK**: `nanobot/nanobot.py` - -## Project-Specific Notes - -- Architecture constraints: [`.agent/design.md`](.agent/design.md) -- Security boundaries: [`.agent/security.md`](.agent/security.md) -- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md) - -## Branching Strategy - -See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines. - -## Code Style - -- Python 3.11+, asyncio throughout. -- Line length: 100. -- Linting: `ruff` with rules E, F, I, N, W (E501 ignored). -- pytest with `asyncio_mode = "auto"`. - -## Common File Locations - -- Config schema: `nanobot/config/schema.py` -- Provider base / new provider template: `nanobot/providers/base.py` -- Channel base / new channel template: `nanobot/channels/base.py` -- Tool registry: `nanobot/agent/tools/registry.py` -- WebUI dev proxy config: `webui/vite.config.ts` -- Tests mirror the `nanobot/` package structure. +@AGENTS.md diff --git a/Dockerfile b/Dockerfile index 484abf295..dece2eb73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ COPY nanobot/ nanobot/ COPY bridge/ bridge/ COPY webui/ webui/ -RUN uv pip install --system --no-cache . +RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache . # Build the WhatsApp bridge WORKDIR /app/bridge diff --git a/README.md b/README.md index 1dbc82db8..16a9091c1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - +
@@ -31,10 +31,30 @@
{children}
@@ -48,6 +51,23 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
});
+ it("falls back to 'text' language when language is undefined", async () => {
+ render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("highlighted-code")).toBeInTheDocument();
+ expect(screen.getByTestId("highlighted-code")).toHaveAttribute("data-language", "text");
+ expect(screen.getByText("const value = 1;")).toBeInTheDocument();
+ });
+
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index 202b9d29e..99f1f0a08 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -34,6 +34,60 @@ const SETTINGS_NAV_KEYS = [
"runtime",
"advanced",
];
+const LOCALIZED_SETTINGS_COPY_KEYS = [
+ "settings.backToChat",
+ "settings.sidebar.title",
+ "settings.sidebar.ariaLabel",
+ "settings.nav.overview",
+ "settings.nav.appearance",
+ "settings.nav.models",
+ "settings.nav.providers",
+ "settings.nav.apps",
+ "settings.nav.runtime",
+ "settings.nav.advanced",
+ "settings.sections.interface",
+ "settings.sections.localPreferences",
+ "settings.sections.webSearch",
+ "settings.sections.webBehavior",
+ "settings.sections.webuiSafety",
+ "settings.sections.capabilities",
+ "settings.sections.apps",
+ "settings.rows.theme",
+ "settings.rows.language",
+ "settings.rows.density",
+ "settings.rows.activityMode",
+ "settings.rows.codeWrap",
+ "settings.rows.brandLogos",
+ "settings.rows.currentModel",
+ "settings.rows.localServiceAccess",
+ "settings.rows.webuiDefaultAccess",
+ "settings.rows.contextWindow",
+ "settings.help.theme",
+ "settings.help.language",
+ "settings.help.density",
+ "settings.help.activityMode",
+ "settings.help.codeWrap",
+ "settings.help.brandLogos",
+ "settings.help.currentModel",
+ "settings.help.localServiceAccess",
+ "settings.help.webuiDefaultAccess",
+ "settings.values.light",
+ "settings.values.dark",
+ "settings.values.comfortable",
+ "settings.values.compact",
+ "settings.values.expanded",
+ "settings.values.enabled",
+ "settings.values.disabled",
+ "settings.values.defaultPermission",
+ "settings.values.fullAccess",
+ "settings.values.configured",
+ "settings.values.notConfigured",
+ "settings.status.loading",
+ "settings.status.unsaved",
+ "settings.status.upToDate",
+ "settings.actions.save",
+ "settings.actions.saving",
+];
function isRecord(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -190,6 +244,20 @@ describe("webui i18n", () => {
}
});
+ it("does not leak English settings chrome into localized locales", () => {
+ const english = flattenResource(resources.en.common);
+
+ for (const [locale, resource] of Object.entries(resources)) {
+ if (locale === "en") continue;
+ const current = flattenResource(resource.common);
+ const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter(
+ (key) => current.get(key) === english.get(key),
+ );
+
+ expect({ locale, leaked }).toEqual({ locale, leaked: [] });
+ }
+ });
+
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx
index e3ff59727..596dde98b 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => {
+ it("renders clickable markdown links in blue", () => {
+ render([local server](http://127.0.0.1:7891/) );
+
+ const link = screen.getByRole("link", { name: "local server" });
+ expect(link).toHaveAttribute("href", "http://127.0.0.1:7891/");
+ expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
+ });
+
it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render(
@@ -16,6 +24,18 @@ describe("MarkdownTextRenderer", () => {
expect(container.querySelector("pre div")).toBeNull();
});
+ it("renders bare fenced code blocks without crashing", () => {
+ const { container } = render(
+
+ {"Some text\n\n```\ncode without language\n```"}
+ ,
+ );
+
+ expect(screen.getByText("code without language")).toBeInTheDocument();
+ expect(screen.getByText("text")).toBeInTheDocument();
+ expect(container.querySelectorAll("pre")).toHaveLength(1);
+ });
+
it("keeps streaming unfinished fenced code blocks to a single shell", () => {
const { container } = render(
@@ -56,6 +76,47 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
+ it("renders title plus url list items as compact link rows", () => {
+ render(
+
+ {
+ "Sources:\n\n- Polymarket โ โWhen will GPT-5.6 be released?โ\n https://polymarket.com/event/when-will-gpt-5pt6-be-released\n- Polymarket โ โGPT-5.6 released by...?โ\n https://polymarket.com/event/gpt-5pt6-released-by"
+ }
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", {
+ name: "Open link: Polymarket โ When will GPT-5.6 be released?",
+ }),
+ ).toHaveAttribute(
+ "href",
+ "https://polymarket.com/event/when-will-gpt-5pt6-be-released",
+ );
+ expect(
+ screen.getByRole("link", {
+ name: "Open link: Polymarket โ GPT-5.6 released by...?",
+ }),
+ ).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
+ expect(screen.queryByText("Polymarket ยท polymarket.com")).not.toBeInTheDocument();
+ });
+
+ it("does not require a source heading for compact link rows", () => {
+ render(
+
+ {
+ "Useful links:\n\n- Polymarket โ โWhen will GPT-5.6 be released?โ\n https://polymarket.com/event/when-will-gpt-5pt6-be-released"
+ }
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", {
+ name: "Open link: Polymarket โ When will GPT-5.6 be released?",
+ }),
+ ).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
+ });
+
it("renders media attachments without an extra preview/code wrapper", () => {
render( );
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 1caaf0194..060f0374a 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -167,6 +167,72 @@ describe("MessageBubble", () => {
);
});
+ it("copies completed assistant replies with the textarea fallback", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: undefined,
+ });
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: execCommand,
+ });
+ const message: UIMessage = {
+ id: "a-copy-fallback",
+ role: "assistant",
+ content: "Fallback copy reply.",
+ createdAt: Date.now(),
+ };
+
+ try {
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
+ );
+ } finally {
+ Reflect.deleteProperty(navigator, "clipboard");
+ Reflect.deleteProperty(document, "execCommand");
+ }
+ });
+
+ it("falls back when the Clipboard API rejects assistant reply copy", async () => {
+ const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: execCommand,
+ });
+ const message: UIMessage = {
+ id: "a-copy-reject",
+ role: "assistant",
+ content: "Rejected clipboard copy.",
+ createdAt: Date.now(),
+ };
+
+ try {
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
+
+ expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy.");
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
+ );
+ } finally {
+ Reflect.deleteProperty(navigator, "clipboard");
+ Reflect.deleteProperty(document, "execCommand");
+ }
+ });
+
it("does not show copy actions for streaming placeholders", () => {
const message: UIMessage = {
id: "a-streaming",
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index 6a3df3de9..fdfad82c7 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -65,6 +65,7 @@ beforeEach(() => {
});
afterEach(() => {
+ Reflect.deleteProperty(window, "nanobotHost");
vi.useRealTimers();
});
@@ -89,6 +90,61 @@ describe("NanobotClient", () => {
});
});
+ it("can swap the socket factory when the runtime URL changes", () => {
+ const browserFactory = vi.fn(
+ (url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
+ );
+ const hostFactory = vi.fn(
+ (url: string) => new FakeSocket(`host:${url}`) as unknown as WebSocket,
+ );
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: browserFactory,
+ });
+
+ client.connect();
+ expect(lastSocket().url).toBe("browser:ws://test");
+ client.close();
+ client.updateUrl("nanobot-host://engine/", hostFactory);
+ client.connect();
+
+ expect(hostFactory).toHaveBeenCalledWith("nanobot-host://engine/");
+ expect(lastSocket().url).toBe("host:nanobot-host://engine/");
+ });
+
+ it("uses the host socket bridge for native host URLs", async () => {
+ let socketEventHandler:
+ | ((event: { id: string; type: "open" | "close" | "error"; message?: string }) => void)
+ | null = null;
+ const openSocket = vi.fn(async () => "host-socket-1");
+ Object.defineProperty(window, "nanobotHost", {
+ configurable: true,
+ value: {
+ openSocket,
+ sendSocket: vi.fn(async () => undefined),
+ closeSocket: vi.fn(async () => undefined),
+ onSocketEvent: vi.fn((handler) => {
+ socketEventHandler = handler;
+ return vi.fn();
+ }),
+ },
+ });
+ const client = new NanobotClient({
+ url: "nanobot-host://engine/",
+ reconnect: false,
+ });
+ const status = vi.fn();
+ client.onStatus(status);
+
+ client.connect();
+ await Promise.resolve();
+ socketEventHandler?.({ id: "host-socket-1", type: "open" });
+
+ expect(openSocket).toHaveBeenCalledWith("nanobot-host://engine/");
+ expect(status).toHaveBeenLastCalledWith("open");
+ });
+
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({
url: "ws://test",
diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx
index c149235aa..0cfe70e0c 100644
--- a/webui/src/tests/settings-view.test.tsx
+++ b/webui/src/tests/settings-view.test.tsx
@@ -81,9 +81,6 @@ function settingsPayload(): SettingsPayload {
},
dream: {
schedule: "every 2h",
- max_batch_size: 20,
- max_iterations: 15,
- annotate_line_ages: true,
},
unified_session: false,
},
@@ -245,6 +242,40 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
+ it("can close the new configuration dialog without trapping the settings page", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return jsonResponse(settingsPayload());
+ if (url === "/api/settings/cli-apps") {
+ return jsonResponse({ apps: [], installed_count: 0 });
+ }
+ if (url === "/api/settings/mcp-presets") {
+ return jsonResponse({ presets: [], installed_count: 0 });
+ }
+ return { ok: false, status: 404, json: async () => ({}) } as Response;
+ }),
+ );
+
+ renderSettingsView({ initialSection: "models" });
+
+ const configurationButton = await screen.findByRole("button", { name: "Current configuration" });
+ fireEvent.pointerDown(configurationButton!);
+ fireEvent.click(await screen.findByText("Add configuration"));
+
+ expect(await screen.findByRole("heading", { name: "New model configuration" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() =>
+ expect(screen.queryByRole("heading", { name: "New model configuration" })).not.toBeInTheDocument(),
+ );
+ expect(document.body.style.pointerEvents).not.toBe("none");
+
+ fireEvent.pointerDown(configurationButton!);
+ expect(await screen.findByText("Add configuration")).toBeInTheDocument();
+ });
+
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index 45f70387e..26cd5ce13 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -386,6 +386,7 @@ describe("ThreadComposer", () => {
expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter");
+ expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers();
});
diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx
index 9319a0951..5d78bb3f8 100644
--- a/webui/src/tests/thread-messages.test.tsx
+++ b/webui/src/tests/thread-messages.test.tsx
@@ -102,7 +102,7 @@ describe("ThreadMessages", () => {
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
});
- it("does not split ordinary tool activity just because segment ids changed", () => {
+ it("keeps ordinary tool activity in one Thought block across segment ids", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -151,6 +151,47 @@ describe("ThreadMessages", () => {
]);
});
+ it("renders a later tool segment after the visible answer that preceded it", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "r1",
+ role: "assistant",
+ content: "",
+ reasoning: "I should do a fresh search.",
+ activitySegmentId: "seg-1",
+ createdAt: 1,
+ },
+ {
+ id: "a1",
+ role: "assistant",
+ content: "Let me search the latest data.",
+ createdAt: 2,
+ },
+ {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: "Searching query: HKUDS/nanobot GitHub stars",
+ traces: ["Searching query: HKUDS/nanobot GitHub stars"],
+ activitySegmentId: "seg-2",
+ createdAt: 3,
+ },
+ ];
+
+ const units = buildDisplayUnits(messages);
+
+ expect(units).toHaveLength(3);
+ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
+ expect(units[1]).toMatchObject({
+ type: "message",
+ message: {
+ id: "a1",
+ content: "Let me search the latest data.",
+ },
+ });
+ expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
+ });
+
it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [
{
@@ -194,7 +235,8 @@ describe("ThreadMessages", () => {
render( );
- expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
+ expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity timeline", () => {
@@ -253,7 +295,7 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument();
});
- it("keeps late activity above the live assistant answer while streaming", () => {
+ it("keeps late activity after the live assistant answer while streaming", () => {
const messages: UIMessage[] = [
{
id: "t0",
@@ -284,11 +326,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
- expect(units).toHaveLength(2);
- expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
- "t0",
- "t1",
- ]);
+ expect(units).toHaveLength(3);
+ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
@@ -296,15 +335,16 @@ describe("ThreadMessages", () => {
content: "partial answer",
},
});
+ expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render( );
- const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer");
- expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ const liveActivity = screen.getByRole("button", { name: /working/i });
+ expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
- it("keeps late activity above a completed assistant answer", () => {
+ it("keeps late activity after a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -334,11 +374,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
- expect(units).toHaveLength(2);
- expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
- "r1",
- "t1",
- ]);
+ expect(units).toHaveLength(3);
+ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
@@ -346,13 +383,14 @@ describe("ThreadMessages", () => {
content: "Hong Kong is hot today.",
},
});
+ expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render( );
- const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today.");
- expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(screen.getAllByText(/thought/i)).toHaveLength(1);
+ const laterActivity = screen.getAllByText(/thought/i).at(-1);
+ expect(laterActivity).toBeTruthy();
+ expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx
index 6523a6f2f..d610d25ec 100644
--- a/webui/src/tests/thread-viewport.test.tsx
+++ b/webui/src/tests/thread-viewport.test.tsx
@@ -170,6 +170,103 @@ describe("ThreadViewport", () => {
expect(screen.getByText("message 299")).toBeInTheDocument();
});
+ it("renders a prompt rail that jumps to user messages", async () => {
+ const promptMessages = makeLongMessages(5);
+ const { container } = render(
+ }
+ />,
+ );
+
+ const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
+ const scrollTo = vi.fn();
+ Object.defineProperties(scroller, {
+ scrollHeight: { configurable: true, value: 1800 },
+ clientHeight: { configurable: true, value: 600 },
+ scrollTop: { configurable: true, value: 0 },
+ scrollTo: { configurable: true, value: scrollTo },
+ });
+
+ const promptEls = Array.from(
+ container.querySelectorAll("[data-user-prompt-id]"),
+ );
+ expect(promptEls).toHaveLength(5);
+ promptEls.forEach((el, index) => {
+ Object.defineProperty(el, "offsetTop", {
+ configurable: true,
+ value: index * 360,
+ });
+ });
+
+ await act(async () => {
+ window.dispatchEvent(new Event("resize"));
+ await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
+ });
+
+ expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Jump to prompt: message 3" }));
+
+ expect(scrollTo).toHaveBeenCalledWith({
+ top: 1064,
+ behavior: "smooth",
+ });
+ });
+
+ it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
+ const promptMessages = makeLongMessages(100);
+ const { container } = render(
+ }
+ />,
+ );
+
+ const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
+ const scrollTo = vi.fn();
+ Object.defineProperties(scroller, {
+ scrollHeight: { configurable: true, value: 10000 },
+ clientHeight: { configurable: true, value: 600 },
+ scrollTop: { configurable: true, value: 0 },
+ scrollTo: { configurable: true, value: scrollTo },
+ });
+
+ const promptEls = Array.from(
+ container.querySelectorAll("[data-user-prompt-id]"),
+ );
+ expect(promptEls).toHaveLength(100);
+ promptEls.forEach((el, index) => {
+ Object.defineProperty(el, "offsetTop", {
+ configurable: true,
+ value: index * 90,
+ });
+ });
+
+ await act(async () => {
+ window.dispatchEvent(new Event("resize"));
+ await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
+ });
+
+ const promptMarkers = screen.getAllByRole("button", { name: /Jump to prompt:/ });
+ expect(promptMarkers.length).toBeGreaterThan(3);
+ expect(promptMarkers.length).toBeLessThan(100);
+ expect(
+ promptMarkers.some((marker) =>
+ marker.getAttribute("aria-label")?.includes("prompts, latest"),
+ ),
+ ).toBe(true);
+
+ fireEvent.click(promptMarkers[promptMarkers.length - 1]);
+
+ expect(scrollTo).toHaveBeenCalledWith({
+ top: 8894,
+ behavior: "smooth",
+ });
+ });
+
it("expands the window start to avoid cutting an agent activity cluster", () => {
const clustered = makeLongMessages(200);
clustered.splice(
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index e880bf90f..87c837ca5 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -658,7 +658,7 @@ describe("useNanobotStream", () => {
}]);
});
- it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
+ it("keeps interrupted pre-tool text as assistant output before activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -692,9 +692,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
- content: "",
- reasoning: "I created the files.",
- isStreaming: false,
+ content: "I created the files.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -739,9 +737,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
- content: "",
- reasoning: "I will inspect the project first.",
- isStreaming: false,
+ content: "I will inspect the project first.",
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -755,6 +751,51 @@ describe("useNanobotStream", () => {
});
});
+ it("splits live assistant output around tool hints without moving it into reasoning", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-live-segments", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-live-segments", {
+ event: "delta",
+ chat_id: "chat-live-segments",
+ text: "Lint passed; now rendering the video.",
+ });
+ fake.emit("chat-live-segments", {
+ event: "message",
+ chat_id: "chat-live-segments",
+ text: 'exec({"cmd":"hyperframes render"})',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-live-segments", {
+ event: "delta",
+ chat_id: "chat-live-segments",
+ text: "Rendered successfully.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(3);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ content: "Lint passed; now rendering the video.",
+ });
+ expect(result.current.messages[0].reasoning).toBeUndefined();
+ expect(result.current.messages[1]).toMatchObject({
+ role: "tool",
+ kind: "trace",
+ traces: ['exec({"cmd":"hyperframes render"})'],
+ });
+ expect(result.current.messages[2]).toMatchObject({
+ role: "assistant",
+ content: "Rendered successfully.",
+ });
+ expect(result.current.messages[2].reasoning).toBeUndefined();
+ });
+
it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -967,7 +1008,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
- it("attaches post-hoc reasoning to the same assistant turn above the answer", () => {
+ it("starts a new Thought block when reasoning arrives after visible output", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -988,12 +1029,96 @@ describe("useNanobotStream", () => {
fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" });
});
- expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].content).toBe("hi~");
- expect(result.current.messages[0].reasoning).toBe(
+ expect(result.current.messages[0].reasoning).toBeUndefined();
+ expect(result.current.messages[1].content).toBe("");
+ expect(result.current.messages[1].reasoning).toBe(
"This reasoning arrived after the answer stream.",
);
- expect(result.current.messages[0].reasoningStreaming).toBe(false);
+ expect(result.current.messages[1].reasoningStreaming).toBe(false);
+ });
+
+ it("stamps completed live Thought blocks with their own latency", async () => {
+ const dateNow = vi.spyOn(Date, "now");
+ let now = Date.UTC(2026, 5, 1, 0, 0, 0);
+ dateNow.mockImplementation(() => now);
+ try {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-r5-lat", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+ await act(async () => {});
+
+ act(() => {
+ fake.emit("chat-r5-lat", {
+ event: "reasoning_delta",
+ chat_id: "chat-r5-lat",
+ text: "Thinking through the tests.",
+ });
+ });
+ await act(async () => {
+ await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
+ });
+
+ expect(result.current.messages[0].createdAt).toBe(now);
+ now += 2100;
+ act(() => {
+ fake.emit("chat-r5-lat", { event: "reasoning_end", chat_id: "chat-r5-lat" });
+ });
+
+ expect(result.current.messages[0].reasoningStreaming).toBe(false);
+ expect(result.current.messages[0].latencyMs).toBe(2100);
+ } finally {
+ dateNow.mockRestore();
+ }
+ });
+
+ it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-r5b", {
+ event: "reasoning_delta",
+ chat_id: "chat-r5b",
+ text: "Plan first.",
+ });
+ fake.emit("chat-r5b", {
+ event: "delta",
+ chat_id: "chat-r5b",
+ text: "Visible progress.",
+ });
+ fake.emit("chat-r5b", {
+ event: "reasoning_delta",
+ chat_id: "chat-r5b",
+ text: "Think again.",
+ });
+ fake.emit("chat-r5b", {
+ event: "delta",
+ chat_id: "chat-r5b",
+ text: "Final visible text.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ reasoning: "Plan first.",
+ content: "Visible progress.",
+ });
+ expect(result.current.messages[1]).toMatchObject({
+ role: "assistant",
+ reasoning: "Think again.",
+ content: "Final visible text.",
+ });
+ expect(result.current.messages[1].activitySegmentId).not.toBe(
+ result.current.messages[0].activitySegmentId,
+ );
});
it("does not attach a new turn's reasoning across the latest user boundary", async () => {