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    /// Catch-all for unknown triggers added in newer schema versions.
451    #[serde(other)]
452    Unknown,
453}
454
455impl CompactionTrigger {
456    pub const fn as_str(self) -> &'static str {
457        match self {
458            Self::Manual => "manual",
459            Self::Auto => "auto",
460            Self::Recovery => "recovery",
461            Self::Unknown => "unknown",
462        }
463    }
464}
465
466#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
467#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
468#[serde(rename_all = "snake_case")]
469pub enum CompactionMode {
470    Provider,
471    Local,
472    /// Catch-all for unknown modes added in newer schema versions.
473    #[serde(other)]
474    Unknown,
475}
476
477impl CompactionMode {
478    pub const fn as_str(self) -> &'static str {
479        match self {
480            Self::Provider => "provider",
481            Self::Local => "local",
482            Self::Unknown => "unknown",
483        }
484    }
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
488#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
489pub struct ThreadCompletedEvent {
490    /// Stable thread identifier for the session.
491    pub thread_id: String,
492    /// Stable session identifier for the runtime that produced the thread.
493    pub session_id: String,
494    /// Coarse result category aligned with SDK-style terminal states.
495    pub subtype: ThreadCompletionSubtype,
496    /// VT Code-specific detailed outcome code.
497    pub outcome_code: String,
498    /// Final assistant result text when the thread completed successfully.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub result: Option<String>,
501    /// Provider stop reason or VT Code terminal reason when available.
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub stop_reason: Option<String>,
504    /// Aggregated token usage across the thread.
505    pub usage: Usage,
506    /// Optional estimated total API cost for the thread.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub total_cost_usd: Option<serde_json::Number>,
509    /// Number of turns executed before completion.
510    pub num_turns: usize,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
514#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
515pub struct ThreadCompactBoundaryEvent {
516    /// Stable thread identifier for the session.
517    pub thread_id: String,
518    /// Whether compaction was triggered manually or automatically.
519    pub trigger: CompactionTrigger,
520    /// Whether the compaction boundary came from provider-native or local compaction.
521    pub mode: CompactionMode,
522    /// Number of messages before compaction.
523    pub original_message_count: usize,
524    /// Number of messages after compaction.
525    pub compacted_message_count: usize,
526    /// Optional persisted artifact containing the archived compaction summary/history.
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub history_artifact_path: Option<String>,
529}
530
531#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
533pub struct TurnStartedEvent {}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
537pub struct TurnCompletedEvent {
538    /// Token usage summary for the completed turn.
539    pub usage: Usage,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
544pub struct TurnFailedEvent {
545    /// Human-readable explanation describing why the turn failed.
546    pub message: String,
547    /// Optional token usage that was consumed before the failure occurred.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub usage: Option<Usage>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
553#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
554pub struct ThreadErrorEvent {
555    /// Fatal error message associated with the thread.
556    pub message: String,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
560#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
561pub struct Usage {
562    /// Number of prompt tokens processed during the turn.
563    pub input_tokens: u64,
564    /// Number of cached prompt tokens reused from previous turns.
565    pub cached_input_tokens: u64,
566    /// Number of cache-creation tokens charged during the turn.
567    pub cache_creation_tokens: u64,
568    /// Number of completion tokens generated by the model.
569    pub output_tokens: u64,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
574pub struct ItemCompletedEvent {
575    /// Snapshot of the thread item that completed.
576    pub item: ThreadItem,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
581pub struct ItemStartedEvent {
582    /// Snapshot of the thread item that began processing.
583    pub item: ThreadItem,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
587#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
588pub struct ItemUpdatedEvent {
589    /// Snapshot of the thread item after it was updated.
590    pub item: ThreadItem,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
595pub struct PlanDeltaEvent {
596    /// Identifier of the thread emitting this plan delta.
597    pub thread_id: String,
598    /// Identifier of the current turn.
599    pub turn_id: String,
600    /// Identifier of the plan item receiving the delta.
601    pub item_id: String,
602    /// Incremental plan text chunk.
603    pub delta: String,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
607#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
608pub struct ThreadItem {
609    /// Stable identifier associated with the item.
610    pub id: String,
611    /// Embedded event details for the item type.
612    #[serde(flatten)]
613    pub details: ThreadItemDetails,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
618#[serde(tag = "type", rename_all = "snake_case")]
619pub enum ThreadItemDetails {
620    /// Message authored by the agent.
621    AgentMessage(AgentMessageItem),
622    /// Structured plan content authored by the agent in Planning workflow.
623    Plan(PlanItem),
624    /// Free-form reasoning text produced during a turn.
625    Reasoning(ReasoningItem),
626    /// Command execution lifecycle update for an actual shell/PTY process.
627    CommandExecution(Box<CommandExecutionItem>),
628    /// Tool invocation lifecycle update.
629    ToolInvocation(ToolInvocationItem),
630    /// Tool output lifecycle update tied to a tool invocation.
631    ToolOutput(ToolOutputItem),
632    /// File change summary associated with the turn.
633    FileChange(Box<FileChangeItem>),
634    /// MCP tool invocation status.
635    McpToolCall(McpToolCallItem),
636    /// Web search event emitted by a registered search provider.
637    WebSearch(WebSearchItem),
638    /// Harness-managed continuation or verification lifecycle event.
639    Harness(HarnessEventItem),
640    /// General error captured for auditing.
641    Error(ErrorItem),
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
646pub struct AgentMessageItem {
647    /// Textual content of the agent message.
648    pub text: String,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
652#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
653pub struct PlanItem {
654    /// Plan markdown content.
655    pub text: String,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
660pub struct ReasoningItem {
661    /// Free-form reasoning content captured during planning.
662    pub text: String,
663    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
664    #[serde(skip_serializing_if = "Option::is_none")]
665    pub stage: Option<String>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
669#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
670#[serde(rename_all = "snake_case")]
671pub enum CommandExecutionStatus {
672    /// Command finished successfully.
673    #[default]
674    Completed,
675    /// Command failed (non-zero exit code or runtime error).
676    Failed,
677    /// Command is still running and may emit additional output.
678    InProgress,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
683pub struct CommandExecutionItem {
684    /// Tool or command identifier executed by the runner.
685    pub command: String,
686    /// Arguments passed to the tool invocation, when available.
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub arguments: Option<Value>,
689    /// Aggregated output emitted by the command.
690    #[serde(default)]
691    pub aggregated_output: String,
692    /// Exit code reported by the process, when available.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub exit_code: Option<i32>,
695    /// Current status of the command execution.
696    pub status: CommandExecutionStatus,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
700#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
701#[serde(rename_all = "snake_case")]
702pub enum ToolCallStatus {
703    /// Tool finished successfully.
704    #[default]
705    Completed,
706    /// Tool failed.
707    Failed,
708    /// Tool is still running and may emit additional output.
709    InProgress,
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
713#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
714pub struct ToolInvocationItem {
715    /// Name of the invoked tool.
716    pub tool_name: String,
717    /// Structured arguments passed to the tool.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub arguments: Option<Value>,
720    /// Raw model-emitted tool call identifier, when available.
721    #[serde(skip_serializing_if = "Option::is_none")]
722    pub tool_call_id: Option<String>,
723    /// Current lifecycle status of the invocation.
724    pub status: ToolCallStatus,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
728#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
729pub struct ToolOutputItem {
730    /// Identifier of the related harness invocation item.
731    pub call_id: String,
732    /// Raw model-emitted tool call identifier, when available.
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub tool_call_id: Option<String>,
735    /// Canonical spool file path when the full output was written to disk.
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub spool_path: Option<String>,
738    /// Aggregated output emitted by the tool.
739    #[serde(default)]
740    pub output: String,
741    /// Exit code reported by the tool, when available.
742    #[serde(skip_serializing_if = "Option::is_none")]
743    pub exit_code: Option<i32>,
744    /// Current lifecycle status of the output item.
745    pub status: ToolCallStatus,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
750pub struct FileChangeItem {
751    /// List of individual file updates included in the change set.
752    pub changes: Vec<FileUpdateChange>,
753    /// Whether the patch application succeeded.
754    pub status: PatchApplyStatus,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
758#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
759pub struct FileUpdateChange {
760    /// Path of the file that was updated.
761    pub path: String,
762    /// Type of change applied to the file.
763    pub kind: PatchChangeKind,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
767#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
768#[serde(rename_all = "snake_case")]
769pub enum PatchApplyStatus {
770    /// Patch successfully applied.
771    Completed,
772    /// Patch application failed.
773    Failed,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
777#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
778#[serde(rename_all = "snake_case")]
779pub enum PatchChangeKind {
780    /// File addition.
781    Add,
782    /// File deletion.
783    Delete,
784    /// File update in place.
785    Update,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
789#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
790pub struct McpToolCallItem {
791    /// Name of the MCP tool invoked by the agent.
792    pub tool_name: String,
793    /// Arguments passed to the tool invocation, if any.
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub arguments: Option<Value>,
796    /// Result payload returned by the tool, if captured.
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub result: Option<String>,
799    /// Lifecycle status for the tool call.
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub status: Option<McpToolCallStatus>,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
805#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
806#[serde(rename_all = "snake_case")]
807pub enum McpToolCallStatus {
808    /// Tool invocation has started.
809    Started,
810    /// Tool invocation completed successfully.
811    Completed,
812    /// Tool invocation failed.
813    Failed,
814}
815
816#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
817#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
818pub struct WebSearchItem {
819    /// Query that triggered the search.
820    pub query: String,
821    /// Search provider identifier, when known.
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub provider: Option<String>,
824    /// Optional raw search results captured for auditing.
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub results: Option<Vec<String>>,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
831#[serde(rename_all = "snake_case")]
832pub enum HarnessEventKind {
833    PlanningStarted,
834    PlanningCompleted,
835    ContinuationStarted,
836    ContinuationSkipped,
837    BlockedHandoffWritten,
838    EvaluationStarted,
839    EvaluationPassed,
840    EvaluationFailed,
841    RevisionStarted,
842    EscalationTriggered,
843    EscalationBypassed,
844    VerificationStarted,
845    VerificationPassed,
846    VerificationFailed,
847    /// Agent recovered from a transient error (e.g. after retry succeeded).
848    ErrorRecovered,
849    /// A transient tool failure triggered an automatic retry attempt.
850    ToolRetryAttempted,
851    /// Latency record for a tool execution, emitted on turn completion.
852    ToolLatencyRecorded,
853    /// A checkpoint snapshot was created for the current turn.
854    SnapshotCreated,
855    /// A checkpoint snapshot was restored (rewind operation).
856    SnapshotRestored,
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
861pub struct HarnessEventItem {
862    /// Specific harness event emitted by the runtime.
863    pub event: HarnessEventKind,
864    /// Optional human-readable message associated with the event.
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub message: Option<String>,
867    /// Optional verification command associated with the event.
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub command: Option<String>,
870    /// Optional artifact path associated with the event.
871    #[serde(skip_serializing_if = "Option::is_none")]
872    pub path: Option<String>,
873    /// Optional exit code associated with verification results.
874    #[serde(skip_serializing_if = "Option::is_none")]
875    pub exit_code: Option<i32>,
876    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub attempt: Option<u32>,
879    /// Canonical error category for retry/recovery events.
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pub error_category: Option<String>,
882    /// Latency in milliseconds for tool-execution latency events.
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub duration_ms: Option<u64>,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
888#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
889pub struct ErrorItem {
890    /// Error message displayed to the user or logs.
891    pub message: String,
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use std::error::Error;
898
899    #[test]
900    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
901        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
902            usage: Usage {
903                input_tokens: 1,
904                cached_input_tokens: 2,
905                cache_creation_tokens: 0,
906                output_tokens: 3,
907            },
908        });
909
910        let json = serde_json::to_string(&event)?;
911        let restored: ThreadEvent = serde_json::from_str(&json)?;
912
913        assert_eq!(restored, event);
914        Ok(())
915    }
916
917    #[test]
918    fn versioned_event_wraps_schema_version() {
919        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
920            thread_id: "abc".to_string(),
921        });
922
923        let versioned = VersionedThreadEvent::new(event.clone());
924
925        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
926        assert_eq!(versioned.event, event);
927        assert_eq!(versioned.into_event(), event);
928    }
929
930    #[cfg(feature = "serde-json")]
931    #[test]
932    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
933        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
934            item: ThreadItem {
935                id: "item-1".to_string(),
936                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
937                    text: "hello".to_string(),
938                }),
939            },
940        });
941
942        let payload = json::versioned_to_string(&event)?;
943        let restored = json::versioned_from_str(&payload)?;
944
945        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
946        assert_eq!(restored.event, event);
947        Ok(())
948    }
949
950    #[test]
951    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
952        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
953            item: ThreadItem {
954                id: "tool_1".to_string(),
955                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
956                    tool_name: "read_file".to_string(),
957                    arguments: Some(serde_json::json!({ "path": "README.md" })),
958                    tool_call_id: Some("tool_call_0".to_string()),
959                    status: ToolCallStatus::Completed,
960                }),
961            },
962        });
963
964        let json = serde_json::to_string(&event)?;
965        let restored: ThreadEvent = serde_json::from_str(&json)?;
966
967        assert_eq!(restored, event);
968        Ok(())
969    }
970
971    #[test]
972    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
973        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
974            item: ThreadItem {
975                id: "tool_1:output".to_string(),
976                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
977                    call_id: "tool_1".to_string(),
978                    tool_call_id: Some("tool_call_0".to_string()),
979                    spool_path: None,
980                    output: "done".to_string(),
981                    exit_code: Some(0),
982                    status: ToolCallStatus::Completed,
983                }),
984            },
985        });
986
987        let json = serde_json::to_string(&event)?;
988        let restored: ThreadEvent = serde_json::from_str(&json)?;
989
990        assert_eq!(restored, event);
991        Ok(())
992    }
993
994    #[test]
995    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
996        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
997            item: ThreadItem {
998                id: "harness_1".to_string(),
999                details: ThreadItemDetails::Harness(HarnessEventItem {
1000                    event: HarnessEventKind::VerificationFailed,
1001                    message: Some("cargo check failed".to_string()),
1002                    command: Some("cargo check".to_string()),
1003                    path: None,
1004                    exit_code: Some(101),
1005                    attempt: None,
1006                    error_category: None,
1007                    duration_ms: None,
1008                }),
1009            },
1010        });
1011
1012        let json = serde_json::to_string(&event)?;
1013        let restored: ThreadEvent = serde_json::from_str(&json)?;
1014
1015        assert_eq!(restored, event);
1016        Ok(())
1017    }
1018
1019    #[test]
1020    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1021        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1022            thread_id: "thread-1".to_string(),
1023            session_id: "session-1".to_string(),
1024            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1025            outcome_code: "budget_limit_reached".to_string(),
1026            result: None,
1027            stop_reason: Some("max_tokens".to_string()),
1028            usage: Usage {
1029                input_tokens: 10,
1030                cached_input_tokens: 4,
1031                cache_creation_tokens: 2,
1032                output_tokens: 5,
1033            },
1034            total_cost_usd: serde_json::Number::from_f64(1.25),
1035            num_turns: 3,
1036        });
1037
1038        let json = serde_json::to_string(&event)?;
1039        let restored: ThreadEvent = serde_json::from_str(&json)?;
1040
1041        assert_eq!(restored, event);
1042        Ok(())
1043    }
1044
1045    #[test]
1046    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1047        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1048            thread_id: "thread-1".to_string(),
1049            trigger: CompactionTrigger::Recovery,
1050            mode: CompactionMode::Provider,
1051            original_message_count: 12,
1052            compacted_message_count: 5,
1053            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1054        });
1055
1056        let json = serde_json::to_string(&event)?;
1057        let restored: ThreadEvent = serde_json::from_str(&json)?;
1058
1059        assert_eq!(restored, event);
1060        Ok(())
1061    }
1062}