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