diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index ffd438331..f6bf1fef8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -353,6 +353,9 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> """ if env_override: return env_override + # No key → nothing to infer from. Return default without inspecting. + if not api_key: + return default_url if api_key.startswith("sk-kimi-"): return KIMI_CODE_BASE_URL return default_url @@ -480,6 +483,14 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> if env_override: return env_override + # No API key set → don't probe (would fire N×M HTTPS requests with an + # empty Bearer token, all returning 401). This path is hit during + # auxiliary-client auto-detection when the user has no Z.AI credentials + # at all — the caller discards the result immediately, so the probe is + # pure latency for every AIAgent construction. + if not api_key: + return default_url + # Check provider-state cache for a previously-detected endpoint. auth_store = _load_auth_store() state = _load_provider_state(auth_store, "zai") or {} diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index f25fb972e..c2bdeeb02 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -416,6 +416,7 @@ class TestDiscordPlayTtsSkip: adapter.platform = Platform.DISCORD adapter.config = config adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_timeout_tasks = {} @@ -931,6 +932,7 @@ class TestDiscordVoiceChannelMethods: adapter.config = config adapter._client = MagicMock() adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_timeout_tasks = {} @@ -1712,6 +1714,7 @@ class TestVoiceTimeoutCleansRunnerState: adapter.platform = Platform.DISCORD adapter.config = config adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_timeout_tasks = {} @@ -1802,6 +1805,7 @@ class TestPlaybackTimeout: adapter.platform = Platform.DISCORD adapter.config = config adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_timeout_tasks = {} @@ -1983,6 +1987,7 @@ class TestVoiceChannelAwareness: config.token = "fake-token" adapter = object.__new__(DiscordAdapter) adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_receivers = {} @@ -2453,6 +2458,7 @@ class TestVoiceTTSPlayback: adapter.platform = Platform.DISCORD adapter.config = config adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_receivers = {} @@ -2633,6 +2639,7 @@ class TestUDPKeepalive: adapter.platform = Platform.DISCORD adapter.config = config adapter._voice_clients = {} + adapter._voice_locks = {} adapter._voice_text_channels = {} adapter._voice_sources = {} adapter._voice_receivers = {} diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index e4825599a..ff99264c7 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -169,6 +169,8 @@ class TestStreamingAccumulator: mock_create.return_value = mock_client agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", model="test/model", quiet_mode=True, skip_context_files=True, diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 9a982bb5b..183f9e514 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -198,12 +198,22 @@ class TestToolsetConsistency: assert inc in TOOLSETS, f"{name} includes unknown toolset '{inc}'" def test_hermes_platforms_share_core_tools(self): - """All hermes-* platform toolsets should have the same tools.""" + """All hermes-* platform toolsets share the same core tools. + + Platform-specific additions (e.g. ``discord_server`` on + hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top — + the invariant is that the core set is identical across platforms. + """ platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] tool_sets = [set(TOOLSETS[p]["tools"]) for p in platforms] - # All platform toolsets should be identical - for ts in tool_sets[1:]: - assert ts == tool_sets[0] + # All platforms must contain the shared core; platform-specific + # extras are OK (subset check, not equality). + core = set.intersection(*tool_sets) + for name, ts in zip(platforms, tool_sets): + assert core.issubset(ts), f"{name} is missing core tools: {core - ts}" + # Sanity: the shared core must be non-trivial (i.e. we didn't + # silently let a platform diverge so far that nothing is shared). + assert len(core) > 20, f"Suspiciously small shared core: {len(core)} tools" class TestPluginToolsets: diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index a7149529d..34fe67213 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -659,6 +659,28 @@ class TestCapabilityDetection: # --------------------------------------------------------------------------- class TestConfigAllowlist: + @pytest.fixture(autouse=True) + def _reset_tools_logger(self): + """Restore the ``tools`` logger level after cross-test pollution. + + ``AIAgent(quiet_mode=True)`` globally sets ``tools`` and + ``tools.*`` children to ``ERROR`` (see run_agent.py quiet_mode + block). xdist workers are persistent, so a streaming test on the + same worker will silence WARNING-level logs from + ``tools.discord_tool`` for every test that follows. Reset here so + ``caplog`` can capture warnings regardless of worker history. + """ + import logging as _logging + _prev_tools = _logging.getLogger("tools").level + _prev_dt = _logging.getLogger("tools.discord_tool").level + _logging.getLogger("tools").setLevel(_logging.NOTSET) + _logging.getLogger("tools.discord_tool").setLevel(_logging.NOTSET) + try: + yield + finally: + _logging.getLogger("tools").setLevel(_prev_tools) + _logging.getLogger("tools.discord_tool").setLevel(_prev_dt) + def test_empty_string_returns_none(self, monkeypatch): """Empty config means no allowlist — all actions visible.""" monkeypatch.setattr(