diff --git a/tests/tui_gateway/test_resume_profile_scope.py b/tests/tui_gateway/test_resume_profile_scope.py index 5ac949ae09..a8db405e66 100644 --- a/tests/tui_gateway/test_resume_profile_scope.py +++ b/tests/tui_gateway/test_resume_profile_scope.py @@ -1,97 +1,93 @@ -# session.resume must resolve stored runtime overrides under the launch-profile scope. -# -# _stored_session_runtime_overrides heals a stale persisted provider via the -# configured provider chain (canonical_custom_identity / is_routable_provider), -# which reads the CURRENT profile config. The eager resume path already wraps -# that call in _profile_build_scope; the deferred and cold paths must do the -# same or the heal resolves against the ambient launch environment and -# mis-routes the resumed chat. See #115607. +"""``session.resume`` resolves a stored session's provider overrides under the SESSION profile's scope. + +Under a multiplexed gateway launched as profile A, resuming profile B's stored session used to +evaluate B's persisted ``custom:`` provider against A's config: the deferred (Desktop +``defer_history``) and cold paths called ``_stored_session_runtime_overrides`` outside +``_profile_build_scope(ctx.profile_home)``, so a provider defined only in B was "unroutable", +got healed to A's entry for the same endpoint, and the build in B died with +``Unknown provider 'custom:local-code'`` (#115607). Two real homes, A→B: B's ``providers.*`` +entry must survive resume; a launch-profile row keeps resolving against the launch config. +""" from __future__ import annotations -import contextlib -import types +import pytest +from hermes_state import SessionDB from tui_gateway import server - -def _install_scope_probe(monkeypatch): - # Stub _profile_build_scope as a recording context manager; return call log. - calls = {'scope_homes': [], 'overrides_in_scope': []} - active = {'home': None} - - @contextlib.contextmanager - def _scope(profile_home): - calls['scope_homes'].append(profile_home) - prev = active['home'] - active['home'] = profile_home - try: - yield - finally: - active['home'] = prev - - def _overrides(found): - calls['overrides_in_scope'].append(active['home']) - return {} - - monkeypatch.setattr(server, '_profile_build_scope', _scope) - monkeypatch.setattr(server, '_stored_session_runtime_overrides', _overrides) - monkeypatch.setattr(server, '_schedule_resume_hydration', lambda *a, **k: None) - monkeypatch.setattr(server, '_schedule_session_cap_enforcement', lambda *a, **k: None) - monkeypatch.setattr(server, '_schedule_agent_build', lambda *a, **k: None) - monkeypatch.setattr(server, '_resume_response', lambda *a, **k: {'ok': True}) - return calls +_BASE_URL = "http://127.0.0.1:59999/v1" +_STORED = "20260919-000000-abcd" -def _deferred_ctx(home): - return types.SimpleNamespace( - found={'id': 's1'}, - profile_home=str(home), - target='s1', - db=None, - owns_db=False, - omit_messages=False, - mint=lambda prompts=True: ('sid-d', 'tui', '/tmp'), - record=lambda source, cwd, history, overrides=None, **kw: {}, - claim=lambda sid, record: None, - info=lambda cwd, overrides: {}, - ) +class _NoopHydration: + def __call__(self, *_a, **_k): + return None -def _cold_ctx(home): - return types.SimpleNamespace( - found={'id': 's1'}, - profile_home=str(home), - target='s1', - rid='1', - mint=lambda prompts=True: ('sid-c', 'tui', '/tmp'), - restore=lambda: ([], [], []), - display_prefix=lambda: [], - record=lambda source, cwd, history, overrides=None, **kw: {}, - claim=lambda sid, record: None, - info=lambda cwd, overrides: {}, - ) +def _write_home(home, config_text: str) -> None: + home.mkdir(parents=True) + (home / "config.yaml").write_text(config_text) + (home / ".env").write_text("") -def test_deferred_resume_resolves_overrides_under_profile_scope(monkeypatch, tmp_path): - home = tmp_path / 'work' - home.mkdir() - calls = _install_scope_probe(monkeypatch) - ctx = _deferred_ctx(home) - - server._resume_deferred(ctx) - - assert calls['scope_homes'] == [str(home)] - assert calls['overrides_in_scope'] == [str(home)] +def _store(home, provider: str) -> None: + db = SessionDB(db_path=home / "state.db") + try: + db.create_session(_STORED, "desktop", model="local-code", + model_config={"model": "local-code", "provider": provider, "base_url": _BASE_URL}) + db.append_message(_STORED, "user", "hi") + db.append_message(_STORED, "assistant", "hello") + finally: + db.close() -def test_cold_resume_resolves_overrides_under_profile_scope(monkeypatch, tmp_path): - home = tmp_path / 'work' - home.mkdir() - calls = _install_scope_probe(monkeypatch) - ctx = _cold_ctx(home) +@pytest.fixture() +def homes(monkeypatch, tmp_path): + """Launch home A owns ``custom_providers: Local-Code``; secondary B owns ``providers.local-vllm`` — the + same endpoint under a different name, so a cross-profile heal is observable.""" + launch, secondary = tmp_path / "a", tmp_path / "b" + _write_home(launch, "model:\n default: gpt-4o\n provider: openai\n" + f"custom_providers:\n - name: Local-Code\n base_url: {_BASE_URL}\n api_key: k-a\n") + _write_home(secondary, "model:\n default: local-code\n provider: custom:local-vllm\n" + f"providers:\n local-vllm:\n api: {_BASE_URL}\n api_key: k-b\n") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setattr(server, "_hermes_home", str(launch)) + monkeypatch.setattr(server, "_profile_home", lambda p: secondary if p == "b" else None) + monkeypatch.setattr(server, "_get_db", lambda: SessionDB(db_path=launch / "state.db")) + monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) + monkeypatch.setattr(server, "_schedule_resume_hydration", _NoopHydration()) + monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None) + monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None) + monkeypatch.setattr(server, "_maybe_schedule_auto_continue", lambda *a, **k: None) + monkeypatch.setattr(server, "_default_session_cwd", lambda *a, **k: str(tmp_path)) + known = set(server._sessions) + yield {"a": launch, "b": secondary} + with server._sessions_lock: + for sid in [s for s in server._sessions if s not in known]: + server._sessions.pop(sid, None) - server._resume_cold(ctx) - assert calls['scope_homes'] == [str(home)] - assert calls['overrides_in_scope'] == [str(home)] +def _resume(**params): + resp = server.handle_request({"id": "1", "method": "session.resume", + "params": {"session_id": _STORED, "source": "desktop", **params}}) + assert "error" not in resp, resp + return server._sessions[resp["result"]["session_id"]]["resume_runtime_overrides"] + + +@pytest.mark.parametrize("path", [{"defer_history": True, "omit_messages": True}, {}], ids=["deferred", "cold"]) +def test_secondary_profile_overrides_resolve_against_its_own_config(homes, path): + _store(homes["b"], "custom:local-vllm") + + overrides = _resume(profile="b", **path) + + assert overrides["provider_override"] == "custom:local-vllm" + assert overrides["model_override"]["provider"] == "custom:local-vllm" + + +def test_launch_profile_overrides_still_resolve_against_launch_config(homes): + _store(homes["a"], "custom:local-code") + + overrides = _resume(defer_history=True, omit_messages=True) + + assert overrides["provider_override"] == "custom:local-code"