Skip to main content

vtcode_exec_events/
lib.rs

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