Skip to main content

vtcode_exec_events/
lib.rs

1#![allow(
2    missing_docs,
3    dead_code,
4    unused_imports,
5    reason = "Intentional compatibility, platform, or test-only suppression."
6)]
7//! Structured execution telemetry events shared across VT Code crates.
8//!
9//! This crate exposes the serialized schema for thread lifecycle updates,
10//! command execution results, and other timeline artifacts emitted by the
11//! automation runtime. Downstream applications can deserialize these
12//! structures to drive dashboards, logging, or auditing pipelines without
13//! depending on the full `vtcode-core` crate.
14//!
15//! # Agent Trace Support
16//!
17//! This crate implements the [Agent Trace](https://agent-trace.dev/) specification
18//! for tracking AI-generated code attribution. See the [`trace`] module for details.
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub mod atif;
24pub mod trace;
25
26/// Semantic version of the serialized event schema exported by this crate.
27pub const EVENT_SCHEMA_VERSION: &str = "0.11.0";
28
29/// Wraps a [`ThreadEvent`] with schema metadata so downstream consumers can
30/// negotiate compatibility before processing an event stream.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
33pub struct VersionedThreadEvent {
34    /// Semantic version describing the schema of the nested event payload.
35    schema_version: String,
36    /// Concrete event emitted by the agent runtime.
37    event: ThreadEvent,
38}
39
40impl VersionedThreadEvent {
41    /// Creates a new [`VersionedThreadEvent`] using the current
42    /// [`EVENT_SCHEMA_VERSION`].
43    pub fn new(event: ThreadEvent) -> Self {
44        Self {
45            schema_version: EVENT_SCHEMA_VERSION.to_string(),
46            event,
47        }
48    }
49
50    /// Returns the nested [`ThreadEvent`], consuming the wrapper.
51    pub fn into_event(self) -> ThreadEvent {
52        self.event
53    }
54}
55
56impl From<ThreadEvent> for VersionedThreadEvent {
57    fn from(event: ThreadEvent) -> Self {
58        Self::new(event)
59    }
60}
61
62/// Sink for processing [`ThreadEvent`] instances.
63pub trait EventEmitter {
64    /// Invoked for each event emitted by the automation runtime.
65    fn emit(&mut self, event: &ThreadEvent);
66}
67
68impl<F> EventEmitter for F
69where
70    F: FnMut(&ThreadEvent),
71{
72    fn emit(&mut self, event: &ThreadEvent) {
73        self(event);
74    }
75}
76
77/// JSON helper utilities for serializing and deserializing thread events.
78#[cfg(feature = "serde-json")]
79pub(crate) mod json {
80    use super::{ThreadEvent, VersionedThreadEvent};
81
82    /// Converts an event into a `serde_json::Value`.
83    pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
84        serde_json::to_value(event)
85    }
86
87    /// Serializes an event into a JSON string.
88    pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
89        serde_json::to_string(event)
90    }
91
92    /// Deserializes an event from a JSON string.
93    pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
94        serde_json::from_str(payload)
95    }
96
97    /// Serializes a [`VersionedThreadEvent`] wrapper.
98    pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
99        serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
100    }
101
102    /// Deserializes a [`VersionedThreadEvent`] wrapper.
103    pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
104        serde_json::from_str(payload)
105    }
106}
107
108#[cfg(feature = "telemetry-log")]
109mod log_support {
110    use log::Level;
111
112    use super::{EventEmitter, ThreadEvent, json};
113
114    /// Emits JSON serialized events to the `log` facade at the configured level.
115    #[derive(Debug, Clone)]
116    pub struct LogEmitter {
117        level: Level,
118    }
119
120    impl LogEmitter {
121        /// Creates a new [`LogEmitter`] that logs at the provided [`Level`].
122        pub fn new(level: Level) -> Self {
123            Self { level }
124        }
125    }
126
127    impl Default for LogEmitter {
128        fn default() -> Self {
129            Self { level: Level::Info }
130        }
131    }
132
133    impl EventEmitter for LogEmitter {
134        fn emit(&mut self, event: &ThreadEvent) {
135            if log::log_enabled!(self.level) {
136                match json::to_string(event) {
137                    Ok(serialized) => log::log!(self.level, "{serialized}"),
138                    Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
139                }
140            }
141        }
142    }
143
144    pub use LogEmitter as PublicLogEmitter;
145}
146
147#[cfg(feature = "telemetry-log")]
148pub use log_support::PublicLogEmitter as LogEmitter;
149
150#[cfg(feature = "telemetry-tracing")]
151mod tracing_support {
152    use tracing::Level;
153
154    use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
155
156    /// Emits structured events as `tracing` events at the specified level.
157    #[derive(Debug, Clone)]
158    pub struct TracingEmitter {
159        level: Level,
160    }
161
162    impl TracingEmitter {
163        /// Creates a new [`TracingEmitter`] with the provided [`Level`].
164        pub fn new(level: Level) -> Self {
165            Self { level }
166        }
167    }
168
169    impl Default for TracingEmitter {
170        fn default() -> Self {
171            Self { level: Level::INFO }
172        }
173    }
174
175    impl EventEmitter for TracingEmitter {
176        fn emit(&mut self, event: &ThreadEvent) {
177            match self.level {
178                Level::TRACE => tracing::event!(
179                    target: "vtcode_exec_events",
180                    Level::TRACE,
181                    schema_version = EVENT_SCHEMA_VERSION,
182                    event = ?VersionedThreadEvent::new(event.clone()),
183                    "vtcode_exec_event"
184                ),
185                Level::DEBUG => tracing::event!(
186                    target: "vtcode_exec_events",
187                    Level::DEBUG,
188                    schema_version = EVENT_SCHEMA_VERSION,
189                    event = ?VersionedThreadEvent::new(event.clone()),
190                    "vtcode_exec_event"
191                ),
192                Level::INFO => tracing::event!(
193                    target: "vtcode_exec_events",
194                    Level::INFO,
195                    schema_version = EVENT_SCHEMA_VERSION,
196                    event = ?VersionedThreadEvent::new(event.clone()),
197                    "vtcode_exec_event"
198                ),
199                Level::WARN => tracing::event!(
200                    target: "vtcode_exec_events",
201                    Level::WARN,
202                    schema_version = EVENT_SCHEMA_VERSION,
203                    event = ?VersionedThreadEvent::new(event.clone()),
204                    "vtcode_exec_event"
205                ),
206                Level::ERROR => tracing::event!(
207                    target: "vtcode_exec_events",
208                    Level::ERROR,
209                    schema_version = EVENT_SCHEMA_VERSION,
210                    event = ?VersionedThreadEvent::new(event.clone()),
211                    "vtcode_exec_event"
212                ),
213            }
214        }
215    }
216
217    pub use TracingEmitter as PublicTracingEmitter;
218}
219
220#[cfg(feature = "telemetry-tracing")]
221pub use tracing_support::PublicTracingEmitter as TracingEmitter;
222
223#[cfg(feature = "telemetry-otel")]
224mod otel_support {
225    use opentelemetry::KeyValue;
226    use opentelemetry::trace::{Span, Status, Tracer};
227
228    use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
229
230    /// Emits [`ThreadEvent`]s as OpenTelemetry spans and span events.
231    ///
232    /// Each `ThreadEvent` is recorded as an OTel span with attributes derived
233    /// from the event payload.  Harness events are attached as span events
234    /// with their own attributes (event kind, message, path, etc.).
235    ///
236    /// # Usage
237    ///
238    /// ```rust,ignore
239    /// // Requires concrete SDK type (e.g. opentelemetry_sdk::trace::SdkTracerProvider)
240    /// # use vtcode_exec_events::OtelEmitter;
241    /// # let tracer = opentelemetry_sdk::trace::SdkTracerProvider::default()
242    /// #     .tracer("vtcode");
243    /// # let mut emitter = OtelEmitter::new(tracer);
244    /// ```
245    pub struct OtelEmitter<T: Tracer> {
246        tracer: T,
247    }
248
249    impl<T: Tracer> OtelEmitter<T> {
250        pub fn new(tracer: T) -> Self {
251            Self { tracer }
252        }
253    }
254
255    impl<T: Tracer> EventEmitter for OtelEmitter<T> {
256        fn emit(&mut self, event: &ThreadEvent) {
257            let span_name = match event {
258                ThreadEvent::ThreadStarted(_) => "thread.started",
259                ThreadEvent::ThreadCompleted(_) => "thread.completed",
260                ThreadEvent::ContextReset(_) => "context.reset",
261                ThreadEvent::TurnStarted(_) => "turn.started",
262                ThreadEvent::TurnCompleted(_) => "turn.completed",
263                ThreadEvent::TurnFailed(_) => "turn.failed",
264                ThreadEvent::ItemStarted(_) => "item.started",
265                ThreadEvent::ItemUpdated(_) => "item.updated",
266                ThreadEvent::ItemCompleted(_) => "item.completed",
267                ThreadEvent::Error(_) => "error",
268                _ => "event",
269            };
270
271            let mut span = self.tracer.start(span_name);
272
273            match event {
274                ThreadEvent::ThreadStarted(e) => {
275                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
276                }
277                ThreadEvent::ThreadCompleted(e) => {
278                    if let Some(ref cost) = e.total_cost_usd {
279                        span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
280                    }
281                    span.set_attribute(KeyValue::new(
282                        "input_tokens",
283                        i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
284                    ));
285                    span.set_attribute(KeyValue::new(
286                        "output_tokens",
287                        i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
288                    ));
289                    span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
290                }
291                ThreadEvent::ContextReset(e) => {
292                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
293                    span.set_attribute(KeyValue::new("turn_id", e.turn_id.clone()));
294                    span.set_attribute(KeyValue::new("plan_preserved", e.plan_preserved));
295                    span.set_attribute(KeyValue::new(
296                        "previous_context_usage_percent",
297                        e.previous_context_usage_percent as i64,
298                    ));
299                    span.set_attribute(KeyValue::new("tool_budget_reset", e.tool_budget_reset));
300                }
301                ThreadEvent::TurnCompleted(e) => {
302                    span.set_attribute(KeyValue::new(
303                        "turn_input_tokens",
304                        i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
305                    ));
306                    span.set_attribute(KeyValue::new(
307                        "turn_output_tokens",
308                        i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
309                    ));
310                }
311                ThreadEvent::ItemCompleted(e) => {
312                    if let ThreadItemDetails::Harness(harness) = &e.item.details {
313                        span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
314                        if let Some(ref msg) = harness.message {
315                            span.set_attribute(KeyValue::new("harness_message", msg.clone()));
316                        }
317                        if let Some(ref path) = harness.path {
318                            span.set_attribute(KeyValue::new("harness_path", path.clone()));
319                        }
320                        if let Some(dur) = harness.duration_ms {
321                            span.set_attribute(KeyValue::new("duration_ms", i64::try_from(dur).unwrap_or(i64::MAX)));
322                        }
323                        let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
324                        if let Some(ref msg) = harness.message {
325                            event_attrs.push(KeyValue::new("message", msg.clone()));
326                        }
327                        span.add_event("harness_event", event_attrs);
328                    }
329                }
330                ThreadEvent::Error(e) => {
331                    span.set_status(Status::Error { description: e.message.clone().into() });
332                    span.set_attribute(KeyValue::new("error_message", e.message.clone()));
333                }
334                _ => {}
335            }
336
337            span.end();
338        }
339    }
340
341    pub use OtelEmitter as PublicOtelEmitter;
342}
343
344#[cfg(feature = "telemetry-otel")]
345pub use otel_support::PublicOtelEmitter as OtelEmitter;
346
347#[cfg(feature = "schema-export")]
348pub mod schema {
349    use schemars::{Schema, schema_for};
350
351    use super::{ThreadEvent, VersionedThreadEvent};
352
353    /// Generates a JSON Schema describing [`ThreadEvent`].
354    pub fn thread_event_schema() -> Schema {
355        schema_for!(ThreadEvent)
356    }
357
358    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
359    pub fn versioned_thread_event_schema() -> Schema {
360        schema_for!(VersionedThreadEvent)
361    }
362}
363
364/// Structured events emitted during autonomous execution.
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
366#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
367#[serde(tag = "type")]
368pub enum ThreadEvent {
369    /// Indicates that a new execution thread has started.
370    #[serde(rename = "thread.started")]
371    ThreadStarted(ThreadStartedEvent),
372    /// Indicates that an execution thread has reached a terminal outcome.
373    #[serde(rename = "thread.completed")]
374    ThreadCompleted(ThreadCompletedEvent),
375    /// Indicates that conversation compaction replaced older history with a boundary.
376    #[serde(rename = "thread.compact_boundary")]
377    ThreadCompactBoundary(ThreadCompactBoundaryEvent),
378    /// Indicates that the approved plan handoff rebuilt a fresh execution context.
379    #[serde(rename = "context.reset")]
380    ContextReset(ContextResetEvent),
381    /// Marks the beginning of an execution turn.
382    #[serde(rename = "turn.started")]
383    TurnStarted(TurnStartedEvent),
384    /// Marks the completion of an execution turn.
385    #[serde(rename = "turn.completed")]
386    TurnCompleted(TurnCompletedEvent),
387    /// Marks a turn as failed with additional context.
388    #[serde(rename = "turn.failed")]
389    TurnFailed(TurnFailedEvent),
390    /// Indicates that an item has started processing.
391    #[serde(rename = "item.started")]
392    ItemStarted(ItemStartedEvent),
393    /// Indicates that an item has been updated.
394    #[serde(rename = "item.updated")]
395    ItemUpdated(ItemUpdatedEvent),
396    /// Indicates that an item reached a terminal state.
397    #[serde(rename = "item.completed")]
398    ItemCompleted(ItemCompletedEvent),
399    /// Emitted when a tool requires user permission before execution.
400    #[serde(rename = "permission.requested")]
401    PermissionRequested(PermissionRequestedEvent),
402    /// Emitted when the user resolves a permission prompt.
403    #[serde(rename = "permission.resolved")]
404    PermissionResolved(PermissionResolvedEvent),
405    /// A mid-turn user interjection was merged into the running turn.
406    #[serde(rename = "interjected")]
407    Interjected(InterjectedEvent),
408    /// Streaming delta for a plan item in Planning workflow.
409    #[serde(rename = "plan.delta")]
410    PlanDelta(PlanDeltaEvent),
411    /// Indicates that a completed plan is waiting for an implementation decision.
412    #[serde(rename = "plan.approval.requested")]
413    PlanApprovalRequested(PlanApprovalRequestedEvent),
414    /// Records the user's or policy's decision about a completed plan.
415    #[serde(rename = "plan.approval.resolved")]
416    PlanApprovalResolved(PlanApprovalResolvedEvent),
417    /// Represents a fatal error.
418    #[serde(rename = "error")]
419    Error(ThreadErrorEvent),
420    /// Catch-all for unknown event types added in newer schema versions.
421    /// Preserves forward compatibility when older binaries read newer event streams.
422    #[serde(other)]
423    Unknown,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
427#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
428pub struct ThreadStartedEvent {
429    /// Unique identifier for the thread that was started.
430    pub thread_id: String,
431}
432
433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
434#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
435#[serde(rename_all = "snake_case")]
436pub enum ThreadCompletionSubtype {
437    Success,
438    ErrorMaxTurns,
439    ErrorMaxBudgetUsd,
440    ErrorDuringExecution,
441    Cancelled,
442    /// Catch-all for unknown completion subtypes added in newer schema versions.
443    #[serde(other)]
444    Unknown,
445}
446
447impl ThreadCompletionSubtype {
448    pub const fn as_str(&self) -> &'static str {
449        match self {
450            Self::Success => "success",
451            Self::ErrorMaxTurns => "error_max_turns",
452            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
453            Self::ErrorDuringExecution => "error_during_execution",
454            Self::Cancelled => "cancelled",
455            Self::Unknown => "unknown",
456        }
457    }
458
459    pub const fn is_success(self) -> bool {
460        matches!(self, Self::Success)
461    }
462}
463
464#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
465#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
466#[serde(rename_all = "snake_case")]
467pub enum CompactionTrigger {
468    Manual,
469    Auto,
470    Recovery,
471    /// Compaction triggered by a mid-session switch of the main model or
472    /// provider, so the newly selected model starts from a clean summary.
473    ModelSwitch,
474    /// Catch-all for unknown triggers added in newer schema versions.
475    #[serde(other)]
476    Unknown,
477}
478
479impl CompactionTrigger {
480    pub const fn as_str(self) -> &'static str {
481        match self {
482            Self::Manual => "manual",
483            Self::Auto => "auto",
484            Self::Recovery => "recovery",
485            Self::ModelSwitch => "model_switch",
486            Self::Unknown => "unknown",
487        }
488    }
489}
490
491#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
493#[serde(rename_all = "snake_case")]
494pub enum CompactionMode {
495    Provider,
496    Local,
497    /// Catch-all for unknown modes added in newer schema versions.
498    #[serde(other)]
499    Unknown,
500}
501
502impl CompactionMode {
503    pub const fn as_str(self) -> &'static str {
504        match self {
505            Self::Provider => "provider",
506            Self::Local => "local",
507            Self::Unknown => "unknown",
508        }
509    }
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
514pub struct ThreadCompletedEvent {
515    /// Stable thread identifier for the session.
516    pub thread_id: String,
517    /// Stable session identifier for the runtime that produced the thread.
518    pub session_id: String,
519    /// Coarse result category aligned with SDK-style terminal states.
520    pub subtype: ThreadCompletionSubtype,
521    /// VT Code-specific detailed outcome code.
522    pub outcome_code: String,
523    /// Final assistant result text when the thread completed successfully.
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub result: Option<String>,
526    /// Provider stop reason or VT Code terminal reason when available.
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub stop_reason: Option<String>,
529    /// Aggregated token usage across the thread.
530    pub usage: Usage,
531    /// Optional estimated total API cost for the thread.
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub total_cost_usd: Option<serde_json::Number>,
534    /// Number of turns executed before completion.
535    pub num_turns: usize,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
540pub struct ThreadCompactBoundaryEvent {
541    /// Stable thread identifier for the session.
542    pub thread_id: String,
543    /// Whether compaction was triggered manually or automatically.
544    pub trigger: CompactionTrigger,
545    /// Whether the compaction boundary came from provider-native or local compaction.
546    pub mode: CompactionMode,
547    /// Number of messages before compaction.
548    pub original_message_count: usize,
549    /// Number of messages after compaction.
550    pub compacted_message_count: usize,
551    /// Optional persisted artifact containing the archived compaction summary/history.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub history_artifact_path: Option<String>,
554    /// Segment identifier that contained the request prefix before compaction.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub previous_segment_id: Option<String>,
557    /// Segment identifier created after compaction.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub new_segment_id: Option<String>,
560    /// Hash of the immutable request prefix before compaction.
561    #[serde(skip_serializing_if = "Option::is_none")]
562    pub previous_prefix_hash: Option<String>,
563    /// Hash of the immutable request prefix after compaction.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub new_prefix_hash: Option<String>,
566    /// Hash of the ordered tool catalog before compaction.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub previous_catalog_hash: Option<String>,
569    /// Hash of the ordered tool catalog after compaction.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub new_catalog_hash: Option<String>,
572}
573
574#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
575#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
576#[serde(rename_all = "snake_case")]
577pub enum ContextResetTrigger {
578    /// The user selected the fresh-context plan approval path.
579    PlanApproval,
580    /// Catch-all for triggers introduced by newer schema versions.
581    #[serde(other)]
582    Unknown,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
587pub struct ContextResetEvent {
588    /// Stable thread identifier for the session.
589    pub thread_id: String,
590    /// Identifier of the turn that approved the plan.
591    pub turn_id: String,
592    /// What initiated the context reset.
593    pub trigger: ContextResetTrigger,
594    /// Whether the approved plan and task tracker survived the reset.
595    pub plan_preserved: bool,
596    /// Context pressure reported before the reset, expressed as a percentage.
597    pub previous_context_usage_percent: u8,
598    /// Whether the per-turn and per-session tool budgets were reset.
599    pub tool_budget_reset: bool,
600}
601
602#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
604pub struct TurnStartedEvent {
605    /// Optional decomposition of the assembled first-request prefix so
606    /// downstream consumers can attribute token overhead without inventing
607    /// parallel event types.
608    #[serde(skip_serializing_if = "Option::is_none")]
609    token_breakdown: Option<TokenBreakdown>,
610}
611
612/// Per-request token-budget breakdown for the assembled first-request prefix.
613#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
615pub struct TokenBreakdown {
616    /// System prompt text tokens.
617    system_prompt_tokens: u64,
618    /// On-wire tool schema tokens.
619    tool_schema_tokens: u64,
620    /// Instruction file tokens included in the prompt.
621    instruction_file_tokens: u64,
622    /// Message history text tokens.
623    message_history_tokens: u64,
624    /// Cache read tokens (served from prior turns).
625    cache_read_tokens: u64,
626    /// Cache write tokens (new cache entries created this turn).
627    cache_write_tokens: u64,
628    /// Tokens that missed cache (neither read nor written).
629    cache_miss_tokens: u64,
630    /// Subagent bootstrap tokens, if this turn spawned a child agent.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    subagent_bootstrap_tokens: Option<u64>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
637pub struct TurnCompletedEvent {
638    /// Token usage summary for the completed turn.
639    pub usage: Usage,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
644pub struct TurnFailedEvent {
645    /// Human-readable explanation describing why the turn failed.
646    pub message: String,
647    /// Optional token usage that was consumed before the failure occurred.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub usage: Option<Usage>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
654pub struct ThreadErrorEvent {
655    /// Fatal error message associated with the thread.
656    pub message: String,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
660#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
661pub struct Usage {
662    /// Number of prompt tokens processed during the turn.
663    pub input_tokens: u64,
664    /// Number of cached prompt tokens reused from previous turns.
665    pub cached_input_tokens: u64,
666    /// Number of cache-creation tokens charged during the turn.
667    pub cache_creation_tokens: u64,
668    /// Number of completion tokens generated by the model.
669    pub output_tokens: u64,
670}
671
672impl Usage {
673    /// Number of input tokens billed at the full input rate: neither served
674    /// from cache nor written to it. `input_tokens` is the total prompt token
675    /// count (uncached + cached + cache-creation), so both cached and
676    /// cache-creation tokens are subtracted out here.
677    #[must_use]
678    fn uncached_input_tokens(&self) -> u64 {
679        self.input_tokens
680            .saturating_sub(self.cached_input_tokens)
681            .saturating_sub(self.cache_creation_tokens)
682    }
683
684    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
685    /// Returns `None` when no input tokens were recorded.
686    #[must_use]
687    pub fn cache_hit_rate(&self) -> Option<f64> {
688        if self.input_tokens == 0 {
689            return None;
690        }
691        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
692    }
693
694    /// Human-readable summary of prompt cache efficiency.
695    #[must_use]
696    pub fn cache_summary(&self) -> String {
697        let total_input = self.input_tokens;
698        if total_input == 0 {
699            return "No input tokens recorded.".to_string();
700        }
701
702        let cached = self.cached_input_tokens;
703        let creation = self.cache_creation_tokens;
704        let uncached = self.uncached_input_tokens();
705        let rate = cached as f64 / total_input as f64 * 100.0;
706        format!(
707            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
708             {creation} cache-creation, {uncached} uncached"
709        )
710    }
711
712    /// Accumulate another usage sample into this one.
713    pub fn add(&mut self, other: &Usage) {
714        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
715        self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
716        self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
717        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
718    }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
723pub struct ItemCompletedEvent {
724    /// Snapshot of the thread item that completed.
725    pub item: ThreadItem,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
730pub struct ItemStartedEvent {
731    /// Snapshot of the thread item that began processing.
732    pub item: ThreadItem,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
737pub struct ItemUpdatedEvent {
738    /// Snapshot of the thread item after it was updated.
739    pub item: ThreadItem,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
744pub struct PlanDeltaEvent {
745    /// Identifier of the thread emitting this plan delta.
746    pub thread_id: String,
747    /// Identifier of the current turn.
748    pub turn_id: String,
749    /// Identifier of the plan item receiving the delta.
750    pub item_id: String,
751    /// Incremental plan text chunk.
752    pub delta: String,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
756#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
757pub struct PlanApprovalRequestedEvent {
758    /// Identifier of the thread emitting the approval request.
759    pub thread_id: String,
760    /// Identifier of the turn that produced the plan.
761    pub turn_id: String,
762    /// Plan file associated with the approval request, when available.
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub plan_file: Option<String>,
765}
766
767#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
768#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
769#[serde(rename_all = "snake_case")]
770pub enum PlanApprovalDecision {
771    /// Execute with normal per-edit approval prompts.
772    Execute,
773    /// Execute with automatic edit approval enabled.
774    AutoAccept,
775    /// Execute the plan after rebuilding a fresh context.
776    FreshContext,
777    /// Keep planning and revise the proposed plan.
778    Revise,
779    /// Dismiss the approval request without implementing.
780    Cancel,
781    /// Hand the plan to the build primary agent.
782    SwitchBuild,
783    /// Hand the plan to the auto primary agent.
784    SwitchAuto,
785    /// Catch-all for decisions added in newer schema versions.
786    #[serde(other)]
787    Unknown,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
791#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
792pub struct PlanApprovalResolvedEvent {
793    /// Identifier of the thread emitting the approval decision.
794    pub thread_id: String,
795    /// Identifier of the turn in which the decision was made.
796    pub turn_id: String,
797    /// Decision selected by the user or active execution policy.
798    pub decision: PlanApprovalDecision,
799    /// Whether the decision came from policy rather than an interactive user action.
800    pub automatic: bool,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
804#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
805pub struct ThreadItem {
806    /// Stable identifier associated with the item.
807    pub id: String,
808    /// Embedded event details for the item type.
809    #[serde(flatten)]
810    pub details: ThreadItemDetails,
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
814#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
815#[serde(tag = "type", rename_all = "snake_case")]
816pub enum ThreadItemDetails {
817    /// Message authored by the agent.
818    AgentMessage(AgentMessageItem),
819    /// Structured plan content authored by the agent in Planning workflow.
820    Plan(PlanItem),
821    /// Free-form reasoning text produced during a turn.
822    Reasoning(ReasoningItem),
823    /// Command execution lifecycle update for an actual shell/PTY process.
824    CommandExecution(Box<CommandExecutionItem>),
825    /// Tool invocation lifecycle update.
826    ToolInvocation(ToolInvocationItem),
827    /// Tool output lifecycle update tied to a tool invocation.
828    ToolOutput(ToolOutputItem),
829    /// File change summary associated with the turn.
830    FileChange(Box<FileChangeItem>),
831    /// MCP tool invocation status.
832    McpToolCall(McpToolCallItem),
833    /// Web search event emitted by a registered search provider.
834    WebSearch(WebSearchItem),
835    /// Harness-managed continuation or verification lifecycle event.
836    Harness(HarnessEventItem),
837    /// General error captured for auditing.
838    Error(ErrorItem),
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
842#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
843pub struct AgentMessageItem {
844    /// Textual content of the agent message.
845    pub text: String,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
850pub struct PlanItem {
851    /// Plan markdown content.
852    pub text: String,
853}
854
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
856#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
857pub struct ReasoningItem {
858    /// Free-form reasoning content captured during planning.
859    pub text: String,
860    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification",
861    /// or the bounded evidence-only "diagnosis" stage).
862    #[serde(skip_serializing_if = "Option::is_none")]
863    pub stage: Option<String>,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
867#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
868#[serde(rename_all = "snake_case")]
869pub enum CommandExecutionStatus {
870    /// Command finished successfully.
871    #[default]
872    Completed,
873    /// Command failed (non-zero exit code or runtime error).
874    Failed,
875    /// Command is still running and may emit additional output.
876    InProgress,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881pub struct CommandExecutionItem {
882    /// Tool or command identifier executed by the runner.
883    pub command: String,
884    /// Arguments passed to the tool invocation, when available.
885    #[serde(skip_serializing_if = "Option::is_none")]
886    pub arguments: Option<Value>,
887    /// Aggregated output emitted by the command.
888    #[serde(default)]
889    pub aggregated_output: String,
890    /// Exit code reported by the process, when available.
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub exit_code: Option<i32>,
893    /// Current status of the command execution.
894    pub status: CommandExecutionStatus,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
898#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
899#[serde(rename_all = "snake_case")]
900pub enum ToolCallStatus {
901    /// Tool finished successfully.
902    #[default]
903    Completed,
904    /// Tool failed.
905    Failed,
906    /// Tool is still running and may emit additional output.
907    InProgress,
908}
909
910/// Fine-grained outcome of a tool invocation lifecycle.
911///
912/// Mirrors the outcome taxonomy used by the runtime: `status` remains the
913/// coarse lifecycle signal (`Completed` / `Failed` / `InProgress`), while
914/// `outcome` captures *why* the invocation terminated. Consumers that only
915/// need success/failure can continue to read `status`; analytics and the UI
916/// layer use `outcome` for richer classification.
917#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
919#[serde(rename_all = "snake_case")]
920pub enum ToolOutcome {
921    /// Tool executed and returned a result.
922    #[default]
923    Success,
924    /// Tool executed but returned an error.
925    Error,
926    /// User rejected the permission prompt.
927    PermissionRejected,
928    /// User cancelled the permission prompt (e.g. Ctrl+C / Esc).
929    PermissionCancelled,
930    /// User provided a followup message instead of approving.
931    Followup,
932    /// A user-configured hook blocked execution.
933    HookDenied,
934    /// Tool not found or arguments couldn't be parsed.
935    InvalidTool,
936    /// Tool was running when the turn was cancelled.
937    Cancelled,
938}
939
940impl ToolOutcome {
941    #[must_use]
942    pub const fn is_terminal(self) -> bool {
943        !matches!(self, Self::Followup)
944    }
945}
946
947/// Map a terminal [`ToolCallStatus`] to its corresponding [`ToolOutcome`].
948///
949/// # Panics
950///
951/// Panics if `status` is [`ToolCallStatus::InProgress`], which is a non-terminal
952/// state and must never be passed to a completion-event emitter.
953#[must_use]
954#[allow(
955    clippy::unreachable,
956    reason = "Intentional compatibility, platform, or test-only suppression."
957)]
958pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
959    match status {
960        ToolCallStatus::Completed => ToolOutcome::Success,
961        ToolCallStatus::Failed => ToolOutcome::Error,
962        ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
963    }
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
967#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
968pub struct ToolInvocationItem {
969    /// Name of the invoked tool.
970    pub tool_name: String,
971    /// Structured arguments passed to the tool.
972    #[serde(skip_serializing_if = "Option::is_none")]
973    pub arguments: Option<Value>,
974    /// Raw model-emitted tool call identifier, when available.
975    #[serde(skip_serializing_if = "Option::is_none")]
976    pub tool_call_id: Option<String>,
977    /// Current lifecycle status of the invocation.
978    pub status: ToolCallStatus,
979    /// Fine-grained outcome of the invocation lifecycle.
980    #[serde(skip_serializing_if = "Option::is_none")]
981    pub outcome: Option<ToolOutcome>,
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
985#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
986pub struct ToolOutputItem {
987    /// Identifier of the related harness invocation item.
988    pub call_id: String,
989    /// Raw model-emitted tool call identifier, when available.
990    #[serde(skip_serializing_if = "Option::is_none")]
991    pub tool_call_id: Option<String>,
992    /// Canonical spool file path when the full output was written to disk.
993    #[serde(skip_serializing_if = "Option::is_none")]
994    pub spool_path: Option<String>,
995    /// Aggregated output emitted by the tool.
996    #[serde(default)]
997    pub output: String,
998    /// Exit code reported by the tool, when available.
999    #[serde(skip_serializing_if = "Option::is_none")]
1000    pub exit_code: Option<i32>,
1001    /// Current lifecycle status of the output item.
1002    pub status: ToolCallStatus,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1006#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1007pub struct FileChangeItem {
1008    /// List of individual file updates included in the change set.
1009    pub changes: Vec<FileUpdateChange>,
1010    /// Whether the patch application succeeded.
1011    pub status: PatchApplyStatus,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1016pub struct FileUpdateChange {
1017    /// Path of the file that was updated.
1018    pub path: String,
1019    /// Type of change applied to the file.
1020    pub kind: PatchChangeKind,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1025#[serde(rename_all = "snake_case")]
1026pub enum PatchApplyStatus {
1027    /// Patch successfully applied.
1028    Completed,
1029    /// Patch application failed.
1030    Failed,
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1034#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1035#[serde(rename_all = "snake_case")]
1036pub enum PatchChangeKind {
1037    /// File addition.
1038    Add,
1039    /// File deletion.
1040    Delete,
1041    /// File update in place.
1042    Update,
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1046#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1047pub struct McpToolCallItem {
1048    /// Name of the MCP tool invoked by the agent.
1049    pub tool_name: String,
1050    /// Arguments passed to the tool invocation, if any.
1051    #[serde(skip_serializing_if = "Option::is_none")]
1052    pub arguments: Option<Value>,
1053    /// Result payload returned by the tool, if captured.
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub result: Option<String>,
1056    /// Lifecycle status for the tool call.
1057    #[serde(skip_serializing_if = "Option::is_none")]
1058    pub status: Option<McpToolCallStatus>,
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1063#[serde(rename_all = "snake_case")]
1064pub enum McpToolCallStatus {
1065    /// Tool invocation has started.
1066    Started,
1067    /// Tool invocation completed successfully.
1068    Completed,
1069    /// Tool invocation failed.
1070    Failed,
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1074#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1075pub struct WebSearchItem {
1076    /// Query that triggered the search.
1077    pub query: String,
1078    /// Search provider identifier, when known.
1079    #[serde(skip_serializing_if = "Option::is_none")]
1080    pub provider: Option<String>,
1081    /// Optional raw search results captured for auditing.
1082    #[serde(skip_serializing_if = "Option::is_none")]
1083    pub results: Option<Vec<String>>,
1084}
1085
1086#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1087#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1088#[serde(rename_all = "snake_case")]
1089pub enum HarnessEventKind {
1090    PlanningStarted,
1091    PlanningCompleted,
1092    ContinuationStarted,
1093    ContinuationSkipped,
1094    BlockedHandoffWritten,
1095    EvaluationStarted,
1096    EvaluationPassed,
1097    EvaluationFailed,
1098    RevisionStarted,
1099    EscalationTriggered,
1100    EscalationBypassed,
1101    VerificationStarted,
1102    VerificationPassed,
1103    VerificationFailed,
1104    /// Agent recovered from a transient error (e.g. after retry succeeded).
1105    ErrorRecovered,
1106    /// A transient tool failure triggered an automatic retry attempt.
1107    ToolRetryAttempted,
1108    /// Latency record for a tool execution, emitted on turn completion.
1109    ToolLatencyRecorded,
1110    /// A checkpoint snapshot was created for the current turn.
1111    SnapshotCreated,
1112    /// A checkpoint snapshot was restored (rewind operation).
1113    SnapshotRestored,
1114}
1115
1116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1117#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1118#[serde(rename_all = "snake_case")]
1119pub enum PermissionDecision {
1120    Allow,
1121    Deny,
1122    Cancelled,
1123    Followup,
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1127#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1128pub struct PermissionRequestedEvent {
1129    /// Name of the tool that requires permission.
1130    pub tool_name: String,
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1135pub struct PermissionResolvedEvent {
1136    /// Name of the tool that was permitted or denied.
1137    pub tool_name: String,
1138    /// User's decision on the permission prompt.
1139    pub decision: PermissionDecision,
1140    /// Wall-clock time the prompt was visible, in milliseconds.
1141    pub wait_ms: u64,
1142}
1143
1144#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1145#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1146#[serde(rename_all = "snake_case")]
1147pub enum InterjectionSource {
1148    Direct,
1149    Queue,
1150}
1151
1152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1153#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1154#[serde(rename_all = "snake_case")]
1155pub enum RedirectKind {
1156    Interjection,
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1160#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1161pub struct InterjectedEvent {
1162    /// How the interjection reached the running turn.
1163    pub source: InterjectionSource,
1164    /// Number of image attachments that accompanied the interjection.
1165    pub image_count: u32,
1166    /// Always `Interjection` for this event; carried so the shared
1167    /// `redirect_kind` field is queryable uniformly across redirect events.
1168    pub redirect_kind: RedirectKind,
1169}
1170
1171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1172#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1173pub struct HarnessEventItem {
1174    /// Specific harness event emitted by the runtime.
1175    pub event: HarnessEventKind,
1176    /// Optional human-readable message associated with the event.
1177    #[serde(skip_serializing_if = "Option::is_none")]
1178    pub message: Option<String>,
1179    /// Optional verification command associated with the event.
1180    #[serde(skip_serializing_if = "Option::is_none")]
1181    pub command: Option<String>,
1182    /// Optional artifact path associated with the event.
1183    #[serde(skip_serializing_if = "Option::is_none")]
1184    pub path: Option<String>,
1185    /// Optional exit code associated with verification results.
1186    #[serde(skip_serializing_if = "Option::is_none")]
1187    pub exit_code: Option<i32>,
1188    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
1189    #[serde(skip_serializing_if = "Option::is_none")]
1190    pub attempt: Option<u32>,
1191    /// Canonical error category for retry/recovery events.
1192    #[serde(skip_serializing_if = "Option::is_none")]
1193    pub error_category: Option<String>,
1194    /// Latency in milliseconds for tool-execution latency events.
1195    #[serde(skip_serializing_if = "Option::is_none")]
1196    pub duration_ms: Option<u64>,
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1201pub struct ErrorItem {
1202    /// Error message displayed to the user or logs.
1203    pub message: String,
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209    use std::error::Error;
1210
1211    #[test]
1212    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1213        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1214            usage: Usage {
1215                input_tokens: 1,
1216                cached_input_tokens: 2,
1217                cache_creation_tokens: 0,
1218                output_tokens: 3,
1219            },
1220        });
1221
1222        let json = serde_json::to_string(&event)?;
1223        let restored: ThreadEvent = serde_json::from_str(&json)?;
1224
1225        assert_eq!(restored, event);
1226        Ok(())
1227    }
1228
1229    #[test]
1230    fn usage_uncached_input_tokens_saturates() {
1231        let usage = Usage {
1232            input_tokens: 1_000,
1233            cached_input_tokens: 800,
1234            cache_creation_tokens: 100,
1235            output_tokens: 50,
1236        };
1237        assert_eq!(usage.uncached_input_tokens(), 100);
1238
1239        let inconsistent = Usage {
1240            input_tokens: 100,
1241            cached_input_tokens: 150,
1242            cache_creation_tokens: 0,
1243            output_tokens: 0,
1244        };
1245        assert_eq!(inconsistent.uncached_input_tokens(), 0);
1246
1247        let inconsistent_with_creation = Usage {
1248            input_tokens: 100,
1249            cached_input_tokens: 80,
1250            cache_creation_tokens: 50,
1251            output_tokens: 0,
1252        };
1253        assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1254    }
1255
1256    #[test]
1257    fn usage_cache_hit_rate() {
1258        assert_eq!(Usage::default().cache_hit_rate(), None);
1259
1260        let usage = Usage {
1261            input_tokens: 1_000,
1262            cached_input_tokens: 750,
1263            cache_creation_tokens: 0,
1264            output_tokens: 0,
1265        };
1266        let rate = usage.cache_hit_rate().expect("rate");
1267        assert!((rate - 0.75).abs() < f64::EPSILON);
1268    }
1269
1270    #[test]
1271    fn usage_cache_summary_formats() {
1272        assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1273
1274        let usage = Usage {
1275            input_tokens: 1_000,
1276            cached_input_tokens: 800,
1277            cache_creation_tokens: 100,
1278            output_tokens: 50,
1279        };
1280        assert_eq!(
1281            usage.cache_summary(),
1282            "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1283        );
1284    }
1285
1286    #[test]
1287    fn usage_add_accumulates_all_fields_with_saturation() {
1288        let mut total = Usage {
1289            input_tokens: 100,
1290            cached_input_tokens: 20,
1291            cache_creation_tokens: 5,
1292            output_tokens: 10,
1293        };
1294        total.add(&Usage {
1295            input_tokens: 50,
1296            cached_input_tokens: 10,
1297            cache_creation_tokens: 2,
1298            output_tokens: 8,
1299        });
1300
1301        assert_eq!(total.input_tokens, 150);
1302        assert_eq!(total.cached_input_tokens, 30);
1303        assert_eq!(total.cache_creation_tokens, 7);
1304        assert_eq!(total.output_tokens, 18);
1305
1306        let mut saturating = Usage {
1307            input_tokens: u64::MAX,
1308            cached_input_tokens: u64::MAX,
1309            cache_creation_tokens: u64::MAX,
1310            output_tokens: u64::MAX,
1311        };
1312        saturating.add(&Usage {
1313            input_tokens: 1,
1314            cached_input_tokens: 1,
1315            cache_creation_tokens: 1,
1316            output_tokens: 1,
1317        });
1318        assert_eq!(saturating.input_tokens, u64::MAX);
1319        assert_eq!(saturating.cached_input_tokens, u64::MAX);
1320        assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1321        assert_eq!(saturating.output_tokens, u64::MAX);
1322    }
1323
1324    #[test]
1325    fn versioned_event_wraps_schema_version() {
1326        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1327
1328        let versioned = VersionedThreadEvent::new(event.clone());
1329
1330        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1331        assert_eq!(versioned.event, event);
1332        assert_eq!(versioned.into_event(), event);
1333    }
1334
1335    #[test]
1336    fn plan_approval_events_round_trip_with_decision() {
1337        let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1338            thread_id: "thread-1".to_string(),
1339            turn_id: "turn-2".to_string(),
1340            plan_file: Some(".vtcode/plans/change.md".to_string()),
1341        });
1342        let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1343            thread_id: "thread-1".to_string(),
1344            turn_id: "turn-3".to_string(),
1345            decision: PlanApprovalDecision::AutoAccept,
1346            automatic: false,
1347        });
1348
1349        for event in [requested, resolved] {
1350            let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1351            let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1352            assert_eq!(restored, event);
1353        }
1354    }
1355
1356    #[test]
1357    fn context_reset_event_round_trips_with_handoff_metadata() {
1358        let event = ThreadEvent::ContextReset(ContextResetEvent {
1359            thread_id: "thread-1".to_string(),
1360            turn_id: "turn-3".to_string(),
1361            trigger: ContextResetTrigger::PlanApproval,
1362            plan_preserved: true,
1363            previous_context_usage_percent: 7,
1364            tool_budget_reset: true,
1365        });
1366
1367        let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1368        let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1369        assert_eq!(restored, event);
1370        assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1371    }
1372
1373    #[test]
1374    fn plan_approval_decision_uses_stable_wire_names() {
1375        let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1376            thread_id: "thread-1".to_string(),
1377            turn_id: "turn-1".to_string(),
1378            decision: PlanApprovalDecision::SwitchBuild,
1379            automatic: false,
1380        });
1381
1382        let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1383        assert_eq!(serialized["type"], "plan.approval.resolved");
1384        assert_eq!(serialized["decision"], "switch_build");
1385    }
1386
1387    #[test]
1388    fn plan_approval_decision_is_forward_compatible() {
1389        let payload = serde_json::json!({
1390            "type": "plan.approval.resolved",
1391            "thread_id": "thread-1",
1392            "turn_id": "turn-1",
1393            "decision": "future_decision",
1394            "automatic": true,
1395        });
1396        let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1397        assert!(matches!(
1398            event,
1399            ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1400                decision: PlanApprovalDecision::Unknown,
1401                automatic: true,
1402                ..
1403            })
1404        ));
1405    }
1406
1407    #[cfg(feature = "serde-json")]
1408    #[test]
1409    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1410        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1411            item: ThreadItem {
1412                id: "item-1".to_string(),
1413                details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1414            },
1415        });
1416
1417        let payload = json::versioned_to_string(&event)?;
1418        let restored = json::versioned_from_str(&payload)?;
1419
1420        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1421        assert_eq!(restored.event, event);
1422        Ok(())
1423    }
1424
1425    #[test]
1426    fn compaction_trigger_serializes_snake_case_and_round_trips() {
1427        for trigger in [
1428            CompactionTrigger::Manual,
1429            CompactionTrigger::Auto,
1430            CompactionTrigger::Recovery,
1431            CompactionTrigger::ModelSwitch,
1432            CompactionTrigger::Unknown,
1433        ] {
1434            let json = serde_json::to_string(&trigger).unwrap();
1435            assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1436            let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1437            assert_eq!(restored, trigger);
1438        }
1439    }
1440
1441    #[test]
1442    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1443        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1444            item: ThreadItem {
1445                id: "tool_1".to_string(),
1446                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1447                    tool_name: "read_file".to_string(),
1448                    arguments: Some(serde_json::json!({ "path": "README.md" })),
1449                    tool_call_id: Some("tool_call_0".to_string()),
1450                    status: ToolCallStatus::Completed,
1451                    outcome: None,
1452                }),
1453            },
1454        });
1455
1456        let json = serde_json::to_string(&event)?;
1457        let restored: ThreadEvent = serde_json::from_str(&json)?;
1458
1459        assert_eq!(restored, event);
1460        Ok(())
1461    }
1462
1463    #[test]
1464    fn tool_outcome_serializes_snake_case() {
1465        for outcome in [
1466            ToolOutcome::Success,
1467            ToolOutcome::Error,
1468            ToolOutcome::PermissionRejected,
1469            ToolOutcome::PermissionCancelled,
1470            ToolOutcome::Followup,
1471            ToolOutcome::HookDenied,
1472            ToolOutcome::InvalidTool,
1473            ToolOutcome::Cancelled,
1474        ] {
1475            let json = serde_json::to_string(&outcome).unwrap();
1476            let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1477            assert_eq!(restored, outcome);
1478        }
1479    }
1480
1481    #[test]
1482    fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1483        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1484            item: ThreadItem {
1485                id: "tool_1".to_string(),
1486                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1487                    tool_name: "exec_command".to_string(),
1488                    arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1489                    tool_call_id: Some("tool_call_0".to_string()),
1490                    status: ToolCallStatus::Failed,
1491                    outcome: Some(ToolOutcome::PermissionRejected),
1492                }),
1493            },
1494        });
1495
1496        let json = serde_json::to_string(&event)?;
1497        let restored: ThreadEvent = serde_json::from_str(&json)?;
1498
1499        assert_eq!(restored, event);
1500        Ok(())
1501    }
1502
1503    #[test]
1504    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1505        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1506            item: ThreadItem {
1507                id: "tool_1:output".to_string(),
1508                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1509                    call_id: "tool_1".to_string(),
1510                    tool_call_id: Some("tool_call_0".to_string()),
1511                    spool_path: None,
1512                    output: "done".to_string(),
1513                    exit_code: Some(0),
1514                    status: ToolCallStatus::Completed,
1515                }),
1516            },
1517        });
1518
1519        let json = serde_json::to_string(&event)?;
1520        let restored: ThreadEvent = serde_json::from_str(&json)?;
1521
1522        assert_eq!(restored, event);
1523        Ok(())
1524    }
1525
1526    #[test]
1527    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1528        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1529            item: ThreadItem {
1530                id: "harness_1".to_string(),
1531                details: ThreadItemDetails::Harness(HarnessEventItem {
1532                    event: HarnessEventKind::VerificationFailed,
1533                    message: Some("cargo check failed".to_string()),
1534                    command: Some("cargo check".to_string()),
1535                    path: None,
1536                    exit_code: Some(101),
1537                    attempt: None,
1538                    error_category: None,
1539                    duration_ms: None,
1540                }),
1541            },
1542        });
1543
1544        let json = serde_json::to_string(&event)?;
1545        let restored: ThreadEvent = serde_json::from_str(&json)?;
1546
1547        assert_eq!(restored, event);
1548        Ok(())
1549    }
1550
1551    #[test]
1552    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1553        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1554            thread_id: "thread-1".to_string(),
1555            session_id: "session-1".to_string(),
1556            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1557            outcome_code: "budget_limit_reached".to_string(),
1558            result: None,
1559            stop_reason: Some("max_tokens".to_string()),
1560            usage: Usage {
1561                input_tokens: 10,
1562                cached_input_tokens: 4,
1563                cache_creation_tokens: 2,
1564                output_tokens: 5,
1565            },
1566            total_cost_usd: serde_json::Number::from_f64(1.25),
1567            num_turns: 3,
1568        });
1569
1570        let json = serde_json::to_string(&event)?;
1571        let restored: ThreadEvent = serde_json::from_str(&json)?;
1572
1573        assert_eq!(restored, event);
1574        Ok(())
1575    }
1576
1577    #[test]
1578    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1579        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1580            thread_id: "thread-1".to_string(),
1581            trigger: CompactionTrigger::Recovery,
1582            mode: CompactionMode::Provider,
1583            original_message_count: 12,
1584            compacted_message_count: 5,
1585            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1586            previous_segment_id: Some("segment-0001".to_string()),
1587            new_segment_id: Some("segment-0002".to_string()),
1588            previous_prefix_hash: Some("prefix-before".to_string()),
1589            new_prefix_hash: Some("prefix-after".to_string()),
1590            previous_catalog_hash: Some("catalog-before".to_string()),
1591            new_catalog_hash: Some("catalog-after".to_string()),
1592        });
1593
1594        let json = serde_json::to_string(&event)?;
1595        let restored: ThreadEvent = serde_json::from_str(&json)?;
1596
1597        assert_eq!(restored, event);
1598        Ok(())
1599    }
1600
1601    #[test]
1602    fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1603        let payload = r#"{
1604            "type":"thread.compact_boundary",
1605            "thread_id":"thread-1",
1606            "trigger":"recovery",
1607            "mode":"provider",
1608            "original_message_count":12,
1609            "compacted_message_count":5
1610        }"#;
1611
1612        let restored: ThreadEvent = serde_json::from_str(payload)?;
1613        let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1614            panic!("expected thread.compact_boundary event");
1615        };
1616
1617        assert_eq!(event.thread_id, "thread-1");
1618        assert_eq!(event.history_artifact_path, None);
1619        assert_eq!(event.previous_segment_id, None);
1620        assert_eq!(event.new_segment_id, None);
1621        assert_eq!(event.previous_prefix_hash, None);
1622        assert_eq!(event.new_prefix_hash, None);
1623        assert_eq!(event.previous_catalog_hash, None);
1624        assert_eq!(event.new_catalog_hash, None);
1625        Ok(())
1626    }
1627}