diff --git a/tests/gateway/test_model_picker_persist.py b/tests/gateway/test_model_picker_persist.py new file mode 100644 index 00000000000..0ff57f4bf32 --- /dev/null +++ b/tests/gateway/test_model_picker_persist.py @@ -0,0 +1,176 @@ +"""Regression tests for gateway inline-keyboard model-picker persistence. + +#49066 made the typed ``/model `` command persist the selected model to +``config.yaml`` by default. But the inline-keyboard picker callback +(``_on_model_selected`` in ``gateway/slash_commands.py``) was left session-only: +it hard-coded ``is_global=False`` and never wrote ``config.yaml``, so *tapping* a +model in the Telegram/Discord picker silently reverted on the next launch while +*typing* the same model persisted — a contradiction the same PR introduced. + +After the fix (#49176), the picker callback honors the resolved +``persist_global`` (defaults to ``True``, still respects ``--session``) and runs +the same read-modify-write block the text path uses, so a tapped model survives +across sessions like a typed one. + +These tests drive the real ``_handle_model_command`` with a fake picker-capable +adapter that captures the ``on_model_selected`` callback, then invoke that +callback and assert ``config.yaml`` is (or isn't) updated — exercising the exact +closure the PR changed, against a real temp ``HERMES_HOME``. +""" + +import yaml +import pytest + +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource + + +class _FakePickerResult: + success = True + + +class _FakePickerAdapter: + """Minimal adapter that looks picker-capable and captures the callback. + + ``_handle_model_command`` gates the picker path on + ``getattr(type(adapter), "send_model_picker", None) is not None``, so the + method must exist on the class, not just the instance. + """ + + def __init__(self): + self.captured_callback = None + + async def send_model_picker(self, *, on_model_selected, **kwargs): + # Stash the closure the handler built so the test can fire a "tap". + self.captured_callback = on_model_selected + return _FakePickerResult() + + +def _make_runner(adapter): + runner = object.__new__(GatewayRunner) + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._running_agents = {} + return runner + + +def _make_event(text): + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"), + ) + + +def _fake_switch_result(): + """A successful ModelSwitchResult that bypasses real provider resolution.""" + from hermes_cli.model_switch import ModelSwitchResult + + return ModelSwitchResult( + success=True, + new_model="gpt-5.5", + target_provider="openrouter", + provider_changed=True, + api_key="sk-test", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + provider_label="OpenRouter", + is_global=True, + ) + + +def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value): + """Write a config.yaml with the given ``model:`` value and stub heavy bits.""" + import gateway.run as gateway_run + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + cfg_path = hermes_home / "config.yaml" + cfg_path.write_text( + yaml.safe_dump({"model": model_yaml_value, "providers": {}}), + 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: _fake_switch_result(), + ) + # save_config writes to ``get_hermes_home() / config.yaml`` — point it here. + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home) + monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home) + return cfg_path + + +async def _drive_picker(runner, event): + """Run the handler (which sends the picker) then fire the captured tap.""" + sent = await runner._handle_model_command(event) + # Bare /model returns None (picker sent); the adapter captured the callback. + assert sent is None + adapter = runner.adapters[Platform.TELEGRAM] + assert adapter.captured_callback is not None, "picker callback was not wired" + # Simulate the user tapping "gpt-5.5" under the openrouter provider. + return await adapter.captured_callback("12345", "gpt-5.5", "openrouter") + + +@pytest.mark.asyncio +async def test_picker_tap_persists_by_default(tmp_path, monkeypatch): + """Tapping a model in the picker (bare /model) persists to config.yaml, + matching the typed ``/model`` default — this is the #49176 fix.""" + adapter = _FakePickerAdapter() + cfg_path = _setup_isolated_home( + tmp_path, monkeypatch, {"default": "old-model", "provider": "openai-codex"} + ) + + confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model")) + + assert confirmation is not None + assert "gpt-5.5" in confirmation + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert written["model"]["default"] == "gpt-5.5" + assert written["model"]["provider"] == "openrouter" + assert written["model"]["base_url"] == "https://openrouter.ai/api/v1" + + +@pytest.mark.asyncio +async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch): + """``/model --session`` then a picker tap stays in-memory only — config + untouched.""" + adapter = _FakePickerAdapter() + cfg_path = _setup_isolated_home( + tmp_path, monkeypatch, {"default": "old-model", "provider": "openai-codex"} + ) + + confirmation = await _drive_picker( + _make_runner(adapter), _make_event("/model --session") + ) + + assert confirmation is not None + assert "gpt-5.5" in confirmation + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + # Config untouched — the session override is in-memory only. + assert written["model"]["default"] == "old-model" + assert written["model"]["provider"] == "openai-codex" + + +@pytest.mark.asyncio +async def test_picker_tap_coerces_flat_string_model(tmp_path, monkeypatch): + """A flat-string ``model:`` in config.yaml is coerced to a nested dict on a + picker tap (the same scalar-``model:`` guard the text path has), instead of + raising ``TypeError`` on assignment.""" + adapter = _FakePickerAdapter() + cfg_path = _setup_isolated_home(tmp_path, monkeypatch, "deepseek-v4-flash") + + confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model")) + + assert confirmation is not None + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert isinstance(written["model"], dict), ( + "model: should be coerced to a dict, got %r" % (written["model"],) + ) + assert written["model"]["default"] == "gpt-5.5" + assert written["model"]["provider"] == "openrouter"