From c64fe0afd8cfcbfe0c26569140db33b473f87854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 16:53:07 +0100 Subject: [PATCH 1/4] fix(tests): resolve failing tests on main branch - Unskip matrix logic by adding missing deps (matrix-nio, nh3, mistune) - Update matrix tests for 'allow_from' default deny security change - Fix asyncio typing keepalive leak in matrix tests - Update context prompt cache assert after runtime message merge - Fix flaky cron service test with mtime sleep - Remove obsolete test_cron_commands.py testing deleted CLI commands --- pyproject.toml | 3 +++ tests/test_context_prompt_cache.py | 9 ++++----- tests/test_cron_commands.py | 29 ----------------------------- tests/test_cron_service.py | 2 ++ tests/test_matrix_channel.py | 20 ++++++++++++++++++-- 5 files changed, 27 insertions(+), 36 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/pyproject.toml b/pyproject.toml index a22053c..0546523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.scripts] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..38b8d35 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + runtime_content = messages[-1]["content"] assert isinstance(runtime_content, str) assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content assert "Current Time:" in runtime_content assert "Channel: cli" in runtime_content assert "Chat ID: direct" in runtime_content - - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + assert "Return exactly: OK" in runtime_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 2a36f4c..9631da5 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: + # Wait slightly to ensure file mtime is definitively different + await asyncio.sleep(0.05) external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c6714c2..c25b95a 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,7 @@ class _FakeAsyncClient: def _make_config(**kwargs) -> MatrixConfig: + kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", @@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: @pytest.mark.asyncio -async def test_room_invite_joins_when_allow_list_is_empty() -> None: +async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -284,9 +285,22 @@ async def test_room_invite_joins_when_allow_list_is_empty() -> None: await channel._on_room_invite(room, event) - assert client.join_calls == ["!room:matrix.org"] + assert client.join_calls == [] +@pytest.mark.asyncio +async def test_room_invite_joins_when_sender_allowed() -> None: + channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -1163,6 +1177,8 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None: assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + await channel.stop() + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: From 88d7642c1ec570e07eef473f47d1d637b38b9b07 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 2/4] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 38b8d35..fa7f02d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -55,11 +55,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - runtime_content = messages[-1]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - assert "Return exactly: OK" in runtime_content + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content From 0209ad57d9655d8fea5f5e551a4bb89bd0f1691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 19:31:39 +0100 Subject: [PATCH 3/4] fix(tests): resolve RequestsDependencyWarning and lark-oapi asyncio/websockets DeprecationWarnings --- nanobot/channels/feishu.py | 32 +++++++++++--------------------- pyproject.toml | 1 + 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..7d26fa8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import FeishuConfig -try: - import lark_oapi as lark - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageReactionRequest, - CreateMessageReactionRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - Emoji, - GetMessageResourceRequest, - P2ImMessageReceiveV1, - ) - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None - Emoji = None +import importlib.util + +FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None # Message type display mapping MSG_TYPE_MAP = { @@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel): logger.error("Feishu app_id and app_secret not configured") return + import lark_oapi as lark self._running = True self._loop = asyncio.get_running_loop() @@ -340,6 +324,7 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" + from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji try: request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ @@ -364,7 +349,7 @@ class FeishuChannel(BaseChannel): Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ - if not self._client or not Emoji: + if not self._client: return loop = asyncio.get_running_loop() @@ -456,6 +441,7 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" + from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -479,6 +465,7 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" + from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -506,6 +493,7 @@ class FeishuChannel(BaseChannel): def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: """Download an image from Feishu message by message_id and image_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = GetMessageResourceRequest.builder() \ .message_id(message_id) \ @@ -530,6 +518,7 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = ( GetMessageResourceRequest.builder() @@ -598,6 +587,7 @@ class FeishuChannel(BaseChannel): def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" + from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody try: request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ diff --git a/pyproject.toml b/pyproject.toml index 0546523..d384f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "chardet>=3.0.2,<6.0.0", ] [project.optional-dependencies] From e032faaeff81d7e4fa39659badbacc7b4004dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 20:04:00 +0100 Subject: [PATCH 4/4] Merge branch 'main' of upstream/main into fix/test-failures --- .gitignore | 2 +- nanobot/agent/tools/base.py | 2 ++ nanobot/agent/tools/cron.py | 5 ++++- nanobot/agent/tools/filesystem.py | 11 +++++++++++ nanobot/channels/feishu.py | 6 ++++++ nanobot/providers/openai_codex_provider.py | 3 +++ pyproject.toml | 2 ++ tests/test_context_prompt_cache.py | 1 + 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d7b930d..742d593 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB — prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars ≤ 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated — file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7d26fa8..0cd84c3 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -519,6 +519,12 @@ class FeishuChannel(BaseChannel): ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" from lark_oapi.api.im.v1 import GetMessageResourceRequest + + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" + try: request = ( GetMessageResourceRequest.builder() diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) diff --git a/pyproject.toml b/pyproject.toml index d384f3f..e5214bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", + "openai>=2.8.0", + ] [project.optional-dependencies] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index fa7f02d..d347e53 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -55,6 +55,7 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" user_content = messages[-1]["content"]