feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree

The single/multi/hybrid 'deployment shape' was a misnomer: these keys only
affect the gateway (the one entrypoint supplying a runtime user ID), and the
three preset names stamped a lossy taxonomy onto three orthogonal knobs while
hiding which keys got written.

Replace it with an intent-led tree gated on gateway detection:
- _gateway_platforms() lazily inspects the gateway config (best-effort, no
  hard dependency); the step auto-skips when no platform is connected.
- 'who talks to this?' → just me / me+others (pooled?) / only others, deriving
  pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result.
- [e] drops to a raw-knob editor for power users.
- The single→multi orphan guard survives as a pooling steer.
This commit is contained in:
Erosika 2026-06-10 16:14:24 -04:00
parent bb5cb32838
commit d7dfeed6dc
2 changed files with 303 additions and 155 deletions

View file

@ -323,19 +323,20 @@ class TestCloneHonchoForProfile:
class TestSetupWizardDeploymentShape:
"""The deployment-shape step writes pinPeerName / userPeerAliases /
runtimePeerPrefix based on the operator's chosen shape.
"""The gateway identity-mapping tree writes pinUserPeer / userPeerAliases /
runtimePeerPrefix based on the operator's intent.
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.
Choice [1] (just me) collapses all platforms to peerName.
Choice [3] (only other people) leaves the resolver to route per-runtime.
Choice [2] (me + others, pooled) aliases the operator's own runtime IDs.
These tests script the interactive _prompt calls and assert the
resulting hermes_host block, so the wizard's deployment-shape
These tests mock gateway detection and script the interactive _prompt
calls, asserting the resulting hermes_host block so the tree's routing
semantics stay locked even as adjacent prompts are added.
"""
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None,
gateway_platforms=("telegram",)):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
@ -348,6 +349,10 @@ class TestSetupWizardDeploymentShape:
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)
# Gate detection is mocked so tests control whether the tree runs.
# None → undetectable; list (possibly empty) → connected platforms.
gw = None if gateway_platforms is None else list(gateway_platforms)
monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw)
# Bypass config.yaml + connection test side effects.
monkeypatch.setattr(
@ -393,14 +398,14 @@ class TestSetupWizardDeploymentShape:
honcho_cli.cmd_setup(SimpleNamespace())
return cfg["hosts"]["hermes"]
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
def test_just_me_pins_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
"1", # tree: just me ← key answer
# remaining prompts fall through to defaults
]
initial_cfg = {
@ -415,14 +420,14 @@ class TestSetupWizardDeploymentShape:
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
def test_only_others_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
"3", # tree: only other people
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
@ -433,14 +438,15 @@ class TestSetupWizardDeploymentShape:
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
def test_pooled_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
"2", # tree: me + other people
"y", # keep my memory pooled? → hybrid
"86701400", # telegram uid
"491827364", # discord snowflake
"", # slack (skip)
@ -467,7 +473,7 @@ class TestSetupWizardDeploymentShape:
}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes", "skip",
"cloud", "", "eri", "hermetika", "hermes", "s",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is True
@ -475,10 +481,10 @@ class TestSetupWizardDeploymentShape:
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.
def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path):
"""Choosing 'only other people' on a currently-pinned profile triggers
the orphan warning, which auto-steers to pooled (hybrid) so the
operator's own runtime IDs keep landing on peerName.
"""
initial_cfg = {
"apiKey": "***",
@ -490,8 +496,8 @@ class TestSetupWizardDeploymentShape:
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"3", # tree: only others — triggers the orphan guard
"y", # pool my own memory instead? → hybrid
"86701400", # telegram uid
"", # discord (skip)
"", # slack (skip)
@ -502,42 +508,40 @@ class TestSetupWizardDeploymentShape:
assert host["pinUserPeer"] 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.
"""
def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path):
"""Operator can decline the steer ('n') and accept orphaning, ending
up with per-user peers (no aliases)."""
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
"3", # tree: only others — triggers the orphan guard
"n", # decline pooling, accept orphaning
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is False
# See test_multi_shape_leaves_pin_false_and_accepts_prefix.
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):
"""Host-level ``pinUserPeer: true`` must classify as ``single``.
Pressing Enter at the shape prompt then preserves the pin instead
of falling through to ``multi`` and orphaning the user's memory
pool the bug the wizard regressed when ``pinUserPeer`` landed
as a higher-precedence alias.
Pressing Enter at the choice prompt then preserves the pin instead
of falling through to per-user routing and orphaning the user's
memory pool the bug the wizard regressed when ``pinUserPeer``
landed as a higher-precedence alias.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}},
}
# Exhaust the iterator before the shape prompt so the scripted
# mock falls through to the prompt's default (which is the
# wizard-detected shape). Scripting an explicit "" would NOT
# exercise that fallthrough — the mock returns it literally.
# Exhaust the iterator before the choice prompt so the scripted
# mock falls through to the prompt's default (the detected shape →
# choice "1"). Scripting an explicit "" would NOT exercise that
# fallthrough — the mock returns it literally.
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
# Scrub-then-write normalises onto the canonical pinUserPeer.
@ -581,16 +585,16 @@ class TestSetupWizardDeploymentShape:
# operator edits live on the host block they're inspecting.
assert host["userPeerAliases"] == {"86701400": "eri"}
def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicit ``multi`` must leave the host ``userPeerAliases`` key
absent, preserving any root-level aliases as a cross-host baseline.
def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicitly choosing 'only other people' must leave the host
``userPeerAliases`` key absent, preserving any root-level aliases as a
cross-host baseline.
Picking ``multi`` here is an active choice detection would have
defaulted to ``hybrid`` because root aliases exist so the
operator's intent is to drop the alias mapping for this host.
We honor that by writing ``pinUserPeer: false`` only, and rely
on the host's absence of ``userPeerAliases`` to inherit root.
That inheritance is intentional: a true wipe would require the
Picking [3] here is an active choice detection would have defaulted
to [2]/hybrid because root aliases exist so the operator's intent is
to drop the alias mapping for this host. We honor that by writing
``pinUserPeer: false`` only, relying on the host's absence of
``userPeerAliases`` to inherit root. A true wipe would require the
operator to delete the root key explicitly.
"""
initial_cfg = {
@ -600,14 +604,14 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # explicit multi override of detected hybrid
"3", # explicit per-user override of detected hybrid
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing ``single`` must overwrite a stale ``pinUserPeer: false``
def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing 'just me' must overwrite a stale ``pinUserPeer: false``
with ``pinUserPeer: true`` so the profile ends up genuinely pinned.
"""
initial_cfg = {
@ -619,11 +623,57 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"single",
"1",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is True
def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""With no gateway platforms connected, the tree is gated off; declining
the 'configure anyway?' prompt leaves identity mapping untouched."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=[],
)
assert "pinUserPeer" not in host
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""When the gateway package can't be inspected (None), the wizard asks
whether the gateway is running; 'no' skips the mapping step."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=None,
)
assert "pinUserPeer" not in host
def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path):
"""The [e] escape hatch lets a power user set pinUserPeer + an alias +
prefix directly, bypassing the intent tree."""
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"e", # tree: edit raw keys
"false", # pinUserPeer
"99887766=eri", # one alias pair
"", # finish aliases
"discord_", # runtimePeerPrefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"99887766": "eri"}
assert host["runtimePeerPrefix"] == "discord_"
class TestCloneCarriesPinUserPeer:
"""``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a