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