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.10.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::TurnStarted(_) => "turn.started",
256 ThreadEvent::TurnCompleted(_) => "turn.completed",
257 ThreadEvent::TurnFailed(_) => "turn.failed",
258 ThreadEvent::ItemStarted(_) => "item.started",
259 ThreadEvent::ItemUpdated(_) => "item.updated",
260 ThreadEvent::ItemCompleted(_) => "item.completed",
261 ThreadEvent::Error(_) => "error",
262 _ => "event",
263 };
264
265 let mut span = self.tracer.start(span_name);
266
267 match event {
268 ThreadEvent::ThreadStarted(e) => {
269 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
270 }
271 ThreadEvent::ThreadCompleted(e) => {
272 if let Some(ref cost) = e.total_cost_usd {
273 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
274 }
275 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
276 span.set_attribute(KeyValue::new("output_tokens", e.usage.output_tokens as i64));
277 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
278 }
279 ThreadEvent::TurnCompleted(e) => {
280 span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
281 span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
282 }
283 ThreadEvent::ItemCompleted(e) => {
284 if let ThreadItemDetails::Harness(harness) = &e.item.details {
285 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
286 if let Some(ref msg) = harness.message {
287 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
288 }
289 if let Some(ref path) = harness.path {
290 span.set_attribute(KeyValue::new("harness_path", path.clone()));
291 }
292 if let Some(dur) = harness.duration_ms {
293 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
294 }
295 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
296 if let Some(ref msg) = harness.message {
297 event_attrs.push(KeyValue::new("message", msg.clone()));
298 }
299 span.add_event("harness_event", event_attrs);
300 }
301 }
302 ThreadEvent::Error(e) => {
303 span.set_status(Status::Error { description: e.message.clone().into() });
304 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
305 }
306 _ => {}
307 }
308
309 span.end();
310 }
311 }
312
313 pub use OtelEmitter as PublicOtelEmitter;
314}
315
316#[cfg(feature = "telemetry-otel")]
317pub use otel_support::PublicOtelEmitter as OtelEmitter;
318
319#[cfg(feature = "schema-export")]
320pub mod schema {
321 use schemars::{Schema, schema_for};
322
323 use super::{ThreadEvent, VersionedThreadEvent};
324
325 pub fn thread_event_schema() -> Schema {
327 schema_for!(ThreadEvent)
328 }
329
330 pub fn versioned_thread_event_schema() -> Schema {
332 schema_for!(VersionedThreadEvent)
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
339#[serde(tag = "type")]
340pub enum ThreadEvent {
341 #[serde(rename = "thread.started")]
343 ThreadStarted(ThreadStartedEvent),
344 #[serde(rename = "thread.completed")]
346 ThreadCompleted(ThreadCompletedEvent),
347 #[serde(rename = "thread.compact_boundary")]
349 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
350 #[serde(rename = "turn.started")]
352 TurnStarted(TurnStartedEvent),
353 #[serde(rename = "turn.completed")]
355 TurnCompleted(TurnCompletedEvent),
356 #[serde(rename = "turn.failed")]
358 TurnFailed(TurnFailedEvent),
359 #[serde(rename = "item.started")]
361 ItemStarted(ItemStartedEvent),
362 #[serde(rename = "item.updated")]
364 ItemUpdated(ItemUpdatedEvent),
365 #[serde(rename = "item.completed")]
367 ItemCompleted(ItemCompletedEvent),
368 #[serde(rename = "permission.requested")]
370 PermissionRequested(PermissionRequestedEvent),
371 #[serde(rename = "permission.resolved")]
373 PermissionResolved(PermissionResolvedEvent),
374 #[serde(rename = "interjected")]
376 Interjected(InterjectedEvent),
377 #[serde(rename = "plan.delta")]
379 PlanDelta(PlanDeltaEvent),
380 #[serde(rename = "plan.approval.requested")]
382 PlanApprovalRequested(PlanApprovalRequestedEvent),
383 #[serde(rename = "plan.approval.resolved")]
385 PlanApprovalResolved(PlanApprovalResolvedEvent),
386 #[serde(rename = "error")]
388 Error(ThreadErrorEvent),
389 #[serde(other)]
392 Unknown,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
397pub struct ThreadStartedEvent {
398 pub thread_id: String,
400}
401
402#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
403#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
404#[serde(rename_all = "snake_case")]
405pub enum ThreadCompletionSubtype {
406 Success,
407 ErrorMaxTurns,
408 ErrorMaxBudgetUsd,
409 ErrorDuringExecution,
410 Cancelled,
411 #[serde(other)]
413 Unknown,
414}
415
416impl ThreadCompletionSubtype {
417 pub const fn as_str(&self) -> &'static str {
418 match self {
419 Self::Success => "success",
420 Self::ErrorMaxTurns => "error_max_turns",
421 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
422 Self::ErrorDuringExecution => "error_during_execution",
423 Self::Cancelled => "cancelled",
424 Self::Unknown => "unknown",
425 }
426 }
427
428 pub const fn is_success(self) -> bool {
429 matches!(self, Self::Success)
430 }
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 CompactionTrigger {
437 Manual,
438 Auto,
439 Recovery,
440 ModelSwitch,
443 #[serde(other)]
445 Unknown,
446}
447
448impl CompactionTrigger {
449 pub const fn as_str(self) -> &'static str {
450 match self {
451 Self::Manual => "manual",
452 Self::Auto => "auto",
453 Self::Recovery => "recovery",
454 Self::ModelSwitch => "model_switch",
455 Self::Unknown => "unknown",
456 }
457 }
458}
459
460#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
461#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
462#[serde(rename_all = "snake_case")]
463pub enum CompactionMode {
464 Provider,
465 Local,
466 #[serde(other)]
468 Unknown,
469}
470
471impl CompactionMode {
472 pub const fn as_str(self) -> &'static str {
473 match self {
474 Self::Provider => "provider",
475 Self::Local => "local",
476 Self::Unknown => "unknown",
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
483pub struct ThreadCompletedEvent {
484 pub thread_id: String,
486 pub session_id: String,
488 pub subtype: ThreadCompletionSubtype,
490 pub outcome_code: String,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub result: Option<String>,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub stop_reason: Option<String>,
498 pub usage: Usage,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub total_cost_usd: Option<serde_json::Number>,
503 pub num_turns: usize,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
509pub struct ThreadCompactBoundaryEvent {
510 pub thread_id: String,
512 pub trigger: CompactionTrigger,
514 pub mode: CompactionMode,
516 pub original_message_count: usize,
518 pub compacted_message_count: usize,
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub history_artifact_path: Option<String>,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub previous_segment_id: Option<String>,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub new_segment_id: Option<String>,
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub previous_prefix_hash: Option<String>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub new_prefix_hash: Option<String>,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub previous_catalog_hash: Option<String>,
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub new_catalog_hash: Option<String>,
541}
542
543#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
544#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
545pub struct TurnStartedEvent {
546 #[serde(skip_serializing_if = "Option::is_none")]
550 token_breakdown: Option<TokenBreakdown>,
551}
552
553#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
555#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
556pub struct TokenBreakdown {
557 system_prompt_tokens: u64,
559 tool_schema_tokens: u64,
561 instruction_file_tokens: u64,
563 message_history_tokens: u64,
565 cache_read_tokens: u64,
567 cache_write_tokens: u64,
569 cache_miss_tokens: u64,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 subagent_bootstrap_tokens: Option<u64>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
578pub struct TurnCompletedEvent {
579 pub usage: Usage,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
585pub struct TurnFailedEvent {
586 pub message: String,
588 #[serde(skip_serializing_if = "Option::is_none")]
590 pub usage: Option<Usage>,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
595pub struct ThreadErrorEvent {
596 pub message: String,
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
601#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
602pub struct Usage {
603 pub input_tokens: u64,
605 pub cached_input_tokens: u64,
607 pub cache_creation_tokens: u64,
609 pub output_tokens: u64,
611}
612
613impl Usage {
614 #[must_use]
619 fn uncached_input_tokens(&self) -> u64 {
620 self.input_tokens
621 .saturating_sub(self.cached_input_tokens)
622 .saturating_sub(self.cache_creation_tokens)
623 }
624
625 #[must_use]
628 pub fn cache_hit_rate(&self) -> Option<f64> {
629 if self.input_tokens == 0 {
630 return None;
631 }
632 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
633 }
634
635 #[must_use]
637 pub fn cache_summary(&self) -> String {
638 let total_input = self.input_tokens;
639 if total_input == 0 {
640 return "No input tokens recorded.".to_string();
641 }
642
643 let cached = self.cached_input_tokens;
644 let creation = self.cache_creation_tokens;
645 let uncached = self.uncached_input_tokens();
646 let rate = cached as f64 / total_input as f64 * 100.0;
647 format!(
648 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
649 {creation} cache-creation, {uncached} uncached"
650 )
651 }
652
653 pub fn add(&mut self, other: &Usage) {
655 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
656 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
657 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
658 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
659 }
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664pub struct ItemCompletedEvent {
665 pub item: ThreadItem,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
670#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
671pub struct ItemStartedEvent {
672 pub item: ThreadItem,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
678pub struct ItemUpdatedEvent {
679 pub item: ThreadItem,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
684#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
685pub struct PlanDeltaEvent {
686 pub thread_id: String,
688 pub turn_id: String,
690 pub item_id: String,
692 pub delta: String,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
697#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
698pub struct PlanApprovalRequestedEvent {
699 pub thread_id: String,
701 pub turn_id: String,
703 #[serde(skip_serializing_if = "Option::is_none")]
705 pub plan_file: Option<String>,
706}
707
708#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
709#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
710#[serde(rename_all = "snake_case")]
711pub enum PlanApprovalDecision {
712 Execute,
714 AutoAccept,
716 Revise,
718 Cancel,
720 SwitchBuild,
722 SwitchAuto,
724 #[serde(other)]
726 Unknown,
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
730#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
731pub struct PlanApprovalResolvedEvent {
732 pub thread_id: String,
734 pub turn_id: String,
736 pub decision: PlanApprovalDecision,
738 pub automatic: bool,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
744pub struct ThreadItem {
745 pub id: String,
747 #[serde(flatten)]
749 pub details: ThreadItemDetails,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
753#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
754#[serde(tag = "type", rename_all = "snake_case")]
755pub enum ThreadItemDetails {
756 AgentMessage(AgentMessageItem),
758 Plan(PlanItem),
760 Reasoning(ReasoningItem),
762 CommandExecution(Box<CommandExecutionItem>),
764 ToolInvocation(ToolInvocationItem),
766 ToolOutput(ToolOutputItem),
768 FileChange(Box<FileChangeItem>),
770 McpToolCall(McpToolCallItem),
772 WebSearch(WebSearchItem),
774 Harness(HarnessEventItem),
776 Error(ErrorItem),
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
782pub struct AgentMessageItem {
783 pub text: String,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
789pub struct PlanItem {
790 pub text: String,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
796pub struct ReasoningItem {
797 pub text: String,
799 #[serde(skip_serializing_if = "Option::is_none")]
801 pub stage: Option<String>,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
805#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
806#[serde(rename_all = "snake_case")]
807pub enum CommandExecutionStatus {
808 #[default]
810 Completed,
811 Failed,
813 InProgress,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
818#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
819pub struct CommandExecutionItem {
820 pub command: String,
822 #[serde(skip_serializing_if = "Option::is_none")]
824 pub arguments: Option<Value>,
825 #[serde(default)]
827 pub aggregated_output: String,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 pub exit_code: Option<i32>,
831 pub status: CommandExecutionStatus,
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
836#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
837#[serde(rename_all = "snake_case")]
838pub enum ToolCallStatus {
839 #[default]
841 Completed,
842 Failed,
844 InProgress,
846}
847
848#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
856#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
857#[serde(rename_all = "snake_case")]
858pub enum ToolOutcome {
859 #[default]
861 Success,
862 Error,
864 PermissionRejected,
866 PermissionCancelled,
868 Followup,
870 HookDenied,
872 InvalidTool,
874 Cancelled,
876}
877
878impl ToolOutcome {
879 #[must_use]
880 pub const fn is_terminal(self) -> bool {
881 !matches!(self, Self::Followup)
882 }
883}
884
885#[must_use]
892#[allow(clippy::unreachable)]
893pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
894 match status {
895 ToolCallStatus::Completed => ToolOutcome::Success,
896 ToolCallStatus::Failed => ToolOutcome::Error,
897 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
898 }
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
902#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
903pub struct ToolInvocationItem {
904 pub tool_name: String,
906 #[serde(skip_serializing_if = "Option::is_none")]
908 pub arguments: Option<Value>,
909 #[serde(skip_serializing_if = "Option::is_none")]
911 pub tool_call_id: Option<String>,
912 pub status: ToolCallStatus,
914 #[serde(skip_serializing_if = "Option::is_none")]
916 pub outcome: Option<ToolOutcome>,
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
920#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
921pub struct ToolOutputItem {
922 pub call_id: String,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 pub tool_call_id: Option<String>,
927 #[serde(skip_serializing_if = "Option::is_none")]
929 pub spool_path: Option<String>,
930 #[serde(default)]
932 pub output: String,
933 #[serde(skip_serializing_if = "Option::is_none")]
935 pub exit_code: Option<i32>,
936 pub status: ToolCallStatus,
938}
939
940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
941#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
942pub struct FileChangeItem {
943 pub changes: Vec<FileUpdateChange>,
945 pub status: PatchApplyStatus,
947}
948
949#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
950#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
951pub struct FileUpdateChange {
952 pub path: String,
954 pub kind: PatchChangeKind,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
959#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
960#[serde(rename_all = "snake_case")]
961pub enum PatchApplyStatus {
962 Completed,
964 Failed,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
970#[serde(rename_all = "snake_case")]
971pub enum PatchChangeKind {
972 Add,
974 Delete,
976 Update,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
981#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
982pub struct McpToolCallItem {
983 pub tool_name: String,
985 #[serde(skip_serializing_if = "Option::is_none")]
987 pub arguments: Option<Value>,
988 #[serde(skip_serializing_if = "Option::is_none")]
990 pub result: Option<String>,
991 #[serde(skip_serializing_if = "Option::is_none")]
993 pub status: Option<McpToolCallStatus>,
994}
995
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
998#[serde(rename_all = "snake_case")]
999pub enum McpToolCallStatus {
1000 Started,
1002 Completed,
1004 Failed,
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1009#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1010pub struct WebSearchItem {
1011 pub query: String,
1013 #[serde(skip_serializing_if = "Option::is_none")]
1015 pub provider: Option<String>,
1016 #[serde(skip_serializing_if = "Option::is_none")]
1018 pub results: Option<Vec<String>>,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1022#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1023#[serde(rename_all = "snake_case")]
1024pub enum HarnessEventKind {
1025 PlanningStarted,
1026 PlanningCompleted,
1027 ContinuationStarted,
1028 ContinuationSkipped,
1029 BlockedHandoffWritten,
1030 EvaluationStarted,
1031 EvaluationPassed,
1032 EvaluationFailed,
1033 RevisionStarted,
1034 EscalationTriggered,
1035 EscalationBypassed,
1036 VerificationStarted,
1037 VerificationPassed,
1038 VerificationFailed,
1039 ErrorRecovered,
1041 ToolRetryAttempted,
1043 ToolLatencyRecorded,
1045 SnapshotCreated,
1047 SnapshotRestored,
1049}
1050
1051#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1052#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1053#[serde(rename_all = "snake_case")]
1054pub enum PermissionDecision {
1055 Allow,
1056 Deny,
1057 Cancelled,
1058 Followup,
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1063pub struct PermissionRequestedEvent {
1064 pub tool_name: String,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1069#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1070pub struct PermissionResolvedEvent {
1071 pub tool_name: String,
1073 pub decision: PermissionDecision,
1075 pub wait_ms: u64,
1077}
1078
1079#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1080#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1081#[serde(rename_all = "snake_case")]
1082pub enum InterjectionSource {
1083 Direct,
1084 Queue,
1085}
1086
1087#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1088#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1089#[serde(rename_all = "snake_case")]
1090pub enum RedirectKind {
1091 Interjection,
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1095#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1096pub struct InterjectedEvent {
1097 pub source: InterjectionSource,
1099 pub image_count: u32,
1101 pub redirect_kind: RedirectKind,
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1107#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1108pub struct HarnessEventItem {
1109 pub event: HarnessEventKind,
1111 #[serde(skip_serializing_if = "Option::is_none")]
1113 pub message: Option<String>,
1114 #[serde(skip_serializing_if = "Option::is_none")]
1116 pub command: Option<String>,
1117 #[serde(skip_serializing_if = "Option::is_none")]
1119 pub path: Option<String>,
1120 #[serde(skip_serializing_if = "Option::is_none")]
1122 pub exit_code: Option<i32>,
1123 #[serde(skip_serializing_if = "Option::is_none")]
1125 pub attempt: Option<u32>,
1126 #[serde(skip_serializing_if = "Option::is_none")]
1128 pub error_category: Option<String>,
1129 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub duration_ms: Option<u64>,
1132}
1133
1134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1135#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1136pub struct ErrorItem {
1137 pub message: String,
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144 use std::error::Error;
1145
1146 #[test]
1147 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1148 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1149 usage: Usage {
1150 input_tokens: 1,
1151 cached_input_tokens: 2,
1152 cache_creation_tokens: 0,
1153 output_tokens: 3,
1154 },
1155 });
1156
1157 let json = serde_json::to_string(&event)?;
1158 let restored: ThreadEvent = serde_json::from_str(&json)?;
1159
1160 assert_eq!(restored, event);
1161 Ok(())
1162 }
1163
1164 #[test]
1165 fn usage_uncached_input_tokens_saturates() {
1166 let usage = Usage {
1167 input_tokens: 1_000,
1168 cached_input_tokens: 800,
1169 cache_creation_tokens: 100,
1170 output_tokens: 50,
1171 };
1172 assert_eq!(usage.uncached_input_tokens(), 100);
1173
1174 let inconsistent = Usage {
1175 input_tokens: 100,
1176 cached_input_tokens: 150,
1177 cache_creation_tokens: 0,
1178 output_tokens: 0,
1179 };
1180 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1181
1182 let inconsistent_with_creation = Usage {
1183 input_tokens: 100,
1184 cached_input_tokens: 80,
1185 cache_creation_tokens: 50,
1186 output_tokens: 0,
1187 };
1188 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1189 }
1190
1191 #[test]
1192 fn usage_cache_hit_rate() {
1193 assert_eq!(Usage::default().cache_hit_rate(), None);
1194
1195 let usage = Usage {
1196 input_tokens: 1_000,
1197 cached_input_tokens: 750,
1198 cache_creation_tokens: 0,
1199 output_tokens: 0,
1200 };
1201 let rate = usage.cache_hit_rate().expect("rate");
1202 assert!((rate - 0.75).abs() < f64::EPSILON);
1203 }
1204
1205 #[test]
1206 fn usage_cache_summary_formats() {
1207 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1208
1209 let usage = Usage {
1210 input_tokens: 1_000,
1211 cached_input_tokens: 800,
1212 cache_creation_tokens: 100,
1213 output_tokens: 50,
1214 };
1215 assert_eq!(
1216 usage.cache_summary(),
1217 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1218 );
1219 }
1220
1221 #[test]
1222 fn usage_add_accumulates_all_fields_with_saturation() {
1223 let mut total = Usage {
1224 input_tokens: 100,
1225 cached_input_tokens: 20,
1226 cache_creation_tokens: 5,
1227 output_tokens: 10,
1228 };
1229 total.add(&Usage {
1230 input_tokens: 50,
1231 cached_input_tokens: 10,
1232 cache_creation_tokens: 2,
1233 output_tokens: 8,
1234 });
1235
1236 assert_eq!(total.input_tokens, 150);
1237 assert_eq!(total.cached_input_tokens, 30);
1238 assert_eq!(total.cache_creation_tokens, 7);
1239 assert_eq!(total.output_tokens, 18);
1240
1241 let mut saturating = Usage {
1242 input_tokens: u64::MAX,
1243 cached_input_tokens: u64::MAX,
1244 cache_creation_tokens: u64::MAX,
1245 output_tokens: u64::MAX,
1246 };
1247 saturating.add(&Usage {
1248 input_tokens: 1,
1249 cached_input_tokens: 1,
1250 cache_creation_tokens: 1,
1251 output_tokens: 1,
1252 });
1253 assert_eq!(saturating.input_tokens, u64::MAX);
1254 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1255 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1256 assert_eq!(saturating.output_tokens, u64::MAX);
1257 }
1258
1259 #[test]
1260 fn versioned_event_wraps_schema_version() {
1261 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1262
1263 let versioned = VersionedThreadEvent::new(event.clone());
1264
1265 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1266 assert_eq!(versioned.event, event);
1267 assert_eq!(versioned.into_event(), event);
1268 }
1269
1270 #[test]
1271 fn plan_approval_events_round_trip_with_decision() {
1272 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1273 thread_id: "thread-1".to_string(),
1274 turn_id: "turn-2".to_string(),
1275 plan_file: Some(".vtcode/plans/change.md".to_string()),
1276 });
1277 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1278 thread_id: "thread-1".to_string(),
1279 turn_id: "turn-3".to_string(),
1280 decision: PlanApprovalDecision::AutoAccept,
1281 automatic: false,
1282 });
1283
1284 for event in [requested, resolved] {
1285 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1286 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1287 assert_eq!(restored, event);
1288 }
1289 }
1290
1291 #[test]
1292 fn plan_approval_decision_uses_stable_wire_names() {
1293 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1294 thread_id: "thread-1".to_string(),
1295 turn_id: "turn-1".to_string(),
1296 decision: PlanApprovalDecision::SwitchBuild,
1297 automatic: false,
1298 });
1299
1300 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1301 assert_eq!(serialized["type"], "plan.approval.resolved");
1302 assert_eq!(serialized["decision"], "switch_build");
1303 }
1304
1305 #[test]
1306 fn plan_approval_decision_is_forward_compatible() {
1307 let payload = serde_json::json!({
1308 "type": "plan.approval.resolved",
1309 "thread_id": "thread-1",
1310 "turn_id": "turn-1",
1311 "decision": "future_decision",
1312 "automatic": true,
1313 });
1314 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1315 assert!(matches!(
1316 event,
1317 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1318 decision: PlanApprovalDecision::Unknown,
1319 automatic: true,
1320 ..
1321 })
1322 ));
1323 }
1324
1325 #[cfg(feature = "serde-json")]
1326 #[test]
1327 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1328 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1329 item: ThreadItem {
1330 id: "item-1".to_string(),
1331 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1332 },
1333 });
1334
1335 let payload = json::versioned_to_string(&event)?;
1336 let restored = json::versioned_from_str(&payload)?;
1337
1338 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1339 assert_eq!(restored.event, event);
1340 Ok(())
1341 }
1342
1343 #[test]
1344 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1345 for trigger in [
1346 CompactionTrigger::Manual,
1347 CompactionTrigger::Auto,
1348 CompactionTrigger::Recovery,
1349 CompactionTrigger::ModelSwitch,
1350 CompactionTrigger::Unknown,
1351 ] {
1352 let json = serde_json::to_string(&trigger).unwrap();
1353 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1354 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1355 assert_eq!(restored, trigger);
1356 }
1357 }
1358
1359 #[test]
1360 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1361 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1362 item: ThreadItem {
1363 id: "tool_1".to_string(),
1364 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1365 tool_name: "read_file".to_string(),
1366 arguments: Some(serde_json::json!({ "path": "README.md" })),
1367 tool_call_id: Some("tool_call_0".to_string()),
1368 status: ToolCallStatus::Completed,
1369 outcome: None,
1370 }),
1371 },
1372 });
1373
1374 let json = serde_json::to_string(&event)?;
1375 let restored: ThreadEvent = serde_json::from_str(&json)?;
1376
1377 assert_eq!(restored, event);
1378 Ok(())
1379 }
1380
1381 #[test]
1382 fn tool_outcome_serializes_snake_case() {
1383 for outcome in [
1384 ToolOutcome::Success,
1385 ToolOutcome::Error,
1386 ToolOutcome::PermissionRejected,
1387 ToolOutcome::PermissionCancelled,
1388 ToolOutcome::Followup,
1389 ToolOutcome::HookDenied,
1390 ToolOutcome::InvalidTool,
1391 ToolOutcome::Cancelled,
1392 ] {
1393 let json = serde_json::to_string(&outcome).unwrap();
1394 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1395 assert_eq!(restored, outcome);
1396 }
1397 }
1398
1399 #[test]
1400 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1401 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1402 item: ThreadItem {
1403 id: "tool_1".to_string(),
1404 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1405 tool_name: "exec_command".to_string(),
1406 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1407 tool_call_id: Some("tool_call_0".to_string()),
1408 status: ToolCallStatus::Failed,
1409 outcome: Some(ToolOutcome::PermissionRejected),
1410 }),
1411 },
1412 });
1413
1414 let json = serde_json::to_string(&event)?;
1415 let restored: ThreadEvent = serde_json::from_str(&json)?;
1416
1417 assert_eq!(restored, event);
1418 Ok(())
1419 }
1420
1421 #[test]
1422 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1423 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1424 item: ThreadItem {
1425 id: "tool_1:output".to_string(),
1426 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1427 call_id: "tool_1".to_string(),
1428 tool_call_id: Some("tool_call_0".to_string()),
1429 spool_path: None,
1430 output: "done".to_string(),
1431 exit_code: Some(0),
1432 status: ToolCallStatus::Completed,
1433 }),
1434 },
1435 });
1436
1437 let json = serde_json::to_string(&event)?;
1438 let restored: ThreadEvent = serde_json::from_str(&json)?;
1439
1440 assert_eq!(restored, event);
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1446 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1447 item: ThreadItem {
1448 id: "harness_1".to_string(),
1449 details: ThreadItemDetails::Harness(HarnessEventItem {
1450 event: HarnessEventKind::VerificationFailed,
1451 message: Some("cargo check failed".to_string()),
1452 command: Some("cargo check".to_string()),
1453 path: None,
1454 exit_code: Some(101),
1455 attempt: None,
1456 error_category: None,
1457 duration_ms: None,
1458 }),
1459 },
1460 });
1461
1462 let json = serde_json::to_string(&event)?;
1463 let restored: ThreadEvent = serde_json::from_str(&json)?;
1464
1465 assert_eq!(restored, event);
1466 Ok(())
1467 }
1468
1469 #[test]
1470 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1471 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1472 thread_id: "thread-1".to_string(),
1473 session_id: "session-1".to_string(),
1474 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1475 outcome_code: "budget_limit_reached".to_string(),
1476 result: None,
1477 stop_reason: Some("max_tokens".to_string()),
1478 usage: Usage {
1479 input_tokens: 10,
1480 cached_input_tokens: 4,
1481 cache_creation_tokens: 2,
1482 output_tokens: 5,
1483 },
1484 total_cost_usd: serde_json::Number::from_f64(1.25),
1485 num_turns: 3,
1486 });
1487
1488 let json = serde_json::to_string(&event)?;
1489 let restored: ThreadEvent = serde_json::from_str(&json)?;
1490
1491 assert_eq!(restored, event);
1492 Ok(())
1493 }
1494
1495 #[test]
1496 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1497 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1498 thread_id: "thread-1".to_string(),
1499 trigger: CompactionTrigger::Recovery,
1500 mode: CompactionMode::Provider,
1501 original_message_count: 12,
1502 compacted_message_count: 5,
1503 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1504 previous_segment_id: Some("segment-0001".to_string()),
1505 new_segment_id: Some("segment-0002".to_string()),
1506 previous_prefix_hash: Some("prefix-before".to_string()),
1507 new_prefix_hash: Some("prefix-after".to_string()),
1508 previous_catalog_hash: Some("catalog-before".to_string()),
1509 new_catalog_hash: Some("catalog-after".to_string()),
1510 });
1511
1512 let json = serde_json::to_string(&event)?;
1513 let restored: ThreadEvent = serde_json::from_str(&json)?;
1514
1515 assert_eq!(restored, event);
1516 Ok(())
1517 }
1518
1519 #[test]
1520 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1521 let payload = r#"{
1522 "type":"thread.compact_boundary",
1523 "thread_id":"thread-1",
1524 "trigger":"recovery",
1525 "mode":"provider",
1526 "original_message_count":12,
1527 "compacted_message_count":5
1528 }"#;
1529
1530 let restored: ThreadEvent = serde_json::from_str(payload)?;
1531 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1532 panic!("expected thread.compact_boundary event");
1533 };
1534
1535 assert_eq!(event.thread_id, "thread-1");
1536 assert_eq!(event.history_artifact_path, None);
1537 assert_eq!(event.previous_segment_id, None);
1538 assert_eq!(event.new_segment_id, None);
1539 assert_eq!(event.previous_prefix_hash, None);
1540 assert_eq!(event.new_prefix_hash, None);
1541 assert_eq!(event.previous_catalog_hash, None);
1542 assert_eq!(event.new_catalog_hash, None);
1543 Ok(())
1544 }
1545}