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.
208 lines
7.7 KiB
Python
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)
|