diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 3a202a2e..d8acdd76 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -5,7 +5,9 @@ set -eu action="${1:-}" -cat >/dev/null 2>/dev/null || true +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-claude-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true case "$action" in working|idle|blocked|release) ;; @@ -17,7 +19,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -28,10 +30,25 @@ source = "herdr:claude" action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") if not pane_id or not socket_path: raise SystemExit(0) +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +is_subagent = bool(hook_input.get("agent_id")) +if is_subagent: + raise SystemExit(0) + request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" if action == "release": request = { diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 3f81b371..f3b02b9b 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -6,7 +6,7 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -286,6 +286,90 @@ fn send_request(socket_path: &Path, json: &str) -> serde_json::Value { serde_json::from_str(&line).unwrap() } +fn run_claude_hook(action: &str, hook_input: &str) -> Option { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(700); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + stream + .write_all(br#"{"id":"test","result":{"type":"ok"}}"#) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + return Some(line); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept failed: {err}"), + } + } + None + }); + + let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/integration/assets/claude/herdr-agent-state.sh"); + let mut child = Command::new("bash") + .arg(hook_path) + .arg(action) + .env("HERDR_ENV", "1") + .env("HERDR_SOCKET_PATH", &socket_path) + .env("HERDR_PANE_ID", "p_test") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(hook_input.as_bytes()).unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "hook failed: status={:?} stderr={} stdout={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + + let request = server.join().unwrap(); + cleanup_test_base(&base); + request.map(|line| serde_json::from_str(&line).unwrap()) +} + +#[test] +fn claude_hook_suppresses_subagent_reports() { + let subagent_input = r#"{"hook_event_name":"PermissionRequest","agent_id":"agent-abc123","agent_type":"Explore"}"#; + + assert!(run_claude_hook("working", subagent_input).is_none()); + assert!(run_claude_hook("blocked", subagent_input).is_none()); + assert!(run_claude_hook("idle", subagent_input).is_none()); + assert!(run_claude_hook("release", subagent_input).is_none()); +} + +#[test] +fn claude_hook_keeps_parent_agent_type_only_blocked() { + let request = run_claude_hook( + "blocked", + r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#, + ) + .expect("parent blocked should still report blocked"); + + assert_eq!(request["method"], "pane.report_agent"); + assert_eq!(request["params"]["state"], "blocked"); +} + #[test] fn pane_run_sends_one_send_input_request_with_enter_key() { let base = unique_test_dir();