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
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
578pub struct ItemCompletedEvent {
579    /// Snapshot of the thread item that completed.
580    pub item: ThreadItem,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
585pub struct ItemStartedEvent {
586    /// Snapshot of the thread item that began processing.
587    pub item: ThreadItem,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct ItemUpdatedEvent {
593    /// Snapshot of the thread item after it was updated.
594    pub item: ThreadItem,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
599pub struct PlanDeltaEvent {
600    /// Identifier of the thread emitting this plan delta.
601    pub thread_id: String,
602    /// Identifier of the current turn.
603    pub turn_id: String,
604    /// Identifier of the plan item receiving the delta.
605    pub item_id: String,
606    /// Incremental plan text chunk.
607    pub delta: String,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
611#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
612pub struct ThreadItem {
613    /// Stable identifier associated with the item.
614    pub id: String,
615    /// Embedded event details for the item type.
616    #[serde(flatten)]
617    pub details: ThreadItemDetails,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
621#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
622#[serde(tag = "type", rename_all = "snake_case")]
623pub enum ThreadItemDetails {
624    /// Message authored by the agent.
625    AgentMessage(AgentMessageItem),
626    /// Structured plan content authored by the agent in Planning workflow.
627    Plan(PlanItem),
628    /// Free-form reasoning text produced during a turn.
629    Reasoning(ReasoningItem),
630    /// Command execution lifecycle update for an actual shell/PTY process.
631    CommandExecution(Box<CommandExecutionItem>),
632    /// Tool invocation lifecycle update.
633    ToolInvocation(ToolInvocationItem),
634    /// Tool output lifecycle update tied to a tool invocation.
635    ToolOutput(ToolOutputItem),
636    /// File change summary associated with the turn.
637    FileChange(Box<FileChangeItem>),
638    /// MCP tool invocation status.
639    McpToolCall(McpToolCallItem),
640    /// Web search event emitted by a registered search provider.
641    WebSearch(WebSearchItem),
642    /// Harness-managed continuation or verification lifecycle event.
643    Harness(HarnessEventItem),
644    /// General error captured for auditing.
645    Error(ErrorItem),
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
650pub struct AgentMessageItem {
651    /// Textual content of the agent message.
652    pub text: String,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
656#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
657pub struct PlanItem {
658    /// Plan markdown content.
659    pub text: String,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664pub struct ReasoningItem {
665    /// Free-form reasoning content captured during planning.
666    pub text: String,
667    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub stage: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
673#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
674#[serde(rename_all = "snake_case")]
675pub enum CommandExecutionStatus {
676    /// Command finished successfully.
677    #[default]
678    Completed,
679    /// Command failed (non-zero exit code or runtime error).
680    Failed,
681    /// Command is still running and may emit additional output.
682    InProgress,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
687pub struct CommandExecutionItem {
688    /// Tool or command identifier executed by the runner.
689    pub command: String,
690    /// Arguments passed to the tool invocation, when available.
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub arguments: Option<Value>,
693    /// Aggregated output emitted by the command.
694    #[serde(default)]
695    pub aggregated_output: String,
696    /// Exit code reported by the process, when available.
697    #[serde(skip_serializing_if = "Option::is_none")]
698    pub exit_code: Option<i32>,
699    /// Current status of the command execution.
700    pub status: CommandExecutionStatus,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
704#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
705#[serde(rename_all = "snake_case")]
706pub enum ToolCallStatus {
707    /// Tool finished successfully.
708    #[default]
709    Completed,
710    /// Tool failed.
711    Failed,
712    /// Tool is still running and may emit additional output.
713    InProgress,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
717#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
718pub struct ToolInvocationItem {
719    /// Name of the invoked tool.
720    pub tool_name: String,
721    /// Structured arguments passed to the tool.
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub arguments: Option<Value>,
724    /// Raw model-emitted tool call identifier, when available.
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub tool_call_id: Option<String>,
727    /// Current lifecycle status of the invocation.
728    pub status: ToolCallStatus,
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
732#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
733pub struct ToolOutputItem {
734    /// Identifier of the related harness invocation item.
735    pub call_id: String,
736    /// Raw model-emitted tool call identifier, when available.
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub tool_call_id: Option<String>,
739    /// Canonical spool file path when the full output was written to disk.
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub spool_path: Option<String>,
742    /// Aggregated output emitted by the tool.
743    #[serde(default)]
744    pub output: String,
745    /// Exit code reported by the tool, when available.
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub exit_code: Option<i32>,
748    /// Current lifecycle status of the output item.
749    pub status: ToolCallStatus,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
753#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
754pub struct FileChangeItem {
755    /// List of individual file updates included in the change set.
756    pub changes: Vec<FileUpdateChange>,
757    /// Whether the patch application succeeded.
758    pub status: PatchApplyStatus,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
762#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
763pub struct FileUpdateChange {
764    /// Path of the file that was updated.
765    pub path: String,
766    /// Type of change applied to the file.
767    pub kind: PatchChangeKind,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
771#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
772#[serde(rename_all = "snake_case")]
773pub enum PatchApplyStatus {
774    /// Patch successfully applied.
775    Completed,
776    /// Patch application failed.
777    Failed,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
782#[serde(rename_all = "snake_case")]
783pub enum PatchChangeKind {
784    /// File addition.
785    Add,
786    /// File deletion.
787    Delete,
788    /// File update in place.
789    Update,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
793#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
794pub struct McpToolCallItem {
795    /// Name of the MCP tool invoked by the agent.
796    pub tool_name: String,
797    /// Arguments passed to the tool invocation, if any.
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub arguments: Option<Value>,
800    /// Result payload returned by the tool, if captured.
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub result: Option<String>,
803    /// Lifecycle status for the tool call.
804    #[serde(skip_serializing_if = "Option::is_none")]
805    pub status: Option<McpToolCallStatus>,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
809#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
810#[serde(rename_all = "snake_case")]
811pub enum McpToolCallStatus {
812    /// Tool invocation has started.
813    Started,
814    /// Tool invocation completed successfully.
815    Completed,
816    /// Tool invocation failed.
817    Failed,
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
821#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
822pub struct WebSearchItem {
823    /// Query that triggered the search.
824    pub query: String,
825    /// Search provider identifier, when known.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub provider: Option<String>,
828    /// Optional raw search results captured for auditing.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub results: Option<Vec<String>>,
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 HarnessEventKind {
837    PlanningStarted,
838    PlanningCompleted,
839    ContinuationStarted,
840    ContinuationSkipped,
841    BlockedHandoffWritten,
842    EvaluationStarted,
843    EvaluationPassed,
844    EvaluationFailed,
845    RevisionStarted,
846    EscalationTriggered,
847    EscalationBypassed,
848    VerificationStarted,
849    VerificationPassed,
850    VerificationFailed,
851    /// Agent recovered from a transient error (e.g. after retry succeeded).
852    ErrorRecovered,
853    /// A transient tool failure triggered an automatic retry attempt.
854    ToolRetryAttempted,
855    /// Latency record for a tool execution, emitted on turn completion.
856    ToolLatencyRecorded,
857    /// A checkpoint snapshot was created for the current turn.
858    SnapshotCreated,
859    /// A checkpoint snapshot was restored (rewind operation).
860    SnapshotRestored,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
864#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
865pub struct HarnessEventItem {
866    /// Specific harness event emitted by the runtime.
867    pub event: HarnessEventKind,
868    /// Optional human-readable message associated with the event.
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub message: Option<String>,
871    /// Optional verification command associated with the event.
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub command: Option<String>,
874    /// Optional artifact path associated with the event.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub path: Option<String>,
877    /// Optional exit code associated with verification results.
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub exit_code: Option<i32>,
880    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub attempt: Option<u32>,
883    /// Canonical error category for retry/recovery events.
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub error_category: Option<String>,
886    /// Latency in milliseconds for tool-execution latency events.
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub duration_ms: Option<u64>,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
892#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
893pub struct ErrorItem {
894    /// Error message displayed to the user or logs.
895    pub message: String,
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use std::error::Error;
902
903    #[test]
904    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
905        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
906            usage: Usage {
907                input_tokens: 1,
908                cached_input_tokens: 2,
909                cache_creation_tokens: 0,
910                output_tokens: 3,
911            },
912        });
913
914        let json = serde_json::to_string(&event)?;
915        let restored: ThreadEvent = serde_json::from_str(&json)?;
916
917        assert_eq!(restored, event);
918        Ok(())
919    }
920
921    #[test]
922    fn versioned_event_wraps_schema_version() {
923        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
924            thread_id: "abc".to_string(),
925        });
926
927        let versioned = VersionedThreadEvent::new(event.clone());
928
929        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
930        assert_eq!(versioned.event, event);
931        assert_eq!(versioned.into_event(), event);
932    }
933
934    #[cfg(feature = "serde-json")]
935    #[test]
936    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
937        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
938            item: ThreadItem {
939                id: "item-1".to_string(),
940                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
941                    text: "hello".to_string(),
942                }),
943            },
944        });
945
946        let payload = json::versioned_to_string(&event)?;
947        let restored = json::versioned_from_str(&payload)?;
948
949        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
950        assert_eq!(restored.event, event);
951        Ok(())
952    }
953
954    #[test]
955    fn compaction_trigger_serializes_snake_case_and_round_trips() {
956        for trigger in [
957            CompactionTrigger::Manual,
958            CompactionTrigger::Auto,
959            CompactionTrigger::Recovery,
960            CompactionTrigger::ModelSwitch,
961            CompactionTrigger::Unknown,
962        ] {
963            let json = serde_json::to_string(&trigger).unwrap();
964            assert_eq!(json, format!("\"{}\"", trigger.as_str()));
965            let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
966            assert_eq!(restored, trigger);
967        }
968    }
969
970    #[test]
971    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
972        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
973            item: ThreadItem {
974                id: "tool_1".to_string(),
975                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
976                    tool_name: "read_file".to_string(),
977                    arguments: Some(serde_json::json!({ "path": "README.md" })),
978                    tool_call_id: Some("tool_call_0".to_string()),
979                    status: ToolCallStatus::Completed,
980                }),
981            },
982        });
983
984        let json = serde_json::to_string(&event)?;
985        let restored: ThreadEvent = serde_json::from_str(&json)?;
986
987        assert_eq!(restored, event);
988        Ok(())
989    }
990
991    #[test]
992    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
993        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
994            item: ThreadItem {
995                id: "tool_1:output".to_string(),
996                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
997                    call_id: "tool_1".to_string(),
998                    tool_call_id: Some("tool_call_0".to_string()),
999                    spool_path: None,
1000                    output: "done".to_string(),
1001                    exit_code: Some(0),
1002                    status: ToolCallStatus::Completed,
1003                }),
1004            },
1005        });
1006
1007        let json = serde_json::to_string(&event)?;
1008        let restored: ThreadEvent = serde_json::from_str(&json)?;
1009
1010        assert_eq!(restored, event);
1011        Ok(())
1012    }
1013
1014    #[test]
1015    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1016        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1017            item: ThreadItem {
1018                id: "harness_1".to_string(),
1019                details: ThreadItemDetails::Harness(HarnessEventItem {
1020                    event: HarnessEventKind::VerificationFailed,
1021                    message: Some("cargo check failed".to_string()),
1022                    command: Some("cargo check".to_string()),
1023                    path: None,
1024                    exit_code: Some(101),
1025                    attempt: None,
1026                    error_category: None,
1027                    duration_ms: None,
1028                }),
1029            },
1030        });
1031
1032        let json = serde_json::to_string(&event)?;
1033        let restored: ThreadEvent = serde_json::from_str(&json)?;
1034
1035        assert_eq!(restored, event);
1036        Ok(())
1037    }
1038
1039    #[test]
1040    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1041        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1042            thread_id: "thread-1".to_string(),
1043            session_id: "session-1".to_string(),
1044            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1045            outcome_code: "budget_limit_reached".to_string(),
1046            result: None,
1047            stop_reason: Some("max_tokens".to_string()),
1048            usage: Usage {
1049                input_tokens: 10,
1050                cached_input_tokens: 4,
1051                cache_creation_tokens: 2,
1052                output_tokens: 5,
1053            },
1054            total_cost_usd: serde_json::Number::from_f64(1.25),
1055            num_turns: 3,
1056        });
1057
1058        let json = serde_json::to_string(&event)?;
1059        let restored: ThreadEvent = serde_json::from_str(&json)?;
1060
1061        assert_eq!(restored, event);
1062        Ok(())
1063    }
1064
1065    #[test]
1066    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1067        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1068            thread_id: "thread-1".to_string(),
1069            trigger: CompactionTrigger::Recovery,
1070            mode: CompactionMode::Provider,
1071            original_message_count: 12,
1072            compacted_message_count: 5,
1073            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1074        });
1075
1076        let json = serde_json::to_string(&event)?;
1077        let restored: ThreadEvent = serde_json::from_str(&json)?;
1078
1079        assert_eq!(restored, event);
1080        Ok(())
1081    }
1082}