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.23.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    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub completed_at: Option<Box<String>>,
475    /// Token usage summary for the completed turn.
476    pub usage: Usage,
477    /// Exec sessions still running when the turn ended (bounded, newest
478    /// first). Empty when every command settled within the turn. Correlates
479    /// with the next turn's transient exec-session resume hint without
480    /// requiring session-id reconstruction.
481    #[serde(
482        default,
483        skip_serializing_if = "Vec::is_empty",
484        deserialize_with = "deserialize_null_as_default"
485    )]
486    pub in_progress_exec_sessions: Vec<String>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
491pub struct TurnFailedEvent {
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub completed_at: Option<Box<String>>,
494    /// Human-readable explanation describing why the turn failed.
495    pub message: String,
496    /// Optional token usage that was consumed before the failure occurred.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub usage: Option<Usage>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
502#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
503pub struct TurnBlockedEvent {
504    /// Runtime terminal timestamp, absent from legacy events.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub completed_at: Option<String>,
507    /// Human-readable explanation describing why the turn was blocked.
508    pub message: String,
509    /// Display label of the last blocked tool call, when known.
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub last_tool: Option<String>,
512    /// Consecutive blocked tool calls observed this turn.
513    #[serde(default)]
514    pub blocked_streak: usize,
515    /// Total blocked tool calls observed this turn.
516    #[serde(default)]
517    pub blocked_total: usize,
518    /// Consecutive cap that was enforced.
519    #[serde(default)]
520    pub consecutive_cap: usize,
521    /// Total cap that was enforced.
522    #[serde(default)]
523    pub total_cap: usize,
524    /// Whether the fuse tripped while a tool-free recovery pass was active.
525    #[serde(default)]
526    pub recovery_active: bool,
527    /// Optional token usage that was consumed before the block occurred.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub usage: Option<Usage>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
533#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
534pub struct ThreadErrorEvent {
535    /// Fatal error message associated with the thread.
536    pub message: String,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
540#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
541pub struct AppSandboxDeniedEvent {
542    /// Tool whose result offered the refused app UI.
543    pub tool: String,
544    /// Apps tier that refused the UI (`off`).
545    pub tier: String,
546    /// Refused `ui://` URIs, in first-seen order (capped at 4).
547    pub uris: Vec<String>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
551#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
552pub struct Usage {
553    /// Number of prompt tokens processed during the turn.
554    #[serde(default, deserialize_with = "deserialize_null_as_default")]
555    pub input_tokens: u64,
556    /// Number of cached prompt tokens reused from previous turns.
557    #[serde(default, deserialize_with = "deserialize_null_as_default")]
558    pub cached_input_tokens: u64,
559    /// Number of cache-creation tokens charged during the turn.
560    #[serde(default, deserialize_with = "deserialize_null_as_default")]
561    pub cache_creation_tokens: u64,
562    /// Number of completion tokens generated by the model.
563    #[serde(default, deserialize_with = "deserialize_null_as_default")]
564    pub output_tokens: u64,
565}
566
567/// Serde helper that accepts explicit `null` as `T::default()` for
568/// backward-compatible checkpoint/diagnostics payloads. Pair with
569/// `#[serde(default, deserialize_with = "deserialize_null_as_default")]` so
570/// both missing and `null` fields degrade to the default instead of failing
571/// deserialization. Reused by downstream crates (e.g. `vtcode-core`
572/// snapshots) so the null-tolerance rule cannot drift between copies.
573pub fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
574where
575    D: serde::Deserializer<'de>,
576    T: Deserialize<'de> + Default,
577{
578    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
579}
580
581impl Usage {
582    /// Number of input tokens billed at the full input rate: neither served
583    /// from cache nor written to it. `input_tokens` is the total prompt token
584    /// count (uncached + cached + cache-creation), so both cached and
585    /// cache-creation tokens are subtracted out here.
586    #[must_use]
587    fn uncached_input_tokens(&self) -> u64 {
588        self.input_tokens
589            .saturating_sub(self.cached_input_tokens)
590            .saturating_sub(self.cache_creation_tokens)
591    }
592
593    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
594    /// Returns `None` when no input tokens were recorded.
595    #[must_use]
596    pub fn cache_hit_rate(&self) -> Option<f64> {
597        if self.input_tokens == 0 {
598            return None;
599        }
600        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
601    }
602
603    /// Human-readable summary of prompt cache efficiency.
604    #[must_use]
605    pub fn cache_summary(&self) -> String {
606        let total_input = self.input_tokens;
607        if total_input == 0 {
608            return "No input tokens recorded.".to_string();
609        }
610
611        let cached = self.cached_input_tokens;
612        let creation = self.cache_creation_tokens;
613        let uncached = self.uncached_input_tokens();
614        let rate = cached as f64 / total_input as f64 * 100.0;
615        format!(
616            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
617             {creation} cache-creation, {uncached} uncached"
618        )
619    }
620
621    /// Accumulate another usage sample into this one.
622    pub fn add(&mut self, other: &Usage) {
623        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
624        self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
625        self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
626        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
627    }
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
631#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
632pub struct ItemCompletedEvent {
633    /// Snapshot of the thread item that completed.
634    pub item: ThreadItem,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
638#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
639pub struct ItemStartedEvent {
640    /// Snapshot of the thread item that began processing.
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 ItemUpdatedEvent {
647    /// Snapshot of the thread item after it was updated.
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 PlanDeltaEvent {
654    /// Identifier of the thread emitting this plan delta.
655    pub thread_id: String,
656    /// Identifier of the current turn.
657    pub turn_id: String,
658    /// Identifier of the plan item receiving the delta.
659    pub item_id: String,
660    /// Incremental plan text chunk.
661    pub delta: String,
662    /// Runtime-owned recovery checkpoint; absent in older sessions.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub recovery: Option<Box<planning::PlanningRecoverySnapshot>>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
668#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
669pub struct PlanApprovalRequestedEvent {
670    /// Identifier of the thread emitting the approval request.
671    pub thread_id: String,
672    /// Identifier of the turn that produced the plan.
673    pub turn_id: String,
674    /// Plan file associated with the approval request, when available.
675    #[serde(skip_serializing_if = "Option::is_none")]
676    pub plan_file: Option<String>,
677}
678
679#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
680#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
681#[serde(rename_all = "snake_case")]
682pub enum PlanApprovalDecision {
683    /// Execute with normal per-edit approval prompts.
684    Execute,
685    /// Execute with automatic edit approval enabled.
686    AutoAccept,
687    /// Execute the plan after rebuilding a fresh context.
688    FreshContext,
689    /// Keep planning and revise the proposed plan.
690    Revise,
691    /// Dismiss the approval request without implementing.
692    Cancel,
693    /// Hand the plan to the build primary agent.
694    SwitchBuild,
695    /// Hand the plan to the auto primary agent.
696    SwitchAuto,
697    /// Catch-all for decisions added in newer schema versions.
698    #[serde(other)]
699    Unknown,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
703#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
704pub struct PlanApprovalResolvedEvent {
705    /// Identifier of the thread emitting the approval decision.
706    pub thread_id: String,
707    /// Identifier of the turn in which the decision was made.
708    pub turn_id: String,
709    /// Decision selected by the user or active execution policy.
710    pub decision: PlanApprovalDecision,
711    /// Whether the decision came from policy rather than an interactive user action.
712    pub automatic: bool,
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
716#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
717#[serde(rename_all = "snake_case")]
718pub enum HarnessEventKind {
719    PlanningStarted,
720    PlanningCompleted,
721    ContinuationStarted,
722    ContinuationSkipped,
723    /// A turn was blocked before success could be confirmed. Carries the fuse
724    /// counters so UI layers can render without correlating multiple events.
725    TurnBlocked,
726    /// A bounded tool-free recovery pass was scheduled after blocked calls.
727    BlockedRecoveryStarted,
728    /// A bounded tool-free recovery pass finished.
729    BlockedRecoveryFinished,
730    BlockedHandoffWritten,
731    /// The owning session resolved its archived blocked handoff and removed
732    /// the live recovery pointer.
733    BlockedHandoffResolved,
734    EvaluationStarted,
735    EvaluationPassed,
736    EvaluationFailed,
737    RevisionStarted,
738    EscalationTriggered,
739    EscalationBypassed,
740    VerificationStarted,
741    VerificationPassed,
742    VerificationFailed,
743    /// Agent recovered from a transient error (e.g. after retry succeeded).
744    ErrorRecovered,
745    /// A transient tool failure triggered an automatic retry attempt.
746    ToolRetryAttempted,
747    /// Latency record for a tool execution, emitted on turn completion.
748    ToolLatencyRecorded,
749    /// A checkpoint snapshot was created for the current turn.
750    SnapshotCreated,
751    /// A checkpoint snapshot was restored (rewind operation).
752    SnapshotRestored,
753    /// The user granted additional session tool-call capacity and the
754    /// pending call will be retried in the same turn.
755    SessionToolLimitIncreased,
756    /// The user granted additional tool-loop capacity for the current turn.
757    ToolLoopLimitIncreased,
758    /// A background subprocess or exec session reached a terminal state.
759    BackgroundSubprocessCompleted,
760    /// A native delegated agent's public status and ancestry were observed.
761    DelegatedAgentStatus,
762    /// A message was sent to a peer session's mailbox.
763    SessionMessageSent,
764    /// A peer session's message was received from this session's mailbox.
765    SessionMessageReceived,
766    /// A mailbox message expired before delivery.
767    SessionMessageExpired,
768}
769
770#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
771#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
772#[serde(rename_all = "snake_case")]
773pub enum PermissionDecision {
774    Allow,
775    Deny,
776    Cancelled,
777    Followup,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
782pub struct PermissionRequestedEvent {
783    /// Name of the tool that requires permission.
784    pub tool_name: String,
785    /// Identifier of the tool invocation that requested permission, when known.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub tool_call_id: Option<String>,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
791#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
792pub struct PermissionResolvedEvent {
793    /// Name of the tool that was permitted or denied.
794    pub tool_name: String,
795    /// Identifier of the tool invocation whose permission was resolved, when known.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub tool_call_id: Option<String>,
798    /// User's decision on the permission prompt.
799    pub decision: PermissionDecision,
800    /// Wall-clock time the prompt was visible, in milliseconds.
801    pub wait_ms: u64,
802}
803
804#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
805#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
806#[serde(rename_all = "snake_case")]
807pub enum InterjectionSource {
808    Direct,
809    Queue,
810}
811
812#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
813#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
814#[serde(rename_all = "snake_case")]
815pub enum RedirectKind {
816    Interjection,
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
820#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
821pub struct InterjectedEvent {
822    /// Public correction text, when recorded by the runtime.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub text: Option<Box<String>>,
825    /// How the interjection reached the running turn.
826    pub source: InterjectionSource,
827    /// Number of image attachments that accompanied the interjection.
828    pub image_count: u32,
829    /// Always `Interjection` for this event; carried so the shared
830    /// `redirect_kind` field is queryable uniformly across redirect events.
831    pub redirect_kind: RedirectKind,
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
835#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
836pub struct HarnessEventItem {
837    /// Specific harness event emitted by the runtime.
838    pub event: HarnessEventKind,
839    /// Optional human-readable message associated with the event.
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub message: Option<String>,
842    /// Optional verification command associated with the event.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub command: Option<String>,
845    /// Optional artifact path associated with the event.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub path: Option<String>,
848    /// Optional exit code associated with verification results.
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub exit_code: Option<i32>,
851    /// Retry/recovery attempt number (1-indexed). Only set for retry-related events.
852    #[serde(skip_serializing_if = "Option::is_none")]
853    pub attempt: Option<u32>,
854    /// Canonical error category for retry/recovery events.
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub error_category: Option<String>,
857    /// Latency in milliseconds for tool-execution latency events.
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub duration_ms: Option<u64>,
860    /// Stable task identifier for background completion events.
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub task_id: Option<String>,
863    /// Child session identifier for background completion events; the peer
864    /// session for session-messaging events.
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub session_id: Option<String>,
867    /// Correlation id linking related harness events (a mailbox question to
868    /// its answer for session-messaging events).
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub correlation_id: Option<String>,
871    /// Exec-session identifier for background completion events.
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub exec_session_id: Option<String>,
874    /// Terminal background status, when the event represents a subprocess.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub status: Option<String>,
877    /// Archived transcript reference for background completion events.
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub transcript_path: Option<String>,
880    /// Archived session reference for background completion events.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub archive_path: Option<String>,
883}
884
885impl HarnessEventItem {
886    /// A harness event with every optional field unset. Prefer this over a
887    /// struct literal so new optional fields do not break construction sites.
888    #[must_use]
889    pub fn new(event: HarnessEventKind) -> Self {
890        Self {
891            event,
892            message: None,
893            command: None,
894            path: None,
895            exit_code: None,
896            attempt: None,
897            error_category: None,
898            duration_ms: None,
899            task_id: None,
900            session_id: None,
901            correlation_id: None,
902            exec_session_id: None,
903            status: None,
904            transcript_path: None,
905            archive_path: None,
906        }
907    }
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
911#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
912pub struct ErrorItem {
913    /// Error message displayed to the user or logs.
914    pub message: String,
915}
916
917#[cfg(test)]
918mod tests;