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:
@@ -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