Files
hermes-agent/tests/hermes_cli/test_update_missing_configured_deps.py
teknium1 a153adcc2e fix(update): install the SDKs of configured gateway platforms instead of only warning (#124228)
A PM environment build keeps the recorded extras selection and never reads
config, so a home with platforms.discord (or telegram) enabled came out of
`hermes update` without the SDK: the update child detected it
(_configured_features_missing_deps) and only printed a warning, and the
gateway's lazy install at connect time is the only recovery — which fails
silently when lazy installs are off or the process is not on the selected
generation.

The update-build child (fresh, selected interpreter) now maps each missing
configured feature to the pyproject extra named after it (platform name ->
extra, MCP -> mcp), keeps only declared, platform-supported extras, and adds
them with an explicit pm.sync_venv. Detection reuses the gateway's own
connected-platform semantics (config + env credentials), so it installs exactly
what the gateway will try to load on restart. Features it could not install
still get the warning; an install failure never fails the update.

Flips tests/e2e/core/upgrade/pm/test_configured_features.py::
test_configured_gateway_platform_has_its_sdk_after_update.
2026-09-28 06:42:00 -07:00

137 lines
6.7 KiB
Python

"""Configured-feature warnings must describe the selected update child (#10651).
PM's atomic dependency union cannot validate a configured but unselected SDK.
These tests use real PM publication and a fresh interpreter; only expensive
frontend compilation is replaced. SDK anchors exist only in the target tree.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
import sysconfig
import subprocess
import pytest
import pm
from pm.environments import activation_environment, runtime_facts_path, selected_venv, site_packages
from pm.environments import venv_python
from tests.pm._fixtures import isolated_python # noqa: F401
from tests.pm.test_source_update_launch import source_launch # noqa: F401
@pytest.fixture
def configured_update(source_launch, tmp_path, monkeypatch):
root, _, _ = source_launch
repository = Path(__file__).resolve().parents[2]
pm.sync_venv(["launch-extra"], explicit=True, project_root=root)
selected = selected_venv(root)
site = site_packages(selected)
# Reuse installed core libraries without exposing the updater's site tree:
# neither SDK, executable .pth hooks, nor editable package finders cross over.
for path in Path(sysconfig.get_path("purelib")).iterdir():
if (path.name in {"lark_oapi", "mcp", "__pycache__"}
or path.name.startswith(("lark_oapi-", "mcp-", "__editable__"))
or path.suffix == ".pth" or (site / path.name).exists()):
continue
(site / path.name).symlink_to(path, target_is_directory=path.is_dir())
# Application source is unchanged; the toy manifest keeps PM resolution
# offline while exercising the real selected-environment launch boundary.
for path in repository.iterdir():
if path.name in {"hermes_cli", "gateway", "agent", "tools", "plugins", "pm"} or path.suffix == ".py":
if (root / path.name).exists():
# source_launch already stubs hermes_cli/source_completion.py; link the rest of that
# package's modules beside it.
for module in path.iterdir():
if not (root / path.name / module.name).exists():
(root / path.name / module.name).symlink_to(module, target_is_directory=module.is_dir())
continue
(root / path.name).symlink_to(path, target_is_directory=path.is_dir())
def build_in_child():
script = (
"import json, sys; from pathlib import Path\n"
"from hermes_cli import source_build\n"
"source_build.source_build_env = lambda **kwargs: {}\n"
"source_build.prepare_source_dependencies = lambda *args, **kwargs: None\n"
"source_build.build_source_tui = lambda *args, **kwargs: None\n"
"source_build.build_source_web = lambda *args, **kwargs: None\n"
f"source_build.build_update_products(Path({str(root)!r}), desktop=False)\n"
"print('TARGET=' + json.dumps({'prefix': sys.prefix, 'python': sys.executable}))\n"
)
subprocess.run([str(venv_python(selected)), "-c", script],
cwd=root, env=activation_environment(root), check=True)
home = tmp_path / "home"
config = home / "config.yaml"
config.write_text(
"platforms:\n feishu:\n enabled: true\n extra:\n app_id: cli_x\n app_secret: y\n"
"mcp_servers:\n fixture:\n command: must-not-run\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
# A stale positive verdict in the updater must not conceal missing child SDKs.
from types import ModuleType
for name in ("lark_oapi", "mcp"):
monkeypatch.setitem(sys.modules, name, ModuleType(name))
return root, selected, site, config, build_in_child
@pytest.mark.platforms("posix")
def test_update_names_missing_configured_features_from_selected_child(configured_update, capfd):
root, selected, site, _, build = configured_update
facts = runtime_facts_path(root).read_bytes()
build()
out = capfd.readouterr().out
assert "fail to load them on restart" in out
assert "Feishu / Lark" in out and "MCP servers" in out
assert "hermes setup" in out and "hermes pm" in out
target = json.loads(next(line.removeprefix("TARGET=") for line in out.splitlines() if line.startswith("TARGET=")))
assert Path(target["prefix"]) == selected
assert Path(target["python"]).parent.parent == selected
assert runtime_facts_path(root).read_bytes() == facts
assert not (root / ".update-incomplete").exists()
# Only the target gains the anchors; no updater module/cache is repaired.
for name in ("lark_oapi", "mcp"):
(site / f"{name}.py").write_text("# Passive dependency-probe fixture.\n", encoding="utf-8")
build()
assert "fail to load them on restart" not in capfd.readouterr().out
assert runtime_facts_path(root).read_bytes() == facts
@pytest.mark.platforms("posix")
def test_unconfigured_or_disabled_features_are_quiet(configured_update, capfd):
root, _, _, config, build = configured_update
for body in (
"platforms:\n feishu:\n enabled: false\n extra:\n app_id: cli_x\n app_secret: y\n",
"platforms:\n feishu:\n enabled: true\n",
):
config.write_text(body, encoding="utf-8")
build()
out = capfd.readouterr().out
assert "TARGET=" in out
assert "fail to load them on restart" not in out
assert "MCP servers" not in out
assert "Feishu / Lark:" not in out
assert not (root / ".update-incomplete").exists()
def test_update_installs_the_extras_configured_features_need(monkeypatch, tmp_path, capsys):
"""#124228: an enabled platform's SDK joins the environment instead of only a warning."""
from hermes_cli import main_install_repair as repair
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "x"\nversion = "0"\n'
"[project.optional-dependencies]\ndiscord = []\ngoogle-chat = []\nmcp = []\n", encoding="utf-8")
monkeypatch.setattr(repair, "_configured_features_missing_deps", lambda: [
("Discord", "discord hint", "discord"), ("Google Chat", "chat hint", "google_chat"),
("IRC", "irc hint", "irc"), ("MCP servers", "mcp hint", "mcp")])
calls = []
monkeypatch.setattr(pm, "sync_venv", lambda extras, **kwargs: calls.append((extras, kwargs)))
repair._install_configured_features_missing_deps(tmp_path)
assert calls == [(["discord", "google-chat", "mcp"],
{"explicit": True, "project_root": tmp_path, "evict_incompatible_plugins": True})]
out = capsys.readouterr().out
assert "IRC: irc hint" in out
assert "Discord" not in out and "MCP servers" not in out