Skip to main content

agent_sdk_foundation/
events.rs

1//! Agent events for real-time streaming.
2//!
3//! The [`AgentEvent`] enum represents all events that can occur during agent
4//! execution. These events are streamed via an async channel for real-time
5//! UI updates and logging.
6//!
7//! # Event Flow
8//!
9//! A typical event sequence looks like:
10//! 1. `Start` - Agent begins processing
11//! 2. `Text` / `ToolCallStart` / `ToolCallEnd` - Processing events
12//! 3. `TurnComplete` - One LLM round-trip finished
13//! 4. `Done` - Agent completed successfully, or `Error` if failed
14
15use 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
23/// Serde adapter encoding a [`Duration`] as a millisecond integer
24/// (`duration_ms`) instead of serde's default `{secs,nanos}` object.
25///
26/// Deserialization is deliberately lenient: durable event rows written
27/// before the wire form changed to `duration_ms` carry serde's default
28/// `{"secs":..,"nanos":..}` object (under the old `duration` key, accepted
29/// via `#[serde(alias = "duration")]` on the fields). Hosts replay those
30/// rows with `serde_json::from_value`, so both representations must decode
31/// or every thread containing a pre-change terminal event becomes
32/// unreadable after an upgrade. Serialization always writes millis.
33mod duration_ms_serde {
34    use serde::{Deserialize, Deserializer, Serializer};
35    use std::time::Duration;
36
37    /// The two wire shapes a duration value can arrive in.
38    #[derive(Deserialize)]
39    #[serde(untagged)]
40    enum DurationRepr {
41        /// Current form: a flat millisecond integer.
42        Millis(u64),
43        /// Legacy form: serde's default `Duration` object.
44        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/// Events emitted by the agent loop during execution.
67/// These are streamed to the client for real-time UI updates.
68#[derive(Clone, Debug, Serialize, Deserialize)]
69#[serde(tag = "type", rename_all = "snake_case")]
70#[non_exhaustive]
71pub enum AgentEvent {
72    /// Agent loop has started
73    Start { thread_id: ThreadId, turn: usize },
74
75    /// The user prompt that opens a turn.
76    ///
77    /// Committed by the worker on the **first attempt** of each
78    /// root-turn task, immediately before the matching
79    /// [`AgentEvent::Start`]. Carries the task's admitted
80    /// `submitted_input` lifted into the LLM-content shape
81    /// (`Vec<ContentBlock>` — text, image, document) so consumers
82    /// can render the prompt without reaching into the projection
83    /// or the task store. Retries of the same turn do not re-emit
84    /// the event; downstream readers can pair `UserInput` 1:1
85    /// with the *first* `Start { turn: N }` per turn.
86    ///
87    /// This is the durable, sequence-numbered admission event the
88    /// projection never carried — `MessageProjection::messages`
89    /// still holds the same prompt as an `llm::Message`, but it
90    /// has no sequence and commingles with tool-result and
91    /// compaction-summary user-role rows. Replay clients that
92    /// need a clean, chronological "this is what the user typed"
93    /// signal read this event instead.
94    UserInput {
95        thread_id: ThreadId,
96        /// Lifted from the admitted task's
97        /// `submitted_input`. Only `Text`, `Image`, and `Document`
98        /// blocks appear — the runtime never admits user prompts
99        /// containing tool blocks, but the broader
100        /// `ContentBlock` type lets the field round-trip through
101        /// the same wire shapes the projection uses.
102        content: Vec<ContentBlock>,
103    },
104
105    /// Agent is "thinking" - complete thinking text after stream ends
106    Thinking { message_id: String, text: String },
107
108    /// A thinking delta for streaming thinking content
109    ThinkingDelta { message_id: String, delta: String },
110
111    /// A text delta for streaming responses
112    TextDelta { message_id: String, delta: String },
113
114    /// Complete text block from the agent
115    Text { message_id: String, text: String },
116
117    /// Agent is about to call a tool
118    ToolCallStart {
119        id: String,
120        name: String,
121        display_name: String,
122        input: serde_json::Value,
123        tier: ToolTier,
124    },
125
126    /// Tool execution completed
127    ToolCallEnd {
128        id: String,
129        name: String,
130        display_name: String,
131        result: ToolResult,
132    },
133
134    /// Progress update from an async tool operation
135    ToolProgress {
136        /// Tool call ID
137        id: String,
138        /// Tool name
139        name: String,
140        /// Human-readable display name
141        display_name: String,
142        /// Progress stage
143        stage: String,
144        /// Human-readable progress message
145        message: String,
146        /// Optional tool-specific data
147        data: Option<serde_json::Value>,
148    },
149
150    /// Tool requires confirmation before execution.
151    /// The application determines the confirmation type (normal, PIN, biometric).
152    ToolRequiresConfirmation {
153        id: String,
154        name: String,
155        display_name: String,
156        input: serde_json::Value,
157        description: String,
158    },
159
160    /// Agent turn completed (one LLM round-trip)
161    TurnComplete { turn: usize, usage: TokenUsage },
162
163    /// Agent loop completed successfully
164    Done {
165        thread_id: ThreadId,
166        total_turns: usize,
167        total_usage: TokenUsage,
168        /// Wall-clock run duration.
169        ///
170        /// Serialized on the wire as `duration_ms` (a millisecond integer) to
171        /// match [`TurnSummary::duration_ms`](crate::types::TurnSummary) — the
172        /// flattened envelope previously encoded this as a nested
173        /// `{"secs":..,"nanos":..}` object, inconsistent with the rest of the
174        /// streaming contract. The Rust field keeps the `Duration` type.
175        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
176        duration: Duration,
177        /// Estimated cost of the run in USD, when the run's provider/model
178        /// has pricing metadata. Omitted from the wire form when `None` so
179        /// the streaming contract stays compatible with consumers that
180        /// predate cost accounting.
181        #[serde(default, skip_serializing_if = "Option::is_none")]
182        estimated_cost_usd: Option<f64>,
183    },
184
185    /// The run was stopped because a run-level usage budget was exceeded.
186    ///
187    /// This is a **terminal** event, emitted once on the budget-exceeded
188    /// return site in place of [`AgentEvent::Done`], so a streaming
189    /// consumer always receives a closing marker. `limit` identifies which
190    /// budget tripped.
191    BudgetExceeded {
192        thread_id: ThreadId,
193        total_turns: usize,
194        total_usage: TokenUsage,
195        /// Wall-clock run duration up to the moment the budget tripped.
196        ///
197        /// Serialized on the wire as `duration_ms` (a millisecond integer),
198        /// mirroring [`AgentEvent::Done`].
199        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
200        duration: Duration,
201        /// Estimated cost of the run in USD at the moment the budget was
202        /// hit, when pricing metadata is available.
203        #[serde(default, skip_serializing_if = "Option::is_none")]
204        estimated_cost_usd: Option<f64>,
205        /// Which budget limit was exceeded.
206        limit: BudgetLimitKind,
207    },
208
209    /// An error occurred during execution
210    Error { message: String, recoverable: bool },
211
212    /// Auto-retry was initiated for a transient LLM error (rate
213    /// limit, server error, etc.). The `delay_ms` field gives the
214    /// runtime's chosen backoff before re-attempting; consumers
215    /// can render a "Retrying X/N in Ys…" indicator and clear it
216    /// on the matching `AutoRetryEnd`.
217    AutoRetryStart {
218        /// 1-based retry attempt number (first retry = 1).
219        attempt: u32,
220        /// Maximum retry attempts configured for this run.
221        max_attempts: u32,
222        /// Backoff before the next attempt in milliseconds.
223        delay_ms: u64,
224        /// Human-readable reason the retry was triggered.
225        error_message: String,
226    },
227
228    /// Auto-retry settled. `success = true` means a subsequent
229    /// attempt succeeded; `success = false` means the retry budget
230    /// was exhausted and `final_error` carries the last error.
231    AutoRetryEnd {
232        /// Total attempts performed (matches the last
233        /// `AutoRetryStart`'s `attempt`).
234        attempt: u32,
235        /// Whether a follow-up attempt eventually succeeded.
236        success: bool,
237        /// Last error when the retry budget ran out.
238        final_error: Option<String>,
239    },
240
241    /// The model refused the request (safety/policy).
242    Refusal {
243        message_id: String,
244        text: Option<String>,
245    },
246
247    /// The run was cancelled via its [`CancellationToken`].
248    ///
249    /// This is a **terminal** event, emitted exactly once on every
250    /// cancellation return site (mirroring [`AgentEvent::Done`] and
251    /// [`AgentEvent::Refusal`]). Cancellation can land at the top of a
252    /// turn, mid-stream while the model is still producing tokens,
253    /// while a tool is in flight, or during context compaction — in
254    /// every case the run closes with this event so a streaming
255    /// consumer receives a closing marker and never hangs waiting for
256    /// `Done`.
257    ///
258    /// `turn` is the turn number reached when the cancel was honored
259    /// and `usage` is the partial token usage accumulated so far.
260    ///
261    /// [`CancellationToken`]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
262    Cancelled { turn: usize, usage: TokenUsage },
263
264    /// Context was compacted to reduce size
265    ContextCompacted {
266        /// Number of messages before compaction
267        original_count: usize,
268        /// Number of messages after compaction
269        new_count: usize,
270        /// Estimated tokens before compaction
271        original_tokens: usize,
272        /// Estimated tokens after compaction
273        new_tokens: usize,
274    },
275
276    /// Progress update from a running subagent
277    SubagentProgress {
278        /// ID of the parent tool call that spawned this subagent
279        subagent_id: String,
280        /// Name of the subagent (e.g., "explore", "plan")
281        subagent_name: String,
282        /// Human-friendly nickname assigned by the parent (e.g., "Zara")
283        nickname: Option<String>,
284        /// Durable child thread reference, when available.
285        child_thread_id: Option<ThreadId>,
286        /// Durable child root task reference, when available.
287        child_root_task_id: Option<String>,
288        /// Durable parent-visible invocation task reference, when available.
289        subagent_task_id: Option<String>,
290        /// Maximum turns configured for this subagent
291        max_turns: Option<u32>,
292        /// Current turn number of the subagent
293        current_turn: Option<u32>,
294        /// Model being used by the subagent
295        model: Option<String>,
296        /// Summary label associated with the latest subagent update.
297        tool_name: String,
298        /// Brief context associated with the latest subagent update.
299        tool_context: String,
300        /// Whether the summarized update represents terminal completion.
301        completed: bool,
302        /// Whether the subagent succeeded (only meaningful if completed)
303        success: bool,
304        /// Current total tool count for this subagent
305        tool_count: u32,
306        /// Current total tokens used by this subagent
307        total_tokens: u64,
308    },
309}
310
311impl AgentEvent {
312    #[must_use]
313    pub const fn start(thread_id: ThreadId, turn: usize) -> Self {
314        Self::Start { thread_id, turn }
315    }
316
317    #[must_use]
318    pub const fn user_input(thread_id: ThreadId, content: Vec<ContentBlock>) -> Self {
319        Self::UserInput { thread_id, content }
320    }
321
322    #[must_use]
323    pub fn thinking(message_id: impl Into<String>, text: impl Into<String>) -> Self {
324        Self::Thinking {
325            message_id: message_id.into(),
326            text: text.into(),
327        }
328    }
329
330    #[must_use]
331    pub fn thinking_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
332        Self::ThinkingDelta {
333            message_id: message_id.into(),
334            delta: delta.into(),
335        }
336    }
337
338    #[must_use]
339    pub fn text_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
340        Self::TextDelta {
341            message_id: message_id.into(),
342            delta: delta.into(),
343        }
344    }
345
346    #[must_use]
347    pub fn text(message_id: impl Into<String>, text: impl Into<String>) -> Self {
348        Self::Text {
349            message_id: message_id.into(),
350            text: text.into(),
351        }
352    }
353
354    #[must_use]
355    pub fn tool_call_start(
356        id: impl Into<String>,
357        name: impl Into<String>,
358        display_name: impl Into<String>,
359        input: serde_json::Value,
360        tier: ToolTier,
361    ) -> Self {
362        Self::ToolCallStart {
363            id: id.into(),
364            name: name.into(),
365            display_name: display_name.into(),
366            input,
367            tier,
368        }
369    }
370
371    #[must_use]
372    pub fn tool_call_end(
373        id: impl Into<String>,
374        name: impl Into<String>,
375        display_name: impl Into<String>,
376        result: ToolResult,
377    ) -> Self {
378        Self::ToolCallEnd {
379            id: id.into(),
380            name: name.into(),
381            display_name: display_name.into(),
382            result,
383        }
384    }
385
386    #[must_use]
387    pub fn tool_progress(
388        id: impl Into<String>,
389        name: impl Into<String>,
390        display_name: impl Into<String>,
391        stage: impl Into<String>,
392        message: impl Into<String>,
393        data: Option<serde_json::Value>,
394    ) -> Self {
395        Self::ToolProgress {
396            id: id.into(),
397            name: name.into(),
398            display_name: display_name.into(),
399            stage: stage.into(),
400            message: message.into(),
401            data,
402        }
403    }
404
405    #[must_use]
406    pub fn tool_requires_confirmation(
407        id: impl Into<String>,
408        name: impl Into<String>,
409        display_name: impl Into<String>,
410        input: serde_json::Value,
411        description: impl Into<String>,
412    ) -> Self {
413        Self::ToolRequiresConfirmation {
414            id: id.into(),
415            name: name.into(),
416            display_name: display_name.into(),
417            input,
418            description: description.into(),
419        }
420    }
421
422    #[must_use]
423    pub const fn done(
424        thread_id: ThreadId,
425        total_turns: usize,
426        total_usage: TokenUsage,
427        duration: Duration,
428    ) -> Self {
429        Self::Done {
430            thread_id,
431            total_turns,
432            total_usage,
433            duration,
434            estimated_cost_usd: None,
435        }
436    }
437
438    #[must_use]
439    pub const fn done_with_cost(
440        thread_id: ThreadId,
441        total_turns: usize,
442        total_usage: TokenUsage,
443        duration: Duration,
444        estimated_cost_usd: Option<f64>,
445    ) -> Self {
446        Self::Done {
447            thread_id,
448            total_turns,
449            total_usage,
450            duration,
451            estimated_cost_usd,
452        }
453    }
454
455    #[must_use]
456    pub const fn budget_exceeded(
457        thread_id: ThreadId,
458        total_turns: usize,
459        total_usage: TokenUsage,
460        duration: Duration,
461        estimated_cost_usd: Option<f64>,
462        limit: BudgetLimitKind,
463    ) -> Self {
464        Self::BudgetExceeded {
465            thread_id,
466            total_turns,
467            total_usage,
468            duration,
469            estimated_cost_usd,
470            limit,
471        }
472    }
473
474    #[must_use]
475    pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
476        Self::Error {
477            message: message.into(),
478            recoverable,
479        }
480    }
481
482    #[must_use]
483    pub fn refusal(message_id: impl Into<String>, text: Option<String>) -> Self {
484        Self::Refusal {
485            message_id: message_id.into(),
486            text,
487        }
488    }
489
490    #[must_use]
491    pub const fn cancelled(turn: usize, usage: TokenUsage) -> Self {
492        Self::Cancelled { turn, usage }
493    }
494
495    #[must_use]
496    pub const fn context_compacted(
497        original_count: usize,
498        new_count: usize,
499        original_tokens: usize,
500        new_tokens: usize,
501    ) -> Self {
502        Self::ContextCompacted {
503            original_count,
504            new_count,
505            original_tokens,
506            new_tokens,
507        }
508    }
509}
510
511/// Monotonically increasing per-run counter for event ordering.
512///
513/// Each `run()` or `run_turn()` call creates a fresh counter starting at 0.
514/// The counter is `Arc`-wrapped so it can be shared across tasks (e.g., subagent
515/// progress events sent from child tokio tasks).
516///
517/// `Ordering::Relaxed` is sufficient because the mpsc channel provides the
518/// happens-before ordering guarantee between sender and receiver.
519#[derive(Clone, Debug)]
520pub struct SequenceCounter(Arc<AtomicU64>);
521
522impl SequenceCounter {
523    /// Create a new counter starting at 0.
524    #[must_use]
525    pub fn new() -> Self {
526        Self(Arc::new(AtomicU64::new(0)))
527    }
528
529    /// Create a counter starting at the given offset.
530    ///
531    /// Used by server mode to resume sequencing across turns within
532    /// the same thread — the server seeds the counter with the last
533    /// known sequence value so numbering is continuous.
534    #[must_use]
535    pub fn with_offset(start: u64) -> Self {
536        Self(Arc::new(AtomicU64::new(start)))
537    }
538
539    /// Get the next sequence number, incrementing the counter.
540    #[must_use]
541    pub fn next(&self) -> u64 {
542        self.0.fetch_add(1, Ordering::Relaxed)
543    }
544}
545
546impl Default for SequenceCounter {
547    fn default() -> Self {
548        Self::new()
549    }
550}
551
552/// Envelope wrapping every [`AgentEvent`] with idempotency metadata.
553///
554/// Mobile clients can use `event_id` for deduplication on retry, `sequence`
555/// for ordering after persistence, and `timestamp` for display.
556///
557/// The `event` field is flattened in JSON so that `event_id`, `sequence`,
558/// `timestamp`, and the event's `type` discriminant all appear at the same level.
559#[derive(Clone, Debug, Serialize, Deserialize)]
560pub struct AgentEventEnvelope {
561    /// Unique identifier for this event emission.
562    ///
563    /// UUID v4 when created via [`AgentEventEnvelope::wrap`] (SDK-local path),
564    /// UUID v7 when created via server-committed `CommittedEvent::into_envelope`.
565    pub event_id: uuid::Uuid,
566    /// Monotonically increasing sequence number within a single run.
567    pub sequence: u64,
568    /// UTC timestamp of when the event was emitted.
569    #[serde(with = "time::serde::rfc3339")]
570    pub timestamp: OffsetDateTime,
571    /// The actual event payload.
572    #[serde(flatten)]
573    pub event: AgentEvent,
574}
575
576impl AgentEventEnvelope {
577    /// Wrap an [`AgentEvent`] in an envelope, assigning it a unique ID,
578    /// the next sequence number, and the current UTC timestamp.
579    #[must_use]
580    pub fn wrap(event: AgentEvent, seq: &SequenceCounter) -> Self {
581        Self {
582            event_id: uuid::Uuid::new_v4(),
583            sequence: seq.next(),
584            timestamp: OffsetDateTime::now_utc(),
585            event,
586        }
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use std::collections::HashSet;
594
595    // ===================
596    // SequenceCounter
597    // ===================
598
599    #[test]
600    fn sequence_counter_starts_at_zero() {
601        let seq = SequenceCounter::new();
602        assert_eq!(seq.next(), 0);
603    }
604
605    #[test]
606    fn sequence_counter_increments_monotonically() {
607        let seq = SequenceCounter::new();
608        for expected in 0..100 {
609            assert_eq!(seq.next(), expected);
610        }
611    }
612
613    #[test]
614    fn sequence_counter_no_gaps() {
615        let seq = SequenceCounter::new();
616        let values: Vec<u64> = (0..50).map(|_| seq.next()).collect();
617        let expected: Vec<u64> = (0..50).collect();
618        assert_eq!(values, expected);
619    }
620
621    #[test]
622    fn sequence_counter_clones_share_state() {
623        let seq = SequenceCounter::new();
624        let clone = seq.clone();
625
626        assert_eq!(seq.next(), 0);
627        assert_eq!(clone.next(), 1);
628        assert_eq!(seq.next(), 2);
629    }
630
631    #[test]
632    fn sequence_counter_default_starts_at_zero() {
633        let seq = SequenceCounter::default();
634        assert_eq!(seq.next(), 0);
635    }
636
637    #[test]
638    fn sequence_counter_with_offset_starts_at_given_value() {
639        let seq = SequenceCounter::with_offset(42);
640        assert_eq!(seq.next(), 42);
641        assert_eq!(seq.next(), 43);
642        assert_eq!(seq.next(), 44);
643    }
644
645    #[test]
646    fn sequence_counter_with_offset_zero_same_as_new() {
647        let seq = SequenceCounter::with_offset(0);
648        assert_eq!(seq.next(), 0);
649        assert_eq!(seq.next(), 1);
650    }
651
652    #[tokio::test]
653    async fn sequence_counter_unique_across_concurrent_tasks() {
654        let seq = SequenceCounter::new();
655        let n = 1000;
656
657        let mut handles = Vec::new();
658        for _ in 0..n {
659            let seq_clone = seq.clone();
660            handles.push(tokio::spawn(async move { seq_clone.next() }));
661        }
662
663        let mut values = HashSet::new();
664        for handle in handles {
665            let val = handle.await.unwrap();
666            assert!(values.insert(val), "duplicate sequence number: {val}");
667        }
668
669        assert_eq!(values.len(), n);
670        // All values should be in [0, n)
671        for v in &values {
672            assert!(*v < n as u64);
673        }
674    }
675
676    // ===================
677    // AgentEventEnvelope
678    // ===================
679
680    fn sample_event() -> AgentEvent {
681        AgentEvent::text("msg_1", "hello")
682    }
683
684    #[test]
685    fn wrap_assigns_unique_event_ids() {
686        let seq = SequenceCounter::new();
687        let ids: HashSet<uuid::Uuid> = (0..100)
688            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq).event_id)
689            .collect();
690        assert_eq!(ids.len(), 100);
691    }
692
693    #[test]
694    fn wrap_event_id_is_valid_uuid_v4() {
695        let seq = SequenceCounter::new();
696        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
697        assert_eq!(envelope.event_id.get_version(), Some(uuid::Version::Random));
698    }
699
700    #[test]
701    fn wrap_assigns_incrementing_sequences() {
702        let seq = SequenceCounter::new();
703        let envelopes: Vec<AgentEventEnvelope> = (0..10)
704            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
705            .collect();
706
707        for (i, env) in envelopes.iter().enumerate() {
708            assert_eq!(env.sequence, i as u64);
709        }
710    }
711
712    #[test]
713    fn wrap_timestamps_are_non_decreasing() {
714        let seq = SequenceCounter::new();
715        let envelopes: Vec<AgentEventEnvelope> = (0..20)
716            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
717            .collect();
718
719        for pair in envelopes.windows(2) {
720            assert!(pair[1].timestamp >= pair[0].timestamp);
721        }
722    }
723
724    #[test]
725    fn wrap_preserves_inner_event() {
726        let seq = SequenceCounter::new();
727        let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_42", "content"), &seq);
728        match &envelope.event {
729            AgentEvent::Text { message_id, text } => {
730                assert_eq!(message_id, "msg_42");
731                assert_eq!(text, "content");
732            }
733            other => panic!("expected Text, got {other:?}"),
734        }
735    }
736
737    #[test]
738    fn separate_counters_produce_independent_sequences() {
739        let seq_a = SequenceCounter::new();
740        let seq_b = SequenceCounter::new();
741
742        let a0 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
743        let b0 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
744        let a1 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
745        let b1 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
746
747        // Both start at 0 independently
748        assert_eq!(a0.sequence, 0);
749        assert_eq!(b0.sequence, 0);
750        assert_eq!(a1.sequence, 1);
751        assert_eq!(b1.sequence, 1);
752
753        // But event_ids are still globally unique
754        let ids: HashSet<uuid::Uuid> = [&a0, &b0, &a1, &b1].iter().map(|e| e.event_id).collect();
755        assert_eq!(ids.len(), 4);
756    }
757
758    // ===================
759    // Serialization
760    // ===================
761
762    #[test]
763    fn envelope_serializes_flat_json() {
764        let seq = SequenceCounter::new();
765        let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hi"), &seq);
766        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
767
768        // Top-level fields from the envelope
769        assert!(json.get("event_id").is_some());
770        assert!(json.get("sequence").is_some());
771        assert!(json.get("timestamp").is_some());
772
773        // Flattened event fields at the same level
774        assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("text"));
775        assert_eq!(
776            json.get("message_id").and_then(|v| v.as_str()),
777            Some("msg_1")
778        );
779        assert_eq!(json.get("text").and_then(|v| v.as_str()), Some("hi"));
780
781        // No nested "event" key
782        assert!(json.get("event").is_none());
783    }
784
785    #[test]
786    fn envelope_event_id_does_not_collide_with_tool_id() {
787        let seq = SequenceCounter::new();
788        let envelope = AgentEventEnvelope::wrap(
789            AgentEvent::tool_call_start(
790                "tool_123",
791                "bash",
792                "Bash",
793                serde_json::json!({}),
794                ToolTier::Observe,
795            ),
796            &seq,
797        );
798        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
799
800        // Both `event_id` and tool `id` are present and distinct
801        let event_id = json.get("event_id").and_then(|v| v.as_str()).unwrap();
802        let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap();
803        assert_ne!(event_id, tool_id);
804        assert_eq!(tool_id, "tool_123");
805    }
806
807    #[test]
808    fn envelope_roundtrip_serde() {
809        let seq = SequenceCounter::new();
810        let original = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hello"), &seq);
811
812        let json_str = serde_json::to_string(&original).expect("serialize");
813        let restored: AgentEventEnvelope = serde_json::from_str(&json_str).expect("deserialize");
814
815        assert_eq!(restored.event_id, original.event_id);
816        assert_eq!(restored.sequence, original.sequence);
817        assert_eq!(restored.timestamp, original.timestamp);
818        match &restored.event {
819            AgentEvent::Text { message_id, text } => {
820                assert_eq!(message_id, "msg_1");
821                assert_eq!(text, "hello");
822            }
823            other => panic!("expected Text, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn envelope_sequence_is_u64_in_json() {
829        let seq = SequenceCounter::new();
830        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
831        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
832
833        assert!(json.get("sequence").unwrap().is_u64());
834        assert_eq!(json.get("sequence").unwrap().as_u64(), Some(0));
835    }
836
837    #[test]
838    fn envelope_timestamp_is_rfc3339_string() {
839        let seq = SequenceCounter::new();
840        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
841        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
842
843        let ts_str = json.get("timestamp").unwrap().as_str().unwrap();
844        // Should parse as RFC 3339
845        time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
846            .expect("timestamp should be valid RFC 3339");
847    }
848
849    #[test]
850    fn done_event_serializes_duration_as_millis() -> serde_json::Result<()> {
851        let seq = SequenceCounter::new();
852        let envelope = AgentEventEnvelope::wrap(
853            AgentEvent::done(
854                ThreadId::from_string("t"),
855                3,
856                TokenUsage::default(),
857                Duration::from_millis(2500),
858            ),
859            &seq,
860        );
861        let json = serde_json::to_value(&envelope)?;
862
863        // Flat millisecond integer, matching `TurnSummary::duration_ms` — not
864        // the old nested `{"secs":..,"nanos":..}` object under `duration`.
865        assert_eq!(
866            json.get("duration_ms").and_then(serde_json::Value::as_u64),
867            Some(2500)
868        );
869        assert!(
870            json.get("duration").is_none(),
871            "old `duration` key must be gone: {json}"
872        );
873
874        let restored: AgentEventEnvelope = serde_json::from_value(json)?;
875        match restored.event {
876            AgentEvent::Done { duration, .. } => {
877                assert_eq!(duration, Duration::from_millis(2500));
878            }
879            other => panic!("expected Done, got {other:?}"),
880        }
881        Ok(())
882    }
883
884    #[test]
885    fn done_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
886        // Durable rows written before the wire form changed to `duration_ms`
887        // carry serde's default object under the old `duration` key. Hosts
888        // replay them with `serde_json::from_value`; they must keep
889        // decoding after an upgrade.
890        let legacy = serde_json::json!({
891            "type": "done",
892            "thread_id": "t-legacy",
893            "total_turns": 3,
894            "total_usage": TokenUsage::default(),
895            "duration": { "secs": 2, "nanos": 500_000_000 },
896        });
897        let event: AgentEvent = serde_json::from_value(legacy)?;
898        match event {
899            AgentEvent::Done {
900                duration,
901                total_turns,
902                ..
903            } => {
904                assert_eq!(duration, Duration::from_millis(2500));
905                assert_eq!(total_turns, 3);
906            }
907            other => panic!("expected Done, got {other:?}"),
908        }
909
910        // The current flat-millis form decodes too...
911        let current = serde_json::json!({
912            "type": "done",
913            "thread_id": "t-current",
914            "total_turns": 3,
915            "total_usage": TokenUsage::default(),
916            "duration_ms": 2500,
917        });
918        let event: AgentEvent = serde_json::from_value(current)?;
919        let AgentEvent::Done { duration, .. } = event else {
920            panic!("expected Done");
921        };
922        assert_eq!(duration, Duration::from_millis(2500));
923
924        // ...and a re-serialized legacy event is normalized to millis.
925        let legacy_event: AgentEvent = serde_json::from_value(serde_json::json!({
926            "type": "done",
927            "thread_id": "t-roundtrip",
928            "total_turns": 1,
929            "total_usage": TokenUsage::default(),
930            "duration": { "secs": 1, "nanos": 0 },
931        }))?;
932        let reserialized = serde_json::to_value(&legacy_event)?;
933        assert_eq!(
934            reserialized
935                .get("duration_ms")
936                .and_then(serde_json::Value::as_u64),
937            Some(1000),
938            "round-trips must write the millis form: {reserialized}"
939        );
940        assert!(reserialized.get("duration").is_none());
941        Ok(())
942    }
943
944    #[test]
945    fn budget_exceeded_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
946        // BudgetExceeded has no pre-rename durable rows, but the field uses
947        // the same adapter — keep it uniformly lenient.
948        let legacy = serde_json::json!({
949            "type": "budget_exceeded",
950            "thread_id": "t-legacy",
951            "total_turns": 2,
952            "total_usage": TokenUsage::default(),
953            "duration": { "secs": 1, "nanos": 250_000_000 },
954            "limit": "total_tokens",
955        });
956        let event: AgentEvent = serde_json::from_value(legacy)?;
957        let AgentEvent::BudgetExceeded { duration, .. } = event else {
958            panic!("expected BudgetExceeded");
959        };
960        assert_eq!(duration, Duration::from_millis(1250));
961        Ok(())
962    }
963
964    #[test]
965    fn budget_exceeded_event_serializes_duration_as_millis() -> serde_json::Result<()> {
966        let seq = SequenceCounter::new();
967        let envelope = AgentEventEnvelope::wrap(
968            AgentEvent::budget_exceeded(
969                ThreadId::from_string("t"),
970                2,
971                TokenUsage::default(),
972                Duration::from_millis(1200),
973                Some(0.5),
974                BudgetLimitKind::TotalTokens,
975            ),
976            &seq,
977        );
978        let json = serde_json::to_value(&envelope)?;
979
980        // Flat millisecond integer, mirroring the `Done` wire form.
981        assert_eq!(
982            json.get("duration_ms").and_then(serde_json::Value::as_u64),
983            Some(1200)
984        );
985        assert!(
986            json.get("duration").is_none(),
987            "no nested `duration` key expected: {json}"
988        );
989
990        let restored: AgentEventEnvelope = serde_json::from_value(json)?;
991        match restored.event {
992            AgentEvent::BudgetExceeded { duration, .. } => {
993                assert_eq!(duration, Duration::from_millis(1200));
994            }
995            other => panic!("expected BudgetExceeded, got {other:?}"),
996        }
997        Ok(())
998    }
999
1000    /// One representative value of every [`AgentEvent`] variant, so the
1001    /// envelope round-trip test exercises the full streaming contract.
1002    ///
1003    /// The variants are produced by a few cohesive builders concatenated
1004    /// in the original order, so the round-trip test still sees the exact
1005    /// same set of values.
1006    fn sample_all_variants() -> Vec<AgentEvent> {
1007        let thread = ThreadId::from_string("thread-1");
1008        let usage = TokenUsage::default();
1009        let mut events = session_open_events(&thread);
1010        events.extend(streamed_content_events());
1011        events.extend(tool_call_events());
1012        events.extend(turn_completion_events(&thread, &usage));
1013        events.extend(failure_and_retry_events());
1014        events.extend(auxiliary_events(&usage));
1015        events
1016    }
1017
1018    /// `Start` / `UserInput`: the events opening a thread turn.
1019    fn session_open_events(thread: &ThreadId) -> Vec<AgentEvent> {
1020        vec![
1021            AgentEvent::Start {
1022                thread_id: thread.clone(),
1023                turn: 1,
1024            },
1025            AgentEvent::UserInput {
1026                thread_id: thread.clone(),
1027                content: vec![ContentBlock::Text { text: "hi".into() }],
1028            },
1029        ]
1030    }
1031
1032    /// Streamed assistant content: consolidated and delta forms of
1033    /// thinking and text.
1034    fn streamed_content_events() -> Vec<AgentEvent> {
1035        vec![
1036            AgentEvent::Thinking {
1037                message_id: "m".into(),
1038                text: "t".into(),
1039            },
1040            AgentEvent::ThinkingDelta {
1041                message_id: "m".into(),
1042                delta: "d".into(),
1043            },
1044            AgentEvent::TextDelta {
1045                message_id: "m".into(),
1046                delta: "d".into(),
1047            },
1048            AgentEvent::Text {
1049                message_id: "m".into(),
1050                text: "t".into(),
1051            },
1052        ]
1053    }
1054
1055    /// Tool-call lifecycle: start, end, progress, and confirmation.
1056    fn tool_call_events() -> Vec<AgentEvent> {
1057        vec![
1058            AgentEvent::ToolCallStart {
1059                id: "id".into(),
1060                name: "n".into(),
1061                display_name: "N".into(),
1062                input: serde_json::json!({}),
1063                tier: ToolTier::Observe,
1064            },
1065            AgentEvent::ToolCallEnd {
1066                id: "id".into(),
1067                name: "n".into(),
1068                display_name: "N".into(),
1069                result: ToolResult::success("ok"),
1070            },
1071            AgentEvent::ToolProgress {
1072                id: "id".into(),
1073                name: "n".into(),
1074                display_name: "N".into(),
1075                stage: "s".into(),
1076                message: "m".into(),
1077                data: None,
1078            },
1079            AgentEvent::ToolRequiresConfirmation {
1080                id: "id".into(),
1081                name: "n".into(),
1082                display_name: "N".into(),
1083                input: serde_json::json!({}),
1084                description: "d".into(),
1085            },
1086        ]
1087    }
1088
1089    /// Turn-completion summaries: `TurnComplete` and the terminal `Done`.
1090    fn turn_completion_events(thread: &ThreadId, usage: &TokenUsage) -> Vec<AgentEvent> {
1091        vec![
1092            AgentEvent::TurnComplete {
1093                turn: 1,
1094                usage: usage.clone(),
1095            },
1096            AgentEvent::Done {
1097                thread_id: thread.clone(),
1098                total_turns: 2,
1099                total_usage: usage.clone(),
1100                duration: Duration::from_millis(1500),
1101                estimated_cost_usd: Some(0.0123),
1102            },
1103        ]
1104    }
1105
1106    /// Error and auto-retry signalling events.
1107    fn failure_and_retry_events() -> Vec<AgentEvent> {
1108        vec![
1109            AgentEvent::Error {
1110                message: "e".into(),
1111                recoverable: true,
1112            },
1113            AgentEvent::AutoRetryStart {
1114                attempt: 1,
1115                max_attempts: 5,
1116                delay_ms: 100,
1117                error_message: "rate limited".into(),
1118            },
1119            AgentEvent::AutoRetryEnd {
1120                attempt: 1,
1121                success: true,
1122                final_error: None,
1123            },
1124        ]
1125    }
1126
1127    /// Remaining auxiliary events: refusal, cancellation, compaction,
1128    /// and subagent progress.
1129    fn auxiliary_events(usage: &TokenUsage) -> Vec<AgentEvent> {
1130        vec![
1131            AgentEvent::Refusal {
1132                message_id: "m".into(),
1133                text: Some("no".into()),
1134            },
1135            AgentEvent::Cancelled {
1136                turn: 1,
1137                usage: usage.clone(),
1138            },
1139            AgentEvent::BudgetExceeded {
1140                thread_id: ThreadId::from_string("thread-1"),
1141                total_turns: 3,
1142                total_usage: usage.clone(),
1143                duration: Duration::from_millis(750),
1144                estimated_cost_usd: Some(0.5),
1145                limit: BudgetLimitKind::CostUsd,
1146            },
1147            AgentEvent::ContextCompacted {
1148                original_count: 10,
1149                new_count: 5,
1150                original_tokens: 100,
1151                new_tokens: 50,
1152            },
1153            AgentEvent::SubagentProgress {
1154                subagent_id: "s".into(),
1155                subagent_name: "explore".into(),
1156                nickname: None,
1157                child_thread_id: None,
1158                child_root_task_id: None,
1159                subagent_task_id: None,
1160                max_turns: None,
1161                current_turn: None,
1162                model: None,
1163                tool_name: "t".into(),
1164                tool_context: "c".into(),
1165                completed: false,
1166                success: false,
1167                tool_count: 0,
1168                total_tokens: 0,
1169            },
1170        ]
1171    }
1172
1173    #[test]
1174    fn every_variant_envelope_has_flat_keys_and_round_trips() -> serde_json::Result<()> {
1175        let seq = SequenceCounter::new();
1176        for event in sample_all_variants() {
1177            let label = format!("{event:?}");
1178            let envelope = AgentEventEnvelope::wrap(event, &seq);
1179            let json = serde_json::to_value(&envelope)?;
1180
1181            // Envelope metadata + the event discriminant are all flat keys.
1182            for key in ["event_id", "sequence", "timestamp", "type"] {
1183                assert!(
1184                    json.get(key).is_some(),
1185                    "{label}: missing flat key `{key}` in {json}"
1186                );
1187            }
1188            // The `#[serde(flatten)]` must not leave a nested wrapper, and no
1189            // variant field may collide with an envelope key.
1190            assert!(
1191                json.get("event").is_none(),
1192                "{label}: unexpected nested `event` key in {json}"
1193            );
1194
1195            let restored: AgentEventEnvelope = serde_json::from_value(json.clone())?;
1196            assert_eq!(
1197                serde_json::to_value(&restored)?,
1198                json,
1199                "{label}: envelope round-trip changed the wire form"
1200            );
1201        }
1202        Ok(())
1203    }
1204}