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