"""Session persistence must not strip a custom provider's identity. ``_runtime_model_config`` persists the live agent's RESOLVED provider into the session row's ``model_config`` JSON. For any named ``providers:`` / ``custom_providers:`` entry (e.g. one called "mimo-v2.5-pro"), ``agent.provider`` is the literal string "custom", so the entry name was lost — and the api_key is deliberately never persisted. On ``session.resume`` or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed provider="custom" back into ``_make_agent`` → ``resolve_runtime_provider(requested="custom")``, which cannot match an entry named "mimo-v2.5-pro". Depending on config the rebuild either raised "No LLM provider configured. Run `hermes model`..." (resume failed) or silently resolved placeholder credentials ("no-key-required") against the patched-back base_url. Fix: persist the REQUESTED/entry identity — ``_runtime_model_config`` maps the agent's base_url back to the canonical ``custom:`` menu key via ``find_custom_provider_identity``; ``_make_agent`` performs the same recovery for rows persisted before the fix (and falls back to handing the stored base_url to the direct-alias branch when no entry matches). Related investigation: GH #44070 / PR #44099 (credential-pool base_url pinning); same family of resolved-vs-requested identity loss. """ import json import types from unittest.mock import MagicMock, patch import hermes_cli.runtime_provider as rp from hermes_state import SessionDB MIMO_URL = "https://token-plan-cn.xiaomimimo.com/v1" MIMO_KEY = "sk-mimo-entry-key" LEGACY_LIST_CONFIG = { "custom_providers": [ { "name": "mimo-v2.5-pro", "base_url": MIMO_URL, "api_key": MIMO_KEY, "api_mode": "chat_completions", } ] } PROVIDERS_DICT_CONFIG = { "providers": { "mimo-v2.5-pro": { "api": MIMO_URL, "api_key": MIMO_KEY, } } } def _custom_agent(base_url=MIMO_URL): return types.SimpleNamespace( model="mimo-v2.5-pro", provider="custom", base_url=base_url, api_mode="chat_completions", reasoning_config=None, service_tier=None, ) class TestRuntimeModelConfigPersistsEntryIdentity: def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG) from tui_gateway.server import _runtime_model_config config = _runtime_model_config(_custom_agent()) assert config["provider"] == "custom:mimo-v2.5-pro" assert config["base_url"] == MIMO_URL # Credentials must keep coming from config/provider resolution, # never from the session DB. assert "api_key" not in config def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: {}) from tui_gateway.server import _runtime_model_config config = _runtime_model_config(_custom_agent()) assert config["provider"] == "custom" def test_non_custom_provider_untouched(self, monkeypatch): def _boom(): raise AssertionError("identity lookup must not run for built-ins") monkeypatch.setattr(rp, "load_config", _boom) from tui_gateway.server import _runtime_model_config agent = _custom_agent() agent.provider = "anthropic" agent.base_url = "https://api.anthropic.com" assert _runtime_model_config(agent)["provider"] == "anthropic" def _make_agent_with_override(override, monkeypatch, config, model_cfg=None): """Run _make_agent through the REAL resolve_runtime_provider against a patched config, returning the kwargs AIAgent was constructed with.""" monkeypatch.setattr(rp, "load_config", lambda: config) monkeypatch.setattr(rp, "_get_model_config", lambda: model_cfg or {}) # Keep credential-pool resolution off the developer's real HERMES home. monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "unused"}} with ( patch("tui_gateway.server._load_cfg", return_value=fake_cfg), patch("tui_gateway.server._get_db", return_value=MagicMock()), patch("tui_gateway.server._load_reasoning_config", return_value=None), patch("tui_gateway.server._load_service_tier", return_value=None), patch("tui_gateway.server._load_enabled_toolsets", return_value=None), patch("run_agent.AIAgent") as mock_agent, ): from tui_gateway.server import _make_agent _make_agent("sid-custom", "key-custom", model_override=override) return mock_agent.call_args.kwargs class TestResumeRoundTrip: def test_round_trip_restores_entry_credentials(self, monkeypatch): """persist → stored-overrides → _make_agent resolves the entry's api_key again (the exact path that raised "No LLM provider configured" before the fix).""" monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG) from tui_gateway.server import ( _runtime_model_config, _stored_session_runtime_overrides, ) model_config = _runtime_model_config(_custom_agent()) row = { "model": "mimo-v2.5-pro", "model_config": json.dumps(model_config), } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro" kwargs = _make_agent_with_override( overrides["model_override"], monkeypatch, LEGACY_LIST_CONFIG ) assert kwargs["provider"] == "custom" assert kwargs["base_url"] == MIMO_URL assert kwargs["api_key"] == MIMO_KEY def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch): """Rows persisted BEFORE the fix stored provider="custom"; the rebuild must recover the entry identity from the stored base_url.""" override = { "model": "mimo-v2.5-pro", "provider": "custom", "base_url": MIMO_URL, "api_mode": "chat_completions", } kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG) assert kwargs["base_url"] == MIMO_URL assert kwargs["api_key"] == MIMO_KEY # --- Regression: bare "custom" WITHOUT a base_url (GH #44022 / #47714) ------ # # The recurring Desktop/TUI "No LLM provider configured" regression. Every # point-fix above recovers the entry identity from the persisted base_url — # but a session can be persisted/restored with bare ``provider="custom"`` and # NO base_url (the agent was built without one on the override). Then bare # "custom" leaked through verbatim, ``resolve_runtime_provider("custom")`` # routed to the OpenRouter default URL with no api_key, and the next turn / # resume failed with "No LLM provider configured". These tests lock the # config-fallback recovery at all three leak sites so it cannot regress again. NAMED_CONFIG = { "model": {"default": "mimo-v2.5-pro", "provider": "custom:mimo-v2.5-pro"}, "custom_providers": [ { "name": "mimo-v2.5-pro", "base_url": MIMO_URL, "api_key": MIMO_KEY, "api_mode": "chat_completions", } ], } class TestBareCustomNoBaseUrlHealsFromConfig: """A named custom provider must never escape as bare ``"custom"`` when the config identifies the active entry — even when no base_url survived.""" def test_canonical_identity_recovers_from_config_when_no_base_url( self, monkeypatch ): monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) # No base_url to reverse-lookup → must fall back to config.model.provider. assert ( rp.canonical_custom_identity(base_url=None) == "custom:mimo-v2.5-pro" ) def test_persist_recovers_entry_when_agent_has_no_base_url(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _runtime_model_config agent = _custom_agent(base_url="") # the regression vector config = _runtime_model_config(agent) # Bare "custom" must NOT be persisted — it heals to the entry identity. assert config["provider"] == "custom:mimo-v2.5-pro" def test_restore_heals_bare_custom_row_without_base_url(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _stored_session_runtime_overrides # A poisoned row from before the fix: bare custom, no base_url. row = { "model": "mimo-v2.5-pro", "model_config": json.dumps( {"model": "mimo-v2.5-pro", "provider": "custom"} ), "billing_provider": "custom", } overrides = _stored_session_runtime_overrides(row) assert overrides["provider_override"] == "custom:mimo-v2.5-pro" assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro" def test_make_agent_heals_bare_custom_no_base_url_end_to_end(self, monkeypatch): """The exact failing path: stored override has bare custom + no base_url; _make_agent must build the AIAgent with the named entry's endpoint + key, NOT the OpenRouter default with an empty key.""" override = { "model": "mimo-v2.5-pro", "provider": "custom", "base_url": None, "api_mode": "chat_completions", } kwargs = _make_agent_with_override( override, monkeypatch, NAMED_CONFIG, model_cfg=NAMED_CONFIG["model"] ) assert kwargs["base_url"] == MIMO_URL assert kwargs["api_key"] == MIMO_KEY assert "openrouter.ai" not in (kwargs.get("base_url") or "") def test_first_db_row_persists_entry_identity_not_bare_custom(self, monkeypatch): """The ORIGIN of poisoned rows: a fresh desktop session's first DB write (_ensure_session_db_row, before the agent is built) copies the composer override's RESOLVED provider. A named custom provider's resolved value is bare "custom" — persisting that verbatim seeds the unresumable row. It must be healed to ``custom:`` here.""" monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) captured = {} class _DB: def create_session(self, key, **kwargs): captured.update(kwargs) from tui_gateway import server as srv monkeypatch.setattr(srv, "_get_db", lambda: _DB()) monkeypatch.setattr(srv, "_resolve_model", lambda: "mimo-v2.5-pro") session = { "session_key": "agent:main:desktop:dm:abc", # composer override carrying the lossy resolved provider + no base_url "model_override": {"model": "mimo-v2.5-pro", "provider": "custom"}, } srv._ensure_session_db_row(session) persisted = captured.get("model_config") or {} assert persisted.get("provider") == "custom:mimo-v2.5-pro" # --- Regression: bare "custom" + no base_url + DIFFERENT default provider ---- # # The config-provider fallback above only heals when ``config.model.provider`` # still points at the custom entry. A user whose global default is a built-in # provider (e.g. Nous) but who switched THIS session to a self-hosted model # gets no heal: the bare provider is dropped, resume falls back to the default # provider, and the default provider's endpoint 404s with "Model '' not # found" (the b200/hermes-ultra-sft report). The stored MODEL NAME is the one # session-scoped fact that still identifies the entry — these tests lock the # model-name recovery tier. ULTRA_URL = "http://b200-cluster:30090/v1" ULTRA_CONFIG = { # Global default deliberately points at a BUILT-IN provider — the config # fallback must not fire; only the model lookup can recover the entry. "model": {"default": "some-nous-model", "provider": "nous"}, "providers": { "hermes-ultra": { "api": ULTRA_URL, "api_key": "sk-ultra", "models": ["hermes-ultra-sft"], } }, } ULTRA_LEGACY_CONFIG = { "model": {"default": "some-nous-model", "provider": "nous"}, "custom_providers": [ { "name": "hermes-ultra", "base_url": ULTRA_URL, "api_key": "sk-ultra", "model": "hermes-ultra-sft", } ], } class TestModelNameRecoversEntryIdentity: def test_identity_by_model_from_providers_dict_models_list(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: ULTRA_CONFIG) assert ( rp.find_custom_provider_identity_by_model("hermes-ultra-sft") == "custom:hermes-ultra" ) class TestStaleProviderNameFallsBack: """A session row stored under a provider that was renamed or removed must not sink agent init with "Unknown provider ''": heal to the entry that still serves the stored model/base_url, else drop the provider so resume falls back to the configured default (or the user's pick).""" def test_stale_bare_name_heals_via_model(self, monkeypatch): """Registry serves mimo-v2.5-pro; the row still names the OLD slug — the exact shape of the renamed-provider report (oldone -> newone).""" monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "mimo-v2.5-pro", "model_config": json.dumps( {"model": "mimo-v2.5-pro", "provider": "stale-provider"} ), "billing_provider": "custom", } overrides = _stored_session_runtime_overrides(row) assert overrides["provider_override"] == "custom:mimo-v2.5-pro" assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro" def test_stale_prefixed_name_heals_and_drops_stale_base_url(self, monkeypatch): """Healing must also drop the snapshot's base_url so the registry URL (the renamed provider's current endpoint) is not overridden.""" monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "mimo-v2.5-pro", "model_config": json.dumps( { "model": "mimo-v2.5-pro", "provider": "custom:stale-provider", "base_url": "https://old.invalid/v1", "api_mode": "chat_completions", } ), "billing_provider": "custom:stale-provider", } overrides = _stored_session_runtime_overrides(row) assert overrides["provider_override"] == "custom:mimo-v2.5-pro" assert overrides["model_override"]["base_url"] is None def test_unrecoverable_provider_drops_to_default(self, monkeypatch): """No entry serves the stored model AND no configured default names a real entry → the provider is dropped; resume falls back to the configured default instead of failing the build.""" config = {"custom_providers": NAMED_CONFIG["custom_providers"]} monkeypatch.setattr(rp, "load_config", lambda: config) monkeypatch.setattr(rp, "_get_model_config", lambda: {}) from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "no-such-model", "model_config": json.dumps( {"model": "no-such-model", "provider": "dead-provider"} ), "billing_provider": "custom", } overrides = _stored_session_runtime_overrides(row) assert "provider_override" not in overrides assert overrides["model_override"]["provider"] is None def test_valid_provider_is_untouched(self, monkeypatch): """A live provider must round-trip unchanged — no healing, no drops.""" monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "mimo-v2.5-pro", "model_config": json.dumps( { "model": "mimo-v2.5-pro", "provider": "custom:mimo-v2.5-pro", "base_url": MIMO_URL, "api_mode": "chat_completions", } ), "billing_provider": "custom:mimo-v2.5-pro", } overrides = _stored_session_runtime_overrides(row) assert overrides["provider_override"] == "custom:mimo-v2.5-pro" assert overrides["model_override"]["base_url"] == MIMO_URL class TestOverridesHaveRoutableProvider: def test_gate_detects_stale_provider(self, monkeypatch): monkeypatch.setattr(rp, "load_config", lambda: NAMED_CONFIG) monkeypatch.setattr(rp, "_get_model_config", lambda: NAMED_CONFIG["model"]) from tui_gateway.server import _overrides_have_routable_provider assert ( _overrides_have_routable_provider( {"provider_override": "custom:mimo-v2.5-pro"} ) is True ) assert ( _overrides_have_routable_provider( {"provider_override": "custom:stale-provider"} ) is False ) assert ( _overrides_have_routable_provider( {"model_override": {"provider": None}} ) is False ) assert _overrides_have_routable_provider({}) is False # --- Bot-Mode room plumbing sessions follow the profile's CURRENT config ------ # # Room plumbing sessions are per-member scratch conversations inside a group # chat (desktop Bot Mode). They must ALWAYS rebuild from the member profile's # current config: restoring the stored model/provider pin from an old row is # what left room bots stuck on a stale provider (e.g. "out of Nous credits" # after the profile was switched to ollama-cloud) while the same bots worked # fine in DMs. The stored-runtime restore stays intact for normal 1:1 chats. # # The contract is an EXPLICIT ``room_plumbing`` marker persisted in # model_config (set by session.create/room consumers), with the hidden + # "Group:" title shape kept as a legacy fallback for rows created by older # desktop builds that never sent the marker. # # Regression: GH #89497 (room bots hang then report "out of Nous credits"). class TestRoomPlumbingRuntimeOverrides: def test_marked_row_returns_no_overrides(self): """A row carrying the room_plumbing marker never restores a stored provider pin — resume falls back to the profile's CURRENT config.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "openai/gpt-5.6-luna-pro", "billing_provider": "nous", "model_config": json.dumps( {"model": "openai/gpt-5.6-luna-pro", "provider": "nous", "room_plumbing": True} ), } assert _stored_session_runtime_overrides(row) == {} def test_marked_row_dict_model_config(self): from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "openai/gpt-5.6-luna-pro", "model_config": {"model": "openai/gpt-5.6-luna-pro", "provider": "nous", "room_plumbing": True}, } assert _stored_session_runtime_overrides(row) == {} def test_legacy_group_title_shape_still_skipped(self): """Rows from older desktop builds (hidden + "Group:" title, no marker) keep the legacy guard: they also rebuild from current config.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "title": "Group: Ceo, Product Designer, Cfo, COO, CTO, Coding", "hidden": 1, "model": "openai/gpt-5.6-luna-pro", "billing_provider": "nous", "model_config": json.dumps({"model": "openai/gpt-5.6-luna-pro", "provider": "nous"}), } assert _stored_session_runtime_overrides(row) == {} def test_normal_row_still_restores_stored_runtime(self): """The intended stored-runtime restore is untouched for normal 1:1 chats: reopening an old chat shows the model it actually used.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "title": "Analyze business idea gaps", "hidden": 0, "model": "glm-5.1", "billing_provider": "ollama-cloud", "model_config": json.dumps( {"model": "glm-5.1", "provider": "ollama-cloud", "service_tier": "normal"} ), } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "glm-5.1" assert overrides["model_override"]["provider"] == "ollama-cloud" def test_hidden_normal_chat_untouched_by_legacy_shape(self): """A hidden NON-room chat (hidden without a "Group:" title) keeps the stored-runtime restore — the legacy shape is narrow on purpose.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "title": "My hidden scratchpad", "hidden": 1, "model": "glm-5.1", "billing_provider": "ollama-cloud", "model_config": json.dumps({"model": "glm-5.1", "provider": "ollama-cloud"}), } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "glm-5.1" # --- Regression: bot DM stuck on a stale provider pin (GH #89497 class) ------ # # Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing # sessions are plugin-owned scratch conversations. They are created with the # explicit ``follow_profile_config`` contract so resume ALWAYS rebuilds from # the member profile's CURRENT config — restoring the stored model/provider # pin from an old row is what left bot DMs stuck on a stale provider (e.g. # "out of Nous credits" after the profile was switched to ollama-cloud) while # the same bot worked fine in rooms. Normal 1:1 user chats keep the # stored-runtime restore (opening an older chat must show the model it # actually used). class TestFollowProfileConfigRuntimeOverrides: def test_marked_row_returns_no_overrides(self): """A row carrying the follow_profile_config marker never restores a stored provider pin — resume falls back to the profile's CURRENT config.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "openai/gpt-5.6-luna-pro", "billing_provider": "nous", "model_config": json.dumps( { "model": "openai/gpt-5.6-luna-pro", "provider": "nous", "follow_profile_config": True, } ), } assert _stored_session_runtime_overrides(row) == {} def test_marked_row_dict_model_config_returns_no_overrides(self): """Same contract when model_config is already a dict (not JSON).""" from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "openai/gpt-5.6-luna-pro", "model_config": { "model": "openai/gpt-5.6-luna-pro", "provider": "nous", "follow_profile_config": True, }, } assert _stored_session_runtime_overrides(row) == {} def test_unmarked_row_still_restores_stored_runtime(self): """Normal 1:1 user chats keep the stored-runtime restore — the contract must not leak into ordinary sessions.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "model": "openai/gpt-5.6-luna-pro", "billing_provider": "nous", "model_config": json.dumps( {"model": "openai/gpt-5.6-luna-pro", "provider": "nous"} ), } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "openai/gpt-5.6-luna-pro" assert overrides["model_override"]["provider"] == "nous" def test_legacy_bot_chat_title_backfills_contract(self): """Canonical Bot Chats created BEFORE the marker existed carry no follow_profile_config, but they are still the plugin-owned forever-DM (identified by the exact title "Bot Chat"). They must also rebuild from the profile's CURRENT config — the live-report shape where every pre-existing Bot Chat stayed pinned to a deleted provider.""" from tui_gateway.server import _stored_session_runtime_overrides for hidden in (0, 1): row = { "title": "Bot Chat", "hidden": hidden, "model": "openai/gpt-5.6-luna-pro", "billing_provider": "nous", "model_config": json.dumps( {"model": "openai/gpt-5.6-luna-pro", "provider": "nous"} ), } assert _stored_session_runtime_overrides(row) == {} def test_bot_chat_prefix_title_is_not_backfilled(self): """Only the EXACT canonical title matches the legacy backfill — a user chat that merely mentions bots keeps its stored runtime.""" from tui_gateway.server import _stored_session_runtime_overrides row = { "title": "Bot Chat ideas for my app", "hidden": 0, "model": "glm-5.1", "billing_provider": "ollama-cloud", "model_config": json.dumps( {"model": "glm-5.1", "provider": "ollama-cloud"} ), } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "glm-5.1" def test_ensure_db_row_persists_contract_marker(self, monkeypatch): """_ensure_session_db_row stamps follow_profile_config into the row's model_config when the session carries the contract.""" import tui_gateway.server as server captured = {} class FakeDB: def create_session(self, *args, **kwargs): captured["model_config"] = kwargs.get("model_config") return None monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) monkeypatch.setattr(server, "_resolve_model", lambda: "glm-5.1") session = { "session_key": "key-1", "model_override": {"model": "glm-5.1", "provider": "ollama-cloud"}, "follow_profile_config": True, } server._ensure_session_db_row(session) assert captured["model_config"].get("follow_profile_config") is True def test_ensure_db_row_omits_marker_without_contract(self, monkeypatch): """Sessions without the contract do NOT get the marker — normal chats keep the stored-runtime restore.""" import tui_gateway.server as server captured = {} class FakeDB: def create_session(self, *args, **kwargs): captured["model_config"] = kwargs.get("model_config") return None monkeypatch.setattr(server, "_get_db", lambda: FakeDB()) monkeypatch.setattr(server, "_resolve_model", lambda: "glm-5.1") session = { "session_key": "key-2", "model_override": {"model": "glm-5.1", "provider": "ollama-cloud"}, } server._ensure_session_db_row(session) assert captured["model_config"].get("follow_profile_config") is None # --- Regression: model column vs model_config desync (stale provider) ---------- # # _runtime_model_config merges the agent's CURRENT identity onto the row's # existing model_config. For model/provider it only SET the key when the agent # attribute was truthy — so a falsy agent provider (agent inherits the profile # default) left the PREVIOUS provider in the JSON while # _persist_live_session_runtime updated the model column separately. Resume # then read the fresh model from the column but the STALE provider/endpoint # from model_config, silently routing the chat to the wrong provider (e.g. a # VeniceAI/empero endpoint under a model that should run on Nous). The sibling # CLI path (_persist_model_switch_to_session) already deletes stale keys with # or-None; the gateway writer must drop them too, not merely omit the write. def _agent_like(model="deepseek/deepseek-v4-flash-0731", provider=""): return types.SimpleNamespace( model=model, provider=provider, base_url="", api_mode="", reasoning_config=None, service_tier=None, ) class TestRuntimeModelConfigDropsStaleKeys: def test_falsy_provider_drops_stale_existing_provider(self): """Agent inherits the profile default (empty provider): the previously persisted provider must NOT survive the merge.""" from tui_gateway.server import _runtime_model_config existing = { "model": "deepseek/deepseek-v4-flash-0731", "provider": "stealth-ox-alpha", # stale from an earlier state "base_url": "https://api.venice.ai/api/v1", "api_mode": "chat_completions", } config = _runtime_model_config(_agent_like(), existing) assert config["model"] == "deepseek/deepseek-v4-flash-0731" assert "provider" not in config, config assert "base_url" not in config, config assert "api_mode" not in config, config def test_falsy_model_drops_stale_existing_model(self): """Mirror the provider rule: an empty agent model cannot keep the row's old model as its own.""" from tui_gateway.server import _runtime_model_config agent = _agent_like(model="", provider="nous") existing = {"model": "meituan/longcat-2.0:free", "provider": "nous"} config = _runtime_model_config(agent, existing) assert "model" not in config, config assert config["provider"] == "nous" def test_truthy_provider_overwrites_stale_existing(self): from tui_gateway.server import _runtime_model_config existing = { "model": "deepseek/deepseek-v4-flash-0731", "provider": "stealth-ox-alpha", "base_url": "https://api.venice.ai/api/v1", } config = _runtime_model_config(_agent_like(provider="nous"), existing) assert config["provider"] == "nous" assert config["model"] == "deepseek/deepseek-v4-flash-0731" def test_resume_overrides_get_no_stale_provider(self): """End-to-end shape: a config merged from an empty-provider agent must NOT resurrect the stale endpoint on resume — the chat falls back to the row's billing provider (the profile default) instead of the stale VeniceAI/empero route.""" from tui_gateway.server import ( _runtime_model_config, _stored_session_runtime_overrides, ) existing = { "model": "deepseek/deepseek-v4-flash-0731", "provider": "stealth-ox-alpha", "base_url": "https://api.venice.ai/api/v1", } config = _runtime_model_config(_agent_like(), existing) row = { "model": "deepseek/deepseek-v4-flash-0731", "model_config": json.dumps(config), "billing_provider": "nous", } overrides = _stored_session_runtime_overrides(row) assert overrides["model_override"]["model"] == "deepseek/deepseek-v4-flash-0731" # The stale endpoint identity is gone; resume routes through the # billing fallback to the profile's real provider. assert overrides["model_override"]["provider"] == "nous" assert overrides["provider_override"] == "nous" def test_real_db_persist_heals_desynced_row(self, tmp_path, monkeypatch): """A row already desynced (fresh model column + stale model_config provider) self-heals on the next live metadata persist.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) db = SessionDB(db_path=tmp_path / "state.db") db.create_session(session_id="desync1", source="desktop", model="old-model") db.update_session_meta( "desync1", json.dumps( { "model": "deepseek/deepseek-v4-flash-0731", "provider": "stealth-ox-alpha", "base_url": "https://api.venice.ai/api/v1", } ), model="deepseek/deepseek-v4-flash-0731", ) from tui_gateway.server import _runtime_model_config row = db.get_session("desync1") assert row is not None existing = json.loads(row["model_config"]) merged = _runtime_model_config(_agent_like(), existing) db.update_session_meta("desync1", json.dumps(merged), model="deepseek/deepseek-v4-flash-0731") healed_row = db.get_session("desync1") assert healed_row is not None healed = json.loads(healed_row["model_config"]) assert healed["model"] == "deepseek/deepseek-v4-flash-0731" assert "provider" not in healed, healed assert "base_url" not in healed, healed def test_existing_none_returns_only_agent_identity(self): """First write (no existing row): the merge starts from an empty dict and reflects only the agent's current identity — no stale keys, no crash on the None existing_config.""" from tui_gateway.server import _runtime_model_config config = _runtime_model_config(_agent_like(provider="nous"), None) assert config == {"model": "deepseek/deepseek-v4-flash-0731", "provider": "nous"}