An independent review of every deleted test against its named keeper
found contracts whose only proof had gone, or that no test had ever
pinned. Each restoration is one assertion in an existing test, proven red
by the production mutation named:
- gateway /model reply shows the context length the async resolver
returns (the deleted async-matches-sync test was its only check);
red when resolve_display_context_length_async drops the value.
- parse_model_switch_args("sonnet") has scope "default" (the deleted
tuple test covered the no-flag case).
- shared metrics runtime sends nothing when collection is on but send is
off; red when the gate reads resolved.enabled instead of resolved.send.
- the terminal env-sync test also probes every key gateway.run's bridge
looks up, so a gateway-only key (which the old source parse caught)
still fails it.
- desktop: the deliver ceiling equals the backend's lock wait plus every
attempt, read from the Python defaults.
Also: the tools/bot_relay.py mirror comment names relay-budget.ts, and a
comment orphaned by the Windows encoding-test deletion goes with it.
117 lines
4.1 KiB
Python
117 lines
4.1 KiB
Python
"""``/model`` context-length resolution must not block the gateway event loop.
|
|
|
|
Behavioral regression tests for the offload of
|
|
``resolve_display_context_length`` (blocking provider probe ladder) out of the
|
|
async ``/model`` handlers, and for the offload of
|
|
``enrich_model_switch_warnings_for_gateway`` (which reaches the same sync
|
|
resolver via ``merge_preflight_compression_warning``).
|
|
|
|
These drive the real ``_handle_model_command`` with a mocked switch pipeline —
|
|
no source-reading assertions; reverting either offload makes the corresponding
|
|
test fail because the blocking work lands back on the loop thread.
|
|
"""
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from gateway.config import Platform
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.session import SessionSource
|
|
|
|
def _make_source() -> SessionSource:
|
|
return SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
user_id="u1",
|
|
chat_id="c1",
|
|
user_name="tester",
|
|
chat_type="dm",
|
|
)
|
|
|
|
def _event(text: str) -> MessageEvent:
|
|
return MessageEvent(
|
|
text=text,
|
|
message_type=MessageType.TEXT,
|
|
source=_make_source(),
|
|
)
|
|
|
|
def _runner_with_store(tmp_path, monkeypatch):
|
|
"""Minimal GatewayRunner harness driving the real /model handler."""
|
|
import hermes_yaml as _yaml
|
|
|
|
import gateway.run as gateway_run
|
|
from gateway.run import GatewayRunner
|
|
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": "old-model", "provider": "openrouter"}}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_switch.switch_model",
|
|
lambda **kw: ModelSwitchResult(
|
|
success=True,
|
|
new_model="gpt-5.5",
|
|
target_provider="openrouter",
|
|
provider_changed=False,
|
|
api_key="sk-test",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
api_mode="chat_completions",
|
|
provider_label="OpenRouter",
|
|
),
|
|
)
|
|
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
|
|
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
|
|
# No expensive-model confirmation detour.
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_cost_guard.expensive_model_warning",
|
|
lambda *a, **k: None,
|
|
)
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {}
|
|
runner._voice_mode = {}
|
|
runner._session_model_overrides = {}
|
|
runner._pending_one_turn_model_restores = {}
|
|
runner._running_agents = {}
|
|
_store = MagicMock()
|
|
_store.set_model_override = AsyncMock()
|
|
_store._store = None
|
|
runner.session_store = None
|
|
runner._async_session_store = _store
|
|
return runner
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_resolution_runs_off_the_loop_thread(tmp_path, monkeypatch):
|
|
"""The sync resolver must execute on a worker thread when the /model
|
|
handler resolves the display context length for the switch reply."""
|
|
from hermes_cli import model_switch
|
|
|
|
seen = {}
|
|
loop_thread = threading.current_thread()
|
|
|
|
def _recording_resolver(model, provider, **kwargs):
|
|
seen.setdefault("threads", []).append(threading.current_thread())
|
|
return 123457
|
|
|
|
monkeypatch.setattr(
|
|
model_switch, "resolve_display_context_length", _recording_resolver
|
|
)
|
|
|
|
runner = _runner_with_store(tmp_path, monkeypatch)
|
|
result = await runner._handle_model_command(_event("/model gpt-5.5"))
|
|
|
|
assert result is not None and "gpt-5.5" in result
|
|
assert "123,457" in result, "the async resolver must hand back the sync resolver's value"
|
|
assert seen.get("threads"), "handler never resolved the context length"
|
|
assert all(th is not loop_thread for th in seen["threads"]), (
|
|
"resolve_display_context_length ran on the event loop thread — "
|
|
"the /model handler must offload it via "
|
|
"resolve_display_context_length_async"
|
|
)
|