Files

1 line
15 KiB
JSON

{"repo": "chainchopper/nirvana-runtime", "files": {"tests/gateway/test_model_command_flat_string_config.py": {"88e2a85827187ecea9fe33b97b0b2951b79e4e1e": {"t": 1785746877.3012595, "n": 7297, "h": "4aa01988f6ef76ba", "c": "\"\"\"Regression tests for gateway /model --global persistence when config.yaml\nhas a flat-string ``model:`` value instead of a nested dict.\n\nBefore fix: ``cfg.setdefault(\"model\", {})`` returned the existing string and\nthe next assignment raised ``TypeError: 'str' object does not support item\nassignment``, so every ``/model X --global`` from Telegram/Discord crashed\nsilently and the user-visible result was \"switch failed\" with no persist.\n\nAfter fix: the persist block coerces a scalar ``model:`` into a nested dict\nbefore mutation, so ``--global`` succeeds and the config is rewritten in\nthe proper ``model: {default: ..., provider: ...}`` form.\n\"\"\"\n\nimport yaml\nimport pytest\n\nfrom gateway.config import Platform\nfrom gateway.platforms.base import MessageEvent, MessageType\nfrom gateway.run import GatewayRunner\nfrom gateway.session import SessionSource\n\n\ndef _make_runner():\n runner = object.__new__(GatewayRunner)\n runner.adapters = {}\n runner._voice_mode = {}\n runner._session_model_overrides = {}\n runner._running_agents = {}\n return runner\n\n\ndef _make_event(text):\n return MessageEvent(\n text=text,\n message_type=MessageType.TEXT,\n source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"12345\", chat_type=\"dm\"),\n )\n\n\ndef _fake_switch_result():\n \"\"\"Build a successful ModelSwitchResult that bypasses real provider resolution.\"\"\"\n from hermes_cli.model_switch import ModelSwitchResult\n\n return ModelSwitchResult(\n success=True,\n new_model=\"gpt-5.5\",\n target_provider=\"openrouter\",\n provider_changed=True,\n api_key=\"sk-test\",\n base_url=\"https://openrouter.ai/api/v1\",\n api_mode=\"chat_completions\",\n provider_label=\"OpenRouter\",\n is_global=True,\n )\n\n\ndef _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):\n \"\"\"Write a config.yaml with the given ``model:`` value and stub the heavy bits.\"\"\"\n import gateway.run as gateway_run\n\n hermes_home = tmp_path / \".hermes\"\n hermes_home.mkdir()\n cfg_path = hermes_home / \"config.yaml\"\n cfg_path.write_text(\n yaml.safe_dump({\"model\": model_yaml_value, \"providers\": {}}),\n encoding=\"utf-8\",\n )\n\n monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n monkeypatch.setattr(\n \"hermes_cli.model_switch.switch_model\",\n lambda **kw: _fake_switch_result(),\n )\n # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.\n monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n return cfg_path\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_flat_string_model(tmp_path, monkeypatch):\n \"\"\"Regression: ``model: deepseek-v4-flash`` (flat string) used to crash\n the gateway ``/model X --global`` persist branch with TypeError. After\n the fix, the flat string is coerced to ``{\"default\": ...}`` and the new\n model+provider are persisted on top.\n \"\"\"\n cfg_path = _setup_isolated_home(tmp_path, monkeypatch, \"deepseek-v4-flash\")\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n # Sanity: the handler returned a success-looking message (not a crash log).\n assert result is not None\n assert \"gpt-5.5\" in result\n\n # The persist block must have rewritten config.yaml as a nested dict.\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert isinstance(written[\"model\"], dict), (\n \"model: should be coerced to a dict, got %r\" % (written[\"model\"],)\n )\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n assert written[\"model\"][\"base_url\"] == \"https://openrouter.ai/api/v1\"\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_missing_model(tmp_path, monkeypatch):\n \"\"\"Companion case: ``model:`` key absent entirely. setdefault would have\n worked here, but the coercion branch also has to handle this cleanly.\n \"\"\"\n import gateway.run as gateway_run\n\n hermes_home = tmp_path / \".hermes\"\n hermes_home.mkdir()\n cfg_path = hermes_home / \"config.yaml\"\n cfg_path.write_text(yaml.safe_dump({\"providers\": {}}), encoding=\"utf-8\")\n\n monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n monkeypatch.setattr(\n \"hermes_cli.model_switch.switch_model\",\n lambda **kw: _fake_switch_result(),\n )\n monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n assert result is not None\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert isinstance(written[\"model\"], dict)\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_proper_dict_model(tmp_path, monkeypatch):\n \"\"\"Already-correct nested dict must still work — no regression on the\n common case.\n \"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n assert result is not None\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n\n\n@pytest.mark.asyncio\nasync def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):\n \"\"\"A plain ``/model X`` (no --global) now persists to config.yaml.\n\n This is the user-facing fix: switching models in one session survives\n into the next without re-typing the switch every time.\n \"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5\")\n )\n\n assert result is not None\n assert \"gpt-5.5\" in result\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n\n\n@pytest.mark.asyncio\nasync def test_model_session_flag_does_not_persist(tmp_path, monkeypatch):\n \"\"\"``/model X --session`` opts out of persistence even under the new default.\"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --session\")\n )\n\n assert result is not None\n assert \"gpt-5.5\" in result\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n # Config untouched — the session override is in-memory only.\n assert written[\"model\"][\"default\"] == \"old-model\"\n"}, "c67785a71f3d9b31f436185a5762b4b1a239351d": {"t": 1785768377.1648078, "n": 7297, "h": "4aa01988f6ef76ba", "c": "\"\"\"Regression tests for gateway /model --global persistence when config.yaml\nhas a flat-string ``model:`` value instead of a nested dict.\n\nBefore fix: ``cfg.setdefault(\"model\", {})`` returned the existing string and\nthe next assignment raised ``TypeError: 'str' object does not support item\nassignment``, so every ``/model X --global`` from Telegram/Discord crashed\nsilently and the user-visible result was \"switch failed\" with no persist.\n\nAfter fix: the persist block coerces a scalar ``model:`` into a nested dict\nbefore mutation, so ``--global`` succeeds and the config is rewritten in\nthe proper ``model: {default: ..., provider: ...}`` form.\n\"\"\"\n\nimport yaml\nimport pytest\n\nfrom gateway.config import Platform\nfrom gateway.platforms.base import MessageEvent, MessageType\nfrom gateway.run import GatewayRunner\nfrom gateway.session import SessionSource\n\n\ndef _make_runner():\n runner = object.__new__(GatewayRunner)\n runner.adapters = {}\n runner._voice_mode = {}\n runner._session_model_overrides = {}\n runner._running_agents = {}\n return runner\n\n\ndef _make_event(text):\n return MessageEvent(\n text=text,\n message_type=MessageType.TEXT,\n source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"12345\", chat_type=\"dm\"),\n )\n\n\ndef _fake_switch_result():\n \"\"\"Build a successful ModelSwitchResult that bypasses real provider resolution.\"\"\"\n from hermes_cli.model_switch import ModelSwitchResult\n\n return ModelSwitchResult(\n success=True,\n new_model=\"gpt-5.5\",\n target_provider=\"openrouter\",\n provider_changed=True,\n api_key=\"sk-test\",\n base_url=\"https://openrouter.ai/api/v1\",\n api_mode=\"chat_completions\",\n provider_label=\"OpenRouter\",\n is_global=True,\n )\n\n\ndef _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):\n \"\"\"Write a config.yaml with the given ``model:`` value and stub the heavy bits.\"\"\"\n import gateway.run as gateway_run\n\n hermes_home = tmp_path / \".hermes\"\n hermes_home.mkdir()\n cfg_path = hermes_home / \"config.yaml\"\n cfg_path.write_text(\n yaml.safe_dump({\"model\": model_yaml_value, \"providers\": {}}),\n encoding=\"utf-8\",\n )\n\n monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n monkeypatch.setattr(\n \"hermes_cli.model_switch.switch_model\",\n lambda **kw: _fake_switch_result(),\n )\n # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.\n monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n return cfg_path\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_flat_string_model(tmp_path, monkeypatch):\n \"\"\"Regression: ``model: deepseek-v4-flash`` (flat string) used to crash\n the gateway ``/model X --global`` persist branch with TypeError. After\n the fix, the flat string is coerced to ``{\"default\": ...}`` and the new\n model+provider are persisted on top.\n \"\"\"\n cfg_path = _setup_isolated_home(tmp_path, monkeypatch, \"deepseek-v4-flash\")\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n # Sanity: the handler returned a success-looking message (not a crash log).\n assert result is not None\n assert \"gpt-5.5\" in result\n\n # The persist block must have rewritten config.yaml as a nested dict.\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert isinstance(written[\"model\"], dict), (\n \"model: should be coerced to a dict, got %r\" % (written[\"model\"],)\n )\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n assert written[\"model\"][\"base_url\"] == \"https://openrouter.ai/api/v1\"\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_missing_model(tmp_path, monkeypatch):\n \"\"\"Companion case: ``model:`` key absent entirely. setdefault would have\n worked here, but the coercion branch also has to handle this cleanly.\n \"\"\"\n import gateway.run as gateway_run\n\n hermes_home = tmp_path / \".hermes\"\n hermes_home.mkdir()\n cfg_path = hermes_home / \"config.yaml\"\n cfg_path.write_text(yaml.safe_dump({\"providers\": {}}), encoding=\"utf-8\")\n\n monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n monkeypatch.setattr(\n \"hermes_cli.model_switch.switch_model\",\n lambda **kw: _fake_switch_result(),\n )\n monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n assert result is not None\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert isinstance(written[\"model\"], dict)\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n\n\n@pytest.mark.asyncio\nasync def test_model_global_persists_when_config_has_proper_dict_model(tmp_path, monkeypatch):\n \"\"\"Already-correct nested dict must still work — no regression on the\n common case.\n \"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --global\")\n )\n\n assert result is not None\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n assert written[\"model\"][\"provider\"] == \"openrouter\"\n\n\n@pytest.mark.asyncio\nasync def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):\n \"\"\"A plain ``/model X`` (no --global) now persists to config.yaml.\n\n This is the user-facing fix: switching models in one session survives\n into the next without re-typing the switch every time.\n \"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5\")\n )\n\n assert result is not None\n assert \"gpt-5.5\" in result\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n\n\n@pytest.mark.asyncio\nasync def test_model_session_flag_does_not_persist(tmp_path, monkeypatch):\n \"\"\"``/model X --session`` opts out of persistence even under the new default.\"\"\"\n cfg_path = _setup_isolated_home(\n tmp_path,\n monkeypatch,\n {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n )\n\n result = await _make_runner()._handle_model_command(\n _make_event(\"/model gpt-5.5 --session\")\n )\n\n assert result is not None\n assert \"gpt-5.5\" in result\n written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n # Config untouched — the session override is in-memory only.\n assert written[\"model\"][\"default\"] == \"old-model\"\n"}}}}