Skip to main content

everruns_core/
events.rs

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