From b94bc18e59b7af2bfcf00bf255ef726c56cf746a Mon Sep 17 00:00:00 2001 From: masterlyj <167326996+masterlyj@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:59:21 +0800 Subject: [PATCH 01/19] fix: treat reasoning_effort="none" as thinking disabled and route gemma to Gemini provider - Do not send reasoning_effort="none" to APIs (prevents 400 on gemma/Gemini) - Treat "none" as thinking disabled in thinking_style, Kimi, and reasoning_content backfill paths - Fix Anthropic extended thinking not respecting "none" - Fix Azure OpenAI temperature suppression and reasoning body for "none" - Fix Codex reasoning body for "none" - Add "gemma" keyword to Gemini ProviderSpec for correct auto routing --- nanobot/providers/anthropic_provider.py | 2 +- nanobot/providers/azure_openai_provider.py | 4 ++-- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/openai_compat_provider.py | 10 +++++----- nanobot/providers/registry.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 5d6d36b79..a46cc32e9 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -434,7 +434,7 @@ class AnthropicProvider(LLMProvider): ) max_tokens = max(1, max_tokens) - thinking_enabled = bool(reasoning_effort) + thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none" # claude-opus-4-7 deprecated the `temperature` parameter entirely — the # API returns 400 if it is present, on any code path. diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 9fd18e1f9..bc2a9d045 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -71,7 +71,7 @@ class AzureOpenAIProvider(LLMProvider): reasoning_effort: str | None = None, ) -> bool: """Return True when temperature is likely supported for this deployment.""" - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": return False name = deployment_name.lower() return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) @@ -102,7 +102,7 @@ class AzureOpenAIProvider(LLMProvider): if self._supports_temperature(deployment, reasoning_effort): body["temperature"] = temperature - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": body["reasoning"] = {"effort": reasoning_effort} body["include"] = ["reasoning.encrypted_content"] diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 0cc8e5ca2..945cae9ba 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -60,7 +60,7 @@ class OpenAICodexProvider(LLMProvider): "tool_choice": tool_choice or "auto", "parallel_tool_calls": True, } - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": body["reasoning"] = {"effort": reasoning_effort} if tools: body["tools"] = convert_tools(tools) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 363a2e142..6712b68bc 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -570,7 +570,7 @@ class OpenAICompatProvider(LLMProvider): # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s. wire_effort = "minimum" - if wire_effort: + if wire_effort and semantic_effort != "none": kwargs["reasoning_effort"] = wire_effort # Provider-specific thinking parameters. @@ -579,7 +579,7 @@ class OpenAICompatProvider(LLMProvider): # The mapping is driven by ProviderSpec.thinking_style so that adding # a new provider never requires touching this function. if spec and spec.thinking_style and reasoning_effort is not None: - thinking_enabled = semantic_effort != "minimal" + thinking_enabled = semantic_effort not in ("none", "minimal") extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled) if extra: kwargs.setdefault("extra_body", {}).update(extra) @@ -589,7 +589,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 = semantic_effort != "minimal" + thinking_enabled = semantic_effort not in ("none", "minimal") kwargs.setdefault("extra_body", {}).update( {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} ) @@ -609,9 +609,9 @@ class OpenAICompatProvider(LLMProvider): # thinking happened on that turn"). thinking_active = ( (spec and spec.thinking_style and reasoning_effort is not None - and semantic_effort != "minimal") + and semantic_effort not in ("none", "minimal")) or (reasoning_effort is not None and _is_kimi_thinking_model(model_name) - and semantic_effort != "minimal") + and semantic_effort not in ("none", "minimal")) ) if thinking_active: for msg in kwargs["messages"]: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 807742077..6f947bab0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -267,7 +267,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # Gemini: Google's OpenAI-compatible endpoint ProviderSpec( name="gemini", - keywords=("gemini",), + keywords=("gemini", "gemma"), env_key="GEMINI_API_KEY", display_name="Gemini", backend="openai_compat", From 2b9b41f9c3d6a959e716bdd3e67b5c620a87ee17 Mon Sep 17 00:00:00 2001 From: masterlyj <167326996+masterlyj@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:37:34 +0800 Subject: [PATCH 02/19] test(providers): cover reasoning_effort="none" and gemma auto-routing - Anthropic: "none" must not enable extended thinking - Azure: "none" must not suppress temperature or inject reasoning body - DeepSeek/DashScope/Kimi: "none" sends thinking disabled, skips reasoning_effort field - Gemini: gemma keyword enables auto-routing for gemma models --- tests/providers/test_anthropic_thinking.py | 7 +++ tests/providers/test_azure_openai_provider.py | 15 ++++++ tests/providers/test_litellm_kwargs.py | 51 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/tests/providers/test_anthropic_thinking.py b/tests/providers/test_anthropic_thinking.py index d0f72b32c..9fb22e2e5 100644 --- a/tests/providers/test_anthropic_thinking.py +++ b/tests/providers/test_anthropic_thinking.py @@ -83,3 +83,10 @@ def test_opus_4_7_omits_temperature_none() -> None: kw = _build(_make_provider("claude-opus-4-7"), None) assert "temperature" not in kw assert "thinking" not in kw + + +def test_reasoning_effort_string_none_does_not_enable_thinking() -> None: + """reasoning_effort='none' must not enable thinking — treated same as disabled.""" + kw = _build(_make_provider(), "none") + assert "thinking" not in kw + assert kw["temperature"] == 0.7 diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 89cea64f0..7ce74ee9f 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -78,6 +78,11 @@ def test_supports_temperature_with_reasoning_effort(): assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False +def test_supports_temperature_with_reasoning_effort_none_string(): + """reasoning_effort='none' must NOT suppress temperature — it means thinking is off.""" + assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="none") is True + + # --------------------------------------------------------------------------- # _build_body — Responses API body construction # --------------------------------------------------------------------------- @@ -131,6 +136,16 @@ def test_build_body_with_reasoning(): assert "temperature" not in body +def test_build_body_reasoning_effort_none_string_omits_reasoning(): + """reasoning_effort='none' must not inject a reasoning body and must allow temperature.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") + body = provider._build_body( + [{"role": "user", "content": "hi"}], None, "gpt-4o", 4096, 0.7, "none", None, + ) + assert "reasoning" not in body + assert body["temperature"] == 0.7 + + def test_build_body_image_conversion(): """image_url content blocks should be converted to input_image.""" provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index e91d1caef..d3b03c60d 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -121,6 +121,14 @@ def test_openrouter_spec_is_gateway() -> None: assert spec.default_api_base == "https://openrouter.ai/api/v1" +def test_gemma_routes_to_gemini_provider() -> None: + """gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set. + Users running gemma via the Gemini API endpoint expect automatic provider detection.""" + spec = find_by_name("gemini") + assert spec is not None + assert "gemma" in spec.keywords + + def test_openrouter_sets_default_attribution_headers() -> None: spec = find_by_name("openrouter") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: @@ -1050,3 +1058,46 @@ def test_kimi_k2_thinking_series_no_thinking_injection() -> None: """kimi-k2-thinking series models must NOT receive extra_body.thinking.""" kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high") assert "extra_body" not in kw + + +# --------------------------------------------------------------------------- +# reasoning_effort="none" — treated as thinking disabled +# --------------------------------------------------------------------------- + +def test_deepseek_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' must send thinking.type=disabled and skip reasoning_effort field.""" + kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="none") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + assert "reasoning_effort" not in kw + + +def test_kimi_k25_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' maps to thinking disabled for kimi-k2.5.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + + +def test_dashscope_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' disables thinking and must not emit reasoning_effort on DashScope.""" + kw = _build_kwargs_for("dashscope", "qwen3.6-plus", reasoning_effort="none") + assert kw.get("extra_body") == {"enable_thinking": False} + assert "reasoning_effort" not in kw + + +def test_deepseek_no_backfill_when_reasoning_effort_none_string() -> None: + """reasoning_effort='none' must NOT trigger reasoning_content backfill (thinking inactive).""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "continue"}, + ] + kw = p._build_kwargs( + messages=list(messages), tools=None, model="deepseek-v4-pro", + max_tokens=1024, temperature=0.7, + reasoning_effort="none", tool_choice=None, + ) + assistant = kw["messages"][1] + assert "reasoning_content" not in assistant From 95715f521179b8fab391359a658aa49eed4dab5a Mon Sep 17 00:00:00 2001 From: Jiajun Xie Date: Tue, 28 Apr 2026 22:32:17 +0800 Subject: [PATCH 03/19] fix: sanitize Matrix user_id for Windows-safe store file names - Replace ':' with '_' in store_name to avoid WinError 123 - Pass sanitized store_name via AsyncClientConfig - Fixes issue #3506 where Matrix channel fails on Windows due to colon in user_id causing invalid file paths in matrix-nio's DefaultStore --- nanobot/channels/matrix.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 85a167a3a..8ce08ae61 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -262,10 +262,18 @@ class MatrixChannel(BaseChannel): self.store_path.mkdir(parents=True, exist_ok=True) self.session_path = self.store_path / "session.json" + # Replace ':' with '_' to produce a Windows-safe filename + safe_store_name = self.config.user_id.replace(":", "_") + f"_{self.config.device_id}.db" + self.client = AsyncClient( - homeserver=self.config.homeserver, user=self.config.user_id, + homeserver=self.config.homeserver, + user=self.config.user_id, store_path=self.store_path, - config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled), + config=AsyncClientConfig( + store_sync_tokens=True, + encryption_enabled=self.config.e2ee_enabled, + store_name=safe_store_name, + ), ) self._register_event_callbacks() From 67b4d113c96953a78931f503a4bcf269cf4f2263 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 29 Apr 2026 08:25:09 +0000 Subject: [PATCH 04/19] chore: update pyproject.toml --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ac2c775d..095c04a6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" license = {text = "MIT"} authors = [ - {name = "nanobot contributors"} + {name = "Xubin Ren"}, + {name = "the nanobot contributors"} ] keywords = ["ai", "agent", "chatbot"] classifiers = [ From 0b111a0e0cbf742ea5f0b9788eff4469c45c7843 Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Mon, 27 Apr 2026 22:44:10 +0800 Subject: [PATCH 05/19] fix(channels): support per-channel progress controls --- docs/configuration.md | 20 ++++ nanobot/channels/manager.py | 48 ++++++++- .../test_channel_manager_delta_coalescing.py | 98 ++++++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 427b64d4c..f295b50c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -474,6 +474,26 @@ Global settings that apply to all channels. Configure under the `channels` secti | `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"`. | +`sendProgress` and `sendToolHints` can also be overridden per channel. The +global values stay as defaults for channels that do not set their own value: + +```json +{ + "channels": { + "sendProgress": true, + "sendToolHints": false, + "telegram": { + "enabled": true, + "sendProgress": false + }, + "websocket": { + "enabled": true, + "sendToolHints": true + } + } +} +``` + ### Retry Behavior Retry is intentionally simple. diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index ccac6306b..81d13293c 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -27,6 +27,24 @@ def _default_webui_dist() -> Path | None: candidate = Path(web_pkg.__file__).resolve().parent / "dist" return candidate if candidate.is_dir() else None + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _coerce_optional_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + return None + + # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) @@ -131,6 +149,28 @@ class ChannelManager: f'Set ["*"] to allow everyone, or add specific user IDs.' ) + def _should_send_progress(self, channel_name: str, *, tool_hint: bool = False) -> bool: + """Resolve progress visibility, allowing per-channel overrides.""" + key = "send_tool_hints" if tool_hint else "send_progress" + default = getattr(self.config.channels, key) + override = self._channel_bool_override(channel_name, key) + return default if override is None else override + + def _channel_bool_override(self, channel_name: str, key: str) -> bool | None: + section = getattr(self.config.channels, channel_name, None) + if section is None: + return None + + camel_key = _snake_to_camel(key) + if isinstance(section, dict): + value = section.get(key, section.get(camel_key)) + return _coerce_optional_bool(value) + + value = getattr(section, key, None) + if value is None: + value = getattr(section, camel_key, None) + return _coerce_optional_bool(value) + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -216,9 +256,13 @@ class ChannelManager: ) if msg.metadata.get("_progress"): - if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + if msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=True, + ): continue - if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + if not msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=False, + ): continue if msg.metadata.get("_retry_wait"): diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 3c150f903..7956c96f0 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -1,6 +1,6 @@ """Tests for ChannelManager delta coalescing to reduce streaming latency.""" import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest @@ -8,7 +8,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager -from nanobot.config.schema import Config +from nanobot.config.schema import ChannelsConfig, Config class MockChannel(BaseChannel): @@ -298,6 +298,100 @@ class TestDispatchOutboundWithCoalescing: assert pending[0].content == "Final" +class TestProgressFiltering: + """Progress filtering should honor per-channel config overrides.""" + + def test_progress_visibility_uses_global_defaults(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is True + assert manager._should_send_progress("mock", tool_hint=True) is False + + def test_progress_visibility_uses_channel_overrides(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + "mock": { + "sendProgress": False, + "sendToolHints": True, + }, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is False + assert manager._should_send_progress("mock", tool_hint=True) is True + assert manager._should_send_progress("other", tool_hint=False) is True + assert manager._should_send_progress("other", tool_hint=True) is False + + @pytest.mark.asyncio + async def test_channel_override_can_drop_progress_message(self, manager, bus): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "mock": {"sendProgress": False}, + }) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="thinking", + metadata={"_progress": True}, + )) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="final answer", + metadata={}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "final answer" + + @pytest.mark.asyncio + async def test_channel_override_can_enable_tool_hints(self, manager, bus): + manager.config.channels = ChannelsConfig.model_validate({ + "sendToolHints": False, + "mock": {"sendToolHints": True}, + }) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="read_file(foo.py)", + metadata={"_progress": True, "_tool_hint": True}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "read_file(foo.py)" + + class TestRetryWaitFiltering: """Internal provider retry heartbeats must never reach channels.""" From a0443e8f9e90b586448cac69df85f270884a511e Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Tue, 28 Apr 2026 21:11:40 +0800 Subject: [PATCH 06/19] fix(channels): address progress override review --- nanobot/channels/manager.py | 18 +++---------- .../test_channel_manager_delta_coalescing.py | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 81d13293c..e2fc0dafc 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from loguru import logger +from pydantic.alias_generators import to_camel from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -28,21 +29,8 @@ def _default_webui_dist() -> Path | None: return candidate if candidate.is_dir() else None -def _snake_to_camel(value: str) -> str: - head, *tail = value.split("_") - return head + "".join(part.capitalize() for part in tail) - - def _coerce_optional_bool(value: Any) -> bool | None: - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"true", "1", "yes", "on"}: - return True - if normalized in {"false", "0", "no", "off"}: - return False - return None + return value if isinstance(value, bool) else None # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) @@ -161,7 +149,7 @@ class ChannelManager: if section is None: return None - camel_key = _snake_to_camel(key) + camel_key = to_camel(key) if isinstance(section, dict): value = section.get(key, section.get(camel_key)) return _coerce_optional_bool(value) diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 7956c96f0..ea4b68334 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -325,6 +325,32 @@ class TestProgressFiltering: assert manager._should_send_progress("other", tool_hint=False) is True assert manager._should_send_progress("other", tool_hint=True) is False + def test_progress_visibility_uses_snake_case_channel_overrides(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + "mock": { + "send_progress": False, + "send_tool_hints": True, + }, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is False + assert manager._should_send_progress("mock", tool_hint=True) is True + + def test_progress_visibility_ignores_non_bool_channel_overrides(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + "mock": { + "sendProgress": "false", + "sendToolHints": "true", + }, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is True + assert manager._should_send_progress("mock", tool_hint=True) is False + @pytest.mark.asyncio async def test_channel_override_can_drop_progress_message(self, manager, bus): manager.config.channels = ChannelsConfig.model_validate({ From 74270bb8a81d827c40dcc86bd9534e5fcae4c8b1 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 29 Apr 2026 13:46:35 +0800 Subject: [PATCH 07/19] refactor(channels): resolve progress overrides at init-time like transcription --- nanobot/channels/base.py | 2 + nanobot/channels/manager.py | 51 +++++++------ .../test_channel_manager_delta_coalescing.py | 73 ++++++------------- 3 files changed, 56 insertions(+), 70 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 62bcd45c1..6097b420f 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -26,6 +26,8 @@ class BaseChannel(ABC): transcription_api_key: str = "" transcription_api_base: str = "" transcription_language: str | None = None + send_progress: bool = True + send_tool_hints: bool = False def __init__(self, config: Any, bus: MessageBus): """ diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e2fc0dafc..14a6b2a5e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from loguru import logger -from pydantic.alias_generators import to_camel from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -29,13 +28,14 @@ def _default_webui_dist() -> Path | None: return candidate if candidate.is_dir() else None -def _coerce_optional_bool(value: Any) -> bool | None: - return value if isinstance(value, bool) else None - - # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) +_BOOL_CAMEL_ALIASES: dict[str, str] = { + "send_progress": "sendProgress", + "send_tool_hints": "sendToolHints", +} + class ChannelManager: """ @@ -96,6 +96,12 @@ class ChannelManager: channel.transcription_api_key = transcription_key channel.transcription_api_base = transcription_base channel.transcription_language = transcription_language + channel.send_progress = self._resolve_bool_override( + section, "send_progress", self.config.channels.send_progress, + ) + channel.send_tool_hints = self._resolve_bool_override( + section, "send_tool_hints", self.config.channels.send_tool_hints, + ) self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -138,26 +144,29 @@ class ChannelManager: ) def _should_send_progress(self, channel_name: str, *, tool_hint: bool = False) -> bool: - """Resolve progress visibility, allowing per-channel overrides.""" - key = "send_tool_hints" if tool_hint else "send_progress" - default = getattr(self.config.channels, key) - override = self._channel_bool_override(channel_name, key) - return default if override is None else override + """Return whether progress (or tool-hints) may be sent to *channel_name*.""" + ch = self.channels.get(channel_name) + if ch is None: + logger.warning("Progress check for unknown channel: {}", channel_name) + return False + return ch.send_tool_hints if tool_hint else ch.send_progress - def _channel_bool_override(self, channel_name: str, key: str) -> bool | None: - section = getattr(self.config.channels, channel_name, None) - if section is None: - return None + def _resolve_bool_override(self, section: Any, key: str, default: bool) -> bool: + """Return *key* from *section* if it is a bool, otherwise *default*. - camel_key = to_camel(key) + For dict configs also checks the camelCase alias (e.g. ``sendProgress`` + for ``send_progress``) so raw JSON/TOML configs work alongside + Pydantic models. + """ if isinstance(section, dict): - value = section.get(key, section.get(camel_key)) - return _coerce_optional_bool(value) - + value = section.get(key) + if value is None: + camel = _BOOL_CAMEL_ALIASES.get(key) + if camel: + value = section.get(camel) + return value if isinstance(value, bool) else default value = getattr(section, key, None) - if value is None: - value = getattr(section, camel_key, None) - return _coerce_optional_bool(value) + return value if isinstance(value, bool) else default async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index ea4b68334..adec72e75 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -8,7 +8,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager -from nanobot.config.schema import ChannelsConfig, Config +from nanobot.config.schema import Config class MockChannel(BaseChannel): @@ -299,64 +299,42 @@ class TestDispatchOutboundWithCoalescing: class TestProgressFiltering: - """Progress filtering should honor per-channel config overrides.""" + """Progress filtering should honor per-channel settings.""" def test_progress_visibility_uses_global_defaults(self, manager): - manager.config.channels = ChannelsConfig.model_validate({ - "sendProgress": True, - "sendToolHints": False, - }) - assert manager._should_send_progress("mock", tool_hint=False) is True assert manager._should_send_progress("mock", tool_hint=True) is False def test_progress_visibility_uses_channel_overrides(self, manager): - manager.config.channels = ChannelsConfig.model_validate({ - "sendProgress": True, - "sendToolHints": False, - "mock": { - "sendProgress": False, - "sendToolHints": True, - }, - }) - - assert manager._should_send_progress("mock", tool_hint=False) is False - assert manager._should_send_progress("mock", tool_hint=True) is True - assert manager._should_send_progress("other", tool_hint=False) is True - assert manager._should_send_progress("other", tool_hint=True) is False - - def test_progress_visibility_uses_snake_case_channel_overrides(self, manager): - manager.config.channels = ChannelsConfig.model_validate({ - "sendProgress": True, - "sendToolHints": False, - "mock": { - "send_progress": False, - "send_tool_hints": True, - }, - }) + manager.channels["mock"].send_progress = False + manager.channels["mock"].send_tool_hints = True assert manager._should_send_progress("mock", tool_hint=False) is False assert manager._should_send_progress("mock", tool_hint=True) is True - def test_progress_visibility_ignores_non_bool_channel_overrides(self, manager): - manager.config.channels = ChannelsConfig.model_validate({ - "sendProgress": True, - "sendToolHints": False, - "mock": { - "sendProgress": "false", - "sendToolHints": "true", - }, - }) + def test_progress_visibility_returns_false_for_missing_channel(self, manager): + assert manager._should_send_progress("nonexistent", tool_hint=False) is False + assert manager._should_send_progress("nonexistent", tool_hint=True) is False - assert manager._should_send_progress("mock", tool_hint=False) is True - assert manager._should_send_progress("mock", tool_hint=True) is False + def test_resolve_bool_override_dict(self, manager): + assert manager._resolve_bool_override({}, "send_progress", True) is True + assert manager._resolve_bool_override({"send_progress": False}, "send_progress", True) is False + assert manager._resolve_bool_override({"sendProgress": False}, "send_progress", True) is False + assert manager._resolve_bool_override({"send_progress": "false"}, "send_progress", True) is True + + def test_resolve_bool_override_model(self, manager): + class FakeSection: + send_progress = False + send_tool_hints = True + + assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False + assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True + # Missing attribute falls back to default + assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True @pytest.mark.asyncio async def test_channel_override_can_drop_progress_message(self, manager, bus): - manager.config.channels = ChannelsConfig.model_validate({ - "sendProgress": True, - "mock": {"sendProgress": False}, - }) + manager.channels["mock"].send_progress = False await bus.publish_outbound(OutboundMessage( channel="mock", chat_id="chat1", @@ -389,10 +367,7 @@ class TestProgressFiltering: @pytest.mark.asyncio async def test_channel_override_can_enable_tool_hints(self, manager, bus): - manager.config.channels = ChannelsConfig.model_validate({ - "sendToolHints": False, - "mock": {"sendToolHints": True}, - }) + manager.channels["mock"].send_tool_hints = True await bus.publish_outbound(OutboundMessage( channel="mock", chat_id="chat1", From 2af45945e23803649ddf084c5dd3ddb6cd871946 Mon Sep 17 00:00:00 2001 From: yorkhellen Date: Tue, 28 Apr 2026 19:31:13 +0800 Subject: [PATCH 08/19] fix(memory): ensure atomic write for history.jsonl Use temp file + os.replace + fsync to prevent partial writes on crash. Add tests for atomic write behavior and tmp file cleanup on exception. --- nanobot/agent/memory.py | 17 ++++++++--- tests/agent/test_memory_store.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index f11bd6af5..3b34f68d7 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import json +import os import re import weakref import tiktoken @@ -359,10 +360,18 @@ class MemoryStore: return None def _write_entries(self, entries: list[dict[str, Any]]) -> None: - """Overwrite history.jsonl with the given entries.""" - with open(self.history_file, "w", encoding="utf-8") as f: - for entry in entries: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + """Overwrite history.jsonl with the given entries (atomic write).""" + tmp_path = self.history_file.with_suffix(self.history_file.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.history_file) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise # -- dream cursor -------------------------------------------------------- diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index 8f3220450..a66274c28 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -141,6 +141,54 @@ class TestHistoryWithCursor: assert len(entries) == 2 assert entries[0]["cursor"] in {4, 5} + def test_write_entries_uses_atomic_write(self, tmp_path): + """_write_entries uses temp file + os.replace for atomicity.""" + store = MemoryStore(tmp_path) + store.append_history("event 1") + store.append_history("event 2") + store.append_history("event 3") + entries = store.read_unprocessed_history(since_cursor=0) + + # Monitor temp file existence + tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") + assert not tmp_path_obj.exists() # Should not exist initially + + # Call _write_entries + store._write_entries(entries) + + # Temp file should be cleaned up + assert not tmp_path_obj.exists() + # Original file should exist + assert store.history_file.exists() + + def test_write_entries_cleans_up_tmp_on_exception(self, tmp_path, monkeypatch): + """Exception during _write_entries cleans up the temp file.""" + store = MemoryStore(tmp_path) + store.append_history("event 1") + entries = store.read_unprocessed_history(since_cursor=0) + + tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") + + # Mock os.replace to raise an exception + original_replace = __import__('os').replace + + def failing_replace(*args, **kwargs): + raise RuntimeError("Simulated failure") + + monkeypatch.setattr('os.replace', failing_replace) + + try: + store._write_entries(entries) + assert False, "Should have raised" + except RuntimeError: + pass + + # Temp file should be cleaned up + assert not tmp_path_obj.exists() + + # Original file should still exist (because replace failed) + assert store.history_file.exists() + class TestAppendHistoryHardCap: """append_history has a defensive cap that catches new callers who forgot From 53ca2836e7bf52f35c387f68b9fa43897249d534 Mon Sep 17 00:00:00 2001 From: yorkhellen Date: Tue, 28 Apr 2026 19:57:15 +0800 Subject: [PATCH 09/19] fix(memory): also fsync directory for rename durability --- nanobot/agent/memory.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 3b34f68d7..4cf687d4a 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -369,6 +369,19 @@ class MemoryStore: f.flush() os.fsync(f.fileno()) os.replace(tmp_path, self.history_file) + + # fsync the directory so the rename is durable. + # On Windows, opening a directory with O_RDONLY raises + # PermissionError — skip the dir sync there (NTFS + # journals metadata synchronously). + try: + fd = os.open(str(self.history_file.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 From 3d7099b4218902f8e4fd974fb18d7bd85452cf2b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 29 Apr 2026 08:54:51 +0000 Subject: [PATCH 10/19] fix(memory): clean atomic write test hygiene Made-with: Cursor --- nanobot/agent/memory.py | 2 +- tests/agent/test_memory_store.py | 25 ++++++++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 4cf687d4a..4bf79c356 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -369,7 +369,7 @@ class MemoryStore: f.flush() os.fsync(f.fileno()) os.replace(tmp_path, self.history_file) - + # fsync the directory so the rename is durable. # On Windows, opening a directory with O_RDONLY raises # PermissionError — skip the dir sync there (NTFS diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index a66274c28..9113437fd 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -148,14 +148,14 @@ class TestHistoryWithCursor: store.append_history("event 2") store.append_history("event 3") entries = store.read_unprocessed_history(since_cursor=0) - + # Monitor temp file existence tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") assert not tmp_path_obj.exists() # Should not exist initially - + # Call _write_entries store._write_entries(entries) - + # Temp file should be cleaned up assert not tmp_path_obj.exists() # Original file should exist @@ -166,26 +166,21 @@ class TestHistoryWithCursor: store = MemoryStore(tmp_path) store.append_history("event 1") entries = store.read_unprocessed_history(since_cursor=0) - + tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") - + # Mock os.replace to raise an exception - original_replace = __import__('os').replace - def failing_replace(*args, **kwargs): raise RuntimeError("Simulated failure") - + monkeypatch.setattr('os.replace', failing_replace) - - try: + + with pytest.raises(RuntimeError): store._write_entries(entries) - assert False, "Should have raised" - except RuntimeError: - pass - + # Temp file should be cleaned up assert not tmp_path_obj.exists() - + # Original file should still exist (because replace failed) assert store.history_file.exists() From 0b1631f33d8040802aa09d66a01bc731e3cb85a2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 29 Apr 2026 10:50:57 +0000 Subject: [PATCH 11/19] chore: bump version to 0.1.5.post3 and update README news MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml + __init__.py: 0.1.5.post2 → 0.1.5.post3 - README: add daily news entries for 2026-04-22 through 2026-04-28 Made-with: Cursor --- README.md | 7 +++++++ nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c9d89e88f..21853955f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ ## 📢 News +- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions. +- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack / Telegram threads. +- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior. +- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup. +- **2026-04-24** 🎥 Video attachments for Telegram / WebSocket / WebUI, DeepSeek thinking control, faster document startup. +- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates. +- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads. - **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. diff --git a/nanobot/__init__.py b/nanobot/__init__.py index e2428d2d7..e6fdbf0ba 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.post2" + return _read_pyproject_version() or "0.1.5.post3" __version__ = _resolve_version() diff --git a/pyproject.toml b/pyproject.toml index 095c04a6d..36185f39f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.5.post2" +version = "0.1.5.post3" description = "A lightweight personal AI assistant framework" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" From 69bcf26ef4a3d9d50b40a07767a1ff9f6fe69ade Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 29 Apr 2026 10:59:19 +0000 Subject: [PATCH 12/19] chore: update README with news for v0.1.5.post3 release --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 21853955f..dad8af7e0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## 📢 News +- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details. - **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions. - **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack / Telegram threads. - **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior. From d23bcae5a356ed1999ecff015f6952b4792d9f6d Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 29 Apr 2026 11:12:50 +0000 Subject: [PATCH 13/19] chore: update README with news for v0.1.5.post4 release --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dad8af7e0..99c5ea2c4 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,10 @@ - **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details. - **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions. -- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack / Telegram threads. +- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads. - **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior. - **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup. -- **2026-04-24** 🎥 Video attachments for Telegram / WebSocket / WebUI, DeepSeek thinking control, faster document startup. +- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup. - **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates. - **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads. - **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. From 71eff09653e556131ee528edd083dd6623209120 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 30 Apr 2026 04:18:31 +0000 Subject: [PATCH 14/19] fix(whatsapp): refresh bridge when source changes --- bridge/src/whatsapp.ts | 13 ++++++----- nanobot/channels/whatsapp.py | 37 +++++++++++++++++++++++++------ nanobot/cli/commands.py | 43 +++++++++++++++++++++++++++--------- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index a98f3a882..55d3a85b6 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom'; import qrcode from 'qrcode-terminal'; import pino from 'pino'; import { readFile, writeFile, mkdir } from 'fs/promises'; -import { join, basename } from 'path'; +import { join, basename, resolve, sep } from 'path'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -196,17 +196,18 @@ export class WhatsAppClient { let outFilename: string; if (fileName) { - // Documents have a filename — use it with a unique prefix to avoid collisions - const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`; - outFilename = prefix + fileName; + const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_'); + outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`; } else { const mime = mimetype || 'application/octet-stream'; - // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf") const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin'); outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`; } - const filepath = join(mediaDir, outFilename); + const filepath = resolve(mediaDir, outFilename); + if (!filepath.startsWith(resolve(mediaDir) + sep)) { + throw new Error(`Path traversal blocked: ${outFilename}`); + } await writeFile(filepath, buffer); return filepath; diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index a7fd82654..e2485da72 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -1,6 +1,7 @@ """WhatsApp channel implementation using Node.js bridge.""" import asyncio +import hashlib import json import mimetypes import os @@ -316,13 +317,7 @@ def _ensure_bridge_setup() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - npm_path = shutil.which("npm") - if not npm_path: - raise RuntimeError("npm not found. Please install Node.js >= 18.") + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge current_file = Path(__file__) @@ -341,6 +336,33 @@ def _ensure_bridge_setup() -> Path: "Try reinstalling: pip install --force-reinstall nanobot" ) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + logger.info("WhatsApp bridge source changed; rebuilding bridge...") + + npm_path = shutil.which("npm") + if not npm_path: + raise RuntimeError("npm not found. Please install Node.js >= 18.") + logger.info("Setting up WhatsApp bridge...") user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): @@ -352,6 +374,7 @@ def _ensure_bridge_setup() -> Path: logger.info(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") logger.info("Bridge ready") return user_bridge diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2fe397469..903555b47 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1271,6 +1271,7 @@ def channels_status( def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" + import hashlib import shutil import subprocess @@ -1278,16 +1279,7 @@ def _get_bridge_dir() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - # Check if already built - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - # Check for npm - npm_path = shutil.which("npm") - if not npm_path: - console.print("[red]npm not found. Please install Node.js >= 18.[/red]") - raise typer.Exit(1) + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) @@ -1304,6 +1296,36 @@ def _get_bridge_dir() -> Path: console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + # Reuse only a bridge built from the currently installed source. + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + console.print(f"{__logo__} WhatsApp bridge source changed; rebuilding bridge...") + + # Check for npm + npm_path = shutil.which("npm") + if not npm_path: + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + console.print(f"{__logo__} Setting up bridge...") # Copy to user directory @@ -1319,6 +1341,7 @@ def _get_bridge_dir() -> Path: console.print(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") console.print("[green]✓[/green] Bridge ready\n") except subprocess.CalledProcessError as e: From 651b6b933f2db26713b5668d0c103d1b022e858c Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Thu, 30 Apr 2026 08:09:26 +0800 Subject: [PATCH 15/19] fix(feishu): streaming card and tool hint respect reply_to_message in groups --- nanobot/channels/feishu.py | 37 +++++++----- tests/channels/test_feishu_reply.py | 87 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0071ef418..9f6859beb 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1135,6 +1135,25 @@ class FeishuChannel(BaseChannel): logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) return None + def _thread_reply_target(self, meta: dict) -> str | None: + """Pick the inbound message_id to reply to with reply_in_thread=True. + + Returns the message_id when the response must travel through the + Reply API (continuation of an existing topic, or user opted in via + ``reply_to_message=True``); returns None to signal a plain send. + Used by streaming-card / fallback / tool-hint paths so they honor + the same gating as the regular send() path and don't unilaterally + spawn new topics in groups when ``reply_to_message`` is off. + """ + if meta.get("chat_type", "group") != "group": + return None + msg_id = meta.get("message_id") + if not msg_id: + return None + if meta.get("thread_id") or self.config.reply_to_message: + return msg_id + return None + def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: """Reply to an existing Feishu message using the Reply API (synchronous). @@ -1409,11 +1428,7 @@ class FeishuChannel(BaseChannel): {"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False, ) - # Fallback: reply via the Reply API for group chats. - # Target message_id — the Feishu API keeps the reply in - # the same topic automatically. - _f_msg = meta.get("message_id") - fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None + fallback_msg_id = self._thread_reply_target(meta) if fallback_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( @@ -1438,12 +1453,7 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - # Send the streaming card as a reply for group chats so it - # lands inside the originating topic/thread. Always target - # message_id (the actual inbound message) — the Feishu Reply - # API keeps the response in the same topic automatically. - is_group = meta.get("chat_type", "group") == "group" - reply_msg_id = meta.get("message_id") if is_group else None + reply_msg_id = self._thread_reply_target(meta) card_id = await loop.run_in_executor( None, self._create_streaming_card_sync, @@ -1498,9 +1508,8 @@ class FeishuChannel(BaseChannel): ]}, ensure_ascii=False, ) - _th_msg_id = msg.metadata.get("message_id") - _th_chat_type = msg.metadata.get("chat_type", "group") - if _th_msg_id and _th_chat_type == "group": + _th_msg_id = self._thread_reply_target(msg.metadata) + if _th_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( _th_msg_id, "interactive", card, diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index f7dc39e5d..7f1d2bb29 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -728,3 +728,90 @@ def test_on_background_task_done_removes_from_set() -> None: loop.close() assert task not in channel._background_tasks + + +# --------------------------------------------------------------------------- +# Issue #3533: streaming card / tool hint must respect reply_to_message +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("reply_to_message", "meta", "expected"), + [ + (True, {"chat_type": "p2p", "message_id": "om_1"}, None), + (False, {"chat_type": "group", "message_id": "om_1"}, None), + (True, {"chat_type": "group", "message_id": "om_1"}, "om_1"), + (False, {"chat_type": "group", "message_id": "om_1", "thread_id": "ot_1"}, "om_1"), + (True, {"chat_type": "group"}, None), + ], +) +def test_thread_reply_target_gating(reply_to_message, meta, expected) -> None: + channel = _make_feishu_channel(reply_to_message=reply_to_message) + assert channel._thread_reply_target(meta) == expected + + +@pytest.mark.asyncio +async def test_tool_hint_skips_reply_for_top_level_group_when_disabled() -> None: + """Bug case: tool-hint card on a top-level group msg must NOT spawn a topic.""" + channel = _make_feishu_channel(reply_to_message=False) + create_resp = MagicMock() + create_resp.success.return_value = True + create_resp.data = SimpleNamespace(message_id="om_hint") + channel._client.im.v1.message.create.return_value = create_resp + + await channel.send(OutboundMessage( + channel="feishu", chat_id="oc_abc", content='web_search("q")', + metadata={"_tool_hint": True, "message_id": "om_user", "chat_type": "group"}, + )) + + channel._client.im.v1.message.create.assert_called_once() + channel._client.im.v1.message.reply.assert_not_called() + + +@pytest.mark.asyncio +async def test_streaming_card_skips_reply_for_top_level_group_when_disabled() -> None: + """Bug case: first streaming delta on a top-level group msg must NOT spawn a topic.""" + channel = _make_feishu_channel(reply_to_message=False) + card_resp = MagicMock() + card_resp.success.return_value = True + card_resp.data = SimpleNamespace(card_id="card_1") + channel._client.cardkit.v1.card.create.return_value = card_resp + send_resp = MagicMock() + send_resp.success.return_value = True + send_resp.data = SimpleNamespace(message_id="om_card") + channel._client.im.v1.message.create.return_value = send_resp + update_resp = MagicMock() + update_resp.success.return_value = True + channel._client.cardkit.v1.card_element.content.return_value = update_resp + + await channel.send_delta( + "oc_abc", "hello", + metadata={"message_id": "om_user", "chat_type": "group"}, + ) + + channel._client.im.v1.message.create.assert_called_once() + channel._client.im.v1.message.reply.assert_not_called() + + +@pytest.mark.asyncio +async def test_streaming_card_keeps_reply_in_topic_even_when_disabled() -> None: + """Regression guard: in-topic continuation must keep using Reply API + so the response stays inside the existing topic — independent of config.""" + channel = _make_feishu_channel(reply_to_message=False) + card_resp = MagicMock() + card_resp.success.return_value = True + card_resp.data = SimpleNamespace(card_id="card_1") + channel._client.cardkit.v1.card.create.return_value = card_resp + reply_resp = MagicMock() + reply_resp.success.return_value = True + channel._client.im.v1.message.reply.return_value = reply_resp + update_resp = MagicMock() + update_resp.success.return_value = True + channel._client.cardkit.v1.card_element.content.return_value = update_resp + + await channel.send_delta( + "oc_abc", "hello", + metadata={"message_id": "om_user", "chat_type": "group", "thread_id": "ot_1"}, + ) + + channel._client.im.v1.message.reply.assert_called_once() From 26e953f0b927b8685d8bb22b75b093cbc07ca0f8 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:09:43 +0800 Subject: [PATCH 16/19] =?UTF-8?q?Revert=20"fix(feishu):=20streaming=20card?= =?UTF-8?q?=20and=20tool=20hint=20respect=20reply=5Fto=5Fmessage=20in?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 651b6b933f2db26713b5668d0c103d1b022e858c. --- nanobot/channels/feishu.py | 37 +++++------- tests/channels/test_feishu_reply.py | 87 ----------------------------- 2 files changed, 14 insertions(+), 110 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 9f6859beb..0071ef418 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1135,25 +1135,6 @@ class FeishuChannel(BaseChannel): logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) return None - def _thread_reply_target(self, meta: dict) -> str | None: - """Pick the inbound message_id to reply to with reply_in_thread=True. - - Returns the message_id when the response must travel through the - Reply API (continuation of an existing topic, or user opted in via - ``reply_to_message=True``); returns None to signal a plain send. - Used by streaming-card / fallback / tool-hint paths so they honor - the same gating as the regular send() path and don't unilaterally - spawn new topics in groups when ``reply_to_message`` is off. - """ - if meta.get("chat_type", "group") != "group": - return None - msg_id = meta.get("message_id") - if not msg_id: - return None - if meta.get("thread_id") or self.config.reply_to_message: - return msg_id - return None - def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: """Reply to an existing Feishu message using the Reply API (synchronous). @@ -1428,7 +1409,11 @@ class FeishuChannel(BaseChannel): {"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False, ) - fallback_msg_id = self._thread_reply_target(meta) + # Fallback: reply via the Reply API for group chats. + # Target message_id — the Feishu API keeps the reply in + # the same topic automatically. + _f_msg = meta.get("message_id") + fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None if fallback_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( @@ -1453,7 +1438,12 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - reply_msg_id = self._thread_reply_target(meta) + # Send the streaming card as a reply for group chats so it + # lands inside the originating topic/thread. Always target + # message_id (the actual inbound message) — the Feishu Reply + # API keeps the response in the same topic automatically. + is_group = meta.get("chat_type", "group") == "group" + reply_msg_id = meta.get("message_id") if is_group else None card_id = await loop.run_in_executor( None, self._create_streaming_card_sync, @@ -1508,8 +1498,9 @@ class FeishuChannel(BaseChannel): ]}, ensure_ascii=False, ) - _th_msg_id = self._thread_reply_target(msg.metadata) - if _th_msg_id: + _th_msg_id = msg.metadata.get("message_id") + _th_chat_type = msg.metadata.get("chat_type", "group") + if _th_msg_id and _th_chat_type == "group": await loop.run_in_executor( None, lambda: self._reply_message_sync( _th_msg_id, "interactive", card, diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 7f1d2bb29..f7dc39e5d 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -728,90 +728,3 @@ def test_on_background_task_done_removes_from_set() -> None: loop.close() assert task not in channel._background_tasks - - -# --------------------------------------------------------------------------- -# Issue #3533: streaming card / tool hint must respect reply_to_message -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - ("reply_to_message", "meta", "expected"), - [ - (True, {"chat_type": "p2p", "message_id": "om_1"}, None), - (False, {"chat_type": "group", "message_id": "om_1"}, None), - (True, {"chat_type": "group", "message_id": "om_1"}, "om_1"), - (False, {"chat_type": "group", "message_id": "om_1", "thread_id": "ot_1"}, "om_1"), - (True, {"chat_type": "group"}, None), - ], -) -def test_thread_reply_target_gating(reply_to_message, meta, expected) -> None: - channel = _make_feishu_channel(reply_to_message=reply_to_message) - assert channel._thread_reply_target(meta) == expected - - -@pytest.mark.asyncio -async def test_tool_hint_skips_reply_for_top_level_group_when_disabled() -> None: - """Bug case: tool-hint card on a top-level group msg must NOT spawn a topic.""" - channel = _make_feishu_channel(reply_to_message=False) - create_resp = MagicMock() - create_resp.success.return_value = True - create_resp.data = SimpleNamespace(message_id="om_hint") - channel._client.im.v1.message.create.return_value = create_resp - - await channel.send(OutboundMessage( - channel="feishu", chat_id="oc_abc", content='web_search("q")', - metadata={"_tool_hint": True, "message_id": "om_user", "chat_type": "group"}, - )) - - channel._client.im.v1.message.create.assert_called_once() - channel._client.im.v1.message.reply.assert_not_called() - - -@pytest.mark.asyncio -async def test_streaming_card_skips_reply_for_top_level_group_when_disabled() -> None: - """Bug case: first streaming delta on a top-level group msg must NOT spawn a topic.""" - channel = _make_feishu_channel(reply_to_message=False) - card_resp = MagicMock() - card_resp.success.return_value = True - card_resp.data = SimpleNamespace(card_id="card_1") - channel._client.cardkit.v1.card.create.return_value = card_resp - send_resp = MagicMock() - send_resp.success.return_value = True - send_resp.data = SimpleNamespace(message_id="om_card") - channel._client.im.v1.message.create.return_value = send_resp - update_resp = MagicMock() - update_resp.success.return_value = True - channel._client.cardkit.v1.card_element.content.return_value = update_resp - - await channel.send_delta( - "oc_abc", "hello", - metadata={"message_id": "om_user", "chat_type": "group"}, - ) - - channel._client.im.v1.message.create.assert_called_once() - channel._client.im.v1.message.reply.assert_not_called() - - -@pytest.mark.asyncio -async def test_streaming_card_keeps_reply_in_topic_even_when_disabled() -> None: - """Regression guard: in-topic continuation must keep using Reply API - so the response stays inside the existing topic — independent of config.""" - channel = _make_feishu_channel(reply_to_message=False) - card_resp = MagicMock() - card_resp.success.return_value = True - card_resp.data = SimpleNamespace(card_id="card_1") - channel._client.cardkit.v1.card.create.return_value = card_resp - reply_resp = MagicMock() - reply_resp.success.return_value = True - channel._client.im.v1.message.reply.return_value = reply_resp - update_resp = MagicMock() - update_resp.success.return_value = True - channel._client.cardkit.v1.card_element.content.return_value = update_resp - - await channel.send_delta( - "oc_abc", "hello", - metadata={"message_id": "om_user", "chat_type": "group", "thread_id": "ot_1"}, - ) - - channel._client.im.v1.message.reply.assert_called_once() From d82f25e4d443d2116a0221aa3fdd6a253a60adef Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Thu, 30 Apr 2026 11:18:34 +0800 Subject: [PATCH 17/19] fix(feishu): respect reply_to_message for group threads --- nanobot/channels/feishu.py | 58 +++++++------ tests/channels/test_feishu_reply.py | 26 ++++++ tests/channels/test_feishu_streaming.py | 111 +++++++++++++++++++++++- 3 files changed, 167 insertions(+), 28 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0071ef418..1f0615f21 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1170,6 +1170,10 @@ class FeishuChannel(BaseChannel): logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) return False + def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool: + """Return whether a group reply should create a Feishu thread/topic.""" + return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message + def _send_message_sync( self, receive_id_type: str, receive_id: str, msg_type: str, content: str ) -> str | None: @@ -1211,13 +1215,15 @@ class FeishuChannel(BaseChannel): receive_id_type: str, chat_id: str, reply_message_id: str | None = None, + *, + reply_in_thread: bool = False, ) -> str | None: """Create a CardKit streaming card, send it to chat, return card_id. When *reply_message_id* is provided the card is delivered via the - reply API (with reply_in_thread=True) so it lands inside the - originating thread / topic. Otherwise the plain create-message - API is used. + reply API. *reply_in_thread* controls whether Feishu creates a + thread/topic for that reply. Otherwise the plain create-message API is + used. """ from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody @@ -1253,7 +1259,7 @@ class FeishuChannel(BaseChannel): if reply_message_id: sent = self._reply_message_sync( reply_message_id, "interactive", card_content, - reply_in_thread=True, + reply_in_thread=reply_in_thread, ) else: sent = self._send_message_sync( @@ -1409,11 +1415,10 @@ class FeishuChannel(BaseChannel): {"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False, ) - # Fallback: reply via the Reply API for group chats. - # Target message_id — the Feishu API keeps the reply in - # the same topic automatically. + # Fallback: only create a group thread/topic when reply-to-message + # is enabled. Otherwise use the plain create-message API. _f_msg = meta.get("message_id") - fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None + fallback_msg_id = _f_msg if self._should_use_reply_in_thread(meta) else None if fallback_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( @@ -1438,16 +1443,18 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - # Send the streaming card as a reply for group chats so it - # lands inside the originating topic/thread. Always target - # message_id (the actual inbound message) — the Feishu Reply - # API keeps the response in the same topic automatically. - is_group = meta.get("chat_type", "group") == "group" - reply_msg_id = meta.get("message_id") if is_group else None + # Send the streaming card as a group thread/topic reply only when + # reply-to-message is enabled. + use_reply_in_thread = self._should_use_reply_in_thread(meta) + reply_msg_id = meta.get("message_id") if use_reply_in_thread else None card_id = await loop.run_in_executor( None, - self._create_streaming_card_sync, - rid_type, chat_id, reply_msg_id, + lambda: self._create_streaming_card_sync( + rid_type, + chat_id, + reply_msg_id, + reply_in_thread=use_reply_in_thread, + ), ) if card_id: buf.card_id = card_id @@ -1489,9 +1496,9 @@ class FeishuChannel(BaseChannel): "\n\n" + self._format_tool_hint_delta(hint) + "\n\n", ) return - # No active streaming card — send as a regular - # interactive card with the same 🔧 prefix style. - # Use reply API for group chats so the hint stays in topic. + # No active streaming card — send as a regular interactive card + # with the same 🔧 prefix style. Only create a group thread/topic + # when reply-to-message is enabled. card = json.dumps( {"config": {"wide_screen_mode": True}, "elements": [ {"tag": "markdown", "content": self._format_tool_hint_delta(hint)}, @@ -1499,8 +1506,7 @@ class FeishuChannel(BaseChannel): ensure_ascii=False, ) _th_msg_id = msg.metadata.get("message_id") - _th_chat_type = msg.metadata.get("chat_type", "group") - if _th_msg_id and _th_chat_type == "group": + if _th_msg_id and self._should_use_reply_in_thread(msg.metadata): await loop.run_in_executor( None, lambda: self._reply_message_sync( _th_msg_id, "interactive", card, @@ -1531,18 +1537,16 @@ class FeishuChannel(BaseChannel): def _do_send(m_type: str, content: str) -> None: """Send via reply (first message) or create (subsequent). - For group chats the reply API always uses reply_in_thread=True. - The Feishu API automatically keeps replies inside existing - topics — reply_in_thread only creates a *new* topic when the - target message is a plain (non-topic) message. + Group chats only set reply_in_thread=True when + reply_to_message is enabled; otherwise a Reply API call for an + existing topic must not create a new topic. """ nonlocal first_send if reply_message_id and first_send: first_send = False - chat_type = msg.metadata.get("chat_type", "group") ok = self._reply_message_sync( reply_message_id, m_type, content, - reply_in_thread=chat_type == "group", + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), ) if ok: return diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index f7dc39e5d..430e5abea 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -580,6 +580,32 @@ async def test_reply_without_reply_in_thread_when_disabled() -> None: channel._client.im.v1.message.create.assert_called_once() +@pytest.mark.asyncio +async def test_topic_reply_does_not_force_reply_in_thread_when_disabled() -> None: + """Topic replies must not create new Feishu topics when reply_to_message is False.""" + channel = _make_feishu_channel(reply_to_message=False) + + reply_resp = MagicMock() + reply_resp.success.return_value = True + channel._client.im.v1.message.reply.return_value = reply_resp + + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + metadata={ + "message_id": "om_child456", + "chat_type": "group", + "thread_id": "om_root123", + }, + )) + + channel._client.im.v1.message.reply.assert_called_once() + call_args = channel._client.im.v1.message.reply.call_args + request = call_args[0][0] + assert request.request_body.reply_in_thread is not True + + @pytest.mark.asyncio async def test_reply_keeps_fallback_when_reply_fails() -> None: """Even with reply_to_message=True, fallback to create on reply failure.""" diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 4bef83548..6a137aa2f 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -10,13 +10,14 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf -def _make_channel(streaming: bool = True) -> FeishuChannel: +def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel: config = FeishuConfig( enabled=True, app_id="cli_test", app_secret="secret", allow_from=["*"], streaming=streaming, + reply_to_message=reply_to_message, ) ch = FeishuChannel(config, MessageBus()) ch._client = MagicMock() @@ -148,6 +149,42 @@ class TestSendDelta: ch._client.im.v1.message.create.assert_called_once() ch._client.cardkit.v1.card_element.content.assert_called_once() + @pytest.mark.asyncio + async def test_group_delta_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + ch._client.im.v1.message.create.return_value = _mock_send_response("om_new") + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_group_delta_replies_in_thread_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_second_delta_within_interval_skips_update(self): ch = _make_channel() @@ -204,6 +241,44 @@ class TestSendDelta: ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.im.v1.message.create.assert_called_once() + @pytest.mark.asyncio + async def test_stream_end_fallback_group_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb") + + await ch.send_delta( + "oc_chat1", + "", + metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_stream_end_fallback_group_replies_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + await ch.send_delta( + "oc_chat1", + "", + metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_stream_end_fallback_when_final_update_fails(self): """If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card.""" @@ -316,6 +391,40 @@ class TestToolHintInlineStreaming: assert "oc_chat1" not in ch._stream_bufs ch._client.im.v1.message.create.assert_called_once() + @pytest.mark.asyncio + async def test_tool_hint_group_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint") + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, + ) + await ch.send(msg) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_tool_hint_group_replies_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, + ) + await ch.send(msg) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_consecutive_tool_hints_append(self): """When multiple tool hints arrive consecutively, each appends to the card.""" From f8fd9f0011e47a4da54b5c81ed812be3f6a5c29b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 30 Apr 2026 04:54:16 +0000 Subject: [PATCH 18/19] fix(feishu): keep streaming replies in existing topics Made-with: Cursor --- nanobot/channels/feishu.py | 36 ++++++++----- tests/channels/test_feishu_streaming.py | 70 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1f0615f21..7ddb8506d 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1174,6 +1174,17 @@ class FeishuChannel(BaseChannel): """Return whether a group reply should create a Feishu thread/topic.""" return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message + def _thread_reply_target(self, metadata: dict[str, Any]) -> str | None: + """Return the message_id that should receive a Reply API response.""" + if metadata.get("chat_type", "group") != "group": + return None + message_id = metadata.get("message_id") + if not message_id: + return None + if metadata.get("thread_id") or self.config.reply_to_message: + return message_id + return None + def _send_message_sync( self, receive_id_type: str, receive_id: str, msg_type: str, content: str ) -> str | None: @@ -1415,15 +1426,14 @@ class FeishuChannel(BaseChannel): {"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False, ) - # Fallback: only create a group thread/topic when reply-to-message - # is enabled. Otherwise use the plain create-message API. - _f_msg = meta.get("message_id") - fallback_msg_id = _f_msg if self._should_use_reply_in_thread(meta) else None + # Fallback replies stay in existing topics, but only create a + # new topic when reply-to-message is enabled. + fallback_msg_id = self._thread_reply_target(meta) if fallback_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( fallback_msg_id, "interactive", card, - reply_in_thread=True, + reply_in_thread=self._should_use_reply_in_thread(meta), ), ) else: @@ -1443,10 +1453,10 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - # Send the streaming card as a group thread/topic reply only when - # reply-to-message is enabled. + # Use the Reply API for existing topics, and only create new topics + # when reply-to-message is enabled. use_reply_in_thread = self._should_use_reply_in_thread(meta) - reply_msg_id = meta.get("message_id") if use_reply_in_thread else None + reply_msg_id = self._thread_reply_target(meta) card_id = await loop.run_in_executor( None, lambda: self._create_streaming_card_sync( @@ -1497,20 +1507,20 @@ class FeishuChannel(BaseChannel): ) return # No active streaming card — send as a regular interactive card - # with the same 🔧 prefix style. Only create a group thread/topic - # when reply-to-message is enabled. + # with the same 🔧 prefix style. Existing topics stay threaded; + # new topics are created only when reply-to-message is enabled. card = json.dumps( {"config": {"wide_screen_mode": True}, "elements": [ {"tag": "markdown", "content": self._format_tool_hint_delta(hint)}, ]}, ensure_ascii=False, ) - _th_msg_id = msg.metadata.get("message_id") - if _th_msg_id and self._should_use_reply_in_thread(msg.metadata): + _th_msg_id = self._thread_reply_target(msg.metadata) + if _th_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( _th_msg_id, "interactive", card, - reply_in_thread=True, + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), ), ) else: diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 6a137aa2f..68232cb42 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -165,6 +165,26 @@ class TestSendDelta: ch._client.im.v1.message.create.assert_called_once() ch._client.im.v1.message.reply.assert_not_called() + @pytest.mark.asyncio + async def test_group_delta_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group", "thread_id": "ot_001"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + @pytest.mark.asyncio async def test_group_delta_replies_in_thread_when_reply_enabled(self): ch = _make_channel(reply_to_message=True) @@ -258,6 +278,32 @@ class TestSendDelta: ch._client.im.v1.message.create.assert_called_once() ch._client.im.v1.message.reply.assert_not_called() + @pytest.mark.asyncio + async def test_stream_end_fallback_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + await ch.send_delta( + "oc_chat1", + "", + metadata={ + "_stream_end": True, + "message_id": "om_001", + "chat_type": "group", + "thread_id": "ot_001", + }, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + @pytest.mark.asyncio async def test_stream_end_fallback_group_replies_when_reply_enabled(self): ch = _make_channel(reply_to_message=True) @@ -406,6 +452,30 @@ class TestToolHintInlineStreaming: ch._client.im.v1.message.create.assert_called_once() ch._client.im.v1.message.reply.assert_not_called() + @pytest.mark.asyncio + async def test_tool_hint_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={ + "_tool_hint": True, + "message_id": "om_001", + "chat_type": "group", + "thread_id": "ot_001", + }, + ) + await ch.send(msg) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + @pytest.mark.asyncio async def test_tool_hint_group_replies_when_reply_enabled(self): ch = _make_channel(reply_to_message=True) From 3c20d16117b07255f22e60350f2fef29a3f358e3 Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Wed, 29 Apr 2026 17:53:28 +0800 Subject: [PATCH 19/19] fix subagent max iteration limit --- nanobot/agent/loop.py | 7 + nanobot/agent/subagent.py | 10 +- nanobot/agent/tools/self.py | 2 + .../tools/test_self_tool_runtime_sync.py | 29 +++++ tests/agent/tools/test_subagent_tools.py | 122 +++++++++++++++++- 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 tests/agent/tools/test_self_tool_runtime_sync.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c03d80651..cbddfc286 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -258,6 +258,7 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, disabled_skills=disabled_skills, + max_iterations=self.max_iterations, ) self._unified_session = unified_session self._max_messages = max_messages if max_messages > 0 else 120 @@ -307,6 +308,10 @@ class AgentLoop: self.commands = CommandRouter() register_builtin_commands(self.commands) + def _sync_subagent_runtime_limits(self) -> None: + """Keep subagent runtime limits aligned with mutable loop settings.""" + self.subagents.max_iterations = self.max_iterations + def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None: """Swap model/provider for future turns without disturbing an active one.""" provider = snapshot.provider @@ -531,6 +536,8 @@ class AgentLoop: Returns (final_content, tools_used, messages, stop_reason, had_injections). """ + self._sync_subagent_runtime_limits() + loop_hook = _LoopHook( self, on_progress=on_progress, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index c100d205b..e64dc8f97 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -20,7 +20,7 @@ from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus -from nanobot.config.schema import ExecToolConfig, WebToolsConfig +from nanobot.config.schema import AgentDefaults, ExecToolConfig, WebToolsConfig from nanobot.providers.base import LLMProvider from nanobot.utils.prompt_templates import render_template @@ -81,6 +81,7 @@ class SubagentManager: exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, disabled_skills: list[str] | None = None, + max_iterations: int | None = None, ): self.provider = provider self.workspace = workspace @@ -91,6 +92,11 @@ class SubagentManager: self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self.disabled_skills = set(disabled_skills or []) + self.max_iterations = ( + max_iterations + if max_iterations is not None + else AgentDefaults().max_tool_iterations + ) self.runner = AgentRunner(provider) self._running_tasks: dict[str, asyncio.Task[None]] = {} self._task_statuses: dict[str, SubagentStatus] = {} @@ -202,7 +208,7 @@ class SubagentManager: initial_messages=messages, tools=tools, model=self.model, - max_iterations=15, + max_iterations=self.max_iterations, max_tool_result_chars=self.max_tool_result_chars, hook=_SubagentHook(task_id, status), max_iterations_message="Task completed but no final response was generated.", diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index f05dbf217..59ece04e7 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -394,6 +394,8 @@ class MyTool(Tool): if "min_len" in spec and len(str(value)) < spec["min_len"]: return f"Error: '{key}' must be at least {spec['min_len']} characters" setattr(self._loop, key, value) + if key == "max_iterations" and hasattr(self._loop, "_sync_subagent_runtime_limits"): + self._loop._sync_subagent_runtime_limits() self._audit("modify", f"{key}: {old!r} -> {value!r}") return f"Set {key} = {value!r} (was {old!r})" diff --git a/tests/agent/tools/test_self_tool_runtime_sync.py b/tests/agent/tools/test_self_tool_runtime_sync.py new file mode 100644 index 000000000..8f65023ff --- /dev/null +++ b/tests/agent/tools/test_self_tool_runtime_sync.py @@ -0,0 +1,29 @@ +"""Focused tests for MyTool runtime sync side effects.""" + +from unittest.mock import MagicMock + +import pytest + +from nanobot.agent.tools.self import MyTool + + +@pytest.mark.asyncio +async def test_my_tool_max_iterations_syncs_subagent_limit() -> None: + loop = MagicMock() + loop.max_iterations = 40 + loop._runtime_vars = {} + loop.subagents = MagicMock() + loop.subagents.max_iterations = loop.max_iterations + + def _sync_subagent_runtime_limits() -> None: + loop.subagents.max_iterations = loop.max_iterations + + loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits + + tool = MyTool(loop=loop) + + result = await tool.execute(action="set", key="max_iterations", value=80) + + assert "Set max_iterations = 80" in result + assert loop.max_iterations == 80 + assert loop.subagents.max_iterations == 80 diff --git a/tests/agent/tools/test_subagent_tools.py b/tests/agent/tools/test_subagent_tools.py index bfe845395..a050a4271 100644 --- a/tests/agent/tools/test_subagent_tools.py +++ b/tests/agent/tools/test_subagent_tools.py @@ -54,11 +54,129 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path): mgr.runner.run.assert_awaited_once() +@pytest.mark.asyncio +async def test_subagent_uses_configured_max_iterations(tmp_path): + """Subagents should honor the configured tool-iteration limit.""" + from nanobot.agent.subagent import SubagentManager, SubagentStatus + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + max_iterations=37, + ) + mgr._announce_result = AsyncMock() + + async def fake_run(spec): + assert spec.max_iterations == 37 + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + status = SubagentStatus( + task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic() + ) + await mgr._run_subagent( + "sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status + ) + + mgr.runner.run.assert_awaited_once() + + +def test_subagent_default_max_iterations_matches_agent_defaults(tmp_path): + """Direct SubagentManager construction should use the agent default limit.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + + assert mgr.max_iterations == AgentDefaults().max_tool_iterations + + +def test_agent_loop_passes_max_iterations_to_subagents(tmp_path): + """AgentLoop's configured limit should be shared with spawned subagents.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + max_iterations=42, + ) + + assert loop.subagents.max_iterations == 42 + + +@pytest.mark.asyncio +async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path): + """Runtime max_iterations changes should be reflected before tool execution.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + max_iterations=42, + ) + loop.tools.get_definitions = MagicMock(return_value=[]) + + async def fake_run(spec): + assert spec.max_iterations == 55 + assert loop.subagents.max_iterations == 55 + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + messages=[], + usage={}, + had_injections=False, + tools_used=[], + ) + + loop.runner.run = AsyncMock(side_effect=fake_run) + loop.max_iterations = 55 + + await loop._run_agent_loop([]) + + loop.runner.run.assert_awaited_once() + + @pytest.mark.asyncio async def test_drain_pending_blocks_while_subagents_running(tmp_path): """_drain_pending should block when no messages are available but sub-agents are still running.""" from nanobot.agent.loop import AgentLoop - from nanobot.agent.subagent import SubagentManager from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.session.manager import Session @@ -74,8 +192,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path): injection_callback = None # Capture the injection_callback that _run_agent_loop creates - original_run = loop.runner.run - async def fake_runner_run(spec): nonlocal injection_callback injection_callback = spec.injection_callback