Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
# Conflicts: # gateway/platforms/base.py # gateway/run.py # tests/gateway/test_command_bypass_active_session.py
This commit is contained in:
@@ -68,9 +68,22 @@ class TestInitialize:
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
caps = resp.agent_capabilities
|
||||
assert isinstance(caps, AgentCapabilities)
|
||||
assert caps.load_session is True
|
||||
assert caps.session_capabilities is not None
|
||||
assert caps.session_capabilities.fork is not None
|
||||
assert caps.session_capabilities.list is not None
|
||||
assert caps.session_capabilities.resume is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_capabilities_wire_format(self, agent):
|
||||
"""Verify the JSON wire format uses correct aliases so ACP clients see the right keys."""
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
payload = resp.agent_capabilities.model_dump(by_alias=True, exclude_none=True)
|
||||
assert payload["loadSession"] is True
|
||||
session_caps = payload["sessionCapabilities"]
|
||||
assert "fork" in session_caps
|
||||
assert "list" in session_caps
|
||||
assert "resume" in session_caps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -410,6 +423,37 @@ class TestPrompt:
|
||||
update = last_call[1].get("update") or last_call[0][1]
|
||||
assert update.session_update == "agent_message_chunk"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_populates_usage_from_top_level_run_conversation_fields(self, agent):
|
||||
"""ACP should map top-level token fields into PromptResponse.usage."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "usage attached",
|
||||
"messages": [],
|
||||
"prompt_tokens": 123,
|
||||
"completion_tokens": 45,
|
||||
"total_tokens": 168,
|
||||
"reasoning_tokens": 7,
|
||||
"cache_read_tokens": 11,
|
||||
})
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="show usage")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, PromptResponse)
|
||||
assert resp.usage is not None
|
||||
assert resp.usage.input_tokens == 123
|
||||
assert resp.usage.output_tokens == 45
|
||||
assert resp.usage.total_tokens == 168
|
||||
assert resp.usage.thought_tokens == 7
|
||||
assert resp.usage.cached_read_tokens == 11
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_cancelled_returns_cancelled_stop_reason(self, agent):
|
||||
"""If cancel is called during prompt, stop_reason should be 'cancelled'."""
|
||||
|
||||
@@ -17,7 +17,6 @@ from agent.anthropic_adapter import (
|
||||
build_anthropic_kwargs,
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
get_anthropic_token_source,
|
||||
is_claude_code_token_valid,
|
||||
normalize_anthropic_response,
|
||||
normalize_model_name,
|
||||
@@ -40,8 +39,13 @@ class TestIsOAuthToken:
|
||||
assert _is_oauth_token("sk-ant-api03-abcdef1234567890") is False
|
||||
|
||||
def test_managed_key(self):
|
||||
# Managed keys from ~/.claude.json are NOT regular API keys
|
||||
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is True
|
||||
# Managed keys from ~/.claude.json without a recognisable Anthropic
|
||||
# prefix are not positively identified as OAuth. They enter the system
|
||||
# via diagnostics-only read_claude_managed_key(), not via
|
||||
# resolve_anthropic_token(), so they don't reach the OAuth gate in
|
||||
# practice. Third-party provider keys (MiniMax, Alibaba) also lack
|
||||
# the sk-ant- prefix and must NOT be treated as OAuth.
|
||||
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is False
|
||||
|
||||
def test_jwt_token(self):
|
||||
# JWTs from OAuth flow
|
||||
@@ -81,6 +85,9 @@ class TestBuildAnthropicClient:
|
||||
build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com")
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
@@ -92,7 +99,20 @@ class TestBuildAnthropicClient:
|
||||
assert kwargs["auth_token"] == "minimax-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
"minimax-cn-secret-123",
|
||||
base_url="https://api.minimaxi.com/anthropic",
|
||||
)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["auth_token"] == "minimax-cn-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
|
||||
@@ -165,15 +185,6 @@ class TestResolveAnthropicToken:
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
(tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
|
||||
assert get_anthropic_token_source("sk-ant-api03-primary") == "claude_json_primary_api_key"
|
||||
|
||||
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"""Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.auxiliary_client import (
|
||||
get_text_auxiliary_client,
|
||||
get_vision_auxiliary_client,
|
||||
get_available_vision_backends,
|
||||
resolve_vision_provider_client,
|
||||
resolve_provider_client,
|
||||
auxiliary_max_tokens_param,
|
||||
call_llm,
|
||||
async_call_llm,
|
||||
_read_codex_access_token,
|
||||
_get_auxiliary_provider,
|
||||
_get_provider_chain,
|
||||
_is_payment_error,
|
||||
_try_payment_fallback,
|
||||
_resolve_forced_provider,
|
||||
_resolve_auto,
|
||||
)
|
||||
|
||||
@@ -660,19 +660,23 @@ class TestGetTextAuxiliaryClient:
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self):
|
||||
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.OpenAI") as mock_openai:
|
||||
client, model = get_text_auxiliary_client()
|
||||
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-5.3-codex"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "https://api.openai.com/v1"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "sk-test"
|
||||
|
||||
|
||||
class TestVisionClientFallback:
|
||||
"""Vision client auto mode resolves known-good multimodal backends."""
|
||||
|
||||
def test_vision_returns_none_without_any_credentials(self):
|
||||
with (
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
patch("agent.auxiliary_client._try_anthropic", return_value=(None, None)),
|
||||
):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch):
|
||||
"""Active provider appears in available backends when credentials exist."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
||||
@@ -754,6 +758,54 @@ class TestAuxiliaryPoolAwareness:
|
||||
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
|
||||
assert call_kwargs["default_headers"]["Editor-Version"]
|
||||
|
||||
def test_copilot_responses_api_model_wrapped_in_codex_client(self, monkeypatch):
|
||||
"""Copilot GPT-5+ models (needing Responses API) are wrapped in CodexAuxiliaryClient."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "test-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
),
|
||||
patch("agent.auxiliary_client.OpenAI"),
|
||||
):
|
||||
client, model = resolve_provider_client("copilot", model="gpt-5.4-mini")
|
||||
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-5.4-mini"
|
||||
|
||||
def test_copilot_chat_completions_model_not_wrapped(self, monkeypatch):
|
||||
"""Copilot models using Chat Completions are returned as plain OpenAI clients."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "test-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
client, model = resolve_provider_client("copilot", model="gpt-4.1-mini")
|
||||
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert not isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-4.1-mini"
|
||||
# Should be the raw mock OpenAI client
|
||||
assert client is mock_openai.return_value
|
||||
|
||||
def test_vision_auto_uses_active_provider_as_fallback(self, monkeypatch):
|
||||
"""When no OpenRouter/Nous available, vision auto falls back to active provider."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
||||
@@ -800,43 +852,6 @@ class TestAuxiliaryPoolAwareness:
|
||||
assert client is not None
|
||||
assert provider == "custom:local"
|
||||
|
||||
def test_vision_direct_endpoint_override(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "vision-key")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert model == "vision-model"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:4567/v1"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "vision-key"
|
||||
|
||||
def test_vision_direct_endpoint_without_key_uses_placeholder(self, monkeypatch):
|
||||
"""Vision endpoint without API key should use 'no-key-required' placeholder."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is not None
|
||||
assert model == "vision-model"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
|
||||
|
||||
def test_vision_uses_openrouter_when_available(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
assert client is not None
|
||||
|
||||
def test_vision_uses_nous_when_available(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \
|
||||
patch("agent.auxiliary_client.OpenAI"):
|
||||
mock_nous.return_value = {"access_token": "nous-tok"}
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
assert client is not None
|
||||
|
||||
def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch):
|
||||
config = {
|
||||
"auxiliary": {
|
||||
@@ -862,53 +877,6 @@ class TestAuxiliaryPoolAwareness:
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "gemini-key"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):
|
||||
"""When explicitly forced to 'main', vision CAN use custom endpoint."""
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"default": "my-local-model",
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is not None
|
||||
assert model == "my-local-model"
|
||||
|
||||
def test_vision_forced_main_returns_none_without_creds(self, monkeypatch):
|
||||
"""Forced main with no credentials still returns None."""
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
# Clear client cache to avoid stale entries from previous tests
|
||||
from agent.auxiliary_client import _client_cache
|
||||
_client_cache.clear()
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_main_provider", return_value=""), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value=""), \
|
||||
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
|
||||
patch("agent.auxiliary_client._resolve_custom_runtime", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_vision_forced_codex(self, monkeypatch, codex_auth_dir):
|
||||
"""When forced to 'codex', vision uses Codex OAuth."""
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "codex")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI"):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-5.2-codex"
|
||||
|
||||
|
||||
class TestGetAuxiliaryProvider:
|
||||
@@ -948,122 +916,6 @@ class TestGetAuxiliaryProvider:
|
||||
assert _get_auxiliary_provider("web_extract") == "main"
|
||||
|
||||
|
||||
class TestResolveForcedProvider:
|
||||
"""Tests for _resolve_forced_provider with explicit provider selection."""
|
||||
|
||||
def test_forced_openrouter(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("openrouter")
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
assert client is not None
|
||||
|
||||
def test_forced_openrouter_no_key(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None):
|
||||
client, model = _resolve_forced_provider("openrouter")
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_forced_nous(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \
|
||||
patch("agent.auxiliary_client.OpenAI"):
|
||||
mock_nous.return_value = {"access_token": "nous-tok"}
|
||||
client, model = _resolve_forced_provider("nous")
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
assert client is not None
|
||||
|
||||
def test_forced_nous_not_configured(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None):
|
||||
client, model = _resolve_forced_provider("nous")
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_forced_main_uses_custom(self, monkeypatch):
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "http://local:8080/v1",
|
||||
"default": "my-local-model",
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
assert model == "my-local-model"
|
||||
|
||||
def test_forced_main_uses_config_saved_custom_endpoint(self, monkeypatch):
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "http://local:8080/v1",
|
||||
"default": "my-local-model",
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
|
||||
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)), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
assert client is not None
|
||||
assert model == "my-local-model"
|
||||
call_kwargs = mock_openai.call_args
|
||||
assert call_kwargs.kwargs["base_url"] == "http://local:8080/v1"
|
||||
|
||||
def test_forced_main_skips_openrouter_nous(self, monkeypatch):
|
||||
"""Even if OpenRouter key is set, 'main' skips it."""
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "http://local:8080/v1",
|
||||
"default": "my-local-model",
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
# Should use custom endpoint, not OpenRouter
|
||||
assert model == "my-local-model"
|
||||
|
||||
def test_forced_main_falls_to_codex(self, codex_auth_dir, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI"):
|
||||
client, model = _resolve_forced_provider("main")
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-5.2-codex"
|
||||
|
||||
def test_forced_codex(self, codex_auth_dir, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI"):
|
||||
client, model = _resolve_forced_provider("codex")
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "gpt-5.2-codex"
|
||||
|
||||
def test_forced_codex_no_token(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = _resolve_forced_provider("codex")
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
def test_forced_unknown_returns_none(self, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = _resolve_forced_provider("invalid-provider")
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
|
||||
class TestTaskSpecificOverrides:
|
||||
"""Integration tests for per-task provider routing via get_text_auxiliary_client(task=...)."""
|
||||
|
||||
@@ -1272,8 +1124,8 @@ class TestCallLlmPaymentFallback:
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
def test_402_triggers_fallback(self, monkeypatch):
|
||||
"""When the primary provider returns 402, call_llm tries the next one."""
|
||||
def test_402_triggers_fallback_when_auto(self, monkeypatch):
|
||||
"""When provider is auto and returns 402, call_llm tries the next one."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
@@ -1286,7 +1138,7 @@ class TestCallLlmPaymentFallback:
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
|
||||
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb:
|
||||
result = call_llm(
|
||||
@@ -1295,13 +1147,62 @@ class TestCallLlmPaymentFallback:
|
||||
)
|
||||
|
||||
assert result is fallback_response
|
||||
mock_fb.assert_called_once_with("openrouter", "compression")
|
||||
mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
|
||||
# Fallback call should use the fallback model
|
||||
fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs
|
||||
assert fb_kwargs["model"] == "gpt-5.2-codex"
|
||||
|
||||
def test_402_no_fallback_when_explicit_provider(self, monkeypatch):
|
||||
"""When provider is explicitly configured (not auto), 402 should NOT fallback (#7559)."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_402_error()
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "local-model")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("custom", "local-model", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
|
||||
with pytest.raises(Exception, match="insufficient credits"):
|
||||
call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
# Fallback should NOT be attempted when provider is explicit
|
||||
mock_fb.assert_not_called()
|
||||
|
||||
def test_connection_error_triggers_fallback_when_auto(self, monkeypatch):
|
||||
"""Connection errors also trigger fallback when provider is auto."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
conn_err = Exception("Connection refused")
|
||||
conn_err.status_code = None
|
||||
primary_client.chat.completions.create.side_effect = conn_err
|
||||
|
||||
fallback_client = MagicMock()
|
||||
fallback_response = MagicMock()
|
||||
fallback_client.chat.completions.create.return_value = fallback_response
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "model")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "model", None, None, None)), \
|
||||
patch("agent.auxiliary_client._is_connection_error", return_value=True), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fallback_client, "fb-model", "nous")) as mock_fb:
|
||||
result = call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert result is fallback_response
|
||||
mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
|
||||
|
||||
def test_non_payment_error_not_caught(self, monkeypatch):
|
||||
"""Non-payment errors (500, connection, etc.) should NOT trigger fallback."""
|
||||
"""Non-payment/non-connection errors (500) should NOT trigger fallback."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
@@ -1312,7 +1213,7 @@ class TestCallLlmPaymentFallback:
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)):
|
||||
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)):
|
||||
with pytest.raises(Exception, match="Internal Server Error"):
|
||||
call_llm(
|
||||
task="compression",
|
||||
@@ -1329,7 +1230,7 @@ class TestCallLlmPaymentFallback:
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
|
||||
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(None, None, "")):
|
||||
with pytest.raises(Exception, match="insufficient credits"):
|
||||
@@ -1337,3 +1238,325 @@ class TestCallLlmPaymentFallback:
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate: _resolve_api_key_provider must skip anthropic when not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
|
||||
"""_resolve_api_key_provider must not try anthropic when user never configured it."""
|
||||
from collections import OrderedDict
|
||||
from hermes_cli.auth import ProviderConfig
|
||||
|
||||
# Build a minimal registry with only "anthropic" so the loop is guaranteed
|
||||
# to reach it without being short-circuited by earlier providers.
|
||||
fake_registry = OrderedDict({
|
||||
"anthropic": ProviderConfig(
|
||||
id="anthropic",
|
||||
name="Anthropic",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.anthropic.com",
|
||||
api_key_env_vars=("ANTHROPIC_API_KEY",),
|
||||
),
|
||||
})
|
||||
|
||||
called = []
|
||||
|
||||
def mock_try_anthropic():
|
||||
called.append("anthropic")
|
||||
return None, None
|
||||
|
||||
monkeypatch.setattr("agent.auxiliary_client._try_anthropic", mock_try_anthropic)
|
||||
monkeypatch.setattr("hermes_cli.auth.PROVIDER_REGISTRY", fake_registry)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.is_provider_explicitly_configured",
|
||||
lambda pid: False,
|
||||
)
|
||||
|
||||
from agent.auxiliary_client import _resolve_api_key_provider
|
||||
_resolve_api_key_provider()
|
||||
|
||||
assert "anthropic" not in called, \
|
||||
"_try_anthropic() should not be called when anthropic is not explicitly configured"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# model="default" elimination (#7512)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelDefaultElimination:
|
||||
"""_resolve_api_key_provider must skip providers without known aux models."""
|
||||
|
||||
def test_unknown_provider_skipped(self, monkeypatch):
|
||||
"""Providers not in _API_KEY_PROVIDER_AUX_MODELS are skipped, not sent model='default'."""
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
# Verify our known providers have entries
|
||||
assert "gemini" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "kimi-coding" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
# A random provider_id not in the dict should return None
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS.get("totally-unknown-provider") is None
|
||||
|
||||
def test_known_provider_gets_real_model(self):
|
||||
"""Known providers get a real model name, not 'default'."""
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
|
||||
for provider_id, model in _API_KEY_PROVIDER_AUX_MODELS.items():
|
||||
assert model != "default", f"{provider_id} should not map to 'default'"
|
||||
assert isinstance(model, str) and model.strip(), \
|
||||
f"{provider_id} should have a non-empty model string"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _try_payment_fallback reason parameter (#7512 bug 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTryPaymentFallbackReason:
|
||||
"""_try_payment_fallback uses the reason parameter in log messages."""
|
||||
|
||||
def test_reason_parameter_passed_through(self, monkeypatch):
|
||||
"""The reason= parameter is accepted without error."""
|
||||
from agent.auxiliary_client import _try_payment_fallback
|
||||
|
||||
# Mock the provider chain to return nothing
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._get_provider_chain",
|
||||
lambda: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
lambda: "",
|
||||
)
|
||||
|
||||
client, model, label = _try_payment_fallback(
|
||||
"openrouter", task="compression", reason="connection error"
|
||||
)
|
||||
assert client is None
|
||||
assert label == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_connection_error coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsConnectionError:
|
||||
"""Tests for _is_connection_error detection."""
|
||||
|
||||
def test_connection_refused(self):
|
||||
from agent.auxiliary_client import _is_connection_error
|
||||
err = Exception("Connection refused")
|
||||
assert _is_connection_error(err) is True
|
||||
|
||||
def test_timeout(self):
|
||||
from agent.auxiliary_client import _is_connection_error
|
||||
err = Exception("Request timed out.")
|
||||
assert _is_connection_error(err) is True
|
||||
|
||||
def test_dns_failure(self):
|
||||
from agent.auxiliary_client import _is_connection_error
|
||||
err = Exception("Name or service not known")
|
||||
assert _is_connection_error(err) is True
|
||||
|
||||
def test_normal_api_error_not_connection(self):
|
||||
from agent.auxiliary_client import _is_connection_error
|
||||
err = Exception("Bad Request: invalid model")
|
||||
err.status_code = 400
|
||||
assert _is_connection_error(err) is False
|
||||
|
||||
def test_500_not_connection(self):
|
||||
from agent.auxiliary_client import _is_connection_error
|
||||
err = Exception("Internal Server Error")
|
||||
err.status_code = 500
|
||||
assert _is_connection_error(err) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# async_call_llm payment / connection fallback (#7512 bug 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncCallLlmFallback:
|
||||
"""async_call_llm mirrors call_llm fallback behavior."""
|
||||
|
||||
def _make_402_error(self, msg="Payment Required: insufficient credits"):
|
||||
exc = Exception(msg)
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_402_triggers_async_fallback_when_auto(self, monkeypatch):
|
||||
"""When provider is auto and returns 402, async_call_llm tries fallback."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create = AsyncMock(
|
||||
side_effect=self._make_402_error())
|
||||
|
||||
# Fallback client (sync) returned by _try_payment_fallback
|
||||
fb_sync_client = MagicMock()
|
||||
fb_async_client = MagicMock()
|
||||
fb_response = MagicMock()
|
||||
fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fb_sync_client, "gpt-5.2-codex", "openai-codex")) as mock_fb, \
|
||||
patch("agent.auxiliary_client._to_async_client",
|
||||
return_value=(fb_async_client, "gpt-5.2-codex")):
|
||||
result = await async_call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert result is fb_response
|
||||
mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_402_no_async_fallback_when_explicit(self, monkeypatch):
|
||||
"""When provider is explicit, 402 should NOT trigger async fallback."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create = AsyncMock(
|
||||
side_effect=self._make_402_error())
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "local-model")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("custom", "local-model", None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
|
||||
with pytest.raises(Exception, match="insufficient credits"):
|
||||
await async_call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
mock_fb.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_error_triggers_async_fallback(self, monkeypatch):
|
||||
"""Connection errors trigger async fallback when provider is auto."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
conn_err = Exception("Connection refused")
|
||||
conn_err.status_code = None
|
||||
primary_client.chat.completions.create = AsyncMock(side_effect=conn_err)
|
||||
|
||||
fb_sync_client = MagicMock()
|
||||
fb_async_client = MagicMock()
|
||||
fb_response = MagicMock()
|
||||
fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "model")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "model", None, None, None)), \
|
||||
patch("agent.auxiliary_client._is_connection_error", return_value=True), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fb_sync_client, "fb-model", "nous")) as mock_fb, \
|
||||
patch("agent.auxiliary_client._to_async_client",
|
||||
return_value=(fb_async_client, "fb-model")):
|
||||
result = await async_call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert result is fb_response
|
||||
mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
|
||||
class TestStaleBaseUrlWarning:
|
||||
"""_resolve_auto() warns when OPENAI_BASE_URL conflicts with config provider (#5161)."""
|
||||
|
||||
def test_warns_when_openai_base_url_set_with_named_provider(self, monkeypatch, caplog):
|
||||
"""Warning fires when OPENAI_BASE_URL is set but provider is a named provider."""
|
||||
import agent.auxiliary_client as mod
|
||||
# Reset the module-level flag so the warning fires
|
||||
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
|
||||
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
||||
_resolve_auto()
|
||||
|
||||
assert any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
|
||||
"Expected a warning about stale OPENAI_BASE_URL"
|
||||
assert mod._stale_base_url_warned is True
|
||||
|
||||
def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):
|
||||
"""No warning when the provider is 'custom' — OPENAI_BASE_URL is expected."""
|
||||
import agent.auxiliary_client as mod
|
||||
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
|
||||
patch("agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("http://localhost:11434/v1", "test-key", None)), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai, \
|
||||
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
||||
mock_openai.return_value = MagicMock()
|
||||
_resolve_auto()
|
||||
|
||||
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
|
||||
"Should NOT warn when provider is 'custom'"
|
||||
|
||||
def test_no_warning_when_provider_is_named_custom(self, monkeypatch, caplog):
|
||||
"""No warning when the provider is 'custom:myname' — base_url comes from config."""
|
||||
import agent.auxiliary_client as mod
|
||||
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="custom:ollama-local"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
|
||||
patch("agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(MagicMock(), "llama3")), \
|
||||
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
||||
_resolve_auto()
|
||||
|
||||
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
|
||||
"Should NOT warn when provider is 'custom:*'"
|
||||
|
||||
def test_no_warning_when_openai_base_url_not_set(self, monkeypatch, caplog):
|
||||
"""No warning when OPENAI_BASE_URL is absent."""
|
||||
import agent.auxiliary_client as mod
|
||||
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
|
||||
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
||||
_resolve_auto()
|
||||
|
||||
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
|
||||
"Should NOT warn when OPENAI_BASE_URL is not set"
|
||||
|
||||
def test_warning_only_fires_once(self, monkeypatch, caplog):
|
||||
"""Warning is suppressed after the first invocation."""
|
||||
import agent.auxiliary_client as mod
|
||||
monkeypatch.setattr(mod, "_stale_base_url_warned", False)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
|
||||
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
|
||||
patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
|
||||
caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
|
||||
_resolve_auto()
|
||||
caplog.clear()
|
||||
_resolve_auto()
|
||||
|
||||
assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
|
||||
"Warning should not fire a second time"
|
||||
|
||||
@@ -12,6 +12,17 @@ def _isolate(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
for env_var in (
|
||||
"AUXILIARY_VISION_PROVIDER",
|
||||
"AUXILIARY_VISION_MODEL",
|
||||
"AUXILIARY_VISION_BASE_URL",
|
||||
"AUXILIARY_VISION_API_KEY",
|
||||
"CONTEXT_VISION_PROVIDER",
|
||||
"CONTEXT_VISION_MODEL",
|
||||
"CONTEXT_VISION_BASE_URL",
|
||||
"CONTEXT_VISION_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
# Write a minimal config so load_config doesn't fail
|
||||
(hermes_home / "config.yaml").write_text("model:\n default: test-model\n")
|
||||
|
||||
@@ -149,3 +160,83 @@ class TestResolveProviderClientNamedCustom:
|
||||
# "coffee" doesn't exist in custom_providers
|
||||
client, model = resolve_provider_client("coffee", "test")
|
||||
assert client is None
|
||||
|
||||
|
||||
class TestResolveProviderClientModelNormalization:
|
||||
"""Direct-provider auxiliary routing should normalize models like main runtime."""
|
||||
|
||||
def test_matching_native_prefix_is_stripped_for_main_provider(self, tmp_path):
|
||||
_write_config(tmp_path, {
|
||||
"model": {"default": "zai/glm-5.1", "provider": "zai"},
|
||||
})
|
||||
with (
|
||||
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
||||
"api_key": "glm-key",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
}),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
client, model = resolve_provider_client("main", "zai/glm-5.1")
|
||||
|
||||
assert client is not None
|
||||
assert model == "glm-5.1"
|
||||
|
||||
def test_non_matching_prefix_is_preserved_for_direct_provider(self, tmp_path):
|
||||
_write_config(tmp_path, {
|
||||
"model": {"default": "zai/glm-5.1", "provider": "zai"},
|
||||
})
|
||||
with (
|
||||
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
||||
"api_key": "glm-key",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
}),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
client, model = resolve_provider_client("zai", "google/gemini-2.5-pro")
|
||||
|
||||
assert client is not None
|
||||
assert model == "google/gemini-2.5-pro"
|
||||
|
||||
def test_aggregator_vendor_slug_is_preserved(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
client, model = resolve_provider_client(
|
||||
"openrouter", "anthropic/claude-sonnet-4.6"
|
||||
)
|
||||
|
||||
assert client is not None
|
||||
assert model == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
class TestResolveVisionProviderClientModelNormalization:
|
||||
"""Vision auto-routing should reuse the same provider-specific normalization."""
|
||||
|
||||
def test_vision_auto_strips_matching_main_provider_prefix(self, tmp_path):
|
||||
_write_config(tmp_path, {
|
||||
"model": {"default": "zai/glm-5.1", "provider": "zai"},
|
||||
})
|
||||
with (
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
|
||||
"api_key": "glm-key",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
}),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "zai"
|
||||
assert client is not None
|
||||
assert model == "glm-5.1"
|
||||
|
||||
@@ -38,16 +38,6 @@ class TestShouldCompress:
|
||||
assert compressor.should_compress(prompt_tokens=50000) is False
|
||||
|
||||
|
||||
class TestShouldCompressPreflight:
|
||||
def test_short_messages(self, compressor):
|
||||
msgs = [{"role": "user", "content": "short"}]
|
||||
assert compressor.should_compress_preflight(msgs) is False
|
||||
|
||||
def test_long_messages(self, compressor):
|
||||
# Each message ~100k chars / 4 = 25k tokens, need >85k threshold
|
||||
msgs = [{"role": "user", "content": "x" * 400000}]
|
||||
assert compressor.should_compress_preflight(msgs) is True
|
||||
|
||||
|
||||
class TestUpdateFromResponse:
|
||||
def test_updates_fields(self, compressor):
|
||||
@@ -58,27 +48,12 @@ class TestUpdateFromResponse:
|
||||
})
|
||||
assert compressor.last_prompt_tokens == 5000
|
||||
assert compressor.last_completion_tokens == 1000
|
||||
assert compressor.last_total_tokens == 6000
|
||||
|
||||
def test_missing_fields_default_zero(self, compressor):
|
||||
compressor.update_from_response({})
|
||||
assert compressor.last_prompt_tokens == 0
|
||||
|
||||
|
||||
class TestGetStatus:
|
||||
def test_returns_expected_keys(self, compressor):
|
||||
status = compressor.get_status()
|
||||
assert "last_prompt_tokens" in status
|
||||
assert "threshold_tokens" in status
|
||||
assert "context_length" in status
|
||||
assert "usage_percent" in status
|
||||
assert "compression_count" in status
|
||||
|
||||
def test_usage_percent_calculation(self, compressor):
|
||||
compressor.last_prompt_tokens = 50000
|
||||
status = compressor.get_status()
|
||||
assert status["usage_percent"] == 50.0
|
||||
|
||||
|
||||
class TestCompress:
|
||||
def _make_messages(self, n):
|
||||
|
||||
250
tests/agent/test_context_engine.py
Normal file
250
tests/agent/test_context_engine.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Tests for the ContextEngine ABC and plugin slot."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A minimal concrete engine for testing the ABC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StubEngine(ContextEngine):
|
||||
"""Minimal engine that satisfies the ABC without doing real work."""
|
||||
|
||||
def __init__(self, context_length=200000, threshold_pct=0.50):
|
||||
self.context_length = context_length
|
||||
self.threshold_tokens = int(context_length * threshold_pct)
|
||||
self._compress_called = False
|
||||
self._tools_called = []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "stub"
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]) -> None:
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", 0)
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
|
||||
return tokens >= self.threshold_tokens
|
||||
|
||||
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
|
||||
self._compress_called = True
|
||||
self.compression_count += 1
|
||||
# Trivial: just return as-is
|
||||
return messages
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "stub_search",
|
||||
"description": "Search the stub engine",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
|
||||
def handle_tool_call(self, name: str, args: Dict[str, Any]) -> str:
|
||||
self._tools_called.append(name)
|
||||
return json.dumps({"ok": True, "tool": name})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ABC contract tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContextEngineABC:
|
||||
"""Verify the ABC enforces the required interface."""
|
||||
|
||||
def test_cannot_instantiate_abc_directly(self):
|
||||
with pytest.raises(TypeError):
|
||||
ContextEngine()
|
||||
|
||||
def test_missing_methods_raises(self):
|
||||
"""A subclass missing required methods cannot be instantiated."""
|
||||
class Incomplete(ContextEngine):
|
||||
@property
|
||||
def name(self):
|
||||
return "incomplete"
|
||||
with pytest.raises(TypeError):
|
||||
Incomplete()
|
||||
|
||||
def test_stub_engine_satisfies_abc(self):
|
||||
engine = StubEngine()
|
||||
assert isinstance(engine, ContextEngine)
|
||||
assert engine.name == "stub"
|
||||
|
||||
def test_compressor_is_context_engine(self):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
|
||||
assert isinstance(c, ContextEngine)
|
||||
assert c.name == "compressor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default method behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDefaults:
|
||||
"""Verify ABC default implementations work correctly."""
|
||||
|
||||
def test_default_tool_schemas_empty(self):
|
||||
engine = StubEngine()
|
||||
# StubEngine overrides this, so test the base via super
|
||||
assert ContextEngine.get_tool_schemas(engine) == []
|
||||
|
||||
def test_default_handle_tool_call_returns_error(self):
|
||||
engine = StubEngine()
|
||||
result = ContextEngine.handle_tool_call(engine, "unknown", {})
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
|
||||
def test_default_get_status(self):
|
||||
engine = StubEngine()
|
||||
engine.last_prompt_tokens = 50000
|
||||
status = engine.get_status()
|
||||
assert status["last_prompt_tokens"] == 50000
|
||||
assert status["context_length"] == 200000
|
||||
assert status["threshold_tokens"] == 100000
|
||||
assert 0 < status["usage_percent"] <= 100
|
||||
|
||||
def test_on_session_reset(self):
|
||||
engine = StubEngine()
|
||||
engine.last_prompt_tokens = 999
|
||||
engine.compression_count = 3
|
||||
engine.on_session_reset()
|
||||
assert engine.last_prompt_tokens == 0
|
||||
assert engine.compression_count == 0
|
||||
|
||||
def test_should_compress_preflight_default_false(self):
|
||||
engine = StubEngine()
|
||||
assert engine.should_compress_preflight([]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StubEngine behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStubEngine:
|
||||
|
||||
def test_should_compress(self):
|
||||
engine = StubEngine(context_length=100000, threshold_pct=0.50)
|
||||
assert not engine.should_compress(40000)
|
||||
assert engine.should_compress(50000)
|
||||
assert engine.should_compress(60000)
|
||||
|
||||
def test_compress_tracks_count(self):
|
||||
engine = StubEngine()
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
result = engine.compress(msgs)
|
||||
assert result == msgs
|
||||
assert engine._compress_called
|
||||
assert engine.compression_count == 1
|
||||
|
||||
def test_tool_schemas(self):
|
||||
engine = StubEngine()
|
||||
schemas = engine.get_tool_schemas()
|
||||
assert len(schemas) == 1
|
||||
assert schemas[0]["name"] == "stub_search"
|
||||
|
||||
def test_handle_tool_call(self):
|
||||
engine = StubEngine()
|
||||
result = engine.handle_tool_call("stub_search", {})
|
||||
assert json.loads(result)["ok"] is True
|
||||
assert "stub_search" in engine._tools_called
|
||||
|
||||
def test_update_from_response(self):
|
||||
engine = StubEngine()
|
||||
engine.update_from_response({"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200})
|
||||
assert engine.last_prompt_tokens == 1000
|
||||
assert engine.last_completion_tokens == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextCompressor session reset via ABC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompressorSessionReset:
|
||||
"""Verify ContextCompressor.on_session_reset() clears all state."""
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
|
||||
c.last_prompt_tokens = 50000
|
||||
c.compression_count = 3
|
||||
c._previous_summary = "some old summary"
|
||||
c._context_probed = True
|
||||
c._context_probe_persistable = True
|
||||
|
||||
c.on_session_reset()
|
||||
|
||||
assert c.last_prompt_tokens == 0
|
||||
assert c.last_completion_tokens == 0
|
||||
assert c.last_total_tokens == 0
|
||||
assert c.compression_count == 0
|
||||
assert c._context_probed is False
|
||||
assert c._context_probe_persistable is False
|
||||
assert c._previous_summary is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin slot (PluginManager integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPluginContextEngineSlot:
|
||||
"""Test register_context_engine on PluginContext."""
|
||||
|
||||
def test_register_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-lcm")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
engine = StubEngine()
|
||||
ctx.register_context_engine(engine)
|
||||
|
||||
assert mgr._context_engine is engine
|
||||
assert mgr._context_engine.name == "stub"
|
||||
|
||||
def test_reject_second_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-lcm")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
engine1 = StubEngine()
|
||||
engine2 = StubEngine()
|
||||
ctx.register_context_engine(engine1)
|
||||
ctx.register_context_engine(engine2) # should be rejected
|
||||
|
||||
assert mgr._context_engine is engine1
|
||||
|
||||
def test_reject_non_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-bad")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_context_engine("not an engine")
|
||||
assert mgr._context_engine is None
|
||||
|
||||
def test_get_plugin_context_engine(self):
|
||||
from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest, get_plugin_context_engine, _plugin_manager
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
# Inject a test manager
|
||||
old_mgr = plugins_mod._plugin_manager
|
||||
try:
|
||||
mgr = PluginManager()
|
||||
plugins_mod._plugin_manager = mgr
|
||||
|
||||
assert get_plugin_context_engine() is None
|
||||
|
||||
engine = StubEngine()
|
||||
mgr._context_engine = engine
|
||||
assert get_plugin_context_engine() is engine
|
||||
finally:
|
||||
plugins_mod._plugin_manager = old_mgr
|
||||
@@ -83,6 +83,24 @@ def test_parse_references_strips_trailing_punctuation():
|
||||
assert refs[1].target == "https://example.com/docs"
|
||||
|
||||
|
||||
def test_parse_quoted_references_with_spaces_and_preserve_unquoted_ranges():
|
||||
from agent.context_references import parse_context_references
|
||||
|
||||
refs = parse_context_references(
|
||||
'review @file:"C:\\Users\\Simba\\My Project\\main.py":7-9 '
|
||||
'and @folder:"docs and specs" plus @file:src/main.py:1-2'
|
||||
)
|
||||
|
||||
assert [ref.kind for ref in refs] == ["file", "folder", "file"]
|
||||
assert refs[0].target == r"C:\Users\Simba\My Project\main.py"
|
||||
assert refs[0].line_start == 7
|
||||
assert refs[0].line_end == 9
|
||||
assert refs[1].target == "docs and specs"
|
||||
assert refs[2].target == "src/main.py"
|
||||
assert refs[2].line_start == 1
|
||||
assert refs[2].line_end == 2
|
||||
|
||||
|
||||
def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
@@ -106,6 +124,30 @@ def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_quoted_file_reference_with_spaces(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
workspace = tmp_path / "repo"
|
||||
folder = workspace / "docs and specs"
|
||||
folder.mkdir(parents=True)
|
||||
file_path = folder / "release notes.txt"
|
||||
file_path.write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
|
||||
|
||||
result = preprocess_context_references(
|
||||
'Review @file:"docs and specs/release notes.txt":2-3',
|
||||
cwd=workspace,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert result.message.startswith("Review")
|
||||
assert "line 1" not in result.message
|
||||
assert "line 2" in result.message
|
||||
assert "line 3" in result.message
|
||||
assert "release notes.txt" in result.message
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_git_diff_staged_and_log(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
|
||||
@@ -567,6 +567,7 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -702,53 +703,6 @@ def test_least_used_strategy_selects_lowest_count(tmp_path, monkeypatch):
|
||||
assert entry.access_token == "sk-or-light"
|
||||
|
||||
|
||||
def test_mark_used_increments_request_count(tmp_path, monkeypatch):
|
||||
"""mark_used should increment the request_count of the current entry."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool.get_pool_strategy",
|
||||
lambda _provider: "fill_first",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_env",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "key-a",
|
||||
"label": "test",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-or-test",
|
||||
"request_count": 5,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
assert entry is not None
|
||||
assert entry.request_count == 5
|
||||
pool.mark_used()
|
||||
updated = pool.current()
|
||||
assert updated is not None
|
||||
assert updated.request_count == 6
|
||||
|
||||
|
||||
def test_thread_safety_concurrent_select(tmp_path, monkeypatch):
|
||||
"""Concurrent select() calls should not corrupt pool state."""
|
||||
import threading as _threading
|
||||
@@ -798,7 +752,6 @@ def test_thread_safety_concurrent_select(tmp_path, monkeypatch):
|
||||
entry = pool.select()
|
||||
if entry:
|
||||
results.append(entry.id)
|
||||
pool.mark_used(entry.id)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
@@ -1056,8 +1009,8 @@ def test_acquire_lease_prefers_unleased_entry(tmp_path, monkeypatch):
|
||||
|
||||
assert first == "cred-1"
|
||||
assert second == "cred-2"
|
||||
assert pool.active_lease_count("cred-1") == 1
|
||||
assert pool.active_lease_count("cred-2") == 1
|
||||
assert pool._active_leases.get("cred-1", 0) == 1
|
||||
assert pool._active_leases.get("cred-2", 0) == 1
|
||||
|
||||
|
||||
|
||||
@@ -1087,7 +1040,34 @@ def test_release_lease_decrements_counter(tmp_path, monkeypatch):
|
||||
pool = load_pool("openrouter")
|
||||
leased = pool.acquire_lease()
|
||||
assert leased == "cred-1"
|
||||
assert pool.active_lease_count("cred-1") == 1
|
||||
assert pool._active_leases.get("cred-1", 0) == 1
|
||||
|
||||
pool.release_lease("cred-1")
|
||||
assert pool.active_lease_count("cred-1") == 0
|
||||
assert pool._active_leases.get("cred-1", 0) == 0
|
||||
|
||||
|
||||
def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_path, monkeypatch):
|
||||
"""Claude Code credentials must not be auto-seeded when the user never selected anthropic."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
# Claude Code credentials exist on disk
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {"accessToken": "sk-ant...oken", "refreshToken": "rt", "expiresAt": 9999999999999},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_hermes_oauth_credentials",
|
||||
lambda: None,
|
||||
)
|
||||
# User configured kimi-coding, NOT anthropic
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.is_provider_explicitly_configured",
|
||||
lambda pid: pid == "kimi-coding",
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("anthropic")
|
||||
|
||||
# Should NOT have seeded the claude_code entry
|
||||
assert pool.entries() == []
|
||||
|
||||
@@ -75,28 +75,6 @@ class TestClassifiedError:
|
||||
e3 = ClassifiedError(reason=FailoverReason.billing)
|
||||
assert e3.is_auth is False
|
||||
|
||||
def test_is_transient_property(self):
|
||||
transient_reasons = [
|
||||
FailoverReason.rate_limit,
|
||||
FailoverReason.overloaded,
|
||||
FailoverReason.server_error,
|
||||
FailoverReason.timeout,
|
||||
FailoverReason.unknown,
|
||||
]
|
||||
for reason in transient_reasons:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is True, f"{reason} should be transient"
|
||||
|
||||
non_transient = [
|
||||
FailoverReason.auth,
|
||||
FailoverReason.billing,
|
||||
FailoverReason.model_not_found,
|
||||
FailoverReason.format_error,
|
||||
]
|
||||
for reason in non_transient:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is False, f"{reason} should NOT be transient"
|
||||
|
||||
def test_defaults(self):
|
||||
e = ClassifiedError(reason=FailoverReason.unknown)
|
||||
assert e.retryable is True
|
||||
@@ -271,6 +249,22 @@ class TestClassifyApiError:
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_alibaba_rate_increased_too_quickly(self):
|
||||
"""Alibaba/DashScope returns a unique throttling message.
|
||||
|
||||
Port from anomalyco/opencode#21355.
|
||||
"""
|
||||
msg = (
|
||||
"Upstream error from Alibaba: Request rate increased too quickly. "
|
||||
"To ensure system stability, please adjust your client logic to "
|
||||
"scale requests more smoothly over time."
|
||||
)
|
||||
e = MockAPIError(msg, status_code=400)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
assert result.should_rotate_credential is True
|
||||
|
||||
# ── Server errors ──
|
||||
|
||||
def test_500_server_error(self):
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from hermes_state import SessionDB
|
||||
from agent.insights import (
|
||||
InsightsEngine,
|
||||
_get_pricing,
|
||||
_estimate_cost,
|
||||
_format_duration,
|
||||
_bar_chart,
|
||||
@@ -118,45 +117,6 @@ def populated_db(db):
|
||||
return db
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pricing helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestPricing:
|
||||
def test_provider_prefix_stripped(self):
|
||||
pricing = _get_pricing("anthropic/claude-sonnet-4-20250514")
|
||||
assert pricing["input"] == 3.00
|
||||
assert pricing["output"] == 15.00
|
||||
|
||||
def test_unknown_models_do_not_use_heuristics(self):
|
||||
pricing = _get_pricing("some-new-opus-model")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
pricing = _get_pricing("anthropic/claude-haiku-future")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
def test_unknown_model_returns_zero_cost(self):
|
||||
"""Unknown/custom models should NOT have fabricated costs."""
|
||||
pricing = _get_pricing("totally-unknown-model-xyz")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
assert pricing["input"] == 0.0
|
||||
assert pricing["output"] == 0.0
|
||||
|
||||
def test_custom_endpoint_model_zero_cost(self):
|
||||
"""Self-hosted models should return zero cost."""
|
||||
for model in ["FP16_Hermes_4.5", "Hermes_4.5_1T_epoch2", "my-local-llama"]:
|
||||
pricing = _get_pricing(model)
|
||||
assert pricing["input"] == 0.0, f"{model} should have zero cost"
|
||||
assert pricing["output"] == 0.0, f"{model} should have zero cost"
|
||||
|
||||
def test_none_model(self):
|
||||
pricing = _get_pricing(None)
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
def test_empty_model(self):
|
||||
pricing = _get_pricing("")
|
||||
assert pricing == _DEFAULT_PRICING
|
||||
|
||||
|
||||
class TestHasKnownPricing:
|
||||
def test_known_commercial_model(self):
|
||||
assert _has_known_pricing("gpt-4o", provider="openai") is True
|
||||
|
||||
70
tests/agent/test_local_stream_timeout.py
Normal file
70
tests/agent/test_local_stream_timeout.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for local provider stream read timeout auto-detection.
|
||||
|
||||
When a local LLM provider is detected (Ollama, llama.cpp, vLLM, etc.),
|
||||
the httpx stream read timeout should be automatically increased from the
|
||||
default 60s to HERMES_API_TIMEOUT (1800s) to avoid premature connection
|
||||
kills during long prefill phases.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
class TestLocalStreamReadTimeout:
|
||||
"""Verify stream read timeout auto-detection logic."""
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://0.0.0.0:5000",
|
||||
"http://192.168.1.100:8000",
|
||||
"http://10.0.0.5:1234",
|
||||
])
|
||||
def test_local_endpoint_bumps_read_timeout(self, base_url):
|
||||
"""Local endpoint + default timeout -> bumps to base_timeout."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 1800.0
|
||||
|
||||
def test_user_override_respected_for_local(self):
|
||||
"""User sets HERMES_STREAM_READ_TIMEOUT -> keep their value even for local."""
|
||||
with patch.dict(os.environ, {"HERMES_STREAM_READ_TIMEOUT": "300"}, clear=False):
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
base_url = "http://localhost:11434"
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 300.0
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://api.openai.com",
|
||||
"https://openrouter.ai/api",
|
||||
"https://api.anthropic.com",
|
||||
])
|
||||
def test_remote_endpoint_keeps_default(self, base_url):
|
||||
"""Remote endpoint -> keep 120s default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 120.0
|
||||
|
||||
def test_empty_base_url_keeps_default(self):
|
||||
"""No base_url set -> keep 120s default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
base_url = ""
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 120.0
|
||||
@@ -1,299 +0,0 @@
|
||||
"""End-to-end test: a SQLite-backed memory plugin exercising the full interface.
|
||||
|
||||
This proves a real plugin can register as a MemoryProvider and get wired
|
||||
into the agent loop via MemoryManager. Uses SQLite + FTS5 (stdlib, no
|
||||
external deps, no API keys).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite FTS5 memory provider — a real, minimal plugin implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SQLiteMemoryProvider(MemoryProvider):
|
||||
"""Minimal SQLite + FTS5 memory provider for testing.
|
||||
|
||||
Demonstrates the full MemoryProvider interface with a real backend.
|
||||
No external dependencies — just stdlib sqlite3.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = ":memory:"):
|
||||
self._db_path = db_path
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "sqlite_memory"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True # SQLite is always available
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._conn = sqlite3.connect(self._db_path)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories
|
||||
USING fts5(content, context, session_id)
|
||||
""")
|
||||
self._session_id = session_id
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not self._conn:
|
||||
return ""
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
if count == 0:
|
||||
return ""
|
||||
return (
|
||||
f"# SQLite Memory Plugin\n"
|
||||
f"Active. {count} memories stored.\n"
|
||||
f"Use sqlite_recall to search, sqlite_retain to store."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
if not self._conn or not query:
|
||||
return ""
|
||||
# FTS5 search
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT content FROM memories WHERE memories MATCH ? LIMIT 5",
|
||||
(query,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return ""
|
||||
results = [row[0] for row in rows]
|
||||
return "## SQLite Memory\n" + "\n".join(f"- {r}" for r in results)
|
||||
except sqlite3.OperationalError:
|
||||
return ""
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
if not self._conn:
|
||||
return
|
||||
combined = f"User: {user_content}\nAssistant: {assistant_content}"
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(combined, "conversation", self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return [
|
||||
{
|
||||
"name": "sqlite_retain",
|
||||
"description": "Store a fact to SQLite memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "What to remember"},
|
||||
"context": {"type": "string", "description": "Category/context"},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sqlite_recall",
|
||||
"description": "Search SQLite memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if tool_name == "sqlite_retain":
|
||||
content = args.get("content", "")
|
||||
context = args.get("context", "explicit")
|
||||
if not content:
|
||||
return json.dumps({"error": "content is required"})
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(content, context, self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return json.dumps({"result": "Stored."})
|
||||
|
||||
elif tool_name == "sqlite_recall":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return json.dumps({"error": "query is required"})
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT content, context FROM memories WHERE memories MATCH ? LIMIT 10",
|
||||
(query,)
|
||||
).fetchall()
|
||||
results = [{"content": r[0], "context": r[1]} for r in rows]
|
||||
return json.dumps({"results": results})
|
||||
except sqlite3.OperationalError:
|
||||
return json.dumps({"results": []})
|
||||
|
||||
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
||||
|
||||
def on_memory_write(self, action, target, content):
|
||||
"""Mirror built-in memory writes to SQLite."""
|
||||
if action == "add" and self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(content, f"builtin_{target}", self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def shutdown(self):
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSQLiteMemoryPlugin:
|
||||
"""Full lifecycle test with the SQLite provider."""
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Exercise init → store → recall → sync → prefetch → shutdown."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
sqlite_mem = SQLiteMemoryProvider()
|
||||
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(sqlite_mem)
|
||||
|
||||
# Initialize
|
||||
mgr.initialize_all(session_id="test-session-1", platform="cli")
|
||||
assert sqlite_mem._conn is not None
|
||||
|
||||
# System prompt — empty at first
|
||||
prompt = mgr.build_system_prompt()
|
||||
assert "SQLite Memory Plugin" not in prompt
|
||||
|
||||
# Store via tool call
|
||||
result = json.loads(mgr.handle_tool_call(
|
||||
"sqlite_retain", {"content": "User prefers dark mode", "context": "preference"}
|
||||
))
|
||||
assert result["result"] == "Stored."
|
||||
|
||||
# System prompt now shows count
|
||||
prompt = mgr.build_system_prompt()
|
||||
assert "1 memories stored" in prompt
|
||||
|
||||
# Recall via tool call
|
||||
result = json.loads(mgr.handle_tool_call(
|
||||
"sqlite_recall", {"query": "dark mode"}
|
||||
))
|
||||
assert len(result["results"]) == 1
|
||||
assert "dark mode" in result["results"][0]["content"]
|
||||
|
||||
# Sync a turn (auto-stores conversation)
|
||||
mgr.sync_all("What's my theme?", "You prefer dark mode.")
|
||||
count = sqlite_mem._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
assert count == 2 # 1 explicit + 1 synced
|
||||
|
||||
# Prefetch for next turn
|
||||
prefetched = mgr.prefetch_all("dark mode")
|
||||
assert "dark mode" in prefetched
|
||||
|
||||
# Memory bridge — mirroring builtin writes
|
||||
mgr.on_memory_write("add", "user", "Timezone: US Pacific")
|
||||
count = sqlite_mem._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
assert count == 3
|
||||
|
||||
# Shutdown
|
||||
mgr.shutdown_all()
|
||||
assert sqlite_mem._conn is None
|
||||
|
||||
def test_tool_routing_with_builtin(self):
|
||||
"""Verify builtin + plugin tools coexist without conflict."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
sqlite_mem = SQLiteMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(sqlite_mem)
|
||||
mgr.initialize_all(session_id="test-2")
|
||||
|
||||
# Builtin has no tools
|
||||
assert len(builtin.get_tool_schemas()) == 0
|
||||
# SQLite has 2 tools
|
||||
schemas = mgr.get_all_tool_schemas()
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {"sqlite_retain", "sqlite_recall"}
|
||||
|
||||
# Routing works
|
||||
assert mgr.has_tool("sqlite_retain")
|
||||
assert mgr.has_tool("sqlite_recall")
|
||||
assert not mgr.has_tool("memory") # builtin doesn't register this
|
||||
|
||||
def test_second_external_plugin_rejected(self):
|
||||
"""Only one external memory provider is allowed at a time."""
|
||||
mgr = MemoryManager()
|
||||
p1 = SQLiteMemoryProvider()
|
||||
p2 = SQLiteMemoryProvider()
|
||||
# Hack name for p2
|
||||
p2._name_override = "sqlite_memory_2"
|
||||
original_name = p2.__class__.name
|
||||
type(p2).name = property(lambda self: getattr(self, '_name_override', 'sqlite_memory'))
|
||||
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2) # should be rejected
|
||||
|
||||
# Only p1 was accepted
|
||||
assert len(mgr.providers) == 1
|
||||
assert mgr.provider_names == ["sqlite_memory"]
|
||||
|
||||
# Restore class
|
||||
type(p2).name = original_name
|
||||
mgr.shutdown_all()
|
||||
|
||||
def test_provider_failure_isolation(self):
|
||||
"""Failing external provider doesn't break builtin."""
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider() # name="builtin", always accepted
|
||||
ext = SQLiteMemoryProvider()
|
||||
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext)
|
||||
mgr.initialize_all(session_id="test-4")
|
||||
|
||||
# Break external provider's connection
|
||||
ext._conn.close()
|
||||
ext._conn = None
|
||||
|
||||
# Sync — external fails silently, builtin (no-op sync) succeeds
|
||||
mgr.sync_all("user", "assistant") # should not raise
|
||||
|
||||
mgr.shutdown_all()
|
||||
|
||||
def test_plugin_registration_flow(self):
|
||||
"""Simulate the full plugin load → agent init path."""
|
||||
# Simulate what AIAgent.__init__ does via plugins/memory/ discovery
|
||||
provider = SQLiteMemoryProvider()
|
||||
|
||||
mem_mgr = MemoryManager()
|
||||
mem_mgr.add_provider(BuiltinMemoryProvider())
|
||||
if provider.is_available():
|
||||
mem_mgr.add_provider(provider)
|
||||
mem_mgr.initialize_all(session_id="agent-session")
|
||||
|
||||
assert len(mem_mgr.providers) == 2
|
||||
assert mem_mgr.provider_names == ["builtin", "sqlite_memory"]
|
||||
assert provider._conn is not None # initialized = connection established
|
||||
|
||||
mem_mgr.shutdown_all()
|
||||
@@ -6,8 +6,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete test provider
|
||||
@@ -118,7 +116,7 @@ class TestMemoryManager:
|
||||
def test_empty_manager(self):
|
||||
mgr = MemoryManager()
|
||||
assert mgr.providers == []
|
||||
assert mgr.provider_names == []
|
||||
assert [p.name for p in mgr.providers] == []
|
||||
assert mgr.get_all_tool_schemas() == []
|
||||
assert mgr.build_system_prompt() == ""
|
||||
assert mgr.prefetch_all("test") == ""
|
||||
@@ -128,7 +126,7 @@ class TestMemoryManager:
|
||||
p = FakeMemoryProvider("test1")
|
||||
mgr.add_provider(p)
|
||||
assert len(mgr.providers) == 1
|
||||
assert mgr.provider_names == ["test1"]
|
||||
assert [p.name for p in mgr.providers] == ["test1"]
|
||||
|
||||
def test_get_provider_by_name(self):
|
||||
mgr = MemoryManager()
|
||||
@@ -143,7 +141,7 @@ class TestMemoryManager:
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
assert mgr.provider_names == ["builtin", "external"]
|
||||
assert [p.name for p in mgr.providers] == ["builtin", "external"]
|
||||
|
||||
def test_second_external_rejected(self):
|
||||
"""Only one non-builtin provider is allowed."""
|
||||
@@ -154,7 +152,7 @@ class TestMemoryManager:
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext1)
|
||||
mgr.add_provider(ext2) # should be rejected
|
||||
assert mgr.provider_names == ["builtin", "mem0"]
|
||||
assert [p.name for p in mgr.providers] == ["builtin", "mem0"]
|
||||
assert len(mgr.providers) == 2
|
||||
|
||||
def test_system_prompt_merges_blocks(self):
|
||||
@@ -321,17 +319,6 @@ class TestMemoryManager:
|
||||
mgr.on_pre_compress([{"role": "user", "content": "old"}])
|
||||
assert p.pre_compress_called
|
||||
|
||||
def test_on_memory_write_skips_builtin(self):
|
||||
"""on_memory_write should skip the builtin provider."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
external = FakeMemoryProvider("external")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(external)
|
||||
|
||||
mgr.on_memory_write("add", "memory", "test fact")
|
||||
assert external.memory_writes == [("add", "memory", "test fact")]
|
||||
|
||||
def test_shutdown_all_reverse_order(self):
|
||||
mgr = MemoryManager()
|
||||
order = []
|
||||
@@ -385,146 +372,6 @@ class TestMemoryManager:
|
||||
assert result == "works fine"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BuiltinMemoryProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuiltinMemoryProvider:
|
||||
def test_name(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.name == "builtin"
|
||||
|
||||
def test_always_available(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.is_available()
|
||||
|
||||
def test_no_tools(self):
|
||||
"""Builtin provider exposes no tools (memory tool is agent-level)."""
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.get_tool_schemas() == []
|
||||
|
||||
def test_system_prompt_with_store(self):
|
||||
store = MagicMock()
|
||||
store.format_for_system_prompt.side_effect = lambda t: f"BLOCK_{t}" if t == "memory" else f"BLOCK_{t}"
|
||||
|
||||
p = BuiltinMemoryProvider(
|
||||
memory_store=store,
|
||||
memory_enabled=True,
|
||||
user_profile_enabled=True,
|
||||
)
|
||||
block = p.system_prompt_block()
|
||||
assert "BLOCK_memory" in block
|
||||
assert "BLOCK_user" in block
|
||||
|
||||
def test_system_prompt_memory_disabled(self):
|
||||
store = MagicMock()
|
||||
store.format_for_system_prompt.return_value = "content"
|
||||
|
||||
p = BuiltinMemoryProvider(
|
||||
memory_store=store,
|
||||
memory_enabled=False,
|
||||
user_profile_enabled=False,
|
||||
)
|
||||
assert p.system_prompt_block() == ""
|
||||
|
||||
def test_system_prompt_no_store(self):
|
||||
p = BuiltinMemoryProvider(memory_store=None, memory_enabled=True)
|
||||
assert p.system_prompt_block() == ""
|
||||
|
||||
def test_prefetch_returns_empty(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.prefetch("anything") == ""
|
||||
|
||||
def test_store_property(self):
|
||||
store = MagicMock()
|
||||
p = BuiltinMemoryProvider(memory_store=store)
|
||||
assert p.store is store
|
||||
|
||||
def test_initialize_loads_from_disk(self):
|
||||
store = MagicMock()
|
||||
p = BuiltinMemoryProvider(memory_store=store)
|
||||
p.initialize(session_id="test")
|
||||
store.load_from_disk.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleProviderGating:
|
||||
"""Only the configured provider should activate."""
|
||||
|
||||
def test_no_provider_configured_means_builtin_only(self):
|
||||
"""When memory.provider is empty, no plugin providers activate."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
# Simulate what run_agent.py does when provider=""
|
||||
configured = ""
|
||||
available_plugins = [
|
||||
FakeMemoryProvider("holographic"),
|
||||
FakeMemoryProvider("mem0"),
|
||||
]
|
||||
# With empty config, no plugins should be added
|
||||
if configured:
|
||||
for p in available_plugins:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
def test_configured_provider_activates(self):
|
||||
"""Only the named provider should be added."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "holographic"
|
||||
p1 = FakeMemoryProvider("holographic")
|
||||
p2 = FakeMemoryProvider("mem0")
|
||||
p3 = FakeMemoryProvider("hindsight")
|
||||
|
||||
for p in [p1, p2, p3]:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin", "holographic"]
|
||||
assert p1.initialized is False # not initialized by the gating logic itself
|
||||
|
||||
def test_unavailable_provider_skipped(self):
|
||||
"""If the configured provider is unavailable, it should be skipped."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "holographic"
|
||||
p1 = FakeMemoryProvider("holographic", available=False)
|
||||
|
||||
for p in [p1]:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
def test_nonexistent_provider_results_in_builtin_only(self):
|
||||
"""If the configured name doesn't match any plugin, only builtin remains."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "nonexistent"
|
||||
plugins = [FakeMemoryProvider("holographic"), FakeMemoryProvider("mem0")]
|
||||
|
||||
for p in plugins:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
|
||||
class TestPluginMemoryDiscovery:
|
||||
"""Memory providers are discovered from plugins/memory/ directory."""
|
||||
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog."""
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking, catalog, beta headers, transport."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMinimaxContextLengths:
|
||||
"""Verify per-model context length entries for MiniMax models."""
|
||||
"""Verify context length entries match official docs (204,800 for all models).
|
||||
|
||||
def test_m1_variants_have_1m_context(self):
|
||||
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"""
|
||||
|
||||
def test_minimax_prefix_has_correct_context(self):
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
# Keys are lowercase because the lookup lowercases model names
|
||||
for model in ("minimax-m1", "minimax-m1-40k", "minimax-m1-80k",
|
||||
"minimax-m1-128k", "minimax-m1-256k"):
|
||||
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_000_000, f"{model} expected 1M"
|
||||
assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 204_800
|
||||
|
||||
def test_m2_variants_have_1m_context(self):
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
# Keys are lowercase because the lookup lowercases model names
|
||||
for model in ("minimax-m2.5", "minimax-m2.7"):
|
||||
assert model in DEFAULT_CONTEXT_LENGTHS, f"{model} missing from context lengths"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[model] == 1_048_576, f"{model} expected 1048576"
|
||||
|
||||
def test_minimax_prefix_fallback(self):
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
# The generic "minimax" prefix entry should be 1M for unknown models
|
||||
assert DEFAULT_CONTEXT_LENGTHS["minimax"] == 1_048_576
|
||||
def test_minimax_models_resolve_via_prefix(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
|
||||
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
|
||||
ctx = get_model_context_length(model, "")
|
||||
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
|
||||
|
||||
|
||||
|
||||
class TestMinimaxThinkingGuard:
|
||||
"""Verify that build_anthropic_kwargs does NOT add thinking params for MiniMax models."""
|
||||
class TestMinimaxThinkingSupport:
|
||||
"""Verify that MiniMax gets manual thinking (not adaptive).
|
||||
|
||||
def test_no_thinking_for_minimax_m27(self):
|
||||
MiniMax's Anthropic-compat endpoint officially supports the thinking
|
||||
parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api).
|
||||
It should get manual thinking (type=enabled + budget_tokens), NOT adaptive
|
||||
thinking (which is Claude 4.6-only).
|
||||
"""
|
||||
|
||||
def test_minimax_m27_gets_manual_thinking(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="MiniMax-M2.7",
|
||||
@@ -38,19 +40,23 @@ class TestMinimaxThinkingGuard:
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "medium"},
|
||||
)
|
||||
assert "thinking" not in kwargs
|
||||
assert "thinking" in kwargs
|
||||
assert kwargs["thinking"]["type"] == "enabled"
|
||||
assert "budget_tokens" in kwargs["thinking"]
|
||||
# MiniMax should NOT get adaptive thinking or output_config
|
||||
assert "output_config" not in kwargs
|
||||
|
||||
def test_no_thinking_for_minimax_m1(self):
|
||||
def test_minimax_m25_gets_manual_thinking(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="MiniMax-M1-128k",
|
||||
model="MiniMax-M2.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
)
|
||||
assert "thinking" not in kwargs
|
||||
assert "thinking" in kwargs
|
||||
assert kwargs["thinking"]["type"] == "enabled"
|
||||
|
||||
def test_thinking_still_works_for_claude(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
@@ -79,27 +85,280 @@ class TestMinimaxAuxModel:
|
||||
|
||||
|
||||
class TestMinimaxModelCatalog:
|
||||
"""Verify the model catalog includes M1 family and excludes deprecated models."""
|
||||
"""Verify the model catalog matches official Anthropic-compat endpoint models.
|
||||
|
||||
def test_catalog_includes_m1_family(self):
|
||||
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"""
|
||||
|
||||
def test_catalog_includes_current_models(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
for provider in ("minimax", "minimax-cn"):
|
||||
models = _PROVIDER_MODELS[provider]
|
||||
assert "MiniMax-M1" in models
|
||||
assert "MiniMax-M1-40k" in models
|
||||
assert "MiniMax-M1-80k" in models
|
||||
assert "MiniMax-M1-128k" in models
|
||||
assert "MiniMax-M1-256k" in models
|
||||
assert "MiniMax-M2.7" in models
|
||||
assert "MiniMax-M2.5" in models
|
||||
assert "MiniMax-M2.1" in models
|
||||
assert "MiniMax-M2" in models
|
||||
|
||||
def test_catalog_excludes_deprecated(self):
|
||||
def test_catalog_excludes_m1_family(self):
|
||||
"""M1 models are not available on the /anthropic endpoint."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
for provider in ("minimax", "minimax-cn"):
|
||||
models = _PROVIDER_MODELS[provider]
|
||||
assert "MiniMax-M2.1" not in models
|
||||
assert "MiniMax-M1" not in models
|
||||
|
||||
def test_catalog_excludes_highspeed(self):
|
||||
"""Highspeed variants are available but not shown in default catalog
|
||||
(users can still specify them manually)."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
for provider in ("minimax", "minimax-cn"):
|
||||
models = _PROVIDER_MODELS[provider]
|
||||
assert "MiniMax-M2.7-highspeed" not in models
|
||||
assert "MiniMax-M2.5-highspeed" not in models
|
||||
|
||||
|
||||
class TestMinimaxBetaHeaders:
|
||||
"""MiniMax Anthropic-compat endpoints reject fine-grained-tool-streaming beta.
|
||||
|
||||
Verify that build_anthropic_client omits the tool-streaming beta for MiniMax
|
||||
(both global and China domains) while keeping it for native Anthropic and
|
||||
other third-party endpoints. Covers the fix for #6510 / #6555.
|
||||
"""
|
||||
|
||||
_TOOL_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
_THINKING_BETA = "interleaved-thinking-2025-05-14"
|
||||
|
||||
# -- helper ----------------------------------------------------------
|
||||
|
||||
def _build_and_get_betas(self, api_key, base_url=None):
|
||||
"""Build client, return the anthropic-beta header string."""
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(api_key, base_url=base_url)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
headers = kwargs.get("default_headers", {})
|
||||
return headers.get("anthropic-beta", "")
|
||||
|
||||
# -- MiniMax global --------------------------------------------------
|
||||
|
||||
def test_minimax_global_omits_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-key-123", base_url="https://api.minimax.io/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_minimax_global_trailing_slash(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-key-123", base_url="https://api.minimax.io/anthropic/"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
|
||||
# -- MiniMax China ---------------------------------------------------
|
||||
|
||||
def test_minimax_cn_omits_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_minimax_cn_trailing_slash(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic/"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
|
||||
# -- Non-MiniMax keeps full betas ------------------------------------
|
||||
|
||||
def test_native_anthropic_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas("sk-ant-api03-real-key-here")
|
||||
assert self._TOOL_BETA in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_third_party_proxy_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"custom-key", base_url="https://my-proxy.example.com/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA in betas
|
||||
|
||||
def test_custom_base_url_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"custom-key", base_url="https://custom.api.com"
|
||||
)
|
||||
assert self._TOOL_BETA in betas
|
||||
|
||||
# -- _common_betas_for_base_url unit tests ---------------------------
|
||||
|
||||
def test_common_betas_none_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url(None) == _COMMON_BETAS
|
||||
|
||||
def test_common_betas_empty_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url("") == _COMMON_BETAS
|
||||
|
||||
def test_common_betas_minimax_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
|
||||
betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
|
||||
assert _TOOL_STREAMING_BETA not in betas
|
||||
assert len(betas) > 0 # still has other betas
|
||||
|
||||
def test_common_betas_minimax_cn_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
|
||||
betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
|
||||
assert _TOOL_STREAMING_BETA not in betas
|
||||
|
||||
def test_common_betas_regular_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
|
||||
|
||||
|
||||
class TestMinimaxApiMode:
|
||||
"""Verify determine_api_mode returns anthropic_messages for MiniMax providers.
|
||||
|
||||
The MiniMax /anthropic endpoint speaks Anthropic Messages wire format,
|
||||
not OpenAI chat completions. The overlay transport must reflect this
|
||||
so that code paths calling determine_api_mode() without a base_url
|
||||
(e.g. /model switch) get the correct api_mode.
|
||||
"""
|
||||
|
||||
def test_minimax_returns_anthropic_messages(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
assert determine_api_mode("minimax") == "anthropic_messages"
|
||||
|
||||
def test_minimax_cn_returns_anthropic_messages(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
assert determine_api_mode("minimax-cn") == "anthropic_messages"
|
||||
|
||||
def test_minimax_with_url_also_works(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
# Even with explicit base_url, provider lookup takes priority
|
||||
assert determine_api_mode("minimax", "https://api.minimax.io/anthropic") == "anthropic_messages"
|
||||
|
||||
def test_anthropic_still_returns_anthropic_messages(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
assert determine_api_mode("anthropic") == "anthropic_messages"
|
||||
|
||||
def test_openai_returns_chat_completions(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
# Sanity check: standard providers are unaffected
|
||||
result = determine_api_mode("deepseek")
|
||||
assert result == "chat_completions"
|
||||
|
||||
|
||||
class TestMinimaxMaxOutput:
|
||||
"""Verify _get_anthropic_max_output returns correct limits for MiniMax models.
|
||||
|
||||
MiniMax max output is 131,072 tokens (source: OpenClaw model definitions,
|
||||
cross-referenced with MiniMax API behavior).
|
||||
"""
|
||||
|
||||
def test_minimax_m27_output_limit(self):
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
|
||||
|
||||
def test_minimax_m25_output_limit(self):
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
|
||||
|
||||
def test_minimax_m2_output_limit(self):
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
|
||||
|
||||
def test_claude_output_unaffected(self):
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
# Sanity: Claude limits are not broken by the MiniMax entry
|
||||
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
|
||||
|
||||
|
||||
class TestMinimaxPreserveDots:
|
||||
"""Verify that MiniMax model names preserve dots through the Anthropic adapter.
|
||||
|
||||
MiniMax model IDs like 'MiniMax-M2.7' must NOT have dots converted to
|
||||
hyphens — the endpoint expects the exact name with dots.
|
||||
"""
|
||||
|
||||
def test_minimax_provider_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="minimax", base_url="")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_minimax_cn_provider_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="minimax-cn", base_url="")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_minimax_url_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="custom", base_url="https://api.minimax.io/anthropic")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_minimax_cn_url_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="custom", base_url="https://api.minimaxi.com/anthropic")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_anthropic_does_not_preserve_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is False
|
||||
|
||||
def test_normalize_preserves_m27_dot(self):
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
|
||||
|
||||
def test_normalize_converts_without_preserve(self):
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
# Without preserve_dots, dots become hyphens (broken for MiniMax)
|
||||
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2-7"
|
||||
|
||||
|
||||
class TestMinimaxSwitchModelCredentialGuard:
|
||||
"""Verify switch_model() does not leak Anthropic credentials to MiniMax.
|
||||
|
||||
The __init__ path correctly guards against this (line 761), but switch_model()
|
||||
must mirror that guard. Without it, /model switch to minimax with no explicit
|
||||
api_key would fall back to resolve_anthropic_token() and send Anthropic creds
|
||||
to the MiniMax endpoint.
|
||||
"""
|
||||
|
||||
def test_switch_to_minimax_does_not_resolve_anthropic_token(self):
|
||||
"""switch_model() should NOT call resolve_anthropic_token() for MiniMax."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
with patch("run_agent.AIAgent.__init__", return_value=None):
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.provider = "anthropic"
|
||||
agent.model = "claude-sonnet-4"
|
||||
agent.api_key = "sk-ant-fake"
|
||||
agent.base_url = "https://api.anthropic.com"
|
||||
agent.api_mode = "anthropic_messages"
|
||||
agent._anthropic_base_url = "https://api.anthropic.com"
|
||||
agent._anthropic_api_key = "sk-ant-fake"
|
||||
agent._is_anthropic_oauth = False
|
||||
agent._client_kwargs = {}
|
||||
agent.client = None
|
||||
agent._anthropic_client = MagicMock()
|
||||
|
||||
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
|
||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \
|
||||
patch("agent.anthropic_adapter._is_oauth_token", return_value=False):
|
||||
|
||||
agent.switch_model(
|
||||
new_model="MiniMax-M2.7",
|
||||
new_provider="minimax",
|
||||
api_mode="anthropic_messages",
|
||||
api_key="mm-key-123",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
)
|
||||
# resolve_anthropic_token should NOT be called for non-Anthropic providers
|
||||
mock_resolve.assert_not_called()
|
||||
# The key passed to build_anthropic_client should be the MiniMax key
|
||||
build_args = mock_build.call_args
|
||||
assert build_args[0][0] == "mm-key-123"
|
||||
|
||||
@@ -132,6 +132,61 @@ class TestDefaultContextLengths:
|
||||
if "gemini" in key:
|
||||
assert value == 1048576, f"{key} should be 1048576"
|
||||
|
||||
def test_grok_models_context_lengths(self):
|
||||
# xAI /v1/models does not return context_length metadata, so
|
||||
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
|
||||
# Values sourced from models.dev (2026-04).
|
||||
expected = {
|
||||
"grok-4.20": 2000000,
|
||||
"grok-4-1-fast": 2000000,
|
||||
"grok-4-fast": 2000000,
|
||||
"grok-4": 256000,
|
||||
"grok-code-fast": 256000,
|
||||
"grok-3": 131072,
|
||||
"grok-2": 131072,
|
||||
"grok-2-vision": 8192,
|
||||
"grok": 131072,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
|
||||
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
|
||||
)
|
||||
|
||||
def test_grok_substring_matching(self):
|
||||
# Longest-first substring matching must resolve the real xAI model
|
||||
# IDs to the correct fallback entries without 128k probe-down.
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
# Fake the provider/API/cache layers so the lookup falls through
|
||||
# to DEFAULT_CONTEXT_LENGTHS.
|
||||
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
|
||||
cases = [
|
||||
("grok-4.20-0309-reasoning", 2000000),
|
||||
("grok-4.20-0309-non-reasoning", 2000000),
|
||||
("grok-4.20-multi-agent-0309", 2000000),
|
||||
("grok-4-1-fast-reasoning", 2000000),
|
||||
("grok-4-1-fast-non-reasoning", 2000000),
|
||||
("grok-4-fast-reasoning", 2000000),
|
||||
("grok-4-fast-non-reasoning", 2000000),
|
||||
("grok-4", 256000),
|
||||
("grok-4-0709", 256000),
|
||||
("grok-code-fast-1", 256000),
|
||||
("grok-3", 131072),
|
||||
("grok-3-mini", 131072),
|
||||
("grok-3-mini-fast", 131072),
|
||||
("grok-2", 131072),
|
||||
("grok-2-vision", 8192),
|
||||
("grok-2-vision-1212", 8192),
|
||||
("grok-beta", 131072),
|
||||
]
|
||||
for model_id, expected_ctx in cases:
|
||||
actual = get_model_context_length(model_id)
|
||||
assert actual == expected_ctx, (
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_all_values_positive(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
assert value > 0, f"{key} has non-positive context length"
|
||||
@@ -167,6 +222,24 @@ class TestGetModelContextLength:
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length("openai/gpt-4o") == 128000
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_qwen3_coder_plus_context_length(self, mock_fetch):
|
||||
"""qwen3-coder-plus has a 1M context window, not the generic 128K Qwen default."""
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length("qwen3-coder-plus") == 1000000
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_qwen3_coder_context_length(self, mock_fetch):
|
||||
"""qwen3-coder has a 256K context window, not the generic 128K Qwen default."""
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length("qwen3-coder") == 262144
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_qwen_generic_context_length(self, mock_fetch):
|
||||
"""Generic qwen models still get the 128K default."""
|
||||
mock_fetch.return_value = {}
|
||||
assert get_model_context_length("qwen3-plus") == 131072
|
||||
|
||||
@patch("agent.model_metadata.fetch_model_metadata")
|
||||
def test_api_missing_context_length_key(self, mock_fetch):
|
||||
"""Model in API but without context_length → defaults to 128000."""
|
||||
|
||||
@@ -11,7 +11,6 @@ from agent.prompt_builder import (
|
||||
_scan_context_content,
|
||||
_truncate_content,
|
||||
_parse_skill_file,
|
||||
_read_skill_conditions,
|
||||
_skill_should_show,
|
||||
_find_hermes_md,
|
||||
_find_git_root,
|
||||
@@ -775,61 +774,6 @@ class TestPromptBuilderConstants:
|
||||
# Conditional skill activation
|
||||
# =========================================================================
|
||||
|
||||
class TestReadSkillConditions:
|
||||
def test_no_conditions_returns_empty_lists(self, tmp_path):
|
||||
skill_file = tmp_path / "SKILL.md"
|
||||
skill_file.write_text("---\nname: test\ndescription: A skill\n---\n")
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
assert conditions["fallback_for_toolsets"] == []
|
||||
assert conditions["requires_toolsets"] == []
|
||||
assert conditions["fallback_for_tools"] == []
|
||||
assert conditions["requires_tools"] == []
|
||||
|
||||
def test_reads_fallback_for_toolsets(self, tmp_path):
|
||||
skill_file = tmp_path / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: ddg\ndescription: DuckDuckGo\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n"
|
||||
)
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
assert conditions["fallback_for_toolsets"] == ["web"]
|
||||
|
||||
def test_reads_requires_toolsets(self, tmp_path):
|
||||
skill_file = tmp_path / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n"
|
||||
)
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
assert conditions["requires_toolsets"] == ["terminal"]
|
||||
|
||||
def test_reads_multiple_conditions(self, tmp_path):
|
||||
skill_file = tmp_path / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: test\ndescription: Test\nmetadata:\n hermes:\n fallback_for_toolsets: [browser]\n requires_tools: [terminal]\n---\n"
|
||||
)
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
assert conditions["fallback_for_toolsets"] == ["browser"]
|
||||
assert conditions["requires_tools"] == ["terminal"]
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
conditions = _read_skill_conditions(tmp_path / "missing.md")
|
||||
assert conditions == {}
|
||||
|
||||
def test_logs_condition_read_failures_and_returns_empty(self, tmp_path, monkeypatch, caplog):
|
||||
skill_file = tmp_path / "SKILL.md"
|
||||
skill_file.write_text("---\nname: broken\n---\n")
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError("read exploded")
|
||||
|
||||
monkeypatch.setattr(type(skill_file), "read_text", boom)
|
||||
with caplog.at_level(logging.DEBUG, logger="agent.prompt_builder"):
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
|
||||
assert conditions == {}
|
||||
assert "Failed to read skill conditions" in caplog.text
|
||||
assert str(skill_file) in caplog.text
|
||||
|
||||
|
||||
class TestSkillShouldShow:
|
||||
def test_no_filter_info_always_shows(self):
|
||||
assert _skill_should_show({}, None, None) is True
|
||||
|
||||
85
tests/cli/test_cli_status_command.py
Normal file
85
tests/cli/test_cli_status_command.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Tests for CLI /status command behavior."""
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "session-123"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj.model = "openai/gpt-5.4"
|
||||
cli_obj.provider = "openai"
|
||||
cli_obj.session_start = datetime(2026, 4, 9, 19, 24)
|
||||
cli_obj._agent_running = False
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._session_db.get_session.return_value = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_status_command_is_available_in_cli_registry():
|
||||
cmd = resolve_command("status")
|
||||
assert cmd is not None
|
||||
assert cmd.gateway_only is False
|
||||
|
||||
|
||||
def test_process_command_status_dispatches_without_toggling_status_bar():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status", create=True) as mock_status:
|
||||
assert cli_obj.process_command("/status") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_statusbar_still_toggles_visibility():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj.process_command("/statusbar") is True
|
||||
assert cli_obj._status_bar_visible is False
|
||||
|
||||
|
||||
def test_status_prefix_prefers_status_command_over_statusbar_toggle():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status") as mock_status:
|
||||
assert cli_obj.process_command("/sta") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_show_session_status_prints_gateway_style_summary():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.agent = SimpleNamespace(
|
||||
session_total_tokens=321,
|
||||
session_api_calls=4,
|
||||
)
|
||||
cli_obj._session_db.get_session.return_value = {
|
||||
"title": "My titled session",
|
||||
"started_at": 1775791440,
|
||||
}
|
||||
|
||||
with patch("cli.display_hermes_home", return_value="~/.hermes"):
|
||||
cli_obj._show_session_status()
|
||||
|
||||
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
|
||||
assert "Hermes CLI Status" in printed
|
||||
assert "Session ID: session-123" in printed
|
||||
assert "Path: ~/.hermes" in printed
|
||||
assert "Title: My titled session" in printed
|
||||
assert "Model: openai/gpt-5.4 (openai)" in printed
|
||||
assert "Tokens: 321" in printed
|
||||
assert "Agent Running: No" in printed
|
||||
_, kwargs = cli_obj.console.print.call_args
|
||||
assert kwargs.get("highlight") is False
|
||||
assert kwargs.get("markup") is False
|
||||
413
tests/cli/test_fast_command.py
Normal file
413
tests/cli/test_fast_command.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""Tests for the /fast CLI command and service-tier config handling."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class TestParseServiceTierConfig(unittest.TestCase):
|
||||
def _parse(self, raw):
|
||||
cli_mod = _import_cli()
|
||||
return cli_mod._parse_service_tier_config(raw)
|
||||
|
||||
def test_fast_maps_to_priority(self):
|
||||
self.assertEqual(self._parse("fast"), "priority")
|
||||
self.assertEqual(self._parse("priority"), "priority")
|
||||
|
||||
def test_normal_disables_service_tier(self):
|
||||
self.assertIsNone(self._parse("normal"))
|
||||
self.assertIsNone(self._parse("off"))
|
||||
self.assertIsNone(self._parse(""))
|
||||
|
||||
|
||||
class TestHandleFastCommand(unittest.TestCase):
|
||||
def _make_cli(self, service_tier=None):
|
||||
return SimpleNamespace(
|
||||
service_tier=service_tier,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.4",
|
||||
_fast_command_available=lambda: True,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier=None)
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
# Bare /fast shows status, does not change config
|
||||
mock_save.assert_not_called()
|
||||
# Should have printed the status line
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("normal", printed)
|
||||
|
||||
def test_no_args_shows_fast_when_enabled(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("fast", printed)
|
||||
|
||||
def test_normal_argument_clears_service_tier(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
|
||||
|
||||
mock_save.assert_called_once_with("agent.service_tier", "normal")
|
||||
self.assertIsNone(stub.service_tier)
|
||||
self.assertIsNone(stub.agent)
|
||||
|
||||
def test_unsupported_model_does_not_expose_fast(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
service_tier=None,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.3-codex",
|
||||
_fast_command_available=lambda: False,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
self.assertTrue(mock_cprint.called)
|
||||
|
||||
|
||||
class TestPriorityProcessingModels(unittest.TestCase):
|
||||
"""Verify the expanded Priority Processing model registry."""
|
||||
|
||||
def test_all_documented_models_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# All models from OpenAI's Priority Processing pricing table
|
||||
supported = [
|
||||
"gpt-5.4", "gpt-5.4-mini", "gpt-5.2",
|
||||
"gpt-5.1", "gpt-5", "gpt-5-mini",
|
||||
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
|
||||
"gpt-4o", "gpt-4o-mini",
|
||||
"o3", "o4-mini",
|
||||
]
|
||||
for model in supported:
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
def test_vendor_prefix_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("openai/gpt-5.4") is True
|
||||
assert model_supports_fast_mode("openai/gpt-4.1") is True
|
||||
assert model_supports_fast_mode("openai/o3") is True
|
||||
|
||||
def test_non_priority_models_rejected(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("gpt-5.3-codex") is False
|
||||
assert model_supports_fast_mode("claude-sonnet-4") is False
|
||||
assert model_supports_fast_mode("") is False
|
||||
assert model_supports_fast_mode(None) is False
|
||||
|
||||
def test_resolve_overrides_returns_service_tier(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-4.1")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_resolve_overrides_none_for_unsupported(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
assert resolve_fast_mode_overrides("gpt-5.3-codex") is None
|
||||
assert resolve_fast_mode_overrides("claude-sonnet-4") is None
|
||||
|
||||
|
||||
class TestFastModeRouting(unittest.TestCase):
|
||||
def test_fast_command_exposed_for_model_even_when_provider_is_auto(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="auto", requested_provider="auto", model="gpt-5.4", agent=None)
|
||||
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_exposed_for_non_codex_models(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="openai", requested_provider="openai", model="gpt-4.1", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
stub = SimpleNamespace(provider="openrouter", requested_provider="openrouter", model="o3", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_turn_route_injects_overrides_without_provider_switch(self):
|
||||
"""Fast mode should add request_overrides but NOT change the provider/runtime."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
# Provider should NOT have changed
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
# But request_overrides should be set
|
||||
assert route["request_overrides"] == {"service_tier": "priority"}
|
||||
|
||||
def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.3-codex",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
primary_route = {
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
},
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route.get("request_overrides") is None
|
||||
|
||||
|
||||
class TestAnthropicFastMode(unittest.TestCase):
|
||||
"""Verify Anthropic Fast Mode model support and override resolution."""
|
||||
|
||||
def test_anthropic_opus_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# Native Anthropic format (hyphens)
|
||||
assert model_supports_fast_mode("claude-opus-4-6") is True
|
||||
# OpenRouter format (dots)
|
||||
assert model_supports_fast_mode("claude-opus-4.6") is True
|
||||
# With vendor prefix
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
|
||||
|
||||
def test_anthropic_non_opus_rejected(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("claude-sonnet-4-6") is False
|
||||
assert model_supports_fast_mode("claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("claude-haiku-4-5") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
|
||||
|
||||
def test_anthropic_variant_tags_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# OpenRouter variant tags after colon should be stripped
|
||||
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
|
||||
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
|
||||
|
||||
def test_resolve_overrides_returns_speed_for_anthropic(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("claude-opus-4-6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
def test_resolve_overrides_returns_service_tier_for_openai(self):
|
||||
"""OpenAI models should still get service_tier, not speed."""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_is_anthropic_fast_model(self):
|
||||
from hermes_cli.models import _is_anthropic_fast_model
|
||||
|
||||
assert _is_anthropic_fast_model("claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("claude-opus-4.6") is True
|
||||
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("gpt-5.4") is False
|
||||
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
|
||||
|
||||
def test_fast_command_exposed_for_anthropic_model(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-opus-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_hidden_for_anthropic_sonnet(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-sonnet-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_turn_route_injects_speed_for_anthropic(self):
|
||||
"""Anthropic models should get speed:'fast' override, not service_tier."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="claude-opus-4-6",
|
||||
api_key="sk-ant-test",
|
||||
base_url="https://api.anthropic.com",
|
||||
provider="anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"provider": "anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "claude-opus-4-6",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "anthropic"
|
||||
assert route["request_overrides"] == {"speed": "fast"}
|
||||
|
||||
|
||||
class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
|
||||
|
||||
def test_fast_mode_adds_speed_and_beta(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert kwargs.get("speed") == "fast"
|
||||
assert "extra_headers" in kwargs
|
||||
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
|
||||
|
||||
def test_fast_mode_off_no_speed(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=False,
|
||||
)
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_skipped_for_third_party_endpoint(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
base_url="https://api.minimax.io/anthropic/v1",
|
||||
)
|
||||
# Third-party endpoints should NOT get speed or fast-mode beta
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
def test_default_config_has_service_tier(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
agent = DEFAULT_CONFIG.get("agent", {})
|
||||
self.assertIn("service_tier", agent)
|
||||
self.assertEqual(agent["service_tier"], "")
|
||||
66
tests/cli/test_manual_compress.py
Normal file
66
tests/cli/test_manual_compress.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for CLI manual compression messaging."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def test_manual_compress_reports_noop_without_success_banner(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages):
|
||||
assert messages == history
|
||||
return 100
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "No changes from compression" in output
|
||||
assert "✅ Compressed" not in output
|
||||
assert "Rough transcript estimate: ~100 tokens (unchanged)" in output
|
||||
|
||||
|
||||
def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [
|
||||
history[0],
|
||||
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
return 120
|
||||
raise AssertionError(f"unexpected transcript: {messages!r}")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✅ Compressed: 4 → 3 messages" in output
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in output
|
||||
assert "denser summaries" in output
|
||||
@@ -619,17 +619,14 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.reasoning_callback = None
|
||||
agent.stream_delta_callback = None
|
||||
agent._reasoning_deltas_fired = False
|
||||
agent.verbose_logging = False
|
||||
return agent
|
||||
|
||||
def test_fire_reasoning_delta_sets_flag(self):
|
||||
def test_fire_reasoning_delta_calls_callback(self):
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
self.assertFalse(agent._reasoning_deltas_fired)
|
||||
agent._fire_reasoning_delta("thinking...")
|
||||
self.assertTrue(agent._reasoning_deltas_fired)
|
||||
self.assertEqual(captured, ["thinking..."])
|
||||
|
||||
def test_build_assistant_message_skips_callback_when_already_streamed(self):
|
||||
@@ -640,8 +637,7 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent.stream_delta_callback = lambda t: None # streaming is active
|
||||
|
||||
# Simulate streaming having fired reasoning
|
||||
agent._reasoning_deltas_fired = True
|
||||
# Simulate streaming having already fired reasoning
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
@@ -665,9 +661,8 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent.stream_delta_callback = lambda t: None # streaming active
|
||||
|
||||
# Even though _reasoning_deltas_fired is False (reasoning came through
|
||||
# content tags, not reasoning_content deltas), callback should not fire
|
||||
agent._reasoning_deltas_fired = False
|
||||
# Reasoning came through content tags, not reasoning_content deltas.
|
||||
# Callback should not fire since streaming is active.
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
@@ -689,7 +684,6 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
# No streaming
|
||||
agent.stream_delta_callback = None
|
||||
agent._reasoning_deltas_fired = False
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
|
||||
138
tests/cli/test_stream_delta_think_tag.py
Normal file
138
tests/cli/test_stream_delta_think_tag.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Tests for _stream_delta's handling of <think> tags in prose vs real reasoning blocks."""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
"""Create a minimal HermesCLI-like object with stream state."""
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli._stream_buf = ""
|
||||
cli._stream_started = False
|
||||
cli._stream_box_opened = False
|
||||
cli._stream_prefilt = ""
|
||||
cli._in_reasoning_block = False
|
||||
cli._reasoning_stream_started = False
|
||||
cli._reasoning_box_opened = False
|
||||
cli._reasoning_buf = ""
|
||||
cli._reasoning_preview_buf = ""
|
||||
cli._deferred_content = ""
|
||||
cli._stream_text_ansi = ""
|
||||
cli._stream_needs_break = False
|
||||
cli._emitted = []
|
||||
|
||||
# Mock _emit_stream_text to capture output
|
||||
def mock_emit(text):
|
||||
cli._emitted.append(text)
|
||||
cli._emit_stream_text = mock_emit
|
||||
|
||||
# Mock _stream_reasoning_delta
|
||||
cli._reasoning_emitted = []
|
||||
def mock_reasoning(text):
|
||||
cli._reasoning_emitted.append(text)
|
||||
cli._stream_reasoning_delta = mock_reasoning
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestThinkTagInProse:
|
||||
"""<think> mentioned in prose should NOT trigger reasoning suppression."""
|
||||
|
||||
def test_think_tag_mid_sentence(self):
|
||||
"""'(/think not producing <think> tags)' should pass through."""
|
||||
cli = _make_cli_stub()
|
||||
tokens = [
|
||||
" 1. Fix reasoning mode in eval ",
|
||||
"(/think not producing ",
|
||||
"<think>",
|
||||
" tags — ~2% gap)",
|
||||
"\n 2. Launch production",
|
||||
]
|
||||
for t in tokens:
|
||||
cli._stream_delta(t)
|
||||
assert not cli._in_reasoning_block, "<think> in prose should not enter reasoning block"
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full, "The literal <think> tag should be in the emitted text"
|
||||
assert "Launch production" in full
|
||||
|
||||
def test_think_tag_after_text_on_same_line(self):
|
||||
"""'some text <think>' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Here is the <think> tag explanation")
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full
|
||||
|
||||
def test_think_tag_in_backticks(self):
|
||||
"""'`<think>`' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Use the `<think>` tag for reasoning")
|
||||
assert not cli._in_reasoning_block
|
||||
|
||||
|
||||
class TestRealReasoningBlock:
|
||||
"""Real <think> tags at block boundaries should still be caught."""
|
||||
|
||||
def test_think_at_start_of_stream(self):
|
||||
"""'<think>reasoning</think>answer' should suppress reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("<think>")
|
||||
assert cli._in_reasoning_block
|
||||
cli._stream_delta("I need to analyze this")
|
||||
cli._stream_delta("</think>")
|
||||
assert not cli._in_reasoning_block
|
||||
cli._stream_delta("Here is my answer")
|
||||
full = "".join(cli._emitted)
|
||||
assert "Here is my answer" in full
|
||||
assert "I need to analyze" not in full # reasoning was suppressed
|
||||
|
||||
def test_think_after_newline(self):
|
||||
"""'text\\n<think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n<think>")
|
||||
assert cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Some preamble" in full
|
||||
|
||||
def test_think_after_newline_with_whitespace(self):
|
||||
"""'text\\n <think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
def test_think_with_only_whitespace_before(self):
|
||||
"""' <think>' (whitespace only prefix) should trigger."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta(" <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
|
||||
class TestFlushRecovery:
|
||||
"""_flush_stream should recover content from false-positive reasoning blocks."""
|
||||
|
||||
def test_flush_recovers_buffered_content(self):
|
||||
"""If somehow in reasoning block at flush, content is recovered."""
|
||||
cli = _make_cli_stub()
|
||||
# Manually set up a false-positive state
|
||||
cli._in_reasoning_block = True
|
||||
cli._stream_prefilt = " tags — ~2% gap)\n 2. Launch production"
|
||||
cli._stream_box_opened = True
|
||||
|
||||
# Mock _close_reasoning_box and box closing
|
||||
cli._close_reasoning_box = lambda: None
|
||||
|
||||
# Call flush
|
||||
from unittest.mock import patch
|
||||
import shutil
|
||||
with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((80, 24))):
|
||||
with patch("cli._cprint"):
|
||||
cli._flush_stream()
|
||||
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Launch production" in full
|
||||
@@ -173,6 +173,40 @@ class TestResolveDeliveryTarget:
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_discord_topic_target_with_thread_id(self):
|
||||
"""deliver: 'discord:chat_id:thread_id' parses correctly."""
|
||||
job = {
|
||||
"deliver": "discord:-1001234567890:17585",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "discord",
|
||||
"chat_id": "-1001234567890",
|
||||
"thread_id": "17585",
|
||||
}
|
||||
|
||||
def test_explicit_discord_chat_id_without_thread_id(self):
|
||||
"""deliver: 'discord:chat_id' sets thread_id to None."""
|
||||
job = {
|
||||
"deliver": "discord:9876543210",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "discord",
|
||||
"chat_id": "9876543210",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_discord_channel_without_thread(self):
|
||||
"""deliver: 'discord:1001234567890' resolves via explicit platform:chat_id path."""
|
||||
job = {
|
||||
"deliver": "discord:1001234567890",
|
||||
}
|
||||
result = _resolve_delivery_target(job)
|
||||
assert result == {
|
||||
"platform": "discord",
|
||||
"chat_id": "1001234567890",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared fixtures for Telegram gateway e2e tests.
|
||||
"""Shared fixtures for gateway e2e tests (Telegram, Discord).
|
||||
|
||||
These tests exercise the full async message flow:
|
||||
adapter.handle_message(event)
|
||||
@@ -14,19 +14,22 @@ import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, SendResult
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
#Ensure telegram module is available (mock it if not installed)
|
||||
# Platform library mocks
|
||||
|
||||
# Ensure telegram module is available (mock it if not installed)
|
||||
def _ensure_telegram_mock():
|
||||
"""Install mock telegram modules so TelegramAdapter can be imported."""
|
||||
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||
return # Real library installed
|
||||
return # Real library installed
|
||||
|
||||
telegram_mod = MagicMock()
|
||||
telegram_mod.Update = MagicMock()
|
||||
@@ -51,24 +54,118 @@ def _ensure_telegram_mock():
|
||||
sys.modules.setdefault(name, telegram_mod)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
# Ensure discord module is available (mock it if not installed)
|
||||
def _ensure_discord_mock():
|
||||
"""Install mock discord modules so DiscordAdapter can be imported."""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
discord_mod.opus.is_loaded.return_value = True
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
sys.modules.setdefault("discord.opus", discord_mod.opus)
|
||||
|
||||
|
||||
def _ensure_slack_mock():
|
||||
"""Install mock slack modules so SlackAdapter can be imported."""
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
_ensure_discord_mock()
|
||||
_ensure_slack_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
|
||||
#GatewayRunner factory (based on tests/gateway/test_status_command.py)
|
||||
|
||||
def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
|
||||
# Platform-generic factories
|
||||
|
||||
def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
user_name="e2e_tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def make_session_entry(platform: Platform, source: SessionSource = None) -> SessionEntry:
|
||||
source = source or make_source(platform)
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=f"sess-{uuid.uuid4().hex[:8]}",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=platform,
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def make_event(platform: Platform, text: str = "/help", chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=make_source(platform, chat_id, user_id),
|
||||
message_id=f"msg-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
|
||||
def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "GatewayRunner":
|
||||
"""Create a GatewayRunner with mocked internals for e2e testing.
|
||||
|
||||
Skips __init__ to avoid filesystem/network side effects.
|
||||
All command-dispatch dependencies are wired manually.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
if session_entry is None:
|
||||
session_entry = make_session_entry(platform)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="e2e-test-token")}
|
||||
platforms={platform: PlatformConfig(enabled=True, token="e2e-test-token")}
|
||||
)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
@@ -99,7 +196,6 @@ def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
|
||||
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
|
||||
runner._emit_gateway_run_progress = AsyncMock()
|
||||
|
||||
# Pairing store (used by authorization rejection path)
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
|
||||
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
|
||||
@@ -107,67 +203,63 @@ def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
|
||||
return runner
|
||||
|
||||
|
||||
#TelegramAdapter factory
|
||||
def make_adapter(platform: Platform, runner=None):
|
||||
"""Create a platform adapter wired to *runner*, with send methods mocked."""
|
||||
if runner is None:
|
||||
runner = make_runner(platform)
|
||||
|
||||
def make_adapter(runner) -> TelegramAdapter:
|
||||
"""Create a TelegramAdapter wired to *runner*, with send methods mocked.
|
||||
|
||||
connect() is NOT called — no polling, no token lock, no real HTTP.
|
||||
"""
|
||||
config = PlatformConfig(enabled=True, token="e2e-test-token")
|
||||
adapter = TelegramAdapter(config)
|
||||
|
||||
# Mock outbound methods so tests can capture what was sent
|
||||
if platform == Platform.DISCORD:
|
||||
with patch.object(DiscordAdapter, "_load_participated_threads", return_value=set()):
|
||||
adapter = DiscordAdapter(config)
|
||||
platform_key = Platform.DISCORD
|
||||
elif platform == Platform.SLACK:
|
||||
adapter = SlackAdapter(config)
|
||||
platform_key = Platform.SLACK
|
||||
else:
|
||||
adapter = TelegramAdapter(config)
|
||||
platform_key = Platform.TELEGRAM
|
||||
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
|
||||
adapter.send_typing = AsyncMock()
|
||||
|
||||
# Wire adapter ↔ runner
|
||||
adapter.set_message_handler(runner._handle_message)
|
||||
runner.adapters[Platform.TELEGRAM] = adapter
|
||||
runner.adapters[platform_key] = adapter
|
||||
|
||||
return adapter
|
||||
|
||||
|
||||
#Helpers
|
||||
|
||||
def make_source(chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
user_name="e2e_tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def make_event(text: str, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=make_source(chat_id, user_id),
|
||||
message_id=f"msg-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
|
||||
def make_session_entry(source: SessionSource = None) -> SessionEntry:
|
||||
source = source or make_source()
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=f"sess-{uuid.uuid4().hex[:8]}",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
async def send_and_capture(adapter: TelegramAdapter, text: str, **event_kwargs) -> AsyncMock:
|
||||
"""Send a message through the full e2e flow and return the send mock.
|
||||
|
||||
Drives: adapter.handle_message → background task → runner dispatch → adapter.send.
|
||||
"""
|
||||
event = make_event(text, **event_kwargs)
|
||||
async def send_and_capture(adapter, text: str, platform: Platform, **event_kwargs) -> AsyncMock:
|
||||
"""Send a message through the full e2e flow and return the send mock."""
|
||||
event = make_event(platform, text, **event_kwargs)
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
# Let the background task complete
|
||||
await asyncio.sleep(0.3)
|
||||
return adapter.send
|
||||
|
||||
|
||||
# Parametrized fixtures for platform-generic tests
|
||||
@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK], ids=["telegram", "discord", "slack"])
|
||||
def platform(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def source(platform):
|
||||
return make_source(platform)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session_entry(platform, source):
|
||||
return make_session_entry(platform, source)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runner(platform, session_entry):
|
||||
return make_runner(platform, session_entry)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adapter(platform, runner):
|
||||
return make_adapter(platform, runner)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""E2E tests for Telegram gateway slash commands.
|
||||
"""E2E tests for gateway slash commands (Telegram, Discord).
|
||||
|
||||
Each test drives a message through the full async pipeline:
|
||||
adapter.handle_message(event)
|
||||
@@ -7,6 +7,7 @@ Each test drives a message through the full async pipeline:
|
||||
→ adapter.send() (captured for assertions)
|
||||
|
||||
No LLM involved — only gateway-level commands are tested.
|
||||
Tests are parametrized over platforms via the ``platform`` fixture in conftest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,46 +16,15 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import SendResult
|
||||
from tests.e2e.conftest import (
|
||||
make_adapter,
|
||||
make_event,
|
||||
make_runner,
|
||||
make_session_entry,
|
||||
make_source,
|
||||
send_and_capture,
|
||||
)
|
||||
from tests.e2e.conftest import make_event, send_and_capture
|
||||
|
||||
|
||||
#Fixtures
|
||||
|
||||
@pytest.fixture()
|
||||
def source():
|
||||
return make_source()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session_entry(source):
|
||||
return make_session_entry(source)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runner(session_entry):
|
||||
return make_runner(session_entry)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adapter(runner):
|
||||
return make_adapter(runner)
|
||||
|
||||
|
||||
#Tests
|
||||
|
||||
class TestTelegramSlashCommands:
|
||||
class TestSlashCommands:
|
||||
"""Gateway slash commands dispatched through the full adapter pipeline."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_returns_command_list(self, adapter):
|
||||
send = await send_and_capture(adapter, "/help")
|
||||
async def test_help_returns_command_list(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/help", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
@@ -62,24 +32,23 @@ class TestTelegramSlashCommands:
|
||||
assert "/status" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_shows_session_info(self, adapter):
|
||||
send = await send_and_capture(adapter, "/status")
|
||||
async def test_status_shows_session_info(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/status", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
# Status output includes session metadata
|
||||
assert "session" in response_text.lower() or "Session" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_resets_session(self, adapter, runner):
|
||||
send = await send_and_capture(adapter, "/new")
|
||||
async def test_new_resets_session(self, adapter, runner, platform):
|
||||
send = await send_and_capture(adapter, "/new", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
runner.session_store.reset_session.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_when_no_agent_running(self, adapter):
|
||||
send = await send_and_capture(adapter, "/stop")
|
||||
async def test_stop_when_no_agent_running(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/stop", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
@@ -87,8 +56,8 @@ class TestTelegramSlashCommands:
|
||||
assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_shows_listing(self, adapter):
|
||||
send = await send_and_capture(adapter, "/commands")
|
||||
async def test_commands_shows_listing(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/commands", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
@@ -96,29 +65,25 @@ class TestTelegramSlashCommands:
|
||||
assert "/" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_commands_share_session(self, adapter):
|
||||
async def test_sequential_commands_share_session(self, adapter, platform):
|
||||
"""Two commands from the same chat_id should both succeed."""
|
||||
send_help = await send_and_capture(adapter, "/help")
|
||||
send_help = await send_and_capture(adapter, "/help", platform)
|
||||
send_help.assert_called_once()
|
||||
|
||||
send_status = await send_and_capture(adapter, "/status")
|
||||
send_status = await send_and_capture(adapter, "/status", platform)
|
||||
send_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(
|
||||
reason="Bug: _handle_provider_command references unbound model_cfg when config.yaml is absent",
|
||||
strict=False,
|
||||
)
|
||||
async def test_provider_shows_current_provider(self, adapter):
|
||||
send = await send_and_capture(adapter, "/provider")
|
||||
async def test_provider_shows_current_provider(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/provider", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "provider" in response_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_responds(self, adapter):
|
||||
send = await send_and_capture(adapter, "/verbose")
|
||||
async def test_verbose_responds(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/verbose", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
@@ -126,42 +91,50 @@ class TestTelegramSlashCommands:
|
||||
assert "verbose" in response_text.lower() or "tool_progress" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_lists_options(self, adapter):
|
||||
send = await send_and_capture(adapter, "/personality")
|
||||
async def test_personality_lists_options(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/personality", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "personalit" in response_text.lower() # matches "personality" or "personalities"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yolo_toggles_mode(self, adapter):
|
||||
send = await send_and_capture(adapter, "/yolo")
|
||||
async def test_yolo_toggles_mode(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/yolo", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "yolo" in response_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_command(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/compress", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "compress" in response_text.lower() or "context" in response_text.lower()
|
||||
|
||||
|
||||
class TestSessionLifecycle:
|
||||
"""Verify session state changes across command sequences."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry):
|
||||
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry, platform):
|
||||
"""After /new, /status should report the fresh session."""
|
||||
await send_and_capture(adapter, "/new")
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
runner.session_store.reset_session.assert_called_once()
|
||||
|
||||
send = await send_and_capture(adapter, "/status")
|
||||
send = await send_and_capture(adapter, "/status", platform)
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
# Session ID from the entry should appear in the status output
|
||||
assert session_entry.session_id[:8] in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_is_idempotent(self, adapter, runner):
|
||||
async def test_new_is_idempotent(self, adapter, runner, platform):
|
||||
"""/new called twice should not crash."""
|
||||
await send_and_capture(adapter, "/new")
|
||||
await send_and_capture(adapter, "/new")
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
assert runner.session_store.reset_session.call_count == 2
|
||||
|
||||
|
||||
@@ -169,11 +142,11 @@ class TestAuthorization:
|
||||
"""Verify the pipeline handles unauthorized users."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner):
|
||||
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner, platform):
|
||||
"""Unauthorized DM should trigger pairing code, not a command response."""
|
||||
runner._is_user_authorized = lambda _source: False
|
||||
|
||||
event = make_event("/help")
|
||||
event = make_event(platform, "/help")
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -185,11 +158,11 @@ class TestAuthorization:
|
||||
assert "recognize" in response_text.lower() or "pair" in response_text.lower() or "ABC123" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_user_does_not_get_help(self, adapter, runner):
|
||||
async def test_unauthorized_user_does_not_get_help(self, adapter, runner, platform):
|
||||
"""Unauthorized user should NOT see the help command output."""
|
||||
runner._is_user_authorized = lambda _source: False
|
||||
|
||||
event = make_event("/help")
|
||||
event = make_event(platform, "/help")
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -204,12 +177,12 @@ class TestSendFailureResilience:
|
||||
"""Verify the pipeline handles send failures gracefully."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_failure_does_not_crash_pipeline(self, adapter):
|
||||
async def test_send_failure_does_not_crash_pipeline(self, adapter, platform):
|
||||
"""If send() returns failure, the pipeline should not raise."""
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=False, error="network timeout"))
|
||||
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
|
||||
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
|
||||
|
||||
event = make_event("/help")
|
||||
event = make_event(platform, "/help")
|
||||
# Should not raise — pipeline handles send failures internally
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
110
tests/gateway/restart_test_helpers.py
Normal file
110
tests/gateway/restart_test_helpers.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class RestartTestAdapter(BasePlatformAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def connect(self):
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
return None
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
self.sent.append(content)
|
||||
return SendResult(success=True, message_id="1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
def make_restart_source(chat_id: str = "123456", chat_type: str = "dm") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
def make_restart_runner(
|
||||
adapter: BasePlatformAdapter | None = None,
|
||||
) -> tuple[GatewayRunner, BasePlatformAdapter]:
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
runner._running = True
|
||||
runner._shutdown_event = asyncio.Event()
|
||||
runner._exit_reason = None
|
||||
runner._exit_code = None
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._background_tasks = set()
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
runner._stop_task = None
|
||||
runner._busy_input_mode = "interrupt"
|
||||
runner._update_prompt_pending = {}
|
||||
runner._voice_mode = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._shutdown_all_gateway_honcho = lambda: None
|
||||
runner._update_runtime_status = MagicMock()
|
||||
runner._queue_or_replace_pending_event = GatewayRunner._queue_or_replace_pending_event.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._session_key_for_source = GatewayRunner._session_key_for_source.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._handle_active_session_busy_message = (
|
||||
GatewayRunner._handle_active_session_busy_message.__get__(runner, GatewayRunner)
|
||||
)
|
||||
runner._handle_restart_command = GatewayRunner._handle_restart_command.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._status_action_label = GatewayRunner._status_action_label.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._status_action_gerund = GatewayRunner._status_action_gerund.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._queue_during_drain_enabled = GatewayRunner._queue_during_drain_enabled.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._running_agent_count = GatewayRunner._running_agent_count.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner.request_restart = GatewayRunner.request_restart.__get__(runner, GatewayRunner)
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.session_store = MagicMock()
|
||||
runner.delivery_router = MagicMock()
|
||||
|
||||
platform_adapter = adapter or RestartTestAdapter()
|
||||
platform_adapter.set_message_handler(AsyncMock(return_value=None))
|
||||
platform_adapter.set_busy_session_handler(runner._handle_active_session_busy_message)
|
||||
runner.adapters = {Platform.TELEGRAM: platform_adapter}
|
||||
return runner, platform_adapter
|
||||
@@ -26,6 +26,7 @@ from gateway.platforms.api_server import (
|
||||
APIServerAdapter,
|
||||
ResponseStore,
|
||||
_CORS_HEADERS,
|
||||
_derive_chat_session_id,
|
||||
check_api_server_requirements,
|
||||
cors_middleware,
|
||||
security_headers_middleware,
|
||||
@@ -463,7 +464,7 @@ class TestChatCompletionsEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_includes_tool_progress(self, adapter):
|
||||
"""tool_progress_callback fires → progress appears in the SSE stream."""
|
||||
"""tool_progress_callback fires → progress appears as custom SSE event, not in delta.content."""
|
||||
import asyncio
|
||||
|
||||
app = _create_app(adapter)
|
||||
@@ -494,8 +495,26 @@ class TestChatCompletionsEndpoint:
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
assert "[DONE]" in body
|
||||
# Tool progress message must appear in the stream
|
||||
assert "ls -la" in body
|
||||
# Tool progress must appear as a custom SSE event, not in
|
||||
# delta.content — prevents model from learning to imitate
|
||||
# markers instead of calling tools (#6972).
|
||||
assert "event: hermes.tool.progress" in body
|
||||
assert '"tool": "terminal"' in body
|
||||
assert '"label": "ls -la"' in body
|
||||
# The progress marker must NOT appear inside any
|
||||
# chat.completion.chunk delta.content field.
|
||||
import json as _json
|
||||
for line in body.splitlines():
|
||||
if line.startswith("data: ") and line.strip() != "data: [DONE]":
|
||||
try:
|
||||
chunk = _json.loads(line[len("data: "):])
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
if chunk.get("object") == "chat.completion.chunk":
|
||||
for choice in chunk.get("choices", []):
|
||||
content = choice.get("delta", {}).get("content", "")
|
||||
# Tool emoji markers must never leak into content
|
||||
assert "ls -la" not in content or content == "Here are the files."
|
||||
# Final content must also be present
|
||||
assert "Here are the files." in body
|
||||
|
||||
@@ -531,10 +550,12 @@ class TestChatCompletionsEndpoint:
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
# Internal _thinking event should NOT appear
|
||||
# Internal _thinking event should NOT appear anywhere
|
||||
assert "some internal state" not in body
|
||||
# Real tool progress should appear
|
||||
assert "Python docs" in body
|
||||
# Real tool progress should appear as custom SSE event
|
||||
assert "event: hermes.tool.progress" in body
|
||||
assert '"tool": "web_search"' in body
|
||||
assert '"label": "Python docs"' in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_message_returns_400(self, adapter):
|
||||
@@ -658,6 +679,98 @@ class TestChatCompletionsEndpoint:
|
||||
data = await resp.json()
|
||||
assert "Provider failed" in data["error"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stable_session_id_across_turns(self, adapter):
|
||||
"""Same conversation (same first user message) produces the same session_id."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
|
||||
app = _create_app(adapter)
|
||||
session_ids = []
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
# Turn 1: single user message
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
# Turn 2: same first message, conversation grew
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
assert session_ids[0] == session_ids[1], "Session ID should be stable across turns"
|
||||
assert session_ids[0].startswith("api-"), "Derived session IDs should have api- prefix"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_conversations_get_different_session_ids(self, adapter):
|
||||
"""Different first messages produce different session_ids."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
|
||||
app = _create_app(adapter)
|
||||
session_ids = []
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
for first_msg in ["Hello", "Goodbye"]:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": first_msg}],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
assert session_ids[0] != session_ids[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _derive_chat_session_id unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeriveChatSessionId:
|
||||
def test_deterministic(self):
|
||||
"""Same inputs always produce the same session ID."""
|
||||
a = _derive_chat_session_id("sys", "hello")
|
||||
b = _derive_chat_session_id("sys", "hello")
|
||||
assert a == b
|
||||
|
||||
def test_prefix(self):
|
||||
assert _derive_chat_session_id(None, "hi").startswith("api-")
|
||||
|
||||
def test_different_system_prompt(self):
|
||||
a = _derive_chat_session_id("You are a pirate.", "Hello")
|
||||
b = _derive_chat_session_id("You are a robot.", "Hello")
|
||||
assert a != b
|
||||
|
||||
def test_different_first_message(self):
|
||||
a = _derive_chat_session_id(None, "Hello")
|
||||
b = _derive_chat_session_id(None, "Goodbye")
|
||||
assert a != b
|
||||
|
||||
def test_none_system_prompt(self):
|
||||
"""None system prompt doesn't crash."""
|
||||
sid = _derive_chat_session_id(None, "test")
|
||||
assert isinstance(sid, str) and len(sid) > 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/responses endpoint
|
||||
@@ -1634,7 +1747,7 @@ class TestSessionIdHeader:
|
||||
assert resp.headers.get("X-Hermes-Session-Id") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provided_session_id_is_used_and_echoed(self, adapter):
|
||||
async def test_provided_session_id_is_used_and_echoed(self, auth_adapter):
|
||||
"""When X-Hermes-Session-Id is provided, it's passed to the agent and echoed in the response."""
|
||||
mock_result = {"final_response": "Continuing!", "messages": [], "api_calls": 1}
|
||||
mock_db = MagicMock()
|
||||
@@ -1642,15 +1755,15 @@ class TestSessionIdHeader:
|
||||
{"role": "user", "content": "previous message"},
|
||||
{"role": "assistant", "content": "previous reply"},
|
||||
]
|
||||
adapter._session_db = mock_db
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = mock_db
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "my-session-123"},
|
||||
headers={"X-Hermes-Session-Id": "my-session-123", "Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Continue"}]},
|
||||
)
|
||||
|
||||
@@ -1660,7 +1773,7 @@ class TestSessionIdHeader:
|
||||
assert call_kwargs["session_id"] == "my-session-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provided_session_id_loads_history_from_db(self, adapter):
|
||||
async def test_provided_session_id_loads_history_from_db(self, auth_adapter):
|
||||
"""When X-Hermes-Session-Id is provided, history comes from SessionDB not request body."""
|
||||
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
||||
db_history = [
|
||||
@@ -1669,15 +1782,15 @@ class TestSessionIdHeader:
|
||||
]
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_messages_as_conversation.return_value = db_history
|
||||
adapter._session_db = mock_db
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = mock_db
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "existing-session"},
|
||||
headers={"X-Hermes-Session-Id": "existing-session", "Authorization": "Bearer sk-secret"},
|
||||
# Request body has different history — should be ignored
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
@@ -1696,20 +1809,20 @@ class TestSessionIdHeader:
|
||||
assert call_kwargs["user_message"] == "new question"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_failure_falls_back_to_empty_history(self, adapter):
|
||||
async def test_db_failure_falls_back_to_empty_history(self, auth_adapter):
|
||||
"""If SessionDB raises, history falls back to empty and request still succeeds."""
|
||||
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
||||
# Simulate DB failure: _session_db is None and SessionDB() constructor raises
|
||||
adapter._session_db = None
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = None
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \
|
||||
patch("hermes_state.SessionDB", side_effect=Exception("DB unavailable")):
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "some-session"},
|
||||
headers={"X-Hermes-Session-Id": "some-session", "Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
)
|
||||
|
||||
|
||||
132
tests/gateway/test_api_server_bind_guard.py
Normal file
132
tests/gateway/test_api_server_bind_guard.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Tests for the API server bind-address startup guard.
|
||||
|
||||
Validates that is_network_accessible() correctly classifies addresses and
|
||||
that connect() refuses to start on non-loopback without API_SERVER_KEY.
|
||||
"""
|
||||
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.api_server import APIServerAdapter
|
||||
from gateway.platforms.base import is_network_accessible
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: is_network_accessible()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsNetworkAccessible:
|
||||
"""Direct tests for the address classification helper."""
|
||||
|
||||
# -- Loopback (safe, should return False) --
|
||||
|
||||
def test_ipv4_loopback(self):
|
||||
assert is_network_accessible("127.0.0.1") is False
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
assert is_network_accessible("::1") is False
|
||||
|
||||
def test_ipv4_mapped_loopback(self):
|
||||
# ::ffff:127.0.0.1 — Python's is_loopback returns False for mapped
|
||||
# addresses; the helper must unwrap and check ipv4_mapped.
|
||||
assert is_network_accessible("::ffff:127.0.0.1") is False
|
||||
|
||||
# -- Network-accessible (should return True) --
|
||||
|
||||
def test_ipv4_wildcard(self):
|
||||
assert is_network_accessible("0.0.0.0") is True
|
||||
|
||||
def test_ipv6_wildcard(self):
|
||||
# This is the bypass vector that the string-based check missed.
|
||||
assert is_network_accessible("::") is True
|
||||
|
||||
def test_ipv4_mapped_unspecified(self):
|
||||
assert is_network_accessible("::ffff:0.0.0.0") is True
|
||||
|
||||
def test_private_ipv4(self):
|
||||
assert is_network_accessible("10.0.0.1") is True
|
||||
|
||||
def test_private_ipv4_class_c(self):
|
||||
assert is_network_accessible("192.168.1.1") is True
|
||||
|
||||
def test_public_ipv4(self):
|
||||
assert is_network_accessible("8.8.8.8") is True
|
||||
|
||||
# -- Hostname resolution --
|
||||
|
||||
def test_localhost_resolves_to_loopback(self):
|
||||
loopback_result = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0)),
|
||||
]
|
||||
with patch("gateway.platforms.base._socket.getaddrinfo", return_value=loopback_result):
|
||||
assert is_network_accessible("localhost") is False
|
||||
|
||||
def test_hostname_resolving_to_non_loopback(self):
|
||||
non_loopback_result = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", 0)),
|
||||
]
|
||||
with patch("gateway.platforms.base._socket.getaddrinfo", return_value=non_loopback_result):
|
||||
assert is_network_accessible("my-server.local") is True
|
||||
|
||||
def test_hostname_mixed_resolution(self):
|
||||
"""If a hostname resolves to both loopback and non-loopback, it's
|
||||
network-accessible (any non-loopback address is enough)."""
|
||||
mixed_result = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", 0)),
|
||||
]
|
||||
with patch("gateway.platforms.base._socket.getaddrinfo", return_value=mixed_result):
|
||||
assert is_network_accessible("dual-host.local") is True
|
||||
|
||||
def test_dns_failure_fails_closed(self):
|
||||
"""Unresolvable hostnames should require an API key (fail closed)."""
|
||||
with patch(
|
||||
"gateway.platforms.base._socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("Name resolution failed"),
|
||||
):
|
||||
assert is_network_accessible("nonexistent.invalid") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: connect() startup guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnectBindGuard:
|
||||
"""Verify that connect() refuses dangerous configurations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_ipv4_wildcard_without_key(self):
|
||||
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"host": "0.0.0.0"}))
|
||||
result = await adapter.connect()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_ipv6_wildcard_without_key(self):
|
||||
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"host": "::"}))
|
||||
result = await adapter.connect()
|
||||
assert result is False
|
||||
|
||||
def test_allows_loopback_without_key(self):
|
||||
"""Loopback with no key should pass the guard."""
|
||||
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"host": "127.0.0.1"}))
|
||||
assert adapter._api_key == ""
|
||||
# The guard condition: is_network_accessible(host) AND NOT api_key
|
||||
# For loopback, is_network_accessible is False so the guard does not block.
|
||||
assert is_network_accessible(adapter._host) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_wildcard_with_key(self):
|
||||
"""Non-loopback with a key should pass the guard."""
|
||||
adapter = APIServerAdapter(
|
||||
PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "sk-test"})
|
||||
)
|
||||
# The guard checks: is_network_accessible(host) AND NOT api_key
|
||||
# With a key set, the guard should not block.
|
||||
assert adapter._api_key == "sk-test"
|
||||
assert is_network_accessible("0.0.0.0") is True
|
||||
# Combined: the guard condition is False (key is set), so it passes
|
||||
@@ -141,7 +141,7 @@ class TestBlockingGatewayApproval:
|
||||
def test_resolve_single_pops_oldest_fifo(self):
|
||||
"""resolve_gateway_approval without resolve_all resolves oldest first."""
|
||||
from tools.approval import (
|
||||
resolve_gateway_approval, pending_approval_count,
|
||||
resolve_gateway_approval,
|
||||
_ApprovalEntry, _gateway_queues,
|
||||
)
|
||||
session_key = "test-fifo"
|
||||
@@ -154,7 +154,7 @@ class TestBlockingGatewayApproval:
|
||||
assert e1.event.is_set()
|
||||
assert e1.result == "once"
|
||||
assert not e2.event.is_set()
|
||||
assert pending_approval_count(session_key) == 1
|
||||
assert len(_gateway_queues[session_key]) == 1
|
||||
|
||||
def test_unregister_signals_all_entries(self):
|
||||
"""unregister_gateway_notify signals all waiting entries to prevent hangs."""
|
||||
@@ -173,35 +173,6 @@ class TestBlockingGatewayApproval:
|
||||
assert e1.event.is_set()
|
||||
assert e2.event.is_set()
|
||||
|
||||
def test_clear_session_signals_all_entries(self):
|
||||
"""clear_session should unblock all waiting approval threads."""
|
||||
from tools.approval import (
|
||||
register_gateway_notify, clear_session,
|
||||
_ApprovalEntry, _gateway_queues,
|
||||
)
|
||||
session_key = "test-clear"
|
||||
register_gateway_notify(session_key, lambda d: None)
|
||||
|
||||
e1 = _ApprovalEntry({"command": "cmd1"})
|
||||
e2 = _ApprovalEntry({"command": "cmd2"})
|
||||
_gateway_queues[session_key] = [e1, e2]
|
||||
|
||||
clear_session(session_key)
|
||||
assert e1.event.is_set()
|
||||
assert e2.event.is_set()
|
||||
|
||||
def test_pending_approval_count(self):
|
||||
from tools.approval import (
|
||||
pending_approval_count, _ApprovalEntry, _gateway_queues,
|
||||
)
|
||||
session_key = "test-count"
|
||||
assert pending_approval_count(session_key) == 0
|
||||
_gateway_queues[session_key] = [
|
||||
_ApprovalEntry({"command": "a"}),
|
||||
_ApprovalEntry({"command": "b"}),
|
||||
]
|
||||
assert pending_approval_count(session_key) == 2
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /approve command
|
||||
@@ -374,6 +345,11 @@ class TestBlockingApprovalE2E:
|
||||
|
||||
def setup_method(self):
|
||||
_clear_approval_state()
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
|
||||
def test_blocking_approval_approve_once(self):
|
||||
"""check_all_command_guards blocks until resolve_gateway_approval is called."""
|
||||
@@ -393,6 +369,7 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -400,6 +377,7 @@ class TestBlockingApprovalE2E:
|
||||
"rm -rf /important", "local"
|
||||
)
|
||||
finally:
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
@@ -439,6 +417,7 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -446,6 +425,7 @@ class TestBlockingApprovalE2E:
|
||||
"rm -rf /important", "local"
|
||||
)
|
||||
finally:
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
@@ -480,6 +460,7 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -489,6 +470,7 @@ class TestBlockingApprovalE2E:
|
||||
"rm -rf /important", "local"
|
||||
)
|
||||
finally:
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
@@ -506,7 +488,7 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import (
|
||||
register_gateway_notify, unregister_gateway_notify,
|
||||
resolve_gateway_approval, check_all_command_guards,
|
||||
pending_approval_count,
|
||||
_gateway_queues,
|
||||
)
|
||||
|
||||
session_key = "e2e-parallel"
|
||||
@@ -520,11 +502,13 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
results[idx] = check_all_command_guards(cmd, "local")
|
||||
finally:
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
@@ -545,7 +529,7 @@ class TestBlockingApprovalE2E:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert len(notified) == 3
|
||||
assert pending_approval_count(session_key) == 3
|
||||
assert len(_gateway_queues.get(session_key, [])) == 3
|
||||
|
||||
# Approve all at once
|
||||
count = resolve_gateway_approval(session_key, "session", resolve_all=True)
|
||||
@@ -575,11 +559,13 @@ class TestBlockingApprovalE2E:
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
results[idx] = check_all_command_guards(cmd, "local")
|
||||
finally:
|
||||
os.environ.pop("HERMES_GATEWAY_SESSION", None)
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
|
||||
@@ -308,6 +308,7 @@ class TestBackgroundInCLICommands:
|
||||
|
||||
def test_background_autocompletes(self):
|
||||
"""The /background command appears in autocomplete results."""
|
||||
pytest.importorskip("prompt_toolkit")
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, ProcessingOutcome, SendResult
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class DummyTelegramAdapter(BasePlatformAdapter):
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
self.processing_hooks.append(("start", event.message_id))
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
self.processing_hooks.append(("complete", event.message_id, success))
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
self.processing_hooks.append(("complete", event.message_id, outcome))
|
||||
|
||||
|
||||
def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent:
|
||||
@@ -142,7 +142,7 @@ class TestBasePlatformTopicSessions:
|
||||
]
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", True),
|
||||
("complete", "1", ProcessingOutcome.SUCCESS),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -168,7 +168,7 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -190,7 +190,7 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -218,5 +218,31 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_marks_expected_cancellation_cancelled(self):
|
||||
adapter = DummyTelegramAdapter()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(_event):
|
||||
await release.wait()
|
||||
return "ack"
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("-1001", "17585")
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await adapter.cancel_background_tasks()
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", ProcessingOutcome.CANCELLED),
|
||||
]
|
||||
|
||||
@@ -359,3 +359,257 @@ class TestBlueBubblesAttachmentDownload:
|
||||
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookUrl:
|
||||
"""_webhook_url property normalises local hosts to 'localhost'."""
|
||||
|
||||
def test_default_host(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# Default webhook_host is 0.0.0.0 → normalized to localhost
|
||||
assert "localhost" in adapter._webhook_url
|
||||
assert str(adapter.webhook_port) in adapter._webhook_url
|
||||
assert adapter.webhook_path in adapter._webhook_url
|
||||
|
||||
@pytest.mark.parametrize("host", ["0.0.0.0", "127.0.0.1", "localhost", "::"])
|
||||
def test_local_hosts_normalized(self, monkeypatch, host):
|
||||
adapter = _make_adapter(monkeypatch, webhook_host=host)
|
||||
assert adapter._webhook_url.startswith("http://localhost:")
|
||||
|
||||
def test_custom_host_preserved(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
|
||||
assert "192.168.1.50" in adapter._webhook_url
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookRegistration:
|
||||
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""
|
||||
|
||||
@staticmethod
|
||||
def _mock_client(get_response=None, post_response=None, delete_ok=True):
|
||||
"""Build a tiny mock httpx.AsyncClient."""
|
||||
|
||||
async def mock_get(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return get_response or {"status": 200, "data": []}
|
||||
return R()
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return post_response or {"status": 200, "data": {}}
|
||||
return R()
|
||||
|
||||
async def mock_delete(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200 if delete_ok else 500
|
||||
def raise_for_status(self_inner):
|
||||
if not delete_ok:
|
||||
raise Exception("delete failed")
|
||||
return R()
|
||||
|
||||
return type(
|
||||
"MockClient", (),
|
||||
{"get": mock_get, "post": mock_post, "delete": mock_delete},
|
||||
)()
|
||||
|
||||
# -- _find_registered_webhooks --
|
||||
|
||||
def test_find_registered_webhooks_returns_matches(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 1, "url": url, "events": ["new-message"]},
|
||||
{"id": 2, "url": "http://other:9999/hook", "events": ["message"]},
|
||||
]}
|
||||
)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(url)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == 1
|
||||
|
||||
def test_find_registered_webhooks_empty_when_none(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []}
|
||||
)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_find_registered_webhooks_handles_api_error(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client()
|
||||
|
||||
# Override _api_get to raise
|
||||
async def bad_get(path):
|
||||
raise ConnectionError("server down")
|
||||
adapter._api_get = bad_get
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||
)
|
||||
assert result == []
|
||||
|
||||
# -- _register_webhook --
|
||||
|
||||
def test_register_fresh(self, monkeypatch):
|
||||
"""No existing webhook → POST creates one."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 200, "data": {"id": 42}},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_register_accepts_201(self, monkeypatch):
|
||||
"""BB might return 201 Created — must still succeed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 201, "data": {"id": 43}},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_register_reuses_existing(self, monkeypatch):
|
||||
"""Crash resilience — existing registration is reused, no POST needed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 7, "url": url, "events": ["new-message"]},
|
||||
]},
|
||||
)
|
||||
|
||||
# Track whether POST was called
|
||||
post_called = False
|
||||
orig_api_post = adapter._api_post
|
||||
async def tracking_post(path, payload):
|
||||
nonlocal post_called
|
||||
post_called = True
|
||||
return await orig_api_post(path, payload)
|
||||
adapter._api_post = tracking_post
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
assert not post_called, "Should reuse existing, not POST again"
|
||||
|
||||
def test_register_returns_false_without_client(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = None
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
def test_register_returns_false_on_server_error(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 500, "message": "internal error"},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
# -- _unregister_webhook --
|
||||
|
||||
def test_unregister_removes_matching(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 10, "url": url},
|
||||
]},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_unregister_removes_all_duplicates(self, monkeypatch):
|
||||
"""Multiple orphaned registrations for same URL — all get removed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
deleted_ids = []
|
||||
|
||||
async def mock_delete(*args, **kwargs):
|
||||
# Extract ID from URL
|
||||
url_str = args[0] if args else ""
|
||||
deleted_ids.append(url_str)
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
return R()
|
||||
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 1, "url": url},
|
||||
{"id": 2, "url": url},
|
||||
{"id": 3, "url": "http://other/hook"},
|
||||
]},
|
||||
)
|
||||
adapter.client.delete = mock_delete
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
assert len(deleted_ids) == 2
|
||||
|
||||
def test_unregister_returns_false_without_client(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = None
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
def test_unregister_handles_api_failure_gracefully(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client()
|
||||
|
||||
async def bad_get(path):
|
||||
raise ConnectionError("server down")
|
||||
adapter._api_get = bad_get
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
@@ -184,6 +184,22 @@ class TestCommandBypassActiveSession:
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:tasks" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_bypasses_guard(self):
|
||||
"""/background must bypass so it spawns a parallel task, not an interrupt."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/background summarize HN"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/background was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:background" in r for r in adapter.sent_responses), (
|
||||
"/background response was not sent back to the user"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass messages still get queued
|
||||
|
||||
121
tests/gateway/test_compress_command.py
Normal file
121
tests/gateway/test_compress_command.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Tests for gateway /compress user-facing messaging."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str = "/compress") -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def _make_runner(history: list[dict[str, str]]):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = history
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner.session_store._save = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_command_reports_noop_without_success_banner():
|
||||
history = _make_history()
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages):
|
||||
assert messages == history
|
||||
return 100
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
assert "No changes from compression" in result
|
||||
assert "Compressed:" not in result
|
||||
assert "Rough transcript estimate: ~100 tokens (unchanged)" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_command_explains_when_token_estimate_rises():
|
||||
history = _make_history()
|
||||
compressed = [
|
||||
history[0],
|
||||
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
|
||||
history[-1],
|
||||
]
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
return 120
|
||||
raise AssertionError(f"unexpected transcript: {messages!r}")
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
assert "Compressed: 4 → 3 messages" in result
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in result
|
||||
assert "denser summaries" in result
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the delivery routing module."""
|
||||
|
||||
from gateway.config import Platform, GatewayConfig, PlatformConfig, HomeChannel
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget, parse_deliver_spec
|
||||
from gateway.config import Platform
|
||||
from gateway.delivery import DeliveryTarget
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
@@ -41,28 +41,6 @@ class TestParseTargetPlatformChat:
|
||||
assert target.platform == Platform.LOCAL
|
||||
|
||||
|
||||
class TestParseDeliverSpec:
|
||||
def test_none_returns_default(self):
|
||||
result = parse_deliver_spec(None)
|
||||
assert result == "origin"
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = parse_deliver_spec("")
|
||||
assert result == "origin"
|
||||
|
||||
def test_custom_default(self):
|
||||
result = parse_deliver_spec(None, default="local")
|
||||
assert result == "local"
|
||||
|
||||
def test_passthrough_string(self):
|
||||
result = parse_deliver_spec("telegram")
|
||||
assert result == "telegram"
|
||||
|
||||
def test_passthrough_list(self):
|
||||
result = parse_deliver_spec(["local", "telegram"])
|
||||
assert result == ["local", "telegram"]
|
||||
|
||||
|
||||
class TestTargetToStringRoundtrip:
|
||||
def test_origin_roundtrip(self):
|
||||
origin = SessionSource(platform=Platform.TELEGRAM, chat_id="111", thread_id="42")
|
||||
@@ -87,10 +65,4 @@ class TestTargetToStringRoundtrip:
|
||||
assert reparsed.chat_id == "999"
|
||||
|
||||
|
||||
class TestDeliveryRouter:
|
||||
def test_resolve_targets_does_not_duplicate_local_when_explicit(self):
|
||||
router = DeliveryRouter(GatewayConfig(always_log_local=True))
|
||||
|
||||
targets = router.resolve_targets(["local"])
|
||||
|
||||
assert [target.platform for target in targets] == [Platform.LOCAL]
|
||||
|
||||
@@ -81,6 +81,7 @@ def adapter(monkeypatch):
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
64
tests/gateway/test_discord_channel_skills.py
Normal file
64
tests/gateway/test_discord_channel_skills.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for Discord channel_skill_bindings auto-skill resolution."""
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a minimal DiscordAdapter with mocked config."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
return adapter
|
||||
|
||||
|
||||
class TestResolveChannelSkills:
|
||||
def test_no_bindings_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._resolve_channel_skills("123") is None
|
||||
|
||||
def test_match_by_channel_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["skill-a", "skill-b"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["skill-a", "skill-b"]
|
||||
|
||||
def test_match_by_parent_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "200", "skills": ["forum-skill"]},
|
||||
]
|
||||
}
|
||||
# channel_id doesn't match, but parent_id does (forum thread)
|
||||
assert adapter._resolve_channel_skills("999", parent_id="200") == ["forum-skill"]
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["skill-a"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("999") is None
|
||||
|
||||
def test_single_skill_string(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skill": "solo-skill"},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["solo-skill"]
|
||||
|
||||
def test_dedup_preserves_order(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["a", "b", "a", "c", "b"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["a", "b", "c"]
|
||||
@@ -91,6 +91,7 @@ def adapter(monkeypatch):
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome, SendResult
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ async def test_reactions_disabled_via_env_zero(adapter, monkeypatch):
|
||||
|
||||
event = _make_event("5", raw_message)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
raw_message.add_reaction.assert_not_awaited()
|
||||
raw_message.remove_reaction.assert_not_awaited()
|
||||
@@ -232,3 +232,17 @@ async def test_reactions_enabled_by_default(adapter, monkeypatch):
|
||||
await adapter.on_processing_start(event)
|
||||
|
||||
raw_message.add_reaction.assert_awaited_once_with("👀")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_cancelled_removes_eyes_without_terminal_reaction(adapter):
|
||||
raw_message = SimpleNamespace(
|
||||
add_reaction=AsyncMock(),
|
||||
remove_reaction=AsyncMock(),
|
||||
)
|
||||
|
||||
event = _make_event("7", raw_message)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
|
||||
|
||||
raw_message.remove_reaction.assert_awaited_once_with("👀", adapter._client.user)
|
||||
raw_message.add_reaction.assert_not_awaited()
|
||||
|
||||
@@ -62,6 +62,7 @@ def adapter():
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
return adapter
|
||||
|
||||
|
||||
|
||||
44
tests/gateway/test_fallback_eviction.py
Normal file
44
tests/gateway/test_fallback_eviction.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Tests for fallback-eviction gating on failed runs (#7130).
|
||||
|
||||
When a run fails, the gateway must NOT evict the cached agent — doing so
|
||||
forces MCP reinit on the next message, creating a CPU-burning restart loop.
|
||||
Eviction should only happen on successful runs where fallback activated.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
|
||||
class TestFallbackEvictionGating:
|
||||
"""The fallback-eviction code path should skip eviction on failed runs."""
|
||||
|
||||
def test_failed_run_does_not_evict_cached_agent(self):
|
||||
"""When result has failed=True, the cached agent should NOT be evicted."""
|
||||
# The fix: `and not _run_failed` guard on the eviction check.
|
||||
# Simulate the variables that the eviction block uses.
|
||||
result = {"failed": True, "final_response": None, "error": "400 invalid model"}
|
||||
_run_failed = result.get("failed") if result else False
|
||||
assert _run_failed is True, "Failed run should be detected"
|
||||
|
||||
def test_successful_run_allows_eviction(self):
|
||||
"""When result is successful, fallback eviction should proceed."""
|
||||
result = {"completed": True, "final_response": "Hello!", "failed": False}
|
||||
_run_failed = result.get("failed") if result else False
|
||||
assert _run_failed is False, "Successful run should not be flagged"
|
||||
|
||||
def test_none_result_treated_as_not_failed(self):
|
||||
"""When result is None (edge case), treat as not-failed."""
|
||||
result = None
|
||||
_run_failed = result.get("failed") if result else False
|
||||
assert _run_failed is False
|
||||
|
||||
def test_missing_failed_key_treated_as_not_failed(self):
|
||||
"""When result dict doesn't have 'failed' key, treat as not-failed."""
|
||||
result = {"completed": True, "final_response": "Hello!"}
|
||||
_run_failed = result.get("failed") if result else False
|
||||
assert not _run_failed, "Missing 'failed' key should be falsy"
|
||||
191
tests/gateway/test_fast_command.py
Normal file
191
tests/gateway/test_fast_command.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for gateway /fast support and Priority Processing routing."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
last_init = None
|
||||
last_run = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
type(self).last_init = dict(kwargs)
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
|
||||
type(self).last_run = {
|
||||
"user_message": user_message,
|
||||
"conversation_history": conversation_history,
|
||||
"task_id": task_id,
|
||||
"persist_user_message": persist_user_message,
|
||||
}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_agent(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._session_model_overrides = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(streaming=None)
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"),
|
||||
load_transcript=lambda session_id: [],
|
||||
)
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED")
|
||||
return runner
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def test_turn_route_injects_priority_processing_without_changing_runtime():
|
||||
runner = _make_runner()
|
||||
runner._service_tier = "priority"
|
||||
runtime_kwargs = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
assert route["request_overrides"] == {"service_tier": "priority"}
|
||||
|
||||
|
||||
def test_turn_route_skips_priority_processing_for_unsupported_models():
|
||||
runner = _make_runner()
|
||||
runner._service_tier = "priority"
|
||||
runtime_kwargs = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
|
||||
assert route["request_overrides"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
|
||||
runner = _make_runner()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
|
||||
response = await runner._handle_fast_command(_make_event("/fast fast"))
|
||||
|
||||
assert "FAST" in response
|
||||
assert runner._service_tier == "priority"
|
||||
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["agent"]["service_tier"] == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch, tmp_path):
|
||||
_install_fake_agent(monkeypatch)
|
||||
runner = _make_runner()
|
||||
|
||||
(tmp_path / "config.yaml").write_text("agent:\n service_tier: fast\n", encoding="utf-8")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=_make_source(),
|
||||
session_id="session-1",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init["service_tier"] == "priority"
|
||||
assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"}
|
||||
@@ -3,43 +3,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
class StubAdapter(BasePlatformAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
|
||||
|
||||
async def connect(self):
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
return None
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
return SendResult(success=True, message_id="1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
def _source(chat_id="123456", chat_type="dm"):
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
)
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
from gateway.session import build_session_key
|
||||
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_cancels_inflight_message_processing():
|
||||
adapter = StubAdapter()
|
||||
_runner, adapter = make_restart_runner()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def block_forever(_event):
|
||||
@@ -47,7 +19,7 @@ async def test_cancel_background_tasks_cancels_inflight_message_processing():
|
||||
return None
|
||||
|
||||
adapter.set_message_handler(block_forever)
|
||||
event = MessageEvent(text="work", source=_source(), message_id="1")
|
||||
event = MessageEvent(text="work", source=make_restart_source(), message_id="1")
|
||||
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0)
|
||||
@@ -65,17 +37,11 @@ async def test_cancel_background_tasks_cancels_inflight_message_processing():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks():
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")})
|
||||
runner._running = True
|
||||
runner._shutdown_event = asyncio.Event()
|
||||
runner._exit_reason = None
|
||||
runner, adapter = make_restart_runner()
|
||||
runner._pending_messages = {"session": "pending text"}
|
||||
runner._pending_approvals = {"session": {"command": "rm -rf /tmp/x"}}
|
||||
runner._background_tasks = set()
|
||||
runner._shutdown_all_gateway_honcho = lambda: None
|
||||
runner._restart_drain_timeout = 0.0
|
||||
|
||||
adapter = StubAdapter()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def block_forever(_event):
|
||||
@@ -83,7 +49,7 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
|
||||
return None
|
||||
|
||||
adapter.set_message_handler(block_forever)
|
||||
event = MessageEvent(text="work", source=_source(), message_id="1")
|
||||
event = MessageEvent(text="work", source=make_restart_source(), message_id="1")
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -93,7 +59,6 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
|
||||
session_key = build_session_key(event.source)
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents = {session_key: running_agent}
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop()
|
||||
@@ -105,3 +70,78 @@ async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks(
|
||||
assert runner._pending_messages == {}
|
||||
assert runner._pending_approvals == {}
|
||||
assert runner._shutdown_event.is_set() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stop_drains_running_agents_before_disconnect():
|
||||
runner, adapter = make_restart_runner()
|
||||
disconnect_mock = AsyncMock()
|
||||
adapter.disconnect = disconnect_mock
|
||||
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents = {"session": running_agent}
|
||||
|
||||
async def finish_agent():
|
||||
await asyncio.sleep(0.05)
|
||||
runner._running_agents.clear()
|
||||
|
||||
asyncio.create_task(finish_agent())
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop()
|
||||
|
||||
running_agent.interrupt.assert_not_called()
|
||||
disconnect_mock.assert_awaited_once()
|
||||
assert runner._shutdown_event.is_set() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stop_interrupts_after_drain_timeout():
|
||||
runner, adapter = make_restart_runner()
|
||||
runner._restart_drain_timeout = 0.05
|
||||
|
||||
disconnect_mock = AsyncMock()
|
||||
adapter.disconnect = disconnect_mock
|
||||
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents = {"session": running_agent}
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop()
|
||||
|
||||
running_agent.interrupt.assert_called_once_with("Gateway shutting down")
|
||||
disconnect_mock.assert_awaited_once()
|
||||
assert runner._shutdown_event.is_set() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stop_service_restart_sets_named_exit_code():
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.disconnect = AsyncMock()
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop(restart=True, service_restart=True)
|
||||
|
||||
assert runner._exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_active_agents_throttles_status_updates():
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner._update_runtime_status = MagicMock()
|
||||
|
||||
runner._running_agents = {"a": MagicMock(), "b": MagicMock()}
|
||||
|
||||
async def finish_agents():
|
||||
await asyncio.sleep(0.12)
|
||||
runner._running_agents.pop("a")
|
||||
await asyncio.sleep(0.12)
|
||||
runner._running_agents.clear()
|
||||
|
||||
task = asyncio.create_task(finish_agents())
|
||||
await runner._drain_active_agents(1.0)
|
||||
await task
|
||||
|
||||
# Start, one count-change update, and final update. Allow one extra update
|
||||
# if the loop observes the zero-agent state before exiting.
|
||||
assert 3 <= runner._update_runtime_status.call_count <= 4
|
||||
|
||||
@@ -128,12 +128,16 @@ async def test_internal_event_bypasses_authorization(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(GatewayRunner, "_is_user_authorized", tracking_auth)
|
||||
|
||||
# _handle_message will proceed past auth check and eventually fail on
|
||||
# downstream logic. We just need to verify auth is skipped.
|
||||
# Stop execution before the agent runner so the test doesn't block in
|
||||
# run_in_executor. Auth check happens before _handle_message_with_agent.
|
||||
async def _raise(*_a, **_kw):
|
||||
raise RuntimeError("sentinel — stop here")
|
||||
monkeypatch.setattr(GatewayRunner, "_handle_message_with_agent", _raise)
|
||||
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
except RuntimeError:
|
||||
pass # Expected sentinel
|
||||
|
||||
assert not auth_called, (
|
||||
"_is_user_authorized should NOT be called for internal events"
|
||||
@@ -175,10 +179,16 @@ async def test_internal_event_does_not_trigger_pairing(monkeypatch, tmp_path):
|
||||
|
||||
runner.pairing_store.generate_code = tracking_generate
|
||||
|
||||
# Stop execution before the agent runner so the test doesn't block in
|
||||
# run_in_executor. Pairing check happens before _handle_message_with_agent.
|
||||
async def _raise(*_a, **_kw):
|
||||
raise RuntimeError("sentinel — stop here")
|
||||
monkeypatch.setattr(GatewayRunner, "_handle_message_with_agent", _raise)
|
||||
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
except RuntimeError:
|
||||
pass # Expected sentinel
|
||||
|
||||
assert not generate_called, (
|
||||
"Pairing code should NOT be generated for internal events"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,24 +11,10 @@ import pytest
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_nio_mock():
|
||||
"""Install a mock nio module when matrix-nio isn't available."""
|
||||
if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"):
|
||||
return
|
||||
nio_mod = MagicMock()
|
||||
nio_mod.MegolmEvent = type("MegolmEvent", (), {})
|
||||
nio_mod.RoomMessageText = type("RoomMessageText", (), {})
|
||||
nio_mod.RoomMessageImage = type("RoomMessageImage", (), {})
|
||||
nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {})
|
||||
nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {})
|
||||
nio_mod.RoomMessageFile = type("RoomMessageFile", (), {})
|
||||
nio_mod.DownloadResponse = type("DownloadResponse", (), {})
|
||||
nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {})
|
||||
nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {})
|
||||
sys.modules.setdefault("nio", nio_mod)
|
||||
|
||||
|
||||
_ensure_nio_mock()
|
||||
# The matrix adapter module is importable without mautrix installed
|
||||
# (module-level imports use try/except with stubs). No need for
|
||||
# module-level mock installation — tests that call adapter methods
|
||||
# needing real mautrix APIs mock them individually.
|
||||
|
||||
|
||||
def _make_adapter(tmp_path=None):
|
||||
@@ -44,29 +30,31 @@ def _make_adapter(tmp_path=None):
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._startup_ts = time.time() - 10 # avoid startup grace filter
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False):
|
||||
"""Create a fake Matrix room."""
|
||||
room = SimpleNamespace(
|
||||
room_id=room_id,
|
||||
member_count=member_count,
|
||||
users={},
|
||||
)
|
||||
return room
|
||||
def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
|
||||
"""Mark a room as DM (or not) in the adapter's cache."""
|
||||
adapter._dm_rooms[room_id] = is_dm
|
||||
|
||||
|
||||
def _make_event(
|
||||
body,
|
||||
sender="@alice:example.org",
|
||||
event_id="$evt1",
|
||||
room_id="!room1:example.org",
|
||||
formatted_body=None,
|
||||
thread_id=None,
|
||||
):
|
||||
"""Create a fake RoomMessageText event."""
|
||||
"""Create a fake room message event.
|
||||
|
||||
The mautrix adapter reads ``event.room_id``, ``event.sender``,
|
||||
``event.event_id``, ``event.timestamp``, and ``event.content``
|
||||
(a dict with ``msgtype``, ``body``, etc.).
|
||||
"""
|
||||
content = {"body": body, "msgtype": "m.text"}
|
||||
if formatted_body:
|
||||
content["formatted_body"] = formatted_body
|
||||
@@ -82,9 +70,9 @@ def _make_event(
|
||||
return SimpleNamespace(
|
||||
sender=sender,
|
||||
event_id=event_id,
|
||||
server_timestamp=int(time.time() * 1000),
|
||||
body=body,
|
||||
source={"content": content},
|
||||
room_id=room_id,
|
||||
timestamp=int(time.time() * 1000),
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,10 +139,9 @@ async def test_require_mention_default_ignores_unmentioned(monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("hello everyone")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -166,10 +153,9 @@ async def test_require_mention_default_processes_mentioned(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "help me"
|
||||
@@ -183,11 +169,10 @@ async def test_require_mention_html_pill(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
|
||||
event = _make_event("Hermes help", formatted_body=formatted)
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -199,11 +184,11 @@ async def test_require_mention_dm_always_responds(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
# member_count=2 triggers DM detection
|
||||
room = _make_room(member_count=2)
|
||||
# Mark the room as a DM via the adapter's cache.
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello without mention")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -215,10 +200,10 @@ async def test_dm_strips_mention(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room(member_count=2)
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "help me"
|
||||
@@ -232,10 +217,9 @@ async def test_bare_mention_passes_empty_string(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("@hermes:example.org")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == ""
|
||||
@@ -249,10 +233,9 @@ async def test_require_mention_free_response_room(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room(room_id="!room1:example.org")
|
||||
event = _make_event("hello without mention")
|
||||
event = _make_event("hello without mention", room_id="!room1:example.org")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -266,10 +249,9 @@ async def test_require_mention_bot_participated_thread(monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_participated_threads.add("$thread1")
|
||||
|
||||
room = _make_room()
|
||||
event = _make_event("hello without mention", thread_id="$thread1")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -281,10 +263,9 @@ async def test_require_mention_disabled(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("hello without mention")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "hello without mention"
|
||||
@@ -302,10 +283,9 @@ async def test_auto_thread_default_creates_thread(monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$msg1"
|
||||
@@ -319,10 +299,9 @@ async def test_auto_thread_preserves_existing_thread(monkeypatch):
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_participated_threads.add("$thread_root")
|
||||
room = _make_room()
|
||||
event = _make_event("reply in thread", thread_id="$thread_root")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$thread_root"
|
||||
@@ -335,10 +314,10 @@ async def test_auto_thread_skips_dm(monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room(member_count=2)
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello dm", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
@@ -351,10 +330,9 @@ async def test_auto_thread_disabled(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
@@ -367,11 +345,10 @@ async def test_auto_thread_tracks_participation(monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
room = _make_room()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
await adapter._on_room_message(room, event)
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$msg1" in adapter._bot_participated_threads
|
||||
|
||||
@@ -384,8 +361,9 @@ async def test_auto_thread_tracks_participation(monkeypatch):
|
||||
class TestThreadPersistence:
|
||||
def test_empty_state_file(self, tmp_path, monkeypatch):
|
||||
"""No state file → empty set."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: tmp_path / "matrix_threads.json"),
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -394,9 +372,10 @@ class TestThreadPersistence:
|
||||
|
||||
def test_track_thread_persists(self, tmp_path, monkeypatch):
|
||||
"""_track_thread writes to disk."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -407,10 +386,11 @@ class TestThreadPersistence:
|
||||
|
||||
def test_threads_survive_reload(self, tmp_path, monkeypatch):
|
||||
"""Persisted threads are loaded by a new adapter instance."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
state_path.write_text(json.dumps(["$t1", "$t2"]))
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -419,9 +399,10 @@ class TestThreadPersistence:
|
||||
|
||||
def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
|
||||
"""Thread set is trimmed to _MAX_TRACKED_THREADS."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.matrix.MatrixAdapter._thread_state_path",
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -435,6 +416,95 @@ class TestThreadPersistence:
|
||||
assert len(data) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM mention-thread feature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_disabled_by_default(monkeypatch):
|
||||
"""Default (dm_mention_threads=false): DM with mention should NOT create a thread."""
|
||||
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_creates_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$dm1"
|
||||
assert msg.text == "help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello without mention", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
adapter._bot_participated_threads.add("$existing_thread")
|
||||
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$existing_thread"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_tracks_participation(monkeypatch):
|
||||
"""DM mention-thread tracks the thread in _bot_participated_threads."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$dm1" in adapter._bot_participated_threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML config bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -479,6 +549,25 @@ class TestMatrixConfigBridge:
|
||||
assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org"
|
||||
assert os.getenv("MATRIX_AUTO_THREAD") == "false"
|
||||
|
||||
def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
|
||||
"""Matrix YAML dm_mention_threads should bridge to env var."""
|
||||
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
|
||||
|
||||
import os
|
||||
import yaml
|
||||
|
||||
yaml_content = {"matrix": {"dm_mention_threads": True}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(yaml_content))
|
||||
|
||||
yaml_cfg = yaml.safe_load(config_file.read_text())
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", str(matrix_cfg["dm_mention_threads"]).lower())
|
||||
|
||||
assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
|
||||
|
||||
def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
|
||||
"""Env vars should not be overwritten by YAML values."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Tests for Matrix voice message support (MSC3245)."""
|
||||
"""Tests for Matrix voice message support (MSC3245).
|
||||
|
||||
Updated for the mautrix-python SDK (no more matrix-nio / nio imports).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Try importing real nio; skip entire file if not available.
|
||||
# A MagicMock in sys.modules (from another test) is not the real package.
|
||||
# Try importing mautrix; skip entire file if not available.
|
||||
try:
|
||||
import nio as _nio_probe
|
||||
if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
|
||||
pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
|
||||
import mautrix as _mautrix_probe
|
||||
if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"):
|
||||
pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True)
|
||||
except ImportError:
|
||||
pytest.skip("matrix-nio not installed", allow_module_level=True)
|
||||
pytest.skip("mautrix not installed", allow_module_level=True)
|
||||
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
@@ -25,7 +30,7 @@ def _make_adapter():
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
@@ -38,32 +43,26 @@ def _make_adapter():
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
|
||||
"""Create a mock Matrix room."""
|
||||
room = MagicMock()
|
||||
room.room_id = room_id
|
||||
room.member_count = member_count
|
||||
return room
|
||||
|
||||
|
||||
def _make_audio_event(
|
||||
event_id: str = "$audio_event",
|
||||
sender: str = "@alice:example.org",
|
||||
room_id: str = "!test:example.org",
|
||||
body: str = "Voice message",
|
||||
url: str = "mxc://example.org/abc123",
|
||||
is_voice: bool = False,
|
||||
mimetype: str = "audio/ogg",
|
||||
timestamp: float = 9999999999000, # ms
|
||||
timestamp: int = 9999999999000, # ms
|
||||
):
|
||||
"""
|
||||
Create a mock RoomMessageAudio event that passes isinstance checks.
|
||||
|
||||
Create a mock mautrix room message event.
|
||||
|
||||
In mautrix, the handler receives a single event object with attributes
|
||||
``room_id``, ``sender``, ``event_id``, ``timestamp``, and ``content``
|
||||
(a dict-like or serializable object).
|
||||
|
||||
Args:
|
||||
is_voice: If True, adds org.matrix.msc3245.voice field to content
|
||||
is_voice: If True, adds org.matrix.msc3245.voice field to content.
|
||||
"""
|
||||
import nio
|
||||
|
||||
# Build the source dict that nio events expose via .source
|
||||
content = {
|
||||
"msgtype": "m.audio",
|
||||
"body": body,
|
||||
@@ -72,39 +71,35 @@ def _make_audio_event(
|
||||
"mimetype": mimetype,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if is_voice:
|
||||
content["org.matrix.msc3245.voice"] = {}
|
||||
|
||||
# Create a real nio RoomMessageAudio-like object
|
||||
# We use MagicMock but configure __class__ to pass isinstance check
|
||||
event = MagicMock(spec=nio.RoomMessageAudio)
|
||||
event.event_id = event_id
|
||||
event.sender = sender
|
||||
event.body = body
|
||||
event.url = url
|
||||
event.server_timestamp = timestamp
|
||||
event.source = {
|
||||
"type": "m.room.message",
|
||||
"content": content,
|
||||
}
|
||||
# For MIME type extraction - needs to be a dict
|
||||
event.content = content
|
||||
|
||||
|
||||
event = SimpleNamespace(
|
||||
event_id=event_id,
|
||||
sender=sender,
|
||||
room_id=room_id,
|
||||
timestamp=timestamp,
|
||||
content=content,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def _make_download_response(body: bytes = b"fake audio data"):
|
||||
"""Create a mock nio.MemoryDownloadResponse."""
|
||||
import nio
|
||||
resp = MagicMock()
|
||||
resp.body = body
|
||||
resp.__class__ = nio.MemoryDownloadResponse
|
||||
return resp
|
||||
def _make_state_store(member_count: int = 2):
|
||||
"""Create a mock state store with get_members/get_member support."""
|
||||
store = MagicMock()
|
||||
# get_members returns a list of member user IDs
|
||||
members = [MagicMock() for _ in range(member_count)]
|
||||
store.get_members = AsyncMock(return_value=members)
|
||||
# get_member returns a single member info object
|
||||
member = MagicMock()
|
||||
member.displayname = "Alice"
|
||||
store.get_member = AsyncMock(return_value=member)
|
||||
return store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: MSC3245 Voice Detection (RED -> GREEN)
|
||||
# Tests: MSC3245 Voice Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixVoiceMessageDetection:
|
||||
@@ -118,27 +113,28 @@ class TestMatrixVoiceMessageDetection:
|
||||
self.adapter._message_handler = AsyncMock()
|
||||
# Mock _mxc_to_http to return a fake HTTP URL
|
||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||
# Mock client for authenticated download
|
||||
# Mock client for authenticated download — download_media returns bytes directly
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.download = AsyncMock(return_value=_make_download_response())
|
||||
self.adapter._client.download_media = AsyncMock(return_value=b"fake audio data")
|
||||
# State store for DM detection
|
||||
self.adapter._client.state_store = _make_state_store()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_has_type_voice(self):
|
||||
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
|
||||
# Capture the MessageEvent passed to handle_message
|
||||
captured_event = None
|
||||
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None, "No event was captured"
|
||||
assert captured_event.message_type == MessageType.VOICE, \
|
||||
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
||||
@@ -146,44 +142,43 @@ class TestMatrixVoiceMessageDetection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_has_local_path(self):
|
||||
"""Voice messages should have a local cached path in media_urls."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
|
||||
captured_event = None
|
||||
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
assert len(captured_event.media_urls) > 0
|
||||
# Should be a local path, not an HTTP URL
|
||||
assert not captured_event.media_urls[0].startswith("http"), \
|
||||
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
|
||||
# download_media is called with a ContentURI wrapping the mxc URL
|
||||
self.adapter._client.download_media.assert_awaited_once()
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_without_msc3245_stays_audio_type(self):
|
||||
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=False) # NOT a voice message
|
||||
|
||||
|
||||
captured_event = None
|
||||
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.message_type == MessageType.AUDIO, \
|
||||
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
||||
@@ -191,25 +186,24 @@ class TestMatrixVoiceMessageDetection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_audio_has_http_url(self):
|
||||
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=False)
|
||||
|
||||
|
||||
captured_event = None
|
||||
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should be HTTP URL, not local path
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download.assert_not_awaited()
|
||||
self.adapter._client.download_media.assert_not_awaited()
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
|
||||
@@ -224,29 +218,26 @@ class TestMatrixVoiceCacheFallback:
|
||||
self.adapter._message_handler = AsyncMock()
|
||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.state_store = _make_state_store()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
||||
"""If caching fails, voice message should still be delivered with HTTP URL."""
|
||||
room = _make_room()
|
||||
"""If caching fails (download returns None), voice message should still be delivered with HTTP URL."""
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
# Make download fail
|
||||
import nio
|
||||
error_resp = MagicMock()
|
||||
error_resp.__class__ = nio.DownloadError
|
||||
self.adapter._client.download = AsyncMock(return_value=error_resp)
|
||||
|
||||
|
||||
# download_media returns None on failure
|
||||
self.adapter._client.download_media = AsyncMock(return_value=None)
|
||||
|
||||
captured_event = None
|
||||
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should fall back to HTTP URL
|
||||
@@ -256,10 +247,9 @@ class TestMatrixVoiceCacheFallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
||||
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
captured_event = None
|
||||
|
||||
@@ -269,7 +259,7 @@ class TestMatrixVoiceCacheFallback:
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
@@ -278,7 +268,7 @@ class TestMatrixVoiceCacheFallback:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: send_voice includes MSC3245 field (RED -> GREEN)
|
||||
# Tests: send_voice includes MSC3245 field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixSendVoiceMSC3245:
|
||||
@@ -287,62 +277,52 @@ class TestMatrixSendVoiceMSC3245:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
# Mock client with successful upload
|
||||
# Mock client — upload_media returns a ContentURI string
|
||||
self.adapter._client = MagicMock()
|
||||
self.upload_call = None
|
||||
|
||||
async def mock_upload(*args, **kwargs):
|
||||
self.upload_call = (args, kwargs)
|
||||
import nio
|
||||
resp = MagicMock()
|
||||
resp.content_uri = "mxc://example.org/uploaded"
|
||||
resp.__class__ = nio.UploadResponse
|
||||
return resp, None
|
||||
async def mock_upload_media(data, mime_type=None, filename=None, **kwargs):
|
||||
self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename}
|
||||
return "mxc://example.org/uploaded"
|
||||
|
||||
self.adapter._client.upload = mock_upload
|
||||
self.adapter._client.upload_media = mock_upload_media
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_includes_msc3245_field(self):
|
||||
@patch("mimetypes.guess_type", return_value=("audio/ogg", None))
|
||||
async def test_send_voice_includes_msc3245_field(self, _mock_guess):
|
||||
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create a temp audio file
|
||||
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
||||
f.write(b"fake audio data")
|
||||
temp_path = f.name
|
||||
|
||||
|
||||
try:
|
||||
# Capture the message content sent to room_send
|
||||
# Capture the message content sent via send_message_event
|
||||
sent_content = None
|
||||
|
||||
async def mock_room_send(room_id, event_type, content):
|
||||
|
||||
async def mock_send_message_event(room_id, event_type, content):
|
||||
nonlocal sent_content
|
||||
sent_content = content
|
||||
resp = MagicMock()
|
||||
resp.event_id = "$sent_event"
|
||||
import nio
|
||||
resp.__class__ = nio.RoomSendResponse
|
||||
return resp
|
||||
|
||||
self.adapter._client.room_send = mock_room_send
|
||||
|
||||
# send_message_event returns an EventID string
|
||||
return "$sent_event"
|
||||
|
||||
self.adapter._client.send_message_event = mock_send_message_event
|
||||
|
||||
await self.adapter.send_voice(
|
||||
chat_id="!room:example.org",
|
||||
audio_path=temp_path,
|
||||
caption="Test voice",
|
||||
)
|
||||
|
||||
|
||||
assert sent_content is not None, "No message was sent"
|
||||
assert "org.matrix.msc3245.voice" in sent_content, \
|
||||
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
||||
assert sent_content["msgtype"] == "m.audio"
|
||||
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
||||
assert self.upload_call is not None, "Expected upload() to be called"
|
||||
args, kwargs = self.upload_call
|
||||
assert isinstance(args[0], io.BytesIO)
|
||||
assert kwargs["content_type"] == "audio/ogg"
|
||||
assert kwargs["filename"].endswith(".ogg")
|
||||
assert self.upload_call is not None, "Expected upload_media() to be called"
|
||||
assert isinstance(self.upload_call["data"], bytes)
|
||||
assert self.upload_call["mime_type"] == "audio/ogg"
|
||||
assert self.upload_call["filename"].endswith(".ogg")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
@@ -34,6 +34,45 @@ def _make_timeout_error() -> httpx.TimeoutException:
|
||||
return httpx.TimeoutException("timed out")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache_image_from_bytes (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheImageFromBytes:
|
||||
"""Tests for gateway.platforms.base.cache_image_from_bytes"""
|
||||
|
||||
def test_caches_valid_jpeg(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
path = cache_image_from_bytes(b"\xff\xd8\xff fake jpeg data", ".jpg")
|
||||
assert path.endswith(".jpg")
|
||||
|
||||
def test_caches_valid_png(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
path = cache_image_from_bytes(b"\x89PNG\r\n\x1a\n fake png data", ".png")
|
||||
assert path.endswith(".png")
|
||||
|
||||
def test_rejects_html_content(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"<!DOCTYPE html><html><title>Slack</title></html>", ".png")
|
||||
|
||||
def test_rejects_empty_data(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"", ".jpg")
|
||||
|
||||
def test_rejects_plain_text(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"just some text, not an image", ".jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache_image_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -71,7 +110,7 @@ class TestCacheImageFromUrl:
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"image data"
|
||||
fake_response.content = b"\xff\xd8\xff image data"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -101,7 +140,7 @@ class TestCacheImageFromUrl:
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
ok_response = MagicMock()
|
||||
ok_response.content = b"image data"
|
||||
ok_response.content = b"\xff\xd8\xff image data"
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -337,6 +376,134 @@ class TestCacheAudioFromUrl:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF redirect guard tests (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSSRFRedirectGuard:
|
||||
"""cache_image_from_url / cache_audio_from_url must reject redirects
|
||||
that land on private/internal hosts (e.g. cloud metadata endpoint)."""
|
||||
|
||||
def _make_redirect_response(self, target_url: str):
|
||||
"""Build a mock httpx response that looks like a redirect."""
|
||||
resp = MagicMock()
|
||||
resp.is_redirect = True
|
||||
resp.next_request = MagicMock(url=target_url)
|
||||
return resp
|
||||
|
||||
def _make_client_capturing_hooks(self):
|
||||
"""Return (mock_client, captured_kwargs dict) where captured_kwargs
|
||||
will contain the kwargs passed to httpx.AsyncClient()."""
|
||||
captured = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return mock_client
|
||||
|
||||
return mock_client, captured, factory
|
||||
|
||||
def test_image_blocks_private_redirect(self, tmp_path, monkeypatch):
|
||||
"""cache_image_from_url rejects a redirect to a private IP."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
# Simulate httpx calling the response event hooks
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
def fake_safe(url):
|
||||
return url == "https://public.example.com/image.png"
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", side_effect=fake_safe), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
await cache_image_from_url(
|
||||
"https://public.example.com/image.png", ext=".png"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Blocked redirect"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_audio_blocks_private_redirect(self, tmp_path, monkeypatch):
|
||||
"""cache_audio_from_url rejects a redirect to a private IP."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"http://10.0.0.1/internal/secrets"
|
||||
)
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
def fake_safe(url):
|
||||
return url == "https://public.example.com/voice.ogg"
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", side_effect=fake_safe), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_audio_from_url
|
||||
await cache_audio_from_url(
|
||||
"https://public.example.com/voice.ogg", ext=".ogg"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Blocked redirect"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_safe_redirect_allowed(self, tmp_path, monkeypatch):
|
||||
"""A redirect to a public IP is allowed through."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"https://cdn.example.com/real-image.png"
|
||||
)
|
||||
|
||||
ok_response = MagicMock()
|
||||
ok_response.content = b"\xff\xd8\xff fake jpeg"
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
ok_response.is_redirect = False
|
||||
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# First call triggers redirect hook, second returns data
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp if call_count == 1 else ok_response)
|
||||
return ok_response
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", return_value=True), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
return await cache_image_from_url(
|
||||
"https://public.example.com/image.png", ext=".jpg"
|
||||
)
|
||||
|
||||
path = asyncio.run(run())
|
||||
assert path.endswith(".jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slack mock setup (mirrors existing test_slack.py approach)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,8 +562,9 @@ class TestSlackDownloadSlackFile:
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"fake image bytes"
|
||||
fake_response.content = b"\x89PNG\r\n\x1a\n fake png"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "image/png"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
@@ -413,14 +581,44 @@ class TestSlackDownloadSlackFile:
|
||||
assert path.endswith(".jpg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_rejects_html_response(self, tmp_path, monkeypatch):
|
||||
"""An HTML sign-in page from Slack is rejected, not cached as image."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"<!DOCTYPE html><html><title>Slack</title></html>"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "text/html; charset=utf-8"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def run():
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
await adapter._download_slack_file(
|
||||
"https://files.slack.com/img.jpg", ext=".jpg"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="HTML instead of media"):
|
||||
asyncio.run(run())
|
||||
|
||||
# Verify nothing was cached
|
||||
img_dir = tmp_path / "img"
|
||||
if img_dir.exists():
|
||||
assert list(img_dir.iterdir()) == []
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""Timeout on first attempt triggers retry; success on second."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"image bytes"
|
||||
fake_response.content = b"\x89PNG\r\n\x1a\n image bytes"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "image/png"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
|
||||
63
tests/gateway/test_model_command_custom_providers.py
Normal file
63
tests/gateway/test_model_command_custom_providers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Regression tests for gateway /model support of config.yaml custom_providers."""
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
runner._session_model_overrides = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _make_event(text="/model"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_model_command_lists_saved_custom_provider(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model": {
|
||||
"default": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
},
|
||||
"providers": {},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Local (127.0.0.1:4141)",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
"model": "rotator-openrouter-coding",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
|
||||
result = await _make_runner()._handle_model_command(_make_event())
|
||||
|
||||
assert result is not None
|
||||
assert "Local (127.0.0.1:4141)" in result
|
||||
assert "custom:local-(127.0.0.1:4141)" in result
|
||||
assert "rotator-openrouter-coding" in result
|
||||
245
tests/gateway/test_model_switch_persistence.py
Normal file
245
tests/gateway/test_model_switch_persistence.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests that gateway /model switch persists across messages.
|
||||
|
||||
The gateway /model command stores session overrides in
|
||||
``_session_model_overrides``. These must:
|
||||
|
||||
1. Be applied in ``run_sync()`` so the next agent uses the switched model.
|
||||
2. Not be mistaken for fallback activation (which evicts the cached agent).
|
||||
3. Survive across multiple messages until /reset clears them.
|
||||
|
||||
Tests exercise the real ``_apply_session_model_override()`` and
|
||||
``_is_intentional_model_switch()`` methods on ``GatewayRunner``.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a minimal GatewayRunner with stubbed internals."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner._session_model_overrides = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._background_tasks = set()
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner._effective_model = None
|
||||
runner._effective_provider = None
|
||||
runner.session_store = MagicMock()
|
||||
session_key = build_session_key(_make_source())
|
||||
session_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store._entries = {session_key: session_entry}
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _apply_session_model_override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplySessionModelOverride:
|
||||
"""Verify _apply_session_model_override replaces config defaults."""
|
||||
|
||||
def test_override_replaces_all_fields(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4-turbo",
|
||||
"provider": "openrouter",
|
||||
"api_key": "or-key-123",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "gpt-5.4-turbo"
|
||||
assert rt["provider"] == "openrouter"
|
||||
assert rt["api_key"] == "or-key-123"
|
||||
assert rt["base_url"] == "https://openrouter.ai/api/v1"
|
||||
assert rt["api_mode"] == "chat_completions"
|
||||
|
||||
def test_no_override_returns_originals(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
orig_model = "anthropic/claude-sonnet-4"
|
||||
orig_rt = {"provider": "anthropic", "api_key": "key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"}
|
||||
|
||||
model, rt = runner._apply_session_model_override(sk, orig_model, dict(orig_rt))
|
||||
|
||||
assert model == orig_model
|
||||
assert rt == orig_rt
|
||||
|
||||
def test_none_values_do_not_overwrite(self):
|
||||
"""Override with None api_key/base_url should preserve config defaults."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": None,
|
||||
"base_url": None,
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "gpt-5.4"
|
||||
assert rt["provider"] == "openai"
|
||||
assert rt["api_key"] == "ant-key" # preserved — None didn't overwrite
|
||||
assert rt["base_url"] == "https://api.anthropic.com" # preserved
|
||||
assert rt["api_mode"] == "chat_completions" # overwritten (not None)
|
||||
|
||||
def test_empty_string_overwrites(self):
|
||||
"""Empty string is not None — it should overwrite the config value."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "local-model",
|
||||
"provider": "custom",
|
||||
"api_key": "local-key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
_, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert rt["base_url"] == "" # empty string overwrites
|
||||
|
||||
def test_different_session_key_not_affected(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
other_sk = "other_session"
|
||||
|
||||
runner._session_model_overrides[other_sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "url", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "anthropic/claude-sonnet-4" # unchanged — wrong session key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _is_intentional_model_switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsIntentionalModelSwitch:
|
||||
"""Verify fallback detection respects intentional /model overrides."""
|
||||
|
||||
def test_matches_override(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is True
|
||||
|
||||
def test_no_override_returns_false(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False
|
||||
|
||||
def test_different_model_returns_false(self):
|
||||
"""Agent fell back to a different model than the override."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4-mini") is False
|
||||
|
||||
def test_wrong_session_key(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides["other_session"] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False
|
||||
@@ -7,7 +7,6 @@ from gateway.session import (
|
||||
_hash_id,
|
||||
_hash_sender_id,
|
||||
_hash_chat_id,
|
||||
_looks_like_phone,
|
||||
)
|
||||
from gateway.config import Platform, HomeChannel
|
||||
|
||||
@@ -39,14 +38,6 @@ class TestHashHelpers:
|
||||
assert len(result) == 12
|
||||
assert "12345" not in result
|
||||
|
||||
def test_looks_like_phone(self):
|
||||
assert _looks_like_phone("+15551234567")
|
||||
assert _looks_like_phone("15551234567")
|
||||
assert _looks_like_phone("+1-555-123-4567")
|
||||
assert not _looks_like_phone("alice")
|
||||
assert not _looks_like_phone("user-123")
|
||||
assert not _looks_like_phone("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: build_session_context_prompt
|
||||
|
||||
@@ -8,7 +8,7 @@ from gateway.platforms.base import (
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
_safe_url_for_log,
|
||||
safe_url_for_log,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestSafeUrlForLog:
|
||||
"https://user:pass@example.com/private/path/image.png"
|
||||
"?X-Amz-Signature=supersecret&token=abc#frag"
|
||||
)
|
||||
result = _safe_url_for_log(url)
|
||||
result = safe_url_for_log(url)
|
||||
assert result == "https://example.com/.../image.png"
|
||||
assert "supersecret" not in result
|
||||
assert "token=abc" not in result
|
||||
@@ -33,15 +33,15 @@ class TestSafeUrlForLog:
|
||||
|
||||
def test_truncates_long_values(self):
|
||||
long_url = "https://example.com/" + ("a" * 300)
|
||||
result = _safe_url_for_log(long_url, max_len=40)
|
||||
result = safe_url_for_log(long_url, max_len=40)
|
||||
assert len(result) == 40
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_handles_small_and_non_positive_max_len(self):
|
||||
url = "https://example.com/very/long/path/file.png?token=secret"
|
||||
assert _safe_url_for_log(url, max_len=3) == "..."
|
||||
assert _safe_url_for_log(url, max_len=2) == ".."
|
||||
assert _safe_url_for_log(url, max_len=0) == ""
|
||||
assert safe_url_for_log(url, max_len=3) == "..."
|
||||
assert safe_url_for_log(url, max_len=2) == ".."
|
||||
assert safe_url_for_log(url, max_len=0) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
160
tests/gateway/test_restart_drain.py
Normal file
160
tests/gateway/test_restart_drain.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
from gateway.session import build_session_key
|
||||
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_while_busy_requests_drain_without_interrupt():
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=make_restart_source(),
|
||||
message_id="m1",
|
||||
)
|
||||
session_key = build_session_key(event.source)
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents[session_key] = running_agent
|
||||
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert result == "⏳ Draining 1 active agent(s) before restart..."
|
||||
running_agent.interrupt.assert_not_called()
|
||||
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_queue_mode_queues_follow_up_without_interrupt():
|
||||
runner, adapter = make_restart_runner()
|
||||
runner._draining = True
|
||||
runner._restart_requested = True
|
||||
runner._busy_input_mode = "queue"
|
||||
|
||||
event = MessageEvent(
|
||||
text="follow up",
|
||||
message_type=MessageType.TEXT,
|
||||
source=make_restart_source(),
|
||||
message_id="m2",
|
||||
)
|
||||
session_key = build_session_key(event.source)
|
||||
adapter._active_sessions[session_key] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(event)
|
||||
|
||||
assert session_key in adapter._pending_messages
|
||||
assert adapter._pending_messages[session_key].text == "follow up"
|
||||
assert not adapter._active_sessions[session_key].is_set()
|
||||
assert any("queued for the next turn" in message for message in adapter.sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draining_rejects_new_session_messages():
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner._draining = True
|
||||
runner._restart_requested = True
|
||||
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=make_restart_source("fresh"),
|
||||
message_id="m3",
|
||||
)
|
||||
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert result == "⏳ Gateway is restarting and is not accepting new work right now."
|
||||
|
||||
|
||||
def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False)
|
||||
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
|
||||
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"display:\n busy_input_mode: queue\n", encoding="utf-8"
|
||||
)
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "queue"
|
||||
|
||||
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "interrupt")
|
||||
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
|
||||
|
||||
|
||||
def test_load_restart_drain_timeout_prefers_env_then_config_then_default(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False)
|
||||
|
||||
assert (
|
||||
gateway_run.GatewayRunner._load_restart_drain_timeout()
|
||||
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
)
|
||||
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"agent:\n restart_drain_timeout: 12\n", encoding="utf-8"
|
||||
)
|
||||
assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 12.0
|
||||
|
||||
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "7")
|
||||
assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 7.0
|
||||
|
||||
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "invalid")
|
||||
assert (
|
||||
gateway_run.GatewayRunner._load_restart_drain_timeout()
|
||||
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
)
|
||||
assert "Invalid restart_drain_timeout" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_restart_is_idempotent():
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.stop = AsyncMock()
|
||||
|
||||
assert runner.request_restart(detached=True, via_service=False) is True
|
||||
first_task = next(iter(runner._background_tasks))
|
||||
assert runner.request_restart(detached=True, via_service=False) is False
|
||||
|
||||
await first_task
|
||||
|
||||
runner.stop.assert_awaited_once_with(
|
||||
restart=True, detached_restart=True, service_restart=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
await runner._launch_detached_restart_command()
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[:2] == ["/usr/bin/setsid", "bash"]
|
||||
assert "gateway restart" in cmd[-1]
|
||||
assert "kill -0 321" in cmd[-1]
|
||||
assert kwargs["start_new_session"] is True
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
@@ -221,5 +221,6 @@ class TestHandleResumeCommand:
|
||||
|
||||
runner._async_flush_memories.assert_called_once_with(
|
||||
"current_session_001",
|
||||
"agent:main:telegram:dm:67890",
|
||||
)
|
||||
db.close()
|
||||
|
||||
@@ -144,7 +144,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa
|
||||
assert adapter.sent == [
|
||||
{
|
||||
"chat_id": "-1001",
|
||||
"content": '💻 terminal: "pwd"',
|
||||
"content": '⚙️ terminal: "pwd"',
|
||||
"reply_to": None,
|
||||
"metadata": {"thread_id": "17585"},
|
||||
}
|
||||
|
||||
@@ -87,3 +87,42 @@ async def test_runner_allows_cron_only_mode_when_no_platforms_are_enabled(monkey
|
||||
assert runner.adapters == {}
|
||||
state = read_runtime_status()
|
||||
assert state["gateway_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_gateway_replace_force_uses_terminate_pid(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
calls = []
|
||||
|
||||
class _CleanExitRunner:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.should_exit_cleanly = True
|
||||
self.exit_reason = None
|
||||
self.adapters = {}
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 42)
|
||||
monkeypatch.setattr("gateway.status.remove_pid_file", lambda: None)
|
||||
monkeypatch.setattr("gateway.status.release_all_scoped_locks", lambda: 0)
|
||||
monkeypatch.setattr("gateway.status.terminate_pid", lambda pid, force=False: calls.append((pid, force)))
|
||||
monkeypatch.setattr("gateway.run.os.getpid", lambda: 100)
|
||||
monkeypatch.setattr("gateway.run.os.kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("tools.skills_sync.sync_skills", lambda quiet=True: None)
|
||||
monkeypatch.setattr("hermes_logging.setup_logging", lambda hermes_home, mode: tmp_path)
|
||||
monkeypatch.setattr("hermes_logging._add_rotating_handler", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("gateway.run.GatewayRunner", _CleanExitRunner)
|
||||
|
||||
from gateway.run import start_gateway
|
||||
|
||||
ok = await start_gateway(config=GatewayConfig(), replace=True, verbosity=None)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [(42, False), (42, True)]
|
||||
|
||||
@@ -90,7 +90,10 @@ class TestSessionSourceRoundtrip:
|
||||
|
||||
class TestSessionSourceDescription:
|
||||
def test_local_cli(self):
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
assert source.description == "CLI terminal"
|
||||
|
||||
def test_dm_with_username(self):
|
||||
@@ -143,7 +146,10 @@ class TestSessionSourceDescription:
|
||||
|
||||
class TestLocalCliFactory:
|
||||
def test_local_cli_defaults(self):
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
assert source.platform == Platform.LOCAL
|
||||
assert source.chat_id == "cli"
|
||||
assert source.chat_type == "dm"
|
||||
@@ -267,7 +273,10 @@ class TestBuildSessionContextPrompt:
|
||||
|
||||
def test_local_prompt_mentions_machine(self):
|
||||
config = GatewayConfig()
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
|
||||
@@ -127,6 +127,16 @@ async def test_shutdown_fires_finalize_for_active_agents(mock_invoke_hook):
|
||||
runner._shutdown_event = MagicMock()
|
||||
runner.adapters = {}
|
||||
runner._exit_reason = "test"
|
||||
runner._exit_code = None
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
runner._restart_drain_timeout = 0.0
|
||||
runner._stop_task = None
|
||||
runner._running_agents_ts = {}
|
||||
runner._update_runtime_status = MagicMock()
|
||||
|
||||
agent1 = MagicMock()
|
||||
agent1.session_id = "sess-a"
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
"""Tests for DM thread session seeding.
|
||||
"""Tests for DM thread session isolation.
|
||||
|
||||
When a bot reply creates a thread in a DM (e.g. Slack), the user's reply
|
||||
in that thread gets a new session (keyed by thread_ts). The seeding logic
|
||||
copies the parent DM session's transcript into the new thread session so
|
||||
the bot retains context of the original conversation.
|
||||
DM thread sessions must start empty — no parent transcript seeding.
|
||||
Thread context is handled by platform adapters (e.g. Slack's
|
||||
_fetch_thread_context fetches actual thread replies via the API).
|
||||
Session-level seeding was removed because it copied the ENTIRE parent
|
||||
DM transcript, causing unrelated conversations to bleed across threads.
|
||||
|
||||
Covers:
|
||||
- Basic seeding: parent transcript copied to new thread session
|
||||
- No seeding for group/channel chats
|
||||
- No seeding when parent session doesn't exist
|
||||
- No seeding on auto-reset sessions
|
||||
- No seeding on existing (non-new) thread sessions
|
||||
- Parent transcript is not mutated by seeding
|
||||
- Multiple threads from same parent each get independent copies
|
||||
- Cross-platform: works for any platform with DM threads (Slack, Telegram, Discord)
|
||||
- Thread sessions start empty (no parent seeding)
|
||||
- Group/channel thread sessions also start empty
|
||||
- Multiple threads from same parent are independent
|
||||
- Existing thread sessions are not mutated on re-access
|
||||
- Cross-platform: consistent behavior for Slack, Telegram, Discord
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -60,48 +58,41 @@ PARENT_HISTORY = [
|
||||
]
|
||||
|
||||
|
||||
class TestDMThreadSeeding:
|
||||
"""Core seeding behavior."""
|
||||
class TestDMThreadIsolation:
|
||||
"""Thread sessions must start empty — no parent transcript seeding."""
|
||||
|
||||
def test_thread_session_seeded_from_parent(self, store):
|
||||
"""New DM thread session should contain the parent's transcript."""
|
||||
# Create parent DM session with history
|
||||
def test_thread_session_starts_empty(self, store):
|
||||
"""New DM thread session should NOT inherit parent's transcript."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread session (user replied in thread)
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
# Thread should have parent's history
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 2
|
||||
assert thread_transcript[0]["content"] == "What's the weather?"
|
||||
assert thread_transcript[1]["content"] == "It's sunny and 72°F."
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_parent_transcript_not_mutated(self, store):
|
||||
"""Seeding should not alter the parent session's transcript."""
|
||||
def test_parent_transcript_unaffected_by_thread(self, store):
|
||||
"""Creating a thread session should not alter parent's transcript."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread and add a message to it
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
store.append_to_transcript(thread_entry.session_id, {
|
||||
"role": "user", "content": "thread-only message"
|
||||
})
|
||||
|
||||
# Parent should still have only its original messages
|
||||
parent_transcript = store.load_transcript(parent_entry.session_id)
|
||||
assert len(parent_transcript) == 2
|
||||
assert all(m["content"] != "thread-only message" for m in parent_transcript)
|
||||
|
||||
def test_multiple_threads_get_independent_copies(self, store):
|
||||
"""Each thread from the same parent gets its own copy."""
|
||||
def test_multiple_threads_are_independent(self, store):
|
||||
"""Each thread from the same parent starts empty and stays independent."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -118,49 +109,43 @@ class TestDMThreadSeeding:
|
||||
thread_b_source = _dm_source(thread_id="2222.000002")
|
||||
thread_b_entry = store.get_or_create_session(thread_b_source)
|
||||
|
||||
# Thread B should have parent history, not thread A's additions
|
||||
# Thread B starts empty
|
||||
thread_b_transcript = store.load_transcript(thread_b_entry.session_id)
|
||||
assert len(thread_b_transcript) == 2
|
||||
assert all(m["content"] != "thread A message" for m in thread_b_transcript)
|
||||
assert len(thread_b_transcript) == 0
|
||||
|
||||
# Thread A should have parent history + its own message
|
||||
# Thread A has only its own message
|
||||
thread_a_transcript = store.load_transcript(thread_a_entry.session_id)
|
||||
assert len(thread_a_transcript) == 3
|
||||
assert len(thread_a_transcript) == 1
|
||||
assert thread_a_transcript[0]["content"] == "thread A message"
|
||||
|
||||
def test_existing_thread_session_not_reseeded(self, store):
|
||||
"""Returning to an existing thread session should not re-copy parent history."""
|
||||
def test_existing_thread_session_preserved(self, store):
|
||||
"""Returning to an existing thread session should not reset it."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread session
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
store.append_to_transcript(thread_entry.session_id, {
|
||||
"role": "user", "content": "follow-up"
|
||||
})
|
||||
|
||||
# Add more to parent after thread was created
|
||||
store.append_to_transcript(parent_entry.session_id, {
|
||||
"role": "user", "content": "new parent message"
|
||||
})
|
||||
|
||||
# Get the same thread session again (not new — created_at != updated_at)
|
||||
# Get the same thread session again
|
||||
thread_entry_again = store.get_or_create_session(thread_source)
|
||||
assert thread_entry_again.session_id == thread_entry.session_id
|
||||
|
||||
# Should still have 3 messages (2 seeded + 1 follow-up), not re-seeded
|
||||
# Should still have only its own message
|
||||
thread_transcript = store.load_transcript(thread_entry_again.session_id)
|
||||
assert len(thread_transcript) == 3
|
||||
assert thread_transcript[2]["content"] == "follow-up"
|
||||
assert len(thread_transcript) == 1
|
||||
assert thread_transcript[0]["content"] == "follow-up"
|
||||
|
||||
|
||||
class TestDMThreadSeedingEdgeCases:
|
||||
"""Edge cases and conditions where seeding should NOT happen."""
|
||||
class TestDMThreadIsolationEdgeCases:
|
||||
"""Edge cases — threads always start empty regardless of context."""
|
||||
|
||||
def test_no_seeding_for_group_threads(self, store):
|
||||
"""Group/channel threads should not trigger seeding."""
|
||||
def test_group_thread_starts_empty(self, store):
|
||||
"""Group/channel threads should also start empty."""
|
||||
parent_source = _group_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -172,7 +157,7 @@ class TestDMThreadSeedingEdgeCases:
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_without_parent_session(self, store):
|
||||
def test_thread_without_parent_session_starts_empty(self, store):
|
||||
"""Thread session without a parent DM session should start empty."""
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
@@ -180,34 +165,21 @@ class TestDMThreadSeedingEdgeCases:
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_with_empty_parent(self, store):
|
||||
"""If parent session exists but has no transcript, thread starts empty."""
|
||||
parent_source = _dm_source()
|
||||
store.get_or_create_session(parent_source)
|
||||
# No messages appended to parent
|
||||
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_for_dm_without_thread_id(self, store):
|
||||
"""Top-level DMs (no thread_id) should not trigger seeding."""
|
||||
def test_dm_without_thread_starts_empty(self, store):
|
||||
"""Top-level DMs (no thread_id) should start empty as always."""
|
||||
source = _dm_source()
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
# Should just be a normal empty session
|
||||
transcript = store.load_transcript(entry.session_id)
|
||||
assert len(transcript) == 0
|
||||
|
||||
|
||||
class TestDMThreadSeedingCrossPlatform:
|
||||
"""Verify seeding works for platforms beyond Slack."""
|
||||
class TestDMThreadIsolationCrossPlatform:
|
||||
"""Verify thread isolation is consistent across all platforms."""
|
||||
|
||||
@pytest.mark.parametrize("platform", [Platform.SLACK, Platform.TELEGRAM, Platform.DISCORD])
|
||||
def test_seeding_works_across_platforms(self, store, platform):
|
||||
"""DM thread seeding should work for any platform that uses thread_id."""
|
||||
def test_thread_starts_empty_across_platforms(self, store, platform):
|
||||
"""DM thread sessions start empty regardless of platform."""
|
||||
parent_source = _dm_source(platform=platform)
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -217,5 +189,4 @@ class TestDMThreadSeedingCrossPlatform:
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 2
|
||||
assert thread_transcript[0]["content"] == "What's the weather?"
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
@@ -3,9 +3,15 @@ import os
|
||||
from gateway.config import Platform
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionContext, SessionSource
|
||||
from gateway.session_context import (
|
||||
get_session_env,
|
||||
set_session_vars,
|
||||
clear_session_vars,
|
||||
)
|
||||
|
||||
|
||||
def test_set_session_env_includes_thread_id(monkeypatch):
|
||||
def test_set_session_env_sets_contextvars(monkeypatch):
|
||||
"""_set_session_env should populate contextvars, not os.environ."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
@@ -21,25 +27,93 @@ def test_set_session_env_includes_thread_id(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
|
||||
|
||||
runner._set_session_env(context)
|
||||
tokens = runner._set_session_env(context)
|
||||
|
||||
assert os.getenv("HERMES_SESSION_PLATFORM") == "telegram"
|
||||
assert os.getenv("HERMES_SESSION_CHAT_ID") == "-1001"
|
||||
assert os.getenv("HERMES_SESSION_CHAT_NAME") == "Group"
|
||||
assert os.getenv("HERMES_SESSION_THREAD_ID") == "17585"
|
||||
# Values should be readable via get_session_env (contextvar path)
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
|
||||
assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
|
||||
assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group"
|
||||
assert get_session_env("HERMES_SESSION_THREAD_ID") == "17585"
|
||||
|
||||
# os.environ should NOT be touched
|
||||
assert os.getenv("HERMES_SESSION_PLATFORM") is None
|
||||
assert os.getenv("HERMES_SESSION_THREAD_ID") is None
|
||||
|
||||
# Clean up
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
|
||||
def test_clear_session_env_removes_thread_id(monkeypatch):
|
||||
def test_clear_session_env_restores_previous_state(monkeypatch):
|
||||
"""_clear_session_env should restore contextvars to their pre-handler values."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
|
||||
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
|
||||
monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001")
|
||||
monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group")
|
||||
monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585")
|
||||
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
|
||||
|
||||
runner._clear_session_env()
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_name="Group",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
)
|
||||
context = SessionContext(source=source, connected_platforms=[], home_channels={})
|
||||
|
||||
assert os.getenv("HERMES_SESSION_PLATFORM") is None
|
||||
assert os.getenv("HERMES_SESSION_CHAT_ID") is None
|
||||
assert os.getenv("HERMES_SESSION_CHAT_NAME") is None
|
||||
assert os.getenv("HERMES_SESSION_THREAD_ID") is None
|
||||
tokens = runner._set_session_env(context)
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
|
||||
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
# After clear, contextvars should return to defaults (empty)
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == ""
|
||||
assert get_session_env("HERMES_SESSION_CHAT_ID") == ""
|
||||
assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
|
||||
assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
|
||||
|
||||
|
||||
def test_get_session_env_falls_back_to_os_environ(monkeypatch):
|
||||
"""get_session_env should fall back to os.environ when contextvar is unset."""
|
||||
monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord")
|
||||
|
||||
# No contextvar set — should read from os.environ
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
|
||||
|
||||
# Now set a contextvar — should prefer it
|
||||
tokens = set_session_vars(platform="telegram")
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
|
||||
|
||||
# Restore — should fall back to os.environ again
|
||||
clear_session_vars(tokens)
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
|
||||
|
||||
|
||||
def test_get_session_env_default_when_nothing_set(monkeypatch):
|
||||
"""get_session_env returns default when neither contextvar nor env is set."""
|
||||
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
|
||||
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == ""
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM", "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_set_session_env_handles_missing_optional_fields():
|
||||
"""_set_session_env should handle None chat_name and thread_id gracefully."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_name=None,
|
||||
chat_type="private",
|
||||
thread_id=None,
|
||||
)
|
||||
context = SessionContext(source=source, connected_platforms=[], home_channels={})
|
||||
|
||||
tokens = runner._set_session_env(context)
|
||||
|
||||
assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
|
||||
assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
|
||||
assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
|
||||
assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
|
||||
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
160
tests/gateway/test_session_model_override_routing.py
Normal file
160
tests/gateway/test_session_model_override_routing.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Regression tests for session-scoped model/provider overrides in gateway agents.
|
||||
|
||||
These cover the bug where `/model ...` stored a session override, but fresh
|
||||
agent constructions still resolved model/provider from global config/runtime.
|
||||
That let helper agents (and cache-miss main agents) route GPT-5.4 to the wrong
|
||||
provider, e.g. Nous instead of OpenAI Codex.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
"""Fake agent that records init kwargs for assertions."""
|
||||
|
||||
last_init = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
type(self).last_init = dict(kwargs)
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, user_message: str, conversation_history=None, task_id=None):
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
}
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner.session_store = None
|
||||
runner.config = None
|
||||
runner._voice_mode = {}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._show_reasoning = False
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._service_tier = None
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._background_tasks = set()
|
||||
runner._session_db = None
|
||||
runner._session_model_overrides = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.hooks.loaded_hooks = []
|
||||
return runner
|
||||
|
||||
|
||||
def _codex_override():
|
||||
return {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
"api_key": "***",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_mode": "codex_responses",
|
||||
}
|
||||
|
||||
|
||||
def _explode_runtime_resolution():
|
||||
raise AssertionError(
|
||||
"global runtime resolution should not run when a complete session override exists"
|
||||
)
|
||||
|
||||
|
||||
def test_run_agent_prefers_session_override_over_global_runtime(monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
runner = _make_runner()
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL,
|
||||
chat_id="cli",
|
||||
chat_name="CLI",
|
||||
chat_type="dm",
|
||||
user_id="user-1",
|
||||
)
|
||||
session_key = "agent:main:local:dm"
|
||||
runner._session_model_overrides[session_key] = _codex_override()
|
||||
|
||||
result = asyncio.run(
|
||||
runner._run_agent(
|
||||
message="ping",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="session-1",
|
||||
session_key=session_key,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init is not None
|
||||
assert _CapturingAgent.last_init["model"] == "gpt-5.4"
|
||||
assert _CapturingAgent.last_init["provider"] == "openai-codex"
|
||||
assert _CapturingAgent.last_init["api_mode"] == "codex_responses"
|
||||
assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
assert _CapturingAgent.last_init["api_key"] == "***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_task_prefers_session_override_over_global_runtime(monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
runner = _make_runner()
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.send = AsyncMock()
|
||||
adapter.extract_media = MagicMock(return_value=([], "ok"))
|
||||
adapter.extract_images = MagicMock(return_value=([], "ok"))
|
||||
runner.adapters[Platform.TELEGRAM] = adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
session_key = runner._session_key_for_source(source)
|
||||
runner._session_model_overrides[session_key] = _codex_override()
|
||||
|
||||
await runner._run_background_task("say hello", source, "bg_test")
|
||||
|
||||
assert _CapturingAgent.last_init is not None
|
||||
assert _CapturingAgent.last_init["model"] == "gpt-5.4"
|
||||
assert _CapturingAgent.last_init["provider"] == "openai-codex"
|
||||
assert _CapturingAgent.last_init["api_mode"] == "codex_responses"
|
||||
assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
assert _CapturingAgent.last_init["api_key"] == "***"
|
||||
@@ -41,6 +41,15 @@ def _make_runner():
|
||||
runner._pending_approvals = {}
|
||||
runner._voice_mode = {}
|
||||
runner._background_tasks = set()
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
runner._restart_drain_timeout = 0.0
|
||||
runner._stop_task = None
|
||||
runner._exit_code = None
|
||||
runner._update_runtime_status = MagicMock()
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
|
||||
@@ -1586,6 +1586,61 @@ class TestFallbackPreservesThreadContext:
|
||||
assert "important screenshot" in call_kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendImageSSRFGuards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendImageSSRFGuards:
|
||||
"""send_image should reject redirects that land on private/internal hosts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_blocks_private_redirect_target(self, adapter):
|
||||
redirect_response = MagicMock()
|
||||
redirect_response.is_redirect = True
|
||||
redirect_response.next_request = MagicMock(
|
||||
url="http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
|
||||
client_kwargs = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def fake_get(_url):
|
||||
for hook in client_kwargs["event_hooks"]["response"]:
|
||||
await hook(redirect_response)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"})
|
||||
|
||||
def fake_async_client(*args, **kwargs):
|
||||
client_kwargs.update(kwargs)
|
||||
return mock_client
|
||||
|
||||
def fake_is_safe_url(url):
|
||||
return url == "https://public.example/image.png"
|
||||
|
||||
with (
|
||||
patch("tools.url_safety.is_safe_url", side_effect=fake_is_safe_url),
|
||||
patch("httpx.AsyncClient", side_effect=fake_async_client),
|
||||
):
|
||||
result = await adapter.send_image(
|
||||
chat_id="C123",
|
||||
image_url="https://public.example/image.png",
|
||||
caption="see this",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert client_kwargs["follow_redirects"] is True
|
||||
assert client_kwargs["event_hooks"]["response"]
|
||||
adapter._app.client.files_upload_v2.assert_not_awaited()
|
||||
adapter._app.client.chat_postMessage.assert_awaited_once()
|
||||
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "see this" in call_kwargs["text"]
|
||||
assert "https://public.example/image.png" in call_kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProgressMessageThread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway import status
|
||||
|
||||
@@ -104,6 +105,41 @@ class TestGatewayRuntimeStatus:
|
||||
assert payload["platforms"]["telegram"]["error_message"] == "another poller is active"
|
||||
|
||||
|
||||
class TestTerminatePid:
|
||||
def test_force_uses_taskkill_on_windows(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False, timeout=None):
|
||||
calls.append((cmd, capture_output, text, timeout))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
||||
|
||||
status.terminate_pid(123, force=True)
|
||||
|
||||
assert calls == [
|
||||
(["taskkill", "/PID", "123", "/T", "/F"], True, True, 10)
|
||||
]
|
||||
|
||||
def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
raise FileNotFoundError
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
calls.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(status.os, "kill", fake_kill)
|
||||
|
||||
status.terminate_pid(456, force=True)
|
||||
|
||||
assert calls == [(456, status.signal.SIGTERM)]
|
||||
|
||||
|
||||
class TestScopedLocks:
|
||||
def test_acquire_scoped_lock_rejects_live_other_process(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
|
||||
@@ -437,6 +437,45 @@ class TestSegmentBreakOnToolBoundary:
|
||||
# Only one send call (the initial message)
|
||||
assert adapter.send.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_segment_breaks_do_not_resend(self):
|
||||
"""On a platform that never returns a message_id (e.g. webhook with
|
||||
github_comment delivery), tool-call segment breaks must NOT trigger
|
||||
a new adapter.send() per boundary. The fix: _message_id == '__no_edit__'
|
||||
suppresses the reset so all text accumulates and is sent once."""
|
||||
adapter = MagicMock()
|
||||
# No message_id on first send, then one more for the fallback final
|
||||
adapter.send = AsyncMock(side_effect=[
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
])
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Simulate: text → tool boundary → text → tool boundary → text (3 segments)
|
||||
consumer.on_delta("Phase 1 text")
|
||||
consumer.on_delta(None) # tool call boundary
|
||||
consumer.on_delta("Phase 2 text")
|
||||
consumer.on_delta(None) # another tool call boundary
|
||||
consumer.on_delta("Phase 3 text")
|
||||
consumer.finish()
|
||||
|
||||
await consumer.run()
|
||||
|
||||
# Before the fix this would post 3 comments (one per segment).
|
||||
# After the fix: only the initial partial + one fallback-final continuation.
|
||||
assert adapter.send.call_count == 2, (
|
||||
f"Expected 2 sends (initial + fallback), got {adapter.send.call_count}"
|
||||
)
|
||||
assert consumer.already_sent
|
||||
# The continuation must contain the text from segments 2 and 3
|
||||
final_text = adapter.send.call_args_list[1][1]["content"]
|
||||
assert "Phase 2" in final_text
|
||||
assert "Phase 3" in final_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_splits_long_continuation_without_dropping_text(self):
|
||||
"""Long continuation tails should be chunked when fallback final-send runs."""
|
||||
|
||||
@@ -40,9 +40,6 @@ async def test_enrich_message_with_transcription_skips_when_stt_disabled():
|
||||
with patch(
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
side_effect=AssertionError("transcribe_audio should not be called when STT is disabled"),
|
||||
), patch(
|
||||
"tools.transcription_tools.get_stt_model_from_config",
|
||||
return_value=None,
|
||||
):
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
@@ -63,9 +60,6 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
|
||||
with patch(
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": False, "error": "VOICE_TOOLS_OPENAI_KEY not set"},
|
||||
), patch(
|
||||
"tools.transcription_tools.get_stt_model_from_config",
|
||||
return_value=None,
|
||||
):
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
@@ -175,33 +175,33 @@ async def test_on_processing_start_handles_missing_ids(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_success(monkeypatch):
|
||||
"""Successful processing should set check mark reaction."""
|
||||
"""Successful processing should set thumbs-up reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||
chat_id=123,
|
||||
message_id=456,
|
||||
reaction="\u2705",
|
||||
reaction="\U0001f44d",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_failure(monkeypatch):
|
||||
"""Failed processing should set cross mark reaction."""
|
||||
"""Failed processing should set thumbs-down reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=False)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||
chat_id=123,
|
||||
message_id=456,
|
||||
reaction="\u274c",
|
||||
reaction="\U0001f44e",
|
||||
)
|
||||
|
||||
|
||||
@@ -212,7 +212,19 @@ async def test_on_processing_complete_skipped_when_disabled(monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_cancelled_keeps_existing_reaction(monkeypatch):
|
||||
"""Expected cancellation should not replace the in-progress reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||
|
||||
|
||||
448
tests/gateway/test_text_batching.py
Normal file
448
tests/gateway/test_text_batching.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""Tests for text message batching across all gateway adapters.
|
||||
|
||||
When a user sends a long message, the messaging client splits it at the
|
||||
platform's character limit. Each adapter should buffer rapid successive
|
||||
text messages from the same session and aggregate them before dispatching.
|
||||
|
||||
Covers: Discord, Matrix, WeCom, and the adaptive delay logic for
|
||||
Telegram and Feishu.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SessionSource
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Helpers
|
||||
# =====================================================================
|
||||
|
||||
def _make_event(
|
||||
text: str,
|
||||
platform: Platform,
|
||||
chat_id: str = "12345",
|
||||
msg_type: MessageType = MessageType.TEXT,
|
||||
) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=msg_type,
|
||||
source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Discord text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_discord_adapter():
|
||||
"""Create a minimal DiscordAdapter for testing text batching."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._platform = Platform.DISCORD
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1 # fast for tests
|
||||
adapter._text_batch_split_delay_seconds = 0.3 # fast for tests
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestDiscordTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_discord_adapter()
|
||||
event = _make_event("hello world", Platform.DISCORD)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
# Not dispatched yet
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# Wait for flush
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
dispatched = adapter.handle_message.call_args[0][0]
|
||||
assert dispatched.text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
"""Two rapid messages from the same chat should be merged."""
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("Part one of a long", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("message that was split.", Platform.DISCORD))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "Part one" in text
|
||||
assert "split" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_way_split_aggregated(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("chunk 1", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("chunk 2", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("chunk 3", Platform.DISCORD))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "chunk 1" in text
|
||||
assert "chunk 2" in text
|
||||
assert "chunk 3" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_chats_not_merged(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("from A", Platform.DISCORD, chat_id="111"))
|
||||
adapter._enqueue_text_event(_make_event("from B", Platform.DISCORD, chat_id="222"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.DISCORD))
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 2000-char limit should trigger longer delay."""
|
||||
adapter = _make_discord_adapter()
|
||||
# Simulate a chunk near Discord's 2000-char split point
|
||||
long_text = "x" * 1950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.DISCORD))
|
||||
|
||||
# After the short delay (0.1s), should NOT have flushed yet (split delay is 0.3s)
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# After the split delay, should be flushed
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Matrix text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_matrix_adapter():
|
||||
"""Create a minimal MatrixAdapter for testing text batching."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(MatrixAdapter)
|
||||
adapter._platform = Platform.MATRIX
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestMatrixTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
event = _make_event("hello world", Platform.MATRIX)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("first part", Platform.MATRIX))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("second part", Platform.MATRIX))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "first part" in text
|
||||
assert "second part" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_rooms_not_merged(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("room A", Platform.MATRIX, chat_id="!aaa:matrix.org"))
|
||||
adapter._enqueue_text_event(_make_event("room B", Platform.MATRIX, chat_id="!bbb:matrix.org"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 4000-char limit should trigger longer delay."""
|
||||
adapter = _make_matrix_adapter()
|
||||
long_text = "x" * 3950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.MATRIX))
|
||||
await asyncio.sleep(0.2)
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# WeCom text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_wecom_adapter():
|
||||
"""Create a minimal WeComAdapter for testing text batching."""
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(WeComAdapter)
|
||||
adapter._platform = Platform.WECOM
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestWeComTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
event = _make_event("hello world", Platform.WECOM)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("first part", Platform.WECOM))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("second part", Platform.WECOM))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "first part" in text
|
||||
assert "second part" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_chats_not_merged(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("chat A", Platform.WECOM, chat_id="chat_a"))
|
||||
adapter._enqueue_text_event(_make_event("chat B", Platform.WECOM, chat_id="chat_b"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 4000-char limit should trigger longer delay."""
|
||||
adapter = _make_wecom_adapter()
|
||||
long_text = "x" * 3950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.WECOM))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.WECOM))
|
||||
await asyncio.sleep(0.2)
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Telegram adaptive delay (PR #6891)
|
||||
# =====================================================================
|
||||
|
||||
def _make_telegram_adapter():
|
||||
"""Create a minimal TelegramAdapter for testing adaptive delay."""
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter._platform = Platform.TELEGRAM
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestTelegramAdaptiveDelay:
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_chunk_uses_normal_delay(self):
|
||||
adapter = _make_telegram_adapter()
|
||||
adapter._enqueue_text_event(_make_event("short msg", Platform.TELEGRAM))
|
||||
|
||||
# Should flush after the normal 0.1s delay
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_chunk_uses_split_delay(self):
|
||||
"""A chunk near the 4096-char limit should trigger longer delay."""
|
||||
adapter = _make_telegram_adapter()
|
||||
long_text = "x" * 4050 # near the 4096 limit
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.TELEGRAM))
|
||||
|
||||
# After the short delay, should NOT have flushed yet
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# After the split delay, should be flushed
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_continuation_merged(self):
|
||||
"""Two near-limit chunks should both be merged."""
|
||||
adapter = _make_telegram_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("x" * 4050, Platform.TELEGRAM))
|
||||
await asyncio.sleep(0.05)
|
||||
adapter._enqueue_text_event(_make_event("continuation text", Platform.TELEGRAM))
|
||||
|
||||
# Short chunk arrived → should use normal delay now
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "continuation text" in text
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Feishu adaptive delay
|
||||
# =====================================================================
|
||||
|
||||
def _make_feishu_adapter():
|
||||
"""Create a minimal FeishuAdapter for testing adaptive delay."""
|
||||
from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._platform = Platform.FEISHU
|
||||
adapter.config = config
|
||||
batch_state = FeishuBatchState()
|
||||
adapter._pending_text_batches = batch_state.events
|
||||
adapter._pending_text_batch_tasks = batch_state.tasks
|
||||
adapter._pending_text_batch_counts = batch_state.counts
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._text_batch_max_messages = 20
|
||||
adapter._text_batch_max_chars = 50000
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter._handle_message_with_guards = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestFeishuAdaptiveDelay:
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_chunk_uses_normal_delay(self):
|
||||
adapter = _make_feishu_adapter()
|
||||
event = _make_event("short msg", Platform.FEISHU)
|
||||
await adapter._enqueue_text_event(event)
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_chunk_uses_split_delay(self):
|
||||
"""A chunk near the 4096-char limit should trigger longer delay."""
|
||||
adapter = _make_feishu_adapter()
|
||||
long_text = "x" * 4050
|
||||
event = _make_event(long_text, Platform.FEISHU)
|
||||
await adapter._enqueue_text_event(event)
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_continuation_merged(self):
|
||||
adapter = _make_feishu_adapter()
|
||||
|
||||
await adapter._enqueue_text_event(_make_event("x" * 4050, Platform.FEISHU))
|
||||
await asyncio.sleep(0.05)
|
||||
await adapter._enqueue_text_event(_make_event("continuation text", Platform.FEISHU))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
text = adapter._handle_message_with_guards.call_args[0][0].text
|
||||
assert "continuation text" in text
|
||||
177
tests/gateway/test_usage_command.py
Normal file
177
tests/gateway/test_usage_command.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for gateway /usage command — agent cache lookup and output fields."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_agent(**overrides):
|
||||
"""Create a mock AIAgent with realistic session counters."""
|
||||
agent = MagicMock()
|
||||
defaults = {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "openrouter",
|
||||
"base_url": None,
|
||||
"session_total_tokens": 50_000,
|
||||
"session_api_calls": 5,
|
||||
"session_prompt_tokens": 40_000,
|
||||
"session_completion_tokens": 10_000,
|
||||
"session_input_tokens": 35_000,
|
||||
"session_output_tokens": 10_000,
|
||||
"session_cache_read_tokens": 5_000,
|
||||
"session_cache_write_tokens": 2_000,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
for k, v in defaults.items():
|
||||
setattr(agent, k, v)
|
||||
|
||||
# Rate limit state
|
||||
rl = MagicMock()
|
||||
rl.has_data = True
|
||||
agent.get_rate_limit_state.return_value = rl
|
||||
|
||||
# Context compressor
|
||||
ctx = MagicMock()
|
||||
ctx.last_prompt_tokens = 30_000
|
||||
ctx.context_length = 200_000
|
||||
ctx.compression_count = 1
|
||||
agent.context_compressor = ctx
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def _make_runner(session_key, agent=None, cached_agent=None):
|
||||
"""Build a bare GatewayRunner with just the fields _handle_usage_command needs."""
|
||||
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner.session_store = MagicMock()
|
||||
|
||||
if agent is not None:
|
||||
runner._running_agents[session_key] = agent
|
||||
|
||||
if cached_agent is not None:
|
||||
runner._agent_cache[session_key] = (cached_agent, "sig")
|
||||
|
||||
# Wire helper
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
SK = "agent:main:telegram:private:12345"
|
||||
|
||||
|
||||
class TestUsageCachedAgent:
|
||||
"""The main fix: /usage should find agents in _agent_cache between turns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_agent_shows_detailed_usage(self):
|
||||
agent = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=0.1234, status="estimated")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
assert "35,000" in result # input tokens
|
||||
assert "10,000" in result # output tokens
|
||||
assert "5,000" in result # cache read
|
||||
assert "2,000" in result # cache write
|
||||
assert "50,000" in result # total
|
||||
assert "$0.1234" in result
|
||||
assert "30,000" in result # context
|
||||
assert "Compressions: 1" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_agent_preferred_over_cache(self):
|
||||
"""When agent is in both dicts, the running one wins."""
|
||||
running = _make_mock_agent(session_api_calls=10, session_total_tokens=80_000)
|
||||
cached = _make_mock_agent(session_api_calls=5, session_total_tokens=50_000)
|
||||
runner = _make_runner(SK, agent=running, cached_agent=cached)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "80,000" in result # running agent's total
|
||||
assert "API calls: 10" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentinel_skipped_uses_cache(self):
|
||||
"""PENDING sentinel in _running_agents should fall through to cache."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
||||
cached = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=cached)
|
||||
runner._running_agents[SK] = _AGENT_PENDING_SENTINEL
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
assert "Session Token Usage" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_agent_anywhere_falls_to_history(self):
|
||||
"""No running or cached agent → rough estimate from transcript."""
|
||||
runner = _make_runner(SK)
|
||||
event = MagicMock()
|
||||
|
||||
session_entry = MagicMock()
|
||||
session_entry.session_id = "sess123"
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=500):
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Session Info" in result
|
||||
assert "Messages: 2" in result
|
||||
assert "~500" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_write_hidden_when_zero(self):
|
||||
"""Cache token lines should be omitted when zero."""
|
||||
agent = _make_mock_agent(session_cache_read_tokens=0, session_cache_write_tokens=0)
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cache read" not in result
|
||||
assert "Cache write" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_included_status(self):
|
||||
"""Subscription-included providers show 'included' instead of dollar amount."""
|
||||
agent = _make_mock_agent(provider="openai-codex")
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="included")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cost: included" in result
|
||||
@@ -508,6 +508,7 @@ class TestInboundMessages:
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||||
|
||||
@@ -539,6 +540,7 @@ class TestInboundMessages:
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
|
||||
214
tests/gateway/test_weixin.py
Normal file
214
tests/gateway/test_weixin.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Tests for the Weixin platform adapter."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
|
||||
from gateway.platforms.weixin import WeixinAdapter
|
||||
from tools.send_message_tool import _parse_target_ref, _send_to_platform
|
||||
|
||||
|
||||
def _make_adapter() -> WeixinAdapter:
|
||||
return WeixinAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
token="test-token",
|
||||
extra={"account_id": "test-account"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestWeixinFormatting:
|
||||
def test_format_message_preserves_markdown_and_rewrites_headers(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "# Title\n\n## Plan\n\nUse **bold** and [docs](https://example.com)."
|
||||
|
||||
assert (
|
||||
adapter.format_message(content)
|
||||
== "【Title】\n\n**Plan**\n\nUse **bold** and [docs](https://example.com)."
|
||||
)
|
||||
|
||||
def test_format_message_rewrites_markdown_tables(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = (
|
||||
"| Setting | Value |\n"
|
||||
"| --- | --- |\n"
|
||||
"| Timeout | 30s |\n"
|
||||
"| Retries | 3 |\n"
|
||||
)
|
||||
|
||||
assert adapter.format_message(content) == (
|
||||
"- Setting: Timeout\n"
|
||||
" Value: 30s\n"
|
||||
"- Setting: Retries\n"
|
||||
" Value: 3"
|
||||
)
|
||||
|
||||
def test_format_message_preserves_fenced_code_blocks(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "## Snippet\n\n```python\nprint('hi')\n```"
|
||||
|
||||
assert adapter.format_message(content) == "**Snippet**\n\n```python\nprint('hi')\n```"
|
||||
|
||||
def test_format_message_returns_empty_string_for_none(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
assert adapter.format_message(None) == ""
|
||||
|
||||
|
||||
class TestWeixinChunking:
|
||||
def test_split_text_sends_top_level_newlines_as_separate_messages(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message("第一行\n第二行\n第三行")
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["第一行", "第二行", "第三行"]
|
||||
|
||||
def test_split_text_keeps_indented_followup_with_previous_line(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message(
|
||||
"| Setting | Value |\n"
|
||||
"| --- | --- |\n"
|
||||
"| Timeout | 30s |\n"
|
||||
"| Retries | 3 |\n"
|
||||
)
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == [
|
||||
"- Setting: Timeout\n Value: 30s",
|
||||
"- Setting: Retries\n Value: 3",
|
||||
]
|
||||
|
||||
def test_split_text_keeps_complete_code_block_together_when_possible(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.MAX_MESSAGE_LENGTH = 80
|
||||
|
||||
content = adapter.format_message(
|
||||
"## Intro\n\nShort paragraph.\n\n```python\nprint('hello world')\nprint('again')\n```\n\nTail paragraph."
|
||||
)
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
assert any(
|
||||
"```python\nprint('hello world')\nprint('again')\n```" in chunk
|
||||
for chunk in chunks
|
||||
)
|
||||
assert all(chunk.count("```") % 2 == 0 for chunk in chunks)
|
||||
|
||||
def test_split_text_safely_splits_long_code_blocks(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.MAX_MESSAGE_LENGTH = 70
|
||||
|
||||
lines = "\n".join(f"line_{idx:02d} = {idx}" for idx in range(10))
|
||||
content = adapter.format_message(f"```python\n{lines}\n```")
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert len(chunks) > 1
|
||||
assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in chunks)
|
||||
assert all(chunk.count("```") >= 2 for chunk in chunks)
|
||||
|
||||
|
||||
class TestWeixinConfig:
|
||||
def test_apply_env_overrides_configures_weixin(self):
|
||||
config = GatewayConfig()
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"WEIXIN_ACCOUNT_ID": "bot-account",
|
||||
"WEIXIN_TOKEN": "bot-token",
|
||||
"WEIXIN_BASE_URL": "https://ilink.example.com/",
|
||||
"WEIXIN_CDN_BASE_URL": "https://cdn.example.com/c2c/",
|
||||
"WEIXIN_DM_POLICY": "allowlist",
|
||||
"WEIXIN_ALLOWED_USERS": "wxid_1,wxid_2",
|
||||
"WEIXIN_HOME_CHANNEL": "wxid_1",
|
||||
"WEIXIN_HOME_CHANNEL_NAME": "Primary DM",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
_apply_env_overrides(config)
|
||||
|
||||
platform_config = config.platforms[Platform.WEIXIN]
|
||||
assert platform_config.enabled is True
|
||||
assert platform_config.token == "bot-token"
|
||||
assert platform_config.extra["account_id"] == "bot-account"
|
||||
assert platform_config.extra["base_url"] == "https://ilink.example.com"
|
||||
assert platform_config.extra["cdn_base_url"] == "https://cdn.example.com/c2c"
|
||||
assert platform_config.extra["dm_policy"] == "allowlist"
|
||||
assert platform_config.extra["allow_from"] == "wxid_1,wxid_2"
|
||||
assert platform_config.home_channel == HomeChannel(Platform.WEIXIN, "wxid_1", "Primary DM")
|
||||
|
||||
def test_get_connected_platforms_includes_weixin_with_token(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WEIXIN: PlatformConfig(
|
||||
enabled=True,
|
||||
token="bot-token",
|
||||
extra={"account_id": "bot-account"},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_connected_platforms() == [Platform.WEIXIN]
|
||||
|
||||
def test_get_connected_platforms_requires_account_id(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WEIXIN: PlatformConfig(
|
||||
enabled=True,
|
||||
token="bot-token",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_connected_platforms() == []
|
||||
|
||||
|
||||
class TestWeixinSendMessageIntegration:
|
||||
def test_parse_target_ref_accepts_weixin_ids(self):
|
||||
assert _parse_target_ref("weixin", "wxid_test123") == ("wxid_test123", None, True)
|
||||
assert _parse_target_ref("weixin", "filehelper") == ("filehelper", None, True)
|
||||
assert _parse_target_ref("weixin", "group@chatroom") == ("group@chatroom", None, True)
|
||||
|
||||
@patch("tools.send_message_tool._send_weixin", new_callable=AsyncMock)
|
||||
def test_send_to_platform_routes_weixin_media_to_native_helper(self, send_weixin_mock):
|
||||
send_weixin_mock.return_value = {"success": True, "platform": "weixin", "chat_id": "wxid_test123"}
|
||||
config = PlatformConfig(enabled=True, token="bot-token", extra={"account_id": "bot-account"})
|
||||
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.WEIXIN,
|
||||
config,
|
||||
"wxid_test123",
|
||||
"hello",
|
||||
media_files=[("/tmp/demo.png", False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
send_weixin_mock.assert_awaited_once_with(
|
||||
config,
|
||||
"wxid_test123",
|
||||
"hello",
|
||||
media_files=[("/tmp/demo.png", False)],
|
||||
)
|
||||
|
||||
|
||||
class TestWeixinRemoteMediaSafety:
|
||||
def test_download_remote_media_blocks_unsafe_urls(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
with patch("tools.url_safety.is_safe_url", return_value=False):
|
||||
try:
|
||||
asyncio.run(adapter._download_remote_media("http://127.0.0.1/private.png"))
|
||||
except ValueError as exc:
|
||||
assert "Blocked unsafe URL" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError for unsafe URL")
|
||||
62
tests/gateway/test_yolo_command.py
Normal file
62
tests/gateway/test_yolo_command.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Tests for gateway /yolo session scoping."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
from tools.approval import clear_session, is_session_yolo_enabled
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_yolo_state(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
clear_session("agent:main:telegram:dm:chat-a")
|
||||
clear_session("agent:main:telegram:dm:chat-b")
|
||||
yield
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
clear_session("agent:main:telegram:dm:chat-a")
|
||||
clear_session("agent:main:telegram:dm:chat-b")
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.session_store = None
|
||||
runner.config = None
|
||||
return runner
|
||||
|
||||
|
||||
def _make_event(chat_id: str) -> MessageEvent:
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id=f"user-{chat_id}",
|
||||
chat_id=chat_id,
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
return MessageEvent(text="/yolo", source=source)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yolo_command_toggles_only_current_session(monkeypatch):
|
||||
runner = _make_runner()
|
||||
|
||||
event_a = _make_event("chat-a")
|
||||
session_a = runner._session_key_for_source(event_a.source)
|
||||
session_b = runner._session_key_for_source(_make_event("chat-b").source)
|
||||
|
||||
result_on = await runner._handle_yolo_command(event_a)
|
||||
|
||||
assert "ON" in result_on
|
||||
assert is_session_yolo_enabled(session_a) is True
|
||||
assert is_session_yolo_enabled(session_b) is False
|
||||
assert os.environ.get("HERMES_YOLO_MODE") is None
|
||||
|
||||
result_off = await runner._handle_yolo_command(event_a)
|
||||
|
||||
assert "OFF" in result_off
|
||||
assert is_session_yolo_enabled(session_a) is False
|
||||
assert os.environ.get("HERMES_YOLO_MODE") is None
|
||||
@@ -40,6 +40,7 @@ class TestProviderRegistry:
|
||||
("copilot", "GitHub Copilot", "api_key"),
|
||||
("huggingface", "Hugging Face", "api_key"),
|
||||
("zai", "Z.AI / GLM", "api_key"),
|
||||
("xai", "xAI", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
@@ -58,6 +59,12 @@ class TestProviderRegistry:
|
||||
assert pconfig.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY")
|
||||
assert pconfig.base_url_env_var == "GLM_BASE_URL"
|
||||
|
||||
def test_xai_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["xai"]
|
||||
assert pconfig.api_key_env_vars == ("XAI_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "XAI_BASE_URL"
|
||||
assert pconfig.inference_base_url == "https://api.x.ai/v1"
|
||||
|
||||
def test_copilot_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["copilot"]
|
||||
assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
@@ -633,6 +640,7 @@ class TestHasAnyProviderConfigured:
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("", ""))
|
||||
# Clear all provider env vars so earlier checks don't short-circuit
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
@@ -727,6 +735,7 @@ class TestHasAnyProviderConfigured:
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("", ""))
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
|
||||
@@ -657,3 +657,41 @@ def test_auth_remove_manual_entry_does_not_touch_env(tmp_path, monkeypatch):
|
||||
|
||||
# .env should be untouched
|
||||
assert env_path.read_text() == "SOME_KEY=some-value\n"
|
||||
|
||||
|
||||
def test_auth_remove_claude_code_suppresses_reseed(tmp_path, monkeypatch):
|
||||
"""Removing a claude_code credential must prevent it from being re-seeded."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, {"claude_code"}),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"anthropic": [{
|
||||
"id": "cc1",
|
||||
"label": "claude_code",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "claude_code",
|
||||
"access_token": "sk-ant-oat01-token",
|
||||
}]
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
auth_remove_command(SimpleNamespace(provider="anthropic", target="1"))
|
||||
|
||||
updated = json.loads((hermes_home / "auth.json").read_text())
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
assert "anthropic" in suppressed
|
||||
assert "claude_code" in suppressed["anthropic"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Regression tests for Nous OAuth refresh + agent-key mint interactions."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -10,6 +11,80 @@ import pytest
|
||||
from hermes_cli.auth import AuthError, get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_verify: CA bundle path validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestResolveVerifyFallback:
|
||||
"""Verify _resolve_verify falls back to True when CA bundle path doesn't exist."""
|
||||
|
||||
def test_missing_ca_bundle_in_auth_state_falls_back(self):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
result = _resolve_verify(auth_state={
|
||||
"tls": {"insecure": False, "ca_bundle": "/nonexistent/ca-bundle.pem"},
|
||||
})
|
||||
assert result is True
|
||||
|
||||
def test_valid_ca_bundle_in_auth_state_is_returned(self, tmp_path):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
ca_file = tmp_path / "ca-bundle.pem"
|
||||
ca_file.write_text("fake cert")
|
||||
result = _resolve_verify(auth_state={
|
||||
"tls": {"insecure": False, "ca_bundle": str(ca_file)},
|
||||
})
|
||||
assert result == str(ca_file)
|
||||
|
||||
def test_missing_ssl_cert_file_env_falls_back(self, monkeypatch):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl-cert.pem")
|
||||
monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False)
|
||||
result = _resolve_verify(auth_state={"tls": {}})
|
||||
assert result is True
|
||||
|
||||
def test_missing_hermes_ca_bundle_env_falls_back(self, monkeypatch):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
monkeypatch.setenv("HERMES_CA_BUNDLE", "/nonexistent/hermes-ca.pem")
|
||||
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||
result = _resolve_verify(auth_state={"tls": {}})
|
||||
assert result is True
|
||||
|
||||
def test_insecure_takes_precedence_over_missing_ca(self):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
result = _resolve_verify(
|
||||
insecure=True,
|
||||
auth_state={"tls": {"ca_bundle": "/nonexistent/ca.pem"}},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_no_ca_bundle_returns_true(self, monkeypatch):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False)
|
||||
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||
result = _resolve_verify(auth_state={"tls": {}})
|
||||
assert result is True
|
||||
|
||||
def test_explicit_ca_bundle_param_missing_falls_back(self):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
result = _resolve_verify(ca_bundle="/nonexistent/explicit-ca.pem")
|
||||
assert result is True
|
||||
|
||||
def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
ca_file = tmp_path / "explicit-ca.pem"
|
||||
ca_file.write_text("fake cert")
|
||||
result = _resolve_verify(ca_bundle=str(ca_file))
|
||||
assert result == str(ca_file)
|
||||
|
||||
|
||||
def _setup_nous_auth(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
|
||||
78
tests/hermes_cli/test_auth_provider_gate.py
Normal file
78
tests/hermes_cli/test_auth_provider_gate.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for is_provider_explicitly_configured()."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_config(tmp_path, config: dict) -> None:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
|
||||
def _write_auth_store(tmp_path, payload: dict) -> None:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def test_returns_false_when_no_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is False
|
||||
|
||||
|
||||
def test_returns_true_when_active_provider_matches(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "anthropic",
|
||||
})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is True
|
||||
|
||||
|
||||
def test_returns_true_when_config_provider_matches(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is True
|
||||
|
||||
|
||||
def test_returns_false_when_config_provider_is_different(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_config(tmp_path, {"model": {"provider": "kimi-coding", "default": "kimi-k2"}})
|
||||
_write_auth_store(tmp_path, {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": None,
|
||||
})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is False
|
||||
|
||||
|
||||
def test_returns_true_when_anthropic_env_var_set(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-realkey")
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is True
|
||||
|
||||
|
||||
def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatch):
|
||||
"""CLAUDE_CODE_OAUTH_TOKEN is set by Claude Code, not the user — must not gate."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-auto-token")
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is False
|
||||
75
tests/hermes_cli/test_clear_stale_base_url.py
Normal file
75
tests/hermes_cli/test_clear_stale_base_url.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
|
||||
|
||||
|
||||
def _write_provider(provider: str, model: str = "test-model"):
|
||||
"""Helper: write a provider + model to config.yaml."""
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
class TestClearStaleOpenaiBaseUrl:
|
||||
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
|
||||
|
||||
def test_clears_when_provider_is_named(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("openrouter")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
|
||||
|
||||
def test_preserves_when_provider_is_custom(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("custom")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert result == "http://localhost:11434/v1", \
|
||||
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
|
||||
|
||||
def test_noop_when_no_openai_base_url(self, monkeypatch):
|
||||
"""No error when OPENAI_BASE_URL is not set."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("openrouter")
|
||||
# Ensure it's not set
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
|
||||
# Should not raise
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
def test_noop_when_provider_empty(self, monkeypatch):
|
||||
"""No cleanup when provider is not set in config."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
cfg = load_config()
|
||||
cfg.pop("model", None)
|
||||
save_config(cfg)
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert result == "http://localhost:11434/v1", \
|
||||
"Should not clear when provider is not configured"
|
||||
@@ -150,6 +150,12 @@ class TestNormalizeModelForProvider:
|
||||
assert changed is False
|
||||
assert cli.model == "gpt-5.4"
|
||||
|
||||
def test_native_provider_prefix_is_stripped_before_agent_startup(self):
|
||||
cli = _make_cli(model="zai/glm-5.1")
|
||||
changed = cli._normalize_model_for_provider("zai")
|
||||
assert changed is True
|
||||
assert cli.model == "glm-5.1"
|
||||
|
||||
def test_bare_codex_model_passes_through(self):
|
||||
cli = _make_cli(model="gpt-5.3-codex")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
|
||||
@@ -449,6 +449,13 @@ class TestSubcommands:
|
||||
assert "show" in subs
|
||||
assert "hide" in subs
|
||||
|
||||
def test_fast_has_subcommands(self):
|
||||
assert "/fast" in SUBCOMMANDS
|
||||
subs = SUBCOMMANDS["/fast"]
|
||||
assert "fast" in subs
|
||||
assert "normal" in subs
|
||||
assert "status" in subs
|
||||
|
||||
def test_voice_has_subcommands(self):
|
||||
assert "/voice" in SUBCOMMANDS
|
||||
assert "on" in SUBCOMMANDS["/voice"]
|
||||
@@ -477,6 +484,20 @@ class TestSubcommandCompletion:
|
||||
assert "high" in texts
|
||||
assert "show" in texts
|
||||
|
||||
def test_fast_subcommand_completion_after_space(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/fast ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "fast" in texts
|
||||
assert "normal" in texts
|
||||
|
||||
def test_fast_command_filtered_out_when_unavailable(self):
|
||||
completions = _completions(
|
||||
SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast"),
|
||||
"/fa",
|
||||
)
|
||||
texts = {c.text for c in completions}
|
||||
assert "fast" not in texts
|
||||
|
||||
def test_subcommand_prefix_filters(self):
|
||||
"""Typing '/reasoning sh' should only show 'show'."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
|
||||
@@ -530,6 +551,13 @@ class TestGhostText:
|
||||
"""/reasoning sh → 'ow'"""
|
||||
assert _suggestion("/reasoning sh") == "ow"
|
||||
|
||||
def test_fast_subcommand_suggestion(self):
|
||||
assert _suggestion("/fast f") == "ast"
|
||||
|
||||
def test_fast_subcommand_suggestion_hidden_when_filtered(self):
|
||||
completer = SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast")
|
||||
assert _suggestion("/fa", completer=completer) is None
|
||||
|
||||
def test_no_suggestion_for_non_slash(self):
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
|
||||
@@ -35,12 +35,6 @@ class TestTokenValidation:
|
||||
valid, msg = validate_copilot_token("")
|
||||
assert valid is False
|
||||
|
||||
def test_is_classic_pat(self):
|
||||
from hermes_cli.copilot_auth import is_classic_pat
|
||||
assert is_classic_pat("ghp_abc123") is True
|
||||
assert is_classic_pat("gho_abc123") is False
|
||||
assert is_classic_pat("github_pat_abc") is False
|
||||
assert is_classic_pat("") is False
|
||||
|
||||
|
||||
class TestResolveToken:
|
||||
|
||||
124
tests/hermes_cli/test_custom_provider_model_switch.py
Normal file
124
tests/hermes_cli/test_custom_provider_model_switch.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests that `hermes model` always shows the model selection menu for custom
|
||||
providers, even when a model is already saved.
|
||||
|
||||
Regression test for the bug where _model_flow_named_custom() returned
|
||||
immediately when provider_info had a saved ``model`` field, making it
|
||||
impossible to switch models on multi-model endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with a minimal config."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
config_yaml = home / "config.yaml"
|
||||
config_yaml.write_text("model: old-model\ncustom_providers: []\n")
|
||||
env_file = home / ".env"
|
||||
env_file.write_text("")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
return home
|
||||
|
||||
|
||||
class TestCustomProviderModelSwitch:
|
||||
"""Ensure _model_flow_named_custom always probes and shows menu."""
|
||||
|
||||
def test_saved_model_still_probes_endpoint(self, config_home):
|
||||
"""When a model is already saved, the function must still call
|
||||
fetch_api_models to probe the endpoint — not skip with early return."""
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My vLLM",
|
||||
"base_url": "https://vllm.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "model-A", # already saved
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
patch("builtins.input", return_value="2"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
# fetch_api_models MUST be called even though model was saved
|
||||
mock_fetch.assert_called_once_with("sk-test", "https://vllm.example.com/v1", timeout=8.0)
|
||||
|
||||
def test_can_switch_to_different_model(self, config_home):
|
||||
"""User selects a different model than the saved one."""
|
||||
import yaml
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My vLLM",
|
||||
"base_url": "https://vllm.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "model-A",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
patch("builtins.input", return_value="2"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "model-B"
|
||||
|
||||
def test_probe_failure_falls_back_to_saved(self, config_home):
|
||||
"""When endpoint probe fails and user presses Enter, saved model is used."""
|
||||
import yaml
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My vLLM",
|
||||
"base_url": "https://vllm.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "model-A",
|
||||
}
|
||||
|
||||
# fetch returns empty list (probe failed), user presses Enter (empty input)
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=[]), \
|
||||
patch("builtins.input", return_value=""), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "model-A"
|
||||
|
||||
def test_no_saved_model_still_works(self, config_home):
|
||||
"""First-time flow (no saved model) still works as before."""
|
||||
import yaml
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My vLLM",
|
||||
"base_url": "https://vllm.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
# no "model" key
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-X"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
patch("builtins.input", return_value="1"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "model-X"
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Tests for detect_external_credentials() -- Phase 2 credential sync."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import detect_external_credentials
|
||||
|
||||
|
||||
class TestDetectCodexCLI:
|
||||
def test_detects_valid_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
auth = codex_dir / "auth.json"
|
||||
auth.write_text(json.dumps({
|
||||
"tokens": {"access_token": "tok-123", "refresh_token": "ref-456"}
|
||||
}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
codex_hits = [c for c in result if c["provider"] == "openai-codex"]
|
||||
assert len(codex_hits) == 1
|
||||
assert "Codex CLI" in codex_hits[0]["label"]
|
||||
|
||||
def test_skips_codex_without_access_token(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text(json.dumps({"tokens": {}}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_missing_codex_dir(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_malformed_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text("{bad json")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_returns_empty_when_nothing_found(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert result == []
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for hermes_cli.gateway."""
|
||||
|
||||
import signal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, call
|
||||
|
||||
@@ -211,8 +210,7 @@ class TestWaitForGatewayExit:
|
||||
assert poll_count == 3
|
||||
|
||||
def test_force_kills_after_grace_period(self, monkeypatch):
|
||||
"""When the process doesn't exit, SIGKILL the saved PID."""
|
||||
import time as _time
|
||||
"""When the process doesn't exit, force-kill the saved PID."""
|
||||
|
||||
# Simulate monotonic time advancing past force_after
|
||||
call_num = 0
|
||||
@@ -224,8 +222,8 @@ class TestWaitForGatewayExit:
|
||||
return call_num * 2.0 # 2, 4, 6, 8, ...
|
||||
|
||||
kills = []
|
||||
def mock_kill(pid, sig):
|
||||
kills.append((pid, sig))
|
||||
def mock_terminate(pid, force=False):
|
||||
kills.append((pid, force))
|
||||
|
||||
# get_running_pid returns the PID until kill is sent, then None
|
||||
def mock_get_running_pid():
|
||||
@@ -234,14 +232,13 @@ class TestWaitForGatewayExit:
|
||||
monkeypatch.setattr("time.monotonic", fake_monotonic)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", mock_get_running_pid)
|
||||
monkeypatch.setattr("os.kill", mock_kill)
|
||||
monkeypatch.setattr(gateway, "terminate_pid", mock_terminate)
|
||||
|
||||
gateway._wait_for_gateway_exit(timeout=10.0, force_after=5.0)
|
||||
assert (42, signal.SIGKILL) in kills
|
||||
assert (42, True) in kills
|
||||
|
||||
def test_handles_process_already_gone_on_kill(self, monkeypatch):
|
||||
"""ProcessLookupError during SIGKILL is not fatal."""
|
||||
import time as _time
|
||||
"""ProcessLookupError during force-kill is not fatal."""
|
||||
|
||||
call_num = 0
|
||||
def fake_monotonic():
|
||||
@@ -249,13 +246,24 @@ class TestWaitForGatewayExit:
|
||||
call_num += 1
|
||||
return call_num * 3.0 # Jump past force_after quickly
|
||||
|
||||
def mock_kill(pid, sig):
|
||||
def mock_terminate(pid, force=False):
|
||||
raise ProcessLookupError
|
||||
|
||||
monkeypatch.setattr("time.monotonic", fake_monotonic)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 99)
|
||||
monkeypatch.setattr("os.kill", mock_kill)
|
||||
monkeypatch.setattr(gateway, "terminate_pid", mock_terminate)
|
||||
|
||||
# Should not raise — ProcessLookupError means it's already gone.
|
||||
gateway._wait_for_gateway_exit(timeout=10.0, force_after=2.0)
|
||||
|
||||
def test_kill_gateway_processes_force_uses_helper(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda exclude_pids=None: [11, 22])
|
||||
monkeypatch.setattr(gateway, "terminate_pid", lambda pid, force=False: calls.append((pid, force)))
|
||||
|
||||
killed = gateway.kill_gateway_processes(force=True)
|
||||
|
||||
assert killed == 2
|
||||
assert calls == [(11, True), (22, True)]
|
||||
|
||||
@@ -5,6 +5,10 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import hermes_cli.gateway as gateway_cli
|
||||
from gateway.restart import (
|
||||
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
|
||||
GATEWAY_SERVICE_RESTART_EXIT_CODE,
|
||||
)
|
||||
|
||||
|
||||
class TestSystemdServiceRefresh:
|
||||
@@ -74,7 +78,7 @@ class TestSystemdServiceRefresh:
|
||||
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
|
||||
assert calls[:2] == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
["systemctl", "--user", "restart", gateway_cli.get_service_name()],
|
||||
["systemctl", "--user", "reload-or-restart", gateway_cli.get_service_name()],
|
||||
]
|
||||
|
||||
|
||||
@@ -84,6 +88,8 @@ class TestGeneratedSystemdUnits:
|
||||
|
||||
assert "ExecStart=" in unit
|
||||
assert "ExecStop=" not in unit
|
||||
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
|
||||
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
|
||||
assert "TimeoutStopSec=60" in unit
|
||||
|
||||
def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
|
||||
@@ -98,6 +104,8 @@ class TestGeneratedSystemdUnits:
|
||||
|
||||
assert "ExecStart=" in unit
|
||||
assert "ExecStop=" not in unit
|
||||
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
|
||||
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
|
||||
assert "TimeoutStopSec=60" in unit
|
||||
assert "WantedBy=multi-user.target" in unit
|
||||
|
||||
@@ -157,6 +165,31 @@ class TestGatewayStopCleanup:
|
||||
|
||||
|
||||
class TestLaunchdServiceRecovery:
|
||||
def test_get_restart_drain_timeout_prefers_env_then_config_then_default(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False)
|
||||
monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: {})
|
||||
|
||||
assert (
|
||||
gateway_cli._get_restart_drain_timeout()
|
||||
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"read_raw_config",
|
||||
lambda: {"agent": {"restart_drain_timeout": 14}},
|
||||
)
|
||||
assert gateway_cli._get_restart_drain_timeout() == 14.0
|
||||
|
||||
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "9")
|
||||
assert gateway_cli._get_restart_drain_timeout() == 9.0
|
||||
|
||||
monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "invalid")
|
||||
assert (
|
||||
gateway_cli._get_restart_drain_timeout()
|
||||
== DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
)
|
||||
|
||||
def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
@@ -234,6 +267,112 @@ class TestLaunchdServiceRecovery:
|
||||
["launchctl", "kickstart", target],
|
||||
]
|
||||
|
||||
def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypatch):
|
||||
calls = []
|
||||
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0)
|
||||
monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True)
|
||||
monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: calls.append(("term", pid, force)))
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid",
|
||||
lambda: 321,
|
||||
)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_restart()
|
||||
|
||||
assert calls == [
|
||||
("term", 321, False),
|
||||
["launchctl", "kickstart", "-k", target],
|
||||
]
|
||||
|
||||
def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid",
|
||||
lambda: 321,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_request_gateway_self_restart",
|
||||
lambda pid: calls.append(("self", pid)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("launchctl should not run")),
|
||||
)
|
||||
|
||||
gateway_cli.launchd_restart()
|
||||
|
||||
assert calls == [("self", 321)]
|
||||
assert "restart requested" in capsys.readouterr().out.lower()
|
||||
|
||||
def test_launchd_stop_uses_bootout_not_kill(self, monkeypatch):
|
||||
"""launchd_stop must bootout the service so KeepAlive doesn't respawn it."""
|
||||
label = gateway_cli.get_launchd_label()
|
||||
domain = gateway_cli._launchd_domain()
|
||||
target = f"{domain}/{label}"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None)
|
||||
|
||||
gateway_cli.launchd_stop()
|
||||
|
||||
assert calls == [["launchctl", "bootout", target]]
|
||||
|
||||
def test_launchd_stop_tolerates_already_unloaded(self, monkeypatch, capsys):
|
||||
"""launchd_stop silently handles exit codes 3/113 (job not loaded)."""
|
||||
label = gateway_cli.get_launchd_label()
|
||||
domain = gateway_cli._launchd_domain()
|
||||
target = f"{domain}/{label}"
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if "bootout" in cmd:
|
||||
raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None)
|
||||
|
||||
# Should not raise — exit code 3 means already unloaded
|
||||
gateway_cli.launchd_stop()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "stopped" in output.lower()
|
||||
|
||||
def test_launchd_stop_waits_for_process_exit(self, monkeypatch):
|
||||
"""launchd_stop calls _wait_for_gateway_exit after bootout."""
|
||||
wait_called = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
def fake_wait(**kwargs):
|
||||
wait_called.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", fake_wait)
|
||||
|
||||
gateway_cli.launchd_stop()
|
||||
|
||||
assert len(wait_called) == 1
|
||||
assert wait_called[0] == {"timeout": 10.0, "force_after": 5.0}
|
||||
|
||||
def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
@@ -280,6 +419,31 @@ class TestGatewayServiceDetection:
|
||||
|
||||
|
||||
class TestGatewaySystemServiceRouting:
|
||||
def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
|
||||
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system)))
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid",
|
||||
lambda: 654,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_request_gateway_self_restart",
|
||||
lambda pid: calls.append(("self", pid)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("systemctl should not run")),
|
||||
)
|
||||
|
||||
gateway_cli.systemd_restart()
|
||||
|
||||
assert calls == [("refresh", False), ("self", 654)]
|
||||
assert "restart requested" in capsys.readouterr().out.lower()
|
||||
|
||||
def test_gateway_install_passes_system_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
@@ -698,6 +862,7 @@ class TestProfileArg:
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
result = gateway_cli._profile_arg(str(hermes_home))
|
||||
assert result == ""
|
||||
|
||||
@@ -706,6 +871,7 @@ class TestProfileArg:
|
||||
profile_dir = tmp_path / ".hermes" / "profiles" / "mybot"
|
||||
profile_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
result = gateway_cli._profile_arg(str(profile_dir))
|
||||
assert result == "--profile mybot"
|
||||
|
||||
@@ -714,6 +880,7 @@ class TestProfileArg:
|
||||
custom_home = tmp_path / "custom" / "hermes"
|
||||
custom_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
result = gateway_cli._profile_arg(str(custom_home))
|
||||
assert result == ""
|
||||
|
||||
@@ -722,6 +889,7 @@ class TestProfileArg:
|
||||
nested = tmp_path / ".hermes" / "profiles" / "mybot" / "subdir"
|
||||
nested.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
result = gateway_cli._profile_arg(str(nested))
|
||||
assert result == ""
|
||||
|
||||
@@ -730,6 +898,7 @@ class TestProfileArg:
|
||||
bad_profile = tmp_path / ".hermes" / "profiles" / "My Bot!"
|
||||
bad_profile.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
result = gateway_cli._profile_arg(str(bad_profile))
|
||||
assert result == ""
|
||||
|
||||
@@ -754,3 +923,63 @@ class TestProfileArg:
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
assert "<string>--profile</string>" in plist
|
||||
assert "<string>mybot</string>" in plist
|
||||
|
||||
|
||||
class TestRemapPathForUser:
|
||||
"""Unit tests for _remap_path_for_user()."""
|
||||
|
||||
def test_remaps_path_under_current_home(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "root")
|
||||
(tmp_path / "root").mkdir()
|
||||
result = gateway_cli._remap_path_for_user(
|
||||
str(tmp_path / "root" / ".hermes" / "hermes-agent"),
|
||||
str(tmp_path / "alice"),
|
||||
)
|
||||
assert result == str(tmp_path / "alice" / ".hermes" / "hermes-agent")
|
||||
|
||||
def test_keeps_system_path_unchanged(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "root")
|
||||
(tmp_path / "root").mkdir()
|
||||
result = gateway_cli._remap_path_for_user("/opt/hermes", str(tmp_path / "alice"))
|
||||
assert result == "/opt/hermes"
|
||||
|
||||
def test_noop_when_same_user(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "alice")
|
||||
(tmp_path / "alice").mkdir()
|
||||
original = str(tmp_path / "alice" / ".hermes" / "hermes-agent")
|
||||
result = gateway_cli._remap_path_for_user(original, str(tmp_path / "alice"))
|
||||
assert result == original
|
||||
|
||||
|
||||
class TestSystemUnitPathRemapping:
|
||||
"""System units must remap ALL paths from the caller's home to the target user."""
|
||||
|
||||
def test_system_unit_has_no_root_paths(self, monkeypatch, tmp_path):
|
||||
root_home = tmp_path / "root"
|
||||
root_home.mkdir()
|
||||
project = root_home / ".hermes" / "hermes-agent"
|
||||
project.mkdir(parents=True)
|
||||
venv_bin = project / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
target_home = "/home/alice"
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: root_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root_home / ".hermes"))
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: root_home / ".hermes")
|
||||
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: project / "venv")
|
||||
monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(venv_bin / "python"))
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_system_service_identity",
|
||||
lambda run_as_user=None: ("alice", "alice", target_home),
|
||||
)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=True)
|
||||
|
||||
# No root paths should leak into the unit
|
||||
assert str(root_home) not in unit
|
||||
# Target user paths should be present
|
||||
assert "/home/alice" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
|
||||
|
||||
279
tests/hermes_cli/test_gateway_wsl.py
Normal file
279
tests/hermes_cli/test_gateway_wsl.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Tests for WSL detection and WSL-aware gateway behavior."""
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway
|
||||
import hermes_constants
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# is_wsl() in hermes_constants
|
||||
# =============================================================================
|
||||
|
||||
class TestIsWsl:
|
||||
"""Test the shared is_wsl() utility."""
|
||||
|
||||
def setup_method(self):
|
||||
# Reset cached value between tests
|
||||
hermes_constants._wsl_detected = None
|
||||
|
||||
def test_detects_wsl2(self):
|
||||
fake_content = (
|
||||
"Linux version 5.15.146.1-microsoft-standard-WSL2 "
|
||||
"(gcc (GCC) 11.2.0) #1 SMP Thu Jan 11 04:09:03 UTC 2024\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
def test_detects_wsl1(self):
|
||||
fake_content = (
|
||||
"Linux version 4.4.0-19041-Microsoft "
|
||||
"(Microsoft@Microsoft.com) (gcc version 5.4.0) #1\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
def test_native_linux(self):
|
||||
fake_content = (
|
||||
"Linux version 6.5.0-44-generic (buildd@lcy02-amd64-015) "
|
||||
"(x86_64-linux-gnu-gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0) #44\n"
|
||||
)
|
||||
with patch("builtins.open", mock_open(read_data=fake_content)):
|
||||
assert hermes_constants.is_wsl() is False
|
||||
|
||||
def test_no_proc_version(self):
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert hermes_constants.is_wsl() is False
|
||||
|
||||
def test_result_is_cached(self):
|
||||
"""After first detection, subsequent calls return the cached value."""
|
||||
hermes_constants._wsl_detected = True
|
||||
# Even with open raising, cached value is returned
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert hermes_constants.is_wsl() is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _wsl_systemd_operational() in gateway
|
||||
# =============================================================================
|
||||
|
||||
class TestWslSystemdOperational:
|
||||
"""Test the WSL systemd check."""
|
||||
|
||||
def test_running(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=0, stdout="running\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_degraded(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="degraded\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_starting(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="starting\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is True
|
||||
|
||||
def test_offline_no_systemd(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
lambda *a, **kw: SimpleNamespace(
|
||||
returncode=1, stdout="offline\n", stderr=""
|
||||
),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
def test_systemctl_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
MagicMock(side_effect=FileNotFoundError),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
def test_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess, "run",
|
||||
MagicMock(side_effect=subprocess.TimeoutExpired("systemctl", 5)),
|
||||
)
|
||||
assert gateway._wsl_systemd_operational() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# supports_systemd_services() WSL integration
|
||||
# =============================================================================
|
||||
|
||||
class TestSupportsSystemdServicesWSL:
|
||||
"""Test that supports_systemd_services() handles WSL correctly."""
|
||||
|
||||
def test_wsl_with_systemd(self, monkeypatch):
|
||||
"""WSL + working systemd → True."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: True)
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_wsl_without_systemd(self, monkeypatch):
|
||||
"""WSL + no systemd → False."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: False)
|
||||
assert gateway.supports_systemd_services() is False
|
||||
|
||||
def test_native_linux(self, monkeypatch):
|
||||
"""Native Linux (not WSL) → True without checking systemd."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_termux_still_excluded(self, monkeypatch):
|
||||
"""Termux → False regardless of WSL status."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: True)
|
||||
assert gateway.supports_systemd_services() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WSL messaging in gateway commands
|
||||
# =============================================================================
|
||||
|
||||
class TestGatewayCommandWSLMessages:
|
||||
"""Test that WSL users see appropriate guidance."""
|
||||
|
||||
def test_install_wsl_no_systemd(self, monkeypatch, capsys):
|
||||
"""hermes gateway install on WSL without systemd shows guidance."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install", force=False, system=False,
|
||||
run_as_user=None,
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway.gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "systemd is not running" in out
|
||||
assert "hermes gateway run" in out
|
||||
assert "tmux" in out
|
||||
|
||||
def test_start_wsl_no_systemd(self, monkeypatch, capsys):
|
||||
"""hermes gateway start on WSL without systemd shows guidance."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway.gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "hermes gateway run" in out
|
||||
assert "wsl.conf" in out
|
||||
|
||||
def test_install_wsl_with_systemd_warns(self, monkeypatch, capsys):
|
||||
"""hermes gateway install on WSL with systemd shows warning but proceeds."""
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
# Mock systemd_install to capture call
|
||||
install_called = []
|
||||
monkeypatch.setattr(
|
||||
gateway, "systemd_install",
|
||||
lambda **kwargs: install_called.append(kwargs),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install", force=False, system=False,
|
||||
run_as_user=None,
|
||||
)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL detected" in out
|
||||
assert "may not survive WSL restarts" in out
|
||||
assert len(install_called) == 1 # install still proceeded
|
||||
|
||||
def test_status_wsl_running_manual(self, monkeypatch, capsys):
|
||||
"""hermes gateway status on WSL with manual process shows WSL note."""
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [12345])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
# Stub out the systemd unit path check
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_systemd_unit_path",
|
||||
lambda system=False: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_launchd_plist_path",
|
||||
lambda: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "WSL note" in out
|
||||
assert "tmux or screen" in out
|
||||
|
||||
def test_status_wsl_not_running(self, monkeypatch, capsys):
|
||||
"""hermes gateway status on WSL with no process shows WSL start advice."""
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_systemd_unit_path",
|
||||
lambda system=False: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway, "get_launchd_plist_path",
|
||||
lambda: SimpleNamespace(exists=lambda: False),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(gateway_command="status", deep=False, system=False)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes gateway run" in out
|
||||
assert "tmux" in out
|
||||
@@ -102,6 +102,21 @@ class TestAggregatorProviders:
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
class TestIssue6211NativeProviderPrefixNormalization:
|
||||
@pytest.mark.parametrize("model,target_provider,expected", [
|
||||
("zai/glm-5.1", "zai", "glm-5.1"),
|
||||
("google/gemini-2.5-pro", "gemini", "google/gemini-2.5-pro"),
|
||||
("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"),
|
||||
("anthropic/claude-sonnet-4.6", "openrouter", "anthropic/claude-sonnet-4.6"),
|
||||
("Qwen/Qwen3.5-397B-A17B", "huggingface", "Qwen/Qwen3.5-397B-A17B"),
|
||||
("modal/zai-org/GLM-5-FP8", "custom", "modal/zai-org/GLM-5-FP8"),
|
||||
])
|
||||
def test_native_provider_prefixes_are_only_stripped_on_matching_provider(
|
||||
self, model, target_provider, expected
|
||||
):
|
||||
assert normalize_model_for_provider(model, target_provider) == expected
|
||||
|
||||
|
||||
# ── detect_vendor ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectVendor:
|
||||
|
||||
104
tests/hermes_cli/test_model_switch_custom_providers.py
Normal file
104
tests/hermes_cli/test_model_switch_custom_providers.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Regression tests for /model support of config.yaml custom_providers.
|
||||
|
||||
The terminal `hermes model` flow already exposes `custom_providers`, but the
|
||||
shared slash-command pipeline (`/model` in CLI/gateway/Telegram) historically
|
||||
only looked at `providers:`.
|
||||
"""
|
||||
|
||||
import hermes_cli.providers as providers_mod
|
||||
from hermes_cli.model_switch import list_authenticated_providers, switch_model
|
||||
from hermes_cli.providers import resolve_provider_full
|
||||
|
||||
|
||||
_MOCK_VALIDATION = {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
|
||||
"""No-args /model menus should include saved custom_providers entries."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Local (127.0.0.1:4141)",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
"model": "rotator-openrouter-coding",
|
||||
}
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
assert any(
|
||||
p["slug"] == "custom:local-(127.0.0.1:4141)"
|
||||
and p["name"] == "Local (127.0.0.1:4141)"
|
||||
and p["models"] == ["rotator-openrouter-coding"]
|
||||
and p["api_url"] == "http://127.0.0.1:4141/v1"
|
||||
for p in providers
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_provider_full_finds_named_custom_provider():
|
||||
"""Explicit /model --provider should resolve saved custom_providers entries."""
|
||||
resolved = resolve_provider_full(
|
||||
"custom:local-(127.0.0.1:4141)",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Local (127.0.0.1:4141)",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.id == "custom:local-(127.0.0.1:4141)"
|
||||
assert resolved.name == "Local (127.0.0.1:4141)"
|
||||
assert resolved.base_url == "http://127.0.0.1:4141/v1"
|
||||
assert resolved.source == "user-config"
|
||||
|
||||
|
||||
def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):
|
||||
"""Shared /model switch pipeline should accept --provider for custom_providers."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested: {
|
||||
"api_key": "no-key-required",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
|
||||
|
||||
result = switch_model(
|
||||
raw_input="rotator-openrouter-coding",
|
||||
current_provider="openai-codex",
|
||||
current_model="gpt-5.4",
|
||||
current_base_url="https://chatgpt.com/backend-api/codex",
|
||||
current_api_key="",
|
||||
explicit_provider="custom:local-(127.0.0.1:4141)",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Local (127.0.0.1:4141)",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
"model": "rotator-openrouter-coding",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.target_provider == "custom:local-(127.0.0.1:4141)"
|
||||
assert result.provider_label == "Local (127.0.0.1:4141)"
|
||||
assert result.new_model == "rotator-openrouter-coding"
|
||||
assert result.base_url == "http://127.0.0.1:4141/v1"
|
||||
assert result.api_key == "no-key-required"
|
||||
@@ -124,7 +124,14 @@ class TestParseModelInput:
|
||||
|
||||
class TestCuratedModelsForProvider:
|
||||
def test_openrouter_returns_curated_list(self):
|
||||
models = curated_models_for_provider("openrouter")
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_openrouter_models",
|
||||
return_value=[
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
],
|
||||
):
|
||||
models = curated_models_for_provider("openrouter")
|
||||
assert len(models) > 0
|
||||
assert any("claude" in m[0] for m in models)
|
||||
|
||||
@@ -169,7 +176,14 @@ class TestProviderLabel:
|
||||
|
||||
class TestProviderModelIds:
|
||||
def test_openrouter_returns_curated_list(self):
|
||||
ids = provider_model_ids("openrouter")
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_openrouter_models",
|
||||
return_value=[
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
],
|
||||
):
|
||||
ids = provider_model_ids("openrouter")
|
||||
assert len(ids) > 0
|
||||
assert all("/" in mid for mid in ids)
|
||||
|
||||
|
||||
@@ -3,55 +3,70 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model,
|
||||
OPENROUTER_MODELS, fetch_openrouter_models, menu_labels, model_ids, detect_provider_for_model,
|
||||
filter_nous_free_models, _NOUS_ALLOWED_FREE_MODELS,
|
||||
is_nous_free_tier, partition_nous_models_by_tier,
|
||||
check_nous_free_tier, clear_nous_free_tier_cache,
|
||||
_FREE_TIER_CACHE_TTL,
|
||||
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
|
||||
)
|
||||
import hermes_cli.models as _models_mod
|
||||
|
||||
LIVE_OPENROUTER_MODELS = [
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
class TestModelIds:
|
||||
def test_returns_non_empty_list(self):
|
||||
ids = model_ids()
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
ids = model_ids()
|
||||
assert isinstance(ids, list)
|
||||
assert len(ids) > 0
|
||||
|
||||
def test_ids_match_models_list(self):
|
||||
ids = model_ids()
|
||||
expected = [mid for mid, _ in OPENROUTER_MODELS]
|
||||
def test_ids_match_fetched_catalog(self):
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
ids = model_ids()
|
||||
expected = [mid for mid, _ in LIVE_OPENROUTER_MODELS]
|
||||
assert ids == expected
|
||||
|
||||
def test_all_ids_contain_provider_slash(self):
|
||||
"""Model IDs should follow the provider/model format."""
|
||||
for mid in model_ids():
|
||||
assert "/" in mid, f"Model ID '{mid}' missing provider/ prefix"
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
for mid in model_ids():
|
||||
assert "/" in mid, f"Model ID '{mid}' missing provider/ prefix"
|
||||
|
||||
def test_no_duplicate_ids(self):
|
||||
ids = model_ids()
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
ids = model_ids()
|
||||
assert len(ids) == len(set(ids)), "Duplicate model IDs found"
|
||||
|
||||
|
||||
class TestMenuLabels:
|
||||
def test_same_length_as_model_ids(self):
|
||||
assert len(menu_labels()) == len(model_ids())
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
assert len(menu_labels()) == len(model_ids())
|
||||
|
||||
def test_first_label_marked_recommended(self):
|
||||
labels = menu_labels()
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
labels = menu_labels()
|
||||
assert "recommended" in labels[0].lower()
|
||||
|
||||
def test_each_label_contains_its_model_id(self):
|
||||
for label, mid in zip(menu_labels(), model_ids()):
|
||||
assert mid in label, f"Label '{label}' doesn't contain model ID '{mid}'"
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
for label, mid in zip(menu_labels(), model_ids()):
|
||||
assert mid in label, f"Label '{label}' doesn't contain model ID '{mid}'"
|
||||
|
||||
def test_non_recommended_labels_have_no_tag(self):
|
||||
"""Only the first model should have (recommended)."""
|
||||
labels = menu_labels()
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
labels = menu_labels()
|
||||
for label in labels[1:]:
|
||||
assert "recommended" not in label.lower(), f"Unexpected 'recommended' in '{label}'"
|
||||
|
||||
|
||||
|
||||
class TestOpenRouterModels:
|
||||
def test_structure_is_list_of_tuples(self):
|
||||
for entry in OPENROUTER_MODELS:
|
||||
@@ -65,30 +80,65 @@ class TestOpenRouterModels:
|
||||
assert len(OPENROUTER_MODELS) >= 5
|
||||
|
||||
|
||||
class TestFetchOpenRouterModels:
|
||||
def test_live_fetch_recomputes_free_tags(self, monkeypatch):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
|
||||
|
||||
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
assert models == [
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
]
|
||||
|
||||
def test_falls_back_to_static_snapshot_on_fetch_failure(self, monkeypatch):
|
||||
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=OSError("boom")):
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
assert models == OPENROUTER_MODELS
|
||||
|
||||
|
||||
class TestFindOpenrouterSlug:
|
||||
def test_exact_match(self):
|
||||
from hermes_cli.models import _find_openrouter_slug
|
||||
assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6"
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6"
|
||||
|
||||
def test_bare_name_match(self):
|
||||
from hermes_cli.models import _find_openrouter_slug
|
||||
result = _find_openrouter_slug("claude-opus-4.6")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = _find_openrouter_slug("claude-opus-4.6")
|
||||
assert result == "anthropic/claude-opus-4.6"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from hermes_cli.models import _find_openrouter_slug
|
||||
result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6")
|
||||
assert result is not None
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
from hermes_cli.models import _find_openrouter_slug
|
||||
assert _find_openrouter_slug("totally-fake-model-xyz") is None
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
assert _find_openrouter_slug("totally-fake-model-xyz") is None
|
||||
|
||||
|
||||
class TestDetectProviderForModel:
|
||||
def test_anthropic_model_detected(self):
|
||||
"""claude-opus-4-6 should resolve to anthropic provider."""
|
||||
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
||||
assert result is not None
|
||||
assert result[0] == "anthropic"
|
||||
|
||||
@@ -105,7 +155,8 @@ class TestDetectProviderForModel:
|
||||
|
||||
def test_openrouter_slug_match(self):
|
||||
"""Models in the OpenRouter catalog should be found."""
|
||||
result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex")
|
||||
assert result is not None
|
||||
assert result[0] == "openrouter"
|
||||
assert result[1] == "anthropic/claude-opus-4.6"
|
||||
@@ -119,18 +170,21 @@ class TestDetectProviderForModel:
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
"""Bare model names should get mapped to full OpenRouter slugs."""
|
||||
result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
|
||||
assert result is not None
|
||||
# Should find it on OpenRouter with full slug
|
||||
assert result[1] == "anthropic/claude-opus-4.6"
|
||||
|
||||
def test_unknown_model_returns_none(self):
|
||||
"""Completely unknown model names should return None."""
|
||||
assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None
|
||||
|
||||
def test_aggregator_not_suggested(self):
|
||||
"""nous/openrouter should never be auto-suggested as target provider."""
|
||||
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
||||
assert result is not None
|
||||
assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested
|
||||
|
||||
@@ -302,12 +356,10 @@ class TestCheckNousFreeTierCache:
|
||||
"""Tests for the TTL cache on check_nous_free_tier()."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset cache before each test."""
|
||||
clear_nous_free_tier_cache()
|
||||
_models_mod._free_tier_cache = None
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset cache after each test."""
|
||||
clear_nous_free_tier_cache()
|
||||
_models_mod._free_tier_cache = None
|
||||
|
||||
@patch("hermes_cli.models.fetch_nous_account_tier")
|
||||
@patch("hermes_cli.models.is_nous_free_tier", return_value=True)
|
||||
@@ -321,7 +373,6 @@ class TestCheckNousFreeTierCache:
|
||||
|
||||
assert result1 is True
|
||||
assert result2 is True
|
||||
# fetch_nous_account_tier should only be called once (cached on second call)
|
||||
assert mock_fetch.call_count == 1
|
||||
|
||||
@patch("hermes_cli.models.fetch_nous_account_tier")
|
||||
@@ -334,7 +385,6 @@ class TestCheckNousFreeTierCache:
|
||||
result1 = check_nous_free_tier()
|
||||
assert mock_fetch.call_count == 1
|
||||
|
||||
# Simulate TTL expiry by backdating the cache timestamp
|
||||
cached_result, cached_at = _models_mod._free_tier_cache
|
||||
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
|
||||
|
||||
@@ -344,15 +394,6 @@ class TestCheckNousFreeTierCache:
|
||||
assert result1 is False
|
||||
assert result2 is False
|
||||
|
||||
def test_clear_cache_forces_refresh(self):
|
||||
"""clear_nous_free_tier_cache() invalidates the cached result."""
|
||||
# Manually seed the cache
|
||||
import time
|
||||
_models_mod._free_tier_cache = (True, time.monotonic())
|
||||
|
||||
clear_nous_free_tier_cache()
|
||||
assert _models_mod._free_tier_cache is None
|
||||
|
||||
def test_cache_ttl_is_short(self):
|
||||
"""TTL should be short enough to catch upgrades quickly (<=5 min)."""
|
||||
assert _FREE_TIER_CACHE_TTL <= 300
|
||||
|
||||
33
tests/hermes_cli/test_opencode_go_in_model_list.py
Normal file
33
tests/hermes_cli/test_opencode_go_in_model_list.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Test that opencode-go appears in /model list when credentials are set."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"OPENCODE_GO_API_KEY": "test-key"}, clear=False)
|
||||
def test_opencode_go_appears_when_api_key_set():
|
||||
"""opencode-go should appear in list_authenticated_providers when OPENCODE_GO_API_KEY is set."""
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
# Find opencode-go in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
|
||||
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
|
||||
assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
# opencode-go is in PROVIDER_TO_MODELS_DEV, so it appears as "built-in" (Part 1)
|
||||
assert opencode_go["source"] == "built-in"
|
||||
|
||||
|
||||
def test_opencode_go_not_appears_when_no_creds():
|
||||
"""opencode-go should NOT appear when no credentials are set."""
|
||||
# Ensure OPENCODE_GO_API_KEY is not set
|
||||
env_without_key = {k: v for k, v in os.environ.items() if k != "OPENCODE_GO_API_KEY"}
|
||||
|
||||
with patch.dict(os.environ, env_without_key, clear=True):
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
# opencode-go should not be in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
assert opencode_go is None, "opencode-go should not appear without credentials"
|
||||
83
tests/hermes_cli/test_overlay_slug_resolution.py
Normal file
83
tests/hermes_cli/test_overlay_slug_resolution.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Test that overlay providers with mismatched models.dev keys resolve correctly.
|
||||
|
||||
HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot") while
|
||||
_PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot"). The slug
|
||||
resolution in list_authenticated_providers() Section 2 must bridge this gap.
|
||||
|
||||
Covers: #5223, #6492
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
# -- Copilot slug resolution (env var path) ----------------------------------
|
||||
|
||||
@patch.dict(os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False)
|
||||
def test_copilot_uses_hermes_slug():
|
||||
"""github-copilot overlay should resolve to slug='copilot' with curated models."""
|
||||
providers = list_authenticated_providers(current_provider="copilot")
|
||||
|
||||
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
|
||||
assert copilot is not None, "copilot should appear when COPILOT_GITHUB_TOKEN is set"
|
||||
assert copilot["total_models"] > 0, "copilot should have curated models"
|
||||
assert copilot["is_current"] is True
|
||||
|
||||
# Must NOT appear under the models.dev key
|
||||
gh_copilot = next((p for p in providers if p["slug"] == "github-copilot"), None)
|
||||
assert gh_copilot is None, "github-copilot slug should not appear (resolved to copilot)"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False)
|
||||
def test_copilot_no_duplicate_entries():
|
||||
"""Copilot must appear only once — not as both 'copilot' (section 1) and 'github-copilot' (section 2)."""
|
||||
providers = list_authenticated_providers(current_provider="copilot")
|
||||
|
||||
copilot_slugs = [p["slug"] for p in providers if "copilot" in p["slug"]]
|
||||
# Should have at most one copilot entry (may also have copilot-acp if creds exist)
|
||||
copilot_main = [s for s in copilot_slugs if s == "copilot"]
|
||||
assert len(copilot_main) == 1, f"Expected exactly one 'copilot' entry, got {copilot_main}"
|
||||
|
||||
|
||||
# -- kimi-for-coding alias in auth.py ----------------------------------------
|
||||
|
||||
def test_kimi_for_coding_alias():
|
||||
"""resolve_provider('kimi-for-coding') should return 'kimi-coding'."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
result = resolve_provider("kimi-for-coding")
|
||||
assert result == "kimi-coding"
|
||||
|
||||
|
||||
# -- Generic slug mismatch providers -----------------------------------------
|
||||
|
||||
@patch.dict(os.environ, {"KIMI_API_KEY": "fake-key"}, clear=False)
|
||||
def test_kimi_for_coding_overlay_uses_hermes_slug():
|
||||
"""kimi-for-coding overlay should resolve to slug='kimi-coding'."""
|
||||
providers = list_authenticated_providers(current_provider="kimi-coding")
|
||||
|
||||
kimi = next((p for p in providers if p["slug"] == "kimi-coding"), None)
|
||||
assert kimi is not None, "kimi-coding should appear when KIMI_API_KEY is set"
|
||||
assert kimi["is_current"] is True
|
||||
|
||||
# Must NOT appear under the models.dev key
|
||||
kimi_mdev = next((p for p in providers if p["slug"] == "kimi-for-coding"), None)
|
||||
assert kimi_mdev is None, "kimi-for-coding slug should not appear (resolved to kimi-coding)"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"KILOCODE_API_KEY": "fake-key"}, clear=False)
|
||||
def test_kilo_overlay_uses_hermes_slug():
|
||||
"""kilo overlay should resolve to slug='kilocode'."""
|
||||
providers = list_authenticated_providers(current_provider="kilocode")
|
||||
|
||||
kilo = next((p for p in providers if p["slug"] == "kilocode"), None)
|
||||
assert kilo is not None, "kilocode should appear when KILOCODE_API_KEY is set"
|
||||
assert kilo["is_current"] is True
|
||||
|
||||
kilo_mdev = next((p for p in providers if p["slug"] == "kilo"), None)
|
||||
assert kilo_mdev is None, "kilo slug should not appear (resolved to kilocode)"
|
||||
@@ -555,3 +555,103 @@ class TestPromptPluginEnvVars:
|
||||
|
||||
# Should not crash, and not save anything
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
# ── curses_radiolist ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCursesRadiolist:
|
||||
"""Test the curses_radiolist function (non-TTY fallback path)."""
|
||||
|
||||
def test_non_tty_returns_default(self):
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
with patch("sys.stdin") as mock_stdin:
|
||||
mock_stdin.isatty.return_value = False
|
||||
result = curses_radiolist("Pick one", ["a", "b", "c"], selected=1)
|
||||
assert result == 1
|
||||
|
||||
def test_non_tty_returns_cancel_value(self):
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
with patch("sys.stdin") as mock_stdin:
|
||||
mock_stdin.isatty.return_value = False
|
||||
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=1)
|
||||
assert result == 1
|
||||
|
||||
|
||||
# ── Provider discovery helpers ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProviderDiscovery:
|
||||
"""Test provider plugin discovery and config helpers."""
|
||||
|
||||
def test_get_current_memory_provider_default(self, tmp_path, monkeypatch):
|
||||
"""Empty config returns empty string."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("memory:\n provider: ''\n")
|
||||
from hermes_cli.plugins_cmd import _get_current_memory_provider
|
||||
result = _get_current_memory_provider()
|
||||
assert result == ""
|
||||
|
||||
def test_get_current_context_engine_default(self, tmp_path, monkeypatch):
|
||||
"""Default config returns 'compressor'."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("context:\n engine: compressor\n")
|
||||
from hermes_cli.plugins_cmd import _get_current_context_engine
|
||||
result = _get_current_context_engine()
|
||||
assert result == "compressor"
|
||||
|
||||
def test_save_memory_provider(self, tmp_path, monkeypatch):
|
||||
"""Saving a memory provider persists to config.yaml."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("memory:\n provider: ''\n")
|
||||
from hermes_cli.plugins_cmd import _save_memory_provider
|
||||
_save_memory_provider("honcho")
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content["memory"]["provider"] == "honcho"
|
||||
|
||||
def test_save_context_engine(self, tmp_path, monkeypatch):
|
||||
"""Saving a context engine persists to config.yaml."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("context:\n engine: compressor\n")
|
||||
from hermes_cli.plugins_cmd import _save_context_engine
|
||||
_save_context_engine("lcm")
|
||||
content = yaml.safe_load(config_file.read_text())
|
||||
assert content["context"]["engine"] == "lcm"
|
||||
|
||||
def test_discover_memory_providers_empty(self):
|
||||
"""Discovery returns empty list when import fails."""
|
||||
with patch("plugins.memory.discover_memory_providers",
|
||||
side_effect=ImportError("no module")):
|
||||
from hermes_cli.plugins_cmd import _discover_memory_providers
|
||||
result = _discover_memory_providers()
|
||||
assert result == []
|
||||
|
||||
def test_discover_context_engines_empty(self):
|
||||
"""Discovery returns empty list when import fails."""
|
||||
with patch("plugins.context_engine.discover_context_engines",
|
||||
side_effect=ImportError("no module")):
|
||||
from hermes_cli.plugins_cmd import _discover_context_engines
|
||||
result = _discover_context_engines()
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── Auto-activation fix ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNoAutoActivation:
|
||||
"""Verify that plugin engines don't auto-activate when config says 'compressor'."""
|
||||
|
||||
def test_compressor_default_ignores_plugin(self):
|
||||
"""When context.engine is 'compressor', a plugin-registered engine should NOT
|
||||
be used — only explicit config triggers plugin engines."""
|
||||
# This tests the run_agent.py logic indirectly by checking that the
|
||||
# code path for default config doesn't call get_plugin_context_engine.
|
||||
import run_agent as ra_module
|
||||
source = open(ra_module.__file__).read()
|
||||
# The old code had: "Even with default config, check if a plugin registered one"
|
||||
# The fix removes this. Verify it's gone.
|
||||
assert "Even with default config, check if a plugin registered one" not in source
|
||||
|
||||
@@ -293,12 +293,16 @@ class TestGetActiveProfileName:
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
||||
assert get_active_profile_name() == "coder"
|
||||
|
||||
def test_custom_path_returns_custom(self, profile_env, monkeypatch):
|
||||
def test_custom_path_returns_default(self, profile_env, monkeypatch):
|
||||
"""A custom HERMES_HOME (Docker, etc.) IS the default root."""
|
||||
tmp_path = profile_env
|
||||
custom = tmp_path / "some" / "other" / "path"
|
||||
custom.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(custom))
|
||||
assert get_active_profile_name() == "custom"
|
||||
# With Docker-aware roots, a custom HERMES_HOME is the default —
|
||||
# not "custom". The user is on the default profile of their
|
||||
# custom deployment.
|
||||
assert get_active_profile_name() == "default"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -706,6 +710,72 @@ class TestInternalHelpers:
|
||||
home = _get_default_hermes_home()
|
||||
assert home == tmp_path / ".hermes"
|
||||
|
||||
def test_profiles_root_docker_deployment(self, tmp_path, monkeypatch):
|
||||
"""In Docker (HERMES_HOME outside ~/.hermes), profiles go under HERMES_HOME."""
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
docker_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
||||
root = _get_profiles_root()
|
||||
assert root == docker_home / "profiles"
|
||||
|
||||
def test_default_hermes_home_docker(self, tmp_path, monkeypatch):
|
||||
"""In Docker, _get_default_hermes_home() returns HERMES_HOME itself."""
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
docker_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
||||
home = _get_default_hermes_home()
|
||||
assert home == docker_home
|
||||
|
||||
def test_profiles_root_profile_mode(self, tmp_path, monkeypatch):
|
||||
"""In profile mode (HERMES_HOME under ~/.hermes), profiles root is still ~/.hermes/profiles."""
|
||||
native = tmp_path / ".hermes"
|
||||
profile_dir = native / "profiles" / "coder"
|
||||
profile_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
||||
root = _get_profiles_root()
|
||||
assert root == native / "profiles"
|
||||
|
||||
def test_active_profile_path_docker(self, tmp_path, monkeypatch):
|
||||
"""In Docker, active_profile file lives under HERMES_HOME."""
|
||||
from hermes_cli.profiles import _get_active_profile_path
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
docker_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
||||
path = _get_active_profile_path()
|
||||
assert path == docker_home / "active_profile"
|
||||
|
||||
def test_create_profile_docker(self, tmp_path, monkeypatch):
|
||||
"""Profile created in Docker lands under HERMES_HOME/profiles/."""
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
docker_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
||||
result = create_profile("orchestrator", no_alias=True)
|
||||
expected = docker_home / "profiles" / "orchestrator"
|
||||
assert result == expected
|
||||
assert expected.is_dir()
|
||||
|
||||
def test_active_profile_name_docker_default(self, tmp_path, monkeypatch):
|
||||
"""In Docker (no profile active), get_active_profile_name() returns 'default'."""
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
docker_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
||||
assert get_active_profile_name() == "default"
|
||||
|
||||
def test_active_profile_name_docker_profile(self, tmp_path, monkeypatch):
|
||||
"""In Docker with a profile active, get_active_profile_name() returns the profile name."""
|
||||
docker_home = tmp_path / "opt" / "data"
|
||||
profile = docker_home / "profiles" / "orchestrator"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
assert get_active_profile_name() == "orchestrator"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Edge cases and additional coverage
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import get_active_provider
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.setup import setup_model_provider
|
||||
@@ -142,6 +144,31 @@ def test_setup_custom_providers_synced(tmp_path, monkeypatch):
|
||||
assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
|
||||
|
||||
|
||||
def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):
|
||||
"""Removing the last custom provider in model setup should persist."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
_stub_tts(monkeypatch)
|
||||
|
||||
config = load_config()
|
||||
config["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
|
||||
save_config(config)
|
||||
|
||||
def fake_select():
|
||||
cfg = load_config()
|
||||
cfg["model"] = {"provider": "openrouter", "default": "anthropic/claude-opus-4.6"}
|
||||
cfg["custom_providers"] = []
|
||||
save_config(cfg)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
reloaded = load_config()
|
||||
assert reloaded.get("custom_providers") == []
|
||||
|
||||
|
||||
def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch):
|
||||
"""When the user cancels provider selection, existing config is preserved."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -201,6 +228,38 @@ def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch):
|
||||
setup_model_provider(config)
|
||||
|
||||
|
||||
def test_select_provider_and_model_warns_if_named_custom_provider_disappears(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""If a saved custom provider is deleted mid-selection, show a warning instead of silently doing nothing."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
cfg = load_config()
|
||||
cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
|
||||
save_config(cfg)
|
||||
|
||||
def fake_prompt_provider_choice(choices, default=0):
|
||||
current = load_config()
|
||||
current["custom_providers"] = []
|
||||
save_config(current)
|
||||
return next(i for i, label in enumerate(choices) if label.startswith("Local (localhost:8080/v1)"))
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda provider: None)
|
||||
monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", fake_prompt_provider_choice)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._model_flow_named_custom",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("named custom flow should not run")),
|
||||
)
|
||||
|
||||
from hermes_cli.main import select_provider_and_model
|
||||
|
||||
select_provider_and_model()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "selected saved custom provider is no longer available" in out
|
||||
|
||||
|
||||
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
|
||||
"""Codex model list fetching uses the runtime access token."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -305,3 +364,52 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
|
||||
|
||||
assert config["terminal"]["backend"] == "modal"
|
||||
assert config["terminal"]["modal_mode"] == "direct"
|
||||
|
||||
|
||||
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None)
|
||||
|
||||
assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"]
|
||||
|
||||
|
||||
def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None)
|
||||
|
||||
assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
|
||||
|
||||
def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"])
|
||||
|
||||
exec_calls = []
|
||||
|
||||
def fake_execvp(path, argv):
|
||||
exec_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_mod._offer_launch_chat()
|
||||
|
||||
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
|
||||
|
||||
|
||||
def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None)
|
||||
|
||||
setup_mod._offer_launch_chat()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Run 'hermes chat' manually" in captured.out
|
||||
|
||||
@@ -22,7 +22,7 @@ def _parse_setup_imports():
|
||||
class TestSetupShutilImport:
|
||||
def test_shutil_imported_at_module_level(self):
|
||||
"""shutil must be imported at module level so setup_gateway can use it
|
||||
for the matrix-nio auto-install path (line ~2126)."""
|
||||
for the mautrix auto-install path."""
|
||||
names = _parse_setup_imports()
|
||||
assert "shutil" in names, (
|
||||
"shutil is not imported at the top of hermes_cli/setup.py. "
|
||||
|
||||
@@ -230,6 +230,39 @@ def test_setup_same_provider_fallback_can_add_another_credential(tmp_path, monke
|
||||
assert config.get("credential_pool_strategies", {}).get("openrouter") == "fill_first"
|
||||
|
||||
|
||||
def test_setup_same_provider_single_credential_keeps_existing_rotation_strategy(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
save_env_value("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
|
||||
|
||||
config = load_config()
|
||||
config["credential_pool_strategies"] = {"openrouter": "round_robin"}
|
||||
save_config(config)
|
||||
|
||||
class _Entry:
|
||||
def __init__(self, label):
|
||||
self.label = label
|
||||
|
||||
class _Pool:
|
||||
def entries(self):
|
||||
return [_Entry("primary")]
|
||||
|
||||
def fake_select():
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||
_stub_tts(monkeypatch)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
|
||||
assert config.get("credential_pool_strategies", {}).get("openrouter") == "round_robin"
|
||||
|
||||
|
||||
def test_setup_pool_step_shows_manual_vs_auto_detected_counts(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
@@ -305,7 +338,6 @@ def test_setup_copilot_acp_skips_same_provider_pool_step(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", fake_prompt_yes_no)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Tests for _setup_provider_model_selection and the zai/kimi/minimax branch.
|
||||
|
||||
Regression test for the is_coding_plan NameError that crashed setup when
|
||||
selecting zai, kimi-coding, minimax, or minimax-cn providers.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_registry():
|
||||
"""Minimal PROVIDER_REGISTRY entries for tested providers."""
|
||||
class FakePConfig:
|
||||
def __init__(self, name, env_vars, base_url_env, inference_url):
|
||||
self.name = name
|
||||
self.api_key_env_vars = env_vars
|
||||
self.base_url_env_var = base_url_env
|
||||
self.inference_base_url = inference_url
|
||||
|
||||
return {
|
||||
"zai": FakePConfig("ZAI", ["ZAI_API_KEY"], "ZAI_BASE_URL", "https://api.zai.example"),
|
||||
"kimi-coding": FakePConfig("Kimi Coding", ["KIMI_API_KEY"], "KIMI_BASE_URL", "https://api.kimi.example"),
|
||||
"minimax": FakePConfig("MiniMax", ["MINIMAX_API_KEY"], "MINIMAX_BASE_URL", "https://api.minimax.example"),
|
||||
"minimax-cn": FakePConfig("MiniMax CN", ["MINIMAX_API_KEY"], "MINIMAX_CN_BASE_URL", "https://api.minimax-cn.example"),
|
||||
"opencode-zen": FakePConfig("OpenCode Zen", ["OPENCODE_ZEN_API_KEY"], "OPENCODE_ZEN_BASE_URL", "https://opencode.ai/zen/v1"),
|
||||
"opencode-go": FakePConfig("OpenCode Go", ["OPENCODE_GO_API_KEY"], "OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1"),
|
||||
}
|
||||
|
||||
|
||||
class TestSetupProviderModelSelection:
|
||||
"""Verify _setup_provider_model_selection works for all providers
|
||||
that previously hit the is_coding_plan NameError."""
|
||||
|
||||
@pytest.mark.parametrize("provider_id,expected_defaults", [
|
||||
("zai", ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"]),
|
||||
("kimi-coding", ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"]),
|
||||
("minimax", ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"]),
|
||||
("minimax-cn", ["MiniMax-M1", "MiniMax-M1-40k", "MiniMax-M1-80k", "MiniMax-M1-128k", "MiniMax-M1-256k", "MiniMax-M2.5", "MiniMax-M2.7"]),
|
||||
("opencode-zen", ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash"]),
|
||||
("opencode-go", ["glm-5", "kimi-k2.5", "minimax-m2.5", "minimax-m2.7"]),
|
||||
])
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=[])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_falls_back_to_default_models_without_crashing(
|
||||
self, mock_env, mock_fetch, provider_id, expected_defaults, mock_provider_registry
|
||||
):
|
||||
"""Previously this code path raised NameError: 'is_coding_plan'.
|
||||
Now it delegates to _setup_provider_model_selection which uses
|
||||
_DEFAULT_PROVIDER_MODELS -- no crash, correct model list."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
# Select "Keep current" (last item)
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id=provider_id,
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
# The offered model list should start with the default models
|
||||
offered = captured_choices["choices"]
|
||||
for model in expected_defaults:
|
||||
assert model in offered, f"{model} not in choices for {provider_id}"
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models")
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_live_models_used_when_available(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
"""When fetch_api_models returns results, those are used instead of defaults."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
live = ["live-model-1", "live-model-2"]
|
||||
mock_fetch.return_value = live
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id="zai",
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
offered = captured_choices["choices"]
|
||||
assert "live-model-1" in offered
|
||||
assert "live-model-2" in offered
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=[])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_custom_model_selection(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
"""Selecting 'Custom model' lets user type a model name."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection, _DEFAULT_PROVIDER_MODELS
|
||||
|
||||
defaults = _DEFAULT_PROVIDER_MODELS["zai"]
|
||||
custom_model_idx = len(defaults) # "Custom model" is right after defaults
|
||||
|
||||
config = {"model": {}}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
return custom_model_idx
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config=config,
|
||||
provider_id="zai",
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: "my-custom-model",
|
||||
)
|
||||
|
||||
assert config["model"]["default"] == "my-custom-model"
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.7"])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_opencode_live_models_are_normalized_for_selection(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id="opencode-go",
|
||||
current_model="opencode-go/kimi-k2.5",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
offered = captured_choices["choices"]
|
||||
assert "kimi-k2.5" in offered
|
||||
assert "minimax-m2.7" in offered
|
||||
assert all("opencode-go/" not in choice for choice in offered)
|
||||
@@ -4,6 +4,7 @@ from argparse import Namespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hermes_cli.config import DEFAULT_CONFIG, load_config, save_config
|
||||
|
||||
|
||||
def _make_setup_args(**overrides):
|
||||
@@ -34,6 +35,36 @@ def _make_chat_args(**overrides):
|
||||
class TestNonInteractiveSetup:
|
||||
"""Verify setup paths exit cleanly in headless/non-interactive environments."""
|
||||
|
||||
def test_cmd_setup_allows_noninteractive_flag_without_tty(self):
|
||||
"""The CLI entrypoint should not block --non-interactive before setup.py handles it."""
|
||||
from hermes_cli.main import cmd_setup
|
||||
|
||||
args = _make_setup_args(non_interactive=True)
|
||||
|
||||
with (
|
||||
patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup,
|
||||
patch("sys.stdin") as mock_stdin,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
cmd_setup(args)
|
||||
|
||||
mock_run_setup.assert_called_once_with(args)
|
||||
|
||||
def test_cmd_setup_defers_no_tty_handling_to_setup_wizard(self):
|
||||
"""Bare `hermes setup` should reach setup.py, which prints headless guidance."""
|
||||
from hermes_cli.main import cmd_setup
|
||||
|
||||
args = _make_setup_args(non_interactive=False)
|
||||
|
||||
with (
|
||||
patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup,
|
||||
patch("sys.stdin") as mock_stdin,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
cmd_setup(args)
|
||||
|
||||
mock_run_setup.assert_called_once_with(args)
|
||||
|
||||
def test_non_interactive_flag_skips_wizard(self, capsys):
|
||||
"""--non-interactive should print guidance and not enter the wizard."""
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
@@ -72,6 +103,26 @@ class TestNonInteractiveSetup:
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes config set model.provider custom" in out
|
||||
|
||||
def test_reset_flag_rewrites_config_before_noninteractive_exit(self, tmp_path, monkeypatch, capsys):
|
||||
"""--reset should rewrite config.yaml even when the wizard cannot run interactively."""
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
cfg = load_config()
|
||||
cfg["model"] = {"provider": "custom", "base_url": "http://localhost:8080/v1", "default": "llama3"}
|
||||
cfg["agent"]["max_turns"] = 12
|
||||
save_config(cfg)
|
||||
|
||||
args = _make_setup_args(non_interactive=True, reset=True)
|
||||
|
||||
run_setup_wizard(args)
|
||||
|
||||
reloaded = load_config()
|
||||
assert reloaded["model"] == DEFAULT_CONFIG["model"]
|
||||
assert reloaded["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"]
|
||||
out = capsys.readouterr().out
|
||||
assert "Configuration reset to defaults." in out
|
||||
|
||||
def test_chat_first_run_headless_skips_setup_prompt(self, capsys):
|
||||
"""Bare `hermes` should not prompt for input when no provider exists and stdin is headless."""
|
||||
from hermes_cli.main import cmd_chat
|
||||
@@ -117,7 +168,7 @@ class TestNonInteractiveSetup:
|
||||
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
|
||||
),
|
||||
patch("hermes_cli.auth.get_active_provider", return_value=None),
|
||||
patch.object(setup_mod, "prompt_choice", return_value=4),
|
||||
patch.object(setup_mod, "prompt_choice", return_value=3),
|
||||
patch.object(
|
||||
setup_mod,
|
||||
"SETUP_SECTIONS",
|
||||
@@ -137,3 +188,59 @@ class TestNonInteractiveSetup:
|
||||
|
||||
terminal_section.assert_called_once_with(config)
|
||||
tts_section.assert_not_called()
|
||||
|
||||
def test_returning_user_menu_does_not_show_separator_rows(self, tmp_path):
|
||||
"""Returning-user menu should only show selectable actions."""
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
args = _make_setup_args()
|
||||
captured = {}
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
captured["question"] = question
|
||||
captured["choices"] = list(choices)
|
||||
return len(choices) - 1
|
||||
|
||||
with (
|
||||
patch.object(setup_mod, "ensure_hermes_home"),
|
||||
patch.object(setup_mod, "load_config", return_value={}),
|
||||
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
|
||||
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
|
||||
patch.object(
|
||||
setup_mod,
|
||||
"get_env_value",
|
||||
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
|
||||
),
|
||||
patch("hermes_cli.auth.get_active_provider", return_value=None),
|
||||
patch.object(setup_mod, "prompt_choice", side_effect=fake_prompt_choice),
|
||||
):
|
||||
setup_mod.run_setup_wizard(args)
|
||||
|
||||
assert captured["question"] == "What would you like to do?"
|
||||
assert "---" not in captured["choices"]
|
||||
assert captured["choices"] == [
|
||||
"Quick Setup - configure missing items only",
|
||||
"Full Setup - reconfigure everything",
|
||||
"Model & Provider",
|
||||
"Terminal Backend",
|
||||
"Messaging Platforms (Gateway)",
|
||||
"Tools",
|
||||
"Agent Settings",
|
||||
"Exit",
|
||||
]
|
||||
|
||||
def test_main_accepts_tts_setup_section(self, monkeypatch):
|
||||
"""`hermes setup tts` should parse and dispatch like other setup sections."""
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
received = {}
|
||||
|
||||
def fake_cmd_setup(args):
|
||||
received["section"] = args.section
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_setup", fake_cmd_setup)
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "setup", "tts"])
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert received["section"] == "tts"
|
||||
|
||||
@@ -196,31 +196,6 @@ class TestDisplayIntegration:
|
||||
set_active_skin("ares")
|
||||
assert get_skin_tool_prefix() == "╎"
|
||||
|
||||
def test_get_skin_faces_default(self):
|
||||
from agent.display import get_skin_faces, KawaiiSpinner
|
||||
faces = get_skin_faces("waiting_faces", KawaiiSpinner.KAWAII_WAITING)
|
||||
# Default skin has no custom faces, so should return the default list
|
||||
assert faces == KawaiiSpinner.KAWAII_WAITING
|
||||
|
||||
def test_get_skin_faces_ares(self):
|
||||
from hermes_cli.skin_engine import set_active_skin
|
||||
from agent.display import get_skin_faces, KawaiiSpinner
|
||||
set_active_skin("ares")
|
||||
faces = get_skin_faces("waiting_faces", KawaiiSpinner.KAWAII_WAITING)
|
||||
assert "(⚔)" in faces
|
||||
|
||||
def test_get_skin_verbs_default(self):
|
||||
from agent.display import get_skin_verbs, KawaiiSpinner
|
||||
verbs = get_skin_verbs()
|
||||
assert verbs == KawaiiSpinner.THINKING_VERBS
|
||||
|
||||
def test_get_skin_verbs_ares(self):
|
||||
from hermes_cli.skin_engine import set_active_skin
|
||||
from agent.display import get_skin_verbs
|
||||
set_active_skin("ares")
|
||||
verbs = get_skin_verbs()
|
||||
assert "forging" in verbs
|
||||
|
||||
def test_tool_message_uses_skin_prefix(self):
|
||||
from hermes_cli.skin_engine import set_active_skin
|
||||
from agent.display import get_cute_tool_message
|
||||
|
||||
106
tests/hermes_cli/test_terminal_menu_fallbacks.py
Normal file
106
tests/hermes_cli/test_terminal_menu_fallbacks.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Regression tests for numbered fallbacks when TerminalMenu cannot initialize."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
|
||||
class _BrokenTerminalMenu:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise subprocess.CalledProcessError(2, ["tput", "clear"])
|
||||
|
||||
|
||||
def test_prompt_model_selection_falls_back_on_terminalmenu_runtime_error(monkeypatch):
|
||||
from hermes_cli.auth import _prompt_model_selection
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"simple_term_menu",
|
||||
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
|
||||
)
|
||||
responses = iter(["2"])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
|
||||
|
||||
selected = _prompt_model_selection(["model-a", "model-b"])
|
||||
|
||||
assert selected == "model-b"
|
||||
|
||||
|
||||
def test_prompt_reasoning_effort_falls_back_on_terminalmenu_runtime_error(monkeypatch):
|
||||
from hermes_cli.main import _prompt_reasoning_effort_selection
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"simple_term_menu",
|
||||
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
|
||||
)
|
||||
responses = iter(["3"])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
|
||||
|
||||
selected = _prompt_reasoning_effort_selection(["low", "medium", "high"], current_effort="")
|
||||
|
||||
assert selected == "high"
|
||||
|
||||
|
||||
def test_remove_custom_provider_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch):
|
||||
from hermes_cli.main import _remove_custom_provider
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"simple_term_menu",
|
||||
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
|
||||
)
|
||||
|
||||
cfg = load_config()
|
||||
cfg["custom_providers"] = [
|
||||
{"name": "Local A", "base_url": "http://localhost:8001/v1"},
|
||||
{"name": "Local B", "base_url": "http://localhost:8002/v1"},
|
||||
]
|
||||
save_config(cfg)
|
||||
|
||||
responses = iter(["1"])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
|
||||
|
||||
_remove_custom_provider(cfg)
|
||||
|
||||
reloaded = load_config()
|
||||
assert reloaded["custom_providers"] == [
|
||||
{"name": "Local B", "base_url": "http://localhost:8002/v1"},
|
||||
]
|
||||
|
||||
|
||||
def test_named_custom_provider_model_picker_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"simple_term_menu",
|
||||
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *args, **kwargs: ["model-a", "model-b"])
|
||||
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
|
||||
|
||||
cfg = load_config()
|
||||
save_config(cfg)
|
||||
|
||||
responses = iter(["2"])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
|
||||
|
||||
_model_flow_named_custom(
|
||||
cfg,
|
||||
{
|
||||
"name": "Local",
|
||||
"base_url": "http://localhost:8000/v1",
|
||||
"api_key": "",
|
||||
"model": "",
|
||||
},
|
||||
)
|
||||
|
||||
reloaded = load_config()
|
||||
assert reloaded["model"]["provider"] == "custom"
|
||||
assert reloaded["model"]["base_url"] == "http://localhost:8000/v1"
|
||||
assert reloaded["model"]["default"] == "model-b"
|
||||
@@ -428,3 +428,31 @@ class TestPlatformToolsetConsistency:
|
||||
f"Platform {platform!r} in tools_config but missing from "
|
||||
f"skills_config PLATFORMS"
|
||||
)
|
||||
|
||||
|
||||
def test_numeric_mcp_server_name_does_not_crash_sorted():
|
||||
"""YAML parses bare numeric keys (e.g. ``12306:``) as int.
|
||||
|
||||
_get_platform_tools must normalise them to str so that sorted()
|
||||
on the returned set never raises TypeError on mixed int/str.
|
||||
|
||||
Regression test for https://github.com/NousResearch/hermes-agent/issues/6901
|
||||
"""
|
||||
config = {
|
||||
"platform_toolsets": {"cli": ["web", 12306]},
|
||||
"mcp_servers": {
|
||||
12306: {"url": "https://example.com/mcp"},
|
||||
"normal-server": {"url": "https://example.com/mcp2"},
|
||||
},
|
||||
}
|
||||
|
||||
enabled = _get_platform_tools(config, "cli")
|
||||
|
||||
# All names must be str — no int leaking through
|
||||
assert all(isinstance(name, str) for name in enabled), (
|
||||
f"Non-string toolset names found: {enabled}"
|
||||
)
|
||||
assert "12306" in enabled
|
||||
|
||||
# sorted() must not raise TypeError
|
||||
sorted(enabled)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user