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 {
74        thread_id: ThreadId,
75        turn: usize,
76        /// Durable task that committed this event. See
77        /// [`AgentEvent::with_emitter_task_id`].
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        emitter_task_id: Option<String>,
80    },
81
82    /// The user prompt that opens a turn.
83    ///
84    /// Committed by the worker on the **first attempt** of each
85    /// root-turn task, immediately before the matching
86    /// [`AgentEvent::Start`]. Carries the task's admitted
87    /// `submitted_input` lifted into the LLM-content shape
88    /// (`Vec<ContentBlock>` — text, image, document) so consumers
89    /// can render the prompt without reaching into the projection
90    /// or the task store. Retries of the same turn do not re-emit
91    /// the event; downstream readers can pair `UserInput` 1:1
92    /// with the *first* `Start { turn: N }` per turn.
93    ///
94    /// This is the durable, sequence-numbered admission event the
95    /// projection never carried — `MessageProjection::messages`
96    /// still holds the same prompt as an `llm::Message`, but it
97    /// has no sequence and commingles with tool-result and
98    /// compaction-summary user-role rows. Replay clients that
99    /// need a clean, chronological "this is what the user typed"
100    /// signal read this event instead.
101    UserInput {
102        thread_id: ThreadId,
103        /// Lifted from the admitted task's
104        /// `submitted_input`. Only `Text`, `Image`, and `Document`
105        /// blocks appear — the runtime never admits user prompts
106        /// containing tool blocks, but the broader
107        /// `ContentBlock` type lets the field round-trip through
108        /// the same wire shapes the projection uses.
109        content: Vec<ContentBlock>,
110    },
111
112    /// Agent is "thinking" - complete thinking text after stream ends
113    Thinking { message_id: String, text: String },
114
115    /// A thinking delta for streaming thinking content
116    ThinkingDelta { message_id: String, delta: String },
117
118    /// A text delta for streaming responses
119    TextDelta { message_id: String, delta: String },
120
121    /// Complete text block from the agent
122    Text { message_id: String, text: String },
123
124    /// Agent is about to call a tool
125    ToolCallStart {
126        id: String,
127        name: String,
128        display_name: String,
129        input: serde_json::Value,
130        tier: ToolTier,
131    },
132
133    /// Tool execution completed
134    ToolCallEnd {
135        id: String,
136        name: String,
137        display_name: String,
138        result: ToolResult,
139    },
140
141    /// Progress update from an async tool operation
142    ToolProgress {
143        /// Tool call ID
144        id: String,
145        /// Tool name
146        name: String,
147        /// Human-readable display name
148        display_name: String,
149        /// Progress stage
150        stage: String,
151        /// Human-readable progress message
152        message: String,
153        /// Optional tool-specific data
154        data: Option<serde_json::Value>,
155    },
156
157    /// Tool requires confirmation before execution.
158    /// The application determines the confirmation type (normal, PIN, biometric).
159    ToolRequiresConfirmation {
160        id: String,
161        name: String,
162        display_name: String,
163        input: serde_json::Value,
164        description: String,
165    },
166
167    /// Agent turn completed (one LLM round-trip)
168    TurnComplete {
169        turn: usize,
170        usage: TokenUsage,
171        /// Durable task that committed this event. See
172        /// [`AgentEvent::with_emitter_task_id`].
173        #[serde(default, skip_serializing_if = "Option::is_none")]
174        emitter_task_id: Option<String>,
175    },
176
177    /// Agent loop completed successfully
178    Done {
179        thread_id: ThreadId,
180        total_turns: usize,
181        total_usage: TokenUsage,
182        /// Wall-clock run duration.
183        ///
184        /// Serialized on the wire as `duration_ms` (a millisecond integer) to
185        /// match [`TurnSummary::duration_ms`](crate::types::TurnSummary) — the
186        /// flattened envelope previously encoded this as a nested
187        /// `{"secs":..,"nanos":..}` object, inconsistent with the rest of the
188        /// streaming contract. The Rust field keeps the `Duration` type.
189        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
190        duration: Duration,
191        /// Estimated cost of the run in USD, when the run's provider/model
192        /// has pricing metadata. Omitted from the wire form when `None` so
193        /// the streaming contract stays compatible with consumers that
194        /// predate cost accounting.
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        estimated_cost_usd: Option<f64>,
197        /// Durable task that committed this event. See
198        /// [`AgentEvent::with_emitter_task_id`].
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        emitter_task_id: Option<String>,
201    },
202
203    /// The run was stopped because a run-level usage budget was exceeded.
204    ///
205    /// This is a **terminal** event, emitted once on the budget-exceeded
206    /// return site in place of [`AgentEvent::Done`], so a streaming
207    /// consumer always receives a closing marker. `limit` identifies which
208    /// budget tripped.
209    BudgetExceeded {
210        thread_id: ThreadId,
211        total_turns: usize,
212        total_usage: TokenUsage,
213        /// Wall-clock run duration up to the moment the budget tripped.
214        ///
215        /// Serialized on the wire as `duration_ms` (a millisecond integer),
216        /// mirroring [`AgentEvent::Done`].
217        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
218        duration: Duration,
219        /// Estimated cost of the run in USD at the moment the budget was
220        /// hit, when pricing metadata is available.
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        estimated_cost_usd: Option<f64>,
223        /// Which budget limit was exceeded.
224        limit: BudgetLimitKind,
225        /// Durable task that committed this event. See
226        /// [`AgentEvent::with_emitter_task_id`].
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        emitter_task_id: Option<String>,
229    },
230
231    /// An error occurred during execution
232    Error {
233        message: String,
234        recoverable: bool,
235        /// Durable task that committed this event. See
236        /// [`AgentEvent::with_emitter_task_id`].
237        #[serde(default, skip_serializing_if = "Option::is_none")]
238        emitter_task_id: Option<String>,
239    },
240
241    /// Auto-retry was initiated for a recoverable LLM error (rate
242    /// limit, server error, connectivity loss). The `delay_ms` field
243    /// gives the runtime's chosen backoff before re-attempting;
244    /// consumers can render a "Retrying X/N in Ys…" indicator and
245    /// clear it on the matching `AutoRetryEnd`.
246    AutoRetryStart {
247        /// 1-based failure ordinal within the turn (first failure = 1).
248        ///
249        /// Not necessarily contiguous across events: a connectivity
250        /// streak emits one `AutoRetryStart` (on its first failure)
251        /// while later failures in the streak still consume ordinals,
252        /// so consecutive events can read e.g. `1, 2, 4`.
253        attempt: u32,
254        /// Maximum retry attempts configured for this run.
255        ///
256        /// `u32::MAX` is a sentinel: the runtime is waiting for
257        /// provider connectivity to return and will retry until it
258        /// does (or the run is cancelled). Render it as an indefinite
259        /// "waiting for connection…" state, never as a literal
260        /// `X/4294967295` counter.
261        max_attempts: u32,
262        /// Backoff before the next attempt in milliseconds.
263        delay_ms: u64,
264        /// Human-readable reason the retry was triggered.
265        error_message: String,
266    },
267
268    /// Auto-retry settled. `success = true` means a subsequent
269    /// attempt succeeded; `success = false` means the retry budget
270    /// was exhausted and `final_error` carries the last error.
271    AutoRetryEnd {
272        /// The `attempt` of the last emitted `AutoRetryStart`, so the
273        /// envelope pairs even when later failures were folded into an
274        /// already-open connectivity streak.
275        attempt: u32,
276        /// Whether a follow-up attempt eventually succeeded.
277        success: bool,
278        /// Last error when the retry budget ran out.
279        final_error: Option<String>,
280    },
281
282    /// The model refused the request (safety/policy).
283    Refusal {
284        message_id: String,
285        text: Option<String>,
286    },
287
288    /// The run was cancelled via its [`CancellationToken`].
289    ///
290    /// This is a **terminal** event, emitted exactly once on every
291    /// cancellation return site (mirroring [`AgentEvent::Done`] and
292    /// [`AgentEvent::Refusal`]). Cancellation can land at the top of a
293    /// turn, mid-stream while the model is still producing tokens,
294    /// while a tool is in flight, or during context compaction — in
295    /// every case the run closes with this event so a streaming
296    /// consumer receives a closing marker and never hangs waiting for
297    /// `Done`.
298    ///
299    /// `turn` is the turn number reached when the cancel was honored
300    /// and `usage` is the partial token usage accumulated so far.
301    ///
302    /// [`CancellationToken`]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
303    Cancelled {
304        turn: usize,
305        usage: TokenUsage,
306        /// Durable task that committed this event — the cancelled
307        /// root, not the promoted successor. See
308        /// [`AgentEvent::with_emitter_task_id`].
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        emitter_task_id: Option<String>,
311    },
312
313    /// Context was compacted to reduce size
314    ContextCompacted {
315        /// Number of messages before compaction
316        original_count: usize,
317        /// Number of messages after compaction
318        new_count: usize,
319        /// Estimated tokens before compaction
320        original_tokens: usize,
321        /// Estimated tokens after compaction
322        new_tokens: usize,
323    },
324
325    /// Progress update from a running subagent
326    SubagentProgress {
327        /// ID of the parent tool call that spawned this subagent
328        subagent_id: String,
329        /// Name of the subagent (e.g., "explore", "plan")
330        subagent_name: String,
331        /// Human-friendly nickname assigned by the parent (e.g., "Zara")
332        nickname: Option<String>,
333        /// Durable child thread reference, when available.
334        child_thread_id: Option<ThreadId>,
335        /// Durable child root task reference, when available.
336        child_root_task_id: Option<String>,
337        /// Durable parent-visible invocation task reference, when available.
338        subagent_task_id: Option<String>,
339        /// Maximum turns configured for this subagent
340        max_turns: Option<u32>,
341        /// Current turn number of the subagent
342        current_turn: Option<u32>,
343        /// Model being used by the subagent
344        model: Option<String>,
345        /// Summary label associated with the latest subagent update.
346        tool_name: String,
347        /// Brief context associated with the latest subagent update.
348        tool_context: String,
349        /// Whether the summarized update represents terminal completion.
350        completed: bool,
351        /// Whether the subagent succeeded (only meaningful if completed)
352        success: bool,
353        /// Current total tool count for this subagent
354        tool_count: u32,
355        /// Current total tokens used by this subagent
356        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    /// Attribute a lifecycle event to the durable task whose execution
371    /// committed it (the task id in its string form).
372    ///
373    /// Only the lifecycle variants (`Start`, `TurnComplete`, `Done`,
374    /// `BudgetExceeded`, `Error`, `Cancelled`) carry the attribution;
375    /// every other variant is returned unchanged. Attribution is
376    /// therefore always the emitter's own identity, never a successor's:
377    /// a cancelled root's late salvage commit still names the cancelled
378    /// root, which is what lets a reader tell a superseded frame apart
379    /// from the thread's live one.
380    ///
381    /// Runs without a durable task behind them (the embedded SDK loop)
382    /// leave the field `None`, as do events journaled before the field
383    /// existed.
384    #[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    /// The durable task that committed this event, when the event is a
412    /// lifecycle variant that was stamped. See
413    /// [`AgentEvent::with_emitter_task_id`].
414    #[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/// Monotonically increasing per-run counter for event ordering.
651///
652/// Each `run()` or `run_turn()` call creates a fresh counter starting at 0.
653/// The counter is `Arc`-wrapped so it can be shared across tasks (e.g., subagent
654/// progress events sent from child tokio tasks).
655///
656/// `Ordering::Relaxed` is sufficient because the mpsc channel provides the
657/// happens-before ordering guarantee between sender and receiver.
658#[derive(Clone, Debug)]
659pub struct SequenceCounter(Arc<AtomicU64>);
660
661impl SequenceCounter {
662    /// Create a new counter starting at 0.
663    #[must_use]
664    pub fn new() -> Self {
665        Self(Arc::new(AtomicU64::new(0)))
666    }
667
668    /// Create a counter starting at the given offset.
669    ///
670    /// Used by server mode to resume sequencing across turns within
671    /// the same thread — the server seeds the counter with the last
672    /// known sequence value so numbering is continuous.
673    #[must_use]
674    pub fn with_offset(start: u64) -> Self {
675        Self(Arc::new(AtomicU64::new(start)))
676    }
677
678    /// Get the next sequence number, incrementing the counter.
679    #[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/// Envelope wrapping every [`AgentEvent`] with idempotency metadata.
692///
693/// Mobile clients can use `event_id` for deduplication on retry, `sequence`
694/// for ordering after persistence, and `timestamp` for display.
695///
696/// The `event` field is flattened in JSON so that `event_id`, `sequence`,
697/// `timestamp`, and the event's `type` discriminant all appear at the same level.
698#[derive(Clone, Debug, Serialize, Deserialize)]
699pub struct AgentEventEnvelope {
700    /// Unique identifier for this event emission.
701    ///
702    /// UUID v4 when created via [`AgentEventEnvelope::wrap`] (SDK-local path),
703    /// UUID v7 when created via server-committed `CommittedEvent::into_envelope`.
704    pub event_id: uuid::Uuid,
705    /// Monotonically increasing sequence number within a single run.
706    pub sequence: u64,
707    /// UTC timestamp of when the event was emitted.
708    #[serde(with = "time::serde::rfc3339")]
709    pub timestamp: OffsetDateTime,
710    /// The actual event payload.
711    #[serde(flatten)]
712    pub event: AgentEvent,
713}
714
715impl AgentEventEnvelope {
716    /// Wrap an [`AgentEvent`] in an envelope, assigning it a unique ID,
717    /// the next sequence number, and the current UTC timestamp.
718    #[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    // ===================
735    // SequenceCounter
736    // ===================
737
738    #[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        // All values should be in [0, n)
810        for v in &values {
811            assert!(*v < n as u64);
812        }
813    }
814
815    // ===================
816    // AgentEventEnvelope
817    // ===================
818
819    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        // Both start at 0 independently
887        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        // But event_ids are still globally unique
893        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    // ===================
898    // Serialization
899    // ===================
900
901    #[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        // Top-level fields from the envelope
908        assert!(json.get("event_id").is_some());
909        assert!(json.get("sequence").is_some());
910        assert!(json.get("timestamp").is_some());
911
912        // Flattened event fields at the same level
913        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        // No nested "event" key
921        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        // Both `event_id` and tool `id` are present and distinct
940        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        // Should parse as RFC 3339
984        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        // Flat millisecond integer, matching `TurnSummary::duration_ms` — not
1003        // the old nested `{"secs":..,"nanos":..}` object under `duration`.
1004        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        // Durable rows written before the wire form changed to `duration_ms`
1026        // carry serde's default object under the old `duration` key. Hosts
1027        // replay them with `serde_json::from_value`; they must keep
1028        // decoding after an upgrade.
1029        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        // The current flat-millis form decodes too...
1050        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        // ...and a re-serialized legacy event is normalized to millis.
1064        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        // BudgetExceeded has no pre-rename durable rows, but the field uses
1086        // the same adapter — keep it uniformly lenient.
1087        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        // Flat millisecond integer, mirroring the `Done` wire form.
1120        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    /// One representative value of every [`AgentEvent`] variant, so the
1140    /// envelope round-trip test exercises the full streaming contract.
1141    ///
1142    /// The variants are produced by a few cohesive builders concatenated
1143    /// in the original order, so the round-trip test still sees the exact
1144    /// same set of values.
1145    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    /// `Start` / `UserInput`: the events opening a thread turn.
1158    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    /// Streamed assistant content: consolidated and delta forms of
1173    /// thinking and text.
1174    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    /// Tool-call lifecycle: start, end, progress, and confirmation.
1196    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    /// Turn-completion summaries: `TurnComplete` and the terminal `Done`.
1230    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    /// Error and auto-retry signalling events.
1249    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    /// Remaining auxiliary events: refusal, cancellation, compaction,
1271    /// and subagent progress.
1272    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    // ===================
1319    // Emitter task identity
1320    // ===================
1321
1322    #[test]
1323    fn emitter_task_id_is_absent_from_journal_rows_written_before_the_field()
1324    -> serde_json::Result<()> {
1325        // Durable rows predating the field carry no `emitter_task_id`
1326        // key; they must decode (as `None`), not fail the whole thread's
1327        // replay.
1328        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        // And an unstamped event never writes the key, so the wire form
1339        // stays byte-identical for consumers that predate it.
1340        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        // Attribution is a lifecycle-only contract: content and tool
1394        // frames pair with the surrounding `Start` by adjacency and
1395        // carry no id of their own.
1396        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            // Envelope metadata + the event discriminant are all flat keys.
1416            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            // The `#[serde(flatten)]` must not leave a nested wrapper, and no
1423            // variant field may collide with an envelope key.
1424            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}