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
+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")