Skip to main content

vtcode_exec_events/
lib.rs

1#![allow(missing_docs)]
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.8.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    pub schema_version: String,
31    /// Concrete event emitted by the agent runtime.
32    pub 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 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 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 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 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!(
134                        self.level,
135                        "failed to serialize vtcode exec event for logging: {err}"
136                    ),
137                }
138            }
139        }
140    }
141
142    pub use LogEmitter as PublicLogEmitter;
143}
144
145#[cfg(feature = "telemetry-log")]
146pub use log_support::PublicLogEmitter as LogEmitter;
147
148#[cfg(feature = "telemetry-tracing")]
149mod tracing_support {
150    use tracing::Level;
151
152    use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
153
154    /// Emits structured events as `tracing` events at the specified level.
155    #[derive(Debug, Clone)]
156    pub struct TracingEmitter {
157        level: Level,
158    }
159
160    impl TracingEmitter {
161        /// Creates a new [`TracingEmitter`] with the provided [`Level`].
162        pub fn new(level: Level) -> Self {
163            Self { level }
164        }
165    }
166
167    impl Default for TracingEmitter {
168        fn default() -> Self {
169            Self { level: Level::INFO }
170        }
171    }
172
173    impl EventEmitter for TracingEmitter {
174        fn emit(&mut self, event: &ThreadEvent) {
175            match self.level {
176                Level::TRACE => tracing::event!(
177                    target: "vtcode_exec_events",
178                    Level::TRACE,
179                    schema_version = EVENT_SCHEMA_VERSION,
180                    event = ?VersionedThreadEvent::new(event.clone()),
181                    "vtcode_exec_event"
182                ),
183                Level::DEBUG => tracing::event!(
184                    target: "vtcode_exec_events",
185                    Level::DEBUG,
186                    schema_version = EVENT_SCHEMA_VERSION,
187                    event = ?VersionedThreadEvent::new(event.clone()),
188                    "vtcode_exec_event"
189                ),
190                Level::INFO => tracing::event!(
191                    target: "vtcode_exec_events",
192                    Level::INFO,
193                    schema_version = EVENT_SCHEMA_VERSION,
194                    event = ?VersionedThreadEvent::new(event.clone()),
195                    "vtcode_exec_event"
196                ),
197                Level::WARN => tracing::event!(
198                    target: "vtcode_exec_events",
199                    Level::WARN,
200                    schema_version = EVENT_SCHEMA_VERSION,
201                    event = ?VersionedThreadEvent::new(event.clone()),
202                    "vtcode_exec_event"
203                ),
204                Level::ERROR => tracing::event!(
205                    target: "vtcode_exec_events",
206                    Level::ERROR,
207                    schema_version = EVENT_SCHEMA_VERSION,
208                    event = ?VersionedThreadEvent::new(event.clone()),
209                    "vtcode_exec_event"
210                ),
211            }
212        }
213    }
214
215    pub use TracingEmitter as PublicTracingEmitter;
216}
217
218#[cfg(feature = "telemetry-tracing")]
219pub use tracing_support::PublicTracingEmitter as TracingEmitter;
220
221#[cfg(feature = "telemetry-otel")]
222mod otel_support {
223    use opentelemetry::KeyValue;
224    use opentelemetry::trace::{Span, Status, Tracer};
225
226    use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
227
228    /// Emits [`ThreadEvent`]s as OpenTelemetry spans and span events.
229    ///
230    /// Each `ThreadEvent` is recorded as an OTel span with attributes derived
231    /// from the event payload.  Harness events are attached as span events
232    /// with their own attributes (event kind, message, path, etc.).
233    ///
234    /// # Usage
235    ///
236    /// ```rust,no_run
237    /// use vtcode_exec_events::OtelEmitter;
238    /// use opentelemetry::trace::TracerProvider;
239    ///
240    /// let provider = TracerProvider::default();
241    /// let tracer = provider.tracer("vtcode");
242    /// let mut emitter = OtelEmitter::new(tracer);
243    /// ```
244    pub struct OtelEmitter<T: Tracer> {
245        tracer: T,
246    }
247
248    impl<T: Tracer> OtelEmitter<T> {
249        pub fn new(tracer: T) -> Self {
250            Self { tracer }
251        }
252    }
253
254    impl<T: Tracer> EventEmitter for OtelEmitter<T> {
255        fn emit(&mut self, event: &ThreadEvent) {
256            let span_name = match event {
257                ThreadEvent::ThreadStarted(_) => "thread.started",
258                ThreadEvent::ThreadCompleted(_) => "thread.completed",
259                ThreadEvent::TurnStarted(_) => "turn.started",
260                ThreadEvent::TurnCompleted(_) => "turn.completed",
261                ThreadEvent::TurnFailed(_) => "turn.failed",
262                ThreadEvent::ItemStarted(_) => "item.started",
263                ThreadEvent::ItemUpdated(_) => "item.updated",
264                ThreadEvent::ItemCompleted(_) => "item.completed",
265                ThreadEvent::Error(_) => "error",
266                _ => "event",
267            };
268
269            let mut span = self.tracer.start(span_name);
270
271            match event {
272                ThreadEvent::ThreadStarted(e) => {
273                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
274                }
275                ThreadEvent::ThreadCompleted(e) => {
276                    if let Some(ref cost) = e.total_cost_usd {
277                        span.set_attribute(KeyValue::new(
278                            "total_cost_usd",
279                            cost.as_f64().unwrap_or(0.0),
280                        ));
281                    }
282                    span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
283                    span.set_attribute(KeyValue::new(
284                        "output_tokens",
285                        e.usage.output_tokens as i64,
286                    ));
287                    span.set_attribute(KeyValue::new(
288                        "completion_subtype",
289                        e.subtype.as_str().to_string(),
290                    ));
291                }
292                ThreadEvent::TurnCompleted(e) => {
293                    span.set_attribute(KeyValue::new(
294                        "turn_input_tokens",
295                        e.usage.input_tokens as i64,
296                    ));
297                    span.set_attribute(KeyValue::new(
298                        "turn_output_tokens",
299                        e.usage.output_tokens as i64,
300                    ));
301                }
302                ThreadEvent::ItemCompleted(e) => {
303                    if let ThreadItemDetails::Harness(harness) = &e.item.details {
304                        span.set_attribute(KeyValue::new(
305                            "harness_event",
306                            format!("{:?}", harness.event),
307                        ));
308                        if let Some(ref msg) = harness.message {
309                            span.set_attribute(KeyValue::new("harness_message", msg.clone()));
310                        }
311                        if let Some(ref path) = harness.path {
312                            span.set_attribute(KeyValue::new("harness_path", path.clone()));
313                        }
314                        if let Some(dur) = harness.duration_ms {
315                            span.set_attribute(KeyValue::new("duration_ms", dur as i64));
316                        }
317                        let mut event_attrs =
318                            vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
319                        if let Some(ref msg) = harness.message {
320                            event_attrs.push(KeyValue::new("message", msg.clone()));
321                        }
322                        span.add_event("harness_event", event_attrs);
323                    }
324                }
325                ThreadEvent::Error(e) => {
326                    span.set_status(Status::Error { description: e.message.clone().into() });
327                    span.set_attribute(KeyValue::new("error_message", e.message.clone()));
328                }
329                _ => {}
330            }
331
332            span.end();
333        }
334    }
335
336    pub use OtelEmitter as PublicOtelEmitter;
337}
338
339#[cfg(feature = "telemetry-otel")]
340pub use otel_support::PublicOtelEmitter as OtelEmitter;
341
342#[cfg(feature = "schema-export")]
343pub mod schema {
344    use schemars::{Schema, schema_for};
345
346    use super::{ThreadEvent, VersionedThreadEvent};
347
348    /// Generates a JSON Schema describing [`ThreadEvent`].
349    pub fn thread_event_schema() -> Schema {
350        schema_for!(ThreadEvent)
351    }
352
353    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
354    pub fn versioned_thread_event_schema() -> Schema {
355        schema_for!(VersionedThreadEvent)
356    }
357}
358
359/// Structured events emitted during autonomous execution.
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
361#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
362#[serde(tag = "type")]
363pub enum ThreadEvent {
364    /// Indicates that a new execution thread has started.
365    #[serde(rename = "thread.started")]
366    ThreadStarted(ThreadStartedEvent),
367    /// Indicates that an execution thread has reached a terminal outcome.
368    #[serde(rename = "thread.completed")]
369    ThreadCompleted(ThreadCompletedEvent),
370    /// Indicates that conversation compaction replaced older history with a boundary.
371    #[serde(rename = "thread.compact_boundary")]
372    ThreadCompactBoundary(ThreadCompactBoundaryEvent),
373    /// Marks the beginning of an execution turn.
374    #[serde(rename = "turn.started")]
375    TurnStarted(TurnStartedEvent),
376    /// Marks the completion of an execution turn.
377    #[serde(rename = "turn.completed")]
378    TurnCompleted(TurnCompletedEvent),
379    /// Marks a turn as failed with additional context.
380    #[serde(rename = "turn.failed")]
381    TurnFailed(TurnFailedEvent),
382    /// Indicates that an item has started processing.
383    #[serde(rename = "item.started")]
384    ItemStarted(ItemStartedEvent),
385    /// Indicates that an item has been updated.
386    #[serde(rename = "item.updated")]
387    ItemUpdated(ItemUpdatedEvent),
388    /// Indicates that an item reached a terminal state.
389    #[serde(rename = "item.completed")]
390    ItemCompleted(ItemCompletedEvent),
391    /// Streaming delta for a plan item in Planning workflow.
392    #[serde(rename = "plan.delta")]
393    PlanDelta(PlanDeltaEvent),
394    /// Represents a fatal error.
395    #[serde(rename = "error")]
396    Error(ThreadErrorEvent),
397    /// Catch-all for unknown event types added in newer schema versions.
398    /// Preserves forward compatibility when older binaries read newer event streams.
399    #[serde(other)]
400    Unknown,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
404#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
405pub struct ThreadStartedEvent {
406    /// Unique identifier for the thread that was started.
407    pub thread_id: String,
408}
409
410#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
411#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
412#[serde(rename_all = "snake_case")]
413pub enum ThreadCompletionSubtype {
414    Success,
415    ErrorMaxTurns,
416    ErrorMaxBudgetUsd,
417    ErrorDuringExecution,
418    Cancelled,
419    /// Catch-all for unknown completion subtypes added in newer schema versions.
420    #[serde(other)]
421    Unknown,
422}
423
424impl ThreadCompletionSubtype {
425    pub const fn as_str(&self) -> &'static str {
426        match self {
427            Self::Success => "success",
428            Self::ErrorMaxTurns => "error_max_turns",
429            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
430            Self::ErrorDuringExecution => "error_during_execution",
431            Self::Cancelled => "cancelled",
432            Self::Unknown => "unknown",
433        }
434    }
435
436    pub const fn is_success(self) -> bool {
437        matches!(self, Self::Success)
438    }
439}
440
441#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
443#[serde(rename_all = "snake_case")]
444pub enum CompactionTrigger {
445    Manual,
446    Auto,
447    Recovery,
448    /// Compaction triggered by a mid-session switch of the main model or
449    /// provider, so the newly selected model starts from a clean summary.
450    ModelSwitch,
451    /// Catch-all for unknown triggers added in newer schema versions.
452    #[serde(other)]
453    Unknown,
454}
455
456impl CompactionTrigger {
457    pub const fn as_str(self) -> &'static str {
458        match self {
459            Self::Manual => "manual",
460            Self::Auto => "auto",
461            Self::Recovery => "recovery",
462            Self::ModelSwitch => "model_switch",
463            Self::Unknown => "unknown",
464        }
465    }
466}
467
468#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
469#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
470#[serde(rename_all = "snake_case")]
471pub enum CompactionMode {
472    Provider,
473    Local,
474    /// Catch-all for unknown modes added in newer schema versions.
475    #[serde(other)]
476    Unknown,
477}
478
479impl CompactionMode {
480    pub const fn as_str(self) -> &'static str {
481        match self {
482            Self::Provider => "provider",
483            Self::Local => "local",
484            Self::Unknown => "unknown",
485        }
486    }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
491pub struct ThreadCompletedEvent {
492    /// Stable thread identifier for the session.
493    pub thread_id: String,
494    /// Stable session identifier for the runtime that produced the thread.
495    pub session_id: String,
496    /// Coarse result category aligned with SDK-style terminal states.
497    pub subtype: ThreadCompletionSubtype,
498    /// VT Code-specific detailed outcome code.
499    pub outcome_code: String,
500    /// Final assistant result text when the thread completed successfully.
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub result: Option<String>,
503    /// Provider stop reason or VT Code terminal reason when available.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub stop_reason: Option<String>,
506    /// Aggregated token usage across the thread.
507    pub usage: Usage,
508    /// Optional estimated total API cost for the thread.
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub total_cost_usd: Option<serde_json::Number>,
511    /// Number of turns executed before completion.
512    pub num_turns: usize,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
517pub struct ThreadCompactBoundaryEvent {
518    /// Stable thread identifier for the session.
519    pub thread_id: String,
520    /// Whether compaction was triggered manually or automatically.
521    pub trigger: CompactionTrigger,
522    /// Whether the compaction boundary came from provider-native or local compaction.
523    pub mode: CompactionMode,
524    /// Number of messages before compaction.
525    pub original_message_count: usize,
526    /// Number of messages after compaction.
527    pub compacted_message_count: usize,
528    /// Optional persisted artifact containing the archived compaction summary/history.
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub history_artifact_path: Option<String>,
531}
532
533#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
534#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
535pub struct TurnStartedEvent {
536    /// Optional decomposition of the assembled first-request prefix so
537    /// downstream consumers can attribute token overhead without inventing
538    /// parallel event types.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub token_breakdown: Option<TokenBreakdown>,
541}
542
543/// Per-request token-budget breakdown for the assembled first-request prefix.
544#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
545#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
546pub struct TokenBreakdown {
547    /// System prompt text tokens.
548    pub system_prompt_tokens: u64,
549    /// On-wire tool schema tokens.
550    pub tool_schema_tokens: u64,
551    /// Instruction file tokens included in the prompt.
552    pub instruction_file_tokens: u64,
553    /// Message history text tokens.
554    pub message_history_tokens: u64,
555    /// Cache read tokens (served from prior turns).
556    pub cache_read_tokens: u64,
557    /// Cache write tokens (new cache entries created this turn).
558    pub cache_write_tokens: u64,
559    /// Tokens that missed cache (neither read nor written).
560    pub cache_miss_tokens: u64,
561    /// Subagent bootstrap tokens, if this turn spawned a child agent.
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub subagent_bootstrap_tokens: Option<u64>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
568pub struct TurnCompletedEvent {
569    /// Token usage summary for the completed turn.
570    pub usage: Usage,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
575pub struct TurnFailedEvent {
576    /// Human-readable explanation describing why the turn failed.
577    pub message: String,
578    /// Optional token usage that was consumed before the failure occurred.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub usage: Option<Usage>,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
585pub struct ThreadErrorEvent {
586    /// Fatal error message associated with the thread.
587    pub message: String,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct Usage {
593    /// Number of prompt tokens processed during the turn.
594    pub input_tokens: u64,
595    /// Number of cached prompt tokens reused from previous turns.
596    pub cached_input_tokens: u64,
597    /// Number of cache-creation tokens charged during the turn.
598    pub cache_creation_tokens: u64,
599    /// Number of completion tokens generated by the model.
600    pub output_tokens: u64,
601}
602
603impl Usage {
604    /// Number of input tokens billed at the full input rate: neither served
605    /// from cache nor written to it. `input_tokens` is the total prompt token
606    /// count (uncached + cached + cache-creation), so both cached and
607    /// cache-creation tokens are subtracted out here.
608    #[must_use]
609    pub fn uncached_input_tokens(&self) -> u64 {
610        self.input_tokens
611            .saturating_sub(self.cached_input_tokens)
612            .saturating_sub(self.cache_creation_tokens)
613    }
614
615    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
616    /// Returns `None` when no input tokens were recorded.
617    #[must_use]
618    pub fn cache_hit_rate(&self) -> Option<f64> {
619        if self.input_tokens == 0 {
620            return None;
621        }
622        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
623    }
624
625    /// Human-readable summary of prompt cache efficiency.
626    #[must_use]
627    pub fn cache_summary(&self) -> String {
628        let total_input = self.input_tokens;
629        if total_input == 0 {
630            return "No input tokens recorded.".to_string();
631        }
632
633        let cached = self.cached_input_tokens;
634        let creation = self.cache_creation_tokens;
635        let uncached = self.uncached_input_tokens();
636        let rate = cached as f64 / total_input as f64 * 100.0;
637        format!(
638            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
639             {creation} cache-creation, {uncached} uncached"
640        )
641    }
642
643    /// Accumulate another usage sample into this one.
644    pub fn add(&mut self, other: &Usage) {
645        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
646        self.cached_input_tokens =
647            self.cached_input_tokens.saturating_add(other.cached_input_tokens);
648        self.cache_creation_tokens =
649            self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
650        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
651    }
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
655#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
656pub struct ItemCompletedEvent {
657    /// Snapshot of the thread item that completed.
658    pub item: ThreadItem,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
662#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
663pub struct ItemStartedEvent {
664    /// Snapshot of the thread item that began processing.
665    pub item: ThreadItem,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
669#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
670pub struct ItemUpdatedEvent {
671    /// Snapshot of the thread item after it was updated.
672    pub item: ThreadItem,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
677pub struct PlanDeltaEvent {
678    /// Identifier of the thread emitting this plan delta.
679    pub thread_id: String,
680    /// Identifier of the current turn.
681    pub turn_id: String,
682    /// Identifier of the plan item receiving the delta.
683    pub item_id: String,
684    /// Incremental plan text chunk.
685    pub delta: String,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
689#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
690pub struct ThreadItem {
691    /// Stable identifier associated with the item.
692    pub id: String,
693    /// Embedded event details for the item type.
694    #[serde(flatten)]
695    pub details: ThreadItemDetails,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
700#[serde(tag = "type", rename_all = "snake_case")]
701pub enum ThreadItemDetails {
702    /// Message authored by the agent.
703    AgentMessage(AgentMessageItem),
704    /// Structured plan content authored by the agent in Planning workflow.
705    Plan(PlanItem),
706    /// Free-form reasoning text produced during a turn.
707    Reasoning(ReasoningItem),
708    /// Command execution lifecycle update for an actual shell/PTY process.
709    CommandExecution(Box<CommandExecutionItem>),
710    /// Tool invocation lifecycle update.
711    ToolInvocation(ToolInvocationItem),
712    /// Tool output lifecycle update tied to a tool invocation.
713    ToolOutput(ToolOutputItem),
714    /// File change summary associated with the turn.
715    FileChange(Box<FileChangeItem>),
716    /// MCP tool invocation status.
717    McpToolCall(McpToolCallItem),
718    /// Web search event emitted by a registered search provider.
719    WebSearch(WebSearchItem),
720    /// Harness-managed continuation or verification lifecycle event.
721    Harness(HarnessEventItem),
722    /// General error captured for auditing.
723    Error(ErrorItem),
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
727#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
728pub struct AgentMessageItem {
729    /// Textual content of the agent message.
730    pub text: String,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
734#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
735pub struct PlanItem {
736    /// Plan markdown content.
737    pub text: String,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
741#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
742pub struct ReasoningItem {
743    /// Free-form reasoning content captured during planning.
744    pub text: String,
745    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub stage: Option<String>,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
752#[serde(rename_all = "snake_case")]
753pub enum CommandExecutionStatus {
754    /// Command finished successfully.
755    #[default]
756    Completed,
757    /// Command failed (non-zero exit code or runtime error).
758    Failed,
759    /// Command is still running and may emit additional output.
760    InProgress,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
764#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
765pub struct CommandExecutionItem {
766    /// Tool or command identifier executed by the runner.
767    pub command: String,
768    /// Arguments passed to the tool invocation, when available.
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub arguments: Option<Value>,
771    /// Aggregated output emitted by the command.
772    #[serde(default)]
773    pub aggregated_output: String,
774    /// Exit code reported by the process, when available.
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub exit_code: Option<i32>,
777    /// Current status of the command execution.
778    pub status: CommandExecutionStatus,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
782#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
783#[serde(rename_all = "snake_case")]
784pub enum ToolCallStatus {
785    /// Tool finished successfully.
786    #[default]
787    Completed,
788    /// Tool failed.
789    Failed,
790    /// Tool is still running and may emit additional output.
791    InProgress,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
796pub struct ToolInvocationItem {
797    /// Name of the invoked tool.
798    pub tool_name: String,
799    /// Structured arguments passed to the tool.
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub arguments: Option<Value>,
802    /// Raw model-emitted tool call identifier, when available.
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub tool_call_id: Option<String>,
805    /// Current lifecycle status of the invocation.
806    pub status: ToolCallStatus,
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
810#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
811pub struct ToolOutputItem {
812    /// Identifier of the related harness invocation item.
813    pub call_id: String,
814    /// Raw model-emitted tool call identifier, when available.
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub tool_call_id: Option<String>,
817    /// Canonical spool file path when the full output was written to disk.
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub spool_path: Option<String>,
820    /// Aggregated output emitted by the tool.
821    #[serde(default)]
822    pub output: String,
823    /// Exit code reported by the tool, when available.
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub exit_code: Option<i32>,
826    /// Current lifecycle status of the output item.
827    pub status: ToolCallStatus,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
831#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
832pub struct FileChangeItem {
833    /// List of individual file updates included in the change set.
834    pub changes: Vec<FileUpdateChange>,
835    /// Whether the patch application succeeded.
836    pub status: PatchApplyStatus,
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
841pub struct FileUpdateChange {
842    /// Path of the file that was updated.
843    pub path: String,
844    /// Type of change applied to the file.
845    pub kind: PatchChangeKind,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
850#[serde(rename_all = "snake_case")]
851pub enum PatchApplyStatus {
852    /// Patch successfully applied.
853    Completed,
854    /// Patch application failed.
855    Failed,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
860#[serde(rename_all = "snake_case")]
861pub enum PatchChangeKind {
862    /// File addition.
863    Add,
864    /// File deletion.
865    Delete,
866    /// File update in place.
867    Update,
868}
869
870#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
871#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
872pub struct McpToolCallItem {
873    /// Name of the MCP tool invoked by the agent.
874    pub tool_name: String,
875    /// Arguments passed to the tool invocation, if any.
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub arguments: Option<Value>,
878    /// Result payload returned by the tool, if captured.
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub result: Option<String>,
881    /// Lifecycle status for the tool call.
882    #[serde(skip_serializing_if = "Option::is_none")]
883    pub status: Option<McpToolCallStatus>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
887#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
888#[serde(rename_all = "snake_case")]
889pub enum McpToolCallStatus {
890    /// Tool invocation has started.
891    Started,
892    /// Tool invocation completed successfully.
893    Completed,
894    /// Tool invocation failed.
895    Failed,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
899#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
900pub struct WebSearchItem {
901    /// Query that triggered the search.
902    pub query: String,
903    /// Search provider identifier, when known.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub provider: Option<String>,
906    /// Optional raw search results captured for auditing.
907    #[serde(skip_serializing_if = "Option::is_none")]
908    pub results: Option<Vec<String>>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
913#[serde(rename_all = "snake_case")]
914pub enum HarnessEventKind {
915    PlanningStarted,
916    PlanningCompleted,
917    ContinuationStarted,
918    ContinuationSkipped,
919    BlockedHandoffWritten,
920    EvaluationStarted,
921    EvaluationPassed,
922    EvaluationFailed,
923    RevisionStarted,
924    EscalationTriggered,
925    EscalationBypassed,
926    VerificationStarted,
927    VerificationPassed,
928    VerificationFailed,
929    /// Agent recovered from a transient error (e.g. after retry succeeded).
930    ErrorRecovered,
931    /// A transient tool failure triggered an automatic retry attempt.
932    ToolRetryAttempted,
933    /// Latency record for a tool execution, emitted on turn completion.
934    ToolLatencyRecorded,
935    /// A checkpoint snapshot was created for the current turn.
936    SnapshotCreated,
937    /// A checkpoint snapshot was restored (rewind operation).
938    SnapshotRestored,
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
942#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
943pub struct HarnessEventItem {
944    /// Specific harness event emitted by the runtime.
945    pub event: HarnessEventKind,
946    /// Optional human-readable message associated with the event.
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub message: Option<String>,
949    /// Optional verification command associated with the event.
950    #[serde(skip_serializing_if = "Option::is_none")]
951    pub command: Option<String>,
952    /// Optional artifact path associated with the event.
953    #[serde(skip_serializing_if = "Option::is_none")]
954    pub path: Option<String>,
955    /// Optional exit code associated with verification results.
956    #[serde(skip_serializing_if = "Option::is_none")]
957    pub exit_code: Option<i32>,
958    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
959    #[serde(skip_serializing_if = "Option::is_none")]
960    pub attempt: Option<u32>,
961    /// Canonical error category for retry/recovery events.
962    #[serde(skip_serializing_if = "Option::is_none")]
963    pub error_category: Option<String>,
964    /// Latency in milliseconds for tool-execution latency events.
965    #[serde(skip_serializing_if = "Option::is_none")]
966    pub duration_ms: Option<u64>,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
970#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
971pub struct ErrorItem {
972    /// Error message displayed to the user or logs.
973    pub message: String,
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use std::error::Error;
980
981    #[test]
982    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
983        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
984            usage: Usage {
985                input_tokens: 1,
986                cached_input_tokens: 2,
987                cache_creation_tokens: 0,
988                output_tokens: 3,
989            },
990        });
991
992        let json = serde_json::to_string(&event)?;
993        let restored: ThreadEvent = serde_json::from_str(&json)?;
994
995        assert_eq!(restored, event);
996        Ok(())
997    }
998
999    #[test]
1000    fn usage_uncached_input_tokens_saturates() {
1001        let usage = Usage {
1002            input_tokens: 1_000,
1003            cached_input_tokens: 800,
1004            cache_creation_tokens: 100,
1005            output_tokens: 50,
1006        };
1007        assert_eq!(usage.uncached_input_tokens(), 100);
1008
1009        let inconsistent = Usage {
1010            input_tokens: 100,
1011            cached_input_tokens: 150,
1012            cache_creation_tokens: 0,
1013            output_tokens: 0,
1014        };
1015        assert_eq!(inconsistent.uncached_input_tokens(), 0);
1016
1017        let inconsistent_with_creation = Usage {
1018            input_tokens: 100,
1019            cached_input_tokens: 80,
1020            cache_creation_tokens: 50,
1021            output_tokens: 0,
1022        };
1023        assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1024    }
1025
1026    #[test]
1027    fn usage_cache_hit_rate() {
1028        assert_eq!(Usage::default().cache_hit_rate(), None);
1029
1030        let usage = Usage {
1031            input_tokens: 1_000,
1032            cached_input_tokens: 750,
1033            cache_creation_tokens: 0,
1034            output_tokens: 0,
1035        };
1036        let rate = usage.cache_hit_rate().expect("rate");
1037        assert!((rate - 0.75).abs() < f64::EPSILON);
1038    }
1039
1040    #[test]
1041    fn usage_cache_summary_formats() {
1042        assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1043
1044        let usage = Usage {
1045            input_tokens: 1_000,
1046            cached_input_tokens: 800,
1047            cache_creation_tokens: 100,
1048            output_tokens: 50,
1049        };
1050        assert_eq!(
1051            usage.cache_summary(),
1052            "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1053        );
1054    }
1055
1056    #[test]
1057    fn usage_add_accumulates_all_fields_with_saturation() {
1058        let mut total = Usage {
1059            input_tokens: 100,
1060            cached_input_tokens: 20,
1061            cache_creation_tokens: 5,
1062            output_tokens: 10,
1063        };
1064        total.add(&Usage {
1065            input_tokens: 50,
1066            cached_input_tokens: 10,
1067            cache_creation_tokens: 2,
1068            output_tokens: 8,
1069        });
1070
1071        assert_eq!(total.input_tokens, 150);
1072        assert_eq!(total.cached_input_tokens, 30);
1073        assert_eq!(total.cache_creation_tokens, 7);
1074        assert_eq!(total.output_tokens, 18);
1075
1076        let mut saturating = Usage {
1077            input_tokens: u64::MAX,
1078            cached_input_tokens: u64::MAX,
1079            cache_creation_tokens: u64::MAX,
1080            output_tokens: u64::MAX,
1081        };
1082        saturating.add(&Usage {
1083            input_tokens: 1,
1084            cached_input_tokens: 1,
1085            cache_creation_tokens: 1,
1086            output_tokens: 1,
1087        });
1088        assert_eq!(saturating.input_tokens, u64::MAX);
1089        assert_eq!(saturating.cached_input_tokens, u64::MAX);
1090        assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1091        assert_eq!(saturating.output_tokens, u64::MAX);
1092    }
1093
1094    #[test]
1095    fn versioned_event_wraps_schema_version() {
1096        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1097
1098        let versioned = VersionedThreadEvent::new(event.clone());
1099
1100        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1101        assert_eq!(versioned.event, event);
1102        assert_eq!(versioned.into_event(), event);
1103    }
1104
1105    #[cfg(feature = "serde-json")]
1106    #[test]
1107    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1108        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1109            item: ThreadItem {
1110                id: "item-1".to_string(),
1111                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
1112                    text: "hello".to_string(),
1113                }),
1114            },
1115        });
1116
1117        let payload = json::versioned_to_string(&event)?;
1118        let restored = json::versioned_from_str(&payload)?;
1119
1120        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1121        assert_eq!(restored.event, event);
1122        Ok(())
1123    }
1124
1125    #[test]
1126    fn compaction_trigger_serializes_snake_case_and_round_trips() {
1127        for trigger in [
1128            CompactionTrigger::Manual,
1129            CompactionTrigger::Auto,
1130            CompactionTrigger::Recovery,
1131            CompactionTrigger::ModelSwitch,
1132            CompactionTrigger::Unknown,
1133        ] {
1134            let json = serde_json::to_string(&trigger).unwrap();
1135            assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1136            let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1137            assert_eq!(restored, trigger);
1138        }
1139    }
1140
1141    #[test]
1142    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1143        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1144            item: ThreadItem {
1145                id: "tool_1".to_string(),
1146                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1147                    tool_name: "read_file".to_string(),
1148                    arguments: Some(serde_json::json!({ "path": "README.md" })),
1149                    tool_call_id: Some("tool_call_0".to_string()),
1150                    status: ToolCallStatus::Completed,
1151                }),
1152            },
1153        });
1154
1155        let json = serde_json::to_string(&event)?;
1156        let restored: ThreadEvent = serde_json::from_str(&json)?;
1157
1158        assert_eq!(restored, event);
1159        Ok(())
1160    }
1161
1162    #[test]
1163    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1164        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1165            item: ThreadItem {
1166                id: "tool_1:output".to_string(),
1167                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1168                    call_id: "tool_1".to_string(),
1169                    tool_call_id: Some("tool_call_0".to_string()),
1170                    spool_path: None,
1171                    output: "done".to_string(),
1172                    exit_code: Some(0),
1173                    status: ToolCallStatus::Completed,
1174                }),
1175            },
1176        });
1177
1178        let json = serde_json::to_string(&event)?;
1179        let restored: ThreadEvent = serde_json::from_str(&json)?;
1180
1181        assert_eq!(restored, event);
1182        Ok(())
1183    }
1184
1185    #[test]
1186    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1187        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1188            item: ThreadItem {
1189                id: "harness_1".to_string(),
1190                details: ThreadItemDetails::Harness(HarnessEventItem {
1191                    event: HarnessEventKind::VerificationFailed,
1192                    message: Some("cargo check failed".to_string()),
1193                    command: Some("cargo check".to_string()),
1194                    path: None,
1195                    exit_code: Some(101),
1196                    attempt: None,
1197                    error_category: None,
1198                    duration_ms: None,
1199                }),
1200            },
1201        });
1202
1203        let json = serde_json::to_string(&event)?;
1204        let restored: ThreadEvent = serde_json::from_str(&json)?;
1205
1206        assert_eq!(restored, event);
1207        Ok(())
1208    }
1209
1210    #[test]
1211    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1212        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1213            thread_id: "thread-1".to_string(),
1214            session_id: "session-1".to_string(),
1215            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1216            outcome_code: "budget_limit_reached".to_string(),
1217            result: None,
1218            stop_reason: Some("max_tokens".to_string()),
1219            usage: Usage {
1220                input_tokens: 10,
1221                cached_input_tokens: 4,
1222                cache_creation_tokens: 2,
1223                output_tokens: 5,
1224            },
1225            total_cost_usd: serde_json::Number::from_f64(1.25),
1226            num_turns: 3,
1227        });
1228
1229        let json = serde_json::to_string(&event)?;
1230        let restored: ThreadEvent = serde_json::from_str(&json)?;
1231
1232        assert_eq!(restored, event);
1233        Ok(())
1234    }
1235
1236    #[test]
1237    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1238        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1239            thread_id: "thread-1".to_string(),
1240            trigger: CompactionTrigger::Recovery,
1241            mode: CompactionMode::Provider,
1242            original_message_count: 12,
1243            compacted_message_count: 5,
1244            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1245        });
1246
1247        let json = serde_json::to_string(&event)?;
1248        let restored: ThreadEvent = serde_json::from_str(&json)?;
1249
1250        assert_eq!(restored, event);
1251        Ok(())
1252    }
1253}