1use serde::{Deserialize, Serialize};
4
5use crate::llm::types::{StopReason, TokenUsage};
6use crate::tool::builtins::floor_char_boundary;
7
8pub const EVENT_MAX_PAYLOAD_BYTES: usize = 65536;
11
12pub 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#[allow(missing_docs)]
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum AgentEvent {
34 RunStarted { agent: String, task: String },
36
37 TurnStarted {
39 agent: String,
40 turn: usize,
41 max_turns: usize,
42 },
43
44 LlmResponse {
46 agent: String,
47 turn: usize,
48 usage: TokenUsage,
49 stop_reason: StopReason,
50 tool_call_count: usize,
51 #[serde(default)]
53 text: String,
54 #[serde(default)]
56 latency_ms: u64,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 model: Option<String>,
60 #[serde(default)]
62 time_to_first_token_ms: u64,
63 },
64
65 Reasoning {
69 agent: String,
70 turn: usize,
71 #[serde(default)]
73 text: String,
74 },
75
76 ToolCallStarted {
78 agent: String,
79 tool_name: String,
80 tool_call_id: String,
81 #[serde(default)]
83 input: String,
84 },
85
86 ToolCallCompleted {
88 agent: String,
89 tool_name: String,
90 tool_call_id: String,
91 is_error: bool,
92 duration_ms: u64,
93 #[serde(default)]
95 output: String,
96 },
97
98 ApprovalRequested {
100 agent: String,
101 turn: usize,
102 tool_names: Vec<String>,
103 },
104
105 ApprovalDecision {
107 agent: String,
108 turn: usize,
109 approved: bool,
110 },
111
112 SubAgentsDispatched { agent: String, agents: Vec<String> },
114
115 SubAgentCompleted {
117 agent: String,
118 success: bool,
119 usage: TokenUsage,
120 },
121
122 ContextSummarized {
124 agent: String,
125 turn: usize,
126 usage: TokenUsage,
127 },
128
129 RunCompleted {
131 agent: String,
132 total_usage: TokenUsage,
133 tool_calls_made: usize,
134 },
135
136 GuardrailDenied {
138 agent: String,
139 hook: String,
141 reason: String,
142 tool_name: Option<String>,
144 },
145
146 GuardrailWarned {
148 agent: String,
149 hook: String,
151 reason: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 tool_name: Option<String>,
155 },
156
157 RunFailed {
159 agent: String,
160 error: String,
161 partial_usage: TokenUsage,
162 },
163
164 RequestRouted {
167 agent: String,
169 mode: String,
171 source: String,
173 confidence: f32,
175 },
176 GateFired {
181 agent: String,
183 gate: String,
188 reason: String,
190 },
191 RetryAttempt {
192 agent: String,
193 attempt: u32,
195 max_retries: u32,
197 delay_ms: u64,
199 #[serde(default)]
201 error_class: String,
202 },
203
204 DoomLoopDetected {
206 agent: String,
207 turn: usize,
208 consecutive_count: u32,
210 #[serde(default)]
212 tool_names: Vec<String>,
213 },
214
215 FuzzyDoomLoopDetected {
217 agent: String,
218 turn: usize,
219 consecutive_count: u32,
221 #[serde(default)]
223 tool_names: Vec<String>,
224 },
225
226 KillSwitchActivated {
228 agent: String,
229 reason: String,
230 #[serde(default)]
231 guardrail_name: String,
232 },
233
234 SessionPruned {
236 agent: String,
237 turn: usize,
238 tool_results_pruned: usize,
240 bytes_saved: usize,
242 tool_results_total: usize,
244 },
245
246 AutoCompactionTriggered {
248 agent: String,
249 turn: usize,
250 success: bool,
252 #[serde(default)]
254 usage: TokenUsage,
255 },
256
257 SensorEventProcessed {
259 sensor_name: String,
260 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 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 ModelEscalated {
279 agent: String,
280 from_tier: String,
281 to_tier: String,
282 reason: String,
284 },
285
286 BudgetExceeded {
288 agent: String,
289 used: u64,
291 limit: u64,
293 partial_usage: TokenUsage,
295 },
296
297 AgentSpawned {
299 agent: String,
300 spawned_name: String,
301 tools: Vec<String>,
302 #[serde(default)]
303 task: String,
304 },
305
306 TaskRouted {
308 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 WorkflowNodeStarted { node: String },
321
322 WorkflowNodeCompleted { node: String },
324
325 WorkflowNodeFailed { node: String },
327
328 ToolNameRepaired {
332 agent: String,
333 original: String,
334 repaired: String,
335 },
336}
337
338impl AgentEvent {
339 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
382pub 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 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 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 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 let text = format!("café{}", "x".repeat(5000));
752 let result = truncate_for_event(&text, 4);
753 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 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 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 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}