From 8ccc634ed23951e9b88f05025791b2c8d06410a1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:22:16 -0700 Subject: [PATCH] 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 ` switch_model() never runs on the gateway loop (#20525) --- .../test_model_command_async_offload.py | 26 ++++++++ .../test_model_command_custom_providers.py | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/gateway/test_model_command_custom_providers.py diff --git a/tests/gateway/test_model_command_async_offload.py b/tests/gateway/test_model_command_async_offload.py index 793652367f..bd4d5aaf00 100644 --- a/tests/gateway/test_model_command_async_offload.py +++ b/tests/gateway/test_model_command_async_offload.py @@ -3,6 +3,7 @@ cache-only catalogs and live-probe only the currently selected custom endpoint, stale provider cache cannot freeze the gateway on blocking HTTP fetches. """ +import threading import pytest @@ -72,6 +73,31 @@ class _FakePickerAdapter: +@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 diff --git a/tests/gateway/test_model_command_custom_providers.py b/tests/gateway/test_model_command_custom_providers.py new file mode 100644 index 0000000000..baa59f0246 --- /dev/null +++ b/tests/gateway/test_model_command_custom_providers.py @@ -0,0 +1,61 @@ +"""Regression tests for gateway /model support of config.yaml custom_providers.""" + +import threading + +import pytest +import yaml + +from gateway.config import Platform +from gateway.platforms.event import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource + + +def _make_runner(): + runner = object.__new__(GatewayRunner) + runner.adapters = {} + runner._voice_mode = {} + runner._session_model_overrides = {} + return runner + + +def _make_event(text="/model"): + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"), + ) + + +@pytest.mark.asyncio +async def test_direct_model_switch_runs_off_the_event_loop(tmp_path, monkeypatch): + """A direct `/model ` switch must run switch_model() on a worker thread so the + blocking models.dev HTTP fetch can't freeze the gateway event loop (#20525).""" + from hermes_cli.model_switch import ModelSwitchResult + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"model": {"default": "gpt-5.4", "provider": "openrouter"}}), + encoding="utf-8", + ) + + import gateway.run as gateway_run + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + switch_threads: list[int] = [] + + # Fail the switch so the handler returns before _finish_switch (which needs + # full runner state) — only where the switch ran matters here. + def _fake_switch(**kwargs): + switch_threads.append(threading.get_ident()) + return ModelSwitchResult(success=False, error_message="nope") + + monkeypatch.setattr("hermes_cli.model_switch.switch_model", _fake_switch) + + result = await _make_runner()._handle_model_command(_make_event("/model gpt-5.4")) + + assert switch_threads, "switch_model never ran" + assert threading.get_ident() not in switch_threads, "switch_model ran inline on the event-loop thread" + assert result is not None and "nope" in result