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/// Typed explanation for a durable task reaching a terminal state.
67///
68/// The tagged wire shape keeps variants stable while allowing structured
69/// details such as the provider error `kind`. `Unknown` is the explicit
70/// forward-compatibility sink for a newer producer's reason; callers must still
71/// name it in exhaustive matches rather than silently discarding it.
72#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
73#[serde(tag = "reason", rename_all = "snake_case")]
74pub enum TerminalReason {
75    /// The task completed successfully.
76    Completed,
77    /// The caller explicitly cancelled this task tree.
78    UserCancel,
79    /// A configured turn, token, cost, or retry budget was exhausted.
80    Budget,
81    /// The subagent watchdog observed no work for the full stall budget.
82    WatchdogStall,
83    /// The provider failed the request with a provider-specific category.
84    ProviderError { kind: String },
85    /// An ancestor was cancelled, so this descendant was cancelled with it.
86    ParentCancelled,
87    /// A pending confirmation was rejected, revoked, or timed out.
88    ConfirmationRejected,
89    /// An internal execution or persistence error terminated the task.
90    InternalError,
91    /// A reason introduced by a newer producer.
92    #[serde(other)]
93    Unknown,
94}
95
96/// Why a daemon changed the OAuth account serving a provider request.
97///
98/// The unknown sink keeps durable journal rows readable when a newer producer
99/// introduces another reason.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum AccountRotationReason {
104    /// The provider returned a typed rate-limit response.
105    RateLimited,
106    /// The provider rejected the active account after refresh was exhausted.
107    AuthenticationFailed,
108    /// The active account could not refresh before the request was dispatched.
109    RefreshFailed,
110    /// A reason introduced by a newer producer.
111    #[serde(other)]
112    Unknown,
113}
114
115/// Events emitted by the agent loop during execution.
116/// These are streamed to the client for real-time UI updates.
117#[derive(Clone, Debug, Serialize, Deserialize)]
118#[serde(tag = "type", rename_all = "snake_case")]
119#[non_exhaustive]
120pub enum AgentEvent {
121    /// A thread aggregate became durably visible.
122    ThreadCreated {
123        thread_id: ThreadId,
124        /// Source thread when this thread was created by `ForkThread`.
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        source_thread_id: Option<ThreadId>,
127        /// Source turn boundary copied by `ForkThread`.
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        fork_after_committed_turns: Option<u32>,
130    },
131
132    /// Agent loop has started
133    Start {
134        thread_id: ThreadId,
135        turn: usize,
136        /// Durable task that committed this event. See
137        /// [`AgentEvent::with_emitter_task_id`].
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        emitter_task_id: Option<String>,
140    },
141
142    /// The user prompt that opens a turn.
143    ///
144    /// Committed by the worker on the **first attempt** of each
145    /// root-turn task, immediately before the matching
146    /// [`AgentEvent::Start`]. Carries the task's admitted
147    /// `submitted_input` lifted into the LLM-content shape
148    /// (`Vec<ContentBlock>` — text, image, document) so consumers
149    /// can render the prompt without reaching into the projection
150    /// or the task store. Retries of the same turn do not re-emit
151    /// the event; downstream readers can pair `UserInput` 1:1
152    /// with the *first* `Start { turn: N }` per turn.
153    ///
154    /// This is the durable, sequence-numbered admission event the
155    /// projection never carried — `MessageProjection::messages`
156    /// still holds the same prompt as an `llm::Message`, but it
157    /// has no sequence and commingles with tool-result and
158    /// compaction-summary user-role rows. Replay clients that
159    /// need a clean, chronological "this is what the user typed"
160    /// signal read this event instead.
161    UserInput {
162        thread_id: ThreadId,
163        /// Lifted from the admitted task's
164        /// `submitted_input`. Only `Text`, `Image`, and `Document`
165        /// blocks appear — the runtime never admits user prompts
166        /// containing tool blocks, but the broader
167        /// `ContentBlock` type lets the field round-trip through
168        /// the same wire shapes the projection uses.
169        content: Vec<ContentBlock>,
170        /// Durable task that admitted this input. Present for boundary
171        /// injections; absent on ordinary root prompts and legacy rows.
172        #[serde(default, skip_serializing_if = "Option::is_none")]
173        emitter_task_id: Option<String>,
174    },
175
176    /// Agent is "thinking" - complete thinking text after stream ends
177    Thinking {
178        message_id: String,
179        text: String,
180        /// Durable task whose execution produced this content. See
181        /// [`AgentEvent::with_emitter_task_id`].
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        emitter_task_id: Option<String>,
184    },
185
186    /// A thinking delta for streaming thinking content
187    ThinkingDelta {
188        message_id: String,
189        delta: String,
190        /// Durable task whose execution produced this content. See
191        /// [`AgentEvent::with_emitter_task_id`].
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        emitter_task_id: Option<String>,
194    },
195
196    /// A text delta for streaming responses
197    TextDelta {
198        message_id: String,
199        delta: String,
200        /// Durable task whose execution produced this content. See
201        /// [`AgentEvent::with_emitter_task_id`].
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        emitter_task_id: Option<String>,
204    },
205
206    /// Complete text block from the agent
207    Text {
208        message_id: String,
209        text: String,
210        /// Durable task whose execution produced this content. See
211        /// [`AgentEvent::with_emitter_task_id`].
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        emitter_task_id: Option<String>,
214    },
215
216    /// Agent is about to call a tool
217    ToolCallStart {
218        id: String,
219        name: String,
220        display_name: String,
221        input: serde_json::Value,
222        tier: ToolTier,
223    },
224
225    /// Tool execution completed
226    ToolCallEnd {
227        id: String,
228        name: String,
229        display_name: String,
230        result: ToolResult,
231    },
232
233    /// Progress update from an async tool operation
234    ToolProgress {
235        /// Tool call ID
236        id: String,
237        /// Tool name
238        name: String,
239        /// Human-readable display name
240        display_name: String,
241        /// Progress stage
242        stage: String,
243        /// Human-readable progress message
244        message: String,
245        /// Optional tool-specific data
246        data: Option<serde_json::Value>,
247    },
248
249    /// Tool requires confirmation before execution.
250    /// The application determines the confirmation type (normal, PIN, biometric).
251    ToolRequiresConfirmation {
252        id: String,
253        name: String,
254        display_name: String,
255        input: serde_json::Value,
256        description: String,
257    },
258
259    /// Agent execution is durably parked until the user answers every
260    /// question in the batch.
261    QuestionAsked {
262        /// Root task to pass to the `AnswerQuestion` RPC.
263        task_id: String,
264        /// User-facing questions, one per pending `ask_user` call, in
265        /// pending-tool-call order. The `AnswerQuestion` RPC must
266        /// resolve all of them in one call.
267        questions: Vec<crate::QuestionPayload>,
268    },
269
270    /// Agent turn completed (one LLM round-trip)
271    TurnComplete {
272        turn: usize,
273        usage: TokenUsage,
274        /// Durable task that committed this event. See
275        /// [`AgentEvent::with_emitter_task_id`].
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        emitter_task_id: Option<String>,
278    },
279
280    /// Agent loop completed successfully
281    Done {
282        thread_id: ThreadId,
283        total_turns: usize,
284        total_usage: TokenUsage,
285        /// Wall-clock run duration.
286        ///
287        /// Serialized on the wire as `duration_ms` (a millisecond integer) to
288        /// match [`TurnSummary::duration_ms`](crate::types::TurnSummary) — the
289        /// flattened envelope previously encoded this as a nested
290        /// `{"secs":..,"nanos":..}` object, inconsistent with the rest of the
291        /// streaming contract. The Rust field keeps the `Duration` type.
292        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
293        duration: Duration,
294        /// Estimated cost of the run in USD, when the run's provider/model
295        /// has pricing metadata. Omitted from the wire form when `None` so
296        /// the streaming contract stays compatible with consumers that
297        /// predate cost accounting.
298        #[serde(default, skip_serializing_if = "Option::is_none")]
299        estimated_cost_usd: Option<f64>,
300        /// Durable task that committed this event. See
301        /// [`AgentEvent::with_emitter_task_id`].
302        #[serde(default, skip_serializing_if = "Option::is_none")]
303        emitter_task_id: Option<String>,
304    },
305
306    /// The run was stopped because a run-level usage budget was exceeded.
307    ///
308    /// This is a **terminal** event, emitted once on the budget-exceeded
309    /// return site in place of [`AgentEvent::Done`], so a streaming
310    /// consumer always receives a closing marker. `limit` identifies which
311    /// budget tripped.
312    BudgetExceeded {
313        thread_id: ThreadId,
314        total_turns: usize,
315        total_usage: TokenUsage,
316        /// Wall-clock run duration up to the moment the budget tripped.
317        ///
318        /// Serialized on the wire as `duration_ms` (a millisecond integer),
319        /// mirroring [`AgentEvent::Done`].
320        #[serde(rename = "duration_ms", alias = "duration", with = "duration_ms_serde")]
321        duration: Duration,
322        /// Estimated cost of the run in USD at the moment the budget was
323        /// hit, when pricing metadata is available.
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        estimated_cost_usd: Option<f64>,
326        /// Which budget limit was exceeded.
327        limit: BudgetLimitKind,
328        /// Durable task that committed this event. See
329        /// [`AgentEvent::with_emitter_task_id`].
330        #[serde(default, skip_serializing_if = "Option::is_none")]
331        emitter_task_id: Option<String>,
332    },
333
334    /// An error occurred during execution
335    Error {
336        message: String,
337        recoverable: bool,
338        /// Typed terminal explanation when this error closes a durable task.
339        #[serde(default, skip_serializing_if = "Option::is_none")]
340        reason: Option<TerminalReason>,
341        /// Durable task that committed this event. See
342        /// [`AgentEvent::with_emitter_task_id`].
343        #[serde(default, skip_serializing_if = "Option::is_none")]
344        emitter_task_id: Option<String>,
345    },
346
347    /// Auto-retry was initiated for a recoverable LLM error (rate
348    /// limit, server error, connectivity loss). The `delay_ms` field
349    /// gives the runtime's chosen backoff before re-attempting;
350    /// consumers can render a "Retrying X/N in Ys…" indicator and
351    /// clear it on the matching `AutoRetryEnd`.
352    AutoRetryStart {
353        /// 1-based failure ordinal within the turn (first failure = 1).
354        ///
355        /// Not necessarily contiguous across events: a connectivity
356        /// streak emits one `AutoRetryStart` (on its first failure)
357        /// while later failures in the streak still consume ordinals,
358        /// so consecutive events can read e.g. `1, 2, 4`.
359        attempt: u32,
360        /// Maximum retry attempts configured for this run.
361        ///
362        /// `u32::MAX` is a sentinel: the runtime is waiting for
363        /// provider connectivity to return and will retry until it
364        /// does (or the run is cancelled). Render it as an indefinite
365        /// "waiting for connection…" state, never as a literal
366        /// `X/4294967295` counter.
367        max_attempts: u32,
368        /// Backoff before the next attempt in milliseconds.
369        delay_ms: u64,
370        /// Human-readable reason the retry was triggered.
371        error_message: String,
372    },
373
374    /// Auto-retry settled. `success = true` means a subsequent
375    /// attempt succeeded; `success = false` means the retry budget
376    /// was exhausted and `final_error` carries the last error.
377    AutoRetryEnd {
378        /// The `attempt` of the last emitted `AutoRetryStart`, so the
379        /// envelope pairs even when later failures were folded into an
380        /// already-open connectivity streak.
381        attempt: u32,
382        /// Whether a follow-up attempt eventually succeeded.
383        success: bool,
384        /// Last error when the retry budget ran out.
385        final_error: Option<String>,
386    },
387
388    /// A provider request succeeded after moving from one OAuth account to
389    /// another. Emitted only after the replacement account has produced a
390    /// successful response, never while merely considering a candidate.
391    AccountRotation {
392        /// Stable provider identifier such as `anthropic`.
393        provider: String,
394        /// Bounded opaque local identifier or safe display label.
395        from_account: String,
396        /// Bounded opaque local identifier or safe display label.
397        to_account: String,
398        /// Account-local failure which initiated the handoff.
399        reason: AccountRotationReason,
400        /// Provider Retry-After, rounded up and capped by the host.
401        #[serde(default, skip_serializing_if = "Option::is_none")]
402        retry_after_seconds: Option<u32>,
403    },
404
405    /// Every OAuth account for one provider is temporarily unavailable.
406    ///
407    /// The retry delay is the longest remaining per-account Retry-After so a
408    /// consumer can preserve retryable semantics and render an honest
409    /// countdown without parsing provider log text.
410    AccountPoolExhausted {
411        /// Stable provider identifier such as `anthropic`.
412        provider: String,
413        /// Number of accounts considered unavailable in the pool.
414        account_count: u32,
415        /// Account-local failure which exhausted the pool.
416        reason: AccountRotationReason,
417        /// Longest remaining Retry-After, rounded up and capped by the host.
418        #[serde(default, skip_serializing_if = "Option::is_none")]
419        retry_after_seconds: Option<u32>,
420    },
421
422    /// The model refused the request (safety/policy).
423    Refusal {
424        message_id: String,
425        text: Option<String>,
426    },
427
428    /// The run was cancelled via its [`CancellationToken`].
429    ///
430    /// This is a **terminal** event, emitted exactly once on every
431    /// cancellation return site (mirroring [`AgentEvent::Done`] and
432    /// [`AgentEvent::Refusal`]). Cancellation can land at the top of a
433    /// turn, mid-stream while the model is still producing tokens,
434    /// while a tool is in flight, or during context compaction — in
435    /// every case the run closes with this event so a streaming
436    /// consumer receives a closing marker and never hangs waiting for
437    /// `Done`.
438    ///
439    /// `turn` is the turn number reached when the cancel was honored
440    /// and `usage` is the partial token usage accumulated so far.
441    ///
442    /// [`CancellationToken`]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
443    Cancelled {
444        turn: usize,
445        usage: TokenUsage,
446        /// Typed explanation for why the durable task was cancelled.
447        #[serde(default, skip_serializing_if = "Option::is_none")]
448        reason: Option<TerminalReason>,
449        /// Durable task that committed this event — the cancelled
450        /// root, not the promoted successor. See
451        /// [`AgentEvent::with_emitter_task_id`].
452        #[serde(default, skip_serializing_if = "Option::is_none")]
453        emitter_task_id: Option<String>,
454    },
455
456    /// Context was compacted to reduce size
457    ContextCompacted {
458        /// Number of messages before compaction
459        original_count: usize,
460        /// Number of messages after compaction
461        new_count: usize,
462        /// Estimated tokens before compaction
463        original_tokens: usize,
464        /// Estimated tokens after compaction
465        new_tokens: usize,
466    },
467
468    /// Progress update from a running subagent
469    SubagentProgress {
470        /// ID of the parent tool call that spawned this subagent
471        subagent_id: String,
472        /// Name of the subagent (e.g., "explore", "plan")
473        subagent_name: String,
474        /// Human-friendly nickname assigned by the parent (e.g., "Zara")
475        nickname: Option<String>,
476        /// Durable child thread reference, when available.
477        child_thread_id: Option<ThreadId>,
478        /// Durable child root task reference, when available.
479        child_root_task_id: Option<String>,
480        /// Durable parent-visible invocation task reference, when available.
481        subagent_task_id: Option<String>,
482        /// Maximum turns configured for this subagent
483        max_turns: Option<u32>,
484        /// Current turn number of the subagent
485        current_turn: Option<u32>,
486        /// Model being used by the subagent
487        model: Option<String>,
488        /// Summary label associated with the latest subagent update.
489        tool_name: String,
490        /// Brief context associated with the latest subagent update.
491        tool_context: String,
492        /// Whether the summarized update represents terminal completion.
493        completed: bool,
494        /// Whether the subagent succeeded (only meaningful if completed)
495        success: bool,
496        /// Current total tool count for this subagent
497        tool_count: u32,
498        /// Current total tokens used by this subagent (input + output).
499        total_tokens: u64,
500        /// Cumulative input tokens used by the child thread.
501        #[serde(default)]
502        input_tokens: u64,
503        /// Cumulative output tokens used by the child thread.
504        #[serde(default)]
505        output_tokens: u64,
506        /// Cumulative cached-input tokens read by the child thread.
507        #[serde(default)]
508        cache_read_input_tokens: u64,
509        /// Cumulative cache-creation input tokens used by the child thread.
510        #[serde(default)]
511        cache_creation_input_tokens: u64,
512    },
513}
514
515impl AgentEvent {
516    #[must_use]
517    pub const fn thread_created(
518        thread_id: ThreadId,
519        source_thread_id: Option<ThreadId>,
520        fork_after_committed_turns: Option<u32>,
521    ) -> Self {
522        Self::ThreadCreated {
523            thread_id,
524            source_thread_id,
525            fork_after_committed_turns,
526        }
527    }
528
529    #[must_use]
530    pub const fn start(thread_id: ThreadId, turn: usize) -> Self {
531        Self::Start {
532            thread_id,
533            turn,
534            emitter_task_id: None,
535        }
536    }
537
538    /// Attribute an event to the durable task whose execution
539    /// committed it (the task id in its string form).
540    ///
541    /// The lifecycle variants (`Start`, `TurnComplete`, `Done`,
542    /// `BudgetExceeded`, `Error`, `Cancelled`) and the content variants
543    /// (`Thinking`, `ThinkingDelta`, `TextDelta`, `Text`) carry the
544    /// attribution; every other variant is returned unchanged.
545    /// Attribution is always the emitter's own identity, never a
546    /// successor's: a cancelled root's late salvage commit — content
547    /// included — still names the cancelled root, which is what lets a
548    /// reader tell a superseded frame apart from the thread's live one
549    /// (and lets the ACP run loop drop a predecessor's stale deltas
550    /// instead of rendering them inside the successor's answer).
551    ///
552    /// Runs without a durable task behind them (the embedded SDK loop)
553    /// leave the field `None`, as do events journaled before the field
554    /// existed.
555    #[must_use]
556    pub fn with_emitter_task_id(mut self, task_id: impl Into<String>) -> Self {
557        let task_id = task_id.into();
558        match &mut self {
559            Self::Start {
560                emitter_task_id, ..
561            }
562            | Self::UserInput {
563                emitter_task_id, ..
564            }
565            | Self::TurnComplete {
566                emitter_task_id, ..
567            }
568            | Self::Done {
569                emitter_task_id, ..
570            }
571            | Self::BudgetExceeded {
572                emitter_task_id, ..
573            }
574            | Self::Error {
575                emitter_task_id, ..
576            }
577            | Self::Cancelled {
578                emitter_task_id, ..
579            }
580            | Self::Thinking {
581                emitter_task_id, ..
582            }
583            | Self::ThinkingDelta {
584                emitter_task_id, ..
585            }
586            | Self::TextDelta {
587                emitter_task_id, ..
588            }
589            | Self::Text {
590                emitter_task_id, ..
591            } => *emitter_task_id = Some(task_id),
592            _ => {}
593        }
594        self
595    }
596
597    /// The durable task that committed this event, when the event is a
598    /// lifecycle or content variant that was stamped. See
599    /// [`AgentEvent::with_emitter_task_id`].
600    #[must_use]
601    pub fn emitter_task_id(&self) -> Option<&str> {
602        match self {
603            Self::Start {
604                emitter_task_id, ..
605            }
606            | Self::UserInput {
607                emitter_task_id, ..
608            }
609            | Self::TurnComplete {
610                emitter_task_id, ..
611            }
612            | Self::Done {
613                emitter_task_id, ..
614            }
615            | Self::BudgetExceeded {
616                emitter_task_id, ..
617            }
618            | Self::Error {
619                emitter_task_id, ..
620            }
621            | Self::Cancelled {
622                emitter_task_id, ..
623            }
624            | Self::Thinking {
625                emitter_task_id, ..
626            }
627            | Self::ThinkingDelta {
628                emitter_task_id, ..
629            }
630            | Self::TextDelta {
631                emitter_task_id, ..
632            }
633            | Self::Text {
634                emitter_task_id, ..
635            } => emitter_task_id.as_deref(),
636            _ => None,
637        }
638    }
639
640    #[must_use]
641    pub const fn user_input(thread_id: ThreadId, content: Vec<ContentBlock>) -> Self {
642        Self::UserInput {
643            thread_id,
644            content,
645            emitter_task_id: None,
646        }
647    }
648
649    #[must_use]
650    pub fn thinking(message_id: impl Into<String>, text: impl Into<String>) -> Self {
651        Self::Thinking {
652            message_id: message_id.into(),
653            text: text.into(),
654            emitter_task_id: None,
655        }
656    }
657
658    #[must_use]
659    pub fn thinking_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
660        Self::ThinkingDelta {
661            message_id: message_id.into(),
662            delta: delta.into(),
663            emitter_task_id: None,
664        }
665    }
666
667    #[must_use]
668    pub fn text_delta(message_id: impl Into<String>, delta: impl Into<String>) -> Self {
669        Self::TextDelta {
670            message_id: message_id.into(),
671            delta: delta.into(),
672            emitter_task_id: None,
673        }
674    }
675
676    #[must_use]
677    pub fn text(message_id: impl Into<String>, text: impl Into<String>) -> Self {
678        Self::Text {
679            message_id: message_id.into(),
680            text: text.into(),
681            emitter_task_id: None,
682        }
683    }
684
685    #[must_use]
686    pub fn tool_call_start(
687        id: impl Into<String>,
688        name: impl Into<String>,
689        display_name: impl Into<String>,
690        input: serde_json::Value,
691        tier: ToolTier,
692    ) -> Self {
693        Self::ToolCallStart {
694            id: id.into(),
695            name: name.into(),
696            display_name: display_name.into(),
697            input,
698            tier,
699        }
700    }
701
702    #[must_use]
703    pub fn tool_call_end(
704        id: impl Into<String>,
705        name: impl Into<String>,
706        display_name: impl Into<String>,
707        result: ToolResult,
708    ) -> Self {
709        Self::ToolCallEnd {
710            id: id.into(),
711            name: name.into(),
712            display_name: display_name.into(),
713            result,
714        }
715    }
716
717    #[must_use]
718    pub fn tool_progress(
719        id: impl Into<String>,
720        name: impl Into<String>,
721        display_name: impl Into<String>,
722        stage: impl Into<String>,
723        message: impl Into<String>,
724        data: Option<serde_json::Value>,
725    ) -> Self {
726        Self::ToolProgress {
727            id: id.into(),
728            name: name.into(),
729            display_name: display_name.into(),
730            stage: stage.into(),
731            message: message.into(),
732            data,
733        }
734    }
735
736    #[must_use]
737    pub fn tool_requires_confirmation(
738        id: impl Into<String>,
739        name: impl Into<String>,
740        display_name: impl Into<String>,
741        input: serde_json::Value,
742        description: impl Into<String>,
743    ) -> Self {
744        Self::ToolRequiresConfirmation {
745            id: id.into(),
746            name: name.into(),
747            display_name: display_name.into(),
748            input,
749            description: description.into(),
750        }
751    }
752
753    /// Build the durable event that asks a client to render the parked
754    /// question batch.
755    #[must_use]
756    pub fn question_asked(
757        task_id: impl Into<String>,
758        questions: Vec<crate::QuestionPayload>,
759    ) -> Self {
760        Self::QuestionAsked {
761            task_id: task_id.into(),
762            questions,
763        }
764    }
765
766    #[must_use]
767    pub const fn turn_complete(turn: usize, usage: TokenUsage) -> Self {
768        Self::TurnComplete {
769            turn,
770            usage,
771            emitter_task_id: None,
772        }
773    }
774
775    #[must_use]
776    pub const fn done(
777        thread_id: ThreadId,
778        total_turns: usize,
779        total_usage: TokenUsage,
780        duration: Duration,
781    ) -> Self {
782        Self::Done {
783            thread_id,
784            total_turns,
785            total_usage,
786            duration,
787            estimated_cost_usd: None,
788            emitter_task_id: None,
789        }
790    }
791
792    #[must_use]
793    pub const fn done_with_cost(
794        thread_id: ThreadId,
795        total_turns: usize,
796        total_usage: TokenUsage,
797        duration: Duration,
798        estimated_cost_usd: Option<f64>,
799    ) -> Self {
800        Self::Done {
801            thread_id,
802            total_turns,
803            total_usage,
804            duration,
805            estimated_cost_usd,
806            emitter_task_id: None,
807        }
808    }
809
810    #[must_use]
811    pub const fn budget_exceeded(
812        thread_id: ThreadId,
813        total_turns: usize,
814        total_usage: TokenUsage,
815        duration: Duration,
816        estimated_cost_usd: Option<f64>,
817        limit: BudgetLimitKind,
818    ) -> Self {
819        Self::BudgetExceeded {
820            thread_id,
821            total_turns,
822            total_usage,
823            duration,
824            estimated_cost_usd,
825            limit,
826            emitter_task_id: None,
827        }
828    }
829
830    #[must_use]
831    pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
832        Self::Error {
833            message: message.into(),
834            recoverable,
835            reason: None,
836            emitter_task_id: None,
837        }
838    }
839
840    /// Build a terminal error event with its durable task reason attached.
841    #[must_use]
842    pub fn terminal_error(message: impl Into<String>, reason: TerminalReason) -> Self {
843        Self::Error {
844            message: message.into(),
845            recoverable: false,
846            reason: Some(reason),
847            emitter_task_id: None,
848        }
849    }
850
851    #[must_use]
852    pub fn refusal(message_id: impl Into<String>, text: Option<String>) -> Self {
853        Self::Refusal {
854            message_id: message_id.into(),
855            text,
856        }
857    }
858
859    #[must_use]
860    pub const fn cancelled(turn: usize, usage: TokenUsage) -> Self {
861        Self::cancelled_with_reason(turn, usage, TerminalReason::UserCancel)
862    }
863
864    /// Build a terminal cancellation event with its durable task reason.
865    #[must_use]
866    pub const fn cancelled_with_reason(
867        turn: usize,
868        usage: TokenUsage,
869        reason: TerminalReason,
870    ) -> Self {
871        Self::Cancelled {
872            turn,
873            usage,
874            reason: Some(reason),
875            emitter_task_id: None,
876        }
877    }
878
879    #[must_use]
880    pub const fn context_compacted(
881        original_count: usize,
882        new_count: usize,
883        original_tokens: usize,
884        new_tokens: usize,
885    ) -> Self {
886        Self::ContextCompacted {
887            original_count,
888            new_count,
889            original_tokens,
890            new_tokens,
891        }
892    }
893}
894
895/// Monotonically increasing per-run counter for event ordering.
896///
897/// Each `run()` or `run_turn()` call creates a fresh counter starting at 0.
898/// The counter is `Arc`-wrapped so it can be shared across tasks (e.g., subagent
899/// progress events sent from child tokio tasks).
900///
901/// `Ordering::Relaxed` is sufficient because the mpsc channel provides the
902/// happens-before ordering guarantee between sender and receiver.
903#[derive(Clone, Debug)]
904pub struct SequenceCounter(Arc<AtomicU64>);
905
906impl SequenceCounter {
907    /// Create a new counter starting at 0.
908    #[must_use]
909    pub fn new() -> Self {
910        Self(Arc::new(AtomicU64::new(0)))
911    }
912
913    /// Create a counter starting at the given offset.
914    ///
915    /// Used by server mode to resume sequencing across turns within
916    /// the same thread — the server seeds the counter with the last
917    /// known sequence value so numbering is continuous.
918    #[must_use]
919    pub fn with_offset(start: u64) -> Self {
920        Self(Arc::new(AtomicU64::new(start)))
921    }
922
923    /// Get the next sequence number, incrementing the counter.
924    #[must_use]
925    pub fn next(&self) -> u64 {
926        self.0.fetch_add(1, Ordering::Relaxed)
927    }
928}
929
930impl Default for SequenceCounter {
931    fn default() -> Self {
932        Self::new()
933    }
934}
935
936/// Envelope wrapping every [`AgentEvent`] with idempotency metadata.
937///
938/// Mobile clients can use `event_id` for deduplication on retry, `sequence`
939/// for ordering after persistence, and `timestamp` for display.
940///
941/// The `event` field is flattened in JSON so that `event_id`, `sequence`,
942/// `timestamp`, and the event's `type` discriminant all appear at the same level.
943#[derive(Clone, Debug, Serialize, Deserialize)]
944pub struct AgentEventEnvelope {
945    /// Unique identifier for this event emission.
946    ///
947    /// UUID v4 when created via [`AgentEventEnvelope::wrap`] (SDK-local path),
948    /// UUID v7 when created via server-committed `CommittedEvent::into_envelope`.
949    pub event_id: uuid::Uuid,
950    /// Monotonically increasing sequence number within a single run.
951    pub sequence: u64,
952    /// UTC timestamp of when the event was emitted.
953    #[serde(with = "time::serde::rfc3339")]
954    pub timestamp: OffsetDateTime,
955    /// The actual event payload.
956    #[serde(flatten)]
957    pub event: AgentEvent,
958}
959
960impl AgentEventEnvelope {
961    /// Wrap an [`AgentEvent`] in an envelope, assigning it a unique ID,
962    /// the next sequence number, and the current UTC timestamp.
963    #[must_use]
964    pub fn wrap(event: AgentEvent, seq: &SequenceCounter) -> Self {
965        Self {
966            event_id: uuid::Uuid::new_v4(),
967            sequence: seq.next(),
968            timestamp: OffsetDateTime::now_utc(),
969            event,
970        }
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977    use std::collections::HashSet;
978
979    // ===================
980    // SequenceCounter
981    // ===================
982
983    #[test]
984    fn sequence_counter_starts_at_zero() {
985        let seq = SequenceCounter::new();
986        assert_eq!(seq.next(), 0);
987    }
988
989    #[test]
990    fn sequence_counter_increments_monotonically() {
991        let seq = SequenceCounter::new();
992        for expected in 0..100 {
993            assert_eq!(seq.next(), expected);
994        }
995    }
996
997    #[test]
998    fn sequence_counter_no_gaps() {
999        let seq = SequenceCounter::new();
1000        let values: Vec<u64> = (0..50).map(|_| seq.next()).collect();
1001        let expected: Vec<u64> = (0..50).collect();
1002        assert_eq!(values, expected);
1003    }
1004
1005    #[test]
1006    fn sequence_counter_clones_share_state() {
1007        let seq = SequenceCounter::new();
1008        let clone = seq.clone();
1009
1010        assert_eq!(seq.next(), 0);
1011        assert_eq!(clone.next(), 1);
1012        assert_eq!(seq.next(), 2);
1013    }
1014
1015    #[test]
1016    fn sequence_counter_default_starts_at_zero() {
1017        let seq = SequenceCounter::default();
1018        assert_eq!(seq.next(), 0);
1019    }
1020
1021    #[test]
1022    fn sequence_counter_with_offset_starts_at_given_value() {
1023        let seq = SequenceCounter::with_offset(42);
1024        assert_eq!(seq.next(), 42);
1025        assert_eq!(seq.next(), 43);
1026        assert_eq!(seq.next(), 44);
1027    }
1028
1029    #[test]
1030    fn sequence_counter_with_offset_zero_same_as_new() {
1031        let seq = SequenceCounter::with_offset(0);
1032        assert_eq!(seq.next(), 0);
1033        assert_eq!(seq.next(), 1);
1034    }
1035
1036    #[tokio::test]
1037    async fn sequence_counter_unique_across_concurrent_tasks() {
1038        let seq = SequenceCounter::new();
1039        let n = 1000;
1040
1041        let mut handles = Vec::new();
1042        for _ in 0..n {
1043            let seq_clone = seq.clone();
1044            handles.push(tokio::spawn(async move { seq_clone.next() }));
1045        }
1046
1047        let mut values = HashSet::new();
1048        for handle in handles {
1049            let val = handle.await.unwrap();
1050            assert!(values.insert(val), "duplicate sequence number: {val}");
1051        }
1052
1053        assert_eq!(values.len(), n);
1054        // All values should be in [0, n)
1055        for v in &values {
1056            assert!(*v < n as u64);
1057        }
1058    }
1059
1060    // ===================
1061    // AgentEventEnvelope
1062    // ===================
1063
1064    fn sample_event() -> AgentEvent {
1065        AgentEvent::text("msg_1", "hello")
1066    }
1067
1068    #[test]
1069    fn wrap_assigns_unique_event_ids() {
1070        let seq = SequenceCounter::new();
1071        let ids: HashSet<uuid::Uuid> = (0..100)
1072            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq).event_id)
1073            .collect();
1074        assert_eq!(ids.len(), 100);
1075    }
1076
1077    #[test]
1078    fn wrap_event_id_is_valid_uuid_v4() {
1079        let seq = SequenceCounter::new();
1080        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1081        assert_eq!(envelope.event_id.get_version(), Some(uuid::Version::Random));
1082    }
1083
1084    #[test]
1085    fn wrap_assigns_incrementing_sequences() {
1086        let seq = SequenceCounter::new();
1087        let envelopes: Vec<AgentEventEnvelope> = (0..10)
1088            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
1089            .collect();
1090
1091        for (i, env) in envelopes.iter().enumerate() {
1092            assert_eq!(env.sequence, i as u64);
1093        }
1094    }
1095
1096    #[test]
1097    fn wrap_timestamps_are_non_decreasing() {
1098        let seq = SequenceCounter::new();
1099        let envelopes: Vec<AgentEventEnvelope> = (0..20)
1100            .map(|_| AgentEventEnvelope::wrap(sample_event(), &seq))
1101            .collect();
1102
1103        for pair in envelopes.windows(2) {
1104            assert!(pair[1].timestamp >= pair[0].timestamp);
1105        }
1106    }
1107
1108    #[test]
1109    fn wrap_preserves_inner_event() {
1110        let seq = SequenceCounter::new();
1111        let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_42", "content"), &seq);
1112        match &envelope.event {
1113            AgentEvent::Text {
1114                message_id, text, ..
1115            } => {
1116                assert_eq!(message_id, "msg_42");
1117                assert_eq!(text, "content");
1118            }
1119            other => panic!("expected Text, got {other:?}"),
1120        }
1121    }
1122
1123    #[test]
1124    fn separate_counters_produce_independent_sequences() {
1125        let seq_a = SequenceCounter::new();
1126        let seq_b = SequenceCounter::new();
1127
1128        let a0 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1129        let b0 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1130        let a1 = AgentEventEnvelope::wrap(sample_event(), &seq_a);
1131        let b1 = AgentEventEnvelope::wrap(sample_event(), &seq_b);
1132
1133        // Both start at 0 independently
1134        assert_eq!(a0.sequence, 0);
1135        assert_eq!(b0.sequence, 0);
1136        assert_eq!(a1.sequence, 1);
1137        assert_eq!(b1.sequence, 1);
1138
1139        // But event_ids are still globally unique
1140        let ids: HashSet<uuid::Uuid> = [&a0, &b0, &a1, &b1].iter().map(|e| e.event_id).collect();
1141        assert_eq!(ids.len(), 4);
1142    }
1143
1144    // ===================
1145    // Serialization
1146    // ===================
1147
1148    #[test]
1149    fn envelope_serializes_flat_json() {
1150        let seq = SequenceCounter::new();
1151        let envelope = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hi"), &seq);
1152        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1153
1154        // Top-level fields from the envelope
1155        assert!(json.get("event_id").is_some());
1156        assert!(json.get("sequence").is_some());
1157        assert!(json.get("timestamp").is_some());
1158
1159        // Flattened event fields at the same level
1160        assert_eq!(json.get("type").and_then(|v| v.as_str()), Some("text"));
1161        assert_eq!(
1162            json.get("message_id").and_then(|v| v.as_str()),
1163            Some("msg_1")
1164        );
1165        assert_eq!(json.get("text").and_then(|v| v.as_str()), Some("hi"));
1166
1167        // No nested "event" key
1168        assert!(json.get("event").is_none());
1169    }
1170
1171    #[test]
1172    fn envelope_event_id_does_not_collide_with_tool_id() {
1173        let seq = SequenceCounter::new();
1174        let envelope = AgentEventEnvelope::wrap(
1175            AgentEvent::tool_call_start(
1176                "tool_123",
1177                "bash",
1178                "Bash",
1179                serde_json::json!({}),
1180                ToolTier::Observe,
1181            ),
1182            &seq,
1183        );
1184        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1185
1186        // Both `event_id` and tool `id` are present and distinct
1187        let event_id = json.get("event_id").and_then(|v| v.as_str()).unwrap();
1188        let tool_id = json.get("id").and_then(|v| v.as_str()).unwrap();
1189        assert_ne!(event_id, tool_id);
1190        assert_eq!(tool_id, "tool_123");
1191    }
1192
1193    #[test]
1194    fn envelope_roundtrip_serde() {
1195        let seq = SequenceCounter::new();
1196        let original = AgentEventEnvelope::wrap(AgentEvent::text("msg_1", "hello"), &seq);
1197
1198        let json_str = serde_json::to_string(&original).expect("serialize");
1199        let restored: AgentEventEnvelope = serde_json::from_str(&json_str).expect("deserialize");
1200
1201        assert_eq!(restored.event_id, original.event_id);
1202        assert_eq!(restored.sequence, original.sequence);
1203        assert_eq!(restored.timestamp, original.timestamp);
1204        match &restored.event {
1205            AgentEvent::Text {
1206                message_id, text, ..
1207            } => {
1208                assert_eq!(message_id, "msg_1");
1209                assert_eq!(text, "hello");
1210            }
1211            other => panic!("expected Text, got {other:?}"),
1212        }
1213    }
1214
1215    #[test]
1216    fn account_pool_events_roundtrip_their_stable_wire_shape() {
1217        let rotation = AgentEvent::AccountRotation {
1218            provider: "anthropic".to_owned(),
1219            from_account: "account-a".to_owned(),
1220            to_account: "account-b".to_owned(),
1221            reason: AccountRotationReason::RateLimited,
1222            retry_after_seconds: Some(42),
1223        };
1224        let json = serde_json::to_value(&rotation).expect("serialize rotation");
1225        assert_eq!(
1226            json,
1227            serde_json::json!({
1228                "type": "account_rotation",
1229                "provider": "anthropic",
1230                "from_account": "account-a",
1231                "to_account": "account-b",
1232                "reason": "rate_limited",
1233                "retry_after_seconds": 42,
1234            })
1235        );
1236        let restored: AgentEvent = serde_json::from_value(json).expect("deserialize rotation");
1237        assert!(matches!(
1238            restored,
1239            AgentEvent::AccountRotation {
1240                reason: AccountRotationReason::RateLimited,
1241                retry_after_seconds: Some(42),
1242                ..
1243            }
1244        ));
1245
1246        let exhausted = AgentEvent::AccountPoolExhausted {
1247            provider: "anthropic".to_owned(),
1248            account_count: 3,
1249            reason: AccountRotationReason::RateLimited,
1250            retry_after_seconds: Some(90),
1251        };
1252        let json = serde_json::to_value(&exhausted).expect("serialize exhaustion");
1253        assert_eq!(
1254            json,
1255            serde_json::json!({
1256                "type": "account_pool_exhausted",
1257                "provider": "anthropic",
1258                "account_count": 3,
1259                "reason": "rate_limited",
1260                "retry_after_seconds": 90,
1261            })
1262        );
1263        let restored: AgentEvent = serde_json::from_value(json).expect("deserialize exhaustion");
1264        assert!(matches!(
1265            restored,
1266            AgentEvent::AccountPoolExhausted {
1267                account_count: 3,
1268                retry_after_seconds: Some(90),
1269                ..
1270            }
1271        ));
1272    }
1273
1274    #[test]
1275    fn account_rotation_reason_has_a_forward_compatible_unknown_sink() {
1276        let event: AgentEvent = serde_json::from_value(serde_json::json!({
1277            "type": "account_rotation",
1278            "provider": "anthropic",
1279            "from_account": "account-a",
1280            "to_account": "account-b",
1281            "reason": "provider_policy",
1282        }))
1283        .expect("unknown reason remains replayable");
1284
1285        assert!(matches!(
1286            event,
1287            AgentEvent::AccountRotation {
1288                reason: AccountRotationReason::Unknown,
1289                retry_after_seconds: None,
1290                ..
1291            }
1292        ));
1293    }
1294
1295    #[test]
1296    fn envelope_sequence_is_u64_in_json() {
1297        let seq = SequenceCounter::new();
1298        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1299        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1300
1301        assert!(json.get("sequence").unwrap().is_u64());
1302        assert_eq!(json.get("sequence").unwrap().as_u64(), Some(0));
1303    }
1304
1305    #[test]
1306    fn envelope_timestamp_is_rfc3339_string() {
1307        let seq = SequenceCounter::new();
1308        let envelope = AgentEventEnvelope::wrap(sample_event(), &seq);
1309        let json: serde_json::Value = serde_json::to_value(&envelope).expect("serialize");
1310
1311        let ts_str = json.get("timestamp").unwrap().as_str().unwrap();
1312        // Should parse as RFC 3339
1313        time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
1314            .expect("timestamp should be valid RFC 3339");
1315    }
1316
1317    #[test]
1318    fn done_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1319        let seq = SequenceCounter::new();
1320        let envelope = AgentEventEnvelope::wrap(
1321            AgentEvent::done(
1322                ThreadId::from_string("t"),
1323                3,
1324                TokenUsage::default(),
1325                Duration::from_millis(2500),
1326            ),
1327            &seq,
1328        );
1329        let json = serde_json::to_value(&envelope)?;
1330
1331        // Flat millisecond integer, matching `TurnSummary::duration_ms` — not
1332        // the old nested `{"secs":..,"nanos":..}` object under `duration`.
1333        assert_eq!(
1334            json.get("duration_ms").and_then(serde_json::Value::as_u64),
1335            Some(2500)
1336        );
1337        assert!(
1338            json.get("duration").is_none(),
1339            "old `duration` key must be gone: {json}"
1340        );
1341
1342        let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1343        match restored.event {
1344            AgentEvent::Done { duration, .. } => {
1345                assert_eq!(duration, Duration::from_millis(2500));
1346            }
1347            other => panic!("expected Done, got {other:?}"),
1348        }
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn done_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1354        // Durable rows written before the wire form changed to `duration_ms`
1355        // carry serde's default object under the old `duration` key. Hosts
1356        // replay them with `serde_json::from_value`; they must keep
1357        // decoding after an upgrade.
1358        let legacy = serde_json::json!({
1359            "type": "done",
1360            "thread_id": "t-legacy",
1361            "total_turns": 3,
1362            "total_usage": TokenUsage::default(),
1363            "duration": { "secs": 2, "nanos": 500_000_000 },
1364        });
1365        let event: AgentEvent = serde_json::from_value(legacy)?;
1366        match event {
1367            AgentEvent::Done {
1368                duration,
1369                total_turns,
1370                ..
1371            } => {
1372                assert_eq!(duration, Duration::from_millis(2500));
1373                assert_eq!(total_turns, 3);
1374            }
1375            other => panic!("expected Done, got {other:?}"),
1376        }
1377
1378        // The current flat-millis form decodes too...
1379        let current = serde_json::json!({
1380            "type": "done",
1381            "thread_id": "t-current",
1382            "total_turns": 3,
1383            "total_usage": TokenUsage::default(),
1384            "duration_ms": 2500,
1385        });
1386        let event: AgentEvent = serde_json::from_value(current)?;
1387        let AgentEvent::Done { duration, .. } = event else {
1388            panic!("expected Done");
1389        };
1390        assert_eq!(duration, Duration::from_millis(2500));
1391
1392        // ...and a re-serialized legacy event is normalized to millis.
1393        let legacy_event: AgentEvent = serde_json::from_value(serde_json::json!({
1394            "type": "done",
1395            "thread_id": "t-roundtrip",
1396            "total_turns": 1,
1397            "total_usage": TokenUsage::default(),
1398            "duration": { "secs": 1, "nanos": 0 },
1399        }))?;
1400        let reserialized = serde_json::to_value(&legacy_event)?;
1401        assert_eq!(
1402            reserialized
1403                .get("duration_ms")
1404                .and_then(serde_json::Value::as_u64),
1405            Some(1000),
1406            "round-trips must write the millis form: {reserialized}"
1407        );
1408        assert!(reserialized.get("duration").is_none());
1409        Ok(())
1410    }
1411
1412    #[test]
1413    fn terminal_reason_round_trips_provider_kind_and_accepts_future_variants()
1414    -> serde_json::Result<()> {
1415        let provider = TerminalReason::ProviderError {
1416            kind: "rate_limited".to_owned(),
1417        };
1418        let encoded = serde_json::to_value(&provider)?;
1419        assert_eq!(
1420            encoded,
1421            serde_json::json!({
1422                "reason": "provider_error",
1423                "kind": "rate_limited",
1424            }),
1425        );
1426        let restored: TerminalReason = serde_json::from_value(encoded)?;
1427        assert_eq!(restored, provider);
1428
1429        let future: TerminalReason = serde_json::from_value(serde_json::json!({
1430            "reason": "provider_shutdown",
1431            "retryable": false,
1432        }))?;
1433        assert_eq!(future, TerminalReason::Unknown);
1434        Ok(())
1435    }
1436
1437    #[test]
1438    fn subagent_progress_deserializes_legacy_rows_without_usage_breakdown() -> serde_json::Result<()>
1439    {
1440        let legacy = serde_json::json!({
1441            "type": "subagent_progress",
1442            "subagent_id": "call-1",
1443            "subagent_name": "explore",
1444            "nickname": null,
1445            "child_thread_id": null,
1446            "child_root_task_id": null,
1447            "subagent_task_id": null,
1448            "max_turns": 3,
1449            "current_turn": 1,
1450            "model": "mock",
1451            "tool_name": "explore",
1452            "tool_context": "inspect",
1453            "completed": false,
1454            "success": false,
1455            "tool_count": 0,
1456            "total_tokens": 12,
1457        });
1458        let event: AgentEvent = serde_json::from_value(legacy)?;
1459        match event {
1460            AgentEvent::SubagentProgress {
1461                total_tokens,
1462                input_tokens,
1463                output_tokens,
1464                cache_read_input_tokens,
1465                cache_creation_input_tokens,
1466                ..
1467            } => {
1468                assert_eq!(total_tokens, 12);
1469                assert_eq!(input_tokens, 0);
1470                assert_eq!(output_tokens, 0);
1471                assert_eq!(cache_read_input_tokens, 0);
1472                assert_eq!(cache_creation_input_tokens, 0);
1473            }
1474            other => panic!("expected SubagentProgress, got {other:?}"),
1475        }
1476        Ok(())
1477    }
1478
1479    #[test]
1480    fn budget_exceeded_event_deserializes_legacy_duration_object() -> serde_json::Result<()> {
1481        // BudgetExceeded has no pre-rename durable rows, but the field uses
1482        // the same adapter — keep it uniformly lenient.
1483        let legacy = serde_json::json!({
1484            "type": "budget_exceeded",
1485            "thread_id": "t-legacy",
1486            "total_turns": 2,
1487            "total_usage": TokenUsage::default(),
1488            "duration": { "secs": 1, "nanos": 250_000_000 },
1489            "limit": "total_tokens",
1490        });
1491        let event: AgentEvent = serde_json::from_value(legacy)?;
1492        let AgentEvent::BudgetExceeded { duration, .. } = event else {
1493            panic!("expected BudgetExceeded");
1494        };
1495        assert_eq!(duration, Duration::from_millis(1250));
1496        Ok(())
1497    }
1498
1499    #[test]
1500    fn budget_exceeded_event_serializes_duration_as_millis() -> serde_json::Result<()> {
1501        let seq = SequenceCounter::new();
1502        let envelope = AgentEventEnvelope::wrap(
1503            AgentEvent::budget_exceeded(
1504                ThreadId::from_string("t"),
1505                2,
1506                TokenUsage::default(),
1507                Duration::from_millis(1200),
1508                Some(0.5),
1509                BudgetLimitKind::TotalTokens,
1510            ),
1511            &seq,
1512        );
1513        let json = serde_json::to_value(&envelope)?;
1514
1515        // Flat millisecond integer, mirroring the `Done` wire form.
1516        assert_eq!(
1517            json.get("duration_ms").and_then(serde_json::Value::as_u64),
1518            Some(1200)
1519        );
1520        assert!(
1521            json.get("duration").is_none(),
1522            "no nested `duration` key expected: {json}"
1523        );
1524
1525        let restored: AgentEventEnvelope = serde_json::from_value(json)?;
1526        match restored.event {
1527            AgentEvent::BudgetExceeded { duration, .. } => {
1528                assert_eq!(duration, Duration::from_millis(1200));
1529            }
1530            other => panic!("expected BudgetExceeded, got {other:?}"),
1531        }
1532        Ok(())
1533    }
1534
1535    /// One representative value of every [`AgentEvent`] variant, so the
1536    /// envelope round-trip test exercises the full streaming contract.
1537    ///
1538    /// The variants are produced by a few cohesive builders concatenated
1539    /// in the original order, so the round-trip test still sees the exact
1540    /// same set of values.
1541    fn sample_all_variants() -> Vec<AgentEvent> {
1542        let thread = ThreadId::from_string("thread-1");
1543        let usage = TokenUsage::default();
1544        let mut events = session_open_events(&thread);
1545        events.extend(streamed_content_events());
1546        events.extend(tool_call_events());
1547        events.extend(turn_completion_events(&thread, &usage));
1548        events.extend(failure_and_retry_events());
1549        events.extend(auxiliary_events(&usage));
1550        events
1551    }
1552
1553    /// `Start` / `UserInput`: the events opening a thread turn.
1554    fn session_open_events(thread: &ThreadId) -> Vec<AgentEvent> {
1555        vec![
1556            AgentEvent::ThreadCreated {
1557                thread_id: thread.clone(),
1558                source_thread_id: None,
1559                fork_after_committed_turns: None,
1560            },
1561            AgentEvent::Start {
1562                thread_id: thread.clone(),
1563                turn: 1,
1564                emitter_task_id: Some("task-start".into()),
1565            },
1566            AgentEvent::UserInput {
1567                thread_id: thread.clone(),
1568                content: vec![ContentBlock::Text { text: "hi".into() }],
1569                emitter_task_id: None,
1570            },
1571        ]
1572    }
1573
1574    /// Streamed assistant content: consolidated and delta forms of
1575    /// thinking and text.
1576    fn streamed_content_events() -> Vec<AgentEvent> {
1577        vec![
1578            AgentEvent::Thinking {
1579                message_id: "m".into(),
1580                text: "t".into(),
1581                emitter_task_id: None,
1582            },
1583            AgentEvent::ThinkingDelta {
1584                message_id: "m".into(),
1585                delta: "d".into(),
1586                emitter_task_id: None,
1587            },
1588            AgentEvent::TextDelta {
1589                message_id: "m".into(),
1590                delta: "d".into(),
1591                emitter_task_id: None,
1592            },
1593            AgentEvent::Text {
1594                message_id: "m".into(),
1595                text: "t".into(),
1596                emitter_task_id: None,
1597            },
1598        ]
1599    }
1600
1601    /// Tool-call lifecycle: start, end, progress, and confirmation.
1602    fn tool_call_events() -> Vec<AgentEvent> {
1603        vec![
1604            AgentEvent::ToolCallStart {
1605                id: "id".into(),
1606                name: "n".into(),
1607                display_name: "N".into(),
1608                input: serde_json::json!({}),
1609                tier: ToolTier::Observe,
1610            },
1611            AgentEvent::ToolCallEnd {
1612                id: "id".into(),
1613                name: "n".into(),
1614                display_name: "N".into(),
1615                result: ToolResult::success("ok"),
1616            },
1617            AgentEvent::ToolProgress {
1618                id: "id".into(),
1619                name: "n".into(),
1620                display_name: "N".into(),
1621                stage: "s".into(),
1622                message: "m".into(),
1623                data: None,
1624            },
1625            AgentEvent::ToolRequiresConfirmation {
1626                id: "id".into(),
1627                name: "n".into(),
1628                display_name: "N".into(),
1629                input: serde_json::json!({}),
1630                description: "d".into(),
1631            },
1632        ]
1633    }
1634
1635    /// Turn-completion summaries: `TurnComplete` and the terminal `Done`.
1636    fn turn_completion_events(thread: &ThreadId, usage: &TokenUsage) -> Vec<AgentEvent> {
1637        vec![
1638            AgentEvent::TurnComplete {
1639                turn: 1,
1640                usage: usage.clone(),
1641                emitter_task_id: Some("task-turn-complete".into()),
1642            },
1643            AgentEvent::Done {
1644                thread_id: thread.clone(),
1645                total_turns: 2,
1646                total_usage: usage.clone(),
1647                duration: Duration::from_millis(1500),
1648                estimated_cost_usd: Some(0.0123),
1649                emitter_task_id: Some("task-done".into()),
1650            },
1651        ]
1652    }
1653
1654    /// Error and auto-retry signalling events.
1655    fn failure_and_retry_events() -> Vec<AgentEvent> {
1656        vec![
1657            AgentEvent::Error {
1658                message: "e".into(),
1659                recoverable: true,
1660                reason: None,
1661                emitter_task_id: Some("task-error".into()),
1662            },
1663            AgentEvent::AutoRetryStart {
1664                attempt: 1,
1665                max_attempts: 5,
1666                delay_ms: 100,
1667                error_message: "rate limited".into(),
1668            },
1669            AgentEvent::AutoRetryEnd {
1670                attempt: 1,
1671                success: true,
1672                final_error: None,
1673            },
1674        ]
1675    }
1676
1677    /// Remaining auxiliary events: refusal, cancellation, compaction,
1678    /// and subagent progress.
1679    fn auxiliary_events(usage: &TokenUsage) -> Vec<AgentEvent> {
1680        vec![
1681            AgentEvent::Refusal {
1682                message_id: "m".into(),
1683                text: Some("no".into()),
1684            },
1685            AgentEvent::Cancelled {
1686                turn: 1,
1687                usage: usage.clone(),
1688                reason: Some(TerminalReason::UserCancel),
1689                emitter_task_id: Some("task-cancelled".into()),
1690            },
1691            AgentEvent::BudgetExceeded {
1692                thread_id: ThreadId::from_string("thread-1"),
1693                total_turns: 3,
1694                total_usage: usage.clone(),
1695                duration: Duration::from_millis(750),
1696                estimated_cost_usd: Some(0.5),
1697                limit: BudgetLimitKind::CostUsd,
1698                emitter_task_id: Some("task-budget".into()),
1699            },
1700            AgentEvent::ContextCompacted {
1701                original_count: 10,
1702                new_count: 5,
1703                original_tokens: 100,
1704                new_tokens: 50,
1705            },
1706            AgentEvent::SubagentProgress {
1707                subagent_id: "s".into(),
1708                subagent_name: "explore".into(),
1709                nickname: None,
1710                child_thread_id: None,
1711                child_root_task_id: None,
1712                subagent_task_id: None,
1713                max_turns: None,
1714                current_turn: None,
1715                model: None,
1716                tool_name: "t".into(),
1717                tool_context: "c".into(),
1718                completed: false,
1719                success: false,
1720                tool_count: 0,
1721                total_tokens: 0,
1722                input_tokens: 0,
1723                output_tokens: 0,
1724                cache_read_input_tokens: 0,
1725                cache_creation_input_tokens: 0,
1726            },
1727        ]
1728    }
1729
1730    // ===================
1731    // Emitter task identity
1732    // ===================
1733
1734    #[test]
1735    fn emitter_task_id_is_absent_from_journal_rows_written_before_the_field()
1736    -> serde_json::Result<()> {
1737        // Durable rows predating the field carry no `emitter_task_id`
1738        // key; they must decode (as `None`), not fail the whole thread's
1739        // replay.
1740        let legacy = serde_json::json!({
1741            "type": "done",
1742            "thread_id": "t-legacy",
1743            "total_turns": 2,
1744            "total_usage": TokenUsage::default(),
1745            "duration_ms": 1000,
1746        });
1747        let event: AgentEvent = serde_json::from_value(legacy)?;
1748        assert_eq!(event.emitter_task_id(), None);
1749
1750        // And an unstamped event never writes the key, so the wire form
1751        // stays byte-identical for consumers that predate it.
1752        let json = serde_json::to_value(&event)?;
1753        assert!(
1754            json.get("emitter_task_id").is_none(),
1755            "unstamped events must omit the key: {json}"
1756        );
1757        Ok(())
1758    }
1759
1760    #[test]
1761    fn with_emitter_task_id_stamps_every_lifecycle_variant() -> serde_json::Result<()> {
1762        let thread = ThreadId::from_string("t");
1763        let usage = TokenUsage::default();
1764        let lifecycle = vec![
1765            AgentEvent::start(thread.clone(), 1),
1766            AgentEvent::TurnComplete {
1767                turn: 1,
1768                usage: usage.clone(),
1769                emitter_task_id: None,
1770            },
1771            AgentEvent::done(thread.clone(), 1, usage.clone(), Duration::from_secs(1)),
1772            AgentEvent::budget_exceeded(
1773                thread,
1774                1,
1775                usage.clone(),
1776                Duration::from_secs(1),
1777                None,
1778                BudgetLimitKind::TotalTokens,
1779            ),
1780            AgentEvent::error("boom", false),
1781            AgentEvent::cancelled(1, usage),
1782        ];
1783        for event in lifecycle {
1784            let label = format!("{event:?}");
1785            assert_eq!(event.emitter_task_id(), None, "{label}: starts unstamped");
1786
1787            let stamped = event.with_emitter_task_id("task-42");
1788            assert_eq!(stamped.emitter_task_id(), Some("task-42"), "{label}");
1789
1790            let json = serde_json::to_value(&stamped)?;
1791            assert_eq!(
1792                json.get("emitter_task_id")
1793                    .and_then(serde_json::Value::as_str),
1794                Some("task-42"),
1795                "{label}: stamped events carry the key: {json}"
1796            );
1797            let restored: AgentEvent = serde_json::from_value(json)?;
1798            assert_eq!(restored.emitter_task_id(), Some("task-42"), "{label}");
1799        }
1800        Ok(())
1801    }
1802
1803    #[test]
1804    fn with_emitter_task_id_covers_content_but_not_tool_frames() -> serde_json::Result<()> {
1805        // Attribution covers lifecycle AND content variants (ENG-9422:
1806        // adjacency pairing broke exactly when a cancelled attempt's
1807        // salvage flush landed deltas after the successor's Start).
1808        let text = AgentEvent::text("m", "hi").with_emitter_task_id("task-42");
1809        assert_eq!(text.emitter_task_id(), Some("task-42"));
1810
1811        // Unattributed content stays byte-identical on the wire.
1812        let bare = AgentEvent::text("m", "hi");
1813        let json = serde_json::to_value(&bare)?;
1814        assert!(
1815            json.get("emitter_task_id").is_none(),
1816            "unattributed content must not grow the key: {json}"
1817        );
1818
1819        // Tool frames still pair by their own call id, not attribution.
1820        let tool = AgentEvent::tool_call_start(
1821            "t1",
1822            "grep",
1823            "Grep",
1824            serde_json::json!({}),
1825            ToolTier::Observe,
1826        )
1827        .with_emitter_task_id("task-42");
1828        assert_eq!(tool.emitter_task_id(), None);
1829        Ok(())
1830    }
1831
1832    #[test]
1833    fn every_variant_envelope_has_flat_keys_and_round_trips() -> serde_json::Result<()> {
1834        let seq = SequenceCounter::new();
1835        for event in sample_all_variants() {
1836            let label = format!("{event:?}");
1837            let envelope = AgentEventEnvelope::wrap(event, &seq);
1838            let json = serde_json::to_value(&envelope)?;
1839
1840            // Envelope metadata + the event discriminant are all flat keys.
1841            for key in ["event_id", "sequence", "timestamp", "type"] {
1842                assert!(
1843                    json.get(key).is_some(),
1844                    "{label}: missing flat key `{key}` in {json}"
1845                );
1846            }
1847            // The `#[serde(flatten)]` must not leave a nested wrapper, and no
1848            // variant field may collide with an envelope key.
1849            assert!(
1850                json.get("event").is_none(),
1851                "{label}: unexpected nested `event` key in {json}"
1852            );
1853
1854            let restored: AgentEventEnvelope = serde_json::from_value(json.clone())?;
1855            assert_eq!(
1856                serde_json::to_value(&restored)?,
1857                json,
1858                "{label}: envelope round-trip changed the wire form"
1859            );
1860        }
1861        Ok(())
1862    }
1863}