From 847c50b2de848a18c0e7cefb0b1fa9c4af3a8315 Mon Sep 17 00:00:00 2001
From: hussein1362
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/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
From d4e34f8c6762303db85bfb9eeb76a2648e11b1cc Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Tue, 21 Apr 2026 21:28:58 +0800
Subject: [PATCH 15/32] fix(commands): intercept non-priority commands during
active turn
Non-priority slash commands (e.g. /new, /help, /dream-log) arriving
while a session has an active LLM turn were silently queued into the
pending injection buffer and later injected as raw user messages into
the LLM conversation. This caused the model to respond to "/new" as
plain text instead of executing the command.
Root cause: the run() loop only checked priority commands (/stop,
/restart, /status) before routing messages to the pending queue. All
other command tiers (exact, prefix) bypassed command dispatch entirely.
Changes:
- Add CommandRouter.is_dispatchable_command() to match exact/prefix
tiers, mirroring the existing is_priority() pattern.
- In run(), intercept dispatchable commands before pending queue
insertion and dispatch them directly via _dispatch_command_inline().
- Extract _cancel_active_tasks() from cmd_stop for reuse; cmd_new now
cancels active tasks before clearing the session to prevent shared
mutable state corruption from concurrent asyncio coroutines.
- Update /new semantics: stops active task first, then clears session.
- Update documentation in help text, docs, and Discord command list.
---
docs/chat-commands.md | 2 +-
nanobot/agent/loop.py | 46 ++++++-
nanobot/channels/discord.py | 2 +-
nanobot/command/builtin.py | 15 +--
nanobot/command/router.py | 14 +++
tests/command/test_router_dispatchable.py | 143 ++++++++++++++++++++++
6 files changed, 205 insertions(+), 17 deletions(-)
create mode 100644 tests/command/test_router_dispatchable.py
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/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 116868bb0..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(
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/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/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
From e15705b4711da860e994630ffacb9ab164a7eaa1 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Tue, 21 Apr 2026 21:33:55 +0800
Subject: [PATCH 16/32] fix(tests): add _cancel_active_tasks mock to cmd_new
test fixtures
The existing test_unified_session tests construct a SimpleNamespace
loop mock that now needs _cancel_active_tasks since cmd_new calls it.
---
tests/agent/test_unified_session.py | 2 ++
1 file changed, 2 insertions(+)
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)
From a00beebd0635243fa34c2cab673562541604ceba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BD=AD=E6=98=9F=E6=9D=B0?= <1198425718@qq.com>
Date: Tue, 21 Apr 2026 14:44:37 +0800
Subject: [PATCH 17/32] fix: use context manager in _extract_xlsx to prevent
resource leak
---
nanobot/utils/document.py | 25 ++++++++++++-------------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py
index 89ffcca96..b73d91b42 100644
--- a/nanobot/utils/document.py
+++ b/nanobot/utils/document.py
@@ -133,19 +133,18 @@ def _extract_docx(path: Path) -> str:
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)
+ with load_workbook(path, read_only=True, data_only=True) as wb:
+ 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)
except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e)
return f"[error: failed to extract XLSX: {e!s}]"
From 46864b09114f43b9737554d999fd60e983cc5c6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BD=AD=E6=98=9F=E6=9D=B0?= <1198425718@qq.com>
Date: Tue, 21 Apr 2026 16:16:27 +0800
Subject: [PATCH 18/32] fix: use try/finally in _extract_xlsx to prevent
resource leak
---
nanobot/utils/document.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py
index b73d91b42..396fe50c4 100644
--- a/nanobot/utils/document.py
+++ b/nanobot/utils/document.py
@@ -133,7 +133,8 @@ def _extract_docx(path: Path) -> str:
def _extract_xlsx(path: Path) -> str:
"""Extract text from XLSX using openpyxl."""
try:
- with load_workbook(path, read_only=True, data_only=True) as wb:
+ wb = load_workbook(path, read_only=True, data_only=True)
+ try:
sheets: list[str] = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
@@ -145,6 +146,8 @@ def _extract_xlsx(path: Path) -> str:
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}]"
From 53ba410e4994b5092c9ea1941ac1baf5846d181a Mon Sep 17 00:00:00 2001
From: aiguozhi123456 <126325311+aiguozhi123456@users.noreply.github.com>
Date: Mon, 20 Apr 2026 23:57:47 +0800
Subject: [PATCH 19/32] feat(read_file): add DOCX, XLSX, PPTX support via
document.extract_text()
Wire up the existing office document extractors in document.py to
ReadFileTool by adding an extension guard and _read_office_doc() method
that follows the established PDF pattern. Handles missing libraries,
corrupt files, empty documents, and 128K truncation consistently.
---
nanobot/agent/tools/filesystem.py | 30 ++++++-
tests/tools/test_read_enhancements.py | 123 +++++++++++++++++++++++++-
2 files changed, 149 insertions(+), 4 deletions(-)
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/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()
From 558aa984911fa395e49a7bad63d81a71de898312 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Tue, 21 Apr 2026 14:33:44 +0000
Subject: [PATCH 20/32] chore: temporary keep WebUI source-only
---
nanobot/cli/commands.py | 62 ------------------------
pyproject.toml | 7 ---
webui/README.md | 6 +--
webui/src/i18n/locales/en/common.json | 2 +-
webui/src/i18n/locales/es/common.json | 2 +-
webui/src/i18n/locales/fr/common.json | 2 +-
webui/src/i18n/locales/id/common.json | 2 +-
webui/src/i18n/locales/ja/common.json | 2 +-
webui/src/i18n/locales/ko/common.json | 2 +-
webui/src/i18n/locales/vi/common.json | 2 +-
webui/src/i18n/locales/zh-CN/common.json | 2 +-
webui/src/i18n/locales/zh-TW/common.json | 2 +-
12 files changed, 12 insertions(+), 81 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index ec572ffe9..7892df0ee 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -950,68 +950,6 @@ def _run_gateway(
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/pyproject.toml b/pyproject.toml
index 3447ffc30..e7282abf3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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/webui/README.md b/webui/README.md
index d318c7452..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
@@ -80,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",
From e5b288c6ebe227a68e458aded6898a4707138699 Mon Sep 17 00:00:00 2001
From: k
Date: Tue, 21 Apr 2026 23:09:36 +0900
Subject: [PATCH 21/32] fix: map MiniMax reasoning_effort to reasoning_split
---
nanobot/providers/openai_compat_provider.py | 2 ++
tests/providers/test_litellm_kwargs.py | 15 +++++++++++++++
2 files changed, 17 insertions(+)
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index 2ffc7c588..e5cac8765 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -398,6 +398,8 @@ class OpenAICompatProvider(LLMProvider):
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",
diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index 0d6280b7d..a12aa9fbb 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -740,6 +740,21 @@ def test_dashscope_no_extra_body_when_reasoning_effort_none() -> 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"}}
From 950dddec499fbbe0353e997158c99808f0bb41e1 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Tue, 21 Apr 2026 17:25:08 +0000
Subject: [PATCH 22/32] chore: bump version to 0.1.5.post2
---
README.md | 14 ++++++++++----
nanobot/__init__.py | 2 +-
pyproject.toml | 2 +-
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index e592f16e9..5d10ae6b5 100644
--- a/README.md
+++ b/README.md
@@ -23,20 +23,26 @@
## 📢 News
+- **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.
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/pyproject.toml b/pyproject.toml
index e7282abf3..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"
From f5b8ee9f784609336915bb3c6ad559af534f6b25 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Tue, 21 Apr 2026 17:50:54 +0000
Subject: [PATCH 23/32] docs: update v0.1.5.post2 release news
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 5d10ae6b5..90a3d2f4c 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@
## 📢 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.
From 1826ab44fa22c52c9c1117846c673115a0672144 Mon Sep 17 00:00:00 2001
From: flobo3
Date: Sun, 19 Apr 2026 20:35:03 +0300
Subject: [PATCH 24/32] feat(transcription): add language parameter for Groq
Whisper STT
---
nanobot/channels/base.py | 2 ++
nanobot/channels/manager.py | 1 +
nanobot/config/schema.py | 1 +
nanobot/providers/transcription.py | 5 ++++-
4 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index a59b31e20..778ed4d4c 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -25,6 +25,7 @@ class BaseChannel(ABC):
transcription_provider: str = "groq"
transcription_api_key: str = ""
transcription_api_base: str = ""
+ transcription_language: str = ""
def __init__(self, config: Any, bus: MessageBus):
"""
@@ -54,6 +55,7 @@ class BaseChannel(ABC):
provider = GroqTranscriptionProvider(
api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None,
+ language=self.transcription_language or None,
)
return await provider.transcribe(file_path)
except Exception as e:
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index f8f2c1d81..0f0b7430d 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -88,6 +88,7 @@ class ChannelManager:
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
channel.transcription_api_base = transcription_base
+ channel.transcription_language = getattr(self.config.channels, "transcription_language", "")
self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name)
except Exception as e:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ac48619f1..a6978ba3e 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -29,6 +29,7 @@ class ChannelsConfig(Base):
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
+ transcription_language: str = "" # Language code for Whisper STT (e.g. "en", "ru", "zh")
class DreamConfig(Base):
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index 617fd3eb1..daeedfbef 100644
--- a/nanobot/providers/transcription.py
+++ b/nanobot/providers/transcription.py
@@ -48,9 +48,10 @@ class GroqTranscriptionProvider:
Groq offers extremely fast transcription with a generous free tier.
"""
- def __init__(self, api_key: str | None = None, api_base: str | None = None):
+ def __init__(self, api_key: str | None = None, api_base: str | None = None, language: str | None = None):
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
+ self.language = language
async def transcribe(self, file_path: str | Path) -> str:
"""
@@ -78,6 +79,8 @@ class GroqTranscriptionProvider:
"file": (path.name, f),
"model": (None, "whisper-large-v3"),
}
+ if self.language:
+ files["language"] = (None, self.language)
headers = {
"Authorization": f"Bearer {self.api_key}",
}
From 123d69bfb76c3691320f22fcbd7be23d2e638a9a Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 22 Apr 2026 07:56:35 +0900
Subject: [PATCH 25/32] fix: allow specifying transcription language
---
docs/configuration.md | 4 +-
nanobot/channels/base.py | 3 +-
nanobot/channels/manager.py | 3 +-
nanobot/config/schema.py | 2 +-
nanobot/providers/transcription.py | 17 ++++-
tests/channels/test_channel_plugins.py | 87 ++++++++++++++++++++++++--
6 files changed, 104 insertions(+), 12 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index a7b3ec0f5..153cbc959 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -45,7 +45,7 @@ IMAP_PASSWORD=your-password-here
## Providers
> [!TIP]
-> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config.
+> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead, and optionally set `"transcriptionLanguage": "en"` (or another ISO-639-1 code) for more accurate transcription. The API key is picked from the matching provider config.
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
@@ -440,6 +440,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
"sendToolHints": false,
"sendMaxRetries": 3,
"transcriptionProvider": "groq",
+ "transcriptionLanguage": null,
"telegram": { ... }
}
}
@@ -451,6 +452,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
+| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
### Retry Behavior
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index 778ed4d4c..62bcd45c1 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -25,7 +25,7 @@ class BaseChannel(ABC):
transcription_provider: str = "groq"
transcription_api_key: str = ""
transcription_api_base: str = ""
- transcription_language: str = ""
+ transcription_language: str | None = None
def __init__(self, config: Any, bus: MessageBus):
"""
@@ -49,6 +49,7 @@ class BaseChannel(ABC):
provider = OpenAITranscriptionProvider(
api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None,
+ language=self.transcription_language or None,
)
else:
from nanobot.providers.transcription import GroqTranscriptionProvider
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 0f0b7430d..7110311b5 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -63,6 +63,7 @@ class ChannelManager:
transcription_provider = self.config.channels.transcription_provider
transcription_key = self._resolve_transcription_key(transcription_provider)
transcription_base = self._resolve_transcription_base(transcription_provider)
+ transcription_language = self.config.channels.transcription_language
for name, cls in discover_all().items():
section = getattr(self.config.channels, name, None)
@@ -88,7 +89,7 @@ class ChannelManager:
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
channel.transcription_api_base = transcription_base
- channel.transcription_language = getattr(self.config.channels, "transcription_language", "")
+ channel.transcription_language = transcription_language
self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name)
except Exception as e:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index a6978ba3e..0c2b1b2ac 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -29,7 +29,7 @@ class ChannelsConfig(Base):
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
- transcription_language: str = "" # Language code for Whisper STT (e.g. "en", "ru", "zh")
+ transcription_language: str | None = None # Optional ISO-639-1 hint for audio transcription
class DreamConfig(Base):
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index daeedfbef..969990166 100644
--- a/nanobot/providers/transcription.py
+++ b/nanobot/providers/transcription.py
@@ -10,13 +10,19 @@ from loguru import logger
class OpenAITranscriptionProvider:
"""Voice transcription provider using OpenAI's Whisper API."""
- def __init__(self, api_key: str | None = None, api_base: str | None = None):
+ def __init__(
+ self,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ language: str | None = None,
+ ):
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.api_url = (
api_base
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
or "https://api.openai.com/v1/audio/transcriptions"
)
+ self.language = language
async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key:
@@ -30,6 +36,8 @@ class OpenAITranscriptionProvider:
async with httpx.AsyncClient() as client:
with open(path, "rb") as f:
files = {"file": (path.name, f), "model": (None, "whisper-1")}
+ if self.language:
+ files["language"] = (None, self.language)
headers = {"Authorization": f"Bearer {self.api_key}"}
response = await client.post(
self.api_url, headers=headers, files=files, timeout=60.0,
@@ -48,7 +56,12 @@ class GroqTranscriptionProvider:
Groq offers extremely fast transcription with a generous free tier.
"""
- def __init__(self, api_key: str | None = None, api_base: str | None = None, language: str | None = None):
+ def __init__(
+ self,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ language: str | None = None,
+ ):
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
self.language = language
diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py
index a6959f937..6abe21f7a 100644
--- a/tests/channels/test_channel_plugins.py
+++ b/tests/channels/test_channel_plugins.py
@@ -15,7 +15,6 @@ from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import ChannelsConfig
from nanobot.utils.restart import RestartNotice
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -200,8 +199,8 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
fake_config = SimpleNamespace(
channels=ChannelsConfig.model_validate({
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
+ "transcriptionLanguage": "en",
}),
- transcription_provider="groq",
providers=SimpleNamespace(
groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"),
openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"),
@@ -223,6 +222,7 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
assert channel.transcription_provider == "groq"
assert channel.transcription_api_key == "groq-key"
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
+ assert channel.transcription_language == "en"
@pytest.mark.asyncio
@@ -269,13 +269,15 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
channel.transcription_provider = "openai"
channel.transcription_api_key = "k"
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
+ channel.transcription_language = "en"
captured: dict[str, object] = {}
class _StubOpenAI:
- def __init__(self, api_key=None, api_base=None):
+ def __init__(self, api_key=None, api_base=None, language=None):
captured["api_key"] = api_key
captured["api_base"] = api_base
+ captured["language"] = language
async def transcribe(self, file_path):
return "ok"
@@ -286,6 +288,7 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
assert result == "ok"
assert captured["api_key"] == "k"
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
+ assert captured["language"] == "en"
def test_openai_transcription_provider_honors_api_base_argument():
@@ -300,10 +303,80 @@ def test_openai_transcription_provider_honors_api_base_argument():
assert custom.api_url == "http://override/v1/audio/transcriptions"
+@pytest.mark.asyncio
+async def test_base_channel_passes_language_to_groq_transcription_provider():
+ """BaseChannel.transcribe_audio must forward transcription_language to Groq."""
+ from nanobot.providers import transcription as transcription_mod
+
+ channel = _FakePlugin({"enabled": True, "allowFrom": ["*"]}, MessageBus())
+ channel.transcription_provider = "groq"
+ channel.transcription_api_key = "k"
+ channel.transcription_api_base = "http://override/v1/audio/transcriptions"
+ channel.transcription_language = "ko"
+
+ captured: dict[str, object] = {}
+
+ class _StubGroq:
+ def __init__(self, api_key=None, api_base=None, language=None):
+ captured["api_key"] = api_key
+ captured["api_base"] = api_base
+ captured["language"] = language
+
+ async def transcribe(self, file_path):
+ return "ok"
+
+ with patch.object(transcription_mod, "GroqTranscriptionProvider", _StubGroq):
+ result = await channel.transcribe_audio("/tmp/does-not-matter.wav")
+
+ assert result == "ok"
+ assert captured["api_key"] == "k"
+ assert captured["api_base"] == "http://override/v1/audio/transcriptions"
+ assert captured["language"] == "ko"
+
+
+@pytest.mark.asyncio
+async def test_groq_transcription_provider_includes_language(tmp_path):
+ from nanobot.providers.transcription import GroqTranscriptionProvider
+
+ audio = tmp_path / "sample.wav"
+ audio.write_bytes(b"audio")
+ captured: dict[str, object] = {}
+
+ class _Response:
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return {"text": "hello"}
+
+ class _AsyncClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def post(self, url, headers=None, files=None, timeout=None):
+ captured["url"] = url
+ captured["headers"] = headers
+ captured["files"] = files
+ captured["timeout"] = timeout
+ return _Response()
+
+ provider = GroqTranscriptionProvider(api_key="k", language="ko")
+
+ with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_AsyncClient()):
+ result = await provider.transcribe(audio)
+
+ assert result == "hello"
+ assert captured["files"]["language"] == (None, "ko")
+
+
def test_channels_login_uses_discovered_plugin_class(monkeypatch):
+ from typer.testing import CliRunner
+
from nanobot.cli.commands import app
from nanobot.config.schema import Config
- from typer.testing import CliRunner
runner = CliRunner()
seen: dict[str, object] = {}
@@ -329,9 +402,10 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
+ from typer.testing import CliRunner
+
from nanobot.cli.commands import app
from nanobot.config.schema import Config
- from typer.testing import CliRunner
runner = CliRunner()
seen: dict[str, object] = {}
@@ -358,9 +432,10 @@ def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
+ from typer.testing import CliRunner
+
from nanobot.cli.commands import app
from nanobot.config.schema import Config
- from typer.testing import CliRunner
runner = CliRunner()
seen: dict[str, object] = {}
From f6a417e77d9b2a3f5958fb756f1e3b2a36f3d371 Mon Sep 17 00:00:00 2001
From: chengyongru
Date: Wed, 22 Apr 2026 10:47:49 +0800
Subject: [PATCH 26/32] fix(transcription): harden language parameter
validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
---
nanobot/config/schema.py | 2 +-
nanobot/providers/transcription.py | 4 +-
tests/channels/test_channel_plugins.py | 88 ++++++++++++++++++++------
3 files changed, 73 insertions(+), 21 deletions(-)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 0c2b1b2ac..cca8f210f 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -29,7 +29,7 @@ class ChannelsConfig(Base):
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
- transcription_language: str | None = None # Optional ISO-639-1 hint for audio transcription
+ transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
class DreamConfig(Base):
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index 969990166..10fcafd6d 100644
--- a/nanobot/providers/transcription.py
+++ b/nanobot/providers/transcription.py
@@ -22,7 +22,7 @@ class OpenAITranscriptionProvider:
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
or "https://api.openai.com/v1/audio/transcriptions"
)
- self.language = language
+ self.language = language or None
async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key:
@@ -64,7 +64,7 @@ class GroqTranscriptionProvider:
):
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
- self.language = language
+ self.language = language or None
async def transcribe(self, file_path: str | Path) -> str:
"""
diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py
index 6abe21f7a..10f045bf8 100644
--- a/tests/channels/test_channel_plugins.py
+++ b/tests/channels/test_channel_plugins.py
@@ -334,21 +334,24 @@ async def test_base_channel_passes_language_to_groq_transcription_provider():
assert captured["language"] == "ko"
-@pytest.mark.asyncio
-async def test_groq_transcription_provider_includes_language(tmp_path):
- from nanobot.providers.transcription import GroqTranscriptionProvider
+# ---------------------------------------------------------------------------
+# Transcription provider HTTP tests
+# ---------------------------------------------------------------------------
- audio = tmp_path / "sample.wav"
- audio.write_bytes(b"audio")
- captured: dict[str, object] = {}
+from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
+from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
- class _Response:
- def raise_for_status(self):
- return None
- def json(self):
- return {"text": "hello"}
+class _StubResponse:
+ def raise_for_status(self):
+ return None
+ def json(self):
+ return {"text": "hello"}
+
+
+def _stub_async_client(captured: dict[str, object]):
+ """Return an httpx.AsyncClient stub that records POST calls into *captured*."""
class _AsyncClient:
async def __aenter__(self):
return self
@@ -357,19 +360,50 @@ async def test_groq_transcription_provider_includes_language(tmp_path):
return False
async def post(self, url, headers=None, files=None, timeout=None):
- captured["url"] = url
- captured["headers"] = headers
captured["files"] = files
- captured["timeout"] = timeout
- return _Response()
+ return _StubResponse()
- provider = GroqTranscriptionProvider(api_key="k", language="ko")
+ return _AsyncClient()
- with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_AsyncClient()):
+
+@pytest.mark.parametrize(
+ "provider_cls,language",
+ [(_GroqProvider, "ko"), (_OpenAIProvider, "en")],
+ ids=["groq", "openai"],
+)
+@pytest.mark.asyncio
+async def test_transcription_provider_includes_language(tmp_path, provider_cls, language):
+ """Provider must include the 'language' field in multipart body when set."""
+ audio = tmp_path / "sample.wav"
+ audio.write_bytes(b"audio")
+ captured: dict[str, object] = {}
+
+ with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
+ provider = provider_cls(api_key="k", language=language)
result = await provider.transcribe(audio)
assert result == "hello"
- assert captured["files"]["language"] == (None, "ko")
+ assert captured["files"]["language"] == (None, language)
+
+
+@pytest.mark.parametrize(
+ "provider_cls",
+ [_GroqProvider, _OpenAIProvider],
+ ids=["groq", "openai"],
+)
+@pytest.mark.asyncio
+async def test_transcription_provider_omits_language_when_none(tmp_path, provider_cls):
+ """When language is not set, the 'language' key must be absent from the multipart body."""
+ audio = tmp_path / "sample.wav"
+ audio.write_bytes(b"audio")
+ captured: dict[str, object] = {}
+
+ with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
+ provider = provider_cls(api_key="k")
+ result = await provider.transcribe(audio)
+
+ assert result == "hello"
+ assert "language" not in captured["files"]
def test_channels_login_uses_discovered_plugin_class(monkeypatch):
@@ -530,6 +564,24 @@ def test_channels_config_send_max_retries_upper_bound():
ChannelsConfig(send_max_retries=11)
+def test_channels_config_transcription_language_pattern():
+ """transcription_language must match ISO-639 format (2-3 lowercase letters) or be None."""
+ from pydantic import ValidationError
+
+ # Valid values
+ assert ChannelsConfig(transcription_language="en").transcription_language == "en"
+ assert ChannelsConfig(transcription_language="kor").transcription_language == "kor"
+ assert ChannelsConfig(transcription_language=None).transcription_language is None
+
+ # Invalid values
+ with pytest.raises(ValidationError):
+ ChannelsConfig(transcription_language="EN") # uppercase
+ with pytest.raises(ValidationError):
+ ChannelsConfig(transcription_language="english") # full word
+ with pytest.raises(ValidationError):
+ ChannelsConfig(transcription_language="en-US") # BCP 47 tag
+
+
# ---------------------------------------------------------------------------
# _send_with_retry
# ---------------------------------------------------------------------------
From 28c42628b058c81e7c377ed8aceedf83f471d747 Mon Sep 17 00:00:00 2001
From: hlg
Date: Wed, 22 Apr 2026 10:24:37 +0800
Subject: [PATCH 27/32] fix: normalize DashScope reasoning_effort (minimal vs
minimum)
DashScope rejects the OpenAI-style value "minimal" with
`'reasoning_effort.effort' must be one of: 'none', 'minimum', 'low',
'medium', 'high', 'xhigh'`, but nanobot was passing the string through
verbatim. Users who tried the documented "minimal" to disable thinking
got a 400; users who tried the DashScope-native "minimum" to work
around it got `enable_thinking=True` because the internal comparison
was a hard string match on "minimal".
Introduce a semantic/wire split in `_build_kwargs`:
- `semantic_effort` is the internal canonical form (OpenAI vocabulary).
"minimum" on the way in is normalized to "minimal" here so both
spellings share one meaning.
- `wire_effort` is what we actually serialize. For DashScope with
semantic_effort == "minimal" we translate to "minimum" on the way
out; other providers are unchanged.
- `thinking_enabled` and the Kimi thinking branch now compare on
`semantic_effort`, so either user spelling correctly disables
provider-side thinking.
Tests:
- Strengthen `test_dashscope_thinking_disabled_for_minimal` to assert
the wire value is "minimum" in addition to the extra_body signal;
the original version only checked extra_body and let the
invalid-value bug slip through.
- Add `test_dashscope_thinking_disabled_for_minimum_alias` so a user
who read the DashScope docs and configured "minimum" still gets
thinking off.
- Add `test_non_dashscope_minimal_not_retranslated` to pin down that
the DashScope-specific translation does not leak to OpenAI et al.
---
nanobot/providers/openai_compat_provider.py | 31 ++++++++++++++++++---
tests/providers/test_litellm_kwargs.py | 22 +++++++++++++++
2 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index e5cac8765..457bf4a2c 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -387,14 +387,37 @@ class OpenAICompatProvider(LLMProvider):
kwargs.update(overrides)
break
- if reasoning_effort:
- kwargs["reasoning_effort"] = reasoning_effort
+ # Semantic vs. wire distinction for reasoning_effort.
+ # - semantic_effort is nanobot's internal canonical form (OpenAI's
+ # vocabulary: "minimal" / "low" / "medium" / "high"). It drives
+ # decisions like whether to disable provider thinking modes.
+ # - wire_effort is what we actually serialize to the provider; some
+ # providers (notably DashScope) reject "minimal" and require
+ # "minimum" instead. We accept either spelling on input and
+ # always compare on the semantic form so a user who configured
+ # "minimum" (DashScope's native spelling) still gets thinking
+ # disabled instead of accidentally enabled.
+ 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's reasoning_effort.effort enum accepts: none /
+ # minimum / low / medium / high / xhigh. Literal "minimal"
+ # returns 400 invalid_value; translate on the outbound side.
+ 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}
@@ -415,7 +438,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/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index a12aa9fbb..5067f094f 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -731,10 +731,32 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
def test_dashscope_thinking_disabled_for_minimal() -> None:
+ """OpenAI-style 'minimal' → DashScope wire value 'minimum' + thinking off.
+ DashScope rejects the literal string 'minimal' (invalid_value), so we
+ must translate on the outbound side while still honouring the 'no
+ thinking' intent via extra_body."""
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:
+ """Users who read DashScope docs may configure the native 'minimum'
+ spelling. Internally it's the same semantic as 'minimal' → thinking
+ must still be disabled (not enabled just because the string isn't
+ literally 'minimal')."""
+ 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:
+ """The DashScope-specific translation must not leak to other providers;
+ OpenAI / Anthropic / etc. speak 'minimal' natively."""
+ 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
From 88c619901ee72c830094c72ddeef768af6313f05 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Wed, 22 Apr 2026 04:47:40 +0000
Subject: [PATCH 28/32] review(providers): tighten comments in reasoning_effort
normalize path
Made-with: Cursor
---
nanobot/providers/openai_compat_provider.py | 17 ++++-------------
tests/providers/test_litellm_kwargs.py | 13 +++----------
2 files changed, 7 insertions(+), 23 deletions(-)
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index 457bf4a2c..4f726df21 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -387,16 +387,9 @@ class OpenAICompatProvider(LLMProvider):
kwargs.update(overrides)
break
- # Semantic vs. wire distinction for reasoning_effort.
- # - semantic_effort is nanobot's internal canonical form (OpenAI's
- # vocabulary: "minimal" / "low" / "medium" / "high"). It drives
- # decisions like whether to disable provider thinking modes.
- # - wire_effort is what we actually serialize to the provider; some
- # providers (notably DashScope) reject "minimal" and require
- # "minimum" instead. We accept either spelling on input and
- # always compare on the semantic form so a user who configured
- # "minimum" (DashScope's native spelling) still gets thinking
- # disabled instead of accidentally enabled.
+ # 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()
@@ -405,9 +398,7 @@ class OpenAICompatProvider(LLMProvider):
wire_effort = reasoning_effort
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
- # DashScope's reasoning_effort.effort enum accepts: none /
- # minimum / low / medium / high / xhigh. Literal "minimal"
- # returns 400 invalid_value; translate on the outbound side.
+ # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
wire_effort = "minimum"
if wire_effort:
diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index 5067f094f..41188c721 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -731,28 +731,21 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
def test_dashscope_thinking_disabled_for_minimal() -> None:
- """OpenAI-style 'minimal' → DashScope wire value 'minimum' + thinking off.
- DashScope rejects the literal string 'minimal' (invalid_value), so we
- must translate on the outbound side while still honouring the 'no
- thinking' intent via extra_body."""
+ """'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:
- """Users who read DashScope docs may configure the native 'minimum'
- spelling. Internally it's the same semantic as 'minimal' → thinking
- must still be disabled (not enabled just because the string isn't
- literally 'minimal')."""
+ """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:
- """The DashScope-specific translation must not leak to other providers;
- OpenAI / Anthropic / etc. speak 'minimal' natively."""
+ """DashScope-specific translation must not leak to other providers."""
kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal")
assert kw["reasoning_effort"] == "minimal"
From 2e419f9ba27e11a601a8cf80f9a47506f31f97f5 Mon Sep 17 00:00:00 2001
From: wood3n
Date: Tue, 21 Apr 2026 13:34:18 -0700
Subject: [PATCH 29/32] fix(cli): respect sys.stdout.isatty() in commands.py
---
nanobot/cli/commands.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 7892df0ee..cfa681b75 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,
)
From ef8bbab7b388e49399c093605d3019fe0a956890 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Wed, 22 Apr 2026 04:54:07 +0000
Subject: [PATCH 30/32] test(cli): lock _render_interactive_ansi force_terminal
to isatty
Made-with: Cursor
---
tests/cli/test_cli_input.py | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
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
From 512bf59b3c4bc0da199a1f25d7973e8740c0b2ac Mon Sep 17 00:00:00 2001
From: hussein1362
Date: Tue, 21 Apr 2026 23:22:43 +0300
Subject: [PATCH 31/32] fix(session): fsync sessions on graceful shutdown to
prevent data loss
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On filesystems with write-back caching (rclone VFS, NFS, FUSE mounts)
the OS page cache may buffer recent session writes. If the process is
killed before the cache flushes, the most recent conversation turns are
silently lost — causing the agent to "forget" recent context and
respond to stale history on the next startup.
Changes:
- session/manager.py: add fsync=True option to save() that flushes the
file and its parent directory to durable storage. Add flush_all() that
re-saves every cached session with fsync. Default save() behavior is
unchanged (no fsync) to avoid performance regression in normal
operation.
- cli/commands.py: call agent.sessions.flush_all() in the gateway
shutdown finally block, after stopping heartbeat/cron/channels.
- tests/session/test_session_fsync.py: 8 tests covering fsync flag
behavior, flush_all with empty/multiple/errored sessions, and
data survival across simulated process restart.
- tests/cli/test_commands.py: add sessions attribute to _FakeAgentLoop
so the gateway health endpoint test passes with the new shutdown
flush.
---
nanobot/cli/commands.py | 6 ++
nanobot/session/manager.py | 39 ++++++++-
tests/cli/test_commands.py | 5 ++
tests/session/__init__.py | 0
tests/session/test_session_fsync.py | 125 ++++++++++++++++++++++++++++
5 files changed, 173 insertions(+), 2 deletions(-)
create mode 100644 tests/session/__init__.py
create mode 100644 tests/session/test_session_fsync.py
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index cfa681b75..08e227610 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -946,6 +946,12 @@ 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())
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 4add4fd3b..392862ff7 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,41 @@ 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.
+ fd = os.open(str(path.parent), os.O_RDONLY)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
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/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/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..8e45c761c
--- /dev/null
+++ b/tests/session/test_session_fsync.py
@@ -0,0 +1,125 @@
+"""Tests for session fsync and flush_all on graceful shutdown."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from nanobot.session.manager import SessionManager
+
+
+@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)
+ # Should be called twice: once for the file, once for the directory
+ assert mock_fsync.call_count == 2
+
+ 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 + directory fsync
+ assert mock_fsync.call_count == 2
+
+ 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"
From 09321898607109aecb2534143ea58f81d714137c Mon Sep 17 00:00:00 2001
From: hussein1362
Date: Wed, 22 Apr 2026 06:47:25 +0300
Subject: [PATCH 32/32] fix: handle Windows PermissionError on directory fsync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On Windows, opening a directory with O_RDONLY raises PermissionError.
Wrap the directory fsync in a try/except PermissionError — NTFS journals
metadata synchronously so the directory sync is unnecessary there.
Also adjust test assertions to expect 1 fsync call (file only) on
Windows vs 2 (file + directory) on POSIX.
---
nanobot/session/manager.py | 14 ++++++++++----
tests/session/test_session_fsync.py | 13 +++++++++----
2 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 392862ff7..436d8225c 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -296,11 +296,17 @@ class SessionManager:
if fsync:
# fsync the directory so the rename is durable.
- fd = os.open(str(path.parent), os.O_RDONLY)
+ # On Windows, opening a directory with O_RDONLY raises
+ # PermissionError — skip the dir sync there (NTFS
+ # journals metadata synchronously).
try:
- os.fsync(fd)
- finally:
- os.close(fd)
+ 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
diff --git a/tests/session/test_session_fsync.py b/tests/session/test_session_fsync.py
index 8e45c761c..3194cf957 100644
--- a/tests/session/test_session_fsync.py
+++ b/tests/session/test_session_fsync.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import sys
from pathlib import Path
from unittest.mock import patch
@@ -9,6 +10,8 @@ import pytest
from nanobot.session.manager import SessionManager
+_IS_WINDOWS = sys.platform == "win32"
+
@pytest.fixture
def sessions_dir(tmp_path: Path) -> Path:
@@ -39,8 +42,9 @@ class TestSaveFsync:
with patch("os.fsync") as mock_fsync:
manager.save(session, fsync=True)
- # Should be called twice: once for the file, once for the directory
- assert mock_fsync.call_count == 2
+ # 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)."""
@@ -77,8 +81,9 @@ class TestFlushAll:
with patch("os.fsync") as mock_fsync:
manager.flush_all()
- # file fsync + directory fsync
- assert mock_fsync.call_count == 2
+ # 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."""