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