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