1#![allow(
2 missing_docs,
3 dead_code,
4 unused_imports,
5 reason = "Intentional compatibility, platform, or test-only suppression."
6)]
7use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub mod atif;
24pub mod trace;
25
26pub const EVENT_SCHEMA_VERSION: &str = "0.11.0";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
33pub struct VersionedThreadEvent {
34 schema_version: String,
36 event: ThreadEvent,
38}
39
40impl VersionedThreadEvent {
41 pub fn new(event: ThreadEvent) -> Self {
44 Self {
45 schema_version: EVENT_SCHEMA_VERSION.to_string(),
46 event,
47 }
48 }
49
50 pub fn into_event(self) -> ThreadEvent {
52 self.event
53 }
54}
55
56impl From<ThreadEvent> for VersionedThreadEvent {
57 fn from(event: ThreadEvent) -> Self {
58 Self::new(event)
59 }
60}
61
62pub trait EventEmitter {
64 fn emit(&mut self, event: &ThreadEvent);
66}
67
68impl<F> EventEmitter for F
69where
70 F: FnMut(&ThreadEvent),
71{
72 fn emit(&mut self, event: &ThreadEvent) {
73 self(event);
74 }
75}
76
77#[cfg(feature = "serde-json")]
79pub(crate) mod json {
80 use super::{ThreadEvent, VersionedThreadEvent};
81
82 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
84 serde_json::to_value(event)
85 }
86
87 pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
89 serde_json::to_string(event)
90 }
91
92 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
94 serde_json::from_str(payload)
95 }
96
97 pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
99 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
100 }
101
102 pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
104 serde_json::from_str(payload)
105 }
106}
107
108#[cfg(feature = "telemetry-log")]
109mod log_support {
110 use log::Level;
111
112 use super::{EventEmitter, ThreadEvent, json};
113
114 #[derive(Debug, Clone)]
116 pub struct LogEmitter {
117 level: Level,
118 }
119
120 impl LogEmitter {
121 pub fn new(level: Level) -> Self {
123 Self { level }
124 }
125 }
126
127 impl Default for LogEmitter {
128 fn default() -> Self {
129 Self { level: Level::Info }
130 }
131 }
132
133 impl EventEmitter for LogEmitter {
134 fn emit(&mut self, event: &ThreadEvent) {
135 if log::log_enabled!(self.level) {
136 match json::to_string(event) {
137 Ok(serialized) => log::log!(self.level, "{serialized}"),
138 Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
139 }
140 }
141 }
142 }
143
144 pub use LogEmitter as PublicLogEmitter;
145}
146
147#[cfg(feature = "telemetry-log")]
148pub use log_support::PublicLogEmitter as LogEmitter;
149
150#[cfg(feature = "telemetry-tracing")]
151mod tracing_support {
152 use tracing::Level;
153
154 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
155
156 #[derive(Debug, Clone)]
158 pub struct TracingEmitter {
159 level: Level,
160 }
161
162 impl TracingEmitter {
163 pub fn new(level: Level) -> Self {
165 Self { level }
166 }
167 }
168
169 impl Default for TracingEmitter {
170 fn default() -> Self {
171 Self { level: Level::INFO }
172 }
173 }
174
175 impl EventEmitter for TracingEmitter {
176 fn emit(&mut self, event: &ThreadEvent) {
177 match self.level {
178 Level::TRACE => tracing::event!(
179 target: "vtcode_exec_events",
180 Level::TRACE,
181 schema_version = EVENT_SCHEMA_VERSION,
182 event = ?VersionedThreadEvent::new(event.clone()),
183 "vtcode_exec_event"
184 ),
185 Level::DEBUG => tracing::event!(
186 target: "vtcode_exec_events",
187 Level::DEBUG,
188 schema_version = EVENT_SCHEMA_VERSION,
189 event = ?VersionedThreadEvent::new(event.clone()),
190 "vtcode_exec_event"
191 ),
192 Level::INFO => tracing::event!(
193 target: "vtcode_exec_events",
194 Level::INFO,
195 schema_version = EVENT_SCHEMA_VERSION,
196 event = ?VersionedThreadEvent::new(event.clone()),
197 "vtcode_exec_event"
198 ),
199 Level::WARN => tracing::event!(
200 target: "vtcode_exec_events",
201 Level::WARN,
202 schema_version = EVENT_SCHEMA_VERSION,
203 event = ?VersionedThreadEvent::new(event.clone()),
204 "vtcode_exec_event"
205 ),
206 Level::ERROR => tracing::event!(
207 target: "vtcode_exec_events",
208 Level::ERROR,
209 schema_version = EVENT_SCHEMA_VERSION,
210 event = ?VersionedThreadEvent::new(event.clone()),
211 "vtcode_exec_event"
212 ),
213 }
214 }
215 }
216
217 pub use TracingEmitter as PublicTracingEmitter;
218}
219
220#[cfg(feature = "telemetry-tracing")]
221pub use tracing_support::PublicTracingEmitter as TracingEmitter;
222
223#[cfg(feature = "telemetry-otel")]
224mod otel_support {
225 use opentelemetry::KeyValue;
226 use opentelemetry::trace::{Span, Status, Tracer};
227
228 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
229
230 pub struct OtelEmitter<T: Tracer> {
246 tracer: T,
247 }
248
249 impl<T: Tracer> OtelEmitter<T> {
250 pub fn new(tracer: T) -> Self {
251 Self { tracer }
252 }
253 }
254
255 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
256 fn emit(&mut self, event: &ThreadEvent) {
257 let span_name = match event {
258 ThreadEvent::ThreadStarted(_) => "thread.started",
259 ThreadEvent::ThreadCompleted(_) => "thread.completed",
260 ThreadEvent::ContextReset(_) => "context.reset",
261 ThreadEvent::TurnStarted(_) => "turn.started",
262 ThreadEvent::TurnCompleted(_) => "turn.completed",
263 ThreadEvent::TurnFailed(_) => "turn.failed",
264 ThreadEvent::ItemStarted(_) => "item.started",
265 ThreadEvent::ItemUpdated(_) => "item.updated",
266 ThreadEvent::ItemCompleted(_) => "item.completed",
267 ThreadEvent::Error(_) => "error",
268 _ => "event",
269 };
270
271 let mut span = self.tracer.start(span_name);
272
273 match event {
274 ThreadEvent::ThreadStarted(e) => {
275 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
276 }
277 ThreadEvent::ThreadCompleted(e) => {
278 if let Some(ref cost) = e.total_cost_usd {
279 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
280 }
281 span.set_attribute(KeyValue::new(
282 "input_tokens",
283 i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
284 ));
285 span.set_attribute(KeyValue::new(
286 "output_tokens",
287 i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
288 ));
289 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
290 }
291 ThreadEvent::ContextReset(e) => {
292 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
293 span.set_attribute(KeyValue::new("turn_id", e.turn_id.clone()));
294 span.set_attribute(KeyValue::new("plan_preserved", e.plan_preserved));
295 span.set_attribute(KeyValue::new(
296 "previous_context_usage_percent",
297 e.previous_context_usage_percent as i64,
298 ));
299 span.set_attribute(KeyValue::new("tool_budget_reset", e.tool_budget_reset));
300 }
301 ThreadEvent::TurnCompleted(e) => {
302 span.set_attribute(KeyValue::new(
303 "turn_input_tokens",
304 i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
305 ));
306 span.set_attribute(KeyValue::new(
307 "turn_output_tokens",
308 i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
309 ));
310 }
311 ThreadEvent::ItemCompleted(e) => {
312 if let ThreadItemDetails::Harness(harness) = &e.item.details {
313 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
314 if let Some(ref msg) = harness.message {
315 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
316 }
317 if let Some(ref path) = harness.path {
318 span.set_attribute(KeyValue::new("harness_path", path.clone()));
319 }
320 if let Some(dur) = harness.duration_ms {
321 span.set_attribute(KeyValue::new("duration_ms", i64::try_from(dur).unwrap_or(i64::MAX)));
322 }
323 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
324 if let Some(ref msg) = harness.message {
325 event_attrs.push(KeyValue::new("message", msg.clone()));
326 }
327 span.add_event("harness_event", event_attrs);
328 }
329 }
330 ThreadEvent::Error(e) => {
331 span.set_status(Status::Error { description: e.message.clone().into() });
332 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
333 }
334 _ => {}
335 }
336
337 span.end();
338 }
339 }
340
341 pub use OtelEmitter as PublicOtelEmitter;
342}
343
344#[cfg(feature = "telemetry-otel")]
345pub use otel_support::PublicOtelEmitter as OtelEmitter;
346
347#[cfg(feature = "schema-export")]
348pub mod schema {
349 use schemars::{Schema, schema_for};
350
351 use super::{ThreadEvent, VersionedThreadEvent};
352
353 pub fn thread_event_schema() -> Schema {
355 schema_for!(ThreadEvent)
356 }
357
358 pub fn versioned_thread_event_schema() -> Schema {
360 schema_for!(VersionedThreadEvent)
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
366#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
367#[serde(tag = "type")]
368pub enum ThreadEvent {
369 #[serde(rename = "thread.started")]
371 ThreadStarted(ThreadStartedEvent),
372 #[serde(rename = "thread.completed")]
374 ThreadCompleted(ThreadCompletedEvent),
375 #[serde(rename = "thread.compact_boundary")]
377 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
378 #[serde(rename = "context.reset")]
380 ContextReset(ContextResetEvent),
381 #[serde(rename = "turn.started")]
383 TurnStarted(TurnStartedEvent),
384 #[serde(rename = "turn.completed")]
386 TurnCompleted(TurnCompletedEvent),
387 #[serde(rename = "turn.failed")]
389 TurnFailed(TurnFailedEvent),
390 #[serde(rename = "item.started")]
392 ItemStarted(ItemStartedEvent),
393 #[serde(rename = "item.updated")]
395 ItemUpdated(ItemUpdatedEvent),
396 #[serde(rename = "item.completed")]
398 ItemCompleted(ItemCompletedEvent),
399 #[serde(rename = "permission.requested")]
401 PermissionRequested(PermissionRequestedEvent),
402 #[serde(rename = "permission.resolved")]
404 PermissionResolved(PermissionResolvedEvent),
405 #[serde(rename = "interjected")]
407 Interjected(InterjectedEvent),
408 #[serde(rename = "plan.delta")]
410 PlanDelta(PlanDeltaEvent),
411 #[serde(rename = "plan.approval.requested")]
413 PlanApprovalRequested(PlanApprovalRequestedEvent),
414 #[serde(rename = "plan.approval.resolved")]
416 PlanApprovalResolved(PlanApprovalResolvedEvent),
417 #[serde(rename = "error")]
419 Error(ThreadErrorEvent),
420 #[serde(other)]
423 Unknown,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
427#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
428pub struct ThreadStartedEvent {
429 pub thread_id: String,
431}
432
433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
434#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
435#[serde(rename_all = "snake_case")]
436pub enum ThreadCompletionSubtype {
437 Success,
438 ErrorMaxTurns,
439 ErrorMaxBudgetUsd,
440 ErrorDuringExecution,
441 Cancelled,
442 #[serde(other)]
444 Unknown,
445}
446
447impl ThreadCompletionSubtype {
448 pub const fn as_str(&self) -> &'static str {
449 match self {
450 Self::Success => "success",
451 Self::ErrorMaxTurns => "error_max_turns",
452 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
453 Self::ErrorDuringExecution => "error_during_execution",
454 Self::Cancelled => "cancelled",
455 Self::Unknown => "unknown",
456 }
457 }
458
459 pub const fn is_success(self) -> bool {
460 matches!(self, Self::Success)
461 }
462}
463
464#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
465#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
466#[serde(rename_all = "snake_case")]
467pub enum CompactionTrigger {
468 Manual,
469 Auto,
470 Recovery,
471 ModelSwitch,
474 #[serde(other)]
476 Unknown,
477}
478
479impl CompactionTrigger {
480 pub const fn as_str(self) -> &'static str {
481 match self {
482 Self::Manual => "manual",
483 Self::Auto => "auto",
484 Self::Recovery => "recovery",
485 Self::ModelSwitch => "model_switch",
486 Self::Unknown => "unknown",
487 }
488 }
489}
490
491#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
493#[serde(rename_all = "snake_case")]
494pub enum CompactionMode {
495 Provider,
496 Local,
497 #[serde(other)]
499 Unknown,
500}
501
502impl CompactionMode {
503 pub const fn as_str(self) -> &'static str {
504 match self {
505 Self::Provider => "provider",
506 Self::Local => "local",
507 Self::Unknown => "unknown",
508 }
509 }
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
514pub struct ThreadCompletedEvent {
515 pub thread_id: String,
517 pub session_id: String,
519 pub subtype: ThreadCompletionSubtype,
521 pub outcome_code: String,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub result: Option<String>,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub stop_reason: Option<String>,
529 pub usage: Usage,
531 #[serde(skip_serializing_if = "Option::is_none")]
533 pub total_cost_usd: Option<serde_json::Number>,
534 pub num_turns: usize,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
540pub struct ThreadCompactBoundaryEvent {
541 pub thread_id: String,
543 pub trigger: CompactionTrigger,
545 pub mode: CompactionMode,
547 pub original_message_count: usize,
549 pub compacted_message_count: usize,
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub history_artifact_path: Option<String>,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub previous_segment_id: Option<String>,
557 #[serde(skip_serializing_if = "Option::is_none")]
559 pub new_segment_id: Option<String>,
560 #[serde(skip_serializing_if = "Option::is_none")]
562 pub previous_prefix_hash: Option<String>,
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub new_prefix_hash: Option<String>,
566 #[serde(skip_serializing_if = "Option::is_none")]
568 pub previous_catalog_hash: Option<String>,
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub new_catalog_hash: Option<String>,
572}
573
574#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
575#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
576#[serde(rename_all = "snake_case")]
577pub enum ContextResetTrigger {
578 PlanApproval,
580 #[serde(other)]
582 Unknown,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
587pub struct ContextResetEvent {
588 pub thread_id: String,
590 pub turn_id: String,
592 pub trigger: ContextResetTrigger,
594 pub plan_preserved: bool,
596 pub previous_context_usage_percent: u8,
598 pub tool_budget_reset: bool,
600}
601
602#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
604pub struct TurnStartedEvent {
605 #[serde(skip_serializing_if = "Option::is_none")]
609 token_breakdown: Option<TokenBreakdown>,
610}
611
612#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
615pub struct TokenBreakdown {
616 system_prompt_tokens: u64,
618 tool_schema_tokens: u64,
620 instruction_file_tokens: u64,
622 message_history_tokens: u64,
624 cache_read_tokens: u64,
626 cache_write_tokens: u64,
628 cache_miss_tokens: u64,
630 #[serde(skip_serializing_if = "Option::is_none")]
632 subagent_bootstrap_tokens: Option<u64>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
637pub struct TurnCompletedEvent {
638 pub usage: Usage,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
644pub struct TurnFailedEvent {
645 pub message: String,
647 #[serde(skip_serializing_if = "Option::is_none")]
649 pub usage: Option<Usage>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
654pub struct ThreadErrorEvent {
655 pub message: String,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
660#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
661pub struct Usage {
662 pub input_tokens: u64,
664 pub cached_input_tokens: u64,
666 pub cache_creation_tokens: u64,
668 pub output_tokens: u64,
670}
671
672impl Usage {
673 #[must_use]
678 fn uncached_input_tokens(&self) -> u64 {
679 self.input_tokens
680 .saturating_sub(self.cached_input_tokens)
681 .saturating_sub(self.cache_creation_tokens)
682 }
683
684 #[must_use]
687 pub fn cache_hit_rate(&self) -> Option<f64> {
688 if self.input_tokens == 0 {
689 return None;
690 }
691 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
692 }
693
694 #[must_use]
696 pub fn cache_summary(&self) -> String {
697 let total_input = self.input_tokens;
698 if total_input == 0 {
699 return "No input tokens recorded.".to_string();
700 }
701
702 let cached = self.cached_input_tokens;
703 let creation = self.cache_creation_tokens;
704 let uncached = self.uncached_input_tokens();
705 let rate = cached as f64 / total_input as f64 * 100.0;
706 format!(
707 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
708 {creation} cache-creation, {uncached} uncached"
709 )
710 }
711
712 pub fn add(&mut self, other: &Usage) {
714 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
715 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
716 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
717 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
723pub struct ItemCompletedEvent {
724 pub item: ThreadItem,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
730pub struct ItemStartedEvent {
731 pub item: ThreadItem,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
737pub struct ItemUpdatedEvent {
738 pub item: ThreadItem,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
744pub struct PlanDeltaEvent {
745 pub thread_id: String,
747 pub turn_id: String,
749 pub item_id: String,
751 pub delta: String,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
756#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
757pub struct PlanApprovalRequestedEvent {
758 pub thread_id: String,
760 pub turn_id: String,
762 #[serde(skip_serializing_if = "Option::is_none")]
764 pub plan_file: Option<String>,
765}
766
767#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
768#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
769#[serde(rename_all = "snake_case")]
770pub enum PlanApprovalDecision {
771 Execute,
773 AutoAccept,
775 FreshContext,
777 Revise,
779 Cancel,
781 SwitchBuild,
783 SwitchAuto,
785 #[serde(other)]
787 Unknown,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
791#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
792pub struct PlanApprovalResolvedEvent {
793 pub thread_id: String,
795 pub turn_id: String,
797 pub decision: PlanApprovalDecision,
799 pub automatic: bool,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
804#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
805pub struct ThreadItem {
806 pub id: String,
808 #[serde(flatten)]
810 pub details: ThreadItemDetails,
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
814#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
815#[serde(tag = "type", rename_all = "snake_case")]
816pub enum ThreadItemDetails {
817 AgentMessage(AgentMessageItem),
819 Plan(PlanItem),
821 Reasoning(ReasoningItem),
823 CommandExecution(Box<CommandExecutionItem>),
825 ToolInvocation(ToolInvocationItem),
827 ToolOutput(ToolOutputItem),
829 FileChange(Box<FileChangeItem>),
831 McpToolCall(McpToolCallItem),
833 WebSearch(WebSearchItem),
835 Harness(HarnessEventItem),
837 Error(ErrorItem),
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
842#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
843pub struct AgentMessageItem {
844 pub text: String,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
850pub struct PlanItem {
851 pub text: String,
853}
854
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
856#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
857pub struct ReasoningItem {
858 pub text: String,
860 #[serde(skip_serializing_if = "Option::is_none")]
862 pub stage: Option<String>,
863}
864
865#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
866#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
867#[serde(rename_all = "snake_case")]
868pub enum CommandExecutionStatus {
869 #[default]
871 Completed,
872 Failed,
874 InProgress,
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
879#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
880pub struct CommandExecutionItem {
881 pub command: String,
883 #[serde(skip_serializing_if = "Option::is_none")]
885 pub arguments: Option<Value>,
886 #[serde(default)]
888 pub aggregated_output: String,
889 #[serde(skip_serializing_if = "Option::is_none")]
891 pub exit_code: Option<i32>,
892 pub status: CommandExecutionStatus,
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
897#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
898#[serde(rename_all = "snake_case")]
899pub enum ToolCallStatus {
900 #[default]
902 Completed,
903 Failed,
905 InProgress,
907}
908
909#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
917#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
918#[serde(rename_all = "snake_case")]
919pub enum ToolOutcome {
920 #[default]
922 Success,
923 Error,
925 PermissionRejected,
927 PermissionCancelled,
929 Followup,
931 HookDenied,
933 InvalidTool,
935 Cancelled,
937}
938
939impl ToolOutcome {
940 #[must_use]
941 pub const fn is_terminal(self) -> bool {
942 !matches!(self, Self::Followup)
943 }
944}
945
946#[must_use]
953#[allow(
954 clippy::unreachable,
955 reason = "Intentional compatibility, platform, or test-only suppression."
956)]
957pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
958 match status {
959 ToolCallStatus::Completed => ToolOutcome::Success,
960 ToolCallStatus::Failed => ToolOutcome::Error,
961 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
962 }
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
966#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
967pub struct ToolInvocationItem {
968 pub tool_name: String,
970 #[serde(skip_serializing_if = "Option::is_none")]
972 pub arguments: Option<Value>,
973 #[serde(skip_serializing_if = "Option::is_none")]
975 pub tool_call_id: Option<String>,
976 pub status: ToolCallStatus,
978 #[serde(skip_serializing_if = "Option::is_none")]
980 pub outcome: Option<ToolOutcome>,
981}
982
983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
985pub struct ToolOutputItem {
986 pub call_id: String,
988 #[serde(skip_serializing_if = "Option::is_none")]
990 pub tool_call_id: Option<String>,
991 #[serde(skip_serializing_if = "Option::is_none")]
993 pub spool_path: Option<String>,
994 #[serde(default)]
996 pub output: String,
997 #[serde(skip_serializing_if = "Option::is_none")]
999 pub exit_code: Option<i32>,
1000 pub status: ToolCallStatus,
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1005#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1006pub struct FileChangeItem {
1007 pub changes: Vec<FileUpdateChange>,
1009 pub status: PatchApplyStatus,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1014#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1015pub struct FileUpdateChange {
1016 pub path: String,
1018 pub kind: PatchChangeKind,
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1023#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1024#[serde(rename_all = "snake_case")]
1025pub enum PatchApplyStatus {
1026 Completed,
1028 Failed,
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1033#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1034#[serde(rename_all = "snake_case")]
1035pub enum PatchChangeKind {
1036 Add,
1038 Delete,
1040 Update,
1042}
1043
1044#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1045#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1046pub struct McpToolCallItem {
1047 pub tool_name: String,
1049 #[serde(skip_serializing_if = "Option::is_none")]
1051 pub arguments: Option<Value>,
1052 #[serde(skip_serializing_if = "Option::is_none")]
1054 pub result: Option<String>,
1055 #[serde(skip_serializing_if = "Option::is_none")]
1057 pub status: Option<McpToolCallStatus>,
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1061#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1062#[serde(rename_all = "snake_case")]
1063pub enum McpToolCallStatus {
1064 Started,
1066 Completed,
1068 Failed,
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1073#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1074pub struct WebSearchItem {
1075 pub query: String,
1077 #[serde(skip_serializing_if = "Option::is_none")]
1079 pub provider: Option<String>,
1080 #[serde(skip_serializing_if = "Option::is_none")]
1082 pub results: Option<Vec<String>>,
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1086#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1087#[serde(rename_all = "snake_case")]
1088pub enum HarnessEventKind {
1089 PlanningStarted,
1090 PlanningCompleted,
1091 ContinuationStarted,
1092 ContinuationSkipped,
1093 BlockedHandoffWritten,
1094 EvaluationStarted,
1095 EvaluationPassed,
1096 EvaluationFailed,
1097 RevisionStarted,
1098 EscalationTriggered,
1099 EscalationBypassed,
1100 VerificationStarted,
1101 VerificationPassed,
1102 VerificationFailed,
1103 ErrorRecovered,
1105 ToolRetryAttempted,
1107 ToolLatencyRecorded,
1109 SnapshotCreated,
1111 SnapshotRestored,
1113}
1114
1115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1116#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1117#[serde(rename_all = "snake_case")]
1118pub enum PermissionDecision {
1119 Allow,
1120 Deny,
1121 Cancelled,
1122 Followup,
1123}
1124
1125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1126#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1127pub struct PermissionRequestedEvent {
1128 pub tool_name: String,
1130}
1131
1132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1133#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1134pub struct PermissionResolvedEvent {
1135 pub tool_name: String,
1137 pub decision: PermissionDecision,
1139 pub wait_ms: u64,
1141}
1142
1143#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1144#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1145#[serde(rename_all = "snake_case")]
1146pub enum InterjectionSource {
1147 Direct,
1148 Queue,
1149}
1150
1151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1152#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1153#[serde(rename_all = "snake_case")]
1154pub enum RedirectKind {
1155 Interjection,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1159#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1160pub struct InterjectedEvent {
1161 pub source: InterjectionSource,
1163 pub image_count: u32,
1165 pub redirect_kind: RedirectKind,
1168}
1169
1170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1171#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1172pub struct HarnessEventItem {
1173 pub event: HarnessEventKind,
1175 #[serde(skip_serializing_if = "Option::is_none")]
1177 pub message: Option<String>,
1178 #[serde(skip_serializing_if = "Option::is_none")]
1180 pub command: Option<String>,
1181 #[serde(skip_serializing_if = "Option::is_none")]
1183 pub path: Option<String>,
1184 #[serde(skip_serializing_if = "Option::is_none")]
1186 pub exit_code: Option<i32>,
1187 #[serde(skip_serializing_if = "Option::is_none")]
1189 pub attempt: Option<u32>,
1190 #[serde(skip_serializing_if = "Option::is_none")]
1192 pub error_category: Option<String>,
1193 #[serde(skip_serializing_if = "Option::is_none")]
1195 pub duration_ms: Option<u64>,
1196}
1197
1198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1199#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1200pub struct ErrorItem {
1201 pub message: String,
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208 use std::error::Error;
1209
1210 #[test]
1211 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1212 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1213 usage: Usage {
1214 input_tokens: 1,
1215 cached_input_tokens: 2,
1216 cache_creation_tokens: 0,
1217 output_tokens: 3,
1218 },
1219 });
1220
1221 let json = serde_json::to_string(&event)?;
1222 let restored: ThreadEvent = serde_json::from_str(&json)?;
1223
1224 assert_eq!(restored, event);
1225 Ok(())
1226 }
1227
1228 #[test]
1229 fn usage_uncached_input_tokens_saturates() {
1230 let usage = Usage {
1231 input_tokens: 1_000,
1232 cached_input_tokens: 800,
1233 cache_creation_tokens: 100,
1234 output_tokens: 50,
1235 };
1236 assert_eq!(usage.uncached_input_tokens(), 100);
1237
1238 let inconsistent = Usage {
1239 input_tokens: 100,
1240 cached_input_tokens: 150,
1241 cache_creation_tokens: 0,
1242 output_tokens: 0,
1243 };
1244 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1245
1246 let inconsistent_with_creation = Usage {
1247 input_tokens: 100,
1248 cached_input_tokens: 80,
1249 cache_creation_tokens: 50,
1250 output_tokens: 0,
1251 };
1252 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1253 }
1254
1255 #[test]
1256 fn usage_cache_hit_rate() {
1257 assert_eq!(Usage::default().cache_hit_rate(), None);
1258
1259 let usage = Usage {
1260 input_tokens: 1_000,
1261 cached_input_tokens: 750,
1262 cache_creation_tokens: 0,
1263 output_tokens: 0,
1264 };
1265 let rate = usage.cache_hit_rate().expect("rate");
1266 assert!((rate - 0.75).abs() < f64::EPSILON);
1267 }
1268
1269 #[test]
1270 fn usage_cache_summary_formats() {
1271 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1272
1273 let usage = Usage {
1274 input_tokens: 1_000,
1275 cached_input_tokens: 800,
1276 cache_creation_tokens: 100,
1277 output_tokens: 50,
1278 };
1279 assert_eq!(
1280 usage.cache_summary(),
1281 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1282 );
1283 }
1284
1285 #[test]
1286 fn usage_add_accumulates_all_fields_with_saturation() {
1287 let mut total = Usage {
1288 input_tokens: 100,
1289 cached_input_tokens: 20,
1290 cache_creation_tokens: 5,
1291 output_tokens: 10,
1292 };
1293 total.add(&Usage {
1294 input_tokens: 50,
1295 cached_input_tokens: 10,
1296 cache_creation_tokens: 2,
1297 output_tokens: 8,
1298 });
1299
1300 assert_eq!(total.input_tokens, 150);
1301 assert_eq!(total.cached_input_tokens, 30);
1302 assert_eq!(total.cache_creation_tokens, 7);
1303 assert_eq!(total.output_tokens, 18);
1304
1305 let mut saturating = Usage {
1306 input_tokens: u64::MAX,
1307 cached_input_tokens: u64::MAX,
1308 cache_creation_tokens: u64::MAX,
1309 output_tokens: u64::MAX,
1310 };
1311 saturating.add(&Usage {
1312 input_tokens: 1,
1313 cached_input_tokens: 1,
1314 cache_creation_tokens: 1,
1315 output_tokens: 1,
1316 });
1317 assert_eq!(saturating.input_tokens, u64::MAX);
1318 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1319 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1320 assert_eq!(saturating.output_tokens, u64::MAX);
1321 }
1322
1323 #[test]
1324 fn versioned_event_wraps_schema_version() {
1325 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1326
1327 let versioned = VersionedThreadEvent::new(event.clone());
1328
1329 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1330 assert_eq!(versioned.event, event);
1331 assert_eq!(versioned.into_event(), event);
1332 }
1333
1334 #[test]
1335 fn plan_approval_events_round_trip_with_decision() {
1336 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1337 thread_id: "thread-1".to_string(),
1338 turn_id: "turn-2".to_string(),
1339 plan_file: Some(".vtcode/plans/change.md".to_string()),
1340 });
1341 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1342 thread_id: "thread-1".to_string(),
1343 turn_id: "turn-3".to_string(),
1344 decision: PlanApprovalDecision::AutoAccept,
1345 automatic: false,
1346 });
1347
1348 for event in [requested, resolved] {
1349 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1350 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1351 assert_eq!(restored, event);
1352 }
1353 }
1354
1355 #[test]
1356 fn context_reset_event_round_trips_with_handoff_metadata() {
1357 let event = ThreadEvent::ContextReset(ContextResetEvent {
1358 thread_id: "thread-1".to_string(),
1359 turn_id: "turn-3".to_string(),
1360 trigger: ContextResetTrigger::PlanApproval,
1361 plan_preserved: true,
1362 previous_context_usage_percent: 7,
1363 tool_budget_reset: true,
1364 });
1365
1366 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1367 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1368 assert_eq!(restored, event);
1369 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1370 }
1371
1372 #[test]
1373 fn plan_approval_decision_uses_stable_wire_names() {
1374 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1375 thread_id: "thread-1".to_string(),
1376 turn_id: "turn-1".to_string(),
1377 decision: PlanApprovalDecision::SwitchBuild,
1378 automatic: false,
1379 });
1380
1381 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1382 assert_eq!(serialized["type"], "plan.approval.resolved");
1383 assert_eq!(serialized["decision"], "switch_build");
1384 }
1385
1386 #[test]
1387 fn plan_approval_decision_is_forward_compatible() {
1388 let payload = serde_json::json!({
1389 "type": "plan.approval.resolved",
1390 "thread_id": "thread-1",
1391 "turn_id": "turn-1",
1392 "decision": "future_decision",
1393 "automatic": true,
1394 });
1395 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1396 assert!(matches!(
1397 event,
1398 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1399 decision: PlanApprovalDecision::Unknown,
1400 automatic: true,
1401 ..
1402 })
1403 ));
1404 }
1405
1406 #[cfg(feature = "serde-json")]
1407 #[test]
1408 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1409 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1410 item: ThreadItem {
1411 id: "item-1".to_string(),
1412 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1413 },
1414 });
1415
1416 let payload = json::versioned_to_string(&event)?;
1417 let restored = json::versioned_from_str(&payload)?;
1418
1419 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1420 assert_eq!(restored.event, event);
1421 Ok(())
1422 }
1423
1424 #[test]
1425 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1426 for trigger in [
1427 CompactionTrigger::Manual,
1428 CompactionTrigger::Auto,
1429 CompactionTrigger::Recovery,
1430 CompactionTrigger::ModelSwitch,
1431 CompactionTrigger::Unknown,
1432 ] {
1433 let json = serde_json::to_string(&trigger).unwrap();
1434 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1435 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1436 assert_eq!(restored, trigger);
1437 }
1438 }
1439
1440 #[test]
1441 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1442 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1443 item: ThreadItem {
1444 id: "tool_1".to_string(),
1445 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1446 tool_name: "read_file".to_string(),
1447 arguments: Some(serde_json::json!({ "path": "README.md" })),
1448 tool_call_id: Some("tool_call_0".to_string()),
1449 status: ToolCallStatus::Completed,
1450 outcome: None,
1451 }),
1452 },
1453 });
1454
1455 let json = serde_json::to_string(&event)?;
1456 let restored: ThreadEvent = serde_json::from_str(&json)?;
1457
1458 assert_eq!(restored, event);
1459 Ok(())
1460 }
1461
1462 #[test]
1463 fn tool_outcome_serializes_snake_case() {
1464 for outcome in [
1465 ToolOutcome::Success,
1466 ToolOutcome::Error,
1467 ToolOutcome::PermissionRejected,
1468 ToolOutcome::PermissionCancelled,
1469 ToolOutcome::Followup,
1470 ToolOutcome::HookDenied,
1471 ToolOutcome::InvalidTool,
1472 ToolOutcome::Cancelled,
1473 ] {
1474 let json = serde_json::to_string(&outcome).unwrap();
1475 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1476 assert_eq!(restored, outcome);
1477 }
1478 }
1479
1480 #[test]
1481 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1482 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1483 item: ThreadItem {
1484 id: "tool_1".to_string(),
1485 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1486 tool_name: "exec_command".to_string(),
1487 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1488 tool_call_id: Some("tool_call_0".to_string()),
1489 status: ToolCallStatus::Failed,
1490 outcome: Some(ToolOutcome::PermissionRejected),
1491 }),
1492 },
1493 });
1494
1495 let json = serde_json::to_string(&event)?;
1496 let restored: ThreadEvent = serde_json::from_str(&json)?;
1497
1498 assert_eq!(restored, event);
1499 Ok(())
1500 }
1501
1502 #[test]
1503 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1504 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1505 item: ThreadItem {
1506 id: "tool_1:output".to_string(),
1507 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1508 call_id: "tool_1".to_string(),
1509 tool_call_id: Some("tool_call_0".to_string()),
1510 spool_path: None,
1511 output: "done".to_string(),
1512 exit_code: Some(0),
1513 status: ToolCallStatus::Completed,
1514 }),
1515 },
1516 });
1517
1518 let json = serde_json::to_string(&event)?;
1519 let restored: ThreadEvent = serde_json::from_str(&json)?;
1520
1521 assert_eq!(restored, event);
1522 Ok(())
1523 }
1524
1525 #[test]
1526 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1527 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1528 item: ThreadItem {
1529 id: "harness_1".to_string(),
1530 details: ThreadItemDetails::Harness(HarnessEventItem {
1531 event: HarnessEventKind::VerificationFailed,
1532 message: Some("cargo check failed".to_string()),
1533 command: Some("cargo check".to_string()),
1534 path: None,
1535 exit_code: Some(101),
1536 attempt: None,
1537 error_category: None,
1538 duration_ms: None,
1539 }),
1540 },
1541 });
1542
1543 let json = serde_json::to_string(&event)?;
1544 let restored: ThreadEvent = serde_json::from_str(&json)?;
1545
1546 assert_eq!(restored, event);
1547 Ok(())
1548 }
1549
1550 #[test]
1551 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1552 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1553 thread_id: "thread-1".to_string(),
1554 session_id: "session-1".to_string(),
1555 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1556 outcome_code: "budget_limit_reached".to_string(),
1557 result: None,
1558 stop_reason: Some("max_tokens".to_string()),
1559 usage: Usage {
1560 input_tokens: 10,
1561 cached_input_tokens: 4,
1562 cache_creation_tokens: 2,
1563 output_tokens: 5,
1564 },
1565 total_cost_usd: serde_json::Number::from_f64(1.25),
1566 num_turns: 3,
1567 });
1568
1569 let json = serde_json::to_string(&event)?;
1570 let restored: ThreadEvent = serde_json::from_str(&json)?;
1571
1572 assert_eq!(restored, event);
1573 Ok(())
1574 }
1575
1576 #[test]
1577 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1578 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1579 thread_id: "thread-1".to_string(),
1580 trigger: CompactionTrigger::Recovery,
1581 mode: CompactionMode::Provider,
1582 original_message_count: 12,
1583 compacted_message_count: 5,
1584 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1585 previous_segment_id: Some("segment-0001".to_string()),
1586 new_segment_id: Some("segment-0002".to_string()),
1587 previous_prefix_hash: Some("prefix-before".to_string()),
1588 new_prefix_hash: Some("prefix-after".to_string()),
1589 previous_catalog_hash: Some("catalog-before".to_string()),
1590 new_catalog_hash: Some("catalog-after".to_string()),
1591 });
1592
1593 let json = serde_json::to_string(&event)?;
1594 let restored: ThreadEvent = serde_json::from_str(&json)?;
1595
1596 assert_eq!(restored, event);
1597 Ok(())
1598 }
1599
1600 #[test]
1601 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1602 let payload = r#"{
1603 "type":"thread.compact_boundary",
1604 "thread_id":"thread-1",
1605 "trigger":"recovery",
1606 "mode":"provider",
1607 "original_message_count":12,
1608 "compacted_message_count":5
1609 }"#;
1610
1611 let restored: ThreadEvent = serde_json::from_str(payload)?;
1612 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1613 panic!("expected thread.compact_boundary event");
1614 };
1615
1616 assert_eq!(event.thread_id, "thread-1");
1617 assert_eq!(event.history_artifact_path, None);
1618 assert_eq!(event.previous_segment_id, None);
1619 assert_eq!(event.new_segment_id, None);
1620 assert_eq!(event.previous_prefix_hash, None);
1621 assert_eq!(event.new_prefix_hash, None);
1622 assert_eq!(event.previous_catalog_hash, None);
1623 assert_eq!(event.new_catalog_hash, None);
1624 Ok(())
1625 }
1626}