1#![allow(missing_docs, dead_code, unused_imports)]
2use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub mod atif;
19pub mod trace;
20
21pub const EVENT_SCHEMA_VERSION: &str = "0.11.0";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
28pub struct VersionedThreadEvent {
29 schema_version: String,
31 event: ThreadEvent,
33}
34
35impl VersionedThreadEvent {
36 pub fn new(event: ThreadEvent) -> Self {
39 Self {
40 schema_version: EVENT_SCHEMA_VERSION.to_string(),
41 event,
42 }
43 }
44
45 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
57pub trait EventEmitter {
59 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#[cfg(feature = "serde-json")]
74pub(crate) mod json {
75 use super::{ThreadEvent, VersionedThreadEvent};
76
77 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
79 serde_json::to_value(event)
80 }
81
82 pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
84 serde_json::to_string(event)
85 }
86
87 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
89 serde_json::from_str(payload)
90 }
91
92 pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
94 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
95 }
96
97 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 #[derive(Debug, Clone)]
111 pub struct LogEmitter {
112 level: Level,
113 }
114
115 impl LogEmitter {
116 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 #[derive(Debug, Clone)]
153 pub struct TracingEmitter {
154 level: Level,
155 }
156
157 impl TracingEmitter {
158 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 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::ContextReset(_) => "context.reset",
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::ContextReset(e) => {
281 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
282 span.set_attribute(KeyValue::new("turn_id", e.turn_id.clone()));
283 span.set_attribute(KeyValue::new("plan_preserved", e.plan_preserved));
284 span.set_attribute(KeyValue::new(
285 "previous_context_usage_percent",
286 e.previous_context_usage_percent as i64,
287 ));
288 span.set_attribute(KeyValue::new("tool_budget_reset", e.tool_budget_reset));
289 }
290 ThreadEvent::TurnCompleted(e) => {
291 span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
292 span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
293 }
294 ThreadEvent::ItemCompleted(e) => {
295 if let ThreadItemDetails::Harness(harness) = &e.item.details {
296 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
297 if let Some(ref msg) = harness.message {
298 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
299 }
300 if let Some(ref path) = harness.path {
301 span.set_attribute(KeyValue::new("harness_path", path.clone()));
302 }
303 if let Some(dur) = harness.duration_ms {
304 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
305 }
306 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
307 if let Some(ref msg) = harness.message {
308 event_attrs.push(KeyValue::new("message", msg.clone()));
309 }
310 span.add_event("harness_event", event_attrs);
311 }
312 }
313 ThreadEvent::Error(e) => {
314 span.set_status(Status::Error { description: e.message.clone().into() });
315 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
316 }
317 _ => {}
318 }
319
320 span.end();
321 }
322 }
323
324 pub use OtelEmitter as PublicOtelEmitter;
325}
326
327#[cfg(feature = "telemetry-otel")]
328pub use otel_support::PublicOtelEmitter as OtelEmitter;
329
330#[cfg(feature = "schema-export")]
331pub mod schema {
332 use schemars::{Schema, schema_for};
333
334 use super::{ThreadEvent, VersionedThreadEvent};
335
336 pub fn thread_event_schema() -> Schema {
338 schema_for!(ThreadEvent)
339 }
340
341 pub fn versioned_thread_event_schema() -> Schema {
343 schema_for!(VersionedThreadEvent)
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
350#[serde(tag = "type")]
351pub enum ThreadEvent {
352 #[serde(rename = "thread.started")]
354 ThreadStarted(ThreadStartedEvent),
355 #[serde(rename = "thread.completed")]
357 ThreadCompleted(ThreadCompletedEvent),
358 #[serde(rename = "thread.compact_boundary")]
360 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
361 #[serde(rename = "context.reset")]
363 ContextReset(ContextResetEvent),
364 #[serde(rename = "turn.started")]
366 TurnStarted(TurnStartedEvent),
367 #[serde(rename = "turn.completed")]
369 TurnCompleted(TurnCompletedEvent),
370 #[serde(rename = "turn.failed")]
372 TurnFailed(TurnFailedEvent),
373 #[serde(rename = "item.started")]
375 ItemStarted(ItemStartedEvent),
376 #[serde(rename = "item.updated")]
378 ItemUpdated(ItemUpdatedEvent),
379 #[serde(rename = "item.completed")]
381 ItemCompleted(ItemCompletedEvent),
382 #[serde(rename = "permission.requested")]
384 PermissionRequested(PermissionRequestedEvent),
385 #[serde(rename = "permission.resolved")]
387 PermissionResolved(PermissionResolvedEvent),
388 #[serde(rename = "interjected")]
390 Interjected(InterjectedEvent),
391 #[serde(rename = "plan.delta")]
393 PlanDelta(PlanDeltaEvent),
394 #[serde(rename = "plan.approval.requested")]
396 PlanApprovalRequested(PlanApprovalRequestedEvent),
397 #[serde(rename = "plan.approval.resolved")]
399 PlanApprovalResolved(PlanApprovalResolvedEvent),
400 #[serde(rename = "error")]
402 Error(ThreadErrorEvent),
403 #[serde(other)]
406 Unknown,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
411pub struct ThreadStartedEvent {
412 pub thread_id: String,
414}
415
416#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
417#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
418#[serde(rename_all = "snake_case")]
419pub enum ThreadCompletionSubtype {
420 Success,
421 ErrorMaxTurns,
422 ErrorMaxBudgetUsd,
423 ErrorDuringExecution,
424 Cancelled,
425 #[serde(other)]
427 Unknown,
428}
429
430impl ThreadCompletionSubtype {
431 pub const fn as_str(&self) -> &'static str {
432 match self {
433 Self::Success => "success",
434 Self::ErrorMaxTurns => "error_max_turns",
435 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
436 Self::ErrorDuringExecution => "error_during_execution",
437 Self::Cancelled => "cancelled",
438 Self::Unknown => "unknown",
439 }
440 }
441
442 pub const fn is_success(self) -> bool {
443 matches!(self, Self::Success)
444 }
445}
446
447#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
448#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
449#[serde(rename_all = "snake_case")]
450pub enum CompactionTrigger {
451 Manual,
452 Auto,
453 Recovery,
454 ModelSwitch,
457 #[serde(other)]
459 Unknown,
460}
461
462impl CompactionTrigger {
463 pub const fn as_str(self) -> &'static str {
464 match self {
465 Self::Manual => "manual",
466 Self::Auto => "auto",
467 Self::Recovery => "recovery",
468 Self::ModelSwitch => "model_switch",
469 Self::Unknown => "unknown",
470 }
471 }
472}
473
474#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
475#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
476#[serde(rename_all = "snake_case")]
477pub enum CompactionMode {
478 Provider,
479 Local,
480 #[serde(other)]
482 Unknown,
483}
484
485impl CompactionMode {
486 pub const fn as_str(self) -> &'static str {
487 match self {
488 Self::Provider => "provider",
489 Self::Local => "local",
490 Self::Unknown => "unknown",
491 }
492 }
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
496#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
497pub struct ThreadCompletedEvent {
498 pub thread_id: String,
500 pub session_id: String,
502 pub subtype: ThreadCompletionSubtype,
504 pub outcome_code: String,
506 #[serde(skip_serializing_if = "Option::is_none")]
508 pub result: Option<String>,
509 #[serde(skip_serializing_if = "Option::is_none")]
511 pub stop_reason: Option<String>,
512 pub usage: Usage,
514 #[serde(skip_serializing_if = "Option::is_none")]
516 pub total_cost_usd: Option<serde_json::Number>,
517 pub num_turns: usize,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
522#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
523pub struct ThreadCompactBoundaryEvent {
524 pub thread_id: String,
526 pub trigger: CompactionTrigger,
528 pub mode: CompactionMode,
530 pub original_message_count: usize,
532 pub compacted_message_count: usize,
534 #[serde(skip_serializing_if = "Option::is_none")]
536 pub history_artifact_path: Option<String>,
537 #[serde(skip_serializing_if = "Option::is_none")]
539 pub previous_segment_id: Option<String>,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub new_segment_id: Option<String>,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub previous_prefix_hash: Option<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub new_prefix_hash: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub previous_catalog_hash: Option<String>,
552 #[serde(skip_serializing_if = "Option::is_none")]
554 pub new_catalog_hash: Option<String>,
555}
556
557#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
558#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
559#[serde(rename_all = "snake_case")]
560pub enum ContextResetTrigger {
561 PlanApproval,
563 #[serde(other)]
565 Unknown,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
569#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
570pub struct ContextResetEvent {
571 pub thread_id: String,
573 pub turn_id: String,
575 pub trigger: ContextResetTrigger,
577 pub plan_preserved: bool,
579 pub previous_context_usage_percent: u8,
581 pub tool_budget_reset: bool,
583}
584
585#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
587pub struct TurnStartedEvent {
588 #[serde(skip_serializing_if = "Option::is_none")]
592 token_breakdown: Option<TokenBreakdown>,
593}
594
595#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
597#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
598pub struct TokenBreakdown {
599 system_prompt_tokens: u64,
601 tool_schema_tokens: u64,
603 instruction_file_tokens: u64,
605 message_history_tokens: u64,
607 cache_read_tokens: u64,
609 cache_write_tokens: u64,
611 cache_miss_tokens: u64,
613 #[serde(skip_serializing_if = "Option::is_none")]
615 subagent_bootstrap_tokens: Option<u64>,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
619#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
620pub struct TurnCompletedEvent {
621 pub usage: Usage,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
626#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
627pub struct TurnFailedEvent {
628 pub message: String,
630 #[serde(skip_serializing_if = "Option::is_none")]
632 pub usage: Option<Usage>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
637pub struct ThreadErrorEvent {
638 pub message: String,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
644pub struct Usage {
645 pub input_tokens: u64,
647 pub cached_input_tokens: u64,
649 pub cache_creation_tokens: u64,
651 pub output_tokens: u64,
653}
654
655impl Usage {
656 #[must_use]
661 fn uncached_input_tokens(&self) -> u64 {
662 self.input_tokens
663 .saturating_sub(self.cached_input_tokens)
664 .saturating_sub(self.cache_creation_tokens)
665 }
666
667 #[must_use]
670 pub fn cache_hit_rate(&self) -> Option<f64> {
671 if self.input_tokens == 0 {
672 return None;
673 }
674 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
675 }
676
677 #[must_use]
679 pub fn cache_summary(&self) -> String {
680 let total_input = self.input_tokens;
681 if total_input == 0 {
682 return "No input tokens recorded.".to_string();
683 }
684
685 let cached = self.cached_input_tokens;
686 let creation = self.cache_creation_tokens;
687 let uncached = self.uncached_input_tokens();
688 let rate = cached as f64 / total_input as f64 * 100.0;
689 format!(
690 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
691 {creation} cache-creation, {uncached} uncached"
692 )
693 }
694
695 pub fn add(&mut self, other: &Usage) {
697 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
698 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
699 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
700 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
701 }
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
705#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
706pub struct ItemCompletedEvent {
707 pub item: ThreadItem,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct ItemStartedEvent {
714 pub item: ThreadItem,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
719#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
720pub struct ItemUpdatedEvent {
721 pub item: ThreadItem,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
727pub struct PlanDeltaEvent {
728 pub thread_id: String,
730 pub turn_id: String,
732 pub item_id: String,
734 pub delta: String,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
739#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
740pub struct PlanApprovalRequestedEvent {
741 pub thread_id: String,
743 pub turn_id: String,
745 #[serde(skip_serializing_if = "Option::is_none")]
747 pub plan_file: Option<String>,
748}
749
750#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
752#[serde(rename_all = "snake_case")]
753pub enum PlanApprovalDecision {
754 Execute,
756 AutoAccept,
758 FreshContext,
760 Revise,
762 Cancel,
764 SwitchBuild,
766 SwitchAuto,
768 #[serde(other)]
770 Unknown,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
775pub struct PlanApprovalResolvedEvent {
776 pub thread_id: String,
778 pub turn_id: String,
780 pub decision: PlanApprovalDecision,
782 pub automatic: bool,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
787#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
788pub struct ThreadItem {
789 pub id: String,
791 #[serde(flatten)]
793 pub details: ThreadItemDetails,
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
797#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
798#[serde(tag = "type", rename_all = "snake_case")]
799pub enum ThreadItemDetails {
800 AgentMessage(AgentMessageItem),
802 Plan(PlanItem),
804 Reasoning(ReasoningItem),
806 CommandExecution(Box<CommandExecutionItem>),
808 ToolInvocation(ToolInvocationItem),
810 ToolOutput(ToolOutputItem),
812 FileChange(Box<FileChangeItem>),
814 McpToolCall(McpToolCallItem),
816 WebSearch(WebSearchItem),
818 Harness(HarnessEventItem),
820 Error(ErrorItem),
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
825#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
826pub struct AgentMessageItem {
827 pub text: String,
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
832#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
833pub struct PlanItem {
834 pub text: String,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
839#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
840pub struct ReasoningItem {
841 pub text: String,
843 #[serde(skip_serializing_if = "Option::is_none")]
845 pub stage: Option<String>,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
850#[serde(rename_all = "snake_case")]
851pub enum CommandExecutionStatus {
852 #[default]
854 Completed,
855 Failed,
857 InProgress,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
862#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
863pub struct CommandExecutionItem {
864 pub command: String,
866 #[serde(skip_serializing_if = "Option::is_none")]
868 pub arguments: Option<Value>,
869 #[serde(default)]
871 pub aggregated_output: String,
872 #[serde(skip_serializing_if = "Option::is_none")]
874 pub exit_code: Option<i32>,
875 pub status: CommandExecutionStatus,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881#[serde(rename_all = "snake_case")]
882pub enum ToolCallStatus {
883 #[default]
885 Completed,
886 Failed,
888 InProgress,
890}
891
892#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
900#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
901#[serde(rename_all = "snake_case")]
902pub enum ToolOutcome {
903 #[default]
905 Success,
906 Error,
908 PermissionRejected,
910 PermissionCancelled,
912 Followup,
914 HookDenied,
916 InvalidTool,
918 Cancelled,
920}
921
922impl ToolOutcome {
923 #[must_use]
924 pub const fn is_terminal(self) -> bool {
925 !matches!(self, Self::Followup)
926 }
927}
928
929#[must_use]
936#[allow(clippy::unreachable)]
937pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
938 match status {
939 ToolCallStatus::Completed => ToolOutcome::Success,
940 ToolCallStatus::Failed => ToolOutcome::Error,
941 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
942 }
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
946#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
947pub struct ToolInvocationItem {
948 pub tool_name: String,
950 #[serde(skip_serializing_if = "Option::is_none")]
952 pub arguments: Option<Value>,
953 #[serde(skip_serializing_if = "Option::is_none")]
955 pub tool_call_id: Option<String>,
956 pub status: ToolCallStatus,
958 #[serde(skip_serializing_if = "Option::is_none")]
960 pub outcome: Option<ToolOutcome>,
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
964#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
965pub struct ToolOutputItem {
966 pub call_id: String,
968 #[serde(skip_serializing_if = "Option::is_none")]
970 pub tool_call_id: Option<String>,
971 #[serde(skip_serializing_if = "Option::is_none")]
973 pub spool_path: Option<String>,
974 #[serde(default)]
976 pub output: String,
977 #[serde(skip_serializing_if = "Option::is_none")]
979 pub exit_code: Option<i32>,
980 pub status: ToolCallStatus,
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
985#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
986pub struct FileChangeItem {
987 pub changes: Vec<FileUpdateChange>,
989 pub status: PatchApplyStatus,
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
994#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
995pub struct FileUpdateChange {
996 pub path: String,
998 pub kind: PatchChangeKind,
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1003#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1004#[serde(rename_all = "snake_case")]
1005pub enum PatchApplyStatus {
1006 Completed,
1008 Failed,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1013#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1014#[serde(rename_all = "snake_case")]
1015pub enum PatchChangeKind {
1016 Add,
1018 Delete,
1020 Update,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1025#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1026pub struct McpToolCallItem {
1027 pub tool_name: String,
1029 #[serde(skip_serializing_if = "Option::is_none")]
1031 pub arguments: Option<Value>,
1032 #[serde(skip_serializing_if = "Option::is_none")]
1034 pub result: Option<String>,
1035 #[serde(skip_serializing_if = "Option::is_none")]
1037 pub status: Option<McpToolCallStatus>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1041#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1042#[serde(rename_all = "snake_case")]
1043pub enum McpToolCallStatus {
1044 Started,
1046 Completed,
1048 Failed,
1050}
1051
1052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1053#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1054pub struct WebSearchItem {
1055 pub query: String,
1057 #[serde(skip_serializing_if = "Option::is_none")]
1059 pub provider: Option<String>,
1060 #[serde(skip_serializing_if = "Option::is_none")]
1062 pub results: Option<Vec<String>>,
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1067#[serde(rename_all = "snake_case")]
1068pub enum HarnessEventKind {
1069 PlanningStarted,
1070 PlanningCompleted,
1071 ContinuationStarted,
1072 ContinuationSkipped,
1073 BlockedHandoffWritten,
1074 EvaluationStarted,
1075 EvaluationPassed,
1076 EvaluationFailed,
1077 RevisionStarted,
1078 EscalationTriggered,
1079 EscalationBypassed,
1080 VerificationStarted,
1081 VerificationPassed,
1082 VerificationFailed,
1083 ErrorRecovered,
1085 ToolRetryAttempted,
1087 ToolLatencyRecorded,
1089 SnapshotCreated,
1091 SnapshotRestored,
1093}
1094
1095#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1096#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1097#[serde(rename_all = "snake_case")]
1098pub enum PermissionDecision {
1099 Allow,
1100 Deny,
1101 Cancelled,
1102 Followup,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1107pub struct PermissionRequestedEvent {
1108 pub tool_name: String,
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114pub struct PermissionResolvedEvent {
1115 pub tool_name: String,
1117 pub decision: PermissionDecision,
1119 pub wait_ms: u64,
1121}
1122
1123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1124#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1125#[serde(rename_all = "snake_case")]
1126pub enum InterjectionSource {
1127 Direct,
1128 Queue,
1129}
1130
1131#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1132#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1133#[serde(rename_all = "snake_case")]
1134pub enum RedirectKind {
1135 Interjection,
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1139#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1140pub struct InterjectedEvent {
1141 pub source: InterjectionSource,
1143 pub image_count: u32,
1145 pub redirect_kind: RedirectKind,
1148}
1149
1150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1151#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1152pub struct HarnessEventItem {
1153 pub event: HarnessEventKind,
1155 #[serde(skip_serializing_if = "Option::is_none")]
1157 pub message: Option<String>,
1158 #[serde(skip_serializing_if = "Option::is_none")]
1160 pub command: Option<String>,
1161 #[serde(skip_serializing_if = "Option::is_none")]
1163 pub path: Option<String>,
1164 #[serde(skip_serializing_if = "Option::is_none")]
1166 pub exit_code: Option<i32>,
1167 #[serde(skip_serializing_if = "Option::is_none")]
1169 pub attempt: Option<u32>,
1170 #[serde(skip_serializing_if = "Option::is_none")]
1172 pub error_category: Option<String>,
1173 #[serde(skip_serializing_if = "Option::is_none")]
1175 pub duration_ms: Option<u64>,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1179#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1180pub struct ErrorItem {
1181 pub message: String,
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use std::error::Error;
1189
1190 #[test]
1191 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1192 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1193 usage: Usage {
1194 input_tokens: 1,
1195 cached_input_tokens: 2,
1196 cache_creation_tokens: 0,
1197 output_tokens: 3,
1198 },
1199 });
1200
1201 let json = serde_json::to_string(&event)?;
1202 let restored: ThreadEvent = serde_json::from_str(&json)?;
1203
1204 assert_eq!(restored, event);
1205 Ok(())
1206 }
1207
1208 #[test]
1209 fn usage_uncached_input_tokens_saturates() {
1210 let usage = Usage {
1211 input_tokens: 1_000,
1212 cached_input_tokens: 800,
1213 cache_creation_tokens: 100,
1214 output_tokens: 50,
1215 };
1216 assert_eq!(usage.uncached_input_tokens(), 100);
1217
1218 let inconsistent = Usage {
1219 input_tokens: 100,
1220 cached_input_tokens: 150,
1221 cache_creation_tokens: 0,
1222 output_tokens: 0,
1223 };
1224 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1225
1226 let inconsistent_with_creation = Usage {
1227 input_tokens: 100,
1228 cached_input_tokens: 80,
1229 cache_creation_tokens: 50,
1230 output_tokens: 0,
1231 };
1232 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1233 }
1234
1235 #[test]
1236 fn usage_cache_hit_rate() {
1237 assert_eq!(Usage::default().cache_hit_rate(), None);
1238
1239 let usage = Usage {
1240 input_tokens: 1_000,
1241 cached_input_tokens: 750,
1242 cache_creation_tokens: 0,
1243 output_tokens: 0,
1244 };
1245 let rate = usage.cache_hit_rate().expect("rate");
1246 assert!((rate - 0.75).abs() < f64::EPSILON);
1247 }
1248
1249 #[test]
1250 fn usage_cache_summary_formats() {
1251 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1252
1253 let usage = Usage {
1254 input_tokens: 1_000,
1255 cached_input_tokens: 800,
1256 cache_creation_tokens: 100,
1257 output_tokens: 50,
1258 };
1259 assert_eq!(
1260 usage.cache_summary(),
1261 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1262 );
1263 }
1264
1265 #[test]
1266 fn usage_add_accumulates_all_fields_with_saturation() {
1267 let mut total = Usage {
1268 input_tokens: 100,
1269 cached_input_tokens: 20,
1270 cache_creation_tokens: 5,
1271 output_tokens: 10,
1272 };
1273 total.add(&Usage {
1274 input_tokens: 50,
1275 cached_input_tokens: 10,
1276 cache_creation_tokens: 2,
1277 output_tokens: 8,
1278 });
1279
1280 assert_eq!(total.input_tokens, 150);
1281 assert_eq!(total.cached_input_tokens, 30);
1282 assert_eq!(total.cache_creation_tokens, 7);
1283 assert_eq!(total.output_tokens, 18);
1284
1285 let mut saturating = Usage {
1286 input_tokens: u64::MAX,
1287 cached_input_tokens: u64::MAX,
1288 cache_creation_tokens: u64::MAX,
1289 output_tokens: u64::MAX,
1290 };
1291 saturating.add(&Usage {
1292 input_tokens: 1,
1293 cached_input_tokens: 1,
1294 cache_creation_tokens: 1,
1295 output_tokens: 1,
1296 });
1297 assert_eq!(saturating.input_tokens, u64::MAX);
1298 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1299 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1300 assert_eq!(saturating.output_tokens, u64::MAX);
1301 }
1302
1303 #[test]
1304 fn versioned_event_wraps_schema_version() {
1305 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1306
1307 let versioned = VersionedThreadEvent::new(event.clone());
1308
1309 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1310 assert_eq!(versioned.event, event);
1311 assert_eq!(versioned.into_event(), event);
1312 }
1313
1314 #[test]
1315 fn plan_approval_events_round_trip_with_decision() {
1316 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1317 thread_id: "thread-1".to_string(),
1318 turn_id: "turn-2".to_string(),
1319 plan_file: Some(".vtcode/plans/change.md".to_string()),
1320 });
1321 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1322 thread_id: "thread-1".to_string(),
1323 turn_id: "turn-3".to_string(),
1324 decision: PlanApprovalDecision::AutoAccept,
1325 automatic: false,
1326 });
1327
1328 for event in [requested, resolved] {
1329 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1330 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1331 assert_eq!(restored, event);
1332 }
1333 }
1334
1335 #[test]
1336 fn context_reset_event_round_trips_with_handoff_metadata() {
1337 let event = ThreadEvent::ContextReset(ContextResetEvent {
1338 thread_id: "thread-1".to_string(),
1339 turn_id: "turn-3".to_string(),
1340 trigger: ContextResetTrigger::PlanApproval,
1341 plan_preserved: true,
1342 previous_context_usage_percent: 7,
1343 tool_budget_reset: true,
1344 });
1345
1346 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1347 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1348 assert_eq!(restored, event);
1349 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1350 }
1351
1352 #[test]
1353 fn plan_approval_decision_uses_stable_wire_names() {
1354 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1355 thread_id: "thread-1".to_string(),
1356 turn_id: "turn-1".to_string(),
1357 decision: PlanApprovalDecision::SwitchBuild,
1358 automatic: false,
1359 });
1360
1361 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1362 assert_eq!(serialized["type"], "plan.approval.resolved");
1363 assert_eq!(serialized["decision"], "switch_build");
1364 }
1365
1366 #[test]
1367 fn plan_approval_decision_is_forward_compatible() {
1368 let payload = serde_json::json!({
1369 "type": "plan.approval.resolved",
1370 "thread_id": "thread-1",
1371 "turn_id": "turn-1",
1372 "decision": "future_decision",
1373 "automatic": true,
1374 });
1375 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1376 assert!(matches!(
1377 event,
1378 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1379 decision: PlanApprovalDecision::Unknown,
1380 automatic: true,
1381 ..
1382 })
1383 ));
1384 }
1385
1386 #[cfg(feature = "serde-json")]
1387 #[test]
1388 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1389 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1390 item: ThreadItem {
1391 id: "item-1".to_string(),
1392 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1393 },
1394 });
1395
1396 let payload = json::versioned_to_string(&event)?;
1397 let restored = json::versioned_from_str(&payload)?;
1398
1399 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1400 assert_eq!(restored.event, event);
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1406 for trigger in [
1407 CompactionTrigger::Manual,
1408 CompactionTrigger::Auto,
1409 CompactionTrigger::Recovery,
1410 CompactionTrigger::ModelSwitch,
1411 CompactionTrigger::Unknown,
1412 ] {
1413 let json = serde_json::to_string(&trigger).unwrap();
1414 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1415 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1416 assert_eq!(restored, trigger);
1417 }
1418 }
1419
1420 #[test]
1421 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1422 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1423 item: ThreadItem {
1424 id: "tool_1".to_string(),
1425 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1426 tool_name: "read_file".to_string(),
1427 arguments: Some(serde_json::json!({ "path": "README.md" })),
1428 tool_call_id: Some("tool_call_0".to_string()),
1429 status: ToolCallStatus::Completed,
1430 outcome: None,
1431 }),
1432 },
1433 });
1434
1435 let json = serde_json::to_string(&event)?;
1436 let restored: ThreadEvent = serde_json::from_str(&json)?;
1437
1438 assert_eq!(restored, event);
1439 Ok(())
1440 }
1441
1442 #[test]
1443 fn tool_outcome_serializes_snake_case() {
1444 for outcome in [
1445 ToolOutcome::Success,
1446 ToolOutcome::Error,
1447 ToolOutcome::PermissionRejected,
1448 ToolOutcome::PermissionCancelled,
1449 ToolOutcome::Followup,
1450 ToolOutcome::HookDenied,
1451 ToolOutcome::InvalidTool,
1452 ToolOutcome::Cancelled,
1453 ] {
1454 let json = serde_json::to_string(&outcome).unwrap();
1455 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1456 assert_eq!(restored, outcome);
1457 }
1458 }
1459
1460 #[test]
1461 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1462 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1463 item: ThreadItem {
1464 id: "tool_1".to_string(),
1465 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1466 tool_name: "exec_command".to_string(),
1467 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1468 tool_call_id: Some("tool_call_0".to_string()),
1469 status: ToolCallStatus::Failed,
1470 outcome: Some(ToolOutcome::PermissionRejected),
1471 }),
1472 },
1473 });
1474
1475 let json = serde_json::to_string(&event)?;
1476 let restored: ThreadEvent = serde_json::from_str(&json)?;
1477
1478 assert_eq!(restored, event);
1479 Ok(())
1480 }
1481
1482 #[test]
1483 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1484 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1485 item: ThreadItem {
1486 id: "tool_1:output".to_string(),
1487 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1488 call_id: "tool_1".to_string(),
1489 tool_call_id: Some("tool_call_0".to_string()),
1490 spool_path: None,
1491 output: "done".to_string(),
1492 exit_code: Some(0),
1493 status: ToolCallStatus::Completed,
1494 }),
1495 },
1496 });
1497
1498 let json = serde_json::to_string(&event)?;
1499 let restored: ThreadEvent = serde_json::from_str(&json)?;
1500
1501 assert_eq!(restored, event);
1502 Ok(())
1503 }
1504
1505 #[test]
1506 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1507 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1508 item: ThreadItem {
1509 id: "harness_1".to_string(),
1510 details: ThreadItemDetails::Harness(HarnessEventItem {
1511 event: HarnessEventKind::VerificationFailed,
1512 message: Some("cargo check failed".to_string()),
1513 command: Some("cargo check".to_string()),
1514 path: None,
1515 exit_code: Some(101),
1516 attempt: None,
1517 error_category: None,
1518 duration_ms: None,
1519 }),
1520 },
1521 });
1522
1523 let json = serde_json::to_string(&event)?;
1524 let restored: ThreadEvent = serde_json::from_str(&json)?;
1525
1526 assert_eq!(restored, event);
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1532 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1533 thread_id: "thread-1".to_string(),
1534 session_id: "session-1".to_string(),
1535 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1536 outcome_code: "budget_limit_reached".to_string(),
1537 result: None,
1538 stop_reason: Some("max_tokens".to_string()),
1539 usage: Usage {
1540 input_tokens: 10,
1541 cached_input_tokens: 4,
1542 cache_creation_tokens: 2,
1543 output_tokens: 5,
1544 },
1545 total_cost_usd: serde_json::Number::from_f64(1.25),
1546 num_turns: 3,
1547 });
1548
1549 let json = serde_json::to_string(&event)?;
1550 let restored: ThreadEvent = serde_json::from_str(&json)?;
1551
1552 assert_eq!(restored, event);
1553 Ok(())
1554 }
1555
1556 #[test]
1557 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1558 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1559 thread_id: "thread-1".to_string(),
1560 trigger: CompactionTrigger::Recovery,
1561 mode: CompactionMode::Provider,
1562 original_message_count: 12,
1563 compacted_message_count: 5,
1564 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1565 previous_segment_id: Some("segment-0001".to_string()),
1566 new_segment_id: Some("segment-0002".to_string()),
1567 previous_prefix_hash: Some("prefix-before".to_string()),
1568 new_prefix_hash: Some("prefix-after".to_string()),
1569 previous_catalog_hash: Some("catalog-before".to_string()),
1570 new_catalog_hash: Some("catalog-after".to_string()),
1571 });
1572
1573 let json = serde_json::to_string(&event)?;
1574 let restored: ThreadEvent = serde_json::from_str(&json)?;
1575
1576 assert_eq!(restored, event);
1577 Ok(())
1578 }
1579
1580 #[test]
1581 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1582 let payload = r#"{
1583 "type":"thread.compact_boundary",
1584 "thread_id":"thread-1",
1585 "trigger":"recovery",
1586 "mode":"provider",
1587 "original_message_count":12,
1588 "compacted_message_count":5
1589 }"#;
1590
1591 let restored: ThreadEvent = serde_json::from_str(payload)?;
1592 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1593 panic!("expected thread.compact_boundary event");
1594 };
1595
1596 assert_eq!(event.thread_id, "thread-1");
1597 assert_eq!(event.history_artifact_path, None);
1598 assert_eq!(event.previous_segment_id, None);
1599 assert_eq!(event.new_segment_id, None);
1600 assert_eq!(event.previous_prefix_hash, None);
1601 assert_eq!(event.new_prefix_hash, None);
1602 assert_eq!(event.previous_catalog_hash, None);
1603 assert_eq!(event.new_catalog_hash, None);
1604 Ok(())
1605 }
1606}