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 diagnostic;
25pub use diagnostic::{Diagnostic, DiagnosticAction, DiagnosticSeverity};
26pub mod items;
27pub use items::{
28    AgentMessageItem, AppPreviewItem, AppUiPosture, CommandExecutionItem, CommandExecutionStatus, DecisionItem,
29    FileChangeItem, FileUpdateChange, McpToolCallItem, McpToolCallStatus, PatchApplyStatus, PatchChangeKind, PlanItem,
30    ReasoningItem, ThreadItem, ThreadItemDetails, ToolCallStatus, ToolInvocationItem, ToolOutcome, ToolOutputItem,
31    WebSearchItem, tool_outcome_from_status,
32};
33pub mod matrix;
34pub mod planning;
35pub mod trace;
36
37/// Semantic version of the serialized event schema exported by this crate.
38pub const EVENT_SCHEMA_VERSION: &str = "0.26.0";
39
40/// Wraps a [`ThreadEvent`] with schema metadata so downstream consumers can
41/// negotiate compatibility before processing an event stream.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
44pub struct VersionedThreadEvent {
45    /// Semantic version describing the schema of the nested event payload.
46    schema_version: String,
47    /// Concrete event emitted by the agent runtime.
48    event: ThreadEvent,
49}
50
51impl VersionedThreadEvent {
52    /// Creates a new [`VersionedThreadEvent`] using the current
53    /// [`EVENT_SCHEMA_VERSION`].
54    pub fn new(event: ThreadEvent) -> Self {
55        Self {
56            schema_version: EVENT_SCHEMA_VERSION.to_string(),
57            event,
58        }
59    }
60
61    /// Returns the nested [`ThreadEvent`] by reference.
62    pub fn event(&self) -> &ThreadEvent {
63        &self.event
64    }
65
66    /// Returns the nested [`ThreadEvent`], consuming the wrapper.
67    pub fn into_event(self) -> ThreadEvent {
68        self.event
69    }
70}
71
72impl From<ThreadEvent> for VersionedThreadEvent {
73    fn from(event: ThreadEvent) -> Self {
74        Self::new(event)
75    }
76}
77
78/// Sink for processing [`ThreadEvent`] instances.
79pub trait EventEmitter {
80    /// Invoked for each event emitted by the automation runtime.
81    fn emit(&mut self, event: &ThreadEvent);
82}
83
84impl<F> EventEmitter for F
85where
86    F: FnMut(&ThreadEvent),
87{
88    fn emit(&mut self, event: &ThreadEvent) {
89        self(event);
90    }
91}
92
93#[cfg(feature = "serde-json")]
94pub(crate) mod json;
95
96#[cfg(feature = "telemetry-log")]
97mod log_support;
98
99#[cfg(feature = "telemetry-log")]
100pub use log_support::PublicLogEmitter as LogEmitter;
101
102#[cfg(feature = "telemetry-tracing")]
103mod tracing_support;
104
105#[cfg(feature = "telemetry-tracing")]
106pub use tracing_support::PublicTracingEmitter as TracingEmitter;
107
108#[cfg(feature = "telemetry-otel")]
109mod otel_support;
110
111#[cfg(feature = "telemetry-otel")]
112pub use otel_support::PublicOtelEmitter as OtelEmitter;
113
114#[cfg(feature = "schema-export")]
115pub mod schema;
116
117/// Structured events emitted during autonomous execution.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
120#[serde(tag = "type")]
121pub enum ThreadEvent {
122    /// Replayable local matrix lifecycle checkpoint.
123    #[serde(rename = "matrix.updated")]
124    MatrixUpdated(Box<matrix::MatrixSnapshot>),
125    /// Indicates that a new execution thread has started.
126    #[serde(rename = "thread.started")]
127    ThreadStarted(ThreadStartedEvent),
128    /// Indicates that an execution thread has reached a terminal outcome.
129    #[serde(rename = "thread.completed")]
130    ThreadCompleted(Box<ThreadCompletedEvent>),
131    /// Indicates that conversation compaction replaced older history with a boundary.
132    #[serde(rename = "thread.compact_boundary")]
133    ThreadCompactBoundary(Box<ThreadCompactBoundaryEvent>),
134    /// Indicates that the approved plan handoff rebuilt a fresh execution context.
135    #[serde(rename = "context.reset")]
136    ContextReset(ContextResetEvent),
137    /// Marks the beginning of an execution turn.
138    #[serde(rename = "turn.started")]
139    TurnStarted(TurnStartedEvent),
140    /// Marks the completion of an execution turn.
141    #[serde(rename = "turn.completed")]
142    TurnCompleted(TurnCompletedEvent),
143    /// Marks a turn as failed with additional context.
144    #[serde(rename = "turn.failed")]
145    TurnFailed(TurnFailedEvent),
146    /// Marks a turn as blocked before success could be confirmed. Emitted
147    /// alongside `turn.failed` so UI subscribers get a first-class signal
148    /// with the fuse counters and last tool instead of inferring it.
149    #[serde(rename = "turn.blocked")]
150    TurnBlocked(Box<TurnBlockedEvent>),
151    /// Indicates that an item has started processing.
152    #[serde(rename = "item.started")]
153    ItemStarted(ItemStartedEvent),
154    /// Indicates that an item has been updated.
155    #[serde(rename = "item.updated")]
156    ItemUpdated(ItemUpdatedEvent),
157    /// Indicates that an item reached a terminal state.
158    #[serde(rename = "item.completed")]
159    ItemCompleted(ItemCompletedEvent),
160    /// Emitted when an MCP server offers app UI that the `off` apps tier
161    /// refuses. No `ui://` resource was fetched and no app rows render; the
162    /// refused URIs are listed so the denial stays visible.
163    #[serde(rename = "app.sandbox_denied")]
164    AppSandboxDenied(Box<AppSandboxDeniedEvent>),
165    /// Emitted when a tool requires user permission before execution.
166    #[serde(rename = "permission.requested")]
167    PermissionRequested(PermissionRequestedEvent),
168    /// Emitted when the user resolves a permission prompt.
169    #[serde(rename = "permission.resolved")]
170    PermissionResolved(PermissionResolvedEvent),
171    /// A mid-turn user interjection was merged into the running turn.
172    #[serde(rename = "interjected")]
173    Interjected(InterjectedEvent),
174    /// Streaming delta for a plan item in Planning workflow.
175    #[serde(rename = "plan.delta")]
176    PlanDelta(Box<PlanDeltaEvent>),
177    /// Indicates that a completed plan is waiting for an implementation decision.
178    #[serde(rename = "plan.approval.requested")]
179    PlanApprovalRequested(PlanApprovalRequestedEvent),
180    /// Records the user's or policy's decision about a completed plan.
181    #[serde(rename = "plan.approval.resolved")]
182    PlanApprovalResolved(PlanApprovalResolvedEvent),
183    /// Represents a fatal error.
184    #[serde(rename = "error")]
185    Error(ThreadErrorEvent),
186    /// Catch-all for unknown event types added in newer schema versions.
187    /// Preserves forward compatibility when older binaries read newer event streams.
188    #[serde(other)]
189    Unknown,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
194pub struct ThreadStartedEvent {
195    /// Unique identifier for the thread that was started.
196    pub thread_id: String,
197}
198
199#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
201#[serde(rename_all = "snake_case")]
202pub enum ThreadCompletionSubtype {
203    Success,
204    ErrorMaxTurns,
205    ErrorMaxBudgetUsd,
206    ErrorDuringExecution,
207    Cancelled,
208    /// Catch-all for unknown completion subtypes added in newer schema versions.
209    #[serde(other)]
210    Unknown,
211}
212
213impl ThreadCompletionSubtype {
214    pub const fn as_str(&self) -> &'static str {
215        match self {
216            Self::Success => "success",
217            Self::ErrorMaxTurns => "error_max_turns",
218            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
219            Self::ErrorDuringExecution => "error_during_execution",
220            Self::Cancelled => "cancelled",
221            Self::Unknown => "unknown",
222        }
223    }
224
225    pub const fn is_success(self) -> bool {
226        matches!(self, Self::Success)
227    }
228}
229
230#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
231#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
232#[serde(rename_all = "snake_case")]
233pub enum CompactionTrigger {
234    Manual,
235    Auto,
236    Recovery,
237    /// Compaction triggered by a mid-session switch of the main model or
238    /// provider, so the newly selected model starts from a clean summary.
239    ModelSwitch,
240    /// Catch-all for unknown triggers added in newer schema versions.
241    #[serde(other)]
242    Unknown,
243}
244
245impl CompactionTrigger {
246    pub const fn as_str(self) -> &'static str {
247        match self {
248            Self::Manual => "manual",
249            Self::Auto => "auto",
250            Self::Recovery => "recovery",
251            Self::ModelSwitch => "model_switch",
252            Self::Unknown => "unknown",
253        }
254    }
255}
256
257#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
258#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
259#[serde(rename_all = "snake_case")]
260pub enum CompactionMode {
261    Provider,
262    Local,
263    /// Catch-all for unknown modes added in newer schema versions.
264    #[serde(other)]
265    Unknown,
266}
267
268impl CompactionMode {
269    pub const fn as_str(self) -> &'static str {
270        match self {
271            Self::Provider => "provider",
272            Self::Local => "local",
273            Self::Unknown => "unknown",
274        }
275    }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
280pub struct ThreadCompletedEvent {
281    /// Runtime completion timestamp, absent from legacy events.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub completed_at: Option<String>,
284    /// Stable thread identifier for the session.
285    pub thread_id: String,
286    /// Stable session identifier for the runtime that produced the thread.
287    pub session_id: String,
288    /// Coarse result category aligned with SDK-style terminal states.
289    pub subtype: ThreadCompletionSubtype,
290    /// VT Code-specific detailed outcome code.
291    pub outcome_code: String,
292    /// Final assistant result text when the thread completed successfully.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub result: Option<String>,
295    /// Provider stop reason or VT Code terminal reason when available.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub stop_reason: Option<String>,
298    /// Aggregated token usage across the thread.
299    pub usage: Usage,
300    /// Optional estimated total API cost for the thread.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub total_cost_usd: Option<serde_json::Number>,
303    /// Number of turns executed before completion.
304    pub num_turns: usize,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
309pub struct ThreadCompactBoundaryEvent {
310    /// Stable thread identifier for the session.
311    pub thread_id: String,
312    /// Whether compaction was triggered manually or automatically.
313    pub trigger: CompactionTrigger,
314    /// Whether the compaction boundary came from provider-native or local compaction.
315    pub mode: CompactionMode,
316    /// Number of messages before compaction.
317    pub original_message_count: usize,
318    /// Number of messages after compaction.
319    pub compacted_message_count: usize,
320    /// Optional persisted artifact containing the archived compaction summary/history.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub history_artifact_path: Option<String>,
323    /// Segment identifier that contained the request prefix before compaction.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub previous_segment_id: Option<String>,
326    /// Segment identifier created after compaction.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub new_segment_id: Option<String>,
329    /// Hash of the immutable request prefix before compaction.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub previous_prefix_hash: Option<String>,
332    /// Hash of the immutable request prefix after compaction.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub new_prefix_hash: Option<String>,
335    /// Hash of the ordered tool catalog before compaction.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub previous_catalog_hash: Option<String>,
338    /// Hash of the ordered tool catalog after compaction.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub new_catalog_hash: Option<String>,
341}
342
343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
344#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
345#[serde(rename_all = "snake_case")]
346pub enum ContextResetTrigger {
347    /// The user selected the fresh-context plan approval path.
348    PlanApproval,
349    /// Catch-all for triggers introduced by newer schema versions.
350    #[serde(other)]
351    Unknown,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
356pub struct ContextResetEvent {
357    /// Stable thread identifier for the session.
358    pub thread_id: String,
359    /// Identifier of the turn that approved the plan.
360    pub turn_id: String,
361    /// What initiated the context reset.
362    pub trigger: ContextResetTrigger,
363    /// Whether the approved plan and task tracker survived the reset.
364    pub plan_preserved: bool,
365    /// Context pressure reported before the reset, expressed as a percentage.
366    pub previous_context_usage_percent: u8,
367    /// Whether the per-turn and per-session tool budgets were reset.
368    pub tool_budget_reset: bool,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
372#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
373pub struct TurnStartedEvent {
374    /// Optional decomposition of the assembled first-request prefix so
375    /// downstream consumers can attribute token overhead without inventing
376    /// parallel event types.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    token_breakdown: Option<Box<TokenBreakdown>>,
379    /// Task identity and public input recorded at the request boundary.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub context: Option<Box<ExecutionContext>>,
382}
383
384impl TurnStartedEvent {
385    /// Recorded prefix token breakdown, absent when the producer did not capture it.
386    pub fn token_breakdown(&self) -> Option<&TokenBreakdown> {
387        self.token_breakdown.as_deref()
388    }
389}
390
391/// Origin of a turn's input; internal turns retain their parent task.
392#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
393#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
394#[serde(rename_all = "snake_case")]
395pub enum InputOrigin {
396    User,
397    Correction,
398    PlanApproval,
399    Continuation,
400    Retry,
401}
402
403/// Optional recorded execution ancestry. Absent fields are historical gaps.
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
405#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
406pub struct ExecutionContext {
407    pub task_id: String,
408    pub turn_id: String,
409    pub actor_id: String,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub parent_actor_id: Option<String>,
412    pub origin: InputOrigin,
413    pub timestamp: String,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub goal: Option<String>,
416}
417
418/// Recorded shell classification, supplied by the runtime's shared classifier.
419#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
420#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
421#[serde(rename_all = "snake_case")]
422pub enum CommandActivity {
423    Inspection,
424    Verification,
425    Mutation,
426}
427
428/// Item identity, timing, and command semantics for explanation consumers.
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
430#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
431pub struct ItemContext {
432    pub task_id: String,
433    pub turn_id: String,
434    pub actor_id: String,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub parent_actor_id: Option<String>,
437    pub timestamp: String,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub activity: Option<CommandActivity>,
440}
441
442/// Per-request token-budget breakdown for the assembled first-request prefix.
443#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
444#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
445pub struct TokenBreakdown {
446    /// System prompt text tokens.
447    system_prompt_tokens: u64,
448    /// On-wire tool schema tokens.
449    tool_schema_tokens: u64,
450    /// Instruction file tokens included in the prompt.
451    instruction_file_tokens: u64,
452    /// Message history text tokens.
453    message_history_tokens: u64,
454    /// Cache read tokens (served from prior turns).
455    cache_read_tokens: u64,
456    /// Cache write tokens (new cache entries created this turn).
457    cache_write_tokens: u64,
458    /// Tokens that missed cache (neither read nor written).
459    cache_miss_tokens: u64,
460    /// Subagent bootstrap tokens, if this turn spawned a child agent.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    subagent_bootstrap_tokens: Option<u64>,
463}
464
465/// Bound on exec session ids recorded in one turn's `turn.completed` event.
466/// Mirrors `SnapshotTurnDiagnostics::in_progress_exec_sessions` (cap 4,
467/// newest first) so `ThreadEvent` and checkpoint diagnostics cannot drift.
468pub const MAX_IN_PROGRESS_EXEC_SESSIONS: usize = 4;
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
472pub struct TurnCompletedEvent {
473    /// Identity of the final agent message, when known.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub final_response_item_id: Option<Box<String>>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub completed_at: Option<Box<String>>,
478    /// Token usage summary for the completed turn.
479    pub usage: Usage,
480    /// Exec sessions still running when the turn ended (bounded, newest
481    /// first). Empty when every command settled within the turn. Correlates
482    /// with the next turn's transient exec-session resume hint without
483    /// requiring session-id reconstruction.
484    #[serde(
485        default,
486        skip_serializing_if = "Vec::is_empty",
487        deserialize_with = "deserialize_null_as_default"
488    )]
489    pub in_progress_exec_sessions: Vec<String>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
493#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
494pub struct TurnFailedEvent {
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub completed_at: Option<Box<String>>,
497    /// Human-readable explanation describing why the turn failed.
498    pub message: String,
499    /// Optional token usage that was consumed before the failure occurred.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub usage: Option<Usage>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
506pub struct TurnBlockedEvent {
507    /// Runtime terminal timestamp, absent from legacy events.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub completed_at: Option<String>,
510    /// Human-readable explanation describing why the turn was blocked.
511    pub message: String,
512    /// Display label of the last blocked tool call, when known.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub last_tool: Option<String>,
515    /// Consecutive blocked tool calls observed this turn.
516    #[serde(default)]
517    pub blocked_streak: usize,
518    /// Total blocked tool calls observed this turn.
519    #[serde(default)]
520    pub blocked_total: usize,
521    /// Consecutive cap that was enforced.
522    #[serde(default)]
523    pub consecutive_cap: usize,
524    /// Total cap that was enforced.
525    #[serde(default)]
526    pub total_cap: usize,
527    /// Whether the fuse tripped while a tool-free recovery pass was active.
528    #[serde(default)]
529    pub recovery_active: bool,
530    /// Optional token usage that was consumed before the block occurred.
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub usage: Option<Usage>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
537pub struct ThreadErrorEvent {
538    /// Fatal error message associated with the thread.
539    pub message: String,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
544pub struct AppSandboxDeniedEvent {
545    /// Tool whose result offered the refused app UI.
546    pub tool: String,
547    /// Apps tier that refused the UI (`off`).
548    pub tier: String,
549    /// Refused `ui://` URIs, in first-seen order (capped at 4).
550    pub uris: Vec<String>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
554#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
555pub struct Usage {
556    /// Number of prompt tokens processed during the turn.
557    #[serde(default, deserialize_with = "deserialize_null_as_default")]
558    pub input_tokens: u64,
559    /// Number of cached prompt tokens reused from previous turns.
560    #[serde(default, deserialize_with = "deserialize_null_as_default")]
561    pub cached_input_tokens: u64,
562    /// Number of cache-creation tokens charged during the turn.
563    #[serde(default, deserialize_with = "deserialize_null_as_default")]
564    pub cache_creation_tokens: u64,
565    /// Number of completion tokens generated by the model.
566    #[serde(default, deserialize_with = "deserialize_null_as_default")]
567    pub output_tokens: u64,
568}
569
570/// Serde helper that accepts explicit `null` as `T::default()` for
571/// backward-compatible checkpoint/diagnostics payloads. Pair with
572/// `#[serde(default, deserialize_with = "deserialize_null_as_default")]` so
573/// both missing and `null` fields degrade to the default instead of failing
574/// deserialization. Reused by downstream crates (e.g. `vtcode-core`
575/// snapshots) so the null-tolerance rule cannot drift between copies.
576pub fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
577where
578    D: serde::Deserializer<'de>,
579    T: Deserialize<'de> + Default,
580{
581    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
582}
583
584impl Usage {
585    /// Number of input tokens billed at the full input rate: neither served
586    /// from cache nor written to it. `input_tokens` is the total prompt token
587    /// count (uncached + cached + cache-creation), so both cached and
588    /// cache-creation tokens are subtracted out here.
589    #[must_use]
590    fn uncached_input_tokens(&self) -> u64 {
591        self.input_tokens
592            .saturating_sub(self.cached_input_tokens)
593            .saturating_sub(self.cache_creation_tokens)
594    }
595
596    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
597    /// Returns `None` when no input tokens were recorded.
598    #[must_use]
599    pub fn cache_hit_rate(&self) -> Option<f64> {
600        if self.input_tokens == 0 {
601            return None;
602        }
603        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
604    }
605
606    /// Human-readable summary of prompt cache efficiency.
607    #[must_use]
608    pub fn cache_summary(&self) -> String {
609        let total_input = self.input_tokens;
610        if total_input == 0 {
611            return "No input tokens recorded.".to_string();
612        }
613
614        let cached = self.cached_input_tokens;
615        let creation = self.cache_creation_tokens;
616        let uncached = self.uncached_input_tokens();
617        let rate = cached as f64 / total_input as f64 * 100.0;
618        format!(
619            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
620             {creation} cache-creation, {uncached} uncached"
621        )
622    }
623
624    /// Accumulate another usage sample into this one.
625    pub fn add(&mut self, other: &Usage) {
626        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
627        self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
628        self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
629        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
630    }
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
634#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
635pub struct ItemCompletedEvent {
636    /// Snapshot of the thread item that completed.
637    pub item: ThreadItem,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
641#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
642pub struct ItemStartedEvent {
643    /// Snapshot of the thread item that began processing.
644    pub item: ThreadItem,
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649pub struct ItemUpdatedEvent {
650    /// Snapshot of the thread item after it was updated.
651    pub item: ThreadItem,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
655#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
656pub struct PlanDeltaEvent {
657    /// Identifier of the thread emitting this plan delta.
658    pub thread_id: String,
659    /// Identifier of the current turn.
660    pub turn_id: String,
661    /// Identifier of the plan item receiving the delta.
662    pub item_id: String,
663    /// Incremental plan text chunk.
664    pub delta: String,
665    /// Runtime-owned recovery checkpoint; absent in older sessions.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub recovery: Option<Box<planning::PlanningRecoverySnapshot>>,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
671#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
672pub struct PlanApprovalRequestedEvent {
673    /// Identifier of the thread emitting the approval request.
674    pub thread_id: String,
675    /// Identifier of the turn that produced the plan.
676    pub turn_id: String,
677    /// Plan file associated with the approval request, when available.
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub plan_file: Option<String>,
680}
681
682#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
683#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
684#[serde(rename_all = "snake_case")]
685pub enum PlanApprovalDecision {
686    /// Execute with normal per-edit approval prompts.
687    Execute,
688    /// Execute with automatic edit approval enabled.
689    AutoAccept,
690    /// Execute the plan after rebuilding a fresh context.
691    FreshContext,
692    /// Keep planning and revise the proposed plan.
693    Revise,
694    /// Dismiss the approval request without implementing.
695    Cancel,
696    /// Hand the plan to the build primary agent.
697    SwitchBuild,
698    /// Hand the plan to the auto primary agent.
699    SwitchAuto,
700    /// Catch-all for decisions added in newer schema versions.
701    #[serde(other)]
702    Unknown,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
707pub struct PlanApprovalResolvedEvent {
708    /// Identifier of the thread emitting the approval decision.
709    pub thread_id: String,
710    /// Identifier of the turn in which the decision was made.
711    pub turn_id: String,
712    /// Decision selected by the user or active execution policy.
713    pub decision: PlanApprovalDecision,
714    /// Whether the decision came from policy rather than an interactive user action.
715    pub automatic: bool,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
719#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
720#[serde(rename_all = "snake_case")]
721pub enum HarnessEventKind {
722    PlanningStarted,
723    PlanningCompleted,
724    ContinuationStarted,
725    ContinuationSkipped,
726    /// A turn was blocked before success could be confirmed. Carries the fuse
727    /// counters so UI layers can render without correlating multiple events.
728    TurnBlocked,
729    /// A bounded tool-free recovery pass was scheduled after blocked calls.
730    BlockedRecoveryStarted,
731    /// A bounded tool-free recovery pass finished.
732    BlockedRecoveryFinished,
733    BlockedHandoffWritten,
734    /// The owning session resolved its archived blocked handoff and removed
735    /// the live recovery pointer.
736    BlockedHandoffResolved,
737    EvaluationStarted,
738    EvaluationPassed,
739    EvaluationFailed,
740    RevisionStarted,
741    EscalationTriggered,
742    EscalationBypassed,
743    VerificationStarted,
744    VerificationPassed,
745    VerificationFailed,
746    /// Agent recovered from a transient error (e.g. after retry succeeded).
747    ErrorRecovered,
748    /// A transient tool failure triggered an automatic retry attempt.
749    ToolRetryAttempted,
750    /// Latency record for a tool execution, emitted on turn completion.
751    ToolLatencyRecorded,
752    /// A checkpoint snapshot was created for the current turn.
753    SnapshotCreated,
754    /// A checkpoint snapshot was restored (rewind operation).
755    SnapshotRestored,
756    /// The user granted additional session tool-call capacity and the
757    /// pending call will be retried in the same turn.
758    SessionToolLimitIncreased,
759    /// The user granted additional tool-loop capacity for the current turn.
760    ToolLoopLimitIncreased,
761    /// A background subprocess or exec session reached a terminal state.
762    BackgroundSubprocessCompleted,
763    /// A native delegated agent's public status and ancestry were observed.
764    DelegatedAgentStatus,
765    /// A message was sent to a peer session's mailbox.
766    SessionMessageSent,
767    /// A peer session's message was received from this session's mailbox.
768    SessionMessageReceived,
769    /// A mailbox message expired before delivery.
770    SessionMessageExpired,
771}
772
773#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
775#[serde(rename_all = "snake_case")]
776pub enum PermissionDecision {
777    Allow,
778    Deny,
779    Cancelled,
780    Followup,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
784#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
785pub struct PermissionRequestedEvent {
786    /// Name of the tool that requires permission.
787    pub tool_name: String,
788    /// Identifier of the tool invocation that requested permission, when known.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub tool_call_id: Option<String>,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
794#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
795pub struct PermissionResolvedEvent {
796    /// Name of the tool that was permitted or denied.
797    pub tool_name: String,
798    /// Identifier of the tool invocation whose permission was resolved, when known.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub tool_call_id: Option<String>,
801    /// User's decision on the permission prompt.
802    pub decision: PermissionDecision,
803    /// Wall-clock time the prompt was visible, in milliseconds.
804    pub wait_ms: u64,
805}
806
807#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
808#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
809#[serde(rename_all = "snake_case")]
810pub enum InterjectionSource {
811    Direct,
812    Queue,
813}
814
815#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
817#[serde(rename_all = "snake_case")]
818pub enum RedirectKind {
819    Interjection,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
823#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
824pub struct InterjectedEvent {
825    /// Public correction text, when recorded by the runtime.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub text: Option<Box<String>>,
828    /// How the interjection reached the running turn.
829    pub source: InterjectionSource,
830    /// Number of image attachments that accompanied the interjection.
831    pub image_count: u32,
832    /// Always `Interjection` for this event; carried so the shared
833    /// `redirect_kind` field is queryable uniformly across redirect events.
834    pub redirect_kind: RedirectKind,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
838#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
839pub struct HarnessEventItem {
840    /// Specific harness event emitted by the runtime.
841    pub event: HarnessEventKind,
842    /// Optional human-readable message associated with the event.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub message: Option<String>,
845    /// Optional verification command associated with the event.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub command: Option<String>,
848    /// Optional artifact path associated with the event.
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub path: Option<String>,
851    /// Optional exit code associated with verification results.
852    #[serde(skip_serializing_if = "Option::is_none")]
853    pub exit_code: Option<i32>,
854    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub attempt: Option<u32>,
857    /// Canonical error category for retry/recovery events.
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub error_category: Option<String>,
860    /// Latency in milliseconds for tool-execution latency events.
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub duration_ms: Option<u64>,
863    /// Stable task identifier for background completion events.
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub task_id: Option<String>,
866    /// Child session identifier for background completion events; the peer
867    /// session for session-messaging events.
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub session_id: Option<String>,
870    /// Correlation id linking related harness events (a mailbox question to
871    /// its answer for session-messaging events).
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub correlation_id: Option<String>,
874    /// Exec-session identifier for background completion events.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub exec_session_id: Option<String>,
877    /// Terminal background status, when the event represents a subprocess.
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub status: Option<String>,
880    /// Archived transcript reference for background completion events.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub transcript_path: Option<String>,
883    /// Archived session reference for background completion events.
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub archive_path: Option<String>,
886}
887
888impl HarnessEventItem {
889    /// A harness event with every optional field unset. Prefer this over a
890    /// struct literal so new optional fields do not break construction sites.
891    #[must_use]
892    pub fn new(event: HarnessEventKind) -> Self {
893        Self {
894            event,
895            message: None,
896            command: None,
897            path: None,
898            exit_code: None,
899            attempt: None,
900            error_category: None,
901            duration_ms: None,
902            task_id: None,
903            session_id: None,
904            correlation_id: None,
905            exec_session_id: None,
906            status: None,
907            transcript_path: None,
908            archive_path: None,
909        }
910    }
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
914#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
915pub struct ErrorItem {
916    /// Error message displayed to the user or logs.
917    pub message: String,
918}
919
920#[cfg(test)]
921mod tests;