diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ea5d20aac4a..8cffed89005 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1051,6 +1051,94 @@ def test_resolve_model_strips_config_model(monkeypatch): assert server._resolve_model() == "nous/hermes-test" +def _sync_test_session(**extra): + session = { + "agent": types.SimpleNamespace(model="old/model"), + "session_key": "session-key", + } + session.update(extra) + return session + + +def _patch_config_model(monkeypatch, model, provider=""): + monkeypatch.delenv("HERMES_MODEL", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False) + cfg_model = {"default": model} + if provider: + cfg_model["provider"] = provider + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": cfg_model}) + + +def test_config_sync_switches_unpinned_session(monkeypatch): + _patch_config_model(monkeypatch, "new/model", provider="nous") + session = _sync_test_session(config_model_seen=("old/model", "nous")) + calls = [] + monkeypatch.setattr( + server, + "_apply_model_switch", + lambda sid, sess, raw, **kw: calls.append((sid, raw, kw)), + ) + + server._sync_agent_model_with_config("sid", session) + + assert calls == [ + ( + "sid", + "new/model --provider nous", + {"confirm_expensive_model": True, "pin_session_override": False}, + ) + ] + assert session["config_model_seen"] == ("new/model", "nous") + + +def test_config_sync_skips_session_pinned_by_model_command(monkeypatch): + _patch_config_model(monkeypatch, "new/model") + session = _sync_test_session( + config_model_seen=("old/model", ""), + model_override={"model": "pinned/model"}, + ) + monkeypatch.setattr( + server, + "_apply_model_switch", + lambda *a, **k: pytest.fail("pinned session must not be switched"), + ) + + server._sync_agent_model_with_config("sid", session) + + +def test_config_sync_noop_when_config_unchanged(monkeypatch): + _patch_config_model(monkeypatch, "old/model") + session = _sync_test_session(config_model_seen=("old/model", "")) + monkeypatch.setattr( + server, + "_apply_model_switch", + lambda *a, **k: pytest.fail("unchanged config must not switch"), + ) + + server._sync_agent_model_with_config("sid", session) + + +def test_config_sync_failure_emits_error_once_per_edit(monkeypatch): + _patch_config_model(monkeypatch, "broken/model") + session = _sync_test_session(config_model_seen=("old/model", "")) + + def boom(*a, **k): + raise ValueError("no such model") + + monkeypatch.setattr(server, "_apply_model_switch", boom) + emits = [] + monkeypatch.setattr( + server, "_emit", lambda ev, sid, payload: emits.append((ev, payload)) + ) + + server._sync_agent_model_with_config("sid", session) + server._sync_agent_model_with_config("sid", session) + + assert len(emits) == 1 + assert emits[0][0] == "error" + assert "broken/model" in emits[0][1]["message"] + + def test_startup_runtime_uses_tui_provider_env(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4eaa356cd2e..3cf05d10214 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -948,6 +948,9 @@ def _start_agent_build(sid: str, session: dict) -> None: # Session DB row deferred to first run_conversation() call. # pending_title applied post-first-message (see cli.exec handler). current["agent"] = agent + # Baseline for the per-turn config sync; the profile home + # override is still active here. + current["config_model_seen"] = _config_model_target() try: worker = _SlashWorker(key, getattr(agent, "model", _resolve_model())) @@ -1414,6 +1417,16 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _config_model_target() -> tuple[str, str]: + """(model, provider) currently selected by env/config.""" + model = _resolve_model() + cfg_model = _load_cfg().get("model") + provider = "" + if isinstance(cfg_model, dict): + provider = str(cfg_model.get("provider") or "").strip() + return model, provider + + def _resolve_startup_runtime() -> tuple[str, str | None]: model = _resolve_model() explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip() @@ -1883,6 +1896,7 @@ def _apply_model_switch( raw_input: str, *, confirm_expensive_model: bool = False, + pin_session_override: bool = True, ) -> dict: from hermes_cli.model_switch import parse_model_flags, switch_model from hermes_cli.runtime_provider import resolve_runtime_provider @@ -1986,7 +2000,7 @@ def _apply_model_switch( # contamination bug). agent.switch_model() above already mutated the right # agent in place; the override dict makes that choice survive a rebuild # without touching shared process state. - if isinstance(session, dict): + if pin_session_override and isinstance(session, dict): session["model_override"] = { "model": result.new_model, "provider": result.target_provider, @@ -2003,6 +2017,42 @@ def _apply_model_switch( } +def _sync_agent_model_with_config(sid: str, session: dict) -> None: + """Adopt a config.yaml model change at turn start, like gateways do per + message. Sessions pinned with /model keep their choice; a failed switch + keeps the current model and never blocks the turn. + """ + agent = session.get("agent") + if agent is None or session.get("model_override"): + return + target = _config_model_target() + if not target[0]: + return + seen = session.get("config_model_seen") + # Record first so a broken config gets one attempt per edit, not per turn. + session["config_model_seen"] = target + if target == seen: + return + if seen is None and target[0] == (getattr(agent, "model", "") or ""): + return + model, provider = target + raw = f"{model} --provider {provider}" if provider else model + try: + _apply_model_switch( + sid, + session, + raw, + confirm_expensive_model=True, + pin_session_override=False, + ) + except Exception as e: + _emit( + "error", + sid, + {"message": f"Could not switch to configured model {model}: {e}"}, + ) + + def _compress_session_history( session: dict, focus_topic: str | None = None, @@ -3140,6 +3190,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict: finally: _clear_session_context(tokens) session["agent"] = new_agent + session["config_model_seen"] = _config_model_target() session["attached_images"] = [] session["edit_snapshots"] = {} session["image_counter"] = 0 @@ -5563,6 +5614,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: # the sudo.request overlay. (secret capture is a module global, so # re-running is a harmless no-op.) _wire_callbacks(sid) + _sync_agent_model_with_config(sid, session) cwd = _session_cwd(session) _register_session_cwd(session) cols = session.get("cols", 80) diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 80eec8bfb3a..a736d41ea1d 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -855,20 +855,29 @@ export default function ModelsPage() { }); }, []); - const load = useCallback(() => { - setLoading(true); - setError(null); - Promise.all([ - api.getModelsAnalytics(days), - api.getAuxiliaryModels().catch(() => null), - ]) - .then(([models, auxData]) => { - setData(models); - setAux(auxData); - }) - .catch((err) => setError(String(err))) - .finally(() => setLoading(false)); - }, [days]); + const load = useCallback( + (opts?: { silent?: boolean }) => { + if (!opts?.silent) { + setLoading(true); + setError(null); + } + Promise.all([ + api.getModelsAnalytics(days), + api.getAuxiliaryModels().catch(() => null), + ]) + .then(([models, auxData]) => { + setData(models); + setAux(auxData); + }) + .catch((err) => { + if (!opts?.silent) setError(String(err)); + }) + .finally(() => { + if (!opts?.silent) setLoading(false); + }); + }, + [days], + ); const onAssigned = useCallback(() => { // Reload aux state after any assignment change. @@ -903,7 +912,7 @@ export default function ModelsPage() { ghost size="icon" className="text-muted-foreground hover:text-foreground" - onClick={load} + onClick={() => load()} disabled={loading} aria-label={t.common.refresh} > @@ -922,6 +931,20 @@ export default function ModelsPage() { load(); }, [load]); + // Model assignments can change outside this page (config editor, chat + // /model --global, CLI) — refetch silently when the page regains focus. + useEffect(() => { + const refetch = () => { + if (document.visibilityState === "visible") load({ silent: true }); + }; + window.addEventListener("focus", refetch); + document.addEventListener("visibilitychange", refetch); + return () => { + window.removeEventListener("focus", refetch); + document.removeEventListener("visibilitychange", refetch); + }; + }, [load]); + return (