"""Regression tests for gateway /model --global persistence when config.yaml has a flat-string ``model:`` value instead of a nested dict. Before fix: ``cfg.setdefault("model", {})`` returned the existing string and the next assignment raised ``TypeError: 'str' object does not support item assignment``, so every ``/model X --global`` from Telegram/Discord crashed silently and the user-visible result was "switch failed" with no persist. After fix: the persist block coerces a scalar ``model:`` into a nested dict before mutation, so ``--global`` succeeds and the config is rewritten in the proper ``model: {default: ..., provider: ...}`` form. """ 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 def _make_runner(): runner = object.__new__(GatewayRunner) runner.adapters = {} 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(): """Build 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 the 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 @pytest.mark.asyncio async def test_model_global_persists_when_config_has_flat_string_model(tmp_path, monkeypatch): """Regression: ``model: deepseek-v4-flash`` (flat string) used to crash the gateway ``/model X --global`` persist branch with TypeError. After the fix, the flat string is coerced to ``{"default": ...}`` and the new model+provider are persisted on top. """ cfg_path = _setup_isolated_home(tmp_path, monkeypatch, "deepseek-v4-flash") result = await _make_runner()._handle_model_command( _make_event("/model gpt-5.5 --global") ) # Sanity: the handler returned a success-looking message (not a crash log). assert result is not None assert "gpt-5.5" in result # The persist block must have rewritten config.yaml as a nested dict. 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" assert written["model"]["base_url"] == "https://openrouter.ai/api/v1" @pytest.mark.asyncio async def test_model_global_persists_when_config_has_missing_model(tmp_path, monkeypatch): """Companion case: ``model:`` key absent entirely. setdefault would have worked here, but the coercion branch also has to handle this cleanly. """ 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({"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(), ) monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home) monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home) result = await _make_runner()._handle_model_command( _make_event("/model gpt-5.5 --global") ) assert result is not None written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) assert isinstance(written["model"], dict) assert written["model"]["default"] == "gpt-5.5" assert written["model"]["provider"] == "openrouter" @pytest.mark.asyncio async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path, monkeypatch): """Already-correct nested dict must still work — no regression on the common case. """ cfg_path = _setup_isolated_home( tmp_path, monkeypatch, {"default": "old-model", "provider": "openai-codex"}, ) result = await _make_runner()._handle_model_command( _make_event("/model gpt-5.5 --global") ) assert result is not None written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) assert written["model"]["default"] == "gpt-5.5" assert written["model"]["provider"] == "openrouter"