Skip to main content

vtcode_exec_events/
lib.rs

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