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 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub unified_diff: Option<String>,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub additions: Option<u64>,
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub deletions: Option<u64>,
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1078#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1079pub struct FileUpdateChange {
1080 pub path: String,
1082 pub kind: PatchChangeKind,
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 PatchApplyStatus {
1090 Completed,
1092 Failed,
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1098#[serde(rename_all = "snake_case")]
1099pub enum PatchChangeKind {
1100 Add,
1102 Delete,
1104 Update,
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1109#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1110pub struct McpToolCallItem {
1111 pub tool_name: String,
1113 #[serde(skip_serializing_if = "Option::is_none")]
1115 pub arguments: Option<Value>,
1116 #[serde(skip_serializing_if = "Option::is_none")]
1118 pub result: Option<String>,
1119 #[serde(skip_serializing_if = "Option::is_none")]
1121 pub status: Option<McpToolCallStatus>,
1122}
1123
1124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1125#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1126#[serde(rename_all = "snake_case")]
1127pub enum McpToolCallStatus {
1128 Started,
1130 Completed,
1132 Failed,
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1137#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1138pub struct WebSearchItem {
1139 pub query: String,
1141 #[serde(skip_serializing_if = "Option::is_none")]
1143 pub provider: Option<String>,
1144 #[serde(skip_serializing_if = "Option::is_none")]
1146 pub results: Option<Vec<String>>,
1147}
1148
1149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1150#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1151#[serde(rename_all = "snake_case")]
1152pub enum HarnessEventKind {
1153 PlanningStarted,
1154 PlanningCompleted,
1155 ContinuationStarted,
1156 ContinuationSkipped,
1157 TurnBlocked,
1160 BlockedRecoveryStarted,
1162 BlockedRecoveryFinished,
1164 BlockedHandoffWritten,
1165 BlockedHandoffResolved,
1168 EvaluationStarted,
1169 EvaluationPassed,
1170 EvaluationFailed,
1171 RevisionStarted,
1172 EscalationTriggered,
1173 EscalationBypassed,
1174 VerificationStarted,
1175 VerificationPassed,
1176 VerificationFailed,
1177 ErrorRecovered,
1179 ToolRetryAttempted,
1181 ToolLatencyRecorded,
1183 SnapshotCreated,
1185 SnapshotRestored,
1187 SessionToolLimitIncreased,
1190 ToolLoopLimitIncreased,
1192}
1193
1194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1195#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1196#[serde(rename_all = "snake_case")]
1197pub enum PermissionDecision {
1198 Allow,
1199 Deny,
1200 Cancelled,
1201 Followup,
1202}
1203
1204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1205#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1206pub struct PermissionRequestedEvent {
1207 pub tool_name: String,
1209}
1210
1211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1212#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1213pub struct PermissionResolvedEvent {
1214 pub tool_name: String,
1216 pub decision: PermissionDecision,
1218 pub wait_ms: u64,
1220}
1221
1222#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1223#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1224#[serde(rename_all = "snake_case")]
1225pub enum InterjectionSource {
1226 Direct,
1227 Queue,
1228}
1229
1230#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1231#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1232#[serde(rename_all = "snake_case")]
1233pub enum RedirectKind {
1234 Interjection,
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1238#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1239pub struct InterjectedEvent {
1240 pub source: InterjectionSource,
1242 pub image_count: u32,
1244 pub redirect_kind: RedirectKind,
1247}
1248
1249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1250#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1251pub struct HarnessEventItem {
1252 pub event: HarnessEventKind,
1254 #[serde(skip_serializing_if = "Option::is_none")]
1256 pub message: Option<String>,
1257 #[serde(skip_serializing_if = "Option::is_none")]
1259 pub command: Option<String>,
1260 #[serde(skip_serializing_if = "Option::is_none")]
1262 pub path: Option<String>,
1263 #[serde(skip_serializing_if = "Option::is_none")]
1265 pub exit_code: Option<i32>,
1266 #[serde(skip_serializing_if = "Option::is_none")]
1268 pub attempt: Option<u32>,
1269 #[serde(skip_serializing_if = "Option::is_none")]
1271 pub error_category: Option<String>,
1272 #[serde(skip_serializing_if = "Option::is_none")]
1274 pub duration_ms: Option<u64>,
1275}
1276
1277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1278#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1279pub struct ErrorItem {
1280 pub message: String,
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use std::error::Error;
1288 use std::mem::size_of;
1289
1290 #[test]
1295 fn thread_event_stays_compact() {
1296 assert!(
1297 size_of::<ThreadEvent>() <= 80,
1298 "ThreadEvent grew to {} bytes; box new large payloads instead of inlining them",
1299 size_of::<ThreadEvent>()
1300 );
1301 }
1302
1303 #[test]
1306 fn boxed_thread_item_details_payloads_stay_boxed() {
1307 assert!(size_of::<Option<Box<CommandExecutionItem>>>() < size_of::<Option<CommandExecutionItem>>());
1308 assert!(size_of::<Option<Box<ToolInvocationItem>>>() < size_of::<Option<ToolInvocationItem>>());
1309 assert!(size_of::<Option<Box<ToolOutputItem>>>() < size_of::<Option<ToolOutputItem>>());
1310 assert!(size_of::<Option<Box<FileChangeItem>>>() < size_of::<Option<FileChangeItem>>());
1311 assert!(size_of::<Option<Box<McpToolCallItem>>>() < size_of::<Option<McpToolCallItem>>());
1312 assert!(size_of::<Option<Box<WebSearchItem>>>() < size_of::<Option<WebSearchItem>>());
1313 assert!(size_of::<Option<Box<HarnessEventItem>>>() < size_of::<Option<HarnessEventItem>>());
1314 }
1315
1316 #[test]
1317 fn file_change_item_optional_diff_fields_round_trip() -> Result<(), Box<dyn Error>> {
1318 let legacy_json = r#"{
1320 "changes": [{"path": "src/main.rs", "kind": "add"}],
1321 "status": "completed"
1322 }"#;
1323 let legacy: FileChangeItem = serde_json::from_str(legacy_json)?;
1324 assert!(legacy.unified_diff.is_none());
1325 assert!(legacy.additions.is_none());
1326 assert!(legacy.deletions.is_none());
1327
1328 let legacy_reserialized = serde_json::to_value(&legacy)?;
1330 assert!(legacy_reserialized.get("unified_diff").is_none());
1331 assert!(legacy_reserialized.get("additions").is_none());
1332 assert!(legacy_reserialized.get("deletions").is_none());
1333
1334 let populated = FileChangeItem {
1336 changes: legacy.changes.clone(),
1337 status: PatchApplyStatus::Completed,
1338 unified_diff: Some("diff --git a/x b/x\n".to_string()),
1339 additions: Some(3),
1340 deletions: Some(1),
1341 };
1342 let json = serde_json::to_string(&populated)?;
1343 let restored: FileChangeItem = serde_json::from_str(&json)?;
1344 assert_eq!(restored, populated);
1345 Ok(())
1346 }
1347
1348 #[test]
1349 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1350 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1351 usage: Usage {
1352 input_tokens: 1,
1353 cached_input_tokens: 2,
1354 cache_creation_tokens: 0,
1355 output_tokens: 3,
1356 },
1357 });
1358
1359 let json = serde_json::to_string(&event)?;
1360 let restored: ThreadEvent = serde_json::from_str(&json)?;
1361
1362 assert_eq!(restored, event);
1363 Ok(())
1364 }
1365
1366 #[test]
1367 fn turn_blocked_event_round_trip() -> Result<(), Box<dyn Error>> {
1368 let event = ThreadEvent::TurnBlocked(Box::new(TurnBlockedEvent {
1369 message: "Blocked tool-call limit reached after 3 consecutive blocked calls.".to_string(),
1370 last_tool: Some("exec_command".to_string()),
1371 blocked_streak: 4,
1372 blocked_total: 4,
1373 consecutive_cap: 3,
1374 total_cap: 6,
1375 recovery_active: false,
1376 usage: None,
1377 }));
1378
1379 let json = serde_json::to_string(&event)?;
1380 assert!(json.contains("turn.blocked"));
1381 let restored: ThreadEvent = serde_json::from_str(&json)?;
1382 assert_eq!(restored, event);
1383
1384 let legacy = serde_json::json!({"type": "turn.blocked", "message": "blocked"});
1386 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1387 assert!(matches!(parsed, ThreadEvent::TurnBlocked(_)));
1388 Ok(())
1389 }
1390
1391 #[test]
1392 fn usage_uncached_input_tokens_saturates() {
1393 let usage = Usage {
1394 input_tokens: 1_000,
1395 cached_input_tokens: 800,
1396 cache_creation_tokens: 100,
1397 output_tokens: 50,
1398 };
1399 assert_eq!(usage.uncached_input_tokens(), 100);
1400
1401 let inconsistent = Usage {
1402 input_tokens: 100,
1403 cached_input_tokens: 150,
1404 cache_creation_tokens: 0,
1405 output_tokens: 0,
1406 };
1407 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1408
1409 let inconsistent_with_creation = Usage {
1410 input_tokens: 100,
1411 cached_input_tokens: 80,
1412 cache_creation_tokens: 50,
1413 output_tokens: 0,
1414 };
1415 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1416 }
1417
1418 #[test]
1419 fn usage_cache_hit_rate() {
1420 assert_eq!(Usage::default().cache_hit_rate(), None);
1421
1422 let usage = Usage {
1423 input_tokens: 1_000,
1424 cached_input_tokens: 750,
1425 cache_creation_tokens: 0,
1426 output_tokens: 0,
1427 };
1428 let rate = usage.cache_hit_rate().expect("rate");
1429 assert!((rate - 0.75).abs() < f64::EPSILON);
1430 }
1431
1432 #[test]
1433 fn usage_cache_summary_formats() {
1434 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1435
1436 let usage = Usage {
1437 input_tokens: 1_000,
1438 cached_input_tokens: 800,
1439 cache_creation_tokens: 100,
1440 output_tokens: 50,
1441 };
1442 assert_eq!(
1443 usage.cache_summary(),
1444 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1445 );
1446 }
1447
1448 #[test]
1449 fn usage_add_accumulates_all_fields_with_saturation() {
1450 let mut total = Usage {
1451 input_tokens: 100,
1452 cached_input_tokens: 20,
1453 cache_creation_tokens: 5,
1454 output_tokens: 10,
1455 };
1456 total.add(&Usage {
1457 input_tokens: 50,
1458 cached_input_tokens: 10,
1459 cache_creation_tokens: 2,
1460 output_tokens: 8,
1461 });
1462
1463 assert_eq!(total.input_tokens, 150);
1464 assert_eq!(total.cached_input_tokens, 30);
1465 assert_eq!(total.cache_creation_tokens, 7);
1466 assert_eq!(total.output_tokens, 18);
1467
1468 let mut saturating = Usage {
1469 input_tokens: u64::MAX,
1470 cached_input_tokens: u64::MAX,
1471 cache_creation_tokens: u64::MAX,
1472 output_tokens: u64::MAX,
1473 };
1474 saturating.add(&Usage {
1475 input_tokens: 1,
1476 cached_input_tokens: 1,
1477 cache_creation_tokens: 1,
1478 output_tokens: 1,
1479 });
1480 assert_eq!(saturating.input_tokens, u64::MAX);
1481 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1482 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1483 assert_eq!(saturating.output_tokens, u64::MAX);
1484 }
1485
1486 #[test]
1487 fn versioned_event_wraps_schema_version() {
1488 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1489
1490 let versioned = VersionedThreadEvent::new(event.clone());
1491
1492 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1493 assert_eq!(versioned.event, event);
1494 assert_eq!(versioned.into_event(), event);
1495 }
1496
1497 #[test]
1498 fn plan_approval_events_round_trip_with_decision() {
1499 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1500 thread_id: "thread-1".to_string(),
1501 turn_id: "turn-2".to_string(),
1502 plan_file: Some(".vtcode/plans/change.md".to_string()),
1503 });
1504 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1505 thread_id: "thread-1".to_string(),
1506 turn_id: "turn-3".to_string(),
1507 decision: PlanApprovalDecision::AutoAccept,
1508 automatic: false,
1509 });
1510
1511 for event in [requested, resolved] {
1512 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1513 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1514 assert_eq!(restored, event);
1515 }
1516 }
1517
1518 #[test]
1519 fn context_reset_event_round_trips_with_handoff_metadata() {
1520 let event = ThreadEvent::ContextReset(ContextResetEvent {
1521 thread_id: "thread-1".to_string(),
1522 turn_id: "turn-3".to_string(),
1523 trigger: ContextResetTrigger::PlanApproval,
1524 plan_preserved: true,
1525 previous_context_usage_percent: 7,
1526 tool_budget_reset: true,
1527 });
1528
1529 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1530 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1531 assert_eq!(restored, event);
1532 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1533 }
1534
1535 #[test]
1536 fn plan_approval_decision_uses_stable_wire_names() {
1537 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1538 thread_id: "thread-1".to_string(),
1539 turn_id: "turn-1".to_string(),
1540 decision: PlanApprovalDecision::SwitchBuild,
1541 automatic: false,
1542 });
1543
1544 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1545 assert_eq!(serialized["type"], "plan.approval.resolved");
1546 assert_eq!(serialized["decision"], "switch_build");
1547 }
1548
1549 #[test]
1550 fn plan_approval_decision_is_forward_compatible() {
1551 let payload = serde_json::json!({
1552 "type": "plan.approval.resolved",
1553 "thread_id": "thread-1",
1554 "turn_id": "turn-1",
1555 "decision": "future_decision",
1556 "automatic": true,
1557 });
1558 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1559 assert!(matches!(
1560 event,
1561 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1562 decision: PlanApprovalDecision::Unknown,
1563 automatic: true,
1564 ..
1565 })
1566 ));
1567 }
1568
1569 #[cfg(feature = "serde-json")]
1570 #[test]
1571 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1572 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1573 item: ThreadItem {
1574 id: "item-1".to_string(),
1575 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1576 },
1577 });
1578
1579 let payload = json::versioned_to_string(&event)?;
1580 let restored = json::versioned_from_str(&payload)?;
1581
1582 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1583 assert_eq!(restored.event, event);
1584 Ok(())
1585 }
1586
1587 #[test]
1588 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1589 for trigger in [
1590 CompactionTrigger::Manual,
1591 CompactionTrigger::Auto,
1592 CompactionTrigger::Recovery,
1593 CompactionTrigger::ModelSwitch,
1594 CompactionTrigger::Unknown,
1595 ] {
1596 let json = serde_json::to_string(&trigger).unwrap();
1597 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1598 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1599 assert_eq!(restored, trigger);
1600 }
1601 }
1602
1603 #[test]
1604 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1605 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1606 item: ThreadItem {
1607 id: "tool_1".to_string(),
1608 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1609 tool_name: "read_file".to_string(),
1610 arguments: Some(serde_json::json!({ "path": "README.md" })),
1611 tool_call_id: Some("tool_call_0".to_string()),
1612 status: ToolCallStatus::Completed,
1613 outcome: None,
1614 })),
1615 },
1616 });
1617
1618 let json = serde_json::to_string(&event)?;
1619 let restored: ThreadEvent = serde_json::from_str(&json)?;
1620
1621 assert_eq!(restored, event);
1622 Ok(())
1623 }
1624
1625 #[test]
1626 fn tool_outcome_serializes_snake_case() {
1627 for outcome in [
1628 ToolOutcome::Success,
1629 ToolOutcome::Error,
1630 ToolOutcome::PermissionRejected,
1631 ToolOutcome::PermissionCancelled,
1632 ToolOutcome::Followup,
1633 ToolOutcome::HookDenied,
1634 ToolOutcome::InvalidTool,
1635 ToolOutcome::Cancelled,
1636 ] {
1637 let json = serde_json::to_string(&outcome).unwrap();
1638 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1639 assert_eq!(restored, outcome);
1640 }
1641 }
1642
1643 #[test]
1644 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1645 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1646 item: ThreadItem {
1647 id: "tool_1".to_string(),
1648 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1649 tool_name: "exec_command".to_string(),
1650 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1651 tool_call_id: Some("tool_call_0".to_string()),
1652 status: ToolCallStatus::Failed,
1653 outcome: Some(ToolOutcome::PermissionRejected),
1654 })),
1655 },
1656 });
1657
1658 let json = serde_json::to_string(&event)?;
1659 let restored: ThreadEvent = serde_json::from_str(&json)?;
1660
1661 assert_eq!(restored, event);
1662 Ok(())
1663 }
1664
1665 #[test]
1666 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1667 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1668 item: ThreadItem {
1669 id: "tool_1:output".to_string(),
1670 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1671 call_id: "tool_1".to_string(),
1672 tool_call_id: Some("tool_call_0".to_string()),
1673 spool_path: None,
1674 output: "done".to_string(),
1675 exit_code: Some(0),
1676 status: ToolCallStatus::Completed,
1677 })),
1678 },
1679 });
1680
1681 let json = serde_json::to_string(&event)?;
1682 let restored: ThreadEvent = serde_json::from_str(&json)?;
1683
1684 assert_eq!(restored, event);
1685 Ok(())
1686 }
1687
1688 #[test]
1689 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1690 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1691 item: ThreadItem {
1692 id: "harness_1".to_string(),
1693 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1694 event: HarnessEventKind::VerificationFailed,
1695 message: Some("cargo check failed".to_string()),
1696 command: Some("cargo check".to_string()),
1697 path: None,
1698 exit_code: Some(101),
1699 attempt: None,
1700 error_category: None,
1701 duration_ms: None,
1702 })),
1703 },
1704 });
1705
1706 let json = serde_json::to_string(&event)?;
1707 let restored: ThreadEvent = serde_json::from_str(&json)?;
1708
1709 assert_eq!(restored, event);
1710 Ok(())
1711 }
1712
1713 #[test]
1714 fn blocked_handoff_resolved_uses_stable_wire_name() -> Result<(), Box<dyn Error>> {
1715 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1716 item: ThreadItem {
1717 id: "harness_resolved".to_string(),
1718 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1719 event: HarnessEventKind::BlockedHandoffResolved,
1720 message: Some("resolved".to_string()),
1721 command: None,
1722 path: None,
1723 exit_code: None,
1724 attempt: None,
1725 error_category: None,
1726 duration_ms: None,
1727 })),
1728 },
1729 });
1730
1731 let value = serde_json::to_value(&event)?;
1732 assert_eq!(value["item"]["event"], "blocked_handoff_resolved");
1733
1734 let restored: ThreadEvent = serde_json::from_value(value)?;
1735 assert_eq!(restored, event);
1736 Ok(())
1737 }
1738
1739 #[test]
1740 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1741 let event = ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1742 thread_id: "thread-1".to_string(),
1743 session_id: "session-1".to_string(),
1744 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1745 outcome_code: "budget_limit_reached".to_string(),
1746 result: None,
1747 stop_reason: Some("max_tokens".to_string()),
1748 usage: Usage {
1749 input_tokens: 10,
1750 cached_input_tokens: 4,
1751 cache_creation_tokens: 2,
1752 output_tokens: 5,
1753 },
1754 total_cost_usd: serde_json::Number::from_f64(1.25),
1755 num_turns: 3,
1756 }));
1757
1758 let json = serde_json::to_string(&event)?;
1759 let restored: ThreadEvent = serde_json::from_str(&json)?;
1760
1761 assert_eq!(restored, event);
1762 Ok(())
1763 }
1764
1765 #[test]
1766 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1767 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1768 thread_id: "thread-1".to_string(),
1769 trigger: CompactionTrigger::Recovery,
1770 mode: CompactionMode::Provider,
1771 original_message_count: 12,
1772 compacted_message_count: 5,
1773 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1774 previous_segment_id: Some("segment-0001".to_string()),
1775 new_segment_id: Some("segment-0002".to_string()),
1776 previous_prefix_hash: Some("prefix-before".to_string()),
1777 new_prefix_hash: Some("prefix-after".to_string()),
1778 previous_catalog_hash: Some("catalog-before".to_string()),
1779 new_catalog_hash: Some("catalog-after".to_string()),
1780 }));
1781
1782 let json = serde_json::to_string(&event)?;
1783 let restored: ThreadEvent = serde_json::from_str(&json)?;
1784
1785 assert_eq!(restored, event);
1786 Ok(())
1787 }
1788
1789 #[test]
1790 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1791 let payload = r#"{
1792 "type":"thread.compact_boundary",
1793 "thread_id":"thread-1",
1794 "trigger":"recovery",
1795 "mode":"provider",
1796 "original_message_count":12,
1797 "compacted_message_count":5
1798 }"#;
1799
1800 let restored: ThreadEvent = serde_json::from_str(payload)?;
1801 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1802 panic!("expected thread.compact_boundary event");
1803 };
1804
1805 assert_eq!(event.thread_id, "thread-1");
1806 assert_eq!(event.history_artifact_path, None);
1807 assert_eq!(event.previous_segment_id, None);
1808 assert_eq!(event.new_segment_id, None);
1809 assert_eq!(event.previous_prefix_hash, None);
1810 assert_eq!(event.new_prefix_hash, None);
1811 assert_eq!(event.previous_catalog_hash, None);
1812 assert_eq!(event.new_catalog_hash, None);
1813 Ok(())
1814 }
1815}