Merge pull request #21012 from stephenschoettler/fix/ci-pr-check-unblock

fix(ci): unblock shared PR checks
This commit is contained in:
ethernet 2026-05-14 16:16:42 -04:00 committed by GitHub
commit cd64bed55e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 194 additions and 70 deletions

View file

@ -12,12 +12,24 @@ Covers:
import json import json
import os import os
import time import time
from types import SimpleNamespace from contextlib import contextmanager
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock, patch, PropertyMock from unittest.mock import MagicMock, patch, PropertyMock
import pytest 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 # AWS credential detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -120,7 +132,7 @@ class TestResolveBedrocRegion:
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
mock_session = MagicMock() mock_session = MagicMock()
mock_session.get_config_variable.return_value = None 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" assert resolve_bedrock_region({}) == "us-east-1"
def test_falls_back_to_botocore_profile_region(self): def test_falls_back_to_botocore_profile_region(self):
@ -128,13 +140,13 @@ class TestResolveBedrocRegion:
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
mock_session = MagicMock() mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1" 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" assert resolve_bedrock_region({}) == "eu-central-1"
def test_botocore_failure_falls_back_to_us_east_1(self): def test_botocore_failure_falls_back_to_us_east_1(self):
from agent.bedrock_adapter import resolve_bedrock_region from agent.bedrock_adapter import resolve_bedrock_region
from unittest.mock import patch 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" assert resolve_bedrock_region({}) == "us-east-1"

View file

@ -253,20 +253,24 @@ class TestErrorClassifierBedrock:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPackaging: 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): def test_bedrock_extra_exists(self):
import configparser extras = self._optional_dependencies()
from pathlib import Path assert "bedrock" in extras
# Read pyproject.toml to verify [bedrock] extra assert any(dep.startswith("boto3==") for dep in extras["bedrock"])
toml_path = Path(__file__).parent.parent.parent / "pyproject.toml"
content = toml_path.read_text()
assert 'bedrock = ["boto3' in content
def test_bedrock_in_all_extra(self): def test_bedrock_is_not_eager_installed_by_all_extra(self):
from pathlib import Path extras = self._optional_dependencies()
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() assert "hermes-agent[bedrock]" not in extras["all"]
assert '"hermes-agent[bedrock]"' in content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -10,6 +10,80 @@ import pytest
from gateway.config import Platform, PlatformConfig 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 # Requirements check
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -18,7 +92,8 @@ from gateway.config import Platform, PlatformConfig
class TestDingTalkRequirements: class TestDingTalkRequirements:
def test_returns_false_when_sdk_missing(self, monkeypatch): 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( monkeypatch.setattr(
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
) )

View file

@ -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): def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
import asyncio 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 = object.__new__(FeishuAdapter)
adapter._bot_open_id = "" adapter._bot_open_id = ""

View file

@ -716,8 +716,10 @@ class TestMatrixModuleImport:
"sys.meta_path.insert(0, _Blocker())\n" "sys.meta_path.insert(0, _Blocker())\n"
"for k in list(sys.modules):\n" "for k in list(sys.modules):\n"
" if k.startswith('mautrix'): del sys.modules[k]\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" "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" "print('OK')\n"
)], )],
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
@ -737,7 +739,8 @@ class TestMatrixRequirements:
import mautrix # noqa: F401 import mautrix # noqa: F401
assert check_matrix_requirements() is True assert check_matrix_requirements() is True
except ImportError: 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): def test_check_requirements_without_creds(self, monkeypatch):
monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False) monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False)
@ -759,7 +762,8 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_ENCRYPTION", "true") monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
from gateway.platforms import matrix as matrix_mod 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 assert matrix_mod.check_matrix_requirements() is False
def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch):
@ -775,7 +779,8 @@ class TestMatrixRequirements:
import mautrix # noqa: F401 import mautrix # noqa: F401
assert matrix_mod.check_matrix_requirements() is True assert matrix_mod.check_matrix_requirements() is True
except ImportError: 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): def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch):
"""MATRIX_ENCRYPTION=true should pass if E2EE deps are available.""" """MATRIX_ENCRYPTION=true should pass if E2EE deps are available."""
@ -789,7 +794,8 @@ class TestMatrixRequirements:
import mautrix # noqa: F401 import mautrix # noqa: F401
assert matrix_mod.check_matrix_requirements() is True assert matrix_mod.check_matrix_requirements() is True
except ImportError: 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -17,6 +17,8 @@ All Bedrock API calls are mocked — no real AWS credentials needed.
""" """
import os import os
from contextlib import contextmanager
from types import ModuleType
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -26,6 +28,19 @@ import pytest
# Shared helpers / fixtures # 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 = [ _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-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"}, {"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), \ with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ 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") providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
@ -310,7 +325,7 @@ class TestBedrockRegionRouting:
mock_session = MagicMock() mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1" 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() region = resolve_bedrock_region()
assert region == "us-west-2", "env var should override botocore profile" assert region == "us-west-2", "env var should override botocore profile"

