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