diff --git a/README.md b/README.md
index 9e6aa391b..90a3d2f4c 100644
--- a/README.md
+++ b/README.md
@@ -23,20 +23,27 @@
## 📢 News
+- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
+- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
+- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
+- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
+- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
+- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
+- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
+
+Earlier news
+
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
-
-Earlier news
-
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
@@ -194,13 +201,19 @@ nanobot agent
display."""
@@ -129,8 +157,8 @@ def _markdown_to_telegram_html(text: str) -> str:
text = re.sub(r'`([^`]+)`', save_inline_code, text)
- # 3. Headers # Title -> just the title text
- text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
+ # 3. Headers # Title -> Title (preserve visual hierarchy)
+ text = re.sub(r'^#{1,6}\s+(.+)$', r'⟪B⟫\1⟪/B⟫', text, flags=re.MULTILINE)
# 4. Blockquotes > text -> just the text (before HTML escaping)
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
@@ -154,6 +182,9 @@ def _markdown_to_telegram_html(text: str) -> str:
# 10. Bullet lists - item -> • item
text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
+ # 10.5. Numbered lists 1. item -> 1. item (keep number, normalize indent)
+ text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
+
# 11. Restore inline code with HTML tags
for i, code in enumerate(inline_codes):
# Escape HTML in code content
@@ -166,6 +197,9 @@ def _markdown_to_telegram_html(text: str) -> str:
escaped = _escape_telegram_html(code)
text = text.replace(f"\x00CB{i}\x00", f"{escaped}
")
+ # 13. Restore header bold markers (inserted in step 3, after HTML escaping)
+ text = text.replace('⟪B⟫', '').replace('⟪/B⟫', '')
+
return text
@@ -637,10 +671,11 @@ class TelegramChannel(BaseChannel):
if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id
if buf.message_id is None:
+ preview = _strip_md_block(buf.text)
try:
sent = await self._call_with_retry(
self._app.bot.send_message,
- chat_id=int_chat_id, text=buf.text,
+ chat_id=int_chat_id, text=preview,
**thread_kwargs,
)
buf.message_id = sent.message_id
@@ -653,11 +688,12 @@ class TelegramChannel(BaseChannel):
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs)
buf.last_edit = now
return
+ preview = _strip_md_block(buf.text)
try:
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
- text=buf.text,
+ text=preview,
)
buf.last_edit = now
except Exception as e:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index ec572ffe9..08e227610 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -145,7 +145,7 @@ def _make_console() -> Console:
def _render_interactive_ansi(render_fn) -> str:
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
ansi_console = Console(
- force_terminal=True,
+ force_terminal=sys.stdout.isatty(),
color_system=console.color_system or "standard",
width=console.width,
)
@@ -946,72 +946,16 @@ def _run_gateway(
cron.stop()
agent.stop()
await channels.stop_all()
+ # Flush all cached sessions to durable storage before exit.
+ # This prevents data loss on filesystems with write-back
+ # caching (rclone VFS, NFS, FUSE mounts, etc.).
+ flushed = agent.sessions.flush_all()
+ if flushed:
+ logger.info("Shutdown: flushed {} session(s) to disk", flushed)
asyncio.run(run())
-@app.command()
-def web(
- port: int | None = typer.Option(None, "--port", "-p", help="WebSocket port for the webui"),
- workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
- verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
- config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
- open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser when ready"),
-):
- """Start the gateway with the embedded webui and (by default) open a browser."""
- if verbose:
- import logging
-
- logging.basicConfig(level=logging.DEBUG)
-
- cfg = _load_runtime_config(config, workspace)
-
- # Force the websocket channel on with token-gated auth so the webui is functional.
- # ``--port`` applies to the webui's websocket/HTTP port, not the gateway's
- # management port, since that's the only surface users visit.
- ws_section = cfg.channels.websocket
- if isinstance(ws_section, dict):
- ws_section.setdefault("host", "127.0.0.1")
- ws_section["enabled"] = True
- ws_section["websocketRequiresToken"] = True
- if port is not None:
- ws_section["port"] = port
- ws_host = ws_section.get("host", "127.0.0.1")
- ws_port = ws_section.get("port", 8765)
- ws_path = ws_section.get("path", "/")
- else:
- ws_section.enabled = True
- if hasattr(ws_section, "websocket_requires_token"):
- ws_section.websocket_requires_token = True
- if port is not None:
- ws_section.port = port
- ws_host = getattr(ws_section, "host", "127.0.0.1") or "127.0.0.1"
- ws_port = getattr(ws_section, "port", 8765)
- ws_path = getattr(ws_section, "path", "/") or "/"
-
- # Confirm the bundled SPA exists before promising the user a browser launch.
- from nanobot.channels.manager import _default_webui_dist
-
- dist = _default_webui_dist()
- if dist is None:
- console.print(
- "[yellow]Warning: webui assets not found at nanobot/web/dist/. "
- "Run `cd webui && bun install && bun run build` from a source checkout.[/yellow]"
- )
-
- scheme = "http"
- # Browsers refuse cookies/JS on 0.0.0.0 — collapse to loopback for the visit URL.
- visit_host = "127.0.0.1" if ws_host in {"0.0.0.0", "::"} else ws_host
- open_url = f"{scheme}://{visit_host}:{ws_port}{ws_path if ws_path != '/' else ''}/"
-
- # The gateway's management port is separate from the webui port; leave it
- # on its configured default so --port only moves the visible surface.
- _run_gateway(
- cfg,
- open_browser_url=open_url if open_browser else None,
- )
-
-
# ============================================================================
# Agent Commands
# ============================================================================
diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py
index 94ee0646a..87d4bf640 100644
--- a/nanobot/command/builtin.py
+++ b/nanobot/command/builtin.py
@@ -17,15 +17,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session."""
loop = ctx.loop
msg = ctx.msg
- tasks = loop._active_tasks.pop(msg.session_key, [])
- cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
- for t in tasks:
- try:
- await t
- except (asyncio.CancelledError, Exception):
- pass
- sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
- total = cancelled + sub_cancelled
+ total = await loop._cancel_active_tasks(msg.session_key)
content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -100,8 +92,9 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
- """Start a fresh session."""
+ """Stop active task and start a fresh session."""
loop = ctx.loop
+ await loop._cancel_active_tasks(ctx.key)
session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:]
session.clear()
@@ -327,7 +320,7 @@ def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = [
"🐈 nanobot commands:",
- "/new — Start a new conversation",
+ "/new — Stop current task and start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/status — Show bot status",
diff --git a/nanobot/command/router.py b/nanobot/command/router.py
index 35a475453..98f938b17 100644
--- a/nanobot/command/router.py
+++ b/nanobot/command/router.py
@@ -57,6 +57,20 @@ class CommandRouter:
def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority
+ def is_dispatchable_command(self, text: str) -> bool:
+ """Check whether *text* matches any non-priority command tier (exact or prefix).
+
+ Does NOT check priority or interceptor tiers.
+ If this returns True, ``dispatch()`` is guaranteed to match a handler.
+ """
+ cmd = text.strip().lower()
+ if cmd in self._exact:
+ return True
+ for pfx, _ in self._prefix:
+ if cmd.startswith(pfx):
+ return True
+ return False
+
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock."""
handler = self._priority.get(ctx.raw.lower())
diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py
index 8c1d5cc21..1d277d7f7 100644
--- a/nanobot/providers/anthropic_provider.py
+++ b/nanobot/providers/anthropic_provider.py
@@ -245,9 +245,41 @@ class AnthropicProvider(LLMProvider):
"source": {"type": "url", "url": url},
}
+ @staticmethod
+ def _has_tool_use(msg: dict[str, Any]) -> bool:
+ """True if ``msg.content`` carries any ``tool_use`` block.
+
+ Anthropic forbids ``tool_use`` inside ``user`` turns, so messages that
+ issued a tool call cannot be safely rerouted when we patch the role.
+ """
+ content = msg.get("content")
+ if not isinstance(content, list):
+ return False
+ return any(
+ isinstance(block, dict) and block.get("type") == "tool_use"
+ for block in content
+ )
+
@staticmethod
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
- """Anthropic requires alternating user/assistant roles."""
+ """Normalize a message sequence for Anthropic's ``/messages`` endpoint.
+
+ Anthropic's contract is stricter than OpenAI's:
+
+ 1. Consecutive same-role turns must be collapsed into one.
+ 2. The conversation cannot end with an ``assistant`` turn — Anthropic
+ does not support assistant-message prefill and returns 400.
+ 3. The conversation cannot start with an ``assistant`` turn — the
+ first message must be ``user``.
+
+ Rules 2 and 3 mirror ``LLMProvider._enforce_role_alternation`` in
+ ``base.py``, which applies the equivalent invariants to OpenAI-compat
+ providers. The only Anthropic-specific wrinkle: ``tool_use`` blocks
+ live inside ``content`` (not a separate ``tool_calls`` field) and are
+ invalid inside ``user`` turns, so the recovery paths below must skip
+ any message carrying them rather than silently producing a malformed
+ request.
+ """
merged: list[dict[str, Any]] = []
for msg in msgs:
if merged and merged[-1]["role"] == msg["role"]:
@@ -262,6 +294,36 @@ class AnthropicProvider(LLMProvider):
merged[-1]["content"] = prev_c
else:
merged.append(msg)
+
+ # Rule 2: strip trailing assistant turns — Anthropic rejects prefill.
+ last_popped: dict[str, Any] | None = None
+ while merged and merged[-1].get("role") == "assistant":
+ last_popped = merged.pop()
+
+ # Recovery for rule 2: if stripping removed every turn, reroute the
+ # last popped assistant as a user turn so upstream code still gets a
+ # valid request instead of a secondary "messages array empty" 400.
+ # Skip when the message carried ``tool_use`` blocks (see _has_tool_use).
+ if (
+ not merged
+ and last_popped is not None
+ and not AnthropicProvider._has_tool_use(last_popped)
+ ):
+ merged.append({"role": "user", "content": last_popped.get("content")})
+
+ # Rule 3: prepend a synthetic opener if the first surviving turn is an
+ # assistant (e.g. upstream history truncation dropped the original
+ # user request). ``tool_use``-carrying assistants are left alone —
+ # that message will still fail validation, but injecting an opener
+ # before it would orphan the tool_use/tool_result pair that follows,
+ # turning a recoverable 400 into a harder-to-diagnose one.
+ if (
+ merged
+ and merged[0].get("role") == "assistant"
+ and not AnthropicProvider._has_tool_use(merged[0])
+ ):
+ merged.insert(0, {"role": "user", "content": "(conversation continued)"})
+
return merged
# ------------------------------------------------------------------
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index 83db7e8f8..4f726df21 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -54,6 +54,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
}
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
"kimi-k2.5",
+ "kimi-k2.6",
"k2.6-code-preview",
})
@@ -62,7 +63,7 @@ def _is_kimi_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a Kimi thinking-capable model.
Supports two forms:
- - Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS
+ - Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
is checked against _KIMI_THINKING_MODELS
@@ -386,17 +387,33 @@ class OpenAICompatProvider(LLMProvider):
kwargs.update(overrides)
break
- if reasoning_effort:
- kwargs["reasoning_effort"] = reasoning_effort
+ # Normalize reasoning_effort into a semantic form (OpenAI vocab)
+ # used for internal decisions, and a wire form actually sent out.
+ # "minimum" is accepted as a DashScope-native alias for "minimal".
+ semantic_effort: str | None = None
+ if isinstance(reasoning_effort, str):
+ semantic_effort = reasoning_effort.lower()
+ if semantic_effort == "minimum":
+ semantic_effort = "minimal"
+
+ wire_effort = reasoning_effort
+ if spec and spec.name == "dashscope" and semantic_effort == "minimal":
+ # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
+ wire_effort = "minimum"
+
+ if wire_effort:
+ kwargs["reasoning_effort"] = wire_effort
# Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise.
if spec and reasoning_effort is not None:
- thinking_enabled = reasoning_effort.lower() != "minimal"
+ thinking_enabled = semantic_effort != "minimal"
extra: dict[str, Any] | None = None
if spec.name == "dashscope":
extra = {"enable_thinking": thinking_enabled}
+ elif spec.name == "minimax":
+ extra = {"reasoning_split": thinking_enabled}
elif spec.name in (
"volcengine", "volcengine_coding_plan",
"byteplus", "byteplus_coding_plan",
@@ -412,7 +429,7 @@ class OpenAICompatProvider(LLMProvider):
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
# identically to bare names like "kimi-k2.5".
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
- thinking_enabled = reasoning_effort.lower() != "minimal"
+ thinking_enabled = semantic_effort != "minimal"
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index be098731c..052373380 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -261,7 +261,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
),
- # Moonshot (月之暗面): Kimi models. K2.5 enforces temperature >= 1.0.
+ # Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
ProviderSpec(
name="moonshot",
keywords=("moonshot", "kimi"),
@@ -269,7 +269,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="Moonshot",
backend="openai_compat",
default_api_base="https://api.moonshot.ai/v1",
- model_overrides=(("kimi-k2.5", {"temperature": 1.0}),),
+ model_overrides=(
+ ("kimi-k2.5", {"temperature": 1.0}),
+ ("kimi-k2.6", {"temperature": 1.0}),
+ ),
),
# MiniMax: OpenAI-compatible API
ProviderSpec(
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 4add4fd3b..436d8225c 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -262,8 +262,16 @@ class SessionManager:
"messages": session.messages,
}
- def save(self, session: Session) -> None:
- """Save a session to disk atomically."""
+ def save(self, session: Session, *, fsync: bool = False) -> None:
+ """Save a session to disk atomically.
+
+ When *fsync* is ``True`` the final file and its parent directory are
+ explicitly flushed to durable storage. This is intentionally off by
+ default (the OS page-cache is sufficient for normal operation) but
+ should be enabled during graceful shutdown so that filesystems with
+ write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
+ the most recent writes.
+ """
path = self._get_session_path(session.key)
tmp_path = path.with_suffix(".jsonl.tmp")
@@ -280,14 +288,47 @@ class SessionManager:
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+ if fsync:
+ f.flush()
+ os.fsync(f.fileno())
os.replace(tmp_path, path)
+
+ if fsync:
+ # fsync the directory so the rename is durable.
+ # On Windows, opening a directory with O_RDONLY raises
+ # PermissionError — skip the dir sync there (NTFS
+ # journals metadata synchronously).
+ try:
+ fd = os.open(str(path.parent), os.O_RDONLY)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+ except PermissionError:
+ pass # Windows — directory fsync not supported
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
self._cache[session.key] = session
+ def flush_all(self) -> int:
+ """Re-save every cached session with fsync for durable shutdown.
+
+ Returns the number of sessions flushed. Errors on individual
+ sessions are logged but do not prevent other sessions from being
+ flushed.
+ """
+ flushed = 0
+ for key, session in list(self._cache.items()):
+ try:
+ self.save(session, fsync=True)
+ flushed += 1
+ except Exception:
+ logger.warning("Failed to flush session {}", key, exc_info=True)
+ return flushed
+
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
self._cache.pop(key, None)
diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py
index 89ffcca96..396fe50c4 100644
--- a/nanobot/utils/document.py
+++ b/nanobot/utils/document.py
@@ -134,18 +134,20 @@ def _extract_xlsx(path: Path) -> str:
"""Extract text from XLSX using openpyxl."""
try:
wb = load_workbook(path, read_only=True, data_only=True)
- sheets: list[str] = []
- for sheet_name in wb.sheetnames:
- ws = wb[sheet_name]
- rows: list[str] = []
- for row in ws.iter_rows(values_only=True):
- row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
- if row_text.strip():
- rows.append(row_text)
- if rows:
- sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
- wb.close()
- return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
+ try:
+ sheets: list[str] = []
+ for sheet_name in wb.sheetnames:
+ ws = wb[sheet_name]
+ rows: list[str] = []
+ for row in ws.iter_rows(values_only=True):
+ row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
+ if row_text.strip():
+ rows.append(row_text)
+ if rows:
+ sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
+ return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
+ finally:
+ wb.close()
except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e)
return f"[error: failed to extract XLSX: {e!s}]"
diff --git a/pyproject.toml b/pyproject.toml
index 3447ffc30..1e4ca97df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.5.post1"
+version = "0.1.5.post2"
description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
@@ -118,13 +118,6 @@ include = [
"nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh",
]
-# Build-time generated assets that live under .gitignore'd paths but must ship
-# in the wheel/sdist. `artifacts` bypasses the VCS filter (unlike `include`).
-# The webui is compiled via `bun run build` into nanobot/web/dist/ right before
-# `python -m build` runs.
-artifacts = [
- "nanobot/web/dist/**/*",
-]
[tool.hatch.build.targets.wheel]
packages = ["nanobot"]
diff --git a/tests/agent/test_cursor_recovery.py b/tests/agent/test_cursor_recovery.py
new file mode 100644
index 000000000..1963da65c
--- /dev/null
+++ b/tests/agent/test_cursor_recovery.py
@@ -0,0 +1,188 @@
+"""Regression tests for cursor recovery after non-integer cursor corruption.
+
+Root cause: cron jobs and other callers occasionally wrote string cursors to
+history.jsonl (e.g. ``"cursor": "abc"``). The original ``_next_cursor`` and
+``read_unprocessed_history`` assumed integer cursors and crashed with
+``TypeError`` / ``ValueError``, blocking all subsequent history appends.
+"""
+
+import json
+
+import pytest
+
+from nanobot.agent.memory import MemoryStore
+
+
+@pytest.fixture
+def store(tmp_path):
+ return MemoryStore(tmp_path)
+
+
+class TestNextCursorRecovery:
+ """``_next_cursor`` must recover a valid int even when the last entry's
+ cursor is corrupted (non-int)."""
+
+ def test_string_cursor_falls_back_to_scan(self, store):
+ """Last entry has a string cursor — scan backwards to find a valid int."""
+ store.history_file.write_text(
+ '{"cursor": 5, "timestamp": "2026-04-01 10:00", "content": "good"}\n'
+ '{"cursor": 6, "timestamp": "2026-04-01 10:01", "content": "also good"}\n'
+ '{"cursor": "bad", "timestamp": "2026-04-01 10:02", "content": "corrupted"}\n',
+ encoding="utf-8",
+ )
+ # Delete .cursor file so _next_cursor falls back to reading JSONL
+ store._cursor_file.unlink(missing_ok=True)
+ cursor = store.append_history("recovered event")
+ assert cursor == 7
+
+ def test_all_corrupted_cursors_return_one(self, store):
+ """Every entry has a non-int cursor — should restart at 1."""
+ store.history_file.write_text(
+ '{"cursor": "a", "timestamp": "2026-04-01 10:00", "content": "bad1"}\n'
+ '{"cursor": "b", "timestamp": "2026-04-01 10:01", "content": "bad2"}\n',
+ encoding="utf-8",
+ )
+ store._cursor_file.unlink(missing_ok=True)
+ cursor = store.append_history("fresh start")
+ assert cursor == 1
+
+ def test_non_int_cursor_types(self, store):
+ """Float, None, list — all non-int types handled gracefully."""
+ store.history_file.write_text(
+ '{"cursor": 3, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
+ '{"cursor": 3.5, "timestamp": "2026-04-01 10:01", "content": "float"}\n'
+ '{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "null"}\n'
+ '{"cursor": [1,2], "timestamp": "2026-04-01 10:03", "content": "list"}\n',
+ encoding="utf-8",
+ )
+ store._cursor_file.unlink(missing_ok=True)
+ cursor = store.append_history("handles weird types")
+ assert cursor == 4
+
+ def test_cursor_file_with_string_content(self, store):
+ """Cursor file contains a non-numeric string — should fall back."""
+ store._cursor_file.write_text("not_a_number", encoding="utf-8")
+ # Also add valid JSONL so the fallback scan finds something
+ store.history_file.write_text(
+ '{"cursor": 10, "timestamp": "2026-04-01 10:00", "content": "valid"}\n',
+ encoding="utf-8",
+ )
+ cursor = store.append_history("after bad cursor file")
+ assert cursor == 11
+
+
+class TestReadUnprocessedWithCorruption:
+ """``read_unprocessed_history`` must skip entries with non-int cursors
+ instead of crashing on comparison."""
+
+ def test_skips_string_cursor_entries(self, store):
+ """Entries with string cursors are silently skipped."""
+ store.history_file.write_text(
+ '{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid1"}\n'
+ '{"cursor": "bad", "timestamp": "2026-04-01 10:01", "content": "corrupted"}\n'
+ '{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "valid3"}\n',
+ encoding="utf-8",
+ )
+ entries = store.read_unprocessed_history(since_cursor=0)
+ assert len(entries) == 2
+ assert [e["cursor"] for e in entries] == [1, 3]
+
+ def test_mixed_corruption_preserves_order(self, store):
+ """Valid entries maintain correct order despite corrupt neighbors."""
+ store.history_file.write_text(
+ '{"cursor": "x", "timestamp": "2026-04-01 10:00", "content": "bad"}\n'
+ '{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "good2"}\n'
+ '{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "also bad"}\n'
+ '{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": "good4"}\n',
+ encoding="utf-8",
+ )
+ entries = store.read_unprocessed_history(since_cursor=0)
+ assert [e["cursor"] for e in entries] == [2, 4]
+
+ def test_all_valid_still_works(self, store):
+ """Normal operation unaffected — baseline regression check."""
+ store.append_history("event 1")
+ store.append_history("event 2")
+ store.append_history("event 3")
+ entries = store.read_unprocessed_history(since_cursor=1)
+ assert len(entries) == 2
+ assert entries[0]["cursor"] == 2
+ assert entries[1]["cursor"] == 3
+
+
+class TestCursorValidationInvariant:
+ """First-principles checks: the cursor validity rules and the
+ observability we layer on top of them."""
+
+ def test_bool_cursor_rejected(self, store):
+ """``isinstance(True, int) is True`` in Python; the guard must
+ still treat ``{"cursor": true}`` as corruption, otherwise a
+ boolean silently becomes cursor ``1`` / ``0`` downstream.
+ """
+ assert MemoryStore._valid_cursor(True) is None
+ assert MemoryStore._valid_cursor(False) is None
+ assert MemoryStore._valid_cursor(5) == 5
+ assert MemoryStore._valid_cursor(0) == 0
+
+ store.history_file.write_text(
+ '{"cursor": 4, "timestamp": "2026-04-01 10:00", "content": "real"}\n'
+ '{"cursor": true, "timestamp": "2026-04-01 10:01", "content": "bool"}\n',
+ encoding="utf-8",
+ )
+ store._cursor_file.unlink(missing_ok=True)
+ assert store.append_history("next") == 5
+
+ entries = store.read_unprocessed_history(since_cursor=0)
+ assert [e["cursor"] for e in entries] == [4, 5]
+
+ def test_next_cursor_returns_max_not_just_last_int(self, store):
+ """Under adversarial corruption, file order ≠ numeric order. The
+ recovery scan must return ``max(valid cursors) + 1``, not the
+ first int seen from the tail, so the returned cursor is strictly
+ greater than every legitimate cursor already on disk.
+ """
+ # Tail is corrupt → recovery scan runs. Valid cursors are 100
+ # and 5, in that order on disk; a naive "first int from the tail"
+ # recovery would return 6, which would then silently collide with
+ # the existing cursor 100. ``max`` is the only safe choice.
+ store.history_file.write_text(
+ '{"cursor": 100, "timestamp": "2026-04-01 10:00", "content": "high"}\n'
+ '{"cursor": 5, "timestamp": "2026-04-01 10:01", "content": "out of order"}\n'
+ '{"cursor": "poison", "timestamp": "2026-04-01 10:02", "content": "tail corrupt"}\n',
+ encoding="utf-8",
+ )
+ store._cursor_file.unlink(missing_ok=True)
+ assert store.append_history("safe next") == 101
+
+ def test_corruption_is_logged_exactly_once_per_store(self, store, caplog):
+ """Observability without spam: the first non-int cursor emits one
+ warning, subsequent reads on the same store stay quiet. Without
+ this, a poisoned file produces one warning per agent turn."""
+ import logging
+ from loguru import logger as loguru_logger
+
+ store.history_file.write_text(
+ '{"cursor": "bad1", "timestamp": "2026-04-01 10:00", "content": "x"}\n'
+ '{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "y"}\n',
+ encoding="utf-8",
+ )
+ store._cursor_file.unlink(missing_ok=True)
+
+ handler_id = loguru_logger.add(
+ caplog.handler, format="{message}", level="WARNING"
+ )
+ try:
+ with caplog.at_level(logging.WARNING):
+ store.read_unprocessed_history(since_cursor=0)
+ store.read_unprocessed_history(since_cursor=0)
+ store.append_history("another")
+ finally:
+ loguru_logger.remove(handler_id)
+
+ corruption_warnings = [
+ r for r in caplog.records if "non-int cursor" in r.getMessage()
+ ]
+ assert len(corruption_warnings) == 1, (
+ "Expected exactly one corruption warning per store instance; "
+ f"got {len(corruption_warnings)}: {[r.getMessage() for r in corruption_warnings]}"
+ )
diff --git a/tests/agent/test_mcp_transient_retry.py b/tests/agent/test_mcp_transient_retry.py
new file mode 100644
index 000000000..c7ef0c4ab
--- /dev/null
+++ b/tests/agent/test_mcp_transient_retry.py
@@ -0,0 +1,368 @@
+"""Tests for MCP tool/resource/prompt transient error retry."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from mcp import types as mcp_types
+from mcp.shared.exceptions import McpError
+from mcp.types import ErrorData
+
+from nanobot.agent.tools.mcp import (
+ MCPPromptWrapper,
+ MCPResourceWrapper,
+ MCPToolWrapper,
+ _is_transient,
+)
+
+# ---------------------------------------------------------------------------
+# _is_transient helper
+# ---------------------------------------------------------------------------
+
+
+class _FakeClosedResourceError(Exception):
+ pass
+
+
+_FakeClosedResourceError.__name__ = "ClosedResourceError"
+
+
+class _FakeEndOfStreamError(Exception):
+ pass
+
+
+_FakeEndOfStreamError.__name__ = "EndOfStream"
+
+
+def test_is_transient_recognizes_closed_resource():
+ assert _is_transient(_FakeClosedResourceError("gone"))
+
+
+def test_is_transient_recognizes_broken_pipe():
+ assert _is_transient(BrokenPipeError("pipe"))
+
+
+def test_is_transient_recognizes_connection_reset():
+ assert _is_transient(ConnectionResetError("reset"))
+
+
+def test_is_transient_recognizes_connection_refused():
+ assert _is_transient(ConnectionRefusedError("refused"))
+
+
+def test_is_transient_recognizes_end_of_stream():
+ assert _is_transient(_FakeEndOfStreamError("eof"))
+
+
+def test_is_transient_rejects_value_error():
+ assert not _is_transient(ValueError("nope"))
+
+
+def test_is_transient_rejects_runtime_error():
+ assert not _is_transient(RuntimeError("nope"))
+
+
+def test_is_transient_rejects_timeout():
+ assert not _is_transient(TimeoutError("timeout"))
+
+
+# ---------------------------------------------------------------------------
+# MCPToolWrapper retry behaviour
+# ---------------------------------------------------------------------------
+
+
+def _make_tool_def(name="test_tool"):
+ return SimpleNamespace(
+ name=name,
+ description="A test tool",
+ inputSchema={"type": "object", "properties": {}},
+ )
+
+
+def _make_tool_result(text):
+ """Build a mock tool result with proper MCP TextContent."""
+ return SimpleNamespace(content=[mcp_types.TextContent(type="text", text=text)])
+
+
+@pytest.mark.asyncio
+async def test_tool_retries_on_transient_error():
+ """Tool should retry once when a transient error occurs, then succeed."""
+ session = AsyncMock()
+ result = _make_tool_result("ok")
+ exc = _FakeClosedResourceError("connection lost")
+ session.call_tool = AsyncMock(side_effect=[exc, result])
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute(foo="bar")
+
+ assert output == "ok"
+ assert session.call_tool.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_tool_fails_after_retry_exhausted():
+ """Tool should fail with retry message when both attempts hit transient errors."""
+ session = AsyncMock()
+ exc1 = _FakeClosedResourceError("still dead")
+ exc2 = _FakeClosedResourceError("still dead again")
+ session.call_tool = AsyncMock(side_effect=[exc1, exc2])
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert "failed after retry" in output
+ assert "ClosedResourceError" in output
+ assert session.call_tool.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_tool_no_retry_on_non_transient_error():
+ """Tool should NOT retry on non-transient errors like ValueError."""
+ session = AsyncMock()
+ session.call_tool = AsyncMock(side_effect=ValueError("bad input"))
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+ output = await wrapper.execute()
+
+ assert "ValueError" in output
+ assert "retry" not in output
+ assert session.call_tool.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_tool_no_retry_on_timeout():
+ """Timeouts should not trigger retry (they have their own handling)."""
+ session = AsyncMock()
+ session.call_tool = AsyncMock(side_effect=asyncio.TimeoutError())
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+ output = await wrapper.execute()
+
+ assert "timed out" in output
+ assert session.call_tool.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_tool_success_on_first_try_no_retry():
+ """Normal success path — no retry logic involved."""
+ session = AsyncMock()
+ result = _make_tool_result("hello")
+ session.call_tool = AsyncMock(return_value=result)
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+ output = await wrapper.execute()
+
+ assert output == "hello"
+ assert session.call_tool.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_tool_does_not_retry_on_cancelled_error():
+ """`asyncio.CancelledError` must short-circuit the retry loop.
+
+ Regression guard: the retry branch lives under ``except Exception``,
+ but ``CancelledError`` inherits from ``BaseException``, not
+ ``Exception``, so it naturally bypasses the retry branch today. If a
+ future refactor ever widens the retry branch to ``BaseException`` (or
+ re-orders the handlers), ``/stop`` would start retrying instead of
+ cancelling — this test pins that invariant.
+ """
+ session = AsyncMock()
+ session.call_tool = AsyncMock(side_effect=asyncio.CancelledError())
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
+ output = await wrapper.execute()
+
+ assert "cancelled" in output
+ assert session.call_tool.call_count == 1
+ mock_sleep.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_tool_retry_on_connection_reset():
+ """ConnectionResetError (a stdlib exception) should also trigger retry."""
+ session = AsyncMock()
+ result = _make_tool_result("recovered")
+ session.call_tool = AsyncMock(
+ side_effect=[ConnectionResetError("reset by peer"), result]
+ )
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert output == "recovered"
+ assert session.call_tool.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_tool_retry_on_end_of_stream():
+ """EndOfStream (anyio) should trigger retry."""
+ session = AsyncMock()
+ result = _make_tool_result("back")
+ session.call_tool = AsyncMock(side_effect=[_FakeEndOfStreamError("eof"), result])
+
+ wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert output == "back"
+ assert session.call_tool.call_count == 2
+
+
+# ---------------------------------------------------------------------------
+# MCPResourceWrapper retry behaviour
+# ---------------------------------------------------------------------------
+
+
+def _make_resource_def(name="test_resource"):
+ return SimpleNamespace(
+ name=name,
+ uri="file:///test",
+ description="A test resource",
+ )
+
+
+def _make_resource_result(text):
+ return SimpleNamespace(
+ contents=[mcp_types.TextResourceContents(uri="file:///test", text=text)]
+ )
+
+
+@pytest.mark.asyncio
+async def test_resource_retries_on_transient_error():
+ """Resource should retry once on transient connection error."""
+ session = AsyncMock()
+ result = _make_resource_result("data")
+ exc = _FakeClosedResourceError("gone")
+ session.read_resource = AsyncMock(side_effect=[exc, result])
+
+ wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert output == "data"
+ assert session.read_resource.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_resource_fails_after_retry_exhausted():
+ """Resource should fail with retry message when both attempts fail."""
+ session = AsyncMock()
+ exc = _FakeClosedResourceError("dead")
+ session.read_resource = AsyncMock(side_effect=[exc, exc])
+
+ wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert "failed after retry" in output
+ assert session.read_resource.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_resource_no_retry_on_non_transient():
+ """Resource should not retry on non-transient errors."""
+ session = AsyncMock()
+ session.read_resource = AsyncMock(side_effect=RuntimeError("bad"))
+
+ wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
+ output = await wrapper.execute()
+
+ assert "RuntimeError" in output
+ assert session.read_resource.call_count == 1
+
+
+# ---------------------------------------------------------------------------
+# MCPPromptWrapper retry behaviour
+# ---------------------------------------------------------------------------
+
+
+def _make_prompt_def(name="test_prompt"):
+ return SimpleNamespace(
+ name=name,
+ description="A test prompt",
+ arguments=[],
+ )
+
+
+def _make_prompt_result(text):
+ return SimpleNamespace(
+ messages=[
+ SimpleNamespace(
+ content=mcp_types.TextContent(type="text", text=text),
+ )
+ ]
+ )
+
+
+@pytest.mark.asyncio
+async def test_prompt_retries_on_transient_error():
+ """Prompt should retry once on transient connection error."""
+ session = AsyncMock()
+ result = _make_prompt_result("prompt text")
+ exc = _FakeClosedResourceError("gone")
+ session.get_prompt = AsyncMock(side_effect=[exc, result])
+
+ wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert output == "prompt text"
+ assert session.get_prompt.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_prompt_fails_after_retry_exhausted():
+ """Prompt should fail with retry message when both attempts fail."""
+ session = AsyncMock()
+ exc = _FakeClosedResourceError("dead")
+ session.get_prompt = AsyncMock(side_effect=[exc, exc])
+
+ wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
+
+ with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
+ output = await wrapper.execute()
+
+ assert "failed after retry" in output
+ assert session.get_prompt.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_prompt_no_retry_on_mcp_error():
+ """McpError (application-level) should NOT trigger retry."""
+ session = AsyncMock()
+ session.get_prompt = AsyncMock(
+ side_effect=McpError(ErrorData(code=-1, message="not found"))
+ )
+
+ wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
+ output = await wrapper.execute()
+
+ assert "not found" in output
+ assert session.get_prompt.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_prompt_no_retry_on_non_transient():
+ """Non-transient errors should not trigger retry for prompts."""
+ session = AsyncMock()
+ session.get_prompt = AsyncMock(side_effect=RuntimeError("bad"))
+
+ wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
+ output = await wrapper.execute()
+
+ assert "RuntimeError" in output
+ assert session.get_prompt.call_count == 1
diff --git a/tests/agent/test_stop_preserves_context.py b/tests/agent/test_stop_preserves_context.py
new file mode 100644
index 000000000..2a082850f
--- /dev/null
+++ b/tests/agent/test_stop_preserves_context.py
@@ -0,0 +1,162 @@
+"""Tests for /stop preserving partial context from interrupted turns.
+
+When /stop cancels an active task, the runtime checkpoint (tool results,
+assistant messages accumulated so far) should be materialized into session
+history rather than silently discarded.
+
+See: https://github.com/HKUDS/nanobot/issues/2966
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch, AsyncMock
+
+import pytest
+
+from nanobot.agent.loop import AgentLoop
+
+
+@pytest.fixture
+def mock_loop():
+ """Create a minimal AgentLoop with mocked dependencies."""
+ with patch.object(AgentLoop, "__init__", lambda self: None):
+ loop = AgentLoop()
+ loop.sessions = MagicMock()
+ loop._pending_queues = {}
+ loop._session_locks = {}
+ loop._active_tasks = {}
+ loop._concurrency_gate = None
+ loop._RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
+ loop._PENDING_USER_TURN_KEY = "pending_user_turn"
+ loop.bus = MagicMock()
+ loop.bus.publish_outbound = AsyncMock()
+ loop.bus.publish_inbound = AsyncMock()
+ loop.commands = MagicMock()
+ loop.commands.dispatch_priority = AsyncMock(return_value=None)
+ return loop
+
+
+class TestStopPreservesContext:
+ """Verify that /stop restores partial context via checkpoint."""
+
+ def test_restore_checkpoint_method_exists(self, mock_loop):
+ """AgentLoop should have _restore_runtime_checkpoint."""
+ assert hasattr(mock_loop, "_restore_runtime_checkpoint")
+
+ def test_checkpoint_key_constant(self, mock_loop):
+ """The runtime checkpoint key should be defined."""
+ assert mock_loop._RUNTIME_CHECKPOINT_KEY == "runtime_checkpoint"
+
+ def test_cancel_dispatch_restores_checkpoint(self, mock_loop):
+ """When a task is cancelled, the checkpoint should be restored."""
+ # Create a mock session with a checkpoint
+ session = MagicMock()
+ session.metadata = {
+ "runtime_checkpoint": {
+ "phase": "awaiting_tools",
+ "iteration": 0,
+ "assistant_message": {
+ "role": "assistant",
+ "content": "Let me search for that.",
+ "tool_calls": [{"id": "tc_1", "type": "function",
+ "function": {"name": "web_search", "arguments": "{}"}}],
+ },
+ "completed_tool_results": [
+ {"role": "tool", "tool_call_id": "tc_1",
+ "content": "Search results: ..."},
+ ],
+ "pending_tool_calls": [],
+ }
+ }
+ session.messages = [
+ {"role": "user", "content": "Search for something"},
+ ]
+ mock_loop.sessions.get_or_create.return_value = session
+
+ # The restore method should add checkpoint messages to session history
+ restored = mock_loop._restore_runtime_checkpoint(session)
+ assert restored is True
+ # After restore, session should have more messages
+ assert len(session.messages) > 1
+ # The checkpoint should be cleared
+ assert "runtime_checkpoint" not in session.metadata
+
+
+@pytest.mark.asyncio
+async def test_dispatch_cancellation_restores_checkpoint():
+ """Regression for #2966: /stop interrupting _dispatch must materialize the
+ in-flight runtime checkpoint into session.messages before the cancellation
+ unwinds, so the next turn can see the partial work.
+
+ This exercises the real _dispatch path (locks, pending queues, the
+ CancelledError handler) rather than poking _restore_runtime_checkpoint in
+ isolation, so a future refactor that drops the cancel-time restore is
+ caught by CI instead of silently regressing.
+ """
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ workspace = MagicMock()
+ workspace.__truediv__ = MagicMock(return_value=MagicMock())
+
+ with patch("nanobot.agent.loop.ContextBuilder"), \
+ patch("nanobot.agent.loop.SessionManager"), \
+ patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
+ MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
+ loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
+
+ checkpoint_key = loop._RUNTIME_CHECKPOINT_KEY
+ session = SimpleNamespace(
+ key="test:c1",
+ metadata={
+ checkpoint_key: {
+ "phase": "awaiting_tools",
+ "iteration": 0,
+ "assistant_message": {
+ "role": "assistant",
+ "content": "Let me search.",
+ "tool_calls": [
+ {
+ "id": "tc_1",
+ "type": "function",
+ "function": {"name": "web_search", "arguments": "{}"},
+ }
+ ],
+ },
+ "completed_tool_results": [
+ {"role": "tool", "tool_call_id": "tc_1", "content": "Search hit."},
+ ],
+ "pending_tool_calls": [],
+ }
+ },
+ messages=[{"role": "user", "content": "Search for something"}],
+ )
+
+ loop.sessions.get_or_create = MagicMock(return_value=session)
+ loop.sessions.save = MagicMock()
+
+ async def _cancel(*_args, **_kwargs):
+ raise asyncio.CancelledError()
+
+ loop._process_message = _cancel
+
+ msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
+
+ with pytest.raises(asyncio.CancelledError):
+ await loop._dispatch(msg)
+
+ roles = [m.get("role") for m in session.messages]
+ assert roles == ["user", "assistant", "tool"], (
+ "Expected the assistant message and completed tool result from the "
+ f"interrupted turn to be materialized into session.messages; got {roles}"
+ )
+ assert checkpoint_key not in session.metadata, \
+ "Checkpoint metadata should be cleared after restore"
+ assert loop.sessions.save.called, \
+ "Session should be persisted so the restored state survives process restart"
diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py
index acf0b9d6d..957c8ead2 100644
--- a/tests/agent/test_unified_session.py
+++ b/tests/agent/test_unified_session.py
@@ -241,6 +241,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
+ _cancel_active_tasks=AsyncMock(return_value=0),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
@@ -274,6 +275,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
+ _cancel_active_tasks=AsyncMock(return_value=0),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py
index e02ca5318..4a69d31a9 100644
--- a/tests/channels/test_telegram_channel.py
+++ b/tests/channels/test_telegram_channel.py
@@ -1471,3 +1471,123 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
# so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt.
# Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback).
assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}"
+
+
+# ---------------------------------------------------------------------------
+# _markdown_to_telegram_html formatting tests
+# ---------------------------------------------------------------------------
+
+def test_markdown_to_html_headers_become_bold() -> None:
+ from nanobot.channels.telegram import _markdown_to_telegram_html
+
+ assert _markdown_to_telegram_html("# Title") == "Title"
+ assert _markdown_to_telegram_html("## Subtitle") == "Subtitle"
+ assert _markdown_to_telegram_html("### Deep") == "Deep"
+
+
+def test_markdown_to_html_numbered_lists_preserved() -> None:
+ from nanobot.channels.telegram import _markdown_to_telegram_html
+
+ text = "1. First\n2. Second\n3. Third"
+ result = _markdown_to_telegram_html(text)
+ assert "1. First" in result
+ assert "2. Second" in result
+ assert "3. Third" in result
+
+
+def test_markdown_to_html_numbered_list_normalizes_whitespace() -> None:
+ from nanobot.channels.telegram import _markdown_to_telegram_html
+
+ # Extra spaces after dot should be normalized
+ text = "1. Lots of space\n2. Two spaces"
+ result = _markdown_to_telegram_html(text)
+ assert "1. Lots of space" in result
+ assert "2. Two spaces" in result
+
+
+def test_markdown_to_html_headers_survive_html_escaping() -> None:
+ """Headers containing special HTML chars should still render as bold."""
+ from nanobot.channels.telegram import _markdown_to_telegram_html
+
+ result = _markdown_to_telegram_html("# A < B & C > D")
+ assert "A < B & C > D" == result
+
+
+def test_markdown_to_html_mixed_formatting() -> None:
+ """Headers, bullets, numbered lists, and bold coexist correctly."""
+ from nanobot.channels.telegram import _markdown_to_telegram_html
+
+ text = "# Overview\n\n- bullet one\n- bullet two\n\n1. step one\n2. step two\n\n**bold text**"
+ result = _markdown_to_telegram_html(text)
+ assert "Overview" in result
+ assert "\u2022 bullet one" in result
+ assert "1. step one" in result
+ assert "bold text" in result
+
+
+# ---------------------------------------------------------------------------
+# _strip_md_block tests
+# ---------------------------------------------------------------------------
+
+def test_strip_md_block_removes_inline_formatting() -> None:
+ from nanobot.channels.telegram import _strip_md_block
+
+ text = "**bold** and _italic_ and ~~struck~~"
+ result = _strip_md_block(text)
+ assert result == "bold and italic and struck"
+
+
+def test_strip_md_block_strips_headers() -> None:
+ from nanobot.channels.telegram import _strip_md_block
+
+ assert _strip_md_block("## Title\nBody") == "Title\nBody"
+
+
+def test_strip_md_block_converts_bullets_and_numbers() -> None:
+ from nanobot.channels.telegram import _strip_md_block
+
+ text = "- item a\n1. item b\n2. item c"
+ result = _strip_md_block(text)
+ assert "\u2022 item a" in result
+ assert "1. item b" in result
+ assert "2. item c" in result
+
+
+def test_strip_md_block_strips_links() -> None:
+ from nanobot.channels.telegram import _strip_md_block
+
+ assert _strip_md_block("[click here](https://example.com)") == "click here"
+
+
+# ---------------------------------------------------------------------------
+# Streaming mid-edit uses _strip_md_block
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_send_delta_mid_stream_strips_markdown() -> None:
+ """Mid-stream edits should strip markdown so users see clean text."""
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+ channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
+ channel._app.bot.edit_message_text = AsyncMock()
+
+ # Initial send with markdown
+ await channel.send_delta("999", "**hello** world")
+ sent_text = channel._app.bot.send_message.call_args.kwargs.get("text", "")
+ # Should NOT contain raw markdown asterisks
+ assert "**" not in sent_text
+ assert "hello world" in sent_text
+
+ # Mid-stream edit
+ import time
+ buf = channel._stream_bufs["999"]
+ buf.last_edit = time.monotonic() - 10 # force edit interval
+ await channel.send_delta("999", "\n### Title\n1. step")
+ edited_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
+ assert "###" not in edited_text
+ assert "**" not in edited_text
+ assert "Title" in edited_text
+ assert "1. step" in edited_text
diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py
index 0e1235b86..e648e818c 100644
--- a/tests/cli/test_cli_input.py
+++ b/tests/cli/test_cli_input.py
@@ -183,3 +183,22 @@ def test_make_console_force_terminal_false_when_stdout_is_not_tty():
with patch.object(sys.stdout, "isatty", return_value=False):
console = stream_mod._make_console()
assert console._force_terminal is False
+
+
+def test_render_interactive_ansi_force_terminal_follows_isatty():
+ """Mirror of _make_console: the capture console used to produce ANSI for
+ prompt_toolkit must also defer to sys.stdout.isatty(), otherwise cursor
+ escapes and spinner frames leak into piped output (#3265, #3370)."""
+ import sys
+ captured: dict = {}
+
+ def render_fn(c):
+ captured["console"] = c
+
+ with patch.object(sys.stdout, "isatty", return_value=True):
+ commands._render_interactive_ansi(render_fn)
+ assert captured["console"]._force_terminal is True
+
+ with patch.object(sys.stdout, "isatty", return_value=False):
+ commands._render_interactive_ansi(render_fn)
+ assert captured["console"]._force_terminal is False
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index 58e6ab2c4..0344af23b 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -1288,10 +1288,15 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def run(self) -> None:
return None
+ class _FakeSessionManager:
+ def flush_all(self) -> int:
+ return 0
+
class _FakeAgentLoop:
def __init__(self, **_kwargs) -> None:
self.model = "test-model"
self.dream = _FakeDream()
+ self.sessions = _FakeSessionManager()
async def run(self) -> None:
await asyncio.Event().wait()
diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py
new file mode 100644
index 000000000..3be684072
--- /dev/null
+++ b/tests/command/test_router_dispatchable.py
@@ -0,0 +1,143 @@
+"""Tests for CommandRouter.is_dispatchable_command and mid-turn command interception."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from nanobot.command.builtin import register_builtin_commands
+from nanobot.command.router import CommandContext, CommandRouter
+
+
+class TestIsDispatchableCommand:
+ """Unit tests for the is_dispatchable_command() predicate."""
+
+ @pytest.fixture()
+ def router(self) -> CommandRouter:
+ r = CommandRouter()
+ register_builtin_commands(r)
+ return r
+
+ def test_exact_commands_match(self, router: CommandRouter) -> None:
+ assert router.is_dispatchable_command("/new")
+ assert router.is_dispatchable_command("/help")
+ assert router.is_dispatchable_command("/dream")
+ assert router.is_dispatchable_command("/dream-log")
+ assert router.is_dispatchable_command("/dream-restore")
+
+ def test_prefix_commands_match(self, router: CommandRouter) -> None:
+ assert router.is_dispatchable_command("/dream-log abc123")
+ assert router.is_dispatchable_command("/dream-restore def456")
+
+ def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
+ # Priority commands are NOT in the dispatchable tiers — they are
+ # handled by is_priority() separately.
+ assert not router.is_dispatchable_command("/stop")
+ assert not router.is_dispatchable_command("/restart")
+
+ def test_regular_text_not_matched(self, router: CommandRouter) -> None:
+ assert not router.is_dispatchable_command("hello")
+ assert not router.is_dispatchable_command("what is 2+2?")
+ assert not router.is_dispatchable_command("")
+
+ def test_case_insensitive(self, router: CommandRouter) -> None:
+ assert router.is_dispatchable_command("/NEW")
+ assert router.is_dispatchable_command("/Help")
+
+ def test_strips_whitespace(self, router: CommandRouter) -> None:
+ assert router.is_dispatchable_command(" /new ")
+
+ def test_unknown_slash_command_not_matched(self, router: CommandRouter) -> None:
+ assert not router.is_dispatchable_command("/unknown")
+ assert not router.is_dispatchable_command("/foo bar")
+
+
+class TestMidTurnCommandDispatchedDirectly:
+ """Verify that commands matching is_dispatchable_command() are dispatched
+ correctly when session=None (the mid-turn path)."""
+
+ @pytest.fixture()
+ def router(self) -> CommandRouter:
+ r = CommandRouter()
+ register_builtin_commands(r)
+ return r
+
+ @pytest.fixture()
+ def fake_loop(self) -> MagicMock:
+ loop = MagicMock()
+ loop.sessions = MagicMock()
+ loop.sessions.get_or_create = MagicMock(return_value=MagicMock(
+ messages=[], last_consolidated=0, clear=MagicMock(),
+ ))
+ loop.sessions.save = MagicMock()
+ loop.sessions.invalidate = MagicMock()
+ loop._schedule_background = MagicMock()
+ loop._cancel_active_tasks = AsyncMock(return_value=0)
+ return loop
+
+ @pytest.fixture()
+ def fake_msg(self) -> MagicMock:
+ msg = MagicMock()
+ msg.channel = "test"
+ msg.chat_id = "chat1"
+ msg.content = "/new"
+ msg.metadata = {}
+ return msg
+
+ @pytest.mark.asyncio
+ async def test_new_dispatched_with_session_none(
+ self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
+ ) -> None:
+ """cmd_new works when session=None (mid-turn dispatch path)."""
+ ctx = CommandContext(
+ msg=fake_msg, session=None,
+ key="test:chat1", raw="/new", loop=fake_loop,
+ )
+ result = await router.dispatch(ctx)
+ assert result is not None
+ assert "New session" in result.content
+ fake_loop.sessions.get_or_create.assert_called_once_with("test:chat1")
+
+ @pytest.mark.asyncio
+ async def test_help_dispatched_with_session_none(
+ self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
+ ) -> None:
+ ctx = CommandContext(
+ msg=fake_msg, session=None,
+ key="test:chat1", raw="/help", loop=fake_loop,
+ )
+ result = await router.dispatch(ctx)
+ assert result is not None
+
+ @pytest.mark.asyncio
+ async def test_prefix_command_args_populated(self, router: CommandRouter) -> None:
+ """Prefix commands have args populated correctly in mid-turn path."""
+ # Use a custom prefix handler to avoid needing full mock setup.
+ custom = CommandRouter()
+ captured_args = []
+
+ async def fake_handler(ctx: CommandContext) -> None:
+ captured_args.append(ctx.args)
+ return None
+
+ custom.prefix("/test ", fake_handler)
+
+ ctx = CommandContext(
+ msg=MagicMock(channel="test", chat_id="c1", metadata={}),
+ session=None, key="test:c1", raw="/test hello world", loop=MagicMock(),
+ )
+ await custom.dispatch(ctx)
+ assert captured_args == ["hello world"]
+
+ @pytest.mark.asyncio
+ async def test_non_command_returns_none(
+ self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
+ ) -> None:
+ """Regular text returns None from dispatch (not a command)."""
+ ctx = CommandContext(
+ msg=fake_msg, session=None,
+ key="test:chat1", raw="hello world", loop=fake_loop,
+ )
+ result = await router.dispatch(ctx)
+ assert result is None
diff --git a/tests/providers/test_anthropic_merge_consecutive.py b/tests/providers/test_anthropic_merge_consecutive.py
new file mode 100644
index 000000000..7013bd144
--- /dev/null
+++ b/tests/providers/test_anthropic_merge_consecutive.py
@@ -0,0 +1,139 @@
+"""Tests for AnthropicProvider._merge_consecutive."""
+
+from nanobot.providers.anthropic_provider import AnthropicProvider
+
+
+class TestMergeConsecutive:
+ """Verify role alternation and trailing-assistant stripping."""
+
+ def test_basic_alternation(self):
+ msgs = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi"},
+ {"role": "user", "content": "bye"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert len(result) == 3
+ assert [m["role"] for m in result] == ["user", "assistant", "user"]
+
+ def test_consecutive_same_role_merged(self):
+ msgs = [
+ {"role": "user", "content": "a"},
+ {"role": "user", "content": "b"},
+ {"role": "assistant", "content": "reply"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ # Two user messages merged into one, trailing assistant stripped
+ assert len(result) == 1
+ assert result[0]["role"] == "user"
+
+ def test_trailing_assistant_stripped(self):
+ """Anthropic rejects prefill — trailing assistant must be removed."""
+ msgs = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert len(result) == 1
+ assert result[0]["role"] == "user"
+ assert result[0]["content"] == "hello"
+
+ def test_multiple_trailing_assistant_stripped(self):
+ msgs = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "a"},
+ {"role": "user", "content": "ok"},
+ {"role": "assistant", "content": "b"},
+ {"role": "assistant", "content": "c"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ # b+c merged into one assistant, then stripped as trailing
+ assert len(result) == 3
+ assert result[-1]["role"] == "user"
+ assert result[-1]["content"] == "ok"
+
+ def test_empty_messages(self):
+ assert AnthropicProvider._merge_consecutive([]) == []
+
+ def test_single_user_message(self):
+ msgs = [{"role": "user", "content": "hi"}]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert len(result) == 1
+
+ def test_single_assistant_rerouted_to_user(self):
+ """When stripping leaves nothing, the last assistant is rerouted to
+ ``user`` so we don't produce an empty messages array."""
+ msgs = [{"role": "assistant", "content": "hi"}]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert len(result) == 1
+ assert result[0]["role"] == "user"
+ assert result[0]["content"] == "hi"
+
+ def test_all_assistants_collapse_then_rerouted(self):
+ """Consecutive trailing assistants merge into one, which is then
+ rerouted as a user turn carrying the merged content."""
+ msgs = [
+ {"role": "assistant", "content": "a"},
+ {"role": "assistant", "content": "b"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert len(result) == 1
+ assert result[0]["role"] == "user"
+ # "b" was merged into "a"'s block list during the merge pass.
+ assert result[0]["content"] == [
+ {"type": "text", "text": "a"},
+ {"type": "text", "text": "b"},
+ ]
+
+ def test_assistant_with_tool_use_not_rerouted(self):
+ """A trailing assistant carrying ``tool_use`` blocks cannot become a
+ user turn (Anthropic rejects ``tool_use`` inside user messages), so
+ the method returns an empty list rather than forging a bad request."""
+ msgs = [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "text", "text": "let me search"},
+ {"type": "tool_use", "id": "t1", "name": "search", "input": {}},
+ ],
+ }
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert result == []
+
+ def test_leading_assistant_gets_synthetic_user(self):
+ """If the first turn is a bare assistant (e.g. history truncation
+ dropped the original user request), prepend a synthetic opener so
+ the conversation still starts with ``user``."""
+ msgs = [
+ {"role": "assistant", "content": "hi"},
+ {"role": "user", "content": "ok"},
+ {"role": "assistant", "content": "reply"},
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert [m["role"] for m in result] == ["user", "assistant", "user"]
+ assert result[0]["content"] == "(conversation continued)"
+ assert result[1]["content"] == "hi"
+ assert result[2]["content"] == "ok"
+
+ def test_leading_assistant_with_tool_use_left_alone(self):
+ """Don't prepend a synthetic opener before an assistant carrying
+ ``tool_use``; doing so would orphan the paired ``tool_result`` that
+ follows. The caller will see the original 400 rather than a
+ harder-to-diagnose tool-pair mismatch."""
+ msgs = [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "tool_use", "id": "t1", "name": "search", "input": {}},
+ ],
+ },
+ {
+ "role": "user",
+ "content": [
+ {"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
+ ],
+ },
+ ]
+ result = AnthropicProvider._merge_consecutive(msgs)
+ assert [m["role"] for m in result] == ["assistant", "user"]
diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index 47db20398..41188c721 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -731,15 +731,45 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
def test_dashscope_thinking_disabled_for_minimal() -> None:
+ """'minimal' → wire 'minimum' + thinking off on DashScope."""
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
+ assert kw["reasoning_effort"] == "minimum"
assert kw["extra_body"] == {"enable_thinking": False}
+def test_dashscope_thinking_disabled_for_minimum_alias() -> None:
+ """Native 'minimum' spelling must also disable thinking, not enable it."""
+ kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimum")
+ assert kw["reasoning_effort"] == "minimum"
+ assert kw["extra_body"] == {"enable_thinking": False}
+
+
+def test_non_dashscope_minimal_not_retranslated() -> None:
+ """DashScope-specific translation must not leak to other providers."""
+ kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal")
+ assert kw["reasoning_effort"] == "minimal"
+
+
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
assert "extra_body" not in kw
+def test_minimax_reasoning_split_enabled_with_reasoning_effort() -> None:
+ kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="medium")
+ assert kw["extra_body"] == {"reasoning_split": True}
+
+
+def test_minimax_reasoning_split_disabled_for_minimal() -> None:
+ kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="minimal")
+ assert kw["extra_body"] == {"reasoning_split": False}
+
+
+def test_minimax_no_extra_body_when_reasoning_effort_none() -> None:
+ kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort=None)
+ assert "extra_body" not in kw
+
+
def test_volcengine_thinking_enabled() -> None:
kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high")
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
@@ -784,6 +814,25 @@ def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
+
+def test_kimi_k26_thinking_enabled() -> None:
+ """kimi-k2.6 with reasoning_effort set should opt in to thinking."""
+ kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
+ assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
+
+
+def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
+ """OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
+ kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
+ assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
+
+
+def test_moonshot_kimi_k26_temperature_override() -> None:
+ """Moonshot registry forces temperature 1.0 for kimi-k2.6 (API requirement)."""
+ kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort=None)
+ assert kw["temperature"] == 1.0
+
+
def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None:
"""OpenRouter names must NOT trigger thinking without reasoning_effort."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None)
diff --git a/tests/session/__init__.py b/tests/session/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/session/test_session_fsync.py b/tests/session/test_session_fsync.py
new file mode 100644
index 000000000..3194cf957
--- /dev/null
+++ b/tests/session/test_session_fsync.py
@@ -0,0 +1,130 @@
+"""Tests for session fsync and flush_all on graceful shutdown."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from nanobot.session.manager import SessionManager
+
+_IS_WINDOWS = sys.platform == "win32"
+
+
+@pytest.fixture
+def sessions_dir(tmp_path: Path) -> Path:
+ d = tmp_path / "sessions"
+ d.mkdir()
+ return tmp_path
+
+
+@pytest.fixture
+def manager(sessions_dir: Path) -> SessionManager:
+ return SessionManager(workspace=sessions_dir)
+
+
+class TestSaveFsync:
+ """Verify that save(fsync=True) calls os.fsync."""
+
+ def test_save_without_fsync_does_not_call_fsync(self, manager: SessionManager):
+ session = manager.get_or_create("test:no-fsync")
+ session.add_message("user", "hello")
+
+ with patch("os.fsync") as mock_fsync:
+ manager.save(session, fsync=False)
+ mock_fsync.assert_not_called()
+
+ def test_save_with_fsync_calls_fsync(self, manager: SessionManager):
+ session = manager.get_or_create("test:with-fsync")
+ session.add_message("user", "hello")
+
+ with patch("os.fsync") as mock_fsync:
+ manager.save(session, fsync=True)
+ # File fsync always runs; directory fsync only on non-Windows.
+ expected = 1 if _IS_WINDOWS else 2
+ assert mock_fsync.call_count == expected
+
+ def test_save_default_no_fsync(self, manager: SessionManager):
+ """Default save() should not fsync (backward compat)."""
+ session = manager.get_or_create("test:default")
+ session.add_message("user", "hello")
+
+ with patch("os.fsync") as mock_fsync:
+ manager.save(session)
+ mock_fsync.assert_not_called()
+
+
+class TestFlushAll:
+ """Verify flush_all re-saves all cached sessions with fsync."""
+
+ def test_flush_all_empty_cache(self, manager: SessionManager):
+ assert manager.flush_all() == 0
+
+ def test_flush_all_saves_cached_sessions(self, manager: SessionManager):
+ s1 = manager.get_or_create("test:session-1")
+ s1.add_message("user", "msg 1")
+ manager.save(s1)
+
+ s2 = manager.get_or_create("test:session-2")
+ s2.add_message("user", "msg 2")
+ manager.save(s2)
+
+ flushed = manager.flush_all()
+ assert flushed == 2
+
+ def test_flush_all_uses_fsync(self, manager: SessionManager):
+ session = manager.get_or_create("test:fsync-check")
+ session.add_message("user", "important")
+ manager.save(session)
+
+ with patch("os.fsync") as mock_fsync:
+ manager.flush_all()
+ # file fsync always; directory fsync only on non-Windows
+ expected = 1 if _IS_WINDOWS else 2
+ assert mock_fsync.call_count == expected
+
+ def test_flush_all_continues_on_error(self, manager: SessionManager):
+ """One broken session should not prevent others from flushing."""
+ s1 = manager.get_or_create("test:good")
+ s1.add_message("user", "ok")
+ manager.save(s1)
+
+ s2 = manager.get_or_create("test:bad")
+ s2.add_message("user", "ok")
+ manager.save(s2)
+
+ original_save = manager.save
+ call_count = {"n": 0}
+
+ def patched_save(session, *, fsync=False):
+ call_count["n"] += 1
+ if session.key == "test:bad":
+ raise OSError("disk on fire")
+ original_save(session, fsync=fsync)
+
+ manager.save = patched_save
+ flushed = manager.flush_all()
+
+ # One succeeded, one failed — flush_all returns successful count
+ assert flushed == 1
+ assert call_count["n"] == 2
+
+ def test_flush_all_data_survives_reload(self, sessions_dir: Path):
+ """Data flushed by flush_all should survive a fresh SessionManager load."""
+ mgr1 = SessionManager(workspace=sessions_dir)
+ session = mgr1.get_or_create("test:persist")
+ session.add_message("user", "remember this")
+ session.add_message("assistant", "noted")
+ mgr1.save(session)
+ mgr1.flush_all()
+
+ # Simulate process restart — new manager, cold cache
+ mgr2 = SessionManager(workspace=sessions_dir)
+ reloaded = mgr2.get_or_create("test:persist")
+ history = reloaded.get_history(max_messages=100)
+
+ assert len(history) == 2
+ assert history[0]["content"] == "remember this"
+ assert history[1]["content"] == "noted"
diff --git a/tests/tools/test_read_enhancements.py b/tests/tools/test_read_enhancements.py
index 0be123700..f7a62f05b 100644
--- a/tests/tools/test_read_enhancements.py
+++ b/tests/tools/test_read_enhancements.py
@@ -1,7 +1,8 @@
-"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist."""
+"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist, office docs."""
import os
import sys
+from unittest.mock import patch
import pytest
@@ -246,3 +247,123 @@ class TestReadFileLineEndingNormalization:
result = await tool.execute(path=str(f))
assert "\r" not in result
assert "alpha" in result and "beta" in result and "gamma" in result
+
+
+# ---------------------------------------------------------------------------
+# Office document support (DOCX, XLSX, PPTX)
+# ---------------------------------------------------------------------------
+
+class TestReadOfficeDocuments:
+
+ @pytest.fixture()
+ def tool(self, tmp_path):
+ return ReadFileTool(workspace=tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_docx_returns_extracted_text(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="Title\n\nParagraph 1"):
+ f = tmp_path / "test.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Title" in result
+ assert "Paragraph 1" in result
+ assert "Error" not in result
+
+ @pytest.mark.asyncio
+ async def test_xlsx_returns_extracted_text(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="--- Sheet: Sheet1 ---\nName\tAge\nAlice\t30"):
+ f = tmp_path / "test.xlsx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Sheet1" in result
+ assert "Alice" in result
+
+ @pytest.mark.asyncio
+ async def test_pptx_returns_extracted_text(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="--- Slide 1 ---\nWelcome\n--- Slide 2 ---\nContent"):
+ f = tmp_path / "test.pptx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Welcome" in result
+ assert "Content" in result
+
+ @pytest.mark.asyncio
+ async def test_docx_missing_library(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="[error: python-docx not installed]"):
+ f = tmp_path / "test.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Error" in result
+ assert "python-docx not installed" in result
+
+ @pytest.mark.asyncio
+ async def test_docx_corrupt_file(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: bad zip]"):
+ f = tmp_path / "test.docx"
+ f.write_bytes(b"not-a-zip")
+ result = await tool.execute(path=str(f))
+ assert "Error" in result
+ assert "failed to extract DOCX" in result
+
+ @pytest.mark.asyncio
+ async def test_unsupported_extension(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value=None):
+ f = tmp_path / "test.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Error" in result
+ assert "Unsupported" in result
+
+ @pytest.mark.asyncio
+ async def test_empty_document_returns_descriptive_message(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value=""):
+ f = tmp_path / "empty.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "no extractable text" in result
+
+
+class TestOfficeDocTruncation:
+
+ @pytest.fixture()
+ def tool(self, tmp_path):
+ return ReadFileTool(workspace=tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_large_document_truncated(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="x" * 200_000):
+ f = tmp_path / "large.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert len(result) <= ReadFileTool._MAX_CHARS + 100
+ assert "truncated at ~128K chars" in result
+
+ @pytest.mark.asyncio
+ async def test_small_document_not_truncated(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="Hello world"):
+ f = tmp_path / "small.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "truncated" not in result
+ assert "Hello world" in result
+
+ @pytest.mark.asyncio
+ async def test_error_response_not_truncated(self, tool, tmp_path):
+ with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: something went wrong]"):
+ f = tmp_path / "bad.docx"
+ f.write_bytes(b"PK")
+ result = await tool.execute(path=str(f))
+ assert "Error" in result
+ assert "truncated" not in result
+
+
+class TestReadDescriptionUpdate:
+
+ def test_description_mentions_documents(self):
+ tool = ReadFileTool()
+ desc = tool.description.lower()
+ assert "document" in desc or "docx" in desc or "xlsx" in desc or "pptx" in desc
+
+ def test_description_no_longer_says_cannot_read(self):
+ tool = ReadFileTool()
+ assert "cannot read" not in tool.description.lower()
diff --git a/webui/README.md b/webui/README.md
index 9d3591d22..602b179e7 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -1,6 +1,6 @@
# nanobot webui
-The browser front-end for `nanobot web`. It is built with Vite + React 18 +
+The browser front-end for the nanobot gateway. It is built with Vite + React 18 +
TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket
multiplex protocol, and reads session metadata from the embedded REST surface
on the same port.
@@ -22,7 +22,7 @@ For the project overview, install guide, and general docs map, see the root
```text
webui/ source tree (this directory)
-nanobot/web/dist/ build output consumed by `nanobot web`
+nanobot/web/dist/ build output served by the gateway
```
## Develop from source
@@ -35,7 +35,15 @@ From the repository root:
pip install -e .
```
-### 2. Start the gateway
+### 2. Enable the WebSocket channel
+
+In `~/.nanobot/config.json`:
+
+```json
+{ "channels": { "websocket": { "enabled": true } } }
+```
+
+### 3. Start the gateway
In one terminal:
@@ -43,7 +51,7 @@ In one terminal:
nanobot gateway
```
-### 3. Start the WebUI dev server
+### 4. Start the WebUI dev server
In another terminal:
@@ -72,7 +80,7 @@ bun run build
```
This writes the production assets to `../nanobot/web/dist`, which is the
-directory served by `nanobot web` and bundled into the Python wheel.
+directory served by `nanobot gateway` and bundled into the Python wheel.
If you are cutting a release, run the build before packaging so the published
wheel contains the current WebUI assets.
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 9fc016637..0b0f34061 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "Couldn't reach nanobot",
- "gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
+ "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 1c0993647..2f11a2fb9 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "No se pudo conectar con nanobot",
- "gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
+ "gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 75e4753d5..33d59c725 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "Impossible de joindre nanobot",
- "gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot web`) et que cette page est ouverte sur la même machine."
+ "gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 6085046ef..f37132bdf 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "Tidak dapat menjangkau nanobot",
- "gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
+ "gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 5f76ac0c9..8529e9a8a 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "nanobot に接続できませんでした",
- "gatewayHint": "gateway(`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
+ "gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index bc840764a..a1e52aca9 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "nanobot에 연결할 수 없습니다",
- "gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
+ "gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index d648f60e9..33099878d 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "Không thể kết nối tới nanobot",
- "gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
+ "gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 67a12f3ff..ae7815e9d 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "无法连接到 nanobot",
- "gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
+ "gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
},
"documentTitle": {
"base": "nanobot",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 743ca5876..f606a7f18 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -7,7 +7,7 @@
},
"error": {
"title": "無法連線到 nanobot",
- "gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
+ "gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
},
"documentTitle": {
"base": "nanobot",