Skip to main content

vtcode_exec_events/
lib.rs

1//! Structured execution telemetry events shared across VT Code crates.
2//!
3//! This crate exposes the serialized schema for thread lifecycle updates,
4//! command execution results, and other timeline artifacts emitted by the
5//! automation runtime. Downstream applications can deserialize these
6//! structures to drive dashboards, logging, or auditing pipelines without
7//! depending on the full `vtcode-core` crate.
8//!
9//! # Agent Trace Support
10//!
11//! This crate implements the [Agent Trace](https://agent-trace.dev/) specification
12//! for tracking AI-generated code attribution. See the [`trace`] module for details.
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub mod atif;
18pub mod trace;
19
20/// Semantic version of the serialized event schema exported by this crate.
21pub const EVENT_SCHEMA_VERSION: &str = "0.7.0";
22
23/// Wraps a [`ThreadEvent`] with schema metadata so downstream consumers can
24/// negotiate compatibility before processing an event stream.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
27pub struct VersionedThreadEvent {
28    /// Semantic version describing the schema of the nested event payload.
29    pub schema_version: String,
30    /// Concrete event emitted by the agent runtime.
31    pub event: ThreadEvent,
32}
33
34impl VersionedThreadEvent {
35    /// Creates a new [`VersionedThreadEvent`] using the current
36    /// [`EVENT_SCHEMA_VERSION`].
37    pub fn new(event: ThreadEvent) -> Self {
38        Self {
39            schema_version: EVENT_SCHEMA_VERSION.to_string(),
40            event,
41        }
42    }
43
44    /// Returns the nested [`ThreadEvent`], consuming the wrapper.
45    pub fn into_event(self) -> ThreadEvent {
46        self.event
47    }
48}
49
50impl From<ThreadEvent> for VersionedThreadEvent {
51    fn from(event: ThreadEvent) -> Self {
52        Self::new(event)
53    }
54}
55
56/// Sink for processing [`ThreadEvent`] instances.
57pub trait EventEmitter {
58    /// Invoked for each event emitted by the automation runtime.
59    fn emit(&mut self, event: &ThreadEvent);
60}
61
62impl<F> EventEmitter for F
63where
64    F: FnMut(&ThreadEvent),
65{
66    fn emit(&mut self, event: &ThreadEvent) {
67        self(event);
68    }
69}
70
71/// JSON helper utilities for serializing and deserializing thread events.
72#[cfg(feature = "serde-json")]
73pub mod json {
74    use super::{ThreadEvent, VersionedThreadEvent};
75
76    /// Converts an event into a `serde_json::Value`.
77    pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
78        serde_json::to_value(event)
79    }
80
81    /// Serializes an event into a JSON string.
82    pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
83        serde_json::to_string(event)
84    }
85
86    /// Deserializes an event from a JSON string.
87    pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
88        serde_json::from_str(payload)
89    }
90
91    /// Serializes a [`VersionedThreadEvent`] wrapper.
92    pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
93        serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
94    }
95
96    /// Deserializes a [`VersionedThreadEvent`] wrapper.
97    pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
98        serde_json::from_str(payload)
99    }
100}
101
102#[cfg(feature = "telemetry-log")]
103mod log_support {
104    use log::Level;
105
106    use super::{EventEmitter, ThreadEvent, json};
107
108    /// Emits JSON serialized events to the `log` facade at the configured level.
109    #[derive(Debug, Clone)]
110    pub struct LogEmitter {
111        level: Level,
112    }
113
114    impl LogEmitter {
115        /// Creates a new [`LogEmitter`] that logs at the provided [`Level`].
116        pub fn new(level: Level) -> Self {
117            Self { level }
118        }
119    }
120
121    impl Default for LogEmitter {
122        fn default() -> Self {
123            Self { level: Level::Info }
124        }
125    }
126
127    impl EventEmitter for LogEmitter {
128        fn emit(&mut self, event: &ThreadEvent) {
129            if log::log_enabled!(self.level) {
130                match json::to_string(event) {
131                    Ok(serialized) => log::log!(self.level, "{}", serialized),
132                    Err(err) => log::log!(
133                        self.level,
134                        "failed to serialize vtcode exec event for logging: {err}"
135                    ),
136                }
137            }
138        }
139    }
140
141    pub use LogEmitter as PublicLogEmitter;
142}
143
144#[cfg(feature = "telemetry-log")]
145pub use log_support::PublicLogEmitter as LogEmitter;
146
147#[cfg(feature = "telemetry-tracing")]
148mod tracing_support {
149    use tracing::Level;
150
151    use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
152
153    /// Emits structured events as `tracing` events at the specified level.
154    #[derive(Debug, Clone)]
155    pub struct TracingEmitter {
156        level: Level,
157    }
158
159    impl TracingEmitter {
160        /// Creates a new [`TracingEmitter`] with the provided [`Level`].
161        pub fn new(level: Level) -> Self {
162            Self { level }
163        }
164    }
165
166    impl Default for TracingEmitter {
167        fn default() -> Self {
168            Self { level: Level::INFO }
169        }
170    }
171
172    impl EventEmitter for TracingEmitter {
173        fn emit(&mut self, event: &ThreadEvent) {
174            match self.level {
175                Level::TRACE => tracing::event!(
176                    target: "vtcode_exec_events",
177                    Level::TRACE,
178                    schema_version = EVENT_SCHEMA_VERSION,
179                    event = ?VersionedThreadEvent::new(event.clone()),
180                    "vtcode_exec_event"
181                ),
182                Level::DEBUG => tracing::event!(
183                    target: "vtcode_exec_events",
184                    Level::DEBUG,
185                    schema_version = EVENT_SCHEMA_VERSION,
186                    event = ?VersionedThreadEvent::new(event.clone()),
187                    "vtcode_exec_event"
188                ),
189                Level::INFO => tracing::event!(
190                    target: "vtcode_exec_events",
191                    Level::INFO,
192                    schema_version = EVENT_SCHEMA_VERSION,
193                    event = ?VersionedThreadEvent::new(event.clone()),
194                    "vtcode_exec_event"
195                ),
196                Level::WARN => tracing::event!(
197                    target: "vtcode_exec_events",
198                    Level::WARN,
199                    schema_version = EVENT_SCHEMA_VERSION,
200                    event = ?VersionedThreadEvent::new(event.clone()),
201                    "vtcode_exec_event"
202                ),
203                Level::ERROR => tracing::event!(
204                    target: "vtcode_exec_events",
205                    Level::ERROR,
206                    schema_version = EVENT_SCHEMA_VERSION,
207                    event = ?VersionedThreadEvent::new(event.clone()),
208                    "vtcode_exec_event"
209                ),
210            }
211        }
212    }
213
214    pub use TracingEmitter as PublicTracingEmitter;
215}
216
217#[cfg(feature = "telemetry-tracing")]
218pub use tracing_support::PublicTracingEmitter as TracingEmitter;
219
220#[cfg(feature = "telemetry-otel")]
221mod otel_support {
222    use opentelemetry::KeyValue;
223    use opentelemetry::trace::{Span, Status, Tracer};
224
225    use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
226
227    /// Emits [`ThreadEvent`]s as OpenTelemetry spans and span events.
228    ///
229    /// Each `ThreadEvent` is recorded as an OTel span with attributes derived
230    /// from the event payload.  Harness events are attached as span events
231    /// with their own attributes (event kind, message, path, etc.).
232    ///
233    /// # Usage
234    ///
235    /// ```rust,no_run
236    /// use vtcode_exec_events::OtelEmitter;
237    /// use opentelemetry::trace::TracerProvider;
238    ///
239    /// let provider = TracerProvider::default();
240    /// let tracer = provider.tracer("vtcode");
241    /// let mut emitter = OtelEmitter::new(tracer);
242    /// ```
243    pub struct OtelEmitter<T: Tracer> {
244        tracer: T,
245    }
246
247    impl<T: Tracer> OtelEmitter<T> {
248        pub fn new(tracer: T) -> Self {
249            Self { tracer }
250        }
251    }
252
253    impl<T: Tracer> EventEmitter for OtelEmitter<T> {
254        fn emit(&mut self, event: &ThreadEvent) {
255            let span_name = match event {
256                ThreadEvent::ThreadStarted(_) => "thread.started",
257                ThreadEvent::ThreadCompleted(_) => "thread.completed",
258                ThreadEvent::TurnStarted(_) => "turn.started",
259                ThreadEvent::TurnCompleted(_) => "turn.completed",
260                ThreadEvent::TurnFailed(_) => "turn.failed",
261                ThreadEvent::ItemStarted(_) => "item.started",
262                ThreadEvent::ItemUpdated(_) => "item.updated",
263                ThreadEvent::ItemCompleted(_) => "item.completed",
264                ThreadEvent::Error(_) => "error",
265                _ => "event",
266            };
267
268            let mut span = self.tracer.start(span_name);
269
270            match event {
271                ThreadEvent::ThreadStarted(e) => {
272                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
273                }
274                ThreadEvent::ThreadCompleted(e) => {
275                    if let Some(ref cost) = e.total_cost_usd {
276                        span.set_attribute(KeyValue::new(
277                            "total_cost_usd",
278                            cost.as_f64().unwrap_or(0.0),
279                        ));
280                    }
281                    span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
282                    span.set_attribute(KeyValue::new(
283                        "output_tokens",
284                        e.usage.output_tokens as i64,
285                    ));
286                    span.set_attribute(KeyValue::new(
287                        "completion_subtype",
288                        e.subtype.as_str().to_string(),
289                    ));
290                }
291                ThreadEvent::TurnCompleted(e) => {
292                    span.set_attribute(KeyValue::new(
293                        "turn_input_tokens",
294                        e.usage.input_tokens as i64,
295                    ));
296                    span.set_attribute(KeyValue::new(
297                        "turn_output_tokens",
298                        e.usage.output_tokens as i64,
299                    ));
300                }
301                ThreadEvent::ItemCompleted(e) => {
302                    if let ThreadItemDetails::Harness(harness) = &e.item.details {
303                        span.set_attribute(KeyValue::new(
304                            "harness_event",
305                            format!("{:?}", harness.event),
306                        ));
307                        if let Some(ref msg) = harness.message {
308                            span.set_attribute(KeyValue::new("harness_message", msg.clone()));
309                        }
310                        if let Some(ref path) = harness.path {
311                            span.set_attribute(KeyValue::new("harness_path", path.clone()));
312                        }
313                        if let Some(dur) = harness.duration_ms {
314                            span.set_attribute(KeyValue::new("duration_ms", dur as i64));
315                        }
316                        let mut event_attrs =
317                            vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
318                        if let Some(ref msg) = harness.message {
319                            event_attrs.push(KeyValue::new("message", msg.clone()));
320                        }
321                        span.add_event("harness_event", event_attrs);
322                    }
323                }
324                ThreadEvent::Error(e) => {
325                    span.set_status(Status::Error {
326                        description: e.message.clone().into(),
327                    });
328                    span.set_attribute(KeyValue::new("error_message", e.message.clone()));
329                }
330                _ => {}
331            }
332
333            span.end();
334        }
335    }
336
337    pub use OtelEmitter as PublicOtelEmitter;
338}
339
340#[cfg(feature = "telemetry-otel")]
341pub use otel_support::PublicOtelEmitter as OtelEmitter;
342
343#[cfg(feature = "schema-export")]
344pub mod schema {
345    use schemars::{Schema, schema_for};
346
347    use super::{ThreadEvent, VersionedThreadEvent};
348
349    /// Generates a JSON Schema describing [`ThreadEvent`].
350    pub fn thread_event_schema() -> Schema {
351        schema_for!(ThreadEvent)
352    }
353
354    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
355    pub fn versioned_thread_event_schema() -> Schema {
356        schema_for!(VersionedThreadEvent)
357    }
358}
359
360/// Structured events emitted during autonomous execution.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
363#[serde(tag = "type")]
364pub enum ThreadEvent {
365    /// Indicates that a new execution thread has started.
366    #[serde(rename = "thread.started")]
367    ThreadStarted(ThreadStartedEvent),
368    /// Indicates that an execution thread has reached a terminal outcome.
369    #[serde(rename = "thread.completed")]
370    ThreadCompleted(ThreadCompletedEvent),
371    /// Indicates that conversation compaction replaced older history with a boundary.
372    #[serde(rename = "thread.compact_boundary")]
373    ThreadCompactBoundary(ThreadCompactBoundaryEvent),
374    /// Marks the beginning of an execution turn.
375    #[serde(rename = "turn.started")]
376    TurnStarted(TurnStartedEvent),
377    /// Marks the completion of an execution turn.
378    #[serde(rename = "turn.completed")]
379    TurnCompleted(TurnCompletedEvent),
380    /// Marks a turn as failed with additional context.
381    #[serde(rename = "turn.failed")]
382    TurnFailed(TurnFailedEvent),
383    /// Indicates that an item has started processing.
384    #[serde(rename = "item.started")]
385    ItemStarted(ItemStartedEvent),
386    /// Indicates that an item has been updated.
387    #[serde(rename = "item.updated")]
388    ItemUpdated(ItemUpdatedEvent),
389    /// Indicates that an item reached a terminal state.
390    #[serde(rename = "item.completed")]
391    ItemCompleted(ItemCompletedEvent),
392    /// Streaming delta for a plan item in Planning workflow.
393    #[serde(rename = "plan.delta")]
394    PlanDelta(PlanDeltaEvent),
395    /// Represents a fatal error.
396    #[serde(rename = "error")]
397    Error(ThreadErrorEvent),
398    /// Catch-all for unknown event types added in newer schema versions.
399    /// Preserves forward compatibility when older binaries read newer event streams.
400    #[serde(other)]
401    Unknown,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
405#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
406pub struct ThreadStartedEvent {
407    /// Unique identifier for the thread that was started.
408    pub thread_id: String,
409}
410
411#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
413#[serde(rename_all = "snake_case")]
414pub enum ThreadCompletionSubtype {
415    Success,
416    ErrorMaxTurns,
417    ErrorMaxBudgetUsd,
418    ErrorDuringExecution,
419    Cancelled,
420    /// Catch-all for unknown completion subtypes added in newer schema versions.
421    #[serde(other)]
422    Unknown,
423}
424
425impl ThreadCompletionSubtype {
426    pub const fn as_str(&self) -> &'static str {
427        match self {
428            Self::Success => "success",
429            Self::ErrorMaxTurns => "error_max_turns",
430            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
431            Self::ErrorDuringExecution => "error_during_execution",
432            Self::Cancelled => "cancelled",
433            Self::Unknown => "unknown",
434        }
435    }
436
437    pub const fn is_success(self) -> bool {
438        matches!(self, Self::Success)
439    }
440}
441
442#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
443#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
444#[serde(rename_all = "snake_case")]
445pub enum CompactionTrigger {
446    Manual,
447    Auto,
448    Recovery,
449    /// Catch-all for unknown triggers added in newer schema versions.
450    #[serde(other)]
451    Unknown,
452}
453
454impl CompactionTrigger {
455    pub const fn as_str(self) -> &'static str {
456        match self {
457            Self::Manual => "manual",
458            Self::Auto => "auto",
459            Self::Recovery => "recovery",
460            Self::Unknown => "unknown",
461        }
462    }
463}
464
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
466#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
467#[serde(rename_all = "snake_case")]
468pub enum CompactionMode {
469    Provider,
470    Local,
471    /// Catch-all for unknown modes added in newer schema versions.
472    #[serde(other)]
473    Unknown,
474}
475
476impl CompactionMode {
477    pub const fn as_str(self) -> &'static str {
478        match self {
479            Self::Provider => "provider",
480            Self::Local => "local",
481            Self::Unknown => "unknown",
482        }
483    }
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
488pub struct ThreadCompletedEvent {
489    /// Stable thread identifier for the session.
490    pub thread_id: String,
491    /// Stable session identifier for the runtime that produced the thread.
492    pub session_id: String,
493    /// Coarse result category aligned with SDK-style terminal states.
494    pub subtype: ThreadCompletionSubtype,
495    /// VT Code-specific detailed outcome code.
496    pub outcome_code: String,
497    /// Final assistant result text when the thread completed successfully.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub result: Option<String>,
500    /// Provider stop reason or VT Code terminal reason when available.
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub stop_reason: Option<String>,
503    /// Aggregated token usage across the thread.
504    pub usage: Usage,
505    /// Optional estimated total API cost for the thread.
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub total_cost_usd: Option<serde_json::Number>,
508    /// Number of turns executed before completion.
509    pub num_turns: usize,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
514pub struct ThreadCompactBoundaryEvent {
515    /// Stable thread identifier for the session.
516    pub thread_id: String,
517    /// Whether compaction was triggered manually or automatically.
518    pub trigger: CompactionTrigger,
519    /// Whether the compaction boundary came from provider-native or local compaction.
520    pub mode: CompactionMode,
521    /// Number of messages before compaction.
522    pub original_message_count: usize,
523    /// Number of messages after compaction.
524    pub compacted_message_count: usize,
525    /// Optional persisted artifact containing the archived compaction summary/history.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub history_artifact_path: Option<String>,
528}
529
530#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
531#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
532pub struct TurnStartedEvent {}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
536pub struct TurnCompletedEvent {
537    /// Token usage summary for the completed turn.
538    pub usage: Usage,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
543pub struct TurnFailedEvent {
544    /// Human-readable explanation describing why the turn failed.
545    pub message: String,
546    /// Optional token usage that was consumed before the failure occurred.
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub usage: Option<Usage>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
552#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
553pub struct ThreadErrorEvent {
554    /// Fatal error message associated with the thread.
555    pub message: String,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
560pub struct Usage {
561    /// Number of prompt tokens processed during the turn.
562    pub input_tokens: u64,
563    /// Number of cached prompt tokens reused from previous turns.
564    pub cached_input_tokens: u64,
565    /// Number of cache-creation tokens charged during the turn.
566    pub cache_creation_tokens: u64,
567    /// Number of completion tokens generated by the model.
568    pub output_tokens: u64,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
573pub struct ItemCompletedEvent {
574    /// Snapshot of the thread item that completed.
575    pub item: ThreadItem,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
579#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
580pub struct ItemStartedEvent {
581    /// Snapshot of the thread item that began processing.
582    pub item: ThreadItem,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
587pub struct ItemUpdatedEvent {
588    /// Snapshot of the thread item after it was updated.
589    pub item: ThreadItem,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
593#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
594pub struct PlanDeltaEvent {
595    /// Identifier of the thread emitting this plan delta.
596    pub thread_id: String,
597    /// Identifier of the current turn.
598    pub turn_id: String,
599    /// Identifier of the plan item receiving the delta.
600    pub item_id: String,
601    /// Incremental plan text chunk.
602    pub delta: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
607pub struct ThreadItem {
608    /// Stable identifier associated with the item.
609    pub id: String,
610    /// Embedded event details for the item type.
611    #[serde(flatten)]
612    pub details: ThreadItemDetails,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
616#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
617#[serde(tag = "type", rename_all = "snake_case")]
618pub enum ThreadItemDetails {
619    /// Message authored by the agent.
620    AgentMessage(AgentMessageItem),
621    /// Structured plan content authored by the agent in Planning workflow.
622    Plan(PlanItem),
623    /// Free-form reasoning text produced during a turn.
624    Reasoning(ReasoningItem),
625    /// Command execution lifecycle update for an actual shell/PTY process.
626    CommandExecution(Box<CommandExecutionItem>),
627    /// Tool invocation lifecycle update.
628    ToolInvocation(ToolInvocationItem),
629    /// Tool output lifecycle update tied to a tool invocation.
630    ToolOutput(ToolOutputItem),
631    /// File change summary associated with the turn.
632    FileChange(Box<FileChangeItem>),
633    /// MCP tool invocation status.
634    McpToolCall(McpToolCallItem),
635    /// Web search event emitted by a registered search provider.
636    WebSearch(WebSearchItem),
637    /// Harness-managed continuation or verification lifecycle event.
638    Harness(HarnessEventItem),
639    /// General error captured for auditing.
640    Error(ErrorItem),
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
644#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
645pub struct AgentMessageItem {
646    /// Textual content of the agent message.
647    pub text: String,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
652pub struct PlanItem {
653    /// Plan markdown content.
654    pub text: String,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
659pub struct ReasoningItem {
660    /// Free-form reasoning content captured during planning.
661    pub text: String,
662    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub stage: Option<String>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
668#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
669#[serde(rename_all = "snake_case")]
670pub enum CommandExecutionStatus {
671    /// Command finished successfully.
672    #[default]
673    Completed,
674    /// Command failed (non-zero exit code or runtime error).
675    Failed,
676    /// Command is still running and may emit additional output.
677    InProgress,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
682pub struct CommandExecutionItem {
683    /// Tool or command identifier executed by the runner.
684    pub command: String,
685    /// Arguments passed to the tool invocation, when available.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub arguments: Option<Value>,
688    /// Aggregated output emitted by the command.
689    #[serde(default)]
690    pub aggregated_output: String,
691    /// Exit code reported by the process, when available.
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub exit_code: Option<i32>,
694    /// Current status of the command execution.
695    pub status: CommandExecutionStatus,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
700#[serde(rename_all = "snake_case")]
701pub enum ToolCallStatus {
702    /// Tool finished successfully.
703    #[default]
704    Completed,
705    /// Tool failed.
706    Failed,
707    /// Tool is still running and may emit additional output.
708    InProgress,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct ToolInvocationItem {
714    /// Name of the invoked tool.
715    pub tool_name: String,
716    /// Structured arguments passed to the tool.
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub arguments: Option<Value>,
719    /// Raw model-emitted tool call identifier, when available.
720    #[serde(skip_serializing_if = "Option::is_none")]
721    pub tool_call_id: Option<String>,
722    /// Current lifecycle status of the invocation.
723    pub status: ToolCallStatus,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
727#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
728pub struct ToolOutputItem {
729    /// Identifier of the related harness invocation item.
730    pub call_id: String,
731    /// Raw model-emitted tool call identifier, when available.
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub tool_call_id: Option<String>,
734    /// Canonical spool file path when the full output was written to disk.
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub spool_path: Option<String>,
737    /// Aggregated output emitted by the tool.
738    #[serde(default)]
739    pub output: String,
740    /// Exit code reported by the tool, when available.
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub exit_code: Option<i32>,
743    /// Current lifecycle status of the output item.
744    pub status: ToolCallStatus,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
748#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
749pub struct FileChangeItem {
750    /// List of individual file updates included in the change set.
751    pub changes: Vec<FileUpdateChange>,
752    /// Whether the patch application succeeded.
753    pub status: PatchApplyStatus,
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
757#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
758pub struct FileUpdateChange {
759    /// Path of the file that was updated.
760    pub path: String,
761    /// Type of change applied to the file.
762    pub kind: PatchChangeKind,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
766#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
767#[serde(rename_all = "snake_case")]
768pub enum PatchApplyStatus {
769    /// Patch successfully applied.
770    Completed,
771    /// Patch application failed.
772    Failed,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777#[serde(rename_all = "snake_case")]
778pub enum PatchChangeKind {
779    /// File addition.
780    Add,
781    /// File deletion.
782    Delete,
783    /// File update in place.
784    Update,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
789pub struct McpToolCallItem {
790    /// Name of the MCP tool invoked by the agent.
791    pub tool_name: String,
792    /// Arguments passed to the tool invocation, if any.
793    #[serde(skip_serializing_if = "Option::is_none")]
794    pub arguments: Option<Value>,
795    /// Result payload returned by the tool, if captured.
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub result: Option<String>,
798    /// Lifecycle status for the tool call.
799    #[serde(skip_serializing_if = "Option::is_none")]
800    pub status: Option<McpToolCallStatus>,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
804#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
805#[serde(rename_all = "snake_case")]
806pub enum McpToolCallStatus {
807    /// Tool invocation has started.
808    Started,
809    /// Tool invocation completed successfully.
810    Completed,
811    /// Tool invocation failed.
812    Failed,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
817pub struct WebSearchItem {
818    /// Query that triggered the search.
819    pub query: String,
820    /// Search provider identifier, when known.
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub provider: Option<String>,
823    /// Optional raw search results captured for auditing.
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub results: Option<Vec<String>>,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
830#[serde(rename_all = "snake_case")]
831pub enum HarnessEventKind {
832    PlanningStarted,
833    PlanningCompleted,
834    ContinuationStarted,
835    ContinuationSkipped,
836    BlockedHandoffWritten,
837    EvaluationStarted,
838    EvaluationPassed,
839    EvaluationFailed,
840    RevisionStarted,
841    EscalationTriggered,
842    EscalationBypassed,
843    VerificationStarted,
844    VerificationPassed,
845    VerificationFailed,
846    /// Agent recovered from a transient error (e.g. after retry succeeded).
847    ErrorRecovered,
848    /// A transient tool failure triggered an automatic retry attempt.
849    ToolRetryAttempted,
850    /// Latency record for a tool execution, emitted on turn completion.
851    ToolLatencyRecorded,
852    /// A checkpoint snapshot was created for the current turn.
853    SnapshotCreated,
854    /// A checkpoint snapshot was restored (rewind operation).
855    SnapshotRestored,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
860pub struct HarnessEventItem {
861    /// Specific harness event emitted by the runtime.
862    pub event: HarnessEventKind,
863    /// Optional human-readable message associated with the event.
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub message: Option<String>,
866    /// Optional verification command associated with the event.
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub command: Option<String>,
869    /// Optional artifact path associated with the event.
870    #[serde(skip_serializing_if = "Option::is_none")]
871    pub path: Option<String>,
872    /// Optional exit code associated with verification results.
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub exit_code: Option<i32>,
875    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub attempt: Option<u32>,
878    /// Canonical error category for retry/recovery events.
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub error_category: Option<String>,
881    /// Latency in milliseconds for tool-execution latency events.
882    #[serde(skip_serializing_if = "Option::is_none")]
883    pub duration_ms: Option<u64>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
887#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
888pub struct ErrorItem {
889    /// Error message displayed to the user or logs.
890    pub message: String,
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use std::error::Error;
897
898    #[test]
899    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
900        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
901            usage: Usage {
902                input_tokens: 1,
903                cached_input_tokens: 2,
904                cache_creation_tokens: 0,
905                output_tokens: 3,
906            },
907        });
908
909        let json = serde_json::to_string(&event)?;
910        let restored: ThreadEvent = serde_json::from_str(&json)?;
911
912        assert_eq!(restored, event);
913        Ok(())
914    }
915
916    #[test]
917    fn versioned_event_wraps_schema_version() {
918        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
919            thread_id: "abc".to_string(),
920        });
921
922        let versioned = VersionedThreadEvent::new(event.clone());
923
924        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
925        assert_eq!(versioned.event, event);
926        assert_eq!(versioned.into_event(), event);
927    }
928
929    #[cfg(feature = "serde-json")]
930    #[test]
931    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
932        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
933            item: ThreadItem {
934                id: "item-1".to_string(),
935                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
936                    text: "hello".to_string(),
937                }),
938            },
939        });
940
941        let payload = json::versioned_to_string(&event)?;
942        let restored = json::versioned_from_str(&payload)?;
943
944        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
945        assert_eq!(restored.event, event);
946        Ok(())
947    }
948
949    #[test]
950    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
951        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
952            item: ThreadItem {
953                id: "tool_1".to_string(),
954                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
955                    tool_name: "read_file".to_string(),
956                    arguments: Some(serde_json::json!({ "path": "README.md" })),
957                    tool_call_id: Some("tool_call_0".to_string()),
958                    status: ToolCallStatus::Completed,
959                }),
960            },
961        });
962
963        let json = serde_json::to_string(&event)?;
964        let restored: ThreadEvent = serde_json::from_str(&json)?;
965
966        assert_eq!(restored, event);
967        Ok(())
968    }
969
970    #[test]
971    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
972        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
973            item: ThreadItem {
974                id: "tool_1:output".to_string(),
975                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
976                    call_id: "tool_1".to_string(),
977                    tool_call_id: Some("tool_call_0".to_string()),
978                    spool_path: None,
979                    output: "done".to_string(),
980                    exit_code: Some(0),
981                    status: ToolCallStatus::Completed,
982                }),
983            },
984        });
985
986        let json = serde_json::to_string(&event)?;
987        let restored: ThreadEvent = serde_json::from_str(&json)?;
988
989        assert_eq!(restored, event);
990        Ok(())
991    }
992
993    #[test]
994    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
995        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
996            item: ThreadItem {
997                id: "harness_1".to_string(),
998                details: ThreadItemDetails::Harness(HarnessEventItem {
999                    event: HarnessEventKind::VerificationFailed,
1000                    message: Some("cargo check failed".to_string()),
1001                    command: Some("cargo check".to_string()),
1002                    path: None,
1003                    exit_code: Some(101),
1004                    attempt: None,
1005                    error_category: None,
1006                }),
1007            },
1008        });
1009
1010        let json = serde_json::to_string(&event)?;
1011        let restored: ThreadEvent = serde_json::from_str(&json)?;
1012
1013        assert_eq!(restored, event);
1014        Ok(())
1015    }
1016
1017    #[test]
1018    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1019        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1020            thread_id: "thread-1".to_string(),
1021            session_id: "session-1".to_string(),
1022            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1023            outcome_code: "budget_limit_reached".to_string(),
1024            result: None,
1025            stop_reason: Some("max_tokens".to_string()),
1026            usage: Usage {
1027                input_tokens: 10,
1028                cached_input_tokens: 4,
1029                cache_creation_tokens: 2,
1030                output_tokens: 5,
1031            },
1032            total_cost_usd: serde_json::Number::from_f64(1.25),
1033            num_turns: 3,
1034        });
1035
1036        let json = serde_json::to_string(&event)?;
1037        let restored: ThreadEvent = serde_json::from_str(&json)?;
1038
1039        assert_eq!(restored, event);
1040        Ok(())
1041    }
1042
1043    #[test]
1044    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1045        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1046            thread_id: "thread-1".to_string(),
1047            trigger: CompactionTrigger::Recovery,
1048            mode: CompactionMode::Provider,
1049            original_message_count: 12,
1050            compacted_message_count: 5,
1051            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1052        });
1053
1054        let json = serde_json::to_string(&event)?;
1055        let restored: ThreadEvent = serde_json::from_str(&json)?;
1056
1057        assert_eq!(restored, event);
1058        Ok(())
1059    }
1060}