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, Debug, Serialize, Deserialize)]
99#[serde(tag = "type", rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum AgentEvent {
102 ThreadCreated {
104 thread_id: ThreadId,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 source_thread_id: Option<ThreadId>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 fork_after_committed_turns: Option<u32>,
111 },
112
113 Start {
115 thread_id: ThreadId,
116 turn: usize,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 emitter_task_id: Option<String>,
121 },
122
123 UserInput {
143 thread_id: ThreadId,
144 content: Vec<ContentBlock>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
154 emitter_task_id: Option<String>,
155 },
156
157 Thinking { message_id: String, text: String },
159
160 ThinkingDelta { message_id: String, delta: String },
162
163 TextDelta { message_id: String, delta: String },
165
166 Text { message_id: String, text: String },
168
169 ToolCallStart {
171 id: String,
172 name: String,
173 display_name: String,
174 input: serde_json::Value,
175 tier: ToolTier,
176 },
177
178 ToolCallEnd {
180 id: String,
181 name: String,
182 display_name: String,
183 result: ToolResult,
184 },
185
186 ToolProgress {
188 id: String,
190 name: String,
192 display_name: String,
194 stage: String,
196 message: String,
198 data: Option<serde_json::Value>,
200 },
201
202 ToolRequiresConfirmation {
205 id: String,
206 name: String,
207 display_name: String,
208 input: serde_json::Value,
209 description: String,
210 },
211
212 QuestionAsked {
215 task_id: String,
217 questions: Vec<crate::QuestionPayload>,
221 },
222
223 TurnComplete {
225 turn: usize,
226 usage: TokenUsage,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 emitter_task_id: Option<String>,
231 },
232
233 Done {
235 thread_id: ThreadId,
236 total_turns: usize,
237 total_usage: TokenUsage,
238 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
246 duration: Duration,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
252 estimated_cost_usd: Option<f64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 emitter_task_id: Option<String>,
257 },
258
259 BudgetExceeded {
266 thread_id: ThreadId,
267 total_turns: usize,
268 total_usage: TokenUsage,
269 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
274 duration: Duration,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
278 estimated_cost_usd: Option<f64>,
279 limit: BudgetLimitKind,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 emitter_task_id: Option<String>,
285 },
286
287 Error {
289 message: String,
290 recoverable: bool,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 reason: Option<TerminalReason>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 emitter_task_id: Option<String>,
298 },
299
300 AutoRetryStart {
306 attempt: u32,
313 max_attempts: u32,
321 delay_ms: u64,
323 error_message: String,
325 },
326
327 AutoRetryEnd {
331 attempt: u32,
335 success: bool,
337 final_error: Option<String>,
339 },
340
341 Refusal {
343 message_id: String,
344 text: Option<String>,
345 },
346
347 Cancelled {
363 turn: usize,
364 usage: TokenUsage,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 reason: Option<TerminalReason>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
372 emitter_task_id: Option<String>,
373 },
374
375 ContextCompacted {
377 original_count: usize,
379 new_count: usize,
381 original_tokens: usize,
383 new_tokens: usize,
385 },
386
387 SubagentProgress {
389 subagent_id: String,
391 subagent_name: String,
393 nickname: Option<String>,
395 child_thread_id: Option<ThreadId>,
397 child_root_task_id: Option<String>,
399 subagent_task_id: Option<String>,
401 max_turns: Option<u32>,
403 current_turn: Option<u32>,
405 model: Option<String>,
407 tool_name: String,
409 tool_context: String,
411 completed: bool,
413 success: bool,
415 tool_count: u32,
417 total_tokens: u64,
419 #[serde(default)]
421 input_tokens: u64,
422 #[serde(default)]
424 output_tokens: u64,
425 #[serde(default)]
427 cache_read_input_tokens: u64,
428 #[serde(default)]
430 cache_creation_input_tokens: u64,
431 },
432}
433
434impl AgentEvent {
435 #[must_use]
436 pub const fn thread_created(
437 thread_id: ThreadId,
438 source_thread_id: Option<ThreadId>,
439 fork_after_committed_turns: Option<u32>,
440 ) -> Self {
441 Self::ThreadCreated {
442 thread_id,
443 source_thread_id,
444 fork_after_committed_turns,
445 }
446 }
447
448 #[must_use]
449 pub const fn start(thread_id: ThreadId, turn: usize) -> Self {
450 Self::Start {
451 thread_id,
452 turn,
453 emitter_task_id: None,
454 }
455 }
456
457 #[must_use]
472 pub fn with_emitter_task_id(mut self, task_id: impl Into<String>) -> Self {
473 let task_id = task_id.into();
474 match &mut self {
475 Self::Start {
476 emitter_task_id, ..
477 }
478 | Self::UserInput {
479 emitter_task_id, ..
480 }
481 | Self::TurnComplete {
482 emitter_task_id, ..
483 }
484 | Self::Done {
485 emitter_task_id, ..
486 }
487 | Self::BudgetExceeded {
488 emitter_task_id, ..
489 }
490 | Self::Error {
491 emitter_task_id, ..
492 }
493 | Self::Cancelled {
494 emitter_task_id, ..
495 } => *emitter_task_id = Some(task_id),
496 _ => {}
497 }
498 self
499 }
500
501 #[must_use]
505 pub fn emitter_task_id(&self) -> Option<&str> {
506 match self {
507 Self::Start {
508 emitter_task_id, ..
509 }
510 | Self::UserInput {
511 emitter_task_id, ..
512 }
513 | Self::TurnComplete {
514 emitter_task_id, ..
515 }
516 | Self::Done {
517 emitter_task_id, ..
518 }
519 | Self::BudgetExceeded {
520 emitter_task_id, ..
521 }
522 | Self::Error {
523 emitter_task_id, ..
524 }
525 | Self::Cancelled {
526 emitter_task_id, ..
527 } => emitter_task_id.as_deref(),
528 _ => None,
529 }
530 }
531
532 #[must_use]
533 pub const fn user_input(thread_id: ThreadId, content: Vec<ContentBlock>) -> Self {
534 Self::UserInput {
535 thread_id,
536 content,
537 emitter_task_id: None,
538 }
539 }
540
541 #[must_use]
542 pub fn thinking(message_id: impl Into<String>, text: impl Into<String>) -> Self {
543 Self::Thinking {
544 message_id: message_id.into(),
545 text: text.into(),
546 }
547 }
548
549 #[must_use]
550 pub fn thinking_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
551 Self::ThinkingDelta {
552 message_id: message_id.into(),
553 delta: delta.into(),
554 }
555 }
556
557 #[must_use]
558 pub fn text_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
559 Self::TextDelta {
560 message_id: message_id.into(),
561 delta: delta.into(),
562 }
563 }
564
565 #[must_use]
566 pub fn text(message_id: impl Into<String>, text: impl Into<String>) -> Self {
567 Self::Text {
568 message_id: message_id.into(),
569 text: text.into(),
570 }
571 }
572
573 #[must_use]
574 pub fn tool_call_start(
575 id: impl Into<String>,
576 name: impl Into<String>,
577 display_name: impl Into<String>,
578 input: serde_json::Value,
579 tier: ToolTier,
580 ) -> Self {
581 Self::ToolCallStart {
582 id: id.into(),
583 name: name.into(),
584 display_name: display_name.into(),
585 input,
586 tier,
587 }
588 }
589
590 #[must_use]
591 pub fn tool_call_end(
592 id: impl Into<String>,
593 name: impl Into<String>,
594 display_name: impl Into<String>,
595 result: ToolResult,
596 ) -> Self {
597 Self::ToolCallEnd {
598 id: id.into(),
599 name: name.into(),
600 display_name: display_name.into(),
601 result,
602 }
603 }
604
605 #[must_use]
606 pub fn tool_progress(
607 id: impl Into<String>,
608 name: impl Into<String>,
609 display_name: impl Into<String>,
610 stage: impl Into<String>,
611 message: impl Into<String>,
612 data: Option<serde_json::Value>,
613 ) -> Self {
614 Self::ToolProgress {
615 id: id.into(),
616 name: name.into(),
617 display_name: display_name.into(),
618 stage: stage.into(),
619 message: message.into(),
620 data,
621 }
622 }
623
624 #[must_use]
625 pub fn tool_requires_confirmation(
626 id: impl Into<String>,
627 name: impl Into<String>,
628 display_name: impl Into<String>,
629 input: serde_json::Value,
630 description: impl Into<String>,
631 ) -> Self {
632 Self::ToolRequiresConfirmation {
633 id: id.into(),
634 name: name.into(),
635 display_name: display_name.into(),
636 input,
637 description: description.into(),
638 }
639 }
640
641 #[must_use]
644 pub fn question_asked(
645 task_id: impl Into<String>,
646 questions: Vec<crate::QuestionPayload>,
647 ) -> Self {
648 Self::QuestionAsked {
649 task_id: task_id.into(),
650 questions,
651 }
652 }
653
654 #[must_use]
655 pub const fn turn_complete(turn: usize, usage: TokenUsage) -> Self {
656 Self::TurnComplete {
657 turn,
658 usage,
659 emitter_task_id: None,
660 }
661 }
662
663 #[must_use]
664 pub const fn done(
665 thread_id: ThreadId,
666 total_turns: usize,
667 total_usage: TokenUsage,
668 duration: Duration,
669 ) -> Self {
670 Self::Done {
671 thread_id,
672 total_turns,
673 total_usage,
674 duration,
675 estimated_cost_usd: None,
676 emitter_task_id: None,
677 }
678 }
679
680 #[must_use]
681 pub const fn done_with_cost(
682 thread_id: ThreadId,
683 total_turns: usize,
684 total_usage: TokenUsage,
685 duration: Duration,
686 estimated_cost_usd: Option<f64>,
687 ) -> Self {
688 Self::Done {
689 thread_id,
690 total_turns,
691 total_usage,
692 duration,
693 estimated_cost_usd,
694 emitter_task_id: None,
695 }
696 }
697
698 #[must_use]
699 pub const fn budget_exceeded(
700 thread_id: ThreadId,
701 total_turns: usize,
702 total_usage: TokenUsage,
703 duration: Duration,
704 estimated_cost_usd: Option<f64>,
705 limit: BudgetLimitKind,
706 ) -> Self {
707 Self::BudgetExceeded {
708 thread_id,
709 total_turns,
710 total_usage,
711 duration,
712 estimated_cost_usd,
713 limit,
714 emitter_task_id: None,
715 }
716 }
717
718 #[must_use]
719 pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
720 Self::Error {
721 message: message.into(),
722 recoverable,
723 reason: None,
724 emitter_task_id: None,
725 }
726 }
727
728 #[must_use]
730 pub fn terminal_error(message: impl Into<String>, reason: TerminalReason) -> Self {
731 Self::Error {
732 message: message.into(),
733 recoverable: false,
734 reason: Some(reason),
735 emitter_task_id: None,
736 }
737 }
738
739 #[must_use]
740 pub fn refusal(message_id: impl Into<String>, text: Option<String>) -> Self {
741 Self::Refusal {
742 message_id: message_id.into(),
743 text,
744 }
745 }
746
747 #[must_use]
748 pub const fn cancelled(turn: usize, usage: TokenUsage) -> Self {
749 Self::cancelled_with_reason(turn, usage, TerminalReason::UserCancel)
750 }
751
752 #[must_use]
754 pub const fn cancelled_with_reason(
755 turn: usize,
756 usage: TokenUsage,
757 reason: TerminalReason,
758 ) -> Self {
759 Self::Cancelled {
760 turn,
761 usage,
762 reason: Some(reason),
763 emitter_task_id: None,
764 }
765 }
766
767 #[must_use]
768 pub const fn context_compacted(
769 original_count: usize,
770 new_count: usize,
771 original_tokens: usize,
772 new_tokens: usize,
773 ) -> Self {
774 Self::ContextCompacted {
775 original_count,
776 new_count,
777 original_tokens,
778 new_tokens,
779 }
780 }
781}
782
783#[derive(Clone, Debug)]
792pub struct SequenceCounter(Arc<AtomicU64>);
793
794impl SequenceCounter {
795 #[must_use]
797 pub fn new() -> Self {
798 Self(Arc::new(AtomicU64::new(0)))
799 }
800
801 #[must_use]
807 pub fn with_offset(start: u64) -> Self {
808 Self(Arc::new(AtomicU64::new(start)))
809 }
810
811 #[must_use]
813 pub fn next(&self) -> u64 {
814 self.0.fetch_add(1, Ordering::Relaxed)
815 }
816}
817
818impl Default for SequenceCounter {
819 fn default() -> Self {
820 Self::new()
821 }
822}
823
824#[derive(Clone, Debug, Serialize, Deserialize)]
832pub struct AgentEventEnvelope {
833 pub event_id: uuid::Uuid,
838 pub sequence: u64,
840 #[serde(with = "time::serde::rfc3339")]
842 pub timestamp: OffsetDateTime,
843 #[serde(flatten)]
845 pub event: AgentEvent,
846}
847
848impl AgentEventEnvelope {
849 #[must_use]
852 pub fn wrap(event: AgentEvent, seq: &SequenceCounter) -> Self {
853 Self {
854 event_id: uuid::Uuid::new_v4(),
855 sequence: seq.next(),
856 timestamp: OffsetDateTime::now_utc(),
857 event,
858 }
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865 use std::collections::HashSet;
866
867 #[test]
872 fn sequence_counter_starts_at_zero() {
873 let seq = SequenceCounter::new();
874 assert_eq!(seq.next(), 0);
875 }
876
877 #[test]
878 fn sequence_counter_increments_monotonically() {
879 let seq = SequenceCounter::new();
880 for expected in 0..100 {
881 assert_eq!(seq.next(), expected);
882 }
883 }
884
885 #[test]
886 fn sequence_counter_no_gaps() {
887 let seq = SequenceCounter::new();
888 let values: Vec<u64> = (0..50).map(|_| seq.next()).collect();
889 let expected: Vec<u64> = (0..50).collect();
890 assert_eq!(values, expected);
891 }
892
893 #[test]
894 fn sequence_counter_clones_share_state() {
895 let seq = SequenceCounter::new();
896 let clone = seq.clone();
897
898 assert_eq!(seq.next(), 0);
899 assert_eq!(clone.next(), 1);
900 assert_eq!(seq.next(), 2);
901 }
902
903 #[test]
904 fn sequence_counter_default_starts_at_zero() {
905 let seq = SequenceCounter::default();
906 assert_eq!(seq.next(), 0);
907 }
908
909 #[test]
910 fn sequence_counter_with_offset_starts_at_given_value() {
911 let seq = SequenceCounter::with_offset(42);
912 assert_eq!(seq.next(), 42);
913 assert_eq!(seq.next(), 43);
914 assert_eq!(seq.next(), 44);
915 }
916
917 #[test]
918 fn sequence_counter_with_offset_zero_same_as_new() {
919 let seq = SequenceCounter::with_offset(0);
920 assert_eq!(seq.next(), 0);
921 assert_eq!(seq.next(), 1);
922 }
923
924 #[tokio::test]
925 async fn sequence_counter_unique_across_concurrent_tasks() {
926 let seq = SequenceCounter::new();
927 let n = 1000;
928
929 let mut handles = Vec::new();
930 for _ in 0..n {
931 let seq_clone = seq.clone();
932 handles.push(tokio::spawn(async move { seq_clone.next() }));
933 }
934
935 let mut values = HashSet::new();
936 for handle in handles {
937 let val = handle.await.unwrap();
938 assert!(values.insert(val), "duplicate sequence number: {val}");
939 }
940
941 assert_eq!(values.len(), n);
942 for v in &values {
944 assert!(*v < n as u64);
945 }
946 }
947
948 fn sample_event() -> AgentEvent {
953 AgentEvent::text("msg_1", "hello")
954 }
955
956 #[test]
957 fn wrap_assigns_unique_event_ids() {
958 let seq = SequenceCounter::new();
959 let ids: HashSet<uuid::Uuid> = (0..100)
960 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq).event_id)
961 .collect();
962 assert_eq!(ids.len(), 100);
963 }
964
965 #[test]
966 fn wrap_event_id_is_valid_uuid_v4() {
967 let seq = SequenceCounter::new();
968 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
969 assert_eq!(envelope.event_id.get_version(), Some(uuid::Version::Random));
970 }
971
972 #[test]
973 fn wrap_assigns_incrementing_sequences() {
974 let seq = SequenceCounter::new();
975 let envelopes: Vec<AgentEventEnvelope> = (0..10)
976 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
977 .collect();
978
979 for (i, env) in envelopes.iter().enumerate() {
980 assert_eq!(env.sequence, i as u64);
981 }
982 }
983
984 #[test]
985 fn wrap_timestamps_are_non_decreasing() {
986 let seq = SequenceCounter::new();
987 let envelopes: Vec<AgentEventEnvelope> = (0..20)
988 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
989 .collect();
990
991 for pair in envelopes.windows(2) {
992 assert!(pair[1].timestamp >= pair[0].timestamp);
993 }
994 }
995
996 #[test]
997 fn wrap_preserves_inner_event() {
998 let seq = SequenceCounter::new();
999 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_42", "content"), &seq);
1000 match &envelope.event {
1001 AgentEvent::Text { message_id, text } => {
1002 assert_eq!(message_id, "msg_42");
1003 assert_eq!(text, "content");
1004 }
1005 other => panic!("expected Text, got {other:?}"),
1006 }
1007 }
1008
1009 #[test]
1010 fn separate_counters_produce_independent_sequences() {
1011 let seq_a = SequenceCounter::new();
1012 let seq_b = SequenceCounter::new();
1013
1014 let a0 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1015 let b0 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1016 let a1 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1017 let b1 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1018
1019 assert_eq!(a0.sequence, 0);
1021 assert_eq!(b0.sequence, 0);
1022 assert_eq!(a1.sequence, 1);
1023 assert_eq!(b1.sequence, 1);
1024
1025 let ids: HashSet<uuid::Uuid> = [&a0, &b0, &a1, &b1].iter().map(|e| e.event_id).collect();
1027 assert_eq!(ids.len(), 4);
1028 }
1029
1030 #[test]
1035 fn envelope_serializes_flat_json() {
1036 let seq = SequenceCounter::new();
1037 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hi"), &seq);
1038 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1039
1040 assert!(json.get("event_id").is_some());
1042 assert!(json.get("sequence").is_some());
1043 assert!(json.get("timestamp").is_some());
1044
1045 assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("text"));
1047 assert_eq!(
1048 json.get("message_id").and_then(|v| v.as_str()),
1049 Some("msg_1")
1050 );
1051 assert_eq!(json.get("text").and_then(|v| v.as_str()), Some("hi"));
1052
1053 assert!(json.get("event").is_none());
1055 }
1056
1057 #[test]
1058 fn envelope_event_id_does_not_collide_with_tool_id() {
1059 let seq = SequenceCounter::new();
1060 let envelope = AgentEventEnvelope::wrap(
1061 AgentEvent::tool_call_start(
1062 "tool_123",
1063 "bash",
1064 "Bash",
1065 serde_json::json!({}),
1066 ToolTier::Observe,
1067 ),
1068 &seq,
1069 );
1070 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1071
1072 let event_id = json.get("event_id").and_then(|v| v.as_str()).unwrap();
1074 let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap();
1075 assert_ne!(event_id, tool_id);
1076 assert_eq!(tool_id, "tool_123");
1077 }
1078
1079 #[test]
1080 fn envelope_roundtrip_serde() {
1081 let seq = SequenceCounter::new();
1082 let original = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hello"), &seq);
1083
1084 let json_str = serde_json::to_string(&original).expect("serialize");
1085 let restored: AgentEventEnvelope = serde_json::from_str(&json_str).expect("deserialize");
1086
1087 assert_eq!(restored.event_id, original.event_id);
1088 assert_eq!(restored.sequence, original.sequence);
1089 assert_eq!(restored.timestamp, original.timestamp);
1090 match &restored.event {
1091 AgentEvent::Text { message_id, text } => {
1092 assert_eq!(message_id, "msg_1");
1093 assert_eq!(text, "hello");
1094 }
1095 other => panic!("expected Text, got {other:?}"),
1096 }
1097 }
1098
1099 #[test]
1100 fn envelope_sequence_is_u64_in_json() {
1101 let seq = SequenceCounter::new();
1102 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1103 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1104
1105 assert!(json.get("sequence").unwrap().is_u64());
1106 assert_eq!(json.get("sequence").unwrap().as_u64(), Some(0));
1107 }
1108
1109 #[test]
1110 fn envelope_timestamp_is_rfc3339_string() {
1111 let seq = SequenceCounter::new();
1112 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1113 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1114
1115 let ts_str = json.get("timestamp").unwrap().as_str().unwrap();
1116 time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
1118 .expect("timestamp should be valid RFC 3339");
1119 }
1120
1121 #[test]
1122 fn done_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1123 let seq = SequenceCounter::new();
1124 let envelope = AgentEventEnvelope::wrap(
1125 AgentEvent::done(
1126 ThreadId::from_string("t"),
1127 3,
1128 TokenUsage::default(),
1129 Duration::from_millis(2500),
1130 ),
1131 &seq,
1132 );
1133 let json = serde_json::to_value(&envelope)?;
1134
1135 assert_eq!(
1138 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1139 Some(2500)
1140 );
1141 assert!(
1142 json.get("duration").is_none(),
1143 "old `duration` key must be gone: {json}"
1144 );
1145
1146 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1147 match restored.event {
1148 AgentEvent::Done { duration, .. } => {
1149 assert_eq!(duration, Duration::from_millis(2500));
1150 }
1151 other => panic!("expected Done, got {other:?}"),
1152 }
1153 Ok(())
1154 }
1155
1156 #[test]
1157 fn done_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1158 let legacy = serde_json::json!({
1163 "type": "done",
1164 "thread_id": "t-legacy",
1165 "total_turns": 3,
1166 "total_usage": TokenUsage::default(),
1167 "duration": { "secs": 2, "nanos": 500_000_000 },
1168 });
1169 let event: AgentEvent = serde_json::from_value(legacy)?;
1170 match event {
1171 AgentEvent::Done {
1172 duration,
1173 total_turns,
1174 ..
1175 } => {
1176 assert_eq!(duration, Duration::from_millis(2500));
1177 assert_eq!(total_turns, 3);
1178 }
1179 other => panic!("expected Done, got {other:?}"),
1180 }
1181
1182 let current = serde_json::json!({
1184 "type": "done",
1185 "thread_id": "t-current",
1186 "total_turns": 3,
1187 "total_usage": TokenUsage::default(),
1188 "duration_ms": 2500,
1189 });
1190 let event: AgentEvent = serde_json::from_value(current)?;
1191 let AgentEvent::Done { duration, .. } = event else {
1192 panic!("expected Done");
1193 };
1194 assert_eq!(duration, Duration::from_millis(2500));
1195
1196 let legacy_event: AgentEvent = serde_json::from_value(serde_json::json!({
1198 "type": "done",
1199 "thread_id": "t-roundtrip",
1200 "total_turns": 1,
1201 "total_usage": TokenUsage::default(),
1202 "duration": { "secs": 1, "nanos": 0 },
1203 }))?;
1204 let reserialized = serde_json::to_value(&legacy_event)?;
1205 assert_eq!(
1206 reserialized
1207 .get("duration_ms")
1208 .and_then(serde_json::Value::as_u64),
1209 Some(1000),
1210 "round-trips must write the millis form: {reserialized}"
1211 );
1212 assert!(reserialized.get("duration").is_none());
1213 Ok(())
1214 }
1215
1216 #[test]
1217 fn terminal_reason_round_trips_provider_kind_and_accepts_future_variants()
1218 -> serde_json::Result<()> {
1219 let provider = TerminalReason::ProviderError {
1220 kind: "rate_limited".to_owned(),
1221 };
1222 let encoded = serde_json::to_value(&provider)?;
1223 assert_eq!(
1224 encoded,
1225 serde_json::json!({
1226 "reason": "provider_error",
1227 "kind": "rate_limited",
1228 }),
1229 );
1230 let restored: TerminalReason = serde_json::from_value(encoded)?;
1231 assert_eq!(restored, provider);
1232
1233 let future: TerminalReason = serde_json::from_value(serde_json::json!({
1234 "reason": "provider_shutdown",
1235 "retryable": false,
1236 }))?;
1237 assert_eq!(future, TerminalReason::Unknown);
1238 Ok(())
1239 }
1240
1241 #[test]
1242 fn subagent_progress_deserializes_legacy_rows_without_usage_breakdown() -> serde_json::Result<()>
1243 {
1244 let legacy = serde_json::json!({
1245 "type": "subagent_progress",
1246 "subagent_id": "call-1",
1247 "subagent_name": "explore",
1248 "nickname": null,
1249 "child_thread_id": null,
1250 "child_root_task_id": null,
1251 "subagent_task_id": null,
1252 "max_turns": 3,
1253 "current_turn": 1,
1254 "model": "mock",
1255 "tool_name": "explore",
1256 "tool_context": "inspect",
1257 "completed": false,
1258 "success": false,
1259 "tool_count": 0,
1260 "total_tokens": 12,
1261 });
1262 let event: AgentEvent = serde_json::from_value(legacy)?;
1263 match event {
1264 AgentEvent::SubagentProgress {
1265 total_tokens,
1266 input_tokens,
1267 output_tokens,
1268 cache_read_input_tokens,
1269 cache_creation_input_tokens,
1270 ..
1271 } => {
1272 assert_eq!(total_tokens, 12);
1273 assert_eq!(input_tokens, 0);
1274 assert_eq!(output_tokens, 0);
1275 assert_eq!(cache_read_input_tokens, 0);
1276 assert_eq!(cache_creation_input_tokens, 0);
1277 }
1278 other => panic!("expected SubagentProgress, got {other:?}"),
1279 }
1280 Ok(())
1281 }
1282
1283 #[test]
1284 fn budget_exceeded_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1285 let legacy = serde_json::json!({
1288 "type": "budget_exceeded",
1289 "thread_id": "t-legacy",
1290 "total_turns": 2,
1291 "total_usage": TokenUsage::default(),
1292 "duration": { "secs": 1, "nanos": 250_000_000 },
1293 "limit": "total_tokens",
1294 });
1295 let event: AgentEvent = serde_json::from_value(legacy)?;
1296 let AgentEvent::BudgetExceeded { duration, .. } = event else {
1297 panic!("expected BudgetExceeded");
1298 };
1299 assert_eq!(duration, Duration::from_millis(1250));
1300 Ok(())
1301 }
1302
1303 #[test]
1304 fn budget_exceeded_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1305 let seq = SequenceCounter::new();
1306 let envelope = AgentEventEnvelope::wrap(
1307 AgentEvent::budget_exceeded(
1308 ThreadId::from_string("t"),
1309 2,
1310 TokenUsage::default(),
1311 Duration::from_millis(1200),
1312 Some(0.5),
1313 BudgetLimitKind::TotalTokens,
1314 ),
1315 &seq,
1316 );
1317 let json = serde_json::to_value(&envelope)?;
1318
1319 assert_eq!(
1321 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1322 Some(1200)
1323 );
1324 assert!(
1325 json.get("duration").is_none(),
1326 "no nested `duration` key expected: {json}"
1327 );
1328
1329 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1330 match restored.event {
1331 AgentEvent::BudgetExceeded { duration, .. } => {
1332 assert_eq!(duration, Duration::from_millis(1200));
1333 }
1334 other => panic!("expected BudgetExceeded, got {other:?}"),
1335 }
1336 Ok(())
1337 }
1338
1339 fn sample_all_variants() -> Vec<AgentEvent> {
1346 let thread = ThreadId::from_string("thread-1");
1347 let usage = TokenUsage::default();
1348 let mut events = session_open_events(&thread);
1349 events.extend(streamed_content_events());
1350 events.extend(tool_call_events());
1351 events.extend(turn_completion_events(&thread, &usage));
1352 events.extend(failure_and_retry_events());
1353 events.extend(auxiliary_events(&usage));
1354 events
1355 }
1356
1357 fn session_open_events(thread: &ThreadId) -> Vec<AgentEvent> {
1359 vec![
1360 AgentEvent::ThreadCreated {
1361 thread_id: thread.clone(),
1362 source_thread_id: None,
1363 fork_after_committed_turns: None,
1364 },
1365 AgentEvent::Start {
1366 thread_id: thread.clone(),
1367 turn: 1,
1368 emitter_task_id: Some("task-start".into()),
1369 },
1370 AgentEvent::UserInput {
1371 thread_id: thread.clone(),
1372 content: vec![ContentBlock::Text { text: "hi".into() }],
1373 emitter_task_id: None,
1374 },
1375 ]
1376 }
1377
1378 fn streamed_content_events() -> Vec<AgentEvent> {
1381 vec![
1382 AgentEvent::Thinking {
1383 message_id: "m".into(),
1384 text: "t".into(),
1385 },
1386 AgentEvent::ThinkingDelta {
1387 message_id: "m".into(),
1388 delta: "d".into(),
1389 },
1390 AgentEvent::TextDelta {
1391 message_id: "m".into(),
1392 delta: "d".into(),
1393 },
1394 AgentEvent::Text {
1395 message_id: "m".into(),
1396 text: "t".into(),
1397 },
1398 ]
1399 }
1400
1401 fn tool_call_events() -> Vec<AgentEvent> {
1403 vec![
1404 AgentEvent::ToolCallStart {
1405 id: "id".into(),
1406 name: "n".into(),
1407 display_name: "N".into(),
1408 input: serde_json::json!({}),
1409 tier: ToolTier::Observe,
1410 },
1411 AgentEvent::ToolCallEnd {
1412 id: "id".into(),
1413 name: "n".into(),
1414 display_name: "N".into(),
1415 result: ToolResult::success("ok"),
1416 },
1417 AgentEvent::ToolProgress {
1418 id: "id".into(),
1419 name: "n".into(),
1420 display_name: "N".into(),
1421 stage: "s".into(),
1422 message: "m".into(),
1423 data: None,
1424 },
1425 AgentEvent::ToolRequiresConfirmation {
1426 id: "id".into(),
1427 name: "n".into(),
1428 display_name: "N".into(),
1429 input: serde_json::json!({}),
1430 description: "d".into(),
1431 },
1432 ]
1433 }
1434
1435 fn turn_completion_events(thread: &ThreadId, usage: &TokenUsage) -> Vec<AgentEvent> {
1437 vec![
1438 AgentEvent::TurnComplete {
1439 turn: 1,
1440 usage: usage.clone(),
1441 emitter_task_id: Some("task-turn-complete".into()),
1442 },
1443 AgentEvent::Done {
1444 thread_id: thread.clone(),
1445 total_turns: 2,
1446 total_usage: usage.clone(),
1447 duration: Duration::from_millis(1500),
1448 estimated_cost_usd: Some(0.0123),
1449 emitter_task_id: Some("task-done".into()),
1450 },
1451 ]
1452 }
1453
1454 fn failure_and_retry_events() -> Vec<AgentEvent> {
1456 vec![
1457 AgentEvent::Error {
1458 message: "e".into(),
1459 recoverable: true,
1460 reason: None,
1461 emitter_task_id: Some("task-error".into()),
1462 },
1463 AgentEvent::AutoRetryStart {
1464 attempt: 1,
1465 max_attempts: 5,
1466 delay_ms: 100,
1467 error_message: "rate limited".into(),
1468 },
1469 AgentEvent::AutoRetryEnd {
1470 attempt: 1,
1471 success: true,
1472 final_error: None,
1473 },
1474 ]
1475 }
1476
1477 fn auxiliary_events(usage: &TokenUsage) -> Vec<AgentEvent> {
1480 vec![
1481 AgentEvent::Refusal {
1482 message_id: "m".into(),
1483 text: Some("no".into()),
1484 },
1485 AgentEvent::Cancelled {
1486 turn: 1,
1487 usage: usage.clone(),
1488 reason: Some(TerminalReason::UserCancel),
1489 emitter_task_id: Some("task-cancelled".into()),
1490 },
1491 AgentEvent::BudgetExceeded {
1492 thread_id: ThreadId::from_string("thread-1"),
1493 total_turns: 3,
1494 total_usage: usage.clone(),
1495 duration: Duration::from_millis(750),
1496 estimated_cost_usd: Some(0.5),
1497 limit: BudgetLimitKind::CostUsd,
1498 emitter_task_id: Some("task-budget".into()),
1499 },
1500 AgentEvent::ContextCompacted {
1501 original_count: 10,
1502 new_count: 5,
1503 original_tokens: 100,
1504 new_tokens: 50,
1505 },
1506 AgentEvent::SubagentProgress {
1507 subagent_id: "s".into(),
1508 subagent_name: "explore".into(),
1509 nickname: None,
1510 child_thread_id: None,
1511 child_root_task_id: None,
1512 subagent_task_id: None,
1513 max_turns: None,
1514 current_turn: None,
1515 model: None,
1516 tool_name: "t".into(),
1517 tool_context: "c".into(),
1518 completed: false,
1519 success: false,
1520 tool_count: 0,
1521 total_tokens: 0,
1522 input_tokens: 0,
1523 output_tokens: 0,
1524 cache_read_input_tokens: 0,
1525 cache_creation_input_tokens: 0,
1526 },
1527 ]
1528 }
1529
1530 #[test]
1535 fn emitter_task_id_is_absent_from_journal_rows_written_before_the_field()
1536 -> serde_json::Result<()> {
1537 let legacy = serde_json::json!({
1541 "type": "done",
1542 "thread_id": "t-legacy",
1543 "total_turns": 2,
1544 "total_usage": TokenUsage::default(),
1545 "duration_ms": 1000,
1546 });
1547 let event: AgentEvent = serde_json::from_value(legacy)?;
1548 assert_eq!(event.emitter_task_id(), None);
1549
1550 let json = serde_json::to_value(&event)?;
1553 assert!(
1554 json.get("emitter_task_id").is_none(),
1555 "unstamped events must omit the key: {json}"
1556 );
1557 Ok(())
1558 }
1559
1560 #[test]
1561 fn with_emitter_task_id_stamps_every_lifecycle_variant() -> serde_json::Result<()> {
1562 let thread = ThreadId::from_string("t");
1563 let usage = TokenUsage::default();
1564 let lifecycle = vec![
1565 AgentEvent::start(thread.clone(), 1),
1566 AgentEvent::TurnComplete {
1567 turn: 1,
1568 usage: usage.clone(),
1569 emitter_task_id: None,
1570 },
1571 AgentEvent::done(thread.clone(), 1, usage.clone(), Duration::from_secs(1)),
1572 AgentEvent::budget_exceeded(
1573 thread,
1574 1,
1575 usage.clone(),
1576 Duration::from_secs(1),
1577 None,
1578 BudgetLimitKind::TotalTokens,
1579 ),
1580 AgentEvent::error("boom", false),
1581 AgentEvent::cancelled(1, usage),
1582 ];
1583 for event in lifecycle {
1584 let label = format!("{event:?}");
1585 assert_eq!(event.emitter_task_id(), None, "{label}: starts unstamped");
1586
1587 let stamped = event.with_emitter_task_id("task-42");
1588 assert_eq!(stamped.emitter_task_id(), Some("task-42"), "{label}");
1589
1590 let json = serde_json::to_value(&stamped)?;
1591 assert_eq!(
1592 json.get("emitter_task_id")
1593 .and_then(serde_json::Value::as_str),
1594 Some("task-42"),
1595 "{label}: stamped events carry the key: {json}"
1596 );
1597 let restored: AgentEvent = serde_json::from_value(json)?;
1598 assert_eq!(restored.emitter_task_id(), Some("task-42"), "{label}");
1599 }
1600 Ok(())
1601 }
1602
1603 #[test]
1604 fn with_emitter_task_id_leaves_non_lifecycle_variants_untouched() -> serde_json::Result<()> {
1605 let text = AgentEvent::text("m", "hi").with_emitter_task_id("task-42");
1609 assert_eq!(text.emitter_task_id(), None);
1610
1611 let json = serde_json::to_value(&text)?;
1612 assert!(
1613 json.get("emitter_task_id").is_none(),
1614 "non-lifecycle variants must not grow the key: {json}"
1615 );
1616 Ok(())
1617 }
1618
1619 #[test]
1620 fn every_variant_envelope_has_flat_keys_and_round_trips() -> serde_json::Result<()> {
1621 let seq = SequenceCounter::new();
1622 for event in sample_all_variants() {
1623 let label = format!("{event:?}");
1624 let envelope = AgentEventEnvelope::wrap(event, &seq);
1625 let json = serde_json::to_value(&envelope)?;
1626
1627 for key in ["event_id", "sequence", "timestamp", "type"] {
1629 assert!(
1630 json.get(key).is_some(),
1631 "{label}: missing flat key `{key}` in {json}"
1632 );
1633 }
1634 assert!(
1637 json.get("event").is_none(),
1638 "{label}: unexpected nested `event` key in {json}"
1639 );
1640
1641 let restored: AgentEventEnvelope = serde_json::from_value(json.clone())?;
1642 assert_eq!(
1643 serde_json::to_value(&restored)?,
1644 json,
1645 "{label}: envelope round-trip changed the wire form"
1646 );
1647 }
1648 Ok(())
1649 }
1650}