From 3c106c89a1759b767e6676b16d45daf4f7640862 Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Wed, 13 May 2026 17:29:43 -0700 Subject: [PATCH] test(ci): stabilize shared optional dependency baselines --- tests/agent/test_bedrock_adapter.py | 20 ++++- tests/agent/test_bedrock_integration.py | 26 ++++--- tests/gateway/test_dingtalk.py | 77 ++++++++++++++++++- tests/gateway/test_feishu_bot_admission.py | 31 +++++++- tests/gateway/test_matrix.py | 16 ++-- tests/hermes_cli/test_bedrock_model_picker.py | 19 ++++- tests/run_agent/test_switch_model_context.py | 15 ++-- tests/tools/test_registry.py | 46 +++-------- tests/tools/test_transcription.py | 11 ++- tests/tools/test_tts_kittentts.py | 3 +- 10 files changed, 194 insertions(+), 70 deletions(-) diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index 6c51288461e..04c0913f289 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -12,12 +12,24 @@ Covers: import json import os import time -from types import SimpleNamespace +from contextlib import contextmanager +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, patch, PropertyMock import pytest +@contextmanager +def _mock_botocore_session(*, return_value=None, side_effect=None): + """Patch botocore.session even when botocore is not installed.""" + botocore_mod = ModuleType("botocore") + session_mod = ModuleType("botocore.session") + session_mod.get_session = MagicMock(return_value=return_value, side_effect=side_effect) + botocore_mod.session = session_mod + with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}): + yield session_mod.get_session + + # --------------------------------------------------------------------------- # AWS credential detection # --------------------------------------------------------------------------- @@ -120,7 +132,7 @@ class TestResolveBedrocRegion: from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = None - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): assert resolve_bedrock_region({}) == "us-east-1" def test_falls_back_to_botocore_profile_region(self): @@ -128,13 +140,13 @@ class TestResolveBedrocRegion: from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): assert resolve_bedrock_region({}) == "eu-central-1" def test_botocore_failure_falls_back_to_us_east_1(self): from agent.bedrock_adapter import resolve_bedrock_region from unittest.mock import patch - with patch("botocore.session.get_session", side_effect=Exception("no botocore")): + with _mock_botocore_session(side_effect=Exception("no botocore")): assert resolve_bedrock_region({}) == "us-east-1" diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index 954075ab722..a5ab3563381 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -253,20 +253,24 @@ class TestErrorClassifierBedrock: # --------------------------------------------------------------------------- class TestPackaging: - """Verify bedrock optional dependency is declared.""" + """Verify Bedrock remains a declared lazy optional dependency.""" + + @staticmethod + def _optional_dependencies(): + import tomllib + from pathlib import Path + + content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() + return tomllib.loads(content)["project"]["optional-dependencies"] def test_bedrock_extra_exists(self): - import configparser - from pathlib import Path - # Read pyproject.toml to verify [bedrock] extra - toml_path = Path(__file__).parent.parent.parent / "pyproject.toml" - content = toml_path.read_text() - assert 'bedrock = ["boto3' in content + extras = self._optional_dependencies() + assert "bedrock" in extras + assert any(dep.startswith("boto3==") for dep in extras["bedrock"]) - def test_bedrock_in_all_extra(self): - from pathlib import Path - content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() - assert '"hermes-agent[bedrock]"' in content + def test_bedrock_is_not_eager_installed_by_all_extra(self): + extras = self._optional_dependencies() + assert "hermes-agent[bedrock]" not in extras["all"] # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index aceb079b4b8..570eb997ba0 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -10,6 +10,80 @@ import pytest from gateway.config import Platform, PlatformConfig +class _FakeDingTalkModel: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class _FakeChatbotMessage(SimpleNamespace): + @classmethod + def from_dict(cls, data): + data = data or {} + return cls( + message_id=data.get("msgId") or data.get("messageId") or data.get("message_id") or "", + conversation_id=data.get("conversationId") or data.get("conversation_id") or "", + conversation_type=str(data.get("conversationType") or data.get("conversation_type") or "1"), + sender_id=data.get("senderId") or data.get("sender_id") or "", + sender_staff_id=data.get("senderStaffId") or data.get("sender_staff_id") or data.get("senderId") or "", + sender_nick=data.get("senderNick") or data.get("sender_nick") or "", + text=data.get("text") or "", + rich_text=data.get("richText") or data.get("rich_text"), + rich_text_content=data.get("richTextContent") or data.get("rich_text_content"), + session_webhook=data.get("sessionWebhook") or data.get("session_webhook") or "", + session_webhook_expired_time=data.get("sessionWebhookExpiredTime") or data.get("session_webhook_expired_time") or 0, + create_at=data.get("createAt") or data.get("create_at") or 0, + at_users=data.get("atUsers") or data.get("at_users") or [], + is_in_at_list=bool(data.get("isInAtList") or data.get("is_in_at_list")), + ) + + +@pytest.fixture(autouse=True) +def _fake_dingtalk_optional_sdks(monkeypatch): + """Keep DingTalk adapter tests hermetic when optional SDKs are absent.""" + from gateway.platforms import dingtalk as dt + + card_models = SimpleNamespace(**{ + name: _FakeDingTalkModel + for name in ( + "CreateCardRequest", + "CreateCardRequestCardData", + "CreateCardRequestImGroupOpenSpaceModel", + "CreateCardRequestImRobotOpenSpaceModel", + "CreateCardHeaders", + "DeliverCardRequest", + "DeliverCardRequestImGroupOpenDeliverModel", + "DeliverCardRequestImRobotOpenDeliverModel", + "DeliverCardHeaders", + "StreamingUpdateRequest", + "StreamingUpdateHeaders", + ) + }) + robot_models = SimpleNamespace(**{ + name: _FakeDingTalkModel + for name in ( + "RobotReplyEmotionRequestTextEmotion", + "RobotReplyEmotionRequest", + "RobotReplyEmotionHeaders", + "RobotRecallEmotionRequestTextEmotion", + "RobotRecallEmotionRequest", + "RobotRecallEmotionHeaders", + "RobotMessageFileDownloadRequest", + "RobotMessageFileDownloadHeaders", + ) + }) + + monkeypatch.setattr(dt, "ChatbotMessage", _FakeChatbotMessage, raising=False) + monkeypatch.setattr( + dt, + "AckMessage", + SimpleNamespace(STATUS_OK=200, STATUS_SYSTEM_EXCEPTION=500), + raising=False, + ) + monkeypatch.setattr(dt, "tea_util_models", SimpleNamespace(RuntimeOptions=_FakeDingTalkModel), raising=False) + monkeypatch.setattr(dt, "dingtalk_card_models", card_models, raising=False) + monkeypatch.setattr(dt, "dingtalk_robot_models", robot_models, raising=False) + + # --------------------------------------------------------------------------- # Requirements check # --------------------------------------------------------------------------- @@ -18,7 +92,8 @@ from gateway.config import Platform, PlatformConfig class TestDingTalkRequirements: def test_returns_false_when_sdk_missing(self, monkeypatch): - with patch.dict("sys.modules", {"dingtalk_stream": None}): + with patch.dict("sys.modules", {"dingtalk_stream": None}), \ + patch("tools.lazy_deps.ensure", side_effect=ImportError("dingtalk_stream unavailable")): monkeypatch.setattr( "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False ) diff --git a/tests/gateway/test_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 83b70238430..5ccc386d83e 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/tests/gateway/test_feishu_bot_admission.py @@ -455,7 +455,36 @@ def test_admit_per_group_require_mention_overrides_global(): def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch): import asyncio - from gateway.platforms.feishu import FeishuAdapter + from gateway.platforms import feishu as feishu_mod + FeishuAdapter = feishu_mod.FeishuAdapter + + class _FakeBaseRequestBuilder: + def __init__(self): + self._request = SimpleNamespace() + + def http_method(self, value): + self._request.http_method = value + return self + + def uri(self, value): + self._request.uri = value + return self + + def token_types(self, value): + self._request.token_types = value + return self + + def build(self): + return self._request + + monkeypatch.setattr( + feishu_mod, + "BaseRequest", + SimpleNamespace(builder=lambda: _FakeBaseRequestBuilder()), + raising=False, + ) + monkeypatch.setattr(feishu_mod, "HttpMethod", SimpleNamespace(GET="GET"), raising=False) + monkeypatch.setattr(feishu_mod, "AccessTokenType", SimpleNamespace(TENANT="TENANT"), raising=False) adapter = object.__new__(FeishuAdapter) adapter._bot_open_id = "" diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index bd95fb6136f..c329441531d 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -716,8 +716,10 @@ class TestMatrixModuleImport: "sys.meta_path.insert(0, _Blocker())\n" "for k in list(sys.modules):\n" " if k.startswith('mautrix'): del sys.modules[k]\n" + "from unittest.mock import patch\n" "from gateway.platforms.matrix import check_matrix_requirements\n" - "assert not check_matrix_requirements()\n" + "with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n" + " assert not check_matrix_requirements()\n" "print('OK')\n" )], capture_output=True, text=True, timeout=10, @@ -737,7 +739,8 @@ class TestMatrixRequirements: import mautrix # noqa: F401 assert check_matrix_requirements() is True except ImportError: - assert check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert check_matrix_requirements() is False def test_check_requirements_without_creds(self, monkeypatch): monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False) @@ -759,7 +762,8 @@ class TestMatrixRequirements: monkeypatch.setenv("MATRIX_ENCRYPTION", "true") from gateway.platforms import matrix as matrix_mod - with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ + patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): @@ -775,7 +779,8 @@ class TestMatrixRequirements: import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - assert matrix_mod.check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): """MATRIX_ENCRYPTION=true should pass if E2EE deps are available.""" @@ -789,7 +794,8 @@ class TestMatrixRequirements: import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - assert matrix_mod.check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert matrix_mod.check_matrix_requirements() is False # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_bedrock_model_picker.py b/tests/hermes_cli/test_bedrock_model_picker.py index 3b2c4d5dc7b..70335be2186 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/tests/hermes_cli/test_bedrock_model_picker.py @@ -17,6 +17,8 @@ All Bedrock API calls are mocked — no real AWS credentials needed. """ import os +from contextlib import contextmanager +from types import ModuleType from unittest.mock import MagicMock, patch import pytest @@ -26,6 +28,19 @@ import pytest # Shared helpers / fixtures # --------------------------------------------------------------------------- + + +@contextmanager +def _mock_botocore_session(*, return_value=None): + """Patch botocore.session even when botocore is not installed.""" + botocore_mod = ModuleType("botocore") + session_mod = ModuleType("botocore.session") + session_mod.get_session = MagicMock(return_value=return_value) + botocore_mod.session = session_mod + with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}): + yield session_mod.get_session + + _EU_MODELS = [ {"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"}, {"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"}, @@ -276,7 +291,7 @@ class TestBedrockRegionRouting: with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("botocore.session.get_session", return_value=mock_session): + _mock_botocore_session(return_value=mock_session): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -310,7 +325,7 @@ class TestBedrockRegionRouting: mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): region = resolve_bedrock_region() assert region == "us-west-2", "env var should override botocore profile" diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 8b04a73262b..c925a508915 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -1,4 +1,4 @@ -"""Tests that switch_model preserves config_context_length.""" +"""Tests that switch_model does not inherit stale context_length overrides.""" from unittest.mock import MagicMock, patch @@ -19,7 +19,7 @@ def _make_agent_with_compressor(config_context_length=None) -> AIAgent: agent.client = MagicMock() agent.quiet_mode = True - # Store config_context_length for later use in switch_model + # Store the initial config_context_length override used at agent construction. agent._config_context_length = config_context_length # Context compressor with primary model values @@ -41,8 +41,8 @@ def _make_agent_with_compressor(config_context_length=None) -> AIAgent: @patch("agent.model_metadata.get_model_context_length", return_value=131_072) -def test_switch_model_preserves_config_context_length(mock_ctx_len): - """When switching models, config_context_length should be passed to get_model_context_length.""" +def test_switch_model_clears_previous_config_context_length(mock_ctx_len): + """Switching models must not reuse the previous model.context_length override.""" agent = _make_agent_with_compressor(config_context_length=32_768) assert agent.context_compressor.model == "primary-model" @@ -51,13 +51,14 @@ def test_switch_model_preserves_config_context_length(mock_ctx_len): # Switch model agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="https://openrouter.ai/api/v1") - # Verify get_model_context_length was called with config_context_length + # Verify the old config override is not passed to the new model. mock_ctx_len.assert_called_once() call_kwargs = mock_ctx_len.call_args.kwargs - assert call_kwargs.get("config_context_length") == 32_768 + assert call_kwargs.get("config_context_length") is None - # Verify compressor was updated + # Verify compressor was updated from the newly resolved model metadata. assert agent.context_compressor.model == "new-model" + assert agent.context_compressor.context_length == 131_072 def test_switch_model_without_config_context_length(): diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 0023b5c9bd2..7ad5fff4f16 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -5,7 +5,7 @@ import threading from pathlib import Path from unittest.mock import patch -from tools.registry import ToolRegistry, discover_builtin_tools +from tools.registry import ToolRegistry, _module_registers_tools, discover_builtin_tools def _dummy_handler(args, **kwargs): @@ -289,43 +289,19 @@ class TestCheckFnExceptionHandling: class TestBuiltinDiscovery: - def test_matches_previous_manual_builtin_tool_set(self): - expected = { - "tools.browser_cdp_tool", - "tools.browser_dialog_tool", - "tools.browser_tool", - "tools.clarify_tool", - "tools.code_execution_tool", - "tools.computer_use_tool", - "tools.cronjob_tools", - "tools.delegate_tool", - "tools.discord_tool", - "tools.feishu_doc_tool", - "tools.feishu_drive_tool", - "tools.file_tools", - "tools.homeassistant_tool", - "tools.image_generation_tool", - "tools.kanban_tools", - "tools.memory_tool", - "tools.mixture_of_agents_tool", - "tools.process_registry", - "tools.rl_training_tool", - "tools.send_message_tool", - "tools.session_search_tool", - "tools.skill_manager_tool", - "tools.skills_tool", - "tools.terminal_tool", - "tools.todo_tool", - "tools.tts_tool", - "tools.vision_tools", - "tools.web_tools", - "tools.yuanbao_tools", - } + def test_discovers_all_real_self_registering_builtin_tool_modules(self): + tools_dir = Path(__file__).resolve().parents[2] / "tools" + expected = [ + f"tools.{path.stem}" + for path in sorted(tools_dir.glob("*.py")) + if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"} + and _module_registers_tools(path) + ] with patch("tools.registry.importlib.import_module"): - imported = discover_builtin_tools(Path(__file__).resolve().parents[2] / "tools") + imported = discover_builtin_tools(tools_dir) - assert set(imported) == expected + assert imported == expected def test_imports_only_self_registering_modules(self, tmp_path): tools_dir = tmp_path / "tools" diff --git a/tests/tools/test_transcription.py b/tests/tools/test_transcription.py index e56577ca556..32f0ad48798 100644 --- a/tests/tools/test_transcription.py +++ b/tests/tools/test_transcription.py @@ -8,11 +8,16 @@ import json import os import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch, mock_open import pytest +def _fake_faster_whisper_module(mock_model): + return SimpleNamespace(WhisperModel=MagicMock(return_value=mock_model)) + + # --------------------------------------------------------------------------- # Provider selection # --------------------------------------------------------------------------- @@ -137,8 +142,9 @@ class TestTranscribeLocal: mock_model = MagicMock() mock_model.transcribe.return_value = ([mock_segment], mock_info) + fake_fw = _fake_faster_whisper_module(mock_model) with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ - patch("faster_whisper.WhisperModel", return_value=mock_model), \ + patch.dict("sys.modules", {"faster_whisper": fake_fw}), \ patch("tools.transcription_tools._local_model", None): from tools.transcription_tools import _transcribe_local result = _transcribe_local(str(audio_file), "base") @@ -300,7 +306,8 @@ class TestNormalizeLocalModel: }), \ patch("tools.transcription_tools._local_model", None), \ patch("tools.transcription_tools._local_model_name", None), \ - patch("faster_whisper.WhisperModel", return_value=mock_model) as mock_cls: + patch.dict("sys.modules", {"faster_whisper": _fake_faster_whisper_module(mock_model)}): + mock_cls = __import__("faster_whisper").WhisperModel from tools.transcription_tools import transcribe_audio transcribe_audio(audio_file) # WhisperModel must NOT have been called with "whisper-1" diff --git a/tests/tools/test_tts_kittentts.py b/tests/tools/test_tts_kittentts.py index ab841f59f4a..f4918df4496 100644 --- a/tests/tools/test_tts_kittentts.py +++ b/tests/tools/test_tts_kittentts.py @@ -3,7 +3,6 @@ import json from unittest.mock import MagicMock, patch -import numpy as np import pytest @@ -27,7 +26,7 @@ def mock_kittentts_module(): """Inject a fake kittentts + soundfile module that return stub objects.""" fake_model = MagicMock() # 24kHz float32 PCM at ~2s of silence - fake_model.generate.return_value = np.zeros(48000, dtype=np.float32) + fake_model.generate.return_value = [0.0] * 48000 fake_cls = MagicMock(return_value=fake_model) fake_kittentts = MagicMock() fake_kittentts.KittenTTS = fake_cls