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