diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index bdfcfb09d..33b35562f 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -167,6 +167,7 @@ def _resolve_runtime_from_pool_entry( api_mode = "chat_completions" elif provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) + base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Honour model.base_url from config.yaml when the configured provider diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 3b44cba4d..2cf64c33b 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -89,7 +89,8 @@ class TestReadCodexAccessToken: hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() + with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + result = _read_codex_access_token() assert result is None def test_empty_token_returns_none(self, tmp_path, monkeypatch): @@ -146,7 +147,8 @@ class TestReadCodexAccessToken: }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() + with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + result = _read_codex_access_token() assert result is None, "Expired JWT should return None" def test_valid_jwt_returns_token(self, tmp_path, monkeypatch): @@ -585,7 +587,10 @@ class TestGetTextAuxiliaryClient: assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" def test_codex_fallback_when_nothing_else(self, codex_auth_dir): - with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "gpt-5.2-codex" @@ -623,17 +628,21 @@ class TestGetTextAuxiliaryClient: monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ - patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \ - patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)): + with patch("agent.auxiliary_client._resolve_auto", return_value=(None, None)): client, model = get_text_auxiliary_client() assert client is None assert model is None - def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self): + def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self, monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \ patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \ + patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 224910ac4..437a6c400 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -232,7 +232,7 @@ class TestResolveVisionProviderClientModelNormalization: assert provider == "zai" assert client is not None - assert model == "glm-5.1" + assert model == "glm-5v-turbo" # zai has dedicated vision model in _PROVIDER_VISION_MODELS class TestVisionPathApiMode: diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index c11782f69..7ec0385b6 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -252,6 +252,11 @@ def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch): def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + # Prevent auto-seeding from Codex CLI tokens on the host + monkeypatch.setattr( + "hermes_cli.auth._import_codex_cli_tokens", + lambda: None, + ) _write_auth_store( tmp_path, { diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py new file mode 100644 index 000000000..5fd8d86fe --- /dev/null +++ b/tests/gateway/conftest.py @@ -0,0 +1,66 @@ +"""Shared fixtures for gateway tests. + +The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of +the ``telegram`` package is registered in :data:`sys.modules` **before** +any test file triggers ``from gateway.platforms.telegram import ...``. + +Without this, ``pytest-xdist`` workers that happen to collect +``test_telegram_caption_merge.py`` (bare top-level import, no per-file +mock) first will cache ``ChatType = None`` from the production +ImportError fallback, causing 30+ downstream test failures wherever +``ChatType.GROUP`` / ``ChatType.SUPERGROUP`` is accessed. + +Individual test files may still call their own ``_ensure_telegram_mock`` +— it short-circuits when the mock is already present. +""" + +import sys +from unittest.mock import MagicMock + + +def _ensure_telegram_mock() -> None: + """Install a comprehensive telegram 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 a module with ``ChatType = None``. + """ + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return # Real library is installed — nothing to mock + + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN = "Markdown" + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ParseMode.HTML = "HTML" + mod.constants.ChatType.PRIVATE = "private" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + + # Real exception classes so ``except (NetworkError, ...)`` clauses + # in production code don't blow up with TypeError. + mod.error.NetworkError = type("NetworkError", (OSError,), {}) + mod.error.TimedOut = type("TimedOut", (OSError,), {}) + mod.error.BadRequest = type("BadRequest", (Exception,), {}) + mod.error.Forbidden = type("Forbidden", (Exception,), {}) + mod.error.InvalidToken = type("InvalidToken", (Exception,), {}) + mod.error.RetryAfter = type("RetryAfter", (Exception,), {"retry_after": 1}) + mod.error.Conflict = type("Conflict", (Exception,), {}) + + # Update.ALL_TYPES used in start_polling() + mod.Update.ALL_TYPES = [] + + for name in ( + "telegram", + "telegram.ext", + "telegram.constants", + "telegram.request", + ): + sys.modules[name] = mod + sys.modules["telegram.error"] = mod.error + + +# Run at collection time — before any test file's module-level imports. +_ensure_telegram_mock() diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index fedbdf4d1..e624a6734 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -613,6 +613,7 @@ class TestDetectVenvDir: # Not inside a virtualenv monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) dot_venv = tmp_path / ".venv" @@ -624,6 +625,7 @@ class TestDetectVenvDir: def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) venv = tmp_path / "venv" @@ -635,6 +637,7 @@ class TestDetectVenvDir: def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) (tmp_path / ".venv").mkdir() @@ -646,6 +649,7 @@ class TestDetectVenvDir: def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) result = gateway_cli._detect_venv_dir() diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 5ed6b9d54..cd0947708 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -163,7 +163,7 @@ class TestNormalizeProvider: class TestProviderLabel: def test_known_labels_and_auto(self): assert provider_label("anthropic") == "Anthropic" - assert provider_label("kimi") == "Kimi / Moonshot" + assert provider_label("kimi") == "Kimi / Kimi Coding Plan" assert provider_label("copilot") == "GitHub Copilot" assert provider_label("copilot-acp") == "GitHub Copilot ACP" assert provider_label("auto") == "Auto" diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py index 1ed72c951..14b2e0f2a 100644 --- a/tests/run_agent/test_invalid_context_length_warning.py +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -9,6 +9,8 @@ def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus- if custom_providers is not None: cfg["custom_providers"] = custom_providers + base_url = model_cfg.get("base_url", "") + with ( patch("hermes_cli.config.load_config", return_value=cfg), patch("agent.model_metadata.get_model_context_length", return_value=128_000), @@ -21,6 +23,7 @@ def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus- agent = AIAgent( model=model, api_key="test-key-1234567890", + base_url=base_url, quiet_mode=True, skip_context_files=True, skip_memory=True, diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c0c62b01b..1817e44a6 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -805,7 +805,10 @@ class TestCodexReasoningPreflight: reasoning_items = [i for i in normalized if i.get("type") == "reasoning"] assert len(reasoning_items) == 1 assert reasoning_items[0]["encrypted_content"] == "abc123encrypted" - assert reasoning_items[0]["id"] == "r_001" + # Note: "id" is intentionally excluded from normalized output — + # with store=False the API returns 404 on server-side id resolution. + # The id is only used for local deduplication via seen_ids. + assert "id" not in reasoning_items[0] assert reasoning_items[0]["summary"] == [{"type": "summary_text", "text": "Thinking about it"}] def test_reasoning_item_without_id(self, monkeypatch): diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 034dd29c0..655f32f52 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -46,6 +46,12 @@ def api_module(monkeypatch, tmp_path): module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) + # Ensure the gws CLI code path is taken even when the binary isn't + # installed (CI). Without this, calendar_list() falls through to the + # Python SDK path which imports ``googleapiclient`` — not in deps. + module._gws_binary = lambda: "/usr/bin/gws" + # Bypass authentication check — no real token file in CI. + module._ensure_authenticated = lambda: None return module @@ -124,35 +130,41 @@ def test_bridge_main_injects_token_env(bridge_module, tmp_path): assert captured["cmd"] == ["gws", "gmail", "+triage"] -def test_api_calendar_list_uses_agenda_by_default(api_module): - """calendar list without dates uses +agenda helper.""" +def test_api_calendar_list_uses_events_list(api_module): + """calendar_list calls _run_gws with events list + params.""" captured = {} def capture_run(cmd, **kwargs): captured["cmd"] = cmd - return MagicMock(returncode=0) + return MagicMock(returncode=0, stdout="{}", stderr="") args = api_module.argparse.Namespace( start="", end="", max=25, calendar="primary", func=api_module.calendar_list, ) - with patch.object(subprocess, "run", side_effect=capture_run): - with pytest.raises(SystemExit): - api_module.calendar_list(args) + with patch.object(api_module.subprocess, "run", side_effect=capture_run): + api_module.calendar_list(args) - gws_args = captured["cmd"][2:] # skip python + bridge path - assert "calendar" in gws_args - assert "+agenda" in gws_args - assert "--days" in gws_args + cmd = captured["cmd"] + # _gws_binary() returns "/usr/bin/gws", so cmd[0] is that binary + assert cmd[0] == "/usr/bin/gws" + assert "calendar" in cmd + assert "events" in cmd + assert "list" in cmd + assert "--params" in cmd + params = json.loads(cmd[cmd.index("--params") + 1]) + assert "timeMin" in params + assert "timeMax" in params + assert params["calendarId"] == "primary" def test_api_calendar_list_respects_date_range(api_module): - """calendar list with --start/--end uses raw events list API.""" + """calendar list with --start/--end passes correct time bounds.""" captured = {} def capture_run(cmd, **kwargs): captured["cmd"] = cmd - return MagicMock(returncode=0) + return MagicMock(returncode=0, stdout="{}", stderr="") args = api_module.argparse.Namespace( start="2026-04-01T00:00:00Z", @@ -162,14 +174,11 @@ def test_api_calendar_list_respects_date_range(api_module): func=api_module.calendar_list, ) - with patch.object(subprocess, "run", side_effect=capture_run): - with pytest.raises(SystemExit): - api_module.calendar_list(args) + with patch.object(api_module.subprocess, "run", side_effect=capture_run): + api_module.calendar_list(args) - gws_args = captured["cmd"][2:] - assert "events" in gws_args - assert "list" in gws_args - params_idx = gws_args.index("--params") - params = json.loads(gws_args[params_idx + 1]) + cmd = captured["cmd"] + params_idx = cmd.index("--params") + params = json.loads(cmd[params_idx + 1]) assert params["timeMin"] == "2026-04-01T00:00:00Z" assert params["timeMax"] == "2026-04-07T23:59:59Z"