"""Issue #78: multi-agent-mux-reset — unit tests for lib_py.reset and end-to-end tests for reset.sh (populated across Commits 3 and 4). """ from __future__ import annotations import ast import json import os import shlex import subprocess import sys from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[1] SKILLS = REPO / ".agents" / "skills" if str(SKILLS) not in sys.path: sys.path.insert(0, str(SKILLS)) UA = "a7800001-0000-4000-8000-000000000001" UB = "b7800002-0000-4000-8000-000000000002" UC = "c7800003-0000-4000-8000-000000000003" UD = "d7800004-0000-4000-8000-000000000004" UORC = "e7800005-0000-4000-8000-000000000005" pytestmark = pytest.mark.usefixtures("own_workspace_root") def _row( name: str, agent: str, cwd: Path, role: str = "reviewer", status: str = "running", created_at: str = "2026-10-01T00:00:00Z", server: str = "default", label: str = "ws", own_id: str | None = None, pid: int = 4242, ) -> dict: from lib_py.agents.registry import own_key key = own_key(agent) if agent in ("claude", "agy", "codex", "grok", "hermes", "opencode") else "unknown_id" return { "name": name, "status": status, "role": role, "agent": agent, "herdr_session": server, "herdr_server": server, "herdr_workspace": label, "herdr_session_created_at": created_at, key: own_id, "pane": {"cwd": str(cwd), "pid": pid, "cmd": agent}, } def _run_reset_py(args: list[str], state: dict | None = None, stdin_text: str | None = None, env_extra: dict | None = None): env = dict(os.environ) env["PYTHONPATH"] = str(SKILLS) env["PYTHONDONTWRITEBYTECODE"] = "1" if state is not None: env["MAM_STATE_JSON"] = json.dumps(state) if env_extra: env.update(env_extra) return subprocess.run( [sys.executable, "-m", "lib_py.reset", *args], env=env, input=stdin_text, capture_output=True, text=True, timeout=15, ) # --------------------------------------------------------------------------- Unit tests: lib_py.reset def test_reset_py_python39_syntax(): src = (SKILLS / "lib_py" / "reset.py").read_text(encoding="utf-8") ast.parse(src, filename="reset.py", feature_version=(3, 9)) def test_select_targets_and_plan_cli(tmp_path: Path): """§5.2-1: select_targets chooses only status==running rows at realpath(WS), resolving symlinks, listing skipped rows with reasons and orchestrator_uuids, and refusing running control-root rows with missing agent or empty role. """ ws = tmp_path / "ws" ws.mkdir() wt = ws / "wt" / "x" wt.mkdir(parents=True) sym_ws = tmp_path / "sym-ws" sym_ws.symlink_to(ws) state = { "orchestrator_uuids": [UORC], "herdr_sessions": [ _row("reviewer-claude-01", "claude", ws, role="planner,reviewer", own_id=UA, server="srv1", label="lbl1"), _row("reviewer-grok-01", "grok", sym_ws, role="reviewer", own_id=UB, server="default", label="lbl2"), _row("creator-claude-wt", "claude", wt, role="creator", own_id=UC), _row("stopped-codex-01", "codex", ws, status="stopped", own_id=UD), _row("dead-hermes-01", "hermes", ws, status="terminated", own_id=None), ], } res = _run_reset_py(["plan", "--workspace", str(ws)], state=state) assert res.returncode == 0, res.stderr plan = json.loads(res.stdout) assert [t["name"] for t in plan["targets"]] == ["reviewer-claude-01", "reviewer-grok-01"] assert plan["targets"][0] == { "name": "reviewer-claude-01", "agent": "claude", "role": "planner,reviewer", "herdr_server": "srv1", "herdr_workspace": "lbl1", "old_id": UA, "old_created_at": "2026-10-01T00:00:00Z", } skipped = {s["name"]: s["reason"] for s in plan["skipped"]} assert "not the control root" in skipped["creator-claude-wt"] assert skipped["stopped-codex-01"] == "status stopped" assert skipped["dead-hermes-01"] == "status terminated" assert plan["orchestrators"] == [UORC] assert plan["errors"] == [] # Empty role or unknown agent on a running control-root row causes plan & precheck to return 1 bad_state = { "herdr_sessions": [ _row("bad-role-claude", "claude", ws, role="", own_id=UA), { "name": "mystery-row", "status": "running", "role": "reviewer", "agent": "not-an-agent", "pane": {"cwd": str(ws), "pid": 123, "cmd": "bash"}, }, ] } res_bad = _run_reset_py(["plan", "--workspace", str(ws)], state=bad_state) assert res_bad.returncode == 1 bad_plan = json.loads(res_bad.stdout) assert len(bad_plan["errors"]) == 2 res_pre = _run_reset_py(["precheck", "--workspace", str(ws)], state=bad_state) assert res_pre.returncode == 1 assert "bad-role-claude" in res_pre.stdout assert "mystery-row" in res_pre.stdout def test_spec_file_write_read_and_validation(tmp_path: Path): """D5: write-spec atomically writes .mam/reset-spec.json; corrupt, wrong-version, or wrong-workspace spec files are rejected with 'check it, then remove it by hand'. """ from lib_py.reset import SpecError, read_spec ws = tmp_path / "ws" ws.mkdir() targets = [ { "name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": UA, "old_created_at": "2026-10-01T00:00:00Z", } ] res = _run_reset_py( ["write-spec", "--workspace", str(ws)], stdin_text=json.dumps({"targets": targets}), ) assert res.returncode == 0, res.stderr spath = ws / ".mam" / "reset-spec.json" assert spath.is_file() doc = read_spec(str(ws)) assert doc["version"] == 1 assert doc["workspace"] == os.path.realpath(str(ws)) assert doc["agents"] == targets # Wrong workspace other_ws = tmp_path / "other" other_ws.mkdir() with pytest.raises(SpecError, match="check it, then remove it by hand"): read_spec(str(other_ws)) # Wrong version spath.write_text(json.dumps({"version": 99, "workspace": str(ws), "agents": []})) with pytest.raises(SpecError, match="check it, then remove it by hand"): read_spec(str(ws)) # Corrupt JSON spath.write_text("{corrupt") with pytest.raises(SpecError, match="check it, then remove it by hand"): read_spec(str(ws)) res_entries = _run_reset_py(["entries", "--workspace", str(ws)], state={}) assert res_entries.returncode == 1 assert "check it, then remove it by hand" in res_entries.stderr def test_entries_5_states_and_resumed_row_9b(tmp_path: Path): """D6 1-5 & §5.2-9b: #1 row absent -> purged #2 same created_at (even if normal stop recorded a new id onto old_id=None) -> pending #3 new created_at and id == old_id -> pending (resumed original conversation, 9b) #4 new created_at and id empty -> created #5 new created_at and id != old_id -> done """ from lib_py.reset import write_spec ws = tmp_path / "ws" ws.mkdir() spec_agents = [ { "name": "s1-purged", "agent": "claude", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": UA, "old_created_at": "2026-10-01T00:00:00Z", }, { "name": "s2-same-inst-newly-recorded", "agent": "agy", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": None, "old_created_at": "2026-10-01T00:00:00Z", }, { "name": "s3-resumed-same-id", "agent": "claude", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": UB, "old_created_at": "2026-10-01T00:00:00Z", }, { "name": "s4-created-no-id", "agent": "hermes", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": UC, "old_created_at": "2026-10-01T00:00:00Z", }, { "name": "s5-done", "agent": "codex", "role": "reviewer", "herdr_server": "default", "herdr_workspace": "ws", "old_id": None, "old_created_at": "2026-10-01T00:00:00Z", }, ] write_spec(str(ws), spec_agents) state = { "herdr_sessions": [ # #2: normal stop wrote UA onto original row (same created_at, status stopped) -> still pending! _row( "s2-same-inst-newly-recorded", "agy", ws, status="stopped", created_at="2026-10-01T00:00:00Z", own_id=UA, ), # #3 (9b): resumed into a new herdr session (different created_at), but same conversation ID UB -> pending! _row( "s3-resumed-same-id", "claude", ws, status="running", created_at="2026-10-02T00:00:00Z", own_id=UB, ), # #4: newly created session, ID not discovered yet -> created _row( "s4-created-no-id", "hermes", ws, status="running", created_at="2026-10-02T00:00:00Z", own_id=None, ), # #5: newly created session, new ID UD recorded (old_id was None) -> done _row( "s5-done", "codex", ws, status="running", created_at="2026-10-02T00:00:00Z", own_id=UD, ), ] } res = _run_reset_py(["entries", "--workspace", str(ws)], state=state) assert res.returncode == 0, res.stderr parsed = {} for line in res.stdout.strip().splitlines(): kv = dict(tok.split("=", 1) for tok in shlex.split(line)) parsed[kv["R_NAME"]] = kv assert parsed["s1-purged"]["R_STATE"] == "purged" assert parsed["s2-same-inst-newly-recorded"]["R_STATE"] == "pending" assert parsed["s2-same-inst-newly-recorded"]["R_ROW_ID"] == UA assert parsed["s3-resumed-same-id"]["R_STATE"] == "pending" assert parsed["s4-created-no-id"]["R_STATE"] == "created" assert parsed["s5-done"]["R_STATE"] == "done" # Check D8 table formatting for these 5 rows (including 9b NEW!=OLD == "no" and 3-1 OLD_ID="-", NEW!=OLD="yes") res_tbl = _run_reset_py( [ "table", "--workspace", str(ws), "--skipped", "s2-same-inst-newly-recorded:purge_refused", ], state=state, ) assert res_tbl.returncode == 4 # 4 takes precedence over 5 rows_by_name = {} for line in res_tbl.stdout.strip().splitlines()[1:]: cols = line.split(None, 6) rows_by_name[cols[0]] = cols # s2: original row where step 1 captured UA, then step 3 refused purge -> NEW_ID is '-', RECORDED 'no', NEW!=OLD '-' assert rows_by_name["s2-same-inst-newly-recorded"][1:6] == ["agy", "-", "-", "no", "-"] assert rows_by_name["s2-same-inst-newly-recorded"][6] == "skipped: purge refused" # s3 (9b): resumed row with same ID UB -> NEW_ID is UB, RECORDED 'yes', NEW!=OLD 'no' assert rows_by_name["s3-resumed-same-id"][1:6] == ["claude", UB, UB, "yes", "no"] # s4: created, no ID yet -> NEW_ID '-', RECORDED 'no', NEW!=OLD '-', RESULT 'created; id not recorded yet' assert rows_by_name["s4-created-no-id"][1:6] == ["hermes", UC, "-", "no", "-"] assert rows_by_name["s4-created-no-id"][6] == "created; id not recorded yet" # s5 (3-1): old_id was None, new_id is UD -> OLD_ID '-', NEW_ID UD, RECORDED 'yes', NEW!=OLD 'yes', RESULT 'done' assert rows_by_name["s5-done"][1:6] == ["codex", "-", UD, "yes", "yes"] assert rows_by_name["s5-done"][6] == "done" def test_precheck_open_jobs_and_caller_target(tmp_path: Path): """D4-5: precheck reports open jobs with close command and refuses when caller is a target.""" ws = tmp_path / "ws" jobs_dir = ws / ".mam" / "jobs" jobs_dir.mkdir(parents=True) (jobs_dir / "j123.json").write_text( json.dumps( { "job_id": "j123", "agent_session": "herdr:reviewer-claude-01", "status": "running", } ) ) state = { "herdr_sessions": [ _row("reviewer-claude-01", "claude", ws, own_id=UA, pid=os.getpid()), ] } res = _run_reset_py( ["precheck", "--workspace", str(ws)], state=state, env_extra={"CLAUDE_CODE_SESSION_ID": UA}, ) assert res.returncode == 1 assert "job j123" in res.stdout assert "close --job j123 --request cancelled" in res.stdout assert "you are reviewer-claude-01, one of the targets; run the reset from the orchestrator" in res.stdout