diff --git a/tests/tui_gateway/test_stale_provider_resume_live.py b/tests/tui_gateway/test_stale_provider_resume_live.py new file mode 100644 index 0000000000..4b947f6002 --- /dev/null +++ b/tests/tui_gateway/test_stale_provider_resume_live.py @@ -0,0 +1,207 @@ +"""LIVE E2E: Desktop/TUI ``session.resume`` with a stale (deleted/renamed) +custom provider, through the REAL gateway dispatch path. + +This is the live-report shape from Discord (endpoints "deleted ages ago" +coming back, Bot Chats pinned to dead providers): a real ``state.db`` session +row whose persisted ``model_config`` names a custom provider that no longer +exists in config.yaml, resumed through ``tui_gateway.server.handle_request`` +(the same dispatch the Desktop WebSocket transport funnels into) with +``eager_build: true`` so the REAL ``_make_agent`` → ``AIAgent`` construction +runs synchronously — no mocks, no pre-seeded sessions. + +On pre-fix main the eager build dies with +``resume failed: Unknown provider 'custom:oldone'`` (or silently pins the +dead identity). Post-fix: + +* renamed provider (same endpoint/model under a new name) → healed to the + new ``custom:`` identity; +* deleted, unrecoverable provider → falls back to the configured default; +* legacy canonical Bot Chat row (titled exactly "Bot Chat", no + follow_profile_config marker) → stored pin ignored, profile's CURRENT + config used. + +Run: python -m pytest tests/tui_gateway/test_stale_provider_resume_live.py -o addopts= -v -s +""" + +from __future__ import annotations + +import json +import os +import tempfile +import uuid +from pathlib import Path + +import pytest +import yaml + +OLD_URL = "https://old-endpoint.invalid/v1" +NEW_URL = "https://new-endpoint.invalid/v1" + + +@pytest.fixture() +def live_home(monkeypatch): + """A REAL isolated HERMES_HOME with a config.yaml + state.db on disk.""" + tmp = Path(tempfile.mkdtemp(prefix="hermes-live-staleprov-")) + home = tmp / ".hermes" + home.mkdir(parents=True) + config = { + "model": {"default": "test-model-live", "provider": "custom:newone"}, + "custom_providers": [ + { + "name": "newone", + "base_url": NEW_URL, + "api_key": "sk-live-test-not-real", + "api_mode": "chat_completions", + } + ], + } + (home / "config.yaml").write_text(yaml.safe_dump(config)) + monkeypatch.setenv("HERMES_HOME", str(home)) + # hermes_constants caches the resolved home at first read — the env var + # alone doesn't repoint an already-imported process. Use the override API + # (the same mechanism profile-scoped resumes use). + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + home_token = set_hermes_home_override(str(home)) + # Neutralize ambient provider creds so resolution uses ONLY the config + # above — this must behave the same on a dev box and a bare CI runner. + for var in list(os.environ): + if var.endswith("_API_KEY") or var in ("OPENROUTER_KEY", "NOUS_KEY"): + monkeypatch.delenv(var, raising=False) + + import hermes_cli.config as hconfig + import hermes_cli.runtime_provider as rp + + for mod in (hconfig, rp): + for attr in ("_config_cache", "_cache", "_CONFIG_CACHE"): + if hasattr(mod, attr): + try: + setattr(mod, attr, None) + except Exception: + pass + + import hermes_state + import tui_gateway.server as server + + # The launch DB handle and the module-level home snapshot are import-time + # caches — repoint both at the isolated home for the duration of the test + # (see references: tui-gateway live WS harness, same trap). The hermetic + # conftest also re-pins hermes_state.DEFAULT_DB_PATH per test; pin it to + # THIS home so server._get_db() opens the same real state.db we seed. + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", home / "state.db") + monkeypatch.setattr(server, "_db", None, raising=False) + monkeypatch.setattr(server, "_db_error", None, raising=False) + monkeypatch.setattr(server, "_hermes_home", str(home), raising=False) + yield home, server + # Detach the shared handle so the tmpdir can be reclaimed. + try: + if server._db is not None: + server._db.close() + except Exception: + pass + server._db = None + try: + reset_hermes_home_override(home_token) + except Exception: + pass + + +def _seed_session_row( + home: Path, + *, + title: str, + provider: str, + base_url: str | None = OLD_URL, + extra_config: dict | None = None, +) -> str: + """Write a REAL session row + one message into the profile's state.db.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=home / "state.db") + sid = uuid.uuid4().hex[:12] + model_config = { + "model": "test-model-live", + "provider": provider, + "api_mode": "chat_completions", + } + if base_url: + model_config["base_url"] = base_url + if extra_config: + model_config.update(extra_config) + db.create_session( + sid, + source="tui", + model="test-model-live", + model_config=model_config, + session_key=f"live-test:{sid}", + ) + db.set_session_title(sid, title) + db.close() + return sid + + +def _resume(server, sid: str) -> dict: + """Drive the REAL dispatch entry (same funnel the Desktop WS uses).""" + return server.handle_request( + { + "id": f"rid-{sid}", + "method": "session.resume", + "params": {"session_id": sid, "eager_build": True, "omit_messages": True}, + } + ) + + +def _teardown(server, resp) -> None: + live_sid = (resp.get("result") or {}).get("session_id") + if live_sid: + server.handle_request( + {"id": "close", "method": "session.close", "params": {"session_id": live_sid}} + ) + + +class TestStaleProviderResumeLive: + def test_deleted_provider_falls_back_to_default(self, live_home): + """The Discord report shape: the stored provider was deleted from + config ages ago. Resume must NOT die with 'Unknown provider' — it + falls back to the configured default and the agent builds.""" + home, server = live_home + sid = _seed_session_row( + home, title="Old work chat", provider="custom:deleted-ages-ago" + ) + resp = _resume(server, sid) + try: + assert "error" not in resp, f"resume failed live: {resp.get('error')}" + result = resp["result"] + assert result.get("resumed") == sid + info = result.get("info") or {} + # Healed via base_url→entry recovery or dropped to the configured + # default — either way the DEAD name must not survive. + assert "deleted-ages-ago" not in json.dumps(info) + finally: + _teardown(server, resp) + + def test_renamed_provider_heals_to_new_identity(self, live_home): + """oldone → newone rename, same model: identity heals to newone.""" + home, server = live_home + sid = _seed_session_row( + home, title="Renamed provider chat", provider="custom:oldone", base_url=None + ) + resp = _resume(server, sid) + try: + assert "error" not in resp, f"resume failed live: {resp.get('error')}" + finally: + _teardown(server, resp) + + def test_legacy_bot_chat_follows_current_profile_config(self, live_home): + """A pre-contract canonical Bot Chat (title exactly 'Bot Chat', no + follow_profile_config marker) pinned to a dead provider must resume + on the profile's CURRENT config.""" + home, server = live_home + sid = _seed_session_row(home, title="Bot Chat", provider="custom:deadbot") + resp = _resume(server, sid) + try: + assert "error" not in resp, f"Bot Chat resume failed live: {resp.get('error')}" + info = (resp.get("result") or {}).get("info") or {} + assert "deadbot" not in json.dumps(info) + finally: + _teardown(server, resp)