hermes-agent/tests/honcho_plugin/test_cli.py
Erosika 6feb2afd50 fix(honcho): plug pinPeerName transition gaps
Three correctness gaps when honcho.json's identity-mapping config changes
mid-flight:

1. The gateway's agent cache signature ignored honcho identity keys, so
   editing peerName / pinPeerName / userPeerAliases / runtimePeerPrefix
   was silently dropped until an unrelated cache eviction. Extend
   _extract_cache_busting_config to fingerprint the resolved honcho
   config so the AIAgent rebuilds on the next message.

2. cmd_setup let single → multi flips orphan the pinned-pool history
   under peerName without warning. Detect the transition, warn that
   runtime users will resolve to fresh empty peers, and auto-steer to
   hybrid (alias the operator's runtime IDs back to peerName) so the
   operator's own continuity survives. yes / no overrides available.

3. README didn't document the orphaning behaviour. Add a "Migrating
   single → multi" callout under Deployment shapes.

Tests:
- TestPinTransition (test_pin_peer_name.py): fresh-manager flip resolves
  to runtime, in-process flip is gated by the per-key session cache
  (documents the gateway-cache-must-bust contract), 3 cache-bust
  signature tests for pin / aliases / prefix.
- TestProfilePeerUniqueness: two profiles pinned to distinct peerNames
  resolve to distinct peers; host-level peerName overrides root when
  pinned.
- test_single_to_multi_steers_to_hybrid_by_default and
  test_single_to_multi_yes_override_keeps_multi (test_cli.py): wizard
  guard end-to-end coverage.
2026-05-27 10:49:33 -07:00

435 lines
19 KiB
Python

"""Tests for plugins/memory/honcho/cli.py."""
from types import SimpleNamespace
class TestResolveApiKey:
"""Test _resolve_api_key with various config shapes."""
def test_returns_api_key_from_root(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
assert honcho_cli._resolve_api_key({"apiKey": "root-key"}) == "root-key"
def test_returns_api_key_from_host_block(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
cfg = {"hosts": {"hermes": {"apiKey": "host-key"}}, "apiKey": "root-key"}
assert honcho_cli._resolve_api_key(cfg) == "host-key"
def test_returns_local_for_base_url_without_api_key(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
cfg = {"baseUrl": "http://localhost:8000"}
assert honcho_cli._resolve_api_key(cfg) == "local"
def test_returns_local_for_base_url_env_var(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.setenv("HONCHO_BASE_URL", "http://10.0.0.5:8000")
assert honcho_cli._resolve_api_key({}) == "local"
def test_returns_empty_when_nothing_configured(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
assert honcho_cli._resolve_api_key({}) == ""
def test_rejects_garbage_base_url_without_scheme(self, monkeypatch):
"""Obvious non-URL literals in baseUrl (typos) must not pass the guard."""
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
# Boolean literals, pure digits, and bare identifiers without
# host-like punctuation are rejected. Schemeless host:port-style
# strings are accepted (see test_accepts_legacy_schemeless_host).
for garbage in ("true", "false", "null", "1", "12345", "localhost"):
assert honcho_cli._resolve_api_key({"baseUrl": garbage}) == "", \
f"expected empty for garbage {garbage!r}"
def test_rejects_non_http_scheme_base_url(self, monkeypatch):
"""file:// / ftp:// / ws:// schemes are rejected as non-HTTP Honcho URLs.
Note: these DO contain ``.`` or ``:`` so they pass the schemeless
host fallback. That's acceptable — the Honcho SDK will still
reject them when it tries to connect. If tighter filtering is
needed later, extend the lowered-literal blocklist or check the
parsed scheme explicitly.
"""
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
# file:/// parses with scheme='file' but empty netloc, so the
# http/https guard rejects; the schemeless fallback also rejects
# because 'file:' starts with a known-non-http scheme prefix.
# ftp://host/ parses with scheme='ftp', netloc='host' — the
# http/https guard rejects but the schemeless fallback accepts
# because 'ftp://host/' contains ':' and '.'. Behaviour is
# intentionally lenient: SDK errors out with clearer message.
def test_accepts_https_base_url(self, monkeypatch):
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
assert honcho_cli._resolve_api_key({"baseUrl": "https://honcho.example.com"}) == "local"
def test_accepts_legacy_schemeless_host(self, monkeypatch):
"""Legacy configs with schemeless host:port must not regress.
Before scheme validation landed, ``baseUrl: "localhost:8000"`` passed
the truthy check and flowed through to the SDK. The lenient
schemeless fallback preserves that behaviour so self-hosters with
older configs don't see spurious "no API key configured" errors.
The SDK itself still rejects malformed URLs at connect time.
"""
import plugins.memory.honcho.cli as honcho_cli
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
for legacy in ("localhost:8000", "10.0.0.5:8000", "honcho.local:8080", "host.example.com"):
assert honcho_cli._resolve_api_key({"baseUrl": legacy}) == "local", \
f"expected local sentinel for legacy schemeless {legacy!r}"
class TestCmdStatus:
def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "honcho.json"
cfg_path.write_text("{}")
class FakeConfig:
enabled = True
api_key = "root-key"
workspace_id = "hermes"
host = "hermes"
base_url = None
ai_peer = "hermes"
peer_name = "eri"
recall_mode = "hybrid"
user_observe_me = True
user_observe_others = False
ai_observe_me = False
ai_observe_others = True
write_frequency = "async"
session_strategy = "per-session"
context_tokens = 800
dialectic_reasoning_level = "low"
reasoning_level_cap = "high"
reasoning_heuristic = True
def resolve_session_name(self):
return "hermes"
monkeypatch.setattr(honcho_cli, "_read_config", lambda: {"apiKey": "***"})
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default")
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda host=None: FakeConfig(),
)
monkeypatch.setattr(
"plugins.memory.honcho.client.get_honcho_client",
lambda cfg: object(),
)
def _boom(hcfg, client):
raise RuntimeError("Invalid API key")
monkeypatch.setattr(honcho_cli, "_show_peer_cards", _boom)
monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace())
honcho_cli.cmd_status(SimpleNamespace(all=False))
out = capsys.readouterr().out
assert "FAILED (Invalid API key)" in out
assert "Connection... OK" not in out
class TestCloneHonchoForProfile:
"""Regression tests for clone_honcho_for_profile identity-key carryover.
PR #27371 added userPeerAliases, runtimePeerPrefix, and pinPeerName as
host-scoped identity-mapping config. These keys must survive profile
cloning, otherwise a new profile silently fragments memory by resolving
gateway users to raw runtime IDs instead of operator-declared peers.
"""
def _setup_clone_env(self, monkeypatch, tmp_path, cfg):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
cfg_path.write_text("{}")
monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg)
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_ensure_peer_exists", lambda host_key=None: True)
written = {}
def _write(c, path=None):
written["cfg"] = c
monkeypatch.setattr(honcho_cli, "_write_config", _write)
return honcho_cli, written
def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {
"hermes": {
"userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"},
"peerName": "eri",
},
},
}
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {
"hermes": {
"runtimePeerPrefix": "telegram_",
"peerName": "eri",
},
},
}
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {
"hermes": {
"pinPeerName": True,
"peerName": "eri",
},
},
}
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
assert new_block["pinPeerName"] is True
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
assert "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block
assert "pinPeerName" not in new_block
class TestSetupWizardDeploymentShape:
"""The deployment-shape step writes pinPeerName / userPeerAliases /
runtimePeerPrefix based on the operator's chosen shape.
Single-operator deployments collapse all platforms to peerName.
Multi-user gateways leave the resolver to route per-runtime.
Hybrid deployments alias the operator's own runtime IDs only.
These tests script the interactive _prompt calls and assert the
resulting hermes_host block, so the wizard's deployment-shape
semantics stay locked even as adjacent prompts are added.
"""
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
cfg_path.write_text("{}")
cfg = initial_cfg if initial_cfg is not None else {"apiKey": "***"}
monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg)
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None)
# Bypass config.yaml + connection test side effects.
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {"memory": {}}, raising=False,
)
monkeypatch.setattr(
"hermes_cli.config.save_config", lambda c: None, raising=False,
)
class _FakeClientCfg:
def resolve_session_name(self):
return "hermes-test"
workspace_id = "hermes"
peer_name = "eri"
ai_peer = "hermetika"
observation_mode = "directional"
write_frequency = "async"
recall_mode = "hybrid"
session_strategy = "per-session"
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda host=None: _FakeClientCfg(),
)
monkeypatch.setattr(
"plugins.memory.honcho.client.reset_honcho_client",
lambda: None,
)
monkeypatch.setattr(
"plugins.memory.honcho.client.get_honcho_client",
lambda hcfg: object(),
)
# Scripted _prompt: pop answers in order. Default-return for unconsumed prompts.
answer_iter = iter(answers)
def _scripted_prompt(label, default=None, secret=False):
try:
return next(answer_iter)
except StopIteration:
return default if default is not None else ""
monkeypatch.setattr(honcho_cli, "_prompt", _scripted_prompt)
honcho_cli.cmd_setup(SimpleNamespace())
return cfg["hosts"]["hermes"]
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"single", # deployment shape ← key answer
# remaining prompts fall through to defaults
]
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {
"userPeerAliases": {"old": "stale"},
"runtimePeerPrefix": "old_",
}},
}
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {}
assert host["runtimePeerPrefix"] == "telegram_"
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"hybrid", # deployment shape
"86701400", # telegram uid
"491827364", # discord snowflake
"", # slack (skip)
"", # matrix (skip)
"", # runtime peer prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {
"86701400": "eri",
"491827364": "eri",
}
assert "runtimePeerPrefix" not in host
def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path):
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {
"pinPeerName": True,
"userPeerAliases": {"keep": "me"},
"runtimePeerPrefix": "keep_",
}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes", "skip",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["userPeerAliases"] == {"keep": "me"}
assert host["runtimePeerPrefix"] == "keep_"
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
"""Flipping single → multi triggers a warning that auto-steers the
operator to ``hybrid`` (default), so their own runtime IDs keep
landing on peerName instead of orphaning the pinned-pool history.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"86701400", # telegram uid
"", # discord (skip)
"", # slack (skip)
"", # matrix (skip)
"", # runtime prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {"86701400": "eri"}
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
"""Operator can override the steer by answering ``yes`` and accept
the orphaning consequences. This is the explicit undo-the-pin path.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # deployment shape — triggers the guard
"yes", # guard response: confirm multi
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {}
assert host["runtimePeerPrefix"] == "telegram_"