Skip to main content

vtcode_exec_events/
lib.rs

1#![allow(missing_docs)]
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    pub schema_version: String,
31    /// Concrete event emitted by the agent runtime.
32    pub 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 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 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 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 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,no_run
234    /// use vtcode_exec_events::OtelEmitter;
235    /// use opentelemetry::trace::TracerProvider;
236    ///
237    /// let provider = TracerProvider::default();
238    /// let tracer = provider.tracer("vtcode");
239    /// let mut emitter = OtelEmitter::new(tracer);
240    /// ```
241    pub struct OtelEmitter<T: Tracer> {
242        tracer: T,
243    }
244
245    impl<T: Tracer> OtelEmitter<T> {
246        pub fn new(tracer: T) -> Self {
247            Self { tracer }
248        }
249    }
250
251    impl<T: Tracer> EventEmitter for OtelEmitter<T> {
252        fn emit(&mut self, event: &ThreadEvent) {
253            let span_name = match event {
254                ThreadEvent::ThreadStarted(_) => "thread.started",
255                ThreadEvent::ThreadCompleted(_) => "thread.completed",
256                ThreadEvent::TurnStarted(_) => "turn.started",
257                ThreadEvent::TurnCompleted(_) => "turn.completed",
258                ThreadEvent::TurnFailed(_) => "turn.failed",
259                ThreadEvent::ItemStarted(_) => "item.started",
260                ThreadEvent::ItemUpdated(_) => "item.updated",
261                ThreadEvent::ItemCompleted(_) => "item.completed",
262                ThreadEvent::Error(_) => "error",
263                _ => "event",
264            };
265
266            let mut span = self.tracer.start(span_name);
267
268            match event {
269                ThreadEvent::ThreadStarted(e) => {
270                    span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
271                }
272                ThreadEvent::ThreadCompleted(e) => {
273                    if let Some(ref cost) = e.total_cost_usd {
274                        span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
275                    }
276                    span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
277                    span.set_attribute(KeyValue::new("output_tokens", e.usage.output_tokens as i64));
278                    span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
279                }
280                ThreadEvent::TurnCompleted(e) => {
281                    span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
282                    span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
283                }
284                ThreadEvent::ItemCompleted(e) => {
285                    if let ThreadItemDetails::Harness(harness) = &e.item.details {
286                        span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
287                        if let Some(ref msg) = harness.message {
288                            span.set_attribute(KeyValue::new("harness_message", msg.clone()));
289                        }
290                        if let Some(ref path) = harness.path {
291                            span.set_attribute(KeyValue::new("harness_path", path.clone()));
292                        }
293                        if let Some(dur) = harness.duration_ms {
294                            span.set_attribute(KeyValue::new("duration_ms", dur as i64));
295                        }
296                        let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
297                        if let Some(ref msg) = harness.message {
298                            event_attrs.push(KeyValue::new("message", msg.clone()));
299                        }
300                        span.add_event("harness_event", event_attrs);
301                    }
302                }
303                ThreadEvent::Error(e) => {
304                    span.set_status(Status::Error { description: e.message.clone().into() });
305                    span.set_attribute(KeyValue::new("error_message", e.message.clone()));
306                }
307                _ => {}
308            }
309
310            span.end();
311        }
312    }
313
314    pub use OtelEmitter as PublicOtelEmitter;
315}
316
317#[cfg(feature = "telemetry-otel")]
318pub use otel_support::PublicOtelEmitter as OtelEmitter;
319
320#[cfg(feature = "schema-export")]
321pub mod schema {
322    use schemars::{Schema, schema_for};
323
324    use super::{ThreadEvent, VersionedThreadEvent};
325
326    /// Generates a JSON Schema describing [`ThreadEvent`].
327    pub fn thread_event_schema() -> Schema {
328        schema_for!(ThreadEvent)
329    }
330
331    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
332    pub fn versioned_thread_event_schema() -> Schema {
333        schema_for!(VersionedThreadEvent)
334    }
335}
336
337/// Structured events emitted during autonomous execution.
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
340#[serde(tag = "type")]
341pub enum ThreadEvent {
342    /// Indicates that a new execution thread has started.
343    #[serde(rename = "thread.started")]
344    ThreadStarted(ThreadStartedEvent),
345    /// Indicates that an execution thread has reached a terminal outcome.
346    #[serde(rename = "thread.completed")]
347    ThreadCompleted(ThreadCompletedEvent),
348    /// Indicates that conversation compaction replaced older history with a boundary.
349    #[serde(rename = "thread.compact_boundary")]
350    ThreadCompactBoundary(ThreadCompactBoundaryEvent),
351    /// Marks the beginning of an execution turn.
352    #[serde(rename = "turn.started")]
353    TurnStarted(TurnStartedEvent),
354    /// Marks the completion of an execution turn.
355    #[serde(rename = "turn.completed")]
356    TurnCompleted(TurnCompletedEvent),
357    /// Marks a turn as failed with additional context.
358    #[serde(rename = "turn.failed")]
359    TurnFailed(TurnFailedEvent),
360    /// Indicates that an item has started processing.
361    #[serde(rename = "item.started")]
362    ItemStarted(ItemStartedEvent),
363    /// Indicates that an item has been updated.
364    #[serde(rename = "item.updated")]
365    ItemUpdated(ItemUpdatedEvent),
366    /// Indicates that an item reached a terminal state.
367    #[serde(rename = "item.completed")]
368    ItemCompleted(ItemCompletedEvent),
369    /// Emitted when a tool requires user permission before execution.
370    #[serde(rename = "permission.requested")]
371    PermissionRequested(PermissionRequestedEvent),
372    /// Emitted when the user resolves a permission prompt.
373    #[serde(rename = "permission.resolved")]
374    PermissionResolved(PermissionResolvedEvent),
375    /// A mid-turn user interjection was merged into the running turn.
376    #[serde(rename = "interjected")]
377    Interjected(InterjectedEvent),
378    /// Streaming delta for a plan item in Planning workflow.
379    #[serde(rename = "plan.delta")]
380    PlanDelta(PlanDeltaEvent),
381    /// Represents a fatal error.
382    #[serde(rename = "error")]
383    Error(ThreadErrorEvent),
384    /// Catch-all for unknown event types added in newer schema versions.
385    /// Preserves forward compatibility when older binaries read newer event streams.
386    #[serde(other)]
387    Unknown,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
392pub struct ThreadStartedEvent {
393    /// Unique identifier for the thread that was started.
394    pub thread_id: String,
395}
396
397#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
398#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
399#[serde(rename_all = "snake_case")]
400pub enum ThreadCompletionSubtype {
401    Success,
402    ErrorMaxTurns,
403    ErrorMaxBudgetUsd,
404    ErrorDuringExecution,
405    Cancelled,
406    /// Catch-all for unknown completion subtypes added in newer schema versions.
407    #[serde(other)]
408    Unknown,
409}
410
411impl ThreadCompletionSubtype {
412    pub const fn as_str(&self) -> &'static str {
413        match self {
414            Self::Success => "success",
415            Self::ErrorMaxTurns => "error_max_turns",
416            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
417            Self::ErrorDuringExecution => "error_during_execution",
418            Self::Cancelled => "cancelled",
419            Self::Unknown => "unknown",
420        }
421    }
422
423    pub const fn is_success(self) -> bool {
424        matches!(self, Self::Success)
425    }
426}
427
428#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
429#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
430#[serde(rename_all = "snake_case")]
431pub enum CompactionTrigger {
432    Manual,
433    Auto,
434    Recovery,
435    /// Compaction triggered by a mid-session switch of the main model or
436    /// provider, so the newly selected model starts from a clean summary.
437    ModelSwitch,
438    /// Catch-all for unknown triggers added in newer schema versions.
439    #[serde(other)]
440    Unknown,
441}
442
443impl CompactionTrigger {
444    pub const fn as_str(self) -> &'static str {
445        match self {
446            Self::Manual => "manual",
447            Self::Auto => "auto",
448            Self::Recovery => "recovery",
449            Self::ModelSwitch => "model_switch",
450            Self::Unknown => "unknown",
451        }
452    }
453}
454
455#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
457#[serde(rename_all = "snake_case")]
458pub enum CompactionMode {
459    Provider,
460    Local,
461    /// Catch-all for unknown modes added in newer schema versions.
462    #[serde(other)]
463    Unknown,
464}
465
466impl CompactionMode {
467    pub const fn as_str(self) -> &'static str {
468        match self {
469            Self::Provider => "provider",
470            Self::Local => "local",
471            Self::Unknown => "unknown",
472        }
473    }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
477#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
478pub struct ThreadCompletedEvent {
479    /// Stable thread identifier for the session.
480    pub thread_id: String,
481    /// Stable session identifier for the runtime that produced the thread.
482    pub session_id: String,
483    /// Coarse result category aligned with SDK-style terminal states.
484    pub subtype: ThreadCompletionSubtype,
485    /// VT Code-specific detailed outcome code.
486    pub outcome_code: String,
487    /// Final assistant result text when the thread completed successfully.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub result: Option<String>,
490    /// Provider stop reason or VT Code terminal reason when available.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub stop_reason: Option<String>,
493    /// Aggregated token usage across the thread.
494    pub usage: Usage,
495    /// Optional estimated total API cost for the thread.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub total_cost_usd: Option<serde_json::Number>,
498    /// Number of turns executed before completion.
499    pub num_turns: usize,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
504pub struct ThreadCompactBoundaryEvent {
505    /// Stable thread identifier for the session.
506    pub thread_id: String,
507    /// Whether compaction was triggered manually or automatically.
508    pub trigger: CompactionTrigger,
509    /// Whether the compaction boundary came from provider-native or local compaction.
510    pub mode: CompactionMode,
511    /// Number of messages before compaction.
512    pub original_message_count: usize,
513    /// Number of messages after compaction.
514    pub compacted_message_count: usize,
515    /// Optional persisted artifact containing the archived compaction summary/history.
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub history_artifact_path: Option<String>,
518}
519
520#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
521#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
522pub struct TurnStartedEvent {
523    /// Optional decomposition of the assembled first-request prefix so
524    /// downstream consumers can attribute token overhead without inventing
525    /// parallel event types.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub token_breakdown: Option<TokenBreakdown>,
528}
529
530/// Per-request token-budget breakdown for the assembled first-request prefix.
531#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
533pub struct TokenBreakdown {
534    /// System prompt text tokens.
535    pub system_prompt_tokens: u64,
536    /// On-wire tool schema tokens.
537    pub tool_schema_tokens: u64,
538    /// Instruction file tokens included in the prompt.
539    pub instruction_file_tokens: u64,
540    /// Message history text tokens.
541    pub message_history_tokens: u64,
542    /// Cache read tokens (served from prior turns).
543    pub cache_read_tokens: u64,
544    /// Cache write tokens (new cache entries created this turn).
545    pub cache_write_tokens: u64,
546    /// Tokens that missed cache (neither read nor written).
547    pub cache_miss_tokens: u64,
548    /// Subagent bootstrap tokens, if this turn spawned a child agent.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub subagent_bootstrap_tokens: Option<u64>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
554#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
555pub struct TurnCompletedEvent {
556    /// Token usage summary for the completed turn.
557    pub usage: Usage,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
561#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
562pub struct TurnFailedEvent {
563    /// Human-readable explanation describing why the turn failed.
564    pub message: String,
565    /// Optional token usage that was consumed before the failure occurred.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub usage: Option<Usage>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
572pub struct ThreadErrorEvent {
573    /// Fatal error message associated with the thread.
574    pub message: String,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
579pub struct Usage {
580    /// Number of prompt tokens processed during the turn.
581    pub input_tokens: u64,
582    /// Number of cached prompt tokens reused from previous turns.
583    pub cached_input_tokens: u64,
584    /// Number of cache-creation tokens charged during the turn.
585    pub cache_creation_tokens: u64,
586    /// Number of completion tokens generated by the model.
587    pub output_tokens: u64,
588}
589
590impl Usage {
591    /// Number of input tokens billed at the full input rate: neither served
592    /// from cache nor written to it. `input_tokens` is the total prompt token
593    /// count (uncached + cached + cache-creation), so both cached and
594    /// cache-creation tokens are subtracted out here.
595    #[must_use]
596    pub fn uncached_input_tokens(&self) -> u64 {
597        self.input_tokens
598            .saturating_sub(self.cached_input_tokens)
599            .saturating_sub(self.cache_creation_tokens)
600    }
601
602    /// Cache hit rate as a fraction (0.0 to 1.0): cached input over total input.
603    /// Returns `None` when no input tokens were recorded.
604    #[must_use]
605    pub fn cache_hit_rate(&self) -> Option<f64> {
606        if self.input_tokens == 0 {
607            return None;
608        }
609        Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
610    }
611
612    /// Human-readable summary of prompt cache efficiency.
613    #[must_use]
614    pub fn cache_summary(&self) -> String {
615        let total_input = self.input_tokens;
616        if total_input == 0 {
617            return "No input tokens recorded.".to_string();
618        }
619
620        let cached = self.cached_input_tokens;
621        let creation = self.cache_creation_tokens;
622        let uncached = self.uncached_input_tokens();
623        let rate = cached as f64 / total_input as f64 * 100.0;
624        format!(
625            "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
626             {creation} cache-creation, {uncached} uncached"
627        )
628    }
629
630    /// Accumulate another usage sample into this one.
631    pub fn add(&mut self, other: &Usage) {
632        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
633        self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
634        self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
635        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
636    }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
640#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
641pub struct ItemCompletedEvent {
642    /// Snapshot of the thread item that completed.
643    pub item: ThreadItem,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
648pub struct ItemStartedEvent {
649    /// Snapshot of the thread item that began processing.
650    pub item: ThreadItem,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
654#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
655pub struct ItemUpdatedEvent {
656    /// Snapshot of the thread item after it was updated.
657    pub item: ThreadItem,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
661#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
662pub struct PlanDeltaEvent {
663    /// Identifier of the thread emitting this plan delta.
664    pub thread_id: String,
665    /// Identifier of the current turn.
666    pub turn_id: String,
667    /// Identifier of the plan item receiving the delta.
668    pub item_id: String,
669    /// Incremental plan text chunk.
670    pub delta: String,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
675pub struct ThreadItem {
676    /// Stable identifier associated with the item.
677    pub id: String,
678    /// Embedded event details for the item type.
679    #[serde(flatten)]
680    pub details: ThreadItemDetails,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
684#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
685#[serde(tag = "type", rename_all = "snake_case")]
686pub enum ThreadItemDetails {
687    /// Message authored by the agent.
688    AgentMessage(AgentMessageItem),
689    /// Structured plan content authored by the agent in Planning workflow.
690    Plan(PlanItem),
691    /// Free-form reasoning text produced during a turn.
692    Reasoning(ReasoningItem),
693    /// Command execution lifecycle update for an actual shell/PTY process.
694    CommandExecution(Box<CommandExecutionItem>),
695    /// Tool invocation lifecycle update.
696    ToolInvocation(ToolInvocationItem),
697    /// Tool output lifecycle update tied to a tool invocation.
698    ToolOutput(ToolOutputItem),
699    /// File change summary associated with the turn.
700    FileChange(Box<FileChangeItem>),
701    /// MCP tool invocation status.
702    McpToolCall(McpToolCallItem),
703    /// Web search event emitted by a registered search provider.
704    WebSearch(WebSearchItem),
705    /// Harness-managed continuation or verification lifecycle event.
706    Harness(HarnessEventItem),
707    /// General error captured for auditing.
708    Error(ErrorItem),
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct AgentMessageItem {
714    /// Textual content of the agent message.
715    pub text: String,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
719#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
720pub struct PlanItem {
721    /// Plan markdown content.
722    pub text: String,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
727pub struct ReasoningItem {
728    /// Free-form reasoning content captured during planning.
729    pub text: String,
730    /// Optional stage of reasoning (e.g., "analysis", "plan", "verification").
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub stage: Option<String>,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
737#[serde(rename_all = "snake_case")]
738pub enum CommandExecutionStatus {
739    /// Command finished successfully.
740    #[default]
741    Completed,
742    /// Command failed (non-zero exit code or runtime error).
743    Failed,
744    /// Command is still running and may emit additional output.
745    InProgress,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
750pub struct CommandExecutionItem {
751    /// Tool or command identifier executed by the runner.
752    pub command: String,
753    /// Arguments passed to the tool invocation, when available.
754    #[serde(skip_serializing_if = "Option::is_none")]
755    pub arguments: Option<Value>,
756    /// Aggregated output emitted by the command.
757    #[serde(default)]
758    pub aggregated_output: String,
759    /// Exit code reported by the process, when available.
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub exit_code: Option<i32>,
762    /// Current status of the command execution.
763    pub status: CommandExecutionStatus,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
767#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
768#[serde(rename_all = "snake_case")]
769pub enum ToolCallStatus {
770    /// Tool finished successfully.
771    #[default]
772    Completed,
773    /// Tool failed.
774    Failed,
775    /// Tool is still running and may emit additional output.
776    InProgress,
777}
778
779/// Fine-grained outcome of a tool invocation lifecycle.
780///
781/// Mirrors the outcome taxonomy used by the runtime: `status` remains the
782/// coarse lifecycle signal (`Completed` / `Failed` / `InProgress`), while
783/// `outcome` captures *why* the invocation terminated. Consumers that only
784/// need success/failure can continue to read `status`; analytics and the UI
785/// layer use `outcome` for richer classification.
786#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
787#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
788#[serde(rename_all = "snake_case")]
789pub enum ToolOutcome {
790    /// Tool executed and returned a result.
791    #[default]
792    Success,
793    /// Tool executed but returned an error.
794    Error,
795    /// User rejected the permission prompt.
796    PermissionRejected,
797    /// User cancelled the permission prompt (e.g. Ctrl+C / Esc).
798    PermissionCancelled,
799    /// User provided a followup message instead of approving.
800    Followup,
801    /// A user-configured hook blocked execution.
802    HookDenied,
803    /// Tool not found or arguments couldn't be parsed.
804    InvalidTool,
805    /// Tool was running when the turn was cancelled.
806    Cancelled,
807}
808
809impl ToolOutcome {
810    #[must_use]
811    pub const fn is_terminal(self) -> bool {
812        !matches!(self, Self::Followup)
813    }
814}
815
816/// Map a terminal [`ToolCallStatus`] to its corresponding [`ToolOutcome`].
817///
818/// # Panics
819///
820/// Panics if `status` is [`ToolCallStatus::InProgress`], which is a non-terminal
821/// state and must never be passed to a completion-event emitter.
822#[must_use]
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}