diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67a4d9b..e00362d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,14 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v4 + - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install .[dev] + - name: Install all dependencies + run: uv sync --all-extras - name: Run tests - run: python -m pytest tests/ -v + run: uv run pytest tests/ diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 1f983dd..ed552b3 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -3,11 +3,12 @@ import asyncio import os import re -import subprocess -import tempfile +import sys from pathlib import Path from typing import Any +from loguru import logger + from nanobot.agent.tools.base import Tool @@ -93,31 +94,32 @@ class ExecTool(Tool): env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append try: - with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file: - process = subprocess.Popen( - command, - stdout=stdout_file, - stderr=stderr_file, - cwd=cwd, - env=env, - shell=True, + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=env, + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=effective_timeout, ) - - deadline = asyncio.get_running_loop().time() + effective_timeout - while process.poll() is None: - if asyncio.get_running_loop().time() >= deadline: - process.kill() + except asyncio.TimeoutError: + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass + finally: + if sys.platform != "win32": try: - process.wait(timeout=5.0) - except subprocess.TimeoutExpired: - pass - return f"Error: Command timed out after {effective_timeout} seconds" - await asyncio.sleep(0.05) - - stdout_file.seek(0) - stderr_file.seek(0) - stdout = stdout_file.read() - stderr = stderr_file.read() + os.waitpid(process.pid, os.WNOHANG) + except (ProcessLookupError, ChildProcessError) as e: + logger.debug("Process already reaped or not found: {}", e) + return f"Error: Command timed out after {effective_timeout} seconds" output_parts = [] diff --git a/pyproject.toml b/pyproject.toml index 7cc0094..e544843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,11 +66,8 @@ matrix = [ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", + "pytest-cov>=6.0.0,<7.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", - "mypy>=1.19.1", ] [project.scripts] diff --git a/tests/test_consolidate_offset.py b/tests/agent/test_consolidate_offset.py similarity index 100% rename from tests/test_consolidate_offset.py rename to tests/agent/test_consolidate_offset.py diff --git a/tests/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py similarity index 100% rename from tests/test_context_prompt_cache.py rename to tests/agent/test_context_prompt_cache.py diff --git a/tests/test_gemini_thought_signature.py b/tests/agent/test_gemini_thought_signature.py similarity index 100% rename from tests/test_gemini_thought_signature.py rename to tests/agent/test_gemini_thought_signature.py diff --git a/tests/test_heartbeat_service.py b/tests/agent/test_heartbeat_service.py similarity index 100% rename from tests/test_heartbeat_service.py rename to tests/agent/test_heartbeat_service.py diff --git a/tests/test_loop_consolidation_tokens.py b/tests/agent/test_loop_consolidation_tokens.py similarity index 100% rename from tests/test_loop_consolidation_tokens.py rename to tests/agent/test_loop_consolidation_tokens.py diff --git a/tests/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py similarity index 100% rename from tests/test_loop_save_turn.py rename to tests/agent/test_loop_save_turn.py diff --git a/tests/test_memory_consolidation_types.py b/tests/agent/test_memory_consolidation_types.py similarity index 100% rename from tests/test_memory_consolidation_types.py rename to tests/agent/test_memory_consolidation_types.py diff --git a/tests/test_onboard_logic.py b/tests/agent/test_onboard_logic.py similarity index 100% rename from tests/test_onboard_logic.py rename to tests/agent/test_onboard_logic.py diff --git a/tests/test_session_manager_history.py b/tests/agent/test_session_manager_history.py similarity index 100% rename from tests/test_session_manager_history.py rename to tests/agent/test_session_manager_history.py diff --git a/tests/test_skill_creator_scripts.py b/tests/agent/test_skill_creator_scripts.py similarity index 100% rename from tests/test_skill_creator_scripts.py rename to tests/agent/test_skill_creator_scripts.py diff --git a/tests/test_task_cancel.py b/tests/agent/test_task_cancel.py similarity index 100% rename from tests/test_task_cancel.py rename to tests/agent/test_task_cancel.py diff --git a/tests/test_base_channel.py b/tests/channels/test_base_channel.py similarity index 100% rename from tests/test_base_channel.py rename to tests/channels/test_base_channel.py diff --git a/tests/test_channel_plugins.py b/tests/channels/test_channel_plugins.py similarity index 100% rename from tests/test_channel_plugins.py rename to tests/channels/test_channel_plugins.py diff --git a/tests/test_dingtalk_channel.py b/tests/channels/test_dingtalk_channel.py similarity index 95% rename from tests/test_dingtalk_channel.py rename to tests/channels/test_dingtalk_channel.py index 1d36ee8..10db726 100644 --- a/tests/test_dingtalk_channel.py +++ b/tests/channels/test_dingtalk_channel.py @@ -3,6 +3,16 @@ from types import SimpleNamespace import pytest +# Check optional dingtalk dependencies before running tests +try: + from nanobot.channels import dingtalk + DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False) +except ImportError: + DINGTALK_AVAILABLE = False + +if not DINGTALK_AVAILABLE: + pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True) + from nanobot.bus.queue import MessageBus import nanobot.channels.dingtalk as dingtalk_module from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler diff --git a/tests/test_email_channel.py b/tests/channels/test_email_channel.py similarity index 100% rename from tests/test_email_channel.py rename to tests/channels/test_email_channel.py diff --git a/tests/test_feishu_markdown_rendering.py b/tests/channels/test_feishu_markdown_rendering.py similarity index 81% rename from tests/test_feishu_markdown_rendering.py rename to tests/channels/test_feishu_markdown_rendering.py index 6812a21..efcd207 100644 --- a/tests/test_feishu_markdown_rendering.py +++ b/tests/channels/test_feishu_markdown_rendering.py @@ -1,3 +1,14 @@ +# Check optional Feishu dependencies before running tests +try: + from nanobot.channels import feishu + FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) +except ImportError: + FEISHU_AVAILABLE = False + +if not FEISHU_AVAILABLE: + import pytest + pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) + from nanobot.channels.feishu import FeishuChannel diff --git a/tests/test_feishu_post_content.py b/tests/channels/test_feishu_post_content.py similarity index 82% rename from tests/test_feishu_post_content.py rename to tests/channels/test_feishu_post_content.py index 7b1cb9d..a4c5bae 100644 --- a/tests/test_feishu_post_content.py +++ b/tests/channels/test_feishu_post_content.py @@ -1,3 +1,14 @@ +# Check optional Feishu dependencies before running tests +try: + from nanobot.channels import feishu + FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) +except ImportError: + FEISHU_AVAILABLE = False + +if not FEISHU_AVAILABLE: + import pytest + pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) + from nanobot.channels.feishu import FeishuChannel, _extract_post_content diff --git a/tests/test_feishu_reply.py b/tests/channels/test_feishu_reply.py similarity index 97% rename from tests/test_feishu_reply.py rename to tests/channels/test_feishu_reply.py index 45626af..e57bc1f 100644 --- a/tests/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -7,6 +7,16 @@ from unittest.mock import MagicMock, patch import pytest +# Check optional Feishu dependencies before running tests +try: + from nanobot.channels import feishu + FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) +except ImportError: + FEISHU_AVAILABLE = False + +if not FEISHU_AVAILABLE: + pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.feishu import FeishuChannel, FeishuConfig diff --git a/tests/test_feishu_table_split.py b/tests/channels/test_feishu_table_split.py similarity index 89% rename from tests/test_feishu_table_split.py rename to tests/channels/test_feishu_table_split.py index af8fa16..030b891 100644 --- a/tests/test_feishu_table_split.py +++ b/tests/channels/test_feishu_table_split.py @@ -6,6 +6,17 @@ list of card elements into groups so that each group contains at most one table, allowing nanobot to send multiple cards instead of failing. """ +# Check optional Feishu dependencies before running tests +try: + from nanobot.channels import feishu + FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) +except ImportError: + FEISHU_AVAILABLE = False + +if not FEISHU_AVAILABLE: + import pytest + pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) + from nanobot.channels.feishu import FeishuChannel diff --git a/tests/channels/test_feishu_tool_hint_code_block.py b/tests/channels/test_feishu_tool_hint_code_block.py new file mode 100644 index 0000000..a65f1d9 --- /dev/null +++ b/tests/channels/test_feishu_tool_hint_code_block.py @@ -0,0 +1,148 @@ +"""Tests for FeishuChannel tool hint code block formatting.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from pytest import mark + +# Check optional Feishu dependencies before running tests +try: + from nanobot.channels import feishu + FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) +except ImportError: + FEISHU_AVAILABLE = False + +if not FEISHU_AVAILABLE: + pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) + +from nanobot.bus.events import OutboundMessage +from nanobot.channels.feishu import FeishuChannel + + +@pytest.fixture +def mock_feishu_channel(): + """Create a FeishuChannel with mocked client.""" + config = MagicMock() + config.app_id = "test_app_id" + config.app_secret = "test_app_secret" + config.encrypt_key = None + config.verification_token = None + bus = MagicMock() + channel = FeishuChannel(config, bus) + channel._client = MagicMock() # Simulate initialized client + return channel + + +@mark.asyncio +async def test_tool_hint_sends_code_message(mock_feishu_channel): + """Tool hint messages should be sent as interactive cards with code blocks.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='web_search("test query")', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + # Verify interactive message with card was sent + assert mock_send.call_count == 1 + call_args = mock_send.call_args[0] + receive_id_type, receive_id, msg_type, content = call_args + + assert receive_id_type == "chat_id" + assert receive_id == "oc_123456" + assert msg_type == "interactive" + + # Parse content to verify card structure + card = json.loads(content) + assert card["config"]["wide_screen_mode"] is True + assert len(card["elements"]) == 1 + assert card["elements"][0]["tag"] == "markdown" + # Check that code block is properly formatted with language hint + expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```" + assert card["elements"][0]["content"] == expected_md + + +@mark.asyncio +async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel): + """Empty tool hint messages should not be sent.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content=" ", # whitespace only + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + # Should not send any message + mock_send.assert_not_called() + + +@mark.asyncio +async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel): + """Regular messages without _tool_hint should use normal formatting.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content="Hello, world!", + metadata={} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + # Should send as text message (detected format) + assert mock_send.call_count == 1 + call_args = mock_send.call_args[0] + _, _, msg_type, content = call_args + assert msg_type == "text" + assert json.loads(content) == {"text": "Hello, world!"} + + +@mark.asyncio +async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): + """Multiple tool calls should be displayed each on its own line in a code block.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='web_search("query"), read_file("/path/to/file")', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + call_args = mock_send.call_args[0] + msg_type = call_args[2] + content = json.loads(call_args[3]) + assert msg_type == "interactive" + # Each tool call should be on its own line + expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```" + assert content["elements"][0]["content"] == expected_md + + +@mark.asyncio +async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel): + """Commas inside a single tool argument must not be split onto a new line.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='web_search("foo, bar"), read_file("/path/to/file")', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + content = json.loads(mock_send.call_args[0][3]) + expected_md = ( + "**Tool Calls**\n\n```text\n" + "web_search(\"foo, bar\"),\n" + "read_file(\"/path/to/file\")\n```" + ) + assert content["elements"][0]["content"] == expected_md diff --git a/tests/test_matrix_channel.py b/tests/channels/test_matrix_channel.py similarity index 99% rename from tests/test_matrix_channel.py rename to tests/channels/test_matrix_channel.py index c25b95a..10bf12d 100644 --- a/tests/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -4,6 +4,12 @@ from types import SimpleNamespace import pytest +# Check optional matrix dependencies before importing +try: + import nh3 # noqa: F401 +except ImportError: + pytest.skip("Matrix dependencies not installed (nh3)", allow_module_level=True) + import nanobot.channels.matrix as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus diff --git a/tests/test_qq_channel.py b/tests/channels/test_qq_channel.py similarity index 98% rename from tests/test_qq_channel.py rename to tests/channels/test_qq_channel.py index fe8287a..cdaf387 100644 --- a/tests/test_qq_channel.py +++ b/tests/channels/test_qq_channel.py @@ -3,6 +3,16 @@ from types import SimpleNamespace import pytest +# Check optional QQ dependencies before running tests +try: + from nanobot.channels import qq + QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False) +except ImportError: + QQ_AVAILABLE = False + +if not QQ_AVAILABLE: + pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.qq import QQChannel, _make_bot_class diff --git a/tests/test_slack_channel.py b/tests/channels/test_slack_channel.py similarity index 95% rename from tests/test_slack_channel.py rename to tests/channels/test_slack_channel.py index 1093fd5..db5829e 100644 --- a/tests/test_slack_channel.py +++ b/tests/channels/test_slack_channel.py @@ -2,6 +2,12 @@ from __future__ import annotations import pytest +# Check optional Slack dependencies before running tests +try: + import slack_sdk # noqa: F401 +except ImportError: + pytest.skip("Slack dependencies not installed (slack-sdk)", allow_module_level=True) + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.slack import SlackChannel diff --git a/tests/test_telegram_channel.py b/tests/channels/test_telegram_channel.py similarity index 99% rename from tests/test_telegram_channel.py rename to tests/channels/test_telegram_channel.py index 2f58c66..cbba3ef 100644 --- a/tests/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -3,6 +3,12 @@ from unittest.mock import AsyncMock import pytest +# Check optional Telegram dependencies before running tests +try: + import telegram # noqa: F401 +except ImportError: + pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True) + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel diff --git a/tests/test_weixin_channel.py b/tests/channels/test_weixin_channel.py similarity index 100% rename from tests/test_weixin_channel.py rename to tests/channels/test_weixin_channel.py diff --git a/tests/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py similarity index 100% rename from tests/test_whatsapp_channel.py rename to tests/channels/test_whatsapp_channel.py diff --git a/tests/test_cli_input.py b/tests/cli/test_cli_input.py similarity index 100% rename from tests/test_cli_input.py rename to tests/cli/test_cli_input.py diff --git a/tests/test_commands.py b/tests/cli/test_commands.py similarity index 100% rename from tests/test_commands.py rename to tests/cli/test_commands.py diff --git a/tests/test_restart_command.py b/tests/cli/test_restart_command.py similarity index 100% rename from tests/test_restart_command.py rename to tests/cli/test_restart_command.py diff --git a/tests/test_config_migration.py b/tests/config/test_config_migration.py similarity index 100% rename from tests/test_config_migration.py rename to tests/config/test_config_migration.py diff --git a/tests/test_config_paths.py b/tests/config/test_config_paths.py similarity index 100% rename from tests/test_config_paths.py rename to tests/config/test_config_paths.py diff --git a/tests/test_cron_service.py b/tests/cron/test_cron_service.py similarity index 100% rename from tests/test_cron_service.py rename to tests/cron/test_cron_service.py diff --git a/tests/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py similarity index 100% rename from tests/test_cron_tool_list.py rename to tests/cron/test_cron_tool_list.py diff --git a/tests/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py similarity index 100% rename from tests/test_azure_openai_provider.py rename to tests/providers/test_azure_openai_provider.py diff --git a/tests/test_custom_provider.py b/tests/providers/test_custom_provider.py similarity index 100% rename from tests/test_custom_provider.py rename to tests/providers/test_custom_provider.py diff --git a/tests/test_mistral_provider.py b/tests/providers/test_mistral_provider.py similarity index 100% rename from tests/test_mistral_provider.py rename to tests/providers/test_mistral_provider.py diff --git a/tests/test_provider_retry.py b/tests/providers/test_provider_retry.py similarity index 100% rename from tests/test_provider_retry.py rename to tests/providers/test_provider_retry.py diff --git a/tests/test_providers_init.py b/tests/providers/test_providers_init.py similarity index 100% rename from tests/test_providers_init.py rename to tests/providers/test_providers_init.py diff --git a/tests/test_security_network.py b/tests/security/test_security_network.py similarity index 100% rename from tests/test_security_network.py rename to tests/security/test_security_network.py diff --git a/tests/test_exec_security.py b/tests/tools/test_exec_security.py similarity index 100% rename from tests/test_exec_security.py rename to tests/tools/test_exec_security.py diff --git a/tests/test_filesystem_tools.py b/tests/tools/test_filesystem_tools.py similarity index 100% rename from tests/test_filesystem_tools.py rename to tests/tools/test_filesystem_tools.py diff --git a/tests/test_mcp_tool.py b/tests/tools/test_mcp_tool.py similarity index 100% rename from tests/test_mcp_tool.py rename to tests/tools/test_mcp_tool.py diff --git a/tests/test_message_tool.py b/tests/tools/test_message_tool.py similarity index 100% rename from tests/test_message_tool.py rename to tests/tools/test_message_tool.py diff --git a/tests/test_message_tool_suppress.py b/tests/tools/test_message_tool_suppress.py similarity index 100% rename from tests/test_message_tool_suppress.py rename to tests/tools/test_message_tool_suppress.py diff --git a/tests/test_tool_validation.py b/tests/tools/test_tool_validation.py similarity index 100% rename from tests/test_tool_validation.py rename to tests/tools/test_tool_validation.py diff --git a/tests/test_web_fetch_security.py b/tests/tools/test_web_fetch_security.py similarity index 100% rename from tests/test_web_fetch_security.py rename to tests/tools/test_web_fetch_security.py