Files
hermes-agent/tests/hermes_cli/test_memory_provider_migration.py
Teknium 03f545ebcf fix(memory): catalog memory-provider installs stop failing on every start and every Install click
Two install routes refused a catalog memory provider that config already
names (memory.provider), so the install could never succeed and was
retried on every agent start / every click — the hindsight (91%) and
mnemosyne (87%) failed rows in hermes.extension.install.count.

1. Agent init (`recover_at_startup`) installs the configured provider
   through the consent gate. Under the classic CLI that gate's input()
   runs on the agent-build thread behind prompt_toolkit, so the turn
   hangs on "Initializing agent..." forever; with no terminal (Desktop,
   gateway, `chat -q`) it is refused — "Install declined: dependency
   install skipped (non-interactive)" — and recorded failed on every
   process start. Agent init now never asks: a provider that shipped in
   core installs with its built-in's consent (security.allow_lazy_installs
   still gates it), any other catalog provider is not attempted and the
   user is told the one command that installs it.

2. The dashboard/Desktop Install click (`dashboard_install_plugin`) on a
   plugin memory.provider already selects hits the publication veto that
   is meant for replacing an active plugin, so it is refused every time
   while the same plugin unselected installs and enables. The click now
   carries the consent the fresh-install enable already acts on; a forced
   reinstall keeps the veto.
2026-10-05 16:06:32 -07:00

295 lines
14 KiB
Python

