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, Serialize, Deserialize)]
69#[serde(tag = "type", rename_all = "snake_case")]
70#[non_exhaustive]
71pub enum AgentEvent {
72 Start {
74 thread_id: ThreadId,
75 turn: usize,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 emitter_task_id: Option<String>,
80 },
81
82 UserInput {
102 thread_id: ThreadId,
103 content: Vec<ContentBlock>,
110 },
111
112 Thinking { message_id: String, text: String },
114
115 ThinkingDelta { message_id: String, delta: String },
117
118 TextDelta { message_id: String, delta: String },
120
121 Text { message_id: String, text: String },
123
124 ToolCallStart {
126 id: String,
127 name: String,
128 display_name: String,
129 input: serde_json::Value,
130 tier: ToolTier,
131 },
132
133 ToolCallEnd {
135 id: String,
136 name: String,
137 display_name: String,
138 result: ToolResult,
139 },
140
141 ToolProgress {
143 id: String,
145 name: String,
147 display_name: String,
149 stage: String,
151 message: String,
153 data: Option<serde_json::Value>,
155 },
156
157 ToolRequiresConfirmation {
160 id: String,
161 name: String,
162 display_name: String,
163 input: serde_json::Value,
164 description: String,
165 },
166
167 TurnComplete {
169 turn: usize,
170 usage: TokenUsage,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 emitter_task_id: Option<String>,
175 },
176
177 Done {
179 thread_id: ThreadId,
180 total_turns: usize,
181 total_usage: TokenUsage,
182 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
190 duration: Duration,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
196 estimated_cost_usd: Option<f64>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 emitter_task_id: Option<String>,
201 },
202
203 BudgetExceeded {
210 thread_id: ThreadId,
211 total_turns: usize,
212 total_usage: TokenUsage,
213 #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
218 duration: Duration,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
222 estimated_cost_usd: Option<f64>,
223 limit: BudgetLimitKind,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
228 emitter_task_id: Option<String>,
229 },
230
231 Error {
233 message: String,
234 recoverable: bool,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 emitter_task_id: Option<String>,
239 },
240
241 AutoRetryStart {
247 attempt: u32,
254 max_attempts: u32,
262 delay_ms: u64,
264 error_message: String,
266 },
267
268 AutoRetryEnd {
272 attempt: u32,
276 success: bool,
278 final_error: Option<String>,
280 },
281
282 Refusal {
284 message_id: String,
285 text: Option<String>,
286 },
287
288 Cancelled {
304 turn: usize,
305 usage: TokenUsage,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
310 emitter_task_id: Option<String>,
311 },
312
313 ContextCompacted {
315 original_count: usize,
317 new_count: usize,
319 original_tokens: usize,
321 new_tokens: usize,
323 },
324
325 SubagentProgress {
327 subagent_id: String,
329 subagent_name: String,
331 nickname: Option<String>,
333 child_thread_id: Option<ThreadId>,
335 child_root_task_id: Option<String>,
337 subagent_task_id: Option<String>,
339 max_turns: Option<u32>,
341 current_turn: Option<u32>,
343 model: Option<String>,
345 tool_name: String,
347 tool_context: String,
349 completed: bool,
351 success: bool,
353 tool_count: u32,
355 total_tokens: u64,
357 },
358}
359
360impl AgentEvent {
361 #[must_use]
362 pub const fn start(thread_id: ThreadId, turn: usize) -> Self {
363 Self::Start {
364 thread_id,
365 turn,
366 emitter_task_id: None,
367 }
368 }
369
370 #[must_use]
385 pub fn with_emitter_task_id(mut self, task_id: impl Into<String>) -> Self {
386 let task_id = task_id.into();
387 match &mut self {
388 Self::Start {
389 emitter_task_id, ..
390 }
391 | Self::TurnComplete {
392 emitter_task_id, ..
393 }
394 | Self::Done {
395 emitter_task_id, ..
396 }
397 | Self::BudgetExceeded {
398 emitter_task_id, ..
399 }
400 | Self::Error {
401 emitter_task_id, ..
402 }
403 | Self::Cancelled {
404 emitter_task_id, ..
405 } => *emitter_task_id = Some(task_id),
406 _ => {}
407 }
408 self
409 }
410
411 #[must_use]
415 pub fn emitter_task_id(&self) -> Option<&str> {
416 match self {
417 Self::Start {
418 emitter_task_id, ..
419 }
420 | Self::TurnComplete {
421 emitter_task_id, ..
422 }
423 | Self::Done {
424 emitter_task_id, ..
425 }
426 | Self::BudgetExceeded {
427 emitter_task_id, ..
428 }
429 | Self::Error {
430 emitter_task_id, ..
431 }
432 | Self::Cancelled {
433 emitter_task_id, ..
434 } => emitter_task_id.as_deref(),
435 _ => None,
436 }
437 }
438
439 #[must_use]
440 pub const fn user_input(thread_id: ThreadId, content: Vec<ContentBlock>) -> Self {
441 Self::UserInput { thread_id, content }
442 }
443
444 #[must_use]
445 pub fn thinking(message_id: impl Into<String>, text: impl Into<String>) -> Self {
446 Self::Thinking {
447 message_id: message_id.into(),
448 text: text.into(),
449 }
450 }
451
452 #[must_use]
453 pub fn thinking_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
454 Self::ThinkingDelta {
455 message_id: message_id.into(),
456 delta: delta.into(),
457 }
458 }
459
460 #[must_use]
461 pub fn text_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
462 Self::TextDelta {
463 message_id: message_id.into(),
464 delta: delta.into(),
465 }
466 }
467
468 #[must_use]
469 pub fn text(message_id: impl Into<String>, text: impl Into<String>) -> Self {
470 Self::Text {
471 message_id: message_id.into(),
472 text: text.into(),
473 }
474 }
475
476 #[must_use]
477 pub fn tool_call_start(
478 id: impl Into<String>,
479 name: impl Into<String>,
480 display_name: impl Into<String>,
481 input: serde_json::Value,
482 tier: ToolTier,
483 ) -> Self {
484 Self::ToolCallStart {
485 id: id.into(),
486 name: name.into(),
487 display_name: display_name.into(),
488 input,
489 tier,
490 }
491 }
492
493 #[must_use]
494 pub fn tool_call_end(
495 id: impl Into<String>,
496 name: impl Into<String>,
497 display_name: impl Into<String>,
498 result: ToolResult,
499 ) -> Self {
500 Self::ToolCallEnd {
501 id: id.into(),
502 name: name.into(),
503 display_name: display_name.into(),
504 result,
505 }
506 }
507
508 #[must_use]
509 pub fn tool_progress(
510 id: impl Into<String>,
511 name: impl Into<String>,
512 display_name: impl Into<String>,
513 stage: impl Into<String>,
514 message: impl Into<String>,
515 data: Option<serde_json::Value>,
516 ) -> Self {
517 Self::ToolProgress {
518 id: id.into(),
519 name: name.into(),
520 display_name: display_name.into(),
521 stage: stage.into(),
522 message: message.into(),
523 data,
524 }
525 }
526
527 #[must_use]
528 pub fn tool_requires_confirmation(
529 id: impl Into<String>,
530 name: impl Into<String>,
531 display_name: impl Into<String>,
532 input: serde_json::Value,
533 description: impl Into<String>,
534 ) -> Self {
535 Self::ToolRequiresConfirmation {
536 id: id.into(),
537 name: name.into(),
538 display_name: display_name.into(),
539 input,
540 description: description.into(),
541 }
542 }
543
544 #[must_use]
545 pub const fn turn_complete(turn: usize, usage: TokenUsage) -> Self {
546 Self::TurnComplete {
547 turn,
548 usage,
549 emitter_task_id: None,
550 }
551 }
552
553 #[must_use]
554 pub const fn done(
555 thread_id: ThreadId,
556 total_turns: usize,
557 total_usage: TokenUsage,
558 duration: Duration,
559 ) -> Self {
560 Self::Done {
561 thread_id,
562 total_turns,
563 total_usage,
564 duration,
565 estimated_cost_usd: None,
566 emitter_task_id: None,
567 }
568 }
569
570 #[must_use]
571 pub const fn done_with_cost(
572 thread_id: ThreadId,
573 total_turns: usize,
574 total_usage: TokenUsage,
575 duration: Duration,
576 estimated_cost_usd: Option<f64>,
577 ) -> Self {
578 Self::Done {
579 thread_id,
580 total_turns,
581 total_usage,
582 duration,
583 estimated_cost_usd,
584 emitter_task_id: None,
585 }
586 }
587
588 #[must_use]
589 pub const fn budget_exceeded(
590 thread_id: ThreadId,
591 total_turns: usize,
592 total_usage: TokenUsage,
593 duration: Duration,
594 estimated_cost_usd: Option<f64>,
595 limit: BudgetLimitKind,
596 ) -> Self {
597 Self::BudgetExceeded {
598 thread_id,
599 total_turns,
600 total_usage,
601 duration,
602 estimated_cost_usd,
603 limit,
604 emitter_task_id: None,
605 }
606 }
607
608 #[must_use]
609 pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
610 Self::Error {
611 message: message.into(),
612 recoverable,
613 emitter_task_id: None,
614 }
615 }
616
617 #[must_use]
618 pub fn refusal(message_id: impl Into<String>, text: Option<String>) -> Self {
619 Self::Refusal {
620 message_id: message_id.into(),
621 text,
622 }
623 }
624
625 #[must_use]
626 pub const fn cancelled(turn: usize, usage: TokenUsage) -> Self {
627 Self::Cancelled {
628 turn,
629 usage,
630 emitter_task_id: None,
631 }
632 }
633
634 #[must_use]
635 pub const fn context_compacted(
636 original_count: usize,
637 new_count: usize,
638 original_tokens: usize,
639 new_tokens: usize,
640 ) -> Self {
641 Self::ContextCompacted {
642 original_count,
643 new_count,
644 original_tokens,
645 new_tokens,
646 }
647 }
648}
649
650#[derive(Clone, Debug)]
659pub struct SequenceCounter(Arc<AtomicU64>);
660
661impl SequenceCounter {
662 #[must_use]
664 pub fn new() -> Self {
665 Self(Arc::new(AtomicU64::new(0)))
666 }
667
668 #[must_use]
674 pub fn with_offset(start: u64) -> Self {
675 Self(Arc::new(AtomicU64::new(start)))
676 }
677
678 #[must_use]
680 pub fn next(&self) -> u64 {
681 self.0.fetch_add(1, Ordering::Relaxed)
682 }
683}
684
685impl Default for SequenceCounter {
686 fn default() -> Self {
687 Self::new()
688 }
689}
690
691#[derive(Clone, Debug, Serialize, Deserialize)]
699pub struct AgentEventEnvelope {
700 pub event_id: uuid::Uuid,
705 pub sequence: u64,
707 #[serde(with = "time::serde::rfc3339")]
709 pub timestamp: OffsetDateTime,
710 #[serde(flatten)]
712 pub event: AgentEvent,
713}
714
715impl AgentEventEnvelope {
716 #[must_use]
719 pub fn wrap(event: AgentEvent, seq: &SequenceCounter) -> Self {
720 Self {
721 event_id: uuid::Uuid::new_v4(),
722 sequence: seq.next(),
723 timestamp: OffsetDateTime::now_utc(),
724 event,
725 }
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use std::collections::HashSet;
733
734 #[test]
739 fn sequence_counter_starts_at_zero() {
740 let seq = SequenceCounter::new();
741 assert_eq!(seq.next(), 0);
742 }
743
744 #[test]
745 fn sequence_counter_increments_monotonically() {
746 let seq = SequenceCounter::new();
747 for expected in 0..100 {
748 assert_eq!(seq.next(), expected);
749 }
750 }
751
752 #[test]
753 fn sequence_counter_no_gaps() {
754 let seq = SequenceCounter::new();
755 let values: Vec<u64> = (0..50).map(|_| seq.next()).collect();
756 let expected: Vec<u64> = (0..50).collect();
757 assert_eq!(values, expected);
758 }
759
760 #[test]
761 fn sequence_counter_clones_share_state() {
762 let seq = SequenceCounter::new();
763 let clone = seq.clone();
764
765 assert_eq!(seq.next(), 0);
766 assert_eq!(clone.next(), 1);
767 assert_eq!(seq.next(), 2);
768 }
769
770 #[test]
771 fn sequence_counter_default_starts_at_zero() {
772 let seq = SequenceCounter::default();
773 assert_eq!(seq.next(), 0);
774 }
775
776 #[test]
777 fn sequence_counter_with_offset_starts_at_given_value() {
778 let seq = SequenceCounter::with_offset(42);
779 assert_eq!(seq.next(), 42);
780 assert_eq!(seq.next(), 43);
781 assert_eq!(seq.next(), 44);
782 }
783
784 #[test]
785 fn sequence_counter_with_offset_zero_same_as_new() {
786 let seq = SequenceCounter::with_offset(0);
787 assert_eq!(seq.next(), 0);
788 assert_eq!(seq.next(), 1);
789 }
790
791 #[tokio::test]
792 async fn sequence_counter_unique_across_concurrent_tasks() {
793 let seq = SequenceCounter::new();
794 let n = 1000;
795
796 let mut handles = Vec::new();
797 for _ in 0..n {
798 let seq_clone = seq.clone();
799 handles.push(tokio::spawn(async move { seq_clone.next() }));
800 }
801
802 let mut values = HashSet::new();
803 for handle in handles {
804 let val = handle.await.unwrap();
805 assert!(values.insert(val), "duplicate sequence number: {val}");
806 }
807
808 assert_eq!(values.len(), n);
809 for v in &values {
811 assert!(*v < n as u64);
812 }
813 }
814
815 fn sample_event() -> AgentEvent {
820 AgentEvent::text("msg_1", "hello")
821 }
822
823 #[test]
824 fn wrap_assigns_unique_event_ids() {
825 let seq = SequenceCounter::new();
826 let ids: HashSet<uuid::Uuid> = (0..100)
827 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq).event_id)
828 .collect();
829 assert_eq!(ids.len(), 100);
830 }
831
832 #[test]
833 fn wrap_event_id_is_valid_uuid_v4() {
834 let seq = SequenceCounter::new();
835 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
836 assert_eq!(envelope.event_id.get_version(), Some(uuid::Version::Random));
837 }
838
839 #[test]
840 fn wrap_assigns_incrementing_sequences() {
841 let seq = SequenceCounter::new();
842 let envelopes: Vec<AgentEventEnvelope> = (0..10)
843 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
844 .collect();
845
846 for (i, env) in envelopes.iter().enumerate() {
847 assert_eq!(env.sequence, i as u64);
848 }
849 }
850
851 #[test]
852 fn wrap_timestamps_are_non_decreasing() {
853 let seq = SequenceCounter::new();
854 let envelopes: Vec<AgentEventEnvelope> = (0..20)
855 .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
856 .collect();
857
858 for pair in envelopes.windows(2) {
859 assert!(pair[1].timestamp >= pair[0].timestamp);
860 }
861 }
862
863 #[test]
864 fn wrap_preserves_inner_event() {
865 let seq = SequenceCounter::new();
866 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_42", "content"), &seq);
867 match &envelope.event {
868 AgentEvent::Text { message_id, text } => {
869 assert_eq!(message_id, "msg_42");
870 assert_eq!(text, "content");
871 }
872 other => panic!("expected Text, got {other:?}"),
873 }
874 }
875
876 #[test]
877 fn separate_counters_produce_independent_sequences() {
878 let seq_a = SequenceCounter::new();
879 let seq_b = SequenceCounter::new();
880
881 let a0 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
882 let b0 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
883 let a1 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
884 let b1 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
885
886 assert_eq!(a0.sequence, 0);
888 assert_eq!(b0.sequence, 0);
889 assert_eq!(a1.sequence, 1);
890 assert_eq!(b1.sequence, 1);
891
892 let ids: HashSet<uuid::Uuid> = [&a0, &b0, &a1, &b1].iter().map(|e| e.event_id).collect();
894 assert_eq!(ids.len(), 4);
895 }
896
897 #[test]
902 fn envelope_serializes_flat_json() {
903 let seq = SequenceCounter::new();
904 let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hi"), &seq);
905 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
906
907 assert!(json.get("event_id").is_some());
909 assert!(json.get("sequence").is_some());
910 assert!(json.get("timestamp").is_some());
911
912 assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("text"));
914 assert_eq!(
915 json.get("message_id").and_then(|v| v.as_str()),
916 Some("msg_1")
917 );
918 assert_eq!(json.get("text").and_then(|v| v.as_str()), Some("hi"));
919
920 assert!(json.get("event").is_none());
922 }
923
924 #[test]
925 fn envelope_event_id_does_not_collide_with_tool_id() {
926 let seq = SequenceCounter::new();
927 let envelope = AgentEventEnvelope::wrap(
928 AgentEvent::tool_call_start(
929 "tool_123",
930 "bash",
931 "Bash",
932 serde_json::json!({}),
933 ToolTier::Observe,
934 ),
935 &seq,
936 );
937 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
938
939 let event_id = json.get("event_id").and_then(|v| v.as_str()).unwrap();
941 let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap();
942 assert_ne!(event_id, tool_id);
943 assert_eq!(tool_id, "tool_123");
944 }
945
946 #[test]
947 fn envelope_roundtrip_serde() {
948 let seq = SequenceCounter::new();
949 let original = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hello"), &seq);
950
951 let json_str = serde_json::to_string(&original).expect("serialize");
952 let restored: AgentEventEnvelope = serde_json::from_str(&json_str).expect("deserialize");
953
954 assert_eq!(restored.event_id, original.event_id);
955 assert_eq!(restored.sequence, original.sequence);
956 assert_eq!(restored.timestamp, original.timestamp);
957 match &restored.event {
958 AgentEvent::Text { message_id, text } => {
959 assert_eq!(message_id, "msg_1");
960 assert_eq!(text, "hello");
961 }
962 other => panic!("expected Text, got {other:?}"),
963 }
964 }
965
966 #[test]
967 fn envelope_sequence_is_u64_in_json() {
968 let seq = SequenceCounter::new();
969 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
970 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
971
972 assert!(json.get("sequence").unwrap().is_u64());
973 assert_eq!(json.get("sequence").unwrap().as_u64(), Some(0));
974 }
975
976 #[test]
977 fn envelope_timestamp_is_rfc3339_string() {
978 let seq = SequenceCounter::new();
979 let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
980 let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
981
982 let ts_str = json.get("timestamp").unwrap().as_str().unwrap();
983 time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
985 .expect("timestamp should be valid RFC 3339");
986 }
987
988 #[test]
989 fn done_event_serializes_duration_as_millis() -> serde_json::Result<()> {
990 let seq = SequenceCounter::new();
991 let envelope = AgentEventEnvelope::wrap(
992 AgentEvent::done(
993 ThreadId::from_string("t"),
994 3,
995 TokenUsage::default(),
996 Duration::from_millis(2500),
997 ),
998 &seq,
999 );
1000 let json = serde_json::to_value(&envelope)?;
1001
1002 assert_eq!(
1005 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1006 Some(2500)
1007 );
1008 assert!(
1009 json.get("duration").is_none(),
1010 "old `duration` key must be gone: {json}"
1011 );
1012
1013 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1014 match restored.event {
1015 AgentEvent::Done { duration, .. } => {
1016 assert_eq!(duration, Duration::from_millis(2500));
1017 }
1018 other => panic!("expected Done, got {other:?}"),
1019 }
1020 Ok(())
1021 }
1022
1023 #[test]
1024 fn done_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1025 let legacy = serde_json::json!({
1030 "type": "done",
1031 "thread_id": "t-legacy",
1032 "total_turns": 3,
1033 "total_usage": TokenUsage::default(),
1034 "duration": { "secs": 2, "nanos": 500_000_000 },
1035 });
1036 let event: AgentEvent = serde_json::from_value(legacy)?;
1037 match event {
1038 AgentEvent::Done {
1039 duration,
1040 total_turns,
1041 ..
1042 } => {
1043 assert_eq!(duration, Duration::from_millis(2500));
1044 assert_eq!(total_turns, 3);
1045 }
1046 other => panic!("expected Done, got {other:?}"),
1047 }
1048
1049 let current = serde_json::json!({
1051 "type": "done",
1052 "thread_id": "t-current",
1053 "total_turns": 3,
1054 "total_usage": TokenUsage::default(),
1055 "duration_ms": 2500,
1056 });
1057 let event: AgentEvent = serde_json::from_value(current)?;
1058 let AgentEvent::Done { duration, .. } = event else {
1059 panic!("expected Done");
1060 };
1061 assert_eq!(duration, Duration::from_millis(2500));
1062
1063 let legacy_event: AgentEvent = serde_json::from_value(serde_json::json!({
1065 "type": "done",
1066 "thread_id": "t-roundtrip",
1067 "total_turns": 1,
1068 "total_usage": TokenUsage::default(),
1069 "duration": { "secs": 1, "nanos": 0 },
1070 }))?;
1071 let reserialized = serde_json::to_value(&legacy_event)?;
1072 assert_eq!(
1073 reserialized
1074 .get("duration_ms")
1075 .and_then(serde_json::Value::as_u64),
1076 Some(1000),
1077 "round-trips must write the millis form: {reserialized}"
1078 );
1079 assert!(reserialized.get("duration").is_none());
1080 Ok(())
1081 }
1082
1083 #[test]
1084 fn budget_exceeded_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1085 let legacy = serde_json::json!({
1088 "type": "budget_exceeded",
1089 "thread_id": "t-legacy",
1090 "total_turns": 2,
1091 "total_usage": TokenUsage::default(),
1092 "duration": { "secs": 1, "nanos": 250_000_000 },
1093 "limit": "total_tokens",
1094 });
1095 let event: AgentEvent = serde_json::from_value(legacy)?;
1096 let AgentEvent::BudgetExceeded { duration, .. } = event else {
1097 panic!("expected BudgetExceeded");
1098 };
1099 assert_eq!(duration, Duration::from_millis(1250));
1100 Ok(())
1101 }
1102
1103 #[test]
1104 fn budget_exceeded_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1105 let seq = SequenceCounter::new();
1106 let envelope = AgentEventEnvelope::wrap(
1107 AgentEvent::budget_exceeded(
1108 ThreadId::from_string("t"),
1109 2,
1110 TokenUsage::default(),
1111 Duration::from_millis(1200),
1112 Some(0.5),
1113 BudgetLimitKind::TotalTokens,
1114 ),
1115 &seq,
1116 );
1117 let json = serde_json::to_value(&envelope)?;
1118
1119 assert_eq!(
1121 json.get("duration_ms").and_then(serde_json::Value::as_u64),
1122 Some(1200)
1123 );
1124 assert!(
1125 json.get("duration").is_none(),
1126 "no nested `duration` key expected: {json}"
1127 );
1128
1129 let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1130 match restored.event {
1131 AgentEvent::BudgetExceeded { duration, .. } => {
1132 assert_eq!(duration, Duration::from_millis(1200));
1133 }
1134 other => panic!("expected BudgetExceeded, got {other:?}"),
1135 }
1136 Ok(())
1137 }
1138
1139 fn sample_all_variants() -> Vec<AgentEvent> {
1146 let thread = ThreadId::from_string("thread-1");
1147 let usage = TokenUsage::default();
1148 let mut events = session_open_events(&thread);
1149 events.extend(streamed_content_events());
1150 events.extend(tool_call_events());
1151 events.extend(turn_completion_events(&thread, &usage));
1152 events.extend(failure_and_retry_events());
1153 events.extend(auxiliary_events(&usage));
1154 events
1155 }
1156
1157 fn session_open_events(thread: &ThreadId) -> Vec<AgentEvent> {
1159 vec![
1160 AgentEvent::Start {
1161 thread_id: thread.clone(),
1162 turn: 1,
1163 emitter_task_id: Some("task-start".into()),
1164 },
1165 AgentEvent::UserInput {
1166 thread_id: thread.clone(),
1167 content: vec![ContentBlock::Text { text: "hi".into() }],
1168 },
1169 ]
1170 }
1171
1172 fn streamed_content_events() -> Vec<AgentEvent> {
1175 vec![
1176 AgentEvent::Thinking {
1177 message_id: "m".into(),
1178 text: "t".into(),
1179 },
1180 AgentEvent::ThinkingDelta {
1181 message_id: "m".into(),
1182 delta: "d".into(),
1183 },
1184 AgentEvent::TextDelta {
1185 message_id: "m".into(),
1186 delta: "d".into(),
1187 },
1188 AgentEvent::Text {
1189 message_id: "m".into(),
1190 text: "t".into(),
1191 },
1192 ]
1193 }
1194
1195 fn tool_call_events() -> Vec<AgentEvent> {
1197 vec![
1198 AgentEvent::ToolCallStart {
1199 id: "id".into(),
1200 name: "n".into(),
1201 display_name: "N".into(),
1202 input: serde_json::json!({}),
1203 tier: ToolTier::Observe,
1204 },
1205 AgentEvent::ToolCallEnd {
1206 id: "id".into(),
1207 name: "n".into(),
1208 display_name: "N".into(),
1209 result: ToolResult::success("ok"),
1210 },
1211 AgentEvent::ToolProgress {
1212 id: "id".into(),
1213 name: "n".into(),
1214 display_name: "N".into(),
1215 stage: "s".into(),
1216 message: "m".into(),
1217 data: None,
1218 },
1219 AgentEvent::ToolRequiresConfirmation {
1220 id: "id".into(),
1221 name: "n".into(),
1222 display_name: "N".into(),
1223 input: serde_json::json!({}),
1224 description: "d".into(),
1225 },
1226 ]
1227 }
1228
1229 fn turn_completion_events(thread: &ThreadId, usage: &TokenUsage) -> Vec<AgentEvent> {
1231 vec![
1232 AgentEvent::TurnComplete {
1233 turn: 1,
1234 usage: usage.clone(),
1235 emitter_task_id: Some("task-turn-complete".into()),
1236 },
1237 AgentEvent::Done {
1238 thread_id: thread.clone(),
1239 total_turns: 2,
1240 total_usage: usage.clone(),
1241 duration: Duration::from_millis(1500),
1242 estimated_cost_usd: Some(0.0123),
1243 emitter_task_id: Some("task-done".into()),
1244 },
1245 ]
1246 }
1247
1248 fn failure_and_retry_events() -> Vec<AgentEvent> {
1250 vec![
1251 AgentEvent::Error {
1252 message: "e".into(),
1253 recoverable: true,
1254 emitter_task_id: Some("task-error".into()),
1255 },
1256 AgentEvent::AutoRetryStart {
1257 attempt: 1,
1258 max_attempts: 5,
1259 delay_ms: 100,
1260 error_message: "rate limited".into(),
1261 },
1262 AgentEvent::AutoRetryEnd {
1263 attempt: 1,
1264 success: true,
1265 final_error: None,
1266 },
1267 ]
1268 }
1269
1270 fn auxiliary_events(usage: &TokenUsage) -> Vec<AgentEvent> {
1273 vec![
1274 AgentEvent::Refusal {
1275 message_id: "m".into(),
1276 text: Some("no".into()),
1277 },
1278 AgentEvent::Cancelled {
1279 turn: 1,
1280 usage: usage.clone(),
1281 emitter_task_id: Some("task-cancelled".into()),
1282 },
1283 AgentEvent::BudgetExceeded {
1284 thread_id: ThreadId::from_string("thread-1"),
1285 total_turns: 3,
1286 total_usage: usage.clone(),
1287 duration: Duration::from_millis(750),
1288 estimated_cost_usd: Some(0.5),
1289 limit: BudgetLimitKind::CostUsd,
1290 emitter_task_id: Some("task-budget".into()),
1291 },
1292 AgentEvent::ContextCompacted {
1293 original_count: 10,
1294 new_count: 5,
1295 original_tokens: 100,
1296 new_tokens: 50,
1297 },
1298 AgentEvent::SubagentProgress {
1299 subagent_id: "s".into(),
1300 subagent_name: "explore".into(),
1301 nickname: None,
1302 child_thread_id: None,
1303 child_root_task_id: None,
1304 subagent_task_id: None,
1305 max_turns: None,
1306 current_turn: None,
1307 model: None,
1308 tool_name: "t".into(),
1309 tool_context: "c".into(),
1310 completed: false,
1311 success: false,
1312 tool_count: 0,
1313 total_tokens: 0,
1314 },
1315 ]
1316 }
1317
1318 #[test]
1323 fn emitter_task_id_is_absent_from_journal_rows_written_before_the_field()
1324 -> serde_json::Result<()> {
1325 let legacy = serde_json::json!({
1329 "type": "done",
1330 "thread_id": "t-legacy",
1331 "total_turns": 2,
1332 "total_usage": TokenUsage::default(),
1333 "duration_ms": 1000,
1334 });
1335 let event: AgentEvent = serde_json::from_value(legacy)?;
1336 assert_eq!(event.emitter_task_id(), None);
1337
1338 let json = serde_json::to_value(&event)?;
1341 assert!(
1342 json.get("emitter_task_id").is_none(),
1343 "unstamped events must omit the key: {json}"
1344 );
1345 Ok(())
1346 }
1347
1348 #[test]
1349 fn with_emitter_task_id_stamps_every_lifecycle_variant() -> serde_json::Result<()> {
1350 let thread = ThreadId::from_string("t");
1351 let usage = TokenUsage::default();
1352 let lifecycle = vec![
1353 AgentEvent::start(thread.clone(), 1),
1354 AgentEvent::TurnComplete {
1355 turn: 1,
1356 usage: usage.clone(),
1357 emitter_task_id: None,
1358 },
1359 AgentEvent::done(thread.clone(), 1, usage.clone(), Duration::from_secs(1)),
1360 AgentEvent::budget_exceeded(
1361 thread,
1362 1,
1363 usage.clone(),
1364 Duration::from_secs(1),
1365 None,
1366 BudgetLimitKind::TotalTokens,
1367 ),
1368 AgentEvent::error("boom", false),
1369 AgentEvent::cancelled(1, usage),
1370 ];
1371 for event in lifecycle {
1372 let label = format!("{event:?}");
1373 assert_eq!(event.emitter_task_id(), None, "{label}: starts unstamped");
1374
1375 let stamped = event.with_emitter_task_id("task-42");
1376 assert_eq!(stamped.emitter_task_id(), Some("task-42"), "{label}");
1377
1378 let json = serde_json::to_value(&stamped)?;
1379 assert_eq!(
1380 json.get("emitter_task_id")
1381 .and_then(serde_json::Value::as_str),
1382 Some("task-42"),
1383 "{label}: stamped events carry the key: {json}"
1384 );
1385 let restored: AgentEvent = serde_json::from_value(json)?;
1386 assert_eq!(restored.emitter_task_id(), Some("task-42"), "{label}");
1387 }
1388 Ok(())
1389 }
1390
1391 #[test]
1392 fn with_emitter_task_id_leaves_non_lifecycle_variants_untouched() -> serde_json::Result<()> {
1393 let text = AgentEvent::text("m", "hi").with_emitter_task_id("task-42");
1397 assert_eq!(text.emitter_task_id(), None);
1398
1399 let json = serde_json::to_value(&text)?;
1400 assert!(
1401 json.get("emitter_task_id").is_none(),
1402 "non-lifecycle variants must not grow the key: {json}"
1403 );
1404 Ok(())
1405 }
1406
1407 #[test]
1408 fn every_variant_envelope_has_flat_keys_and_round_trips() -> serde_json::Result<()> {
1409 let seq = SequenceCounter::new();
1410 for event in sample_all_variants() {
1411 let label = format!("{event:?}");
1412 let envelope = AgentEventEnvelope::wrap(event, &seq);
1413 let json = serde_json::to_value(&envelope)?;
1414
1415 for key in ["event_id", "sequence", "timestamp", "type"] {
1417 assert!(
1418 json.get(key).is_some(),
1419 "{label}: missing flat key `{key}` in {json}"
1420 );
1421 }
1422 assert!(
1425 json.get("event").is_none(),
1426 "{label}: unexpected nested `event` key in {json}"
1427 );
1428
1429 let restored: AgentEventEnvelope = serde_json::from_value(json.clone())?;
1430 assert_eq!(
1431 serde_json::to_value(&restored)?,
1432 json,
1433 "{label}: envelope round-trip changed the wire form"
1434 );
1435 }
1436 Ok(())
1437 }
1438}