Skip to main content

everruns_core/
events.rs

1// ==========================================================================
2// PUBLIC CONTRACT - Event Protocol
3// ==========================================================================
4//
5// This module defines the Everruns event protocol - a PUBLIC API CONTRACT.
6// Changes must follow the compatibility guidelines in knowledge/execution/events.md.
7//
8// STABILITY: Stable (v1)
9// - Event structure (id, type, ts, session_id, context, data) is frozen
10// - New event types are additive (non-breaking)
11// - New optional fields are non-breaking
12// - Unsupported events are filtered before API responses
13//
14// See: knowledge/execution/events.md for full contract specification.
15// ==========================================================================
16//
17// All events follow a consistent structure: id, type, ts, context, data.
18// Events are the source of truth for conversation data and provide
19// observability into session execution.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Deserializer, Serialize};
23use serde_json::Value;
24use std::collections::HashMap;
25use uuid::Uuid;
26
27#[cfg(feature = "openapi")]
28use utoipa::ToSchema;
29
30use crate::localization::localized_tool_display_name;
31use crate::typed_id::{AgentId, EventId, ExecId, HarnessId, MessageId, ModelId, SessionId, TurnId};
32use crate::user_facing_error::{UserFacingError, UserFacingErrorFields};
33
34// ============================================================================
35// Event Type Constants
36// ============================================================================
37
38// Input events
39pub const INPUT_MESSAGE: &str = "input.message";
40
41// Output events (lifecycle: started → delta* → completed)
42pub const OUTPUT_MESSAGE_STARTED: &str = "output.message.started";
43pub const OUTPUT_MESSAGE_DELTA: &str = "output.message.delta";
44pub const OUTPUT_MESSAGE_COMPLETED: &str = "output.message.completed";
45/// Streaming output was withheld by an output guardrail. Clients should
46/// discard everything they accumulated for `turn_id` and show `replacement`
47/// instead. The subsequent `output.message.completed` event carries the
48/// replacement as the persisted assistant message.
49pub const OUTPUT_MESSAGE_REPLACED: &str = "output.message.replaced";
50
51// Turn lifecycle events
52pub const TURN_STARTED: &str = "turn.started";
53pub const TURN_COMPLETED: &str = "turn.completed";
54pub const TURN_FAILED: &str = "turn.failed";
55/// Turn was deliberately sealed (stopped to prevent waste): no forward progress
56/// across repeated crash-reclaims, or work budget exhausted. Distinct from
57/// `turn.completed` (success) and `turn.failed` (error). Carries a `reason`.
58/// See EVE-534 and `knowledge/operations/durable-execution-engine.md`.
59pub const TURN_SEALED: &str = "turn.sealed";
60pub const TURN_CANCELLED: &str = "turn.cancelled";
61
62// Atom lifecycle events
63pub const REASON_STARTED: &str = "reason.started";
64pub const REASON_COMPLETED: &str = "reason.completed";
65pub const REASON_RECOVERED: &str = "reason.recovered";
66pub const CAPABILITY_USAGE: &str = "capability.usage";
67pub const ACT_STARTED: &str = "act.started";
68pub const ACT_COMPLETED: &str = "act.completed";
69pub const TOOL_STARTED: &str = "tool.started";
70pub const TOOL_COMPLETED: &str = "tool.completed";
71pub const TOOL_PROGRESS: &str = "tool.progress";
72pub const TOOL_OUTPUT_DELTA: &str = "tool.output.delta";
73pub const TOOL_CALL_REQUESTED: &str = "tool.call_requested";
74pub const TRANSCRIPT_REPAIRED: &str = "transcript.repaired";
75/// A malformed tool call was repaired (or repair was attempted) by the opt-in
76/// `tool_call_repair` capability (EVE-600). Carries an outcome label
77/// (`local-salvage` | `re-prompt` | `gave-up`).
78pub const TOOL_CALL_REPAIRED: &str = "tool.call_repaired";
79
80// LLM events
81pub const LLM_GENERATION: &str = "llm.generation";
82
83/// Single source of truth for which event types are ephemeral. Both
84/// `EventRequest::is_ephemeral` and `Event::is_ephemeral` delegate here so the
85/// match set cannot drift between the input and persisted forms.
86fn is_ephemeral_event_type(event_type: &str) -> bool {
87    matches!(
88        event_type,
89        OUTPUT_MESSAGE_DELTA
90            | REASON_THINKING_DELTA
91            | TOOL_OUTPUT_DELTA
92            | VOICE_INPUT_TRANSCRIPT_DELTA
93            | VOICE_OUTPUT_TRANSCRIPT_DELTA
94    )
95}
96
97// Reasoning/thinking events (extended thinking from models like Claude)
98pub const REASON_THINKING_STARTED: &str = "reason.thinking.started";
99pub const REASON_THINKING_DELTA: &str = "reason.thinking.delta";
100pub const REASON_THINKING_COMPLETED: &str = "reason.thinking.completed";
101
102/// Durable record of an opaque assistant reasoning response item.
103///
104/// Distinct from `reason.thinking.*` (user-visible thinking streams). This event
105/// captures provider-supplied opaque/encrypted reasoning artifacts plus safe
106/// summary text and per-item metadata, without persisting plaintext hidden
107/// chain-of-thought.
108pub const REASON_ITEM: &str = "reason.item";
109
110// Session events
111pub const SESSION_STARTED: &str = "session.started";
112pub const SESSION_ACTIVATED: &str = "session.activated";
113pub const SESSION_IDLED: &str = "session.idled";
114/// Session title changed through a mutation path that participates in the
115/// semantic event protocol.
116pub const SESSION_TITLE_UPDATED: &str = "session.title.updated";
117
118// Schedule events
119pub const SCHEDULE_TRIGGERED: &str = "schedule.triggered";
120
121// Subagent lifecycle events (`subagent.*`) were retired (EVE-585): the subagent
122// flow became Session Tasks and now emits `task.*` events. The legacy types are
123// no longer emitted or parsed; historical `subagent.*` rows in old session logs
124// deserialize via the generic unsupported-type fallback. See knowledge/execution/events.md.
125
126// Session task lifecycle events (knowledge/runtime-resources/session-tasks.md)
127pub const TASK_CREATED: &str = "task.created";
128pub const TASK_UPDATED: &str = "task.updated";
129pub const TASK_MESSAGE_SENT: &str = "task.message.sent";
130pub const TASK_MESSAGE_RECEIVED: &str = "task.message.received";
131
132// Context compaction events
133pub const CONTEXT_COMPACTING: &str = "context.compacting";
134pub const CONTEXT_COMPACTED: &str = "context.compacted";
135
136// File events
137pub const FILE_WRITTEN: &str = "file.written";
138
139// Budget events
140pub const BUDGET_WARNING: &str = "budget.warning";
141pub const BUDGET_PAUSED: &str = "budget.paused";
142pub const BUDGET_EXHAUSTED: &str = "budget.exhausted";
143pub const BUDGET_RESUMED: &str = "budget.resumed";
144
145// Voice events
146pub const VOICE_SESSION_STARTED: &str = "voice.session.started";
147pub const VOICE_INPUT_TRANSCRIPT_DELTA: &str = "voice.input_transcript.delta";
148pub const VOICE_INPUT_TRANSCRIPT_COMPLETED: &str = "voice.input_transcript.completed";
149pub const VOICE_OUTPUT_TRANSCRIPT_DELTA: &str = "voice.output_transcript.delta";
150pub const VOICE_OUTPUT_TRANSCRIPT_COMPLETED: &str = "voice.output_transcript.completed";
151pub const VOICE_SESSION_ENDED: &str = "voice.session.ended";
152pub const VOICE_SESSION_FAILED: &str = "voice.session.failed";
153
154/// All valid event types for API filtering validation.
155/// Used by `types` and `exclude` query parameter validation to reject unknown types
156/// and prevent unbounded arrays from reaching the database.
157pub const VALID_EVENT_TYPES: &[&str] = &[
158    INPUT_MESSAGE,
159    OUTPUT_MESSAGE_STARTED,
160    OUTPUT_MESSAGE_DELTA,
161    OUTPUT_MESSAGE_COMPLETED,
162    OUTPUT_MESSAGE_REPLACED,
163    TURN_STARTED,
164    TURN_COMPLETED,
165    TURN_FAILED,
166    TURN_SEALED,
167    TURN_CANCELLED,
168    REASON_STARTED,
169    REASON_COMPLETED,
170    REASON_RECOVERED,
171    ACT_STARTED,
172    ACT_COMPLETED,
173    TOOL_STARTED,
174    TOOL_COMPLETED,
175    TOOL_PROGRESS,
176    TOOL_OUTPUT_DELTA,
177    TOOL_CALL_REQUESTED,
178    TRANSCRIPT_REPAIRED,
179    TOOL_CALL_REPAIRED,
180    LLM_GENERATION,
181    REASON_THINKING_STARTED,
182    REASON_THINKING_DELTA,
183    REASON_THINKING_COMPLETED,
184    REASON_ITEM,
185    SESSION_STARTED,
186    SESSION_ACTIVATED,
187    SESSION_IDLED,
188    SESSION_TITLE_UPDATED,
189    SCHEDULE_TRIGGERED,
190    CONTEXT_COMPACTING,
191    CONTEXT_COMPACTED,
192    BUDGET_WARNING,
193    BUDGET_PAUSED,
194    BUDGET_EXHAUSTED,
195    BUDGET_RESUMED,
196    VOICE_SESSION_STARTED,
197    VOICE_INPUT_TRANSCRIPT_DELTA,
198    VOICE_INPUT_TRANSCRIPT_COMPLETED,
199    VOICE_OUTPUT_TRANSCRIPT_DELTA,
200    VOICE_OUTPUT_TRANSCRIPT_COMPLETED,
201    VOICE_SESSION_ENDED,
202    VOICE_SESSION_FAILED,
203    FILE_WRITTEN,
204    CAPABILITY_USAGE,
205];
206
207// ============================================================================
208// Event Context
209// ============================================================================
210
211use crate::atoms::AtomContext;
212
213/// Context for event correlation and tracing
214///
215/// Uses OpenTelemetry-style trace/span IDs for observability correlation:
216/// - `trace_id`: Root of the trace (typically the turn_id string)
217/// - `span_id`: This event's unique span identifier
218/// - `parent_span_id`: The parent span's identifier for hierarchical linking
219#[derive(Debug, Clone, Serialize, Deserialize, Default)]
220#[cfg_attr(feature = "openapi", derive(ToSchema))]
221pub struct EventContext {
222    /// Turn identifier (for turn-scoped events)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "turn_01933b5a00007000800000000000001"))]
225    pub turn_id: Option<TurnId>,
226
227    /// User message that triggered this turn
228    #[serde(skip_serializing_if = "Option::is_none")]
229    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "message_01933b5a00007000800000000000001"))]
230    pub input_message_id: Option<MessageId>,
231
232    /// Atom execution identifier
233    #[serde(skip_serializing_if = "Option::is_none")]
234    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "exec_01933b5a00007000800000000000001"))]
235    pub exec_id: Option<ExecId>,
236
237    /// Trace ID for observability (OTel-style). Groups related spans into a single trace.
238    /// For agent turns, this is typically the turn_id string.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub trace_id: Option<String>,
241
242    /// This event's span ID for observability (OTel-style).
243    /// Uniquely identifies this span within the trace.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub span_id: Option<String>,
246
247    /// Parent span ID for hierarchical linking (OTel-style).
248    /// Links this span to its parent in the trace hierarchy.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub parent_span_id: Option<String>,
251}
252
253impl EventContext {
254    /// Create an empty context (for session-level events)
255    pub fn empty() -> Self {
256        Self::default()
257    }
258
259    /// Create a full context from an AtomContext
260    pub fn from_atom_context(ctx: &AtomContext) -> Self {
261        Self {
262            turn_id: Some(ctx.turn_id),
263            input_message_id: Some(ctx.input_message_id),
264            exec_id: Some(ctx.exec_id),
265            trace_id: None,
266            span_id: None,
267            parent_span_id: None,
268        }
269    }
270
271    /// Create a context for turn-scoped events (without exec_id)
272    pub fn turn(turn_id: TurnId, input_message_id: MessageId) -> Self {
273        Self {
274            turn_id: Some(turn_id),
275            input_message_id: Some(input_message_id),
276            exec_id: None,
277            trace_id: None,
278            span_id: None,
279            parent_span_id: None,
280        }
281    }
282
283    /// Set OTel-style span context for hierarchical tracing
284    pub fn with_span(
285        mut self,
286        trace_id: String,
287        span_id: String,
288        parent_span_id: Option<String>,
289    ) -> Self {
290        self.trace_id = Some(trace_id);
291        self.span_id = Some(span_id);
292        self.parent_span_id = parent_span_id;
293        self
294    }
295}
296
297// ============================================================================
298// Standard Event Schema
299// ============================================================================
300
301/// Standard event following the Everruns event protocol.
302///
303/// All events have a consistent structure:
304/// - `id`: Unique event identifier (format: event_{32-hex})
305/// - `type`: Event type in dot notation (e.g., "input.message", "reason.started")
306/// - `ts`: ISO 8601 timestamp with millisecond precision
307/// - `session_id`: Session this event belongs to (format: session_{32-hex})
308/// - `context`: Correlation context for tracing
309/// - `data`: Event-specific payload (typed via EventData enum)
310/// - `metadata`: Optional arbitrary metadata
311/// - `tags`: Optional list of tags for filtering
312#[derive(Debug, Clone, Serialize)]
313#[cfg_attr(feature = "openapi", derive(ToSchema))]
314pub struct Event {
315    /// Unique event identifier (format: event_{32-hex})
316    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "event_01933b5a00007000800000000000001"))]
317    pub id: EventId,
318
319    /// Event type in dot notation
320    #[serde(rename = "type")]
321    pub event_type: String,
322
323    /// Event timestamp
324    pub ts: DateTime<Utc>,
325
326    /// Session this event belongs to (format: session_{32-hex})
327    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
328    pub session_id: SessionId,
329
330    /// Correlation context
331    pub context: EventContext,
332
333    /// Event-specific payload. The schema depends on the event type.
334    /// See EventData documentation for the mapping of type to data schema.
335    pub data: EventData,
336
337    /// Arbitrary metadata for the event
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub metadata: Option<serde_json::Value>,
340
341    /// Tags for filtering and categorization
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub tags: Option<Vec<String>>,
344
345    /// Sequence number within session (for ordering)
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub sequence: Option<i32>,
348}
349
350#[derive(Debug, Deserialize)]
351struct RawEvent {
352    id: EventId,
353    #[serde(rename = "type")]
354    event_type: String,
355    ts: DateTime<Utc>,
356    session_id: SessionId,
357    context: EventContext,
358    data: serde_json::Value,
359    metadata: Option<serde_json::Value>,
360    tags: Option<Vec<String>>,
361    sequence: Option<i32>,
362}
363
364impl<'de> Deserialize<'de> for Event {
365    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
366    where
367        D: Deserializer<'de>,
368    {
369        let raw = RawEvent::deserialize(deserializer)?;
370        let data = deserialize_event_data(&raw.event_type, raw.data);
371        Ok(Self {
372            id: raw.id,
373            event_type: raw.event_type,
374            ts: raw.ts,
375            session_id: raw.session_id,
376            context: raw.context,
377            data,
378            metadata: raw.metadata,
379            tags: raw.tags,
380            sequence: raw.sequence,
381        })
382    }
383}
384
385impl Event {
386    /// Create a new event with the given session_id, context, and typed data
387    ///
388    /// The event type is automatically inferred from the data type.
389    pub fn new(session_id: SessionId, context: EventContext, data: impl Into<EventData>) -> Self {
390        let data = data.into();
391        let event_type = data.event_type().to_string();
392        Self {
393            id: EventId::new(),
394            event_type,
395            ts: Utc::now(),
396            session_id,
397            context,
398            data,
399            metadata: None,
400            tags: None,
401            sequence: None,
402        }
403    }
404
405    /// Create an event with a specific ID (for testing or replay)
406    pub fn with_id(
407        id: EventId,
408        session_id: SessionId,
409        context: EventContext,
410        data: impl Into<EventData>,
411    ) -> Self {
412        let data = data.into();
413        let event_type = data.event_type().to_string();
414        Self {
415            id,
416            event_type,
417            ts: Utc::now(),
418            session_id,
419            context,
420            data,
421            metadata: None,
422            tags: None,
423            sequence: None,
424        }
425    }
426
427    /// Set the sequence number
428    pub fn with_sequence(mut self, sequence: i32) -> Self {
429        self.sequence = Some(sequence);
430        self
431    }
432
433    /// Set metadata
434    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
435        self.metadata = Some(metadata);
436        self
437    }
438
439    /// Set tags
440    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
441        self.tags = Some(tags);
442        self
443    }
444
445    /// Get the session_id as raw UUID
446    pub fn session_uuid(&self) -> Uuid {
447        self.session_id.uuid()
448    }
449
450    /// Check if this is an input or output message event
451    pub fn is_message_event(&self) -> bool {
452        self.event_type == INPUT_MESSAGE || self.event_type == OUTPUT_MESSAGE_COMPLETED
453    }
454
455    /// Whether this event is ephemeral. Delta events may be delivered to
456    /// listeners without ever being inserted into the `events` table, so any
457    /// downstream storage that holds an FK to `events.id` must treat the
458    /// reference as best-effort and skip it for ephemeral sources.
459    ///
460    /// Mirror of [`EventRequest::is_ephemeral`]; both delegate to
461    /// `is_ephemeral_event_type` so the match set stays in lockstep.
462    pub fn is_ephemeral(&self) -> bool {
463        is_ephemeral_event_type(&self.event_type)
464    }
465
466    /// Check if this is an input event
467    pub fn is_input_event(&self) -> bool {
468        self.event_type.starts_with("input.")
469    }
470
471    /// Check if this is an output event
472    pub fn is_output_event(&self) -> bool {
473        self.event_type.starts_with("output.")
474    }
475
476    /// Check if this is an atom lifecycle event
477    pub fn is_atom_event(&self) -> bool {
478        matches!(
479            self.event_type.as_str(),
480            REASON_STARTED
481                | REASON_COMPLETED
482                | REASON_RECOVERED
483                | ACT_STARTED
484                | ACT_COMPLETED
485                | TOOL_STARTED
486                | TOOL_COMPLETED
487                | TOOL_PROGRESS
488                | TOOL_CALL_REQUESTED
489                | TRANSCRIPT_REPAIRED
490        )
491    }
492
493    /// Check if this is a turn lifecycle event
494    pub fn is_turn_event(&self) -> bool {
495        self.event_type.starts_with("turn.")
496    }
497
498    /// Check if this is a session lifecycle event
499    pub fn is_session_event(&self) -> bool {
500        self.event_type.starts_with("session.")
501    }
502
503    /// Check if this event has unsupported data.
504    /// Unsupported events should be filtered before API responses.
505    pub fn is_unsupported(&self) -> bool {
506        self.data.is_unsupported()
507    }
508}
509
510// ============================================================================
511// Input/Output Event Data Types
512// ============================================================================
513
514use crate::message::{ContentPart, ExecutionPhase, Message};
515use crate::tool_narration::{
516    ToolNarrationPhase, render_group_headline_with_locale, render_tool_narration_with_locale,
517};
518use crate::tool_types::ToolCall;
519
520/// Metadata about the model used for generation
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[cfg_attr(feature = "openapi", derive(ToSchema))]
523pub struct ModelMetadata {
524    /// Model name (e.g., "gpt-4o", "claude-3-sonnet")
525    pub model: String,
526
527    /// Model ID (internal identifier)
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub model_id: Option<Uuid>,
530
531    /// Provider ID (internal identifier)
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub provider_id: Option<Uuid>,
534}
535
536/// Token usage statistics
537///
538/// Tracks token consumption per LLM call including cache tokens for cost
539/// optimization.
540///
541/// # Disjoint bucket convention
542///
543/// Prompt token buckets are **disjoint** (non-overlapping). Drivers normalize
544/// provider wire formats at the boundary so this holds for every provider:
545///
546/// ```text
547/// total_prompt = input_tokens + cache_read_tokens + cache_creation_tokens
548/// ```
549///
550/// - `input_tokens` — non-cached prompt tokens only.
551/// - `cache_read_tokens` — tokens served from cache, never counted in
552///   `input_tokens`.
553/// - `cache_creation_tokens` — tokens written to cache, never counted in
554///   `input_tokens`.
555///
556/// Inclusive providers (OpenAI Responses / Chat Completions, Gemini) report a
557/// prompt count that *includes* cached reads; their drivers subtract the cached
558/// subset so the value stored here is the non-cached remainder. Anthropic /
559/// Bedrock already report disjoint buckets. Cost is therefore uniform across
560/// providers (`input·in + cache_read·cr + cache_creation·cw + output·out`);
561/// consumers must not re-derive a non-cached input by subtracting cache reads.
562#[derive(Debug, Clone, Serialize, Deserialize, Default)]
563#[cfg_attr(feature = "openapi", derive(ToSchema))]
564pub struct TokenUsage {
565    /// Number of non-cached prompt tokens (cached reads/writes are tracked
566    /// separately; see the disjoint bucket convention on the struct)
567    pub input_tokens: u32,
568    /// Number of output/completion tokens
569    pub output_tokens: u32,
570    /// Number of tokens read from cache (reduces cost), disjoint from `input_tokens`
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub cache_read_tokens: Option<u32>,
573    /// Number of tokens written to cache, disjoint from `input_tokens`
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub cache_creation_tokens: Option<u32>,
576
577    /// Actual cost of this generation in USD, as reported by the provider inline
578    /// (e.g. OpenRouter's `usage.cost`, which reflects real post-routing/BYOK/cache
579    /// pricing). `None` for providers that do not return a cost.
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub actual_cost_usd: Option<f64>,
582
583    /// Estimated cost of this generation in USD, derived from the model's static
584    /// price-table profile. Computed whenever a profile with cost data exists,
585    /// independently of `actual_cost_usd`, so estimate-vs-actual drift can be
586    /// reconciled. `None` when there is no profile cost data for the model.
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub estimated_cost_usd: Option<f64>,
589
590    /// Best-effort USD cost for already-aggregated usage. Per-generation usage
591    /// leaves this unset and derives the effective cost from actual/estimated.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub effective_cost_usd: Option<f64>,
594}
595
596impl TokenUsage {
597    /// Create a new TokenUsage with just input and output tokens
598    pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
599        Self {
600            input_tokens,
601            output_tokens,
602            cache_read_tokens: None,
603            cache_creation_tokens: None,
604            actual_cost_usd: None,
605            estimated_cost_usd: None,
606            effective_cost_usd: None,
607        }
608    }
609
610    /// Create a TokenUsage with cache tokens
611    pub fn with_cache(
612        input_tokens: u32,
613        output_tokens: u32,
614        cache_read_tokens: Option<u32>,
615        cache_creation_tokens: Option<u32>,
616    ) -> Self {
617        Self {
618            input_tokens,
619            output_tokens,
620            cache_read_tokens,
621            cache_creation_tokens,
622            actual_cost_usd: None,
623            estimated_cost_usd: None,
624            effective_cost_usd: None,
625        }
626    }
627
628    /// Set the actual (provider-reported) and estimated (price-table) USD costs,
629    /// returning `self` for chaining. The two are tracked independently so an
630    /// authoritative charge stays distinguishable from an estimate.
631    pub fn with_cost(
632        mut self,
633        actual_cost_usd: Option<f64>,
634        estimated_cost_usd: Option<f64>,
635    ) -> Self {
636        self.actual_cost_usd = actual_cost_usd;
637        self.estimated_cost_usd = estimated_cost_usd;
638        self
639    }
640
641    /// Set the precomputed best-effort USD cost for aggregate usage, returning
642    /// `self` for chaining. Generation usage should leave this unset so the
643    /// best-effort cost remains actual-else-estimated for that generation.
644    pub fn with_effective_cost(mut self, effective_cost_usd: Option<f64>) -> Self {
645        self.effective_cost_usd = effective_cost_usd;
646        self
647    }
648
649    /// Best-effort cost in USD. Aggregate usage can carry a precomputed total
650    /// that sums actual-else-estimated per generation; otherwise this falls back
651    /// to the generation-level actual-else-estimated behavior.
652    pub fn effective_cost_usd(&self) -> Option<f64> {
653        self.effective_cost_usd
654            .or(self.actual_cost_usd.or(self.estimated_cost_usd))
655    }
656
657    /// Get total tokens (input + output)
658    pub fn total_tokens(&self) -> u32 {
659        self.input_tokens.saturating_add(self.output_tokens)
660    }
661
662    /// Add another TokenUsage to this one (for aggregation)
663    pub fn add(&mut self, other: &TokenUsage) {
664        // Capture the accumulator's effective cost BEFORE mutating the
665        // actual/estimated slots below: `effective_cost_usd()` falls back to
666        // those slots when no explicit total is set, so reading it afterward
667        // would fold in `other`'s just-added actual/estimated and double-count.
668        let current_cost = self.effective_cost_usd();
669        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
670        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
671        if let Some(cache) = other.cache_read_tokens {
672            let total = self.cache_read_tokens.get_or_insert(0);
673            *total = total.saturating_add(cache);
674        }
675        if let Some(cache) = other.cache_creation_tokens {
676            let total = self.cache_creation_tokens.get_or_insert(0);
677            *total = total.saturating_add(cache);
678        }
679        if let Some(cost) = other.actual_cost_usd {
680            *self.actual_cost_usd.get_or_insert(0.0) += cost;
681        }
682        if let Some(cost) = other.estimated_cost_usd {
683            *self.estimated_cost_usd.get_or_insert(0.0) += cost;
684        }
685        if let Some(cost) = other.effective_cost_usd() {
686            *self
687                .effective_cost_usd
688                .get_or_insert(current_cost.unwrap_or(0.0)) += cost;
689        }
690    }
691}
692
693#[cfg(test)]
694mod token_usage_tests {
695    use super::TokenUsage;
696
697    #[test]
698    fn aggregate_effective_cost_preserves_mixed_actual_and_estimated_total() {
699        let aggregate = TokenUsage::with_cache(30, 15, None, None)
700            .with_cost(Some(1.0), Some(2.0))
701            .with_effective_cost(Some(3.0));
702
703        assert_eq!(aggregate.actual_cost_usd, Some(1.0));
704        assert_eq!(aggregate.estimated_cost_usd, Some(2.0));
705        assert_eq!(aggregate.effective_cost_usd(), Some(3.0));
706    }
707
708    #[test]
709    fn generation_effective_cost_still_prefers_actual_over_estimated() {
710        let generation = TokenUsage::new(10, 5).with_cost(Some(1.0), Some(2.0));
711
712        assert_eq!(generation.effective_cost_usd(), Some(1.0));
713    }
714
715    #[test]
716    fn add_seeds_effective_cost_from_accumulator_implicit_cost() {
717        let mut aggregate = TokenUsage::new(10, 5).with_cost(Some(1.0), Some(10.0));
718        let generation = TokenUsage::new(20, 10).with_cost(None, Some(2.0));
719
720        aggregate.add(&generation);
721
722        assert_eq!(aggregate.actual_cost_usd, Some(1.0));
723        assert_eq!(aggregate.estimated_cost_usd, Some(12.0));
724        assert_eq!(aggregate.effective_cost_usd(), Some(3.0));
725    }
726
727    #[test]
728    fn add_effective_cost_does_not_double_count_when_actual_is_mutated() {
729        // Neither side carries an explicit effective total, so each side's
730        // effective cost falls back to `actual`. Adding must sum the two
731        // actuals once (1.0 + 3.0), not fold the accumulator's post-add actual
732        // back into the effective total.
733        let mut aggregate = TokenUsage::new(10, 5).with_cost(Some(1.0), None);
734        let generation = TokenUsage::new(20, 10).with_cost(Some(3.0), None);
735
736        aggregate.add(&generation);
737
738        assert_eq!(aggregate.actual_cost_usd, Some(4.0));
739        assert_eq!(aggregate.effective_cost_usd(), Some(4.0));
740    }
741
742    #[test]
743    fn aggregate_token_counters_saturate_at_their_bound() {
744        let mut aggregate = TokenUsage::with_cache(
745            u32::MAX - 1,
746            u32::MAX - 1,
747            Some(u32::MAX - 1),
748            Some(u32::MAX - 1),
749        );
750        aggregate.add(&TokenUsage::with_cache(10, 10, Some(10), Some(10)));
751
752        assert_eq!(aggregate.input_tokens, u32::MAX);
753        assert_eq!(aggregate.output_tokens, u32::MAX);
754        assert_eq!(aggregate.cache_read_tokens, Some(u32::MAX));
755        assert_eq!(aggregate.cache_creation_tokens, Some(u32::MAX));
756        assert_eq!(aggregate.total_tokens(), u32::MAX);
757    }
758}
759
760/// Data for input.message event
761#[derive(Debug, Clone, Serialize, Deserialize)]
762#[cfg_attr(feature = "openapi", derive(ToSchema))]
763pub struct InputMessageData {
764    /// The user message
765    pub message: Message,
766}
767
768impl InputMessageData {
769    pub fn new(message: Message) -> Self {
770        Self { message }
771    }
772}
773
774// ============================================================================
775// Output Event Data Types
776// ============================================================================
777
778/// Data for output.message.started event
779///
780/// Emitted when the LLM starts generating a response. UI can show a
781/// "thinking" indicator until output.message.delta or output.message.completed events arrive.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783#[cfg_attr(feature = "openapi", derive(ToSchema))]
784pub struct OutputMessageStartedData {
785    /// Turn ID this output belongs to
786    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
787    pub turn_id: TurnId,
788
789    /// Stable public ID for this assistant message across its streaming lifecycle.
790    /// This is the same identifier as `OutputMessageCompletedData.message.id`.
791    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_550e8400e29b41d4a716446655440000"))]
792    pub message_id: MessageId,
793
794    /// Optional model name being used
795    #[serde(skip_serializing_if = "Option::is_none")]
796    pub model: Option<String>,
797
798    /// Current iteration number within this turn (1-based).
799    /// Useful for UI to show progress during multi-step tool-calling flows.
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub iteration: Option<u32>,
802
803    /// Best-effort streamed phase hint (EVE-774).
804    ///
805    /// `None` means "not yet classified — treat as ordinary assistant text",
806    /// NEVER "thinking". A missing/unknown phase must fall back to the
807    /// assistant-text channel, never the reasoning channel. Only populated when
808    /// the provider stream reveals a native phase before this event is emitted;
809    /// today `output.message.started` is emitted before the LLM call, so this is
810    /// generally `None` at start. The authoritative classification remains the
811    /// completed `Message.phase`. See `knowledge/execution/events.md`.
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub phase: Option<ExecutionPhase>,
814}
815
816/// Data for output.message.delta event
817///
818/// Incremental text update during LLM generation. Events are batched (~100ms)
819/// to reduce volume while providing real-time feedback.
820#[derive(Debug, Clone, Serialize, Deserialize)]
821#[cfg_attr(feature = "openapi", derive(ToSchema))]
822pub struct OutputMessageDeltaData {
823    /// Turn ID this delta belongs to
824    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
825    pub turn_id: TurnId,
826
827    /// Stable public ID for this assistant message across its streaming lifecycle.
828    /// This is the same identifier as `OutputMessageCompletedData.message.id`.
829    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_550e8400e29b41d4a716446655440000"))]
830    pub message_id: MessageId,
831
832    /// The new text chunk
833    pub delta: String,
834
835    /// Accumulated text so far
836    pub accumulated: String,
837
838    /// Best-effort streamed phase hint (EVE-774).
839    ///
840    /// `None` means "not yet classified — treat as ordinary assistant text",
841    /// NEVER "thinking". Refinement is monotonic: once a stream reveals a native
842    /// phase (`Commentary` or `FinalAnswer`) it stays fixed for the rest of the
843    /// message — it never flip-flops and never reverts to `None`
844    /// (see `ExecutionPhase::refine_streamed_hint`). Providers without native
845    /// mid-stream phase (Anthropic, Gemini, …) leave this `None` until
846    /// completion. The authoritative classification remains the completed
847    /// `Message.phase`. See `knowledge/execution/events.md`.
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub phase: Option<ExecutionPhase>,
850}
851
852/// Data for output.message.completed event
853#[derive(Debug, Clone, Serialize, Deserialize)]
854#[cfg_attr(feature = "openapi", derive(ToSchema))]
855pub struct OutputMessageCompletedData {
856    /// The agent message
857    pub message: Message,
858
859    /// Metadata about the model used
860    #[serde(skip_serializing_if = "Option::is_none")]
861    pub metadata: Option<ModelMetadata>,
862
863    /// Token usage
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub usage: Option<TokenUsage>,
866
867    /// Stable error code for user-facing failures surfaced as assistant text.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub error_code: Option<String>,
870
871    /// Structured interpolation fields for localized error rendering.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
874    pub error_fields: Option<UserFacingErrorFields>,
875
876    /// Error-disclosure mode applied to `error_code`/`error_fields`
877    /// ("generic" | "standard" | "detailed"). Tracking metadata; absent for
878    /// non-error messages and for paths that predate disclosure modes.
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub error_disclosure: Option<String>,
881}
882
883impl OutputMessageCompletedData {
884    pub fn new(message: Message) -> Self {
885        Self {
886            message,
887            metadata: None,
888            usage: None,
889            error_code: None,
890            error_fields: None,
891            error_disclosure: None,
892        }
893    }
894
895    pub fn with_metadata(mut self, metadata: ModelMetadata) -> Self {
896        self.metadata = Some(metadata);
897        self
898    }
899
900    pub fn with_usage(mut self, usage: TokenUsage) -> Self {
901        self.usage = Some(usage);
902        self
903    }
904
905    pub fn with_user_facing_error(mut self, error: &UserFacingError) -> Self {
906        error.apply_to_event_fields(&mut self.error_code, &mut self.error_fields);
907        self
908    }
909
910    pub fn with_error_disclosure(mut self, mode: crate::ErrorDisclosure) -> Self {
911        self.error_disclosure = Some(mode.as_str().to_string());
912        self
913    }
914}
915
916/// Data for `output.message.replaced` event.
917///
918/// Emitted between the last (suppressed) `output.message.delta` and the final
919/// `output.message.completed`. Tells the client to discard the text accumulated
920/// for `message_id` and use `replacement` as that assistant message's text. The
921/// original model output is never persisted or replayed.
922#[derive(Debug, Clone, Serialize, Deserialize)]
923#[cfg_attr(feature = "openapi", derive(ToSchema))]
924pub struct OutputMessageReplacedData {
925    /// Turn ID this replacement belongs to.
926    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
927    pub turn_id: TurnId,
928
929    /// Stable public ID for the assistant message whose streamed text is replaced.
930    /// This is the same identifier as the subsequent completed `Message.id`.
931    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_550e8400e29b41d4a716446655440000"))]
932    pub message_id: MessageId,
933
934    /// Stable ID of the capability that contributed the guardrail
935    /// (e.g. `"prompt_canary_guardrail"`).
936    pub guardrail_capability_id: String,
937
938    /// Stable ID of the guardrail itself (e.g. `"prompt_canary"`).
939    pub guardrail_id: String,
940
941    /// Stable machine-readable reason code (e.g. `"system_prompt_leak"`).
942    /// Clients localize their copy from this rather than the human text.
943    pub reason_code: String,
944
945    /// Replacement text shown to the user and stored as the assistant message.
946    pub replacement: String,
947}
948
949// ============================================================================
950// Atom Event Data Types
951// ============================================================================
952
953/// Data for reason.started event
954#[derive(Debug, Clone, Serialize, Deserialize)]
955#[cfg_attr(feature = "openapi", derive(ToSchema))]
956pub struct ReasonStartedData {
957    /// Harness ID being used
958    pub harness_id: HarnessId,
959
960    /// Agent ID being used (optional)
961    #[serde(skip_serializing_if = "Option::is_none")]
962    pub agent_id: Option<AgentId>,
963
964    /// Metadata about the model being used
965    #[serde(skip_serializing_if = "Option::is_none")]
966    pub metadata: Option<ModelMetadata>,
967}
968
969/// Data for reason.completed event
970#[derive(Debug, Clone, Serialize, Deserialize)]
971#[cfg_attr(feature = "openapi", derive(ToSchema))]
972pub struct ReasonCompletedData {
973    /// Whether the LLM call succeeded
974    pub success: bool,
975
976    /// Text response preview (first 200 chars)
977    #[serde(skip_serializing_if = "Option::is_none")]
978    pub text_preview: Option<String>,
979
980    /// Whether tool calls were requested
981    pub has_tool_calls: bool,
982
983    /// Number of tool calls requested
984    pub tool_call_count: u32,
985
986    /// Error message if failed
987    #[serde(skip_serializing_if = "Option::is_none")]
988    pub error: Option<String>,
989
990    /// Duration of the reason phase in milliseconds
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub duration_ms: Option<u64>,
993
994    /// Token usage from the LLM call
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub usage: Option<TokenUsage>,
997}
998
999impl ReasonCompletedData {
1000    pub fn success(
1001        text: &str,
1002        has_tool_calls: bool,
1003        tool_call_count: u32,
1004        duration_ms: Option<u64>,
1005        usage: Option<TokenUsage>,
1006    ) -> Self {
1007        let text_preview = if text.is_empty() {
1008            None
1009        } else {
1010            Some(text.chars().take(200).collect())
1011        };
1012
1013        Self {
1014            success: true,
1015            text_preview,
1016            has_tool_calls,
1017            tool_call_count,
1018            error: None,
1019            duration_ms,
1020            usage,
1021        }
1022    }
1023
1024    pub fn failure(error: String, duration_ms: Option<u64>) -> Self {
1025        Self {
1026            success: false,
1027            text_preview: None,
1028            has_tool_calls: false,
1029            tool_call_count: 0,
1030            error: Some(error),
1031            duration_ms,
1032            usage: None,
1033        }
1034    }
1035}
1036
1037/// Recovery mode chosen by the ContinuePartial classifier (EVE-532).
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1039#[cfg_attr(feature = "openapi", derive(ToSchema))]
1040#[serde(rename_all = "snake_case")]
1041pub enum RecoveryMode {
1042    /// Persisted accumulated text was finalised as the assistant message;
1043    /// no second provider call was made.
1044    Finalize,
1045    /// Partial was unusable (empty accumulated); re-issued the provider call.
1046    Restart,
1047}
1048
1049/// Data for the `reason.recovered` event (EVE-532).
1050///
1051/// Emitted by `ReasonAtom` when it detects an in-flight partial assistant
1052/// message from a previous worker execution and applies the ContinuePartial
1053/// recovery policy.
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1055#[cfg_attr(feature = "openapi", derive(ToSchema))]
1056pub struct ReasonRecoveredData {
1057    /// Turn ID the partial belonged to.
1058    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
1059    pub turn_id: TurnId,
1060
1061    /// Recovery action taken.
1062    pub mode: RecoveryMode,
1063
1064    /// Character length of the persisted accumulated text.
1065    pub accumulated_len: usize,
1066}
1067
1068/// Reporting-only capability usage kinds.
1069#[derive(Debug, Clone, Serialize, Deserialize)]
1070#[serde(rename_all = "snake_case")]
1071#[cfg_attr(feature = "openapi", derive(ToSchema))]
1072pub enum CapabilityUsageKind {
1073    Configured,
1074    Resolved,
1075    Exposed,
1076    Invoked,
1077    EffectRan,
1078}
1079
1080/// Single capability usage record. This intentionally carries only stable IDs
1081/// and small snapshots; prompts, messages, tool arguments, and results are not
1082/// allowed in reporting facts.
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1084#[cfg_attr(feature = "openapi", derive(ToSchema))]
1085pub struct CapabilityUsageRecord {
1086    /// Capability id (prefixed or namespaced) attributing this usage.
1087    pub capability_id: String,
1088    /// Capability display name for UI. `None` when the capability is unnamed.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub capability_name: Option<String>,
1091    /// Discriminator for the kind of usage being recorded (e.g. `tool_call`, `subagent_spawn`).
1092    pub usage_kind: CapabilityUsageKind,
1093    /// Concrete tool name when `usage_kind` is `tool_call`. `None` for non-tool usage kinds.
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub tool_name: Option<String>,
1096    /// Number of distinct usages recorded in this record (count-style records). `None` for duration-style records.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub usage_count: Option<u64>,
1099    /// Total wall-clock duration of the usage in milliseconds (duration-style records). `None` for count-style records.
1100    #[serde(default, skip_serializing_if = "Option::is_none")]
1101    pub duration_ms: Option<u64>,
1102}
1103
1104/// Data for capability.usage events.
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106#[cfg_attr(feature = "openapi", derive(ToSchema))]
1107pub struct CapabilityUsageData {
1108    pub records: Vec<CapabilityUsageRecord>,
1109}
1110
1111/// Summary of a tool call (compact form without arguments)
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113#[cfg_attr(feature = "openapi", derive(ToSchema))]
1114pub struct ToolCallSummary {
1115    pub id: String,
1116    pub name: String,
1117    /// Human-readable display name for UI rendering
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub display_name: Option<String>,
1120    /// Human-readable narration for timeline rendering
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub narration: Option<String>,
1123    /// Human-readable narration after the call completes
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub completed_narration: Option<String>,
1126}
1127
1128impl From<&ToolCall> for ToolCallSummary {
1129    fn from(tc: &ToolCall) -> Self {
1130        Self {
1131            id: tc.id.clone(),
1132            name: tc.name.clone(),
1133            display_name: None,
1134            narration: None,
1135            completed_narration: None,
1136        }
1137    }
1138}
1139
1140/// Summary of a tool definition (compact form for events)
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1142#[cfg_attr(feature = "openapi", derive(ToSchema))]
1143pub struct ToolDefinitionSummary {
1144    /// Tool name
1145    pub name: String,
1146    /// Human-readable display name for UI rendering
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub display_name: Option<String>,
1149    /// Tool category for namespace grouping.
1150    #[serde(default, skip_serializing_if = "Option::is_none")]
1151    pub category: Option<String>,
1152    /// Capability that contributed the tool definition, when known.
1153    #[serde(default, skip_serializing_if = "Option::is_none")]
1154    pub capability_id: Option<String>,
1155    /// Human-readable capability name snapshot, when known.
1156    #[serde(default, skip_serializing_if = "Option::is_none")]
1157    pub capability_name: Option<String>,
1158    /// Tool description
1159    pub description: String,
1160}
1161
1162impl From<&crate::tool_types::ToolDefinition> for ToolDefinitionSummary {
1163    fn from(tool: &crate::tool_types::ToolDefinition) -> Self {
1164        let capability_attribution = tool.capability_attribution();
1165        Self {
1166            name: tool.name().to_string(),
1167            display_name: tool.display_name().map(|s| s.to_string()),
1168            category: tool.category().map(|s| s.to_string()),
1169            capability_id: capability_attribution.map(|(id, _)| id.to_string()),
1170            capability_name: capability_attribution.and_then(|(_, name)| name.map(str::to_string)),
1171            description: tool.description().to_string(),
1172        }
1173    }
1174}
1175
1176/// Data for act.started event
1177#[derive(Debug, Clone, Serialize, Deserialize)]
1178#[cfg_attr(feature = "openapi", derive(ToSchema))]
1179pub struct ActStartedData {
1180    /// Tool calls to be executed
1181    pub tool_calls: Vec<ToolCallSummary>,
1182    /// Human-readable headline for the batch
1183    #[serde(default, skip_serializing_if = "Option::is_none")]
1184    pub headline: Option<String>,
1185}
1186
1187impl ActStartedData {
1188    pub fn new(tool_calls: &[ToolCall]) -> Self {
1189        Self::new_with_locale(tool_calls, None)
1190    }
1191
1192    pub fn new_with_locale(tool_calls: &[ToolCall], locale: Option<&str>) -> Self {
1193        Self {
1194            tool_calls: tool_calls.iter().map(ToolCallSummary::from).collect(),
1195            headline: render_group_headline_with_locale(
1196                tool_calls,
1197                &[],
1198                ToolNarrationPhase::Started,
1199                locale,
1200            ),
1201        }
1202    }
1203
1204    /// Create with display names resolved from tool definitions
1205    pub fn with_definitions(
1206        tool_calls: &[ToolCall],
1207        tool_defs: &[crate::tool_types::ToolDefinition],
1208    ) -> Self {
1209        Self::with_definitions_and_locale(tool_calls, tool_defs, None)
1210    }
1211
1212    pub fn with_definitions_and_locale(
1213        tool_calls: &[ToolCall],
1214        tool_defs: &[crate::tool_types::ToolDefinition],
1215        locale: Option<&str>,
1216    ) -> Self {
1217        let def_map: std::collections::HashMap<&str, &crate::tool_types::ToolDefinition> =
1218            tool_defs.iter().map(|d| (d.name(), d)).collect();
1219        Self {
1220            tool_calls: tool_calls
1221                .iter()
1222                .map(|tc| {
1223                    let tool_def = def_map.get(tc.name.as_str()).copied();
1224                    let display_name = localized_tool_display_name(
1225                        &tc.name,
1226                        tool_def.and_then(|d| d.display_name()),
1227                        locale,
1228                    );
1229                    ToolCallSummary {
1230                        id: tc.id.clone(),
1231                        name: tc.name.clone(),
1232                        display_name,
1233                        narration: Some(render_tool_narration_with_locale(
1234                            tool_def,
1235                            tc,
1236                            ToolNarrationPhase::Started,
1237                            locale,
1238                        )),
1239                        completed_narration: Some(render_tool_narration_with_locale(
1240                            tool_def,
1241                            tc,
1242                            ToolNarrationPhase::Completed,
1243                            locale,
1244                        )),
1245                    }
1246                })
1247                .collect(),
1248            headline: render_group_headline_with_locale(
1249                tool_calls,
1250                tool_defs,
1251                ToolNarrationPhase::Started,
1252                locale,
1253            ),
1254        }
1255    }
1256}
1257
1258/// Data for act.completed event
1259#[derive(Debug, Clone, Serialize, Deserialize)]
1260#[cfg_attr(feature = "openapi", derive(ToSchema))]
1261pub struct ActCompletedData {
1262    /// Whether all tool calls completed
1263    pub completed: bool,
1264
1265    /// Number of successful tool calls
1266    pub success_count: u32,
1267
1268    /// Number of failed tool calls
1269    pub error_count: u32,
1270
1271    /// Duration of the act phase in milliseconds
1272    #[serde(skip_serializing_if = "Option::is_none")]
1273    pub duration_ms: Option<u64>,
1274    /// Human-readable headline for the completed batch
1275    #[serde(default, skip_serializing_if = "Option::is_none")]
1276    pub headline: Option<String>,
1277}
1278
1279/// Data for tool.started event
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1281#[cfg_attr(feature = "openapi", derive(ToSchema))]
1282pub struct ToolStartedData {
1283    /// The tool call being executed
1284    pub tool_call: ToolCall,
1285    /// Stable fingerprint of tool name + normalized arguments.
1286    #[serde(default, skip_serializing_if = "Option::is_none")]
1287    pub tool_call_fingerprint: Option<String>,
1288    /// Human-readable display name for UI rendering
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub display_name: Option<String>,
1291    /// Human-readable narration for timeline rendering
1292    #[serde(default, skip_serializing_if = "Option::is_none")]
1293    pub narration: Option<String>,
1294}
1295
1296/// Data for tool.completed event
1297#[derive(Debug, Clone, Serialize, Deserialize)]
1298#[cfg_attr(feature = "openapi", derive(ToSchema))]
1299pub struct ToolCompletedData {
1300    /// Tool call ID
1301    pub tool_call_id: String,
1302
1303    /// Tool name
1304    pub tool_name: String,
1305
1306    /// Stable fingerprint of tool name + normalized arguments.
1307    #[serde(default, skip_serializing_if = "Option::is_none")]
1308    pub tool_call_fingerprint: Option<String>,
1309
1310    /// Stable fingerprint of tool name + normalized result/error.
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    pub tool_result_fingerprint: Option<String>,
1313
1314    /// Human-readable display name for UI rendering
1315    #[serde(default, skip_serializing_if = "Option::is_none")]
1316    pub display_name: Option<String>,
1317
1318    /// Whether the tool call succeeded
1319    pub success: bool,
1320
1321    /// Status: "success", "error", "timeout", "cancelled"
1322    pub status: String,
1323
1324    /// Result content (for successful calls)
1325    #[serde(skip_serializing_if = "Option::is_none")]
1326    pub result: Option<Vec<ContentPart>>,
1327
1328    /// Error message if failed
1329    #[serde(skip_serializing_if = "Option::is_none")]
1330    pub error: Option<String>,
1331
1332    /// Duration of the tool call in milliseconds
1333    #[serde(skip_serializing_if = "Option::is_none")]
1334    pub duration_ms: Option<u64>,
1335
1336    /// Capability that contributed the tool definition, when known.
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    pub capability_id: Option<String>,
1339
1340    /// Human-readable capability name snapshot, when known.
1341    #[serde(default, skip_serializing_if = "Option::is_none")]
1342    pub capability_name: Option<String>,
1343
1344    /// Human-readable narration for timeline rendering
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub narration: Option<String>,
1347}
1348
1349impl ToolCompletedData {
1350    pub fn success(
1351        tool_call_id: String,
1352        tool_name: String,
1353        result: Vec<ContentPart>,
1354        duration_ms: Option<u64>,
1355    ) -> Self {
1356        Self {
1357            tool_call_id,
1358            tool_name,
1359            tool_call_fingerprint: None,
1360            tool_result_fingerprint: None,
1361            display_name: None,
1362            success: true,
1363            status: "success".to_string(),
1364            result: Some(result),
1365            error: None,
1366            duration_ms,
1367            capability_id: None,
1368            capability_name: None,
1369            narration: None,
1370        }
1371    }
1372
1373    pub fn failure(
1374        tool_call_id: String,
1375        tool_name: String,
1376        status: String,
1377        error: String,
1378        duration_ms: Option<u64>,
1379    ) -> Self {
1380        Self {
1381            tool_call_id,
1382            tool_name,
1383            tool_call_fingerprint: None,
1384            tool_result_fingerprint: None,
1385            display_name: None,
1386            success: false,
1387            status,
1388            result: None,
1389            error: Some(error),
1390            duration_ms,
1391            capability_id: None,
1392            capability_name: None,
1393            narration: None,
1394        }
1395    }
1396
1397    /// Set display name on this event data
1398    pub fn with_display_name(mut self, display_name: Option<String>) -> Self {
1399        self.display_name = display_name;
1400        self
1401    }
1402
1403    pub fn with_fingerprints(
1404        mut self,
1405        tool_call_fingerprint: String,
1406        tool_result_fingerprint: String,
1407    ) -> Self {
1408        self.tool_call_fingerprint = Some(tool_call_fingerprint);
1409        self.tool_result_fingerprint = Some(tool_result_fingerprint);
1410        self
1411    }
1412
1413    /// Set narration on this event data
1414    pub fn with_narration(mut self, narration: Option<String>) -> Self {
1415        self.narration = narration;
1416        self
1417    }
1418
1419    /// Set reporting attribution on this event data.
1420    pub fn with_capability_attribution(
1421        mut self,
1422        capability_id: Option<String>,
1423        capability_name: Option<String>,
1424    ) -> Self {
1425        self.capability_id = capability_id;
1426        self.capability_name = capability_name;
1427        self
1428    }
1429}
1430
1431/// Data for tool.progress event.
1432///
1433/// Emitted by tools during execution to report interim status updates.
1434/// This allows long-running tools (e.g., browser operations, sandbox setup)
1435/// to stream progress feedback between tool.started and tool.completed.
1436#[derive(Debug, Clone, Serialize, Deserialize)]
1437#[cfg_attr(feature = "openapi", derive(ToSchema))]
1438pub struct ToolProgressData {
1439    /// Tool call ID this progress belongs to
1440    pub tool_call_id: String,
1441
1442    /// Tool name
1443    pub tool_name: String,
1444
1445    /// Human-readable status message (e.g., "Connecting to browser…")
1446    pub message: String,
1447
1448    /// Human-readable display name for UI rendering
1449    #[serde(default, skip_serializing_if = "Option::is_none")]
1450    pub display_name: Option<String>,
1451}
1452
1453/// Data for tool.output.delta event.
1454///
1455/// Emitted by tools during execution to stream incremental output chunks.
1456/// This enables live output rendering (e.g., bash stdout/stderr, command output)
1457/// between tool.started and tool.completed. Generic — usable by any tool that
1458/// produces streamed output (bashkit, Daytona exec, subagent speech, etc.).
1459///
1460/// The consumer accumulates deltas by tool_call_id for display. The final
1461/// tool.completed result is authoritative — deltas are informational only.
1462#[derive(Debug, Clone, Serialize, Deserialize)]
1463#[cfg_attr(feature = "openapi", derive(ToSchema))]
1464pub struct ToolOutputDeltaData {
1465    /// Tool call ID this output belongs to
1466    pub tool_call_id: String,
1467
1468    /// Tool name
1469    pub tool_name: String,
1470
1471    /// Incremental output chunk
1472    pub delta: String,
1473
1474    /// Output stream identifier (e.g., "stdout", "stderr")
1475    pub stream: String,
1476}
1477
1478/// Action taken during transcript repair for a dangling tool call.
1479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1480#[cfg_attr(feature = "openapi", derive(ToSchema))]
1481#[serde(rename_all = "snake_case")]
1482pub enum TranscriptRepairAction {
1483    /// A settled result was found in durable storage and replayed into the transcript.
1484    Replay,
1485    /// A synthetic interrupted result was synthesized to make the transcript well-formed.
1486    Synthesize,
1487}
1488
1489/// Data for transcript.repaired event (EVE-533).
1490///
1491/// Emitted once per dangling tool call when transcript repair runs before a `reason` call.
1492/// A dangling call is an assistant `tool_call` with no matching `ToolResult` in the
1493/// message history. Repair makes the transcript well-formed so the next LLM call succeeds.
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1495#[cfg_attr(feature = "openapi", derive(ToSchema))]
1496pub struct TranscriptRepairedData {
1497    /// The tool call ID that was repaired.
1498    pub tool_call_id: String,
1499
1500    /// The tool name, if known.
1501    #[serde(default, skip_serializing_if = "Option::is_none")]
1502    pub tool_name: Option<String>,
1503
1504    /// Action taken: `replay` (settled result reused) or `synthesize` (interrupted placeholder added).
1505    pub action: TranscriptRepairAction,
1506}
1507
1508/// Data for the `tool.call_repaired` event (EVE-600).
1509///
1510/// Emitted once per malformed tool call handled by the opt-in
1511/// `tool_call_repair` capability. `outcome` is the stable label
1512/// (`local-salvage` | `re-prompt` | `gave-up`).
1513#[derive(Debug, Clone, Serialize, Deserialize)]
1514#[cfg_attr(feature = "openapi", derive(ToSchema))]
1515pub struct ToolCallRepairedData {
1516    /// Turn this repair belongs to.
1517    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
1518    pub turn_id: TurnId,
1519
1520    /// The tool call ID that was inspected/repaired.
1521    pub tool_call_id: String,
1522
1523    /// The tool name the malformed call targeted.
1524    pub tool_name: String,
1525
1526    /// Stable outcome label: `local-salvage`, `re-prompt`, or `gave-up`.
1527    pub outcome: String,
1528}
1529
1530/// Data for tool.call_requested event
1531///
1532/// Emitted when the agent needs client-side tool calls executed.
1533/// The workflow pauses until the client submits results via the API.
1534#[derive(Debug, Clone, Serialize, Deserialize)]
1535#[cfg_attr(feature = "openapi", derive(ToSchema))]
1536pub struct ToolCallRequestedData {
1537    /// Tool calls that need to be executed by the client
1538    pub tool_calls: Vec<ToolCall>,
1539    /// Optional summaries with display names and narration for UI rendering
1540    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1541    pub tool_summaries: Vec<ToolCallSummary>,
1542    /// Human-readable headline for the requested batch
1543    #[serde(default, skip_serializing_if = "Option::is_none")]
1544    pub headline: Option<String>,
1545    /// Human-readable headline after the requested batch completes
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub completed_headline: Option<String>,
1548}
1549
1550impl ToolCallRequestedData {
1551    pub fn with_definitions(
1552        tool_calls: &[ToolCall],
1553        tool_defs: &[crate::tool_types::ToolDefinition],
1554    ) -> Self {
1555        Self::with_definitions_and_locale(tool_calls, tool_defs, None)
1556    }
1557
1558    pub fn with_definitions_and_locale(
1559        tool_calls: &[ToolCall],
1560        tool_defs: &[crate::tool_types::ToolDefinition],
1561        locale: Option<&str>,
1562    ) -> Self {
1563        let def_map: std::collections::HashMap<&str, &crate::tool_types::ToolDefinition> =
1564            tool_defs.iter().map(|d| (d.name(), d)).collect();
1565
1566        let tool_summaries = tool_calls
1567            .iter()
1568            .map(|tool_call| {
1569                let tool_def = def_map.get(tool_call.name.as_str()).copied();
1570                ToolCallSummary {
1571                    id: tool_call.id.clone(),
1572                    name: tool_call.name.clone(),
1573                    display_name: localized_tool_display_name(
1574                        &tool_call.name,
1575                        tool_def.and_then(|def| def.display_name()),
1576                        locale,
1577                    ),
1578                    narration: Some(render_tool_narration_with_locale(
1579                        tool_def,
1580                        tool_call,
1581                        ToolNarrationPhase::Waiting,
1582                        locale,
1583                    )),
1584                    completed_narration: Some(render_tool_narration_with_locale(
1585                        tool_def,
1586                        tool_call,
1587                        ToolNarrationPhase::Completed,
1588                        locale,
1589                    )),
1590                }
1591            })
1592            .collect();
1593
1594        Self {
1595            tool_calls: tool_calls.to_vec(),
1596            tool_summaries,
1597            headline: render_group_headline_with_locale(
1598                tool_calls,
1599                tool_defs,
1600                ToolNarrationPhase::Waiting,
1601                locale,
1602            ),
1603            completed_headline: render_group_headline_with_locale(
1604                tool_calls,
1605                tool_defs,
1606                ToolNarrationPhase::Completed,
1607                locale,
1608            ),
1609        }
1610    }
1611}
1612
1613// ============================================================================
1614// LLM Event Data Types
1615// ============================================================================
1616
1617/// LLM generation output
1618#[derive(Debug, Clone, Serialize, Deserialize)]
1619#[cfg_attr(feature = "openapi", derive(ToSchema))]
1620pub struct LlmGenerationOutput {
1621    /// Text response from the model
1622    #[serde(skip_serializing_if = "Option::is_none")]
1623    pub text: Option<String>,
1624
1625    /// Tool calls requested by the model
1626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1627    pub tool_calls: Vec<ToolCall>,
1628}
1629
1630/// Request options applied to an LLM generation.
1631///
1632/// These fields capture request-side intent such as prompt caching or deferred
1633/// tool loading. They complement `usage`, which captures what actually happened.
1634#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1635#[cfg_attr(feature = "openapi", derive(ToSchema))]
1636pub struct LlmRequestOptions {
1637    /// Prompt caching configuration for this request.
1638    #[serde(skip_serializing_if = "Option::is_none")]
1639    pub prompt_cache: Option<LlmPromptCacheInfo>,
1640    /// Deferred tool-loading configuration for this request.
1641    #[serde(skip_serializing_if = "Option::is_none")]
1642    pub tool_search: Option<LlmToolSearchInfo>,
1643    /// Provider-specific request options that do not warrant dedicated fields.
1644    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1645    pub provider_options: HashMap<String, Value>,
1646    /// General request metadata passed to the LLM provider for tracking and observability.
1647    /// Includes embedder-supplied labels merged with system tracking keys (session_id, turn_id, etc.).
1648    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1649    pub metadata: HashMap<String, String>,
1650}
1651
1652impl LlmRequestOptions {
1653    pub fn is_empty(&self) -> bool {
1654        self.prompt_cache.is_none()
1655            && self.tool_search.is_none()
1656            && self.provider_options.is_empty()
1657            && self.metadata.is_empty()
1658    }
1659}
1660
1661/// Request-side prompt cache settings for an LLM generation.
1662#[derive(Debug, Clone, Serialize, Deserialize)]
1663#[cfg_attr(feature = "openapi", derive(ToSchema))]
1664pub struct LlmPromptCacheInfo {
1665    /// Whether prompt caching was enabled on the request.
1666    pub enabled: bool,
1667    /// Strategy used to enable prompt caching.
1668    pub strategy: crate::driver_registry::PromptCacheStrategy,
1669    /// Provider-specific prompt-cache mode used by the driver.
1670    #[serde(skip_serializing_if = "Option::is_none")]
1671    pub provider_mode: Option<String>,
1672}
1673
1674/// Request-side tool_search settings for an LLM generation.
1675#[derive(Debug, Clone, Serialize, Deserialize)]
1676#[cfg_attr(feature = "openapi", derive(ToSchema))]
1677pub struct LlmToolSearchInfo {
1678    /// Whether tool_search was enabled on the request.
1679    pub enabled: bool,
1680    /// Minimum number of tools before deferred loading activates.
1681    pub threshold: usize,
1682}
1683
1684/// Metadata about an LLM generation
1685#[derive(Debug, Clone, Serialize, Deserialize)]
1686#[cfg_attr(feature = "openapi", derive(ToSchema))]
1687pub struct LlmGenerationMetadata {
1688    /// Model identifier used for generation
1689    #[cfg_attr(feature = "openapi", schema(example = "claude-sonnet-4-5"))]
1690    pub model: String,
1691
1692    /// Provider type (openai, anthropic, etc.)
1693    #[serde(skip_serializing_if = "Option::is_none")]
1694    #[cfg_attr(feature = "openapi", schema(example = "anthropic"))]
1695    pub provider: Option<String>,
1696
1697    /// Token usage statistics
1698    #[serde(skip_serializing_if = "Option::is_none")]
1699    pub usage: Option<TokenUsage>,
1700
1701    /// Duration of the generation in milliseconds
1702    #[serde(skip_serializing_if = "Option::is_none")]
1703    #[cfg_attr(feature = "openapi", schema(example = 1_842u64))]
1704    pub duration_ms: Option<u64>,
1705
1706    /// Time to first token in milliseconds (streaming latency)
1707    #[serde(skip_serializing_if = "Option::is_none")]
1708    #[cfg_attr(feature = "openapi", schema(example = 312u64))]
1709    pub time_to_first_token_ms: Option<u64>,
1710
1711    /// Whether the generation was successful
1712    #[cfg_attr(feature = "openapi", schema(example = true))]
1713    pub success: bool,
1714
1715    /// Error message if generation failed
1716    #[serde(skip_serializing_if = "Option::is_none")]
1717    #[cfg_attr(feature = "openapi", schema(example = "provider returned 503"))]
1718    pub error: Option<String>,
1719
1720    /// Finish reasons from the LLM (e.g., ["stop"], ["tool_calls"])
1721    /// Required for gen-ai semantic conventions
1722    #[serde(skip_serializing_if = "Option::is_none")]
1723    #[cfg_attr(feature = "openapi", schema(example = json!(["tool_calls"])))]
1724    pub finish_reasons: Option<Vec<String>>,
1725
1726    /// Unique response identifier from the LLM provider
1727    /// Required for gen-ai semantic conventions
1728    #[serde(skip_serializing_if = "Option::is_none")]
1729    #[cfg_attr(feature = "openapi", schema(example = "msg_01ABCDef0123456789"))]
1730    pub response_id: Option<String>,
1731
1732    /// Retry information if rate limit retries occurred
1733    /// Contains number of retries and total wait time
1734    #[serde(skip_serializing_if = "Option::is_none")]
1735    pub retry: Option<LlmRetryInfo>,
1736
1737    /// Compaction information if context was compressed before generation
1738    /// Occurs when the conversation context exceeded the model's limit
1739    #[serde(skip_serializing_if = "Option::is_none")]
1740    pub compaction: Option<LlmCompactionInfo>,
1741
1742    /// Request-side driver options that were enabled for this generation.
1743    #[serde(skip_serializing_if = "Option::is_none")]
1744    pub request_options: Option<LlmRequestOptions>,
1745}
1746
1747/// Information about rate limit retries during LLM generation
1748#[derive(Debug, Clone, Serialize, Deserialize)]
1749#[cfg_attr(feature = "openapi", derive(ToSchema))]
1750pub struct LlmRetryInfo {
1751    /// Number of retry attempts made (0 = succeeded on first try)
1752    pub attempts: u32,
1753
1754    /// Total time spent waiting between retries in milliseconds
1755    pub total_wait_ms: u64,
1756}
1757
1758/// Information about context compaction performed before LLM generation
1759///
1760/// When the conversation context exceeds the model's limit, compaction is
1761/// automatically triggered to compress the context before retrying.
1762#[derive(Debug, Clone, Serialize, Deserialize)]
1763#[cfg_attr(feature = "openapi", derive(ToSchema))]
1764pub struct LlmCompactionInfo {
1765    /// Whether compaction was performed
1766    pub compacted: bool,
1767
1768    /// Number of input tokens before compaction
1769    #[serde(skip_serializing_if = "Option::is_none")]
1770    pub input_tokens_before: Option<u32>,
1771
1772    /// Number of input tokens after compaction
1773    #[serde(skip_serializing_if = "Option::is_none")]
1774    pub input_tokens_after: Option<u32>,
1775
1776    /// Duration of the compaction operation in milliseconds
1777    #[serde(skip_serializing_if = "Option::is_none")]
1778    pub duration_ms: Option<u64>,
1779}
1780
1781impl LlmCompactionInfo {
1782    /// Create info for a successful compaction
1783    pub fn new(
1784        input_tokens_before: Option<u32>,
1785        input_tokens_after: Option<u32>,
1786        duration_ms: Option<u64>,
1787    ) -> Self {
1788        Self {
1789            compacted: true,
1790            input_tokens_before,
1791            input_tokens_after,
1792            duration_ms,
1793        }
1794    }
1795}
1796
1797/// Data for llm.generation event
1798///
1799/// Emitted after each LLM API call to provide full visibility into
1800/// the messages sent to the model and the response received.
1801#[derive(Debug, Clone, Serialize, Deserialize)]
1802#[cfg_attr(feature = "openapi", derive(ToSchema))]
1803pub struct LlmGenerationData {
1804    /// Messages sent to the LLM (including system prompt)
1805    pub messages: Vec<Message>,
1806
1807    /// Tools available to the LLM for this generation
1808    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1809    pub tools: Vec<ToolDefinitionSummary>,
1810
1811    /// Output from the LLM
1812    pub output: LlmGenerationOutput,
1813
1814    /// Metadata about the generation
1815    pub metadata: LlmGenerationMetadata,
1816}
1817
1818impl LlmGenerationData {
1819    /// Create a successful generation event
1820    #[allow(clippy::too_many_arguments)]
1821    pub fn success(
1822        messages: Vec<Message>,
1823        tools: Vec<ToolDefinitionSummary>,
1824        text: Option<String>,
1825        tool_calls: Vec<ToolCall>,
1826        model: String,
1827        provider: Option<String>,
1828        usage: Option<TokenUsage>,
1829        duration_ms: Option<u64>,
1830        time_to_first_token_ms: Option<u64>,
1831    ) -> Self {
1832        // Infer finish reasons from content
1833        let finish_reasons = if !tool_calls.is_empty() {
1834            Some(vec!["tool_calls".to_string()])
1835        } else {
1836            Some(vec!["stop".to_string()])
1837        };
1838
1839        Self {
1840            messages,
1841            tools,
1842            output: LlmGenerationOutput { text, tool_calls },
1843            metadata: LlmGenerationMetadata {
1844                model,
1845                provider,
1846                usage,
1847                duration_ms,
1848                time_to_first_token_ms,
1849                success: true,
1850                error: None,
1851                finish_reasons,
1852                response_id: None,
1853                retry: None,
1854                compaction: None,
1855                request_options: None,
1856            },
1857        }
1858    }
1859
1860    /// Create a successful generation event with full metadata
1861    #[allow(clippy::too_many_arguments)]
1862    pub fn success_with_metadata(
1863        messages: Vec<Message>,
1864        tools: Vec<ToolDefinitionSummary>,
1865        text: Option<String>,
1866        tool_calls: Vec<ToolCall>,
1867        model: String,
1868        provider: Option<String>,
1869        usage: Option<TokenUsage>,
1870        duration_ms: Option<u64>,
1871        time_to_first_token_ms: Option<u64>,
1872        finish_reasons: Option<Vec<String>>,
1873        response_id: Option<String>,
1874    ) -> Self {
1875        Self {
1876            messages,
1877            tools,
1878            output: LlmGenerationOutput { text, tool_calls },
1879            metadata: LlmGenerationMetadata {
1880                model,
1881                provider,
1882                usage,
1883                duration_ms,
1884                time_to_first_token_ms,
1885                success: true,
1886                error: None,
1887                finish_reasons,
1888                response_id,
1889                retry: None,
1890                compaction: None,
1891                request_options: None,
1892            },
1893        }
1894    }
1895
1896    /// Create a successful generation event with retry information
1897    #[allow(clippy::too_many_arguments)]
1898    pub fn success_with_retry(
1899        messages: Vec<Message>,
1900        tools: Vec<ToolDefinitionSummary>,
1901        text: Option<String>,
1902        tool_calls: Vec<ToolCall>,
1903        model: String,
1904        provider: Option<String>,
1905        usage: Option<TokenUsage>,
1906        duration_ms: Option<u64>,
1907        time_to_first_token_ms: Option<u64>,
1908        finish_reasons: Option<Vec<String>>,
1909        response_id: Option<String>,
1910        retry: Option<LlmRetryInfo>,
1911    ) -> Self {
1912        Self {
1913            messages,
1914            tools,
1915            output: LlmGenerationOutput { text, tool_calls },
1916            metadata: LlmGenerationMetadata {
1917                model,
1918                provider,
1919                usage,
1920                duration_ms,
1921                time_to_first_token_ms,
1922                success: true,
1923                error: None,
1924                finish_reasons,
1925                response_id,
1926                retry,
1927                compaction: None,
1928                request_options: None,
1929            },
1930        }
1931    }
1932
1933    /// Create a failed generation event
1934    pub fn failure(
1935        messages: Vec<Message>,
1936        tools: Vec<ToolDefinitionSummary>,
1937        model: String,
1938        provider: Option<String>,
1939        error: String,
1940        duration_ms: Option<u64>,
1941        time_to_first_token_ms: Option<u64>,
1942    ) -> Self {
1943        Self {
1944            messages,
1945            tools,
1946            output: LlmGenerationOutput {
1947                text: None,
1948                tool_calls: vec![],
1949            },
1950            metadata: LlmGenerationMetadata {
1951                model,
1952                provider,
1953                usage: None,
1954                duration_ms,
1955                time_to_first_token_ms,
1956                success: false,
1957                error: Some(error),
1958                finish_reasons: Some(vec!["error".to_string()]),
1959                response_id: None,
1960                retry: None,
1961                compaction: None,
1962                request_options: None,
1963            },
1964        }
1965    }
1966
1967    /// Set compaction info on this generation event
1968    ///
1969    /// Call this when context was compacted before a successful retry.
1970    pub fn with_compaction(mut self, compaction: LlmCompactionInfo) -> Self {
1971        self.metadata.compaction = Some(compaction);
1972        self
1973    }
1974
1975    /// Set retry info on this generation event
1976    pub fn with_retry(mut self, retry: LlmRetryInfo) -> Self {
1977        self.metadata.retry = Some(retry);
1978        self
1979    }
1980
1981    /// Set request-side options on this generation event.
1982    pub fn with_request_options(mut self, request_options: LlmRequestOptions) -> Self {
1983        if !request_options.is_empty() {
1984            self.metadata.request_options = Some(request_options);
1985        }
1986        self
1987    }
1988}
1989
1990// ============================================================================
1991// Extended Thinking Event Data Types
1992// ============================================================================
1993
1994/// Data for reason.thinking.started event
1995///
1996/// Emitted when extended thinking begins during reasoning phase.
1997/// This signals the model is using chain-of-thought reasoning.
1998/// UI can show a "thinking" indicator.
1999#[derive(Debug, Clone, Serialize, Deserialize)]
2000#[cfg_attr(feature = "openapi", derive(ToSchema))]
2001pub struct ReasonThinkingStartedData {
2002    /// Turn ID this thinking belongs to
2003    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2004    pub turn_id: TurnId,
2005
2006    /// Optional model name being used
2007    #[serde(skip_serializing_if = "Option::is_none")]
2008    pub model: Option<String>,
2009}
2010
2011/// Data for reason.thinking.delta event (extended thinking content from models like Claude)
2012///
2013/// This event streams incremental thinking/reasoning content from models that support
2014/// extended thinking mode (e.g., Claude with thinking enabled). The thinking content
2015/// represents the model's chain-of-thought reasoning before producing the final response.
2016#[derive(Debug, Clone, Serialize, Deserialize)]
2017#[cfg_attr(feature = "openapi", derive(ToSchema))]
2018pub struct ReasonThinkingDeltaData {
2019    /// Turn ID this delta belongs to (for correlation)
2020    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2021    pub turn_id: TurnId,
2022
2023    /// The thinking delta (new thinking text since last delta)
2024    pub delta: String,
2025
2026    /// Accumulated thinking text so far (convenience for UI)
2027    pub accumulated: String,
2028}
2029
2030/// Data for reason.thinking.completed event
2031///
2032/// Emitted when extended thinking completes and the model transitions
2033/// to producing the final response. Contains the complete thinking content.
2034#[derive(Debug, Clone, Serialize, Deserialize)]
2035#[cfg_attr(feature = "openapi", derive(ToSchema))]
2036pub struct ReasonThinkingCompletedData {
2037    /// Turn ID this thinking belongs to
2038    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2039    pub turn_id: TurnId,
2040
2041    /// Complete thinking content
2042    pub thinking: String,
2043}
2044
2045/// Data for `reason.item` event.
2046///
2047/// Durable record of an opaque assistant reasoning response item (e.g., OpenAI
2048/// Responses API reasoning items). Carries provider-supplied opaque artifacts
2049/// and curated summary text only. Plaintext hidden chain-of-thought is never
2050/// persisted in this event — emitters must strip any plaintext reasoning
2051/// content before constructing it.
2052#[derive(Debug, Clone, Serialize, Deserialize)]
2053#[cfg_attr(feature = "openapi", derive(ToSchema))]
2054pub struct ReasonItemData {
2055    /// Turn ID this reasoning item belongs to.
2056    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2057    pub turn_id: TurnId,
2058
2059    /// Provider that produced the reasoning item (e.g., "openai").
2060    pub provider: String,
2061
2062    /// Model identifier reported by the provider, if known.
2063    #[serde(skip_serializing_if = "Option::is_none")]
2064    pub model: Option<String>,
2065
2066    /// Provider-assigned identifier for the reasoning item.
2067    pub item_id: String,
2068
2069    /// Provider-encrypted reasoning context, if supplied. Opaque to consumers.
2070    #[serde(skip_serializing_if = "Option::is_none")]
2071    pub encrypted_content: Option<String>,
2072
2073    /// Safe summary text segments curated by the provider. Never includes
2074    /// plaintext reasoning content.
2075    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2076    pub summary: Vec<String>,
2077
2078    /// Per-item reasoning token count, when the provider reports one.
2079    #[serde(skip_serializing_if = "Option::is_none")]
2080    pub token_count: Option<u32>,
2081}
2082
2083// ============================================================================
2084// Turn Event Data Types
2085// ============================================================================
2086
2087/// Data for turn.started event
2088#[derive(Debug, Clone, Serialize, Deserialize)]
2089#[cfg_attr(feature = "openapi", derive(ToSchema))]
2090pub struct TurnStartedData {
2091    /// Turn identifier
2092    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2093    pub turn_id: TurnId,
2094
2095    /// Input message ID that triggered this turn
2096    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
2097    pub input_message_id: MessageId,
2098
2099    /// Input message content (for observability)
2100    #[serde(skip_serializing_if = "Option::is_none")]
2101    pub input_content: Option<String>,
2102}
2103
2104/// Data for turn.completed event
2105#[derive(Debug, Clone, Serialize, Deserialize)]
2106#[cfg_attr(feature = "openapi", derive(ToSchema))]
2107pub struct TurnCompletedData {
2108    /// Turn identifier
2109    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2110    pub turn_id: TurnId,
2111
2112    /// Number of iterations in this turn
2113    pub iterations: u32,
2114
2115    /// Duration in milliseconds
2116    #[serde(skip_serializing_if = "Option::is_none")]
2117    pub duration_ms: Option<u64>,
2118
2119    /// Aggregated token usage for all LLM calls in this turn
2120    #[serde(skip_serializing_if = "Option::is_none")]
2121    pub usage: Option<TokenUsage>,
2122
2123    /// Input message content (for observability, passed through from turn.started)
2124    #[serde(skip_serializing_if = "Option::is_none")]
2125    pub input_content: Option<String>,
2126
2127    /// Canonical assistant message emitted by `output.message.completed`.
2128    #[serde(skip_serializing_if = "Option::is_none")]
2129    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "message_01933b5a00007000800000000000001"))]
2130    pub final_message_id: Option<MessageId>,
2131
2132    /// Bounded preview of the final visible assistant answer.
2133    #[serde(skip_serializing_if = "Option::is_none")]
2134    pub final_answer_preview: Option<String>,
2135
2136    /// First-token latency for the turn, usually from the first LLM generation.
2137    #[serde(skip_serializing_if = "Option::is_none")]
2138    pub time_to_first_token_ms: Option<u64>,
2139
2140    /// Number of tool calls completed during the turn.
2141    #[serde(skip_serializing_if = "Option::is_none")]
2142    pub tool_call_count: Option<u32>,
2143
2144    /// Number of LLM generation calls executed during the turn.
2145    #[serde(skip_serializing_if = "Option::is_none")]
2146    pub llm_call_count: Option<u32>,
2147
2148    /// Optional explicit completion status for consumers that summarize turns.
2149    #[serde(skip_serializing_if = "Option::is_none")]
2150    pub status: Option<String>,
2151}
2152
2153/// Data for turn.failed event
2154#[derive(Debug, Clone, Serialize, Deserialize)]
2155#[cfg_attr(feature = "openapi", derive(ToSchema))]
2156pub struct TurnFailedData {
2157    /// Turn identifier
2158    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2159    pub turn_id: TurnId,
2160
2161    /// Error message
2162    pub error: String,
2163
2164    /// Error code
2165    #[serde(default, skip_serializing_if = "Option::is_none")]
2166    pub error_code: Option<String>,
2167
2168    /// Structured interpolation fields for localized error rendering.
2169    #[serde(default, skip_serializing_if = "Option::is_none")]
2170    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
2171    pub error_fields: Option<UserFacingErrorFields>,
2172
2173    /// Error-disclosure mode applied to `error_code`/`error_fields`
2174    /// ("generic" | "standard" | "detailed"). Full diagnostic detail remains
2175    /// available to operators via reason.completed failure events and tracing.
2176    #[serde(default, skip_serializing_if = "Option::is_none")]
2177    pub error_disclosure: Option<String>,
2178}
2179
2180/// Data for turn.sealed event (EVE-534).
2181///
2182/// A sealed turn was deliberately stopped to prevent waste. It is observably
2183/// distinct from `turn.completed` (success) and `turn.failed` (error). The
2184/// `reason` is the stable wire form of `everruns_core::turn::SealReason`
2185/// (`"no_progress"` or `"budget"`).
2186#[derive(Debug, Clone, Serialize, Deserialize)]
2187#[cfg_attr(feature = "openapi", derive(ToSchema))]
2188pub struct TurnSealedData {
2189    /// Turn identifier
2190    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2191    pub turn_id: TurnId,
2192
2193    /// Why the turn was sealed: `"no_progress"` (crash-loop with no forward
2194    /// progress) or `"budget"` (work budget exhausted).
2195    pub reason: String,
2196
2197    /// Human-readable detail for operators (optional).
2198    #[serde(default, skip_serializing_if = "Option::is_none")]
2199    pub detail: Option<String>,
2200
2201    /// Iterations completed before the turn was sealed (if known).
2202    #[serde(default, skip_serializing_if = "Option::is_none")]
2203    pub iterations: Option<u32>,
2204
2205    /// Aggregated token usage before sealing, if available.
2206    #[serde(default, skip_serializing_if = "Option::is_none")]
2207    pub usage: Option<TokenUsage>,
2208}
2209
2210/// Data for turn.cancelled event
2211#[derive(Debug, Clone, Serialize, Deserialize)]
2212#[cfg_attr(feature = "openapi", derive(ToSchema))]
2213pub struct TurnCancelledData {
2214    /// Turn identifier
2215    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2216    pub turn_id: TurnId,
2217
2218    /// Reason for cancellation
2219    #[serde(skip_serializing_if = "Option::is_none")]
2220    pub reason: Option<String>,
2221
2222    /// Token usage before cancellation (if available)
2223    #[serde(skip_serializing_if = "Option::is_none")]
2224    pub usage: Option<TokenUsage>,
2225}
2226
2227// ============================================================================
2228// Session Event Data Types
2229// ============================================================================
2230
2231/// Data for session.started event
2232#[derive(Debug, Clone, Serialize, Deserialize)]
2233#[cfg_attr(feature = "openapi", derive(ToSchema))]
2234pub struct SessionStartedData {
2235    /// Harness ID
2236    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
2237    pub harness_id: HarnessId,
2238
2239    /// Agent ID (optional)
2240    #[serde(skip_serializing_if = "Option::is_none")]
2241    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
2242    pub agent_id: Option<AgentId>,
2243
2244    /// Model ID if specified
2245    #[serde(skip_serializing_if = "Option::is_none")]
2246    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
2247    pub model_id: Option<ModelId>,
2248}
2249
2250/// Data for session.activated event (turn started, session now active)
2251#[derive(Debug, Clone, Serialize, Deserialize)]
2252#[cfg_attr(feature = "openapi", derive(ToSchema))]
2253pub struct SessionActivatedData {
2254    /// Turn ID that activated the session
2255    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2256    pub turn_id: TurnId,
2257
2258    /// Input message ID that triggered the turn
2259    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
2260    pub input_message_id: MessageId,
2261}
2262
2263/// Data for session.idled event (turn completed, session now idle)
2264#[derive(Debug, Clone, Serialize, Deserialize)]
2265#[cfg_attr(feature = "openapi", derive(ToSchema))]
2266pub struct SessionIdledData {
2267    /// Turn ID that just completed
2268    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
2269    pub turn_id: TurnId,
2270
2271    /// Number of iterations in the completed turn
2272    #[serde(skip_serializing_if = "Option::is_none")]
2273    pub iterations: Option<u32>,
2274
2275    /// Cumulative token usage for the session at this point
2276    #[serde(skip_serializing_if = "Option::is_none")]
2277    pub usage: Option<TokenUsage>,
2278}
2279
2280/// Data for `session.title.updated`.
2281#[derive(Debug, Clone, Serialize, Deserialize)]
2282#[cfg_attr(feature = "openapi", derive(ToSchema))]
2283pub struct SessionTitleUpdatedData {
2284    /// Title before the mutation. `None` means the session was untitled.
2285    pub previous_title: Option<String>,
2286
2287    /// New session title.
2288    pub title: String,
2289}
2290
2291// ============================================================================
2292// Session task event data
2293// ============================================================================
2294
2295/// Data for task lifecycle events (`task.created`, `task.updated`).
2296///
2297/// Carries the full task snapshot so consumers never need a follow-up read;
2298/// UIs reconcile by `task.id` (snapshot-then-delta).
2299#[derive(Debug, Clone, Serialize, Deserialize)]
2300#[cfg_attr(feature = "openapi", derive(ToSchema))]
2301pub struct SessionTaskEventData {
2302    pub task: crate::session_task::SessionTask,
2303}
2304
2305/// Data for task message events (`task.message.sent`, `task.message.received`).
2306#[derive(Debug, Clone, Serialize, Deserialize)]
2307#[cfg_attr(feature = "openapi", derive(ToSchema))]
2308pub struct TaskMessageEventData {
2309    pub task_id: String,
2310    pub message: crate::session_task::TaskMessage,
2311}
2312
2313// ============================================================================
2314// Context compaction event data
2315// ============================================================================
2316
2317/// Reason why compaction was triggered.
2318#[derive(Debug, Clone, Serialize, Deserialize)]
2319#[cfg_attr(feature = "openapi", derive(ToSchema))]
2320#[serde(rename_all = "snake_case")]
2321pub enum CompactionReason {
2322    /// Triggered proactively at budget threshold.
2323    ProactiveBudget,
2324    /// Triggered reactively on RequestTooLarge error.
2325    RequestTooLarge,
2326    /// Triggered manually by user command.
2327    Manual,
2328}
2329
2330impl std::fmt::Display for CompactionReason {
2331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2332        match self {
2333            Self::ProactiveBudget => write!(f, "proactive_budget"),
2334            Self::RequestTooLarge => write!(f, "request_too_large"),
2335            Self::Manual => write!(f, "manual"),
2336        }
2337    }
2338}
2339
2340/// Data for context.compacting event (compaction starting).
2341#[derive(Debug, Clone, Serialize, Deserialize)]
2342#[cfg_attr(feature = "openapi", derive(ToSchema))]
2343pub struct ContextCompactingData {
2344    /// Why compaction was triggered.
2345    pub reason: CompactionReason,
2346    /// Strategy requested (may differ from strategy_used in the completed event).
2347    pub strategy: String,
2348    /// Number of messages before compaction.
2349    pub messages_before: usize,
2350    /// Estimated or provider-reported input tokens before compaction.
2351    #[serde(default, skip_serializing_if = "Option::is_none")]
2352    pub tokens_before: Option<u64>,
2353    /// Serialized request-context bytes before compaction, when measurable.
2354    #[serde(default, skip_serializing_if = "Option::is_none")]
2355    pub bytes_before: Option<u64>,
2356}
2357
2358/// A single step in a compaction cascade.
2359#[derive(Debug, Clone, Serialize, Deserialize)]
2360#[cfg_attr(feature = "openapi", derive(ToSchema))]
2361pub struct CompactionStepData {
2362    /// Strategy used in this step.
2363    pub strategy: String,
2364    /// Number of messages after this step.
2365    pub messages_after: usize,
2366    /// Duration of this step in milliseconds.
2367    pub duration_ms: u64,
2368}
2369
2370/// Data for context.compacted event (compaction completed).
2371#[derive(Debug, Clone, Serialize, Deserialize)]
2372#[cfg_attr(feature = "openapi", derive(ToSchema))]
2373pub struct ContextCompactedData {
2374    /// Durable checkpoint installed by this compaction, when applicable.
2375    #[serde(default, skip_serializing_if = "Option::is_none")]
2376    pub checkpoint_id: Option<String>,
2377    /// Combined strategy description (e.g., "observation_masking+native").
2378    pub strategy_used: String,
2379    /// Number of messages before compaction.
2380    pub messages_before: usize,
2381    /// Number of messages after compaction.
2382    pub messages_after: usize,
2383    /// Estimated or provider-reported input tokens before compaction.
2384    #[serde(default, skip_serializing_if = "Option::is_none")]
2385    pub tokens_before: Option<u64>,
2386    /// Provider-reported output tokens after compaction, when available.
2387    #[serde(default, skip_serializing_if = "Option::is_none")]
2388    pub tokens_after: Option<u64>,
2389    /// Serialized request-context bytes before compaction, when measurable.
2390    #[serde(default, skip_serializing_if = "Option::is_none")]
2391    pub bytes_before: Option<u64>,
2392    /// Serialized compact output bytes, when measurable.
2393    #[serde(default, skip_serializing_if = "Option::is_none")]
2394    pub bytes_after: Option<u64>,
2395    /// Total duration of all compaction steps in milliseconds.
2396    pub duration_ms: u64,
2397    /// Individual steps in the cascade.
2398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2399    pub steps: Vec<CompactionStepData>,
2400}
2401
2402// ============================================================================
2403// File event data
2404// ============================================================================
2405
2406/// Data for file.written events emitted when files are written to the session filesystem.
2407#[derive(Debug, Clone, Serialize, Deserialize)]
2408#[cfg_attr(feature = "openapi", derive(ToSchema))]
2409pub struct FileWrittenData {
2410    /// File path within the session filesystem (normalized, e.g. "/reports/summary.md").
2411    pub path: String,
2412    /// Operation type (see `FILE_OP_*` constants).
2413    pub operation: String,
2414    /// File size in bytes after write.
2415    pub size_bytes: i64,
2416    /// Whether this is a new file (true) or an update to an existing file (false).
2417    pub created: bool,
2418}
2419
2420/// File operation constants for `FileWrittenData.operation`.
2421pub const FILE_OP_CREATE: &str = "create";
2422pub const FILE_OP_UPDATE: &str = "update";
2423
2424// ============================================================================
2425// Budget event data
2426// ============================================================================
2427
2428/// Data for budget lifecycle events (warning, paused, exhausted, resumed).
2429#[derive(Debug, Clone, Serialize, Deserialize)]
2430#[cfg_attr(feature = "openapi", derive(ToSchema))]
2431pub struct BudgetEventData {
2432    /// Budget that triggered this event.
2433    pub budget_id: String,
2434    /// Current remaining balance.
2435    pub balance: f64,
2436    /// Budget limit.
2437    pub limit: f64,
2438    /// Budget currency (e.g. "usd", "tokens").
2439    pub currency: String,
2440    /// Human-readable message.
2441    #[serde(skip_serializing_if = "Option::is_none")]
2442    pub message: Option<String>,
2443    /// Soft limit threshold (present for warning/paused events).
2444    #[serde(skip_serializing_if = "Option::is_none")]
2445    pub soft_limit: Option<f64>,
2446}
2447
2448// ============================================================================
2449// Voice Event Data Types
2450// ============================================================================
2451
2452/// Data for voice.session.started.
2453#[derive(Debug, Clone, Serialize, Deserialize)]
2454#[cfg_attr(feature = "openapi", derive(ToSchema))]
2455pub struct VoiceSessionStartedData {
2456    /// Prefixed voice connection identifier for the started session.
2457    #[cfg_attr(
2458        feature = "openapi",
2459        schema(example = "voice_01933b5a00007000800000000000001")
2460    )]
2461    pub voice_connection_id: String,
2462    /// Provider-side realtime model identifier negotiated for this session.
2463    #[cfg_attr(feature = "openapi", schema(example = "gpt-realtime"))]
2464    pub model: String,
2465    /// Realtime voice preset selected for this session.
2466    #[cfg_attr(feature = "openapi", schema(example = "alloy"))]
2467    pub voice: String,
2468    /// Reasoning effort applied to the realtime model. One of `low`, `medium`, `high`.
2469    #[cfg_attr(feature = "openapi", schema(example = "medium"))]
2470    pub reasoning_effort: String,
2471    /// Transport carrying the audio stream. One of `webrtc`, `sip`, `websocket`.
2472    #[cfg_attr(feature = "openapi", schema(example = "webrtc"))]
2473    pub transport: String,
2474}
2475
2476/// Data for voice transcript delta/completed events.
2477#[derive(Debug, Clone, Serialize, Deserialize)]
2478#[cfg_attr(feature = "openapi", derive(ToSchema))]
2479pub struct VoiceTranscriptData {
2480    /// Prefixed voice connection identifier this transcript belongs to.
2481    pub voice_connection_id: String,
2482    /// Provider-specific identifier of the conversation item being transcribed. `None` when not yet assigned.
2483    #[serde(default, skip_serializing_if = "Option::is_none")]
2484    pub item_id: Option<String>,
2485    /// Provider-specific identifier of the response stream emitting this transcript. `None` for user-side transcripts.
2486    #[serde(default, skip_serializing_if = "Option::is_none")]
2487    pub response_id: Option<String>,
2488    /// Transcript phase: `user_partial`, `user_final`, `assistant_partial`, `assistant_final`. `None` when not yet classified.
2489    #[serde(default, skip_serializing_if = "Option::is_none")]
2490    pub phase: Option<String>,
2491    /// Newly transcribed text chunk delivered in this event. Empty for "final" events that only mark completion.
2492    #[serde(default, skip_serializing_if = "String::is_empty")]
2493    pub delta: String,
2494    /// Full transcript accumulated for this item up to and including `delta`.
2495    pub accumulated: String,
2496}
2497
2498/// Data for voice.session.ended.
2499#[derive(Debug, Clone, Serialize, Deserialize)]
2500#[cfg_attr(feature = "openapi", derive(ToSchema))]
2501pub struct VoiceSessionEndedData {
2502    /// Prefixed voice connection identifier for the ended session.
2503    #[cfg_attr(
2504        feature = "openapi",
2505        schema(example = "voice_01933b5a00007000800000000000001")
2506    )]
2507    pub voice_connection_id: String,
2508    /// Free-text end reason captured from the client or server. `None` when no reason was supplied.
2509    #[serde(default, skip_serializing_if = "Option::is_none")]
2510    #[cfg_attr(
2511        feature = "openapi",
2512        schema(example = "User hung up after refund confirmed.")
2513    )]
2514    pub reason: Option<String>,
2515    /// Total wall-clock duration of the connection in milliseconds. `None` when the connection
2516    /// never completed an audio handshake.
2517    #[serde(default, skip_serializing_if = "Option::is_none")]
2518    #[cfg_attr(feature = "openapi", schema(example = 184_500_u64))]
2519    pub duration_ms: Option<u64>,
2520}
2521
2522/// Data for voice.session.failed.
2523#[derive(Debug, Clone, Serialize, Deserialize)]
2524#[cfg_attr(feature = "openapi", derive(ToSchema))]
2525pub struct VoiceSessionFailedData {
2526    /// Prefixed voice connection identifier for the failed session.
2527    #[cfg_attr(
2528        feature = "openapi",
2529        schema(example = "voice_01933b5a00007000800000000000001")
2530    )]
2531    pub voice_connection_id: String,
2532    /// Error message captured at failure. Provider-formatted; not stable for parsing.
2533    #[cfg_attr(
2534        feature = "openapi",
2535        schema(example = "realtime provider closed stream: 1011 internal_error")
2536    )]
2537    pub error: String,
2538}
2539
2540// ============================================================================
2541// EventData Enum - Typed event payloads
2542// ============================================================================
2543
2544/// Typed event data enum for all event payloads
2545///
2546/// This enum provides type safety for event data. Each variant corresponds
2547/// to a specific event type and contains the appropriate data structure.
2548/// The `Raw` variant is used for backward compatibility with legacy events
2549/// or unknown event types.
2550///
2551/// The data type depends on the event `type` field:
2552/// - `input.message` → InputMessageData
2553/// - `output.message.started` → OutputMessageStartedData
2554/// - `output.message.delta` → OutputMessageDeltaData
2555/// - `output.message.completed` → OutputMessageCompletedData
2556/// - `turn.started` → TurnStartedData
2557/// - `turn.completed` → TurnCompletedData
2558/// - `turn.failed` → TurnFailedData
2559/// - `turn.cancelled` → TurnCancelledData
2560/// - `reason.started` → ReasonStartedData
2561/// - `reason.completed` → ReasonCompletedData
2562/// - `capability.usage` → CapabilityUsageData
2563/// - `act.started` → ActStartedData
2564/// - `act.completed` → ActCompletedData
2565/// - `tool.started` → ToolStartedData
2566/// - `tool.completed` → ToolCompletedData
2567/// - `tool.output.delta` → ToolOutputDeltaData
2568/// - `tool.call_requested` → ToolCallRequestedData
2569/// - `llm.generation` → LlmGenerationData
2570/// - `reason.thinking.started` → ReasonThinkingStartedData
2571/// - `reason.thinking.delta` → ReasonThinkingDeltaData
2572/// - `reason.thinking.completed` → ReasonThinkingCompletedData
2573/// - `reason.item` → ReasonItemData
2574/// - `session.started` → SessionStartedData
2575/// - `session.activated` → SessionActivatedData
2576/// - `session.idled` → SessionIdledData
2577/// - `session.title.updated` → SessionTitleUpdatedData
2578/// - `file.written` → FileWrittenData
2579// `untagged` is retained ONLY for encoding and schema, not decoding:
2580//   - `Serialize` emits the payload inline (the event `type` lives as a sibling
2581//     field on `Event`/`EventRequest`, never inside `data`), and
2582//   - the OpenAPI schema renders as a `oneOf` of the payload schemas.
2583// Decoding never goes through serde's untagged matching. The single source of
2584// truth for the `type` -> variant mapping is `event_data_kinds!` below, used by
2585// `deserialize_event_data`. `EventData` deliberately does NOT derive
2586// `Deserialize`, so the declaration order of variants is irrelevant.
2587#[derive(Debug, Clone, Serialize)]
2588#[serde(untagged)]
2589#[cfg_attr(feature = "openapi", derive(ToSchema))]
2590#[cfg_attr(feature = "openapi", schema(
2591    title = "EventData",
2592    description = "Event-specific payload. The schema depends on the event type field.",
2593    example = json!({"message": {"id": "...", "role": "user", "content": []}})
2594))]
2595pub enum EventData {
2596    // Input events
2597    InputMessage(InputMessageData),
2598
2599    // Output events (lifecycle: started → delta* → completed)
2600    OutputMessageDelta(OutputMessageDeltaData),
2601    OutputMessageStarted(OutputMessageStartedData),
2602    OutputMessageReplaced(OutputMessageReplacedData),
2603    OutputMessageCompleted(OutputMessageCompletedData),
2604
2605    // Turn lifecycle events
2606    TurnStarted(TurnStartedData),
2607    TurnCompleted(TurnCompletedData),
2608    TurnFailed(TurnFailedData),
2609
2610    // Atom lifecycle events
2611    ReasonStarted(ReasonStartedData),
2612    ReasonCompleted(ReasonCompletedData),
2613    ReasonRecovered(ReasonRecoveredData),
2614    CapabilityUsage(CapabilityUsageData),
2615    ActStarted(ActStartedData),
2616    ActCompleted(ActCompletedData),
2617    ToolStarted(ToolStartedData),
2618    ToolCompleted(ToolCompletedData),
2619    ToolProgress(ToolProgressData),
2620    ToolOutputDelta(ToolOutputDeltaData),
2621    ToolCallRequested(ToolCallRequestedData),
2622
2623    // Recovery / repair events
2624    TranscriptRepaired(TranscriptRepairedData),
2625    ToolCallRepaired(ToolCallRepairedData),
2626
2627    // LLM events
2628    LlmGeneration(LlmGenerationData),
2629
2630    // Extended thinking events (for models with reasoning like Claude)
2631    ReasonThinkingDelta(ReasonThinkingDeltaData),
2632    ReasonItem(ReasonItemData),
2633    ReasonThinkingStarted(ReasonThinkingStartedData),
2634    ReasonThinkingCompleted(ReasonThinkingCompletedData),
2635
2636    TurnSealed(TurnSealedData),
2637    TurnCancelled(TurnCancelledData),
2638
2639    // Session events
2640    SessionStarted(SessionStartedData),
2641    SessionActivated(SessionActivatedData),
2642    SessionIdled(SessionIdledData),
2643    SessionTitleUpdated(SessionTitleUpdatedData),
2644
2645    // Session task lifecycle events (full snapshots)
2646    TaskCreated(SessionTaskEventData),
2647    TaskUpdated(SessionTaskEventData),
2648    TaskMessageSent(TaskMessageEventData),
2649    TaskMessageReceived(TaskMessageEventData),
2650
2651    // Context compaction events
2652    ContextCompacting(ContextCompactingData),
2653    ContextCompacted(ContextCompactedData),
2654
2655    // File events
2656    FileWritten(FileWrittenData),
2657
2658    // Budget events
2659    BudgetWarning(BudgetEventData),
2660    BudgetPaused(BudgetEventData),
2661    BudgetExhausted(BudgetEventData),
2662    BudgetResumed(BudgetEventData),
2663
2664    // Voice events
2665    VoiceSessionStarted(VoiceSessionStartedData),
2666    VoiceInputTranscriptDelta(VoiceTranscriptData),
2667    VoiceInputTranscriptCompleted(VoiceTranscriptData),
2668    VoiceOutputTranscriptDelta(VoiceTranscriptData),
2669    VoiceOutputTranscriptCompleted(VoiceTranscriptData),
2670    VoiceSessionEnded(VoiceSessionEndedData),
2671    VoiceSessionFailed(VoiceSessionFailedData),
2672
2673    /// Internal-only variant for unknown event types.
2674    /// Never serialized to API responses - filtered out before transmission.
2675    /// Logs a warning when created to alert developers of unknown types.
2676    #[serde(skip)]
2677    Unsupported {
2678        /// The unknown event type string
2679        event_type: String,
2680        /// The raw JSON data
2681        data: serde_json::Value,
2682    },
2683}
2684
2685impl EventData {
2686    /// Check if this is an unsupported event type.
2687    /// Unsupported events should be filtered before API responses.
2688    pub fn is_unsupported(&self) -> bool {
2689        matches!(self, EventData::Unsupported { .. })
2690    }
2691
2692    /// Create an unsupported event data with warning log.
2693    /// This is used when deserializing unknown event types.
2694    pub fn unsupported(event_type: String, data: serde_json::Value) -> Self {
2695        tracing::warn!(
2696            event_type = %event_type,
2697            "Encountered unsupported event type - will be filtered from API responses"
2698        );
2699        EventData::Unsupported { event_type, data }
2700    }
2701}
2702
2703/// Single source of truth for event identity.
2704///
2705/// Each entry maps an event `type` string to its [`EventData`] variant and the
2706/// payload struct that variant carries. The macro generates both directions from
2707/// this one list:
2708///   - [`EventData::event_type`] (variant -> `type` string), and
2709///   - [`deserialize_event_data`] (`type` string -> variant).
2710///
2711/// Keeping them in one place means the two can never drift out of sync, which is
2712/// why there is no separate hand-written dispatcher. Encoding and the OpenAPI
2713/// schema are handled by the `#[serde(untagged)]` `Serialize` derive on the enum;
2714/// declaration order there is irrelevant because decoding is driven entirely by
2715/// the `type` string below.
2716macro_rules! event_data_kinds {
2717    ($( $variant:ident($data:ty) = $type_const:path ),+ $(,)?) => {
2718        impl EventData {
2719            /// Get the event type constant for this data.
2720            /// For Unsupported events, returns "unsupported" (internal use only).
2721            pub fn event_type(&self) -> &'static str {
2722                match self {
2723                    $( EventData::$variant(_) => $type_const, )+
2724                    EventData::Unsupported { .. } => "unsupported",
2725                }
2726            }
2727        }
2728
2729        /// Deserialize event data from JSON based on `event_type`.
2730        ///
2731        /// The outer `type` string selects the variant, avoiding serde's untagged
2732        /// matching where a payload with fewer required fields could shadow a more
2733        /// specific one.
2734        ///
2735        /// # Returns
2736        /// The deserialized [`EventData`] variant. Unknown event types, and known
2737        /// types whose payload fails to decode, fall back to
2738        /// [`EventData::Unsupported`] (logged) rather than erroring or panicking.
2739        /// Unsupported events should be filtered before API responses.
2740        pub fn deserialize_event_data(event_type: &str, data: serde_json::Value) -> EventData {
2741            let result = match event_type {
2742                $(
2743                    $type_const => serde_json::from_value::<$data>(data.clone())
2744                        .map(EventData::$variant),
2745                )+
2746                _ => return EventData::unsupported(event_type.to_string(), data),
2747            };
2748
2749            result.unwrap_or_else(|e| {
2750                tracing::warn!(
2751                    event_type = %event_type,
2752                    error = %e,
2753                    "Failed to deserialize known event type - treating as unsupported"
2754                );
2755                EventData::Unsupported {
2756                    event_type: event_type.to_string(),
2757                    data,
2758                }
2759            })
2760        }
2761    };
2762}
2763
2764event_data_kinds! {
2765    // Input events
2766    InputMessage(InputMessageData) = INPUT_MESSAGE,
2767
2768    // Output events
2769    OutputMessageStarted(OutputMessageStartedData) = OUTPUT_MESSAGE_STARTED,
2770    OutputMessageDelta(OutputMessageDeltaData) = OUTPUT_MESSAGE_DELTA,
2771    OutputMessageReplaced(OutputMessageReplacedData) = OUTPUT_MESSAGE_REPLACED,
2772    OutputMessageCompleted(OutputMessageCompletedData) = OUTPUT_MESSAGE_COMPLETED,
2773
2774    // Turn lifecycle events
2775    TurnStarted(TurnStartedData) = TURN_STARTED,
2776    TurnCompleted(TurnCompletedData) = TURN_COMPLETED,
2777    TurnFailed(TurnFailedData) = TURN_FAILED,
2778    TurnSealed(TurnSealedData) = TURN_SEALED,
2779    TurnCancelled(TurnCancelledData) = TURN_CANCELLED,
2780
2781    // Atom lifecycle events
2782    ReasonStarted(ReasonStartedData) = REASON_STARTED,
2783    ReasonCompleted(ReasonCompletedData) = REASON_COMPLETED,
2784    ReasonRecovered(ReasonRecoveredData) = REASON_RECOVERED,
2785    CapabilityUsage(CapabilityUsageData) = CAPABILITY_USAGE,
2786    ActStarted(ActStartedData) = ACT_STARTED,
2787    ActCompleted(ActCompletedData) = ACT_COMPLETED,
2788    ToolStarted(ToolStartedData) = TOOL_STARTED,
2789    ToolCompleted(ToolCompletedData) = TOOL_COMPLETED,
2790    ToolProgress(ToolProgressData) = TOOL_PROGRESS,
2791    ToolOutputDelta(ToolOutputDeltaData) = TOOL_OUTPUT_DELTA,
2792    ToolCallRequested(ToolCallRequestedData) = TOOL_CALL_REQUESTED,
2793
2794    // Recovery / repair events
2795    TranscriptRepaired(TranscriptRepairedData) = TRANSCRIPT_REPAIRED,
2796    ToolCallRepaired(ToolCallRepairedData) = TOOL_CALL_REPAIRED,
2797
2798    // LLM events
2799    LlmGeneration(LlmGenerationData) = LLM_GENERATION,
2800
2801    // Extended thinking events
2802    ReasonThinkingStarted(ReasonThinkingStartedData) = REASON_THINKING_STARTED,
2803    ReasonThinkingDelta(ReasonThinkingDeltaData) = REASON_THINKING_DELTA,
2804    ReasonThinkingCompleted(ReasonThinkingCompletedData) = REASON_THINKING_COMPLETED,
2805    ReasonItem(ReasonItemData) = REASON_ITEM,
2806
2807    // Session events
2808    SessionStarted(SessionStartedData) = SESSION_STARTED,
2809    SessionActivated(SessionActivatedData) = SESSION_ACTIVATED,
2810    SessionIdled(SessionIdledData) = SESSION_IDLED,
2811    SessionTitleUpdated(SessionTitleUpdatedData) = SESSION_TITLE_UPDATED,
2812
2813    // Context compaction events
2814    ContextCompacting(ContextCompactingData) = CONTEXT_COMPACTING,
2815    ContextCompacted(ContextCompactedData) = CONTEXT_COMPACTED,
2816
2817    // File events
2818    FileWritten(FileWrittenData) = FILE_WRITTEN,
2819
2820    // Budget events (all four share BudgetEventData)
2821    BudgetWarning(BudgetEventData) = BUDGET_WARNING,
2822    BudgetPaused(BudgetEventData) = BUDGET_PAUSED,
2823    BudgetExhausted(BudgetEventData) = BUDGET_EXHAUSTED,
2824    BudgetResumed(BudgetEventData) = BUDGET_RESUMED,
2825
2826    // Voice events
2827    VoiceSessionStarted(VoiceSessionStartedData) = VOICE_SESSION_STARTED,
2828    VoiceInputTranscriptDelta(VoiceTranscriptData) = VOICE_INPUT_TRANSCRIPT_DELTA,
2829    VoiceInputTranscriptCompleted(VoiceTranscriptData) = VOICE_INPUT_TRANSCRIPT_COMPLETED,
2830    VoiceOutputTranscriptDelta(VoiceTranscriptData) = VOICE_OUTPUT_TRANSCRIPT_DELTA,
2831    VoiceOutputTranscriptCompleted(VoiceTranscriptData) = VOICE_OUTPUT_TRANSCRIPT_COMPLETED,
2832    VoiceSessionEnded(VoiceSessionEndedData) = VOICE_SESSION_ENDED,
2833    VoiceSessionFailed(VoiceSessionFailedData) = VOICE_SESSION_FAILED,
2834
2835    // Session task lifecycle events
2836    TaskCreated(SessionTaskEventData) = TASK_CREATED,
2837    TaskUpdated(SessionTaskEventData) = TASK_UPDATED,
2838    TaskMessageSent(TaskMessageEventData) = TASK_MESSAGE_SENT,
2839    TaskMessageReceived(TaskMessageEventData) = TASK_MESSAGE_RECEIVED,
2840}
2841
2842/// Macro to generate From implementations for EventData variants.
2843///
2844/// Reduces boilerplate from 5 lines to 1 line per variant.
2845macro_rules! impl_from_event_data {
2846    ($($data_type:ty => $variant:ident),* $(,)?) => {
2847        $(
2848            impl From<$data_type> for EventData {
2849                fn from(data: $data_type) -> Self {
2850                    EventData::$variant(data)
2851                }
2852            }
2853        )*
2854    };
2855}
2856
2857// Generate From implementations for all typed event data
2858impl_from_event_data! {
2859    InputMessageData => InputMessage,
2860    OutputMessageStartedData => OutputMessageStarted,
2861    OutputMessageDeltaData => OutputMessageDelta,
2862    OutputMessageReplacedData => OutputMessageReplaced,
2863    OutputMessageCompletedData => OutputMessageCompleted,
2864    TurnStartedData => TurnStarted,
2865    TurnCompletedData => TurnCompleted,
2866    TurnFailedData => TurnFailed,
2867    TurnSealedData => TurnSealed,
2868    TurnCancelledData => TurnCancelled,
2869    ReasonStartedData => ReasonStarted,
2870    ReasonCompletedData => ReasonCompleted,
2871    ReasonRecoveredData => ReasonRecovered,
2872    CapabilityUsageData => CapabilityUsage,
2873    ActStartedData => ActStarted,
2874    ActCompletedData => ActCompleted,
2875    ToolStartedData => ToolStarted,
2876    ToolCompletedData => ToolCompleted,
2877    ToolProgressData => ToolProgress,
2878    ToolOutputDeltaData => ToolOutputDelta,
2879    ToolCallRequestedData => ToolCallRequested,
2880    TranscriptRepairedData => TranscriptRepaired,
2881    ToolCallRepairedData => ToolCallRepaired,
2882    LlmGenerationData => LlmGeneration,
2883    ReasonThinkingStartedData => ReasonThinkingStarted,
2884    ReasonThinkingDeltaData => ReasonThinkingDelta,
2885    ReasonThinkingCompletedData => ReasonThinkingCompleted,
2886    ReasonItemData => ReasonItem,
2887    SessionStartedData => SessionStarted,
2888    SessionActivatedData => SessionActivated,
2889    SessionIdledData => SessionIdled,
2890    SessionTitleUpdatedData => SessionTitleUpdated,
2891    ContextCompactingData => ContextCompacting,
2892    ContextCompactedData => ContextCompacted,
2893    FileWrittenData => FileWritten,
2894    VoiceSessionStartedData => VoiceSessionStarted,
2895    VoiceSessionEndedData => VoiceSessionEnded,
2896    VoiceSessionFailedData => VoiceSessionFailed,
2897}
2898
2899impl EventData {
2900    pub fn voice_transcript_event(data: VoiceTranscriptData, event_type: &str) -> Self {
2901        match event_type {
2902            VOICE_INPUT_TRANSCRIPT_DELTA => EventData::VoiceInputTranscriptDelta(data),
2903            VOICE_INPUT_TRANSCRIPT_COMPLETED => EventData::VoiceInputTranscriptCompleted(data),
2904            VOICE_OUTPUT_TRANSCRIPT_DELTA => EventData::VoiceOutputTranscriptDelta(data),
2905            VOICE_OUTPUT_TRANSCRIPT_COMPLETED => EventData::VoiceOutputTranscriptCompleted(data),
2906            _ => EventData::unsupported(
2907                event_type.to_string(),
2908                serde_json::to_value(&data).unwrap_or(serde_json::Value::Null),
2909            ),
2910        }
2911    }
2912}
2913
2914// Budget events reuse BudgetEventData for all four variants,
2915// so we can't use the macro (it would conflict). Named constructor instead.
2916impl EventData {
2917    pub fn budget_event(data: BudgetEventData, event_type: &str) -> Self {
2918        match event_type {
2919            BUDGET_WARNING => EventData::BudgetWarning(data),
2920            BUDGET_PAUSED => EventData::BudgetPaused(data),
2921            BUDGET_EXHAUSTED => EventData::BudgetExhausted(data),
2922            BUDGET_RESUMED => EventData::BudgetResumed(data),
2923            _ => EventData::unsupported(
2924                event_type.to_string(),
2925                serde_json::to_value(&data).unwrap_or(serde_json::Value::Null),
2926            ),
2927        }
2928    }
2929}
2930
2931// ============================================================================
2932// Event Request (input type without id/sequence)
2933// ============================================================================
2934
2935/// Request to create a new event.
2936///
2937/// This is the input type for event ingestion. It contains all the data
2938/// needed to create an event, but without the `id` and `sequence` fields
2939/// which are assigned by the storage layer.
2940#[derive(Debug, Clone, Serialize)]
2941#[cfg_attr(feature = "openapi", derive(ToSchema))]
2942pub struct EventRequest {
2943    /// Event type in dot notation
2944    #[serde(rename = "type")]
2945    pub event_type: String,
2946
2947    /// Event timestamp
2948    pub ts: DateTime<Utc>,
2949
2950    /// Session this event belongs to
2951    pub session_id: SessionId,
2952
2953    /// Correlation context
2954    pub context: EventContext,
2955
2956    /// Event-specific payload
2957    pub data: EventData,
2958
2959    /// Arbitrary metadata for the event
2960    #[serde(skip_serializing_if = "Option::is_none")]
2961    pub metadata: Option<serde_json::Value>,
2962
2963    /// Tags for filtering and categorization
2964    #[serde(skip_serializing_if = "Option::is_none")]
2965    pub tags: Option<Vec<String>>,
2966}
2967
2968#[derive(Debug, Deserialize)]
2969struct RawEventRequest {
2970    #[serde(rename = "type")]
2971    event_type: String,
2972    ts: DateTime<Utc>,
2973    session_id: SessionId,
2974    context: EventContext,
2975    data: serde_json::Value,
2976    metadata: Option<serde_json::Value>,
2977    tags: Option<Vec<String>>,
2978}
2979
2980impl<'de> Deserialize<'de> for EventRequest {
2981    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2982    where
2983        D: Deserializer<'de>,
2984    {
2985        let raw = RawEventRequest::deserialize(deserializer)?;
2986        let data = deserialize_event_data(&raw.event_type, raw.data);
2987        Ok(Self {
2988            event_type: raw.event_type,
2989            ts: raw.ts,
2990            session_id: raw.session_id,
2991            context: raw.context,
2992            data,
2993            metadata: raw.metadata,
2994            tags: raw.tags,
2995        })
2996    }
2997}
2998
2999impl EventRequest {
3000    /// Create a new event request with the given session_id, context, and typed data
3001    ///
3002    /// The event type is automatically inferred from the data type.
3003    pub fn new(session_id: SessionId, context: EventContext, data: impl Into<EventData>) -> Self {
3004        let data = data.into();
3005        let event_type = data.event_type().to_string();
3006        Self {
3007            event_type,
3008            ts: Utc::now(),
3009            session_id,
3010            context,
3011            data,
3012            metadata: None,
3013            tags: None,
3014        }
3015    }
3016
3017    /// Set metadata
3018    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
3019        self.metadata = Some(metadata);
3020        self
3021    }
3022
3023    /// Set tags
3024    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
3025        self.tags = Some(tags);
3026        self
3027    }
3028
3029    /// Whether this event is ephemeral (high-frequency streaming deltas that
3030    /// don't need durable storage). Delivery backends that support ephemeral
3031    /// routing can publish these events without inserting them into PostgreSQL.
3032    ///
3033    /// The authoritative content lives in the corresponding "completed" event
3034    /// (e.g. `output.message.completed` has the full text), so missing a delta
3035    /// on reconnect is acceptable.
3036    pub fn is_ephemeral(&self) -> bool {
3037        is_ephemeral_event_type(&self.event_type)
3038    }
3039
3040    /// Convert to an Event with the given id and sequence
3041    pub fn into_event(self, id: EventId, sequence: i32) -> Event {
3042        Event {
3043            id,
3044            event_type: self.event_type,
3045            ts: self.ts,
3046            session_id: self.session_id,
3047            context: self.context,
3048            data: self.data,
3049            metadata: self.metadata,
3050            tags: self.tags,
3051            sequence: Some(sequence),
3052        }
3053    }
3054}
3055
3056// ============================================================================
3057// Event Builder
3058// ============================================================================
3059
3060/// Builder for creating events with fluent API
3061pub struct EventBuilder {
3062    session_id: SessionId,
3063    context: EventContext,
3064}
3065
3066impl EventBuilder {
3067    pub fn new(session_id: SessionId) -> Self {
3068        Self {
3069            session_id,
3070            context: EventContext::empty(),
3071        }
3072    }
3073
3074    pub fn with_turn(mut self, turn_id: TurnId, input_message_id: MessageId) -> Self {
3075        self.context.turn_id = Some(turn_id);
3076        self.context.input_message_id = Some(input_message_id);
3077        self
3078    }
3079
3080    pub fn with_exec(mut self, exec_id: ExecId) -> Self {
3081        self.context.exec_id = Some(exec_id);
3082        self
3083    }
3084
3085    pub fn build(self, data: impl Into<EventData>) -> Event {
3086        Event::new(self.session_id, self.context, data)
3087    }
3088}
3089
3090// ============================================================================
3091// Tests
3092// ============================================================================
3093
3094#[cfg(test)]
3095mod tests {
3096    use super::*;
3097    use crate::driver_registry::PromptCacheStrategy;
3098    use serde_json::json;
3099    use std::collections::HashMap;
3100
3101    #[test]
3102    fn test_event_creation() {
3103        let session_id = SessionId::new();
3104        let context = EventContext::empty();
3105        let data = InputMessageData::new(Message::user("test"));
3106
3107        let event = Event::new(session_id, context, data);
3108
3109        assert_eq!(event.event_type, "input.message");
3110        assert_eq!(event.session_uuid(), session_id.uuid());
3111        assert!(event.is_input_event());
3112        assert!(event.is_message_event());
3113    }
3114
3115    #[test]
3116    fn test_event_context_from_atom_context() {
3117        let session_id = SessionId::new();
3118        let turn_id = TurnId::new();
3119        let input_message_id = MessageId::new();
3120
3121        let atom_ctx = AtomContext::new(session_id, turn_id, input_message_id);
3122        let context = EventContext::from_atom_context(&atom_ctx);
3123
3124        assert_eq!(context.turn_id, Some(turn_id));
3125        assert_eq!(context.input_message_id, Some(input_message_id));
3126        assert_eq!(context.exec_id, Some(atom_ctx.exec_id));
3127    }
3128
3129    #[test]
3130    fn test_event_serialization() {
3131        let session_id = SessionId::new();
3132        let context = EventContext::empty();
3133        let event = Event::new(
3134            session_id,
3135            context,
3136            InputMessageData::new(Message::user("test")),
3137        );
3138
3139        let json = serde_json::to_string(&event).unwrap();
3140
3141        assert!(json.contains("\"type\":\"input.message\""));
3142        assert!(json.contains("\"session_id\""));
3143        assert!(json.contains("\"context\""));
3144        assert!(json.contains("\"data\""));
3145    }
3146
3147    #[test]
3148    fn transcript_repaired_is_valid_filter_event_type() {
3149        assert!(VALID_EVENT_TYPES.contains(&TRANSCRIPT_REPAIRED));
3150    }
3151
3152    /// `capability.usage` is emitted (`EventData::CapabilityUsage`) and documented
3153    /// as streamable, so the public `types`/`exclude` filter allowlist must accept
3154    /// it instead of rejecting it as an unknown event type.
3155    #[test]
3156    fn capability_usage_is_valid_filter_event_type() {
3157        assert!(VALID_EVENT_TYPES.contains(&CAPABILITY_USAGE));
3158    }
3159
3160    #[test]
3161    fn test_event_builder() {
3162        let session_id = SessionId::new();
3163        let turn_id = TurnId::new();
3164        let input_message_id = MessageId::new();
3165        let exec_id = ExecId::new();
3166
3167        let event = EventBuilder::new(session_id)
3168            .with_turn(turn_id, input_message_id)
3169            .with_exec(exec_id)
3170            .build(ReasonStartedData {
3171                harness_id: HarnessId::from_seed(1),
3172                agent_id: Some(AgentId::new()),
3173                metadata: Some(ModelMetadata {
3174                    model: "gpt-4o".to_string(),
3175                    model_id: None,
3176                    provider_id: None,
3177                }),
3178            });
3179
3180        assert_eq!(event.event_type, "reason.started");
3181        assert_eq!(event.session_id, session_id);
3182        assert_eq!(event.context.turn_id, Some(turn_id));
3183        assert_eq!(event.context.exec_id, Some(exec_id));
3184    }
3185
3186    #[test]
3187    fn test_reason_completed_data() {
3188        let data = ReasonCompletedData::success("Hello world", true, 2, Some(1000), None);
3189        assert!(data.success);
3190        assert_eq!(data.text_preview, Some("Hello world".to_string()));
3191        assert!(data.has_tool_calls);
3192        assert_eq!(data.tool_call_count, 2);
3193        assert_eq!(data.duration_ms, Some(1000));
3194        assert!(data.usage.is_none());
3195
3196        let data = ReasonCompletedData::failure("Network error".to_string(), Some(500));
3197        assert!(!data.success);
3198        assert_eq!(data.error, Some("Network error".to_string()));
3199        assert_eq!(data.duration_ms, Some(500));
3200    }
3201
3202    #[test]
3203    fn test_input_output_event_types() {
3204        assert_eq!(INPUT_MESSAGE, "input.message");
3205        assert_eq!(OUTPUT_MESSAGE_STARTED, "output.message.started");
3206        assert_eq!(OUTPUT_MESSAGE_DELTA, "output.message.delta");
3207        assert_eq!(OUTPUT_MESSAGE_COMPLETED, "output.message.completed");
3208    }
3209
3210    #[test]
3211    fn test_turn_event_types() {
3212        assert_eq!(TURN_STARTED, "turn.started");
3213        assert_eq!(TURN_COMPLETED, "turn.completed");
3214        assert_eq!(TURN_FAILED, "turn.failed");
3215        assert_eq!(TURN_CANCELLED, "turn.cancelled");
3216    }
3217
3218    #[test]
3219    fn test_turn_cancelled_data() {
3220        let data = TurnCancelledData {
3221            turn_id: TurnId::from_uuid(Uuid::now_v7()),
3222            reason: Some("User requested cancellation".to_string()),
3223            usage: Some(TokenUsage::new(100, 50)),
3224        };
3225
3226        let event_data: EventData = data.into();
3227        assert_eq!(event_data.event_type(), TURN_CANCELLED);
3228    }
3229
3230    #[test]
3231    fn test_tool_event_types() {
3232        assert_eq!(TOOL_STARTED, "tool.started");
3233        assert_eq!(TOOL_COMPLETED, "tool.completed");
3234    }
3235
3236    #[test]
3237    fn test_llm_generation_event_type() {
3238        assert_eq!(LLM_GENERATION, "llm.generation");
3239    }
3240
3241    #[test]
3242    fn test_llm_generation_data_success() {
3243        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
3244        let tools = vec![ToolDefinitionSummary {
3245            name: "get_weather".to_string(),
3246            display_name: None,
3247            category: None,
3248            capability_id: None,
3249            capability_name: None,
3250            description: "Get weather for a city".to_string(),
3251        }];
3252        let tool_calls = vec![];
3253        let data = LlmGenerationData::success(
3254            messages.clone(),
3255            tools,
3256            Some("Hi there!".to_string()),
3257            tool_calls,
3258            "gpt-4o".to_string(),
3259            Some("openai".to_string()),
3260            Some(TokenUsage {
3261                input_tokens: 10,
3262                output_tokens: 5,
3263                cache_read_tokens: None,
3264                cache_creation_tokens: None,
3265                actual_cost_usd: None,
3266                estimated_cost_usd: None,
3267                effective_cost_usd: None,
3268            }),
3269            Some(100),
3270            Some(25), // time_to_first_token_ms
3271        );
3272
3273        assert_eq!(data.messages.len(), 2);
3274        assert_eq!(data.tools.len(), 1);
3275        assert_eq!(data.tools[0].name, "get_weather");
3276        assert_eq!(data.output.text, Some("Hi there!".to_string()));
3277        assert!(data.output.tool_calls.is_empty());
3278        assert!(data.metadata.success);
3279        assert_eq!(data.metadata.model, "gpt-4o");
3280        assert_eq!(data.metadata.provider, Some("openai".to_string()));
3281        assert!(data.metadata.error.is_none());
3282        // New fields for gen-ai semantic conventions
3283        assert_eq!(data.metadata.finish_reasons, Some(vec!["stop".to_string()]));
3284        assert!(data.metadata.response_id.is_none());
3285    }
3286
3287    #[test]
3288    fn test_llm_generation_data_with_full_metadata() {
3289        let messages = vec![Message::user("Hello")];
3290        let data = LlmGenerationData::success_with_metadata(
3291            messages,
3292            vec![],
3293            Some("Hi!".to_string()),
3294            vec![],
3295            "claude-3-opus".to_string(),
3296            Some("anthropic".to_string()),
3297            Some(TokenUsage {
3298                input_tokens: 5,
3299                output_tokens: 3,
3300                cache_read_tokens: None,
3301                cache_creation_tokens: None,
3302                actual_cost_usd: None,
3303                estimated_cost_usd: None,
3304                effective_cost_usd: None,
3305            }),
3306            Some(50),
3307            Some(25), // time_to_first_token_ms
3308            Some(vec!["end_turn".to_string()]),
3309            Some("msg_12345".to_string()),
3310        );
3311
3312        assert!(data.metadata.success);
3313        assert_eq!(data.metadata.model, "claude-3-opus");
3314        assert_eq!(data.metadata.provider, Some("anthropic".to_string()));
3315        assert_eq!(data.metadata.time_to_first_token_ms, Some(25));
3316        assert_eq!(
3317            data.metadata.finish_reasons,
3318            Some(vec!["end_turn".to_string()])
3319        );
3320        assert_eq!(data.metadata.response_id, Some("msg_12345".to_string()));
3321    }
3322
3323    #[test]
3324    fn test_llm_generation_data_failure() {
3325        let messages = vec![Message::user("Hello")];
3326        let data = LlmGenerationData::failure(
3327            messages,
3328            vec![],
3329            "gpt-4o".to_string(),
3330            Some("openai".to_string()),
3331            "Rate limit exceeded".to_string(),
3332            Some(50),
3333            None, // time_to_first_token_ms
3334        );
3335
3336        assert!(!data.metadata.success);
3337        assert_eq!(data.metadata.error, Some("Rate limit exceeded".to_string()));
3338        assert!(data.output.text.is_none());
3339        assert!(data.output.tool_calls.is_empty());
3340    }
3341
3342    #[test]
3343    fn test_llm_generation_event_data() {
3344        let data = LlmGenerationData::success(
3345            vec![Message::user("test")],
3346            vec![],
3347            Some("response".to_string()),
3348            vec![],
3349            "model".to_string(),
3350            None,
3351            None,
3352            None,
3353            None, // time_to_first_token_ms
3354        );
3355
3356        let event_data: EventData = data.into();
3357        assert_eq!(event_data.event_type(), LLM_GENERATION);
3358    }
3359
3360    #[test]
3361    fn test_llm_generation_is_durable_not_ephemeral() {
3362        let session_id = SessionId::new();
3363        let data = LlmGenerationData::success(
3364            vec![Message::user("test")],
3365            vec![],
3366            Some("response".to_string()),
3367            vec![],
3368            "model".to_string(),
3369            None,
3370            None,
3371            None,
3372            None,
3373        );
3374
3375        let request = EventRequest::new(session_id, EventContext::empty(), data);
3376        assert!(!request.is_ephemeral());
3377    }
3378
3379    #[test]
3380    fn test_delta_events_are_ephemeral() {
3381        let session_id = SessionId::new();
3382        let turn_id = TurnId::new();
3383
3384        let output_delta = EventRequest::new(
3385            session_id,
3386            EventContext::empty(),
3387            OutputMessageDeltaData {
3388                turn_id,
3389                message_id: MessageId::new(),
3390                delta: "hel".to_string(),
3391                accumulated: "hel".to_string(),
3392                phase: None,
3393            },
3394        );
3395        assert!(output_delta.is_ephemeral());
3396
3397        let thinking_delta = EventRequest::new(
3398            session_id,
3399            EventContext::empty(),
3400            ReasonThinkingDeltaData {
3401                turn_id,
3402                delta: "step".to_string(),
3403                accumulated: "step".to_string(),
3404            },
3405        );
3406        assert!(thinking_delta.is_ephemeral());
3407
3408        let tool_delta = EventRequest::new(
3409            session_id,
3410            EventContext::empty(),
3411            ToolOutputDeltaData {
3412                tool_call_id: "call_123".to_string(),
3413                tool_name: "bash".to_string(),
3414                delta: "line".to_string(),
3415                stream: "stdout".to_string(),
3416            },
3417        );
3418        assert!(tool_delta.is_ephemeral());
3419    }
3420
3421    #[test]
3422    fn test_llm_generation_data_with_request_options() {
3423        let mut provider_options = HashMap::new();
3424        provider_options.insert(
3425            "openai".to_string(),
3426            json!({ "previous_response_id": true }),
3427        );
3428
3429        let data = LlmGenerationData::success(
3430            vec![Message::user("Hello")],
3431            vec![],
3432            Some("Hi".to_string()),
3433            vec![],
3434            "gpt-5.4".to_string(),
3435            Some("openai".to_string()),
3436            None,
3437            Some(42),
3438            Some(12),
3439        )
3440        .with_request_options(LlmRequestOptions {
3441            prompt_cache: Some(LlmPromptCacheInfo {
3442                enabled: true,
3443                strategy: PromptCacheStrategy::Auto,
3444                provider_mode: Some("prompt_cache_key".to_string()),
3445            }),
3446            tool_search: Some(LlmToolSearchInfo {
3447                enabled: true,
3448                threshold: 8,
3449            }),
3450            provider_options,
3451            metadata: Default::default(),
3452        });
3453
3454        let json = serde_json::to_value(&data).unwrap();
3455        assert_eq!(
3456            json["metadata"]["request_options"]["prompt_cache"]["provider_mode"],
3457            "prompt_cache_key"
3458        );
3459        assert_eq!(
3460            json["metadata"]["request_options"]["tool_search"]["threshold"],
3461            8
3462        );
3463        assert_eq!(
3464            json["metadata"]["request_options"]["provider_options"]["openai"]["previous_response_id"],
3465            true
3466        );
3467    }
3468
3469    #[test]
3470    fn test_extended_thinking_event_types() {
3471        assert_eq!(REASON_THINKING_STARTED, "reason.thinking.started");
3472        assert_eq!(REASON_THINKING_DELTA, "reason.thinking.delta");
3473        assert_eq!(REASON_THINKING_COMPLETED, "reason.thinking.completed");
3474    }
3475
3476    #[test]
3477    fn test_output_message_started_data() {
3478        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3479        let data = OutputMessageStartedData {
3480            turn_id,
3481            message_id: MessageId::new(),
3482            model: Some("claude-4-opus".to_string()),
3483            iteration: None,
3484            phase: None,
3485        };
3486
3487        let event_data: EventData = data.into();
3488        assert_eq!(event_data.event_type(), OUTPUT_MESSAGE_STARTED);
3489
3490        // Test serialization
3491        let json = serde_json::to_string(&event_data).unwrap();
3492        assert!(json.contains("turn_id"));
3493        assert!(json.contains("claude-4-opus"));
3494    }
3495
3496    #[test]
3497    fn test_output_message_started_data_without_model() {
3498        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3499        let data = OutputMessageStartedData {
3500            turn_id,
3501            message_id: MessageId::new(),
3502            model: None,
3503            iteration: None,
3504            phase: None,
3505        };
3506
3507        // Model should be skipped when None
3508        let json = serde_json::to_string(&data).unwrap();
3509        assert!(!json.contains("model"));
3510    }
3511
3512    #[test]
3513    fn test_reason_thinking_started_data() {
3514        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3515        let data = ReasonThinkingStartedData {
3516            turn_id,
3517            model: Some("claude-4-opus".to_string()),
3518        };
3519
3520        let event_data: EventData = data.into();
3521        assert_eq!(event_data.event_type(), REASON_THINKING_STARTED);
3522
3523        // Test serialization
3524        let json = serde_json::to_string(&event_data).unwrap();
3525        assert!(json.contains("turn_id"));
3526        assert!(json.contains("claude-4-opus"));
3527    }
3528
3529    #[test]
3530    fn test_reason_thinking_delta_data() {
3531        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3532        let data = ReasonThinkingDeltaData {
3533            turn_id,
3534            delta: "thinking step 1".to_string(),
3535            accumulated: "thinking step 1".to_string(),
3536        };
3537
3538        let event_data: EventData = data.into();
3539        assert_eq!(event_data.event_type(), REASON_THINKING_DELTA);
3540
3541        // Test serialization
3542        let json = serde_json::to_string(&event_data).unwrap();
3543        assert!(json.contains("turn_id"));
3544        assert!(json.contains("delta"));
3545        assert!(json.contains("accumulated"));
3546    }
3547
3548    #[test]
3549    fn test_reason_thinking_completed_data() {
3550        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3551        let data = ReasonThinkingCompletedData {
3552            turn_id,
3553            thinking: "Full thinking content here".to_string(),
3554        };
3555
3556        let event_data: EventData = data.into();
3557        assert_eq!(event_data.event_type(), REASON_THINKING_COMPLETED);
3558
3559        // Test serialization
3560        let json = serde_json::to_string(&event_data).unwrap();
3561        assert!(json.contains("turn_id"));
3562        assert!(json.contains("thinking"));
3563    }
3564
3565    #[test]
3566    fn test_output_message_delta_data() {
3567        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3568        let data = OutputMessageDeltaData {
3569            turn_id,
3570            message_id: MessageId::new(),
3571            delta: "Hello".to_string(),
3572            accumulated: "Hello".to_string(),
3573            phase: None,
3574        };
3575
3576        let event_data: EventData = data.into();
3577        assert_eq!(event_data.event_type(), OUTPUT_MESSAGE_DELTA);
3578
3579        // Test serialization
3580        let json = serde_json::to_string(&event_data).unwrap();
3581        assert!(json.contains("turn_id"));
3582        assert!(json.contains("delta"));
3583        assert!(json.contains("accumulated"));
3584    }
3585
3586    #[test]
3587    fn test_output_message_lifecycle_shares_message_id() {
3588        let turn_id = TurnId::new();
3589        let message_id = MessageId::new();
3590        let next_message_id = MessageId::new();
3591
3592        let started = OutputMessageStartedData {
3593            turn_id,
3594            message_id,
3595            model: None,
3596            iteration: Some(1),
3597            phase: None,
3598        };
3599        let next_started = OutputMessageStartedData {
3600            turn_id,
3601            message_id: next_message_id,
3602            model: None,
3603            iteration: Some(2),
3604            phase: None,
3605        };
3606        let delta = OutputMessageDeltaData {
3607            turn_id,
3608            message_id,
3609            delta: "Hello".to_string(),
3610            accumulated: "Hello".to_string(),
3611            phase: None,
3612        };
3613        let replaced = OutputMessageReplacedData {
3614            turn_id,
3615            message_id,
3616            guardrail_capability_id: "guardrails".to_string(),
3617            guardrail_id: "example".to_string(),
3618            reason_code: "blocked".to_string(),
3619            replacement: "Safe response".to_string(),
3620        };
3621        let completed = OutputMessageCompletedData::new(
3622            Message::assistant("Safe response").with_id(message_id),
3623        );
3624
3625        assert_eq!(started.message_id, message_id);
3626        assert_eq!(delta.message_id, message_id);
3627        assert_eq!(replaced.message_id, message_id);
3628        assert_eq!(completed.message.id, message_id);
3629        assert_ne!(started.message_id, next_started.message_id);
3630
3631        for value in [
3632            serde_json::to_value(started).unwrap(),
3633            serde_json::to_value(delta).unwrap(),
3634            serde_json::to_value(replaced).unwrap(),
3635        ] {
3636            assert_eq!(value["message_id"], message_id.to_string());
3637        }
3638    }
3639
3640    #[test]
3641    fn test_output_message_phase_hint_serde() {
3642        // EVE-774: the streamed phase hint is skipped when None (so existing
3643        // consumers see no new field) and serialized as the provider wire value
3644        // when present, on both started and delta events.
3645        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3646
3647        let started_none = OutputMessageStartedData {
3648            turn_id,
3649            message_id: MessageId::new(),
3650            model: None,
3651            iteration: None,
3652            phase: None,
3653        };
3654        assert!(
3655            !serde_json::to_string(&started_none)
3656                .unwrap()
3657                .contains("phase")
3658        );
3659
3660        let delta_commentary = OutputMessageDeltaData {
3661            turn_id,
3662            message_id: MessageId::new(),
3663            delta: "one moment".to_string(),
3664            accumulated: "one moment".to_string(),
3665            phase: Some(crate::message::ExecutionPhase::Commentary),
3666        };
3667        let json = serde_json::to_value(&delta_commentary).unwrap();
3668        assert_eq!(json["phase"], "commentary");
3669
3670        // Round-trips back to the same phase.
3671        let back: OutputMessageDeltaData = serde_json::from_value(json).unwrap();
3672        assert_eq!(back.phase, Some(crate::message::ExecutionPhase::Commentary));
3673
3674        let delta_final = OutputMessageDeltaData {
3675            turn_id,
3676            message_id: MessageId::new(),
3677            delta: "done".to_string(),
3678            accumulated: "done".to_string(),
3679            phase: Some(crate::message::ExecutionPhase::FinalAnswer),
3680        };
3681        assert_eq!(
3682            serde_json::to_value(&delta_final).unwrap()["phase"],
3683            "final_answer"
3684        );
3685    }
3686
3687    #[test]
3688    fn test_output_message_delta_deserialization_preserves_fields() {
3689        // Verify OutputMessageDelta decodes with all fields preserved through the
3690        // type-driven dispatcher (regression guard for field-dropping decode bugs).
3691        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3692        let data = OutputMessageDeltaData {
3693            turn_id,
3694            message_id: MessageId::new(),
3695            delta: "Hello world".to_string(),
3696            accumulated: "Hello world".to_string(),
3697            phase: None,
3698        };
3699
3700        // Serialize to JSON
3701        let json = serde_json::to_value(EventData::OutputMessageDelta(data.clone())).unwrap();
3702
3703        // Deserialize back through the real (type-driven) decode path
3704        let deserialized = deserialize_event_data(OUTPUT_MESSAGE_DELTA, json);
3705
3706        // Verify it's OutputMessageDelta and fields are preserved
3707        match deserialized {
3708            EventData::OutputMessageDelta(td) => {
3709                assert_eq!(td.turn_id, turn_id);
3710                assert_eq!(td.delta, "Hello world");
3711                assert_eq!(td.accumulated, "Hello world");
3712            }
3713            _ => panic!("Expected OutputMessageDelta, got different variant"),
3714        }
3715    }
3716
3717    #[test]
3718    fn test_output_message_started_deserialization() {
3719        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3720        let data = OutputMessageStartedData {
3721            turn_id,
3722            message_id: MessageId::new(),
3723            model: Some("claude-3".to_string()),
3724            iteration: None,
3725            phase: None,
3726        };
3727
3728        // Serialize to JSON
3729        let json = serde_json::to_value(EventData::OutputMessageStarted(data.clone())).unwrap();
3730
3731        // Deserialize back through the real (type-driven) decode path
3732        let deserialized = deserialize_event_data(OUTPUT_MESSAGE_STARTED, json);
3733
3734        // Verify it's OutputMessageStarted and fields are preserved
3735        match deserialized {
3736            EventData::OutputMessageStarted(at) => {
3737                assert_eq!(at.turn_id, turn_id);
3738                assert_eq!(at.model, Some("claude-3".to_string()));
3739            }
3740            _ => panic!("Expected OutputMessageStarted, got different variant"),
3741        }
3742    }
3743
3744    #[test]
3745    fn test_reason_thinking_started_deserialization() {
3746        // NOTE: ReasonThinkingStartedData and OutputMessageStartedData have identical
3747        // structures (turn_id + model), so a payload alone can't distinguish them.
3748        // Decoding therefore goes through deserialize_event_data(), which selects the
3749        // correct variant from the event_type.
3750        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3751        let data = ReasonThinkingStartedData {
3752            turn_id,
3753            model: Some("claude-3".to_string()),
3754        };
3755
3756        // Serialize to JSON
3757        let json = serde_json::to_value(&data).unwrap();
3758
3759        // Deserialize using typed function (not raw serde)
3760        let deserialized = deserialize_event_data(REASON_THINKING_STARTED, json);
3761
3762        // Verify it's ReasonThinkingStarted and fields are preserved
3763        match deserialized {
3764            EventData::ReasonThinkingStarted(at) => {
3765                assert_eq!(at.turn_id, turn_id);
3766                assert_eq!(at.model, Some("claude-3".to_string()));
3767            }
3768            other => panic!("Expected ReasonThinkingStarted, got {}", other.event_type()),
3769        }
3770    }
3771
3772    #[test]
3773    fn test_llm_generation_with_ttft() {
3774        let messages = vec![Message::user("Hello")];
3775        let data = LlmGenerationData::success_with_metadata(
3776            messages,
3777            vec![],
3778            Some("Hi!".to_string()),
3779            vec![],
3780            "gpt-4o".to_string(),
3781            Some("openai".to_string()),
3782            Some(TokenUsage {
3783                input_tokens: 10,
3784                output_tokens: 5,
3785                cache_read_tokens: None,
3786                cache_creation_tokens: None,
3787                actual_cost_usd: None,
3788                estimated_cost_usd: None,
3789                effective_cost_usd: None,
3790            }),
3791            Some(500), // duration_ms
3792            Some(120), // time_to_first_token_ms
3793            Some(vec!["stop".to_string()]),
3794            None,
3795        );
3796
3797        assert!(data.metadata.success);
3798        assert_eq!(data.metadata.duration_ms, Some(500));
3799        assert_eq!(data.metadata.time_to_first_token_ms, Some(120));
3800    }
3801
3802    #[test]
3803    fn test_llm_generation_ttft_serialization() {
3804        let messages = vec![Message::user("test")];
3805        let data = LlmGenerationData::success_with_metadata(
3806            messages,
3807            vec![],
3808            Some("response".to_string()),
3809            vec![],
3810            "model".to_string(),
3811            None,
3812            None,
3813            Some(1000),
3814            Some(150), // TTFT
3815            None,
3816            None,
3817        );
3818
3819        let json = serde_json::to_string(&data).unwrap();
3820        assert!(json.contains("time_to_first_token_ms"));
3821        assert!(json.contains("150"));
3822    }
3823
3824    #[test]
3825    fn test_reason_item_data_event_type_and_serialization() {
3826        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3827        let data = ReasonItemData {
3828            turn_id,
3829            provider: "openai".to_string(),
3830            model: Some("gpt-5.5".to_string()),
3831            item_id: "rs_abc".to_string(),
3832            encrypted_content: Some("OPAQUE_BLOB".to_string()),
3833            summary: vec!["safe summary".to_string()],
3834            token_count: Some(123),
3835        };
3836
3837        let event_data: EventData = data.into();
3838        assert_eq!(event_data.event_type(), REASON_ITEM);
3839
3840        let json = serde_json::to_string(&event_data).unwrap();
3841        assert!(json.contains("turn_id"));
3842        assert!(json.contains("openai"));
3843        assert!(json.contains("rs_abc"));
3844        assert!(json.contains("OPAQUE_BLOB"));
3845        assert!(json.contains("safe summary"));
3846    }
3847
3848    #[test]
3849    fn test_event_deserialize_reason_item_uses_event_type_dispatch() {
3850        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3851        let payload = serde_json::json!({
3852            "id": EventId::new().to_string(),
3853            "type": REASON_ITEM,
3854            "ts": Utc::now().to_rfc3339(),
3855            "session_id": SessionId::from_uuid(Uuid::now_v7()).to_string(),
3856            "context": {"trace_id": "t", "span_id": "s", "parent_span_id": null},
3857            "data": {
3858                "turn_id": turn_id.to_string(),
3859                "provider": "openai",
3860                "model": "gpt-5",
3861                "item_id": "rs_event",
3862                "encrypted_content": "ENC",
3863                "summary": ["safe"],
3864                "token_count": 9
3865            }
3866        });
3867
3868        let event: Event = serde_json::from_value(payload).expect("event deserializes");
3869        match event.data {
3870            EventData::ReasonItem(data) => {
3871                assert_eq!(data.turn_id, turn_id);
3872                assert_eq!(data.provider, "openai");
3873                assert_eq!(data.item_id, "rs_event");
3874                assert_eq!(data.token_count, Some(9));
3875            }
3876            other => panic!("expected reason.item data, got {}", other.event_type()),
3877        }
3878    }
3879
3880    #[test]
3881    fn test_event_request_deserialize_reason_item_uses_event_type_dispatch() {
3882        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3883        let payload = serde_json::json!({
3884            "type": REASON_ITEM,
3885            "ts": Utc::now().to_rfc3339(),
3886            "session_id": SessionId::from_uuid(Uuid::now_v7()).to_string(),
3887            "context": {"trace_id": "t", "span_id": "s", "parent_span_id": null},
3888            "data": {
3889                "turn_id": turn_id.to_string(),
3890                "provider": "openai",
3891                "item_id": "rs_request",
3892                "encrypted_content": "ENC",
3893                "summary": ["safe"]
3894            }
3895        });
3896
3897        let req: EventRequest = serde_json::from_value(payload).expect("request deserializes");
3898        match req.data {
3899            EventData::ReasonItem(data) => {
3900                assert_eq!(data.turn_id, turn_id);
3901                assert_eq!(data.provider, "openai");
3902                assert_eq!(data.item_id, "rs_request");
3903            }
3904            other => panic!("expected reason.item data, got {}", other.event_type()),
3905        }
3906    }
3907
3908    #[test]
3909    fn test_reason_item_data_round_trip_uses_typed_dispatch() {
3910        // ReasonItemData carries (turn_id, item_id, provider...) which is
3911        // structurally close to other turn-scoped events. Verify the typed
3912        // dispatcher selects the correct variant.
3913        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3914        let data = ReasonItemData {
3915            turn_id,
3916            provider: "openai".to_string(),
3917            model: Some("gpt-5".to_string()),
3918            item_id: "rs_xyz".to_string(),
3919            encrypted_content: Some("ENC".to_string()),
3920            summary: vec![],
3921            token_count: None,
3922        };
3923
3924        let json = serde_json::to_value(&data).unwrap();
3925        let deserialized = deserialize_event_data(REASON_ITEM, json);
3926
3927        match deserialized {
3928            EventData::ReasonItem(out) => {
3929                assert_eq!(out.turn_id, turn_id);
3930                assert_eq!(out.provider, "openai");
3931                assert_eq!(out.item_id, "rs_xyz");
3932                assert_eq!(out.encrypted_content.as_deref(), Some("ENC"));
3933            }
3934            other => panic!("Expected ReasonItem, got {}", other.event_type()),
3935        }
3936    }
3937
3938    /// `ReasonThinkingStartedData` only requires `turn_id`, so a richer
3939    /// `reason.item` payload also satisfies it structurally. Decoding does not
3940    /// rely on serde to disambiguate (`EventData` has no `Deserialize` impl, so
3941    /// variant declaration order is irrelevant); `deserialize_event_data`
3942    /// selects the variant from the outer `type` string. Guard that the overlap
3943    /// exists and that type dispatch resolves `reason.item` to `ReasonItem`
3944    /// (keeping `provider`, `item_id`, …) rather than the looser
3945    /// `ReasonThinkingStarted`.
3946    #[test]
3947    fn test_reason_item_resolves_via_type_dispatch_despite_overlap() {
3948        // The two reasoning variants overlap structurally: ReasonThinkingStarted
3949        // (turn_id + optional model) accepts any superset, while ReasonItem
3950        // (turn_id + provider + item_id + …) is richer. Confirm both parse in
3951        // isolation, then that type dispatch picks ReasonItem.
3952        let turn_id = TurnId::from_uuid(Uuid::now_v7());
3953        let json = serde_json::json!({
3954            "turn_id": turn_id.to_string(),
3955            "provider": "openai",
3956            "model": "gpt-5",
3957            "item_id": "rs_keep",
3958            "encrypted_content": "ENC",
3959            "summary": ["s"],
3960            "token_count": 7,
3961        });
3962
3963        // Both candidate structs accept the payload in isolation
3964        // (ReasonThinkingStarted ignores the extra fields), proving the overlap.
3965        // The canonical path disambiguates via the event_type, not via any
3966        // declaration order.
3967        let as_thinking: ReasonThinkingStartedData =
3968            serde_json::from_value(json.clone()).expect("thinking ignores extra fields");
3969        assert_eq!(as_thinking.turn_id, turn_id);
3970        assert_eq!(as_thinking.model.as_deref(), Some("gpt-5"));
3971
3972        let as_item: ReasonItemData =
3973            serde_json::from_value(json.clone()).expect("ReasonItem accepts payload");
3974        assert_eq!(as_item.item_id, "rs_keep");
3975        assert_eq!(as_item.provider, "openai");
3976
3977        // Canonical parse via type dispatch: this is the path used by Event
3978        // and EventRequest deserialization (see `deserialize_event_data`).
3979        let event_data = deserialize_event_data(REASON_ITEM, json);
3980        match event_data {
3981            EventData::ReasonItem(out) => {
3982                assert_eq!(out.item_id, "rs_keep");
3983                assert_eq!(out.provider, "openai");
3984            }
3985            other => panic!(
3986                "Typed dispatcher must select ReasonItem for {REASON_ITEM}, got {}",
3987                other.event_type()
3988            ),
3989        }
3990    }
3991
3992    /// Regression guard for EVE-485: the persisted `reason.item` event must
3993    /// never carry plaintext hidden reasoning content. Construction only
3994    /// accepts `encrypted_content` and `summary` (curated by the provider).
3995    /// Assert structurally on parsed JSON keys rather than substrings so a
3996    /// payload value that happens to contain "content"/"thinking" cannot mask
3997    /// the guard.
3998    #[test]
3999    fn test_reason_item_data_excludes_plaintext_reasoning() {
4000        let turn_id = TurnId::from_uuid(Uuid::now_v7());
4001        let data = ReasonItemData {
4002            turn_id,
4003            provider: "openai".to_string(),
4004            model: Some("gpt-5".to_string()),
4005            item_id: "rs_secret".to_string(),
4006            // Deliberately stuff the substrings the old guard checked into a
4007            // legitimate value to prove the structural check still rejects
4008            // them when present only as values.
4009            encrypted_content: Some("opaque_blob_thinking_content_reasoning_text".to_string()),
4010            summary: vec!["safe summary mentioning content and thinking".to_string()],
4011            token_count: Some(1),
4012        };
4013
4014        let value = serde_json::to_value(&data).expect("serializable");
4015        let object = value.as_object().expect("data serializes to JSON object");
4016        for forbidden in [
4017            "content",
4018            "reasoning_text",
4019            "thinking",
4020            "reasoning_content",
4021            "raw_reasoning",
4022        ] {
4023            assert!(
4024                !object.contains_key(forbidden),
4025                "ReasonItemData JSON must not expose `{forbidden}` key, got: {object:?}",
4026            );
4027        }
4028        // The only sanctioned fields that carry reasoning artifacts.
4029        assert!(object.contains_key("encrypted_content"));
4030        assert!(object.contains_key("summary"));
4031    }
4032
4033    #[test]
4034    fn test_llm_generation_ttft_omitted_when_none() {
4035        let messages = vec![Message::user("test")];
4036        let data = LlmGenerationData::success(
4037            messages,
4038            vec![],
4039            Some("response".to_string()),
4040            vec![],
4041            "model".to_string(),
4042            None,
4043            None,
4044            None,
4045            None, // time_to_first_token_ms
4046        );
4047
4048        // TTFT should be None when passed as None
4049        assert!(data.metadata.time_to_first_token_ms.is_none());
4050
4051        // Should not appear in JSON when None
4052        let json = serde_json::to_string(&data).unwrap();
4053        assert!(!json.contains("time_to_first_token_ms"));
4054    }
4055}
4056
4057// ============================================================================
4058// Contract Tests
4059// ============================================================================
4060//
4061// These tests validate the event protocol contract defined in knowledge/execution/events.md.
4062// Snapshot tests ensure JSON structure doesn't change accidentally.
4063// Forward compatibility tests verify unknown fields are handled correctly.
4064
4065#[cfg(test)]
4066mod contract_tests {
4067    use super::*;
4068    use insta::{assert_json_snapshot, with_settings};
4069
4070    /// Helper to create deterministic test IDs for snapshot stability
4071    fn test_session_id() -> SessionId {
4072        SessionId::from_uuid(uuid::Uuid::from_u128(
4073            0x0000_0000_0000_0000_0000_0000_0000_0001,
4074        ))
4075    }
4076
4077    fn test_turn_id() -> TurnId {
4078        TurnId::from_uuid(uuid::Uuid::from_u128(
4079            0x0000_0000_0000_0000_0000_0000_0000_0002,
4080        ))
4081    }
4082
4083    fn test_message_id() -> MessageId {
4084        MessageId::from_uuid(uuid::Uuid::from_u128(
4085            0x0000_0000_0000_0000_0000_0000_0000_0003,
4086        ))
4087    }
4088
4089    fn test_agent_id() -> AgentId {
4090        AgentId::from_uuid(uuid::Uuid::from_u128(
4091            0x0000_0000_0000_0000_0000_0000_0000_0004,
4092        ))
4093    }
4094
4095    fn test_harness_id() -> HarnessId {
4096        HarnessId::from_uuid(uuid::Uuid::from_u128(
4097            0x0000_0000_0000_0000_0000_0000_0000_0005,
4098        ))
4099    }
4100
4101    // ========================================================================
4102    // Serialization Snapshot Tests
4103    // ========================================================================
4104    // These tests capture the canonical JSON representation of each event type.
4105    // Changes to these snapshots indicate a potential breaking change.
4106
4107    #[test]
4108    fn snapshot_input_message() {
4109        let data = InputMessageData::new(Message::user("Hello, world!"));
4110        with_settings!({
4111            sort_maps => true,
4112        }, {
4113            // Redact volatile fields (id, created_at) to ensure snapshot stability
4114            assert_json_snapshot!("event_data_input_message", data, {
4115                ".message.id" => "[MESSAGE_ID]",
4116                ".message.created_at" => "[TIMESTAMP]"
4117            });
4118        });
4119    }
4120
4121    #[test]
4122    fn snapshot_output_message_started() {
4123        let data = OutputMessageStartedData {
4124            turn_id: test_turn_id(),
4125            message_id: test_message_id(),
4126            model: Some("gpt-4o".to_string()),
4127            iteration: None,
4128            phase: None,
4129        };
4130        with_settings!({
4131            sort_maps => true,
4132        }, {
4133            assert_json_snapshot!("event_data_output_message_started", data);
4134        });
4135    }
4136
4137    #[test]
4138    fn snapshot_output_message_delta() {
4139        let data = OutputMessageDeltaData {
4140            turn_id: test_turn_id(),
4141            message_id: test_message_id(),
4142            delta: "Hello".to_string(),
4143            accumulated: "Hello".to_string(),
4144            phase: None,
4145        };
4146        with_settings!({
4147            sort_maps => true,
4148        }, {
4149            assert_json_snapshot!("event_data_output_message_delta", data);
4150        });
4151    }
4152
4153    #[test]
4154    fn snapshot_output_message_completed() {
4155        let data = OutputMessageCompletedData::new(Message::assistant("Hello!"));
4156        with_settings!({
4157            sort_maps => true,
4158        }, {
4159            // Redact volatile fields (id, created_at) to ensure snapshot stability
4160            assert_json_snapshot!("event_data_output_message_completed", data, {
4161                ".message.id" => "[MESSAGE_ID]",
4162                ".message.created_at" => "[TIMESTAMP]"
4163            });
4164        });
4165    }
4166
4167    #[test]
4168    fn snapshot_turn_started() {
4169        let data = TurnStartedData {
4170            turn_id: test_turn_id(),
4171            input_message_id: test_message_id(),
4172            input_content: Some("Hello".to_string()),
4173        };
4174        with_settings!({
4175            sort_maps => true,
4176        }, {
4177            assert_json_snapshot!("event_data_turn_started", data);
4178        });
4179    }
4180
4181    #[test]
4182    fn snapshot_turn_completed() {
4183        let data = TurnCompletedData {
4184            turn_id: test_turn_id(),
4185            iterations: 3,
4186            duration_ms: Some(1500),
4187            usage: Some(TokenUsage::new(100, 50)),
4188            input_content: None,
4189            final_message_id: Some(test_message_id()),
4190            final_answer_preview: Some("Done.".to_string()),
4191            time_to_first_token_ms: Some(120),
4192            tool_call_count: Some(2),
4193            llm_call_count: Some(3),
4194            status: Some("completed".to_string()),
4195        };
4196        with_settings!({
4197            sort_maps => true,
4198        }, {
4199            assert_json_snapshot!("event_data_turn_completed", data);
4200        });
4201    }
4202
4203    #[test]
4204    fn snapshot_turn_failed() {
4205        let data = TurnFailedData {
4206            turn_id: test_turn_id(),
4207            error: "Rate limit exceeded".to_string(),
4208            error_code: Some("RATE_LIMIT".to_string()),
4209            error_fields: None,
4210            error_disclosure: None,
4211        };
4212        with_settings!({
4213            sort_maps => true,
4214        }, {
4215            assert_json_snapshot!("event_data_turn_failed", data);
4216        });
4217    }
4218
4219    #[test]
4220    fn snapshot_turn_cancelled() {
4221        let data = TurnCancelledData {
4222            turn_id: test_turn_id(),
4223            reason: Some("User requested".to_string()),
4224            usage: Some(TokenUsage::new(50, 25)),
4225        };
4226        with_settings!({
4227            sort_maps => true,
4228        }, {
4229            assert_json_snapshot!("event_data_turn_cancelled", data);
4230        });
4231    }
4232
4233    #[test]
4234    fn snapshot_reason_started() {
4235        let data = ReasonStartedData {
4236            harness_id: test_harness_id(),
4237            agent_id: Some(test_agent_id()),
4238            metadata: Some(ModelMetadata {
4239                model: "gpt-4o".to_string(),
4240                model_id: None,
4241                provider_id: None,
4242            }),
4243        };
4244        with_settings!({
4245            sort_maps => true,
4246        }, {
4247            assert_json_snapshot!("event_data_reason_started", data);
4248        });
4249    }
4250
4251    #[test]
4252    fn snapshot_reason_completed() {
4253        let data = ReasonCompletedData::success(
4254            "Hello world",
4255            true,
4256            2,
4257            Some(1000),
4258            Some(TokenUsage::new(100, 50)),
4259        );
4260        with_settings!({
4261            sort_maps => true,
4262        }, {
4263            assert_json_snapshot!("event_data_reason_completed", data);
4264        });
4265    }
4266
4267    #[test]
4268    fn snapshot_act_started() {
4269        let data = ActStartedData {
4270            tool_calls: vec![ToolCallSummary {
4271                id: "tc_1".to_string(),
4272                name: "get_weather".to_string(),
4273                display_name: None,
4274                narration: None,
4275                completed_narration: None,
4276            }],
4277            headline: None,
4278        };
4279        with_settings!({
4280            sort_maps => true,
4281        }, {
4282            assert_json_snapshot!("event_data_act_started", data);
4283        });
4284    }
4285
4286    #[test]
4287    fn snapshot_act_completed() {
4288        let data = ActCompletedData {
4289            completed: true,
4290            success_count: 2,
4291            error_count: 0,
4292            duration_ms: Some(500),
4293            headline: None,
4294        };
4295        with_settings!({
4296            sort_maps => true,
4297        }, {
4298            assert_json_snapshot!("event_data_act_completed", data);
4299        });
4300    }
4301
4302    #[test]
4303    fn snapshot_tool_started() {
4304        let data = ToolStartedData {
4305            tool_call: ToolCall {
4306                id: "tc_1".to_string(),
4307                name: "get_weather".to_string(),
4308                arguments: serde_json::json!({"city": "London"}),
4309            },
4310            tool_call_fingerprint: None,
4311            display_name: None,
4312            narration: None,
4313        };
4314        with_settings!({
4315            sort_maps => true,
4316        }, {
4317            assert_json_snapshot!("event_data_tool_started", data);
4318        });
4319    }
4320
4321    #[test]
4322    fn snapshot_tool_completed() {
4323        let data = ToolCompletedData::success(
4324            "tc_1".to_string(),
4325            "get_weather".to_string(),
4326            vec![crate::message::ContentPart::text("Sunny, 22°C")],
4327            Some(250),
4328        );
4329        with_settings!({
4330            sort_maps => true,
4331        }, {
4332            assert_json_snapshot!("event_data_tool_completed", data);
4333        });
4334    }
4335
4336    #[test]
4337    fn snapshot_llm_generation() {
4338        let data = LlmGenerationData::success(
4339            vec![Message::user("Hello")],
4340            vec![ToolDefinitionSummary {
4341                name: "tool1".to_string(),
4342                display_name: None,
4343                category: None,
4344                capability_id: None,
4345                capability_name: None,
4346                description: "A tool".to_string(),
4347            }],
4348            Some("Hi there!".to_string()),
4349            vec![],
4350            "gpt-4o".to_string(),
4351            Some("openai".to_string()),
4352            Some(TokenUsage::new(10, 5)),
4353            Some(100),
4354            Some(25),
4355        );
4356        with_settings!({
4357            sort_maps => true,
4358        }, {
4359            // Redact volatile fields (id, created_at) in messages array
4360            assert_json_snapshot!("event_data_llm_generation", data, {
4361                ".messages[].id" => "[MESSAGE_ID]",
4362                ".messages[].created_at" => "[TIMESTAMP]"
4363            });
4364        });
4365    }
4366
4367    #[test]
4368    fn snapshot_reason_thinking_started() {
4369        let data = ReasonThinkingStartedData {
4370            turn_id: test_turn_id(),
4371            model: Some("claude-4-opus".to_string()),
4372        };
4373        with_settings!({
4374            sort_maps => true,
4375        }, {
4376            assert_json_snapshot!("event_data_reason_thinking_started", data);
4377        });
4378    }
4379
4380    #[test]
4381    fn snapshot_reason_thinking_delta() {
4382        let data = ReasonThinkingDeltaData {
4383            turn_id: test_turn_id(),
4384            delta: "Let me think...".to_string(),
4385            accumulated: "Let me think...".to_string(),
4386        };
4387        with_settings!({
4388            sort_maps => true,
4389        }, {
4390            assert_json_snapshot!("event_data_reason_thinking_delta", data);
4391        });
4392    }
4393
4394    #[test]
4395    fn snapshot_reason_thinking_completed() {
4396        let data = ReasonThinkingCompletedData {
4397            turn_id: test_turn_id(),
4398            thinking: "I need to consider...".to_string(),
4399        };
4400        with_settings!({
4401            sort_maps => true,
4402        }, {
4403            assert_json_snapshot!("event_data_reason_thinking_completed", data);
4404        });
4405    }
4406
4407    #[test]
4408    fn snapshot_reason_item() {
4409        let data = ReasonItemData {
4410            turn_id: test_turn_id(),
4411            provider: "openai".to_string(),
4412            model: Some("gpt-5.5".to_string()),
4413            item_id: "rs_test".to_string(),
4414            encrypted_content: Some("OPAQUE".to_string()),
4415            summary: vec!["safe summary".to_string()],
4416            token_count: Some(42),
4417        };
4418        with_settings!({
4419            sort_maps => true,
4420        }, {
4421            assert_json_snapshot!("event_data_reason_item", data);
4422        });
4423    }
4424
4425    #[test]
4426    fn snapshot_session_started() {
4427        let data = SessionStartedData {
4428            harness_id: test_harness_id(),
4429            agent_id: Some(test_agent_id()),
4430            model_id: None,
4431        };
4432        with_settings!({
4433            sort_maps => true,
4434        }, {
4435            assert_json_snapshot!("event_data_session_started", data);
4436        });
4437    }
4438
4439    #[test]
4440    fn snapshot_session_activated() {
4441        let data = SessionActivatedData {
4442            turn_id: test_turn_id(),
4443            input_message_id: test_message_id(),
4444        };
4445        with_settings!({
4446            sort_maps => true,
4447        }, {
4448            assert_json_snapshot!("event_data_session_activated", data);
4449        });
4450    }
4451
4452    #[test]
4453    fn snapshot_session_idled() {
4454        let data = SessionIdledData {
4455            turn_id: test_turn_id(),
4456            iterations: Some(3),
4457            usage: Some(TokenUsage::new(500, 200)),
4458        };
4459        with_settings!({
4460            sort_maps => true,
4461        }, {
4462            assert_json_snapshot!("event_data_session_idled", data);
4463        });
4464    }
4465
4466    #[test]
4467    fn snapshot_session_title_updated() {
4468        let data = SessionTitleUpdatedData {
4469            previous_title: Some("Old title".to_string()),
4470            title: "Automatic Session Titles".to_string(),
4471        };
4472        with_settings!({
4473            sort_maps => true,
4474        }, {
4475            assert_json_snapshot!("event_data_session_title_updated", data);
4476        });
4477
4478        let untitled = serde_json::to_value(SessionTitleUpdatedData {
4479            previous_title: None,
4480            title: "First title".to_string(),
4481        })
4482        .expect("serialize title event");
4483        assert!(untitled["previous_title"].is_null());
4484    }
4485
4486    // ========================================================================
4487    // Display Name Tests
4488    // ========================================================================
4489    // Verify display_name propagation through event data types.
4490
4491    #[test]
4492    fn tool_call_summary_with_display_name() {
4493        let summary = ToolCallSummary {
4494            id: "tc_1".to_string(),
4495            name: "get_weather".to_string(),
4496            display_name: Some("Get Weather".to_string()),
4497            narration: None,
4498            completed_narration: None,
4499        };
4500        let json = serde_json::to_value(&summary).unwrap();
4501        assert_eq!(json["display_name"], "Get Weather");
4502
4503        // Round-trip
4504        let deserialized: ToolCallSummary = serde_json::from_value(json).unwrap();
4505        assert_eq!(deserialized.display_name.as_deref(), Some("Get Weather"));
4506    }
4507
4508    #[test]
4509    fn tool_call_summary_without_display_name_omits_field() {
4510        let summary = ToolCallSummary {
4511            id: "tc_1".to_string(),
4512            name: "get_weather".to_string(),
4513            display_name: None,
4514            narration: None,
4515            completed_narration: None,
4516        };
4517        let json = serde_json::to_string(&summary).unwrap();
4518        assert!(!json.contains("display_name"));
4519
4520        // Deserialize without display_name field present
4521        let json_without = r#"{"id":"tc_1","name":"get_weather"}"#;
4522        let deserialized: ToolCallSummary = serde_json::from_str(json_without).unwrap();
4523        assert_eq!(deserialized.display_name, None);
4524    }
4525
4526    #[test]
4527    fn act_started_with_definitions_populates_display_names() {
4528        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolPolicy};
4529
4530        let tool_calls = vec![
4531            ToolCall {
4532                id: "tc_1".to_string(),
4533                name: "get_weather".to_string(),
4534                arguments: serde_json::json!({}),
4535            },
4536            ToolCall {
4537                id: "tc_2".to_string(),
4538                name: "unknown_tool".to_string(),
4539                arguments: serde_json::json!({}),
4540            },
4541        ];
4542        let tool_defs = vec![crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
4543            name: "get_weather".to_string(),
4544            display_name: Some("Get Weather".to_string()),
4545            description: "Gets weather".to_string(),
4546            parameters: serde_json::json!({}),
4547            policy: ToolPolicy::Auto,
4548            category: None,
4549            deferrable: DeferrablePolicy::default(),
4550            hints: crate::tool_types::ToolHints::default(),
4551            full_parameters: None,
4552        })];
4553
4554        let data = ActStartedData::with_definitions(&tool_calls, &tool_defs);
4555        assert_eq!(data.tool_calls.len(), 2);
4556        assert_eq!(
4557            data.tool_calls[0].display_name.as_deref(),
4558            Some("Get Weather")
4559        );
4560        assert_eq!(
4561            data.tool_calls[0].narration.as_deref(),
4562            Some("Running Get Weather")
4563        );
4564        assert_eq!(
4565            data.tool_calls[0].completed_narration.as_deref(),
4566            Some("Ran Get Weather")
4567        );
4568        assert_eq!(data.tool_calls[1].display_name, None);
4569    }
4570
4571    #[test]
4572    fn tool_completed_with_display_name_roundtrip() {
4573        let data = ToolCompletedData::success(
4574            "tc_1".to_string(),
4575            "get_weather".to_string(),
4576            vec![crate::message::ContentPart::text("Sunny")],
4577            Some(100),
4578        )
4579        .with_display_name(Some("Get Weather".to_string()));
4580
4581        assert_eq!(data.display_name.as_deref(), Some("Get Weather"));
4582
4583        let json = serde_json::to_value(&data).unwrap();
4584        assert_eq!(json["display_name"], "Get Weather");
4585
4586        let deserialized: ToolCompletedData = serde_json::from_value(json).unwrap();
4587        assert_eq!(deserialized.display_name.as_deref(), Some("Get Weather"));
4588    }
4589
4590    #[test]
4591    fn tool_started_display_name_serialization() {
4592        let data = ToolStartedData {
4593            tool_call: ToolCall {
4594                id: "tc_1".to_string(),
4595                name: "bash".to_string(),
4596                arguments: serde_json::json!({"command": "ls"}),
4597            },
4598            tool_call_fingerprint: None,
4599            display_name: Some("Bash".to_string()),
4600            narration: None,
4601        };
4602
4603        let json = serde_json::to_value(&data).unwrap();
4604        assert_eq!(json["display_name"], "Bash");
4605    }
4606
4607    #[test]
4608    fn tool_definition_summary_display_name() {
4609        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolPolicy};
4610
4611        let def = crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
4612            name: "read_file".to_string(),
4613            display_name: Some("Read File".to_string()),
4614            description: "Reads a file".to_string(),
4615            parameters: serde_json::json!({}),
4616            policy: ToolPolicy::Auto,
4617            category: None,
4618            deferrable: DeferrablePolicy::default(),
4619            hints: crate::tool_types::ToolHints::default(),
4620            full_parameters: None,
4621        });
4622
4623        let summary = ToolDefinitionSummary::from(&def);
4624        assert_eq!(summary.display_name.as_deref(), Some("Read File"));
4625
4626        let json = serde_json::to_value(&summary).unwrap();
4627        assert_eq!(json["display_name"], "Read File");
4628    }
4629
4630    // ========================================================================
4631    // Forward Compatibility Tests
4632    // ========================================================================
4633    // These tests verify that unknown fields and types are handled correctly
4634    // per the contract specification.
4635
4636    #[test]
4637    fn forward_compat_unknown_fields_ignored() {
4638        // Unknown fields should be silently ignored during deserialization
4639        let json = r#"{
4640            "turn_id": "turn_00000000000000000000000000000002",
4641            "iterations": 3,
4642            "duration_ms": 1500,
4643            "usage": {"input_tokens": 100, "output_tokens": 50},
4644            "future_field": "should be ignored",
4645            "another_new_field": 42
4646        }"#;
4647
4648        let data: TurnCompletedData = serde_json::from_str(json).unwrap();
4649        assert_eq!(data.iterations, 3);
4650        assert_eq!(data.duration_ms, Some(1500));
4651    }
4652
4653    #[test]
4654    fn forward_compat_unknown_event_type_becomes_unsupported() {
4655        // Unknown event types should deserialize to Unsupported
4656        let json = serde_json::json!({"some_field": "value"});
4657        let data = deserialize_event_data("future.event.type", json);
4658
4659        assert!(data.is_unsupported());
4660        assert_eq!(data.event_type(), "unsupported");
4661    }
4662
4663    #[test]
4664    fn forward_compat_unsupported_preserves_data() {
4665        // Unsupported events should preserve the original data for debugging
4666        let original = serde_json::json!({"key": "value", "nested": {"a": 1}});
4667        let data = deserialize_event_data("unknown.event", original.clone());
4668
4669        match data {
4670            EventData::Unsupported { event_type, data } => {
4671                assert_eq!(event_type, "unknown.event");
4672                assert_eq!(data, original);
4673            }
4674            _ => panic!("Expected Unsupported variant"),
4675        }
4676    }
4677
4678    #[test]
4679    fn forward_compat_optional_fields_absent() {
4680        // Optional fields can be absent without causing errors
4681        let json = r#"{
4682            "turn_id": "turn_00000000000000000000000000000002",
4683            "iterations": 3
4684        }"#;
4685
4686        let data: TurnCompletedData = serde_json::from_str(json).unwrap();
4687        assert_eq!(data.iterations, 3);
4688        assert!(data.duration_ms.is_none());
4689        assert!(data.usage.is_none());
4690        assert!(data.input_content.is_none());
4691        assert!(data.final_message_id.is_none());
4692        assert!(data.final_answer_preview.is_none());
4693        assert!(data.time_to_first_token_ms.is_none());
4694        assert!(data.tool_call_count.is_none());
4695        assert!(data.llm_call_count.is_none());
4696        assert!(data.status.is_none());
4697    }
4698
4699    // ========================================================================
4700    // Round-Trip Serialization Tests
4701    // ========================================================================
4702    // These tests verify that events survive serialization/deserialization.
4703
4704    #[test]
4705    fn round_trip_all_event_data_types() {
4706        // Test that all event data types can be serialized and deserialized
4707        let test_cases: Vec<(&str, EventData)> = vec![
4708            (
4709                INPUT_MESSAGE,
4710                InputMessageData::new(Message::user("test")).into(),
4711            ),
4712            (
4713                OUTPUT_MESSAGE_STARTED,
4714                OutputMessageStartedData {
4715                    turn_id: test_turn_id(),
4716                    message_id: test_message_id(),
4717                    model: None,
4718                    iteration: None,
4719                    phase: None,
4720                }
4721                .into(),
4722            ),
4723            (
4724                OUTPUT_MESSAGE_DELTA,
4725                OutputMessageDeltaData {
4726                    turn_id: test_turn_id(),
4727                    message_id: test_message_id(),
4728                    delta: "x".to_string(),
4729                    accumulated: "x".to_string(),
4730                    phase: None,
4731                }
4732                .into(),
4733            ),
4734            (
4735                OUTPUT_MESSAGE_COMPLETED,
4736                OutputMessageCompletedData::new(Message::assistant("hi")).into(),
4737            ),
4738            (
4739                TURN_STARTED,
4740                TurnStartedData {
4741                    turn_id: test_turn_id(),
4742                    input_message_id: test_message_id(),
4743                    input_content: None,
4744                }
4745                .into(),
4746            ),
4747            (
4748                TURN_COMPLETED,
4749                TurnCompletedData {
4750                    turn_id: test_turn_id(),
4751                    iterations: 1,
4752                    duration_ms: None,
4753                    usage: None,
4754                    input_content: None,
4755                    final_message_id: None,
4756                    final_answer_preview: None,
4757                    time_to_first_token_ms: None,
4758                    tool_call_count: None,
4759                    llm_call_count: None,
4760                    status: None,
4761                }
4762                .into(),
4763            ),
4764            (
4765                TURN_FAILED,
4766                TurnFailedData {
4767                    turn_id: test_turn_id(),
4768                    error: "err".to_string(),
4769                    error_code: None,
4770                    error_fields: None,
4771                    error_disclosure: None,
4772                }
4773                .into(),
4774            ),
4775            (
4776                TURN_CANCELLED,
4777                TurnCancelledData {
4778                    turn_id: test_turn_id(),
4779                    reason: None,
4780                    usage: None,
4781                }
4782                .into(),
4783            ),
4784            (
4785                TURN_SEALED,
4786                TurnSealedData {
4787                    turn_id: test_turn_id(),
4788                    reason: "no_progress".to_string(),
4789                    detail: Some("sealed".to_string()),
4790                    iterations: Some(3),
4791                    usage: None,
4792                }
4793                .into(),
4794            ),
4795            (
4796                REASON_STARTED,
4797                ReasonStartedData {
4798                    harness_id: test_harness_id(),
4799                    agent_id: Some(test_agent_id()),
4800                    metadata: None,
4801                }
4802                .into(),
4803            ),
4804            (
4805                REASON_COMPLETED,
4806                ReasonCompletedData::success("", false, 0, None, None).into(),
4807            ),
4808            (
4809                ACT_STARTED,
4810                ActStartedData {
4811                    tool_calls: vec![],
4812                    headline: None,
4813                }
4814                .into(),
4815            ),
4816            (
4817                ACT_COMPLETED,
4818                ActCompletedData {
4819                    completed: true,
4820                    success_count: 0,
4821                    error_count: 0,
4822                    duration_ms: None,
4823                    headline: None,
4824                }
4825                .into(),
4826            ),
4827            (
4828                SESSION_STARTED,
4829                SessionStartedData {
4830                    harness_id: test_harness_id(),
4831                    agent_id: Some(test_agent_id()),
4832                    model_id: None,
4833                }
4834                .into(),
4835            ),
4836            (
4837                SESSION_ACTIVATED,
4838                SessionActivatedData {
4839                    turn_id: test_turn_id(),
4840                    input_message_id: test_message_id(),
4841                }
4842                .into(),
4843            ),
4844            (
4845                SESSION_IDLED,
4846                SessionIdledData {
4847                    turn_id: test_turn_id(),
4848                    iterations: None,
4849                    usage: None,
4850                }
4851                .into(),
4852            ),
4853            (
4854                SESSION_TITLE_UPDATED,
4855                SessionTitleUpdatedData {
4856                    previous_title: Some("Old title".to_string()),
4857                    title: "New title".to_string(),
4858                }
4859                .into(),
4860            ),
4861        ];
4862
4863        for (event_type, original) in test_cases {
4864            // Serialize
4865            let json = serde_json::to_value(&original).unwrap();
4866            // Deserialize using type-directed function
4867            let deserialized = deserialize_event_data(event_type, json);
4868            // Verify same event type
4869            assert_eq!(
4870                original.event_type(),
4871                deserialized.event_type(),
4872                "Event type mismatch for {}",
4873                event_type
4874            );
4875        }
4876    }
4877
4878    // ========================================================================
4879    // Event Structure Tests
4880    // ========================================================================
4881    // Tests for the Event container structure
4882
4883    #[test]
4884    fn event_structure_has_required_fields() {
4885        let session_id = test_session_id();
4886        let context = EventContext::turn(test_turn_id(), test_message_id());
4887        let event = Event::new(
4888            session_id,
4889            context,
4890            InputMessageData::new(Message::user("test")),
4891        );
4892
4893        // Verify all required fields are present
4894        let json = serde_json::to_value(&event).unwrap();
4895        assert!(json.get("id").is_some(), "Missing id field");
4896        assert!(json.get("type").is_some(), "Missing type field");
4897        assert!(json.get("ts").is_some(), "Missing ts field");
4898        assert!(json.get("session_id").is_some(), "Missing session_id field");
4899        assert!(json.get("context").is_some(), "Missing context field");
4900        assert!(json.get("data").is_some(), "Missing data field");
4901    }
4902
4903    #[test]
4904    fn event_context_span_fields() {
4905        let context = EventContext::empty().with_span(
4906            "trace123".to_string(),
4907            "span456".to_string(),
4908            Some("parent789".to_string()),
4909        );
4910
4911        let json = serde_json::to_value(&context).unwrap();
4912        assert_eq!(
4913            json.get("trace_id").and_then(|v| v.as_str()),
4914            Some("trace123")
4915        );
4916        assert_eq!(
4917            json.get("span_id").and_then(|v| v.as_str()),
4918            Some("span456")
4919        );
4920        assert_eq!(
4921            json.get("parent_span_id").and_then(|v| v.as_str()),
4922            Some("parent789")
4923        );
4924    }
4925
4926    #[test]
4927    fn is_unsupported_returns_false_for_known_types() {
4928        let data = InputMessageData::new(Message::user("test"));
4929        let event_data: EventData = data.into();
4930        assert!(!event_data.is_unsupported());
4931    }
4932
4933    #[test]
4934    fn is_unsupported_returns_true_for_unsupported() {
4935        let data = deserialize_event_data("unknown.type", serde_json::json!({}));
4936        assert!(data.is_unsupported());
4937    }
4938}