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.14.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(Box<ThreadCompletedEvent>),
375 #[serde(rename = "thread.compact_boundary")]
377 ThreadCompactBoundary(Box<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 = "turn.blocked")]
394 TurnBlocked(Box<TurnBlockedEvent>),
395 #[serde(rename = "item.started")]
397 ItemStarted(ItemStartedEvent),
398 #[serde(rename = "item.updated")]
400 ItemUpdated(ItemUpdatedEvent),
401 #[serde(rename = "item.completed")]
403 ItemCompleted(ItemCompletedEvent),
404 #[serde(rename = "permission.requested")]
406 PermissionRequested(PermissionRequestedEvent),
407 #[serde(rename = "permission.resolved")]
409 PermissionResolved(PermissionResolvedEvent),
410 #[serde(rename = "interjected")]
412 Interjected(InterjectedEvent),
413 #[serde(rename = "plan.delta")]
415 PlanDelta(Box<PlanDeltaEvent>),
416 #[serde(rename = "plan.approval.requested")]
418 PlanApprovalRequested(PlanApprovalRequestedEvent),
419 #[serde(rename = "plan.approval.resolved")]
421 PlanApprovalResolved(PlanApprovalResolvedEvent),
422 #[serde(rename = "error")]
424 Error(ThreadErrorEvent),
425 #[serde(other)]
428 Unknown,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
432#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
433pub struct ThreadStartedEvent {
434 pub thread_id: String,
436}
437
438#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
439#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
440#[serde(rename_all = "snake_case")]
441pub enum ThreadCompletionSubtype {
442 Success,
443 ErrorMaxTurns,
444 ErrorMaxBudgetUsd,
445 ErrorDuringExecution,
446 Cancelled,
447 #[serde(other)]
449 Unknown,
450}
451
452impl ThreadCompletionSubtype {
453 pub const fn as_str(&self) -> &'static str {
454 match self {
455 Self::Success => "success",
456 Self::ErrorMaxTurns => "error_max_turns",
457 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
458 Self::ErrorDuringExecution => "error_during_execution",
459 Self::Cancelled => "cancelled",
460 Self::Unknown => "unknown",
461 }
462 }
463
464 pub const fn is_success(self) -> bool {
465 matches!(self, Self::Success)
466 }
467}
468
469#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
470#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
471#[serde(rename_all = "snake_case")]
472pub enum CompactionTrigger {
473 Manual,
474 Auto,
475 Recovery,
476 ModelSwitch,
479 #[serde(other)]
481 Unknown,
482}
483
484impl CompactionTrigger {
485 pub const fn as_str(self) -> &'static str {
486 match self {
487 Self::Manual => "manual",
488 Self::Auto => "auto",
489 Self::Recovery => "recovery",
490 Self::ModelSwitch => "model_switch",
491 Self::Unknown => "unknown",
492 }
493 }
494}
495
496#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
497#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
498#[serde(rename_all = "snake_case")]
499pub enum CompactionMode {
500 Provider,
501 Local,
502 #[serde(other)]
504 Unknown,
505}
506
507impl CompactionMode {
508 pub const fn as_str(self) -> &'static str {
509 match self {
510 Self::Provider => "provider",
511 Self::Local => "local",
512 Self::Unknown => "unknown",
513 }
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
519pub struct ThreadCompletedEvent {
520 pub thread_id: String,
522 pub session_id: String,
524 pub subtype: ThreadCompletionSubtype,
526 pub outcome_code: String,
528 #[serde(skip_serializing_if = "Option::is_none")]
530 pub result: Option<String>,
531 #[serde(skip_serializing_if = "Option::is_none")]
533 pub stop_reason: Option<String>,
534 pub usage: Usage,
536 #[serde(skip_serializing_if = "Option::is_none")]
538 pub total_cost_usd: Option<serde_json::Number>,
539 pub num_turns: usize,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
544#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
545pub struct ThreadCompactBoundaryEvent {
546 pub thread_id: String,
548 pub trigger: CompactionTrigger,
550 pub mode: CompactionMode,
552 pub original_message_count: usize,
554 pub compacted_message_count: usize,
556 #[serde(skip_serializing_if = "Option::is_none")]
558 pub history_artifact_path: Option<String>,
559 #[serde(skip_serializing_if = "Option::is_none")]
561 pub previous_segment_id: Option<String>,
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub new_segment_id: Option<String>,
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub previous_prefix_hash: Option<String>,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub new_prefix_hash: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub previous_catalog_hash: Option<String>,
574 #[serde(skip_serializing_if = "Option::is_none")]
576 pub new_catalog_hash: Option<String>,
577}
578
579#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
581#[serde(rename_all = "snake_case")]
582pub enum ContextResetTrigger {
583 PlanApproval,
585 #[serde(other)]
587 Unknown,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct ContextResetEvent {
593 pub thread_id: String,
595 pub turn_id: String,
597 pub trigger: ContextResetTrigger,
599 pub plan_preserved: bool,
601 pub previous_context_usage_percent: u8,
603 pub tool_budget_reset: bool,
605}
606
607#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
608#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
609pub struct TurnStartedEvent {
610 #[serde(skip_serializing_if = "Option::is_none")]
614 token_breakdown: Option<TokenBreakdown>,
615}
616
617#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
619#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
620pub struct TokenBreakdown {
621 system_prompt_tokens: u64,
623 tool_schema_tokens: u64,
625 instruction_file_tokens: u64,
627 message_history_tokens: u64,
629 cache_read_tokens: u64,
631 cache_write_tokens: u64,
633 cache_miss_tokens: u64,
635 #[serde(skip_serializing_if = "Option::is_none")]
637 subagent_bootstrap_tokens: Option<u64>,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
641#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
642pub struct TurnCompletedEvent {
643 pub usage: Usage,
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649pub struct TurnFailedEvent {
650 pub message: String,
652 #[serde(skip_serializing_if = "Option::is_none")]
654 pub usage: Option<Usage>,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
659pub struct TurnBlockedEvent {
660 pub message: String,
662 #[serde(skip_serializing_if = "Option::is_none")]
664 pub last_tool: Option<String>,
665 #[serde(default)]
667 pub blocked_streak: usize,
668 #[serde(default)]
670 pub blocked_total: usize,
671 #[serde(default)]
673 pub consecutive_cap: usize,
674 #[serde(default)]
676 pub total_cap: usize,
677 #[serde(default)]
679 pub recovery_active: bool,
680 #[serde(skip_serializing_if = "Option::is_none")]
682 pub usage: Option<Usage>,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
687pub struct ThreadErrorEvent {
688 pub message: String,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
693#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
694pub struct Usage {
695 #[serde(default, deserialize_with = "deserialize_null_as_default")]
697 pub input_tokens: u64,
698 #[serde(default, deserialize_with = "deserialize_null_as_default")]
700 pub cached_input_tokens: u64,
701 #[serde(default, deserialize_with = "deserialize_null_as_default")]
703 pub cache_creation_tokens: u64,
704 #[serde(default, deserialize_with = "deserialize_null_as_default")]
706 pub output_tokens: u64,
707}
708
709pub fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
716where
717 D: serde::Deserializer<'de>,
718 T: Deserialize<'de> + Default,
719{
720 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
721}
722
723impl Usage {
724 #[must_use]
729 fn uncached_input_tokens(&self) -> u64 {
730 self.input_tokens
731 .saturating_sub(self.cached_input_tokens)
732 .saturating_sub(self.cache_creation_tokens)
733 }
734
735 #[must_use]
738 pub fn cache_hit_rate(&self) -> Option<f64> {
739 if self.input_tokens == 0 {
740 return None;
741 }
742 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
743 }
744
745 #[must_use]
747 pub fn cache_summary(&self) -> String {
748 let total_input = self.input_tokens;
749 if total_input == 0 {
750 return "No input tokens recorded.".to_string();
751 }
752
753 let cached = self.cached_input_tokens;
754 let creation = self.cache_creation_tokens;
755 let uncached = self.uncached_input_tokens();
756 let rate = cached as f64 / total_input as f64 * 100.0;
757 format!(
758 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
759 {creation} cache-creation, {uncached} uncached"
760 )
761 }
762
763 pub fn add(&mut self, other: &Usage) {
765 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
766 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
767 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
768 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
769 }
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
773#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
774pub struct ItemCompletedEvent {
775 pub item: ThreadItem,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
780#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
781pub struct ItemStartedEvent {
782 pub item: ThreadItem,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
787#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
788pub struct ItemUpdatedEvent {
789 pub item: ThreadItem,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
794#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
795pub struct PlanDeltaEvent {
796 pub thread_id: String,
798 pub turn_id: String,
800 pub item_id: String,
802 pub delta: String,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
807#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
808pub struct PlanApprovalRequestedEvent {
809 pub thread_id: String,
811 pub turn_id: String,
813 #[serde(skip_serializing_if = "Option::is_none")]
815 pub plan_file: Option<String>,
816}
817
818#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
819#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
820#[serde(rename_all = "snake_case")]
821pub enum PlanApprovalDecision {
822 Execute,
824 AutoAccept,
826 FreshContext,
828 Revise,
830 Cancel,
832 SwitchBuild,
834 SwitchAuto,
836 #[serde(other)]
838 Unknown,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
842#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
843pub struct PlanApprovalResolvedEvent {
844 pub thread_id: String,
846 pub turn_id: String,
848 pub decision: PlanApprovalDecision,
850 pub automatic: bool,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
855#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
856pub struct ThreadItem {
857 pub id: String,
859 #[serde(flatten)]
861 pub details: ThreadItemDetails,
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
865#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
866#[serde(tag = "type", rename_all = "snake_case")]
867pub enum ThreadItemDetails {
868 AgentMessage(AgentMessageItem),
870 Plan(PlanItem),
872 Reasoning(ReasoningItem),
874 CommandExecution(Box<CommandExecutionItem>),
876 ToolInvocation(Box<ToolInvocationItem>),
878 ToolOutput(Box<ToolOutputItem>),
880 FileChange(Box<FileChangeItem>),
882 McpToolCall(Box<McpToolCallItem>),
884 WebSearch(Box<WebSearchItem>),
886 Harness(Box<HarnessEventItem>),
888 Error(ErrorItem),
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
894pub struct AgentMessageItem {
895 pub text: String,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
900#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
901pub struct PlanItem {
902 pub text: String,
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
907#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
908pub struct ReasoningItem {
909 pub text: String,
911 #[serde(skip_serializing_if = "Option::is_none")]
914 pub stage: Option<String>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
919#[serde(rename_all = "snake_case")]
920pub enum CommandExecutionStatus {
921 #[default]
923 Completed,
924 Failed,
926 InProgress,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
931#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
932pub struct CommandExecutionItem {
933 pub command: String,
935 #[serde(skip_serializing_if = "Option::is_none")]
937 pub arguments: Option<Value>,
938 #[serde(default)]
940 pub aggregated_output: String,
941 #[serde(skip_serializing_if = "Option::is_none")]
943 pub exit_code: Option<i32>,
944 pub status: CommandExecutionStatus,
946}
947
948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
949#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
950#[serde(rename_all = "snake_case")]
951pub enum ToolCallStatus {
952 #[default]
954 Completed,
955 Failed,
957 InProgress,
959}
960
961#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
970#[serde(rename_all = "snake_case")]
971pub enum ToolOutcome {
972 #[default]
974 Success,
975 Error,
977 PermissionRejected,
979 PermissionCancelled,
981 Followup,
983 HookDenied,
985 InvalidTool,
987 Cancelled,
989}
990
991impl ToolOutcome {
992 #[must_use]
993 pub const fn is_terminal(self) -> bool {
994 !matches!(self, Self::Followup)
995 }
996}
997
998#[must_use]
1005#[allow(
1006 clippy::unreachable,
1007 reason = "Intentional compatibility, platform, or test-only suppression."
1008)]
1009pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
1010 match status {
1011 ToolCallStatus::Completed => ToolOutcome::Success,
1012 ToolCallStatus::Failed => ToolOutcome::Error,
1013 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
1014 }
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1019pub struct ToolInvocationItem {
1020 pub tool_name: String,
1022 #[serde(skip_serializing_if = "Option::is_none")]
1024 pub arguments: Option<Value>,
1025 #[serde(skip_serializing_if = "Option::is_none")]
1027 pub tool_call_id: Option<String>,
1028 pub status: ToolCallStatus,
1030 #[serde(skip_serializing_if = "Option::is_none")]
1032 pub outcome: Option<ToolOutcome>,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1036#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1037pub struct ToolOutputItem {
1038 pub call_id: String,
1040 #[serde(skip_serializing_if = "Option::is_none")]
1042 pub tool_call_id: Option<String>,
1043 #[serde(skip_serializing_if = "Option::is_none")]
1045 pub spool_path: Option<String>,
1046 #[serde(default)]
1048 pub output: String,
1049 #[serde(skip_serializing_if = "Option::is_none")]
1051 pub exit_code: Option<i32>,
1052 pub status: ToolCallStatus,
1054}
1055
1056#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1057#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1058pub struct FileChangeItem {
1059 pub changes: Vec<FileUpdateChange>,
1061 pub status: PatchApplyStatus,
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1067pub struct FileUpdateChange {
1068 pub path: String,
1070 pub kind: PatchChangeKind,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1075#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1076#[serde(rename_all = "snake_case")]
1077pub enum PatchApplyStatus {
1078 Completed,
1080 Failed,
1082}
1083
1084#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1085#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1086#[serde(rename_all = "snake_case")]
1087pub enum PatchChangeKind {
1088 Add,
1090 Delete,
1092 Update,
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1098pub struct McpToolCallItem {
1099 pub tool_name: String,
1101 #[serde(skip_serializing_if = "Option::is_none")]
1103 pub arguments: Option<Value>,
1104 #[serde(skip_serializing_if = "Option::is_none")]
1106 pub result: Option<String>,
1107 #[serde(skip_serializing_if = "Option::is_none")]
1109 pub status: Option<McpToolCallStatus>,
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114#[serde(rename_all = "snake_case")]
1115pub enum McpToolCallStatus {
1116 Started,
1118 Completed,
1120 Failed,
1122}
1123
1124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1125#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1126pub struct WebSearchItem {
1127 pub query: String,
1129 #[serde(skip_serializing_if = "Option::is_none")]
1131 pub provider: Option<String>,
1132 #[serde(skip_serializing_if = "Option::is_none")]
1134 pub results: Option<Vec<String>>,
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1138#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1139#[serde(rename_all = "snake_case")]
1140pub enum HarnessEventKind {
1141 PlanningStarted,
1142 PlanningCompleted,
1143 ContinuationStarted,
1144 ContinuationSkipped,
1145 TurnBlocked,
1148 BlockedRecoveryStarted,
1150 BlockedRecoveryFinished,
1152 BlockedHandoffWritten,
1153 BlockedHandoffResolved,
1156 EvaluationStarted,
1157 EvaluationPassed,
1158 EvaluationFailed,
1159 RevisionStarted,
1160 EscalationTriggered,
1161 EscalationBypassed,
1162 VerificationStarted,
1163 VerificationPassed,
1164 VerificationFailed,
1165 ErrorRecovered,
1167 ToolRetryAttempted,
1169 ToolLatencyRecorded,
1171 SnapshotCreated,
1173 SnapshotRestored,
1175 SessionToolLimitIncreased,
1178 ToolLoopLimitIncreased,
1180}
1181
1182#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1183#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1184#[serde(rename_all = "snake_case")]
1185pub enum PermissionDecision {
1186 Allow,
1187 Deny,
1188 Cancelled,
1189 Followup,
1190}
1191
1192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1194pub struct PermissionRequestedEvent {
1195 pub tool_name: String,
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1201pub struct PermissionResolvedEvent {
1202 pub tool_name: String,
1204 pub decision: PermissionDecision,
1206 pub wait_ms: u64,
1208}
1209
1210#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1211#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1212#[serde(rename_all = "snake_case")]
1213pub enum InterjectionSource {
1214 Direct,
1215 Queue,
1216}
1217
1218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1219#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1220#[serde(rename_all = "snake_case")]
1221pub enum RedirectKind {
1222 Interjection,
1223}
1224
1225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1226#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1227pub struct InterjectedEvent {
1228 pub source: InterjectionSource,
1230 pub image_count: u32,
1232 pub redirect_kind: RedirectKind,
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1238#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1239pub struct HarnessEventItem {
1240 pub event: HarnessEventKind,
1242 #[serde(skip_serializing_if = "Option::is_none")]
1244 pub message: Option<String>,
1245 #[serde(skip_serializing_if = "Option::is_none")]
1247 pub command: Option<String>,
1248 #[serde(skip_serializing_if = "Option::is_none")]
1250 pub path: Option<String>,
1251 #[serde(skip_serializing_if = "Option::is_none")]
1253 pub exit_code: Option<i32>,
1254 #[serde(skip_serializing_if = "Option::is_none")]
1256 pub attempt: Option<u32>,
1257 #[serde(skip_serializing_if = "Option::is_none")]
1259 pub error_category: Option<String>,
1260 #[serde(skip_serializing_if = "Option::is_none")]
1262 pub duration_ms: Option<u64>,
1263}
1264
1265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1266#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1267pub struct ErrorItem {
1268 pub message: String,
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275 use std::error::Error;
1276 use std::mem::size_of;
1277
1278 #[test]
1283 fn thread_event_stays_compact() {
1284 assert!(
1285 size_of::<ThreadEvent>() <= 80,
1286 "ThreadEvent grew to {} bytes; box new large payloads instead of inlining them",
1287 size_of::<ThreadEvent>()
1288 );
1289 }
1290
1291 #[test]
1294 fn boxed_thread_item_details_payloads_stay_boxed() {
1295 assert!(size_of::<Option<Box<CommandExecutionItem>>>() < size_of::<Option<CommandExecutionItem>>());
1296 assert!(size_of::<Option<Box<ToolInvocationItem>>>() < size_of::<Option<ToolInvocationItem>>());
1297 assert!(size_of::<Option<Box<ToolOutputItem>>>() < size_of::<Option<ToolOutputItem>>());
1298 assert!(size_of::<Option<Box<FileChangeItem>>>() < size_of::<Option<FileChangeItem>>());
1299 assert!(size_of::<Option<Box<McpToolCallItem>>>() < size_of::<Option<McpToolCallItem>>());
1300 assert!(size_of::<Option<Box<WebSearchItem>>>() < size_of::<Option<WebSearchItem>>());
1301 assert!(size_of::<Option<Box<HarnessEventItem>>>() < size_of::<Option<HarnessEventItem>>());
1302 }
1303
1304 #[test]
1305 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1306 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1307 usage: Usage {
1308 input_tokens: 1,
1309 cached_input_tokens: 2,
1310 cache_creation_tokens: 0,
1311 output_tokens: 3,
1312 },
1313 });
1314
1315 let json = serde_json::to_string(&event)?;
1316 let restored: ThreadEvent = serde_json::from_str(&json)?;
1317
1318 assert_eq!(restored, event);
1319 Ok(())
1320 }
1321
1322 #[test]
1323 fn turn_blocked_event_round_trip() -> Result<(), Box<dyn Error>> {
1324 let event = ThreadEvent::TurnBlocked(Box::new(TurnBlockedEvent {
1325 message: "Blocked tool-call limit reached after 3 consecutive blocked calls.".to_string(),
1326 last_tool: Some("exec_command".to_string()),
1327 blocked_streak: 4,
1328 blocked_total: 4,
1329 consecutive_cap: 3,
1330 total_cap: 6,
1331 recovery_active: false,
1332 usage: None,
1333 }));
1334
1335 let json = serde_json::to_string(&event)?;
1336 assert!(json.contains("turn.blocked"));
1337 let restored: ThreadEvent = serde_json::from_str(&json)?;
1338 assert_eq!(restored, event);
1339
1340 let legacy = serde_json::json!({"type": "turn.blocked", "message": "blocked"});
1342 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1343 assert!(matches!(parsed, ThreadEvent::TurnBlocked(_)));
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn usage_uncached_input_tokens_saturates() {
1349 let usage = Usage {
1350 input_tokens: 1_000,
1351 cached_input_tokens: 800,
1352 cache_creation_tokens: 100,
1353 output_tokens: 50,
1354 };
1355 assert_eq!(usage.uncached_input_tokens(), 100);
1356
1357 let inconsistent = Usage {
1358 input_tokens: 100,
1359 cached_input_tokens: 150,
1360 cache_creation_tokens: 0,
1361 output_tokens: 0,
1362 };
1363 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1364
1365 let inconsistent_with_creation = Usage {
1366 input_tokens: 100,
1367 cached_input_tokens: 80,
1368 cache_creation_tokens: 50,
1369 output_tokens: 0,
1370 };
1371 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1372 }
1373
1374 #[test]
1375 fn usage_cache_hit_rate() {
1376 assert_eq!(Usage::default().cache_hit_rate(), None);
1377
1378 let usage = Usage {
1379 input_tokens: 1_000,
1380 cached_input_tokens: 750,
1381 cache_creation_tokens: 0,
1382 output_tokens: 0,
1383 };
1384 let rate = usage.cache_hit_rate().expect("rate");
1385 assert!((rate - 0.75).abs() < f64::EPSILON);
1386 }
1387
1388 #[test]
1389 fn usage_cache_summary_formats() {
1390 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1391
1392 let usage = Usage {
1393 input_tokens: 1_000,
1394 cached_input_tokens: 800,
1395 cache_creation_tokens: 100,
1396 output_tokens: 50,
1397 };
1398 assert_eq!(
1399 usage.cache_summary(),
1400 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1401 );
1402 }
1403
1404 #[test]
1405 fn usage_add_accumulates_all_fields_with_saturation() {
1406 let mut total = Usage {
1407 input_tokens: 100,
1408 cached_input_tokens: 20,
1409 cache_creation_tokens: 5,
1410 output_tokens: 10,
1411 };
1412 total.add(&Usage {
1413 input_tokens: 50,
1414 cached_input_tokens: 10,
1415 cache_creation_tokens: 2,
1416 output_tokens: 8,
1417 });
1418
1419 assert_eq!(total.input_tokens, 150);
1420 assert_eq!(total.cached_input_tokens, 30);
1421 assert_eq!(total.cache_creation_tokens, 7);
1422 assert_eq!(total.output_tokens, 18);
1423
1424 let mut saturating = Usage {
1425 input_tokens: u64::MAX,
1426 cached_input_tokens: u64::MAX,
1427 cache_creation_tokens: u64::MAX,
1428 output_tokens: u64::MAX,
1429 };
1430 saturating.add(&Usage {
1431 input_tokens: 1,
1432 cached_input_tokens: 1,
1433 cache_creation_tokens: 1,
1434 output_tokens: 1,
1435 });
1436 assert_eq!(saturating.input_tokens, u64::MAX);
1437 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1438 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1439 assert_eq!(saturating.output_tokens, u64::MAX);
1440 }
1441
1442 #[test]
1443 fn versioned_event_wraps_schema_version() {
1444 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1445
1446 let versioned = VersionedThreadEvent::new(event.clone());
1447
1448 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1449 assert_eq!(versioned.event, event);
1450 assert_eq!(versioned.into_event(), event);
1451 }
1452
1453 #[test]
1454 fn plan_approval_events_round_trip_with_decision() {
1455 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1456 thread_id: "thread-1".to_string(),
1457 turn_id: "turn-2".to_string(),
1458 plan_file: Some(".vtcode/plans/change.md".to_string()),
1459 });
1460 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1461 thread_id: "thread-1".to_string(),
1462 turn_id: "turn-3".to_string(),
1463 decision: PlanApprovalDecision::AutoAccept,
1464 automatic: false,
1465 });
1466
1467 for event in [requested, resolved] {
1468 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1469 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1470 assert_eq!(restored, event);
1471 }
1472 }
1473
1474 #[test]
1475 fn context_reset_event_round_trips_with_handoff_metadata() {
1476 let event = ThreadEvent::ContextReset(ContextResetEvent {
1477 thread_id: "thread-1".to_string(),
1478 turn_id: "turn-3".to_string(),
1479 trigger: ContextResetTrigger::PlanApproval,
1480 plan_preserved: true,
1481 previous_context_usage_percent: 7,
1482 tool_budget_reset: true,
1483 });
1484
1485 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1486 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1487 assert_eq!(restored, event);
1488 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1489 }
1490
1491 #[test]
1492 fn plan_approval_decision_uses_stable_wire_names() {
1493 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1494 thread_id: "thread-1".to_string(),
1495 turn_id: "turn-1".to_string(),
1496 decision: PlanApprovalDecision::SwitchBuild,
1497 automatic: false,
1498 });
1499
1500 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1501 assert_eq!(serialized["type"], "plan.approval.resolved");
1502 assert_eq!(serialized["decision"], "switch_build");
1503 }
1504
1505 #[test]
1506 fn plan_approval_decision_is_forward_compatible() {
1507 let payload = serde_json::json!({
1508 "type": "plan.approval.resolved",
1509 "thread_id": "thread-1",
1510 "turn_id": "turn-1",
1511 "decision": "future_decision",
1512 "automatic": true,
1513 });
1514 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1515 assert!(matches!(
1516 event,
1517 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1518 decision: PlanApprovalDecision::Unknown,
1519 automatic: true,
1520 ..
1521 })
1522 ));
1523 }
1524
1525 #[cfg(feature = "serde-json")]
1526 #[test]
1527 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1528 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1529 item: ThreadItem {
1530 id: "item-1".to_string(),
1531 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1532 },
1533 });
1534
1535 let payload = json::versioned_to_string(&event)?;
1536 let restored = json::versioned_from_str(&payload)?;
1537
1538 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1539 assert_eq!(restored.event, event);
1540 Ok(())
1541 }
1542
1543 #[test]
1544 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1545 for trigger in [
1546 CompactionTrigger::Manual,
1547 CompactionTrigger::Auto,
1548 CompactionTrigger::Recovery,
1549 CompactionTrigger::ModelSwitch,
1550 CompactionTrigger::Unknown,
1551 ] {
1552 let json = serde_json::to_string(&trigger).unwrap();
1553 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1554 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1555 assert_eq!(restored, trigger);
1556 }
1557 }
1558
1559 #[test]
1560 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1561 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1562 item: ThreadItem {
1563 id: "tool_1".to_string(),
1564 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1565 tool_name: "read_file".to_string(),
1566 arguments: Some(serde_json::json!({ "path": "README.md" })),
1567 tool_call_id: Some("tool_call_0".to_string()),
1568 status: ToolCallStatus::Completed,
1569 outcome: None,
1570 })),
1571 },
1572 });
1573
1574 let json = serde_json::to_string(&event)?;
1575 let restored: ThreadEvent = serde_json::from_str(&json)?;
1576
1577 assert_eq!(restored, event);
1578 Ok(())
1579 }
1580
1581 #[test]
1582 fn tool_outcome_serializes_snake_case() {
1583 for outcome in [
1584 ToolOutcome::Success,
1585 ToolOutcome::Error,
1586 ToolOutcome::PermissionRejected,
1587 ToolOutcome::PermissionCancelled,
1588 ToolOutcome::Followup,
1589 ToolOutcome::HookDenied,
1590 ToolOutcome::InvalidTool,
1591 ToolOutcome::Cancelled,
1592 ] {
1593 let json = serde_json::to_string(&outcome).unwrap();
1594 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1595 assert_eq!(restored, outcome);
1596 }
1597 }
1598
1599 #[test]
1600 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1601 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1602 item: ThreadItem {
1603 id: "tool_1".to_string(),
1604 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1605 tool_name: "exec_command".to_string(),
1606 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1607 tool_call_id: Some("tool_call_0".to_string()),
1608 status: ToolCallStatus::Failed,
1609 outcome: Some(ToolOutcome::PermissionRejected),
1610 })),
1611 },
1612 });
1613
1614 let json = serde_json::to_string(&event)?;
1615 let restored: ThreadEvent = serde_json::from_str(&json)?;
1616
1617 assert_eq!(restored, event);
1618 Ok(())
1619 }
1620
1621 #[test]
1622 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1623 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1624 item: ThreadItem {
1625 id: "tool_1:output".to_string(),
1626 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1627 call_id: "tool_1".to_string(),
1628 tool_call_id: Some("tool_call_0".to_string()),
1629 spool_path: None,
1630 output: "done".to_string(),
1631 exit_code: Some(0),
1632 status: ToolCallStatus::Completed,
1633 })),
1634 },
1635 });
1636
1637 let json = serde_json::to_string(&event)?;
1638 let restored: ThreadEvent = serde_json::from_str(&json)?;
1639
1640 assert_eq!(restored, event);
1641 Ok(())
1642 }
1643
1644 #[test]
1645 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1646 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1647 item: ThreadItem {
1648 id: "harness_1".to_string(),
1649 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1650 event: HarnessEventKind::VerificationFailed,
1651 message: Some("cargo check failed".to_string()),
1652 command: Some("cargo check".to_string()),
1653 path: None,
1654 exit_code: Some(101),
1655 attempt: None,
1656 error_category: None,
1657 duration_ms: None,
1658 })),
1659 },
1660 });
1661
1662 let json = serde_json::to_string(&event)?;
1663 let restored: ThreadEvent = serde_json::from_str(&json)?;
1664
1665 assert_eq!(restored, event);
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn blocked_handoff_resolved_uses_stable_wire_name() -> Result<(), Box<dyn Error>> {
1671 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1672 item: ThreadItem {
1673 id: "harness_resolved".to_string(),
1674 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1675 event: HarnessEventKind::BlockedHandoffResolved,
1676 message: Some("resolved".to_string()),
1677 command: None,
1678 path: None,
1679 exit_code: None,
1680 attempt: None,
1681 error_category: None,
1682 duration_ms: None,
1683 })),
1684 },
1685 });
1686
1687 let value = serde_json::to_value(&event)?;
1688 assert_eq!(value["item"]["event"], "blocked_handoff_resolved");
1689
1690 let restored: ThreadEvent = serde_json::from_value(value)?;
1691 assert_eq!(restored, event);
1692 Ok(())
1693 }
1694
1695 #[test]
1696 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1697 let event = ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1698 thread_id: "thread-1".to_string(),
1699 session_id: "session-1".to_string(),
1700 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1701 outcome_code: "budget_limit_reached".to_string(),
1702 result: None,
1703 stop_reason: Some("max_tokens".to_string()),
1704 usage: Usage {
1705 input_tokens: 10,
1706 cached_input_tokens: 4,
1707 cache_creation_tokens: 2,
1708 output_tokens: 5,
1709 },
1710 total_cost_usd: serde_json::Number::from_f64(1.25),
1711 num_turns: 3,
1712 }));
1713
1714 let json = serde_json::to_string(&event)?;
1715 let restored: ThreadEvent = serde_json::from_str(&json)?;
1716
1717 assert_eq!(restored, event);
1718 Ok(())
1719 }
1720
1721 #[test]
1722 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1723 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1724 thread_id: "thread-1".to_string(),
1725 trigger: CompactionTrigger::Recovery,
1726 mode: CompactionMode::Provider,
1727 original_message_count: 12,
1728 compacted_message_count: 5,
1729 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1730 previous_segment_id: Some("segment-0001".to_string()),
1731 new_segment_id: Some("segment-0002".to_string()),
1732 previous_prefix_hash: Some("prefix-before".to_string()),
1733 new_prefix_hash: Some("prefix-after".to_string()),
1734 previous_catalog_hash: Some("catalog-before".to_string()),
1735 new_catalog_hash: Some("catalog-after".to_string()),
1736 }));
1737
1738 let json = serde_json::to_string(&event)?;
1739 let restored: ThreadEvent = serde_json::from_str(&json)?;
1740
1741 assert_eq!(restored, event);
1742 Ok(())
1743 }
1744
1745 #[test]
1746 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1747 let payload = r#"{
1748 "type":"thread.compact_boundary",
1749 "thread_id":"thread-1",
1750 "trigger":"recovery",
1751 "mode":"provider",
1752 "original_message_count":12,
1753 "compacted_message_count":5
1754 }"#;
1755
1756 let restored: ThreadEvent = serde_json::from_str(payload)?;
1757 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1758 panic!("expected thread.compact_boundary event");
1759 };
1760
1761 assert_eq!(event.thread_id, "thread-1");
1762 assert_eq!(event.history_artifact_path, None);
1763 assert_eq!(event.previous_segment_id, None);
1764 assert_eq!(event.new_segment_id, None);
1765 assert_eq!(event.previous_prefix_hash, None);
1766 assert_eq!(event.new_prefix_hash, None);
1767 assert_eq!(event.previous_catalog_hash, None);
1768 assert_eq!(event.new_catalog_hash, None);
1769 Ok(())
1770 }
1771}