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