Skip to main content

recursive/hooks/
mod.rs

1//! Lifecycle hooks for the agent loop.
2//!
3//! Hooks are callbacks invoked at well-defined points during an agent run.
4//! They allow consumers to observe, log, gate, or transform behaviour without
5//! modifying the agent loop itself.
6//!
7//! # Hook points
8//!
9//! - `SessionStart` — at the top of an agent run, before any LLM call.
10//! - `PreToolCall` — before each tool dispatch (after the permission hook).
11//! - `PostToolCall` — after each tool returns.
12//! - `PreCompact` — before compaction fires.
13//! - `PostCompact` — after compaction completes.
14//! - `SessionEnd` — at terminal finishes (currently only on the legacy
15//!   `Agent` path; the `AgentRuntime` does not dispatch this yet).
16//!
17//! # Usage
18//!
19//! ```ignore
20//! use recursive::hooks::{Hook, HookEvent, HookAction, HookRegistry};
21//!
22//! struct MyHook;
23//! impl Hook for MyHook {
24//!     fn on_event(&self, event: HookEvent) -> HookAction {
25//!         match event {
26//!             HookEvent::PreToolCall { name, .. } => {
27//!                 eprintln!("about to call {name}");
28//!                 HookAction::Continue
29//!             }
30//!             _ => HookAction::Continue,
31//!         }
32//!     }
33//! }
34//!
35//! let mut registry = HookRegistry::new();
36//! registry.register(Arc::new(MyHook));
37//! ```
38
39pub mod config;
40pub mod external;
41
42pub use config::{load_hooks_config, HookCommand, HookCommandType, HookMatcher, HooksConfig};
43pub use external::{
44    ExternalHookRunner, HookInput, HookResult, PermissionDecision, SdkHookForwarder,
45};
46
47use std::sync::Arc;
48
49use serde_json::Value;
50
51use crate::runtime::RuntimeOutcome;
52use std::collections::HashMap;
53use std::time::Instant;
54
55/// Action a hook can request in response to an event.
56#[derive(Debug, Clone)]
57pub enum HookAction {
58    /// Proceed normally.
59    Continue,
60    /// Skip this tool call (PreToolCall only; treated as Continue for other events).
61    Skip,
62    /// Abort with an error message (PreToolCall only; treated as Continue for other events).
63    Error(String),
64}
65
66/// Events emitted at lifecycle points during an agent run.
67///
68/// This enum is `#[non_exhaustive]` — new variants may be added in future
69/// releases without a breaking change.
70#[derive(Debug, Clone)]
71#[non_exhaustive]
72pub enum HookEvent<'a> {
73    /// Fired at the start of an agent run, before any LLM call.
74    SessionStart {
75        /// The goal text passed to `AgentRuntime::run()`.
76        goal: &'a str,
77    },
78    /// Fired before a tool is dispatched (after the permission hook).
79    PreToolCall {
80        /// Name of the tool about to be called.
81        name: &'a str,
82        /// Arguments that will be passed to the tool.
83        args: &'a Value,
84    },
85    /// Fired after a tool returns.
86    PostToolCall {
87        /// Name of the tool that was called.
88        name: &'a str,
89        /// Arguments that were passed to the tool.
90        args: &'a Value,
91        /// The result string returned by the tool (or error message).
92        result: &'a str,
93        /// Wall-clock duration of the tool execution in milliseconds.
94        duration_ms: u64,
95    },
96    /// Fired before compaction is attempted.
97    PreCompact {
98        /// Current transcript length in characters.
99        transcript_len: usize,
100    },
101    /// Fired after compaction completes.
102    PostCompact {
103        /// Number of messages removed during compaction.
104        removed: usize,
105        /// Character count of the summary message added.
106        summary_chars: usize,
107    },
108    /// Fired at the end of an agent run, before returning.
109    ///
110    /// Dispatched by `AgentRuntime::run()` after every turn completes.
111    SessionEnd {
112        /// The outcome that will be returned.
113        outcome: &'a RuntimeOutcome,
114    },
115    /// Fired after the user submits a message, before the LLM processes it.
116    UserPromptSubmit {
117        /// The user's input content.
118        content: &'a str,
119    },
120    /// Fired when the agent completes normally (`NoMoreToolCalls`).
121    Stop {
122        /// The final outcome.
123        outcome: &'a RuntimeOutcome,
124    },
125    /// Fired before a sub-agent is dispatched.
126    SubagentStart {
127        /// The sub-agent's goal text.
128        goal: &'a str,
129        /// Nesting depth (0 = top-level).
130        depth: usize,
131    },
132    /// Fired after a sub-agent completes.
133    SubagentStop {
134        /// The sub-agent's outcome.
135        outcome: &'a RuntimeOutcome,
136        /// Nesting depth.
137        depth: usize,
138    },
139    /// Fired when a tool call returns an error (before `PostToolCall`).
140    PostToolCallFailure {
141        /// Name of the tool that failed.
142        name: &'a str,
143        /// Arguments that were passed to the tool.
144        args: &'a Value,
145        /// The error message.
146        error: &'a str,
147    },
148    /// Fired when a permission check denies a tool call.
149    PermissionDenied {
150        /// Name of the tool that was denied.
151        tool_name: &'a str,
152        /// Human-readable reason for the denial.
153        reason: &'a str,
154    },
155    /// Fired when the agent emits a notification to the user.
156    Notification {
157        /// Notification content.
158        message: &'a str,
159    },
160    /// Fired once at the very beginning, before `SessionStart`, for one-time setup.
161    Setup,
162}
163
164/// A lifecycle hook that can observe and influence agent behaviour.
165pub trait Hook: Send + Sync {
166    /// Called when a lifecycle event occurs.
167    ///
168    /// Return `HookAction::Continue` to proceed normally.
169    /// Return `HookAction::Skip` or `HookAction::Error` from a `PreToolCall`
170    /// event to prevent tool execution. For all other event types, `Skip`
171    /// and `Error` are treated as `Continue`.
172    fn on_event(&self, event: HookEvent) -> HookAction;
173}
174
175/// A registry of hooks that dispatches events to all registered hooks in order.
176///
177/// Hooks are stored as `Arc<dyn Hook>` and dispatched sequentially. If any
178/// hook returns `HookAction::Skip` or `HookAction::Error` from a `PreToolCall`
179/// event, the first non-`Continue` action is returned and remaining hooks
180/// are not called for that event.
181#[derive(Clone, Default)]
182pub struct HookRegistry {
183    hooks: Vec<Arc<dyn Hook>>,
184}
185
186impl HookRegistry {
187    /// Create an empty hook registry.
188    pub fn new() -> Self {
189        Self::default()
190    }
191
192    /// Register a hook. Hooks fire in registration order.
193    pub fn register(&mut self, hook: Arc<dyn Hook>) {
194        self.hooks.push(hook);
195    }
196
197    /// Dispatch an event to all registered hooks.
198    ///
199    /// Returns the first non-`Continue` action, or `Continue` if all hooks
200    /// agree. For non-`PreToolCall` events, `Skip` and `Error` are converted
201    /// to `Continue`.
202    pub fn dispatch(&self, event: HookEvent) -> HookAction {
203        let is_pre_tool = matches!(event, HookEvent::PreToolCall { .. });
204        for hook in &self.hooks {
205            // Continue (and non-PreToolCall Skip/Error) share one arm: a
206            // dedicated `Continue => continue` is behavior-equivalent to
207            // falling through `_`, so cargo-mutants' "delete Continue arm"
208            // is not meaningful debt.
209            match hook.on_event(event.clone()) {
210                HookAction::Skip if is_pre_tool => return HookAction::Skip,
211                HookAction::Error(msg) if is_pre_tool => return HookAction::Error(msg),
212                _ => continue,
213            }
214        }
215        HookAction::Continue
216    }
217
218    /// Returns true if hooks of the given type would be dispatched.
219    /// Currently always true when hooks are registered; reserved for future filtering.
220    pub fn has_hooks(&self) -> bool {
221        !self.hooks.is_empty()
222    }
223
224    /// Returns true if no hooks are registered.
225    pub fn is_empty(&self) -> bool {
226        self.hooks.is_empty()
227    }
228
229    /// Returns the number of registered hooks.
230    pub fn len(&self) -> usize {
231        self.hooks.len()
232    }
233}
234
235/// A hook that prints tool call timing information to stderr.
236///
237/// On `PostToolCall` events, prints `[hook] {name} took {duration_ms}ms`.
238/// All other events return `HookAction::Continue`.
239pub struct ToolTimingHook {
240    start_times: std::sync::Mutex<HashMap<String, Instant>>,
241}
242
243impl ToolTimingHook {
244    pub fn new() -> Self {
245        Self {
246            start_times: std::sync::Mutex::new(HashMap::new()),
247        }
248    }
249
250    /// Test-only: whether a PreToolCall recorded a start for `name`.
251    #[cfg(test)]
252    fn has_start(&self, name: &str) -> bool {
253        #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
254        self.start_times.lock().unwrap().contains_key(name)
255    }
256}
257
258impl Default for ToolTimingHook {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl Hook for ToolTimingHook {
265    fn on_event(&self, event: HookEvent) -> HookAction {
266        match event {
267            HookEvent::PreToolCall { name, .. } => {
268                #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
269                let mut map = self.start_times.lock().unwrap();
270                map.insert(name.to_string(), Instant::now());
271                HookAction::Continue
272            }
273            HookEvent::PostToolCall {
274                name, duration_ms, ..
275            } => {
276                // Clear the matching Pre start so Post is observable without
277                // relying on stderr (eprintln alone is untestable in unit tests).
278                #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
279                {
280                    let mut map = self.start_times.lock().unwrap();
281                    map.remove(name);
282                }
283                eprintln!("[hook] {name} took {duration_ms}ms");
284                HookAction::Continue
285            }
286            _ => HookAction::Continue,
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use std::sync::atomic::{AtomicUsize, Ordering};
295
296    struct SkipHook;
297
298    impl Hook for SkipHook {
299        fn on_event(&self, event: HookEvent) -> HookAction {
300            match event {
301                HookEvent::PreToolCall { .. } => HookAction::Skip,
302                _ => HookAction::Continue,
303            }
304        }
305    }
306
307    struct ErrorHook;
308
309    impl Hook for ErrorHook {
310        fn on_event(&self, event: HookEvent) -> HookAction {
311            match event {
312                HookEvent::PreToolCall { .. } => HookAction::Error("nope".into()),
313                _ => HookAction::Continue,
314            }
315        }
316    }
317
318    #[test]
319    fn empty_registry_returns_continue() {
320        let reg = HookRegistry::new();
321        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
322        assert!(matches!(action, HookAction::Continue));
323    }
324
325    #[test]
326    fn session_start_fires_with_correct_goal() {
327        let captured = Arc::new(std::sync::Mutex::new(String::new()));
328        let c = captured.clone();
329        struct GoalCapture(Arc<std::sync::Mutex<String>>);
330        impl Hook for GoalCapture {
331            fn on_event(&self, event: HookEvent) -> HookAction {
332                if let HookEvent::SessionStart { goal } = event {
333                    *self.0.lock().unwrap() = goal.to_string();
334                }
335                HookAction::Continue
336            }
337        }
338        let mut reg = HookRegistry::new();
339        reg.register(Arc::new(GoalCapture(c)));
340        reg.dispatch(HookEvent::SessionStart { goal: "my goal" });
341        assert_eq!(*captured.lock().unwrap(), "my goal");
342    }
343
344    #[test]
345    fn pre_tool_call_skip_prevents_execution() {
346        let mut reg = HookRegistry::new();
347        reg.register(Arc::new(SkipHook));
348        let action = reg.dispatch(HookEvent::PreToolCall {
349            name: "Write",
350            args: &serde_json::json!({"path": "foo.txt"}),
351        });
352        assert!(matches!(action, HookAction::Skip));
353    }
354
355    #[test]
356    fn pre_tool_call_error_returns_message() {
357        let mut reg = HookRegistry::new();
358        reg.register(Arc::new(ErrorHook));
359        let action = reg.dispatch(HookEvent::PreToolCall {
360            name: "Write",
361            args: &serde_json::json!({"path": "foo.txt"}),
362        });
363        assert!(matches!(action, HookAction::Error(ref msg) if msg == "nope"));
364    }
365
366    #[test]
367    fn skip_and_error_on_non_pre_tool_are_continue() {
368        let mut reg = HookRegistry::new();
369        reg.register(Arc::new(SkipHook));
370        reg.register(Arc::new(ErrorHook));
371        // SessionStart — SkipHook returns Continue, ErrorHook returns Continue
372        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
373        assert!(matches!(action, HookAction::Continue));
374        // PostToolCall — same
375        let action = reg.dispatch(HookEvent::PostToolCall {
376            name: "Read",
377            args: &serde_json::json!({"path": "foo.txt"}),
378            result: "ok",
379            duration_ms: 5,
380        });
381        assert!(matches!(action, HookAction::Continue));
382    }
383
384    #[test]
385    fn multiple_hooks_fire_in_order() {
386        let order = Arc::new(std::sync::Mutex::new(Vec::new()));
387        let o1 = order.clone();
388        let o2 = order.clone();
389
390        struct OrdHook(usize, Arc<std::sync::Mutex<Vec<usize>>>);
391        impl Hook for OrdHook {
392            fn on_event(&self, _event: HookEvent) -> HookAction {
393                self.1.lock().unwrap().push(self.0);
394                HookAction::Continue
395            }
396        }
397
398        let mut reg = HookRegistry::new();
399        reg.register(Arc::new(OrdHook(1, o1)));
400        reg.register(Arc::new(OrdHook(2, o2)));
401        reg.dispatch(HookEvent::SessionStart { goal: "test" });
402        assert_eq!(*order.lock().unwrap(), vec![1, 2]);
403    }
404
405    #[test]
406    fn continue_action_does_not_short_circuit() {
407        // Kills: `delete match arm HookAction::Continue` — without the arm,
408        // Continue falls into `_ => continue` (same control flow) OR, if the
409        // match is reshaped, may stop iterating. Pin that two Continue hooks
410        // both fire and the final action is Continue.
411        let count = Arc::new(AtomicUsize::new(0));
412        let c1 = count.clone();
413        let c2 = count.clone();
414
415        struct CountingContinue(Arc<AtomicUsize>);
416        impl Hook for CountingContinue {
417            fn on_event(&self, _event: HookEvent) -> HookAction {
418                self.0.fetch_add(1, Ordering::SeqCst);
419                HookAction::Continue
420            }
421        }
422
423        let mut reg = HookRegistry::new();
424        reg.register(Arc::new(CountingContinue(c1)));
425        reg.register(Arc::new(CountingContinue(c2)));
426        let action = reg.dispatch(HookEvent::SessionStart { goal: "g" });
427        assert!(matches!(action, HookAction::Continue));
428        assert_eq!(
429            count.load(Ordering::SeqCst),
430            2,
431            "both Continue hooks must run"
432        );
433    }
434
435    #[test]
436    fn first_skip_short_circuits_remaining_hooks() {
437        let count = Arc::new(AtomicUsize::new(0));
438        let c1 = count.clone();
439        let c2 = count.clone();
440
441        struct FirstSkip(Arc<AtomicUsize>);
442        impl Hook for FirstSkip {
443            fn on_event(&self, event: HookEvent) -> HookAction {
444                match event {
445                    HookEvent::PreToolCall { .. } => HookAction::Skip,
446                    _ => {
447                        self.0.fetch_add(1, Ordering::SeqCst);
448                        HookAction::Continue
449                    }
450                }
451            }
452        }
453
454        struct SecondCounter(Arc<AtomicUsize>);
455        impl Hook for SecondCounter {
456            fn on_event(&self, _event: HookEvent) -> HookAction {
457                self.0.fetch_add(1, Ordering::SeqCst);
458                HookAction::Continue
459            }
460        }
461
462        let mut reg = HookRegistry::new();
463        reg.register(Arc::new(FirstSkip(c1)));
464        reg.register(Arc::new(SecondCounter(c2.clone())));
465        let action = reg.dispatch(HookEvent::PreToolCall {
466            name: "Write",
467            args: &serde_json::json!({"path": "foo.txt"}),
468        });
469        assert!(matches!(action, HookAction::Skip));
470        // SecondCounter should NOT have been called
471        assert_eq!(c2.load(Ordering::SeqCst), 0);
472    }
473
474    #[test]
475    fn post_tool_call_receives_correct_fields() {
476        let captured = Arc::new(std::sync::Mutex::new(None::<(String, String, u64)>));
477        let c = captured.clone();
478        struct CaptureHook(Arc<std::sync::Mutex<Option<(String, String, u64)>>>);
479        impl Hook for CaptureHook {
480            fn on_event(&self, event: HookEvent) -> HookAction {
481                if let HookEvent::PostToolCall {
482                    name,
483                    result,
484                    duration_ms,
485                    ..
486                } = event
487                {
488                    *self.0.lock().unwrap() =
489                        Some((name.to_string(), result.to_string(), duration_ms));
490                }
491                HookAction::Continue
492            }
493        }
494        let mut reg = HookRegistry::new();
495        reg.register(Arc::new(CaptureHook(c)));
496        reg.dispatch(HookEvent::PostToolCall {
497            name: "Read",
498            args: &serde_json::json!({"path": "foo.txt"}),
499            result: "file contents",
500            duration_ms: 42,
501        });
502        let captured = captured.lock().unwrap().clone().unwrap();
503        assert_eq!(captured.0, "Read");
504        assert_eq!(captured.1, "file contents");
505        assert_eq!(captured.2, 42);
506    }
507
508    #[test]
509    fn session_end_receives_outcome() {
510        let captured = Arc::new(std::sync::Mutex::new(None));
511        let c = captured.clone();
512        struct CaptureOutcome(Arc<std::sync::Mutex<Option<RuntimeOutcome>>>);
513        impl Hook for CaptureOutcome {
514            fn on_event(&self, event: HookEvent) -> HookAction {
515                if let HookEvent::SessionEnd { outcome } = event {
516                    *self.0.lock().unwrap() = Some(outcome.clone());
517                }
518                HookAction::Continue
519            }
520        }
521        let outcome = RuntimeOutcome {
522            final_text: Some("done".into()),
523            finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
524            total_usage: crate::llm::TokenUsage::default(),
525            steps: 3,
526            llm_latency_ms: 100,
527            checkpoint_id: None,
528        };
529        let mut reg = HookRegistry::new();
530        reg.register(Arc::new(CaptureOutcome(c)));
531        reg.dispatch(HookEvent::SessionEnd { outcome: &outcome });
532        let captured = captured.lock().unwrap().take().unwrap();
533        assert_eq!(captured.final_text.as_deref(), Some("done"));
534        assert_eq!(captured.steps, 3);
535    }
536
537    #[test]
538    fn pre_compact_receives_transcript_len() {
539        let captured = Arc::new(std::sync::Mutex::new(0usize));
540        let c = captured.clone();
541        struct CaptureLen(Arc<std::sync::Mutex<usize>>);
542        impl Hook for CaptureLen {
543            fn on_event(&self, event: HookEvent) -> HookAction {
544                if let HookEvent::PreCompact { transcript_len } = event {
545                    *self.0.lock().unwrap() = transcript_len;
546                }
547                HookAction::Continue
548            }
549        }
550        let mut reg = HookRegistry::new();
551        reg.register(Arc::new(CaptureLen(c)));
552        reg.dispatch(HookEvent::PreCompact {
553            transcript_len: 5000,
554        });
555        assert_eq!(*captured.lock().unwrap(), 5000);
556    }
557
558    #[test]
559    fn post_compact_receives_removed_and_summary_chars() {
560        let captured = Arc::new(std::sync::Mutex::new((0usize, 0usize)));
561        let c = captured.clone();
562        struct CaptureCompact(Arc<std::sync::Mutex<(usize, usize)>>);
563        impl Hook for CaptureCompact {
564            fn on_event(&self, event: HookEvent) -> HookAction {
565                if let HookEvent::PostCompact {
566                    removed,
567                    summary_chars,
568                } = event
569                {
570                    *self.0.lock().unwrap() = (removed, summary_chars);
571                }
572                HookAction::Continue
573            }
574        }
575        let mut reg = HookRegistry::new();
576        reg.register(Arc::new(CaptureCompact(c)));
577        reg.dispatch(HookEvent::PostCompact {
578            removed: 10,
579            summary_chars: 200,
580        });
581        assert_eq!(captured.lock().unwrap().0, 10);
582        assert_eq!(captured.lock().unwrap().1, 200);
583    }
584
585    #[test]
586    fn hook_event_is_non_exhaustive() {
587        // Compile-time check: HookEvent is #[non_exhaustive]
588        let _ = HookEvent::SessionStart { goal: "test" };
589    }
590
591    // ── Goal 204 new event tests ──────────────────────────────────
592
593    #[test]
594    fn new_events_compile_and_dispatch() {
595        let reg = HookRegistry::new();
596        let outcome = RuntimeOutcome {
597            final_text: None,
598            finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
599            total_usage: crate::llm::TokenUsage::default(),
600            steps: 0,
601            llm_latency_ms: 0,
602            checkpoint_id: None,
603        };
604        let empty_args = serde_json::json!({});
605
606        let action = reg.dispatch(HookEvent::UserPromptSubmit { content: "hello" });
607        assert!(matches!(action, HookAction::Continue));
608        let action = reg.dispatch(HookEvent::Stop { outcome: &outcome });
609        assert!(matches!(action, HookAction::Continue));
610        let action = reg.dispatch(HookEvent::SubagentStart {
611            goal: "sub-goal",
612            depth: 1,
613        });
614        assert!(matches!(action, HookAction::Continue));
615        let action = reg.dispatch(HookEvent::SubagentStop {
616            outcome: &outcome,
617            depth: 1,
618        });
619        assert!(matches!(action, HookAction::Continue));
620        let action = reg.dispatch(HookEvent::PostToolCallFailure {
621            name: "Bash",
622            args: &empty_args,
623            error: "err",
624        });
625        assert!(matches!(action, HookAction::Continue));
626        let action = reg.dispatch(HookEvent::PermissionDenied {
627            tool_name: "Write",
628            reason: "not allowed",
629        });
630        assert!(matches!(action, HookAction::Continue));
631        let action = reg.dispatch(HookEvent::Notification { message: "hi" });
632        assert!(matches!(action, HookAction::Continue));
633        let action = reg.dispatch(HookEvent::Setup);
634        assert!(matches!(action, HookAction::Continue));
635    }
636
637    #[test]
638    fn post_tool_call_failure_dispatched_to_hook() {
639        let captured = Arc::new(std::sync::Mutex::new(None::<(String, String)>));
640        let c = captured.clone();
641        struct CaptureFailure(Arc<std::sync::Mutex<Option<(String, String)>>>);
642        impl Hook for CaptureFailure {
643            fn on_event(&self, event: HookEvent) -> HookAction {
644                if let HookEvent::PostToolCallFailure { name, error, .. } = event {
645                    *self.0.lock().unwrap() = Some((name.to_string(), error.to_string()));
646                }
647                HookAction::Continue
648            }
649        }
650        let mut reg = HookRegistry::new();
651        reg.register(Arc::new(CaptureFailure(c)));
652        let args = serde_json::json!({"command": "rm -rf /"});
653        reg.dispatch(HookEvent::PostToolCallFailure {
654            name: "Bash",
655            args: &args,
656            error: "permission denied",
657        });
658        let cap = captured.lock().unwrap().clone().unwrap();
659        assert_eq!(cap.0, "Bash");
660        assert_eq!(cap.1, "permission denied");
661    }
662
663    #[test]
664    fn user_prompt_submit_received_by_hook() {
665        let captured = Arc::new(std::sync::Mutex::new(String::new()));
666        let c = captured.clone();
667        struct CapturePrompt(Arc<std::sync::Mutex<String>>);
668        impl Hook for CapturePrompt {
669            fn on_event(&self, event: HookEvent) -> HookAction {
670                if let HookEvent::UserPromptSubmit { content } = event {
671                    *self.0.lock().unwrap() = content.to_string();
672                }
673                HookAction::Continue
674            }
675        }
676        let mut reg = HookRegistry::new();
677        reg.register(Arc::new(CapturePrompt(c)));
678        reg.dispatch(HookEvent::UserPromptSubmit {
679            content: "test prompt",
680        });
681        assert_eq!(*captured.lock().unwrap(), "test prompt");
682    }
683
684    #[test]
685    fn tool_timing_hook_prints_to_stderr_on_post_tool_call() {
686        // Capture stderr by redirecting
687        let hook = ToolTimingHook::new();
688        let action = hook.on_event(HookEvent::PostToolCall {
689            name: "Read",
690            args: &serde_json::json!({"path": "foo.txt"}),
691            result: "ok",
692            duration_ms: 42,
693        });
694        assert!(matches!(action, HookAction::Continue));
695    }
696
697    #[test]
698    fn tool_timing_hook_returns_continue_for_non_tool_events() {
699        let hook = ToolTimingHook::new();
700        let action = hook.on_event(HookEvent::SessionStart { goal: "test" });
701        assert!(matches!(action, HookAction::Continue));
702
703        let action = hook.on_event(HookEvent::PreCompact {
704            transcript_len: 100,
705        });
706        assert!(matches!(action, HookAction::Continue));
707
708        let action = hook.on_event(HookEvent::PostCompact {
709            removed: 5,
710            summary_chars: 50,
711        });
712        assert!(matches!(action, HookAction::Continue));
713
714        let outcome = RuntimeOutcome {
715            final_text: Some("done".into()),
716            finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
717            total_usage: crate::llm::TokenUsage::default(),
718            steps: 1,
719            llm_latency_ms: 0,
720            checkpoint_id: None,
721        };
722        let action = hook.on_event(HookEvent::SessionEnd { outcome: &outcome });
723        assert!(matches!(action, HookAction::Continue));
724    }
725
726    #[test]
727    fn tool_timing_hook_pre_tool_call_returns_continue() {
728        let hook = ToolTimingHook::new();
729        let action = hook.on_event(HookEvent::PreToolCall {
730            name: "Write",
731            args: &serde_json::json!({"path": "foo.txt"}),
732        });
733        assert!(matches!(action, HookAction::Continue));
734        // Pins PreToolCall arm: deleting it falls through `_` without insert.
735        assert!(
736            hook.has_start("Write"),
737            "PreToolCall must record a start time for the tool name"
738        );
739    }
740
741    #[test]
742    fn tool_timing_hook_post_clears_recorded_start() {
743        let hook = ToolTimingHook::new();
744        let _ = hook.on_event(HookEvent::PreToolCall {
745            name: "Read",
746            args: &serde_json::json!({}),
747        });
748        assert!(hook.has_start("Read"));
749        let action = hook.on_event(HookEvent::PostToolCall {
750            name: "Read",
751            args: &serde_json::json!({}),
752            result: "ok",
753            duration_ms: 12,
754        });
755        assert!(matches!(action, HookAction::Continue));
756        // Pins PostToolCall arm: deleting it leaves the Pre start in the map.
757        assert!(
758            !hook.has_start("Read"),
759            "PostToolCall must clear the matching Pre start"
760        );
761    }
762
763    // ── guard mutant coverage: is_pre_tool guard must be checked correctly ────
764
765    /// A hook that unconditionally returns Skip for ANY event (including
766    /// non-PreToolCall events like SessionStart). This lets us distinguish
767    /// `is_pre_tool → true` (mutant) from the correct `is_pre_tool` guard.
768    struct AlwaysSkipHook;
769    impl Hook for AlwaysSkipHook {
770        fn on_event(&self, _event: HookEvent) -> HookAction {
771            HookAction::Skip
772        }
773    }
774
775    /// A hook that unconditionally returns Error for ANY event.
776    struct AlwaysErrorHook;
777    impl Hook for AlwaysErrorHook {
778        fn on_event(&self, _event: HookEvent) -> HookAction {
779            HookAction::Error("always".into())
780        }
781    }
782
783    #[test]
784    fn skip_on_non_pre_tool_call_event_is_treated_as_continue() {
785        // AlwaysSkipHook returns HookAction::Skip even for SessionStart.
786        // The dispatch must still return Continue because `is_pre_tool = false`.
787        // With mutant `replace match guard is_pre_tool with true` (line 205):
788        //   `HookAction::Skip if true => return HookAction::Skip`
789        //   → dispatch returns Skip for SessionStart → this test FAILS → mutant KILLED.
790        let mut reg = HookRegistry::new();
791        reg.register(Arc::new(AlwaysSkipHook));
792        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
793        assert!(
794            matches!(action, HookAction::Continue),
795            "Skip from a non-PreToolCall hook must be treated as Continue"
796        );
797    }
798
799    #[test]
800    fn error_on_non_pre_tool_call_event_is_treated_as_continue() {
801        // AlwaysErrorHook returns HookAction::Error even for SessionStart.
802        // The dispatch must still return Continue because `is_pre_tool = false`.
803        // With mutant `replace match guard is_pre_tool with true` (line 206):
804        //   `HookAction::Error(msg) if true => return HookAction::Error(msg)`
805        //   → dispatch returns Error for SessionStart → this test FAILS → mutant KILLED.
806        let mut reg = HookRegistry::new();
807        reg.register(Arc::new(AlwaysErrorHook));
808        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
809        assert!(
810            matches!(action, HookAction::Continue),
811            "Error from a non-PreToolCall hook must be treated as Continue"
812        );
813    }
814
815    #[test]
816    fn has_hooks_true_when_registered() {
817        // kills has_hooks → bool with false and delete !
818        let mut reg = HookRegistry::new();
819        assert!(!reg.has_hooks(), "empty registry has no hooks");
820        reg.register(Arc::new(AlwaysSkipHook));
821        assert!(reg.has_hooks(), "non-empty registry has hooks");
822    }
823
824    #[test]
825    fn is_empty_and_len_reflect_registration() {
826        // kills is_empty → bool with true/false and len → 0/1
827        let mut reg = HookRegistry::new();
828        assert!(reg.is_empty(), "fresh registry must be empty");
829        assert_eq!(reg.len(), 0, "fresh registry len must be 0");
830        reg.register(Arc::new(AlwaysSkipHook));
831        assert!(
832            !reg.is_empty(),
833            "after register, registry must not be empty"
834        );
835        assert_eq!(reg.len(), 1, "after one register, len must be 1");
836    }
837}