Skip to main content

heartbit_core/agent/
events.rs

1//! Agent lifecycle events emitted during execution via the `OnEvent` callback.
2
3use serde::{Deserialize, Serialize};
4
5use crate::llm::types::{StopReason, TokenUsage};
6use crate::tool::builtins::floor_char_boundary;
7
8/// Maximum byte size for event payload strings (LLM text, tool I/O).
9/// Payloads exceeding this are truncated with a `[truncated: N bytes omitted]` suffix.
10pub const EVENT_MAX_PAYLOAD_BYTES: usize = 65536;
11
12/// Truncate a string for event payloads. Short strings (≤ `max_bytes`) pass
13/// through unchanged. Long strings are cut at a UTF-8 char boundary with a
14/// `[truncated: N bytes omitted]` suffix appended (the suffix itself is not
15/// counted against `max_bytes`).
16pub fn truncate_for_event(text: &str, max_bytes: usize) -> String {
17    if text.len() <= max_bytes {
18        return text.to_string();
19    }
20    let cut = floor_char_boundary(text, max_bytes);
21    let omitted = text.len() - cut;
22    format!("{}[truncated: {omitted} bytes omitted]", &text[..cut])
23}
24
25/// Structured events emitted during agent and orchestrator execution.
26///
27/// All events carry the agent name for identification in multi-agent runs.
28/// Events are emitted synchronously via the `OnEvent` callback — keep
29/// handlers fast to avoid blocking the agent loop.
30#[allow(missing_docs)]
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum AgentEvent {
34    /// Agent loop started.
35    RunStarted { agent: String, task: String },
36
37    /// A new turn in the agent loop.
38    TurnStarted {
39        agent: String,
40        turn: usize,
41        max_turns: usize,
42    },
43
44    /// LLM call completed.
45    LlmResponse {
46        agent: String,
47        turn: usize,
48        usage: TokenUsage,
49        stop_reason: StopReason,
50        tool_call_count: usize,
51        /// Truncated LLM response text.
52        #[serde(default)]
53        text: String,
54        /// Wall-clock milliseconds for the LLM call.
55        #[serde(default)]
56        latency_ms: u64,
57        /// Model name from the provider, if available.
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        model: Option<String>,
60        /// Time-to-first-token in milliseconds (streaming only). 0 for non-streaming.
61        #[serde(default)]
62        time_to_first_token_ms: u64,
63    },
64
65    /// Tool execution started.
66    ToolCallStarted {
67        agent: String,
68        tool_name: String,
69        tool_call_id: String,
70        /// Truncated JSON string of tool input.
71        #[serde(default)]
72        input: String,
73    },
74
75    /// Tool execution completed.
76    ToolCallCompleted {
77        agent: String,
78        tool_name: String,
79        tool_call_id: String,
80        is_error: bool,
81        duration_ms: u64,
82        /// Truncated tool output content.
83        #[serde(default)]
84        output: String,
85    },
86
87    /// Human approval requested.
88    ApprovalRequested {
89        agent: String,
90        turn: usize,
91        tool_names: Vec<String>,
92    },
93
94    /// Human approval decision received.
95    ApprovalDecision {
96        agent: String,
97        turn: usize,
98        approved: bool,
99    },
100
101    /// Orchestrator dispatched sub-agents.
102    SubAgentsDispatched { agent: String, agents: Vec<String> },
103
104    /// A sub-agent completed.
105    SubAgentCompleted {
106        agent: String,
107        success: bool,
108        usage: TokenUsage,
109    },
110
111    /// Context was summarized due to threshold.
112    ContextSummarized {
113        agent: String,
114        turn: usize,
115        usage: TokenUsage,
116    },
117
118    /// Agent run completed successfully.
119    RunCompleted {
120        agent: String,
121        total_usage: TokenUsage,
122        tool_calls_made: usize,
123    },
124
125    /// A guardrail denied an LLM response or tool call.
126    GuardrailDenied {
127        agent: String,
128        /// Which hook triggered the denial: `"post_llm"`, `"pre_tool"`, or `"post_tool"`.
129        hook: String,
130        reason: String,
131        /// Set for `pre_tool` and `post_tool` denials, `None` for `post_llm`.
132        tool_name: Option<String>,
133    },
134
135    /// A guardrail issued a warning but allowed the operation to proceed.
136    GuardrailWarned {
137        agent: String,
138        /// Which hook triggered the warning: `"post_llm"` or `"pre_tool"`.
139        hook: String,
140        reason: String,
141        /// Set for `pre_tool` warnings, `None` for `post_llm`.
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        tool_name: Option<String>,
144    },
145
146    /// Agent run failed.
147    RunFailed {
148        agent: String,
149        error: String,
150        partial_usage: TokenUsage,
151    },
152
153    /// An LLM retry attempt is about to happen (before the sleep).
154    RetryAttempt {
155        agent: String,
156        /// Current attempt number (1-indexed).
157        attempt: u32,
158        /// Maximum retries configured.
159        max_retries: u32,
160        /// Delay in milliseconds before the retry.
161        delay_ms: u64,
162        /// Classified error that triggered the retry.
163        #[serde(default)]
164        error_class: String,
165    },
166
167    /// Doom loop detected: the agent repeated identical tool calls too many times.
168    DoomLoopDetected {
169        agent: String,
170        turn: usize,
171        /// Number of consecutive identical turns.
172        consecutive_count: u32,
173        /// Tool names in the repeated batch.
174        #[serde(default)]
175        tool_names: Vec<String>,
176    },
177
178    /// Fuzzy doom loop detected: the agent repeated the same tools with different inputs.
179    FuzzyDoomLoopDetected {
180        agent: String,
181        turn: usize,
182        /// Number of consecutive fuzzy-identical turns.
183        consecutive_count: u32,
184        /// Tool names in the repeated batch.
185        #[serde(default)]
186        tool_names: Vec<String>,
187    },
188
189    /// Kill switch activated: a guardrail triggered an immediate agent termination.
190    KillSwitchActivated {
191        agent: String,
192        reason: String,
193        #[serde(default)]
194        guardrail_name: String,
195    },
196
197    /// Session pruning truncated old tool results before an LLM call.
198    SessionPruned {
199        agent: String,
200        turn: usize,
201        /// Number of tool results that were truncated.
202        tool_results_pruned: usize,
203        /// Total bytes removed across all truncated tool results.
204        bytes_saved: usize,
205        /// Total tool results inspected (pruned + skipped).
206        tool_results_total: usize,
207    },
208
209    /// Auto-compaction was triggered due to context overflow.
210    AutoCompactionTriggered {
211        agent: String,
212        turn: usize,
213        /// Whether compaction succeeded.
214        success: bool,
215        /// Token usage from the compaction LLM call.
216        #[serde(default)]
217        usage: TokenUsage,
218    },
219
220    /// A sensor event was processed through the triage pipeline.
221    SensorEventProcessed {
222        sensor_name: String,
223        /// "promote", "drop", or "dead_letter"
224        decision: String,
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        priority: Option<String>,
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        story_id: Option<String>,
229    },
230
231    /// A story was created or updated with new correlated events.
232    StoryUpdated {
233        story_id: String,
234        subject: String,
235        event_count: usize,
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        priority: Option<String>,
238    },
239
240    /// Model cascade escalated from a cheaper tier.
241    ModelEscalated {
242        agent: String,
243        from_tier: String,
244        to_tier: String,
245        /// "gate_rejected" or "tier_error"
246        reason: String,
247    },
248
249    /// Token budget exceeded: the agent consumed more tokens than the configured limit.
250    BudgetExceeded {
251        agent: String,
252        /// Total tokens consumed (input + output) across all turns.
253        used: u64,
254        /// The configured token budget limit.
255        limit: u64,
256        /// Partial token usage accumulated before the budget was exceeded.
257        partial_usage: TokenUsage,
258    },
259
260    /// A dynamic agent was spawned at runtime by the orchestrator.
261    AgentSpawned {
262        agent: String,
263        spawned_name: String,
264        tools: Vec<String>,
265        #[serde(default)]
266        task: String,
267    },
268
269    /// Task was routed to single-agent or orchestrator by the complexity analyzer.
270    TaskRouted {
271        /// "single_agent" or "orchestrate"
272        decision: String,
273        reason: String,
274        #[serde(default, skip_serializing_if = "Option::is_none")]
275        selected_agent: Option<String>,
276        #[serde(default)]
277        complexity_score: f32,
278        #[serde(default)]
279        escalated: bool,
280    },
281
282    /// A workflow node agent has started executing (emitted by the workflow executor).
283    WorkflowNodeStarted { node: String },
284
285    /// A workflow node agent has completed executing (emitted by the workflow executor).
286    WorkflowNodeCompleted { node: String },
287
288    /// A workflow node agent has failed (emitted by the workflow executor).
289    WorkflowNodeFailed { node: String },
290
291    /// LLM emitted an unknown tool name that was repaired via Levenshtein distance.
292    /// The repair happens **before** permission/guardrail evaluation so the policy
293    /// applies to the repaired name, not the typo.
294    ToolNameRepaired {
295        agent: String,
296        original: String,
297        repaired: String,
298    },
299}
300
301impl AgentEvent {
302    /// Returns the serde tag name for this event variant (e.g. `"run_started"`).
303    ///
304    /// Matches the `#[serde(tag = "type", rename_all = "snake_case")]` tags
305    /// without requiring JSON serialization.
306    pub fn type_name(&self) -> &'static str {
307        match self {
308            Self::RunStarted { .. } => "run_started",
309            Self::TurnStarted { .. } => "turn_started",
310            Self::LlmResponse { .. } => "llm_response",
311            Self::ToolCallStarted { .. } => "tool_call_started",
312            Self::ToolCallCompleted { .. } => "tool_call_completed",
313            Self::ApprovalRequested { .. } => "approval_requested",
314            Self::ApprovalDecision { .. } => "approval_decision",
315            Self::SubAgentsDispatched { .. } => "sub_agents_dispatched",
316            Self::SubAgentCompleted { .. } => "sub_agent_completed",
317            Self::ContextSummarized { .. } => "context_summarized",
318            Self::RunCompleted { .. } => "run_completed",
319            Self::GuardrailDenied { .. } => "guardrail_denied",
320            Self::GuardrailWarned { .. } => "guardrail_warned",
321            Self::RunFailed { .. } => "run_failed",
322            Self::RetryAttempt { .. } => "retry_attempt",
323            Self::DoomLoopDetected { .. } => "doom_loop_detected",
324            Self::FuzzyDoomLoopDetected { .. } => "fuzzy_doom_loop_detected",
325            Self::KillSwitchActivated { .. } => "kill_switch_activated",
326            Self::SessionPruned { .. } => "session_pruned",
327            Self::AutoCompactionTriggered { .. } => "auto_compaction_triggered",
328            Self::SensorEventProcessed { .. } => "sensor_event_processed",
329            Self::StoryUpdated { .. } => "story_updated",
330            Self::ModelEscalated { .. } => "model_escalated",
331            Self::BudgetExceeded { .. } => "budget_exceeded",
332            Self::AgentSpawned { .. } => "agent_spawned",
333            Self::TaskRouted { .. } => "task_routed",
334            Self::WorkflowNodeStarted { .. } => "workflow_node_started",
335            Self::WorkflowNodeCompleted { .. } => "workflow_node_completed",
336            Self::WorkflowNodeFailed { .. } => "workflow_node_failed",
337            Self::ToolNameRepaired { .. } => "tool_name_repaired",
338        }
339    }
340}
341
342/// Callback type for receiving structured agent events.
343pub type OnEvent = dyn Fn(AgentEvent) + Send + Sync;
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn event_serializes_to_tagged_json() {
351        let event = AgentEvent::RunStarted {
352            agent: "researcher".into(),
353            task: "find info".into(),
354        };
355        let json = serde_json::to_string(&event).unwrap();
356        assert!(json.contains(r#""type":"run_started""#), "json: {json}");
357        assert!(json.contains(r#""agent":"researcher""#), "json: {json}");
358    }
359
360    #[test]
361    fn event_roundtrips_through_json() {
362        let event = AgentEvent::LlmResponse {
363            agent: "coder".into(),
364            turn: 3,
365            usage: TokenUsage {
366                input_tokens: 100,
367                output_tokens: 50,
368                cache_creation_input_tokens: 10,
369                cache_read_input_tokens: 20,
370                reasoning_tokens: 0,
371            },
372            stop_reason: StopReason::ToolUse,
373            tool_call_count: 2,
374            text: "hello world".into(),
375            latency_ms: 42,
376            model: Some("claude-3-5-sonnet".into()),
377            time_to_first_token_ms: 0,
378        };
379        let json = serde_json::to_string(&event).unwrap();
380        let back: AgentEvent = serde_json::from_str(&json).unwrap();
381        match back {
382            AgentEvent::LlmResponse {
383                agent,
384                turn,
385                usage,
386                tool_call_count,
387                text,
388                latency_ms,
389                model,
390                ..
391            } => {
392                assert_eq!(agent, "coder");
393                assert_eq!(turn, 3);
394                assert_eq!(usage.input_tokens, 100);
395                assert_eq!(tool_call_count, 2);
396                assert_eq!(text, "hello world");
397                assert_eq!(latency_ms, 42);
398                assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
399            }
400            other => panic!("expected LlmResponse, got: {other:?}"),
401        }
402    }
403
404    #[test]
405    fn tool_call_events_roundtrip() {
406        let started = AgentEvent::ToolCallStarted {
407            agent: "worker".into(),
408            tool_name: "web_search".into(),
409            tool_call_id: "call-1".into(),
410            input: r#"{"query":"rust async"}"#.into(),
411        };
412        let json = serde_json::to_string(&started).unwrap();
413        assert!(json.contains(r#""type":"tool_call_started""#));
414        let back: AgentEvent = serde_json::from_str(&json).unwrap();
415        match back {
416            AgentEvent::ToolCallStarted { input, .. } => {
417                assert_eq!(input, r#"{"query":"rust async"}"#);
418            }
419            other => panic!("expected ToolCallStarted, got: {other:?}"),
420        }
421
422        let completed = AgentEvent::ToolCallCompleted {
423            agent: "worker".into(),
424            tool_name: "web_search".into(),
425            tool_call_id: "call-1".into(),
426            is_error: false,
427            duration_ms: 150,
428            output: "search results here".into(),
429        };
430        let json = serde_json::to_string(&completed).unwrap();
431        let back: AgentEvent = serde_json::from_str(&json).unwrap();
432        match back {
433            AgentEvent::ToolCallCompleted {
434                duration_ms,
435                is_error,
436                output,
437                ..
438            } => {
439                assert_eq!(duration_ms, 150);
440                assert!(!is_error);
441                assert_eq!(output, "search results here");
442            }
443            other => panic!("expected ToolCallCompleted, got: {other:?}"),
444        }
445    }
446
447    #[test]
448    fn all_variants_serialize() {
449        // Ensure every variant can be serialized without error
450        let events = vec![
451            AgentEvent::RunStarted {
452                agent: "a".into(),
453                task: "t".into(),
454            },
455            AgentEvent::TurnStarted {
456                agent: "a".into(),
457                turn: 1,
458                max_turns: 10,
459            },
460            AgentEvent::LlmResponse {
461                agent: "a".into(),
462                turn: 1,
463                usage: TokenUsage::default(),
464                stop_reason: StopReason::EndTurn,
465                tool_call_count: 0,
466                text: String::new(),
467                latency_ms: 0,
468                model: None,
469                time_to_first_token_ms: 0,
470            },
471            AgentEvent::ToolCallStarted {
472                agent: "a".into(),
473                tool_name: "t".into(),
474                tool_call_id: "c".into(),
475                input: "{}".into(),
476            },
477            AgentEvent::ToolCallCompleted {
478                agent: "a".into(),
479                tool_name: "t".into(),
480                tool_call_id: "c".into(),
481                is_error: false,
482                duration_ms: 0,
483                output: String::new(),
484            },
485            AgentEvent::ApprovalRequested {
486                agent: "a".into(),
487                turn: 1,
488                tool_names: vec!["t".into()],
489            },
490            AgentEvent::ApprovalDecision {
491                agent: "a".into(),
492                turn: 1,
493                approved: true,
494            },
495            AgentEvent::SubAgentsDispatched {
496                agent: "orchestrator".into(),
497                agents: vec!["a".into()],
498            },
499            AgentEvent::SubAgentCompleted {
500                agent: "a".into(),
501                success: true,
502                usage: TokenUsage::default(),
503            },
504            AgentEvent::ContextSummarized {
505                agent: "a".into(),
506                turn: 2,
507                usage: TokenUsage::default(),
508            },
509            AgentEvent::RunCompleted {
510                agent: "a".into(),
511                total_usage: TokenUsage::default(),
512                tool_calls_made: 0,
513            },
514            AgentEvent::GuardrailDenied {
515                agent: "a".into(),
516                hook: "post_llm".into(),
517                reason: "unsafe".into(),
518                tool_name: None,
519            },
520            AgentEvent::GuardrailDenied {
521                agent: "a".into(),
522                hook: "pre_tool".into(),
523                reason: "blocked".into(),
524                tool_name: Some("web_search".into()),
525            },
526            AgentEvent::GuardrailDenied {
527                agent: "a".into(),
528                hook: "post_tool".into(),
529                reason: "output too long".into(),
530                tool_name: Some("bash".into()),
531            },
532            AgentEvent::GuardrailWarned {
533                agent: "a".into(),
534                hook: "post_llm".into(),
535                reason: "suspicious pattern".into(),
536                tool_name: None,
537            },
538            AgentEvent::GuardrailWarned {
539                agent: "a".into(),
540                hook: "pre_tool".into(),
541                reason: "unusual input".into(),
542                tool_name: Some("bash".into()),
543            },
544            AgentEvent::RunFailed {
545                agent: "a".into(),
546                error: "oops".into(),
547                partial_usage: TokenUsage::default(),
548            },
549            AgentEvent::RetryAttempt {
550                agent: "a".into(),
551                attempt: 1,
552                max_retries: 3,
553                delay_ms: 500,
554                error_class: "rate_limited".into(),
555            },
556            AgentEvent::DoomLoopDetected {
557                agent: "a".into(),
558                turn: 4,
559                consecutive_count: 3,
560                tool_names: vec!["web_search".into()],
561            },
562            AgentEvent::FuzzyDoomLoopDetected {
563                agent: "a".into(),
564                turn: 5,
565                consecutive_count: 4,
566                tool_names: vec!["web_search".into()],
567            },
568            AgentEvent::KillSwitchActivated {
569                agent: "a".into(),
570                reason: "critical detection".into(),
571                guardrail_name: "content_fence".into(),
572            },
573            AgentEvent::AutoCompactionTriggered {
574                agent: "a".into(),
575                turn: 2,
576                success: true,
577                usage: TokenUsage::default(),
578            },
579            AgentEvent::SensorEventProcessed {
580                sensor_name: "tech_rss".into(),
581                decision: "promote".into(),
582                priority: Some("normal".into()),
583                story_id: Some("story-123".into()),
584            },
585            AgentEvent::StoryUpdated {
586                story_id: "story-123".into(),
587                subject: "Rust ecosystem news".into(),
588                event_count: 3,
589                priority: Some("normal".into()),
590            },
591            AgentEvent::SessionPruned {
592                agent: "a".into(),
593                turn: 3,
594                tool_results_pruned: 2,
595                bytes_saved: 1500,
596                tool_results_total: 4,
597            },
598            AgentEvent::ModelEscalated {
599                agent: "a".into(),
600                from_tier: "haiku".into(),
601                to_tier: "sonnet".into(),
602                reason: "gate_rejected".into(),
603            },
604            AgentEvent::BudgetExceeded {
605                agent: "a".into(),
606                used: 150000,
607                limit: 100000,
608                partial_usage: TokenUsage::default(),
609            },
610            AgentEvent::AgentSpawned {
611                agent: "orchestrator".into(),
612                spawned_name: "spawn:tax_specialist".into(),
613                tools: vec!["read".into(), "grep".into()],
614                task: "analyze tax implications".into(),
615            },
616            AgentEvent::TaskRouted {
617                decision: "single_agent".into(),
618                reason: "heuristic score below threshold".into(),
619                selected_agent: Some("coder".into()),
620                complexity_score: 0.15,
621                escalated: false,
622            },
623        ];
624        for event in events {
625            let json = serde_json::to_string(&event).unwrap();
626            let _back: AgentEvent = serde_json::from_str(&json).unwrap();
627        }
628    }
629
630    #[test]
631    fn type_name_matches_serde_tag() {
632        // Verify type_name() matches the serialized "type" field for a few key variants
633        let cases = vec![
634            (
635                AgentEvent::RunStarted {
636                    agent: "a".into(),
637                    task: "t".into(),
638                },
639                "run_started",
640            ),
641            (
642                AgentEvent::LlmResponse {
643                    agent: "a".into(),
644                    turn: 1,
645                    usage: TokenUsage::default(),
646                    stop_reason: StopReason::EndTurn,
647                    tool_call_count: 0,
648                    text: String::new(),
649                    latency_ms: 0,
650                    model: None,
651                    time_to_first_token_ms: 0,
652                },
653                "llm_response",
654            ),
655            (
656                AgentEvent::SubAgentsDispatched {
657                    agent: "a".into(),
658                    agents: vec![],
659                },
660                "sub_agents_dispatched",
661            ),
662            (
663                AgentEvent::KillSwitchActivated {
664                    agent: "a".into(),
665                    reason: "r".into(),
666                    guardrail_name: "g".into(),
667                },
668                "kill_switch_activated",
669            ),
670        ];
671        for (event, expected) in cases {
672            assert_eq!(event.type_name(), expected);
673            let json = serde_json::to_value(&event).unwrap();
674            let serde_type = json.get("type").unwrap().as_str().unwrap();
675            assert_eq!(
676                event.type_name(),
677                serde_type,
678                "type_name() diverges from serde tag for {:?}",
679                expected
680            );
681        }
682    }
683
684    #[test]
685    fn truncate_for_event_noop_when_short() {
686        let short = "hello world";
687        assert_eq!(truncate_for_event(short, 100), short);
688    }
689
690    #[test]
691    fn truncate_for_event_zero_max_bytes() {
692        let result = truncate_for_event("hello", 0);
693        assert!(result.contains("[truncated: 5 bytes omitted]"));
694        // Content portion should be empty
695        assert!(result.starts_with("[truncated:"));
696    }
697
698    #[test]
699    fn truncate_for_event_truncates_long_string() {
700        let long = "a".repeat(5000);
701        let result = truncate_for_event(&long, 100);
702        assert!(result.len() < long.len());
703        assert!(result.contains("[truncated:"));
704        assert!(result.contains("bytes omitted]"));
705    }
706
707    #[test]
708    fn truncate_for_event_preserves_utf8() {
709        // "café" is 5 bytes: c(1) a(1) f(1) é(2)
710        // Truncating at 4 bytes must not split the 'é'
711        let text = format!("café{}", "x".repeat(5000));
712        let result = truncate_for_event(&text, 4);
713        // Should cut before 'é' (at byte 3) since byte 4 is mid-char
714        assert!(result.starts_with("caf"));
715        assert!(result.contains("[truncated:"));
716    }
717
718    #[test]
719    fn llm_response_text_and_latency_roundtrip() {
720        let event = AgentEvent::LlmResponse {
721            agent: "a".into(),
722            turn: 1,
723            usage: TokenUsage::default(),
724            stop_reason: StopReason::EndTurn,
725            tool_call_count: 0,
726            text: "some response text".into(),
727            latency_ms: 123,
728            model: Some("claude-3-opus".into()),
729            time_to_first_token_ms: 0,
730        };
731        let json = serde_json::to_string(&event).unwrap();
732        let back: AgentEvent = serde_json::from_str(&json).unwrap();
733        match back {
734            AgentEvent::LlmResponse {
735                text,
736                latency_ms,
737                model,
738                ..
739            } => {
740                assert_eq!(text, "some response text");
741                assert_eq!(latency_ms, 123);
742                assert_eq!(model.as_deref(), Some("claude-3-opus"));
743            }
744            other => panic!("expected LlmResponse, got: {other:?}"),
745        }
746    }
747
748    #[test]
749    fn llm_response_model_none_roundtrip() {
750        let event = AgentEvent::LlmResponse {
751            agent: "a".into(),
752            turn: 1,
753            usage: TokenUsage::default(),
754            stop_reason: StopReason::EndTurn,
755            tool_call_count: 0,
756            text: String::new(),
757            latency_ms: 0,
758            model: None,
759            time_to_first_token_ms: 0,
760        };
761        let json = serde_json::to_string(&event).unwrap();
762        // model should not appear in JSON when None
763        assert!(!json.contains("model"), "json: {json}");
764        let back: AgentEvent = serde_json::from_str(&json).unwrap();
765        match back {
766            AgentEvent::LlmResponse { model, .. } => assert!(model.is_none()),
767            other => panic!("expected LlmResponse, got: {other:?}"),
768        }
769    }
770
771    #[test]
772    fn tool_call_started_input_roundtrip() {
773        let event = AgentEvent::ToolCallStarted {
774            agent: "a".into(),
775            tool_name: "read_file".into(),
776            tool_call_id: "c1".into(),
777            input: r#"{"path":"/tmp/f"}"#.into(),
778        };
779        let json = serde_json::to_string(&event).unwrap();
780        let back: AgentEvent = serde_json::from_str(&json).unwrap();
781        match back {
782            AgentEvent::ToolCallStarted { input, .. } => {
783                assert_eq!(input, r#"{"path":"/tmp/f"}"#);
784            }
785            other => panic!("expected ToolCallStarted, got: {other:?}"),
786        }
787    }
788
789    #[test]
790    fn tool_call_completed_output_roundtrip() {
791        let event = AgentEvent::ToolCallCompleted {
792            agent: "a".into(),
793            tool_name: "bash".into(),
794            tool_call_id: "c2".into(),
795            is_error: false,
796            duration_ms: 50,
797            output: "command output here".into(),
798        };
799        let json = serde_json::to_string(&event).unwrap();
800        let back: AgentEvent = serde_json::from_str(&json).unwrap();
801        match back {
802            AgentEvent::ToolCallCompleted { output, .. } => {
803                assert_eq!(output, "command output here");
804            }
805            other => panic!("expected ToolCallCompleted, got: {other:?}"),
806        }
807    }
808
809    #[test]
810    fn retry_attempt_roundtrip() {
811        let event = AgentEvent::RetryAttempt {
812            agent: "a".into(),
813            attempt: 2,
814            max_retries: 3,
815            delay_ms: 1000,
816            error_class: "rate_limited".into(),
817        };
818        let json = serde_json::to_string(&event).unwrap();
819        assert!(json.contains(r#""type":"retry_attempt""#));
820        let back: AgentEvent = serde_json::from_str(&json).unwrap();
821        match back {
822            AgentEvent::RetryAttempt {
823                agent,
824                attempt,
825                max_retries,
826                delay_ms,
827                error_class,
828            } => {
829                assert_eq!(agent, "a");
830                assert_eq!(attempt, 2);
831                assert_eq!(max_retries, 3);
832                assert_eq!(delay_ms, 1000);
833                assert_eq!(error_class, "rate_limited");
834            }
835            other => panic!("expected RetryAttempt, got: {other:?}"),
836        }
837    }
838
839    #[test]
840    fn doom_loop_detected_roundtrip() {
841        let event = AgentEvent::DoomLoopDetected {
842            agent: "b".into(),
843            turn: 5,
844            consecutive_count: 3,
845            tool_names: vec!["web_search".into(), "read_file".into()],
846        };
847        let json = serde_json::to_string(&event).unwrap();
848        assert!(json.contains(r#""type":"doom_loop_detected""#));
849        let back: AgentEvent = serde_json::from_str(&json).unwrap();
850        match back {
851            AgentEvent::DoomLoopDetected {
852                agent,
853                turn,
854                consecutive_count,
855                tool_names,
856            } => {
857                assert_eq!(agent, "b");
858                assert_eq!(turn, 5);
859                assert_eq!(consecutive_count, 3);
860                assert_eq!(tool_names, vec!["web_search", "read_file"]);
861            }
862            other => panic!("expected DoomLoopDetected, got: {other:?}"),
863        }
864    }
865
866    #[test]
867    fn llm_response_ttft_roundtrip() {
868        let event = AgentEvent::LlmResponse {
869            agent: "a".into(),
870            turn: 1,
871            usage: TokenUsage::default(),
872            stop_reason: StopReason::EndTurn,
873            tool_call_count: 0,
874            text: "hello".into(),
875            latency_ms: 500,
876            model: None,
877            time_to_first_token_ms: 42,
878        };
879        let json = serde_json::to_string(&event).unwrap();
880        assert!(
881            json.contains(r#""time_to_first_token_ms":42"#),
882            "json: {json}"
883        );
884        let back: AgentEvent = serde_json::from_str(&json).unwrap();
885        match back {
886            AgentEvent::LlmResponse {
887                time_to_first_token_ms,
888                ..
889            } => {
890                assert_eq!(time_to_first_token_ms, 42);
891            }
892            other => panic!("expected LlmResponse, got: {other:?}"),
893        }
894    }
895
896    #[test]
897    fn backward_compat_llm_response_without_ttft() {
898        // Old JSON without time_to_first_token_ms should deserialize with default 0
899        let json = r#"{
900            "type":"llm_response",
901            "agent":"a",
902            "turn":1,
903            "usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},
904            "stop_reason":"end_turn",
905            "tool_call_count":0,
906            "text":"hello",
907            "latency_ms":100
908        }"#;
909        let event: AgentEvent = serde_json::from_str(json).unwrap();
910        match event {
911            AgentEvent::LlmResponse {
912                time_to_first_token_ms,
913                ..
914            } => {
915                assert_eq!(time_to_first_token_ms, 0);
916            }
917            other => panic!("expected LlmResponse, got: {other:?}"),
918        }
919    }
920
921    #[test]
922    fn guardrail_warned_roundtrip() {
923        let event = AgentEvent::GuardrailWarned {
924            agent: "a".into(),
925            hook: "pre_tool".into(),
926            reason: "suspicious input".into(),
927            tool_name: Some("bash".into()),
928        };
929        let json = serde_json::to_string(&event).unwrap();
930        assert!(json.contains(r#""type":"guardrail_warned""#));
931        let back: AgentEvent = serde_json::from_str(&json).unwrap();
932        match back {
933            AgentEvent::GuardrailWarned {
934                agent,
935                hook,
936                reason,
937                tool_name,
938            } => {
939                assert_eq!(agent, "a");
940                assert_eq!(hook, "pre_tool");
941                assert_eq!(reason, "suspicious input");
942                assert_eq!(tool_name.as_deref(), Some("bash"));
943            }
944            other => panic!("expected GuardrailWarned, got: {other:?}"),
945        }
946    }
947
948    #[test]
949    fn guardrail_warned_no_tool_name_omits_field() {
950        let event = AgentEvent::GuardrailWarned {
951            agent: "a".into(),
952            hook: "post_llm".into(),
953            reason: "test".into(),
954            tool_name: None,
955        };
956        let json = serde_json::to_string(&event).unwrap();
957        assert!(!json.contains("tool_name"), "json: {json}");
958    }
959
960    #[test]
961    fn auto_compaction_triggered_roundtrip() {
962        let usage = TokenUsage {
963            input_tokens: 500,
964            output_tokens: 200,
965            ..Default::default()
966        };
967        let event = AgentEvent::AutoCompactionTriggered {
968            agent: "c".into(),
969            turn: 3,
970            success: true,
971            usage,
972        };
973        let json = serde_json::to_string(&event).unwrap();
974        assert!(json.contains(r#""type":"auto_compaction_triggered""#));
975        let back: AgentEvent = serde_json::from_str(&json).unwrap();
976        match back {
977            AgentEvent::AutoCompactionTriggered {
978                agent,
979                turn,
980                success,
981                usage,
982            } => {
983                assert_eq!(agent, "c");
984                assert_eq!(turn, 3);
985                assert!(success);
986                assert_eq!(usage.input_tokens, 500);
987                assert_eq!(usage.output_tokens, 200);
988            }
989            other => panic!("expected AutoCompactionTriggered, got: {other:?}"),
990        }
991    }
992
993    #[test]
994    fn backward_compatible_deserialization_without_new_fields() {
995        // Old-format JSON without the new fields should still deserialize
996        let json = r#"{
997            "type":"llm_response",
998            "agent":"a",
999            "turn":1,
1000            "usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},
1001            "stop_reason":"end_turn",
1002            "tool_call_count":0
1003        }"#;
1004        let event: AgentEvent = serde_json::from_str(json).unwrap();
1005        match event {
1006            AgentEvent::LlmResponse {
1007                text,
1008                latency_ms,
1009                model,
1010                ..
1011            } => {
1012                assert_eq!(text, "");
1013                assert_eq!(latency_ms, 0);
1014                assert!(model.is_none());
1015            }
1016            other => panic!("expected LlmResponse, got: {other:?}"),
1017        }
1018
1019        let json = r#"{
1020            "type":"tool_call_started",
1021            "agent":"a",
1022            "tool_name":"t",
1023            "tool_call_id":"c"
1024        }"#;
1025        let event: AgentEvent = serde_json::from_str(json).unwrap();
1026        match event {
1027            AgentEvent::ToolCallStarted { input, .. } => assert_eq!(input, ""),
1028            other => panic!("expected ToolCallStarted, got: {other:?}"),
1029        }
1030
1031        let json = r#"{
1032            "type":"tool_call_completed",
1033            "agent":"a",
1034            "tool_name":"t",
1035            "tool_call_id":"c",
1036            "is_error":false,
1037            "duration_ms":0
1038        }"#;
1039        let event: AgentEvent = serde_json::from_str(json).unwrap();
1040        match event {
1041            AgentEvent::ToolCallCompleted { output, .. } => assert_eq!(output, ""),
1042            other => panic!("expected ToolCallCompleted, got: {other:?}"),
1043        }
1044    }
1045
1046    #[test]
1047    fn model_escalated_roundtrip() {
1048        let event = AgentEvent::ModelEscalated {
1049            agent: "a".into(),
1050            from_tier: "haiku".into(),
1051            to_tier: "sonnet".into(),
1052            reason: "gate_rejected".into(),
1053        };
1054        let json = serde_json::to_string(&event).unwrap();
1055        assert!(json.contains(r#""type":"model_escalated""#));
1056        let back: AgentEvent = serde_json::from_str(&json).unwrap();
1057        match back {
1058            AgentEvent::ModelEscalated {
1059                agent,
1060                from_tier,
1061                to_tier,
1062                reason,
1063            } => {
1064                assert_eq!(agent, "a");
1065                assert_eq!(from_tier, "haiku");
1066                assert_eq!(to_tier, "sonnet");
1067                assert_eq!(reason, "gate_rejected");
1068            }
1069            other => panic!("expected ModelEscalated, got: {other:?}"),
1070        }
1071    }
1072}