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:
+109
-39
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user