diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b6172a29e..67d95e1ca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -49,7 +49,7 @@ body: attributes: label: nanobot Version description: Run `nanobot --version` or `pip show nanobot-ai` - placeholder: e.g., 0.1.5 + placeholder: e.g., 0.2.0 validations: required: true diff --git a/Dockerfile b/Dockerfile index 3b86d61b6..484abf295 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ RUN apt-get update && \ WORKDIR /app -# Install Python dependencies first (cached layer) -COPY pyproject.toml README.md LICENSE ./ +# Install Python dependencies first (cached layer). Hatch reads the custom build +# hook from hatch_build.py even for this metadata-only install. +COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ uv pip install --system --no-cache . && \ rm -rf nanobot bridge @@ -23,6 +24,7 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ # Copy the full source and install COPY nanobot/ nanobot/ COPY bridge/ bridge/ +COPY webui/ webui/ RUN uv pip install --system --no-cache . # Build the WhatsApp bridge @@ -43,8 +45,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/ent USER nanobot ENV HOME=/home/nanobot -# Gateway default port -EXPOSE 18790 +# Gateway health endpoint and optional WebUI/WebSocket channel ports +EXPOSE 18790 8765 ENTRYPOINT ["entrypoint.sh"] CMD ["status"] diff --git a/README.md b/README.md index ccc854fa6..d4e5db46c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## 📢 News +- **2026-05-15** 🚀 Released **v0.2.0** — **`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details. - **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat. - **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects. - **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads. @@ -214,10 +215,9 @@ nanobot agent - Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md) - Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md) -## 🧪 WebUI (Development) +## 🌐 WebUI -> [!NOTE] -> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs and build steps. +The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
@@ -235,13 +235,12 @@ nanobot agent
nanobot gateway
```
-**3. Start the webui dev server**
+**3. Open the WebUI**
-```bash
-cd webui
-bun install
-bun run dev
-```
+Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
+
+> [!TIP]
+> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
## 🏗️ Architecture
diff --git a/docker-compose.yml b/docker-compose.yml
index 21beb1c6f..1d87092f0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,6 +20,7 @@ services:
restart: unless-stopped
ports:
- 18790:18790
+ - 8765:8765
deploy:
resources:
limits:
diff --git a/docs/README.md b/docs/README.md
index 56b8dab2f..7ac873bd1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,7 @@ Start here for setup, everyday usage, and deployment.
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
+| WebUI | [`../webui/README.md`](../webui/README.md) | Open the bundled browser UI; LAN access; Vite dev server for contributors |
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
diff --git a/docs/configuration.md b/docs/configuration.md
index 338991a33..b5d74f7ca 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -26,7 +26,52 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
}
```
-For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
+Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
+
+If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
+
+### More examples
+
+**MCP servers** — both stdio `env` and HTTP `headers`:
+
+```json
+{
+ "tools": {
+ "mcpServers": {
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-github"],
+ "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
+ },
+ "remote": {
+ "url": "https://example.com/mcp/",
+ "headers": { "Authorization": "Bearer ${REMOTE_MCP_TOKEN}" }
+ }
+ }
+ }
+}
+```
+
+**Web search providers:**
+
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "brave",
+ "apiKey": "${BRAVE_API_KEY}"
+ }
+ }
+ }
+}
+```
+
+### Loading variables at startup
+
+Pick whatever fits your deployment — nanobot only reads `os.environ` at startup, so any mechanism that populates the process environment works.
+
+**systemd** — use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
```ini
# /etc/systemd/system/nanobot.service (excerpt)
@@ -42,6 +87,35 @@ TELEGRAM_TOKEN=your-token-here
IMAP_PASSWORD=your-password-here
```
+**Docker** — pass an env file to the locally built image (one `KEY=VALUE` per line), or use `-e KEY=value`:
+
+```bash
+docker run --rm --env-file=./nanobot.env \
+ -v ~/.nanobot:/home/nanobot/.nanobot \
+ nanobot agent -m "Hello"
+```
+
+**direnv** — drop a `.envrc` in your working directory and run `direnv allow`:
+
+```bash
+# .envrc (auto-loaded by direnv)
+export TELEGRAM_TOKEN=your-token-here
+export ANTHROPIC_API_KEY=...
+```
+
+**Secret managers (1Password, Bitwarden, pass)** — wrap the process so secrets only exist as env vars for the lifetime of the run, never on disk:
+
+```bash
+# 1Password — references in .env.tpl look like `op://Vault/Item/field`
+op run --env-file=.env.tpl -- nanobot agent
+
+# pass (passwordstore.org)
+ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
+
+# Bitwarden
+ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
+```
+
## Providers
> [!TIP]
@@ -917,7 +991,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "brave",
- "apiKey": "BSA..."
+ "apiKey": "${BRAVE_API_KEY}"
}
}
}
@@ -931,7 +1005,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "tavily",
- "apiKey": "tvly-..."
+ "apiKey": "${TAVILY_API_KEY}"
}
}
}
@@ -945,7 +1019,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "jina",
- "apiKey": "jina_..."
+ "apiKey": "${JINA_API_KEY}"
}
}
}
@@ -959,7 +1033,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "kagi",
- "apiKey": "your-kagi-api-key"
+ "apiKey": "${KAGI_API_KEY}"
}
}
}
@@ -973,7 +1047,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": {
"search": {
"provider": "olostep",
- "apiKey": "YOUR_OLOSTEP_API_KEY"
+ "apiKey": "${OLOSTEP_API_KEY}"
}
}
}
@@ -1136,6 +1210,8 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
+For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
+
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
diff --git a/docs/deployment.md b/docs/deployment.md
index 746c35218..8a2cd89eb 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -10,6 +10,18 @@
> [!IMPORTANT]
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
+> [!IMPORTANT]
+> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container:
+>
+> ```json
+> {
+> "gateway": { "host": "0.0.0.0" },
+> "channels": { "websocket": { "host": "0.0.0.0" } }
+> }
+> ```
+>
+> When `host` is `0.0.0.0`, the gateway refuses to start unless `token` or `tokenIssueSecret` is also configured on the WebSocket channel — see [`webui/README.md`](../webui/README.md) for details.
+
### Docker Compose
```bash
@@ -36,8 +48,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
# Edit config on host to add API keys
vim ~/.nanobot/config.json
-# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
-docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
+# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
+# Mirrors the security caps and port mappings declared in docker-compose.yml:
+# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
+# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
+# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
+# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
+# endpoint on 18790.
+docker run \
+ --cap-drop ALL --cap-add SYS_ADMIN \
+ --security-opt apparmor=unconfined \
+ --security-opt seccomp=unconfined \
+ -v ~/.nanobot:/home/nanobot/.nanobot \
+ -p 18790:18790 -p 8765:8765 \
+ nanobot gateway
# Or run a single command
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
diff --git a/hatch_build.py b/hatch_build.py
new file mode 100644
index 000000000..28dbcd09a
--- /dev/null
+++ b/hatch_build.py
@@ -0,0 +1,101 @@
+"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
+
+Triggered automatically by `python -m build` (and any other hatch-driven build)
+so published wheels and sdists ship a fresh webui without requiring developers
+to remember `cd webui && bun run build` beforehand.
+
+Behaviour:
+
+- Skips for editable installs (`pip install -e .`). Editable mode is for Python
+ development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
+ do not need a packaged `dist/`.
+- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
+ already contains a prebuilt `nanobot/web/dist/`).
+- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
+- Skips when `nanobot/web/dist/index.html` already exists, unless
+ `NANOBOT_FORCE_WEBUI_BUILD=1` is set.
+- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
+ performs `install` followed by `run build`.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+from hatchling.builders.hooks.plugin.interface import BuildHookInterface
+
+
+class WebUIBuildHook(BuildHookInterface):
+ PLUGIN_NAME = "webui-build"
+
+ def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
+ root = Path(self.root)
+ webui_dir = root / "webui"
+ package_json = webui_dir / "package.json"
+ dist_dir = root / "nanobot" / "web" / "dist"
+ index_html = dist_dir / "index.html"
+
+ # `pip install -e .` builds an editable wheel; skip the (slow) webui
+ # bundle since editable installs target Python development and webui
+ # work uses `bun run dev` instead.
+ if self.target_name == "wheel" and version == "editable":
+ self.app.display_info(
+ "[webui-build] skipped for editable install "
+ "(use `cd webui && bun run build` to bundle webui manually)"
+ )
+ return
+
+ if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
+ self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
+ return
+
+ if not package_json.is_file():
+ self.app.display_info(
+ "[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
+ )
+ return
+
+ force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
+ if index_html.is_file() and not force:
+ self.app.display_info(
+ f"[webui-build] reusing existing build at {dist_dir} "
+ "(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
+ )
+ return
+
+ runner = self._pick_runner()
+ if runner is None:
+ raise RuntimeError(
+ "[webui-build] neither `bun` nor `npm` is available on PATH; "
+ "install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
+ )
+
+ self.app.display_info(f"[webui-build] using {runner} to build webui")
+ self._run([runner, "install"], cwd=webui_dir)
+ self._run([runner, "run", "build"], cwd=webui_dir)
+
+ if not index_html.is_file():
+ raise RuntimeError(
+ f"[webui-build] build finished but {index_html} is missing; "
+ "check webui/vite.config.ts outDir."
+ )
+ self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
+
+ @staticmethod
+ def _pick_runner() -> str | None:
+ for candidate in ("bun", "npm"):
+ if shutil.which(candidate):
+ return candidate
+ return None
+
+ def _run(self, cmd: list[str], *, cwd: Path) -> None:
+ self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
+ try:
+ subprocess.run(cmd, cwd=cwd, check=True)
+ except subprocess.CalledProcessError as exc:
+ raise RuntimeError(
+ f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
+ ) from exc
diff --git a/nanobot/__init__.py b/nanobot/__init__.py
index e6fdbf0ba..8ab213a33 100644
--- a/nanobot/__init__.py
+++ b/nanobot/__init__.py
@@ -21,7 +21,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai")
except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info.
- return _read_pyproject_version() or "0.1.5.post3"
+ return _read_pyproject_version() or "0.2.0"
__version__ = _resolve_version()
diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py
index 11e531039..4ad241170 100644
--- a/nanobot/agent/autocompact.py
+++ b/nanobot/agent/autocompact.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
-from typing import TYPE_CHECKING, Any, Callable, Coroutine
+from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger
@@ -37,27 +37,6 @@ class AutoCompact:
def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
- def _split_unconsolidated(
- self, session: Session,
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
- """Split live session tail into archiveable prefix and retained recent suffix."""
- tail = list(session.messages[session.last_consolidated:])
- if not tail:
- return [], []
-
- probe = Session(
- key=session.key,
- messages=tail.copy(),
- created_at=session.created_at,
- updated_at=session.updated_at,
- metadata={},
- last_consolidated=0,
- )
- probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
- kept = probe.messages
- cut = len(tail) - len(kept)
- return tail[:cut], kept
-
def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
@@ -74,33 +53,17 @@ class AutoCompact:
async def _archive(self, key: str) -> None:
try:
- self.sessions.invalidate(key)
- session = self.sessions.get_or_create(key)
- archive_msgs, kept_msgs = self._split_unconsolidated(session)
- if not archive_msgs and not kept_msgs:
- session.updated_at = datetime.now()
- self.sessions.save(session)
- return
-
- last_active = session.updated_at
- summary = ""
- if archive_msgs:
- summary = await self.consolidator.archive(archive_msgs) or ""
+ summary = await self.consolidator.compact_idle_session(
+ key, self._RECENT_SUFFIX_MESSAGES,
+ )
if summary and summary != "(nothing)":
- self._summaries[key] = (summary, last_active)
- session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
- session.messages = kept_msgs
- session.last_consolidated = 0
- session.updated_at = datetime.now()
- self.sessions.save(session)
- if archive_msgs:
- logger.info(
- "Auto-compact: archived {} (archived={}, kept={}, summary={})",
- key,
- len(archive_msgs),
- len(kept_msgs),
- bool(summary),
- )
+ session = self.sessions.get_or_create(key)
+ meta = session.metadata.get("_last_summary")
+ if isinstance(meta, dict):
+ self._summaries[key] = (
+ meta["text"],
+ datetime.fromisoformat(meta["last_active"]),
+ )
except Exception:
logger.exception("Auto-compact: failed for {}", key)
finally:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index bc807092e..c1f521170 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import (
- goal_state_ws_blob,
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
@@ -42,10 +41,14 @@ from nanobot.utils.document import extract_documents
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
+from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
-from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
-from nanobot.utils.webui_turn_helpers import publish_turn_run_status
+from nanobot.utils.webui_turn_helpers import (
+ WebuiTurnCoordinator,
+ build_bus_progress_callback,
+ mark_webui_session,
+)
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -136,6 +139,11 @@ class AgentLoop:
def tool_names(self) -> list[str]:
return self.tools.tool_names
+ def llm_runtime(self) -> LLMRuntime:
+ """Return the current provider/model pair owned by this loop."""
+ self._refresh_provider_snapshot()
+ return LLMRuntime(self.provider, self.model)
+
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
@@ -237,6 +245,11 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
+ self._webui_turns = WebuiTurnCoordinator(
+ bus=self.bus,
+ sessions=self.sessions,
+ schedule_background=lambda coro: self._schedule_background(coro),
+ )
self.tools = ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
@@ -524,34 +537,7 @@ class AgentLoop:
self, msg: InboundMessage
) -> Callable[..., Awaitable[None]]:
"""Build a progress callback that publishes to the message bus."""
-
- async def _bus_progress(
- content: str,
- *,
- tool_hint: bool = False,
- tool_events: list[dict[str, Any]] | None = None,
- reasoning: bool = False,
- reasoning_end: bool = False,
- ) -> None:
- meta = dict(msg.metadata or {})
- meta["_progress"] = True
- meta["_tool_hint"] = tool_hint
- if reasoning:
- meta["_reasoning_delta"] = True
- if reasoning_end:
- meta["_reasoning_end"] = True
- if tool_events:
- meta["_tool_events"] = tool_events
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=content,
- metadata=meta,
- )
- )
-
- return _bus_progress
+ return build_bus_progress_callback(self.bus, msg)
async def _build_retry_wait_callback(
self, msg: InboundMessage
@@ -938,38 +924,12 @@ class AgentLoop:
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
- # Signal that the turn is fully complete (all tools executed,
- # final text streamed). This lets WS clients know when to
- # definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
- turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
- if turn_lat is not None:
- turn_metadata["latency_ms"] = int(turn_lat)
- sess_turn = self.sessions.get_or_create(session_key)
- turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
- content="", metadata=turn_metadata,
- ))
- if msg.metadata.get("webui") is True:
- async def _generate_title_and_notify() -> None:
- generated = await maybe_generate_webui_title_after_turn(
- channel=msg.channel,
- metadata=msg.metadata,
- sessions=self.sessions,
- session_key=session_key,
- provider=self.provider,
- model=self.model,
- )
- if generated:
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="",
- metadata={**msg.metadata, "_session_updated": True},
- ))
-
- self._schedule_background(_generate_title_and_notify())
+ await self._webui_turns.handle_turn_end(
+ msg,
+ session_key=session_key,
+ latency_ms=turn_lat,
+ )
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
@@ -1021,8 +981,9 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
- await publish_turn_run_status(self.bus, msg, "idle")
+ await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
+ self._webui_turns.discard(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1338,6 +1299,11 @@ class AgentLoop:
"include_timestamps": True,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
+ self._webui_turns.capture_title_context(
+ ctx.session_key,
+ ctx.msg,
+ self.llm_runtime(),
+ )
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
@@ -1354,7 +1320,7 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
- await publish_turn_run_status(self.bus, ctx.msg, "running")
+ await self._webui_turns.publish_run_status(ctx.msg, "running")
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index fd233bfa3..ffc9c5f0e 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -678,11 +678,18 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window.
"""
- if not session.messages or self.context_window_tokens <= 0:
+ if self.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
+ # Refresh session reference: AutoCompact may have replaced it.
+ fresh = self.sessions.get_or_create(session.key)
+ if fresh is not session:
+ session = fresh
+ if not session.messages:
+ return
+
budget = self._input_token_budget
target = int(budget * self.consolidation_ratio)
last_summary = await self._consolidate_replay_overflow(
@@ -769,6 +776,74 @@ class Consolidator:
# the summary injection strategy with AutoCompact._archive().
self._persist_last_summary(session, last_summary)
+ async def compact_idle_session(
+ self,
+ session_key: str,
+ max_suffix: int = 8,
+ ) -> str | None:
+ """Hard-truncate an idle session under the consolidation lock.
+
+ Used by AutoCompact so all session mutation goes through a single
+ lock-protected path. Returns the summary text on success, ``None``
+ if the LLM failed (raw_archive fallback), or ``""`` if there was
+ nothing to archive.
+ """
+ lock = self.get_lock(session_key)
+ async with lock:
+ self.sessions.invalidate(session_key)
+ session = self.sessions.get_or_create(session_key)
+
+ tail = list(session.messages[session.last_consolidated:])
+ if not tail:
+ session.updated_at = datetime.now()
+ self.sessions.save(session)
+ return ""
+
+ probe = Session(
+ key=session.key,
+ messages=tail.copy(),
+ created_at=session.created_at,
+ updated_at=session.updated_at,
+ metadata={},
+ last_consolidated=0,
+ )
+ probe.retain_recent_legal_suffix(max_suffix)
+ kept = probe.messages
+ cut = len(tail) - len(kept)
+ archive_msgs = tail[:cut]
+
+ if not archive_msgs and not kept:
+ session.updated_at = datetime.now()
+ self.sessions.save(session)
+ return ""
+
+ last_active = session.updated_at
+ summary: str | None = ""
+ if archive_msgs:
+ summary = await self.archive(archive_msgs)
+
+ if summary and summary != "(nothing)":
+ session.metadata["_last_summary"] = {
+ "text": summary,
+ "last_active": last_active.isoformat(),
+ }
+
+ session.messages = kept
+ session.last_consolidated = 0
+ session.updated_at = datetime.now()
+ self.sessions.save(session)
+
+ if archive_msgs:
+ logger.info(
+ "Idle-session compact for {}: archived={}, kept={}, summary={}",
+ session_key,
+ len(archive_msgs),
+ len(kept),
+ bool(summary),
+ )
+
+ return summary
+
# ---------------------------------------------------------------------------
# Dream — heavyweight cron-scheduled memory consolidation
diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py
index 56482f75b..776885ecb 100644
--- a/nanobot/agent/runner.py
+++ b/nanobot/agent/runner.py
@@ -15,6 +15,12 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+from nanobot.utils.file_edit_events import (
+ build_file_edit_end_event,
+ build_file_edit_error_event,
+ build_file_edit_start_event,
+ prepare_file_edit_tracker,
+)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@@ -26,6 +32,10 @@ from nanobot.utils.helpers import (
strip_think,
truncate_text,
)
+from nanobot.utils.progress_events import (
+ invoke_file_edit_progress,
+ on_progress_accepts_file_edit_events,
+)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
@@ -813,6 +823,30 @@ class AgentRunner:
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
+ emit_file_edit_events = (
+ spec.progress_callback is not None
+ and on_progress_accepts_file_edit_events(spec.progress_callback)
+ )
+ progress_callback = spec.progress_callback if emit_file_edit_events else None
+ file_edit_tracker = (
+ prepare_file_edit_tracker(
+ call_id=tool_call.id,
+ tool_name=tool_call.name,
+ tool=tool,
+ workspace=spec.workspace,
+ params=params if isinstance(params, dict) else None,
+ )
+ if progress_callback is not None
+ else None
+ )
+ if file_edit_tracker is not None and progress_callback is not None:
+ await invoke_file_edit_progress(
+ progress_callback,
+ [build_file_edit_start_event(
+ file_edit_tracker,
+ params if isinstance(params, dict) else None,
+ )],
+ )
try:
if tool is not None:
result = await tool.execute(**params)
@@ -821,6 +855,11 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
+ if file_edit_tracker is not None and progress_callback is not None:
+ await invoke_file_edit_progress(
+ progress_callback,
+ [build_file_edit_error_event(file_edit_tracker, str(exc))],
+ )
event = {
"name": tool_call.name,
"status": "error",
@@ -842,6 +881,11 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
+ if file_edit_tracker is not None and progress_callback is not None:
+ await invoke_file_edit_progress(
+ progress_callback,
+ [build_file_edit_error_event(file_edit_tracker, result)],
+ )
event = {
"name": tool_call.name,
"status": "error",
@@ -860,6 +904,12 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
+ if file_edit_tracker is not None and progress_callback is not None:
+ await invoke_file_edit_progress(
+ progress_callback,
+ [build_file_edit_end_event(file_edit_tracker)],
+ )
+
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 26e00ff6a..0202bd33d 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}"
+def _provider_requires_api_key(spec: Any) -> bool:
+ if spec.backend == "azure_openai":
+ return True
+ if spec.is_local or spec.is_direct:
+ return False
+ return True
+
+
+def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
+ if _provider_requires_api_key(spec):
+ return bool(provider_config.api_key)
+ return bool(
+ provider_config.api_key
+ or provider_config.api_base
+ or getattr(provider_config, "region", None)
+ or getattr(provider_config, "profile", None)
+ )
+
+
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
@@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
- if provider_config is None or spec.is_oauth or spec.is_local:
+ if provider_config is None or spec.is_oauth:
continue
providers.append(
{
"name": spec.name,
"label": spec.label,
- "configured": bool(provider_config.api_key),
+ "configured": _provider_configured_for_settings(spec, provider_config),
+ "api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
@@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
if find_by_name(provider) is None:
return _http_error(400, "unknown provider")
provider_config = getattr(config.providers, provider, None)
- if provider_config is None or not provider_config.api_key:
+ spec = find_by_name(provider)
+ if (
+ provider_config is None
+ or spec is None
+ or not _provider_configured_for_settings(spec, provider_config)
+ ):
return _http_error(400, "provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
@@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
if not provider_name:
return _http_error(400, "provider is required")
spec = find_by_name(provider_name)
- if spec is None or spec.is_oauth or spec.is_local:
+ if spec is None or spec.is_oauth:
return _http_error(400, "unknown provider")
config = load_config()
@@ -1581,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
if not conns:
if (
msg.metadata.get("_progress")
+ or msg.metadata.get("_file_edit_events")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
@@ -1613,7 +1639,22 @@ class WebSocketChannel(BaseChannel):
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
return
if msg.metadata.get("_session_updated"):
- await self.send_session_updated(msg.chat_id)
+ scope = msg.metadata.get("_session_update_scope")
+ await self.send_session_updated(
+ msg.chat_id,
+ scope=scope if isinstance(scope, str) else None,
+ )
+ return
+ if msg.metadata.get("_file_edit_events"):
+ payload: dict[str, Any] = {
+ "event": "file_edit",
+ "chat_id": msg.chat_id,
+ "edits": msg.metadata["_file_edit_events"],
+ }
+ self._try_append_webui_transcript(msg.chat_id, payload)
+ raw = json.dumps(payload, ensure_ascii=False)
+ for connection in conns:
+ await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
payload: dict[str, Any] = {
@@ -1780,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" goal_status ")
- async def send_session_updated(self, chat_id: str) -> None:
+ async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
+ if scope:
+ body["scope"] = scope
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index edc7339da..cedc03bd0 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -91,6 +91,8 @@ app = typer.Typer(
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
+_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
+_REASONING_FLUSH_CHARS = 60
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
@@ -242,6 +244,35 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
target.print(f" [dim]↳ {text}[/dim]")
+class _ReasoningBuffer:
+ def __init__(self) -> None:
+ self._text = ""
+
+ def add(self, text: str) -> str | None:
+ if not text:
+ return None
+ self._text += text
+ if self._should_flush(text):
+ return self.flush()
+ return None
+
+ def flush(self) -> str | None:
+ text = self._text.strip()
+ self._text = ""
+ return text or None
+
+ def clear(self) -> None:
+ self._text = ""
+
+ def _should_flush(self, text: str) -> bool:
+ stripped = text.rstrip()
+ return (
+ "\n" in text
+ or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
+ or len(self._text) >= _REASONING_FLUSH_CHARS
+ )
+
+
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print reasoning/thinking content in a distinct style."""
if not text.strip():
@@ -254,6 +285,16 @@ def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer:
target.print(f"[dim italic]✻ {text}[/dim italic]")
+def _flush_cli_reasoning(
+ reasoning_buffer: _ReasoningBuffer,
+ thinking: ThinkingSpinner | None,
+ renderer: StreamRenderer | None = None,
+) -> None:
+ text = reasoning_buffer.flush()
+ if text:
+ _print_cli_reasoning(text, thinking, renderer)
+
+
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print an interactive progress line, pausing the spinner if needed."""
if not text.strip():
@@ -272,6 +313,7 @@ async def _maybe_print_interactive_progress(
thinking: ThinkingSpinner | None,
channels_config: Any,
renderer: StreamRenderer | None = None,
+ reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
@@ -281,12 +323,24 @@ async def _maybe_print_interactive_progress(
if not metadata.get("_progress"):
return False
+ reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
+
+ if metadata.get("_reasoning_end"):
+ if channels_config and not channels_config.show_reasoning:
+ reasoning_buffer.clear()
+ else:
+ _flush_cli_reasoning(reasoning_buffer, thinking, renderer)
+ return True
+
is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
+ reasoning_buffer.clear()
return True
- _print_cli_reasoning(msg.content, thinking, renderer)
+ text = reasoning_buffer.add(msg.content)
+ if text:
+ _print_cli_reasoning(text, thinking, renderer)
return True
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True
@@ -918,8 +972,7 @@ def _run_gateway(
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
- provider=agent.provider,
- model=agent.model,
+ llm_runtime=agent.llm_runtime,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
interval_s=hb_cfg.interval_s,
@@ -1111,12 +1164,25 @@ def agent(
_thinking: ThinkingSpinner | None = None
def _make_progress(renderer: StreamRenderer | None = None):
+ reasoning_buffer = _ReasoningBuffer()
+
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
ch = agent_loop.channels_config
+
+ if _kwargs.get("reasoning_end"):
+ if ch and not ch.show_reasoning:
+ reasoning_buffer.clear()
+ else:
+ _flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
+ return
+
if reasoning:
if ch and not ch.show_reasoning:
+ reasoning_buffer.clear()
return
- _print_cli_reasoning(content, _thinking, renderer)
+ text = reasoning_buffer.add(content)
+ if text:
+ _print_cli_reasoning(text, _thinking, renderer)
return
if ch and tool_hint and not ch.send_tool_hints:
return
@@ -1187,6 +1253,7 @@ def agent(
turn_done.set()
turn_response: list[tuple[str, dict]] = []
renderer: StreamRenderer | None = None
+ reasoning_buffer = _ReasoningBuffer()
async def _consume_outbound():
while True:
@@ -1212,6 +1279,7 @@ def agent(
renderer,
agent_loop.channels_config,
renderer,
+ reasoning_buffer,
):
continue
@@ -1252,6 +1320,7 @@ def agent(
turn_done.clear()
turn_response.clear()
+ reasoning_buffer.clear()
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=config.agents.defaults.bot_name,
diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py
index 96c97c088..9f5fc0a88 100644
--- a/nanobot/cli/onboard.py
+++ b/nanobot/cli/onboard.py
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
get_model_suggestions,
)
from nanobot.config.loader import get_config_path, load_config
-from nanobot.config.schema import Config
+from nanobot.config.schema import Config, ModelPresetConfig
console = Console()
@@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
_BACK_PRESSED = object() # Sentinel value for back navigation
+# Cache of model-preset names populated at runtime so that field handlers can
+# offer existing presets as choices (e.g. AgentDefaults.model_preset).
+_MODEL_PRESET_CACHE: set[str] = set()
+
def _get_questionary():
"""Return questionary or raise a clear error when wizard deps are unavailable."""
@@ -588,9 +592,102 @@ def _handle_context_window_field(
setattr(working_model, field_name, new_value)
+def _handle_model_preset_field(
+ working_model: BaseModel, field_name: str, field_display: str, current_value: Any
+) -> None:
+ """Handle the 'model_preset' field with a list of existing presets."""
+ preset_names = sorted(_MODEL_PRESET_CACHE)
+ choices = ["(clear/unset)"] + preset_names
+ default_choice = str(current_value) if current_value else "(clear/unset)"
+ new_value = _select_with_back(field_display, choices, default=default_choice)
+ if new_value is _BACK_PRESSED:
+ return
+ if new_value == "(clear/unset)":
+ setattr(working_model, field_name, None)
+ elif new_value is not None:
+ setattr(working_model, field_name, new_value)
+
+
+def _handle_provider_field(
+ working_model: BaseModel, field_name: str, field_display: str, current_value: Any
+) -> None:
+ """Handle the 'provider' field with a list of registered providers."""
+ provider_names = sorted(_get_provider_names().keys())
+ choices = ["auto"] + provider_names
+ default_choice = str(current_value) if current_value else "auto"
+ new_value = _select_with_back(field_display, choices, default=default_choice)
+ if new_value is _BACK_PRESSED:
+ return
+ if new_value is not None:
+ setattr(working_model, field_name, new_value)
+
+
+def _handle_fallback_models_field(
+ working_model: BaseModel, field_name: str, field_display: str, current_value: Any
+) -> None:
+ """Handle the 'fallback_models' field with preset-aware list management."""
+ from nanobot.config.schema import InlineFallbackConfig
+
+ items: list[Any] = list(current_value) if isinstance(current_value, list) else []
+ preset_names = sorted(_MODEL_PRESET_CACHE)
+
+ while True:
+ console.clear()
+ console.print(f"[bold]{field_display}[/bold]")
+ if items:
+ for idx, item in enumerate(items, 1):
+ if isinstance(item, InlineFallbackConfig):
+ console.print(f" {idx}. {item.model} ({item.provider}) [inline]")
+ else:
+ console.print(f" {idx}. {item}")
+ else:
+ console.print(" [dim](empty)[/dim]")
+ console.print()
+
+ choices = ["[+] Add preset"]
+ if items:
+ choices.append("[-] Remove last")
+ choices.append("[X] Clear all")
+ choices.append("[Done]")
+ choices.append("<- Back")
+
+ answer = _get_questionary().select(
+ "Manage fallback models:",
+ choices=choices,
+ qmark=">",
+ ).ask()
+
+ if answer is None or answer == "<- Back":
+ return
+ if answer == "[Done]":
+ setattr(working_model, field_name, items)
+ return
+ if answer == "[+] Add preset":
+ if not preset_names:
+ console.print("[yellow]! No presets defined yet.[/yellow]")
+ _get_questionary().press_any_key_to_continue().ask()
+ continue
+ add_choices = [p for p in preset_names if p not in items]
+ if not add_choices:
+ console.print("[yellow]! All presets already added.[/yellow]")
+ _get_questionary().press_any_key_to_continue().ask()
+ continue
+ picked = _select_with_back("Select preset:", add_choices)
+ if picked is _BACK_PRESSED or picked is None:
+ continue
+ items.append(picked)
+ elif answer == "[-] Remove last" and items:
+ items.pop()
+ elif answer == "[X] Clear all" and items:
+ items.clear()
+
+
_FIELD_HANDLERS: dict[str, Any] = {
"model": _handle_model_field,
"context_window_tokens": _handle_context_window_field,
+ "model_preset": _handle_model_preset_field,
+ "provider": _handle_provider_field,
+ "fallback_models": _handle_fallback_models_field,
}
@@ -757,6 +854,116 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
+# --- Model Preset Configuration ---
+
+
+def _sync_preset_cache(config: Config) -> None:
+ """Synchronise the module-level preset name cache from config."""
+ _MODEL_PRESET_CACHE.clear()
+ _MODEL_PRESET_CACHE.update(config.model_presets.keys())
+
+
+def _configure_model_presets(config: Config) -> None:
+ """Configure model presets (CRUD)."""
+ _sync_preset_cache(config)
+
+ def get_preset_choices() -> list[str]:
+ choices: list[str] = []
+ for name, preset in config.model_presets.items():
+ choices.append(f"{name} ({preset.model})")
+ choices.append("[+] Add new preset")
+ choices.append("<- Back")
+ return choices
+
+ last_preset_name: str | None = None
+ while True:
+ try:
+ console.clear()
+ _show_section_header(
+ "Model Presets",
+ "Create, edit or delete named model presets for quick switching",
+ )
+ choices = get_preset_choices()
+ default_choice = None
+ if last_preset_name:
+ for c in choices:
+ if c.startswith(last_preset_name + " ("):
+ default_choice = c
+ break
+ answer = _select_with_back(
+ "Select preset:", choices, default=default_choice
+ )
+
+ if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
+ break
+
+ assert isinstance(answer, str)
+
+ if answer == "[+] Add new preset":
+ name_input = _get_questionary().text(
+ "Preset name:",
+ validate=lambda t: True if t and t.strip() else "Name cannot be empty",
+ ).ask()
+ if not name_input:
+ continue
+ name = name_input.strip()
+ if name in config.model_presets:
+ console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
+ _pause()
+ continue
+ if name == "default":
+ console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]")
+ _pause()
+ continue
+ new_preset = ModelPresetConfig(model="")
+ updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
+ if updated is not None:
+ config.model_presets[name] = updated
+ _sync_preset_cache(config)
+ last_preset_name = name
+ continue
+
+ # Editing / deleting an existing preset
+ preset_name = answer.split(" (", 1)[0]
+ preset = config.model_presets.get(preset_name)
+ if preset is None:
+ continue
+
+ last_preset_name = preset_name
+
+ choices = ["Edit", "Cancel"]
+ if preset_name != "default":
+ choices.insert(1, "Delete")
+ action = _select_with_back(
+ f"Preset: {preset_name}",
+ choices,
+ default="Edit",
+ )
+ if action is _BACK_PRESSED or action == "Cancel" or action is None:
+ continue
+
+ if action == "Delete":
+ confirm = _get_questionary().confirm(
+ f"Delete preset '{preset_name}'?",
+ default=False,
+ ).ask()
+ if confirm:
+ del config.model_presets[preset_name]
+ _sync_preset_cache(config)
+ last_preset_name = None
+ continue
+
+ if action == "Edit":
+ updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
+ if updated is not None:
+ config.model_presets[preset_name] = updated
+ _sync_preset_cache(config)
+
+ except KeyboardInterrupt:
+ console.print("\n[dim]Returning to main menu...[/dim]")
+ break
+
+
# --- Provider Configuration ---
@@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None:
channel_rows.append((display, status))
_print_summary_panel(channel_rows, "Chat Channels")
+ # Model Presets
+ preset_rows = []
+ for name, preset in config.model_presets.items():
+ preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})"))
+ _print_summary_panel(preset_rows, "Model Presets")
+
# Settings sections
for title, model in [
("Agent Settings", config.agents.defaults),
@@ -1112,6 +1325,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
original_config = base_config.model_copy(deep=True)
config = base_config.model_copy(deep=True)
+ _sync_preset_cache(config)
last_main_choice: str | None = None
while True:
@@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"What would you like to configure?",
choices=[
"[P] LLM Provider",
+ "[M] Model Presets",
"[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings",
@@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
_menu_dispatch = {
"[P] LLM Provider": lambda: _configure_providers(config),
+ "[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py
index b41ee7a1e..55d26cf11 100644
--- a/nanobot/heartbeat/service.py
+++ b/nanobot/heartbeat/service.py
@@ -4,12 +4,12 @@ from __future__ import annotations
import asyncio
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, Coroutine
+from typing import Any, Callable, Coroutine
from loguru import logger
-if TYPE_CHECKING:
- from nanobot.providers.base import LLMProvider
+from nanobot.providers.base import LLMProvider
+from nanobot.utils.llm_runtime import LLMRuntimeResolver, static_llm_runtime
_HEARTBEAT_TOOL = [
{
@@ -53,17 +53,21 @@ class HeartbeatService:
def __init__(
self,
workspace: Path,
- provider: LLMProvider,
- model: str,
+ provider: LLMProvider | None = None,
+ model: str | None = None,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = 30 * 60,
enabled: bool = True,
timezone: str | None = None,
+ llm_runtime: LLMRuntimeResolver | None = None,
):
self.workspace = workspace
- self.provider = provider
- self.model = model
+ if llm_runtime is None:
+ if provider is None or model is None:
+ raise ValueError("HeartbeatService requires either llm_runtime or provider/model")
+ llm_runtime = static_llm_runtime(provider, model)
+ self._llm_runtime = llm_runtime
self.on_execute = on_execute
self.on_notify = on_notify
self.interval_s = interval_s
@@ -91,7 +95,9 @@ class HeartbeatService:
"""
from nanobot.utils.helpers import current_time_str
- response = await self.provider.chat_with_retry(
+ llm = self._llm_runtime()
+
+ response = await llm.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
@@ -101,7 +107,7 @@ class HeartbeatService:
)},
],
tools=_HEARTBEAT_TOOL,
- model=self.model,
+ model=llm.model,
)
if not response.should_execute_tools:
@@ -214,8 +220,9 @@ class HeartbeatService:
)
return
+ llm = self._llm_runtime()
should_notify = await evaluate_response(
- response, tasks, self.provider, self.model,
+ response, tasks, llm.provider, llm.model,
)
if should_notify and self.on_notify:
logger.info("Heartbeat: completed, delivering response")
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 4dba0c46d..e6f022187 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
name="vllm",
keywords=("vllm",),
env_key="HOSTED_VLLM_API_KEY",
- display_name="vLLM/Local",
+ display_name="vLLM",
backend="openai_compat",
is_local=True,
),
diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py
new file mode 100644
index 000000000..8164aa18d
--- /dev/null
+++ b/nanobot/utils/file_edit_events.py
@@ -0,0 +1,311 @@
+"""File-edit activity helpers for WebUI progress events."""
+
+from __future__ import annotations
+
+import difflib
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
+_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
+
+
+@dataclass(slots=True)
+class FileSnapshot:
+ path: Path
+ exists: bool
+ text: str | None
+ unreadable: bool = False
+ binary: bool = False
+ oversized: bool = False
+
+ @property
+ def countable(self) -> bool:
+ return (
+ self.text is not None
+ and not self.binary
+ and not self.oversized
+ and not self.unreadable
+ )
+
+
+@dataclass(slots=True)
+class FileEditTracker:
+ call_id: str
+ tool: str
+ path: Path
+ display_path: str
+ before: FileSnapshot
+
+
+def is_file_edit_tool(tool_name: str | None) -> bool:
+ return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
+
+
+def resolve_file_edit_path(
+ tool: Any,
+ workspace: Path | None,
+ params: dict[str, Any] | None,
+) -> Path | None:
+ """Resolve the target file path after tool argument preparation."""
+ if not isinstance(params, dict):
+ return None
+ raw_path = params.get("path")
+ if not isinstance(raw_path, str) or not raw_path.strip():
+ return None
+ resolver = getattr(tool, "_resolve", None)
+ if callable(resolver):
+ try:
+ resolved = resolver(raw_path)
+ if isinstance(resolved, Path):
+ return resolved
+ if resolved:
+ return Path(resolved)
+ except Exception:
+ return None
+ if workspace is None:
+ return Path(raw_path).expanduser().resolve()
+ return (workspace / raw_path).expanduser().resolve()
+
+
+def display_file_edit_path(path: Path, workspace: Path | None) -> str:
+ if workspace is not None:
+ try:
+ return path.resolve().relative_to(workspace.resolve()).as_posix()
+ except Exception:
+ pass
+ return path.as_posix()
+
+
+def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
+ try:
+ if not path.exists() or not path.is_file():
+ return FileSnapshot(path=path, exists=False, text="")
+ size = path.stat().st_size
+ if size > max_bytes:
+ return FileSnapshot(path=path, exists=True, text=None, oversized=True)
+ raw = path.read_bytes()
+ except OSError:
+ return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
+ if b"\x00" in raw:
+ return FileSnapshot(path=path, exists=True, text=None, binary=True)
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ return FileSnapshot(path=path, exists=True, text=None, binary=True)
+ return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
+
+
+def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
+ """Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
+ if before is None or after is None:
+ return 0, 0
+ before_lines = before.replace("\r\n", "\n").splitlines()
+ after_lines = after.replace("\r\n", "\n").splitlines()
+ added = 0
+ deleted = 0
+ matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "equal":
+ continue
+ if tag in ("replace", "delete"):
+ deleted += i2 - i1
+ if tag in ("replace", "insert"):
+ added += j2 - j1
+ return added, deleted
+
+
+def prepare_file_edit_tracker(
+ *,
+ call_id: str,
+ tool_name: str,
+ tool: Any,
+ workspace: Path | None,
+ params: dict[str, Any] | None,
+) -> FileEditTracker | None:
+ if not is_file_edit_tool(tool_name):
+ return None
+ path = resolve_file_edit_path(tool, workspace, params)
+ if path is None:
+ return None
+ before = read_file_snapshot(path)
+ return FileEditTracker(
+ call_id=str(call_id or ""),
+ tool=tool_name,
+ path=path,
+ display_path=display_file_edit_path(path, workspace),
+ before=before,
+ )
+
+
+def build_file_edit_start_event(
+ tracker: FileEditTracker,
+ params: dict[str, Any] | None,
+) -> dict[str, Any]:
+ predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
+ if tracker.before.countable and predicted_after is not None:
+ added, deleted = line_diff_stats(tracker.before.text, predicted_after)
+ else:
+ added, deleted = 0, 0
+ return _event_payload(
+ tracker,
+ phase="start",
+ status="editing",
+ added=added,
+ deleted=deleted,
+ approximate=True,
+ )
+
+
+def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
+ after = read_file_snapshot(tracker.path)
+ if tracker.before.countable and after.countable:
+ added, deleted = line_diff_stats(tracker.before.text, after.text)
+ else:
+ added, deleted = 0, 0
+ return _event_payload(
+ tracker,
+ phase="end",
+ status="done",
+ added=added,
+ deleted=deleted,
+ approximate=False,
+ binary=after.binary or after.oversized or after.unreadable,
+ )
+
+
+def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
+ payload = _event_payload(
+ tracker,
+ phase="error",
+ status="error",
+ added=0,
+ deleted=0,
+ approximate=False,
+ )
+ if error:
+ payload["error"] = error.strip()[:240]
+ return payload
+
+
+def _event_payload(
+ tracker: FileEditTracker,
+ *,
+ phase: str,
+ status: str,
+ added: int,
+ deleted: int,
+ approximate: bool,
+ binary: bool = False,
+) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "version": 1,
+ "call_id": tracker.call_id,
+ "tool": tracker.tool,
+ "path": tracker.display_path,
+ "phase": phase,
+ "added": max(0, int(added)),
+ "deleted": max(0, int(deleted)),
+ "approximate": bool(approximate),
+ "status": status,
+ }
+ if binary:
+ payload["binary"] = True
+ return payload
+
+
+def _predict_after_text(
+ tool_name: str,
+ params: dict[str, Any],
+ before: FileSnapshot,
+) -> str | None:
+ if not before.countable:
+ return None
+ before_text = before.text or ""
+ if tool_name == "write_file":
+ content = params.get("content")
+ return content if isinstance(content, str) else ""
+ if tool_name == "edit_file":
+ old_text = params.get("old_text")
+ new_text = params.get("new_text")
+ if not isinstance(old_text, str) or not isinstance(new_text, str):
+ return None
+ replace_all = bool(params.get("replace_all"))
+ if old_text == "":
+ return new_text if not before.exists else before_text
+ if old_text in before_text:
+ if replace_all:
+ return before_text.replace(old_text, new_text)
+ return before_text.replace(old_text, new_text, 1)
+ return None
+ if tool_name == "notebook_edit":
+ return _predict_notebook_after_text(params, before_text)
+ return None
+
+
+def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
+ try:
+ nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
+ except Exception:
+ return None
+ cells = nb.get("cells")
+ if not isinstance(cells, list):
+ return None
+ try:
+ cell_index = int(params.get("cell_index", 0))
+ except (TypeError, ValueError):
+ return None
+ new_source = params.get("new_source")
+ source = new_source if isinstance(new_source, str) else ""
+ cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
+ mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
+ if mode == "delete":
+ if 0 <= cell_index < len(cells):
+ cells.pop(cell_index)
+ else:
+ return None
+ elif mode == "insert":
+ insert_at = min(max(cell_index + 1, 0), len(cells))
+ cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
+ else:
+ if not (0 <= cell_index < len(cells)):
+ return None
+ cell = cells[cell_index]
+ if not isinstance(cell, dict):
+ return None
+ cell["source"] = source
+ cell["cell_type"] = cell_type
+ if cell_type == "code":
+ cell.setdefault("outputs", [])
+ cell.setdefault("execution_count", None)
+ else:
+ cell.pop("outputs", None)
+ cell.pop("execution_count", None)
+ nb["cells"] = cells
+ try:
+ return json.dumps(nb, indent=1, ensure_ascii=False)
+ except Exception:
+ return None
+
+
+def _empty_notebook() -> dict[str, Any]:
+ return {
+ "nbformat": 4,
+ "nbformat_minor": 5,
+ "metadata": {
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
+ "language_info": {"name": "python"},
+ },
+ "cells": [],
+ }
+
+
+def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
+ cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
+ if cell_type == "code":
+ cell["outputs"] = []
+ cell["execution_count"] = None
+ return cell
diff --git a/nanobot/utils/llm_runtime.py b/nanobot/utils/llm_runtime.py
new file mode 100644
index 000000000..a74f0d8c0
--- /dev/null
+++ b/nanobot/utils/llm_runtime.py
@@ -0,0 +1,22 @@
+"""Small helpers for passing the active LLM provider/model together."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from nanobot.providers.base import LLMProvider
+
+
+@dataclass(frozen=True)
+class LLMRuntime:
+ provider: LLMProvider
+ model: str
+
+
+LLMRuntimeResolver = Callable[[], LLMRuntime]
+
+
+def static_llm_runtime(provider: LLMProvider, model: str) -> LLMRuntimeResolver:
+ runtime = LLMRuntime(provider=provider, model=model)
+ return lambda: runtime
diff --git a/nanobot/utils/progress_events.py b/nanobot/utils/progress_events.py
index 10a282b99..ccf125ec4 100644
--- a/nanobot/utils/progress_events.py
+++ b/nanobot/utils/progress_events.py
@@ -10,13 +10,21 @@ from nanobot.agent.hook import AgentHookContext
def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool:
+ return _on_progress_accepts(cb, "tool_events")
+
+
+def on_progress_accepts_file_edit_events(cb: Callable[..., Any]) -> bool:
+ return _on_progress_accepts(cb, "file_edit_events")
+
+
+def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
try:
sig = inspect.signature(cb)
except (TypeError, ValueError):
return False
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
return True
- return "tool_events" in sig.parameters
+ return name in sig.parameters
async def invoke_on_progress(
@@ -32,6 +40,15 @@ async def invoke_on_progress(
await on_progress(content, tool_hint=tool_hint)
+async def invoke_file_edit_progress(
+ on_progress: Callable[..., Awaitable[None]],
+ file_edit_events: list[dict[str, Any]],
+) -> None:
+ if not file_edit_events or not on_progress_accepts_file_edit_events(on_progress):
+ return
+ await on_progress("", file_edit_events=file_edit_events)
+
+
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
return {
"version": 1,
diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py
deleted file mode 100644
index 2d363f926..000000000
--- a/nanobot/utils/webui_titles.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""Helpers for WebUI chat title generation."""
-
-from __future__ import annotations
-
-import re
-from typing import Any
-
-from loguru import logger
-
-from nanobot.providers.base import LLMProvider
-from nanobot.session.manager import Session, SessionManager
-from nanobot.utils.helpers import truncate_text
-
-WEBUI_SESSION_METADATA_KEY = "webui"
-WEBUI_TITLE_METADATA_KEY = "title"
-WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
-TITLE_MAX_CHARS = 60
-
-
-def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
- """Persist a WebUI marker only when the inbound websocket frame opted in."""
- if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- session.metadata[WEBUI_SESSION_METADATA_KEY] = True
- return True
-
-
-def clean_generated_title(raw: str | None) -> str:
- text = (raw or "").strip()
- if not text:
- return ""
- text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
- text = text.strip().strip("\"'`“”‘’")
- text = re.sub(r"\s+", " ", text).strip()
- text = text.rstrip("。.!!??,,;;:")
- if len(text) > TITLE_MAX_CHARS:
- text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
- return text
-
-
-def _title_inputs(session: Session) -> tuple[str, str]:
- user_text = ""
- assistant_text = ""
- for message in session.messages:
- role = message.get("role")
- content = message.get("content")
- if not isinstance(content, str) or not content.strip():
- continue
- if role == "user" and not user_text:
- user_text = content.strip()
- elif role == "assistant" and not assistant_text:
- assistant_text = content.strip()
- if user_text and assistant_text:
- break
- return user_text, assistant_text
-
-
-async def maybe_generate_webui_title(
- *,
- sessions: SessionManager,
- session_key: str,
- provider: LLMProvider,
- model: str,
-) -> bool:
- """Generate and persist a short title for WebUI-owned sessions only."""
- session = sessions.get_or_create(session_key)
- if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
- return False
- current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
- if isinstance(current_title, str) and current_title.strip():
- return False
-
- user_text, assistant_text = _title_inputs(session)
- if not user_text:
- return False
-
- prompt = (
- "Generate a concise title for this chat.\n"
- "Rules:\n"
- "- Use the same language as the user when practical.\n"
- "- 3 to 8 words.\n"
- "- No quotes.\n"
- "- No punctuation at the end.\n"
- "- Return only the title.\n\n"
- f"User: {truncate_text(user_text, 1_000)}"
- )
- if assistant_text:
- prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
-
- try:
- response = await provider.chat_with_retry(
- [
- {
- "role": "system",
- "content": (
- "You write short, neutral chat titles. "
- "Return only the title text."
- ),
- },
- {"role": "user", "content": prompt},
- ],
- tools=None,
- model=model,
- max_tokens=32,
- temperature=0.2,
- retry_mode="standard",
- )
- except Exception:
- logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
- return False
-
- title = clean_generated_title(response.content)
- if not title or title.lower().startswith("error"):
- return False
- session.metadata[WEBUI_TITLE_METADATA_KEY] = title
- sessions.save(session)
- return True
-
-
-async def maybe_generate_webui_title_after_turn(
- *,
- channel: str,
- metadata: dict[str, Any],
- sessions: SessionManager,
- session_key: str,
- provider: LLMProvider,
- model: str,
-) -> bool:
- if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- return await maybe_generate_webui_title(
- sessions=sessions,
- session_key=session_key,
- provider=provider,
- model=model,
- )
diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py
index dde0e9168..bee71c542 100644
--- a/nanobot/utils/webui_transcript.py
+++ b/nanobot/utils/webui_transcript.py
@@ -125,11 +125,25 @@ def replay_transcript_to_ui_messages(
buffer_message_id: str | None = None
buffer_parts: list[str] = []
suppress_until_turn_end = False
+ active_activity_segment_id: str | None = None
+ active_file_edit_segment_id: str | None = None
+ activity_segment_counter = 0
_ts_base = int(time.time() * 1000)
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
+ def _new_activity_segment(*, activate: bool = True) -> str:
+ nonlocal active_activity_segment_id, activity_segment_counter
+ activity_segment_counter += 1
+ segment_id = f"activity-{activity_segment_counter}"
+ if activate:
+ active_activity_segment_id = segment_id
+ return segment_id
+
+ def _ensure_activity_segment() -> str:
+ return active_activity_segment_id or _new_activity_segment()
+
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
@@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
**candidate,
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
+ "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
if not has_answer and candidate.get("isStreaming"):
- prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True}
+ prev[i] = {
+ **candidate,
+ "reasoning": chunk,
+ "reasoningStreaming": True,
+ "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
+ }
return
break
+ segment = _ensure_activity_segment()
prev.append(
{
"id": _new_id("as", idx),
@@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
"isStreaming": True,
"reasoning": chunk,
"reasoningStreaming": True,
+ "activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
+ nonlocal active_activity_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
@@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
**extra,
},
)
+ active_activity_segment_id = None
+
+ def _file_edit_key(edit: dict[str, Any]) -> str:
+ return "|".join(
+ str(edit.get(k) or "")
+ for k in ("call_id", "tool", "path")
+ )
+
+ def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
+ nonlocal active_file_edit_segment_id
+ if not edits:
+ return
+ last = messages[-1] if messages else None
+ if (
+ active_file_edit_segment_id
+ and last
+ and last.get("kind") == "trace"
+ and last.get("fileEdits")
+ ):
+ segment = active_file_edit_segment_id
+ else:
+ segment = _new_activity_segment(activate=False)
+ active_file_edit_segment_id = segment
+ if not (
+ last
+ and last.get("kind") == "trace"
+ and not last.get("isStreaming")
+ and last.get("fileEdits")
+ and last.get("activitySegmentId") == segment
+ ):
+ messages.append(
+ {
+ "id": _new_id("tr", idx),
+ "role": "tool",
+ "kind": "trace",
+ "content": "",
+ "traces": [],
+ "fileEdits": [],
+ "activitySegmentId": segment,
+ "createdAt": _ts_base + idx,
+ },
+ )
+ last = messages[-1]
+ existing = list(last.get("fileEdits") or [])
+ index_by_key = {
+ _file_edit_key(edit): pos
+ for pos, edit in enumerate(existing)
+ if isinstance(edit, dict)
+ }
+ for edit in edits:
+ if not isinstance(edit, dict):
+ continue
+ key = _file_edit_key(edit)
+ if key in index_by_key:
+ pos = index_by_key[key]
+ existing[pos] = {**existing[pos], **edit}
+ else:
+ index_by_key[key] = len(existing)
+ existing.append(dict(edit))
+ messages[-1] = {
+ **last,
+ "fileEdits": existing,
+ "activitySegmentId": last.get("activitySegmentId") or segment,
+ }
for idx, rec in enumerate(lines):
ev = rec.get("event")
if ev == "user":
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
text = rec.get("text")
text_s = text if isinstance(text, str) else ""
media_paths = rec.get("media_paths")
@@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
messages.append(row)
continue
+ if ev == "file_edit":
+ raw_edits = rec.get("edits")
+ if isinstance(raw_edits, list):
+ upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
+ continue
+
if ev == "delta":
if suppress_until_turn_end:
continue
@@ -338,14 +433,21 @@ def replay_transcript_to_ui_messages(
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if not trace_lines:
continue
+ segment = _ensure_activity_segment()
last = messages[-1] if messages else None
- if last and last.get("kind") == "trace" and not last.get("isStreaming"):
+ if (
+ last
+ and last.get("kind") == "trace"
+ and not last.get("isStreaming")
+ and (last.get("activitySegmentId") in (None, segment))
+ ):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
+ "activitySegmentId": last.get("activitySegmentId") or segment,
}
else:
messages.append(
@@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
+ "activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
if ev == "turn_end":
suppress_until_turn_end = False
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}
diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/utils/webui_turn_helpers.py
index 3fbca3729..6a3ac2ba0 100644
--- a/nanobot/utils/webui_turn_helpers.py
+++ b/nanobot/utils/webui_turn_helpers.py
@@ -6,17 +6,163 @@ AgentLoop uses these without importing a concrete channel plugin; only
from __future__ import annotations
+import re
import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
from typing import Any
+from loguru import logger
+
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
+from nanobot.providers.base import LLMProvider
+from nanobot.session.goal_state import goal_state_ws_blob
+from nanobot.session.manager import Session, SessionManager
+from nanobot.utils.helpers import truncate_text
+from nanobot.utils.llm_runtime import LLMRuntime
+
+WEBUI_SESSION_METADATA_KEY = "webui"
+WEBUI_TITLE_METADATA_KEY = "title"
+WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
+TITLE_MAX_CHARS = 60
+TITLE_GENERATION_MAX_TOKENS = 96
+TITLE_GENERATION_REASONING_EFFORT = "none"
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
+def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
+ """Persist a WebUI marker only when the inbound websocket frame opted in."""
+ if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ session.metadata[WEBUI_SESSION_METADATA_KEY] = True
+ return True
+
+
+def clean_generated_title(raw: str | None) -> str:
+ text = (raw or "").strip()
+ if not text:
+ return ""
+ text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
+ text = text.strip().strip("\"'`“”‘’")
+ text = re.sub(r"\s+", " ", text).strip()
+ text = text.rstrip("。.!!??,,;;:")
+ if len(text) > TITLE_MAX_CHARS:
+ text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
+ return text
+
+
+def _title_inputs(session: Session) -> tuple[str, str]:
+ user_text = ""
+ assistant_text = ""
+ for message in session.messages:
+ if message.get("_command") is True:
+ continue
+ role = message.get("role")
+ content = message.get("content")
+ if not isinstance(content, str) or not content.strip():
+ continue
+ if role == "user" and not user_text:
+ user_text = content.strip()
+ elif role == "assistant" and not assistant_text:
+ assistant_text = content.strip()
+ if user_text and assistant_text:
+ break
+ return user_text, assistant_text
+
+
+async def maybe_generate_webui_title(
+ *,
+ sessions: SessionManager,
+ session_key: str,
+ provider: LLMProvider,
+ model: str,
+) -> bool:
+ """Generate and persist a short title for WebUI-owned sessions only."""
+ session = sessions.get_or_create(session_key)
+ if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
+ return False
+ current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
+ if isinstance(current_title, str) and current_title.strip():
+ return False
+
+ user_text, assistant_text = _title_inputs(session)
+ if not user_text:
+ return False
+
+ prompt = (
+ "Generate a concise title for this chat.\n"
+ "Rules:\n"
+ "- Use the same language as the user when practical.\n"
+ "- 3 to 8 words.\n"
+ "- No quotes.\n"
+ "- No punctuation at the end.\n"
+ "- Return only the title.\n\n"
+ f"User: {truncate_text(user_text, 1_000)}"
+ )
+ if assistant_text:
+ prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
+
+ try:
+ response = await provider.chat_with_retry(
+ [
+ {
+ "role": "system",
+ "content": (
+ "You write short, neutral chat titles. "
+ "Return only the title text."
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ tools=None,
+ model=model,
+ max_tokens=TITLE_GENERATION_MAX_TOKENS,
+ temperature=0.2,
+ reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
+ retry_mode="standard",
+ )
+ except Exception:
+ logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
+ return False
+
+ title = clean_generated_title(response.content)
+ if not title or title.lower().startswith("error"):
+ logger.debug(
+ "WebUI title generation returned no usable title for {} (finish_reason={})",
+ session_key,
+ response.finish_reason,
+ )
+ return False
+ session.metadata[WEBUI_TITLE_METADATA_KEY] = title
+ sessions.save(session)
+ return True
+
+
+async def maybe_generate_webui_title_after_turn(
+ *,
+ channel: str,
+ metadata: dict[str, Any],
+ sessions: SessionManager,
+ session_key: str,
+ provider: LLMProvider,
+ model: str,
+) -> bool:
+ if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ return await maybe_generate_webui_title(
+ sessions=sessions,
+ session_key=session_key,
+ provider=provider,
+ model=model,
+ )
+
+
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
"""Return ``time.time()`` when the active user turn began, if still running."""
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
@@ -46,3 +192,156 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
metadata=meta,
),
)
+
+
+def build_bus_progress_callback(
+ bus: MessageBus,
+ msg: InboundMessage,
+) -> Callable[..., Awaitable[None]]:
+ """Return the bus progress callback for agent runtime events."""
+
+ async def _publish_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict[str, Any]] | None = None,
+ file_edit_events: list[dict[str, Any]] | None = None,
+ reasoning: bool = False,
+ reasoning_end: bool = False,
+ ) -> None:
+ meta = dict(msg.metadata or {})
+ meta["_progress"] = True
+ meta["_tool_hint"] = tool_hint
+ if reasoning:
+ meta["_reasoning_delta"] = True
+ if reasoning_end:
+ meta["_reasoning_end"] = True
+ if tool_events:
+ meta["_tool_events"] = tool_events
+ if file_edit_events:
+ meta["_file_edit_events"] = file_edit_events
+ await bus.publish_outbound(
+ OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=content,
+ metadata=meta,
+ )
+ )
+
+ if msg.channel == "websocket":
+ async def _websocket_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict[str, Any]] | None = None,
+ file_edit_events: list[dict[str, Any]] | None = None,
+ reasoning: bool = False,
+ reasoning_end: bool = False,
+ ) -> None:
+ await _publish_progress(
+ content,
+ tool_hint=tool_hint,
+ tool_events=tool_events,
+ file_edit_events=file_edit_events,
+ reasoning=reasoning,
+ reasoning_end=reasoning_end,
+ )
+
+ return _websocket_progress
+
+ async def _bus_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict[str, Any]] | None = None,
+ reasoning: bool = False,
+ reasoning_end: bool = False,
+ ) -> None:
+ await _publish_progress(
+ content,
+ tool_hint=tool_hint,
+ tool_events=tool_events,
+ reasoning=reasoning,
+ reasoning_end=reasoning_end,
+ )
+
+ return _bus_progress
+
+
+@dataclass
+class WebuiTurnCoordinator:
+ """Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
+
+ bus: MessageBus
+ sessions: SessionManager
+ schedule_background: Callable[[Awaitable[None]], None]
+ _title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
+
+ def capture_title_context(
+ self,
+ session_key: str,
+ msg: InboundMessage,
+ llm: LLMRuntime,
+ ) -> None:
+ if msg.channel == "websocket" and msg.metadata.get("webui") is True:
+ self._title_contexts[session_key] = llm
+
+ def discard(self, session_key: str) -> None:
+ self._title_contexts.pop(session_key, None)
+
+ async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
+ await publish_turn_run_status(self.bus, msg, status)
+
+ async def handle_turn_end(
+ self,
+ msg: InboundMessage,
+ *,
+ session_key: str,
+ latency_ms: int | None,
+ ) -> None:
+ if msg.channel != "websocket":
+ return
+
+ turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
+ if latency_ms is not None:
+ turn_metadata["latency_ms"] = int(latency_ms)
+ session = self.sessions.get_or_create(session_key)
+ turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="",
+ metadata=turn_metadata,
+ ))
+ self._schedule_title_update(msg, session_key=session_key)
+
+ def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
+ title_context = self._title_contexts.pop(session_key, None)
+ if msg.metadata.get("webui") is not True or title_context is None:
+ return
+
+ async def _generate_title_and_notify(
+ title_llm: LLMRuntime = title_context,
+ ) -> None:
+ generated = await maybe_generate_webui_title_after_turn(
+ channel=msg.channel,
+ metadata=msg.metadata,
+ sessions=self.sessions,
+ session_key=session_key,
+ provider=title_llm.provider,
+ model=title_llm.model,
+ )
+ if generated:
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="",
+ metadata={
+ **msg.metadata,
+ "_session_updated": True,
+ "_session_update_scope": "metadata",
+ },
+ ))
+
+ self.schedule_background(_generate_title_and_notify())
diff --git a/nanobot/web/__init__.py b/nanobot/web/__init__.py
index 7a08932f6..36ee3e934 100644
--- a/nanobot/web/__init__.py
+++ b/nanobot/web/__init__.py
@@ -1,6 +1,8 @@
"""Embedded web UI assets.
-The ``dist/`` subdirectory is populated by ``cd webui && bun run build`` and
-is shipped in the wheel; it stays empty in source checkouts until that command
-has been run.
+The ``dist/`` subdirectory holds the production WebUI bundle served by the
+gateway. It is shipped inside the published wheel and is rebuilt automatically
+by the ``webui-build`` Hatch hook during ``python -m build``. In an editable
+source checkout it stays empty until you run ``cd webui && bun run build``
+(or use the Vite dev server at ``cd webui && bun run dev``).
"""
diff --git a/pyproject.toml b/pyproject.toml
index 16ed57dd2..eaf57a2ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.5.post3"
+version = "0.2.0"
description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
@@ -121,12 +121,22 @@ build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
+[tool.hatch.build.hooks.custom]
+# Implementation lives in the conventional `hatch_build.py` at the repo root.
+
[tool.hatch.build]
include = [
"nanobot/**/*.py",
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh",
+ "nanobot/web/dist/**/*",
+]
+# nanobot/web/dist/ is produced by `cd webui && bun run build` and is
+# git-ignored. List it as an artifact so hatch ships it in both wheel and
+# sdist even though VCS does not track it.
+artifacts = [
+ "nanobot/web/dist/**/*",
]
[tool.hatch.build.targets.wheel]
@@ -141,7 +151,9 @@ packages = ["nanobot"]
[tool.hatch.build.targets.sdist]
include = [
"nanobot/",
+ "nanobot/web/dist/",
"bridge/",
+ "hatch_build.py",
"README.md",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py
index 0bc02a694..37fcbfdae 100644
--- a/tests/agent/test_auto_compact.py
+++ b/tests/agent/test_auto_compact.py
@@ -45,6 +45,73 @@ def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
session.add_message("assistant", f"{prefix} assistant {i}")
+def _make_fake_compact(
+ loop: AgentLoop,
+ *,
+ summary: str = "Summary.",
+ on_archive=None,
+ track_archived: list | None = None,
+ track_count: bool = False,
+):
+ """Return a fake compact_idle_session that mirrors the real method's session mutation."""
+ from nanobot.session.manager import Session as _Session
+
+ state = {"count": 0}
+
+ async def _fake_compact(key: str, max_suffix: int = 8) -> str:
+ state["count"] += 1
+ session = loop.sessions.get_or_create(key)
+
+ tail = list(session.messages[session.last_consolidated:])
+ if not tail:
+ session.updated_at = datetime.now()
+ loop.sessions.save(session)
+ return ""
+
+ probe = _Session(
+ key=session.key,
+ messages=tail.copy(),
+ created_at=session.created_at,
+ updated_at=session.updated_at,
+ metadata={},
+ last_consolidated=0,
+ )
+ probe.retain_recent_legal_suffix(max_suffix)
+ kept = probe.messages
+ cut = len(tail) - len(kept)
+ archive_msgs = tail[:cut]
+
+ if not archive_msgs and not kept:
+ session.updated_at = datetime.now()
+ loop.sessions.save(session)
+ return ""
+
+ last_active = session.updated_at
+ s = summary
+ if archive_msgs:
+ if on_archive:
+ result = on_archive(archive_msgs)
+ s = result if isinstance(result, str) else summary
+ if track_archived is not None:
+ track_archived.extend(archive_msgs)
+
+ if s and s != "(nothing)":
+ session.metadata["_last_summary"] = {
+ "text": s,
+ "last_active": last_active.isoformat(),
+ }
+
+ session.messages = kept
+ session.last_consolidated = 0
+ session.updated_at = datetime.now()
+ loop.sessions.save(session)
+ return s
+
+ # Attach state for count access
+ _fake_compact.state = state # type: ignore[attr-defined]
+ return _fake_compact
+
+
class TestSessionTTLConfig:
"""Test session TTL configuration."""
@@ -201,10 +268,7 @@ class TestAutoCompact:
s2.add_message("user", "recent")
loop.sessions.save(s2)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
loop.auto_compact.check_expired(loop._schedule_background)
await asyncio.sleep(0.1)
@@ -222,12 +286,9 @@ class TestAutoCompact:
loop.sessions.save(session)
archived_messages = []
-
- async def _fake_archive(messages):
- archived_messages.extend(messages)
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, track_archived=archived_messages,
+ )
await loop.auto_compact._archive("cli:test")
@@ -246,10 +307,9 @@ class TestAutoCompact:
_add_turns(session, 6, prefix="hello")
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "User said hello."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="User said hello.",
+ )
await loop.auto_compact._archive("cli:test")
@@ -262,23 +322,16 @@ class TestAutoCompact:
@pytest.mark.asyncio
async def test_auto_compact_empty_session(self, tmp_path):
- """_archive on empty session should not archive."""
+ """_archive on empty session should not store a summary."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
- archive_called = False
-
- async def _fake_archive(messages):
- nonlocal archive_called
- archive_called = True
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
- assert not archive_called
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
+ assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@@ -290,18 +343,14 @@ class TestAutoCompact:
session.last_consolidated = 18
loop.sessions.save(session)
- archived_count = 0
-
- async def _fake_archive(messages):
- nonlocal archived_count
- archived_count = len(messages)
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ archived_messages = []
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, track_archived=archived_messages,
+ )
await loop.auto_compact._archive("cli:test")
- assert archived_count == 2
+ assert len(archived_messages) == 2
await loop.close_mcp()
@@ -334,12 +383,9 @@ class TestAutoCompactIdleDetection:
loop.sessions.save(session)
archived_messages = []
-
- async def _fake_archive(messages):
- archived_messages.extend(messages)
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, track_archived=archived_messages,
+ )
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@@ -402,10 +448,7 @@ class TestAutoCompactIdleDetection:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(msg)
@@ -466,10 +509,7 @@ class TestAutoCompactSystemMessages:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# Simulate proactive archive completing before system message arrives
await loop.auto_compact._archive("cli:test")
@@ -547,12 +587,9 @@ class TestAutoCompactEdgeCases:
loop.sessions.save(session)
archived_messages = []
-
- async def _fake_archive(messages):
- archived_messages.extend(messages)
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, track_archived=archived_messages,
+ )
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@@ -644,10 +681,7 @@ class TestAutoCompactIntegration:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test")
@@ -704,12 +738,9 @@ class TestProactiveAutoCompact:
loop.sessions.save(session)
archived_messages = []
-
- async def _fake_archive(messages):
- archived_messages.extend(messages)
- return "User chatted about old things."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="User chatted about old things.", track_archived=archived_messages,
+ )
await self._run_check_expired(loop)
@@ -748,14 +779,14 @@ class TestProactiveAutoCompact:
started = asyncio.Event()
block_forever = asyncio.Event()
- async def _slow_archive(messages):
+ async def _slow_compact(key, max_suffix=8):
nonlocal archive_count
archive_count += 1
started.set()
await block_forever.wait()
return "Summary."
- loop.consolidator.archive = _slow_archive
+ loop.consolidator.compact_idle_session = _slow_compact
# First call starts archiving via callback
loop.auto_compact.check_expired(loop._schedule_background)
@@ -781,10 +812,10 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _failing_archive(messages):
+ async def _failing_compact(key, max_suffix=8):
raise RuntimeError("LLM down")
- loop.consolidator.archive = _failing_archive
+ loop.consolidator.compact_idle_session = _failing_compact
# Should not raise
await self._run_check_expired(loop)
@@ -795,24 +826,18 @@ class TestProactiveAutoCompact:
@pytest.mark.asyncio
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
- """Proactive archive should not call LLM for sessions with no un-consolidated messages."""
+ """Proactive archive should not produce a summary for sessions with no messages."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_called = False
-
- async def _fake_archive(messages):
- nonlocal archive_called
- archive_called = True
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await self._run_check_expired(loop)
- assert not archive_called
+ # Empty session should not produce a summary
+ assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@@ -824,18 +849,12 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ _fake_compact = _make_fake_compact(loop)
+ loop.consolidator.compact_idle_session = _fake_compact
# Simulate an active agent task for this session
await self._run_check_expired(loop, active_session_keys={"cli:test"})
- assert archive_count == 0
+ assert _fake_compact.state["count"] == 0
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 # All messages preserved
@@ -851,22 +870,16 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ _fake_compact = _make_fake_compact(loop)
+ loop.consolidator.compact_idle_session = _fake_compact
# First tick: active task, skip
await self._run_check_expired(loop, active_session_keys={"cli:test"})
- assert archive_count == 0
+ assert _fake_compact.state["count"] == 0
# Second tick: task completed, should archive
await self._run_check_expired(loop)
- assert archive_count == 1
+ assert _fake_compact.state["count"] == 1
await loop.close_mcp()
@pytest.mark.asyncio
@@ -888,18 +901,12 @@ class TestProactiveAutoCompact:
s3.add_message("user", "recent")
loop.sessions.save(s3)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ _fake_compact = _make_fake_compact(loop)
+ loop.consolidator.compact_idle_session = _fake_compact
await self._run_check_expired(loop, active_session_keys={"cli:expired_active"})
- assert archive_count == 1
+ assert _fake_compact.state["count"] == 1
s1_after = loop.sessions.get_or_create("cli:expired_idle")
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
s2_after = loop.sessions.get_or_create("cli:expired_active")
@@ -917,22 +924,16 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ _fake_compact = _make_fake_compact(loop)
+ loop.consolidator.compact_idle_session = _fake_compact
# First tick: archives the session
await self._run_check_expired(loop)
- assert archive_count == 1
+ assert _fake_compact.state["count"] == 1
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
await self._run_check_expired(loop)
- assert archive_count == 1 # Still 1, not re-scheduled
+ assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp()
@pytest.mark.asyncio
@@ -943,22 +944,15 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# First tick: skips (no messages), refreshes updated_at
await self._run_check_expired(loop)
- assert archive_count == 0
+ assert "cli:test" not in loop.auto_compact._summaries
# Second tick: should NOT re-schedule because updated_at is fresh
await self._run_check_expired(loop)
- assert archive_count == 0
+ assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
@pytest.mark.asyncio
@@ -970,18 +964,12 @@ class TestProactiveAutoCompact:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- archive_count = 0
-
- async def _fake_archive(messages):
- nonlocal archive_count
- archive_count += 1
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ _fake_compact = _make_fake_compact(loop)
+ loop.consolidator.compact_idle_session = _fake_compact
# First compact cycle
await loop.auto_compact._archive("cli:test")
- assert archive_count == 1
+ assert _fake_compact.state["count"] == 1
# User returns, sends new messages
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second topic")
@@ -995,7 +983,7 @@ class TestProactiveAutoCompact:
# Second compact cycle should succeed
await loop.auto_compact._archive("cli:test")
- assert archive_count == 2
+ assert _fake_compact.state["count"] == 2
await loop.close_mcp()
@@ -1011,10 +999,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "User said hello."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="User said hello.",
+ )
await loop.auto_compact._archive("cli:test")
@@ -1036,10 +1023,9 @@ class TestSummaryPersistence:
session.updated_at = last_active
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "User said hello."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="User said hello.",
+ )
# Archive
await loop.auto_compact._archive("cli:test")
@@ -1069,10 +1055,7 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
@@ -1100,10 +1083,7 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(loop)
await loop.auto_compact._archive("cli:test")
@@ -1129,10 +1109,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "First summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="First summary.",
+ )
await loop.auto_compact._archive("cli:test")
# Consume the first summary via hot path
@@ -1148,10 +1127,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive2(messages):
- return "Second summary."
-
- loop.consolidator.archive = _fake_archive2
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="Second summary.",
+ )
await loop.auto_compact._archive("cli:test")
# The second archive writes a new summary
@@ -1173,10 +1151,9 @@ class TestSummaryPersistence:
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
- async def _fake_archive(messages):
- return "Old summary."
-
- loop.consolidator.archive = _fake_archive
+ loop.consolidator.compact_idle_session = _make_fake_compact(
+ loop, summary="Old summary.",
+ )
await loop.auto_compact._archive("cli:test")
# Verify summary exists before /new
diff --git a/tests/agent/test_autocompact_unit.py b/tests/agent/test_autocompact_unit.py
index d501770dd..1d3277a01 100644
--- a/tests/agent/test_autocompact_unit.py
+++ b/tests/agent/test_autocompact_unit.py
@@ -38,7 +38,7 @@ def _make_autocompact(
sessions = MagicMock(spec=SessionManager)
if consolidator is None:
consolidator = MagicMock()
- consolidator.archive = AsyncMock(return_value="Summary.")
+ consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
return AutoCompact(
sessions=sessions,
consolidator=consolidator,
@@ -178,62 +178,6 @@ class TestFormatSummary:
assert result.startswith("Previous conversation summary (last active ")
-# ---------------------------------------------------------------------------
-# _split_unconsolidated
-# ---------------------------------------------------------------------------
-
-
-class TestSplitUnconsolidated:
- """Test AutoCompact._split_unconsolidated splitting logic."""
-
- def test_empty_session_returns_both_empty(self):
- """Empty session should return ([], [])."""
- ac = _make_autocompact()
- session = _make_session(messages=[])
- archive, kept = ac._split_unconsolidated(session)
- assert archive == []
- assert kept == []
-
- def test_all_messages_archivable_when_more_than_suffix(self):
- """Session with many messages should archive a prefix and keep suffix."""
- ac = _make_autocompact()
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- archive, kept = ac._split_unconsolidated(session)
- assert len(archive) > 0
- assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
-
- def test_fewer_messages_than_suffix_returns_empty_archive(self):
- """Session with fewer messages than suffix should have empty archive."""
- ac = _make_autocompact()
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(3)]
- session = _make_session(messages=msgs)
- archive, kept = ac._split_unconsolidated(session)
- assert archive == []
- assert len(kept) == len(msgs)
-
- def test_respects_last_consolidated_offset(self):
- """Only messages after last_consolidated should be considered."""
- ac = _make_autocompact()
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- # First 10 are already consolidated
- session = _make_session(messages=msgs, last_consolidated=10)
- archive, kept = ac._split_unconsolidated(session)
- # Only the tail of 10 messages is considered for splitting
- assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in kept)
- assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in archive)
-
- def test_retain_recent_legal_suffix_keeps_last_n(self):
- """The kept suffix should be at most _RECENT_SUFFIX_MESSAGES long."""
- ac = _make_autocompact()
- # 20 user messages = 20 messages total, all after last_consolidated=0
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- archive, kept = ac._split_unconsolidated(session)
- assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
- assert len(archive) == len(msgs) - len(kept)
-
-
# ---------------------------------------------------------------------------
# check_expired
# ---------------------------------------------------------------------------
@@ -313,126 +257,71 @@ class TestCheckExpired:
# ---------------------------------------------------------------------------
-class TestArchive:
- """Test AutoCompact._archive async method."""
+class TestArchiveDelegates:
+ """_archive should delegate all session mutation to Consolidator."""
@pytest.mark.asyncio
- async def test_empty_session_updates_timestamp_no_archive_call(self):
- """Empty session should refresh updated_at and not call consolidator.archive."""
+ async def test_calls_compact_idle_session(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
- empty_session = _make_session(messages=[])
- mock_sm.get_or_create.return_value = empty_session
ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(return_value="Summary.")
+ ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
await ac._archive("cli:test")
- ac.consolidator.archive.assert_not_called()
- mock_sm.save.assert_called_once_with(empty_session)
- # updated_at was refreshed
- assert empty_session.updated_at > datetime.now() - timedelta(seconds=5)
+ ac.consolidator.compact_idle_session.assert_awaited_once_with(
+ "cli:test", ac._RECENT_SUFFIX_MESSAGES,
+ )
@pytest.mark.asyncio
- async def test_archive_returns_empty_string_no_summary_stored(self):
- """If archive returns empty string, no summary should be stored."""
+ async def test_populates_summaries_from_metadata(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
+ session = _make_session(
+ metadata={"_last_summary": {"text": "Hello.", "last_active": "2026-05-13T10:00:00"}}
+ )
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(return_value="")
+ ac.consolidator.compact_idle_session = AsyncMock(return_value="Hello.")
await ac._archive("cli:test")
- assert "cli:test" not in ac._summaries
-
- @pytest.mark.asyncio
- async def test_archive_returns_nothing_no_summary_stored(self):
- """If archive returns '(nothing)', no summary should be stored."""
- ac = _make_autocompact()
- mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- mock_sm.get_or_create.return_value = session
- ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(return_value="(nothing)")
-
- await ac._archive("cli:test")
-
- assert "cli:test" not in ac._summaries
-
- @pytest.mark.asyncio
- async def test_archive_exception_caught_key_removed_from_archiving(self):
- """If archive raises, exception is caught and key removed from _archiving."""
- ac = _make_autocompact()
- mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- mock_sm.get_or_create.return_value = session
- ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("LLM down"))
-
- # Should not raise
- await ac._archive("cli:test")
-
- assert "cli:test" not in ac._archiving
-
- @pytest.mark.asyncio
- async def test_successful_archive_stores_summary_in_summaries_and_metadata(self):
- """Successful archive should store summary in _summaries dict and metadata."""
- ac = _make_autocompact()
- mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- last_active = datetime(2026, 5, 13, 10, 0, 0)
- session = _make_session(messages=msgs, updated_at=last_active)
- mock_sm.get_or_create.return_value = session
- ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(return_value="User discussed AI.")
-
- await ac._archive("cli:test")
-
- # _summaries
entry = ac._summaries.get("cli:test")
assert entry is not None
- assert entry[0] == "User discussed AI."
- assert entry[1] == last_active
- # metadata
- meta = session.metadata.get("_last_summary")
- assert meta is not None
- assert meta["text"] == "User discussed AI."
- assert "last_active" in meta
+ assert entry[0] == "Hello."
@pytest.mark.asyncio
- async def test_finally_block_always_removes_from_archiving(self):
- """Finally block should always remove key from _archiving, even on error."""
+ async def test_no_summary_when_compact_returns_empty(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("fail"))
+ ac.consolidator.compact_idle_session = AsyncMock(return_value="")
- # Pre-add key to archiving to verify it gets removed
- ac._archiving.add("cli:test")
await ac._archive("cli:test")
- assert "cli:test" not in ac._archiving
+
+ assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
- async def test_finally_removes_from_archiving_on_success(self):
- """Finally block should remove key from _archiving on success too."""
+ async def test_no_summary_when_compact_returns_nothing(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
- msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
- session = _make_session(messages=msgs)
- mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
- ac.consolidator.archive = AsyncMock(return_value="Summary.")
+ ac.consolidator.compact_idle_session = AsyncMock(return_value="(nothing)")
+
+ await ac._archive("cli:test")
+
+ assert "cli:test" not in ac._summaries
+
+ @pytest.mark.asyncio
+ async def test_exception_still_removes_from_archiving(self):
+ ac = _make_autocompact()
+ mock_sm = MagicMock(spec=SessionManager)
+ ac.sessions = mock_sm
+ ac.consolidator.compact_idle_session = AsyncMock(side_effect=RuntimeError("fail"))
ac._archiving.add("cli:test")
await ac._archive("cli:test")
+
assert "cli:test" not in ac._archiving
diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py
index 64ef9a886..1fa05d3c8 100644
--- a/tests/agent/test_consolidator.py
+++ b/tests/agent/test_consolidator.py
@@ -28,6 +28,12 @@ def mock_provider():
def consolidator(store, mock_provider):
sessions = MagicMock()
sessions.save = MagicMock()
+ # When maybe_consolidate_by_tokens refreshes the session reference via
+ # get_or_create(session.key), it should get back the same object the test
+ # passed in. Store sessions by key so the lookup is transparent.
+ _session_cache: dict[str, MagicMock] = {}
+ sessions.get_or_create = MagicMock(side_effect=lambda key: _session_cache.get(key, MagicMock()))
+ sessions._session_cache = _session_cache
return Consolidator(
store=store,
provider=mock_provider,
@@ -117,6 +123,7 @@ class TestConsolidatorTokenBudget:
session.last_consolidated = 0
session.messages = [{"role": "user", "content": "hi"}]
session.key = "test:key"
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
@@ -152,6 +159,7 @@ class TestConsolidatorTokenBudget:
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value="old conversation summary")
@@ -184,6 +192,7 @@ class TestConsolidatorTokenBudget:
session.add_message("tool", "tool result", tool_call_id="call-1", name="x")
session.add_message("assistant", "final answer")
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value="tool turn summary")
@@ -210,6 +219,7 @@ class TestConsolidatorTokenBudget:
}
for i in range(70)
]
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@@ -238,6 +248,7 @@ class TestConsolidatorTokenBudget:
for i in range(70)
]
session.metadata = {}
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@@ -263,6 +274,7 @@ class TestConsolidatorTokenBudget:
for i in range(70)
]
session.metadata = {}
+ consolidator.sessions._session_cache[session.key] = session
# Keep estimates high so the loop would otherwise run multiple rounds.
consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(1200, "tiktoken")
@@ -287,6 +299,7 @@ class TestConsolidatorTokenBudget:
}
for i in range(70)
]
+ consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
@@ -299,6 +312,260 @@ class TestConsolidatorTokenBudget:
assert session.last_consolidated == 61
+class TestCompactIdleSession:
+ """Tests for Consolidator.compact_idle_session — lock-protected idle truncation."""
+
+ @pytest.fixture
+ def real_consolidator(self, store, mock_provider):
+ """Create a Consolidator with a real SessionManager (not a mock)."""
+ from nanobot.session.manager import SessionManager
+
+ sessions = SessionManager(store.workspace)
+ return Consolidator(
+ store=store,
+ provider=mock_provider,
+ model="test-model",
+ sessions=sessions,
+ context_window_tokens=1000,
+ build_messages=MagicMock(return_value=[]),
+ get_tool_definitions=MagicMock(return_value=[]),
+ max_completion_tokens=100,
+ )
+
+ @pytest.mark.asyncio
+ async def test_archives_prefix_keeps_suffix(self, real_consolidator, mock_provider):
+ """20 user/assistant turns → compact with max_suffix=8 → messages ≤ 8,
+ last_consolidated=0, _last_summary stored."""
+ mock_provider.chat_with_retry.return_value = MagicMock(
+ content="Summary of old conversation.", finish_reason="stop"
+ )
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:test")
+ for i in range(20):
+ session.add_message("user", f"user msg {i}")
+ session.add_message("assistant", f"assistant msg {i}")
+ sessions.save(session)
+
+ result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
+ assert result == "Summary of old conversation."
+
+ reloaded = sessions.get_or_create("cli:test")
+ assert len(reloaded.messages) <= 8
+ assert reloaded.last_consolidated == 0
+ meta = reloaded.metadata.get("_last_summary")
+ assert meta is not None
+ assert meta["text"] == "Summary of old conversation."
+ assert "last_active" in meta
+
+ @pytest.mark.asyncio
+ async def test_empty_session_refreshes_timestamp(self, real_consolidator):
+ """Empty session with old updated_at → refreshed after call, returns ''."""
+ from datetime import datetime, timedelta
+
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:empty")
+ old_ts = datetime.now() - timedelta(hours=2)
+ session.updated_at = old_ts
+ sessions.save(session)
+
+ result = await real_consolidator.compact_idle_session("cli:empty")
+ assert result == ""
+
+ reloaded = sessions.get_or_create("cli:empty")
+ assert reloaded.updated_at > old_ts
+
+ @pytest.mark.asyncio
+ async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
+ """LLM returns '(nothing)' → _last_summary NOT in metadata."""
+ mock_provider.chat_with_retry.return_value = MagicMock(
+ content="(nothing)", finish_reason="stop"
+ )
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:nothing")
+ for i in range(10):
+ session.add_message("user", f"u{i}")
+ session.add_message("assistant", f"a{i}")
+ sessions.save(session)
+
+ result = await real_consolidator.compact_idle_session("cli:nothing", max_suffix=4)
+ assert result == "(nothing)"
+
+ reloaded = sessions.get_or_create("cli:nothing")
+ assert "_last_summary" not in reloaded.metadata
+
+ @pytest.mark.asyncio
+ async def test_llm_failure_still_truncates(self, real_consolidator, mock_provider, store):
+ """LLM raises RuntimeError → raw_archive fires, session still truncated, returns None."""
+ mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:fail")
+ for i in range(10):
+ session.add_message("user", f"u{i}")
+ session.add_message("assistant", f"a{i}")
+ sessions.save(session)
+
+ result = await real_consolidator.compact_idle_session("cli:fail", max_suffix=4)
+ assert result is None
+
+ # raw_archive should have been called (history.jsonl gets an entry)
+ entries = store.read_unprocessed_history(since_cursor=0)
+ assert any("[RAW]" in e["content"] for e in entries)
+
+ # Session should still be truncated
+ reloaded = sessions.get_or_create("cli:fail")
+ assert len(reloaded.messages) <= 4
+
+ @pytest.mark.asyncio
+ async def test_respects_last_consolidated(self, real_consolidator, mock_provider):
+ """30 turns with last_consolidated=50 → only unconsolidated tail considered."""
+ mock_provider.chat_with_retry.return_value = MagicMock(
+ content="Tail summary.", finish_reason="stop"
+ )
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:offset")
+ for i in range(30):
+ session.add_message("user", f"u{i}")
+ session.add_message("assistant", f"a{i}")
+ session.last_consolidated = 50 # Only 10 messages unconsolidated
+ sessions.save(session)
+
+ result = await real_consolidator.compact_idle_session("cli:offset", max_suffix=4)
+ assert result == "Tail summary."
+
+ # Verify only the unconsolidated tail was processed:
+ # 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
+ archived_call = mock_provider.chat_with_retry.call_args
+ user_content = archived_call.kwargs["messages"][1]["content"]
+ # Should contain only tail messages, not early ones
+ assert "u0" not in user_content
+ assert "u25" in user_content or "a25" in user_content
+
+ @pytest.mark.asyncio
+ async def test_acquires_consolidation_lock(self, real_consolidator, mock_provider):
+ """Verify lock is held during execution."""
+ import asyncio
+
+ # Use a slow LLM response to ensure the lock is held while we check
+ started = asyncio.Event()
+
+ async def slow_chat(**kwargs):
+ started.set()
+ await asyncio.sleep(0.1)
+ return MagicMock(content="Summary.", finish_reason="stop")
+
+ mock_provider.chat_with_retry = slow_chat
+
+ sessions = real_consolidator.sessions
+ session = sessions.get_or_create("cli:lock")
+ for i in range(10):
+ session.add_message("user", f"u{i}")
+ session.add_message("assistant", f"a{i}")
+ sessions.save(session)
+
+ lock = real_consolidator.get_lock("cli:lock")
+ assert not lock.locked()
+
+ task = asyncio.ensure_future(
+ real_consolidator.compact_idle_session("cli:lock", max_suffix=4)
+ )
+ await started.wait()
+ assert lock.locked()
+ await task
+ assert not lock.locked()
+
+
+class TestConsolidatorSessionRefresh:
+ """Background consolidation must detect stale session references."""
+
+ @pytest.mark.asyncio
+ async def test_reloads_before_empty_session_guard(self, tmp_path):
+ """A stale empty reference must not skip a non-empty cached session."""
+ from nanobot.agent.memory import Consolidator, MemoryStore
+ from nanobot.session.manager import Session, SessionManager
+
+ store = MemoryStore(tmp_path)
+ provider = MagicMock()
+ provider.chat_with_retry = AsyncMock(
+ return_value=MagicMock(content="summary", finish_reason="stop")
+ )
+ provider.generation.max_tokens = 4096
+ provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
+ sessions = SessionManager(tmp_path)
+ consolidator = Consolidator(
+ store=store,
+ provider=provider,
+ model="test-model",
+ sessions=sessions,
+ context_window_tokens=128_000,
+ build_messages=MagicMock(return_value=[]),
+ get_tool_definitions=MagicMock(return_value=[]),
+ )
+
+ fresh = sessions.get_or_create("cli:test")
+ fresh.add_message("user", "fresh message")
+ sessions.save(fresh)
+ stale_empty = Session(key="cli:test")
+
+ seen: dict[str, Session] = {}
+
+ def estimate(session: Session):
+ seen["session"] = session
+ return 10, "test"
+
+ consolidator.estimate_session_prompt_tokens = MagicMock(side_effect=estimate)
+
+ await consolidator.maybe_consolidate_by_tokens(stale_empty)
+
+ assert seen["session"] is fresh
+
+ @pytest.mark.asyncio
+ async def test_reloads_stale_session_after_compact(self, tmp_path):
+ """After compact_idle_session replaces the session, a concurrent
+ maybe_consolidate_by_tokens with the old reference should use the
+ fresh session from cache instead of overwriting."""
+ from nanobot.agent.memory import Consolidator, MemoryStore
+ from nanobot.session.manager import SessionManager
+
+ store = MemoryStore(tmp_path)
+ provider = MagicMock()
+ provider.chat_with_retry = AsyncMock(
+ return_value=MagicMock(content="summary", finish_reason="stop")
+ )
+ provider.generation.max_tokens = 4096
+ provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
+ sessions = SessionManager(tmp_path)
+ consolidator = Consolidator(
+ store=store,
+ provider=provider,
+ model="test-model",
+ sessions=sessions,
+ context_window_tokens=128_000,
+ build_messages=MagicMock(return_value=[]),
+ get_tool_definitions=MagicMock(return_value=[]),
+ )
+
+ # Populate session with many messages
+ session = sessions.get_or_create("cli:test")
+ for i in range(20):
+ session.add_message("user", f"u{i}")
+ session.add_message("assistant", f"a{i}")
+ sessions.save(session)
+
+ # Simulate: background consolidation captures old reference
+ old_ref = session
+
+ # AutoCompact runs first and truncates to 8
+ await consolidator.compact_idle_session("cli:test", max_suffix=8)
+
+ # Background consolidation runs with stale reference —
+ # should detect the session was replaced and not undo the compact.
+ await consolidator.maybe_consolidate_by_tokens(old_ref)
+
+ session_after = sessions.get_or_create("cli:test")
+ # Messages should still be truncated (not restored to 40)
+ assert len(session_after.messages) <= 8
+
+
class TestRawArchiveTruncation:
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
diff --git a/tests/agent/test_heartbeat_service.py b/tests/agent/test_heartbeat_service.py
index 8f563cff4..fe7b54256 100644
--- a/tests/agent/test_heartbeat_service.py
+++ b/tests/agent/test_heartbeat_service.py
@@ -4,6 +4,7 @@ import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+from nanobot.utils.llm_runtime import LLMRuntime
class DummyProvider(LLMProvider):
@@ -11,9 +12,11 @@ class DummyProvider(LLMProvider):
super().__init__()
self._responses = list(responses)
self.calls = 0
+ self.models: list[str | None] = []
async def chat(self, *args, **kwargs) -> LLMResponse:
self.calls += 1
+ self.models.append(kwargs.get("model"))
if self._responses:
return self._responses.pop(0)
return LLMResponse(content="", tool_calls=[])
@@ -215,6 +218,51 @@ async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) ->
assert notified == []
+def test_tick_uses_runtime_provider_and_model(tmp_path, monkeypatch) -> None:
+ """Preset changes must apply to heartbeat decision and post-run evaluation."""
+ (tmp_path / "HEARTBEAT.md").write_text("- [ ] check runtime model", encoding="utf-8")
+
+ runtime_provider = DummyProvider([
+ LLMResponse(
+ content="",
+ tool_calls=[
+ ToolCallRequest(
+ id="hb_1",
+ name="heartbeat",
+ arguments={"action": "run", "tasks": "check runtime model"},
+ )
+ ],
+ ),
+ ])
+ runtime_model = "openai/gpt-4.1"
+
+ executed: list[str] = []
+ evaluated: list[tuple[LLMProvider, str]] = []
+
+ async def _on_execute(tasks: str) -> str:
+ executed.append(tasks)
+ return "runtime model produced a user-facing update"
+
+ async def _eval_capture(response, tasks, provider, model):
+ evaluated.append((provider, model))
+ return False
+
+ service = HeartbeatService(
+ workspace=tmp_path,
+ llm_runtime=lambda: LLMRuntime(runtime_provider, runtime_model),
+ on_execute=_on_execute,
+ )
+
+ monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_capture)
+
+ asyncio.run(service._tick())
+
+ assert runtime_provider.calls == 1
+ assert runtime_provider.models == [runtime_model]
+ assert executed == ["check runtime model"]
+ assert evaluated == [(runtime_provider, runtime_model)]
+
+
@pytest.mark.asyncio
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
provider = DummyProvider([
@@ -286,4 +334,3 @@ async def test_decide_prompt_includes_current_time(tmp_path) -> None:
user_msg = captured_messages[1]
assert user_msg["role"] == "user"
assert "Current Time:" in user_msg["content"]
-
diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py
index fcf6198c1..43a691437 100644
--- a/tests/agent/test_loop_progress.py
+++ b/tests/agent/test_loop_progress.py
@@ -6,10 +6,15 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
+import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
+from nanobot.utils.progress_events import (
+ invoke_file_edit_progress,
+ on_progress_accepts_file_edit_events,
+)
def _make_loop(tmp_path: Path) -> AgentLoop:
@@ -82,6 +87,142 @@ class TestToolEventProgress:
),
]
+ @pytest.mark.asyncio
+ async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ target = tmp_path / "foo.txt"
+ target.write_text("old\n", encoding="utf-8")
+ tool_call = ToolCallRequest(
+ id="call-write",
+ name="write_file",
+ arguments={"path": "foo.txt", "content": "new\nextra\n"},
+ )
+ calls = iter([
+ LLMResponse(content="", tool_calls=[tool_call]),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None),
+ )
+
+ async def execute(name: str, params: dict) -> str:
+ target.write_text(params["content"], encoding="utf-8")
+ return "ok"
+
+ loop.tools.execute = AsyncMock(side_effect=execute)
+ file_events: list[dict] = []
+
+ async def on_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict] | None = None,
+ file_edit_events: list[dict] | None = None,
+ ) -> None:
+ if file_edit_events:
+ file_events.extend(file_edit_events)
+
+ final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert final_content == "Done"
+ assert [event["phase"] for event in file_events] == ["start", "end"]
+ assert file_events[0] == {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 2,
+ "deleted": 1,
+ "approximate": True,
+ "status": "editing",
+ }
+ assert file_events[1]["status"] == "done"
+ assert file_events[1]["approximate"] is False
+ assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
+
+ @pytest.mark.asyncio
+ async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ loop = _make_loop(tmp_path)
+ target = tmp_path / "foo.txt"
+ target.write_text("old\n", encoding="utf-8")
+ tool_call = ToolCallRequest(
+ id="call-write",
+ name="write_file",
+ arguments={"path": "foo.txt", "content": "new\n"},
+ )
+ calls = iter([
+ LLMResponse(content="", tool_calls=[tool_call]),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(None, {"path": "foo.txt", "content": "new\n"}, None),
+ )
+
+ async def execute(name: str, params: dict) -> str:
+ target.write_text(params["content"], encoding="utf-8")
+ return "ok"
+
+ loop.tools.execute = AsyncMock(side_effect=execute)
+ prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot"))
+ monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker)
+
+ async def on_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict] | None = None,
+ ) -> None:
+ pass
+
+ final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert final_content == "Done"
+ assert target.read_text(encoding="utf-8") == "new\n"
+ prepare_tracker.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ tool_call = ToolCallRequest(
+ id="call-exec",
+ name="exec",
+ arguments={"command": "printf hi > foo.txt"},
+ )
+ calls = iter([
+ LLMResponse(content="", tool_calls=[tool_call]),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(None, {"command": "printf hi > foo.txt"}, None),
+ )
+ loop.tools.execute = AsyncMock(return_value="ok")
+ file_events: list[dict] = []
+
+ async def on_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict] | None = None,
+ file_edit_events: list[dict] | None = None,
+ ) -> None:
+ if file_edit_events:
+ file_events.extend(file_edit_events)
+
+ await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert file_events == []
+
@pytest.mark.asyncio
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
@@ -130,6 +271,44 @@ class TestToolEventProgress:
assert finish["phase"] == "end"
assert finish["result"] == "file.txt"
+ @pytest.mark.asyncio
+ async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+ edit_events = [{
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 1,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ }]
+
+ websocket_progress = await loop._build_bus_progress_callback(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="edit",
+ ))
+ assert on_progress_accepts_file_edit_events(websocket_progress) is True
+ await websocket_progress("", file_edit_events=edit_events)
+ outbound = await bus.consume_outbound()
+ assert outbound.metadata["_file_edit_events"] == edit_events
+
+ telegram_progress = await loop._build_bus_progress_callback(InboundMessage(
+ channel="telegram",
+ sender_id="u1",
+ chat_id="chat2",
+ content="edit",
+ ))
+ assert on_progress_accepts_file_edit_events(telegram_progress) is False
+ await invoke_file_edit_progress(telegram_progress, edit_events)
+ assert bus.outbound_size == 0
+
@pytest.mark.asyncio
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
@@ -353,8 +532,93 @@ class TestToolEventProgress:
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
+ assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert provider.chat_with_retry.await_count == 2
+ @pytest.mark.asyncio
+ async def test_webui_title_generation_uses_turn_model_snapshot(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+
+ captured: dict[str, object] = {}
+
+ async def fake_title_after_turn(**kwargs: object) -> bool:
+ captured.update(kwargs)
+ return False
+
+ monkeypatch.setattr(
+ "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ fake_title_after_turn,
+ )
+ scheduled_title: list[object] = []
+
+ def schedule_background(coro: object) -> None:
+ name = getattr(coro, "__qualname__", "")
+ if "_generate_title_and_notify" in name:
+ scheduled_title.append(coro)
+ elif hasattr(coro, "close"):
+ coro.close()
+
+ loop._schedule_background = schedule_background # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="say hello",
+ metadata={"webui": True},
+ ))
+
+ assert len(scheduled_title) == 1
+ loop.provider = MagicMock()
+ loop.model = "switched-after-turn"
+
+ await scheduled_title[0] # type: ignore[misc]
+
+ assert captured["provider"] is provider
+ assert captured["model"] == "test-model"
+
+ @pytest.mark.asyncio
+ async def test_webui_command_turn_does_not_schedule_title_generation(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+
+ async def fake_title_after_turn(**_kwargs: object) -> bool:
+ raise AssertionError("command-only turns should not generate titles")
+
+ monkeypatch.setattr(
+ "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ fake_title_after_turn,
+ )
+ scheduled: list[object] = []
+ loop._schedule_background = scheduled.append # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="/model",
+ metadata={"webui": True},
+ ))
+
+ assert scheduled == []
+
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index ed78e7192..9814c386d 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -10,12 +10,16 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
-from nanobot.session.manager import Session
-from nanobot.utils.webui_titles import (
+from nanobot.session.manager import Session, SessionManager
+from nanobot.utils.webui_turn_helpers import (
+ TITLE_GENERATION_MAX_TOKENS,
+ TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
+ WebuiTurnCoordinator,
maybe_generate_webui_title,
)
+from nanobot.utils.llm_runtime import LLMRuntime
def _mk_loop() -> AgentLoop:
@@ -33,6 +37,22 @@ def _make_full_loop(tmp_path: Path) -> AgentLoop:
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
+def test_agent_loop_llm_runtime_reflects_current_provider_and_model(tmp_path: Path) -> None:
+ loop = _make_full_loop(tmp_path)
+ runtime = loop.llm_runtime()
+
+ assert runtime.provider is loop.provider
+ assert runtime.model == "test-model"
+
+ next_provider = MagicMock()
+ loop.provider = next_provider
+ loop.model = "next-model"
+ runtime = loop.llm_runtime()
+
+ assert runtime.provider is next_provider
+ assert runtime.model == "next-model"
+
+
@pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
@@ -55,6 +75,11 @@ async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Pat
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
+ assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS
+ assert (
+ loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"]
+ == TITLE_GENERATION_REASONING_EFFORT
+ )
@pytest.mark.asyncio
@@ -79,6 +104,80 @@ async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Pat
loop.provider.chat_with_retry.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None:
+ loop = _make_full_loop(tmp_path)
+ session = loop.sessions.get_or_create("websocket:command-title")
+ session.metadata[WEBUI_SESSION_METADATA_KEY] = True
+ session.add_message("user", "/model deep", _command=True)
+ session.add_message(
+ "assistant",
+ "Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`",
+ _command=True,
+ )
+ loop.sessions.save(session)
+
+ generated = await maybe_generate_webui_title(
+ sessions=loop.sessions,
+ session_key="websocket:command-title",
+ provider=loop.provider,
+ model=loop.model,
+ )
+
+ assert generated is False
+ assert WEBUI_TITLE_METADATA_KEY not in session.metadata
+ loop.provider.chat_with_retry.assert_not_awaited()
+
+
+def test_webui_title_update_uses_captured_llm_runtime(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ bus = MessageBus()
+ sessions = SessionManager(tmp_path)
+ scheduled: list[object] = []
+ captured: dict[str, object] = {}
+
+ async def fake_title_after_turn(**kwargs: object) -> bool:
+ captured.update(kwargs)
+ return False
+
+ monkeypatch.setattr(
+ "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ fake_title_after_turn,
+ )
+ coordinator = WebuiTurnCoordinator(
+ bus=bus,
+ sessions=sessions,
+ schedule_background=lambda coro: scheduled.append(coro),
+ )
+ provider = MagicMock()
+ msg = InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="say hello",
+ metadata={"webui": True},
+ )
+
+ coordinator.capture_title_context(
+ "websocket:chat1",
+ msg,
+ LLMRuntime(provider, "turn-model"),
+ )
+ asyncio.run(coordinator.handle_turn_end(
+ msg,
+ session_key="websocket:chat1",
+ latency_ms=None,
+ ))
+
+ assert len(scheduled) == 1
+ asyncio.run(scheduled[0]) # type: ignore[arg-type]
+
+ assert captured["provider"] is provider
+ assert captured["model"] == "turn-model"
+
+
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")
diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py
index f192cacee..11a284bb5 100644
--- a/tests/agent/test_onboard_logic.py
+++ b/tests/agent/test_onboard_logic.py
@@ -1074,3 +1074,242 @@ class TestConfigurePydanticModelEmptyString:
result = _configure_pydantic_model(model, "Test")
assert result is not None
assert result.api_key == ""
+
+
+class TestModelPresetWizard:
+ """Tests for model preset CRUD in the onboard wizard."""
+
+ def test_sync_preset_cache(self):
+ """_sync_preset_cache should populate the module-level cache."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _sync_preset_cache
+ from nanobot.config.schema import ModelPresetConfig
+
+ config = Config()
+ config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
+ config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
+ _sync_preset_cache(config)
+ assert _MODEL_PRESET_CACHE == {"fast", "power"}
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_model_preset_add(self, monkeypatch):
+ """_configure_model_presets should add a new preset."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
+ from nanobot.config.schema import ModelPresetConfig
+
+ config = Config()
+ _MODEL_PRESET_CACHE.clear()
+
+ responses = iter([
+ "[+] Add new preset",
+ "my-preset",
+ "<- Back",
+ ])
+
+ class FakePrompt:
+ def __init__(self, response):
+ self.response = response
+
+ def ask(self):
+ if isinstance(self.response, BaseException):
+ raise self.response
+ return self.response
+
+ def fake_select(*_args, **_kwargs):
+ return FakePrompt(next(responses))
+
+ def fake_text(*_args, **_kwargs):
+ return FakePrompt(next(responses))
+
+ def fake_configure(*_model, **_kwargs):
+ return ModelPresetConfig(model="gpt-test", temperature=0.5)
+
+ def fake_select_with_back(*_args, **_kwargs):
+ return next(responses)
+
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
+ monkeypatch.setattr(
+ onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text)
+ )
+ monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure)
+ monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
+ monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
+
+ _configure_model_presets(config)
+
+ assert "my-preset" in config.model_presets
+ assert config.model_presets["my-preset"].model == "gpt-test"
+ assert config.model_presets["my-preset"].temperature == 0.5
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_model_preset_delete(self, monkeypatch):
+ """_configure_model_presets should delete an existing preset."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
+ from nanobot.config.schema import ModelPresetConfig
+
+ config = Config()
+ config.model_presets["old"] = ModelPresetConfig(model="x")
+ _MODEL_PRESET_CACHE.clear()
+ _MODEL_PRESET_CACHE.update({"old", "default"})
+
+ responses = iter([
+ "old (x)",
+ "Delete",
+ True,
+ "<- Back",
+ ])
+
+ class FakePrompt:
+ def __init__(self, response):
+ self.response = response
+
+ def ask(self):
+ if isinstance(self.response, BaseException):
+ raise self.response
+ return self.response
+
+ def fake_select(*_args, **_kwargs):
+ return FakePrompt(next(responses))
+
+ def fake_confirm(*_args, **_kwargs):
+ return FakePrompt(next(responses))
+
+ def fake_select_with_back(*_args, **_kwargs):
+ return next(responses)
+
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
+ monkeypatch.setattr(
+ onboard_wizard, "questionary", SimpleNamespace(select=fake_select, confirm=fake_confirm)
+ )
+ monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
+ monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
+
+ _configure_model_presets(config)
+
+ assert "old" not in config.model_presets
+ assert "old" not in _MODEL_PRESET_CACHE
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_model_preset_field_handler(self, monkeypatch):
+ """_handle_model_preset_field should set a preset name from choices."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
+ from nanobot.config.schema import AgentDefaults
+
+ _MODEL_PRESET_CACHE.clear()
+ _MODEL_PRESET_CACHE.update({"fast", "power", "default"})
+
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
+
+ defaults = AgentDefaults()
+ _handle_model_preset_field(defaults, "model_preset", "Model Preset", None)
+ assert defaults.model_preset == "fast"
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_model_preset_field_handler_clear(self, monkeypatch):
+ """_handle_model_preset_field should clear preset when (clear/unset) chosen."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
+ from nanobot.config.schema import AgentDefaults
+
+ _MODEL_PRESET_CACHE.clear()
+ _MODEL_PRESET_CACHE.add("fast")
+
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "(clear/unset)")
+
+ defaults = AgentDefaults(model_preset="fast")
+ _handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
+ assert defaults.model_preset is None
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_main_menu_dispatch_includes_model_presets(self):
+ """_configure_model_presets should be importable and callable."""
+ from nanobot.cli.onboard import _configure_model_presets
+
+ assert callable(_configure_model_presets)
+
+ def test_run_onboard_model_presets_edit(self, monkeypatch):
+ """run_onboard should handle [M] Model Presets correctly."""
+ from nanobot.config.schema import ModelPresetConfig
+
+ initial_config = Config()
+
+ responses = iter([
+ "[M] Model Presets",
+ "[S] Save and Exit",
+ ])
+
+ class FakePrompt:
+ def __init__(self, response):
+ self.response = response
+
+ def ask(self):
+ if isinstance(self.response, BaseException):
+ raise self.response
+ return self.response
+
+ def fake_select(*_args, **_kwargs):
+ return FakePrompt(next(responses))
+
+ preset_mutated = {"n": 0}
+
+ def fake_configure_model_presets(config):
+ preset_mutated["n"] += 1
+ config.model_presets["test"] = ModelPresetConfig(model="gpt-test")
+
+ monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
+ monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets)
+ monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
+ monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
+ monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
+
+ result = run_onboard(initial_config)
+ assert result.should_save is True
+ assert preset_mutated["n"] == 1
+ assert "test" in result.config.model_presets
+
+ def test_fallback_models_field_add(self, monkeypatch):
+ """_handle_fallback_models_field should add a preset name."""
+ from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_models_field
+ from nanobot.config.schema import AgentDefaults
+
+ _MODEL_PRESET_CACHE.clear()
+ _MODEL_PRESET_CACHE.update({"fast", "default"})
+
+ select_responses = iter(["fast"])
+ questionary_responses = iter(["[+] Add preset", "[Done]"])
+
+ class FakePrompt:
+ def __init__(self, response):
+ self.response = response
+
+ def ask(self):
+ if isinstance(self.response, BaseException):
+ raise self.response
+ return self.response
+
+ def fake_questionary_select(*_args, **_kwargs):
+ return FakePrompt(next(questionary_responses))
+
+ def fake_select_with_back(*_args, **_kwargs):
+ return next(select_responses)
+
+ monkeypatch.setattr(
+ onboard_wizard, "questionary",
+ SimpleNamespace(select=fake_questionary_select, press_any_key_to_continue=lambda: FakePrompt(None)),
+ )
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
+ monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
+
+ defaults = AgentDefaults()
+ _handle_fallback_models_field(defaults, "fallback_models", "Fallback Models", [])
+ assert defaults.fallback_models == ["fast"]
+ _MODEL_PRESET_CACHE.clear()
+
+ def test_provider_field_handler(self, monkeypatch):
+ """_handle_provider_field should set provider from choices."""
+ from nanobot.cli.onboard import _handle_provider_field
+ from nanobot.config.schema import AgentDefaults
+
+ monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
+
+ defaults = AgentDefaults()
+ _handle_provider_field(defaults, "provider", "Provider", "auto")
+ assert defaults.provider == "anthropic"
diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py
index a6b19a9d8..b36b1899b 100644
--- a/tests/agent/test_runtime_refresh.py
+++ b/tests/agent/test_runtime_refresh.py
@@ -47,3 +47,28 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
assert loop.dream.provider is new_provider
assert loop.dream.model == "new-model"
assert loop.dream._runner.provider is new_provider
+
+
+def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
+ old_provider = _provider("old-model")
+ new_provider = _provider("new-model", max_tokens=456)
+ loop = AgentLoop(
+ bus=MessageBus(),
+ provider=old_provider,
+ workspace=tmp_path,
+ model="old-model",
+ context_window_tokens=1000,
+ provider_snapshot_loader=lambda: ProviderSnapshot(
+ provider=new_provider,
+ model="new-model",
+ context_window_tokens=2000,
+ signature=("new-model",),
+ ),
+ )
+
+ runtime = loop.llm_runtime()
+
+ assert runtime.provider is new_provider
+ assert runtime.model == "new-model"
+ assert loop.provider is new_provider
+ assert loop.runner.provider is new_provider
diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py
index 839f62f57..f22290ba6 100644
--- a/tests/agent/test_unified_session.py
+++ b/tests/agent/test_unified_session.py
@@ -387,6 +387,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
session = Session(key="unified:default")
session.messages = [{"role": "user", "content": "msg"}]
+ sessions.get_or_create.return_value = session
# Simulate over-budget: estimated > budget
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index 9b481e251..c6f9d66a3 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None:
]
+@pytest.mark.asyncio
+async def test_send_file_edit_progress_uses_file_edit_event() -> None:
+ bus = MagicMock()
+ channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
+ mock_ws = AsyncMock()
+ channel._attach(mock_ws, "chat-1")
+
+ await channel.send(OutboundMessage(
+ channel="websocket",
+ chat_id="chat-1",
+ content="",
+ metadata={
+ "_progress": True,
+ "_file_edit_events": [
+ {
+ "version": 1,
+ "phase": "start",
+ "call_id": "call-1",
+ "tool": "write_file",
+ "path": "src/app.py",
+ "added": 12,
+ "deleted": 2,
+ "approximate": True,
+ "status": "editing",
+ }
+ ],
+ },
+ ))
+
+ payload = json.loads(mock_ws.send.await_args.args[0])
+ assert payload == {
+ "event": "file_edit",
+ "chat_id": "chat-1",
+ "edits": [
+ {
+ "version": 1,
+ "phase": "start",
+ "call_id": "call-1",
+ "tool": "write_file",
+ "path": "src/app.py",
+ "added": 12,
+ "deleted": 2,
+ "approximate": True,
+ "status": "editing",
+ }
+ ],
+ }
+
+
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
@@ -758,6 +807,25 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
assert body == {"event": "session_updated", "chat_id": "chat-1"}
+@pytest.mark.asyncio
+async def test_send_session_updated_includes_scope_when_present() -> None:
+ bus = MagicMock()
+ channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
+ mock_ws = AsyncMock()
+ channel._attach(mock_ws, "chat-1")
+
+ await channel.send(OutboundMessage(
+ channel="websocket",
+ chat_id="chat-1",
+ content="",
+ metadata={"_session_updated": True, "_session_update_scope": "metadata"},
+ ))
+
+ mock_ws.send.assert_awaited_once()
+ body = json.loads(mock_ws.send.await_args.args[0])
+ assert body == {"event": "session_updated", "chat_id": "chat-1", "scope": "metadata"}
+
+
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
@@ -946,7 +1014,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key"
+ assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False
+ assert providers["openrouter"]["api_key_required"] is True
+ assert providers["atomic_chat"]["configured"] is False
+ assert providers["atomic_chat"]["api_key_required"] is False
+ assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
@@ -969,10 +1042,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_rows["openrouter"]["configured"] is True
assert "sk-or-test" not in provider_updated.text
+ local_provider_updated = await _http_get(
+ "http://127.0.0.1:"
+ f"{port}/api/settings/provider/update?provider=atomic_chat"
+ "&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
+ headers={"Authorization": "Bearer tok"},
+ )
+ assert local_provider_updated.status_code == 200
+ local_provider_body = local_provider_updated.json()
+ local_provider_rows = {
+ provider["name"]: provider for provider in local_provider_body["providers"]
+ }
+ assert local_provider_rows["atomic_chat"]["configured"] is True
+ assert "localhost:1337" in local_provider_updated.text
+
updated = await _http_get(
"http://127.0.0.1:"
- f"{port}/api/settings/update?model=openrouter/test"
- "&provider=openrouter",
+ f"{port}/api/settings/update?model=atomic_chat/test"
+ "&provider=atomic_chat",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
@@ -992,10 +1079,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["base_url"] == "https://search.example.com"
saved = load_config(config_path)
- assert saved.agents.defaults.model == "openrouter/test"
- assert saved.agents.defaults.provider == "openrouter"
+ assert saved.agents.defaults.model == "atomic_chat/test"
+ assert saved.agents.defaults.provider == "atomic_chat"
assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
+ assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com"
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index 90c2ce877..2778ddbbb 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -1170,6 +1170,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
self.model = "test-model"
self.provider = kwargs.get("provider", object())
self.tools = {}
+ seen["agent"] = self
async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(
@@ -1218,6 +1219,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert isinstance(cron, _FakeCron)
assert cron.on_job is not None
+ runtime_provider = object()
+ agent = seen["agent"]
+ agent.provider = runtime_provider
+ agent.model = "runtime-model"
+
job = CronJob(
id="cron-1",
name="stretch",
@@ -1233,8 +1239,8 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert response == "Time to stretch."
assert seen["response"] == "Time to stretch."
- assert seen["provider"] is provider
- assert seen["model"] == "test-model"
+ assert seen["provider"] is runtime_provider
+ assert seen["model"] == "runtime-model"
assert seen["task_context"] == (
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "
@@ -1543,6 +1549,9 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
self.dream = _FakeDream()
self.sessions = _FakeSessionManager()
+ def llm_runtime(self) -> None:
+ return None
+
async def run(self) -> None:
await asyncio.Event().wait()
diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py
index 52c27d2c9..5eeb2c128 100644
--- a/tests/cli/test_interactive_retry_wait.py
+++ b/tests/cli/test_interactive_retry_wait.py
@@ -69,6 +69,72 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
assert calls == ["I should search first."]
+@pytest.mark.asyncio
+async def test_reasoning_delta_buffers_until_sentence_boundary():
+ calls: list[str] = []
+ channels_config = SimpleNamespace(
+ send_progress=True, send_tool_hints=False, show_reasoning=True,
+ )
+ reasoning_buffer = commands._ReasoningBuffer()
+
+ with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ first = await commands._maybe_print_interactive_progress(
+ SimpleNamespace(
+ content="The",
+ metadata={"_progress": True, "_reasoning_delta": True},
+ ),
+ None,
+ channels_config,
+ reasoning_buffer=reasoning_buffer,
+ )
+ second = await commands._maybe_print_interactive_progress(
+ SimpleNamespace(
+ content=" user asked.",
+ metadata={"_progress": True, "_reasoning_delta": True},
+ ),
+ None,
+ channels_config,
+ reasoning_buffer=reasoning_buffer,
+ )
+
+ assert first is True
+ assert second is True
+ assert calls == ["The user asked."]
+
+
+@pytest.mark.asyncio
+async def test_reasoning_end_flushes_buffered_delta():
+ calls: list[str] = []
+ channels_config = SimpleNamespace(
+ send_progress=True, send_tool_hints=False, show_reasoning=True,
+ )
+ reasoning_buffer = commands._ReasoningBuffer()
+
+ with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ delta = await commands._maybe_print_interactive_progress(
+ SimpleNamespace(
+ content="The user asked",
+ metadata={"_progress": True, "_reasoning_delta": True},
+ ),
+ None,
+ channels_config,
+ reasoning_buffer=reasoning_buffer,
+ )
+ end = await commands._maybe_print_interactive_progress(
+ SimpleNamespace(
+ content="",
+ metadata={"_progress": True, "_reasoning_end": True},
+ ),
+ None,
+ channels_config,
+ reasoning_buffer=reasoning_buffer,
+ )
+
+ assert delta is True
+ assert end is True
+ assert calls == ["The user asked"]
+
+
@pytest.mark.asyncio
async def test_reasoning_hidden_when_show_reasoning_disabled():
"""Reasoning content should be suppressed when show_reasoning is False."""
diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py
new file mode 100644
index 000000000..6176a5e36
--- /dev/null
+++ b/tests/utils/test_file_edit_events.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from nanobot.utils.file_edit_events import (
+ build_file_edit_end_event,
+ build_file_edit_start_event,
+ line_diff_stats,
+ prepare_file_edit_tracker,
+ read_file_snapshot,
+)
+
+
+def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
+ added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
+ assert (added, deleted) == (2, 1)
+
+
+def test_line_diff_stats_normalizes_crlf() -> None:
+ assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
+
+
+def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
+ target = tmp_path / "notes.txt"
+ target.write_text("old\nkeep\n", encoding="utf-8")
+ params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
+ tracker = prepare_file_edit_tracker(
+ call_id="call-write",
+ tool_name="write_file",
+ tool=None,
+ workspace=tmp_path,
+ params=params,
+ )
+
+ assert tracker is not None
+ start = build_file_edit_start_event(tracker, params)
+ assert start == {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "notes.txt",
+ "phase": "start",
+ "added": 2,
+ "deleted": 1,
+ "approximate": True,
+ "status": "editing",
+ }
+
+ target.write_text("new\nkeep\nextra\n", encoding="utf-8")
+ end = build_file_edit_end_event(tracker)
+ assert end["phase"] == "end"
+ assert end["status"] == "done"
+ assert end["approximate"] is False
+ assert (end["added"], end["deleted"]) == (2, 1)
+
+
+def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
+ target = tmp_path / "data.bin"
+ target.write_bytes(b"\x00\x01before")
+ tracker = prepare_file_edit_tracker(
+ call_id="call-bin",
+ tool_name="edit_file",
+ tool=None,
+ workspace=tmp_path,
+ params={"path": "data.bin", "old_text": "before", "new_text": "after"},
+ )
+
+ assert tracker is not None
+ assert not read_file_snapshot(target).countable
+ target.write_bytes(b"\x00\x01after")
+ event = build_file_edit_end_event(tracker)
+ assert event["binary"] is True
+ assert (event["added"], event["deleted"]) == (0, 0)
+
+
+def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
+ assert prepare_file_edit_tracker(
+ call_id="call-exec",
+ tool_name="exec",
+ tool=None,
+ workspace=tmp_path,
+ params={"path": "created-by-shell.txt"},
+ ) is None
diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py
index 419abbfcd..f13380f46 100644
--- a/tests/utils/test_webui_transcript.py
+++ b/tests/utils/test_webui_transcript.py
@@ -42,6 +42,62 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
+def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-file"
+ for ev in (
+ {"event": "user", "chat_id": "t-file", "text": "edit"},
+ {
+ "event": "message",
+ "chat_id": "t-file",
+ "text": 'write_file({"path":"foo.txt"})',
+ "kind": "tool_hint",
+ },
+ {
+ "event": "file_edit",
+ "chat_id": "t-file",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 2,
+ "deleted": 1,
+ "approximate": False,
+ "status": "done",
+ },
+ ],
+ },
+ ):
+ append_transcript_object(key, ev)
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+
+ assert len(msgs) == 3
+ assert msgs[1]["kind"] == "trace"
+ assert msgs[1]["traces"] == ['write_file({"path":"foo.txt"})']
+ assert "fileEdits" not in msgs[1]
+ assert msgs[2]["kind"] == "trace"
+ assert msgs[2]["traces"] == []
+ assert msgs[2]["fileEdits"] == [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 2,
+ "deleted": 1,
+ "approximate": False,
+ "status": "done",
+ },
+ ]
+ assert msgs[2]["activitySegmentId"]
+ assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
+
+
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.utils.webui_transcript import build_webui_thread_response
diff --git a/webui/README.md b/webui/README.md
index b99874ba0..8538bc1ed 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -8,15 +8,11 @@ on the same port.
For the project overview, install guide, and general docs map, see the root
[`README.md`](../README.md).
-## Current status
+## Just want to use the WebUI?
-> [!NOTE]
-> The standalone WebUI development workflow currently requires a source
-> checkout.
->
-> WebUI changes in the GitHub repository may land before they are included in
-> the next packaged release, so source installs and published package versions
-> are not yet guaranteed to move in lockstep.
+If you installed nanobot via `pip install nanobot-ai`, the WebUI is **already bundled** in the wheel. Enable the WebSocket channel in `~/.nanobot/config.json` and run `nanobot gateway` — see the root [`README.md`](../README.md#-webui) for the 3-step setup. You do **not** need anything in this directory.
+
+This `webui/` tree is for people **hacking on the WebUI itself** (UI changes, new components, styling, etc.).
## Layout
@@ -25,7 +21,7 @@ webui/ source tree (this directory)
nanobot/web/dist/ build output served by the gateway
```
-## Develop from source
+## Develop the WebUI (Vite HMR)
### 1. Install nanobot from source
@@ -35,6 +31,8 @@ From the repository root:
pip install -e .
```
+> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
+
### 2. Enable the WebSocket channel
In `~/.nanobot/config.json`:
@@ -63,8 +61,7 @@ bun run dev
Then open `http://127.0.0.1:5173`.
-By default, the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket
-traffic to `http://127.0.0.1:8765`.
+By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
If your gateway listens on a non-default port, point the dev server at it:
@@ -74,7 +71,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
### Access from another device (LAN)
-To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
+To use the WebUI from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
```json
{
@@ -91,20 +88,20 @@ To use the webui from another device on the same network, set `host` to `"0.0.0.
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
-Then open `http://
+
);
-
- useEffect(() => {
- const el = document.documentElement;
- const observer = new MutationObserver(() => {
- setIsDark(el.classList.contains("dark"));
- });
- observer.observe(el, { attributeFilter: ["class"] });
- return () => observer.disconnect();
- }, []);
-
- return isDark;
}
-export function CodeBlock({ language, code, className }: CodeBlockProps) {
+export function CodeBlock({
+ language,
+ code,
+ className,
+ highlight = true,
+}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
- const isDark = useIsDark();
+ const isDark = useThemeValue() === "dark";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@@ -86,20 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
{copied ? t("code.copied") : t("code.copy")}
{code}
+
+ {kids}
+
+ );
+ }
+ return (
+
+ {kids}
+
+ );
+ },
+ pre({ children: markdownChildren }) {
+ const kids = Children.toArray(markdownChildren);
+ const lone = kids.length === 1 ? kids[0] : null;
+ /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
+ {markdownChildren}
+
+ );
+ },
+ a({ href, children: markdownChildren, ...props }) {
+ return (
+
+ {markdownChildren}
+
+ );
+ },
+ }),
+ [highlightCode],
+ );
+
return (
- {kids}
-
- );
- }
- return (
-
- {kids}
-
- );
- },
- pre({ children: markdownChildren }) {
- const kids = Children.toArray(markdownChildren);
- const lone = kids.length === 1 ? kids[0] : null;
- /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
- {markdownChildren}
-
- );
- },
- a({ href, children: markdownChildren, ...props }) {
- return (
-
- {markdownChildren}
-
- );
- },
- }}
+ remarkPlugins={remarkPlugins}
+ rehypePlugins={rehypePlugins}
+ components={components}
>
{children}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index ae15ced62..98ab0c941 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -1,6 +1,5 @@
import {
useCallback,
- useDeferredValue,
useEffect,
useRef,
useState,
@@ -120,7 +119,7 @@ export function MessageBubble({