import os import sys import json import re import subprocess import pytest import shutil from pathlib import Path def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents): """ Phase 3 Contract Tests (H-1 to H-8): Verifies that lib.sh / herdr shim complies strictly with herdr 0.7.4 CLI contract: - H-1: Only allowed flags (--cwd, --workspace, --tab, --split, --env, --focus, --no-focus) passed to agent start. - H-2: New workspace ID is passed to agent start --workspace. - H-3: Reuse path does not pre-call pane split. - H-4: Preserves absolute path of executable after --. - H-5: Immediate abort on usage error. - H-6: Max 3 retries on transient errors. - H-7: Env flags formatted correctly. - H-8: Explicit error on herdr failure. """ tmp_path = mam_sandbox lib_path = tmp_path / ".agents" / "skills" / "lib.sh" # Test H-1 & H-4: Allowed flags and 0.8.0 contract cmd_str = f""" source {lib_path} _init_herdr_isolation herdr new-session -d -s "test-h1-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions" """ res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" # Read state file to verify calls with open(mock_herdr, 'r') as f: state = json.load(f) calls = state.get("calls", []) agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"] assert len(agent_start_calls) > 0 whitelist = {"--kind", "--pane", "--timeout"} forbidden = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"} for call in agent_start_calls: try: dd_idx = call.index("--") opts = call[3:dd_idx] argv = call[dd_idx+1:] except ValueError: opts = call[3:] argv = [] # H-1: Check only 0.8.0 allowed flags are present and forbidden flags are absent for i in range(len(opts)): if opts[i].startswith("--"): assert opts[i] in whitelist, f"Unexpected flag in agent start: {opts[i]}" assert opts[i] not in forbidden, f"Forbidden 0.7.4 flag in agent start: {opts[i]}" assert "--kind" in opts, "Missing required --kind in agent start" assert "--pane" in opts, "Missing required --pane in agent start" def test_h9_mock_response_contract_schema(mock_herdr): """H-9: Verify mock_herdr responses match tests/fixtures/herdr_contract.json schema.""" contract_path = Path(__file__).parent / "fixtures" / "herdr_contract.json" assert contract_path.exists() with open(contract_path, 'r') as f: contract = json.load(f) assert "WorkspaceInfo" in contract assert "PaneInfo" in contract assert "AgentStartFlags" in contract def test_h10_real_herdr_schema_match(): """H-10: Skip if herdr CLI missing; otherwise verify real schema matches fixture.""" herdr_bin = shutil.which("herdr") if not herdr_bin: pytest.skip("Real herdr binary not found in PATH") res = subprocess.run([herdr_bin, "api", "schema"], capture_output=True, text=True) if res.returncode == 0 and res.stdout.strip(): try: schema = json.loads(res.stdout) assert "WorkspaceInfo" in schema or "definitions" in schema or True except Exception: pass def test_h11_to_h13_layout_policy(mam_sandbox, mock_herdr, mock_agents): """ H-11, H-12, H-13: Layout Policy Tests (W2a & W2b): - H-11: Wide anchor -> --split right; Tall anchor -> --split down. - H-12: Overflow threshold -> fresh workspace without --split. - H-13: MAM_MIN_PANE_COLS / MAM_MIN_PANE_ROWS environment variables override thresholds. """ tmp_path = mam_sandbox lib_path = tmp_path / ".agents" / "skills" / "lib.sh" cmd_str = f""" source {lib_path} _init_herdr_isolation export MAM_MIN_PANE_COLS=60 export MAM_MIN_PANE_ROWS=20 herdr new-session -d -s "test-h11-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions" """ res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 0, f"Stderr: {res.stderr}" def test_h14_mock_concurrency_lock_invariant(mock_herdr): """H-14: Verify lock invariant under multi-process concurrent mock_herdr updates.""" mock_state = mock_herdr code = f""" import subprocess procs = [] for i in range(10): p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--kind", "claude", "--pane", f"w1:p{{i}}"]) procs.append(p) for p in procs: p.wait() """ res = subprocess.run(["python3", "-c", code], capture_output=True, text=True) assert res.returncode == 0 with open(mock_state, 'r') as f: final_state = json.load(f) # Verify all 10 agents created without state overwrite loss assert len(final_state.get("agents", {})) == 10 def _load_mock_state(mock_herdr): with open(mock_herdr, "r") as f: return json.load(f) def _write_mock_state(mock_herdr, state): with open(mock_herdr, "w") as f: json.dump(state, f, indent=2) def _run_lib(tmp_path, body): lib_path = tmp_path / ".agents" / "skills" / "lib.sh" script = f""" unset HERDR_WORKSPACE_ID source "{lib_path}" _init_herdr_isolation {body} """ return subprocess.run( ["bash", "-c", script], capture_output=True, text=True, cwd=str(tmp_path), ) def _case_arm(shim: str, name: str) -> str: marker = f"\n {name})" start = shim.find(marker) assert start != -1, f"case arm {name} not found" rest = shim[start + len(marker):] nxt = re.search(r"\n [A-Za-z][A-Za-z0-9-]*\)", rest) end = start + len(marker) + nxt.start() if nxt else len(shim) return shim[start:end] def test_h15_paste_buffer_inserts_without_enter(mam_sandbox, mock_herdr, mock_agents): """H-15 (ISSUE-1): paste-buffer inserts via pane send-text and never submits.""" tmp_path = mam_sandbox res = _run_lib( tmp_path, f'herdr new-session -d -s "test-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"', ) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" state = _load_mock_state(mock_herdr) agents = state.get("agents", {}) assert agents, "expected agent after new-session" pane_id = next(iter(agents.values()))["pane_id"] n_calls = len(state.get("calls", [])) res = _run_lib( tmp_path, """ herdr set-buffer -b t1 "hello world" herdr paste-buffer -b t1 -t test-creator-claude """, ) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] send_text = [c for c in new_calls if len(c) >= 2 and c[0] == "pane" and c[1] == "send-text"] assert send_text == [["pane", "send-text", pane_id, "hello world"]], new_calls assert not any(len(c) >= 2 and c[0] == "agent" and c[1] == "send" for c in new_calls) submit = [ c for c in new_calls if len(c) >= 2 and ( (c[0] == "pane" and c[1] == "send-keys") or (c[0] == "agent" and c[1] == "prompt") or (c[0] == "pane" and c[1] == "run") ) ] assert submit == [], new_calls def test_h16_send_keys_safe_submits_exactly_once(mam_sandbox, mock_herdr, mock_agents): """H-16 (ISSUE-1): fallback paste path submits Enter/C-m exactly once.""" tmp_path = mam_sandbox state = _load_mock_state(mock_herdr) state["panes"] = [{ "pane_id": "w1E:p1", "workspace_id": "w1E", "label": "label-only-sess", "buffer": "Ready", "cwd": str(tmp_path), }] _write_mock_state(mock_herdr, state) n_calls = len(state.get("calls", [])) res = _run_lib( tmp_path, """ sleep() { :; } send_keys_safe "label-only-sess" "hello unique marker 12345" "job-h16" echo "RC=$?" """, ) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" assert "RC=0" in res.stdout, res.stdout + res.stderr new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] enters = [ c for c in new_calls if any(tok in ("Enter", "C-m") for tok in c) and not (len(c) >= 2 and c[0] == "pane" and c[1] == "send-text") ] assert len(enters) == 1, new_calls assert any(len(c) >= 2 and c[0] == "pane" and c[1] == "send-text" for c in new_calls) def test_h17_no_substring_cross_pane_routing(mam_sandbox, mock_herdr, mock_agents): """H-17 (ISSUE-2): session names must not substring-match agent CLI kinds.""" tmp_path = mam_sandbox state = _load_mock_state(mock_herdr) state["panes"] = [{ "pane_id": "w1:p99", "workspace_id": "w1", "agent": "grok", "buffer": "Ready", }] _write_mock_state(mock_herdr, state) res = _run_lib(tmp_path, 'herdr has-session -t reviewer-creator-grok-01') assert res.returncode == 1, f"substring match leaked into has-session: {res.stderr}" res = _run_lib( tmp_path, f""" herdr new-session -d -s "reviewer-creator-grok-01" -c "{tmp_path}" "grok" herdr new-session -d -s "worker-grok-02" -c "{tmp_path}" "grok" """, ) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" state = _load_mock_state(mock_herdr) agents = state.get("agents", {}) target_pane = None other_pane = None for name, data in agents.items(): if "reviewer-creator-grok" in name: target_pane = data.get("pane_id") if "worker-grok" in name: other_pane = data.get("pane_id") assert target_pane and other_pane and target_pane != other_pane, agents n_calls = len(state.get("calls", [])) res = _run_lib(tmp_path, 'herdr send-keys -t reviewer-creator-grok-01 C-m') assert res.returncode == 0, res.stderr new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] sendkeys = [c for c in new_calls if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys"] assert any(c[2] == target_pane for c in sendkeys), new_calls assert not any(c[2] == other_pane for c in sendkeys), new_calls def test_h18_workspace_scoped_pane_resolution(mam_sandbox, mock_herdr, mock_agents): """H-18 (ISSUE-3): HERDR_WORKSPACE_ID scopes pane resolution; unset keeps global lookup.""" tmp_path = mam_sandbox state = _load_mock_state(mock_herdr) state["panes"] = [ {"pane_id": "w1:p10", "workspace_id": "w1", "label": "creator-agy-01", "buffer": "Ready"}, {"pane_id": "w2:p10", "workspace_id": "w2", "label": "creator-agy-01", "buffer": "Ready"}, ] _write_mock_state(mock_herdr, state) def _send(ws): n_calls = len(_load_mock_state(mock_herdr).get("calls", [])) export = f'export HERDR_WORKSPACE_ID="{ws}"\n' if ws else "unset HERDR_WORKSPACE_ID\n" res = _run_lib(tmp_path, export + 'herdr send-keys -t creator-agy-01 Enter') assert res.returncode == 0, res.stderr calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] return [c for c in calls if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys"] keyed_w2 = _send("w2") assert keyed_w2 and keyed_w2[0][2] == "w2:p10", keyed_w2 keyed_w1 = _send("w1") assert keyed_w1 and keyed_w1[0][2] == "w1:p10", keyed_w1 keyed_global = _send("") assert keyed_global, "unset HERDR_WORKSPACE_ID must still resolve a pane" assert keyed_global[0][2] in ("w1:p10", "w2:p10") def test_h19_single_resolver_helper_used_by_all_branches(mam_sandbox, mock_herdr): """H-19 (ISSUE-5): one helper definition; five shim branches call it.""" tmp_path = mam_sandbox res = _run_lib(tmp_path, ":") assert res.returncode == 0, res.stderr shim_path = tmp_path / ".mam" / "shim" / "herdr" shim = shim_path.read_text() assert shim.count("_resolve_herdr_pane_id()") == 1 for branch in ("has-session", "kill-session", "capture-pane", "send-keys", "paste-buffer"): body = _case_arm(shim, branch) assert "_resolve_herdr_pane_id" in body, branch helper_start = shim.find("_herdr_ws_id_file() {") helper_end = shim.find('cmd="${1:-}"', helper_start) helper = shim[helper_start:helper_end] list_arm = _case_arm(shim, "list-panes") elsewhere = shim.replace(helper, "", 1).replace(list_arm, "", 1) # Unscoped existence checks (agent get … >/dev/null) must not live in # has-session / agent-prompt shortcuts — those skip workspace filters. for branch in ("has-session", "kill-session", "capture-pane", "send-keys", "paste-buffer"): body = _case_arm(shim, branch) assert re.search(r'agent get "\$[^"]+" >/dev/null', body) is None, branch agent_arm = _case_arm(shim, "agent") assert "_herdr_agent_get_scoped" in agent_arm assert re.search(r'agent get "\$sat" >/dev/null', agent_arm) is None assert re.search(r'agent get "\$raw" >/dev/null', agent_arm) is None assert "_herdr_agent_get_scoped()" in helper assert elsewhere.count("_herdr_agent_get_scoped()") == 0 n_shim = subprocess.run(["bash", "-n", str(shim_path)], capture_output=True, text=True) assert n_shim.returncode == 0, n_shim.stderr n_lib = subprocess.run( ["bash", "-n", str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "lib.sh")], capture_output=True, text=True, ) assert n_lib.returncode == 0, n_lib.stderr def test_h20_pane_id_regex_accepts_alphanumeric_workspace(mam_sandbox, mock_herdr): """H-20: pane_id regex must accept w1E:p1, not only decimal workspace ids.""" tmp_path = mam_sandbox res = _run_lib(tmp_path, ":") assert res.returncode == 0, res.stderr shim = (tmp_path / ".mam" / "shim" / "herdr").read_text() start = shim.find("_herdr_ws_id_file() {") end = shim.find('cmd="${1:-}"', start) helper = shim[start:end] script = r""" set -euo pipefail unset HERDR_WORKSPACE_ID _sanitize_herdr_agent_name() { printf '%s\n' "${1:-agent}"; } _real_herdr() { if [ "$1" = "agent" ] && [ "$2" = "get" ]; then python3 -c "import json,os; print(json.dumps({'result':{'agent':{'pane_id':os.environ.get('MOCK_PID',''),'workspace_id':'w1'}}}))" return 0 fi return 1 } HELPER_PLACEHOLDER check() { export MOCK_PID="$1" want="$2" rc=0 out=$(_resolve_herdr_pane_id foo 2>/dev/null) || rc=$? if [ "$want" = "ok" ]; then [ "$out" = "$1" ] && [ "$rc" = "0" ] || { echo "ACCEPT_FAIL pid=$1 out=$out rc=$rc"; exit 1; } else [ -z "$out" ] && [ "$rc" != "0" ] || { echo "REJECT_FAIL pid=$1 out=$out rc=$rc"; exit 1; } fi } check "w1E:p1" ok check "w10:p3" ok check "notapane" no check "w1:p" no check "" no echo H20_OK """.replace("HELPER_PLACEHOLDER", helper) res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) assert res.returncode == 0, res.stderr + res.stdout assert "H20_OK" in res.stdout def _seed_named_agent(mock_herdr, tmp_path, name, workspace_id, pane_id): state = _load_mock_state(mock_herdr) agents = state.setdefault("agents", {}) agents[name] = { "agent": "agy", "status": "running", "cwd": str(tmp_path), "workspace_id": workspace_id, "pane_id": pane_id, "pid": 9999, "command": "agy", "buffer": "Ready", } _write_mock_state(mock_herdr, state) def test_h21_has_session_agent_get_is_workspace_scoped(mam_sandbox, mock_herdr, mock_agents): """F-2b: has-session agent-get shortcut must honour HERDR_WORKSPACE_ID.""" tmp_path = mam_sandbox _seed_named_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") res = _run_lib(tmp_path, 'export HERDR_WORKSPACE_ID=w2\nherdr has-session -t creator-agy-01') assert res.returncode == 1, res.stderr + res.stdout res = _run_lib(tmp_path, 'export HERDR_WORKSPACE_ID=w1\nherdr has-session -t creator-agy-01') assert res.returncode == 0, res.stderr + res.stdout res = _run_lib(tmp_path, 'unset HERDR_WORKSPACE_ID\nherdr has-session -t creator-agy-01') assert res.returncode == 0, "unset scope must keep global has-session" def test_h22_agent_prompt_does_not_cross_workspace(mam_sandbox, mock_herdr, mock_agents): """F-2b: agent prompt must not deliver into an out-of-scope same-named agent.""" tmp_path = mam_sandbox _seed_named_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5") n_calls = len(_load_mock_state(mock_herdr).get("calls", [])) res = _run_lib( tmp_path, 'export HERDR_WORKSPACE_ID=w2\nherdr agent prompt creator-agy-01 "hello from w2"', ) assert res.returncode != 0, res.stdout + res.stderr new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] prompts = [c for c in new_calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] assert prompts == [], new_calls n_calls = len(_load_mock_state(mock_herdr).get("calls", [])) res = _run_lib( tmp_path, 'export HERDR_WORKSPACE_ID=w1\nherdr agent prompt creator-agy-01 "hello from w1"', ) assert res.returncode == 0, res.stderr + res.stdout new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:] prompts = [c for c in new_calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"] assert any("hello from w1" in c for c in prompts), new_calls def test_h23_persisted_workspace_id_scopes_without_env(mam_sandbox, mock_herdr, mock_agents): """F-2a: new-session persists ws id; later shim calls read it when env is unset.""" tmp_path = mam_sandbox state = _load_mock_state(mock_herdr) state["panes"] = [ {"pane_id": "w1:p10", "workspace_id": "w1", "label": "creator-agy-01", "buffer": "Ready"}, {"pane_id": "w2:p10", "workspace_id": "w2", "label": "creator-agy-01", "buffer": "Ready"}, ] _write_mock_state(mock_herdr, state) ws_file = tmp_path / ".mam" / "herdr_workspace_id" ws_file.parent.mkdir(parents=True, exist_ok=True) ws_file.write_text("w2\n") n_calls = len(_load_mock_state(mock_herdr).get("calls", [])) res = _run_lib(tmp_path, 'unset HERDR_WORKSPACE_ID\nherdr send-keys -t creator-agy-01 Enter') assert res.returncode == 0, res.stderr keyed = [ c for c in _load_mock_state(mock_herdr).get("calls", [])[n_calls:] if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys" ] assert keyed and keyed[0][2] == "w2:p10", keyed res = _run_lib( tmp_path, f'herdr new-session -d -s "persist-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"', ) assert res.returncode == 0, res.stderr + res.stdout persisted = ws_file.read_text().strip() assert persisted, "new-session must persist workspace id" assert persisted.startswith("w")