aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh21
-rw-r--r--tests/cli_wrapper.rs86
2 files changed, 104 insertions, 3 deletions
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<serde_json::Value> {
+ 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();