Skip to main content

everruns_core/
events.rs

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