View file

@ -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 from unittest.mock import MagicMock, patch
@ -19,7 +19,7 @@ def _make_agent_with_compressor(config_context_length=None) -> AIAgent:
agent.client = MagicMock() agent.client = MagicMock()
agent.quiet_mode = True 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 agent._config_context_length = config_context_length
# Context compressor with primary model values # 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) @patch("agent.model_metadata.get_model_context_length", return_value=131_072)
def test_switch_model_preserves_config_context_length(mock_ctx_len): def test_switch_model_clears_previous_config_context_length(mock_ctx_len):
"""When switching models, config_context_length should be passed to get_model_context_length.""" """Switching models must not reuse the previous model.context_length override."""
agent = _make_agent_with_compressor(config_context_length=32_768) agent = _make_agent_with_compressor(config_context_length=32_768)
assert agent.context_compressor.model == "primary-model" assert agent.context_compressor.model == "primary-model"
@ -51,13 +51,14 @@ def test_switch_model_preserves_config_context_length(mock_ctx_len):
# Switch model # Switch model
agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="https://openrouter.ai/api/v1") 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() mock_ctx_len.assert_called_once()
call_kwargs = mock_ctx_len.call_args.kwargs 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.model == "new-model"
assert agent.context_compressor.context_length == 131_072
def test_switch_model_without_config_context_length(): def test_switch_model_without_config_context_length():

View file

@ -5,7 +5,7 @@ import threading
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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): def _dummy_handler(args, **kwargs):
@ -289,43 +289,19 @@ class TestCheckFnExceptionHandling:
class TestBuiltinDiscovery: class TestBuiltinDiscovery:
def test_matches_previous_manual_builtin_tool_set(self): def test_discovers_all_real_self_registering_builtin_tool_modules(self):
expected = { tools_dir = Path(__file__).resolve().parents[2] / "tools"
"tools.browser_cdp_tool", expected = [
"tools.browser_dialog_tool", f"tools.{path.stem}"
"tools.browser_tool", for path in sorted(tools_dir.glob("*.py"))
"tools.clarify_tool", if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
"tools.code_execution_tool", and _module_registers_tools(path)
"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",
}
with patch("tools.registry.importlib.import_module"): 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): def test_imports_only_self_registering_modules(self, tmp_path):
tools_dir = tmp_path / "tools" tools_dir = tmp_path / "tools"

View file

@ -8,11 +8,16 @@ import json
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch, mock_open from unittest.mock import MagicMock, patch, mock_open
import pytest import pytest
def _fake_faster_whisper_module(mock_model):
return SimpleNamespace(WhisperModel=MagicMock(return_value=mock_model))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Provider selection # Provider selection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -137,8 +142,9 @@ class TestTranscribeLocal:
mock_model = MagicMock() mock_model = MagicMock()
mock_model.transcribe.return_value = ([mock_segment], mock_info) 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), \ 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): patch("tools.transcription_tools._local_model", None):
from tools.transcription_tools import _transcribe_local from tools.transcription_tools import _transcribe_local
result = _transcribe_local(str(audio_file), "base") 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", None), \
patch("tools.transcription_tools._local_model_name", 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 from tools.transcription_tools import transcribe_audio
transcribe_audio(audio_file) transcribe_audio(audio_file)
# WhisperModel must NOT have been called with "whisper-1" # WhisperModel must NOT have been called with "whisper-1"

View file

@ -3,7 +3,6 @@
import json import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import numpy as np
import pytest import pytest
@ -27,7 +26,7 @@ def mock_kittentts_module():
"""Inject a fake kittentts + soundfile module that return stub objects.""" """Inject a fake kittentts + soundfile module that return stub objects."""
fake_model = MagicMock() fake_model = MagicMock()
# 24kHz float32 PCM at ~2s of silence # 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_cls = MagicMock(return_value=fake_model)
fake_kittentts = MagicMock() fake_kittentts = MagicMock()
fake_kittentts.KittenTTS = fake_cls fake_kittentts.KittenTTS = fake_cls