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 ToolCallStarted {
67 agent: String,
68 tool_name: String,
69 tool_call_id: String,
70 #[serde(default)]
72 input: String,
73 },
74
75 ToolCallCompleted {
77 agent: String,
78 tool_name: String,
79 tool_call_id: String,
80 is_error: bool,
81 duration_ms: u64,
82 #[serde(default)]
84 output: String,
85 },
86
87 ApprovalRequested {
89 agent: String,
90 turn: usize,
91 tool_names: Vec<String>,
92 },
93
94 ApprovalDecision {
96 agent: String,
97 turn: usize,
98 approved: bool,
99 },
100
101 SubAgentsDispatched { agent: String, agents: Vec<String> },
103
104 SubAgentCompleted {
106 agent: String,
107 success: bool,
108 usage: TokenUsage,
109 },
110
111 ContextSummarized {
113 agent: String,
114 turn: usize,
115 usage: TokenUsage,
116 },
117
118 RunCompleted {
120 agent: String,
121 total_usage: TokenUsage,
122 tool_calls_made: usize,
123 },
124
125 GuardrailDenied {
127 agent: String,
128 hook: String,
130 reason: String,
131 tool_name: Option<String>,
133 },
134
135 GuardrailWarned {
137 agent: String,
138 hook: String,
140 reason: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 tool_name: Option<String>,
144 },
145
146 RunFailed {
148 agent: String,
149 error: String,
150 partial_usage: TokenUsage,
151 },
152
153 RetryAttempt {
155 agent: String,
156 attempt: u32,
158 max_retries: u32,
160 delay_ms: u64,
162 #[serde(default)]
164 error_class: String,
165 },
166
167 DoomLoopDetected {
169 agent: String,
170 turn: usize,
171 consecutive_count: u32,
173 #[serde(default)]
175 tool_names: Vec<String>,
176 },
177
178 FuzzyDoomLoopDetected {
180 agent: String,
181 turn: usize,
182 consecutive_count: u32,
184 #[serde(default)]
186 tool_names: Vec<String>,
187 },
188
189 KillSwitchActivated {
191 agent: String,
192 reason: String,
193 #[serde(default)]
194 guardrail_name: String,
195 },
196
197 SessionPruned {
199 agent: String,
200 turn: usize,
201 tool_results_pruned: usize,
203 bytes_saved: usize,
205 tool_results_total: usize,
207 },
208
209 AutoCompactionTriggered {
211 agent: String,
212 turn: usize,
213 success: bool,
215 #[serde(default)]
217 usage: TokenUsage,
218 },
219
220 SensorEventProcessed {
222 sensor_name: String,
223 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 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 ModelEscalated {
242 agent: String,
243 from_tier: String,
244 to_tier: String,
245 reason: String,
247 },
248
249 BudgetExceeded {
251 agent: String,
252 used: u64,
254 limit: u64,
256 partial_usage: TokenUsage,
258 },
259
260 AgentSpawned {
262 agent: String,
263 spawned_name: String,
264 tools: Vec<String>,
265 #[serde(default)]
266 task: String,
267 },
268
269 TaskRouted {
271 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 WorkflowNodeStarted { node: String },
284
285 WorkflowNodeCompleted { node: String },
287
288 WorkflowNodeFailed { node: String },
290
291 ToolNameRepaired {
295 agent: String,
296 original: String,
297 repaired: String,
298 },
299}
300
301impl AgentEvent {
302 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
342pub 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 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 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 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 let text = format!("café{}", "x".repeat(5000));
712 let result = truncate_for_event(&text, 4);
713 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 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 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 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}