Skip to main content

ai_agents_hooks/
lib.rs

1use async_trait::async_trait;
2use serde_json::Value;
3use std::sync::Arc;
4use std::time::Instant;
5use tracing::{debug, error, info, warn};
6
7use ai_agents_core::{AgentError, AgentResponse, ToolExecutionRecord};
8use ai_agents_hitl::{ApprovalRequest, ApprovalResult};
9use ai_agents_llm::{ChatMessage, LLMResponse};
10use ai_agents_memory::{MemoryBudgetEvent, MemoryCompressEvent, MemoryEvictEvent};
11use ai_agents_tools::ToolResult;
12
13fn preview_text(text: &str, max_chars: usize) -> String {
14    if max_chars == 0 {
15        return String::new();
16    }
17
18    let mut chars = text.chars();
19    let preview: String = chars.by_ref().take(max_chars).collect();
20    if chars.next().is_some() {
21        format!("{}...", preview)
22    } else {
23        text.to_string()
24    }
25}
26
27#[async_trait]
28pub trait AgentHooks: Send + Sync {
29    async fn on_message_received(&self, _message: &str) {}
30
31    async fn on_llm_start(&self, _messages: &[ChatMessage]) {}
32
33    async fn on_llm_complete(&self, _response: &LLMResponse, _duration_ms: u64) {}
34
35    async fn on_tool_start(&self, _tool: &str, _args: &Value) {}
36
37    async fn on_tool_complete(&self, _tool: &str, _result: &ToolResult, _duration_ms: u64) {}
38
39    async fn on_tool_execution_record(&self, _record: &ToolExecutionRecord) {}
40
41    async fn on_state_transition(&self, _from: Option<&str>, _to: &str, _reason: &str) {}
42
43    async fn on_error(&self, _error: &AgentError) {}
44
45    async fn on_response(&self, _response: &AgentResponse) {}
46
47    async fn on_approval_requested(&self, _request: &ApprovalRequest) {}
48
49    async fn on_approval_result(&self, _request_id: &str, _result: &ApprovalResult) {}
50
51    async fn on_memory_compress(&self, _event: &MemoryCompressEvent) {}
52
53    async fn on_memory_evict(&self, _event: &MemoryEvictEvent) {}
54
55    async fn on_memory_budget_warning(&self, _event: &MemoryBudgetEvent) {}
56
57    /// Fired when a delegated state starts forwarding to a registry agent.
58    async fn on_delegate_start(&self, _agent_id: &str, _state: &str) {}
59
60    /// Fired when a delegated state completes.
61    async fn on_delegate_complete(&self, _agent_id: &str, _state: &str, _duration_ms: u64) {}
62
63    /// Fired when a concurrent state completes aggregation.
64    async fn on_concurrent_complete(
65        &self,
66        _agent_ids: &[String],
67        _strategy: &str,
68        _duration_ms: u64,
69    ) {
70    }
71
72    /// Fired when a group chat round completes.
73    async fn on_group_chat_round(&self, _round: u32, _speaker: &str, _content: &str) {}
74
75    /// Fired after each pipeline stage completes.
76    async fn on_pipeline_stage(&self, _stage: usize, _agent_id: &str, _duration_ms: u64) {}
77
78    /// Fired when a pipeline completes all stages.
79    async fn on_pipeline_complete(&self, _stages: usize, _duration_ms: u64) {}
80
81    /// Fired when a handoff chain starts.
82    async fn on_handoff_start(&self, _initial_agent: &str) {}
83
84    /// Fired on each agent-to-agent control transfer.
85    async fn on_handoff(&self, _from: &str, _to: &str, _reason: &str) {}
86
87    /// Fired when a persona field is mutated via evolve().
88    async fn on_persona_evolve(
89        &self,
90        _field: &str,
91        _old_value: &Value,
92        _new_value: &Value,
93        _reason: Option<&str>,
94    ) {
95    }
96
97    /// Fired when a secret's reveal conditions are satisfied for the first time.
98    async fn on_secret_revealed(&self, _content: &str) {}
99
100    /// Fired after facts are extracted from a conversation turn.
101    async fn on_facts_extracted(&self, _actor_id: &str, _facts: &[ai_agents_core::KeyFact]) {}
102
103    /// Fired when actor memory (facts) is loaded from storage at session start.
104    async fn on_actor_memory_loaded(&self, _actor_id: &str, _fact_count: usize) {}
105
106    /// Fired when a session is created with metadata.
107    async fn on_session_created(&self, _session_id: &str) {}
108
109    /// Fired when sessions are cleaned up due to TTL expiry.
110    async fn on_sessions_expired(&self, _count: usize) {}
111
112    /// Fired when relationship memory is loaded for an actor.
113    async fn on_relationship_loaded(
114        &self,
115        _actor_id: &str,
116        _relationship: &ai_agents_relationships::Relationship,
117    ) {
118    }
119
120    /// Fired after relationship dimensions change.
121    async fn on_relationship_change(
122        &self,
123        _actor_id: &str,
124        _changes: &[ai_agents_relationships::DimensionChange],
125    ) {
126    }
127
128    /// Fired after a notable relationship event is recorded.
129    async fn on_notable_event(
130        &self,
131        _actor_id: &str,
132        _event: &ai_agents_relationships::RelationshipEvent,
133    ) {
134    }
135}
136
137pub struct NoopHooks;
138
139#[async_trait]
140impl AgentHooks for NoopHooks {}
141
142pub struct LoggingHooks {
143    prefix: String,
144}
145
146impl LoggingHooks {
147    pub fn new() -> Self {
148        Self {
149            prefix: "[Agent]".to_string(),
150        }
151    }
152
153    pub fn with_prefix(prefix: impl Into<String>) -> Self {
154        Self {
155            prefix: prefix.into(),
156        }
157    }
158}
159
160impl Default for LoggingHooks {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166#[async_trait]
167impl AgentHooks for LoggingHooks {
168    async fn on_message_received(&self, message: &str) {
169        let preview = preview_text(message, 100);
170        info!("{} Message received: {}", self.prefix, preview);
171    }
172
173    async fn on_llm_start(&self, messages: &[ChatMessage]) {
174        debug!(
175            "{} LLM starting with {} messages",
176            self.prefix,
177            messages.len()
178        );
179    }
180
181    async fn on_llm_complete(&self, response: &LLMResponse, duration_ms: u64) {
182        info!(
183            "{} LLM complete in {}ms, tokens: {:?}",
184            self.prefix, duration_ms, response.usage
185        );
186    }
187
188    async fn on_tool_start(&self, tool: &str, args: &Value) {
189        debug!("{} Tool {} starting with args: {}", self.prefix, tool, args);
190    }
191
192    async fn on_tool_complete(&self, tool: &str, result: &ToolResult, duration_ms: u64) {
193        if result.success {
194            info!(
195                "{} Tool {} completed in {}ms",
196                self.prefix, tool, duration_ms
197            );
198        } else {
199            warn!(
200                "{} Tool {} failed in {}ms: {}",
201                self.prefix, tool, duration_ms, result.output
202            );
203        }
204    }
205
206    async fn on_state_transition(&self, from: Option<&str>, to: &str, reason: &str) {
207        info!(
208            "{} State transition: {:?} -> {} ({})",
209            self.prefix, from, to, reason
210        );
211    }
212
213    async fn on_error(&self, err: &AgentError) {
214        error!("{} Error: {}", self.prefix, err);
215    }
216
217    async fn on_response(&self, response: &AgentResponse) {
218        let preview = preview_text(&response.content, 100);
219        debug!("{} Response: {}", self.prefix, preview);
220    }
221
222    async fn on_approval_requested(&self, request: &ApprovalRequest) {
223        info!(
224            "{} Approval requested [{}]: {}",
225            self.prefix, request.id, request.message
226        );
227    }
228
229    async fn on_approval_result(&self, request_id: &str, result: &ApprovalResult) {
230        match result {
231            ApprovalResult::Approved => {
232                info!("{} Approval [{}]: approved", self.prefix, request_id);
233            }
234            ApprovalResult::Rejected { reason } => {
235                warn!(
236                    "{} Approval [{}]: rejected ({:?})",
237                    self.prefix, request_id, reason
238                );
239            }
240            ApprovalResult::Modified { .. } => {
241                info!(
242                    "{} Approval [{}]: approved with modifications",
243                    self.prefix, request_id
244                );
245            }
246            ApprovalResult::Timeout => {
247                warn!("{} Approval [{}]: timeout", self.prefix, request_id);
248            }
249        }
250    }
251
252    async fn on_memory_compress(&self, event: &MemoryCompressEvent) {
253        info!(
254            "{} Memory compressed: {} messages, ratio: {:.2}",
255            self.prefix, event.messages_compressed, event.compression_ratio
256        );
257    }
258
259    async fn on_memory_evict(&self, event: &MemoryEvictEvent) {
260        warn!(
261            "{} Memory evicted: {} messages, reason: {:?}",
262            self.prefix, event.messages_evicted, event.reason
263        );
264    }
265
266    async fn on_memory_budget_warning(&self, event: &MemoryBudgetEvent) {
267        warn!(
268            "{} Memory budget warning: {} at {:.1}% ({}/{} tokens)",
269            self.prefix,
270            event.component,
271            event.usage_percent,
272            event.used_tokens,
273            event.budget_tokens
274        );
275    }
276
277    async fn on_delegate_start(&self, agent_id: &str, state: &str) {
278        info!(
279            "{} Delegation started: agent={}, state={}",
280            self.prefix, agent_id, state
281        );
282    }
283
284    async fn on_delegate_complete(&self, agent_id: &str, state: &str, duration_ms: u64) {
285        info!(
286            "{} Delegation complete: agent={}, state={}, duration={}ms",
287            self.prefix, agent_id, state, duration_ms
288        );
289    }
290
291    async fn on_concurrent_complete(&self, agent_ids: &[String], strategy: &str, duration_ms: u64) {
292        info!(
293            "{} Concurrent complete: agents={:?}, strategy={}, duration={}ms",
294            self.prefix, agent_ids, strategy, duration_ms
295        );
296    }
297
298    async fn on_group_chat_round(&self, round: u32, speaker: &str, content: &str) {
299        let preview = preview_text(content, 80);
300        debug!(
301            "{} Group chat round {}: {} said: {}",
302            self.prefix, round, speaker, preview
303        );
304    }
305
306    async fn on_pipeline_stage(&self, stage: usize, agent_id: &str, duration_ms: u64) {
307        info!(
308            "{} Pipeline stage {}: agent={}, duration={}ms",
309            self.prefix, stage, agent_id, duration_ms
310        );
311    }
312
313    async fn on_pipeline_complete(&self, stages: usize, duration_ms: u64) {
314        info!(
315            "{} Pipeline complete: {} stages, duration={}ms",
316            self.prefix, stages, duration_ms
317        );
318    }
319
320    async fn on_handoff_start(&self, initial_agent: &str) {
321        info!(
322            "{} Handoff chain started: initial_agent={}",
323            self.prefix, initial_agent
324        );
325    }
326
327    async fn on_handoff(&self, from: &str, to: &str, reason: &str) {
328        info!("{} Handoff: {} -> {} ({})", self.prefix, from, to, reason);
329    }
330
331    async fn on_persona_evolve(
332        &self,
333        field: &str,
334        _old_value: &Value,
335        new_value: &Value,
336        reason: Option<&str>,
337    ) {
338        info!(
339            "{} Persona evolved: field={}, new_value={}, reason={}",
340            self.prefix,
341            field,
342            new_value,
343            reason.unwrap_or("(none)")
344        );
345    }
346
347    async fn on_secret_revealed(&self, content: &str) {
348        debug!("{}[secret_revealed] {}", self.prefix, content);
349    }
350
351    async fn on_facts_extracted(&self, actor_id: &str, facts: &[ai_agents_core::KeyFact]) {
352        debug!(
353            "{}[facts_extracted] actor={} count={}",
354            self.prefix,
355            actor_id,
356            facts.len()
357        );
358    }
359
360    async fn on_actor_memory_loaded(&self, actor_id: &str, fact_count: usize) {
361        debug!(
362            "{}[actor_memory_loaded] actor={} facts={}",
363            self.prefix, actor_id, fact_count
364        );
365    }
366
367    async fn on_session_created(&self, session_id: &str) {
368        debug!("{}[session_created] session={}", self.prefix, session_id);
369    }
370
371    async fn on_sessions_expired(&self, count: usize) {
372        debug!("{}[sessions_expired] count={}", self.prefix, count);
373    }
374
375    async fn on_relationship_loaded(
376        &self,
377        actor_id: &str,
378        relationship: &ai_agents_relationships::Relationship,
379    ) {
380        debug!(
381            "{}[relationship_loaded] actor={} dimensions={}",
382            self.prefix,
383            actor_id,
384            relationship.dimensions.len()
385        );
386    }
387
388    async fn on_relationship_change(
389        &self,
390        actor_id: &str,
391        changes: &[ai_agents_relationships::DimensionChange],
392    ) {
393        debug!(
394            "{}[relationship_change] actor={} changes={}",
395            self.prefix,
396            actor_id,
397            changes.len()
398        );
399    }
400
401    async fn on_notable_event(
402        &self,
403        actor_id: &str,
404        event: &ai_agents_relationships::RelationshipEvent,
405    ) {
406        debug!(
407            "{}[notable_event] actor={} significance={:.2} description={}",
408            self.prefix, actor_id, event.significance, event.description
409        );
410    }
411}
412
413pub struct CompositeHooks {
414    hooks: Vec<Arc<dyn AgentHooks>>,
415}
416
417impl CompositeHooks {
418    pub fn new() -> Self {
419        Self { hooks: Vec::new() }
420    }
421
422    pub fn add(mut self, hooks: Arc<dyn AgentHooks>) -> Self {
423        self.hooks.push(hooks);
424        self
425    }
426
427    pub fn with_hooks(hooks: Vec<Arc<dyn AgentHooks>>) -> Self {
428        Self { hooks }
429    }
430}
431
432impl Default for CompositeHooks {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438#[async_trait]
439impl AgentHooks for CompositeHooks {
440    async fn on_message_received(&self, message: &str) {
441        for hook in &self.hooks {
442            hook.on_message_received(message).await;
443        }
444    }
445
446    async fn on_llm_start(&self, messages: &[ChatMessage]) {
447        for hook in &self.hooks {
448            hook.on_llm_start(messages).await;
449        }
450    }
451
452    async fn on_llm_complete(&self, response: &LLMResponse, duration_ms: u64) {
453        for hook in &self.hooks {
454            hook.on_llm_complete(response, duration_ms).await;
455        }
456    }
457
458    async fn on_tool_start(&self, tool: &str, args: &Value) {
459        for hook in &self.hooks {
460            hook.on_tool_start(tool, args).await;
461        }
462    }
463
464    async fn on_tool_complete(&self, tool: &str, result: &ToolResult, duration_ms: u64) {
465        for hook in &self.hooks {
466            hook.on_tool_complete(tool, result, duration_ms).await;
467        }
468    }
469
470    async fn on_state_transition(&self, from: Option<&str>, to: &str, reason: &str) {
471        for hook in &self.hooks {
472            hook.on_state_transition(from, to, reason).await;
473        }
474    }
475
476    async fn on_error(&self, error: &AgentError) {
477        for hook in &self.hooks {
478            hook.on_error(error).await;
479        }
480    }
481
482    async fn on_response(&self, response: &AgentResponse) {
483        for hook in &self.hooks {
484            hook.on_response(response).await;
485        }
486    }
487
488    async fn on_approval_requested(&self, request: &ApprovalRequest) {
489        for hook in &self.hooks {
490            hook.on_approval_requested(request).await;
491        }
492    }
493
494    async fn on_approval_result(&self, request_id: &str, result: &ApprovalResult) {
495        for hook in &self.hooks {
496            hook.on_approval_result(request_id, result).await;
497        }
498    }
499
500    async fn on_memory_compress(&self, event: &MemoryCompressEvent) {
501        for hook in &self.hooks {
502            hook.on_memory_compress(event).await;
503        }
504    }
505
506    async fn on_memory_evict(&self, event: &MemoryEvictEvent) {
507        for hook in &self.hooks {
508            hook.on_memory_evict(event).await;
509        }
510    }
511
512    async fn on_memory_budget_warning(&self, event: &MemoryBudgetEvent) {
513        for hook in &self.hooks {
514            hook.on_memory_budget_warning(event).await;
515        }
516    }
517
518    async fn on_delegate_start(&self, agent_id: &str, state: &str) {
519        for hook in &self.hooks {
520            hook.on_delegate_start(agent_id, state).await;
521        }
522    }
523
524    async fn on_delegate_complete(&self, agent_id: &str, state: &str, duration_ms: u64) {
525        for hook in &self.hooks {
526            hook.on_delegate_complete(agent_id, state, duration_ms)
527                .await;
528        }
529    }
530
531    async fn on_concurrent_complete(&self, agent_ids: &[String], strategy: &str, duration_ms: u64) {
532        for hook in &self.hooks {
533            hook.on_concurrent_complete(agent_ids, strategy, duration_ms)
534                .await;
535        }
536    }
537
538    async fn on_group_chat_round(&self, round: u32, speaker: &str, content: &str) {
539        for hook in &self.hooks {
540            hook.on_group_chat_round(round, speaker, content).await;
541        }
542    }
543
544    async fn on_pipeline_stage(&self, stage: usize, agent_id: &str, duration_ms: u64) {
545        for hook in &self.hooks {
546            hook.on_pipeline_stage(stage, agent_id, duration_ms).await;
547        }
548    }
549
550    async fn on_pipeline_complete(&self, stages: usize, duration_ms: u64) {
551        for hook in &self.hooks {
552            hook.on_pipeline_complete(stages, duration_ms).await;
553        }
554    }
555
556    async fn on_handoff_start(&self, initial_agent: &str) {
557        for hook in &self.hooks {
558            hook.on_handoff_start(initial_agent).await;
559        }
560    }
561
562    async fn on_handoff(&self, _from: &str, _to: &str, _reason: &str) {
563        for hook in &self.hooks {
564            hook.on_handoff(_from, _to, _reason).await;
565        }
566    }
567
568    async fn on_persona_evolve(
569        &self,
570        _field: &str,
571        _old_value: &Value,
572        _new_value: &Value,
573        _reason: Option<&str>,
574    ) {
575        for hook in &self.hooks {
576            hook.on_persona_evolve(_field, _old_value, _new_value, _reason)
577                .await;
578        }
579    }
580
581    async fn on_secret_revealed(&self, content: &str) {
582        for hook in &self.hooks {
583            hook.on_secret_revealed(content).await;
584        }
585    }
586
587    async fn on_facts_extracted(&self, actor_id: &str, facts: &[ai_agents_core::KeyFact]) {
588        for hook in &self.hooks {
589            hook.on_facts_extracted(actor_id, facts).await;
590        }
591    }
592
593    async fn on_actor_memory_loaded(&self, actor_id: &str, fact_count: usize) {
594        for hook in &self.hooks {
595            hook.on_actor_memory_loaded(actor_id, fact_count).await;
596        }
597    }
598
599    async fn on_session_created(&self, session_id: &str) {
600        for hook in &self.hooks {
601            hook.on_session_created(session_id).await;
602        }
603    }
604
605    async fn on_sessions_expired(&self, count: usize) {
606        for hook in &self.hooks {
607            hook.on_sessions_expired(count).await;
608        }
609    }
610
611    async fn on_relationship_loaded(
612        &self,
613        actor_id: &str,
614        relationship: &ai_agents_relationships::Relationship,
615    ) {
616        for hook in &self.hooks {
617            hook.on_relationship_loaded(actor_id, relationship).await;
618        }
619    }
620
621    async fn on_relationship_change(
622        &self,
623        actor_id: &str,
624        changes: &[ai_agents_relationships::DimensionChange],
625    ) {
626        for hook in &self.hooks {
627            hook.on_relationship_change(actor_id, changes).await;
628        }
629    }
630
631    async fn on_notable_event(
632        &self,
633        actor_id: &str,
634        event: &ai_agents_relationships::RelationshipEvent,
635    ) {
636        for hook in &self.hooks {
637            hook.on_notable_event(actor_id, event).await;
638        }
639    }
640}
641
642pub struct HookTimer {
643    start: Instant,
644}
645
646impl HookTimer {
647    pub fn start() -> Self {
648        Self {
649            start: Instant::now(),
650        }
651    }
652
653    pub fn elapsed_ms(&self) -> u64 {
654        self.start.elapsed().as_millis() as u64
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use parking_lot::Mutex;
662
663    struct RecordingHooks {
664        events: Arc<Mutex<Vec<String>>>,
665    }
666
667    impl RecordingHooks {
668        fn new() -> Self {
669            Self {
670                events: Arc::new(Mutex::new(Vec::new())),
671            }
672        }
673
674        fn events(&self) -> Vec<String> {
675            self.events.lock().clone()
676        }
677    }
678
679    #[async_trait]
680    impl AgentHooks for RecordingHooks {
681        async fn on_message_received(&self, message: &str) {
682            self.events
683                .lock()
684                .push(format!("message_received:{}", message));
685        }
686
687        async fn on_llm_start(&self, messages: &[ChatMessage]) {
688            self.events
689                .lock()
690                .push(format!("llm_start:{}", messages.len()));
691        }
692
693        async fn on_llm_complete(&self, _response: &LLMResponse, duration_ms: u64) {
694            self.events
695                .lock()
696                .push(format!("llm_complete:{}", duration_ms));
697        }
698
699        async fn on_tool_start(&self, tool: &str, _args: &Value) {
700            self.events.lock().push(format!("tool_start:{}", tool));
701        }
702
703        async fn on_tool_complete(&self, tool: &str, result: &ToolResult, duration_ms: u64) {
704            self.events.lock().push(format!(
705                "tool_complete:{}:{}:{}",
706                tool, result.success, duration_ms
707            ));
708        }
709
710        async fn on_state_transition(&self, from: Option<&str>, to: &str, reason: &str) {
711            self.events
712                .lock()
713                .push(format!("state_transition:{:?}:{}:{}", from, to, reason));
714        }
715
716        async fn on_error(&self, error: &AgentError) {
717            self.events.lock().push(format!("error:{}", error));
718        }
719
720        async fn on_response(&self, response: &AgentResponse) {
721            self.events
722                .lock()
723                .push(format!("response:{}", response.content.len()));
724        }
725
726        async fn on_approval_requested(&self, request: &ApprovalRequest) {
727            self.events
728                .lock()
729                .push(format!("approval_requested:{}", request.id));
730        }
731
732        async fn on_approval_result(&self, request_id: &str, result: &ApprovalResult) {
733            let status = match result {
734                ApprovalResult::Approved => "approved",
735                ApprovalResult::Rejected { .. } => "rejected",
736                ApprovalResult::Modified { .. } => "modified",
737                ApprovalResult::Timeout => "timeout",
738            };
739            self.events
740                .lock()
741                .push(format!("approval_result:{}:{}", request_id, status));
742        }
743    }
744
745    #[tokio::test]
746    async fn test_noop_hooks() {
747        let hooks = NoopHooks;
748        hooks.on_message_received("test").await;
749        hooks.on_llm_start(&[]).await;
750    }
751
752    #[tokio::test]
753    async fn test_logging_hooks() {
754        let hooks = LoggingHooks::new();
755        hooks.on_message_received("test message").await;
756        hooks.on_llm_start(&[ChatMessage::user("hello")]).await;
757    }
758
759    #[test]
760    fn test_preview_text_handles_unicode_boundaries() {
761        let text = "제 이름은 Jay이고 가족관계 관련해서 계약서 내용을 확인하고 싶어서";
762        let preview = preview_text(text, 34);
763        assert!(preview.ends_with("..."));
764        assert!(preview.starts_with("제 이름은 Jay"));
765    }
766
767    #[tokio::test]
768    async fn test_recording_hooks() {
769        let hooks = RecordingHooks::new();
770
771        hooks.on_message_received("hello").await;
772        hooks.on_llm_start(&[ChatMessage::user("test")]).await;
773
774        let events = hooks.events();
775        assert_eq!(events.len(), 2);
776        assert!(events[0].contains("message_received"));
777        assert!(events[1].contains("llm_start"));
778    }
779
780    #[tokio::test]
781    async fn test_composite_hooks_with_vec() {
782        let hooks1 = Arc::new(RecordingHooks::new());
783        let hooks2 = Arc::new(RecordingHooks::new());
784
785        let composite = CompositeHooks::with_hooks(vec![
786            hooks1.clone() as Arc<dyn AgentHooks>,
787            hooks2.clone() as Arc<dyn AgentHooks>,
788        ]);
789
790        composite
791            .on_tool_start("calculator", &serde_json::json!({}))
792            .await;
793
794        assert_eq!(hooks1.events().len(), 1);
795        assert_eq!(hooks2.events().len(), 1);
796    }
797
798    #[tokio::test]
799    async fn test_hook_timer() {
800        let timer = HookTimer::start();
801        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
802        let elapsed = timer.elapsed_ms();
803        assert!(elapsed >= 10);
804    }
805
806    #[test]
807    fn test_composite_hooks_default() {
808        let hooks = CompositeHooks::default();
809        assert!(hooks.hooks.is_empty());
810    }
811}