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 nanobot webui preview

-**1. Start the gateway** +**1. Enable the WebSocket channel in `~/.nanobot/config.json`** + +```json +{ "channels": { "websocket": { "enabled": true } } } +``` + +**2. Start the gateway** ```bash nanobot gateway ``` -**2. Start the webui dev server** +**3. Start the webui dev server** ```bash cd webui diff --git a/docs/chat-commands.md b/docs/chat-commands.md index 72707e764..816292e74 100644 --- a/docs/chat-commands.md +++ b/docs/chat-commands.md @@ -4,7 +4,7 @@ These commands work inside chat channels and interactive agent sessions: | Command | Description | |---------|-------------| -| `/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/docs/configuration.md b/docs/configuration.md index dcce6aed6..153cbc959 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -420,7 +420,7 @@ That's it! Environment variables, model routing, config matching, and `nanobot s |-------|-------------|---------| | `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` | | `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` | -| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` | +| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}), ("kimi-k2.6", {"temperature": 1.0}),)` | | `is_gateway` | Can route any model (like OpenRouter) | `True` | | `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` | | `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` | diff --git a/nanobot/__init__.py b/nanobot/__init__.py index 5e6954d96..e2428d2d7 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.post1" + return _read_pyproject_version() or "0.1.5.post2" __version__ = _resolve_version() diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a3b29fb93..25af137c8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -345,6 +345,36 @@ class AgentLoop: return format_tool_hints(tool_calls) + async def _dispatch_command_inline( + self, + msg: InboundMessage, + key: str, + raw: str, + dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]], + ) -> None: + """Dispatch a command directly from the run() loop and publish the result.""" + ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self) + result = await dispatch_fn(ctx) + if result: + await self.bus.publish_outbound(result) + else: + logger.warning("Command '{}' matched but dispatch returned None", raw) + + async def _cancel_active_tasks(self, key: str) -> int: + """Cancel and await all active tasks and subagents for *key*. + + Returns the total number of cancelled tasks + subagents. + """ + tasks = self._active_tasks.pop(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 self.subagents.cancel_by_session(key) + return cancelled + sub_cancelled + def _effective_session_key(self, msg: InboundMessage) -> str: """Return the session key used for task routing and mid-turn injections.""" if self._unified_session and not msg.session_key_override: @@ -478,16 +508,24 @@ class AgentLoop: raw = msg.content.strip() if self.commands.is_priority(raw): - ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self) - result = await self.commands.dispatch_priority(ctx) - if result: - await self.bus.publish_outbound(result) + await self._dispatch_command_inline( + msg, msg.session_key, raw, + self.commands.dispatch_priority, + ) continue effective_key = self._effective_session_key(msg) # If this session already has an active pending queue (i.e. a task # is processing this session), route the message there for mid-turn # injection instead of creating a competing task. if effective_key in self._pending_queues: + # Non-priority commands must not be queued for injection; + # dispatch them directly (same pattern as priority commands). + if self.commands.is_dispatchable_command(raw): + await self._dispatch_command_inline( + msg, effective_key, raw, + self.commands.dispatch, + ) + continue pending_msg = msg if effective_key != msg.session_key: pending_msg = dataclasses.replace( @@ -579,6 +617,29 @@ class AgentLoop: )) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) + # Preserve partial context from the interrupted turn so + # the user does not lose tool results and assistant + # messages accumulated before /stop. The checkpoint was + # already persisted to session metadata by + # _emit_checkpoint during tool execution; materializing + # it into session history now makes it visible in the + # next conversation turn. + try: + key = self._effective_session_key(msg) + session = self.sessions.get_or_create(key) + if self._restore_runtime_checkpoint(session): + self._clear_pending_user_turn(session) + self.sessions.save(session) + logger.info( + "Restored partial context for cancelled session {}", + key, + ) + except Exception: + logger.debug( + "Could not restore checkpoint for cancelled session {}", + session_key, + exc_info=True, + ) raise except Exception: logger.exception("Error processing message for session {}", session_key) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 60b542082..60bc9accb 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -8,7 +8,7 @@ import re import weakref from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Iterator from loguru import logger @@ -49,6 +49,7 @@ class MemoryStore: self.user_file = workspace / "USER.md" self._cursor_file = self.memory_dir / ".cursor" self._dream_cursor_file = self.memory_dir / ".dream_cursor" + self._corruption_logged = False # rate-limit non-int cursor warning self._git = GitStore(workspace, tracked_files=[ "SOUL.md", "USER.md", "memory/MEMORY.md", ]) @@ -246,22 +247,52 @@ class MemoryStore: self._cursor_file.write_text(str(cursor), encoding="utf-8") return cursor + @staticmethod + def _valid_cursor(value: Any) -> int | None: + """Int cursors only — reject bool (``isinstance(True, int)`` is True).""" + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]: + """Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption.""" + poisoned: Any = None + for entry in self._read_entries(): + raw = entry.get("cursor") + if raw is None: + continue + cursor = self._valid_cursor(raw) + if cursor is None: + poisoned = raw + continue + yield entry, cursor + if poisoned is not None and not self._corruption_logged: + self._corruption_logged = True + logger.warning( + "history.jsonl contains a non-int cursor ({!r}); dropping it. " + "Usually caused by an external writer; further occurrences suppressed.", + poisoned, + ) + def _next_cursor(self) -> int: - """Read the current cursor counter and return next value.""" + """Read the current cursor counter and return the next value.""" if self._cursor_file.exists(): try: return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1 except (ValueError, OSError): pass - # Fallback: read last line's cursor from the JSONL file. - last = self._read_last_entry() - if last and last.get("cursor"): - return last["cursor"] + 1 - return 1 + # Fast path: trust the tail when intact. Otherwise scan the whole + # file and take ``max`` — that stays correct even if the monotonic + # invariant was broken by external writes. + last = self._read_last_entry() or {} + cursor = self._valid_cursor(last.get("cursor")) + if cursor is not None: + return cursor + 1 + return max((c for _, c in self._iter_valid_entries()), default=0) + 1 def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]: - """Return history entries with cursor > *since_cursor*.""" - return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor] + """Return history entries with a valid cursor > *since_cursor*.""" + return [e for e, c in self._iter_valid_entries() if c > since_cursor] def compact_history(self) -> None: """Drop oldest entries if the file exceeds *max_history_entries*.""" diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 1f3afd341..c0d628a71 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -137,10 +137,11 @@ class ReadFileTool(_FsTool): @property def description(self) -> str: return ( - "Read a file (text or image). Text output format: LINE_NUM|CONTENT. " + "Read a file (text, image, or document). " + "Text output format: LINE_NUM|CONTENT. " "Images return visual content for analysis. " - "Use offset and limit for large files. " - "Cannot read non-image binary files. " + "Supports PDF, DOCX, XLSX, PPTX documents. " + "Use offset and limit for large text files. " "Reads exceeding ~128K chars are truncated." ) @@ -169,6 +170,10 @@ class ReadFileTool(_FsTool): if fp.suffix.lower() == ".pdf": return self._read_pdf(fp, pages) + # Office document support + if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}: + return self._read_office_doc(fp) + raw = fp.read_bytes() if not raw: return f"(Empty file: {path})" @@ -304,6 +309,25 @@ class ReadFileTool(_FsTool): result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)" return result + def _read_office_doc(self, fp: Path) -> str: + from nanobot.utils.document import extract_text + + result = extract_text(fp) + + if result is None: + return f"Error: Unsupported file format: {fp.suffix}" + + if result.startswith("[error:"): + return f"Error reading {fp.suffix.upper()} file: {result}" + + if not result: + return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})" + + if len(result) > self._MAX_CHARS: + result = result[:self._MAX_CHARS] + "\n\n(Document text truncated at ~128K chars)" + + return result + # --------------------------------------------------------------------------- # write_file diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 2aea19279..ab95b446c 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -10,6 +10,25 @@ from loguru import logger from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry +# Transient connection errors that warrant a single retry. +# These typically happen when an MCP server restarts or a network +# connection is interrupted between calls. +_TRANSIENT_EXC_NAMES: frozenset[str] = frozenset(( + "ClosedResourceError", + "BrokenResourceError", + "EndOfStream", + "BrokenPipeError", + "ConnectionResetError", + "ConnectionRefusedError", + "ConnectionAbortedError", + "ConnectionError", +)) + + +def _is_transient(exc: BaseException) -> bool: + """Check if an exception looks like a transient connection error.""" + return type(exc).__name__ in _TRANSIENT_EXC_NAMES + def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None: """Return the single non-null branch for nullable unions.""" @@ -99,38 +118,61 @@ class MCPToolWrapper(Tool): async def execute(self, **kwargs: Any) -> str: from mcp import types - try: - result = await asyncio.wait_for( - self._session.call_tool(self._original_name, arguments=kwargs), - timeout=self._tool_timeout, - ) - except asyncio.TimeoutError: - logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) - return f"(MCP tool call timed out after {self._tool_timeout}s)" - except asyncio.CancelledError: - # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. - # Re-raise only if our task was externally cancelled (e.g. /stop). - task = asyncio.current_task() - if task is not None and task.cancelling() > 0: - raise - logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) - return "(MCP tool call was cancelled)" - except Exception as exc: - logger.exception( - "MCP tool '{}' failed: {}: {}", - self._name, - type(exc).__name__, - exc, - ) - return f"(MCP tool call failed: {type(exc).__name__})" - - parts = [] - for block in result.content: - if isinstance(block, types.TextContent): - parts.append(block.text) + for attempt in range(2): # At most 1 retry + try: + result = await asyncio.wait_for( + self._session.call_tool(self._original_name, arguments=kwargs), + timeout=self._tool_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "MCP tool '{}' timed out after {}s", self._name, self._tool_timeout + ) + return f"(MCP tool call timed out after {self._tool_timeout}s)" + except asyncio.CancelledError: + # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. + # Re-raise only if our task was externally cancelled (e.g. /stop). + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) + return "(MCP tool call was cancelled)" + except Exception as exc: + if _is_transient(exc): + if attempt == 0: + logger.warning( + "MCP tool '{}' hit transient error ({}), retrying once...", + self._name, + type(exc).__name__, + ) + await asyncio.sleep(1) # Brief backoff before retry + continue + # Second transient failure — give up with retry-specific message + logger.error( + "MCP tool '{}' failed after retry: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP tool call failed after retry: {type(exc).__name__})" + logger.exception( + "MCP tool '{}' failed: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP tool call failed: {type(exc).__name__})" else: - parts.append(str(block)) - return "\n".join(parts) or "(no output)" + # Success — extract result + parts = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + return "\n".join(parts) or "(no output)" + + return "(MCP tool call failed)" # Unreachable, but satisfies type checkers class MCPResourceWrapper(Tool): @@ -168,40 +210,59 @@ class MCPResourceWrapper(Tool): async def execute(self, **kwargs: Any) -> str: from mcp import types - try: - result = await asyncio.wait_for( - self._session.read_resource(self._uri), - timeout=self._resource_timeout, - ) - except asyncio.TimeoutError: - logger.warning( - "MCP resource '{}' timed out after {}s", self._name, self._resource_timeout - ) - return f"(MCP resource read timed out after {self._resource_timeout}s)" - except asyncio.CancelledError: - task = asyncio.current_task() - if task is not None and task.cancelling() > 0: - raise - logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name) - return "(MCP resource read was cancelled)" - except Exception as exc: - logger.exception( - "MCP resource '{}' failed: {}: {}", - self._name, - type(exc).__name__, - exc, - ) - return f"(MCP resource read failed: {type(exc).__name__})" - - parts: list[str] = [] - for block in result.contents: - if isinstance(block, types.TextResourceContents): - parts.append(block.text) - elif isinstance(block, types.BlobResourceContents): - parts.append(f"[Binary resource: {len(block.blob)} bytes]") + for attempt in range(2): + try: + result = await asyncio.wait_for( + self._session.read_resource(self._uri), + timeout=self._resource_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "MCP resource '{}' timed out after {}s", self._name, self._resource_timeout + ) + return f"(MCP resource read timed out after {self._resource_timeout}s)" + except asyncio.CancelledError: + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name) + return "(MCP resource read was cancelled)" + except Exception as exc: + if _is_transient(exc): + if attempt == 0: + logger.warning( + "MCP resource '{}' hit transient error ({}), retrying once...", + self._name, + type(exc).__name__, + ) + await asyncio.sleep(1) + continue + logger.error( + "MCP resource '{}' failed after retry: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP resource read failed after retry: {type(exc).__name__})" + logger.exception( + "MCP resource '{}' failed: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP resource read failed: {type(exc).__name__})" else: - parts.append(str(block)) - return "\n".join(parts) or "(no output)" + parts: list[str] = [] + for block in result.contents: + if isinstance(block, types.TextResourceContents): + parts.append(block.text) + elif isinstance(block, types.BlobResourceContents): + parts.append(f"[Binary resource: {len(block.blob)} bytes]") + else: + parts.append(str(block)) + return "\n".join(parts) or "(no output)" + + return "(MCP resource read failed)" # Unreachable class MCPPromptWrapper(Tool): @@ -254,52 +315,72 @@ class MCPPromptWrapper(Tool): from mcp import types from mcp.shared.exceptions import McpError - try: - result = await asyncio.wait_for( - self._session.get_prompt(self._prompt_name, arguments=kwargs), - timeout=self._prompt_timeout, - ) - except asyncio.TimeoutError: - logger.warning("MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout) - return f"(MCP prompt call timed out after {self._prompt_timeout}s)" - except asyncio.CancelledError: - task = asyncio.current_task() - if task is not None and task.cancelling() > 0: - raise - logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) - return "(MCP prompt call was cancelled)" - except McpError as exc: - logger.error( - "MCP prompt '{}' failed: code={} message={}", - self._name, - exc.error.code, - exc.error.message, - ) - return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])" - except Exception as exc: - logger.exception( - "MCP prompt '{}' failed: {}: {}", - self._name, - type(exc).__name__, - exc, - ) - return f"(MCP prompt call failed: {type(exc).__name__})" - - parts: list[str] = [] - for message in result.messages: - content = message.content - # content is a single ContentBlock (not a list) in MCP SDK >= 1.x - if isinstance(content, types.TextContent): - parts.append(content.text) - elif isinstance(content, list): - for block in content: - if isinstance(block, types.TextContent): - parts.append(block.text) - else: - parts.append(str(block)) + for attempt in range(2): + try: + result = await asyncio.wait_for( + self._session.get_prompt(self._prompt_name, arguments=kwargs), + timeout=self._prompt_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout + ) + return f"(MCP prompt call timed out after {self._prompt_timeout}s)" + except asyncio.CancelledError: + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) + return "(MCP prompt call was cancelled)" + except McpError as exc: + logger.error( + "MCP prompt '{}' failed: code={} message={}", + self._name, + exc.error.code, + exc.error.message, + ) + return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])" + except Exception as exc: + if _is_transient(exc): + if attempt == 0: + logger.warning( + "MCP prompt '{}' hit transient error ({}), retrying once...", + self._name, + type(exc).__name__, + ) + await asyncio.sleep(1) + continue + logger.error( + "MCP prompt '{}' failed after retry: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP prompt call failed after retry: {type(exc).__name__})" + logger.exception( + "MCP prompt '{}' failed: {}: {}", + self._name, + type(exc).__name__, + exc, + ) + return f"(MCP prompt call failed: {type(exc).__name__})" else: - parts.append(str(content)) - return "\n".join(parts) or "(no output)" + parts: list[str] = [] + for message in result.messages: + content = message.content + if isinstance(content, types.TextContent): + parts.append(content.text) + elif isinstance(content, list): + for block in content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + else: + parts.append(str(content)) + return "\n".join(parts) or "(no output)" + + return "(MCP prompt call failed)" # Unreachable async def connect_mcp_servers( diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 9710c5efc..97fa30bd0 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -135,7 +135,7 @@ if DISCORD_AVAILABLE: def _register_app_commands(self) -> None: commands = ( - ("new", "Start a new conversation", "/new"), + ("new", "Stop current task and start a new conversation", "/new"), ("stop", "Stop the current task", "/stop"), ("restart", "Restart the bot", "/restart"), ("status", "Show bot status", "/status"), diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ca0639bc1..6925658de 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -53,6 +53,34 @@ def _strip_md(s: str) -> str: return s.strip() +def _strip_md_block(text: str) -> str: + """Strip block-level and inline markdown for readable plain-text preview. + + Used during streaming mid-edits so users see clean text instead of raw + markdown syntax while the response is still being generated. + """ + # Code blocks -> just the code + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', r'\1', text) + # Headers -> plain text + text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) + # Blockquotes + text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) + # Bold / italic / strikethrough + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'(? text + text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) + # Bullet lists + text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) + # Numbered lists (normalize spacing) + text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE) + return text + + def _render_table_box(table_lines: list[str]) -> str: """Convert markdown pipe-table to compact aligned text for
 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",