mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
"""Tests for the gateway interactive choice picker (/reasoning, /fast).
|
|
|
|
The picker mirrors the /model picker architecture: the gateway gates on the
|
|
adapter *type* exposing ``send_choice_picker``, sends a flat choice list, and
|
|
falls back to the text status card when the platform has no picker or the
|
|
send fails. Selection flows through the same application path as the typed
|
|
command, so picker and typed arguments can never diverge.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
import gateway.run as gateway_run
|
|
from gateway.config import Platform
|
|
from gateway.platforms.base import MessageEvent, SendResult
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
def _make_event(text="/reasoning", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
|
|
source = SessionSource(
|
|
platform=platform,
|
|
user_id=user_id,
|
|
chat_id=chat_id,
|
|
user_name="testuser",
|
|
)
|
|
return MessageEvent(text=text, source=source)
|
|
|
|
|
|
class _PickerAdapter:
|
|
"""Adapter whose *type* exposes ``send_choice_picker`` (the gate the
|
|
handler checks via ``getattr(type(adapter), 'send_choice_picker', None)``)."""
|
|
|
|
def __init__(self, success=True):
|
|
self.calls = []
|
|
self._success = success
|
|
|
|
async def send_choice_picker(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return SendResult(success=self._success, message_id="m1")
|
|
|
|
|
|
class _NoPickerAdapter:
|
|
"""Adapter with no choice-picker capability."""
|
|
|
|
|
|
def _make_runner(adapter=None):
|
|
runner = object.__new__(gateway_run.GatewayRunner)
|
|
runner.adapters = {}
|
|
runner._ephemeral_system_prompt = ""
|
|
runner._prefill_messages = []
|
|
runner._reasoning_config = None
|
|
runner._session_reasoning_overrides = {}
|
|
runner._show_reasoning = False
|
|
runner._provider_routing = {}
|
|
runner._fallback_model = None
|
|
runner._running_agents = {}
|
|
runner.hooks = MagicMock()
|
|
runner.hooks.emit = AsyncMock()
|
|
runner.hooks.loaded_hooks = []
|
|
runner._session_db = None
|
|
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
|
runner._adapter_for_source = lambda source: adapter
|
|
runner._thread_metadata_for_source = lambda source, anchor=None: {}
|
|
runner._reply_anchor_for_event = lambda event: None
|
|
return runner
|
|
|
|
|
|
class TestReasoningChoicePicker:
|
|
@pytest.mark.asyncio
|
|
async def test_bare_reasoning_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
adapter = _PickerAdapter()
|
|
runner = _make_runner(adapter)
|
|
|
|
result = await runner._handle_reasoning_command(_make_event("/reasoning"))
|
|
|
|
assert result is None # picker sent — adapter owns the response
|
|
assert len(adapter.calls) == 1
|
|
call = adapter.calls[0]
|
|
values = [c["value"] for c in call["choices"]]
|
|
# Full canonical ladder + none + subcommands, in order
|
|
from hermes_constants import VALID_REASONING_EFFORTS
|
|
assert values[0] == "none"
|
|
assert values[1:1 + len(VALID_REASONING_EFFORTS)] == list(VALID_REASONING_EFFORTS)
|
|
assert values[-3:] == ["reset", "show", "hide"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_picker_selection_applies_same_as_typed(self, tmp_path, monkeypatch):
|
|
"""The picker's on_choice_selected must produce the identical state
|
|
change as typing the argument (single application path)."""
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
adapter = _PickerAdapter()
|
|
runner = _make_runner(adapter)
|
|
event = _make_event("/reasoning")
|
|
session_key = runner._session_key_for_source(event.source)
|
|
|
|
await runner._handle_reasoning_command(event)
|
|
on_choice = adapter.calls[0]["on_choice_selected"]
|
|
|
|
reply = await on_choice(event.source.chat_id, "ultra")
|
|
|
|
assert "ultra" in reply
|
|
override = runner._session_reasoning_overrides.get(session_key)
|
|
assert override == {"enabled": True, "effort": "ultra"}
|
|
|
|
|
|
class TestFastChoicePicker:
|
|
def _patch_fast_support(self, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
|
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg: "gpt-5.6")
|
|
import hermes_cli.models as models_mod
|
|
monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda m: True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bare_fast_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
|
|
self._patch_fast_support(monkeypatch, tmp_path)
|
|
adapter = _PickerAdapter()
|
|
runner = _make_runner(adapter)
|
|
|
|
result = await runner._handle_fast_command(_make_event("/fast"))
|
|
|
|
assert result is None
|
|
values = [c["value"] for c in adapter.calls[0]["choices"]]
|
|
assert values == ["fast", "normal"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypatch):
|
|
"""A bare /fast picker tap applies a session override, not a config write."""
|
|
self._patch_fast_support(monkeypatch, tmp_path)
|
|
adapter = _PickerAdapter()
|
|
runner = _make_runner(adapter)
|
|
event = _make_event("/fast")
|
|
|
|
await runner._handle_fast_command(event)
|
|
on_choice = adapter.calls[0]["on_choice_selected"]
|
|
await on_choice(event.source.chat_id, "fast")
|
|
|
|
assert runner._service_tier == "priority"
|
|
assert runner._session_service_tier_overrides
|
|
assert not (tmp_path / "config.yaml").exists()
|
|
|
|
|