feat: scroll aware sticky prompt
This commit is contained in:
@@ -365,7 +365,7 @@ class TestExpiredCodexFallback:
|
||||
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
||||
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
|
||||
# Mock resolve_anthropic_token to return an OAuth-style token
|
||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \
|
||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat-hermes-token"), \
|
||||
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
|
||||
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
||||
mock_build.return_value = MagicMock()
|
||||
@@ -420,7 +420,7 @@ class TestExpiredCodexFallback:
|
||||
|
||||
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
|
||||
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cc-oauth-token-test")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat-cc-test-token")
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
||||
mock_build.return_value = MagicMock()
|
||||
@@ -786,7 +786,7 @@ class TestAuxiliaryPoolAwareness:
|
||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
|
||||
):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert client is not None
|
||||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||
|
||||
@@ -25,6 +25,11 @@ def _make_compressor():
|
||||
compressor._previous_summary = None
|
||||
compressor._summary_failure_cooldown_until = 0.0
|
||||
compressor.summary_model = None
|
||||
compressor.model = "test-model"
|
||||
compressor.provider = "test"
|
||||
compressor.base_url = "http://localhost"
|
||||
compressor.api_key = "test-key"
|
||||
compressor.api_mode = "chat_completions"
|
||||
return compressor
|
||||
|
||||
|
||||
|
||||
@@ -109,14 +109,12 @@ class TestMemoryManagerUserIdThreading:
|
||||
assert "user_id" not in p._init_kwargs
|
||||
|
||||
def test_multiple_providers_all_receive_user_id(self):
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
mgr = MemoryManager()
|
||||
# Use builtin + one external (MemoryManager only allows one external)
|
||||
builtin = BuiltinMemoryProvider()
|
||||
ext = RecordingProvider("external")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext)
|
||||
# Use one provider named "builtin" (always accepted) and one external
|
||||
p1 = RecordingProvider("builtin")
|
||||
p2 = RecordingProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.initialize_all(
|
||||
session_id="sess-multi",
|
||||
@@ -124,8 +122,10 @@ class TestMemoryManagerUserIdThreading:
|
||||
user_id="slack_U12345",
|
||||
)
|
||||
|
||||
assert ext._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert ext._init_kwargs.get("platform") == "slack"
|
||||
assert p1._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert p1._init_kwargs.get("platform") == "slack"
|
||||
assert p2._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert p2._init_kwargs.get("platform") == "slack"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -211,17 +211,17 @@ class TestHonchoUserIdScoping:
|
||||
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
|
||||
|
||||
def test_gateway_user_id_overrides_peer_name(self):
|
||||
"""When user_id is in kwargs, cfg.peer_name should be overridden."""
|
||||
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
|
||||
# Create a mock config with a static peer_name
|
||||
# Create a mock config with NO explicit peer_name
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.enabled = True
|
||||
mock_cfg.api_key = "test-key"
|
||||
mock_cfg.base_url = None
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
|
||||
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
|
||||
|
||||
with patch(
|
||||
|
||||
@@ -63,6 +63,7 @@ class TestCLISubagentInterrupt(unittest.TestCase):
|
||||
parent._delegate_depth = 0
|
||||
parent._delegate_spinner = None
|
||||
parent.tool_progress_callback = None
|
||||
parent._execution_thread_id = None
|
||||
|
||||
# We'll track what happens with _active_children
|
||||
original_children = parent._active_children
|
||||
|
||||
@@ -576,8 +576,9 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||
|
||||
# After the probe detects a single model ("llm"), the flow asks
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", ""])
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||
# then display name.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
|
||||
|
||||
@@ -641,3 +642,46 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||
"ca_bundle": "/tmp/local-ca.pem",
|
||||
"insecure": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _auto_provider_name — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auto_provider_name_localhost():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
|
||||
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
|
||||
|
||||
|
||||
def test_auto_provider_name_runpod():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1")
|
||||
|
||||
|
||||
def test_auto_provider_name_remote():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
result = _auto_provider_name("https://api.together.xyz/v1")
|
||||
assert result == "Api.together.xyz"
|
||||
|
||||
|
||||
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
|
||||
"""When a display name is passed, it should appear in the saved entry."""
|
||||
import yaml
|
||||
from hermes_cli.main import _save_custom_provider
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.dump({}))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
|
||||
)
|
||||
saved = {}
|
||||
def _save(cfg):
|
||||
saved.update(cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", _save)
|
||||
|
||||
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
|
||||
entries = saved.get("custom_providers", [])
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "Ollama"
|
||||
|
||||
@@ -369,7 +369,8 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert kwargs.get("speed") == "fast"
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" in kwargs
|
||||
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
|
||||
|
||||
@@ -384,6 +385,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
reasoning_config=None,
|
||||
fast_mode=False,
|
||||
)
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
@@ -400,9 +402,24 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
base_url="https://api.minimax.io/anthropic/v1",
|
||||
)
|
||||
# Third-party endpoints should NOT get speed or fast-mode beta
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(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,
|
||||
)
|
||||
assert "speed" not in kwargs
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
def test_default_config_has_service_tier(self):
|
||||
|
||||
@@ -220,41 +220,6 @@ class TestPlatformDefaults:
|
||||
assert resolve_display_setting({}, "telegram", "streaming") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_effective_display / get_platform_defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHelpers:
|
||||
"""Helper functions return correct composite results."""
|
||||
|
||||
def test_get_effective_display_merges_correctly(self):
|
||||
from gateway.display_config import get_effective_display
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "new",
|
||||
"show_reasoning": True,
|
||||
"platforms": {
|
||||
"telegram": {"tool_progress": "verbose"},
|
||||
},
|
||||
}
|
||||
}
|
||||
eff = get_effective_display(config, "telegram")
|
||||
assert eff["tool_progress"] == "verbose" # platform override
|
||||
assert eff["show_reasoning"] is True # global
|
||||
assert "tool_preview_length" in eff # default filled in
|
||||
|
||||
def test_get_platform_defaults_returns_dict(self):
|
||||
from gateway.display_config import get_platform_defaults
|
||||
|
||||
defaults = get_platform_defaults("telegram")
|
||||
assert "tool_progress" in defaults
|
||||
assert "show_reasoning" in defaults
|
||||
# Returns a new dict (not the shared tier dict)
|
||||
defaults["tool_progress"] = "changed"
|
||||
assert get_platform_defaults("telegram")["tool_progress"] != "changed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config migration: tool_progress_overrides → display.platforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -334,10 +334,12 @@ class TestChannelDirectory(unittest.TestCase):
|
||||
"""Verify email in channel directory session-based discovery."""
|
||||
|
||||
def test_email_in_session_discovery(self):
|
||||
import gateway.channel_directory
|
||||
import inspect
|
||||
source = inspect.getsource(gateway.channel_directory.build_channel_directory)
|
||||
self.assertIn('"email"', source)
|
||||
from gateway.config import Platform
|
||||
# Verify email is a Platform enum member — the dynamic loop in
|
||||
# build_channel_directory iterates all Platform members, so email
|
||||
# is included automatically as long as it's in the enum.
|
||||
email_values = [p.value for p in Platform]
|
||||
self.assertIn("email", email_values)
|
||||
|
||||
|
||||
class TestGatewaySetup(unittest.TestCase):
|
||||
|
||||
@@ -631,6 +631,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
calls.append("card_action")
|
||||
return self
|
||||
|
||||
def register_p2_im_chat_member_bot_added_v1(self, _handler):
|
||||
calls.append("bot_added")
|
||||
return self
|
||||
|
||||
def register_p2_im_chat_member_bot_deleted_v1(self, _handler):
|
||||
calls.append("bot_deleted")
|
||||
return self
|
||||
|
||||
def build(self):
|
||||
calls.append("build")
|
||||
return "handler"
|
||||
@@ -654,6 +662,8 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
"reaction_created",
|
||||
"reaction_deleted",
|
||||
"card_action",
|
||||
"bot_added",
|
||||
"bot_deleted",
|
||||
"build",
|
||||
],
|
||||
)
|
||||
|
||||
460
tests/gateway/test_qqbot.py
Normal file
460
tests/gateway/test_qqbot.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""Tests for the QQ Bot platform adapter."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config(**extra):
|
||||
"""Build a PlatformConfig(enabled=True, extra=extra) for testing."""
|
||||
return PlatformConfig(enabled=True, extra=extra)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_qq_requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQRequirements:
|
||||
def test_returns_bool(self):
|
||||
from gateway.platforms.qqbot import check_qq_requirements
|
||||
result = check_qq_requirements()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQAdapter.__init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQAdapterInit:
|
||||
def _make(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_basic_attributes(self):
|
||||
adapter = self._make(app_id="123", client_secret="sec")
|
||||
assert adapter._app_id == "123"
|
||||
assert adapter._client_secret == "sec"
|
||||
|
||||
def test_env_fallback(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id", "QQ_CLIENT_SECRET": "env_sec"}, clear=False):
|
||||
adapter = self._make()
|
||||
assert adapter._app_id == "env_id"
|
||||
assert adapter._client_secret == "env_sec"
|
||||
|
||||
def test_env_fallback_extra_wins(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id"}, clear=False):
|
||||
adapter = self._make(app_id="extra_id", client_secret="sec")
|
||||
assert adapter._app_id == "extra_id"
|
||||
|
||||
def test_dm_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._dm_policy == "open"
|
||||
|
||||
def test_dm_policy_explicit(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", dm_policy="allowlist")
|
||||
assert adapter._dm_policy == "allowlist"
|
||||
|
||||
def test_group_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._group_policy == "open"
|
||||
|
||||
def test_allow_from_parsing_string(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from="x, y , z")
|
||||
assert adapter._allow_from == ["x", "y", "z"]
|
||||
|
||||
def test_allow_from_parsing_list(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from=["a", "b"])
|
||||
assert adapter._allow_from == ["a", "b"]
|
||||
|
||||
def test_allow_from_default_empty(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._allow_from == []
|
||||
|
||||
def test_group_allow_from(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", group_allow_from="g1,g2")
|
||||
assert adapter._group_allow_from == ["g1", "g2"]
|
||||
|
||||
def test_markdown_support_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._markdown_support is True
|
||||
|
||||
def test_markdown_support_false(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", markdown_support=False)
|
||||
assert adapter._markdown_support is False
|
||||
|
||||
def test_name_property(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter.name == "QQBot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _coerce_list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCoerceList:
|
||||
def _fn(self, value):
|
||||
from gateway.platforms.qqbot import _coerce_list
|
||||
return _coerce_list(value)
|
||||
|
||||
def test_none(self):
|
||||
assert self._fn(None) == []
|
||||
|
||||
def test_string(self):
|
||||
assert self._fn("a, b ,c") == ["a", "b", "c"]
|
||||
|
||||
def test_list(self):
|
||||
assert self._fn(["x", "y"]) == ["x", "y"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == []
|
||||
|
||||
def test_tuple(self):
|
||||
assert self._fn(("a", "b")) == ["a", "b"]
|
||||
|
||||
def test_single_item_string(self):
|
||||
assert self._fn("hello") == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_voice_content_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsVoiceContentType:
|
||||
def _fn(self, content_type, filename):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._is_voice_content_type(content_type, filename)
|
||||
|
||||
def test_voice_content_type(self):
|
||||
assert self._fn("voice", "msg.silk") is True
|
||||
|
||||
def test_audio_content_type(self):
|
||||
assert self._fn("audio/mp3", "file.mp3") is True
|
||||
|
||||
def test_voice_extension(self):
|
||||
assert self._fn("", "file.silk") is True
|
||||
|
||||
def test_non_voice(self):
|
||||
assert self._fn("image/jpeg", "photo.jpg") is False
|
||||
|
||||
def test_audio_extension_amr(self):
|
||||
assert self._fn("", "recording.amr") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_at_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStripAtMention:
|
||||
def _fn(self, content):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._strip_at_mention(content)
|
||||
|
||||
def test_removes_mention(self):
|
||||
result = self._fn("@BotUser hello there")
|
||||
assert result == "hello there"
|
||||
|
||||
def test_no_mention(self):
|
||||
result = self._fn("just text")
|
||||
assert result == "just text"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == ""
|
||||
|
||||
def test_only_mention(self):
|
||||
assert self._fn("@Someone ") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_dm_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDmAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="open")
|
||||
assert adapter._is_dm_allowed("any_user") is True
|
||||
|
||||
def test_disabled_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="disabled")
|
||||
assert adapter._is_dm_allowed("any_user") is False
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user3") is False
|
||||
|
||||
def test_allowlist_wildcard(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="*")
|
||||
assert adapter._is_dm_allowed("anyone") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_group_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGroupAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="open")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp2", "user1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_stt_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveSTTConfig:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_no_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
assert adapter._resolve_stt_config() is None
|
||||
|
||||
def test_env_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {
|
||||
"QQ_STT_API_KEY": "key123",
|
||||
"QQ_STT_BASE_URL": "https://example.com/v1",
|
||||
"QQ_STT_MODEL": "my-model",
|
||||
}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["api_key"] == "key123"
|
||||
assert cfg["base_url"] == "https://example.com/v1"
|
||||
assert cfg["model"] == "my-model"
|
||||
|
||||
def test_extra_config(self):
|
||||
stt_cfg = {
|
||||
"baseUrl": "https://custom.api/v4",
|
||||
"apiKey": "sk_extra",
|
||||
"model": "glm-asr",
|
||||
}
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", stt=stt_cfg)
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["base_url"] == "https://custom.api/v4"
|
||||
assert cfg["api_key"] == "sk_extra"
|
||||
assert cfg["model"] == "glm-asr"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_message_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectMessageType:
|
||||
def _fn(self, media_urls, media_types):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._detect_message_type(media_urls, media_types)
|
||||
|
||||
def test_no_media(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn([], []) == MessageType.TEXT
|
||||
|
||||
def test_image(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["file.jpg"], ["image/jpeg"]) == MessageType.PHOTO
|
||||
|
||||
def test_voice(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["voice.silk"], ["audio/silk"]) == MessageType.VOICE
|
||||
|
||||
def test_video(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["vid.mp4"], ["video/mp4"]) == MessageType.VIDEO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQCloseError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQCloseError:
|
||||
def test_attributes(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(4004, "bad token")
|
||||
assert err.code == 4004
|
||||
assert err.reason == "bad token"
|
||||
|
||||
def test_code_none(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(None, "")
|
||||
assert err.code is None
|
||||
|
||||
def test_string_to_int(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError("4914", "banned")
|
||||
assert err.code == 4914
|
||||
assert err.reason == "banned"
|
||||
|
||||
def test_message_format(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(4008, "rate limit")
|
||||
assert "4008" in str(err)
|
||||
assert "rate limit" in str(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatchPayload:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
adapter = QQAdapter(_make_config(**extra))
|
||||
return adapter
|
||||
|
||||
def test_unknown_op(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 99, "d": {}})
|
||||
# last_seq should remain None
|
||||
assert adapter._last_seq is None
|
||||
|
||||
def test_op10_updates_heartbeat_interval(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 10, "d": {"heartbeat_interval": 50000}})
|
||||
# Should be 50000 / 1000 * 0.8 = 40.0
|
||||
assert adapter._heartbeat_interval == 40.0
|
||||
|
||||
def test_op11_heartbeat_ack(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 11, "t": "HEARTBEAT_ACK", "s": 42})
|
||||
|
||||
def test_seq_tracking(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 100, "d": {}})
|
||||
assert adapter._last_seq == 100
|
||||
|
||||
def test_seq_increments(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 5, "d": {}})
|
||||
adapter._dispatch_payload({"op": 0, "t": "SOME_EVENT", "s": 10, "d": {}})
|
||||
assert adapter._last_seq == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READY / RESUMED handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadyHandling:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_ready_stores_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "READY",
|
||||
"s": 1,
|
||||
"d": {"session_id": "sess_abc123"},
|
||||
})
|
||||
assert adapter._session_id == "sess_abc123"
|
||||
|
||||
def test_resumed_preserves_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._session_id = "old_sess"
|
||||
adapter._last_seq = 50
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "RESUMED", "s": 60, "d": {},
|
||||
})
|
||||
# Session should remain unchanged on RESUMED
|
||||
assert adapter._session_id == "old_sess"
|
||||
assert adapter._last_seq == 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseJson:
|
||||
def _fn(self, raw):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._parse_json(raw)
|
||||
|
||||
def test_valid_json(self):
|
||||
result = self._fn('{"op": 10, "d": {}}')
|
||||
assert result == {"op": 10, "d": {}}
|
||||
|
||||
def test_invalid_json(self):
|
||||
result = self._fn("not json")
|
||||
assert result is None
|
||||
|
||||
def test_none_input(self):
|
||||
result = self._fn(None)
|
||||
assert result is None
|
||||
|
||||
def test_non_dict_json(self):
|
||||
result = self._fn('"just a string"')
|
||||
assert result is None
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = self._fn('{}')
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_text_body
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildTextBody:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_plain_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("hello world")
|
||||
assert body["msg_type"] == 0 # MSG_TYPE_TEXT
|
||||
assert body["content"] == "hello world"
|
||||
|
||||
def test_markdown_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=True)
|
||||
body = adapter._build_text_body("**bold** text")
|
||||
assert body["msg_type"] == 2 # MSG_TYPE_MARKDOWN
|
||||
assert body["markdown"]["content"] == "**bold** text"
|
||||
|
||||
def test_truncation(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
long_text = "x" * 10000
|
||||
body = adapter._build_text_body(long_text)
|
||||
assert len(body["content"]) == adapter.MAX_MESSAGE_LENGTH
|
||||
|
||||
def test_empty_string(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("")
|
||||
assert body["content"] == ""
|
||||
|
||||
def test_reply_to(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("reply text", reply_to="msg_123")
|
||||
assert body.get("message_reference", {}).get("message_id") == "msg_123"
|
||||
@@ -13,7 +13,10 @@ from tests.gateway.restart_test_helpers import make_restart_runner, make_restart
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_while_busy_requests_drain_without_interrupt():
|
||||
async def test_restart_command_while_busy_requests_drain_without_interrupt(monkeypatch):
|
||||
# Ensure INVOCATION_ID is NOT set — systemd sets this in service mode,
|
||||
# which changes the restart call signature.
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
event = MessageEvent(
|
||||
|
||||
@@ -186,10 +186,13 @@ def test_set_session_env_includes_session_key():
|
||||
session_key="tg:-1001:17585",
|
||||
)
|
||||
|
||||
# Capture baseline value before setting (may be non-empty from another
|
||||
# test in the same pytest-xdist worker sharing the context).
|
||||
baseline = get_session_env("HERMES_SESSION_KEY")
|
||||
tokens = runner._set_session_env(context)
|
||||
assert get_session_env("HERMES_SESSION_KEY") == "tg:-1001:17585"
|
||||
runner._clear_session_env(tokens)
|
||||
assert get_session_env("HERMES_SESSION_KEY") == ""
|
||||
assert get_session_env("HERMES_SESSION_KEY") == baseline
|
||||
|
||||
|
||||
def test_session_key_no_race_condition_with_contextvars(monkeypatch):
|
||||
|
||||
@@ -374,6 +374,7 @@ async def test_session_hygiene_messages_stay_in_originating_topic(monkeypatch, t
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
user_id="12345",
|
||||
),
|
||||
message_id="1",
|
||||
)
|
||||
|
||||
@@ -155,6 +155,90 @@ class TestSendOrEditMediaStripping:
|
||||
|
||||
adapter.send.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_with_cursor_skips_new_message(self):
|
||||
"""Short text + cursor should not create a standalone new message.
|
||||
|
||||
During rapid tool-calling the model often emits 1-2 tokens before
|
||||
switching to tool calls. Sending 'I ▉' as a new message risks
|
||||
leaving the cursor permanently visible if the follow-up edit is
|
||||
rate-limited. The guard should skip the first send and let the
|
||||
text accumulate into the next segment.
|
||||
"""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
StreamConsumerConfig(cursor=" ▉"),
|
||||
)
|
||||
# No message_id yet (first send) — short text + cursor should be skipped
|
||||
assert consumer._message_id is None
|
||||
result = await consumer._send_or_edit("I ▉")
|
||||
assert result is True
|
||||
adapter.send.assert_not_called()
|
||||
|
||||
# 3 chars is still under the threshold
|
||||
result = await consumer._send_or_edit("Hi! ▉")
|
||||
assert result is True
|
||||
adapter.send.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_longer_text_with_cursor_sends_new_message(self):
|
||||
"""Text >= 4 visible chars + cursor should create a new message normally."""
|
||||
adapter = MagicMock()
|
||||
send_result = SimpleNamespace(success=True, message_id="msg_1")
|
||||
adapter.send = AsyncMock(return_value=send_result)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
StreamConsumerConfig(cursor=" ▉"),
|
||||
)
|
||||
result = await consumer._send_or_edit("Hello ▉")
|
||||
assert result is True
|
||||
adapter.send.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_without_cursor_sends_normally(self):
|
||||
"""Short text without cursor (e.g. final edit) should send normally."""
|
||||
adapter = MagicMock()
|
||||
send_result = SimpleNamespace(success=True, message_id="msg_1")
|
||||
adapter.send = AsyncMock(return_value=send_result)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
StreamConsumerConfig(cursor=" ▉"),
|
||||
)
|
||||
# No cursor in text — even short text should be sent
|
||||
result = await consumer._send_or_edit("OK")
|
||||
assert result is True
|
||||
adapter.send.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_cursor_edit_existing_message_allowed(self):
|
||||
"""Short text + cursor editing an existing message should proceed."""
|
||||
adapter = MagicMock()
|
||||
edit_result = SimpleNamespace(success=True)
|
||||
adapter.edit_message = AsyncMock(return_value=edit_result)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
StreamConsumerConfig(cursor=" ▉"),
|
||||
)
|
||||
consumer._message_id = "msg_1" # Existing message — guard should not fire
|
||||
consumer._last_sent_text = ""
|
||||
result = await consumer._send_or_edit("I ▉")
|
||||
assert result is True
|
||||
adapter.edit_message.assert_called_once()
|
||||
|
||||
|
||||
# ── Integration: full stream run ─────────────────────────────────────────
|
||||
|
||||
@@ -507,7 +591,7 @@ class TestSegmentBreakOnToolBoundary:
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=" ▉")
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
prefix = "abc"
|
||||
prefix = "Hello world"
|
||||
tail = "x" * 620
|
||||
consumer.on_delta(prefix)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
@@ -680,3 +764,202 @@ class TestCancelledConsumerSetsFlags:
|
||||
# Without a successful send, final_response_sent should stay False
|
||||
# so the normal gateway send path can deliver the response.
|
||||
assert consumer.final_response_sent is False
|
||||
|
||||
|
||||
# ── Think-block filtering unit tests ─────────────────────────────────────
|
||||
|
||||
|
||||
def _make_consumer() -> GatewayStreamConsumer:
|
||||
"""Create a bare consumer for unit-testing the filter (no adapter needed)."""
|
||||
adapter = MagicMock()
|
||||
return GatewayStreamConsumer(adapter, "chat_test")
|
||||
|
||||
|
||||
class TestFilterAndAccumulate:
|
||||
"""Unit tests for _filter_and_accumulate think-block suppression."""
|
||||
|
||||
def test_plain_text_passes_through(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("Hello world")
|
||||
assert c._accumulated == "Hello world"
|
||||
|
||||
def test_complete_think_block_stripped(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<think>internal reasoning</think>Answer here")
|
||||
assert c._accumulated == "Answer here"
|
||||
|
||||
def test_think_block_in_middle(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("Prefix\n<think>reasoning</think>\nSuffix")
|
||||
assert c._accumulated == "Prefix\n\nSuffix"
|
||||
|
||||
def test_think_block_split_across_deltas(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<think>start of")
|
||||
c._filter_and_accumulate(" reasoning</think>visible text")
|
||||
assert c._accumulated == "visible text"
|
||||
|
||||
def test_opening_tag_split_across_deltas(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<thi")
|
||||
# Partial tag held back
|
||||
assert c._accumulated == ""
|
||||
c._filter_and_accumulate("nk>hidden</think>shown")
|
||||
assert c._accumulated == "shown"
|
||||
|
||||
def test_closing_tag_split_across_deltas(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<think>hidden</thi")
|
||||
assert c._accumulated == ""
|
||||
c._filter_and_accumulate("nk>shown")
|
||||
assert c._accumulated == "shown"
|
||||
|
||||
def test_multiple_think_blocks(self):
|
||||
c = _make_consumer()
|
||||
# Consecutive blocks with no text between them — both stripped
|
||||
c._filter_and_accumulate(
|
||||
"<think>block1</think><think>block2</think>visible"
|
||||
)
|
||||
assert c._accumulated == "visible"
|
||||
|
||||
def test_multiple_think_blocks_with_text_between(self):
|
||||
"""Think tag after non-whitespace is NOT a boundary (prose safety)."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate(
|
||||
"<think>block1</think>A<think>block2</think>B"
|
||||
)
|
||||
# Second <think> follows 'A' (not a block boundary) — treated as prose
|
||||
assert "A" in c._accumulated
|
||||
assert "B" in c._accumulated
|
||||
|
||||
def test_thinking_tag_variant(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<thinking>deep thought</thinking>Result")
|
||||
assert c._accumulated == "Result"
|
||||
|
||||
def test_thought_tag_variant(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<thought>Gemma style</thought>Output")
|
||||
assert c._accumulated == "Output"
|
||||
|
||||
def test_reasoning_scratchpad_variant(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate(
|
||||
"<REASONING_SCRATCHPAD>long plan</REASONING_SCRATCHPAD>Done"
|
||||
)
|
||||
assert c._accumulated == "Done"
|
||||
|
||||
def test_case_insensitive_THINKING(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<THINKING>caps</THINKING>answer")
|
||||
assert c._accumulated == "answer"
|
||||
|
||||
def test_prose_mention_not_stripped(self):
|
||||
"""<think> mentioned mid-line in prose should NOT trigger filtering."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("The <think> tag is used for reasoning")
|
||||
assert "<think>" in c._accumulated
|
||||
assert "used for reasoning" in c._accumulated
|
||||
|
||||
def test_prose_mention_after_text(self):
|
||||
"""<think> after non-whitespace on same line is not a block boundary."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("Try using <think>some content</think> tags")
|
||||
assert "<think>" in c._accumulated
|
||||
|
||||
def test_think_at_line_start_is_stripped(self):
|
||||
"""<think> at start of a new line IS a block boundary."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("Previous line\n<think>reasoning</think>Next")
|
||||
assert "Previous line\nNext" == c._accumulated
|
||||
|
||||
def test_think_with_only_whitespace_before(self):
|
||||
"""<think> preceded by only whitespace on its line is a boundary."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate(" <think>hidden</think>visible")
|
||||
# Leading whitespace before the tag is emitted, then block is stripped
|
||||
assert c._accumulated == " visible"
|
||||
|
||||
def test_flush_think_buffer_on_non_tag(self):
|
||||
"""Partial tag that turns out not to be a tag is flushed."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<thi")
|
||||
assert c._accumulated == ""
|
||||
# Flush explicitly (simulates stream end)
|
||||
c._flush_think_buffer()
|
||||
assert c._accumulated == "<thi"
|
||||
|
||||
def test_flush_think_buffer_when_inside_block(self):
|
||||
"""Flush while inside a think block does NOT emit buffered content."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<think>still thinking")
|
||||
c._flush_think_buffer()
|
||||
assert c._accumulated == ""
|
||||
|
||||
def test_unclosed_think_block_suppresses(self):
|
||||
"""An unclosed <think> suppresses all subsequent content."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("Before\n<think>reasoning that never ends...")
|
||||
assert c._accumulated == "Before\n"
|
||||
|
||||
def test_multiline_think_block(self):
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate(
|
||||
"<think>\nLine 1\nLine 2\nLine 3\n</think>Final answer"
|
||||
)
|
||||
assert c._accumulated == "Final answer"
|
||||
|
||||
def test_segment_reset_preserves_think_state(self):
|
||||
"""_reset_segment_state should NOT clear think-block filter state."""
|
||||
c = _make_consumer()
|
||||
c._filter_and_accumulate("<think>start")
|
||||
c._reset_segment_state()
|
||||
# Still inside think block — subsequent text should be suppressed
|
||||
c._filter_and_accumulate("still hidden</think>visible")
|
||||
assert c._accumulated == "visible"
|
||||
|
||||
|
||||
class TestFilterAndAccumulateIntegration:
|
||||
"""Integration: verify think blocks don't leak through the full run() path."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_think_block_not_sent_to_platform(self):
|
||||
"""Think blocks should be filtered before platform edit."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_1")
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True)
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_test",
|
||||
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),
|
||||
)
|
||||
|
||||
# Simulate streaming: think block then visible text
|
||||
consumer.on_delta("<think>deep reasoning here</think>")
|
||||
consumer.on_delta("The answer is 42.")
|
||||
consumer.finish()
|
||||
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# The final text sent to the platform should NOT contain <think>
|
||||
all_calls = list(adapter.send.call_args_list) + list(
|
||||
adapter.edit_message.call_args_list
|
||||
)
|
||||
for call in all_calls:
|
||||
args, kwargs = call
|
||||
content = kwargs.get("content") or (args[0] if args else "")
|
||||
assert "<think>" not in content, f"Think tag leaked: {content}"
|
||||
assert "deep reasoning" not in content
|
||||
|
||||
try:
|
||||
task.cancel()
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock
|
||||
from gateway.config import Platform, PlatformConfig, load_gateway_config
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None):
|
||||
def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None, ignored_threads=None):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
extra = {}
|
||||
@@ -15,6 +15,8 @@ def _make_adapter(require_mention=None, free_response_chats=None, mention_patter
|
||||
extra["free_response_chats"] = free_response_chats
|
||||
if mention_patterns is not None:
|
||||
extra["mention_patterns"] = mention_patterns
|
||||
if ignored_threads is not None:
|
||||
extra["ignored_threads"] = ignored_threads
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
@@ -28,7 +30,16 @@ def _make_adapter(require_mention=None, free_response_chats=None, mention_patter
|
||||
return adapter
|
||||
|
||||
|
||||
def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None):
|
||||
def _group_message(
|
||||
text="hello",
|
||||
*,
|
||||
chat_id=-100,
|
||||
thread_id=None,
|
||||
reply_to_bot=False,
|
||||
entities=None,
|
||||
caption=None,
|
||||
caption_entities=None,
|
||||
):
|
||||
reply_to_message = None
|
||||
if reply_to_bot:
|
||||
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
|
||||
@@ -37,6 +48,7 @@ def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=N
|
||||
caption=caption,
|
||||
entities=entities or [],
|
||||
caption_entities=caption_entities or [],
|
||||
message_thread_id=thread_id,
|
||||
chat=SimpleNamespace(id=chat_id, type="group"),
|
||||
reply_to_message=reply_to_message,
|
||||
)
|
||||
@@ -69,6 +81,14 @@ def test_free_response_chats_bypass_mention_requirement():
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
|
||||
|
||||
|
||||
def test_ignored_threads_drop_group_messages_before_other_gates():
|
||||
adapter = _make_adapter(require_mention=False, free_response_chats=["-200"], ignored_threads=[31, "42"])
|
||||
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=31)) is False
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=42)) is False
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=99)) is True
|
||||
|
||||
|
||||
def test_regex_mention_patterns_allow_custom_wake_words():
|
||||
adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])
|
||||
|
||||
@@ -108,3 +128,23 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
||||
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
|
||||
assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
|
||||
assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
|
||||
|
||||
|
||||
def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"telegram:\n"
|
||||
" ignored_threads:\n"
|
||||
" - 31\n"
|
||||
" - \"42\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_IGNORED_THREADS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
assert __import__("os").environ["TELEGRAM_IGNORED_THREADS"] == "31,42"
|
||||
|
||||
@@ -130,13 +130,17 @@ class TestMatrixSyncAuthRetry:
|
||||
|
||||
sync_count = 0
|
||||
|
||||
async def fake_sync(timeout=30000):
|
||||
async def fake_sync(timeout=30000, since=None):
|
||||
nonlocal sync_count
|
||||
sync_count += 1
|
||||
return SyncError("M_UNKNOWN_TOKEN: Invalid access token")
|
||||
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.sync = fake_sync
|
||||
adapter._client.sync_store = MagicMock()
|
||||
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
|
||||
adapter._pending_megolm = []
|
||||
adapter._joined_rooms = set()
|
||||
|
||||
async def run():
|
||||
import sys
|
||||
@@ -157,13 +161,17 @@ class TestMatrixSyncAuthRetry:
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_sync(timeout=30000):
|
||||
async def fake_sync(timeout=30000, since=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RuntimeError("HTTP 401 Unauthorized")
|
||||
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.sync = fake_sync
|
||||
adapter._client.sync_store = MagicMock()
|
||||
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
|
||||
adapter._pending_megolm = []
|
||||
adapter._joined_rooms = set()
|
||||
|
||||
async def run():
|
||||
import types
|
||||
@@ -188,7 +196,7 @@ class TestMatrixSyncAuthRetry:
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_sync(timeout=30000):
|
||||
async def fake_sync(timeout=30000, since=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count >= 2:
|
||||
@@ -198,6 +206,10 @@ class TestMatrixSyncAuthRetry:
|
||||
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.sync = fake_sync
|
||||
adapter._client.sync_store = MagicMock()
|
||||
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
|
||||
adapter._pending_megolm = []
|
||||
adapter._joined_rooms = set()
|
||||
|
||||
async def run():
|
||||
import types
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestProviderRegistry:
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "AI Gateway", "api_key"),
|
||||
("ai-gateway", "Vercel AI Gateway", "api_key"),
|
||||
("kilocode", "Kilo Code", "api_key"),
|
||||
])
|
||||
def test_provider_registered(self, provider_id, name, auth_type):
|
||||
|
||||
@@ -238,6 +238,10 @@ def test_auth_remove_reindexes_priorities(tmp_path, monkeypatch):
|
||||
|
||||
def test_auth_remove_accepts_label_target(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -281,6 +285,10 @@ def test_auth_remove_accepts_label_target(tmp_path, monkeypatch):
|
||||
|
||||
def test_auth_remove_prefers_exact_numeric_label_over_index(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
|
||||
@@ -18,6 +18,13 @@ def _write_auth_store(tmp_path, payload: dict) -> None:
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_anthropic_env(monkeypatch):
|
||||
"""Strip Anthropic env vars so CI secrets don't leak into tests."""
|
||||
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -436,9 +436,23 @@ class TestValidateApiNotFound:
|
||||
|
||||
def test_warning_includes_suggestions(self):
|
||||
result = _validate("anthropic/claude-opus-4.5")
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "Similar models" in result["message"]
|
||||
assert result["accepted"] is True
|
||||
# Close match auto-corrects; less similar inputs show suggestions
|
||||
assert "Auto-corrected" in result["message"] or "Similar models" in result["message"]
|
||||
|
||||
def test_auto_correction_returns_corrected_model(self):
|
||||
"""When a very close match exists, validate returns corrected_model."""
|
||||
result = _validate("anthropic/claude-opus-4.5")
|
||||
assert result["accepted"] is True
|
||||
assert result.get("corrected_model") == "anthropic/claude-opus-4.6"
|
||||
assert result["recognized"] is True
|
||||
|
||||
def test_dissimilar_model_shows_suggestions_not_autocorrect(self):
|
||||
"""Models too different for auto-correction still get suggestions."""
|
||||
result = _validate("anthropic/claude-nonexistent")
|
||||
assert result["accepted"] is True
|
||||
assert result.get("corrected_model") is None
|
||||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
# -- validate — API unreachable — reject with guidance ----------------
|
||||
@@ -487,3 +501,40 @@ class TestValidateApiFallback:
|
||||
assert result["persist"] is False
|
||||
assert "http://localhost:8000/v1/models" in result["message"]
|
||||
assert "http://localhost:8000/v1" in result["message"]
|
||||
|
||||
|
||||
# -- validate — Codex auto-correction ------------------------------------------
|
||||
|
||||
class TestValidateCodexAutoCorrection:
|
||||
"""Auto-correction for typos on openai-codex provider."""
|
||||
|
||||
def test_missing_dash_auto_corrects(self):
|
||||
"""gpt5.3-codex (missing dash) auto-corrects to gpt-5.3-codex."""
|
||||
codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex",
|
||||
"gpt-5.2-codex", "gpt-5.1-codex-max"]
|
||||
with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
|
||||
result = validate_requested_model("gpt5.3-codex", "openai-codex")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
assert result["corrected_model"] == "gpt-5.3-codex"
|
||||
assert "Auto-corrected" in result["message"]
|
||||
|
||||
def test_exact_match_no_correction(self):
|
||||
"""Exact model name does not trigger auto-correction."""
|
||||
codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"]
|
||||
with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
|
||||
result = validate_requested_model("gpt-5.3-codex", "openai-codex")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
assert result.get("corrected_model") is None
|
||||
assert result["message"] is None
|
||||
|
||||
def test_very_different_name_falls_to_suggestions(self):
|
||||
"""Names too different for auto-correction get the suggestion list."""
|
||||
codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"]
|
||||
with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
|
||||
result = validate_requested_model("totally-wrong", "openai-codex")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is False
|
||||
assert result.get("corrected_model") is None
|
||||
assert "not found" in result["message"]
|
||||
|
||||
@@ -16,8 +16,10 @@ def test_opencode_go_appears_when_api_key_set():
|
||||
|
||||
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"
|
||||
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
|
||||
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
|
||||
# the API is unavailable, e.g. in CI).
|
||||
assert opencode_go["source"] in ("built-in", "hermes")
|
||||
|
||||
|
||||
def test_opencode_go_not_appears_when_no_creds():
|
||||
|
||||
@@ -18,6 +18,7 @@ from hermes_cli.plugins import (
|
||||
PluginManager,
|
||||
PluginManifest,
|
||||
get_plugin_manager,
|
||||
get_pre_tool_call_block_message,
|
||||
discover_plugins,
|
||||
invoke_hook,
|
||||
)
|
||||
@@ -310,6 +311,50 @@ class TestPluginHooks:
|
||||
assert any("on_banana" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
class TestPreToolCallBlocking:
|
||||
"""Tests for the pre_tool_call block directive helper."""
|
||||
|
||||
def test_block_message_returned_for_valid_directive(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [{"action": "block", "message": "blocked by plugin"}],
|
||||
)
|
||||
assert get_pre_tool_call_block_message("todo", {}, task_id="t1") == "blocked by plugin"
|
||||
|
||||
def test_invalid_returns_are_ignored(self, monkeypatch):
|
||||
"""Various malformed hook returns should not trigger a block."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [
|
||||
"block", # not a dict
|
||||
123, # not a dict
|
||||
{"action": "block"}, # missing message
|
||||
{"action": "deny", "message": "nope"}, # wrong action
|
||||
{"message": "missing action"}, # no action key
|
||||
{"action": "block", "message": 123}, # message not str
|
||||
],
|
||||
)
|
||||
assert get_pre_tool_call_block_message("todo", {}, task_id="t1") is None
|
||||
|
||||
def test_none_when_no_hooks(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
assert get_pre_tool_call_block_message("web_search", {"q": "test"}) is None
|
||||
|
||||
def test_first_valid_block_wins(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [
|
||||
{"action": "allow"},
|
||||
{"action": "block", "message": "first blocker"},
|
||||
{"action": "block", "message": "second blocker"},
|
||||
],
|
||||
)
|
||||
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
|
||||
|
||||
|
||||
# ── TestPluginContext ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,28 @@ class TestBuiltinSkins:
|
||||
assert skin.name == "slate"
|
||||
assert skin.get_color("banner_title") == "#7eb8f6"
|
||||
|
||||
def test_daylight_skin_loads(self):
|
||||
from hermes_cli.skin_engine import load_skin
|
||||
|
||||
skin = load_skin("daylight")
|
||||
assert skin.name == "daylight"
|
||||
assert skin.tool_prefix == "│"
|
||||
assert skin.get_color("banner_title") == "#0F172A"
|
||||
assert skin.get_color("status_bar_bg") == "#E5EDF8"
|
||||
assert skin.get_color("voice_status_bg") == "#E5EDF8"
|
||||
assert skin.get_color("completion_menu_bg") == "#F8FAFC"
|
||||
assert skin.get_color("completion_menu_current_bg") == "#DBEAFE"
|
||||
assert skin.get_color("completion_menu_meta_bg") == "#EEF2FF"
|
||||
assert skin.get_color("completion_menu_meta_current_bg") == "#BFDBFE"
|
||||
|
||||
def test_warm_lightmode_skin_loads(self):
|
||||
from hermes_cli.skin_engine import load_skin
|
||||
|
||||
skin = load_skin("warm-lightmode")
|
||||
assert skin.name == "warm-lightmode"
|
||||
assert skin.get_color("banner_text") == "#2C1810"
|
||||
assert skin.get_color("completion_menu_bg") == "#F5EFE0"
|
||||
|
||||
def test_unknown_skin_falls_back_to_default(self):
|
||||
from hermes_cli.skin_engine import load_skin
|
||||
skin = load_skin("nonexistent_skin_xyz")
|
||||
@@ -114,6 +136,8 @@ class TestSkinManagement:
|
||||
assert "ares" in names
|
||||
assert "mono" in names
|
||||
assert "slate" in names
|
||||
assert "daylight" in names
|
||||
assert "warm-lightmode" in names
|
||||
for s in skins:
|
||||
assert "source" in s
|
||||
assert s["source"] == "builtin"
|
||||
@@ -242,6 +266,15 @@ class TestCliBrandingHelpers:
|
||||
"completion-menu.completion.current",
|
||||
"completion-menu.meta.completion",
|
||||
"completion-menu.meta.completion.current",
|
||||
"status-bar",
|
||||
"status-bar-strong",
|
||||
"status-bar-dim",
|
||||
"status-bar-good",
|
||||
"status-bar-warn",
|
||||
"status-bar-bad",
|
||||
"status-bar-critical",
|
||||
"voice-status",
|
||||
"voice-status-recording",
|
||||
"clarify-border",
|
||||
"clarify-title",
|
||||
"clarify-question",
|
||||
@@ -277,3 +310,9 @@ class TestCliBrandingHelpers:
|
||||
assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold"
|
||||
assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold"
|
||||
assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||
|
||||
set_active_skin("daylight")
|
||||
skin = get_active_skin()
|
||||
overrides = get_prompt_toolkit_style_overrides()
|
||||
assert overrides["status-bar"] == f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('banner_text')}"
|
||||
assert overrides["voice-status"] == f"bg:{skin.get_color('voice_status_bg')} {skin.get_color('ui_label')}"
|
||||
|
||||
@@ -673,3 +673,282 @@ class TestNewEndpoints:
|
||||
resp = self.client.get("/api/auth/session-token")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["token"] == _SESSION_TOKEN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model context length: normalize/denormalize + /api/model/info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelContextLength:
|
||||
"""Tests for model_context_length in normalize/denormalize and /api/model/info."""
|
||||
|
||||
def test_normalize_extracts_context_length_from_dict(self):
|
||||
"""normalize should surface context_length from model dict."""
|
||||
from hermes_cli.web_server import _normalize_config_for_web
|
||||
|
||||
cfg = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"provider": "openrouter",
|
||||
"context_length": 200000,
|
||||
}
|
||||
}
|
||||
result = _normalize_config_for_web(cfg)
|
||||
assert result["model"] == "anthropic/claude-opus-4.6"
|
||||
assert result["model_context_length"] == 200000
|
||||
|
||||
def test_normalize_bare_string_model_yields_zero(self):
|
||||
"""normalize should set model_context_length=0 for bare string model."""
|
||||
from hermes_cli.web_server import _normalize_config_for_web
|
||||
|
||||
result = _normalize_config_for_web({"model": "anthropic/claude-sonnet-4"})
|
||||
assert result["model"] == "anthropic/claude-sonnet-4"
|
||||
assert result["model_context_length"] == 0
|
||||
|
||||
def test_normalize_dict_without_context_length_yields_zero(self):
|
||||
"""normalize should default to 0 when model dict has no context_length."""
|
||||
from hermes_cli.web_server import _normalize_config_for_web
|
||||
|
||||
cfg = {"model": {"default": "test/model", "provider": "openrouter"}}
|
||||
result = _normalize_config_for_web(cfg)
|
||||
assert result["model_context_length"] == 0
|
||||
|
||||
def test_normalize_non_int_context_length_yields_zero(self):
|
||||
"""normalize should coerce non-int context_length to 0."""
|
||||
from hermes_cli.web_server import _normalize_config_for_web
|
||||
|
||||
cfg = {"model": {"default": "test/model", "context_length": "invalid"}}
|
||||
result = _normalize_config_for_web(cfg)
|
||||
assert result["model_context_length"] == 0
|
||||
|
||||
def test_denormalize_writes_context_length_into_model_dict(self):
|
||||
"""denormalize should write model_context_length back into model dict."""
|
||||
from hermes_cli.web_server import _denormalize_config_from_web
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
# Set up disk config with model as a dict
|
||||
save_config({
|
||||
"model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}
|
||||
})
|
||||
|
||||
result = _denormalize_config_from_web({
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
"model_context_length": 100000,
|
||||
})
|
||||
assert isinstance(result["model"], dict)
|
||||
assert result["model"]["context_length"] == 100000
|
||||
assert "model_context_length" not in result # virtual field removed
|
||||
|
||||
def test_denormalize_zero_removes_context_length(self):
|
||||
"""denormalize with model_context_length=0 should remove context_length key."""
|
||||
from hermes_cli.web_server import _denormalize_config_from_web
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
save_config({
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"provider": "openrouter",
|
||||
"context_length": 50000,
|
||||
}
|
||||
})
|
||||
|
||||
result = _denormalize_config_from_web({
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
"model_context_length": 0,
|
||||
})
|
||||
assert isinstance(result["model"], dict)
|
||||
assert "context_length" not in result["model"]
|
||||
|
||||
def test_denormalize_upgrades_bare_string_to_dict(self):
|
||||
"""denormalize should upgrade bare string model to dict when context_length set."""
|
||||
from hermes_cli.web_server import _denormalize_config_from_web
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
# Disk has model as bare string
|
||||
save_config({"model": "anthropic/claude-sonnet-4"})
|
||||
|
||||
result = _denormalize_config_from_web({
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"model_context_length": 65000,
|
||||
})
|
||||
assert isinstance(result["model"], dict)
|
||||
assert result["model"]["default"] == "anthropic/claude-sonnet-4"
|
||||
assert result["model"]["context_length"] == 65000
|
||||
|
||||
def test_denormalize_bare_string_stays_string_when_zero(self):
|
||||
"""denormalize should keep bare string model as string when context_length=0."""
|
||||
from hermes_cli.web_server import _denormalize_config_from_web
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
save_config({"model": "anthropic/claude-sonnet-4"})
|
||||
|
||||
result = _denormalize_config_from_web({
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"model_context_length": 0,
|
||||
})
|
||||
assert result["model"] == "anthropic/claude-sonnet-4"
|
||||
|
||||
def test_denormalize_coerces_string_context_length(self):
|
||||
"""denormalize should handle string model_context_length from frontend."""
|
||||
from hermes_cli.web_server import _denormalize_config_from_web
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
save_config({
|
||||
"model": {"default": "test/model", "provider": "openrouter"}
|
||||
})
|
||||
|
||||
result = _denormalize_config_from_web({
|
||||
"model": "test/model",
|
||||
"model_context_length": "32000",
|
||||
})
|
||||
assert isinstance(result["model"], dict)
|
||||
assert result["model"]["context_length"] == 32000
|
||||
|
||||
|
||||
class TestModelContextLengthSchema:
|
||||
"""Tests for model_context_length placement in CONFIG_SCHEMA."""
|
||||
|
||||
def test_schema_has_model_context_length(self):
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
assert "model_context_length" in CONFIG_SCHEMA
|
||||
|
||||
def test_schema_model_context_length_after_model(self):
|
||||
"""model_context_length should appear immediately after model in schema."""
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
keys = list(CONFIG_SCHEMA.keys())
|
||||
model_idx = keys.index("model")
|
||||
assert keys[model_idx + 1] == "model_context_length"
|
||||
|
||||
def test_schema_model_context_length_is_number(self):
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
entry = CONFIG_SCHEMA["model_context_length"]
|
||||
assert entry["type"] == "number"
|
||||
assert "category" in entry
|
||||
|
||||
|
||||
class TestModelInfoEndpoint:
|
||||
"""Tests for GET /api/model/info endpoint."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_model_info_returns_200(self):
|
||||
resp = self.client.get("/api/model/info")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "model" in data
|
||||
assert "provider" in data
|
||||
assert "auto_context_length" in data
|
||||
assert "config_context_length" in data
|
||||
assert "effective_context_length" in data
|
||||
assert "capabilities" in data
|
||||
|
||||
def test_model_info_with_dict_config(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"provider": "openrouter",
|
||||
"context_length": 100000,
|
||||
}
|
||||
})
|
||||
|
||||
with patch("agent.model_metadata.get_model_context_length", return_value=200000):
|
||||
resp = self.client.get("/api/model/info")
|
||||
|
||||
data = resp.json()
|
||||
assert data["model"] == "anthropic/claude-opus-4.6"
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["auto_context_length"] == 200000
|
||||
assert data["config_context_length"] == 100000
|
||||
assert data["effective_context_length"] == 100000 # override wins
|
||||
|
||||
def test_model_info_auto_detect_when_no_override(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {
|
||||
"model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}
|
||||
})
|
||||
|
||||
with patch("agent.model_metadata.get_model_context_length", return_value=200000):
|
||||
resp = self.client.get("/api/model/info")
|
||||
|
||||
data = resp.json()
|
||||
assert data["auto_context_length"] == 200000
|
||||
assert data["config_context_length"] == 0
|
||||
assert data["effective_context_length"] == 200000 # auto wins
|
||||
|
||||
def test_model_info_empty_model(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {"model": ""})
|
||||
|
||||
resp = self.client.get("/api/model/info")
|
||||
data = resp.json()
|
||||
assert data["model"] == ""
|
||||
assert data["effective_context_length"] == 0
|
||||
|
||||
def test_model_info_bare_string_model(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
})
|
||||
|
||||
with patch("agent.model_metadata.get_model_context_length", return_value=200000):
|
||||
resp = self.client.get("/api/model/info")
|
||||
|
||||
data = resp.json()
|
||||
assert data["model"] == "anthropic/claude-sonnet-4"
|
||||
assert data["provider"] == ""
|
||||
assert data["config_context_length"] == 0
|
||||
assert data["effective_context_length"] == 200000
|
||||
|
||||
def test_model_info_capabilities(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {
|
||||
"model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}
|
||||
})
|
||||
|
||||
mock_caps = MagicMock()
|
||||
mock_caps.supports_tools = True
|
||||
mock_caps.supports_vision = True
|
||||
mock_caps.supports_reasoning = True
|
||||
mock_caps.context_window = 200000
|
||||
mock_caps.max_output_tokens = 32000
|
||||
mock_caps.model_family = "claude-opus"
|
||||
|
||||
with patch("agent.model_metadata.get_model_context_length", return_value=200000), \
|
||||
patch("agent.models_dev.get_model_capabilities", return_value=mock_caps):
|
||||
resp = self.client.get("/api/model/info")
|
||||
|
||||
caps = resp.json()["capabilities"]
|
||||
assert caps["supports_tools"] is True
|
||||
assert caps["supports_vision"] is True
|
||||
assert caps["supports_reasoning"] is True
|
||||
assert caps["max_output_tokens"] == 32000
|
||||
assert caps["model_family"] == "claude-opus"
|
||||
|
||||
def test_model_info_graceful_on_metadata_error(self, monkeypatch):
|
||||
"""Endpoint should return zeros on import/resolution errors, not 500."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", lambda: {
|
||||
"model": "some/obscure-model"
|
||||
})
|
||||
|
||||
with patch("agent.model_metadata.get_model_context_length", side_effect=Exception("boom")):
|
||||
resp = self.client.get("/api/model/info")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["auto_context_length"] == 0
|
||||
|
||||
@@ -1442,7 +1442,7 @@ class TestConcurrentToolExecution:
|
||||
tool_call_id=None,
|
||||
session_id=agent.session_id,
|
||||
enabled_tools=list(agent.valid_tool_names),
|
||||
|
||||
skip_pre_tool_call_hook=True,
|
||||
)
|
||||
assert result == "result"
|
||||
|
||||
@@ -1489,6 +1489,73 @@ class TestConcurrentToolExecution:
|
||||
mock_todo.assert_called_once()
|
||||
assert "ok" in result
|
||||
|
||||
def test_invoke_tool_blocked_returns_error_and_skips_execution(self, agent, monkeypatch):
|
||||
"""_invoke_tool should return error JSON when a plugin blocks the tool."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked by test policy",
|
||||
)
|
||||
with patch("tools.todo_tool.todo_tool", side_effect=AssertionError("should not run")) as mock_todo:
|
||||
result = agent._invoke_tool("todo", {"todos": []}, "task-1")
|
||||
|
||||
assert json.loads(result) == {"error": "Blocked by test policy"}
|
||||
mock_todo.assert_not_called()
|
||||
|
||||
def test_invoke_tool_blocked_skips_handle_function_call(self, agent, monkeypatch):
|
||||
"""Blocked registry tools should not reach handle_function_call."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked",
|
||||
)
|
||||
with patch("run_agent.handle_function_call", side_effect=AssertionError("should not run")):
|
||||
result = agent._invoke_tool("web_search", {"q": "test"}, "task-1")
|
||||
|
||||
assert json.loads(result) == {"error": "Blocked"}
|
||||
|
||||
def test_sequential_blocked_tool_skips_checkpoints_and_callbacks(self, agent, monkeypatch):
|
||||
"""Sequential path: blocked tool should not trigger checkpoints or start callbacks."""
|
||||
tool_call = _mock_tool_call(name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello"}',
|
||||
call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
|
||||
messages = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked by policy",
|
||||
)
|
||||
agent._checkpoint_mgr.enabled = True
|
||||
agent._checkpoint_mgr.ensure_checkpoint = MagicMock(
|
||||
side_effect=AssertionError("checkpoint should not run")
|
||||
)
|
||||
|
||||
starts = []
|
||||
agent.tool_start_callback = lambda *a: starts.append(a)
|
||||
|
||||
with patch("run_agent.handle_function_call", side_effect=AssertionError("should not run")):
|
||||
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
|
||||
|
||||
agent._checkpoint_mgr.ensure_checkpoint.assert_not_called()
|
||||
assert starts == []
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
assert json.loads(messages[0]["content"]) == {"error": "Blocked by policy"}
|
||||
|
||||
def test_blocked_memory_tool_does_not_reset_counter(self, agent, monkeypatch):
|
||||
"""Blocked memory tool should not reset the nudge counter."""
|
||||
agent._turns_since_memory = 5
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked",
|
||||
)
|
||||
with patch("tools.memory_tool.memory_tool", side_effect=AssertionError("should not run")):
|
||||
result = agent._invoke_tool(
|
||||
"memory", {"action": "add", "target": "memory", "content": "x"}, "task-1",
|
||||
)
|
||||
|
||||
assert json.loads(result) == {"error": "Blocked"}
|
||||
assert agent._turns_since_memory == 5
|
||||
|
||||
|
||||
class TestPathsOverlap:
|
||||
"""Unit tests for the _paths_overlap helper."""
|
||||
|
||||
@@ -287,6 +287,69 @@ def test_build_api_kwargs_codex(monkeypatch):
|
||||
assert "extra_body" not in kwargs
|
||||
|
||||
|
||||
def test_build_api_kwargs_codex_clamps_minimal_effort(monkeypatch):
|
||||
"""'minimal' reasoning effort is clamped to 'low' on the Responses API.
|
||||
|
||||
GPT-5.4 supports none/low/medium/high/xhigh but NOT 'minimal'.
|
||||
Users may configure 'minimal' via OpenRouter conventions, so the Codex
|
||||
Responses path must clamp it to the nearest supported level.
|
||||
"""
|
||||
_patch_agent_bootstrap(monkeypatch)
|
||||
|
||||
agent = run_agent.AIAgent(
|
||||
model="gpt-5-codex",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key="codex-token",
|
||||
quiet_mode=True,
|
||||
max_iterations=4,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
reasoning_config={"enabled": True, "effort": "minimal"},
|
||||
)
|
||||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
|
||||
kwargs = agent._build_api_kwargs(
|
||||
[
|
||||
{"role": "system", "content": "You are Hermes."},
|
||||
{"role": "user", "content": "Ping"},
|
||||
]
|
||||
)
|
||||
|
||||
assert kwargs["reasoning"]["effort"] == "low"
|
||||
|
||||
|
||||
def test_build_api_kwargs_codex_preserves_supported_efforts(monkeypatch):
|
||||
"""Effort levels natively supported by the Responses API pass through unchanged."""
|
||||
_patch_agent_bootstrap(monkeypatch)
|
||||
|
||||
for effort in ("low", "medium", "high", "xhigh"):
|
||||
agent = run_agent.AIAgent(
|
||||
model="gpt-5-codex",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key="codex-token",
|
||||
quiet_mode=True,
|
||||
max_iterations=4,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
)
|
||||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
|
||||
kwargs = agent._build_api_kwargs(
|
||||
[
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
)
|
||||
assert kwargs["reasoning"]["effort"] == effort, f"{effort} should pass through unchanged"
|
||||
|
||||
|
||||
def test_build_api_kwargs_copilot_responses_omits_openai_only_fields(monkeypatch):
|
||||
agent = _build_copilot_agent(monkeypatch)
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
|
||||
@@ -91,6 +91,91 @@ class TestAgentLoopTools:
|
||||
assert "terminal" not in _AGENT_LOOP_TOOLS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pre-tool-call blocking via plugin hooks
|
||||
# =========================================================================
|
||||
|
||||
class TestPreToolCallBlocking:
|
||||
"""Verify that pre_tool_call hooks can block tool execution."""
|
||||
|
||||
def test_blocked_tool_returns_error_and_skips_dispatch(self, monkeypatch):
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
if hook_name == "pre_tool_call":
|
||||
return [{"action": "block", "message": "Blocked by policy"}]
|
||||
return []
|
||||
|
||||
dispatch_called = False
|
||||
_orig_dispatch = None
|
||||
|
||||
def fake_dispatch(*args, **kwargs):
|
||||
nonlocal dispatch_called
|
||||
dispatch_called = True
|
||||
raise AssertionError("dispatch should not run when blocked")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
||||
monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch)
|
||||
|
||||
result = json.loads(handle_function_call("read_file", {"path": "test.txt"}, task_id="t1"))
|
||||
assert result == {"error": "Blocked by policy"}
|
||||
assert not dispatch_called
|
||||
|
||||
def test_blocked_tool_skips_read_loop_notification(self, monkeypatch):
|
||||
notifications = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
if hook_name == "pre_tool_call":
|
||||
return [{"action": "block", "message": "Blocked"}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
||||
monkeypatch.setattr("model_tools.registry.dispatch",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(AssertionError("should not run")))
|
||||
monkeypatch.setattr("tools.file_tools.notify_other_tool_call",
|
||||
lambda task_id: notifications.append(task_id))
|
||||
|
||||
result = json.loads(handle_function_call("web_search", {"q": "test"}, task_id="t1"))
|
||||
assert result == {"error": "Blocked"}
|
||||
assert notifications == []
|
||||
|
||||
def test_invalid_hook_returns_do_not_block(self, monkeypatch):
|
||||
"""Malformed hook returns should be ignored — tool executes normally."""
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
if hook_name == "pre_tool_call":
|
||||
return [
|
||||
"block",
|
||||
{"action": "block"}, # missing message
|
||||
{"action": "deny", "message": "nope"},
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
||||
monkeypatch.setattr("model_tools.registry.dispatch",
|
||||
lambda *a, **kw: json.dumps({"ok": True}))
|
||||
|
||||
result = json.loads(handle_function_call("read_file", {"path": "test.txt"}, task_id="t1"))
|
||||
assert result == {"ok": True}
|
||||
|
||||
def test_skip_flag_prevents_double_block_check(self, monkeypatch):
|
||||
"""When skip_pre_tool_call_hook=True, blocking is not checked (caller did it)."""
|
||||
hook_calls = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
hook_calls.append(hook_name)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
|
||||
monkeypatch.setattr("model_tools.registry.dispatch",
|
||||
lambda *a, **kw: json.dumps({"ok": True}))
|
||||
|
||||
handle_function_call("web_search", {"q": "test"}, task_id="t1",
|
||||
skip_pre_tool_call_hook=True)
|
||||
|
||||
# Hook still fires for observer notification, but get_pre_tool_call_block_message
|
||||
# is not called — invoke_hook fires directly in the skip=True branch.
|
||||
assert "pre_tool_call" in hook_calls
|
||||
assert "post_tool_call" in hook_calls
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Legacy toolset map
|
||||
# =========================================================================
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for toolsets.py — toolset resolution, validation, and composition."""
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.registry import ToolRegistry
|
||||
from toolsets import (
|
||||
TOOLSETS,
|
||||
get_toolset,
|
||||
@@ -15,6 +14,18 @@ from toolsets import (
|
||||
)
|
||||
|
||||
|
||||
def _dummy_handler(args, **kwargs):
|
||||
return "{}"
|
||||
|
||||
|
||||
def _make_schema(name: str, description: str = "test tool"):
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
|
||||
|
||||
class TestGetToolset:
|
||||
def test_known_toolset(self):
|
||||
ts = get_toolset("web")
|
||||
@@ -52,6 +63,25 @@ class TestResolveToolset:
|
||||
def test_unknown_toolset_returns_empty(self):
|
||||
assert resolve_toolset("nonexistent") == []
|
||||
|
||||
def test_plugin_toolset_uses_registry_snapshot(self, monkeypatch):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="plugin_b",
|
||||
toolset="plugin_example",
|
||||
schema=_make_schema("plugin_b", "B"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
reg.register(
|
||||
name="plugin_a",
|
||||
toolset="plugin_example",
|
||||
schema=_make_schema("plugin_a", "A"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tools.registry.registry", reg)
|
||||
|
||||
assert resolve_toolset("plugin_example") == ["plugin_a", "plugin_b"]
|
||||
|
||||
def test_all_alias(self):
|
||||
tools = resolve_toolset("all")
|
||||
assert len(tools) > 10 # Should resolve all tools from all toolsets
|
||||
@@ -141,3 +171,20 @@ class TestToolsetConsistency:
|
||||
# All platform toolsets should be identical
|
||||
for ts in tool_sets[1:]:
|
||||
assert ts == tool_sets[0]
|
||||
|
||||
|
||||
class TestPluginToolsets:
|
||||
def test_get_all_toolsets_includes_plugin_toolset(self, monkeypatch):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="plugin_tool",
|
||||
toolset="plugin_bundle",
|
||||
schema=_make_schema("plugin_tool", "Plugin tool"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tools.registry.registry", reg)
|
||||
|
||||
all_toolsets = get_all_toolsets()
|
||||
assert "plugin_bundle" in all_toolsets
|
||||
assert all_toolsets["plugin_bundle"]["tools"] == ["plugin_tool"]
|
||||
|
||||
@@ -380,7 +380,7 @@ class TestStubSchemaDrift(unittest.TestCase):
|
||||
# Parameters that are internal (injected by the handler, not user-facing)
|
||||
_INTERNAL_PARAMS = {"task_id", "user_task"}
|
||||
# Parameters intentionally blocked in the sandbox
|
||||
_BLOCKED_TERMINAL_PARAMS = {"background", "pty", "notify_on_complete"}
|
||||
_BLOCKED_TERMINAL_PARAMS = {"background", "pty", "notify_on_complete", "watch_patterns"}
|
||||
|
||||
def test_stubs_cover_all_schema_params(self):
|
||||
"""Every user-facing parameter in the real schema must appear in the
|
||||
|
||||
@@ -29,8 +29,11 @@ class TestInterruptModule:
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Set from one thread targeting another thread's ident."""
|
||||
from tools.interrupt import set_interrupt, is_interrupted
|
||||
from tools.interrupt import set_interrupt, is_interrupted, _interrupted_threads, _lock
|
||||
set_interrupt(False)
|
||||
# Clear any stale thread idents left by prior tests in this worker.
|
||||
with _lock:
|
||||
_interrupted_threads.clear()
|
||||
|
||||
seen = {"value": False}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ All tests use mocks -- no real MCP servers or subprocesses are started.
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -255,6 +257,77 @@ class TestToolHandler:
|
||||
finally:
|
||||
_servers.pop("test_srv", None)
|
||||
|
||||
def test_interrupted_call_returns_interrupted_error(self):
|
||||
from tools.mcp_tool import _make_tool_handler, _servers
|
||||
|
||||
mock_session = MagicMock()
|
||||
server = _make_mock_server("test_srv", session=mock_session)
|
||||
_servers["test_srv"] = server
|
||||
|
||||
try:
|
||||
handler = _make_tool_handler("test_srv", "greet", 120)
|
||||
def _interrupting_run(coro, timeout=30):
|
||||
coro.close()
|
||||
raise InterruptedError("User sent a new message")
|
||||
with patch(
|
||||
"tools.mcp_tool._run_on_mcp_loop",
|
||||
side_effect=_interrupting_run,
|
||||
):
|
||||
result = json.loads(handler({}))
|
||||
assert result == {"error": "MCP call interrupted: user sent a new message"}
|
||||
finally:
|
||||
_servers.pop("test_srv", None)
|
||||
|
||||
|
||||
class TestRunOnMCPLoopInterrupts:
|
||||
def test_interrupt_cancels_waiting_mcp_call(self):
|
||||
import tools.mcp_tool as mcp_mod
|
||||
from tools.interrupt import set_interrupt
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
cancelled = threading.Event()
|
||||
|
||||
async def _slow_call():
|
||||
try:
|
||||
await asyncio.sleep(5)
|
||||
return "done"
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
old_loop = mcp_mod._mcp_loop
|
||||
old_thread = mcp_mod._mcp_thread
|
||||
mcp_mod._mcp_loop = loop
|
||||
mcp_mod._mcp_thread = thread
|
||||
|
||||
waiter_tid = threading.current_thread().ident
|
||||
|
||||
def _interrupt_soon():
|
||||
time.sleep(0.2)
|
||||
set_interrupt(True, waiter_tid)
|
||||
|
||||
interrupter = threading.Thread(target=_interrupt_soon, daemon=True)
|
||||
interrupter.start()
|
||||
|
||||
try:
|
||||
with pytest.raises(InterruptedError, match="User sent a new message"):
|
||||
mcp_mod._run_on_mcp_loop(_slow_call(), timeout=2)
|
||||
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline and not cancelled.is_set():
|
||||
time.sleep(0.05)
|
||||
assert cancelled.is_set()
|
||||
finally:
|
||||
set_interrupt(False, waiter_tid)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
loop.close()
|
||||
mcp_mod._mcp_loop = old_loop
|
||||
mcp_mod._mcp_thread = old_thread
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registration (discovery + register)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the central tool registry."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
from tools.registry import ToolRegistry
|
||||
|
||||
@@ -167,6 +168,32 @@ class TestToolsetAvailability:
|
||||
)
|
||||
assert reg.get_all_tool_names() == ["a_tool", "z_tool"]
|
||||
|
||||
def test_get_registered_toolset_names(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="first", toolset="zeta", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
reg.register(
|
||||
name="second", toolset="alpha", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
reg.register(
|
||||
name="third", toolset="alpha", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
assert reg.get_registered_toolset_names() == ["alpha", "zeta"]
|
||||
|
||||
def test_get_tool_names_for_toolset(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="z_tool", toolset="grouped", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
reg.register(
|
||||
name="a_tool", toolset="grouped", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
reg.register(
|
||||
name="other_tool", toolset="other", schema=_make_schema(), handler=_dummy_handler
|
||||
)
|
||||
assert reg.get_tool_names_for_toolset("grouped") == ["a_tool", "z_tool"]
|
||||
|
||||
def test_handler_exception_returns_error(self):
|
||||
reg = ToolRegistry()
|
||||
|
||||
@@ -301,6 +328,22 @@ class TestEmojiMetadata:
|
||||
assert reg.get_emoji("t") == "⚡"
|
||||
|
||||
|
||||
class TestEntryLookup:
|
||||
def test_get_entry_returns_registered_entry(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="alpha", toolset="core", schema=_make_schema("alpha"), handler=_dummy_handler
|
||||
)
|
||||
entry = reg.get_entry("alpha")
|
||||
assert entry is not None
|
||||
assert entry.name == "alpha"
|
||||
assert entry.toolset == "core"
|
||||
|
||||
def test_get_entry_returns_none_for_unknown_tool(self):
|
||||
reg = ToolRegistry()
|
||||
assert reg.get_entry("missing") is None
|
||||
|
||||
|
||||
class TestSecretCaptureResultContract:
|
||||
def test_secret_request_result_does_not_include_secret_value(self):
|
||||
result = {
|
||||
@@ -309,3 +352,141 @@ class TestSecretCaptureResultContract:
|
||||
"validated": False,
|
||||
}
|
||||
assert "secret" not in json.dumps(result).lower()
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_get_available_toolsets_uses_coherent_snapshot(self, monkeypatch):
|
||||
reg = ToolRegistry()
|
||||
reg.register(
|
||||
name="alpha",
|
||||
toolset="gated",
|
||||
schema=_make_schema("alpha"),
|
||||
handler=_dummy_handler,
|
||||
check_fn=lambda: False,
|
||||
)
|
||||
|
||||
entries, toolset_checks = reg._snapshot_state()
|
||||
|
||||
def snapshot_then_mutate():
|
||||
reg.deregister("alpha")
|
||||
return entries, toolset_checks
|
||||
|
||||
monkeypatch.setattr(reg, "_snapshot_state", snapshot_then_mutate)
|
||||
|
||||
toolsets = reg.get_available_toolsets()
|
||||
assert toolsets["gated"]["available"] is False
|
||||
assert toolsets["gated"]["tools"] == ["alpha"]
|
||||
|
||||
def test_check_tool_availability_tolerates_concurrent_register(self):
|
||||
reg = ToolRegistry()
|
||||
check_started = threading.Event()
|
||||
writer_done = threading.Event()
|
||||
errors = []
|
||||
result_holder = {}
|
||||
writer_completed_during_check = {}
|
||||
|
||||
def blocking_check():
|
||||
check_started.set()
|
||||
writer_completed_during_check["value"] = writer_done.wait(timeout=1)
|
||||
return True
|
||||
|
||||
reg.register(
|
||||
name="alpha",
|
||||
toolset="gated",
|
||||
schema=_make_schema("alpha"),
|
||||
handler=_dummy_handler,
|
||||
check_fn=blocking_check,
|
||||
)
|
||||
reg.register(
|
||||
name="beta",
|
||||
toolset="plain",
|
||||
schema=_make_schema("beta"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
|
||||
def reader():
|
||||
try:
|
||||
result_holder["value"] = reg.check_tool_availability()
|
||||
except Exception as exc: # pragma: no cover - exercised on failure only
|
||||
errors.append(exc)
|
||||
|
||||
def writer():
|
||||
assert check_started.wait(timeout=1)
|
||||
reg.register(
|
||||
name="gamma",
|
||||
toolset="new",
|
||||
schema=_make_schema("gamma"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
writer_done.set()
|
||||
|
||||
reader_thread = threading.Thread(target=reader)
|
||||
writer_thread = threading.Thread(target=writer)
|
||||
reader_thread.start()
|
||||
writer_thread.start()
|
||||
reader_thread.join(timeout=2)
|
||||
writer_thread.join(timeout=2)
|
||||
|
||||
assert not reader_thread.is_alive()
|
||||
assert not writer_thread.is_alive()
|
||||
assert writer_completed_during_check["value"] is True
|
||||
assert errors == []
|
||||
|
||||
available, unavailable = result_holder["value"]
|
||||
assert "gated" in available
|
||||
assert "plain" in available
|
||||
assert unavailable == []
|
||||
|
||||
def test_get_available_toolsets_tolerates_concurrent_deregister(self):
|
||||
reg = ToolRegistry()
|
||||
check_started = threading.Event()
|
||||
writer_done = threading.Event()
|
||||
errors = []
|
||||
result_holder = {}
|
||||
writer_completed_during_check = {}
|
||||
|
||||
def blocking_check():
|
||||
check_started.set()
|
||||
writer_completed_during_check["value"] = writer_done.wait(timeout=1)
|
||||
return True
|
||||
|
||||
reg.register(
|
||||
name="alpha",
|
||||
toolset="gated",
|
||||
schema=_make_schema("alpha"),
|
||||
handler=_dummy_handler,
|
||||
check_fn=blocking_check,
|
||||
)
|
||||
reg.register(
|
||||
name="beta",
|
||||
toolset="plain",
|
||||
schema=_make_schema("beta"),
|
||||
handler=_dummy_handler,
|
||||
)
|
||||
|
||||
def reader():
|
||||
try:
|
||||
result_holder["value"] = reg.get_available_toolsets()
|
||||
except Exception as exc: # pragma: no cover - exercised on failure only
|
||||
errors.append(exc)
|
||||
|
||||
def writer():
|
||||
assert check_started.wait(timeout=1)
|
||||
reg.deregister("beta")
|
||||
writer_done.set()
|
||||
|
||||
reader_thread = threading.Thread(target=reader)
|
||||
writer_thread = threading.Thread(target=writer)
|
||||
reader_thread.start()
|
||||
writer_thread.start()
|
||||
reader_thread.join(timeout=2)
|
||||
writer_thread.join(timeout=2)
|
||||
|
||||
assert not reader_thread.is_alive()
|
||||
assert not writer_thread.is_alive()
|
||||
assert writer_completed_during_check["value"] is True
|
||||
assert errors == []
|
||||
|
||||
toolsets = result_holder["value"]
|
||||
assert "gated" in toolsets
|
||||
assert toolsets["gated"]["available"] is True
|
||||
|
||||
Reference in New Issue
Block a user