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.4.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 = "schema-export")]
221pub mod schema {
222    use schemars::{Schema, schema_for};
223
224    use super::{ThreadEvent, VersionedThreadEvent};
225
226    /// Generates a JSON Schema describing [`ThreadEvent`].
227    pub fn thread_event_schema() -> Schema {
228        schema_for!(ThreadEvent)
229    }
230
231    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
232    pub fn versioned_thread_event_schema() -> Schema {
233        schema_for!(VersionedThreadEvent)
234    }
235}
236
237/// Structured events emitted during autonomous execution.
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
240#[serde(tag = "type")]
241pub enum ThreadEvent {
242    /// Indicates that a new execution thread has started.
243    #[serde(rename = "thread.started")]
244    ThreadStarted(ThreadStartedEvent),
245    /// Indicates that an execution thread has reached a terminal outcome.
246    #[serde(rename = "thread.completed")]
247    ThreadCompleted(ThreadCompletedEvent),
248    /// Indicates that conversation compaction replaced older history with a boundary.
249    #[serde(rename = "thread.compact_boundary")]
250    ThreadCompactBoundary(ThreadCompactBoundaryEvent),
251    /// Marks the beginning of an execution turn.
252    #[serde(rename = "turn.started")]
253    TurnStarted(TurnStartedEvent),
254    /// Marks the completion of an execution turn.
255    #[serde(rename = "turn.completed")]
256    TurnCompleted(TurnCompletedEvent),
257    /// Marks a turn as failed with additional context.
258    #[serde(rename = "turn.failed")]
259    TurnFailed(TurnFailedEvent),
260    /// Indicates that an item has started processing.
261    #[serde(rename = "item.started")]
262    ItemStarted(ItemStartedEvent),
263    /// Indicates that an item has been updated.
264    #[serde(rename = "item.updated")]
265    ItemUpdated(ItemUpdatedEvent),
266    /// Indicates that an item reached a terminal state.
267    #[serde(rename = "item.completed")]
268    ItemCompleted(ItemCompletedEvent),
269    /// Streaming delta for a plan item in Plan Mode.
270    #[serde(rename = "plan.delta")]
271    PlanDelta(PlanDeltaEvent),
272    /// Represents a fatal error.
273    #[serde(rename = "error")]
274    Error(ThreadErrorEvent),
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
278#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
279pub struct ThreadStartedEvent {
280    /// Unique identifier for the thread that was started.
281    pub thread_id: String,
282}
283
284#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
285#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
286#[serde(rename_all = "snake_case")]
287pub enum ThreadCompletionSubtype {
288    Success,
289    ErrorMaxTurns,
290    ErrorMaxBudgetUsd,
291    ErrorDuringExecution,
292    Cancelled,
293}
294
295impl ThreadCompletionSubtype {
296    pub const fn as_str(&self) -> &'static str {
297        match self {
298            Self::Success => "success",
299            Self::ErrorMaxTurns => "error_max_turns",
300            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
301            Self::ErrorDuringExecution => "error_during_execution",
302            Self::Cancelled => "cancelled",
303        }
304    }
305
306    pub const fn is_success(self) -> bool {
307        matches!(self, Self::Success)
308    }
309}
310
311#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
312#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
313#[serde(rename_all = "snake_case")]
314pub enum CompactionTrigger {
315    Manual,
316    Auto,
317    Recovery,
318}
319
320impl CompactionTrigger {
321    pub const fn as_str(self) -> &'static str {
322        match self {
323            Self::Manual => "manual",
324            Self::Auto => "auto",
325            Self::Recovery => "recovery",
326        }
327    }
328}
329
330#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
332#[serde(rename_all = "snake_case")]
333pub enum CompactionMode {
334    Provider,
335    Local,
336}
337
338impl CompactionMode {
339    pub const fn as_str(self) -> &'static str {
340        match self {
341            Self::Provider => "provider",
342            Self::Local => "local",
343        }
344    }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
349pub struct ThreadCompletedEvent {
350    /// Stable thread identifier for the session.
351    pub thread_id: String,
352    /// Stable session identifier for the runtime that produced the thread.
353    pub session_id: String,
354    /// Coarse result category aligned with SDK-style terminal states.
355    pub subtype: ThreadCompletionSubtype,
356    /// VT Code-specific detailed outcome code.
357    pub outcome_code: String,
358    /// Final assistant result text when the thread completed successfully.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub result: Option<String>,
361    /// Provider stop reason or VT Code terminal reason when available.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub stop_reason: Option<String>,
364    /// Aggregated token usage across the thread.
365    pub usage: Usage,
366    /// Optional estimated total API cost for the thread.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub total_cost_usd: Option<serde_json::Number>,
369    /// Number of turns executed before completion.
370    pub num_turns: usize,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
374#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
375pub struct ThreadCompactBoundaryEvent {
376    /// Stable thread identifier for the session.
377    pub thread_id: String,
378    /// Whether compaction was triggered manually or automatically.
379    pub trigger: CompactionTrigger,
380    /// Whether the compaction boundary came from provider-native or local compaction.
381    pub mode: CompactionMode,
382    /// Number of messages before compaction.
383    pub original_message_count: usize,
384    /// Number of messages after compaction.
385    pub compacted_message_count: usize,
386    /// Optional persisted artifact containing the archived compaction summary/history.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub history_artifact_path: Option<String>,
389}
390
391#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
392#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
393pub struct TurnStartedEvent {}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
397pub struct TurnCompletedEvent {
398    /// Token usage summary for the completed turn.
399    pub usage: Usage,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
404pub struct TurnFailedEvent {
405    /// Human-readable explanation describing why the turn failed.
406    pub message: String,
407    /// Optional token usage that was consumed before the failure occurred.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub usage: Option<Usage>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
414pub struct ThreadErrorEvent {
415    /// Fatal error message associated with the thread.
416    pub message: String,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
420#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
421pub struct Usage {
422    /// Number of prompt tokens processed during the turn.
423    pub input_tokens: u64,
424    /// Number of cached prompt tokens reused from previous turns.
425    pub cached_input_tokens: u64,
426    /// Number of cache-creation tokens charged during the turn.
427    pub cache_creation_tokens: u64,
428    /// Number of completion tokens generated by the model.
429    pub output_tokens: u64,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
433#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
434pub struct ItemCompletedEvent {
435    /// Snapshot of the thread item that completed.
436    pub item: ThreadItem,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
440#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
441pub struct ItemStartedEvent {
442    /// Snapshot of the thread item that began processing.
443    pub item: ThreadItem,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
447#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
448pub struct ItemUpdatedEvent {
449    /// Snapshot of the thread item after it was updated.
450    pub item: ThreadItem,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
455pub struct PlanDeltaEvent {
456    /// Identifier of the thread emitting this plan delta.
457    pub thread_id: String,
458    /// Identifier of the current turn.
459    pub turn_id: String,
460    /// Identifier of the plan item receiving the delta.
461    pub item_id: String,
462    /// Incremental plan text chunk.
463    pub delta: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
467#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
468pub struct ThreadItem {
469    /// Stable identifier associated with the item.
470    pub id: String,
471    /// Embedded event details for the item type.
472    #[serde(flatten)]
473    pub details: ThreadItemDetails,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
477#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
478#[serde(tag = "type", rename_all = "snake_case")]
479pub enum ThreadItemDetails {
480    /// Message authored by the agent.
481    AgentMessage(AgentMessageItem),
482    /// Structured plan content authored by the agent in Plan Mode.
483    Plan(PlanItem),
484    /// Free-form reasoning text produced during a turn.
485    Reasoning(ReasoningItem),
486    /// Command execution lifecycle update for an actual shell/PTY process.
487    CommandExecution(Box<CommandExecutionItem>),
488    /// Tool invocation lifecycle update.
489    ToolInvocation(ToolInvocationItem),
490    /// Tool output lifecycle update tied to a tool invocation.
491    ToolOutput(ToolOutputItem),
492    /// File change summary associated with the turn.
493    FileChange(Box<FileChangeItem>),
494    /// MCP tool invocation status.
495    McpToolCall(McpToolCallItem),
496    /// Web search event emitted by a registered search provider.
497    WebSearch(WebSearchItem),
498    /// Harness-managed continuation or verification lifecycle event.
499    Harness(HarnessEventItem),
500    /// General error captured for auditing.
501    Error(ErrorItem),
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
506pub struct AgentMessageItem {
507    /// Textual content of the agent message.
508    pub text: String,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
513pub struct PlanItem {
514    /// Plan markdown content.
515    pub text: String,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
520pub struct ReasoningItem {
521    /// Free-form reasoning content captured during planning.
522    pub text: String,
523    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub stage: Option<String>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
529#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
530#[serde(rename_all = "snake_case")]
531pub enum CommandExecutionStatus {
532    /// Command finished successfully.
533    #[default]
534    Completed,
535    /// Command failed (non-zero exit code or runtime error).
536    Failed,
537    /// Command is still running and may emit additional output.
538    InProgress,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
543pub struct CommandExecutionItem {
544    /// Tool or command identifier executed by the runner.
545    pub command: String,
546    /// Arguments passed to the tool invocation, when available.
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub arguments: Option<Value>,
549    /// Aggregated output emitted by the command.
550    #[serde(default)]
551    pub aggregated_output: String,
552    /// Exit code reported by the process, when available.
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub exit_code: Option<i32>,
555    /// Current status of the command execution.
556    pub status: CommandExecutionStatus,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
560#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
561#[serde(rename_all = "snake_case")]
562pub enum ToolCallStatus {
563    /// Tool finished successfully.
564    #[default]
565    Completed,
566    /// Tool failed.
567    Failed,
568    /// Tool is still running and may emit additional output.
569    InProgress,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
574pub struct ToolInvocationItem {
575    /// Name of the invoked tool.
576    pub tool_name: String,
577    /// Structured arguments passed to the tool.
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub arguments: Option<Value>,
580    /// Raw model-emitted tool call identifier, when available.
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub tool_call_id: Option<String>,
583    /// Current lifecycle status of the invocation.
584    pub status: ToolCallStatus,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
589pub struct ToolOutputItem {
590    /// Identifier of the related harness invocation item.
591    pub call_id: String,
592    /// Raw model-emitted tool call identifier, when available.
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub tool_call_id: Option<String>,
595    /// Canonical spool file path when the full output was written to disk.
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub spool_path: Option<String>,
598    /// Aggregated output emitted by the tool.
599    #[serde(default)]
600    pub output: String,
601    /// Exit code reported by the tool, when available.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub exit_code: Option<i32>,
604    /// Current lifecycle status of the output item.
605    pub status: ToolCallStatus,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
609#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
610pub struct FileChangeItem {
611    /// List of individual file updates included in the change set.
612    pub changes: Vec<FileUpdateChange>,
613    /// Whether the patch application succeeded.
614    pub status: PatchApplyStatus,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
618#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
619pub struct FileUpdateChange {
620    /// Path of the file that was updated.
621    pub path: String,
622    /// Type of change applied to the file.
623    pub kind: PatchChangeKind,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
627#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
628#[serde(rename_all = "snake_case")]
629pub enum PatchApplyStatus {
630    /// Patch successfully applied.
631    Completed,
632    /// Patch application failed.
633    Failed,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
637#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
638#[serde(rename_all = "snake_case")]
639pub enum PatchChangeKind {
640    /// File addition.
641    Add,
642    /// File deletion.
643    Delete,
644    /// File update in place.
645    Update,
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
650pub struct McpToolCallItem {
651    /// Name of the MCP tool invoked by the agent.
652    pub tool_name: String,
653    /// Arguments passed to the tool invocation, if any.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub arguments: Option<Value>,
656    /// Result payload returned by the tool, if captured.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub result: Option<String>,
659    /// Lifecycle status for the tool call.
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub status: Option<McpToolCallStatus>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
665#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
666#[serde(rename_all = "snake_case")]
667pub enum McpToolCallStatus {
668    /// Tool invocation has started.
669    Started,
670    /// Tool invocation completed successfully.
671    Completed,
672    /// Tool invocation failed.
673    Failed,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
678pub struct WebSearchItem {
679    /// Query that triggered the search.
680    pub query: String,
681    /// Search provider identifier, when known.
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub provider: Option<String>,
684    /// Optional raw search results captured for auditing.
685    #[serde(skip_serializing_if = "Option::is_none")]
686    pub results: Option<Vec<String>>,
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
690#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
691#[serde(rename_all = "snake_case")]
692pub enum HarnessEventKind {
693    PlanningStarted,
694    PlanningCompleted,
695    ContinuationStarted,
696    ContinuationSkipped,
697    BlockedHandoffWritten,
698    EvaluationStarted,
699    EvaluationPassed,
700    EvaluationFailed,
701    RevisionStarted,
702    VerificationStarted,
703    VerificationPassed,
704    VerificationFailed,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
709pub struct HarnessEventItem {
710    /// Specific harness event emitted by the runtime.
711    pub event: HarnessEventKind,
712    /// Optional human-readable message associated with the event.
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub message: Option<String>,
715    /// Optional verification command associated with the event.
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub command: Option<String>,
718    /// Optional artifact path associated with the event.
719    #[serde(skip_serializing_if = "Option::is_none")]
720    pub path: Option<String>,
721    /// Optional exit code associated with verification results.
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub exit_code: Option<i32>,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
727#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
728pub struct ErrorItem {
729    /// Error message displayed to the user or logs.
730    pub message: String,
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use std::error::Error;
737
738    #[test]
739    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
740        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
741            usage: Usage {
742                input_tokens: 1,
743                cached_input_tokens: 2,
744                cache_creation_tokens: 0,
745                output_tokens: 3,
746            },
747        });
748
749        let json = serde_json::to_string(&event)?;
750        let restored: ThreadEvent = serde_json::from_str(&json)?;
751
752        assert_eq!(restored, event);
753        Ok(())
754    }
755
756    #[test]
757    fn versioned_event_wraps_schema_version() {
758        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
759            thread_id: "abc".to_string(),
760        });
761
762        let versioned = VersionedThreadEvent::new(event.clone());
763
764        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
765        assert_eq!(versioned.event, event);
766        assert_eq!(versioned.into_event(), event);
767    }
768
769    #[cfg(feature = "serde-json")]
770    #[test]
771    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
772        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
773            item: ThreadItem {
774                id: "item-1".to_string(),
775                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
776                    text: "hello".to_string(),
777                }),
778            },
779        });
780
781        let payload = json::versioned_to_string(&event)?;
782        let restored = json::versioned_from_str(&payload)?;
783
784        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
785        assert_eq!(restored.event, event);
786        Ok(())
787    }
788
789    #[test]
790    fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
791        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
792            item: ThreadItem {
793                id: "tool_1".to_string(),
794                details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
795                    tool_name: "read_file".to_string(),
796                    arguments: Some(serde_json::json!({ "path": "README.md" })),
797                    tool_call_id: Some("tool_call_0".to_string()),
798                    status: ToolCallStatus::Completed,
799                }),
800            },
801        });
802
803        let json = serde_json::to_string(&event)?;
804        let restored: ThreadEvent = serde_json::from_str(&json)?;
805
806        assert_eq!(restored, event);
807        Ok(())
808    }
809
810    #[test]
811    fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
812        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
813            item: ThreadItem {
814                id: "tool_1:output".to_string(),
815                details: ThreadItemDetails::ToolOutput(ToolOutputItem {
816                    call_id: "tool_1".to_string(),
817                    tool_call_id: Some("tool_call_0".to_string()),
818                    spool_path: None,
819                    output: "done".to_string(),
820                    exit_code: Some(0),
821                    status: ToolCallStatus::Completed,
822                }),
823            },
824        });
825
826        let json = serde_json::to_string(&event)?;
827        let restored: ThreadEvent = serde_json::from_str(&json)?;
828
829        assert_eq!(restored, event);
830        Ok(())
831    }
832
833    #[test]
834    fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
835        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
836            item: ThreadItem {
837                id: "harness_1".to_string(),
838                details: ThreadItemDetails::Harness(HarnessEventItem {
839                    event: HarnessEventKind::VerificationFailed,
840                    message: Some("cargo check failed".to_string()),
841                    command: Some("cargo check".to_string()),
842                    path: None,
843                    exit_code: Some(101),
844                }),
845            },
846        });
847
848        let json = serde_json::to_string(&event)?;
849        let restored: ThreadEvent = serde_json::from_str(&json)?;
850
851        assert_eq!(restored, event);
852        Ok(())
853    }
854
855    #[test]
856    fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
857        let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
858            thread_id: "thread-1".to_string(),
859            session_id: "session-1".to_string(),
860            subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
861            outcome_code: "budget_limit_reached".to_string(),
862            result: None,
863            stop_reason: Some("max_tokens".to_string()),
864            usage: Usage {
865                input_tokens: 10,
866                cached_input_tokens: 4,
867                cache_creation_tokens: 2,
868                output_tokens: 5,
869            },
870            total_cost_usd: serde_json::Number::from_f64(1.25),
871            num_turns: 3,
872        });
873
874        let json = serde_json::to_string(&event)?;
875        let restored: ThreadEvent = serde_json::from_str(&json)?;
876
877        assert_eq!(restored, event);
878        Ok(())
879    }
880
881    #[test]
882    fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
883        let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
884            thread_id: "thread-1".to_string(),
885            trigger: CompactionTrigger::Recovery,
886            mode: CompactionMode::Provider,
887            original_message_count: 12,
888            compacted_message_count: 5,
889            history_artifact_path: Some("/tmp/history.jsonl".to_string()),
890        });
891
892        let json = serde_json::to_string(&event)?;
893        let restored: ThreadEvent = serde_json::from_str(&json)?;
894
895        assert_eq!(restored, event);
896        Ok(())
897    }
898}