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