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 pub input_tokens: u64,
697 pub cached_input_tokens: u64,
699 pub cache_creation_tokens: u64,
701 pub output_tokens: u64,
703}
704
705impl Usage {
706 #[must_use]
711 fn uncached_input_tokens(&self) -> u64 {
712 self.input_tokens
713 .saturating_sub(self.cached_input_tokens)
714 .saturating_sub(self.cache_creation_tokens)
715 }
716
717 #[must_use]
720 pub fn cache_hit_rate(&self) -> Option<f64> {
721 if self.input_tokens == 0 {
722 return None;
723 }
724 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
725 }
726
727 #[must_use]
729 pub fn cache_summary(&self) -> String {
730 let total_input = self.input_tokens;
731 if total_input == 0 {
732 return "No input tokens recorded.".to_string();
733 }
734
735 let cached = self.cached_input_tokens;
736 let creation = self.cache_creation_tokens;
737 let uncached = self.uncached_input_tokens();
738 let rate = cached as f64 / total_input as f64 * 100.0;
739 format!(
740 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
741 {creation} cache-creation, {uncached} uncached"
742 )
743 }
744
745 pub fn add(&mut self, other: &Usage) {
747 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
748 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
749 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
750 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
751 }
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
755#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
756pub struct ItemCompletedEvent {
757 pub item: ThreadItem,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
762#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
763pub struct ItemStartedEvent {
764 pub item: ThreadItem,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
769#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
770pub struct ItemUpdatedEvent {
771 pub item: ThreadItem,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777pub struct PlanDeltaEvent {
778 pub thread_id: String,
780 pub turn_id: String,
782 pub item_id: String,
784 pub delta: String,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
789#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
790pub struct PlanApprovalRequestedEvent {
791 pub thread_id: String,
793 pub turn_id: String,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub plan_file: Option<String>,
798}
799
800#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
802#[serde(rename_all = "snake_case")]
803pub enum PlanApprovalDecision {
804 Execute,
806 AutoAccept,
808 FreshContext,
810 Revise,
812 Cancel,
814 SwitchBuild,
816 SwitchAuto,
818 #[serde(other)]
820 Unknown,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
824#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
825pub struct PlanApprovalResolvedEvent {
826 pub thread_id: String,
828 pub turn_id: String,
830 pub decision: PlanApprovalDecision,
832 pub automatic: bool,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
837#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
838pub struct ThreadItem {
839 pub id: String,
841 #[serde(flatten)]
843 pub details: ThreadItemDetails,
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
847#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
848#[serde(tag = "type", rename_all = "snake_case")]
849pub enum ThreadItemDetails {
850 AgentMessage(AgentMessageItem),
852 Plan(PlanItem),
854 Reasoning(ReasoningItem),
856 CommandExecution(Box<CommandExecutionItem>),
858 ToolInvocation(Box<ToolInvocationItem>),
860 ToolOutput(Box<ToolOutputItem>),
862 FileChange(Box<FileChangeItem>),
864 McpToolCall(Box<McpToolCallItem>),
866 WebSearch(Box<WebSearchItem>),
868 Harness(Box<HarnessEventItem>),
870 Error(ErrorItem),
872}
873
874#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
875#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
876pub struct AgentMessageItem {
877 pub text: String,
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
882#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
883pub struct PlanItem {
884 pub text: String,
886}
887
888#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
889#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
890pub struct ReasoningItem {
891 pub text: String,
893 #[serde(skip_serializing_if = "Option::is_none")]
896 pub stage: Option<String>,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
900#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
901#[serde(rename_all = "snake_case")]
902pub enum CommandExecutionStatus {
903 #[default]
905 Completed,
906 Failed,
908 InProgress,
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
913#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
914pub struct CommandExecutionItem {
915 pub command: String,
917 #[serde(skip_serializing_if = "Option::is_none")]
919 pub arguments: Option<Value>,
920 #[serde(default)]
922 pub aggregated_output: String,
923 #[serde(skip_serializing_if = "Option::is_none")]
925 pub exit_code: Option<i32>,
926 pub status: CommandExecutionStatus,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
931#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
932#[serde(rename_all = "snake_case")]
933pub enum ToolCallStatus {
934 #[default]
936 Completed,
937 Failed,
939 InProgress,
941}
942
943#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
951#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
952#[serde(rename_all = "snake_case")]
953pub enum ToolOutcome {
954 #[default]
956 Success,
957 Error,
959 PermissionRejected,
961 PermissionCancelled,
963 Followup,
965 HookDenied,
967 InvalidTool,
969 Cancelled,
971}
972
973impl ToolOutcome {
974 #[must_use]
975 pub const fn is_terminal(self) -> bool {
976 !matches!(self, Self::Followup)
977 }
978}
979
980#[must_use]
987#[allow(
988 clippy::unreachable,
989 reason = "Intentional compatibility, platform, or test-only suppression."
990)]
991pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
992 match status {
993 ToolCallStatus::Completed => ToolOutcome::Success,
994 ToolCallStatus::Failed => ToolOutcome::Error,
995 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
996 }
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1000#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1001pub struct ToolInvocationItem {
1002 pub tool_name: String,
1004 #[serde(skip_serializing_if = "Option::is_none")]
1006 pub arguments: Option<Value>,
1007 #[serde(skip_serializing_if = "Option::is_none")]
1009 pub tool_call_id: Option<String>,
1010 pub status: ToolCallStatus,
1012 #[serde(skip_serializing_if = "Option::is_none")]
1014 pub outcome: Option<ToolOutcome>,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1019pub struct ToolOutputItem {
1020 pub call_id: String,
1022 #[serde(skip_serializing_if = "Option::is_none")]
1024 pub tool_call_id: Option<String>,
1025 #[serde(skip_serializing_if = "Option::is_none")]
1027 pub spool_path: Option<String>,
1028 #[serde(default)]
1030 pub output: String,
1031 #[serde(skip_serializing_if = "Option::is_none")]
1033 pub exit_code: Option<i32>,
1034 pub status: ToolCallStatus,
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1039#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1040pub struct FileChangeItem {
1041 pub changes: Vec<FileUpdateChange>,
1043 pub status: PatchApplyStatus,
1045}
1046
1047#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1048#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1049pub struct FileUpdateChange {
1050 pub path: String,
1052 pub kind: PatchChangeKind,
1054}
1055
1056#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1057#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1058#[serde(rename_all = "snake_case")]
1059pub enum PatchApplyStatus {
1060 Completed,
1062 Failed,
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1067#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1068#[serde(rename_all = "snake_case")]
1069pub enum PatchChangeKind {
1070 Add,
1072 Delete,
1074 Update,
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1079#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1080pub struct McpToolCallItem {
1081 pub tool_name: String,
1083 #[serde(skip_serializing_if = "Option::is_none")]
1085 pub arguments: Option<Value>,
1086 #[serde(skip_serializing_if = "Option::is_none")]
1088 pub result: Option<String>,
1089 #[serde(skip_serializing_if = "Option::is_none")]
1091 pub status: Option<McpToolCallStatus>,
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1095#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1096#[serde(rename_all = "snake_case")]
1097pub enum McpToolCallStatus {
1098 Started,
1100 Completed,
1102 Failed,
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1107#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1108pub struct WebSearchItem {
1109 pub query: String,
1111 #[serde(skip_serializing_if = "Option::is_none")]
1113 pub provider: Option<String>,
1114 #[serde(skip_serializing_if = "Option::is_none")]
1116 pub results: Option<Vec<String>>,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1120#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1121#[serde(rename_all = "snake_case")]
1122pub enum HarnessEventKind {
1123 PlanningStarted,
1124 PlanningCompleted,
1125 ContinuationStarted,
1126 ContinuationSkipped,
1127 TurnBlocked,
1130 BlockedRecoveryStarted,
1132 BlockedRecoveryFinished,
1134 BlockedHandoffWritten,
1135 BlockedHandoffResolved,
1138 EvaluationStarted,
1139 EvaluationPassed,
1140 EvaluationFailed,
1141 RevisionStarted,
1142 EscalationTriggered,
1143 EscalationBypassed,
1144 VerificationStarted,
1145 VerificationPassed,
1146 VerificationFailed,
1147 ErrorRecovered,
1149 ToolRetryAttempted,
1151 ToolLatencyRecorded,
1153 SnapshotCreated,
1155 SnapshotRestored,
1157 SessionToolLimitIncreased,
1160 ToolLoopLimitIncreased,
1162}
1163
1164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1165#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1166#[serde(rename_all = "snake_case")]
1167pub enum PermissionDecision {
1168 Allow,
1169 Deny,
1170 Cancelled,
1171 Followup,
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1175#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1176pub struct PermissionRequestedEvent {
1177 pub tool_name: String,
1179}
1180
1181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1182#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1183pub struct PermissionResolvedEvent {
1184 pub tool_name: String,
1186 pub decision: PermissionDecision,
1188 pub wait_ms: u64,
1190}
1191
1192#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1194#[serde(rename_all = "snake_case")]
1195pub enum InterjectionSource {
1196 Direct,
1197 Queue,
1198}
1199
1200#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1201#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1202#[serde(rename_all = "snake_case")]
1203pub enum RedirectKind {
1204 Interjection,
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1208#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1209pub struct InterjectedEvent {
1210 pub source: InterjectionSource,
1212 pub image_count: u32,
1214 pub redirect_kind: RedirectKind,
1217}
1218
1219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1220#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1221pub struct HarnessEventItem {
1222 pub event: HarnessEventKind,
1224 #[serde(skip_serializing_if = "Option::is_none")]
1226 pub message: Option<String>,
1227 #[serde(skip_serializing_if = "Option::is_none")]
1229 pub command: Option<String>,
1230 #[serde(skip_serializing_if = "Option::is_none")]
1232 pub path: Option<String>,
1233 #[serde(skip_serializing_if = "Option::is_none")]
1235 pub exit_code: Option<i32>,
1236 #[serde(skip_serializing_if = "Option::is_none")]
1238 pub attempt: Option<u32>,
1239 #[serde(skip_serializing_if = "Option::is_none")]
1241 pub error_category: Option<String>,
1242 #[serde(skip_serializing_if = "Option::is_none")]
1244 pub duration_ms: Option<u64>,
1245}
1246
1247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1249pub struct ErrorItem {
1250 pub message: String,
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257 use std::error::Error;
1258 use std::mem::size_of;
1259
1260 #[test]
1265 fn thread_event_stays_compact() {
1266 assert!(
1267 size_of::<ThreadEvent>() <= 80,
1268 "ThreadEvent grew to {} bytes; box new large payloads instead of inlining them",
1269 size_of::<ThreadEvent>()
1270 );
1271 }
1272
1273 #[test]
1276 fn boxed_thread_item_details_payloads_stay_boxed() {
1277 assert!(size_of::<Option<Box<CommandExecutionItem>>>() < size_of::<Option<CommandExecutionItem>>());
1278 assert!(size_of::<Option<Box<ToolInvocationItem>>>() < size_of::<Option<ToolInvocationItem>>());
1279 assert!(size_of::<Option<Box<ToolOutputItem>>>() < size_of::<Option<ToolOutputItem>>());
1280 assert!(size_of::<Option<Box<FileChangeItem>>>() < size_of::<Option<FileChangeItem>>());
1281 assert!(size_of::<Option<Box<McpToolCallItem>>>() < size_of::<Option<McpToolCallItem>>());
1282 assert!(size_of::<Option<Box<WebSearchItem>>>() < size_of::<Option<WebSearchItem>>());
1283 assert!(size_of::<Option<Box<HarnessEventItem>>>() < size_of::<Option<HarnessEventItem>>());
1284 }
1285
1286 #[test]
1287 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1288 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1289 usage: Usage {
1290 input_tokens: 1,
1291 cached_input_tokens: 2,
1292 cache_creation_tokens: 0,
1293 output_tokens: 3,
1294 },
1295 });
1296
1297 let json = serde_json::to_string(&event)?;
1298 let restored: ThreadEvent = serde_json::from_str(&json)?;
1299
1300 assert_eq!(restored, event);
1301 Ok(())
1302 }
1303
1304 #[test]
1305 fn turn_blocked_event_round_trip() -> Result<(), Box<dyn Error>> {
1306 let event = ThreadEvent::TurnBlocked(Box::new(TurnBlockedEvent {
1307 message: "Blocked tool-call limit reached after 3 consecutive blocked calls.".to_string(),
1308 last_tool: Some("exec_command".to_string()),
1309 blocked_streak: 4,
1310 blocked_total: 4,
1311 consecutive_cap: 3,
1312 total_cap: 6,
1313 recovery_active: false,
1314 usage: None,
1315 }));
1316
1317 let json = serde_json::to_string(&event)?;
1318 assert!(json.contains("turn.blocked"));
1319 let restored: ThreadEvent = serde_json::from_str(&json)?;
1320 assert_eq!(restored, event);
1321
1322 let legacy = serde_json::json!({"type": "turn.blocked", "message": "blocked"});
1324 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1325 assert!(matches!(parsed, ThreadEvent::TurnBlocked(_)));
1326 Ok(())
1327 }
1328
1329 #[test]
1330 fn usage_uncached_input_tokens_saturates() {
1331 let usage = Usage {
1332 input_tokens: 1_000,
1333 cached_input_tokens: 800,
1334 cache_creation_tokens: 100,
1335 output_tokens: 50,
1336 };
1337 assert_eq!(usage.uncached_input_tokens(), 100);
1338
1339 let inconsistent = Usage {
1340 input_tokens: 100,
1341 cached_input_tokens: 150,
1342 cache_creation_tokens: 0,
1343 output_tokens: 0,
1344 };
1345 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1346
1347 let inconsistent_with_creation = Usage {
1348 input_tokens: 100,
1349 cached_input_tokens: 80,
1350 cache_creation_tokens: 50,
1351 output_tokens: 0,
1352 };
1353 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1354 }
1355
1356 #[test]
1357 fn usage_cache_hit_rate() {
1358 assert_eq!(Usage::default().cache_hit_rate(), None);
1359
1360 let usage = Usage {
1361 input_tokens: 1_000,
1362 cached_input_tokens: 750,
1363 cache_creation_tokens: 0,
1364 output_tokens: 0,
1365 };
1366 let rate = usage.cache_hit_rate().expect("rate");
1367 assert!((rate - 0.75).abs() < f64::EPSILON);
1368 }
1369
1370 #[test]
1371 fn usage_cache_summary_formats() {
1372 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1373
1374 let usage = Usage {
1375 input_tokens: 1_000,
1376 cached_input_tokens: 800,
1377 cache_creation_tokens: 100,
1378 output_tokens: 50,
1379 };
1380 assert_eq!(
1381 usage.cache_summary(),
1382 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1383 );
1384 }
1385
1386 #[test]
1387 fn usage_add_accumulates_all_fields_with_saturation() {
1388 let mut total = Usage {
1389 input_tokens: 100,
1390 cached_input_tokens: 20,
1391 cache_creation_tokens: 5,
1392 output_tokens: 10,
1393 };
1394 total.add(&Usage {
1395 input_tokens: 50,
1396 cached_input_tokens: 10,
1397 cache_creation_tokens: 2,
1398 output_tokens: 8,
1399 });
1400
1401 assert_eq!(total.input_tokens, 150);
1402 assert_eq!(total.cached_input_tokens, 30);
1403 assert_eq!(total.cache_creation_tokens, 7);
1404 assert_eq!(total.output_tokens, 18);
1405
1406 let mut saturating = Usage {
1407 input_tokens: u64::MAX,
1408 cached_input_tokens: u64::MAX,
1409 cache_creation_tokens: u64::MAX,
1410 output_tokens: u64::MAX,
1411 };
1412 saturating.add(&Usage {
1413 input_tokens: 1,
1414 cached_input_tokens: 1,
1415 cache_creation_tokens: 1,
1416 output_tokens: 1,
1417 });
1418 assert_eq!(saturating.input_tokens, u64::MAX);
1419 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1420 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1421 assert_eq!(saturating.output_tokens, u64::MAX);
1422 }
1423
1424 #[test]
1425 fn versioned_event_wraps_schema_version() {
1426 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1427
1428 let versioned = VersionedThreadEvent::new(event.clone());
1429
1430 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1431 assert_eq!(versioned.event, event);
1432 assert_eq!(versioned.into_event(), event);
1433 }
1434
1435 #[test]
1436 fn plan_approval_events_round_trip_with_decision() {
1437 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1438 thread_id: "thread-1".to_string(),
1439 turn_id: "turn-2".to_string(),
1440 plan_file: Some(".vtcode/plans/change.md".to_string()),
1441 });
1442 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1443 thread_id: "thread-1".to_string(),
1444 turn_id: "turn-3".to_string(),
1445 decision: PlanApprovalDecision::AutoAccept,
1446 automatic: false,
1447 });
1448
1449 for event in [requested, resolved] {
1450 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1451 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1452 assert_eq!(restored, event);
1453 }
1454 }
1455
1456 #[test]
1457 fn context_reset_event_round_trips_with_handoff_metadata() {
1458 let event = ThreadEvent::ContextReset(ContextResetEvent {
1459 thread_id: "thread-1".to_string(),
1460 turn_id: "turn-3".to_string(),
1461 trigger: ContextResetTrigger::PlanApproval,
1462 plan_preserved: true,
1463 previous_context_usage_percent: 7,
1464 tool_budget_reset: true,
1465 });
1466
1467 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1468 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1469 assert_eq!(restored, event);
1470 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1471 }
1472
1473 #[test]
1474 fn plan_approval_decision_uses_stable_wire_names() {
1475 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1476 thread_id: "thread-1".to_string(),
1477 turn_id: "turn-1".to_string(),
1478 decision: PlanApprovalDecision::SwitchBuild,
1479 automatic: false,
1480 });
1481
1482 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1483 assert_eq!(serialized["type"], "plan.approval.resolved");
1484 assert_eq!(serialized["decision"], "switch_build");
1485 }
1486
1487 #[test]
1488 fn plan_approval_decision_is_forward_compatible() {
1489 let payload = serde_json::json!({
1490 "type": "plan.approval.resolved",
1491 "thread_id": "thread-1",
1492 "turn_id": "turn-1",
1493 "decision": "future_decision",
1494 "automatic": true,
1495 });
1496 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1497 assert!(matches!(
1498 event,
1499 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1500 decision: PlanApprovalDecision::Unknown,
1501 automatic: true,
1502 ..
1503 })
1504 ));
1505 }
1506
1507 #[cfg(feature = "serde-json")]
1508 #[test]
1509 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1510 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1511 item: ThreadItem {
1512 id: "item-1".to_string(),
1513 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1514 },
1515 });
1516
1517 let payload = json::versioned_to_string(&event)?;
1518 let restored = json::versioned_from_str(&payload)?;
1519
1520 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1521 assert_eq!(restored.event, event);
1522 Ok(())
1523 }
1524
1525 #[test]
1526 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1527 for trigger in [
1528 CompactionTrigger::Manual,
1529 CompactionTrigger::Auto,
1530 CompactionTrigger::Recovery,
1531 CompactionTrigger::ModelSwitch,
1532 CompactionTrigger::Unknown,
1533 ] {
1534 let json = serde_json::to_string(&trigger).unwrap();
1535 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1536 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1537 assert_eq!(restored, trigger);
1538 }
1539 }
1540
1541 #[test]
1542 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1543 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1544 item: ThreadItem {
1545 id: "tool_1".to_string(),
1546 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1547 tool_name: "read_file".to_string(),
1548 arguments: Some(serde_json::json!({ "path": "README.md" })),
1549 tool_call_id: Some("tool_call_0".to_string()),
1550 status: ToolCallStatus::Completed,
1551 outcome: None,
1552 })),
1553 },
1554 });
1555
1556 let json = serde_json::to_string(&event)?;
1557 let restored: ThreadEvent = serde_json::from_str(&json)?;
1558
1559 assert_eq!(restored, event);
1560 Ok(())
1561 }
1562
1563 #[test]
1564 fn tool_outcome_serializes_snake_case() {
1565 for outcome in [
1566 ToolOutcome::Success,
1567 ToolOutcome::Error,
1568 ToolOutcome::PermissionRejected,
1569 ToolOutcome::PermissionCancelled,
1570 ToolOutcome::Followup,
1571 ToolOutcome::HookDenied,
1572 ToolOutcome::InvalidTool,
1573 ToolOutcome::Cancelled,
1574 ] {
1575 let json = serde_json::to_string(&outcome).unwrap();
1576 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1577 assert_eq!(restored, outcome);
1578 }
1579 }
1580
1581 #[test]
1582 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1583 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1584 item: ThreadItem {
1585 id: "tool_1".to_string(),
1586 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1587 tool_name: "exec_command".to_string(),
1588 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1589 tool_call_id: Some("tool_call_0".to_string()),
1590 status: ToolCallStatus::Failed,
1591 outcome: Some(ToolOutcome::PermissionRejected),
1592 })),
1593 },
1594 });
1595
1596 let json = serde_json::to_string(&event)?;
1597 let restored: ThreadEvent = serde_json::from_str(&json)?;
1598
1599 assert_eq!(restored, event);
1600 Ok(())
1601 }
1602
1603 #[test]
1604 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1605 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1606 item: ThreadItem {
1607 id: "tool_1:output".to_string(),
1608 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1609 call_id: "tool_1".to_string(),
1610 tool_call_id: Some("tool_call_0".to_string()),
1611 spool_path: None,
1612 output: "done".to_string(),
1613 exit_code: Some(0),
1614 status: ToolCallStatus::Completed,
1615 })),
1616 },
1617 });
1618
1619 let json = serde_json::to_string(&event)?;
1620 let restored: ThreadEvent = serde_json::from_str(&json)?;
1621
1622 assert_eq!(restored, event);
1623 Ok(())
1624 }
1625
1626 #[test]
1627 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1628 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1629 item: ThreadItem {
1630 id: "harness_1".to_string(),
1631 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1632 event: HarnessEventKind::VerificationFailed,
1633 message: Some("cargo check failed".to_string()),
1634 command: Some("cargo check".to_string()),
1635 path: None,
1636 exit_code: Some(101),
1637 attempt: None,
1638 error_category: None,
1639 duration_ms: None,
1640 })),
1641 },
1642 });
1643
1644 let json = serde_json::to_string(&event)?;
1645 let restored: ThreadEvent = serde_json::from_str(&json)?;
1646
1647 assert_eq!(restored, event);
1648 Ok(())
1649 }
1650
1651 #[test]
1652 fn blocked_handoff_resolved_uses_stable_wire_name() -> Result<(), Box<dyn Error>> {
1653 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1654 item: ThreadItem {
1655 id: "harness_resolved".to_string(),
1656 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1657 event: HarnessEventKind::BlockedHandoffResolved,
1658 message: Some("resolved".to_string()),
1659 command: None,
1660 path: None,
1661 exit_code: None,
1662 attempt: None,
1663 error_category: None,
1664 duration_ms: None,
1665 })),
1666 },
1667 });
1668
1669 let value = serde_json::to_value(&event)?;
1670 assert_eq!(value["item"]["event"], "blocked_handoff_resolved");
1671
1672 let restored: ThreadEvent = serde_json::from_value(value)?;
1673 assert_eq!(restored, event);
1674 Ok(())
1675 }
1676
1677 #[test]
1678 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1679 let event = ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1680 thread_id: "thread-1".to_string(),
1681 session_id: "session-1".to_string(),
1682 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1683 outcome_code: "budget_limit_reached".to_string(),
1684 result: None,
1685 stop_reason: Some("max_tokens".to_string()),
1686 usage: Usage {
1687 input_tokens: 10,
1688 cached_input_tokens: 4,
1689 cache_creation_tokens: 2,
1690 output_tokens: 5,
1691 },
1692 total_cost_usd: serde_json::Number::from_f64(1.25),
1693 num_turns: 3,
1694 }));
1695
1696 let json = serde_json::to_string(&event)?;
1697 let restored: ThreadEvent = serde_json::from_str(&json)?;
1698
1699 assert_eq!(restored, event);
1700 Ok(())
1701 }
1702
1703 #[test]
1704 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1705 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1706 thread_id: "thread-1".to_string(),
1707 trigger: CompactionTrigger::Recovery,
1708 mode: CompactionMode::Provider,
1709 original_message_count: 12,
1710 compacted_message_count: 5,
1711 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1712 previous_segment_id: Some("segment-0001".to_string()),
1713 new_segment_id: Some("segment-0002".to_string()),
1714 previous_prefix_hash: Some("prefix-before".to_string()),
1715 new_prefix_hash: Some("prefix-after".to_string()),
1716 previous_catalog_hash: Some("catalog-before".to_string()),
1717 new_catalog_hash: Some("catalog-after".to_string()),
1718 }));
1719
1720 let json = serde_json::to_string(&event)?;
1721 let restored: ThreadEvent = serde_json::from_str(&json)?;
1722
1723 assert_eq!(restored, event);
1724 Ok(())
1725 }
1726
1727 #[test]
1728 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1729 let payload = r#"{
1730 "type":"thread.compact_boundary",
1731 "thread_id":"thread-1",
1732 "trigger":"recovery",
1733 "mode":"provider",
1734 "original_message_count":12,
1735 "compacted_message_count":5
1736 }"#;
1737
1738 let restored: ThreadEvent = serde_json::from_str(payload)?;
1739 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1740 panic!("expected thread.compact_boundary event");
1741 };
1742
1743 assert_eq!(event.thread_id, "thread-1");
1744 assert_eq!(event.history_artifact_path, None);
1745 assert_eq!(event.previous_segment_id, None);
1746 assert_eq!(event.new_segment_id, None);
1747 assert_eq!(event.previous_prefix_hash, None);
1748 assert_eq!(event.new_prefix_hash, None);
1749 assert_eq!(event.previous_catalog_hash, None);
1750 assert_eq!(event.new_catalog_hash, None);
1751 Ok(())
1752 }
1753}