fix(herdr): resolve shim routing defects, add workspace scoping, and bump to v3.0.1

- Resolve Herdr shim 5 routing & paste defects (ISSUE-1 ~ ISSUE-5):
  * paste-buffer: use pane send-text without auto-enter, propagate rc=3 to send_keys_safe
  * exact-match pane resolution: remove substring matching ('in tn') across all branches
  * workspace scoping: introduce HERDR_WORKSPACE_ID and .mam/herdr_workspace_id persistence
  * unified resolver: single _resolve_herdr_pane_id helper across shim commands
- Resolve dialog token false-positive on 'Yes, try it' tip and isolate fullscreen modal rejection
- Sync mock Herdr CLI contracts in tests/conftest.py
- Add contract tests H-15~H-23 and regression tests D-4~D-7 (412 tests, 100% PASS)
- Add multi-agent loop plans, review reports, and bug report
- Update framework and skill packages to v3.0.1
This commit is contained in:
2026-08-27 21:42:37 +09:00
parent 4bbd03bf2d
commit c3631e2aa1
18 changed files with 1982 additions and 115 deletions
+109 -39
View File
@@ -44,6 +44,7 @@ def mam_sandbox(tmp_path, monkeypatch):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
import sys
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
@@ -150,7 +151,13 @@ def save_state():
if "panes" in state:
disk_panes = disk_state.setdefault("panes", [])
for p in state["panes"]:
if not any(dp.get("pane_id") == p.get("pane_id") for dp in disk_panes):
matched = False
for i, dp in enumerate(disk_panes):
if dp.get("pane_id") == p.get("pane_id"):
disk_panes[i] = p
matched = True
break
if not matched:
disk_panes.append(p)
disk_calls = disk_state.setdefault("calls", [])
if sys.argv[1:] and (not disk_calls or disk_calls[-1] != sys.argv[1:]):
@@ -260,23 +267,42 @@ elif cmd1 == "pane":
else:
i += 1
panes_list = []
# Build panes from agents or state["panes"]
seen = set()
# Merge agent-derived panes with state["panes"] (dedupe by pane_id).
# Label-only panes live in state["panes"] and must remain visible
# even when other agents are registered.
for name, data in state.get("agents", {}).items():
ws_id = data.get("workspace_id", "w1")
if target_ws and ws_id != target_ws:
continue
panes_list.append({
"pane_id": data.get("pane_id", f"{ws_id}:p1"),
pid = data.get("pane_id", f"{ws_id}:p1")
entry = {
"pane_id": pid,
"workspace_id": ws_id,
"cwd": data.get("cwd", "."),
"tab_id": f"{ws_id}:t1",
"agent": data.get("agent", "claude")
})
if not panes_list:
for p in state.get("panes", []):
if target_ws and p.get("workspace_id") != target_ws:
continue
panes_list.append(p)
"tab_id": data.get("tab_id", f"{ws_id}:t1"),
"agent": data.get("agent", "claude"),
"name": name,
}
if data.get("label"):
entry["label"] = data["label"]
panes_list.append(entry)
seen.add(pid)
for p in state.get("panes", []):
if target_ws and p.get("workspace_id") != target_ws:
continue
pid = p.get("pane_id")
if pid in seen:
for existing in panes_list:
if existing.get("pane_id") == pid:
for k in ("label", "name"):
if p.get(k) and not existing.get(k):
existing[k] = p[k]
break
continue
panes_list.append(p)
if pid:
seen.add(pid)
print(json.dumps({"result": {"panes": panes_list}}))
sys.exit(0)
elif cmd2 == "split":
@@ -335,8 +361,70 @@ elif cmd1 == "pane":
state["agents"] = agents
save_state()
sys.exit(0)
else:
for p in state.get("panes", []):
if p.get("pane_id") == name:
p["sent_keys"] = p.get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
p["buffer"] = p.get("buffer", "") + "\\n\\nesc to interrupt"
save_state()
sys.exit(0)
sys.exit(1)
elif cmd2 == "send-text":
if len(args) < 4:
sys.exit(1)
pane_id = args[2]
text = args[3]
found = False
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or a_name == pane_id:
data["sent_text"] = data.get("sent_text", "") + text
data["buffer"] = data.get("buffer", "") + "\\n" + text
found = True
break
if not found:
for p in state.get("panes", []):
if p.get("pane_id") == pane_id:
p["sent_text"] = p.get("sent_text", "") + text
p["buffer"] = p.get("buffer", "") + "\\n" + text
found = True
break
if found:
save_state()
sys.exit(0)
sys.exit(1)
elif cmd2 == "read":
if len(args) < 3:
sys.exit(1)
pane_id = args[2]
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or a_name == pane_id:
print(data.get("buffer", "Ready"))
sys.exit(0)
for p in state.get("panes", []):
if p.get("pane_id") == pane_id:
print(p.get("buffer", ""))
sys.exit(0)
sys.stderr.write("Pane " + pane_id + " not found\\n")
sys.exit(1)
elif cmd2 == "rename":
if len(args) < 4:
sys.exit(1)
pane_id = args[2]
label = args[3]
panes = state.setdefault("panes", [])
renamed = False
for p in panes:
if p.get("pane_id") == pane_id:
p["label"] = label
renamed = True
break
if not renamed:
panes.append({"pane_id": pane_id, "label": label})
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id:
data["label"] = label
save_state()
sys.exit(0)
elif cmd2 == "process-info":
pane_id = ""
if "--pane" in args:
@@ -633,33 +721,15 @@ elif cmd1 == "agent":
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
state["agents"] = agents
save_state()
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
elif cmd2 == "send":
if len(args) < 4:
sys.exit(1)
name = args[2]
text = args[3]
agents = state.get("agents", {})
matched_k = None
for k in agents:
if _match_agent(k, name):
matched_k = k
break
if matched_k:
agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text
if text in ("C-m", "Enter"):
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
else:
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n" + text
if "/exit" in text or "exit" in text or "Exit" in text:
agents[matched_k]["status"] = "stopped"
state["agents"] = agents
save_state()
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
else:
sys.stderr.write("Agent " + name + " not found\\\\n")
sys.exit(1)
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
else:
# Real herdr has no `agent send`. Unknown subcommands must fail
# (exit 1) rather than silently succeeding.
sys.stderr.write("error: unrecognized subcommand '" + cmd2 + "'\\n")
sys.exit(1)
elif cmd1 == "session":
if len(args) < 2:
+104 -3
View File
@@ -165,8 +165,92 @@ rm -f "$KEYS"
assert "Enter" not in res.stdout.split("KEYS_CONTENT=")[-1]
def _lib_helper_src():
content = open(_LIB_SH, encoding="utf-8").read()
start = content.find("_herdr_ws_id_file() {")
end = content.find('cmd="${1:-}"', start)
assert start != -1 and end != -1
return content[start:end]
def test_resolve_pane_id_fails_cleanly_under_set_e():
"""D-4: _resolve_herdr_pane_id must return 1 with empty stdout under set -e."""
helper = _lib_helper_src()
script = """
set -euo pipefail
unset HERDR_WORKSPACE_ID
source "LIB_SH_PLACEHOLDER"
_init_herdr_isolation() { :; }
_real_herdr() { return 1; }
HELPER_PLACEHOLDER
_resolve_herdr_pane_id nonexistent >/dev/null 2>&1 || true
echo SURVIVED
rc=0
out=$(_resolve_herdr_pane_id nonexistent 2>/dev/null) || rc=$?
printf 'OUT=%s\\n' "$out"
echo "RC=$rc"
""".replace("LIB_SH_PLACEHOLDER", _LIB_SH).replace("HELPER_PLACEHOLDER", helper)
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
assert res.returncode == 0, res.stderr + res.stdout
assert "SURVIVED" in res.stdout
assert "RC=1" in res.stdout
out_line = [l for l in res.stdout.splitlines() if l.startswith("OUT=")][0]
assert out_line == "OUT="
def test_send_keys_safe_returns_3_when_paste_buffer_fails():
"""D-5 (ISSUE-1): paste-buffer failure returns 3 and still deletes the buffer."""
script = """
_pane_quiescent() { return 0; }
_pane_dialog_open() { return 1; }
LOG=/tmp/mam-d5-sks.$$
: > "$LOG"
_sks_herdr() {
echo "$*" >> "$LOG"
if [ "${1:-}" = "agent" ] && [ "${2:-}" = "prompt" ]; then return 1; fi
if [ "${1:-}" = "paste-buffer" ]; then return 1; fi
return 0
}
rc=0
send_keys_safe "d5-sess" "hello" "job-d5" || rc=$?
echo "RC=$rc"
echo "LOG_CONTENT=$(tr '\\n' '|' < "$LOG")"
rm -f "$LOG"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "RC=3" in res.stdout
log = res.stdout.split("LOG_CONTENT=")[-1]
assert "C-m" not in log
assert "delete-buffer" in log
def test_no_substring_matching_remains_in_lib_sh():
"""D-6 (ISSUE-2): no `in tn` substring matching remains in lib.sh."""
import re
content = open(_LIB_SH, encoding="utf-8").read()
assert re.search(r"\bin tn\b", content) is None
assert re.search(r'agent"\) in tn', content) is None
start = content.find("_resolve_herdr_pane_id()")
assert start != -1
body = content[start:content.find('cmd="${1:-}"', start)]
assert r"^w[A-Za-z0-9]+:p[A-Za-z0-9]+$" in body
def test_paste_buffer_branch_never_submits():
"""D-7 (ISSUE-1): paste-buffer arm must not submit or invoke prompt/run."""
content = open(_LIB_SH, encoding="utf-8").read()
start = content.find(" paste-buffer)")
assert start != -1
end = content.find("\n delete-buffer)", start)
assert end != -1
body = content[start:end]
for token in ("Enter", "C-m", "pane run", "agent prompt"):
assert token not in body, token
def test_fullscreen_modal_is_rejected_not_accepted():
"""D-3: Yes, try it modal is a dialog; dismiss with Escape, never Enter."""
"""D-3: fullscreen modal is dismissed with Escape, never Enter."""
script = f"""
_pane_capture() {{ printf '%s' '{_FULLSCREEN_MODAL}'; }}
KEYS=/tmp/mam-fs-modal-keys.$$
@@ -175,19 +259,36 @@ _sks_herdr() {{
if [ "${{1:-}}" = "send-keys" ]; then echo "$*" >> "$KEYS"; fi
return 0
}}
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
handle_startup_dialogs dummy 1
echo "KEYS_CONTENT=$(tr '\\n' '|' < "$KEYS")"
rm -f "$KEYS"
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "DIALOG_OPEN" in res.stdout
keys = res.stdout.split("KEYS_CONTENT=")[-1]
assert "Escape" in keys
assert "Enter" not in keys
def test_prose_yes_try_it_is_not_a_dialog():
"""N-1 / F-1: conversational 'Yes, try it' must not trip _pane_dialog_open."""
script = r"""
_pane_capture() { printf '%s' 'Sure — if the build fails again, Yes, try it with the --clean flag.'; }
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
"""
res = _run_lib_helpers(script)
assert res.returncode == 0, res.stderr + res.stdout
assert "DIALOG_CLOSED" in res.stdout
def test_mam_dialog_tokens_exclude_yes_try_it():
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays."""
content = open(_LIB_SH, encoding="utf-8").read()
token_line = next(l for l in content.splitlines() if l.startswith("_MAM_DIALOG_TOKENS="))
assert "Yes, try it" not in token_line
assert "grep -q 'Yes, try it'" in content
def test_wait_for_tui_ready_succeeds_on_fullscreen_tip():
"""D-2c: tip + ready banner must not deadlock wait_for_tui_ready."""
script = f"""
+356
View File
@@ -1,6 +1,7 @@
import os
import sys
import json
import re
import subprocess
import pytest
import shutil
@@ -125,3 +126,358 @@ for p in procs:
# 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")