Files
hermes-agent/tests/gateway/test_model_command_async_offload.py
teknium1 8ccc634ed2 test: restore /model event-loop offload guards dropped by #120071
The purge dropped both /model offload tests as mechanism pins (they spied
asyncio.to_thread). Restored as liveness invariants instead: the blocking
call records the thread it ran on and must not be the event-loop thread.
Both go red when asyncio.to_thread is made to run inline.

- test_model_command_async_offload.py::test_picker_path_runs_provider_listing_off_the_event_loop
  guards: list_picker_providers (can block on HTTP) never runs on the gateway loop (#41289/#41304)
- test_model_command_custom_providers.py::test_direct_model_switch_runs_off_the_event_loop
  guards: `/model <name>` switch_model() never runs on the gateway loop (#20525)
2026-09-23 10:34:54 -07:00

123 lines
5.1 KiB
Python

"""Gateway ``/model`` picker listing is a read path (#41289, #74003): it must ask for
cache-only catalogs and live-probe only the currently selected custom endpoint, so a
stale provider cache cannot freeze the gateway on blocking HTTP fetches.
"""
import threading
import pytest
from gateway.config import Platform
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
# --------------------------------------------------------------------------- #
# Harness
# --------------------------------------------------------------------------- #
def _make_runner():
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._running_agents = {}
return runner
def _make_event():
"""A bare ``/model`` (no args) — triggers the listing branch."""
return MessageEvent(
text="/model",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
)
@pytest.fixture
def _isolated_config(tmp_path, monkeypatch):
"""Point the handler at an empty isolated home so config loading is cheap
and deterministic (no real provider creds / network)."""
import gateway.run as gateway_run
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model:\n default: gpt-x\n provider: openrouter\nproviders: {}\n", encoding="utf-8")
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
return hermes_home
# --------------------------------------------------------------------------- #
# Text-fallback path -> list_authenticated_providers
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# Picker path -> list_picker_providers
# --------------------------------------------------------------------------- #
class _FakePickerResult:
success = True
class _FakePickerAdapter:
"""Adapter whose *type* exposes ``send_model_picker`` (the gate the handler
checks via ``getattr(type(adapter), 'send_model_picker', None)``)."""
async def send_model_picker(self, **kwargs):
return _FakePickerResult()
def _thread_metadata(self, *a, **k): # pragma: no cover - not exercised
return None
@pytest.mark.asyncio
async def test_picker_path_runs_provider_listing_off_the_event_loop(_isolated_config, monkeypatch):
"""#41289/#41304: ``list_picker_providers`` can fall through to a blocking HTTP fetch, so the
picker branch must run it on a worker thread — never on the gateway's event-loop thread."""
listing_threads: list[int] = []
def _fake_list_picker_providers(**kwargs):
listing_threads.append(threading.get_ident())
return [{"slug": "openrouter", "name": "OpenRouter", "is_current": True,
"models": ["gpt-x"], "total_models": 1}]
monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", _fake_list_picker_providers)
runner = _make_runner()
runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()}
monkeypatch.setattr(runner, "_thread_metadata_for_source", lambda *a, **k: None, raising=False)
monkeypatch.setattr(runner, "_reply_anchor_for_event", lambda *a, **k: None, raising=False)
# Picker "sent" => handler returns None, proving it got past the listing call.
assert await runner._handle_model_command(_make_event()) is None
assert listing_threads, "listing never ran"
assert threading.get_ident() not in listing_threads, (
"list_picker_providers ran inline on the event-loop thread"
)
@pytest.mark.asyncio
async def test_picker_path_lists_cache_only_and_probes_only_the_current_custom_endpoint(_isolated_config, monkeypatch):
"""#74003: the chat ``/model`` reply is a read path. The listing must ask for cache-only catalogs
and must not live-probe every saved custom endpoint (only the selected one), matching the GUI."""
seen: list[dict] = []
def _fake_list_picker_providers(**kwargs):
seen.append(kwargs)
return [{"slug": "openrouter", "name": "OpenRouter", "is_current": True,
"models": ["gpt-x"], "total_models": 1}]
monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", _fake_list_picker_providers)
runner = _make_runner()
runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()}
monkeypatch.setattr(runner, "_thread_metadata_for_source", lambda *a, **k: None, raising=False)
monkeypatch.setattr(runner, "_reply_anchor_for_event", lambda *a, **k: None, raising=False)
assert await runner._handle_model_command(_make_event()) is None
assert seen, "listing never ran"
flags = {k: seen[0].get(k) for k in ("non_blocking_catalogs", "probe_custom_providers", "probe_current_custom_provider")}
assert flags == {"non_blocking_catalogs": True, "probe_custom_providers": False,
"probe_current_custom_provider": True}, flags