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