import os import subprocess import json import sqlite3 import pytest import shutil import yaml from pathlib import Path # Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash def run_mutation(mam_sandbox, mutation_str, env=None): lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}" run_env = dict(os.environ) if env: run_env.update(env) res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env) return res def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_agents, monkeypatch): """ Tier 3: Create Session Integration Covers pairwise combination: --onboard + --submit-job + --wrapper + HERDR_SERVER_NAME env override """ tmp_path = mam_sandbox script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" # Set HERDR_SERVER_NAME env var to test environment override combination monkeypatch.setenv("HERDR_SERVER_NAME", "custom_server") cmd = [ "bash", str(script_path), "--workspace", str(tmp_path), "--agent", "claude", "--role", "Creator", "--onboard", "--submit-job", "Test onboard prompt", "--wrapper" ] res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path)) if res.returncode != 0: with open(mock_herdr, 'r') as f: state = json.load(f) debug_info = ( f"MOCK HERDR CALLS: {state.get('calls', [])}\n" f"MOCK HERDR AGENTS: {state.get('agents', {})}\n" ) assert False, f"Stdout: {res.stdout}\nStderr: {res.stderr}\nDebug:\n{debug_info}" assert res.returncode == 0 # Verify it is registered in yaml yaml_path = tmp_path / ".mam" / "agent-sessions.yaml" assert yaml_path.exists() with open(yaml_path, 'r') as f: reg = yaml.safe_load(f) sessions = reg.get("herdr_sessions", []) assert len(sessions) == 1 session = sessions[0] assert session["role"] == "Creator" assert session.get("herdr_session") == "custom_server" assert session["delegate_job_id"] is not None assert session["status"] == "running" # Verify delegate job JSON file exists and contains correct info job_id = session["delegate_job_id"] job_file = tmp_path / ".mam" / "jobs" / f"{job_id}.json" assert job_file.exists() with open(job_file, 'r') as jf: job_data = json.load(jf) assert job_data["job_id"] == job_id assert "Test onboard prompt" in job_data["prompt"] assert job_data["agent_session"] == f"herdr:{session['name']}" def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents): """ Tier 3: Stop Session Integration Covers pairwise combination: --purge-conversation + --yes + HOME override """ tmp_path = mam_sandbox # 1. Run create_session to register a session with isolation enabled create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" cmd_create = [ "bash", str(create_script), "--workspace", str(tmp_path), "--agent", "claude", "--role", "Creator" ] res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path)) assert res_create.returncode == 0, f"Stdout: {res_create.stdout}\nStderr: {res_create.stderr}" # Get session name and isolation uuid yaml_path = tmp_path / ".mam" / "agent-sessions.yaml" with open(yaml_path, 'r') as f: reg = yaml.safe_load(f) session = reg["herdr_sessions"][0] session_name = session["name"] iso_uuid = session["isolation"]["uuid"] iso_root = Path(session["isolation"]["root"]) # Ensure isolation home root folder is provisioned assert iso_root.exists() # Locate dynamically generated conversation file in claude projects key = str(tmp_path).replace('/', '-').replace('_', '-') proj_dir = iso_root / "projects" / key assert proj_dir.exists(), f"Expected isolated projects directory {proj_dir} to exist" jsonls = list(proj_dir.glob("*.jsonl")) assert len(jsonls) == 1, f"Expected exactly 1 jsonl file in {proj_dir}, found {jsonls}" jsonl_file = jsonls[0] assert jsonl_file.exists() # Update mock herdr's state to match the running agent so the stop kill-chain works gracefully with open(mock_herdr, 'r') as f: state = json.load(f) state["agents"][session_name] = { "status": "running", "agent": "claude", "cwd": str(tmp_path), "pid": 12345, "pane_id": "w1:p1", "command": "claude", "buffer": "Anthropic Claude Ready" } with open(mock_herdr, 'w') as f: json.dump(state, f, indent=2) # 2. Calling stop_session without --yes fails for --purge-conversation stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" cmd_stop_no_yes = [ "bash", str(stop_script), "--session", session_name, "--purge-conversation" ] res_stop_no_yes = subprocess.run(cmd_stop_no_yes, capture_output=True, text=True, cwd=str(tmp_path)) assert res_stop_no_yes.returncode == 3 assert "DANGER: --purge-conversation" in res_stop_no_yes.stdout # Ensure nothing was deleted yet assert iso_root.exists() assert jsonl_file.exists() # 3. Run stop_session with --yes cmd_stop_yes = [ "bash", str(stop_script), "--session", session_name, "--purge-conversation", "--yes" ] res_stop_yes = subprocess.run(cmd_stop_yes, capture_output=True, text=True, cwd=str(tmp_path)) assert res_stop_yes.returncode == 0, f"Stdout: {res_stop_yes.stdout}\nStderr: {res_stop_yes.stderr}" # Assertions: # - Isolation directory deleted assert not iso_root.exists() # - Conversation files deleted assert not jsonl_file.exists() # - Session completely removed from YAML/DB registry with open(yaml_path, 'r') as f: reg = yaml.safe_load(f) assert not any(s["name"] == session_name for s in reg.get("herdr_sessions", [])) db_path = tmp_path / ".mam" / "agent-sessions.db" conn = sqlite3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM sessions WHERE name=?", (session_name,)) assert cursor.fetchone()[0] == 0 conn.close() def test_integration_resume_fallbacks(mam_sandbox, mock_herdr, mock_agents): """ Tier 3: Resume Session Fallbacks Covers boundary checks, invalid / missing UUIDs, and workspace disk scan fallback resolution. """ tmp_path = mam_sandbox # 1. Calling resume_session.sh with missing arguments resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" cmd_missing = ["bash", str(resume_script), "--session", "test-session"] res_missing = subprocess.run(cmd_missing, capture_output=True, text=True, cwd=str(tmp_path)) assert res_missing.returncode == 2 assert "ERROR:" in res_missing.stderr # 2. Calling with non-existent session cmd_nonexistent = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", "non-existent"] res_nonexistent = subprocess.run(cmd_nonexistent, capture_output=True, text=True, cwd=str(tmp_path)) assert res_nonexistent.returncode == 1 assert "ERROR: No saved session for" in res_nonexistent.stderr # 3. Fallback resolution: set up a stopped session in YAML with NO own ID session_name = "test-fallback-creator-claude" mutation = """ d['herdr_sessions'] = [{ 'name': 'test-fallback-creator-claude', 'status': 'stopped', 'role': 'Creator', 'pane': {'cwd': 'WS_PLACEHOLDER'} }] """.replace("WS_PLACEHOLDER", str(tmp_path)) res_mut = run_mutation(tmp_path, mutation) assert res_mut.returncode == 0 # Seed a jsonl file representing a conversation under the workspace path key = str(tmp_path).replace('/', '-').replace('_', '-') proj_dir = tmp_path / ".claude" / "projects" / key proj_dir.mkdir(parents=True, exist_ok=True) scanned_uuid = "scanned-uuid-xyz-123" (proj_dir / f"{scanned_uuid}.jsonl").write_text(json.dumps({"sessionId": scanned_uuid}) + "\n") # Run resume_session.sh cmd_resume = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", session_name] res_resume = subprocess.run(cmd_resume, capture_output=True, text=True, cwd=str(tmp_path)) assert res_resume.returncode == 0, f"Stdout: {res_resume.stdout}\nStderr: {res_resume.stderr}" # Verify the session resumed using the resolved UUID with open(mock_herdr, 'r') as f: state = json.load(f) calls = state.get("calls", []) # Find the agent start/new-session call new_sess_call = None for call in calls: if "agent" in call and "start" in call and session_name in call: new_sess_call = call break assert new_sess_call is not None, f"Calls: {calls}" # Verify the argument contains the resolved scanned_uuid assert any(scanned_uuid in arg for arg in new_sess_call), f"Resume arguments: {new_sess_call}" def test_integration_reconcile_diff_formats(mam_sandbox, mock_herdr): """ Tier 3: Reconcile Drift Detection and Diff Formatting Covers outputs of reconcile.sh --once --emit-diff under dry-run and actual mutation. """ tmp_path = mam_sandbox reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" # Seed a running session in YAML/DB that is NOT in herdr (Drift class A) session_name = "drift-a-creator-claude" mutation = """ d['herdr_sessions'] = [{ 'name': 'drift-a-creator-claude', 'status': 'running', 'role': 'Creator', 'pane': { 'cwd': 'WS_PLACEHOLDER', 'pid': 9876, 'cmd': 'claude', 'cmd_full': 'claude' } }] """.replace("WS_PLACEHOLDER", str(tmp_path)) res_mut = run_mutation(tmp_path, mutation) assert res_mut.returncode == 0 db_path = tmp_path / ".mam" / "agent-sessions.db" assert db_path.exists() # Verify DB content conn = sqlite3.connect(str(db_path)) db_rows = conn.execute("SELECT * FROM sessions").fetchall() db_state = conn.execute("SELECT * FROM state").fetchall() conn.close() # Verify YAML content yaml_path = tmp_path / ".mam" / "agent-sessions.yaml" with open(yaml_path, 'r') as f: yaml_content = f.read() # Verify load_state_json output lib_path = tmp_path / ".agents" / "skills" / "lib.sh" res_load = subprocess.run(["bash", "-c", f"source {lib_path} && load_state_json"], capture_output=True, text=True) # Verify herdr ls -F output res_ls = subprocess.run(["herdr", "ls", "-F", "#{session_name}|#{session_created}"], capture_output=True, text=True) # 1. Run reconcile with --dry-run cmd_dry = ["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"] res_dry = subprocess.run(cmd_dry, capture_output=True, text=True, cwd=str(tmp_path)) assert res_dry.returncode == 0, f"Stderr: {res_dry.stderr}" # Verify stdout is valid JSON and reports class A drift try: data_dry = json.loads(res_dry.stdout) except Exception as e: assert False, f"Failed to parse JSON. stdout: {res_dry.stdout}, stderr: {res_dry.stderr}, error: {e}" assert "drifts" in data_dry, f"JSON: {data_dry}" drifts_dry = data_dry["drifts"] # Debug print on failure if not any(d["class"] == "A" and d["name"] == session_name for d in drifts_dry): debug_info = ( f"DB_ROWS: {db_rows}\n" f"DB_STATE: {db_state}\n" f"YAML: {yaml_content}\n" f"LOAD_STATE_JSON STDOUT: {res_load.stdout}\n" f"LOAD_STATE_JSON STDERR: {res_load.stderr}\n" f"HERDR LS STDOUT: {res_ls.stdout}\n" f"HERDR LS STDERR: {res_ls.stderr}\n" f"AGENT_SESSIONS_YAML ENV: {os.environ.get('AGENT_SESSIONS_YAML')}\n" ) # Execute Python sub-process trace py_code = """ import os, json, glob, subprocess, time, sqlite3 from datetime import datetime, timezone import yaml yaml_path = os.environ['YAML_PATH'] home = os.environ['HOME_DIR'] d = {} ws_root = os.environ.get('WORKSPACE_ROOT') print("WORKSPACE_ROOT:", ws_root) script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json" out = subprocess.check_output(['bash', '-c', script]) print("LOAD STATE JSON OUT:", out.decode('utf-8')) """ env = { "YAML_PATH": str(yaml_path), "HOME_DIR": str(tmp_path), "CLAUDE_PROJECT_DIR": str(tmp_path / ".claude" / "projects"), "LOCAL_BIN": str(tmp_path / ".local" / "bin"), "WORKSPACE_ROOT": str(tmp_path), "AGENT_SESSIONS_YAML": str(yaml_path), "PATH": os.environ.get("PATH", "") } res_test = subprocess.run(["python3", "-c", py_code], env=env, capture_output=True, text=True) debug_info += f"TEST_CODE_STDOUT: {res_test.stdout}\nTEST_CODE_STDERR: {res_test.stderr}\n" assert False, f"Drift class A not found in drifts. Output JSON: {json.dumps(data_dry, indent=2)}\nStderr: {res_dry.stderr}\nDebug:\n{debug_info}" # Verify dry-run made no state changes in SQLite conn = sqlite3.connect(str(db_path)) row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone() assert row[0] == "running" conn.close() # 2. Run reconcile without --dry-run cmd_real = ["bash", str(reconcile_script), "--once", "--emit-diff"] res_real = subprocess.run(cmd_real, capture_output=True, text=True, cwd=str(tmp_path)) assert res_real.returncode == 0, f"Stderr: {res_real.stderr}" # Verify DB has been updated to terminated conn = sqlite3.connect(str(db_path)) row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone() assert row[0] == "terminated" conn.close() def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mock_agents): """ Tier 3: Invalid Arguments & Exit Status Checks Verifies that all scripts correctly handle invalid options and combinations with correct exit statuses. """ tmp_path = mam_sandbox # 1. create_session.sh with missing arguments create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" res = subprocess.run(["bash", str(create_script)], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 2 assert "ERROR: --workspace required" in res.stderr res = subprocess.run(["bash", str(create_script), "--workspace", str(tmp_path)], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 2 assert "ERROR: --agent required" in res.stderr # 2. stop_session.sh with missing arguments stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" res = subprocess.run(["bash", str(stop_script)], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 2 assert "ERROR: --session required" in res.stderr # 3. stop_session.sh cannot infer agent from weird session name res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 2 assert "ERROR: cannot infer agent" in res.stderr # 4. stop_session.sh on non-existent session res = subprocess.run(["bash", str(stop_script), "--session", "nonexistent-creator-claude"], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 1 assert "ERROR: session 'nonexistent-creator-claude' not in" in res.stderr # 5. reconcile.sh with invalid flag reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" res = subprocess.run(["bash", str(reconcile_script), "--invalid-flag"], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 2 assert "ERROR: unknown arg" in res.stderr