Files
hermes-agent/tests/tui_gateway/test_stale_provider_resume_live.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

208 lines
7.7 KiB
Python

"""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:<name>`` 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 hermes_yaml as 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)