1use crate::llm::ContentBlock;
16use crate::types::{BudgetLimitKind, ThreadId, TokenUsage, ToolResult, ToolTier};
17use serde::{Deserialize, Serialize};
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21use time::OffsetDateTime;
22
23mod duration_ms_serde {
34 use serde::{Deserialize, Deserializer, Serializer};
35 use std::time::Duration;
36
37 #[derive(Deserialize)]
39 #[serde(untagged)]
40 enum DurationRepr {
41 Millis(u64),
43 Legacy { secs: u64, nanos: u32 },
45 }
46
47 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
48 where
49 S: Serializer,
50 {
51 let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
52 serializer.serialize_u64(ms)
53 }
54
55 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
56 where
57 D: Deserializer<'de>,
58 {
59 match DurationRepr::deserialize(deserializer)? {
60 DurationRepr::Millis(ms) => Ok(Duration::from_millis(ms)),
61 DurationRepr::Legacy { secs, nanos } => Ok(Duration::new(secs, nanos)),
62 }
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
73#[serde(tag = "reason", rename_all = "snake_case")]
74pub enum TerminalReason {
75 Completed,
77 UserCancel,
79 Budget,
81 WatchdogStall,
83 ProviderError { kind: String },
85 ParentCancelled,
87 ConfirmationRejected,
89 InternalError,
91 #[serde(other)]
93 Unknown,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum AccountRotationReason {
104 RateLimited,
106 AuthenticationFailed,
108 RefreshFailed,
110 #[serde(other)]
112 Unknown,
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize)]
118#[serde(tag = "type", rename_all = "snake_case")]
119#[non_exhaustive]
120pub enum AgentEvent {
121 ThreadCreated {
123 thread_id: ThreadId,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 source_thread_id: Option<ThreadId>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 fork_after_committed_turns: Option<u32>,
130 },
131
132 Start {
134 thread_id: ThreadId,
135 turn: usize,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 emitter_task_id: Option<String>,
140 },
141
142 UserInput {
162 thread_id: ThreadId,
163 content: Vec<ContentBlock>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
173 emitter_task_id: Option<String>,
174 },
175
176 Thinking {
178 message_id: String,
179 text: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 emitter_task_id: Option<String>,
184 },
185
186 ThinkingDelta {
188 message_id: String,
189 delta: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 emitter_task_id: Option<String>,
194 },
195
196 TextDelta {
198 message_id: String,
199 delta: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 emitter_task_id: Option<String>,
204 },
205
206 Text {
208 message_id: String,
209 text: String,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 emitter_task_id: Option<String>,
214 },
215
216 ToolCallStart {
218 id: String,
219 name: String,
220 display_name: String,
221 input: serde_json::Value,
222 tier: ToolTier,
223 },
224
225 ToolCallEnd {
227 id: String,
228 name: String,
229 display_name: String,
230 result: ToolResult,
231 },
232
233 ToolProgress {
235 id: String,
237 name: String,
239 display_name: String,
241 stage: String,
243 message: String,
245 data: Option<serde_json::Value>,
247 },
248
249 ToolRequiresConfirmation {
252 id: String,
253 name: String,
254 display_name: String,
255 input: serde_json::Value,
256 description: String,
257 },
258
259 QuestionAsked {
262 task_id: String,
264 questions: Vec<crate::QuestionPayload>,
268 },
269
270 TurnComplete {
272 turn: usize,
273 usage: TokenUsage,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 emitter_task_id: Option<String>,
278 },
279
280 Done {
282 thread_id: ThreadId,
283 total_turns: usize,
284 total_usage: TokenUsage,
285 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
293 duration: Duration,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
299 estimated_cost_usd: Option<f64>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
303 emitter_task_id: Option<String>,
304 },
305
306 BudgetExceeded {
313 thread_id: ThreadId,
314 total_turns: usize,
315 total_usage: TokenUsage,
316 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
321 duration: Duration,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
325 estimated_cost_usd: Option<f64>,
326 limit: BudgetLimitKind,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 emitter_task_id: Option<String>,
332 },
333
334 Error {
336 message: String,
337 recoverable: bool,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 reason: Option<TerminalReason>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 emitter_task_id: Option<String>,
345 },
346
347 AutoRetryStart {
353 attempt: u32,
360 max_attempts: u32,
368 delay_ms: u64,
370 error_message: String,
372 },
373
374 AutoRetryEnd {
378 attempt: u32,
382 success: bool,
384 final_error: Option<String>,
386 },
387
388 AccountRotation {
392 provider: String,
394 from_account: String,
396 to_account: String,
398 reason: AccountRotationReason,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 retry_after_seconds: Option<u32>,
403 },
404
405 AccountPoolExhausted {
411 provider: String,
413 account_count: u32,
415 reason: AccountRotationReason,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 retry_after_seconds: Option<u32>,
420 },
421
422 Refusal {
424 message_id: String,
425 text: Option<String>,
426 },
427
428 Cancelled {
444 turn: usize,
445 usage: TokenUsage,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 reason: Option<TerminalReason>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
453 emitter_task_id: Option<String>,
454 },
455
456 ContextCompacted {
458 original_count: usize,
460 new_count: usize,
462 original_tokens: usize,
464 new_tokens: usize,
466 },
467
468 SubagentProgress {
470 subagent_id: String,
472 subagent_name: String,
474 nickname: Option<String>,
476 child_thread_id: Option<ThreadId>,
478 child_root_task_id: Option<String>,
480 subagent_task_id: Option<String>,
482 max_turns: Option<u32>,
484 current_turn: Option<u32>,
486 model: Option<String>,
488 tool_name: String,
490 tool_context: String,
492 completed: bool,
494 success: bool,
496 tool_count: u32,
498 total_tokens: u64,
500 #[serde(default)]
502 input_tokens: u64,
503 #[serde(default)]
505 output_tokens: u64,
506 #[serde(default)]
508 cache_read_input_tokens: u64,
509 #[serde(default)]
511 cache_creation_input_tokens: u64,
512 },
513}
514
515impl AgentEvent {
516 #[must_use]
517 pub const fn thread_created(
518 thread_id: ThreadId,
519 source_thread_id: Option<ThreadId>,
520 fork_after_committed_turns: Option<u32>,
521 ) -> Self {
522 Self::ThreadCreated {
523 thread_id,
524 source_thread_id,
525 fork_after_committed_turns,
526 }
527 }
528
529 #[must_use]
530 pub const fn start(thread_id: ThreadId, turn: usize) -> Self {
531 Self::Start {
532 thread_id,
533 turn,
534 emitter_task_id: None,
535 }
536 }
537
538 #[must_use]
556 pub fn with_emitter_task_id(mut self, task_id: impl Into<String>) -> Self {
557 let task_id = task_id.into();
558 match &mut self {
559 Self::Start {
560 emitter_task_id, ..
561 }
562 | Self::UserInput {
563 emitter_task_id, ..
564 }
565 | Self::TurnComplete {
566 emitter_task_id, ..
567 }
568 | Self::Done {
569 emitter_task_id, ..
570 }
571 | Self::BudgetExceeded {
572 emitter_task_id, ..
573 }
574 | Self::Error {
575 emitter_task_id, ..
576 }
577 | Self::Cancelled {
578 emitter_task_id, ..
579 }
580 | Self::Thinking {
581 emitter_task_id, ..
582 }
583 | Self::ThinkingDelta {
584 emitter_task_id, ..
585 }
586 | Self::TextDelta {
587 emitter_task_id, ..
588 }
589 | Self::Text {
590 emitter_task_id, ..
591 } => *emitter_task_id = Some(task_id),
592 _ => {}
593 }
594 self
595 }
596
597 #[must_use]
601 pub fn emitter_task_id(&self) -> Option<&str> {
602 match self {
603 Self::Start {
604 emitter_task_id, ..
605 }
606 | Self::UserInput {
607 emitter_task_id, ..
608 }
609 | Self::TurnComplete {
610 emitter_task_id, ..
611 }
612 | Self::Done {
613 emitter_task_id, ..
614 }
615 | Self::BudgetExceeded {
616 emitter_task_id, ..
617 }
618 | Self::Error {
619 emitter_task_id, ..
620 }
621 | Self::Cancelled {
622 emitter_task_id, ..
623 }
624 | Self::Thinking {
625 emitter_task_id, ..
626 }
627 | Self::ThinkingDelta {
628 emitter_task_id, ..
629 }
630 | Self::TextDelta {
631 emitter_task_id, ..
632 }
633 | Self::Text {
634 emitter_task_id, ..
635 } => emitter_task_id.as_deref(),
636 _ => None,
637 }
638 }
639
640 #[must_use]
641 pub const fn user_input(thread_id: ThreadId, content: Vec<ContentBlock>) -> Self {
642 Self::UserInput {
643 thread_id,
644 content,
645 emitter_task_id: None,
646 }
647 }
648
649 #[must_use]
650 pub fn thinking(message_id: impl Into<String>, text: impl Into<String>) -> Self {
651 Self::Thinking {
652 message_id: message_id.into(),
653 text: text.into(),
654 emitter_task_id: None,
655 }
656 }
657
658 #[must_use]
659 pub fn thinking_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
660 Self::ThinkingDelta {
661 message_id: message_id.into(),
662 delta: delta.into(),
663 emitter_task_id: None,
664 }
665 }
666
667 #[must_use]
668 pub fn text_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
669 Self::TextDelta {
670 message_id: message_id.into(),
671 delta: delta.into(),
672 emitter_task_id: None,
673 }
674 }
675
676 #[must_use]
677 pub fn text(message_id: impl Into<String>, text: impl Into<String>) -> Self {
678 Self::Text {
679 message_id: message_id.into(),
680 text: text.into(),
681 emitter_task_id: None,
682 }
683 }
684
685 #[must_use]
686 pub fn tool_call_start(
687 id: impl Into<String>,
688 name: impl Into<String>,
689 display_name: impl Into<String>,
690 input: serde_json::Value,
691 tier: ToolTier,
692 ) -> Self {
693 Self::ToolCallStart {
694 id: id.into(),
695 name: name.into(),
696 display_name: display_name.into(),
697 input,
698 tier,
699 }
700 }
701
702 #[must_use]
703 pub fn tool_call_end(
704 id: impl Into<String>,
705 name: impl Into<String>,
706 display_name: impl Into<String>,
707 result: ToolResult,
708 ) -> Self {
709 Self::ToolCallEnd {
710 id: id.into(),
711 name: name.into(),
712 display_name: display_name.into(),
713 result,
714 }
715 }
716
717 #[must_use]
718 pub fn tool_progress(
719 id: impl Into<String>,
720 name: impl Into<String>,
721 display_name: impl Into<String>,
722 stage: impl Into<String>,
723 message: impl Into<String>,
724 data: Option<serde_json::Value>,
725 ) -> Self {
726 Self::ToolProgress {
727 id: id.into(),
728 name: name.into(),
729 display_name: display_name.into(),
730 stage: stage.into(),
731 message: message.into(),
732 data,
733 }
734 }
735
736 #[must_use]
737 pub fn tool_requires_confirmation(
738 id: impl Into<String>,
739 name: impl Into<String>,
740 display_name: impl Into<String>,
741 input: serde_json::Value,
742 description: impl Into<String>,
743 ) -> Self {
744 Self::ToolRequiresConfirmation {
745 id: id.into(),
746 name: name.into(),
747 display_name: display_name.into(),
748 input,
749 description: description.into(),
750 }
751 }
752
753 #[must_use]
756 pub fn question_asked(
757 task_id: impl Into<String>,
758 questions: Vec<crate::QuestionPayload>,
759 ) -> Self {
760 Self::QuestionAsked {
761 task_id: task_id.into(),
762 questions,
763 }
764 }
765
766 #[must_use]
767 pub const fn turn_complete(turn: usize, usage: TokenUsage) -> Self {
768 Self::TurnComplete {
769 turn,
770 usage,
771 emitter_task_id: None,
772 }
773 }
774
775 #[must_use]
776 pub const fn done(
777 thread_id: ThreadId,
778 total_turns: usize,
779 total_usage: TokenUsage,
780 duration: Duration,
781 ) -> Self {
782 Self::Done {
783 thread_id,
784 total_turns,
785 total_usage,
786 duration,
787 estimated_cost_usd: None,
788 emitter_task_id: None,
789 }
790 }
791
792 #[must_use]
793 pub const fn done_with_cost(
794 thread_id: ThreadId,
795 total_turns: usize,
796 total_usage: TokenUsage,
797 duration: Duration,
798 estimated_cost_usd: Option<f64>,
799 ) -> Self {
800 Self::Done {
801 thread_id,
802 total_turns,
803 total_usage,
804 duration,
805 estimated_cost_usd,
806 emitter_task_id: None,
807 }
808 }
809
810 #[must_use]
811 pub const fn budget_exceeded(
812 thread_id: ThreadId,
813 total_turns: usize,
814 total_usage: TokenUsage,
815 duration: Duration,
816 estimated_cost_usd: Option<f64>,
817 limit: BudgetLimitKind,
818 ) -> Self {
819 Self::BudgetExceeded {
820 thread_id,
821 total_turns,
822 total_usage,
823 duration,
824 estimated_cost_usd,
825 limit,
826 emitter_task_id: None,
827 }
828 }
829
830 #[must_use]
831 pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
832 Self::Error {
833 message: message.into(),
834 recoverable,
835 reason: None,
836 emitter_task_id: None,
837 }
838 }
839
840 #[must_use]
842 pub fn terminal_error(message: impl Into<String>, reason: TerminalReason) -> Self {
843 Self::Error {
844 message: message.into(),
845 recoverable: false,
846 reason: Some(reason),
847 emitter_task_id: None,
848 }
849 }
850
851 #[must_use]
852 pub fn refusal(message_id: impl Into<String>, text: Option<String>) -> Self {
853 Self::Refusal {
854 message_id: message_id.into(),
855 text,
856 }
857 }
858
859 #[must_use]
860 pub const fn cancelled(turn: usize, usage: TokenUsage) -> Self {
861 Self::cancelled_with_reason(turn, usage, TerminalReason::UserCancel)
862 }
863
864 #[must_use]
866 pub const fn cancelled_with_reason(
867 turn: usize,
868 usage: TokenUsage,
869 reason: TerminalReason,
870 ) -> Self {
871 Self::Cancelled {
872 turn,
873 usage,
874 reason: Some(reason),
875 emitter_task_id: None,
876 }
877 }
878
879 #[must_use]
880 pub const fn context_compacted(
881 original_count: usize,
882 new_count: usize,
883 original_tokens: usize,
884 new_tokens: usize,
885 ) -> Self {
886 Self::ContextCompacted {
887 original_count,
888 new_count,
889 original_tokens,
890 new_tokens,
891 }
892 }
893}
894
895#[derive(Clone, Debug)]
904pub struct SequenceCounter(Arc<AtomicU64>);
905
906impl SequenceCounter {
907 #[must_use]
909 pub fn new() -> Self {
910 Self(Arc::new(AtomicU64::new(0)))
911 }
912
913 #[must_use]
919 pub fn with_offset(start: u64) -> Self {
920 Self(Arc::new(AtomicU64::new(start)))
921 }
922
923 #[must_use]
925 pub fn next(&self) -> u64 {
926 self.0.fetch_add(1, Ordering::Relaxed)
927 }
928}
929
930impl Default for SequenceCounter {
931 fn default() -> Self {
932 Self::new()
933 }
934}
935
936#[derive(Clone, Debug, Serialize, Deserialize)]
944pub struct AgentEventEnvelope {
945 pub event_id: uuid::Uuid,
950 pub sequence: u64,
952 #[serde(with = "time::serde::rfc3339")]
954 pub timestamp: OffsetDateTime,
955 #[serde(flatten)]
957 pub event: AgentEvent,
958}
959
960impl AgentEventEnvelope {
961 #[must_use]
964 pub fn wrap(event: AgentEvent, seq: &SequenceCounter) -> Self {
965 Self {
966 event_id: uuid::Uuid::new_v4(),
967 sequence: seq.next(),
968 timestamp: OffsetDateTime::now_utc(),
969 event,
970 }
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use std::collections::HashSet;
978
979 #[test]
984 fn sequence_counter_starts_at_zero() {
985 let seq = SequenceCounter::new();
986 assert_eq!(seq.next(), 0);
987 }
988
989 #[test]
990 fn sequence_counter_increments_monotonically() {
991 let seq = SequenceCounter::new();
992 for expected in 0..100 {
993 assert_eq!(seq.next(), expected);
994 }
995 }
996
997 #[test]
998 fn sequence_counter_no_gaps() {
999 let seq = SequenceCounter::new();
1000 let values: Vec<u64> = (0..50).map(|_| seq.next()).collect();
1001 let expected: Vec<u64> = (0..50).collect();
1002 assert_eq!(values, expected);
1003 }
1004
1005 #[test]
1006 fn sequence_counter_clones_share_state() {
1007 let seq = SequenceCounter::new();
1008 let clone = seq.clone();
1009
1010 assert_eq!(seq.next(), 0);
1011 assert_eq!(clone.next(), 1);
1012 assert_eq!(seq.next(), 2);
1013 }
1014
1015 #[test]
1016 fn sequence_counter_default_starts_at_zero() {
1017 let seq = SequenceCounter::default();
1018 assert_eq!(seq.next(), 0);
1019 }
1020
1021 #[test]
1022 fn sequence_counter_with_offset_starts_at_given_value() {
1023 let seq = SequenceCounter::with_offset(42);
1024 assert_eq!(seq.next(), 42);
1025 assert_eq!(seq.next(), 43);
1026 assert_eq!(seq.next(), 44);
1027 }
1028
1029 #[test]
1030 fn sequence_counter_with_offset_zero_same_as_new() {
1031 let seq = SequenceCounter::with_offset(0);
1032 assert_eq!(seq.next(), 0);
1033 assert_eq!(seq.next(), 1);
1034 }
1035
1036 #[tokio::test]
1037 async fn sequence_counter_unique_across_concurrent_tasks() {
1038 let seq = SequenceCounter::new();
1039 let n = 1000;
1040
1041 let mut handles = Vec::new();
1042 for _ in 0..n {
1043 let seq_clone = seq.clone();
1044 handles.push(tokio::spawn(async move { seq_clone.next() }));
1045 }
1046
1047 let mut values = HashSet::new();
1048 for handle in handles {
1049 let val = handle.await.unwrap();
1050 assert!(values.insert(val), "duplicate sequence number: {val}");
1051 }
1052
1053 assert_eq!(values.len(), n);
1054 for v in &values {
1056 assert!(*v < n as u64);
1057 }
1058 }
1059
1060 fn sample_event() -> AgentEvent {
1065 AgentEvent::text("msg_1", "hello")
1066 }
1067
1068 #[test]
1069 fn wrap_assigns_unique_event_ids() {
1070 let seq = SequenceCounter::new();
1071 let ids: HashSet<uuid::Uuid> = (0..100)
1072 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq).event_id)
1073 .collect();
1074 assert_eq!(ids.len(), 100);
1075 }
1076
1077 #[test]
1078 fn wrap_event_id_is_valid_uuid_v4() {
1079 let seq = SequenceCounter::new();
1080 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1081 assert_eq!(envelope.event_id.get_version(), Some(uuid::Version::Random));
1082 }
1083
1084 #[test]
1085 fn wrap_assigns_incrementing_sequences() {
1086 let seq = SequenceCounter::new();
1087 let envelopes: Vec<AgentEventEnvelope> = (0..10)
1088 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
1089 .collect();
1090
1091 for (i, env) in envelopes.iter().enumerate() {
1092 assert_eq!(env.sequence, i as u64);
1093 }
1094 }
1095
1096 #[test]
1097 fn wrap_timestamps_are_non_decreasing() {
1098 let seq = SequenceCounter::new();
1099 let envelopes: Vec<AgentEventEnvelope> = (0..20)
1100 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
1101 .collect();
1102
1103 for pair in envelopes.windows(2) {
1104 assert!(pair[1].timestamp >= pair[0].timestamp);
1105 }
1106 }
1107
1108 #[test]
1109 fn wrap_preserves_inner_event() {
1110 let seq = SequenceCounter::new();
1111 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_42", "content"), &seq);
1112 match &envelope.event {
1113 AgentEvent::Text {
1114 message_id, text, ..
1115 } => {
1116 assert_eq!(message_id, "msg_42");
1117 assert_eq!(text, "content");
1118 }
1119 other => panic!("expected Text, got {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn separate_counters_produce_independent_sequences() {
1125 let seq_a = SequenceCounter::new();
1126 let seq_b = SequenceCounter::new();
1127
1128 let a0 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1129 let b0 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1130 let a1 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1131 let b1 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1132
1133 assert_eq!(a0.sequence, 0);
1135 assert_eq!(b0.sequence, 0);
1136 assert_eq!(a1.sequence, 1);
1137 assert_eq!(b1.sequence, 1);
1138
1139 let ids: HashSet<uuid::Uuid> = [&a0, &b0, &a1, &b1].iter().map(|e| e.event_id).collect();
1141 assert_eq!(ids.len(), 4);
1142 }
1143
1144 #[test]
1149 fn envelope_serializes_flat_json() {
1150 let seq = SequenceCounter::new();
1151 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hi"), &seq);
1152 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1153
1154 assert!(json.get("event_id").is_some());
1156 assert!(json.get("sequence").is_some());
1157 assert!(json.get("timestamp").is_some());
1158
1159 assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("text"));
1161 assert_eq!(
1162 json.get("message_id").and_then(|v| v.as_str()),
1163 Some("msg_1")
1164 );
1165 assert_eq!(json.get("text").and_then(|v| v.as_str()), Some("hi"));
1166
1167 assert!(json.get("event").is_none());
1169 }
1170
1171 #[test]
1172 fn envelope_event_id_does_not_collide_with_tool_id() {
1173 let seq = SequenceCounter::new();
1174 let envelope = AgentEventEnvelope::wrap(
1175 AgentEvent::tool_call_start(
1176 "tool_123",
1177 "bash",
1178 "Bash",
1179 serde_json::json!({}),
1180 ToolTier::Observe,
1181 ),
1182 &seq,
1183 );
1184 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1185
1186 let event_id = json.get("event_id").and_then(|v| v.as_str()).unwrap();
1188 let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap();
1189 assert_ne!(event_id, tool_id);
1190 assert_eq!(tool_id, "tool_123");
1191 }
1192
1193 #[test]
1194 fn envelope_roundtrip_serde() {
1195 let seq = SequenceCounter::new();
1196 let original = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hello"), &seq);
1197
1198 let json_str = serde_json::to_string(&original).expect("serialize");
1199 let restored: AgentEventEnvelope = serde_json::from_str(&json_str).expect("deserialize");
1200
1201 assert_eq!(restored.event_id, original.event_id);
1202 assert_eq!(restored.sequence, original.sequence);
1203 assert_eq!(restored.timestamp, original.timestamp);
1204 match &restored.event {
1205 AgentEvent::Text {
1206 message_id, text, ..
1207 } => {
1208 assert_eq!(message_id, "msg_1");
1209 assert_eq!(text, "hello");
1210 }
1211 other => panic!("expected Text, got {other:?}"),
1212 }
1213 }
1214
1215 #[test]
1216 fn account_pool_events_roundtrip_their_stable_wire_shape() {
1217 let rotation = AgentEvent::AccountRotation {
1218 provider: "anthropic".to_owned(),
1219 from_account: "account-a".to_owned(),
1220 to_account: "account-b".to_owned(),
1221 reason: AccountRotationReason::RateLimited,
1222 retry_after_seconds: Some(42),
1223 };
1224 let json = serde_json::to_value(&rotation).expect("serialize rotation");
1225 assert_eq!(
1226 json,
1227 serde_json::json!({
1228 "type": "account_rotation",
1229 "provider": "anthropic",
1230 "from_account": "account-a",
1231 "to_account": "account-b",
1232 "reason": "rate_limited",
1233 "retry_after_seconds": 42,
1234 })
1235 );
1236 let restored: AgentEvent = serde_json::from_value(json).expect("deserialize rotation");
1237 assert!(matches!(
1238 restored,
1239 AgentEvent::AccountRotation {
1240 reason: AccountRotationReason::RateLimited,
1241 retry_after_seconds: Some(42),
1242 ..
1243 }
1244 ));
1245
1246 let exhausted = AgentEvent::AccountPoolExhausted {
1247 provider: "anthropic".to_owned(),
1248 account_count: 3,
1249 reason: AccountRotationReason::RateLimited,
1250 retry_after_seconds: Some(90),
1251 };
1252 let json = serde_json::to_value(&exhausted).expect("serialize exhaustion");
1253 assert_eq!(
1254 json,
1255 serde_json::json!({
1256 "type": "account_pool_exhausted",
1257 "provider": "anthropic",
1258 "account_count": 3,
1259 "reason": "rate_limited",
1260 "retry_after_seconds": 90,
1261 })
1262 );
1263 let restored: AgentEvent = serde_json::from_value(json).expect("deserialize exhaustion");
1264 assert!(matches!(
1265 restored,
1266 AgentEvent::AccountPoolExhausted {
1267 account_count: 3,
1268 retry_after_seconds: Some(90),
1269 ..
1270 }
1271 ));
1272 }
1273
1274 #[test]
1275 fn account_rotation_reason_has_a_forward_compatible_unknown_sink() {
1276 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1277 "type": "account_rotation",
1278 "provider": "anthropic",
1279 "from_account": "account-a",
1280 "to_account": "account-b",
1281 "reason": "provider_policy",
1282 }))
1283 .expect("unknown reason remains replayable");
1284
1285 assert!(matches!(
1286 event,
1287 AgentEvent::AccountRotation {
1288 reason: AccountRotationReason::Unknown,
1289 retry_after_seconds: None,
1290 ..
1291 }
1292 ));
1293 }
1294
1295 #[test]
1296 fn envelope_sequence_is_u64_in_json() {
1297 let seq = SequenceCounter::new();
1298 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1299 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1300
1301 assert!(json.get("sequence").unwrap().is_u64());
1302 assert_eq!(json.get("sequence").unwrap().as_u64(), Some(0));
1303 }
1304
1305 #[test]
1306 fn envelope_timestamp_is_rfc3339_string() {
1307 let seq = SequenceCounter::new();
1308 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1309 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1310
1311 let ts_str = json.get("timestamp").unwrap().as_str().unwrap();
1312 time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
1314 .expect("timestamp should be valid RFC 3339");
1315 }
1316
1317 #[test]
1318 fn done_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1319 let seq = SequenceCounter::new();
1320 let envelope = AgentEventEnvelope::wrap(
1321 AgentEvent::done(
1322 ThreadId::from_string("t"),
1323 3,
1324 TokenUsage::default(),
1325 Duration::from_millis(2500),
1326 ),
1327 &seq,
1328 );
1329 let json = serde_json::to_value(&envelope)?;
1330
1331 assert_eq!(
1334 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1335 Some(2500)
1336 );
1337 assert!(
1338 json.get("duration").is_none(),
1339 "old `duration` key must be gone: {json}"
1340 );
1341
1342 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1343 match restored.event {
1344 AgentEvent::Done { duration, .. } => {
1345 assert_eq!(duration, Duration::from_millis(2500));
1346 }
1347 other => panic!("expected Done, got {other:?}"),
1348 }
1349 Ok(())
1350 }
1351
1352 #[test]
1353 fn done_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1354 let legacy = serde_json::json!({
1359 "type": "done",
1360 "thread_id": "t-legacy",
1361 "total_turns": 3,
1362 "total_usage": TokenUsage::default(),
1363 "duration": { "secs": 2, "nanos": 500_000_000 },
1364 });
1365 let event: AgentEvent = serde_json::from_value(legacy)?;
1366 match event {
1367 AgentEvent::Done {
1368 duration,
1369 total_turns,
1370 ..
1371 } => {
1372 assert_eq!(duration, Duration::from_millis(2500));
1373 assert_eq!(total_turns, 3);
1374 }
1375 other => panic!("expected Done, got {other:?}"),
1376 }
1377
1378 let current = serde_json::json!({
1380 "type": "done",
1381 "thread_id": "t-current",
1382 "total_turns": 3,
1383 "total_usage": TokenUsage::default(),
1384 "duration_ms": 2500,
1385 });
1386 let event: AgentEvent = serde_json::from_value(current)?;
1387 let AgentEvent::Done { duration, .. } = event else {
1388 panic!("expected Done");
1389 };
1390 assert_eq!(duration, Duration::from_millis(2500));
1391
1392 let legacy_event: AgentEvent = serde_json::from_value(serde_json::json!({
1394 "type": "done",
1395 "thread_id": "t-roundtrip",
1396 "total_turns": 1,
1397 "total_usage": TokenUsage::default(),
1398 "duration": { "secs": 1, "nanos": 0 },
1399 }))?;
1400 let reserialized = serde_json::to_value(&legacy_event)?;
1401 assert_eq!(
1402 reserialized
1403 .get("duration_ms")
1404 .and_then(serde_json::Value::as_u64),
1405 Some(1000),
1406 "round-trips must write the millis form: {reserialized}"
1407 );
1408 assert!(reserialized.get("duration").is_none());
1409 Ok(())
1410 }
1411
1412 #[test]
1413 fn terminal_reason_round_trips_provider_kind_and_accepts_future_variants()
1414 -> serde_json::Result<()> {
1415 let provider = TerminalReason::ProviderError {
1416 kind: "rate_limited".to_owned(),
1417 };
1418 let encoded = serde_json::to_value(&provider)?;
1419 assert_eq!(
1420 encoded,
1421 serde_json::json!({
1422 "reason": "provider_error",
1423 "kind": "rate_limited",
1424 }),
1425 );
1426 let restored: TerminalReason = serde_json::from_value(encoded)?;
1427 assert_eq!(restored, provider);
1428
1429 let future: TerminalReason = serde_json::from_value(serde_json::json!({
1430 "reason": "provider_shutdown",
1431 "retryable": false,
1432 }))?;
1433 assert_eq!(future, TerminalReason::Unknown);
1434 Ok(())
1435 }
1436
1437 #[test]
1438 fn subagent_progress_deserializes_legacy_rows_without_usage_breakdown() -> serde_json::Result<()>
1439 {
1440 let legacy = serde_json::json!({
1441 "type": "subagent_progress",
1442 "subagent_id": "call-1",
1443 "subagent_name": "explore",
1444 "nickname": null,
1445 "child_thread_id": null,
1446 "child_root_task_id": null,
1447 "subagent_task_id": null,
1448 "max_turns": 3,
1449 "current_turn": 1,
1450 "model": "mock",
1451 "tool_name": "explore",
1452 "tool_context": "inspect",
1453 "completed": false,
1454 "success": false,
1455 "tool_count": 0,
1456 "total_tokens": 12,
1457 });
1458 let event: AgentEvent = serde_json::from_value(legacy)?;
1459 match event {
1460 AgentEvent::SubagentProgress {
1461 total_tokens,
1462 input_tokens,
1463 output_tokens,
1464 cache_read_input_tokens,
1465 cache_creation_input_tokens,
1466 ..
1467 } => {
1468 assert_eq!(total_tokens, 12);
1469 assert_eq!(input_tokens, 0);
1470 assert_eq!(output_tokens, 0);
1471 assert_eq!(cache_read_input_tokens, 0);
1472 assert_eq!(cache_creation_input_tokens, 0);
1473 }
1474 other => panic!("expected SubagentProgress, got {other:?}"),
1475 }
1476 Ok(())
1477 }
1478
1479 #[test]
1480 fn budget_exceeded_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1481 let legacy = serde_json::json!({
1484 "type": "budget_exceeded",
1485 "thread_id": "t-legacy",
1486 "total_turns": 2,
1487 "total_usage": TokenUsage::default(),
1488 "duration": { "secs": 1, "nanos": 250_000_000 },
1489 "limit": "total_tokens",
1490 });
1491 let event: AgentEvent = serde_json::from_value(legacy)?;
1492 let AgentEvent::BudgetExceeded { duration, .. } = event else {
1493 panic!("expected BudgetExceeded");
1494 };
1495 assert_eq!(duration, Duration::from_millis(1250));
1496 Ok(())
1497 }
1498
1499 #[test]
1500 fn budget_exceeded_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1501 let seq = SequenceCounter::new();
1502 let envelope = AgentEventEnvelope::wrap(
1503 AgentEvent::budget_exceeded(
1504 ThreadId::from_string("t"),
1505 2,
1506 TokenUsage::default(),
1507 Duration::from_millis(1200),
1508 Some(0.5),
1509 BudgetLimitKind::TotalTokens,
1510 ),
1511 &seq,
1512 );
1513 let json = serde_json::to_value(&envelope)?;
1514
1515 assert_eq!(
1517 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1518 Some(1200)
1519 );
1520 assert!(
1521 json.get("duration").is_none(),
1522 "no nested `duration` key expected: {json}"
1523 );
1524
1525 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1526 match restored.event {
1527 AgentEvent::BudgetExceeded { duration, .. } => {
1528 assert_eq!(duration, Duration::from_millis(1200));
1529 }
1530 other => panic!("expected BudgetExceeded, got {other:?}"),
1531 }
1532 Ok(())
1533 }
1534
1535 fn sample_all_variants() -> Vec<AgentEvent> {
1542 let thread = ThreadId::from_string("thread-1");
1543 let usage = TokenUsage::default();
1544 let mut events = session_open_events(&thread);
1545 events.extend(streamed_content_events());
1546 events.extend(tool_call_events());
1547 events.extend(turn_completion_events(&thread, &usage));
1548 events.extend(failure_and_retry_events());
1549 events.extend(auxiliary_events(&usage));
1550 events
1551 }
1552
1553 fn session_open_events(thread: &ThreadId) -> Vec<AgentEvent> {
1555 vec![
1556 AgentEvent::ThreadCreated {
1557 thread_id: thread.clone(),
1558 source_thread_id: None,
1559 fork_after_committed_turns: None,
1560 },
1561 AgentEvent::Start {
1562 thread_id: thread.clone(),
1563 turn: 1,
1564 emitter_task_id: Some("task-start".into()),
1565 },
1566 AgentEvent::UserInput {
1567 thread_id: thread.clone(),
1568 content: vec![ContentBlock::Text { text: "hi".into() }],
1569 emitter_task_id: None,
1570 },
1571 ]
1572 }
1573
1574 fn streamed_content_events() -> Vec<AgentEvent> {
1577 vec![
1578 AgentEvent::Thinking {
1579 message_id: "m".into(),
1580 text: "t".into(),
1581 emitter_task_id: None,
1582 },
1583 AgentEvent::ThinkingDelta {
1584 message_id: "m".into(),
1585 delta: "d".into(),
1586 emitter_task_id: None,
1587 },
1588 AgentEvent::TextDelta {
1589 message_id: "m".into(),
1590 delta: "d".into(),
1591 emitter_task_id: None,
1592 },
1593 AgentEvent::Text {
1594 message_id: "m".into(),
1595 text: "t".into(),
1596 emitter_task_id: None,
1597 },
1598 ]
1599 }
1600
1601 fn tool_call_events() -> Vec<AgentEvent> {
1603 vec![
1604 AgentEvent::ToolCallStart {
1605 id: "id".into(),
1606 name: "n".into(),
1607 display_name: "N".into(),
1608 input: serde_json::json!({}),
1609 tier: ToolTier::Observe,
1610 },
1611 AgentEvent::ToolCallEnd {
1612 id: "id".into(),
1613 name: "n".into(),
1614 display_name: "N".into(),
1615 result: ToolResult::success("ok"),
1616 },
1617 AgentEvent::ToolProgress {
1618 id: "id".into(),
1619 name: "n".into(),
1620 display_name: "N".into(),
1621 stage: "s".into(),
1622 message: "m".into(),
1623 data: None,
1624 },
1625 AgentEvent::ToolRequiresConfirmation {
1626 id: "id".into(),
1627 name: "n".into(),
1628 display_name: "N".into(),
1629 input: serde_json::json!({}),
1630 description: "d".into(),
1631 },
1632 ]
1633 }
1634
1635 fn turn_completion_events(thread: &ThreadId, usage: &TokenUsage) -> Vec<AgentEvent> {
1637 vec![
1638 AgentEvent::TurnComplete {
1639 turn: 1,
1640 usage: usage.clone(),
1641 emitter_task_id: Some("task-turn-complete".into()),
1642 },
1643 AgentEvent::Done {
1644 thread_id: thread.clone(),
1645 total_turns: 2,
1646 total_usage: usage.clone(),
1647 duration: Duration::from_millis(1500),
1648 estimated_cost_usd: Some(0.0123),
1649 emitter_task_id: Some("task-done".into()),
1650 },
1651 ]
1652 }
1653
1654 fn failure_and_retry_events() -> Vec<AgentEvent> {
1656 vec![
1657 AgentEvent::Error {
1658 message: "e".into(),
1659 recoverable: true,
1660 reason: None,
1661 emitter_task_id: Some("task-error".into()),
1662 },
1663 AgentEvent::AutoRetryStart {
1664 attempt: 1,
1665 max_attempts: 5,
1666 delay_ms: 100,
1667 error_message: "rate limited".into(),
1668 },
1669 AgentEvent::AutoRetryEnd {
1670 attempt: 1,
1671 success: true,
1672 final_error: None,
1673 },
1674 ]
1675 }
1676
1677 fn auxiliary_events(usage: &TokenUsage) -> Vec<AgentEvent> {
1680 vec![
1681 AgentEvent::Refusal {
1682 message_id: "m".into(),
1683 text: Some("no".into()),
1684 },
1685 AgentEvent::Cancelled {
1686 turn: 1,
1687 usage: usage.clone(),
1688 reason: Some(TerminalReason::UserCancel),
1689 emitter_task_id: Some("task-cancelled".into()),
1690 },
1691 AgentEvent::BudgetExceeded {
1692 thread_id: ThreadId::from_string("thread-1"),
1693 total_turns: 3,
1694 total_usage: usage.clone(),
1695 duration: Duration::from_millis(750),
1696 estimated_cost_usd: Some(0.5),
1697 limit: BudgetLimitKind::CostUsd,
1698 emitter_task_id: Some("task-budget".into()),
1699 },
1700 AgentEvent::ContextCompacted {
1701 original_count: 10,
1702 new_count: 5,
1703 original_tokens: 100,
1704 new_tokens: 50,
1705 },
1706 AgentEvent::SubagentProgress {
1707 subagent_id: "s".into(),
1708 subagent_name: "explore".into(),
1709 nickname: None,
1710 child_thread_id: None,
1711 child_root_task_id: None,
1712 subagent_task_id: None,
1713 max_turns: None,
1714 current_turn: None,
1715 model: None,
1716 tool_name: "t".into(),
1717 tool_context: "c".into(),
1718 completed: false,
1719 success: false,
1720 tool_count: 0,
1721 total_tokens: 0,
1722 input_tokens: 0,
1723 output_tokens: 0,
1724 cache_read_input_tokens: 0,
1725 cache_creation_input_tokens: 0,
1726 },
1727 ]
1728 }
1729
1730 #[test]
1735 fn emitter_task_id_is_absent_from_journal_rows_written_before_the_field()
1736 -> serde_json::Result<()> {
1737 let legacy = serde_json::json!({
1741 "type": "done",
1742 "thread_id": "t-legacy",
1743 "total_turns": 2,
1744 "total_usage": TokenUsage::default(),
1745 "duration_ms": 1000,
1746 });
1747 let event: AgentEvent = serde_json::from_value(legacy)?;
1748 assert_eq!(event.emitter_task_id(), None);
1749
1750 let json = serde_json::to_value(&event)?;
1753 assert!(
1754 json.get("emitter_task_id").is_none(),
1755 "unstamped events must omit the key: {json}"
1756 );
1757 Ok(())
1758 }
1759
1760 #[test]
1761 fn with_emitter_task_id_stamps_every_lifecycle_variant() -> serde_json::Result<()> {
1762 let thread = ThreadId::from_string("t");
1763 let usage = TokenUsage::default();
1764 let lifecycle = vec![
1765 AgentEvent::start(thread.clone(), 1),
1766 AgentEvent::TurnComplete {
1767 turn: 1,
1768 usage: usage.clone(),
1769 emitter_task_id: None,
1770 },
1771 AgentEvent::done(thread.clone(), 1, usage.clone(), Duration::from_secs(1)),
1772 AgentEvent::budget_exceeded(
1773 thread,
1774 1,
1775 usage.clone(),
1776 Duration::from_secs(1),
1777 None,
1778 BudgetLimitKind::TotalTokens,
1779 ),
1780 AgentEvent::error("boom", false),
1781 AgentEvent::cancelled(1, usage),
1782 ];
1783 for event in lifecycle {
1784 let label = format!("{event:?}");
1785 assert_eq!(event.emitter_task_id(), None, "{label}: starts unstamped");
1786
1787 let stamped = event.with_emitter_task_id("task-42");
1788 assert_eq!(stamped.emitter_task_id(), Some("task-42"), "{label}");
1789
1790 let json = serde_json::to_value(&stamped)?;
1791 assert_eq!(
1792 json.get("emitter_task_id")
1793 .and_then(serde_json::Value::as_str),
1794 Some("task-42"),
1795 "{label}: stamped events carry the key: {json}"
1796 );
1797 let restored: AgentEvent = serde_json::from_value(json)?;
1798 assert_eq!(restored.emitter_task_id(), Some("task-42"), "{label}");
1799 }
1800 Ok(())
1801 }
1802
1803 #[test]
1804 fn with_emitter_task_id_covers_content_but_not_tool_frames() -> serde_json::Result<()> {
1805 let text = AgentEvent::text("m", "hi").with_emitter_task_id("task-42");
1809 assert_eq!(text.emitter_task_id(), Some("task-42"));
1810
1811 let bare = AgentEvent::text("m", "hi");
1813 let json = serde_json::to_value(&bare)?;
1814 assert!(
1815 json.get("emitter_task_id").is_none(),
1816 "unattributed content must not grow the key: {json}"
1817 );
1818
1819 let tool = AgentEvent::tool_call_start(
1821 "t1",
1822 "grep",
1823 "Grep",
1824 serde_json::json!({}),
1825 ToolTier::Observe,
1826 )
1827 .with_emitter_task_id("task-42");
1828 assert_eq!(tool.emitter_task_id(), None);
1829 Ok(())
1830 }
1831
1832 #[test]
1833 fn every_variant_envelope_has_flat_keys_and_round_trips() -> serde_json::Result<()> {
1834 let seq = SequenceCounter::new();
1835 for event in sample_all_variants() {
1836 let label = format!("{event:?}");
1837 let envelope = AgentEventEnvelope::wrap(event, &seq);
1838 let json = serde_json::to_value(&envelope)?;
1839
1840 for key in ["event_id", "sequence", "timestamp", "type"] {
1842 assert!(
1843 json.get(key).is_some(),
1844 "{label}: missing flat key `{key}` in {json}"
1845 );
1846 }
1847 assert!(
1850 json.get("event").is_none(),
1851 "{label}: unexpected nested `event` key in {json}"
1852 );
1853
1854 let restored: AgentEventEnvelope = serde_json::from_value(json.clone())?;
1855 assert_eq!(
1856 serde_json::to_value(&restored)?,
1857 json,
1858 "{label}: envelope round-trip changed the wire form"
1859 );
1860 }
1861 Ok(())
1862 }
1863}