Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import uuid
|
||||
import sqlite3
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def mam_sandbox(tmp_path, monkeypatch):
|
||||
"""
|
||||
Manages a temporary sandboxed directory for .mam/ configuration files
|
||||
and copies the scripts from .agents/skills to prevent contaminating the real codebase.
|
||||
Also overrides environments (HOME, PATH, AGENT_SESSIONS_YAML, etc.) for isolation.
|
||||
"""
|
||||
# 1. Copy the skill scripts to sandboxed tmp_path/skills and tmp_path/.agents/skills
|
||||
src_skills = "/home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills"
|
||||
shutil.copytree(src_skills, tmp_path / "skills", ignore=shutil.ignore_patterns('multi-agent-mux-ui'))
|
||||
shutil.copytree(src_skills, tmp_path / ".agents" / "skills", ignore=shutil.ignore_patterns('multi-agent-mux-ui'))
|
||||
|
||||
# 2. Create sandboxed directory structure
|
||||
mam_dir = tmp_path / ".mam"
|
||||
mam_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yaml_path = mam_dir / "agent-sessions.yaml"
|
||||
# Create empty registry structure
|
||||
yaml_path.write_text("herdr_sessions: []\ndelegation_jobs: []\n")
|
||||
|
||||
# 3. Setup mock home directory contents
|
||||
(tmp_path / ".claude" / "projects").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / ".local" / "bin").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4. Monkeypatch environment variables to point inside the sandbox
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HOME_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / ".claude" / "projects"))
|
||||
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
|
||||
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
import sys
|
||||
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
|
||||
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def mock_herdr(mam_sandbox, monkeypatch):
|
||||
"""
|
||||
Generates a mock 'herdr' executable and prepends it to PATH.
|
||||
Allows tests to control and verify state via mock_herdr_state.json.
|
||||
Automatically generates mock session database files upon 'agent start'.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
state_file = tmp_path / "mock_herdr_state.json"
|
||||
|
||||
# Initial mock state
|
||||
initial_state = {
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_id": "w1",
|
||||
"label": "default",
|
||||
"cwd": str(tmp_path)
|
||||
}
|
||||
],
|
||||
"agents": {},
|
||||
"calls": []
|
||||
}
|
||||
state_file.write_text(json.dumps(initial_state, indent=2))
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
herdr_bin = bin_dir / "herdr"
|
||||
|
||||
# Write Python implementation of mock herdr (using string replacement to avoid f-string escaping issues)
|
||||
# Double escape backslashes for newline (\\\\n) and write correctly
|
||||
code = """#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import sqlite3
|
||||
import fcntl
|
||||
|
||||
state_file = "STATE_FILE_PLACEHOLDER"
|
||||
|
||||
if not os.path.exists(state_file):
|
||||
sys.exit(0)
|
||||
|
||||
# Lock the state file exclusively to prevent concurrent race conditions
|
||||
lock_f = open(state_file + ".lock", "w")
|
||||
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||
|
||||
with open(state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Record the command call
|
||||
state["calls"].append(sys.argv[1:])
|
||||
|
||||
def save_state():
|
||||
with open(state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# Save calls immediately so they persist even if we exit early or error out
|
||||
save_state()
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
sys.exit(0)
|
||||
|
||||
cmd1 = args[0]
|
||||
if cmd1 == "workspace":
|
||||
if len(args) < 2:
|
||||
sys.exit(0)
|
||||
cmd2 = args[1]
|
||||
if cmd2 == "list":
|
||||
res = {"workspaces": state.get("workspaces", [])}
|
||||
print(json.dumps({"result": res}))
|
||||
sys.exit(0)
|
||||
elif cmd2 == "create":
|
||||
label = "default"
|
||||
cwd = "."
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] == "--label":
|
||||
label = args[i+1]
|
||||
i += 2
|
||||
elif args[i] == "--cwd":
|
||||
cwd = args[i+1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
workspaces = state.get("workspaces", [])
|
||||
if not any(w["label"] == label for w in workspaces):
|
||||
ws_id = f"w{len(workspaces) + 1}"
|
||||
workspaces.append({"workspace_id": ws_id, "label": label, "cwd": cwd})
|
||||
state["workspaces"] = workspaces
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
|
||||
elif cmd1 == "agent":
|
||||
if len(args) < 2:
|
||||
sys.exit(0)
|
||||
cmd2 = args[1]
|
||||
if cmd2 == "list":
|
||||
agents_list = []
|
||||
for name, data in state.get("agents", {}).items():
|
||||
agents_list.append({
|
||||
"name": name,
|
||||
"agent": data.get("agent", "claude"),
|
||||
"agent_status": data.get("status", "running"),
|
||||
"cwd": data.get("cwd", ""),
|
||||
"pane_id": data.get("pane_id", "w1:p1"),
|
||||
"workspace_id": data.get("workspace_id", "w1")
|
||||
})
|
||||
print(json.dumps({"result": {"agents": agents_list}}))
|
||||
sys.exit(0)
|
||||
elif cmd2 == "start":
|
||||
if len(args) < 3:
|
||||
sys.exit(1)
|
||||
name = args[2]
|
||||
ws = ""
|
||||
cwd = ""
|
||||
# Find where -- is
|
||||
try:
|
||||
double_dash_idx = args.index("--")
|
||||
agent_cmd = args[double_dash_idx+1:]
|
||||
opts = args[3:double_dash_idx]
|
||||
except ValueError:
|
||||
agent_cmd = []
|
||||
opts = args[3:]
|
||||
|
||||
i = 0
|
||||
while i < len(opts):
|
||||
if opts[i] == "--workspace":
|
||||
ws = opts[i+1]
|
||||
i += 2
|
||||
elif opts[i] == "--cwd":
|
||||
cwd = opts[i+1]
|
||||
i += 2
|
||||
elif opts[i] == "--env":
|
||||
env_val = opts[i+1]
|
||||
if "=" in env_val:
|
||||
k, v = env_val.split("=", 1)
|
||||
os.environ[k] = v
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Determine the agent type (claude, agy, hermes, cline)
|
||||
agent_type = "claude"
|
||||
if agent_cmd:
|
||||
if "claude" in agent_cmd[0]:
|
||||
agent_type = "claude"
|
||||
elif "agy" in agent_cmd[0]:
|
||||
agent_type = "agy"
|
||||
elif "hermes" in agent_cmd[0]:
|
||||
agent_type = "hermes"
|
||||
elif "cline" in agent_cmd[0]:
|
||||
agent_type = "cline"
|
||||
else:
|
||||
# guess from name
|
||||
if "claude" in name:
|
||||
agent_type = "claude"
|
||||
elif "agy" in name:
|
||||
agent_type = "agy"
|
||||
elif "hermes" in name:
|
||||
agent_type = "hermes"
|
||||
elif "cline" in name:
|
||||
agent_type = "cline"
|
||||
|
||||
# TUI Welcome Tokens definition to prevent TUI readiness check timeout
|
||||
buffer_content = {
|
||||
"claude": "Anthropic Claude Ready",
|
||||
"agy": "Antigravity Ready",
|
||||
"hermes": "Hermes Ready",
|
||||
"cline": "Cline Chat Ready"
|
||||
}.get(agent_type, "Ready")
|
||||
|
||||
agents = state.get("agents", {})
|
||||
agents[name] = {
|
||||
"agent": agent_type,
|
||||
"status": "running",
|
||||
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
|
||||
"workspace_id": ws or "w1",
|
||||
"pid": 9999,
|
||||
"pane_id": "w1:p1",
|
||||
"command": " ".join(agent_cmd),
|
||||
"buffer": buffer_content
|
||||
}
|
||||
|
||||
# Generate session/conversation UUID and files to simulate agent startup
|
||||
session_uuid = str(uuid.uuid4())
|
||||
own_key_map = {
|
||||
"claude": "claude_session_id_own",
|
||||
"agy": "agy_conversation_id_own",
|
||||
"hermes": "hermes_conversation_id_own",
|
||||
"cline": "cline_conversation_id_own"
|
||||
}
|
||||
own_key = own_key_map.get(agent_type)
|
||||
if own_key:
|
||||
agents[name][own_key] = session_uuid
|
||||
|
||||
# Find isolation directory (e.g. if HOME has agent_homes/<uuid>)
|
||||
home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER")
|
||||
ws_abs = os.path.abspath(cwd or "TMP_PATH_PLACEHOLDER")
|
||||
ws_key = ws_abs.replace('/', '-').replace('_', '-')
|
||||
|
||||
if agent_type == "claude":
|
||||
ccd = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
if ccd:
|
||||
cp_dir = os.path.join(ccd, "projects")
|
||||
else:
|
||||
cp_dir = os.environ.get("CLAUDE_PROJECT_DIR", f"{home_dir}/.claude/projects")
|
||||
proj_dir = os.path.join(cp_dir, ws_key)
|
||||
os.makedirs(proj_dir, exist_ok=True)
|
||||
jsonl_file = os.path.join(proj_dir, f"{session_uuid}.jsonl")
|
||||
with open(jsonl_file, 'w') as jf:
|
||||
jf.write(json.dumps({"sessionId": session_uuid}) + "\\\\n")
|
||||
elif agent_type == "agy":
|
||||
db_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "conversations")
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_file = os.path.join(db_dir, f"{session_uuid}.db")
|
||||
with open(db_file, 'w') as df:
|
||||
df.write("")
|
||||
|
||||
lc_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "cache")
|
||||
os.makedirs(lc_dir, exist_ok=True)
|
||||
lc_file = os.path.join(lc_dir, "last_conversations.json")
|
||||
lc_data = {}
|
||||
if os.path.exists(lc_file):
|
||||
try:
|
||||
with open(lc_file, 'r') as lcf:
|
||||
lc_data = json.load(lcf)
|
||||
except Exception:
|
||||
pass
|
||||
lc_data[ws_abs] = session_uuid
|
||||
with open(lc_file, 'w') as lcf:
|
||||
json.dump(lc_data, lcf)
|
||||
elif agent_type == "hermes":
|
||||
hermes_dir = os.path.join(home_dir, ".hermes")
|
||||
os.makedirs(hermes_dir, exist_ok=True)
|
||||
hdb = os.path.join(hermes_dir, "state.db")
|
||||
conn = sqlite3.connect(hdb)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||
conn.execute("INSERT OR REPLACE INTO sessions (id, cwd) VALUES (?, ?)", (session_uuid, ws_abs))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
elif agent_type == "cline":
|
||||
clin_base = os.path.join(home_dir, ".cline", "data", "sessions")
|
||||
if "agent_homes" in home_dir:
|
||||
clin_base = os.path.join(home_dir, "sessions")
|
||||
session_dir = os.path.join(clin_base, session_uuid)
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
json_file = os.path.join(session_dir, f"{session_uuid}.json")
|
||||
with open(json_file, 'w') as jf:
|
||||
jf.write(json.dumps({"id": session_uuid}))
|
||||
|
||||
state["agents"] = agents
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
elif cmd2 == "get":
|
||||
if len(args) < 3:
|
||||
sys.exit(1)
|
||||
name = args[2]
|
||||
agents = state.get("agents", {})
|
||||
if name in agents:
|
||||
agent_data = agents[name]
|
||||
pane_info = {
|
||||
"pid": agent_data.get("pid", 9999),
|
||||
"cwd": agent_data.get("cwd", ""),
|
||||
"command": agent_data.get("command", ""),
|
||||
"argv": agent_data.get("command", "")
|
||||
}
|
||||
print(json.dumps({"pane": pane_info}))
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||
sys.exit(1)
|
||||
elif cmd2 == "read":
|
||||
if len(args) < 3:
|
||||
sys.exit(1)
|
||||
name = args[2]
|
||||
agents = state.get("agents", {})
|
||||
if name in agents:
|
||||
buffer_content = agents[name].get("buffer", "Ready")
|
||||
print(buffer_content)
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||
sys.exit(1)
|
||||
elif cmd2 == "send":
|
||||
if len(args) < 4:
|
||||
sys.exit(1)
|
||||
name = args[2]
|
||||
text = args[3]
|
||||
agents = state.get("agents", {})
|
||||
if name in agents:
|
||||
agents[name]["sent_text"] = agents[name].get("sent_text", "") + text
|
||||
if text == "C-m":
|
||||
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\nesc to interrupt"
|
||||
else:
|
||||
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\n" + text
|
||||
if "/exit" in text or "exit" in text or "Exit" in text:
|
||||
agents[name]["status"] = "stopped"
|
||||
state["agents"] = agents
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "session":
|
||||
if len(args) < 3:
|
||||
sys.exit(1)
|
||||
cmd2 = args[1]
|
||||
name = args[2]
|
||||
agents = state.get("agents", {})
|
||||
if cmd2 == "stop":
|
||||
if name in agents:
|
||||
agents[name]["status"] = "stopped"
|
||||
state["agents"] = agents
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
elif cmd2 == "delete":
|
||||
if name in agents:
|
||||
del agents[name]
|
||||
state["agents"] = agents
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "pane":
|
||||
if len(args) < 4:
|
||||
sys.exit(1)
|
||||
cmd2 = args[1]
|
||||
if cmd2 == "send-keys":
|
||||
name = args[2]
|
||||
key = args[3]
|
||||
agents = state.get("agents", {})
|
||||
if name in agents:
|
||||
agents[name]["sent_keys"] = agents[name].get("sent_keys", []) + [key]
|
||||
if key in ("Enter", "C-m"):
|
||||
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\nesc to interrupt"
|
||||
state["agents"] = agents
|
||||
save_state()
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd1 == "ls":
|
||||
if "-F" in args:
|
||||
for name, data in state.get("agents", {}).items():
|
||||
print(f"{name}|999999")
|
||||
sys.exit(0)
|
||||
with open("/tmp/debug_mock_herdr.log", "a") as f_debug:
|
||||
f_debug.write(f"ARGS: {sys.argv[1:]} | AGENTS: {list(state.get('agents', {}).keys())} | PATH: {os.path.exists(state_file)}\\n")
|
||||
agents_list = []
|
||||
for name, data in state.get("agents", {}).items():
|
||||
agents_list.append({
|
||||
"name": name,
|
||||
"agent": data.get("agent", "claude"),
|
||||
"agent_status": data.get("status", "running")
|
||||
})
|
||||
print(json.dumps({"result": {"agents": agents_list}}))
|
||||
sys.exit(0)
|
||||
|
||||
sys.exit(0)
|
||||
""".replace("STATE_FILE_PLACEHOLDER", str(state_file)).replace("TMP_PATH_PLACEHOLDER", str(tmp_path))
|
||||
|
||||
herdr_bin.write_text(code)
|
||||
herdr_bin.chmod(0o755)
|
||||
|
||||
# Update PATH using monkeypatch.
|
||||
# Prepend bin_dir, and append standard Homebrew/system paths so lib.sh won't prepend them
|
||||
old_path = os.environ.get("PATH", "")
|
||||
extra_dirs = [
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
"/home/linuxbrew/.linuxbrew/sbin",
|
||||
f"{tmp_path}/.local/bin",
|
||||
f"{tmp_path}/.npm-global/bin"
|
||||
]
|
||||
new_path = old_path
|
||||
for d in extra_dirs:
|
||||
if d not in new_path:
|
||||
new_path = f"{new_path}:{d}"
|
||||
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
|
||||
|
||||
return state_file
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agents(mam_sandbox, monkeypatch):
|
||||
"""
|
||||
Generates mock agent binaries (claude, agy, hermes, cline)
|
||||
and places them in the sandboxed bin folder to satisfy preflight checks.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. claude mock
|
||||
claude_bin = bin_dir / "claude"
|
||||
claude_bin.write_text("""#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 2 and args[0] == "auth" and args[1] == "status":
|
||||
print(json.dumps({"loggedIn": True}))
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
""")
|
||||
claude_bin.chmod(0o755)
|
||||
|
||||
# 2. agy mock
|
||||
agy_bin = bin_dir / "agy"
|
||||
agy_bin.write_text("""#!/usr/bin/env python3
|
||||
import sys
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 1 and args[0] == "models":
|
||||
print("gemini-1.5-pro\\ngemini-1.5-flash")
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
""")
|
||||
agy_bin.chmod(0o755)
|
||||
|
||||
# 3. hermes mock
|
||||
hermes_bin = bin_dir / "hermes"
|
||||
hermes_bin.write_text("""#!/usr/bin/env python3
|
||||
import sys
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 1 and args[0] == "status":
|
||||
print("Hermes functional")
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
""")
|
||||
hermes_bin.chmod(0o755)
|
||||
|
||||
# 4. cline mock
|
||||
cline_bin = bin_dir / "cline"
|
||||
cline_bin.write_text("""#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 1 and args[0] == "history":
|
||||
print(json.dumps([]))
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
""")
|
||||
cline_bin.chmod(0o755)
|
||||
|
||||
# 5. uuidgen mock to guarantee isolated creation UUIDs
|
||||
uuidgen_bin = bin_dir / "uuidgen"
|
||||
uuidgen_bin.write_text("""#!/usr/bin/env python3
|
||||
import uuid
|
||||
print(str(uuid.uuid4()))
|
||||
""")
|
||||
uuidgen_bin.chmod(0o755)
|
||||
|
||||
# Prepend bin directory to PATH
|
||||
old_path = os.environ.get("PATH", "")
|
||||
extra_dirs = [
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
"/home/linuxbrew/.linuxbrew/sbin",
|
||||
f"{tmp_path}/.local/bin",
|
||||
f"{tmp_path}/.npm-global/bin"
|
||||
]
|
||||
new_path = old_path
|
||||
for d in extra_dirs:
|
||||
if d not in new_path:
|
||||
new_path = f"{new_path}:{d}"
|
||||
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
|
||||
|
||||
return bin_dir
|
||||
@@ -0,0 +1,182 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import pytest
|
||||
import sys
|
||||
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_unbound_key_handling(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify unbound key handling: herdr has-session/kill-session with trailing parameters
|
||||
handles it gracefully (returns status 1 with error) instead of crashing with unbound var or shift errors.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
assert shim_path.exists(), "Shim herdr was not created"
|
||||
|
||||
# Run has-session with trailing -t
|
||||
res = subprocess.run([str(shim_path), "has-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 1
|
||||
assert "Error: -t requires a value" in res.stderr
|
||||
|
||||
# Run kill-session with trailing -t
|
||||
res = subprocess.run([str(shim_path), "kill-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 1
|
||||
assert "Error: -t requires a value" in res.stderr
|
||||
|
||||
|
||||
def test_new_session_fallback_shell(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify new-session: launches shell when no command is provided.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
|
||||
test_shell = "/bin/custom_sh"
|
||||
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), SHELL=test_shell)
|
||||
|
||||
res = subprocess.run([str(shim_path), "new-session", "-s", "fallback-session"], capture_output=True, text=True, env=run_env)
|
||||
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
calls = state.get("calls", [])
|
||||
|
||||
start_call = None
|
||||
for call in calls:
|
||||
if "agent" in call and "start" in call and "fallback-session" in call:
|
||||
start_call = call
|
||||
break
|
||||
assert start_call is not None, f"No agent start call found in: {calls}"
|
||||
assert start_call[-1] == test_shell, f"Expected last arg to be {test_shell}, got {start_call[-1]}"
|
||||
|
||||
|
||||
def test_ls_key_error(mam_sandbox):
|
||||
"""
|
||||
Verify ls key error: parses agent list without 'name' correctly using fallback keys.
|
||||
"""
|
||||
py_code = """
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
res = data.get('result', data)
|
||||
for a in res.get('agents', []):
|
||||
try:
|
||||
name = a.get('name') or a.get('agent') or 'unknown'
|
||||
print(f"{name}|0")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
"""
|
||||
input_json = json.dumps({
|
||||
"result": {
|
||||
"agents": [
|
||||
{"agent": "claude", "status": "running"},
|
||||
{"name": "agent-with-name", "agent": "agy"}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
res = subprocess.run([sys.executable, "-c", py_code], input=input_json, capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
lines = res.stdout.strip().split('\n')
|
||||
assert "claude|0" in lines
|
||||
assert "agent-with-name|0" in lines
|
||||
|
||||
|
||||
def test_reconcile_relocatability(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify relocatability: reconcile.sh runs when relocated or inside different paths.
|
||||
"""
|
||||
reconcile_script = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
run_dir = mam_sandbox / "some_other_dir"
|
||||
run_dir.mkdir()
|
||||
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], cwd=str(run_dir), capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 0, f"Failed when run from different directory. Stderr: {res.stderr}"
|
||||
|
||||
|
||||
def test_workspace_server_mapping(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify workspace/server mapping: status output workspace is correct.
|
||||
"""
|
||||
status_script = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
session_name = "test-mapping-sess-creator-claude"
|
||||
|
||||
mutation = f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': '{session_name}',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'herdr_workspace': 'my-custom-workspace',
|
||||
'pane': {{
|
||||
'cwd': 'WS_PLACEHOLDER',
|
||||
'pid': 7777,
|
||||
'cmd': 'claude',
|
||||
'cmd_full': 'claude'
|
||||
}}
|
||||
}}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
res_mut = run_mutation(mam_sandbox, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True, cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
sess_detail = data["sessions_detail"]
|
||||
target_sess = [s for s in sess_detail if s["name"] == session_name][0]
|
||||
|
||||
assert target_sess["server"] == "my-custom-workspace"
|
||||
|
||||
|
||||
def test_variable_splicing_injection_safety(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Verify variable splicing injection: no vulnerabilities or syntax errors remain.
|
||||
"""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||
|
||||
adversarial_name = 'my"server; import os; os.system("echo INJECTED")'
|
||||
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), HERDR_SERVER_NAME=adversarial_name)
|
||||
|
||||
res = subprocess.run([str(shim_path), "new-session", "-s", "injection-test-session"], capture_output=True, text=True, env=run_env)
|
||||
assert res.returncode == 0, f"Failed with adversarial HERDR_SERVER_NAME. Stderr: {res.stderr}"
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
assert any("injection-test-session" in call for call in state["calls"]), "Expected injection-test-session call to succeed"
|
||||
|
||||
|
||||
def test_export_masking_exit_code_preservation():
|
||||
"""
|
||||
Verify export masking: exit codes are preserved when assigning and exporting.
|
||||
"""
|
||||
# 1. Export masked version exits 0 (silent fail)
|
||||
cmd_masked = ["bash", "-c", "set -e; export TEST_VAR=$(false); echo 'survived'"]
|
||||
res_masked = subprocess.run(cmd_masked, capture_output=True, text=True)
|
||||
assert res_masked.returncode == 0
|
||||
assert res_masked.stdout.strip() == "survived"
|
||||
|
||||
# 2. Fixed split version exits non-zero (preserves failure exit code)
|
||||
cmd_fixed = ["bash", "-c", "set -e; TEST_VAR=$(false); export TEST_VAR; echo 'survived'"]
|
||||
res_fixed = subprocess.run(cmd_fixed, capture_output=True, text=True)
|
||||
assert res_fixed.returncode != 0
|
||||
assert res_fixed.stdout.strip() != "survived"
|
||||
@@ -0,0 +1,74 @@
|
||||
import subprocess
|
||||
import json
|
||||
import yaml
|
||||
|
||||
def test_create_session_dry_run(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Sanity test verifying that create_session.sh --dry-run parses inputs,
|
||||
invokes herdr's preflight checks, and exits successfully without side effects.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(tmp_path),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator",
|
||||
"--dry-run"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||
assert "[dry-run] would provision isolation" in res.stdout
|
||||
assert "[dry-run] would spawn" in res.stdout
|
||||
|
||||
# Verify that mock herdr was called for checking session existence (which gets translated to agent get)
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
calls = state.get("calls", [])
|
||||
assert any("agent" in call and "get" in call for call in calls), f"Calls: {calls}"
|
||||
|
||||
def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Sanity test verifying that a full create_session.sh run intercepts herdr calls,
|
||||
completes the TUI readiness check using mock responses, and serializes state
|
||||
correctly to the sandboxed agent-sessions.yaml registry.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(tmp_path),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||
|
||||
# 1. Verify mock herdr state file registers the agent session
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
agents = state.get("agents", {})
|
||||
assert len(agents) == 1, "There should be exactly one registered agent in the mock herdr state."
|
||||
|
||||
session_name = list(agents.keys())[0]
|
||||
assert session_name.endswith("-creator-claude")
|
||||
assert agents[session_name]["status"] == "running"
|
||||
assert agents[session_name]["agent"] == "claude"
|
||||
|
||||
# 2. Verify that agent-sessions.yaml is successfully written
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
assert yaml_path.exists(), "The agent-sessions.yaml configuration file was not created."
|
||||
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
|
||||
sessions = reg.get("herdr_sessions", [])
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["name"] == session_name
|
||||
assert sessions[0]["status"] == "running"
|
||||
assert sessions[0]["role"] == "Creator"
|
||||
assert sessions[0]["isolation"]["uuid"] is not None
|
||||
@@ -0,0 +1,363 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import shlex
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
# Helper to run bash snippets sourcing lib.sh
|
||||
def run_lib_func(mam_sandbox, func_name, *args, env=None):
|
||||
lib_path = mam_sandbox / "skills" / "lib.sh"
|
||||
cmd_str = f"source {lib_path} && {func_name} " + " ".join(shlex.quote(str(a)) for a in args)
|
||||
run_env = dict(os.environ)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, env=run_env)
|
||||
return res
|
||||
|
||||
def get_mqtt_common(mam_sandbox):
|
||||
script_path = str(mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
||||
if script_path not in sys.path:
|
||||
sys.path.insert(0, script_path)
|
||||
import mqtt_common
|
||||
return mqtt_common
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 1: Create Session (7 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_create_derive_session_name_standard(mam_sandbox):
|
||||
"""Test standard derive_session_name slug generation."""
|
||||
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/user/project", "claude")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "user-project-creator-claude"
|
||||
|
||||
def test_create_derive_session_name_nested(mam_sandbox):
|
||||
"""Test derive_session_name with nested paths, upper casing, and underscores."""
|
||||
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/User_Name/My_New_Project", "agy")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "user-name-my-new-project-creator-agy"
|
||||
|
||||
def test_create_derive_session_name_weird_characters(mam_sandbox):
|
||||
"""Test derive_session_name with spaces and punctuation in the path."""
|
||||
res = run_lib_func(mam_sandbox, "derive_session_name", "/a/b c/d-e!f", "hermes")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
|
||||
|
||||
def test_create_isolation_lever(mam_sandbox):
|
||||
"""Test isolation_lever outputs for each supported agent."""
|
||||
agents = {
|
||||
"claude": "claude_config_dir",
|
||||
"cline": "cline_data_dir",
|
||||
"agy": "home",
|
||||
"hermes": "home",
|
||||
"unknown": ""
|
||||
}
|
||||
for agent, expected in agents.items():
|
||||
res = run_lib_func(mam_sandbox, "isolation_lever", agent)
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == expected
|
||||
|
||||
def test_create_isolation_env_prefix(mam_sandbox):
|
||||
"""Test isolation_env_prefix format outputs."""
|
||||
res = run_lib_func(mam_sandbox, "isolation_env_prefix", "claude", "/tmp/iso")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout == "CLAUDE_CONFIG_DIR=/tmp/iso "
|
||||
|
||||
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
|
||||
assert res2.returncode == 0
|
||||
assert res2.stdout == "HOME=/tmp/iso "
|
||||
|
||||
res3 = run_lib_func(mam_sandbox, "isolation_env_prefix", "cline", "/tmp/iso")
|
||||
assert res3.returncode == 0
|
||||
assert res3.stdout == ""
|
||||
|
||||
def test_create_isolation_cmd_args(mam_sandbox):
|
||||
"""Test isolation_cmd_args format outputs."""
|
||||
res = run_lib_func(mam_sandbox, "isolation_cmd_args", "cline", "/tmp/iso")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout == "--data-dir /tmp/iso"
|
||||
|
||||
res2 = run_lib_func(mam_sandbox, "isolation_cmd_args", "claude", "/tmp/iso")
|
||||
assert res2.returncode == 0
|
||||
assert res2.stdout == ""
|
||||
|
||||
def test_create_validate_env_key(mam_sandbox):
|
||||
"""Test _validate_env_key function with valid and blocked environment keys."""
|
||||
# Valid key
|
||||
res = run_lib_func(mam_sandbox, "_validate_env_key", "MY_VALID_KEY")
|
||||
assert res.returncode == 0
|
||||
|
||||
# Malformed key (starts with number)
|
||||
res2 = run_lib_func(mam_sandbox, "_validate_env_key", "123BAD")
|
||||
assert res2.returncode != 0
|
||||
|
||||
# Blocked key (LD_PRELOAD)
|
||||
res3 = run_lib_func(mam_sandbox, "_validate_env_key", "LD_PRELOAD")
|
||||
assert res3.returncode != 0
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 2: Resume Session (6 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_resume_resolve_herdr_workspace_default(mam_sandbox):
|
||||
"""Test resolve_herdr_workspace fallback behavior when session is not in YAML."""
|
||||
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "w1"
|
||||
|
||||
def test_resume_resolve_herdr_workspace_env(mam_sandbox):
|
||||
"""Test resolve_herdr_workspace fallback to HERDR_SERVER_NAME env var."""
|
||||
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "w1" # default mapping to w1 unless resolved from workspace list
|
||||
|
||||
def test_resume_find_workspace_uuid_empty(mam_sandbox):
|
||||
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
|
||||
res = run_lib_func(mam_sandbox, "find_workspace_uuid", "/non/existent/path", "claude")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_resume_find_workspace_uuid_target_non_existent(mam_sandbox):
|
||||
"""Test target session query with target that does not exist in YAML."""
|
||||
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "claude", "non-existent-session")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_resume_find_workspace_uuid_invalid_agent(mam_sandbox):
|
||||
"""Test find_workspace_uuid behavior with an unsupported agent name."""
|
||||
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "invalidagent")
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == ""
|
||||
|
||||
def test_resume_script_invalid_args(mam_sandbox):
|
||||
"""Test calling resolve_session_id.sh with missing arguments."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--workspace", str(mam_sandbox)], capture_output=True, text=True)
|
||||
assert res.returncode == 2
|
||||
assert "ERROR: --agent required" in res.stderr
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_stop_check_is_nfs_local(mam_sandbox):
|
||||
"""Test that _check_is_nfs on local temp directory returns non-zero (not NFS)."""
|
||||
res = run_lib_func(mam_sandbox, "_check_is_nfs", str(mam_sandbox))
|
||||
# It will exit with 1 if it is not NFS
|
||||
assert res.returncode == 1
|
||||
|
||||
def test_stop_is_already_stopped_not_found(mam_sandbox):
|
||||
"""Test is_already_stopped exits with 1 when session is not in YAML."""
|
||||
res = run_lib_func(mam_sandbox, "is_already_stopped", "non-existent-session")
|
||||
assert res.returncode == 1
|
||||
|
||||
def test_stop_session_invalid_agent_suffix(mam_sandbox):
|
||||
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
|
||||
assert res.returncode == 2
|
||||
assert "ERROR: cannot infer agent" in res.stderr
|
||||
|
||||
def test_stop_session_missing_required_args(mam_sandbox):
|
||||
"""Test stop_session.sh fails when session name is missing."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
||||
assert res.returncode == 2
|
||||
assert "ERROR: --session required" in res.stderr
|
||||
|
||||
def test_stop_session_purge_no_yes(mam_sandbox):
|
||||
"""Test stop_session.sh exits with 3 when purge is requested without --yes."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Seed yaml with the session first to avoid "not in yaml" exit 1
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
yaml_path.write_text("""herdr_sessions:
|
||||
- name: test-project-creator-claude
|
||||
status: running
|
||||
pane:
|
||||
cwd: /tmp
|
||||
""")
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-project-creator-claude", "--purge-conversation"], capture_output=True, text=True)
|
||||
assert res.returncode == 3
|
||||
assert "DANGER: --purge-conversation will DELETE" in res.stdout
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 4: Status Query (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_status_json_schema_fields(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""Verify status.sh output JSON contains the expected structure."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
|
||||
# Run status.sh with --json
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
assert "timestamp" in data
|
||||
assert "yaml_path" in data
|
||||
assert "drifts" in data
|
||||
assert "sessions_detail" in data
|
||||
|
||||
def test_status_text_headers_presence(mam_sandbox):
|
||||
"""Verify status.sh output in text mode includes header columns."""
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
|
||||
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert "NAME" in res.stdout
|
||||
assert "WORKSPACE" in res.stdout
|
||||
assert "YAML" in res.stdout
|
||||
assert "HERDR" in res.stdout
|
||||
assert "DRIFT" in res.stdout
|
||||
|
||||
def test_status_resume_on_disk_helper_claude(mam_sandbox):
|
||||
"""Verify resume_on_disk behavior for claude inside status.sh logic via mock YAML queries."""
|
||||
# We can write a custom yaml and check status
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
yaml_path.write_text("""herdr_sessions:
|
||||
- name: test-project-creator-claude
|
||||
status: running
|
||||
claude_session_id_own: some-uuid
|
||||
pane:
|
||||
cwd: /tmp/nonexistent-workspace
|
||||
""")
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
detail = data["sessions_detail"][0]
|
||||
assert detail["name"] == "test-project-creator-claude"
|
||||
assert detail["resume_state"] == "MISSING"
|
||||
|
||||
def test_status_resume_on_disk_helper_agy(mam_sandbox):
|
||||
"""Verify resume_on_disk behavior for agy inside status.sh logic."""
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
yaml_path.write_text("""herdr_sessions:
|
||||
- name: test-project-creator-agy
|
||||
status: running
|
||||
agy_conversation_id_own: some-uuid
|
||||
pane:
|
||||
cwd: /tmp/nonexistent-workspace
|
||||
""")
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
detail = data["sessions_detail"][0]
|
||||
assert detail["name"] == "test-project-creator-agy"
|
||||
assert detail["resume_state"] == "MISSING"
|
||||
|
||||
def test_status_get_job_status_helper(mam_sandbox):
|
||||
"""Verify get_job_status parses non-existent jobs gracefully."""
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
yaml_path.write_text("""herdr_sessions:
|
||||
- name: test-project-creator-claude
|
||||
status: running
|
||||
delegate_job_id: nonexistent-job-id
|
||||
pane:
|
||||
cwd: /tmp
|
||||
""")
|
||||
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
detail = data["sessions_detail"][0]
|
||||
assert detail["job_id"] == "nonexistent-job-id"
|
||||
assert detail["job_status"] == "unknown"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_mqtt_topic_prefix_for(mam_sandbox):
|
||||
"""Test topic prefix helper methods in mqtt_common."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
prefix = mqtt_common.topic_prefix_for("job123")
|
||||
assert prefix == "python/mqtt/jobs/job123"
|
||||
|
||||
events_topic = mqtt_common.events_topic_for("job123")
|
||||
assert events_topic == "python/mqtt/jobs/job123/events"
|
||||
|
||||
def test_mqtt_verify_hmac_no_token(mam_sandbox):
|
||||
"""Verify verify_hmac returns True when no token is present."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
payload = {"data": {"hmac_sig": "somesig"}}
|
||||
assert mqtt_common.verify_hmac(payload, None) is True
|
||||
assert mqtt_common.verify_hmac(payload, "") is True
|
||||
|
||||
def test_mqtt_verify_hmac_valid_invalid(mam_sandbox):
|
||||
"""Verify verify_hmac signature validation matching logic."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
token = "secret_key"
|
||||
payload = {
|
||||
"job_id": "job1",
|
||||
"event": "started",
|
||||
"seq": 1,
|
||||
"timestamp": "2026-07-19T00:00:00Z",
|
||||
"data": {
|
||||
"some_key": "some_val"
|
||||
}
|
||||
}
|
||||
# Calculate HMAC
|
||||
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
sig = hmac.new(token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
# Put HMAC signature inside data block
|
||||
payload["data"]["hmac_sig"] = sig
|
||||
|
||||
assert mqtt_common.verify_hmac(payload, token) is True
|
||||
|
||||
# Modifying payload should cause verification to fail
|
||||
payload["seq"] = 2
|
||||
assert mqtt_common.verify_hmac(payload, token) is False
|
||||
|
||||
def test_mqtt_reason_code_value(mam_sandbox):
|
||||
"""Verify reason_code_value correctly extracts values from paho reason codes."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
|
||||
# Test plain int
|
||||
assert mqtt_common.reason_code_value(0) == 0
|
||||
assert mqtt_common.reason_code_value(5) == 5
|
||||
|
||||
# Test object with .value attribute
|
||||
class DummyReasonCode:
|
||||
def __init__(self, val):
|
||||
self.value = val
|
||||
assert mqtt_common.reason_code_value(DummyReasonCode(0)) == 0
|
||||
assert mqtt_common.reason_code_value(DummyReasonCode(16)) == 16
|
||||
|
||||
def test_mqtt_with_retry_success(mam_sandbox):
|
||||
"""Verify with_retry decorator works on direct success."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
|
||||
calls = []
|
||||
@mqtt_common.with_retry(attempts=3)
|
||||
def dummy_func(x):
|
||||
calls.append(x)
|
||||
return x * 2
|
||||
|
||||
res = dummy_func(5)
|
||||
assert res == 10
|
||||
assert calls == [5]
|
||||
|
||||
def test_mqtt_with_retry_failure(mam_sandbox):
|
||||
"""Verify with_retry decorator raises error after specified attempts."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
|
||||
calls = []
|
||||
@mqtt_common.with_retry(attempts=3, base_delay=0.01)
|
||||
def failing_func():
|
||||
calls.append(1)
|
||||
raise ValueError("failing")
|
||||
|
||||
with pytest.raises(ValueError, match="failing"):
|
||||
failing_func()
|
||||
assert len(calls) == 3
|
||||
@@ -0,0 +1,734 @@
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import pytest
|
||||
import time
|
||||
import sys
|
||||
import shlex
|
||||
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 get_mqtt_common(mam_sandbox):
|
||||
script_path = str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
||||
if script_path not in sys.path:
|
||||
sys.path.insert(0, script_path)
|
||||
import mqtt_common
|
||||
return mqtt_common
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 1: Create Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_create_schema_validation(mam_sandbox):
|
||||
"""Verify that atomic_dump_yaml validates the schema and rejects malformed formats."""
|
||||
# Try setting herdr_sessions to a dictionary instead of list
|
||||
mutation = "d['herdr_sessions'] = {}"
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert "VALIDATE: herdr_sessions is not a list" in res.stderr
|
||||
|
||||
# Try setting status to an invalid state
|
||||
mutation_invalid_status = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'session1',
|
||||
'status': 'invalidstatus',
|
||||
'pane': {}
|
||||
}]
|
||||
"""
|
||||
res2 = run_mutation(mam_sandbox, mutation_invalid_status)
|
||||
assert "VALIDATE:" in res2.stderr
|
||||
|
||||
def test_comp_create_role_immutability(mam_sandbox):
|
||||
"""Verify that once a role is defined in the sessions, it cannot be mutated."""
|
||||
# 1. Setup session with a role
|
||||
mutation_setup = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'session-imm-role',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'pane': {}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation_setup)
|
||||
assert res.returncode == 0
|
||||
|
||||
# 2. Try mutating the role
|
||||
mutation_bad = """
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == 'session-imm-role':
|
||||
s['role'] = 'Reviewer'
|
||||
"""
|
||||
res_bad = run_mutation(mam_sandbox, mutation_bad)
|
||||
assert res_bad.returncode != 0
|
||||
assert "role of session 'session-imm-role' cannot be modified" in res_bad.stderr
|
||||
|
||||
def test_comp_create_duplicate_running_ids(mam_sandbox):
|
||||
"""Verify that duplicate running conversation IDs across sessions are rejected."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [
|
||||
{
|
||||
'name': 'sess1',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'uuid-1234',
|
||||
'pane': {}
|
||||
},
|
||||
{
|
||||
'name': 'sess2',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'uuid-1234',
|
||||
'pane': {}
|
||||
}
|
||||
]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode != 0
|
||||
assert "Duplicate running conversation ID" in res.stderr
|
||||
|
||||
def test_comp_create_isolation_folder_setup(mam_sandbox):
|
||||
"""Verify that provision_isolation correctly creates directories and symlinks."""
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
iso_root = mam_sandbox / "iso_home_test"
|
||||
|
||||
# Mock global claude credentials
|
||||
claude_cred = mam_sandbox / ".claude"
|
||||
claude_cred.mkdir(parents=True, exist_ok=True)
|
||||
(claude_cred / ".credentials.json").write_text('{"token": "xyz"}')
|
||||
|
||||
cmd_str = f"source {lib_path} && provision_isolation claude {iso_root}"
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify symlink exists and points to the credentials
|
||||
cred_sym = iso_root / ".credentials.json"
|
||||
assert cred_sym.is_symlink()
|
||||
assert cred_sym.read_text() == '{"token": "xyz"}'
|
||||
|
||||
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""Verify that tables exist and contain records after a full create_session.sh run."""
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(mam_sandbox),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
assert db_path.exists()
|
||||
|
||||
# Connect directly to the SQLite DB
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check tables
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
assert "state" in tables
|
||||
assert "sessions" in tables
|
||||
|
||||
# Check entries in sessions table
|
||||
cursor.execute("SELECT name, status, pane_cwd FROM sessions;")
|
||||
rows = cursor.fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0].endswith("-creator-claude")
|
||||
assert rows[0][1] == "running"
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 2: Resume Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_resume_config_restore(mam_sandbox):
|
||||
"""Verify that isolation details are preserved and can be read from the DB registry."""
|
||||
# Write a test session with isolation details
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-session-iso',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'},
|
||||
'isolation': {
|
||||
'uuid': 'iso-uuid-789',
|
||||
'root': '/tmp/iso_root',
|
||||
'lever': 'claude_config_dir',
|
||||
'seeded': []
|
||||
}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Read the configuration via resume_session.sh isolation resolution check
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||
# We run in dry-run/mock mode or just execute python command checking block
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
|
||||
# Call the python parsing block from resume_session.sh directly
|
||||
python_block = """
|
||||
import os, json, sqlite3
|
||||
name = "test-session-iso"
|
||||
db_path = "DB_PATH_PLACEHOLDER"
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
import json
|
||||
s = json.loads(row[0])
|
||||
print(json.dumps(s.get('isolation') or {}))
|
||||
""".replace("DB_PATH_PLACEHOLDER", str(db_path))
|
||||
|
||||
res_py = subprocess.run(["python3", "-c", python_block], capture_output=True, text=True)
|
||||
assert res_py.returncode == 0
|
||||
data = json.loads(res_py.stdout)
|
||||
assert data["uuid"] == "iso-uuid-789"
|
||||
assert data["root"] == "/tmp/iso_root"
|
||||
|
||||
def test_comp_resume_metadata_read_integrity(mam_sandbox):
|
||||
"""Verify metadata read integrity from the SQLite DB by changing state outside YAML."""
|
||||
# Write initial YAML state
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'sess-integrity',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Update SQLite directly to terminated
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("UPDATE sessions SET status='terminated' WHERE name='sess-integrity'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Read via atomic_dump_yaml check (which pulls from DB sessions table)
|
||||
# If the DB reading has integrity, d will contain sess-integrity as status 'terminated'
|
||||
mutation_check = """
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == 'sess-integrity':
|
||||
print(f"STATUS={s['status']}")
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation_check)
|
||||
assert "STATUS=terminated" in res.stdout
|
||||
|
||||
def test_comp_resume_update_yaml(mam_sandbox):
|
||||
"""Verify that update_yaml_resumed.sh cleans up stop fields and marks status running."""
|
||||
# Seed a stopped session with stop metadata
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-resumed-session-creator-claude',
|
||||
'status': 'stopped',
|
||||
'stopped_at': '2026-07-19T00:00:00Z',
|
||||
'stop_reason': 'manual_stop',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-resumed-session-creator-claude", "--uuid", "new-uuid-999"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify stopped fields are popped
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT data FROM sessions WHERE name='test-resumed-session-creator-claude'").fetchone()
|
||||
s = json.loads(row[0])
|
||||
assert s["status"] == "running"
|
||||
assert "stopped_at" not in s
|
||||
assert "stop_reason" not in s
|
||||
assert s["claude_session_id_own"] == "new-uuid-999"
|
||||
conn.close()
|
||||
|
||||
def test_comp_resume_find_workspace_uuid_tier1_own_id(mam_sandbox):
|
||||
"""Verify find_workspace_uuid resolves the per-row own ID properly."""
|
||||
# Write a running session with a claude_session_id_own
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'ws-own-creator-claude',
|
||||
'status': 'running',
|
||||
'claude_session_id_own': 'own-uuid-111',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
# Target session mode scopes resolution to the row
|
||||
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude ws-own-creator-claude"
|
||||
# Since we are mock-checking, own_exists function mock will query disk format.
|
||||
# Claude disk format requires a project file: projects/<key>/<uuid>.jsonl
|
||||
# Let's create it.
|
||||
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
(proj_dir / "own-uuid-111.jsonl").write_text('{"sessionId": "own-uuid-111"}')
|
||||
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
# Should resolve to own-uuid-111 because it exists in projects folder
|
||||
assert res.stdout.strip() == "own-uuid-111"
|
||||
|
||||
def test_comp_resume_find_workspace_uuid_tier2_disk_scan_claude(mam_sandbox):
|
||||
"""Verify find_workspace_uuid scans workspace on disk if own ID isn't set in the row."""
|
||||
# Seed session without own ID
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'ws-scan-creator-claude',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Create JSONL file
|
||||
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
(proj_dir / "scanned-uuid.jsonl").write_text('{"sessionId": "scanned-uuid"}')
|
||||
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude"
|
||||
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "scanned-uuid"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_stop_safe_path_checking(mam_sandbox):
|
||||
"""Verify path guards block directory deletion if isolation path check fails."""
|
||||
# Mock a terminated session where isolation root is set outside .mam folder
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-guard-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
||||
'isolation': {
|
||||
'uuid': 'some-uuid',
|
||||
'root': '/tmp/unauthorized_path_outside_mam'
|
||||
}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert "WARN: isolated home path check failed" in res.stdout
|
||||
|
||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-stop-sqlite-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-stop-sqlite-creator-claude"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Query database directly
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT status, data FROM sessions WHERE name='test-stop-sqlite-creator-claude'").fetchone()
|
||||
assert row[0] == "stopped"
|
||||
s = json.loads(row[1])
|
||||
assert s["status"] == "stopped"
|
||||
assert "stopped_at" in s
|
||||
assert s["stop_reason"] == "manual_stop"
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_purge_record_removal(mam_sandbox):
|
||||
"""Verify that purge completely removes the session record from DB."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-record-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-record-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Check SQLite
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT COUNT(*) FROM sessions WHERE name='test-purge-record-creator-claude'").fetchone()
|
||||
assert row[0] == 0
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_lock_release(mam_sandbox):
|
||||
"""Verify SQLite lock is released after running atomic_dump_yaml."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-lock-release',
|
||||
'status': 'stopped',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Since res has returned, the lock should be free.
|
||||
# We test this by immediately acquiring a direct SQLite write connection
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path), timeout=0.1)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute("UPDATE sessions SET status='archived' WHERE name='test-lock-release'")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError as e:
|
||||
pytest.fail(f"Could not acquire SQLite lock: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_comp_stop_graceful_kill_chain(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""Verify that the graceful stop command fallback chain runs when herdr has-session is alive."""
|
||||
# Write a running session in YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-graceful-chain-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER', 'pid': 12345}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Pre-populate herdr mock with the running session so it passes has-session check
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"]["test-graceful-chain-creator-claude"] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(mam_sandbox),
|
||||
"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)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Run stop_session.sh. This should call send-keys first
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-graceful-chain-creator-claude"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Verify mock herdr calls recorded the keys "/exit" sent
|
||||
calls = state.get("calls", [])
|
||||
|
||||
# Should see send-keys call
|
||||
assert any("send" in call and "/exit" in call for call in calls)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 4: Status Query (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_status_read_lock(mam_sandbox):
|
||||
"""Verify status.sh accesses SQLite database with a read lock, not writing."""
|
||||
# Seed DB first so it exists
|
||||
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
|
||||
# Run status.sh in a background sub-process
|
||||
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
|
||||
# Verify no writing occurred by comparing DB file modified time
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
t1 = os.path.getmtime(db_path)
|
||||
|
||||
# Run status.sh again
|
||||
subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||
t2 = os.path.getmtime(db_path)
|
||||
|
||||
assert t1 == t2
|
||||
|
||||
def test_comp_status_drift_class_parsing(mam_sandbox):
|
||||
"""Verify status.sh correctly parses drifts returned by reconcile.sh."""
|
||||
# Write reconcile mock logic that outputs a drift JSON
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
|
||||
drift_output = {
|
||||
"timestamp": "2026-07-19T00:00:00Z",
|
||||
"yaml_path": str(mam_sandbox / ".mam" / "agent-sessions.yaml"),
|
||||
"herdr_sessions_alive": ["test-session|default"],
|
||||
"herdr_confirmed": True,
|
||||
"drifts": [{"class": "A", "name": "test-session", "msg": "drift detected"}],
|
||||
"actions": []
|
||||
}
|
||||
|
||||
# Overwrite reconcile.sh to print this JSON directly
|
||||
reconcile_script.write_text(f"""#!/usr/bin/env bash
|
||||
echo '{json.dumps(drift_output)}'
|
||||
""")
|
||||
|
||||
# Seed YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-session',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': '/tmp'}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
assert data["sessions_detail"][0]["drift_classes"] == ["A"]
|
||||
|
||||
def test_comp_status_job_candidates_parsing(mam_sandbox):
|
||||
"""Verify status.sh reads job statuses from audit logs or registry JSONs."""
|
||||
# Write YAML session
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-job-session-creator-claude',
|
||||
'status': 'running',
|
||||
'delegate_job_id': 'job12345',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Create the status.json in both candidate folders to cover path resolutions
|
||||
job_logs_dir1 = mam_sandbox / ".mam" / "delegate_job_logs" / "job12345"
|
||||
job_logs_dir1.mkdir(parents=True, exist_ok=True)
|
||||
(job_logs_dir1 / "status.json").write_text('{"status": "completed"}')
|
||||
|
||||
job_logs_dir2 = mam_sandbox.parent / ".mam" / "delegate_job_logs" / "job12345"
|
||||
job_logs_dir2.mkdir(parents=True, exist_ok=True)
|
||||
(job_logs_dir2 / "status.json").write_text('{"status": "completed"}')
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
assert data["sessions_detail"][0]["job_status"] == "completed"
|
||||
|
||||
def test_comp_status_no_side_effects(mam_sandbox):
|
||||
"""Verify that running status.sh leaves YAML and DB unchanged."""
|
||||
# Seed DB first so it exists
|
||||
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||
|
||||
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
|
||||
y_content_1 = yaml_path.read_text()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
d_content_1 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||
conn.close()
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
subprocess.run(["bash", str(status_script)], capture_output=True, text=True)
|
||||
|
||||
y_content_2 = yaml_path.read_text()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
d_content_2 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||
conn.close()
|
||||
|
||||
assert y_content_1 == y_content_2
|
||||
assert d_content_1 == d_content_2
|
||||
|
||||
def test_comp_status_yaml_to_json_structure(mam_sandbox):
|
||||
"""Verify YAML-to-JSON structure translation preserves all nested maps."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-struct-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {
|
||||
'index': 0,
|
||||
'pid': 1234,
|
||||
'cmd': 'claude',
|
||||
'cwd': '/tmp'
|
||||
}
|
||||
}]
|
||||
"""
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
data = json.loads(res.stdout)
|
||||
session = data["sessions_detail"][0]
|
||||
assert session["pane_pid"] == 1234
|
||||
assert session["pane_cwd"] == "/tmp"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_monitor_concurrency_lock(mam_sandbox):
|
||||
"""Verify that multiple subscriber reconciles cannot run concurrently."""
|
||||
# Reconcile subscribe script tries to acquire .mam/monitor.lock
|
||||
# Let's write a mock subscriber that acquires the lock
|
||||
workspace_root = mam_sandbox
|
||||
lock_file_path = workspace_root / ".mam" / "monitor.lock"
|
||||
|
||||
# Hold lock in Python
|
||||
import fcntl
|
||||
lock_file = open(lock_file_path, 'w')
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
# Now execute reconcile subscribe loop, it should exit immediately
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--subscribe", "--idle-timeout", "1"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
assert "MQTT Monitor: another subscriber is already running" in res.stdout
|
||||
|
||||
lock_file.close()
|
||||
|
||||
def test_comp_monitor_sqlite_journal_wal(mam_sandbox):
|
||||
"""Verify that SQLite database defaults to WAL mode on standard filesystems."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
# Runs atomic_dump_yaml which sets journal mode
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
conn.close()
|
||||
assert mode.lower() == "wal"
|
||||
|
||||
def test_comp_monitor_sqlite_journal_delete_nfs(mam_sandbox):
|
||||
"""Verify that SQLite database falls back to DELETE mode on NFS filesystems."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
# Set MAM_IS_NFS env var
|
||||
res = run_mutation(mam_sandbox, mutation, env={"MAM_IS_NFS": "true"})
|
||||
assert res.returncode == 0
|
||||
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
conn.close()
|
||||
assert mode.lower() == "delete"
|
||||
|
||||
def test_comp_monitor_db_schema_auto_update(mam_sandbox):
|
||||
"""Verify that table structure and indexes are automatically created if DB is deleted."""
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
mutation = """
|
||||
d['herdr_sessions'] = []
|
||||
"""
|
||||
res = run_mutation(mam_sandbox, mutation)
|
||||
assert res.returncode == 0
|
||||
|
||||
assert db_path.exists()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
assert "state" in tables
|
||||
assert "sessions" in tables
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index';")
|
||||
indexes = [row[0] for row in cursor.fetchall()]
|
||||
assert "idx_sessions_pane_cwd" in indexes
|
||||
conn.close()
|
||||
|
||||
def test_comp_monitor_monotonic_seq_updates(mam_sandbox):
|
||||
"""Verify sequence monotonic updates in mqtt_common."""
|
||||
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||
|
||||
# Setup job JSON in jobs registry dir
|
||||
registry_dir = mam_sandbox / ".mam" / "jobs"
|
||||
registry_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
job_id = "job-seq-123"
|
||||
job_data = {
|
||||
"job_id": job_id,
|
||||
"status": "running",
|
||||
"last_seq": 0
|
||||
}
|
||||
with open(registry_dir / f"{job_id}.json", "w") as f:
|
||||
json.dump(job_data, f)
|
||||
|
||||
# Get sequence multiple times
|
||||
s1 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
s2 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
s3 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||
|
||||
assert s1 == 1
|
||||
assert s2 == 2
|
||||
assert s3 == 3
|
||||
|
||||
# Reload and check
|
||||
with open(registry_dir / f"{job_id}.json", "r") as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded["last_seq"] == 3
|
||||
|
||||
def test_comp_monitor_auto_state_recovery(mam_sandbox, mock_herdr):
|
||||
"""Verify that reconcile.sh auto-terminates a session if herdr is confirmed dead."""
|
||||
# Write a running session in YAML
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-autorecovery-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
# Run reconcile.sh --once --emit-diff (which runs the dry-run, which does NOT write but reports)
|
||||
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
drifts = data["drifts"]
|
||||
# Should detect drift class A: herdr gone
|
||||
assert any(dr["class"] == "A" and dr["name"] == "test-autorecovery-creator-claude" for dr in drifts)
|
||||
|
||||
# Now run reconcile.sh --once --emit-diff (without --dry-run) to trigger write
|
||||
res_write = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res_write.returncode == 0
|
||||
|
||||
# Verify session is marked terminated in the SQLite DB
|
||||
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT status FROM sessions WHERE name='test-autorecovery-creator-claude'").fetchone()
|
||||
assert row[0] == "terminated"
|
||||
conn.close()
|
||||
@@ -0,0 +1,401 @@
|
||||
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["herdr_server"] == "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
|
||||
@@ -0,0 +1,424 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import sqlite3
|
||||
import pytest
|
||||
import shutil
|
||||
import yaml
|
||||
import time
|
||||
import concurrent.futures
|
||||
import threading
|
||||
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_e2e_scenario1_standard_lifecycle(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Scenario 1: Standard Agent Session Lifecycle
|
||||
Spawn session, check registry status, query status via status.sh, and stop session.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
status_script = tmp_path / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
|
||||
session_name = "e2e-sess1-creator-claude"
|
||||
|
||||
# 1. Spawn session using create_session.sh
|
||||
cmd_create = [
|
||||
"bash", str(create_script),
|
||||
"--workspace", str(tmp_path),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator",
|
||||
"--session", session_name
|
||||
]
|
||||
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_create.returncode == 0, f"Stderr: {res_create.stderr}"
|
||||
|
||||
# Verify mock herdr registers it
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
assert session_name in state["agents"]
|
||||
assert state["agents"][session_name]["status"] == "running"
|
||||
|
||||
# 2. Check status via status.sh --json
|
||||
cmd_status = ["bash", str(status_script), "--json"]
|
||||
res_status = subprocess.run(cmd_status, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_status.returncode == 0
|
||||
|
||||
status_data = json.loads(res_status.stdout)
|
||||
sessions_detail = status_data["sessions_detail"]
|
||||
assert any(s["name"] == session_name and s["status"] == "running" for s in sessions_detail)
|
||||
|
||||
# 3. Stop it via stop_session.sh
|
||||
cmd_stop = ["bash", str(stop_script), "--session", session_name]
|
||||
res_stop = subprocess.run(cmd_stop, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_stop.returncode == 0
|
||||
|
||||
# Verify status in YAML/DB becomes stopped
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
session_entry = [s for s in reg.get("herdr_sessions", []) if s["name"] == session_name][0]
|
||||
assert session_entry["status"] == "stopped"
|
||||
|
||||
|
||||
def test_e2e_scenario2_disconnect_resume(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Scenario 2: Session Disconnect and Resume
|
||||
Spawn session, simulate process death in herdr state, run resume_session.sh, and assert resume UUID.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||
|
||||
session_name = "e2e-sess2-creator-claude"
|
||||
|
||||
# 1. Spawn session
|
||||
cmd_create = [
|
||||
"bash", str(create_script),
|
||||
"--workspace", str(tmp_path),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator",
|
||||
"--session", session_name
|
||||
]
|
||||
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_create.returncode == 0
|
||||
|
||||
# Get own UUID from YAML
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
orig_session = reg["herdr_sessions"][0]
|
||||
# Simulate first message creating own ID (materialized own ID)
|
||||
own_uuid = "e2e-own-uuid-111"
|
||||
|
||||
iso_root = Path(orig_session["isolation"]["root"])
|
||||
key = str(tmp_path).replace('/', '-').replace('_', '-')
|
||||
proj_dir = iso_root / "projects" / key
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
(proj_dir / f"{own_uuid}.jsonl").write_text(json.dumps({"sessionId": own_uuid}) + "\n")
|
||||
|
||||
mutation = f"""
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == '{session_name}':
|
||||
s['status'] = 'stopped'
|
||||
s['claude_session_id_own'] = '{own_uuid}'
|
||||
"""
|
||||
res_mut = run_mutation(tmp_path, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# 2. Simulate process death in mock herdr state
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
if session_name in state["agents"]:
|
||||
del state["agents"][session_name]
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# Clear herdr calls to isolate assertions
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["calls"] = []
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# 3. 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"Stderr: {res_resume.stderr}"
|
||||
|
||||
# 4. Assert that it resumes using the correct UUID in the start command arguments
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
calls = state.get("calls", [])
|
||||
resume_call = None
|
||||
for call in calls:
|
||||
if "agent" in call and "start" in call and session_name in call:
|
||||
resume_call = call
|
||||
break
|
||||
|
||||
assert resume_call is not None, f"Could not find resume agent start call in calls: {calls}"
|
||||
assert any(own_uuid in arg for arg in resume_call), f"Expected UUID {own_uuid} to be in resume command: {resume_call}"
|
||||
|
||||
|
||||
def test_e2e_scenario3_drift_auto_reconciliation(mam_sandbox, mock_herdr):
|
||||
"""
|
||||
Scenario 3: Drift Detection and Auto-Reconciliation
|
||||
Setup drift states (running in herdr but not in YAML registry, and running in YAML registry but terminated in herdr).
|
||||
Verify reconcile.sh automatically reconciles both.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||
|
||||
# Drift 1: Running in herdr but not in YAML
|
||||
drift_herdr_only = "drift-herdr-only-creator-claude"
|
||||
with open(mock_herdr, 'r') as f:
|
||||
state = json.load(f)
|
||||
state["agents"][drift_herdr_only] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 5555,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# Drift 2: Running in YAML registry but terminated in herdr
|
||||
drift_yaml_only = "drift-yaml-only-creator-claude"
|
||||
mutation = f"""
|
||||
d['herdr_sessions'] = [{{
|
||||
'name': '{drift_yaml_only}',
|
||||
'status': 'running',
|
||||
'role': 'Creator',
|
||||
'pane': {{
|
||||
'cwd': 'WS_PLACEHOLDER',
|
||||
'pid': 6666,
|
||||
'cmd': 'claude',
|
||||
'cmd_full': 'claude'
|
||||
}}
|
||||
}}]
|
||||
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||
res_mut = run_mutation(tmp_path, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# Run reconcile.sh --once
|
||||
cmd_reconcile = ["bash", str(reconcile_script), "--once"]
|
||||
res_recon = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
assert res_recon.returncode == 0, f"Stderr: {res_recon.stderr}"
|
||||
|
||||
# Verify YAML/DB states
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
sessions = reg.get("herdr_sessions", [])
|
||||
|
||||
# drift-herdr-only-creator-claude should have been auto-registered as running
|
||||
sess_herdr_only = [s for s in sessions if s["name"] == drift_herdr_only]
|
||||
assert len(sess_herdr_only) == 1
|
||||
assert sess_herdr_only[0]["status"] == "running"
|
||||
|
||||
# drift-yaml-only-creator-claude should have been auto-terminated
|
||||
sess_yaml_only = [s for s in sessions if s["name"] == drift_yaml_only]
|
||||
assert len(sess_yaml_only) == 1
|
||||
assert sess_yaml_only[0]["status"] == "terminated"
|
||||
|
||||
|
||||
def test_e2e_scenario4_parallel_flock_locking(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Scenario 4: Parallel Session Operations with flock Locking
|
||||
Run multiple concurrent session creation scripts to verify SQLite locking prevents database corruption.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
num_sessions = 6
|
||||
|
||||
def run_create(i):
|
||||
session_name = f"parallel-sess-{i}-creator-claude"
|
||||
cmd = [
|
||||
"bash", str(create_script),
|
||||
"--workspace", str(tmp_path),
|
||||
"--agent", "claude",
|
||||
"--role", "Creator",
|
||||
"--session", session_name
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||
return res
|
||||
|
||||
# Execute in parallel
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=num_sessions) as executor:
|
||||
futures = [executor.submit(run_create, i) for i in range(num_sessions)]
|
||||
results = [f.result() for f in futures]
|
||||
|
||||
# Verify that all succeeded
|
||||
for i, res in enumerate(results):
|
||||
assert res.returncode == 0, f"Session {i} failed. Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||
|
||||
# Verify all 6 sessions are present in YAML registry
|
||||
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||
with open(yaml_path, 'r') as f:
|
||||
reg = yaml.safe_load(f)
|
||||
sessions = reg.get("herdr_sessions", [])
|
||||
|
||||
registered_names = {s["name"] for s in sessions}
|
||||
for i in range(num_sessions):
|
||||
assert f"parallel-sess-{i}-creator-claude" in registered_names
|
||||
|
||||
|
||||
def test_e2e_scenario5_multi_agent_review_loop(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""
|
||||
Scenario 5: Multi-Agent Review Loop
|
||||
Run run_loop.sh under simulated conditions where reviewer output is mocked.
|
||||
Verify loop terminates correctly with expected PASS and NOT PASS verdicts.
|
||||
"""
|
||||
tmp_path = mam_sandbox
|
||||
loop_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||
|
||||
# 1. Seed sessions in registry for the worker, reviewer, and planner
|
||||
worker_name = "test-worker-creator-claude"
|
||||
reviewer_name = "test-reviewer-creator-claude"
|
||||
planner_name = "test-planner-creator-claude"
|
||||
|
||||
mutation = f"""
|
||||
d['herdr_sessions'] = [
|
||||
{{
|
||||
'name': '{worker_name}',
|
||||
'status': 'running',
|
||||
'role': 'worker',
|
||||
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||
}},
|
||||
{{
|
||||
'name': '{reviewer_name}',
|
||||
'status': 'running',
|
||||
'role': 'reviewer',
|
||||
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||
}},
|
||||
{{
|
||||
'name': '{planner_name}',
|
||||
'status': 'running',
|
||||
'role': 'planner',
|
||||
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||
}}
|
||||
]
|
||||
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||
res_mut = run_mutation(tmp_path, mutation)
|
||||
assert res_mut.returncode == 0
|
||||
|
||||
# Seed mock herdr state with these running sessions to satisfy has-session checks
|
||||
with open(mock_herdr, 'r') as f:
|
||||
herdr_state = json.load(f)
|
||||
for name in [worker_name, reviewer_name, planner_name]:
|
||||
herdr_state["agents"][name] = {
|
||||
"status": "running",
|
||||
"agent": "claude",
|
||||
"cwd": str(tmp_path),
|
||||
"pid": 9999,
|
||||
"pane_id": "w1:p1",
|
||||
"command": "claude",
|
||||
"buffer": "Anthropic Claude Ready"
|
||||
}
|
||||
with open(mock_herdr, 'w') as f:
|
||||
json.dump(herdr_state, f, indent=2)
|
||||
|
||||
# Define mock reviewer and planner outputs
|
||||
# Let's mock a scenario:
|
||||
# Critique: worker challenge
|
||||
# Refinement: refined plan
|
||||
# Review: First try NOT PASS, Second try PASS
|
||||
job_responses = {
|
||||
"Planner": "Refined Plan:\n1. Implement X\n2. Verify X",
|
||||
"critique": "Creator Critique: Plan has 1 edge case.",
|
||||
"Worker": "Creator Output: Code updated.",
|
||||
"Reviewer": "Reviewer verdict:\n\n[VERDICT: PASS]" # will override inside simulator to test iteration logic
|
||||
}
|
||||
|
||||
# We will run a background simulator thread to resolve jobs
|
||||
stop_event = threading.Event()
|
||||
|
||||
def simulate_delegate_jobs():
|
||||
jobs_dir = tmp_path / ".mam" / "jobs"
|
||||
iteration = 1
|
||||
|
||||
while not stop_event.is_set():
|
||||
if not jobs_dir.exists():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
for job_file in jobs_dir.glob("*.json"):
|
||||
try:
|
||||
with open(job_file, 'r+') as f:
|
||||
job = json.load(f)
|
||||
if job.get("status") == "pending":
|
||||
job_id = job["job_id"]
|
||||
role = job.get("role", "Worker")
|
||||
prompt = job.get("prompt", "")
|
||||
|
||||
# Determine response text
|
||||
response_text = ""
|
||||
if role == "Planner":
|
||||
response_text = job_responses["Planner"]
|
||||
elif "Challenge" in prompt or "Critique" in prompt:
|
||||
response_text = job_responses["critique"]
|
||||
elif role == "Worker":
|
||||
response_text = job_responses["Worker"]
|
||||
elif role == "Reviewer":
|
||||
# For Reviewer, fail the first time, pass the second time
|
||||
if iteration == 1:
|
||||
response_text = "Review report:\nSome lint issues found.\n\n[VERDICT: NOT PASS]"
|
||||
iteration += 1
|
||||
else:
|
||||
response_text = "Review report:\nAll clean.\n\n[VERDICT: PASS]"
|
||||
|
||||
# Write final report
|
||||
job_work_dir = jobs_dir / job_id
|
||||
job_work_dir.mkdir(parents=True, exist_ok=True)
|
||||
(job_work_dir / "report-final.md").write_text(response_text)
|
||||
|
||||
# Complete job
|
||||
job["status"] = "completed"
|
||||
f.seek(0)
|
||||
json.dump(job, f, indent=2)
|
||||
f.truncate()
|
||||
|
||||
# Publish completed event over MQTT using publish_event.py
|
||||
pub_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
|
||||
cmd_pub = [
|
||||
sys.executable, str(pub_script),
|
||||
"--registry-dir", str(jobs_dir),
|
||||
"--job", job_id,
|
||||
"--event", "completed",
|
||||
"--detail", f"{role} finished work"
|
||||
]
|
||||
subprocess.run(cmd_pub, capture_output=True, text=True)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
|
||||
sim_thread = threading.Thread(target=simulate_delegate_jobs)
|
||||
sim_thread.daemon = True
|
||||
sim_thread.start()
|
||||
|
||||
try:
|
||||
# Run run_loop.sh
|
||||
cmd_loop = [
|
||||
"bash", str(loop_script),
|
||||
"--target-agent", worker_name,
|
||||
"--reviewer", reviewer_name,
|
||||
"--plan",
|
||||
"--plan-talk", "1",
|
||||
"--max-loop", "3",
|
||||
"--task", "Implement feature X and verify"
|
||||
]
|
||||
import sys
|
||||
run_env = dict(os.environ)
|
||||
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||
res_loop = subprocess.run(cmd_loop, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
|
||||
# Verify that it succeeded and executed the corrective loop
|
||||
assert res_loop.returncode == 0, f"Loop failed. Stdout: {res_loop.stdout}\nStderr: {res_loop.stderr}"
|
||||
assert "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stdout or "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stderr or "NOT PASS" in res_loop.stdout
|
||||
assert "Reviewer 'test-reviewer-creator-claude': PASS" in res_loop.stdout
|
||||
assert "Mux loop finished with 100% PASS verdicts." in res_loop.stdout
|
||||
|
||||
finally:
|
||||
stop_event.set()
|
||||
sim_thread.join(timeout=1.0)
|
||||
Reference in New Issue
Block a user