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")]
863 pub stage: Option<String>,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
867#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
868#[serde(rename_all = "snake_case")]
869pub enum CommandExecutionStatus {
870 #[default]
872 Completed,
873 Failed,
875 InProgress,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881pub struct CommandExecutionItem {
882 pub command: String,
884 #[serde(skip_serializing_if = "Option::is_none")]
886 pub arguments: Option<Value>,
887 #[serde(default)]
889 pub aggregated_output: String,
890 #[serde(skip_serializing_if = "Option::is_none")]
892 pub exit_code: Option<i32>,
893 pub status: CommandExecutionStatus,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
898#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
899#[serde(rename_all = "snake_case")]
900pub enum ToolCallStatus {
901 #[default]
903 Completed,
904 Failed,
906 InProgress,
908}
909
910#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
919#[serde(rename_all = "snake_case")]
920pub enum ToolOutcome {
921 #[default]
923 Success,
924 Error,
926 PermissionRejected,
928 PermissionCancelled,
930 Followup,
932 HookDenied,
934 InvalidTool,
936 Cancelled,
938}
939
940impl ToolOutcome {
941 #[must_use]
942 pub const fn is_terminal(self) -> bool {
943 !matches!(self, Self::Followup)
944 }
945}
946
947#[must_use]
954#[allow(
955 clippy::unreachable,
956 reason = "Intentional compatibility, platform, or test-only suppression."
957)]
958pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
959 match status {
960 ToolCallStatus::Completed => ToolOutcome::Success,
961 ToolCallStatus::Failed => ToolOutcome::Error,
962 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
963 }
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
967#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
968pub struct ToolInvocationItem {
969 pub tool_name: String,
971 #[serde(skip_serializing_if = "Option::is_none")]
973 pub arguments: Option<Value>,
974 #[serde(skip_serializing_if = "Option::is_none")]
976 pub tool_call_id: Option<String>,
977 pub status: ToolCallStatus,
979 #[serde(skip_serializing_if = "Option::is_none")]
981 pub outcome: Option<ToolOutcome>,
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
985#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
986pub struct ToolOutputItem {
987 pub call_id: String,
989 #[serde(skip_serializing_if = "Option::is_none")]
991 pub tool_call_id: Option<String>,
992 #[serde(skip_serializing_if = "Option::is_none")]
994 pub spool_path: Option<String>,
995 #[serde(default)]
997 pub output: String,
998 #[serde(skip_serializing_if = "Option::is_none")]
1000 pub exit_code: Option<i32>,
1001 pub status: ToolCallStatus,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1006#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1007pub struct FileChangeItem {
1008 pub changes: Vec<FileUpdateChange>,
1010 pub status: PatchApplyStatus,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1016pub struct FileUpdateChange {
1017 pub path: String,
1019 pub kind: PatchChangeKind,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1025#[serde(rename_all = "snake_case")]
1026pub enum PatchApplyStatus {
1027 Completed,
1029 Failed,
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1034#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1035#[serde(rename_all = "snake_case")]
1036pub enum PatchChangeKind {
1037 Add,
1039 Delete,
1041 Update,
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1046#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1047pub struct McpToolCallItem {
1048 pub tool_name: String,
1050 #[serde(skip_serializing_if = "Option::is_none")]
1052 pub arguments: Option<Value>,
1053 #[serde(skip_serializing_if = "Option::is_none")]
1055 pub result: Option<String>,
1056 #[serde(skip_serializing_if = "Option::is_none")]
1058 pub status: Option<McpToolCallStatus>,
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1063#[serde(rename_all = "snake_case")]
1064pub enum McpToolCallStatus {
1065 Started,
1067 Completed,
1069 Failed,
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1074#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1075pub struct WebSearchItem {
1076 pub query: String,
1078 #[serde(skip_serializing_if = "Option::is_none")]
1080 pub provider: Option<String>,
1081 #[serde(skip_serializing_if = "Option::is_none")]
1083 pub results: Option<Vec<String>>,
1084}
1085
1086#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1087#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1088#[serde(rename_all = "snake_case")]
1089pub enum HarnessEventKind {
1090 PlanningStarted,
1091 PlanningCompleted,
1092 ContinuationStarted,
1093 ContinuationSkipped,
1094 BlockedHandoffWritten,
1095 EvaluationStarted,
1096 EvaluationPassed,
1097 EvaluationFailed,
1098 RevisionStarted,
1099 EscalationTriggered,
1100 EscalationBypassed,
1101 VerificationStarted,
1102 VerificationPassed,
1103 VerificationFailed,
1104 ErrorRecovered,
1106 ToolRetryAttempted,
1108 ToolLatencyRecorded,
1110 SnapshotCreated,
1112 SnapshotRestored,
1114}
1115
1116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1117#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1118#[serde(rename_all = "snake_case")]
1119pub enum PermissionDecision {
1120 Allow,
1121 Deny,
1122 Cancelled,
1123 Followup,
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1127#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1128pub struct PermissionRequestedEvent {
1129 pub tool_name: String,
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1135pub struct PermissionResolvedEvent {
1136 pub tool_name: String,
1138 pub decision: PermissionDecision,
1140 pub wait_ms: u64,
1142}
1143
1144#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1145#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1146#[serde(rename_all = "snake_case")]
1147pub enum InterjectionSource {
1148 Direct,
1149 Queue,
1150}
1151
1152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1153#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1154#[serde(rename_all = "snake_case")]
1155pub enum RedirectKind {
1156 Interjection,
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1160#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1161pub struct InterjectedEvent {
1162 pub source: InterjectionSource,
1164 pub image_count: u32,
1166 pub redirect_kind: RedirectKind,
1169}
1170
1171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1172#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1173pub struct HarnessEventItem {
1174 pub event: HarnessEventKind,
1176 #[serde(skip_serializing_if = "Option::is_none")]
1178 pub message: Option<String>,
1179 #[serde(skip_serializing_if = "Option::is_none")]
1181 pub command: Option<String>,
1182 #[serde(skip_serializing_if = "Option::is_none")]
1184 pub path: Option<String>,
1185 #[serde(skip_serializing_if = "Option::is_none")]
1187 pub exit_code: Option<i32>,
1188 #[serde(skip_serializing_if = "Option::is_none")]
1190 pub attempt: Option<u32>,
1191 #[serde(skip_serializing_if = "Option::is_none")]
1193 pub error_category: Option<String>,
1194 #[serde(skip_serializing_if = "Option::is_none")]
1196 pub duration_ms: Option<u64>,
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1201pub struct ErrorItem {
1202 pub message: String,
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 use std::error::Error;
1210
1211 #[test]
1212 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1213 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1214 usage: Usage {
1215 input_tokens: 1,
1216 cached_input_tokens: 2,
1217 cache_creation_tokens: 0,
1218 output_tokens: 3,
1219 },
1220 });
1221
1222 let json = serde_json::to_string(&event)?;
1223 let restored: ThreadEvent = serde_json::from_str(&json)?;
1224
1225 assert_eq!(restored, event);
1226 Ok(())
1227 }
1228
1229 #[test]
1230 fn usage_uncached_input_tokens_saturates() {
1231 let usage = Usage {
1232 input_tokens: 1_000,
1233 cached_input_tokens: 800,
1234 cache_creation_tokens: 100,
1235 output_tokens: 50,
1236 };
1237 assert_eq!(usage.uncached_input_tokens(), 100);
1238
1239 let inconsistent = Usage {
1240 input_tokens: 100,
1241 cached_input_tokens: 150,
1242 cache_creation_tokens: 0,
1243 output_tokens: 0,
1244 };
1245 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1246
1247 let inconsistent_with_creation = Usage {
1248 input_tokens: 100,
1249 cached_input_tokens: 80,
1250 cache_creation_tokens: 50,
1251 output_tokens: 0,
1252 };
1253 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1254 }
1255
1256 #[test]
1257 fn usage_cache_hit_rate() {
1258 assert_eq!(Usage::default().cache_hit_rate(), None);
1259
1260 let usage = Usage {
1261 input_tokens: 1_000,
1262 cached_input_tokens: 750,
1263 cache_creation_tokens: 0,
1264 output_tokens: 0,
1265 };
1266 let rate = usage.cache_hit_rate().expect("rate");
1267 assert!((rate - 0.75).abs() < f64::EPSILON);
1268 }
1269
1270 #[test]
1271 fn usage_cache_summary_formats() {
1272 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1273
1274 let usage = Usage {
1275 input_tokens: 1_000,
1276 cached_input_tokens: 800,
1277 cache_creation_tokens: 100,
1278 output_tokens: 50,
1279 };
1280 assert_eq!(
1281 usage.cache_summary(),
1282 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1283 );
1284 }
1285
1286 #[test]
1287 fn usage_add_accumulates_all_fields_with_saturation() {
1288 let mut total = Usage {
1289 input_tokens: 100,
1290 cached_input_tokens: 20,
1291 cache_creation_tokens: 5,
1292 output_tokens: 10,
1293 };
1294 total.add(&Usage {
1295 input_tokens: 50,
1296 cached_input_tokens: 10,
1297 cache_creation_tokens: 2,
1298 output_tokens: 8,
1299 });
1300
1301 assert_eq!(total.input_tokens, 150);
1302 assert_eq!(total.cached_input_tokens, 30);
1303 assert_eq!(total.cache_creation_tokens, 7);
1304 assert_eq!(total.output_tokens, 18);
1305
1306 let mut saturating = Usage {
1307 input_tokens: u64::MAX,
1308 cached_input_tokens: u64::MAX,
1309 cache_creation_tokens: u64::MAX,
1310 output_tokens: u64::MAX,
1311 };
1312 saturating.add(&Usage {
1313 input_tokens: 1,
1314 cached_input_tokens: 1,
1315 cache_creation_tokens: 1,
1316 output_tokens: 1,
1317 });
1318 assert_eq!(saturating.input_tokens, u64::MAX);
1319 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1320 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1321 assert_eq!(saturating.output_tokens, u64::MAX);
1322 }
1323
1324 #[test]
1325 fn versioned_event_wraps_schema_version() {
1326 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1327
1328 let versioned = VersionedThreadEvent::new(event.clone());
1329
1330 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1331 assert_eq!(versioned.event, event);
1332 assert_eq!(versioned.into_event(), event);
1333 }
1334
1335 #[test]
1336 fn plan_approval_events_round_trip_with_decision() {
1337 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1338 thread_id: "thread-1".to_string(),
1339 turn_id: "turn-2".to_string(),
1340 plan_file: Some(".vtcode/plans/change.md".to_string()),
1341 });
1342 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1343 thread_id: "thread-1".to_string(),
1344 turn_id: "turn-3".to_string(),
1345 decision: PlanApprovalDecision::AutoAccept,
1346 automatic: false,
1347 });
1348
1349 for event in [requested, resolved] {
1350 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1351 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1352 assert_eq!(restored, event);
1353 }
1354 }
1355
1356 #[test]
1357 fn context_reset_event_round_trips_with_handoff_metadata() {
1358 let event = ThreadEvent::ContextReset(ContextResetEvent {
1359 thread_id: "thread-1".to_string(),
1360 turn_id: "turn-3".to_string(),
1361 trigger: ContextResetTrigger::PlanApproval,
1362 plan_preserved: true,
1363 previous_context_usage_percent: 7,
1364 tool_budget_reset: true,
1365 });
1366
1367 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1368 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1369 assert_eq!(restored, event);
1370 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1371 }
1372
1373 #[test]
1374 fn plan_approval_decision_uses_stable_wire_names() {
1375 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1376 thread_id: "thread-1".to_string(),
1377 turn_id: "turn-1".to_string(),
1378 decision: PlanApprovalDecision::SwitchBuild,
1379 automatic: false,
1380 });
1381
1382 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1383 assert_eq!(serialized["type"], "plan.approval.resolved");
1384 assert_eq!(serialized["decision"], "switch_build");
1385 }
1386
1387 #[test]
1388 fn plan_approval_decision_is_forward_compatible() {
1389 let payload = serde_json::json!({
1390 "type": "plan.approval.resolved",
1391 "thread_id": "thread-1",
1392 "turn_id": "turn-1",
1393 "decision": "future_decision",
1394 "automatic": true,
1395 });
1396 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1397 assert!(matches!(
1398 event,
1399 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1400 decision: PlanApprovalDecision::Unknown,
1401 automatic: true,
1402 ..
1403 })
1404 ));
1405 }
1406
1407 #[cfg(feature = "serde-json")]
1408 #[test]
1409 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1410 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1411 item: ThreadItem {
1412 id: "item-1".to_string(),
1413 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1414 },
1415 });
1416
1417 let payload = json::versioned_to_string(&event)?;
1418 let restored = json::versioned_from_str(&payload)?;
1419
1420 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1421 assert_eq!(restored.event, event);
1422 Ok(())
1423 }
1424
1425 #[test]
1426 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1427 for trigger in [
1428 CompactionTrigger::Manual,
1429 CompactionTrigger::Auto,
1430 CompactionTrigger::Recovery,
1431 CompactionTrigger::ModelSwitch,
1432 CompactionTrigger::Unknown,
1433 ] {
1434 let json = serde_json::to_string(&trigger).unwrap();
1435 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1436 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1437 assert_eq!(restored, trigger);
1438 }
1439 }
1440
1441 #[test]
1442 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1443 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1444 item: ThreadItem {
1445 id: "tool_1".to_string(),
1446 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1447 tool_name: "read_file".to_string(),
1448 arguments: Some(serde_json::json!({ "path": "README.md" })),
1449 tool_call_id: Some("tool_call_0".to_string()),
1450 status: ToolCallStatus::Completed,
1451 outcome: None,
1452 }),
1453 },
1454 });
1455
1456 let json = serde_json::to_string(&event)?;
1457 let restored: ThreadEvent = serde_json::from_str(&json)?;
1458
1459 assert_eq!(restored, event);
1460 Ok(())
1461 }
1462
1463 #[test]
1464 fn tool_outcome_serializes_snake_case() {
1465 for outcome in [
1466 ToolOutcome::Success,
1467 ToolOutcome::Error,
1468 ToolOutcome::PermissionRejected,
1469 ToolOutcome::PermissionCancelled,
1470 ToolOutcome::Followup,
1471 ToolOutcome::HookDenied,
1472 ToolOutcome::InvalidTool,
1473 ToolOutcome::Cancelled,
1474 ] {
1475 let json = serde_json::to_string(&outcome).unwrap();
1476 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1477 assert_eq!(restored, outcome);
1478 }
1479 }
1480
1481 #[test]
1482 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1483 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1484 item: ThreadItem {
1485 id: "tool_1".to_string(),
1486 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1487 tool_name: "exec_command".to_string(),
1488 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1489 tool_call_id: Some("tool_call_0".to_string()),
1490 status: ToolCallStatus::Failed,
1491 outcome: Some(ToolOutcome::PermissionRejected),
1492 }),
1493 },
1494 });
1495
1496 let json = serde_json::to_string(&event)?;
1497 let restored: ThreadEvent = serde_json::from_str(&json)?;
1498
1499 assert_eq!(restored, event);
1500 Ok(())
1501 }
1502
1503 #[test]
1504 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1505 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1506 item: ThreadItem {
1507 id: "tool_1:output".to_string(),
1508 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1509 call_id: "tool_1".to_string(),
1510 tool_call_id: Some("tool_call_0".to_string()),
1511 spool_path: None,
1512 output: "done".to_string(),
1513 exit_code: Some(0),
1514 status: ToolCallStatus::Completed,
1515 }),
1516 },
1517 });
1518
1519 let json = serde_json::to_string(&event)?;
1520 let restored: ThreadEvent = serde_json::from_str(&json)?;
1521
1522 assert_eq!(restored, event);
1523 Ok(())
1524 }
1525
1526 #[test]
1527 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1528 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1529 item: ThreadItem {
1530 id: "harness_1".to_string(),
1531 details: ThreadItemDetails::Harness(HarnessEventItem {
1532 event: HarnessEventKind::VerificationFailed,
1533 message: Some("cargo check failed".to_string()),
1534 command: Some("cargo check".to_string()),
1535 path: None,
1536 exit_code: Some(101),
1537 attempt: None,
1538 error_category: None,
1539 duration_ms: None,
1540 }),
1541 },
1542 });
1543
1544 let json = serde_json::to_string(&event)?;
1545 let restored: ThreadEvent = serde_json::from_str(&json)?;
1546
1547 assert_eq!(restored, event);
1548 Ok(())
1549 }
1550
1551 #[test]
1552 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1553 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1554 thread_id: "thread-1".to_string(),
1555 session_id: "session-1".to_string(),
1556 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1557 outcome_code: "budget_limit_reached".to_string(),
1558 result: None,
1559 stop_reason: Some("max_tokens".to_string()),
1560 usage: Usage {
1561 input_tokens: 10,
1562 cached_input_tokens: 4,
1563 cache_creation_tokens: 2,
1564 output_tokens: 5,
1565 },
1566 total_cost_usd: serde_json::Number::from_f64(1.25),
1567 num_turns: 3,
1568 });
1569
1570 let json = serde_json::to_string(&event)?;
1571 let restored: ThreadEvent = serde_json::from_str(&json)?;
1572
1573 assert_eq!(restored, event);
1574 Ok(())
1575 }
1576
1577 #[test]
1578 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1579 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1580 thread_id: "thread-1".to_string(),
1581 trigger: CompactionTrigger::Recovery,
1582 mode: CompactionMode::Provider,
1583 original_message_count: 12,
1584 compacted_message_count: 5,
1585 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1586 previous_segment_id: Some("segment-0001".to_string()),
1587 new_segment_id: Some("segment-0002".to_string()),
1588 previous_prefix_hash: Some("prefix-before".to_string()),
1589 new_prefix_hash: Some("prefix-after".to_string()),
1590 previous_catalog_hash: Some("catalog-before".to_string()),
1591 new_catalog_hash: Some("catalog-after".to_string()),
1592 });
1593
1594 let json = serde_json::to_string(&event)?;
1595 let restored: ThreadEvent = serde_json::from_str(&json)?;
1596
1597 assert_eq!(restored, event);
1598 Ok(())
1599 }
1600
1601 #[test]
1602 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1603 let payload = r#"{
1604 "type":"thread.compact_boundary",
1605 "thread_id":"thread-1",
1606 "trigger":"recovery",
1607 "mode":"provider",
1608 "original_message_count":12,
1609 "compacted_message_count":5
1610 }"#;
1611
1612 let restored: ThreadEvent = serde_json::from_str(payload)?;
1613 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1614 panic!("expected thread.compact_boundary event");
1615 };
1616
1617 assert_eq!(event.thread_id, "thread-1");
1618 assert_eq!(event.history_artifact_path, None);
1619 assert_eq!(event.previous_segment_id, None);
1620 assert_eq!(event.new_segment_id, None);
1621 assert_eq!(event.previous_prefix_hash, None);
1622 assert_eq!(event.new_prefix_hash, None);
1623 assert_eq!(event.previous_catalog_hash, None);
1624 assert_eq!(event.new_catalog_hash, None);
1625 Ok(())
1626 }
1627}