Files
hermes-agent/tests/hermes_cli/test_model_switch_persist_default.py
Teknium aaa34b0e08 fix(desktop): model picker no longer hardcodes --global; one persist policy for every surface (#90235)
Symptom: picking a model in the Desktop composer for the primary chat
silently rewrote config.yaml (model.default + model.provider) as the
profile default, ignoring model.persist_switch_by_default. A throwaway
pick that resolved to e.g. openai-api (no key) left the profile with an
unusable default on the next launch (#90235).

Root cause: 7d96537bc8 (#86414) made use-model-controls.ts send --global
for every primary-tile pick so a fresh profile would get a persisted
provider instead of falling through to a leftover OPENAI_API_KEY env var.
That put a persistence policy in the client, contradicting the
server-side rule /model uses (resolve_persist_behavior).

Fix:
- resolve_persist_behavior gains one rule, ahead of the --provider
  session-only rule: when neither model.default nor model.provider is
  configured yet, persist. This preserves #86414's first-pick motivation
  for CLI, gateway and Desktop alike. With a default configured, a plain
  pick is session-only unless --global / persist_switch_by_default.
- Desktop primary-tile picks send no scope flag and let the gateway decide.
  Secondary tiles and MoA presets still send --session.
- /model help text in cli.py said "(persists)"; it now matches reality and
  lists --global.
- Docs: desktop.md picker note + slash-commands /model row.

Tests: test_first_pick_persists_then_session_only (fails on main), and the
existing use-model-controls vitest updated to assert the flag-less request.
2026-09-02 05:33:33 -07:00

97 lines
3.8 KiB
Python

"""Tests for session-scoped-by-default model switching.
Covers:
- ``parse_model_flags`` recognises ``--session`` (and keeps ``--global``).
- ``resolve_persist_behavior`` applies the config-gated default and the
``--session`` / ``--global`` overrides.
- The default (no flags) is session-only, which is the user-facing fix: a
plain ``/model <name>`` affects only the current session unless the user
passes ``--global`` or sets ``model.persist_switch_by_default: true``.
"""
from unittest.mock import patch
from hermes_cli.model_switch import parse_model_flags, resolve_persist_behavior
# ---------------------------------------------------------------------------
# parse_model_flags
# ---------------------------------------------------------------------------
class TestParseModelFlagsSession:
def test_no_flags(self):
assert parse_model_flags("sonnet") == ("sonnet", "", False, False, False)
def test_unicode_dash_session_normalized(self):
# Telegram/iOS auto-converts -- to en/em dashes.
assert parse_model_flags("sonnet \u2013session") == (
"sonnet",
"",
False,
False,
True,
)
# ---------------------------------------------------------------------------
# resolve_persist_behavior
# ---------------------------------------------------------------------------
class TestResolvePersistBehavior:
def test_session_flag_always_session_only(self):
# --session opts out even if the config default is True.
with _config({"model": {"persist_switch_by_default": True}}):
assert resolve_persist_behavior(False, True) is False
def test_no_provider_uses_config_default(self):
# No --provider → respects config default (True).
with _config({"model": {"persist_switch_by_default": True}}):
assert resolve_persist_behavior(False, False, explicit_provider="") is True
def test_first_pick_persists_then_session_only(self):
# #90235 / #86414: the ONE policy every surface (CLI, gateway, Desktop
# picker) defers to. With no default ever configured, the first pick
# persists (even with --provider, which is how the Desktop picker
# always sends it) so resolve_provider never falls through to a stray
# env key on restart. Once a default exists, a plain pick is
# session-only unless --global / persist_switch_by_default.
with _config({"model": {}}):
assert resolve_persist_behavior(False, False, explicit_provider="anthropic") is True
with _config({"model": ""}):
assert resolve_persist_behavior(False, False) is True
with _config({"model": {"default": "gpt-5.6", "provider": "openai-codex"}}):
assert resolve_persist_behavior(False, False, explicit_provider="openai-api") is False
assert resolve_persist_behavior(False, False) is False
assert resolve_persist_behavior(True, False, explicit_provider="openai-api") is True
with _config({"model": "gpt-5.6"}):
assert resolve_persist_behavior(False, False) is False
# ---------------------------------------------------------------------------
# helper
# ---------------------------------------------------------------------------
class _config:
"""Context manager that patches ``load_config`` to return a fixed dict."""
def __init__(self, cfg: dict):
self.cfg = cfg
def __enter__(self):
self._patch = patch(
"hermes_cli.config.load_config",
return_value=self.cfg,
)
# resolve_persist_behavior imports load_config lazily inside the
# function, so patching the source module is sufficient.
self._patch.start()
return self
def __exit__(self, *exc):
self._patch.stop()