diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:52:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:56:07 +0300 |
| commit | 3f80947678a9723434aa7fddfe23750b5393e24c (patch) | |
| tree | 6f463797132acfc51dfb6e135820a052954b4f13 /tests/cli/hooks.rs | |
| parent | f76f895efe5b9f19aa9db909354c6c0f2195630b (diff) | |
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'tests/cli/hooks.rs')
| -rw-r--r-- | tests/cli/hooks.rs | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs new file mode 100644 index 00000000..b2edd3e7 --- /dev/null +++ b/tests/cli/hooks.rs @@ -0,0 +1,308 @@ +use super::harness::*; + +fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/claude/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/codex/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/copilot/herdr-agent-state.sh", + &[], + hook_input, + ) +} + +fn run_devin_hook( + action: &str, + hook_input: &str, + envs: &[(&str, &str)], +) -> Option<serde_json::Value> { + run_shell_hook_with_env( + "src/integration/assets/devin/herdr-agent-state.sh", + &[action], + hook_input, + envs, + ) +} + +fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook_with_env(asset_path, args, hook_input, &[]) +} + +fn run_shell_hook_with_env( + asset_path: &str, + args: &[&str], + hook_input: &str, + envs: &[(&str, &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(); + let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); + let _ = stream.write_all(b"\n"); + let _ = stream.flush(); + 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(asset_path); + let mut command = Command::new("bash"); + command + .arg(hook_path) + .args(args) + .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()); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command.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_ignores_state_actions() { + let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; + + assert!(run_claude_hook("working", subagent_input).is_none()); + assert!(run_claude_hook("blocked", subagent_input).is_none()); +} + +#[test] +fn claude_hook_ignores_subagent_completion_reports() { + let subagent_input = + r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#; + + assert!(run_claude_hook("working", 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"}"#, + ); + + assert!(request.is_none()); +} + +#[test] +fn claude_hook_reports_session_id_from_stdin() { + let request = run_claude_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, + ) + .expect("session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "claude-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn codex_hook_reports_session_id_from_stdin() { + let request = run_codex_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#, + ) + .expect("codex hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "codex-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn copilot_hook_reports_session_id_from_stdin() { + let request = run_copilot_hook( + r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#, + ) + .expect("copilot session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "copilot"); + assert_eq!(request["params"]["agent_session_id"], "copilot-session"); + assert!(request["params"].get("state").is_none()); + + let camel = run_copilot_hook( + r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#, + ) + .expect("copilot camelCase session start should report session identity"); + + assert_eq!(camel["method"], "pane.report_agent_session"); + assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session"); + assert!(camel["params"].get("state").is_none()); +} + +#[test] +fn copilot_hook_does_not_report_lifecycle_state() { + for payload in [ + r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, + r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#, + r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#, + r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, + r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#, + ] { + assert!( + run_copilot_hook(payload).is_none(), + "copilot session-only hook should ignore lifecycle payload {payload}" + ); + } +} + +#[test] +fn devin_hook_ignores_prompt_session_list_fallback() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} + +#[test] +fn devin_hook_reports_session_id_from_stdin_without_state() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#, + &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)], + ) + .expect("devin session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "devin"); + assert_eq!(request["params"]["agent_session_id"], "devin-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_prefers_hook_session_id_over_list() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#, + ), + ], + ) + .expect("devin tool hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "fresh-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_reports_tool_session_from_list_without_state() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, + ), + ], + ) + .expect("devin tool hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "devin"); + assert_eq!(request["params"]["agent_session_id"], "devin-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_ignores_startup_session_list_fallback() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"SessionStart","source":"startup"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} + +#[test] +fn devin_hook_ignores_non_matching_session_list_entries() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} |