"""A memory provider that left core is installed from the catalog, config untouched; a provider the
catalog does not know is reported with the one-liner instead of silently dropping memory."""
from pathlib import Path
import pytest
from hermes_cli import memory_provider_migration as mig
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("memory:\n provider: honcho\n honcho:\n workspace: keep-me\n")
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
return tmp_path
def test_missing_provider_installs_its_catalog_plugin_and_keeps_config(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
calls: list[str] = []
said: list[str] = []
def fake_install(name: str) -> dict:
calls.append(name)
(home / "plugins" / name).mkdir(parents=True)
return {"ok": True}
assert mig.migrate_home(home, install=fake_install, say=said.append) == "honcho"
assert calls == ["honcho"]
# Must not imply config.yaml's memory.<name> keys still apply: the plugin reads its own (#124038).
assert "memory.honcho" not in said[0] and "hermes memory status" in said[0]
assert "workspace: keep-me" in (home / "config.yaml").read_text()
# present now → nothing to do, nothing said
assert mig.migrate_home(home, install=fake_install, say=said.append) is None
assert calls == ["honcho"]
def test_presence_is_checked_in_the_home_being_migrated(tmp_path, monkeypatch):
"""The update hook walks several profile homes from one process; a provider installed in profile B
must count as present for B even when the process-level home (A) lacks it. Real lookup, no mock."""
a, b = tmp_path / "a", tmp_path / "b"
for h in (a, b):
h.mkdir(); (h / "config.yaml").write_text("memory:\n provider: twin\n")
(b / "plugins" / "twin").mkdir(parents=True)
(b / "plugins" / "twin" / "__init__.py").write_text("class Twin(MemoryProvider): ...\n")
monkeypatch.setenv("HERMES_HOME", str(a))
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
installs: list[Path] = []
assert mig.migrate_home(b, install=lambda n: installs.append(b) or {"ok": True}, say=lambda s: None) is None
assert installs == []
assert mig.migrate_home(a, install=lambda n: installs.append(a) or {"ok": True}, say=lambda s: None) == "twin"
def test_provider_unknown_to_catalog_is_reported_not_installed(home, monkeypatch):
monkeypatch.setattr(mig, "catalog_source", lambda name: None)
said: list[str] = []
assert mig.migrate_home(home, install=lambda n: pytest.fail("must not install"), say=said.append) is None
assert "not in the plugin catalog" in said[0] and "memory.provider" in said[0]
def test_startup_recovery_attempts_each_profile_home(tmp_path, monkeypatch):
"""One multiplexed process can start agents for two homes missing the same provider."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from pm import install as pm_install
homes = [tmp_path / "a", tmp_path / "b"]
for profile_home in homes:
profile_home.mkdir()
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(mig, "_LEFT_CORE", frozenset({"twin"}))
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True)
installed = []
def fake_installer(profile_home, **_kw):
def install(name):
plugin_dir = profile_home / "plugins" / name
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("class Twin(MemoryProvider): ...\n", encoding="utf-8")
installed.append(profile_home)
return {"ok": True}
return install
monkeypatch.setattr(mig, "_install_into", fake_installer)
outcomes = []
for profile_home in (homes[0], homes[1], homes[0]):
token = set_hermes_home_override(profile_home)
try:
outcomes.append(mig.recover_at_startup("twin"))
finally:
reset_hermes_home_override(token)
assert outcomes == [True, True, False]
assert installed == homes
@pytest.mark.parametrize(("name", "tty", "lazy", "consent"), [
("hindsight", False, True, True), # Desktop/gateway/scripted update: nobody can answer the prompt
("hindsight", False, False, False), # allow_lazy_installs off: still refused, with the install hint
("hindsight", True, True, False), # a terminal: the user is asked
("someplugin", False, True, False), # never bundled: a config naming it is not consent to install it
])
def test_unattended_migration_install_carries_lazy_install_consent(tmp_path, monkeypatch, name, tty, lazy, consent):
"""Every provider still in core declares Python deps; without this, no non-interactive migration
(the whole point of the agent-start hook) can ever publish one."""
import io
from hermes_cli import plugins_cmd
from pm import install as pm_install
class _Stream(io.StringIO):
def isatty(self):
return tty
monkeypatch.setattr("sys.stdin", _Stream())
monkeypatch.setattr("sys.stdout", _Stream())
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: lazy)
seen = {}
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *a, **kw: seen.update(kw) or {"ok": True})
assert mig._install_into(tmp_path)(name) == {"ok": True}
assert seen["catalog_name"] == name
assert seen.get("assume_deps_consent", False) is consent
@pytest.mark.parametrize(("name", "installs"), [("hindsight", True), ("someplugin", False)])
def test_startup_recovery_never_asks_a_dependency_question(tmp_path, monkeypatch, name, installs):
"""Agent init cannot answer a prompt: in the CLI the question hangs the turn behind the chat input,
with no terminal it is refused on every process start. A provider that shipped in core installs
with its built-in's consent even on a terminal; any other one is not attempted and names the command."""
import io
from hermes_cli import plugins_cmd
from pm import install as pm_install
class _Tty(io.StringIO):
def isatty(self):
return True
(tmp_path / "config.yaml").write_text(f"memory:\n provider: {name}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("sys.stdin", _Tty())
monkeypatch.setattr("sys.stdout", _Tty())
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setattr(mig, "catalog_source", lambda n: n)
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: True)
monkeypatch.setattr("builtins.input", lambda *_a: pytest.fail("agent init asked a question"))
seen: list[dict] = []
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *a, **kw: seen.append(kw) or {"ok": False})
said: list[str] = []
mig.recover_at_startup(name, say=said.append)
assert [kw["assume_deps_consent"] for kw in seen] == ([True] if installs else [])
assert installs or f"`hermes plugins install {name}`" in said[-1]
def test_update_asks_once_and_names_each_profile(tmp_path, monkeypatch):
"""Profiles sharing one environment face one dependency question for a migrating provider
(#125794); every line names its profile, and a decline names the rest once with their commands."""
from hermes_cli import plugins_cmd_install
root = tmp_path / ".hermes"
homes = [root, root / "profiles" / "work-a", root / "profiles" / "work-b"]
for profile_home in homes:
profile_home.mkdir(parents=True, exist_ok=True)
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
monkeypatch.setattr("pm.plugins_state.dependency_homes", lambda: homes)
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(plugins_cmd_install.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd_install.sys.stdout, "isatty", lambda: True)
class Console:
def print(self, *args, **kwargs):
pass
def installer(profile_home):
def install(name): # the real consent gate every catalog install passes through
consented, reason = plugins_cmd_install._consent_python_deps(name, ("twin-client",), Console())
if not consented:
return {"ok": False, "error": reason}
(profile_home / "plugins" / name).mkdir(parents=True)
return {"ok": True}
return install
monkeypatch.setattr(mig, "_install_into", installer)
for answer, expected in (("n", []), ("y", ["twin"] * 3)):
prompts, said = [], []
monkeypatch.setattr("builtins.input", lambda prompt, a=answer: prompts.append(prompt) or a)
assert mig.migrate_all_homes(say=said.append) == expected
assert len(prompts) == 1
if answer == "n":
assert "[profile 'default']" in said[1] and "dependency install declined" in said[1]
assert "hermes -p work-a plugins install twin" in said[2]
assert "hermes -p work-b plugins install twin" in said[2]
assert not any((h / "plugins").exists() for h in homes)
else:
assert [line.split("]")[0] for line in said[1:]] == [
" [profile 'default'", " [profile 'work-a'", " [profile 'work-b'"]
def _profile_homes(tmp_path, monkeypatch, *names):
root = tmp_path / ".hermes"
homes = [root, *(root / "profiles" / n for n in names)]
for profile_home in homes:
profile_home.mkdir(parents=True, exist_ok=True)
(profile_home / "config.yaml").write_text("memory:\n provider: twin\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
monkeypatch.setattr(mig, "provider_present", lambda name, home: (home / "plugins" / name).is_dir())
monkeypatch.setattr(mig, "catalog_source", lambda name: name)
monkeypatch.setattr(mig, "_LEFT_CORE", frozenset({"twin"})) # stands in for a formerly bundled provider
return homes
def test_unattended_update_refusal_in_one_profile_does_not_skip_the_others(tmp_path, monkeypatch):
"""Unattended consent is each home's own allow_lazy_installs: the default home refusing says
nothing about a profile that allows it, which must still migrate (not be reported as failed)."""
import io
from hermes_cli import plugins_cmd
from hermes_constants import get_hermes_home
from pm import install as pm_install
default, work = _profile_homes(tmp_path, monkeypatch, "work")
monkeypatch.setattr("pm.plugins_state.dependency_homes", lambda: [default, work])
monkeypatch.setattr("sys.stdin", io.StringIO()) # Desktop / scripted `hermes update`: no terminal
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: get_hermes_home() == work)
def install(*_a, catalog_name, assume_deps_consent, **_kw):
if not assume_deps_consent:
return {"ok": False, "error": "dependency install skipped (non-interactive)"}
(get_hermes_home() / "plugins" / catalog_name).mkdir(parents=True)
return {"ok": True}
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", install)
said: list[str] = []
assert mig.migrate_all_homes(say=said.append) == ["twin"]
assert (work / "plugins" / "twin").is_dir() and not (default / "plugins").exists()
assert not any("either" in line for line in said)
def test_startup_hint_installs_into_the_profile_that_printed_it(tmp_path, monkeypatch):
"""A bare `hermes plugins install` from a shell targets the sticky profile, so the hint an agent
started for a named profile (Desktop, gateway, `hermes -p`) prints must carry `-p`."""
from pm import install as pm_install
default, work = _profile_homes(tmp_path, monkeypatch, "work")
monkeypatch.setattr(pm_install, "lazy_installs_allowed", lambda: False)
for profile_home, command in ((work, "hermes -p work plugins install twin"),
(default, "hermes plugins install twin")):
monkeypatch.setattr(mig, "_attempted", set())
monkeypatch.setenv("HERMES_HOME", str(profile_home))
said: list[str] = []
assert mig.recover_at_startup("twin", say=said.append) is False
assert f"`{command}`" in said[0]
def test_missing_catalog_provider_recovery_names_the_profile_install_command(tmp_path, monkeypatch, capsys):
"""Offline, lazy installs off, or a failed migration: every surface a user turns to (doctor,
``memory status``, ``memory setup honcho``, ``hermes honcho``) names the one command that installs
the catalog plugin into THIS profile. Real in-tree catalog, nothing installed."""
from types import SimpleNamespace
from hermes_cli import doctor_state, memory_setup
from hermes_cli._parser import build_top_level_parser
home = tmp_path / "profiles" / "work"
home.mkdir(parents=True)
(home / "config.yaml").write_text("memory:\n provider: honcho\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
want = "hermes -p work plugins install honcho"
doctor_state._memory_provider_generic("honcho")
memory_setup.cmd_status(SimpleNamespace())
memory_setup.cmd_setup_provider("honcho")
assert capsys.readouterr().out.count(want) == 3
parser, _subparsers, _chat = build_top_level_parser()
with pytest.raises(SystemExit):
parser.parse_args(["honcho", "status"])
assert want in capsys.readouterr().err
def test_recovery_copy_without_a_catalog_memory_entry_keeps_the_generic_hint(tmp_path, monkeypatch, capsys):
from hermes_cli import doctor_state
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # not a profile home: no -p to add
assert mig.catalog_install_hint("honcho", category="memory") == "hermes plugins install honcho"
assert mig.catalog_install_hint("honcho", category="tools") is None
assert mig.catalog_install_hint("no-such-provider") is None
doctor_state._memory_provider_generic("no-such-provider")
assert "run: hermes memory setup" in capsys.readouterr().out