Files
hermes-agent/tests/gateway/test_model_command_async_offload.py
kshitijk4poor ab2f4602de refactor: MessageEvent to gateway/platforms/event.py; ElicitationHandler takes a call_context thunk
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two
sites now name the real types.

gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved
out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py
imported helpers.py at module level, so helpers could not name MessageEvent. Now
TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed
(`from gateway.platforms.base import` -> `.event`, preserving each import's layout);
gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT
pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType,
photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event.
Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans).

tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its
MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only
ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]`
and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one
`functools.partial`, run directly or inside the captured Context.

ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved.
(The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional
exposes ~60 unguarded call sites — separate follow-up.)

Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31
failures reproduce identically on origin/main (macOS /private/tmp, systemd socket,
long-path fixtures, live-service tests).
2026-09-07 22:47:33 +05:30

143 lines
5.4 KiB
Python

"""Regression tests for #41289: the Discord/Telegram ``/model`` slash command
must not run the blocking provider-listing on the gateway's async event loop.
``list_picker_providers`` / ``list_authenticated_providers`` are synchronous and
can fall through to a blocking ``urllib`` HTTP fetch when the on-disk provider
cache is stale. Running that directly on the event loop froze the gateway for
120-150s ("application did not respond" + delayed agent starts).
Fix (ported from #41304, which patched the old ``gateway/run.py`` location):
``_handle_model_command`` offloads BOTH provider-listing calls via
``asyncio.to_thread`` so the loop stays responsive:
* line ~1161 — picker path -> ``list_picker_providers``
* line ~1382 — text-fallback -> ``list_authenticated_providers``
These tests assert the *offload contract* at the real handler seam: each listing
function must be dispatched through ``asyncio.to_thread`` and must NOT be invoked
directly. Reverting either ``to_thread`` wrap (calling the sync fn inline again)
makes the corresponding test fail — i.e. the tests are mutation-survivable.
"""
import asyncio
import pytest
import gateway.slash_commands as slash_commands
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"),
)
class _ToThreadSpy:
"""Wraps the real ``asyncio.to_thread`` and records what it was asked to run."""
def __init__(self):
self.calls = [] # list of (func, args, kwargs)
self._real = asyncio.to_thread
async def __call__(self, func, /, *args, **kwargs):
self.calls.append((func, args, kwargs))
return await self._real(func, *args, **kwargs)
def funcs_offloaded(self):
return [c[0] for c in self.calls]
@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_offloads_list_picker_providers(_isolated_config, monkeypatch):
"""A picker-capable adapter => handler takes the picker branch, which must
offload ``list_picker_providers`` to a worker thread."""
spy = _ToThreadSpy()
monkeypatch.setattr(slash_commands.asyncio, "to_thread", spy)
# Non-empty providers so the handler proceeds to send_model_picker (and
# returns None), proving we got past the offloaded listing call.
fake_providers = [{"slug": "openrouter", "name": "OpenRouter", "is_current": True,
"models": ["gpt-x"], "total_models": 1}]
def _fake_list_picker_providers(**kwargs):
return fake_providers
monkeypatch.setattr(
"hermes_cli.model_switch_providers.list_picker_providers",
_fake_list_picker_providers,
)
runner = _make_runner()
runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()}
# Stub the metadata/anchor helpers the picker branch calls before sending.
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)
result = await runner._handle_model_command(_make_event())
# Picker "sent" => handler returns None.
assert result is None
offloaded = spy.funcs_offloaded()
assert _fake_list_picker_providers in offloaded, (
"list_picker_providers must be dispatched via asyncio.to_thread "
"(it was called inline on the event loop instead)"
)