diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 360055487..2129788be 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1044,7 +1044,7 @@ def detect_provider_for_model( return (resolved_provider, default_models[0]) # Aggregators list other providers' models — never auto-switch TO them - _AGGREGATORS = {"nous", "openrouter"} + _AGGREGATORS = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} # If the model belongs to the current provider's catalog, don't suggest switching current_models = _PROVIDER_MODELS.get(current_provider, []) diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 9c5bf0cca..624e166a8 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -578,7 +578,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): # After the probe detects a single model ("llm"), the flow asks # "Use this model? [Y/n]:" — confirm with Enter, then context length, # then display name. - answers = iter(["http://localhost:8000", "local-key", "", "", ""]) + answers = iter(["http://localhost:8000", "local-key", "", "", "", ""]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers)) diff --git a/tests/cli/test_surrogate_sanitization.py b/tests/cli/test_surrogate_sanitization.py index defad587e..43af7fe16 100644 --- a/tests/cli/test_surrogate_sanitization.py +++ b/tests/cli/test_surrogate_sanitization.py @@ -138,7 +138,7 @@ class TestRunConversationSurrogateSanitization: mock_stream.return_value = mock_response mock_api.return_value = mock_response - agent = AIAgent(model="test/model", quiet_mode=True, skip_memory=True, skip_context_files=True) + agent = AIAgent(model="test/model", api_key="test-key", base_url="http://localhost:1234/v1", quiet_mode=True, skip_memory=True, skip_context_files=True) agent.client = MagicMock() # Pass a message with surrogates diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 5fd8d86fe..d2f55ff9f 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -62,5 +62,86 @@ def _ensure_telegram_mock() -> None: sys.modules["telegram.error"] = mod.error +def _ensure_discord_mock() -> None: + """Install a comprehensive discord mock in sys.modules. + + Idempotent — skips when the real library is already imported. + Uses ``sys.modules[name] = mod`` (overwrite) instead of + ``setdefault`` so it wins even if a partial/broken import already + cached the module. + + This mock is comprehensive — it includes **all** attributes needed by + every gateway discord test file. Individual test files should call + this function (it short-circuits when already present) rather than + maintaining their own mock setup. + """ + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return # Real library is installed — nothing to mock + + from types import SimpleNamespace + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.ui = SimpleNamespace( + View=object, + button=lambda *a, **k: (lambda fn: fn), + Button=object, + ) + discord_mod.ButtonStyle = SimpleNamespace( + success=1, primary=2, secondary=2, danger=3, + green=1, grey=2, blurple=2, red=3, + ) + discord_mod.Color = SimpleNamespace( + orange=lambda: 1, green=lambda: 2, blue=lambda: 3, + red=lambda: 4, purple=lambda: 5, + ) + + # app_commands — needed by _register_slash_commands auto-registration + class _FakeGroup: + def __init__(self, *, name, description, parent=None): + self.name = name + self.description = description + self.parent = parent + self._children: dict = {} + if parent is not None: + parent.add_command(self) + + def add_command(self, cmd): + self._children[cmd.name] = cmd + + class _FakeCommand: + def __init__(self, *, name, description, callback, parent=None): + self.name = name + self.description = description + self.callback = callback + self.parent = parent + + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + Group=_FakeGroup, + Command=_FakeCommand, + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + for name in ("discord", "discord.ext", "discord.ext.commands"): + sys.modules[name] = discord_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + + # Run at collection time — before any test file's module-level imports. _ensure_telegram_mock() +_ensure_discord_mock() diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index 8a3b440bb..0203bfab6 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -284,9 +284,20 @@ class TestEnvVarOverride: # Tests for reply_to_text extraction in _handle_message # ------------------------------------------------------------------ -class FakeDMChannel: +# Build FakeDMChannel as a subclass of the real discord.DMChannel when the +# library is installed — this guarantees isinstance() checks pass in +# production code regardless of test ordering or monkeypatch state. +try: + import discord as _discord_lib + _DMChannelBase = _discord_lib.DMChannel +except (ImportError, AttributeError): + _DMChannelBase = object + + +class FakeDMChannel(_DMChannelBase): """Minimal DM channel stub (skips mention / channel-allow checks).""" def __init__(self, channel_id: int = 100, name: str = "dm"): + # Do NOT call super().__init__() — real DMChannel requires State self.id = channel_id self.name = name @@ -309,10 +320,6 @@ def _make_message(*, content: str = "hi", reference=None): @pytest.fixture def reply_text_adapter(monkeypatch): """DiscordAdapter wired for _handle_message → handle_message capture.""" - import gateway.platforms.discord as discord_platform - - monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False) - config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) diff --git a/tests/gateway/test_telegram_network.py b/tests/gateway/test_telegram_network.py index 2770211f3..ff74d4c66 100644 --- a/tests/gateway/test_telegram_network.py +++ b/tests/gateway/test_telegram_network.py @@ -322,7 +322,7 @@ class TestFallbackTransportInit: seen_kwargs.append(kwargs.copy()) return FakeTransport([], {}) - for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY"): monkeypatch.delenv(key, raising=False) monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory) diff --git a/tests/run_agent/test_context_token_tracking.py b/tests/run_agent/test_context_token_tracking.py index b924448b6..6800a2b49 100644 --- a/tests/run_agent/test_context_token_tracking.py +++ b/tests/run_agent/test_context_token_tracking.py @@ -59,7 +59,7 @@ def _make_agent(monkeypatch, api_mode, provider, response_fn): self._disable_streaming = True return super().run_conversation(msg, conversation_history=conversation_history, task_id=task_id) - return _A(model="test-model", api_key="test-key", provider=provider, api_mode=api_mode) + return _A(model="test-model", api_key="test-key", base_url="http://localhost:1234/v1", provider=provider, api_mode=api_mode) def _anthropic_resp(input_tok, output_tok, cache_read=0, cache_creation=0): diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 49ef1dc8f..46eec2cf7 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4115,8 +4115,8 @@ class TestMemoryNudgeCounterPersistence: """Counters must exist on the agent after __init__.""" with patch("run_agent.get_tool_definitions", return_value=[]): a = AIAgent( - model="test", api_key="test-key", provider="openrouter", - skip_context_files=True, skip_memory=True, + model="test", api_key="test-key", base_url="http://localhost:1234/v1", + provider="openrouter", skip_context_files=True, skip_memory=True, ) assert hasattr(a, "_turns_since_memory") assert hasattr(a, "_iters_since_skill")