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