Files
hermes-agent/tests/hermes_cli/test_sessions_repair_profiles.py
teknium1 b9616fe1d1 test(sessions): trim the repair-profiles title-clash tests to two invariants
Six parametrized cases collapse to two: the clash on the batch's first row with a title at
MAX_TITLE_LENGTH (the suffix must still fit) and an import-stage failure whose lineage waits
for the next run. Assertions are unchanged.
2026-09-23 07:01:11 -07:00

337 lines
17 KiB
Python

"""``hermes sessions repair-profiles``: the backward-looking half of the per-profile store model.
The forward-only fixes (#88734 store routing, the #88381 inheritance fence, #76423 topic labels,
#75198 voice keys) put NEW state in the right place; nothing settled what earlier releases left
crossed. One fixture carries all six kinds of crossing; the contracts are that a dry run mutates
nothing, that apply settles every repairable finding, and that a second apply finds nothing.
Part of #88715.
"""
from __future__ import annotations
import json
import sqlite3
from argparse import Namespace
from pathlib import Path
import pytest
from hermes_state import SessionDB
ROOT_KEY = "agent:main:telegram:dm:100"
ACME_KEY = "agent:acme:telegram:dm:200"
BETA_KEY = "agent:beta:telegram:dm:300"
GHOST_KEY = "agent:ghost:telegram:dm:400"
@pytest.fixture
def homes(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
root = tmp_path / ".hermes"
root.mkdir()
monkeypatch.setenv("HERMES_HOME", str(root))
from hermes_cli.profiles import create_profile
for name in ("acme", "beta"):
create_profile(name, no_alias=True, no_skills=True)
return {"default": root, "acme": root / "profiles" / "acme", "beta": root / "profiles" / "beta"}
def _session(db: SessionDB, sid: str, key: str, *, profile: str, parent=None, messages=2, chat_id=None,
source="telegram"):
db.create_session(sid, source, session_key=key, chat_id=chat_id or key.rsplit(":", 1)[-1],
chat_type="dm", profile_name=profile, parent_session_id=parent, system_prompt="sys")
for i in range(messages):
db.append_message(sid, "user" if i % 2 == 0 else "assistant", f"{sid} m{i}")
def _seed_crossings(homes):
"""Every defect the command knows, spread over the three stores."""
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
beta = SessionDB(homes["beta"] / "state.db")
scope = str((homes["default"] / "sessions").resolve())
# healthy controls — must survive untouched
_session(root, "ok-root", ROOT_KEY, profile="default")
_session(acme, "ok-acme", ACME_KEY, profile="acme")
root.save_gateway_routing_entry(ACME_KEY, json.dumps({"session_key": ACME_KEY, "session_id": "ok-acme"}), scope=scope)
# 1. label ≠ key namespace (row in the right store)
_session(acme, "mislabelled", "agent:acme:telegram:dm:201", profile="default")
# 2. wrong store: acme's key in the root store, with a compressed parent it points at (moves whole)
_session(root, "stray-parent", "agent:acme:telegram:dm:202", profile="acme", messages=3)
_session(root, "stray-child", "agent:acme:telegram:dm:202", profile="acme", parent="stray-parent")
# …and a legacy agent:main row inside acme's store (reported, not repaired by default)
_session(acme, "legacy-main", "agent:main:telegram:dm:203", profile="default")
# …and a row keyed to a profile that does not exist
_session(root, "ghost", GHOST_KEY, profile="ghost")
# 3. parent crossing namespaces inside beta's store: beta's own child forked from an acme row
# (which is itself a wrong-store row and moves out — the child must not follow a pointer
# into another profile's store)
_session(beta, "beta-parent", BETA_KEY, profile="beta")
_session(beta, "acme-in-beta", "agent:acme:telegram:dm:204", profile="acme")
_session(beta, "beta-child", "agent:beta:telegram:dm:301", profile="beta", parent="acme-in-beta")
# 4. routing rows: beta's index row copied into beta's store (#66887) + a ghost row in the root
beta.save_gateway_routing_entry(BETA_KEY, json.dumps({"session_key": BETA_KEY, "session_id": "beta-parent"}), scope=scope)
root.save_gateway_routing_entry(GHOST_KEY, json.dumps({"session_key": GHOST_KEY, "session_id": "ghost"}), scope=scope)
# 5. profile-less topic binding + voice-mode entry for a chat only acme's bot holds
acme.bind_telegram_topic(chat_id="200", thread_id="7", user_id="200", session_key=ACME_KEY,
session_id="ok-acme", profile_name="default")
(homes["default"] / "gateway_voice_mode.json").write_text(json.dumps({
"telegram:200": "all", # acme's chat, unprefixed
"telegram:100": "voice_only", # the default bot's chat — legitimately unprefixed
}))
# 6. sessions.json mirror entry for the ghost namespace
(homes["default"] / "sessions").mkdir(exist_ok=True)
(homes["default"] / "sessions" / "sessions.json").write_text(json.dumps({
"_README": "x", GHOST_KEY: {"session_key": GHOST_KEY, "session_id": "ghost"},
ROOT_KEY: {"session_key": ROOT_KEY, "session_id": "ok-root"},
}))
for db in (root, acme, beta):
db.close()
def _dump(path: Path) -> dict:
"""Every user table as sorted row tuples — the whole-file mutation oracle for the dry run."""
conn = sqlite3.connect(path)
try:
tables = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' "
"AND name NOT LIKE '%_fts%' ORDER BY name")]
return {t: sorted(map(repr, conn.execute(f"SELECT * FROM {t}").fetchall())) for t in tables}
finally:
conn.close()
def _run(**kw) -> int:
from hermes_cli.sessions_cmd import cmd_sessions
args = Namespace(sessions_action="repair-profiles", apply=False, json=False, yes=True, legacy_main="report")
for k, v in kw.items():
setattr(args, k, v)
return cmd_sessions(args) or 0
def _report() -> dict:
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
assert _run(json=True) == 0
return json.loads(buf.getvalue())
def test_dry_run_names_every_crossing_and_changes_nothing(homes, capsys):
_seed_crossings(homes)
before = {name: _dump(home / "state.db") for name, home in homes.items()}
files_before = {p.name: p.read_text() for p in (homes["default"] / "gateway_voice_mode.json",
homes["default"] / "sessions" / "sessions.json")}
report = _report()
kinds = {(f["kind"], f["subject"]) for f in report["findings"]}
assert {
("mislabelled", "mislabelled"),
("wrong_store", "stray-parent"), ("wrong_store", "stray-child"),
("legacy_main", "legacy-main"),
("unclaimed_namespace", "ghost"),
("wrong_store", "acme-in-beta"), ("crossed_parent", "beta-child"),
("routing_unclaimed", f"{GHOST_KEY} (scope {str((homes['default'] / 'sessions').resolve())!r})"),
("topic_profile_less", "chat 200 thread 7"),
("voice_profile_less", "telegram:200"),
("sessions_json_unclaimed", GHOST_KEY),
} <= kinds
# beta's own routing row: a standalone gateway's index lives in its own store; nothing recorded a
# multiplexing verdict here, so it is NOT reported as stray.
assert not any(f["kind"] == "routing_stray" for f in report["findings"])
# the healthy controls and the default bot's own voice key are not findings
assert not any(f["subject"] in {"ok-root", "ok-acme", "telegram:100", ROOT_KEY} for f in report["findings"])
# the two report-only kinds say why
by_kind = {f["kind"]: f for f in report["findings"]}
assert by_kind["legacy_main"]["action"] is None and "--legacy-main" in by_kind["legacy_main"]["reason"]
assert by_kind["unclaimed_namespace"]["action"] is None and "ghost" in by_kind["unclaimed_namespace"]["reason"]
assert {name: _dump(home / "state.db") for name, home in homes.items()} == before
assert {p.name: p.read_text() for p in (homes["default"] / "gateway_voice_mode.json",
homes["default"] / "sessions" / "sessions.json")} == files_before
def test_apply_settles_every_repairable_crossing_and_is_idempotent(homes, monkeypatch, capsys):
_seed_crossings(homes)
snapshots = []
monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot",
lambda store: snapshots.append(store.profile) or f"snap-{store.profile}")
assert _run(apply=True) == 0
assert set(snapshots) == {"default", "acme", "beta"}
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
beta = SessionDB(homes["beta"] / "state.db")
try:
# 1. relabelled from the key
assert acme.get_session("mislabelled")["profile_name"] == "acme"
# 2. moved whole: parent link and every message intact in the target, gone from the source
assert root.get_session("stray-parent") is None and root.get_session("stray-child") is None
moved_child = acme.get_session("stray-child")
assert moved_child["profile_name"] == "acme" and moved_child["parent_session_id"] == "stray-parent"
assert len(acme.get_messages("stray-parent")) == 3 and len(acme.get_messages("stray-child")) == 2
assert acme.get_session("stray-parent")["system_prompt"] == "sys"
# legacy agent:main row and the ghost row are left exactly as they were
assert acme.get_session("legacy-main")["session_key"] == "agent:main:telegram:dm:203"
assert root.get_session("ghost")["session_key"] == GHOST_KEY
# 3. severed, own identity kept; the acme row it pointed at moved to acme's store
child = beta.get_session("beta-child")
assert child["parent_session_id"] is None and child["profile_name"] == "beta"
assert beta.get_session("acme-in-beta") is None and acme.get_session("acme-in-beta") is not None
# 4. ghost routing row dropped, acme's healthy row kept, beta's own row kept (standalone)
scope = str((homes["default"] / "sessions").resolve())
assert set(root.load_gateway_routing_entries(scope=scope)) == {ACME_KEY}
assert set(beta.load_gateway_routing_entries(scope=scope)) == {BETA_KEY}
# 5. topic binding carries acme; voice key prefixed only for acme's chat
assert acme.get_telegram_topic_binding(chat_id="200", thread_id="7", profile_name="acme") is not None
assert acme.get_telegram_topic_binding(chat_id="200", thread_id="7", profile_name="default") is None
voice = json.loads((homes["default"] / "gateway_voice_mode.json").read_text())
assert voice == {"acme:telegram:200": "all", "telegram:100": "voice_only"}
# 6. ghost mirror entry dropped, the default's kept
mirror = json.loads((homes["default"] / "sessions" / "sessions.json").read_text())
assert set(mirror) == {"_README", ROOT_KEY}
# controls untouched
assert root.get_session("ok-root")["profile_name"] == "default"
assert acme.get_session("ok-acme")["profile_name"] == "acme"
finally:
for db in (root, acme, beta):
db.close()
# idempotent: only the two report-only findings remain, and nothing is repairable
report = _report()
assert {f["kind"] for f in report["findings"]} == {"legacy_main", "unclaimed_namespace"}
assert report["repairable"] == 0
def test_apply_refuses_while_a_gateway_owns_a_store(homes, monkeypatch, capsys):
_seed_crossings(homes)
monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.live_gateway_homes",
lambda stores: [("default", 4242)])
before = _dump(homes["default"] / "state.db")
assert _run(apply=True) == 1
assert "pid 4242" in capsys.readouterr().err
assert _dump(homes["default"] / "state.db") == before
def test_legacy_main_rekey_adopts_a_standalone_gateways_history(homes, monkeypatch):
"""The #113884 incident: multiplexing switched on, every existing key of the named profile's own
gateway stopped resolving. ``--legacy-main rekey`` gives them the namespace the multiplexer reads."""
_seed_crossings(homes)
monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot", lambda store: "snap")
assert _run(apply=True, legacy_main="rekey") == 0
acme = SessionDB(homes["acme"] / "state.db")
try:
row = acme.get_session("legacy-main")
assert row["session_key"] == "agent:acme:telegram:dm:203" and row["profile_name"] == "acme"
finally:
acme.close()
assert not any(f["kind"] == "legacy_main" for f in _report()["findings"])
def test_move_into_a_store_that_already_holds_the_title(homes, monkeypatch):
"""Titles are unique per store only. A stranded row whose title an unrelated session of the
target profile already holds used to raise on the unique-title index, fail every row of the
batch, and leave the rows copied before it in both stores, on every run. The clash is the
FIRST row of the batch and its title is at the length cap, so the suffix must still fit."""
title = "G" * SessionDB.MAX_TITLE_LENGTH
monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot", lambda store: "snap")
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
try:
_session(root, "s-a", "agent:acme:telegram:dm:210", profile="acme")
_session(root, "s-b", "agent:acme:telegram:dm:211", profile="acme", messages=3)
_session(acme, "w-1", ACME_KEY, profile="acme")
clash, other = "s-a", "s-b"
root.set_session_title(clash, title)
root.set_session_title(other, "Weekend plans")
acme.set_session_title("w-1", title)
finally:
root.close()
acme.close()
assert _run(apply=True) == 0
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
try:
assert root.get_session("s-a") is None and root.get_session("s-b") is None
assert len(acme.get_messages("s-a")) == 2 and len(acme.get_messages("s-b")) == 3
assert acme.get_session(other)["title"] == "Weekend plans"
# the resident row keeps the name, so resolving it by title still finds it
assert acme.get_session("w-1")["title"] == title
assert acme.resolve_session_by_title(title) == "w-1"
moved = acme.get_session(clash)["title"]
assert moved not in (None, title) and len(moved) <= SessionDB.MAX_TITLE_LENGTH
finally:
root.close()
acme.close()
assert not any(f["kind"] == "wrong_store" for f in _report()["findings"])
def test_a_failing_row_moves_alone_and_its_lineage_waits_with_it(homes, monkeypatch, capsys):
"""One row that cannot move is that row's failure: an unrelated row of the same batch still
moves, the batch is not re-run per finding, and the failed row's lineage stays linked so the
next run moves it whole. The failure is at import (the shape a unique-index clash takes)."""
stage = "import"
monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot", lambda store: "snap")
root = SessionDB(homes["default"] / "state.db")
try:
_session(root, "gp", "agent:acme:telegram:dm:220", profile="acme")
_session(root, "par", "agent:acme:telegram:dm:220", profile="acme", parent="gp")
_session(root, "kid", "agent:acme:telegram:dm:220", profile="acme", parent="par")
_session(root, "lone", "agent:acme:telegram:dm:221", profile="acme")
finally:
root.close()
method = "import_moved_session" if stage == "import" else "delete_moved_session"
real = getattr(SessionDB, method)
calls = []
def flaky(self, arg, **kw):
sid = arg["session"]["id"] if stage == "import" else arg
calls.append(sid)
if sid == "par":
raise sqlite3.OperationalError("disk I/O error")
return real(self, arg, **kw)
monkeypatch.setattr(SessionDB, method, flaky)
assert _run(apply=True) == 1
out = capsys.readouterr().out
assert "wrong_store par: OperationalError: disk I/O error" in out
assert sorted(calls) == sorted(set(calls)), "the batch was re-run for a later finding"
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
try:
assert root.get_session("lone") is None and acme.get_session("lone") is not None
if stage == "import":
assert "wrong_store kid:" in out
# the child never lands parentless; the grandparent stays for the parent that points at it
assert acme.get_session("kid") is None and acme.get_session("par") is None
assert root.get_session("kid")["parent_session_id"] == "par"
assert root.get_session("par")["parent_session_id"] == "gp"
finally:
root.close()
acme.close()
monkeypatch.setattr(SessionDB, method, real)
assert _run(apply=True) == 0
root = SessionDB(homes["default"] / "state.db")
acme = SessionDB(homes["acme"] / "state.db")
try:
assert all(root.get_session(sid) is None for sid in ("gp", "par", "kid", "lone"))
assert acme.get_session("par")["parent_session_id"] == "gp"
assert acme.get_session("kid")["parent_session_id"] == "par"
finally:
root.close()
acme.close()
assert not any(f["kind"] == "wrong_store" for f in _report()["findings"])