Skip to main content

vtcode_exec_events/
lib.rs

1#![allow(
2    missing_docs,
3    dead_code,
4    unused_imports,
5    reason = "Intentional compatibility, platform, or test-only suppression."
6)]
7//! Structured execution telemetry events shared across VT Code crates.
8//!
9//! This crate exposes the serialized schema for thread lifecycle updates,
10//! command execution results, and other timeline artifacts emitted by the
11//! automation runtime. Downstream applications can deserialize these
12//! structures to drive dashboards, logging, or auditing pipelines without
13//! depending on the full `vtcode-core` crate.
14//!
15//! # Agent Trace Support
16//!
17//! This crate implements the [Agent Trace](https://agent-trace.dev/) specification
18//! for tracking AI-generated code attribution. See the [`trace`] module for details.
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub mod atif;
24pub mod matrix;
25pub mod trace;
26
27/// Semantic version of the serialized event schema exported by this crate.
28pub const EVENT_SCHEMA_VERSION: &str = "0.18.0";
29
30/// Wraps a [`ThreadEvent`] with schema metadata so downstream consumers can
31/// negotiate compatibility before processing an event stream.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
34pub struct VersionedThreadEvent {
35    /// Semantic version describing the schema of the nested event payload.
36    schema_version: String,
37    /// Concrete event emitted by the agent runtime.
38    event: ThreadEvent,
39}
40
41impl VersionedThreadEvent {
42    /// Creates a new [`VersionedThreadEvent`] using the current
43    /// [`EVENT_SCHEMA_VERSION`].
44    pub fn new(event: ThreadEvent) -> Self {
45        Self {
46            schema_version: EVENT_SCHEMA_VERSION.to_string(),
47            event,
48        }
49    }
50
51    /// Returns the nested [`ThreadEvent`], consuming the wrapper.
52    pub fn into_event(self) -> ThreadEvent {
53        self.event
54    }
55}
56
57impl From<ThreadEvent> for VersionedThreadEvent {
58    fn from(event: ThreadEvent) -> Self {
59        Self::new(event)
60    }
61}
62
63/// Sink for processing [`ThreadEvent`] instances.
64pub trait EventEmitter {
65    /// Invoked for each event emitted by the automation runtime.
66    fn emit(&mut self, event: &ThreadEvent);
67}
68
69impl<F> EventEmitter for F
70where
71    F: FnMut(&ThreadEvent),
72{
73    fn emit(&mut self, event: &ThreadEvent) {
74        self(event);
75    }
76}
77
78/// JSON helper utilities for serializing and deserializing thread events.
79#[cfg(feature = "serde-json")]
80pub(crate) mod json {
81    use super::{ThreadEvent, VersionedThreadEvent};
82
83    /// Converts an event into a `serde_json::Value`.
84    pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
85        serde_json::to_value(event)
86    }
87
88    /// Serializes an event into a JSON string.
89    pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
90        serde_json::to_string(event)
91    }
92
93    /// Deserializes an event from a JSON string.
94    pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
95        serde_json::from_str(payload)
96    }
97
98    /// Serializes a [`VersionedThreadEvent`] wrapper.
99    pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
100        serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
101    }
102
103    /// Deserializes a [`VersionedThreadEvent`] wrapper.
104    pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
105        serde_json::from_str(payload)
106    }
107}
108
109#[cfg(feature = "telemetry-log")]
110mod log_support {
111    use log::Level;
112
113    use super::{EventEmitter, ThreadEvent, json};
114
115    /// Emits JSON serialized events to the `log` facade at the configured level.
116    #[derive(Debug, Clone)]
117    pub struct LogEmitter {
118        level: Level,
119    }
120
121    impl LogEmitter {
122        /// Creates a new [`LogEmitter`] that logs at the provided [`Level`].
123        pub fn new(level: Level) -> Self {
124            Self { level }
125        }
126    }
127
128    impl Default for LogEmitter {
129        fn default() -> Self {
130            Self { level: Level::Info }
131        }
132    }
133
134    impl EventEmitter for LogEmitter {
135        fn emit(&mut self, event: &ThreadEvent) {
136            if log::log_enabled!(self.level) {
137                match json::to_string(event) {
138                    Ok(serialized) => log::log!(self.level, "{serialized}"),
139                    Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
140                }
141            }
142        }
143    }
144
145    pub use LogEmitter as PublicLogEmitter;
146}
147
148#[cfg(feature = "telemetry-log")]
149pub use log_support::PublicLogEmitter as LogEmitter;
150
151#[cfg(feature = "telemetry-tracing")]
152mod tracing_support {
153    use tracing::Level;
154
155    use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
156
157    /// Emits structured events as `tracing` events at the specified level.
158    #[derive(Debug, Clone)]
159    pub struct TracingEmitter {
160        level: Level,
161    }
162
163    impl TracingEmitter {
164        /// Creates a new [`TracingEmitter`] with the provided [`Level`].
165        pub fn new(level: Level) -> Self {
166            Self { level }
167        }
168    }
169
170    impl Default for TracingEmitter {
171        fn default() -> Self {
172            Self { level: Level::INFO }
173        }
174    }
175
176    impl EventEmitter for TracingEmitter {
177        fn emit(&mut self, event: &ThreadEvent) {
178            match self.level {
179                Level::TRACE => tracing::event!(
180                    target: "vtcode_exec_events",
181                    Level::TRACE,
182                    schema_version = EVENT_SCHEMA_VERSION,
183                    event = ?VersionedThreadEvent::new(event.clone()),
184                    "vtcode_exec_event"
185                ),
186                Level::DEBUG => tracing::event!(
187                    target: "vtcode_exec_events",
188                    Level::DEBUG,
189                    schema_version = EVENT_SCHEMA_VERSION,
190                    event = ?VersionedThreadEvent::new(event.clone()),
191                    "vtcode_exec_event"
192                ),
193                Level::INFO => tracing::event!(
194                    target: "vtcode_exec_events",
195                    Level::INFO,
196                    schema_version = EVENT_SCHEMA_VERSION,
197                    event = ?VersionedThreadEvent::new(event.clone()),
198                    "vtcode_exec_event"
199                ),
200                Level::WARN => tracing::event!(
201                    target: "vtcode_exec_events",
202                    Level::WARN,
203                    schema_version = EVENT_SCHEMA_VERSION,
204                    event = ?VersionedThreadEvent::new(event.clone()),
205                    "vtcode_exec_event"
206                ),
207                Level::ERROR => tracing::event!(
208                    target: "vtcode_exec_events",
209                    Level::ERROR,
210                    schema_version = EVENT_SCHEMA_VERSION,
211                    event = ?VersionedThreadEvent::new(event.clone()),
212                    "vtcode_exec_event"
213                ),
214            }
215        }
216    }
217
218    pub use TracingEmitter as PublicTracingEmitter;
219}
220
221#[cfg(feature = "telemetry-tracing")]
222pub use tracing_support::PublicTracingEmitter as TracingEmitter;
223
224#[cfg(feature = "telemetry-otel")]
225mod otel_support {
226    use opentelemetry::KeyValue;
227    use opentelemetry::trace::{Span, Status, Tracer};
228
229    use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
230
231    /// Emits [`ThreadEvent`]s as OpenTelemetry spans and span events.
232    ///
233    /// Each `ThreadEvent` is recorded as an OTel span with attributes derived
234    /// from the event payload.  Harness events are attached as span events
235    /// with their own attributes (event kind, message, path, etc.).
236    ///
237    /// # Usage
238    ///
239    /// ```rust,ignore
240    /// // Requires concrete SDK type (e.g. opentelemetry_sdk::trace::SdkTracerProvider)
241    /// # use vtcode_exec_events::OtelEmitter;
242    /// # let tracer = opentelemetry_sdk::trace::SdkTracerProvider::default()
243    /// #     .tracer("vtcode");
244    /// # let mut emitter = OtelEmitter::new(tracer);
245    /// ```
246    pub struct OtelEmitter<T: Tracer> {
247        tracer: T,
248    }
249
250    impl<T: Tracer> OtelEmitter<T> {
251        pub fn new(tracer: T) -> Self {
252            Self { tracer }
253        }
254    }
255
256    impl<T: Tracer> EventEmitter for OtelEmitter<T> {
257        fn emit(&mut self, event: &ThreadEvent) {
258            let span_name = match event {
259                ThreadEvent::ThreadStarted(_) => "thread.started",
260                ThreadEvent::ThreadCompleted(_) => "thread.completed",
261                ThreadEvent::ContextReset(_) => "context.reset",
262                ThreadEvent::TurnStarted(_) => "turn.started",
263                ThreadEvent::TurnCompleted(_) => "turn.completed",
264                ThreadEvent::TurnFailed(_) => "turn.failed",
265                ThreadEvent::ItemStarted(_) => "item.started",
266                ThreadEvent::ItemUpdated(_) => "item.updated",
267                ThreadEvent::ItemCompleted(_) => "item.completed",
268                ThreadEvent::Error(_) => "error",
269                _ => "event",
270            };
271
272            let mut span = self.tracer.start(span_name);
273
274            match event {
275                ThreadEvent::ThreadStarted(e) => {
276                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
277                }
278                ThreadEvent::ThreadCompleted(e) => {
279                    if let Some(ref cost) = e.total_cost_usd {
280                        span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
281                    }
282                    span.set_attribute(KeyValue::new(
283                        "input_tokens",
284                        i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
285                    ));
286                    span.set_attribute(KeyValue::new(
287                        "output_tokens",
288                        i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
289                    ));
290                    span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
291                }
292                ThreadEvent::ContextReset(e) => {
293                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
294                    span.set_attribute(KeyValue::new("turn_id", e.turn_id.clone()));
295                    span.set_attribute(KeyValue::new("plan_preserved", e.plan_preserved));
296                    span.set_attribute(KeyValue::new(
297                        "previous_context_usage_percent",
298                        e.previous_context_usage_percent as i64,
299                    ));
300                    span.set_attribute(KeyValue::new("tool_budget_reset", e.tool_budget_reset));
301                }
302                ThreadEvent::TurnCompleted(e) => {
303                    span.set_attribute(KeyValue::new(
304                        "turn_input_tokens",
305                        i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
306                    ));
307                    span.set_attribute(KeyValue::new(
308                        "turn_output_tokens",
309                        i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
310                    ));
311                }
312                ThreadEvent::ItemCompleted(e) => {
313                    if let ThreadItemDetails::Harness(harness) = &e.item.details {
314                        span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
315                        if let Some(ref msg) = harness.message {
316                            span.set_attribute(KeyValue::new("harness_message", msg.clone()));
317                        }
318                        if let Some(ref path) = harness.path {
319                            span.set_attribute(KeyValue::new("harness_path", path.clone()));
320                        }
321                        if let Some(dur) = harness.duration_ms {
322                            span.set_attribute(KeyValue::new("duration_ms", i64::try_from(dur).unwrap_or(i64::MAX)));
323                        }
324                        let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
325                        if let Some(ref msg) = harness.message {
326                            event_attrs.push(KeyValue::new("message", msg.clone()));
327                        }
328                        span.add_event("harness_event", event_attrs);
329                    }
330                }
331                ThreadEvent::Error(e) => {
332                    span.set_status(Status::Error { description: e.message.clone().into() });
333                    span.set_attribute(KeyValue::new("error_message", e.message.clone()));
334                }
335                _ => {}
336            }
337
338            span.end();
339        }
340    }
341
342    pub use OtelEmitter as PublicOtelEmitter;
343}
344
345#[cfg(feature = "telemetry-otel")]
346pub use otel_support::PublicOtelEmitter as OtelEmitter;
347
348#[cfg(feature = "schema-export")]
349pub mod schema {
350    use schemars::{Schema, schema_for};
351
352    use super::{ThreadEvent, VersionedThreadEvent};
353
354    /// Generates a JSON Schema describing [`ThreadEvent`].
355    pub fn thread_event_schema() -> Schema {
356        schema_for!(ThreadEvent)
357    }
358
359    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
360    pub fn versioned_thread_event_schema() -> Schema {
361        schema_for!(VersionedThreadEvent)
362    }
363}
364
365/// Structured events emitted during autonomous execution.
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
367#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
368#[serde(tag = "type")]
369pub enum ThreadEvent {
370    /// Replayable local matrix lifecycle checkpoint.
371    #[serde(rename = "matrix.updated")]
372    MatrixUpdated(Box<matrix::MatrixSnapshot>),
373    /// Indicates that a new execution thread has started.
374    #[serde(rename = "thread.started")]
375    ThreadStarted(ThreadStartedEvent),
376    /// Indicates that an execution thread has reached a terminal outcome.
377    #[serde(rename = "thread.completed")]
378    ThreadCompleted(Box<ThreadCompletedEvent>),
379    /// Indicates that conversation compaction replaced older history with a boundary.
380    #[serde(rename = "thread.compact_boundary")]
381    ThreadCompactBoundary(Box<ThreadCompactBoundaryEvent>),
382    /// Indicates that the approved plan handoff rebuilt a fresh execution context.
383    #[serde(rename = "context.reset")]
384    ContextReset(ContextResetEvent),
385    /// Marks the beginning of an execution turn.
386    #[serde(rename = "turn.started")]
387    TurnStarted(TurnStartedEvent),
388    /// Marks the completion of an execution turn.
389    #[serde(rename = "turn.completed")]
390    TurnCompleted(TurnCompletedEvent),
391    /// Marks a turn as failed with additional context.
392    #[serde(rename = "turn.failed")]
393    TurnFailed(TurnFailedEvent),
394    /// Marks a turn as blocked before success could be confirmed. Emitted
395    /// alongside `turn.failed` so UI subscribers get a first-class signal
396    /// with the fuse counters and last tool instead of inferring it.
397    #[serde(rename = "turn.blocked")]
398    TurnBlocked(Box<TurnBlockedEvent>),
399    /// Indicates that an item has started processing.
400    #[serde(rename = "item.started")]
401    ItemStarted(ItemStartedEvent),
402    /// Indicates that an item has been updated.
403    #[serde(rename = "item.updated")]
404    ItemUpdated(ItemUpdatedEvent),
405    /// Indicates that an item reached a terminal state.
406    #[serde(rename = "item.completed")]
407    ItemCompleted(ItemCompletedEvent),
408    /// Emitted when a tool requires user permission before execution.
409    #[serde(rename = "permission.requested")]
410    PermissionRequested(PermissionRequestedEvent),
411    /// Emitted when the user resolves a permission prompt.
412    #[serde(rename = "permission.resolved")]
413    PermissionResolved(PermissionResolvedEvent),
414    /// A mid-turn user interjection was merged into the running turn.
415    #[serde(rename = "interjected")]
416    Interjected(InterjectedEvent),
417    /// Streaming delta for a plan item in Planning workflow.
418    #[serde(rename = "plan.delta")]
419    PlanDelta(Box<PlanDeltaEvent>),
420    /// Indicates that a completed plan is waiting for an implementation decision.
421    #[serde(rename = "plan.approval.requested")]
422    PlanApprovalRequested(PlanApprovalRequestedEvent),
423    /// Records the user's or policy's decision about a completed plan.
424    #[serde(rename = "plan.approval.resolved")]
425    PlanApprovalResolved(PlanApprovalResolvedEvent),
426    /// Represents a fatal error.
427    #[serde(rename = "error")]
428    Error(ThreadErrorEvent),
429    /// Catch-all for unknown event types added in newer schema versions.
430    /// Preserves forward compatibility when older binaries read newer event streams.
431    #[serde(other)]
432    Unknown,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
437pub struct ThreadStartedEvent {
438    /// Unique identifier for the thread that was started.
439    pub thread_id: String,
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 ThreadCompletionSubtype {
446    Success,
447    ErrorMaxTurns,
448    ErrorMaxBudgetUsd,
449    ErrorDuringExecution,
450    Cancelled,
451    /// Catch-all for unknown completion subtypes added in newer schema versions.
452    #[serde(other)]
453    Unknown,
454}
455
456impl ThreadCompletionSubtype {
457    pub const fn as_str(&self) -> &'static str {
458        match self {
459            Self::Success => "success",
460            Self::ErrorMaxTurns => "error_max_turns",
461            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
462            Self::ErrorDuringExecution => "error_during_execution",
463            Self::Cancelled => "cancelled",
464            Self::Unknown => "unknown",
465        }
466    }
467
468    pub const fn is_success(self) -> bool {
469        matches!(self, Self::Success)
470    }
471}
472
473#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
474#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
475#[serde(rename_all = "snake_case")]
476pub enum CompactionTrigger {
477    Manual,
478    Auto,
479    Recovery,
480    /// Compaction triggered by a mid-session switch of the main model or
481    /// provider, so the newly selected model starts from a clean summary.
482    ModelSwitch,
483    /// Catch-all for unknown triggers added in newer schema versions.
484    #[serde(other)]
485    Unknown,
486}
487
488impl CompactionTrigger {
489    pub const fn as_str(self) -> &'static str {
490        match self {
491            Self::Manual => "manual",
492            Self::Auto => "auto",
493            Self::Recovery => "recovery",
494            Self::ModelSwitch => "model_switch",
495            Self::Unknown => "unknown",
496        }
497    }
498}
499
500#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
501#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
502#[serde(rename_all = "snake_case")]
503pub enum CompactionMode {
504    Provider,
505    Local,
506    /// Catch-all for unknown modes added in newer schema versions.
507    #[serde(other)]
508    Unknown,
509}
510
511impl CompactionMode {
512    pub const fn as_str(self) -> &'static str {
513        match self {
514            Self::Provider => "provider",
515            Self::Local => "local",
516            Self::Unknown => "unknown",
517        }
518    }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
522#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
523pub struct ThreadCompletedEvent {
524    /// Runtime completion timestamp, absent from legacy events.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub completed_at: Option<String>,
527    /// Stable thread identifier for the session.
528    pub thread_id: String,
529    /// Stable session identifier for the runtime that produced the thread.
530    pub session_id: String,
531    /// Coarse result category aligned with SDK-style terminal states.
532    pub subtype: ThreadCompletionSubtype,
533    /// VT Code-specific detailed outcome code.
534    pub outcome_code: String,
535    /// Final assistant result text when the thread completed successfully.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub result: Option<String>,
538    /// Provider stop reason or VT Code terminal reason when available.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub stop_reason: Option<String>,
541    /// Aggregated token usage across the thread.
542    pub usage: Usage,
543    /// Optional estimated total API cost for the thread.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub total_cost_usd: Option<serde_json::Number>,
546    /// Number of turns executed before completion.
547    pub num_turns: usize,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
551#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
552pub struct ThreadCompactBoundaryEvent {
553    /// Stable thread identifier for the session.
554    pub thread_id: String,
555    /// Whether compaction was triggered manually or automatically.
556    pub trigger: CompactionTrigger,
557    /// Whether the compaction boundary came from provider-native or local compaction.
558    pub mode: CompactionMode,
559    /// Number of messages before compaction.
560    pub original_message_count: usize,
561    /// Number of messages after compaction.
562    pub compacted_message_count: usize,
563    /// Optional persisted artifact containing the archived compaction summary/history.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub history_artifact_path: Option<String>,
566    /// Segment identifier that contained the request prefix before compaction.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub previous_segment_id: Option<String>,
569    /// Segment identifier created after compaction.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub new_segment_id: Option<String>,
572    /// Hash of the immutable request prefix before compaction.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub previous_prefix_hash: Option<String>,
575    /// Hash of the immutable request prefix after compaction.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub new_prefix_hash: Option<String>,
578    /// Hash of the ordered tool catalog before compaction.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub previous_catalog_hash: Option<String>,
581    /// Hash of the ordered tool catalog after compaction.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub new_catalog_hash: Option<String>,
584}
585
586#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
587#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
588#[serde(rename_all = "snake_case")]
589pub enum ContextResetTrigger {
590    /// The user selected the fresh-context plan approval path.
591    PlanApproval,
592    /// Catch-all for triggers introduced by newer schema versions.
593    #[serde(other)]
594    Unknown,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
599pub struct ContextResetEvent {
600    /// Stable thread identifier for the session.
601    pub thread_id: String,
602    /// Identifier of the turn that approved the plan.
603    pub turn_id: String,
604    /// What initiated the context reset.
605    pub trigger: ContextResetTrigger,
606    /// Whether the approved plan and task tracker survived the reset.
607    pub plan_preserved: bool,
608    /// Context pressure reported before the reset, expressed as a percentage.
609    pub previous_context_usage_percent: u8,
610    /// Whether the per-turn and per-session tool budgets were reset.
611    pub tool_budget_reset: bool,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
615#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
616pub struct TurnStartedEvent {
617    /// Optional decomposition of the assembled first-request prefix so
618    /// downstream consumers can attribute token overhead without inventing
619    /// parallel event types.
620    #[serde(skip_serializing_if = "Option::is_none")]
621    token_breakdown: Option<Box<TokenBreakdown>>,
622    /// Task identity and public input recorded at the request boundary.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub context: Option<Box<ExecutionContext>>,
625}
626
627impl TurnStartedEvent {
628    /// Recorded prefix token breakdown, absent when the producer did not capture it.
629    pub fn token_breakdown(&self) -> Option<&TokenBreakdown> {
630        self.token_breakdown.as_deref()
631    }
632}
633
634/// Origin of a turn's input; internal turns retain their parent task.
635#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
637#[serde(rename_all = "snake_case")]
638pub enum InputOrigin {
639    User,
640    Correction,
641    PlanApproval,
642    Continuation,
643    Retry,
644}
645
646/// Optional recorded execution ancestry. Absent fields are historical gaps.
647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649pub struct ExecutionContext {
650    pub task_id: String,
651    pub turn_id: String,
652    pub actor_id: String,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub parent_actor_id: Option<String>,
655    pub origin: InputOrigin,
656    pub timestamp: String,
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub goal: Option<String>,
659}
660
661/// Recorded shell classification, supplied by the runtime's shared classifier.
662#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664#[serde(rename_all = "snake_case")]
665pub enum CommandActivity {
666    Inspection,
667    Verification,
668    Mutation,
669}
670
671/// Item identity, timing, and command semantics for explanation consumers.
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
674pub struct ItemContext {
675    pub task_id: String,
676    pub turn_id: String,
677    pub actor_id: String,
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub parent_actor_id: Option<String>,
680    pub timestamp: String,
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub activity: Option<CommandActivity>,
683}
684
685/// Per-request token-budget breakdown for the assembled first-request prefix.
686#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
687#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
688pub struct TokenBreakdown {
689    /// System prompt text tokens.
690    system_prompt_tokens: u64,
691    /// On-wire tool schema tokens.
692    tool_schema_tokens: u64,
693    /// Instruction file tokens included in the prompt.
694    instruction_file_tokens: u64,
695    /// Message history text tokens.
696    message_history_tokens: u64,
697    /// Cache read tokens (served from prior turns).
698    cache_read_tokens: u64,
699    /// Cache write tokens (new cache entries created this turn).
700    cache_write_tokens: u64,
701    /// Tokens that missed cache (neither read nor written).
702    cache_miss_tokens: u64,
703    /// Subagent bootstrap tokens, if this turn spawned a child agent.
704    #[serde(skip_serializing_if = "Option::is_none")]
705    subagent_bootstrap_tokens: Option<u64>,
706}
707
708/// Bound on exec session ids recorded in one turn's `turn.completed` event.
709/// Mirrors `SnapshotTurnDiagnostics::in_progress_exec_sessions` (cap 4,
710/// newest first) so `ThreadEvent` and checkpoint diagnostics cannot drift.
711pub const MAX_IN_PROGRESS_EXEC_SESSIONS: usize = 4;
712
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
715pub struct TurnCompletedEvent {
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub completed_at: Option<Box<String>>,
718    /// Token usage summary for the completed turn.
719    pub usage: Usage,
720    /// Exec sessions still running when the turn ended (bounded, newest
721    /// first). Empty when every command settled within the turn. Correlates
722    /// with the next turn's transient exec-session resume hint without
723    /// requiring session-id reconstruction.
724    #[serde(
725        default,
726        skip_serializing_if = "Vec::is_empty",
727        deserialize_with = "deserialize_null_as_default"
728    )]
729    pub in_progress_exec_sessions: Vec<String>,
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
733#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
734pub struct TurnFailedEvent {
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub completed_at: Option<Box<String>>,
737    /// Human-readable explanation describing why the turn failed.
738    pub message: String,
739    /// Optional token usage that was consumed before the failure occurred.
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub usage: Option<Usage>,
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
745#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
746pub struct TurnBlockedEvent {
747    /// Runtime terminal timestamp, absent from legacy events.
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub completed_at: Option<String>,
750    /// Human-readable explanation describing why the turn was blocked.
751    pub message: String,
752    /// Display label of the last blocked tool call, when known.
753    #[serde(skip_serializing_if = "Option::is_none")]
754    pub last_tool: Option<String>,
755    /// Consecutive blocked tool calls observed this turn.
756    #[serde(default)]
757    pub blocked_streak: usize,
758    /// Total blocked tool calls observed this turn.
759    #[serde(default)]
760    pub blocked_total: usize,
761    /// Consecutive cap that was enforced.
762    #[serde(default)]
763    pub consecutive_cap: usize,
764    /// Total cap that was enforced.
765    #[serde(default)]
766    pub total_cap: usize,
767    /// Whether the fuse tripped while a tool-free recovery pass was active.
768    #[serde(default)]
769    pub recovery_active: bool,
770    /// Optional token usage that was consumed before the block occurred.
771    #[serde(skip_serializing_if = "Option::is_none")]
772    pub usage: Option<Usage>,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777pub struct ThreadErrorEvent {
778    /// Fatal error message associated with the thread.
779    pub message: String,
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
783#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
784pub struct Usage {
785    /// Number of prompt tokens processed during the turn.
786    #[serde(default, deserialize_with = "deserialize_null_as_default")]
787    pub input_tokens: u64,
788    /// Number of cached prompt tokens reused from previous turns.
789    #[serde(default, deserialize_with = "deserialize_null_as_default")]
790    pub cached_input_tokens: u64,
791    /// Number of cache-creation tokens charged during the turn.
792    #[serde(default, deserialize_with = "deserialize_null_as_default")]
793    pub cache_creation_tokens: u64,
794    /// Number of completion tokens generated by the model.
795    #[serde(default, deserialize_with = "deserialize_null_as_default")]
796    pub output_tokens: u64,
797}
798
799/// Serde helper that accepts explicit `null` as `T::default()` for
800/// backward-compatible checkpoint/diagnostics payloads. Pair with
801/// `#[serde(default, deserialize_with = "deserialize_null_as_default")]` so
802/// both missing and `null` fields degrade to the default instead of failing
803/// deserialization. Reused by downstream crates (e.g. `vtcode-core`
804/// snapshots) so the null-tolerance rule cannot drift between copies.
805pub fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
806where
807    D: serde::Deserializer<'de>,
808    T: Deserialize<'de> + Default,
809{
810    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
811}
812
813impl Usage {
814    /// Number of input tokens billed at the full input rate: neither served
815    /// from cache nor written to it. `input_tokens` is the total prompt token
816    /// count (uncached + cached + cache-creation), so both cached and
817    /// cache-creation tokens are subtracted out here.
818    #[must_use]
819    fn uncached_input_tokens(&self) -> u64 {
820        self.input_tokens
821            .saturating_sub(self.cached_input_tokens)
822            .saturating_sub(self.cache_creation_tokens)
823    }
824
825    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
826    /// Returns `None` when no input tokens were recorded.
827    #[must_use]
828    pub fn cache_hit_rate(&self) -> Option<f64> {
829        if self.input_tokens == 0 {
830            return None;
831        }
832        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
833    }
834
835    /// Human-readable summary of prompt cache efficiency.
836    #[must_use]
837    pub fn cache_summary(&self) -> String {
838        let total_input = self.input_tokens;
839        if total_input == 0 {
840            return "No input tokens recorded.".to_string();
841        }
842
843        let cached = self.cached_input_tokens;
844        let creation = self.cache_creation_tokens;
845        let uncached = self.uncached_input_tokens();
846        let rate = cached as f64 / total_input as f64 * 100.0;
847        format!(
848            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
849             {creation} cache-creation, {uncached} uncached"
850        )
851    }
852
853    /// Accumulate another usage sample into this one.
854    pub fn add(&mut self, other: &Usage) {
855        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
856        self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
857        self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
858        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
859    }
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
863#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
864pub struct ItemCompletedEvent {
865    /// Snapshot of the thread item that completed.
866    pub item: ThreadItem,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
870#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
871pub struct ItemStartedEvent {
872    /// Snapshot of the thread item that began processing.
873    pub item: ThreadItem,
874}
875
876#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
877#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
878pub struct ItemUpdatedEvent {
879    /// Snapshot of the thread item after it was updated.
880    pub item: ThreadItem,
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
884#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
885pub struct PlanDeltaEvent {
886    /// Identifier of the thread emitting this plan delta.
887    pub thread_id: String,
888    /// Identifier of the current turn.
889    pub turn_id: String,
890    /// Identifier of the plan item receiving the delta.
891    pub item_id: String,
892    /// Incremental plan text chunk.
893    pub delta: String,
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
897#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
898pub struct PlanApprovalRequestedEvent {
899    /// Identifier of the thread emitting the approval request.
900    pub thread_id: String,
901    /// Identifier of the turn that produced the plan.
902    pub turn_id: String,
903    /// Plan file associated with the approval request, when available.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub plan_file: Option<String>,
906}
907
908#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
909#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
910#[serde(rename_all = "snake_case")]
911pub enum PlanApprovalDecision {
912    /// Execute with normal per-edit approval prompts.
913    Execute,
914    /// Execute with automatic edit approval enabled.
915    AutoAccept,
916    /// Execute the plan after rebuilding a fresh context.
917    FreshContext,
918    /// Keep planning and revise the proposed plan.
919    Revise,
920    /// Dismiss the approval request without implementing.
921    Cancel,
922    /// Hand the plan to the build primary agent.
923    SwitchBuild,
924    /// Hand the plan to the auto primary agent.
925    SwitchAuto,
926    /// Catch-all for decisions added in newer schema versions.
927    #[serde(other)]
928    Unknown,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
932#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
933pub struct PlanApprovalResolvedEvent {
934    /// Identifier of the thread emitting the approval decision.
935    pub thread_id: String,
936    /// Identifier of the turn in which the decision was made.
937    pub turn_id: String,
938    /// Decision selected by the user or active execution policy.
939    pub decision: PlanApprovalDecision,
940    /// Whether the decision came from policy rather than an interactive user action.
941    pub automatic: bool,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
946pub struct ThreadItem {
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub context: Option<Box<ItemContext>>,
949    /// Stable identifier associated with the item.
950    pub id: String,
951    /// Embedded event details for the item type.
952    #[serde(flatten)]
953    pub details: ThreadItemDetails,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
957#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
958#[serde(tag = "type", rename_all = "snake_case")]
959pub enum ThreadItemDetails {
960    /// Message authored by the agent.
961    AgentMessage(AgentMessageItem),
962    /// Structured plan content authored by the agent in Planning workflow.
963    Plan(PlanItem),
964    /// Free-form reasoning text produced during a turn.
965    Reasoning(Box<ReasoningItem>),
966    /// Public rationale explicitly recorded by the agent; never private reasoning.
967    Decision(Box<DecisionItem>),
968    /// Command execution lifecycle update for an actual shell/PTY process.
969    CommandExecution(Box<CommandExecutionItem>),
970    /// Tool invocation lifecycle update.
971    ToolInvocation(Box<ToolInvocationItem>),
972    /// Tool output lifecycle update tied to a tool invocation.
973    ToolOutput(Box<ToolOutputItem>),
974    /// File change summary associated with the turn.
975    FileChange(Box<FileChangeItem>),
976    /// MCP tool invocation status.
977    McpToolCall(Box<McpToolCallItem>),
978    /// Web search event emitted by a registered search provider.
979    WebSearch(Box<WebSearchItem>),
980    /// Harness-managed continuation or verification lifecycle event.
981    Harness(Box<HarnessEventItem>),
982    /// General error captured for auditing.
983    Error(ErrorItem),
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
987#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
988pub struct AgentMessageItem {
989    /// Textual content of the agent message.
990    pub text: String,
991}
992
993/// A consequential choice and the agent's reported public rationale.
994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
995#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
996pub struct DecisionItem {
997    pub summary: String,
998    pub rationale: String,
999    #[serde(default)]
1000    pub alternatives: Vec<String>,
1001    #[serde(default)]
1002    pub evidence_ids: Vec<String>,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1006#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1007pub struct PlanItem {
1008    /// Plan markdown content.
1009    pub text: String,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1013#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1014pub struct ReasoningItem {
1015    /// Free-form reasoning content captured during planning.
1016    pub text: String,
1017    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification",
1018    /// or the bounded evidence-only "diagnosis" stage).
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub stage: Option<String>,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1025#[serde(rename_all = "snake_case")]
1026pub enum CommandExecutionStatus {
1027    /// Command finished successfully.
1028    #[default]
1029    Completed,
1030    /// Command failed (non-zero exit code or runtime error).
1031    Failed,
1032    /// Command is still running and may emit additional output.
1033    InProgress,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1037#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1038pub struct CommandExecutionItem {
1039    /// Tool or command identifier executed by the runner.
1040    pub command: String,
1041    /// Arguments passed to the tool invocation, when available.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub arguments: Option<Value>,
1044    /// Aggregated output emitted by the command.
1045    #[serde(default)]
1046    pub aggregated_output: String,
1047    /// Exit code reported by the process, when available.
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    pub exit_code: Option<i32>,
1050    /// Current status of the command execution.
1051    pub status: CommandExecutionStatus,
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1055#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1056#[serde(rename_all = "snake_case")]
1057pub enum ToolCallStatus {
1058    /// Tool finished successfully.
1059    #[default]
1060    Completed,
1061    /// Tool failed.
1062    Failed,
1063    /// Tool is still running and may emit additional output.
1064    InProgress,
1065}
1066
1067/// Fine-grained outcome of a tool invocation lifecycle.
1068///
1069/// Mirrors the outcome taxonomy used by the runtime: `status` remains the
1070/// coarse lifecycle signal (`Completed` / `Failed` / `InProgress`), while
1071/// `outcome` captures *why* the invocation terminated. Consumers that only
1072/// need success/failure can continue to read `status`; analytics and the UI
1073/// layer use `outcome` for richer classification.
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1075#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1076#[serde(rename_all = "snake_case")]
1077pub enum ToolOutcome {
1078    /// Tool executed and returned a result.
1079    #[default]
1080    Success,
1081    /// Tool executed but returned an error.
1082    Error,
1083    /// User rejected the permission prompt.
1084    PermissionRejected,
1085    /// User cancelled the permission prompt (e.g. Ctrl+C / Esc).
1086    PermissionCancelled,
1087    /// User provided a followup message instead of approving.
1088    Followup,
1089    /// A user-configured hook blocked execution.
1090    HookDenied,
1091    /// Tool not found or arguments couldn't be parsed.
1092    InvalidTool,
1093    /// Tool was cancelled during execution or closed before dispatch when its turn ended.
1094    Cancelled,
1095}
1096
1097impl ToolOutcome {
1098    #[must_use]
1099    pub const fn is_terminal(self) -> bool {
1100        !matches!(self, Self::Followup)
1101    }
1102}
1103
1104/// Map a terminal [`ToolCallStatus`] to its corresponding [`ToolOutcome`].
1105///
1106/// # Panics
1107///
1108/// Panics if `status` is [`ToolCallStatus::InProgress`], which is a non-terminal
1109/// state and must never be passed to a completion-event emitter.
1110#[must_use]
1111#[allow(
1112    clippy::unreachable,
1113    reason = "Intentional compatibility, platform, or test-only suppression."
1114)]
1115pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
1116    match status {
1117        ToolCallStatus::Completed => ToolOutcome::Success,
1118        ToolCallStatus::Failed => ToolOutcome::Error,
1119        ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
1120    }
1121}
1122
1123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1124#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1125pub struct ToolInvocationItem {
1126    /// Name of the invoked tool.
1127    pub tool_name: String,
1128    /// Structured arguments passed to the tool.
1129    #[serde(skip_serializing_if = "Option::is_none")]
1130    pub arguments: Option<Value>,
1131    /// Raw model-emitted tool call identifier, when available.
1132    #[serde(skip_serializing_if = "Option::is_none")]
1133    pub tool_call_id: Option<String>,
1134    /// Current lifecycle status of the invocation.
1135    pub status: ToolCallStatus,
1136    /// Fine-grained outcome of the invocation lifecycle.
1137    #[serde(skip_serializing_if = "Option::is_none")]
1138    pub outcome: Option<ToolOutcome>,
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1142#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1143pub struct ToolOutputItem {
1144    /// Identifier of the related harness invocation item.
1145    pub call_id: String,
1146    /// Raw model-emitted tool call identifier, when available.
1147    #[serde(skip_serializing_if = "Option::is_none")]
1148    pub tool_call_id: Option<String>,
1149    /// Canonical spool file path when the full output was written to disk.
1150    #[serde(skip_serializing_if = "Option::is_none")]
1151    pub spool_path: Option<String>,
1152    /// Aggregated output emitted by the tool.
1153    #[serde(default)]
1154    pub output: String,
1155    /// Exit code reported by the tool, when available.
1156    #[serde(skip_serializing_if = "Option::is_none")]
1157    pub exit_code: Option<i32>,
1158    /// Current lifecycle status of the output item.
1159    pub status: ToolCallStatus,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1163#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1164pub struct FileChangeItem {
1165    /// Captured preview was truncated, suppressed, or unavailable.
1166    #[serde(default, skip_serializing_if = "Option::is_none")]
1167    pub diff_incomplete: Option<bool>,
1168    /// List of individual file updates included in the change set.
1169    pub changes: Vec<FileUpdateChange>,
1170    /// Whether the patch application succeeded.
1171    pub status: PatchApplyStatus,
1172    /// Optional precomputed unified diff for the change set.
1173    ///
1174    /// Populated by the turn diff tracker so consumers can render per-change
1175    /// previews without recomputation. Absent in older events.
1176    #[serde(default, skip_serializing_if = "Option::is_none")]
1177    pub unified_diff: Option<String>,
1178    /// Optional added-line count for the change set.
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    pub additions: Option<u64>,
1181    /// Optional deleted-line count for the change set.
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub deletions: Option<u64>,
1184}
1185
1186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1187#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1188pub struct FileUpdateChange {
1189    /// Path of the file that was updated.
1190    pub path: String,
1191    /// Type of change applied to the file.
1192    pub kind: PatchChangeKind,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1196#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1197#[serde(rename_all = "snake_case")]
1198pub enum PatchApplyStatus {
1199    /// Patch successfully applied.
1200    Completed,
1201    /// Patch application failed.
1202    Failed,
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1206#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1207#[serde(rename_all = "snake_case")]
1208pub enum PatchChangeKind {
1209    /// File addition.
1210    Add,
1211    /// File deletion.
1212    Delete,
1213    /// File update in place.
1214    Update,
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1218#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1219pub struct McpToolCallItem {
1220    /// Name of the MCP tool invoked by the agent.
1221    pub tool_name: String,
1222    /// Arguments passed to the tool invocation, if any.
1223    #[serde(skip_serializing_if = "Option::is_none")]
1224    pub arguments: Option<Value>,
1225    /// Result payload returned by the tool, if captured.
1226    #[serde(skip_serializing_if = "Option::is_none")]
1227    pub result: Option<String>,
1228    /// Lifecycle status for the tool call.
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub status: Option<McpToolCallStatus>,
1231}
1232
1233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1235#[serde(rename_all = "snake_case")]
1236pub enum McpToolCallStatus {
1237    /// Tool invocation has started.
1238    Started,
1239    /// Tool invocation completed successfully.
1240    Completed,
1241    /// Tool invocation failed.
1242    Failed,
1243}
1244
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1246#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1247pub struct WebSearchItem {
1248    /// Query that triggered the search.
1249    pub query: String,
1250    /// Search provider identifier, when known.
1251    #[serde(skip_serializing_if = "Option::is_none")]
1252    pub provider: Option<String>,
1253    /// Optional raw search results captured for auditing.
1254    #[serde(skip_serializing_if = "Option::is_none")]
1255    pub results: Option<Vec<String>>,
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1260#[serde(rename_all = "snake_case")]
1261pub enum HarnessEventKind {
1262    PlanningStarted,
1263    PlanningCompleted,
1264    ContinuationStarted,
1265    ContinuationSkipped,
1266    /// A turn was blocked before success could be confirmed. Carries the fuse
1267    /// counters so UI layers can render without correlating multiple events.
1268    TurnBlocked,
1269    /// A bounded tool-free recovery pass was scheduled after blocked calls.
1270    BlockedRecoveryStarted,
1271    /// A bounded tool-free recovery pass finished.
1272    BlockedRecoveryFinished,
1273    BlockedHandoffWritten,
1274    /// The owning session resolved its archived blocked handoff and removed
1275    /// the live recovery pointer.
1276    BlockedHandoffResolved,
1277    EvaluationStarted,
1278    EvaluationPassed,
1279    EvaluationFailed,
1280    RevisionStarted,
1281    EscalationTriggered,
1282    EscalationBypassed,
1283    VerificationStarted,
1284    VerificationPassed,
1285    VerificationFailed,
1286    /// Agent recovered from a transient error (e.g. after retry succeeded).
1287    ErrorRecovered,
1288    /// A transient tool failure triggered an automatic retry attempt.
1289    ToolRetryAttempted,
1290    /// Latency record for a tool execution, emitted on turn completion.
1291    ToolLatencyRecorded,
1292    /// A checkpoint snapshot was created for the current turn.
1293    SnapshotCreated,
1294    /// A checkpoint snapshot was restored (rewind operation).
1295    SnapshotRestored,
1296    /// The user granted additional session tool-call capacity and the
1297    /// pending call will be retried in the same turn.
1298    SessionToolLimitIncreased,
1299    /// The user granted additional tool-loop capacity for the current turn.
1300    ToolLoopLimitIncreased,
1301    /// A background subprocess or exec session reached a terminal state.
1302    BackgroundSubprocessCompleted,
1303    /// A native delegated agent's public status and ancestry were observed.
1304    DelegatedAgentStatus,
1305}
1306
1307#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1308#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1309#[serde(rename_all = "snake_case")]
1310pub enum PermissionDecision {
1311    Allow,
1312    Deny,
1313    Cancelled,
1314    Followup,
1315}
1316
1317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1318#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1319pub struct PermissionRequestedEvent {
1320    /// Name of the tool that requires permission.
1321    pub tool_name: String,
1322}
1323
1324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1325#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1326pub struct PermissionResolvedEvent {
1327    /// Name of the tool that was permitted or denied.
1328    pub tool_name: String,
1329    /// User's decision on the permission prompt.
1330    pub decision: PermissionDecision,
1331    /// Wall-clock time the prompt was visible, in milliseconds.
1332    pub wait_ms: u64,
1333}
1334
1335#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1336#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1337#[serde(rename_all = "snake_case")]
1338pub enum InterjectionSource {
1339    Direct,
1340    Queue,
1341}
1342
1343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1344#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1345#[serde(rename_all = "snake_case")]
1346pub enum RedirectKind {
1347    Interjection,
1348}
1349
1350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1351#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1352pub struct InterjectedEvent {
1353    /// Public correction text, when recorded by the runtime.
1354    #[serde(default, skip_serializing_if = "Option::is_none")]
1355    pub text: Option<Box<String>>,
1356    /// How the interjection reached the running turn.
1357    pub source: InterjectionSource,
1358    /// Number of image attachments that accompanied the interjection.
1359    pub image_count: u32,
1360    /// Always `Interjection` for this event; carried so the shared
1361    /// `redirect_kind` field is queryable uniformly across redirect events.
1362    pub redirect_kind: RedirectKind,
1363}
1364
1365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1366#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1367pub struct HarnessEventItem {
1368    /// Specific harness event emitted by the runtime.
1369    pub event: HarnessEventKind,
1370    /// Optional human-readable message associated with the event.
1371    #[serde(skip_serializing_if = "Option::is_none")]
1372    pub message: Option<String>,
1373    /// Optional verification command associated with the event.
1374    #[serde(skip_serializing_if = "Option::is_none")]
1375    pub command: Option<String>,
1376    /// Optional artifact path associated with the event.
1377    #[serde(skip_serializing_if = "Option::is_none")]
1378    pub path: Option<String>,
1379    /// Optional exit code associated with verification results.
1380    #[serde(skip_serializing_if = "Option::is_none")]
1381    pub exit_code: Option<i32>,
1382    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
1383    #[serde(skip_serializing_if = "Option::is_none")]
1384    pub attempt: Option<u32>,
1385    /// Canonical error category for retry/recovery events.
1386    #[serde(skip_serializing_if = "Option::is_none")]
1387    pub error_category: Option<String>,
1388    /// Latency in milliseconds for tool-execution latency events.
1389    #[serde(skip_serializing_if = "Option::is_none")]
1390    pub duration_ms: Option<u64>,
1391    /// Stable task identifier for background completion events.
1392    #[serde(skip_serializing_if = "Option::is_none")]
1393    pub task_id: Option<String>,
1394    /// Child session identifier for background completion events.
1395    #[serde(skip_serializing_if = "Option::is_none")]
1396    pub session_id: Option<String>,
1397    /// Exec-session identifier for background completion events.
1398    #[serde(skip_serializing_if = "Option::is_none")]
1399    pub exec_session_id: Option<String>,
1400    /// Terminal background status, when the event represents a subprocess.
1401    #[serde(skip_serializing_if = "Option::is_none")]
1402    pub status: Option<String>,
1403    /// Archived transcript reference for background completion events.
1404    #[serde(skip_serializing_if = "Option::is_none")]
1405    pub transcript_path: Option<String>,
1406    /// Archived session reference for background completion events.
1407    #[serde(skip_serializing_if = "Option::is_none")]
1408    pub archive_path: Option<String>,
1409}
1410
1411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1413pub struct ErrorItem {
1414    /// Error message displayed to the user or logs.
1415    pub message: String,
1416}
1417
1418#[cfg(test)]
1419mod tests;