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