1#![allow(missing_docs, dead_code, unused_imports)]
2use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub mod atif;
19pub mod trace;
20
21pub const EVENT_SCHEMA_VERSION: &str = "0.9.0";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
28pub struct VersionedThreadEvent {
29 schema_version: String,
31 event: ThreadEvent,
33}
34
35impl VersionedThreadEvent {
36 pub fn new(event: ThreadEvent) -> Self {
39 Self {
40 schema_version: EVENT_SCHEMA_VERSION.to_string(),
41 event,
42 }
43 }
44
45 pub fn into_event(self) -> ThreadEvent {
47 self.event
48 }
49}
50
51impl From<ThreadEvent> for VersionedThreadEvent {
52 fn from(event: ThreadEvent) -> Self {
53 Self::new(event)
54 }
55}
56
57pub trait EventEmitter {
59 fn emit(&mut self, event: &ThreadEvent);
61}
62
63impl<F> EventEmitter for F
64where
65 F: FnMut(&ThreadEvent),
66{
67 fn emit(&mut self, event: &ThreadEvent) {
68 self(event);
69 }
70}
71
72#[cfg(feature = "serde-json")]
74pub(crate) mod json {
75 use super::{ThreadEvent, VersionedThreadEvent};
76
77 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
79 serde_json::to_value(event)
80 }
81
82 pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
84 serde_json::to_string(event)
85 }
86
87 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
89 serde_json::from_str(payload)
90 }
91
92 pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
94 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
95 }
96
97 pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
99 serde_json::from_str(payload)
100 }
101}
102
103#[cfg(feature = "telemetry-log")]
104mod log_support {
105 use log::Level;
106
107 use super::{EventEmitter, ThreadEvent, json};
108
109 #[derive(Debug, Clone)]
111 pub struct LogEmitter {
112 level: Level,
113 }
114
115 impl LogEmitter {
116 pub fn new(level: Level) -> Self {
118 Self { level }
119 }
120 }
121
122 impl Default for LogEmitter {
123 fn default() -> Self {
124 Self { level: Level::Info }
125 }
126 }
127
128 impl EventEmitter for LogEmitter {
129 fn emit(&mut self, event: &ThreadEvent) {
130 if log::log_enabled!(self.level) {
131 match json::to_string(event) {
132 Ok(serialized) => log::log!(self.level, "{serialized}"),
133 Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
134 }
135 }
136 }
137 }
138
139 pub use LogEmitter as PublicLogEmitter;
140}
141
142#[cfg(feature = "telemetry-log")]
143pub use log_support::PublicLogEmitter as LogEmitter;
144
145#[cfg(feature = "telemetry-tracing")]
146mod tracing_support {
147 use tracing::Level;
148
149 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
150
151 #[derive(Debug, Clone)]
153 pub struct TracingEmitter {
154 level: Level,
155 }
156
157 impl TracingEmitter {
158 pub fn new(level: Level) -> Self {
160 Self { level }
161 }
162 }
163
164 impl Default for TracingEmitter {
165 fn default() -> Self {
166 Self { level: Level::INFO }
167 }
168 }
169
170 impl EventEmitter for TracingEmitter {
171 fn emit(&mut self, event: &ThreadEvent) {
172 match self.level {
173 Level::TRACE => tracing::event!(
174 target: "vtcode_exec_events",
175 Level::TRACE,
176 schema_version = EVENT_SCHEMA_VERSION,
177 event = ?VersionedThreadEvent::new(event.clone()),
178 "vtcode_exec_event"
179 ),
180 Level::DEBUG => tracing::event!(
181 target: "vtcode_exec_events",
182 Level::DEBUG,
183 schema_version = EVENT_SCHEMA_VERSION,
184 event = ?VersionedThreadEvent::new(event.clone()),
185 "vtcode_exec_event"
186 ),
187 Level::INFO => tracing::event!(
188 target: "vtcode_exec_events",
189 Level::INFO,
190 schema_version = EVENT_SCHEMA_VERSION,
191 event = ?VersionedThreadEvent::new(event.clone()),
192 "vtcode_exec_event"
193 ),
194 Level::WARN => tracing::event!(
195 target: "vtcode_exec_events",
196 Level::WARN,
197 schema_version = EVENT_SCHEMA_VERSION,
198 event = ?VersionedThreadEvent::new(event.clone()),
199 "vtcode_exec_event"
200 ),
201 Level::ERROR => tracing::event!(
202 target: "vtcode_exec_events",
203 Level::ERROR,
204 schema_version = EVENT_SCHEMA_VERSION,
205 event = ?VersionedThreadEvent::new(event.clone()),
206 "vtcode_exec_event"
207 ),
208 }
209 }
210 }
211
212 pub use TracingEmitter as PublicTracingEmitter;
213}
214
215#[cfg(feature = "telemetry-tracing")]
216pub use tracing_support::PublicTracingEmitter as TracingEmitter;
217
218#[cfg(feature = "telemetry-otel")]
219mod otel_support {
220 use opentelemetry::KeyValue;
221 use opentelemetry::trace::{Span, Status, Tracer};
222
223 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
224
225 pub struct OtelEmitter<T: Tracer> {
241 tracer: T,
242 }
243
244 impl<T: Tracer> OtelEmitter<T> {
245 pub fn new(tracer: T) -> Self {
246 Self { tracer }
247 }
248 }
249
250 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
251 fn emit(&mut self, event: &ThreadEvent) {
252 let span_name = match event {
253 ThreadEvent::ThreadStarted(_) => "thread.started",
254 ThreadEvent::ThreadCompleted(_) => "thread.completed",
255 ThreadEvent::TurnStarted(_) => "turn.started",
256 ThreadEvent::TurnCompleted(_) => "turn.completed",
257 ThreadEvent::TurnFailed(_) => "turn.failed",
258 ThreadEvent::ItemStarted(_) => "item.started",
259 ThreadEvent::ItemUpdated(_) => "item.updated",
260 ThreadEvent::ItemCompleted(_) => "item.completed",
261 ThreadEvent::Error(_) => "error",
262 _ => "event",
263 };
264
265 let mut span = self.tracer.start(span_name);
266
267 match event {
268 ThreadEvent::ThreadStarted(e) => {
269 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
270 }
271 ThreadEvent::ThreadCompleted(e) => {
272 if let Some(ref cost) = e.total_cost_usd {
273 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
274 }
275 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
276 span.set_attribute(KeyValue::new("output_tokens", e.usage.output_tokens as i64));
277 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
278 }
279 ThreadEvent::TurnCompleted(e) => {
280 span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
281 span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
282 }
283 ThreadEvent::ItemCompleted(e) => {
284 if let ThreadItemDetails::Harness(harness) = &e.item.details {
285 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
286 if let Some(ref msg) = harness.message {
287 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
288 }
289 if let Some(ref path) = harness.path {
290 span.set_attribute(KeyValue::new("harness_path", path.clone()));
291 }
292 if let Some(dur) = harness.duration_ms {
293 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
294 }
295 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
296 if let Some(ref msg) = harness.message {
297 event_attrs.push(KeyValue::new("message", msg.clone()));
298 }
299 span.add_event("harness_event", event_attrs);
300 }
301 }
302 ThreadEvent::Error(e) => {
303 span.set_status(Status::Error { description: e.message.clone().into() });
304 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
305 }
306 _ => {}
307 }
308
309 span.end();
310 }
311 }
312
313 pub use OtelEmitter as PublicOtelEmitter;
314}
315
316#[cfg(feature = "telemetry-otel")]
317pub use otel_support::PublicOtelEmitter as OtelEmitter;
318
319#[cfg(feature = "schema-export")]
320pub mod schema {
321 use schemars::{Schema, schema_for};
322
323 use super::{ThreadEvent, VersionedThreadEvent};
324
325 pub fn thread_event_schema() -> Schema {
327 schema_for!(ThreadEvent)
328 }
329
330 pub fn versioned_thread_event_schema() -> Schema {
332 schema_for!(VersionedThreadEvent)
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
339#[serde(tag = "type")]
340pub enum ThreadEvent {
341 #[serde(rename = "thread.started")]
343 ThreadStarted(ThreadStartedEvent),
344 #[serde(rename = "thread.completed")]
346 ThreadCompleted(ThreadCompletedEvent),
347 #[serde(rename = "thread.compact_boundary")]
349 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
350 #[serde(rename = "turn.started")]
352 TurnStarted(TurnStartedEvent),
353 #[serde(rename = "turn.completed")]
355 TurnCompleted(TurnCompletedEvent),
356 #[serde(rename = "turn.failed")]
358 TurnFailed(TurnFailedEvent),
359 #[serde(rename = "item.started")]
361 ItemStarted(ItemStartedEvent),
362 #[serde(rename = "item.updated")]
364 ItemUpdated(ItemUpdatedEvent),
365 #[serde(rename = "item.completed")]
367 ItemCompleted(ItemCompletedEvent),
368 #[serde(rename = "permission.requested")]
370 PermissionRequested(PermissionRequestedEvent),
371 #[serde(rename = "permission.resolved")]
373 PermissionResolved(PermissionResolvedEvent),
374 #[serde(rename = "interjected")]
376 Interjected(InterjectedEvent),
377 #[serde(rename = "plan.delta")]
379 PlanDelta(PlanDeltaEvent),
380 #[serde(rename = "plan.approval.requested")]
382 PlanApprovalRequested(PlanApprovalRequestedEvent),
383 #[serde(rename = "plan.approval.resolved")]
385 PlanApprovalResolved(PlanApprovalResolvedEvent),
386 #[serde(rename = "error")]
388 Error(ThreadErrorEvent),
389 #[serde(other)]
392 Unknown,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
397pub struct ThreadStartedEvent {
398 pub thread_id: String,
400}
401
402#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
403#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
404#[serde(rename_all = "snake_case")]
405pub enum ThreadCompletionSubtype {
406 Success,
407 ErrorMaxTurns,
408 ErrorMaxBudgetUsd,
409 ErrorDuringExecution,
410 Cancelled,
411 #[serde(other)]
413 Unknown,
414}
415
416impl ThreadCompletionSubtype {
417 pub const fn as_str(&self) -> &'static str {
418 match self {
419 Self::Success => "success",
420 Self::ErrorMaxTurns => "error_max_turns",
421 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
422 Self::ErrorDuringExecution => "error_during_execution",
423 Self::Cancelled => "cancelled",
424 Self::Unknown => "unknown",
425 }
426 }
427
428 pub const fn is_success(self) -> bool {
429 matches!(self, Self::Success)
430 }
431}
432
433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
434#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
435#[serde(rename_all = "snake_case")]
436pub enum CompactionTrigger {
437 Manual,
438 Auto,
439 Recovery,
440 ModelSwitch,
443 #[serde(other)]
445 Unknown,
446}
447
448impl CompactionTrigger {
449 pub const fn as_str(self) -> &'static str {
450 match self {
451 Self::Manual => "manual",
452 Self::Auto => "auto",
453 Self::Recovery => "recovery",
454 Self::ModelSwitch => "model_switch",
455 Self::Unknown => "unknown",
456 }
457 }
458}
459
460#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
461#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
462#[serde(rename_all = "snake_case")]
463pub enum CompactionMode {
464 Provider,
465 Local,
466 #[serde(other)]
468 Unknown,
469}
470
471impl CompactionMode {
472 pub const fn as_str(self) -> &'static str {
473 match self {
474 Self::Provider => "provider",
475 Self::Local => "local",
476 Self::Unknown => "unknown",
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
483pub struct ThreadCompletedEvent {
484 pub thread_id: String,
486 pub session_id: String,
488 pub subtype: ThreadCompletionSubtype,
490 pub outcome_code: String,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub result: Option<String>,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub stop_reason: Option<String>,
498 pub usage: Usage,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub total_cost_usd: Option<serde_json::Number>,
503 pub num_turns: usize,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
509pub struct ThreadCompactBoundaryEvent {
510 pub thread_id: String,
512 pub trigger: CompactionTrigger,
514 pub mode: CompactionMode,
516 pub original_message_count: usize,
518 pub compacted_message_count: usize,
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub history_artifact_path: Option<String>,
523}
524
525#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
527pub struct TurnStartedEvent {
528 #[serde(skip_serializing_if = "Option::is_none")]
532 token_breakdown: Option<TokenBreakdown>,
533}
534
535#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
537#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
538pub struct TokenBreakdown {
539 system_prompt_tokens: u64,
541 tool_schema_tokens: u64,
543 instruction_file_tokens: u64,
545 message_history_tokens: u64,
547 cache_read_tokens: u64,
549 cache_write_tokens: u64,
551 cache_miss_tokens: u64,
553 #[serde(skip_serializing_if = "Option::is_none")]
555 subagent_bootstrap_tokens: Option<u64>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
560pub struct TurnCompletedEvent {
561 pub usage: Usage,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
566#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
567pub struct TurnFailedEvent {
568 pub message: String,
570 #[serde(skip_serializing_if = "Option::is_none")]
572 pub usage: Option<Usage>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
577pub struct ThreadErrorEvent {
578 pub message: String,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
584pub struct Usage {
585 pub input_tokens: u64,
587 pub cached_input_tokens: u64,
589 pub cache_creation_tokens: u64,
591 pub output_tokens: u64,
593}
594
595impl Usage {
596 #[must_use]
601 fn uncached_input_tokens(&self) -> u64 {
602 self.input_tokens
603 .saturating_sub(self.cached_input_tokens)
604 .saturating_sub(self.cache_creation_tokens)
605 }
606
607 #[must_use]
610 pub fn cache_hit_rate(&self) -> Option<f64> {
611 if self.input_tokens == 0 {
612 return None;
613 }
614 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
615 }
616
617 #[must_use]
619 pub fn cache_summary(&self) -> String {
620 let total_input = self.input_tokens;
621 if total_input == 0 {
622 return "No input tokens recorded.".to_string();
623 }
624
625 let cached = self.cached_input_tokens;
626 let creation = self.cache_creation_tokens;
627 let uncached = self.uncached_input_tokens();
628 let rate = cached as f64 / total_input as f64 * 100.0;
629 format!(
630 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
631 {creation} cache-creation, {uncached} uncached"
632 )
633 }
634
635 pub fn add(&mut self, other: &Usage) {
637 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
638 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
639 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
640 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
641 }
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
646pub struct ItemCompletedEvent {
647 pub item: ThreadItem,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
652#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
653pub struct ItemStartedEvent {
654 pub item: ThreadItem,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
660pub struct ItemUpdatedEvent {
661 pub item: ThreadItem,
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
666#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
667pub struct PlanDeltaEvent {
668 pub thread_id: String,
670 pub turn_id: String,
672 pub item_id: String,
674 pub delta: String,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
679#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
680pub struct PlanApprovalRequestedEvent {
681 pub thread_id: String,
683 pub turn_id: String,
685 #[serde(skip_serializing_if = "Option::is_none")]
687 pub plan_file: Option<String>,
688}
689
690#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
691#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
692#[serde(rename_all = "snake_case")]
693pub enum PlanApprovalDecision {
694 Execute,
696 AutoAccept,
698 Revise,
700 Cancel,
702 SwitchBuild,
704 SwitchAuto,
706 #[serde(other)]
708 Unknown,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct PlanApprovalResolvedEvent {
714 pub thread_id: String,
716 pub turn_id: String,
718 pub decision: PlanApprovalDecision,
720 pub automatic: bool,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
725#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
726pub struct ThreadItem {
727 pub id: String,
729 #[serde(flatten)]
731 pub details: ThreadItemDetails,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
736#[serde(tag = "type", rename_all = "snake_case")]
737pub enum ThreadItemDetails {
738 AgentMessage(AgentMessageItem),
740 Plan(PlanItem),
742 Reasoning(ReasoningItem),
744 CommandExecution(Box<CommandExecutionItem>),
746 ToolInvocation(ToolInvocationItem),
748 ToolOutput(ToolOutputItem),
750 FileChange(Box<FileChangeItem>),
752 McpToolCall(McpToolCallItem),
754 WebSearch(WebSearchItem),
756 Harness(HarnessEventItem),
758 Error(ErrorItem),
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
763#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
764pub struct AgentMessageItem {
765 pub text: String,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
770#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
771pub struct PlanItem {
772 pub text: String,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
777#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
778pub struct ReasoningItem {
779 pub text: String,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub stage: Option<String>,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
787#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
788#[serde(rename_all = "snake_case")]
789pub enum CommandExecutionStatus {
790 #[default]
792 Completed,
793 Failed,
795 InProgress,
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
800#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
801pub struct CommandExecutionItem {
802 pub command: String,
804 #[serde(skip_serializing_if = "Option::is_none")]
806 pub arguments: Option<Value>,
807 #[serde(default)]
809 pub aggregated_output: String,
810 #[serde(skip_serializing_if = "Option::is_none")]
812 pub exit_code: Option<i32>,
813 pub status: CommandExecutionStatus,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
818#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
819#[serde(rename_all = "snake_case")]
820pub enum ToolCallStatus {
821 #[default]
823 Completed,
824 Failed,
826 InProgress,
828}
829
830#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
838#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
839#[serde(rename_all = "snake_case")]
840pub enum ToolOutcome {
841 #[default]
843 Success,
844 Error,
846 PermissionRejected,
848 PermissionCancelled,
850 Followup,
852 HookDenied,
854 InvalidTool,
856 Cancelled,
858}
859
860impl ToolOutcome {
861 #[must_use]
862 pub const fn is_terminal(self) -> bool {
863 !matches!(self, Self::Followup)
864 }
865}
866
867#[must_use]
874#[allow(clippy::unreachable)]
875pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
876 match status {
877 ToolCallStatus::Completed => ToolOutcome::Success,
878 ToolCallStatus::Failed => ToolOutcome::Error,
879 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
880 }
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
884#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
885pub struct ToolInvocationItem {
886 pub tool_name: String,
888 #[serde(skip_serializing_if = "Option::is_none")]
890 pub arguments: Option<Value>,
891 #[serde(skip_serializing_if = "Option::is_none")]
893 pub tool_call_id: Option<String>,
894 pub status: ToolCallStatus,
896 #[serde(skip_serializing_if = "Option::is_none")]
898 pub outcome: Option<ToolOutcome>,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
902#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
903pub struct ToolOutputItem {
904 pub call_id: String,
906 #[serde(skip_serializing_if = "Option::is_none")]
908 pub tool_call_id: Option<String>,
909 #[serde(skip_serializing_if = "Option::is_none")]
911 pub spool_path: Option<String>,
912 #[serde(default)]
914 pub output: String,
915 #[serde(skip_serializing_if = "Option::is_none")]
917 pub exit_code: Option<i32>,
918 pub status: ToolCallStatus,
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
923#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
924pub struct FileChangeItem {
925 pub changes: Vec<FileUpdateChange>,
927 pub status: PatchApplyStatus,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
932#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
933pub struct FileUpdateChange {
934 pub path: String,
936 pub kind: PatchChangeKind,
938}
939
940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
941#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
942#[serde(rename_all = "snake_case")]
943pub enum PatchApplyStatus {
944 Completed,
946 Failed,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
951#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
952#[serde(rename_all = "snake_case")]
953pub enum PatchChangeKind {
954 Add,
956 Delete,
958 Update,
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
963#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
964pub struct McpToolCallItem {
965 pub tool_name: String,
967 #[serde(skip_serializing_if = "Option::is_none")]
969 pub arguments: Option<Value>,
970 #[serde(skip_serializing_if = "Option::is_none")]
972 pub result: Option<String>,
973 #[serde(skip_serializing_if = "Option::is_none")]
975 pub status: Option<McpToolCallStatus>,
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
979#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
980#[serde(rename_all = "snake_case")]
981pub enum McpToolCallStatus {
982 Started,
984 Completed,
986 Failed,
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
991#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
992pub struct WebSearchItem {
993 pub query: String,
995 #[serde(skip_serializing_if = "Option::is_none")]
997 pub provider: Option<String>,
998 #[serde(skip_serializing_if = "Option::is_none")]
1000 pub results: Option<Vec<String>>,
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1004#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1005#[serde(rename_all = "snake_case")]
1006pub enum HarnessEventKind {
1007 PlanningStarted,
1008 PlanningCompleted,
1009 ContinuationStarted,
1010 ContinuationSkipped,
1011 BlockedHandoffWritten,
1012 EvaluationStarted,
1013 EvaluationPassed,
1014 EvaluationFailed,
1015 RevisionStarted,
1016 EscalationTriggered,
1017 EscalationBypassed,
1018 VerificationStarted,
1019 VerificationPassed,
1020 VerificationFailed,
1021 ErrorRecovered,
1023 ToolRetryAttempted,
1025 ToolLatencyRecorded,
1027 SnapshotCreated,
1029 SnapshotRestored,
1031}
1032
1033#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1034#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1035#[serde(rename_all = "snake_case")]
1036pub enum PermissionDecision {
1037 Allow,
1038 Deny,
1039 Cancelled,
1040 Followup,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1044#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1045pub struct PermissionRequestedEvent {
1046 pub tool_name: String,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1051#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1052pub struct PermissionResolvedEvent {
1053 pub tool_name: String,
1055 pub decision: PermissionDecision,
1057 pub wait_ms: u64,
1059}
1060
1061#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1063#[serde(rename_all = "snake_case")]
1064pub enum InterjectionSource {
1065 Direct,
1066 Queue,
1067}
1068
1069#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1070#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1071#[serde(rename_all = "snake_case")]
1072pub enum RedirectKind {
1073 Interjection,
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1077#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1078pub struct InterjectedEvent {
1079 pub source: InterjectionSource,
1081 pub image_count: u32,
1083 pub redirect_kind: RedirectKind,
1086}
1087
1088#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1089#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1090pub struct HarnessEventItem {
1091 pub event: HarnessEventKind,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1095 pub message: Option<String>,
1096 #[serde(skip_serializing_if = "Option::is_none")]
1098 pub command: Option<String>,
1099 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub path: Option<String>,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1104 pub exit_code: Option<i32>,
1105 #[serde(skip_serializing_if = "Option::is_none")]
1107 pub attempt: Option<u32>,
1108 #[serde(skip_serializing_if = "Option::is_none")]
1110 pub error_category: Option<String>,
1111 #[serde(skip_serializing_if = "Option::is_none")]
1113 pub duration_ms: Option<u64>,
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1117#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1118pub struct ErrorItem {
1119 pub message: String,
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use std::error::Error;
1127
1128 #[test]
1129 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1130 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1131 usage: Usage {
1132 input_tokens: 1,
1133 cached_input_tokens: 2,
1134 cache_creation_tokens: 0,
1135 output_tokens: 3,
1136 },
1137 });
1138
1139 let json = serde_json::to_string(&event)?;
1140 let restored: ThreadEvent = serde_json::from_str(&json)?;
1141
1142 assert_eq!(restored, event);
1143 Ok(())
1144 }
1145
1146 #[test]
1147 fn usage_uncached_input_tokens_saturates() {
1148 let usage = Usage {
1149 input_tokens: 1_000,
1150 cached_input_tokens: 800,
1151 cache_creation_tokens: 100,
1152 output_tokens: 50,
1153 };
1154 assert_eq!(usage.uncached_input_tokens(), 100);
1155
1156 let inconsistent = Usage {
1157 input_tokens: 100,
1158 cached_input_tokens: 150,
1159 cache_creation_tokens: 0,
1160 output_tokens: 0,
1161 };
1162 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1163
1164 let inconsistent_with_creation = Usage {
1165 input_tokens: 100,
1166 cached_input_tokens: 80,
1167 cache_creation_tokens: 50,
1168 output_tokens: 0,
1169 };
1170 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1171 }
1172
1173 #[test]
1174 fn usage_cache_hit_rate() {
1175 assert_eq!(Usage::default().cache_hit_rate(), None);
1176
1177 let usage = Usage {
1178 input_tokens: 1_000,
1179 cached_input_tokens: 750,
1180 cache_creation_tokens: 0,
1181 output_tokens: 0,
1182 };
1183 let rate = usage.cache_hit_rate().expect("rate");
1184 assert!((rate - 0.75).abs() < f64::EPSILON);
1185 }
1186
1187 #[test]
1188 fn usage_cache_summary_formats() {
1189 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1190
1191 let usage = Usage {
1192 input_tokens: 1_000,
1193 cached_input_tokens: 800,
1194 cache_creation_tokens: 100,
1195 output_tokens: 50,
1196 };
1197 assert_eq!(
1198 usage.cache_summary(),
1199 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1200 );
1201 }
1202
1203 #[test]
1204 fn usage_add_accumulates_all_fields_with_saturation() {
1205 let mut total = Usage {
1206 input_tokens: 100,
1207 cached_input_tokens: 20,
1208 cache_creation_tokens: 5,
1209 output_tokens: 10,
1210 };
1211 total.add(&Usage {
1212 input_tokens: 50,
1213 cached_input_tokens: 10,
1214 cache_creation_tokens: 2,
1215 output_tokens: 8,
1216 });
1217
1218 assert_eq!(total.input_tokens, 150);
1219 assert_eq!(total.cached_input_tokens, 30);
1220 assert_eq!(total.cache_creation_tokens, 7);
1221 assert_eq!(total.output_tokens, 18);
1222
1223 let mut saturating = Usage {
1224 input_tokens: u64::MAX,
1225 cached_input_tokens: u64::MAX,
1226 cache_creation_tokens: u64::MAX,
1227 output_tokens: u64::MAX,
1228 };
1229 saturating.add(&Usage {
1230 input_tokens: 1,
1231 cached_input_tokens: 1,
1232 cache_creation_tokens: 1,
1233 output_tokens: 1,
1234 });
1235 assert_eq!(saturating.input_tokens, u64::MAX);
1236 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1237 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1238 assert_eq!(saturating.output_tokens, u64::MAX);
1239 }
1240
1241 #[test]
1242 fn versioned_event_wraps_schema_version() {
1243 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1244
1245 let versioned = VersionedThreadEvent::new(event.clone());
1246
1247 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1248 assert_eq!(versioned.event, event);
1249 assert_eq!(versioned.into_event(), event);
1250 }
1251
1252 #[test]
1253 fn plan_approval_events_round_trip_with_decision() {
1254 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1255 thread_id: "thread-1".to_string(),
1256 turn_id: "turn-2".to_string(),
1257 plan_file: Some(".vtcode/plans/change.md".to_string()),
1258 });
1259 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1260 thread_id: "thread-1".to_string(),
1261 turn_id: "turn-3".to_string(),
1262 decision: PlanApprovalDecision::AutoAccept,
1263 automatic: false,
1264 });
1265
1266 for event in [requested, resolved] {
1267 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1268 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1269 assert_eq!(restored, event);
1270 }
1271 }
1272
1273 #[test]
1274 fn plan_approval_decision_uses_stable_wire_names() {
1275 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1276 thread_id: "thread-1".to_string(),
1277 turn_id: "turn-1".to_string(),
1278 decision: PlanApprovalDecision::SwitchBuild,
1279 automatic: false,
1280 });
1281
1282 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1283 assert_eq!(serialized["type"], "plan.approval.resolved");
1284 assert_eq!(serialized["decision"], "switch_build");
1285 }
1286
1287 #[test]
1288 fn plan_approval_decision_is_forward_compatible() {
1289 let payload = serde_json::json!({
1290 "type": "plan.approval.resolved",
1291 "thread_id": "thread-1",
1292 "turn_id": "turn-1",
1293 "decision": "future_decision",
1294 "automatic": true,
1295 });
1296 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1297 assert!(matches!(
1298 event,
1299 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1300 decision: PlanApprovalDecision::Unknown,
1301 automatic: true,
1302 ..
1303 })
1304 ));
1305 }
1306
1307 #[cfg(feature = "serde-json")]
1308 #[test]
1309 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1310 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1311 item: ThreadItem {
1312 id: "item-1".to_string(),
1313 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1314 },
1315 });
1316
1317 let payload = json::versioned_to_string(&event)?;
1318 let restored = json::versioned_from_str(&payload)?;
1319
1320 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1321 assert_eq!(restored.event, event);
1322 Ok(())
1323 }
1324
1325 #[test]
1326 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1327 for trigger in [
1328 CompactionTrigger::Manual,
1329 CompactionTrigger::Auto,
1330 CompactionTrigger::Recovery,
1331 CompactionTrigger::ModelSwitch,
1332 CompactionTrigger::Unknown,
1333 ] {
1334 let json = serde_json::to_string(&trigger).unwrap();
1335 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1336 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1337 assert_eq!(restored, trigger);
1338 }
1339 }
1340
1341 #[test]
1342 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1343 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1344 item: ThreadItem {
1345 id: "tool_1".to_string(),
1346 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1347 tool_name: "read_file".to_string(),
1348 arguments: Some(serde_json::json!({ "path": "README.md" })),
1349 tool_call_id: Some("tool_call_0".to_string()),
1350 status: ToolCallStatus::Completed,
1351 outcome: None,
1352 }),
1353 },
1354 });
1355
1356 let json = serde_json::to_string(&event)?;
1357 let restored: ThreadEvent = serde_json::from_str(&json)?;
1358
1359 assert_eq!(restored, event);
1360 Ok(())
1361 }
1362
1363 #[test]
1364 fn tool_outcome_serializes_snake_case() {
1365 for outcome in [
1366 ToolOutcome::Success,
1367 ToolOutcome::Error,
1368 ToolOutcome::PermissionRejected,
1369 ToolOutcome::PermissionCancelled,
1370 ToolOutcome::Followup,
1371 ToolOutcome::HookDenied,
1372 ToolOutcome::InvalidTool,
1373 ToolOutcome::Cancelled,
1374 ] {
1375 let json = serde_json::to_string(&outcome).unwrap();
1376 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1377 assert_eq!(restored, outcome);
1378 }
1379 }
1380
1381 #[test]
1382 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1383 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1384 item: ThreadItem {
1385 id: "tool_1".to_string(),
1386 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1387 tool_name: "exec_command".to_string(),
1388 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1389 tool_call_id: Some("tool_call_0".to_string()),
1390 status: ToolCallStatus::Failed,
1391 outcome: Some(ToolOutcome::PermissionRejected),
1392 }),
1393 },
1394 });
1395
1396 let json = serde_json::to_string(&event)?;
1397 let restored: ThreadEvent = serde_json::from_str(&json)?;
1398
1399 assert_eq!(restored, event);
1400 Ok(())
1401 }
1402
1403 #[test]
1404 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1405 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1406 item: ThreadItem {
1407 id: "tool_1:output".to_string(),
1408 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1409 call_id: "tool_1".to_string(),
1410 tool_call_id: Some("tool_call_0".to_string()),
1411 spool_path: None,
1412 output: "done".to_string(),
1413 exit_code: Some(0),
1414 status: ToolCallStatus::Completed,
1415 }),
1416 },
1417 });
1418
1419 let json = serde_json::to_string(&event)?;
1420 let restored: ThreadEvent = serde_json::from_str(&json)?;
1421
1422 assert_eq!(restored, event);
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1428 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1429 item: ThreadItem {
1430 id: "harness_1".to_string(),
1431 details: ThreadItemDetails::Harness(HarnessEventItem {
1432 event: HarnessEventKind::VerificationFailed,
1433 message: Some("cargo check failed".to_string()),
1434 command: Some("cargo check".to_string()),
1435 path: None,
1436 exit_code: Some(101),
1437 attempt: None,
1438 error_category: None,
1439 duration_ms: None,
1440 }),
1441 },
1442 });
1443
1444 let json = serde_json::to_string(&event)?;
1445 let restored: ThreadEvent = serde_json::from_str(&json)?;
1446
1447 assert_eq!(restored, event);
1448 Ok(())
1449 }
1450
1451 #[test]
1452 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1453 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1454 thread_id: "thread-1".to_string(),
1455 session_id: "session-1".to_string(),
1456 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1457 outcome_code: "budget_limit_reached".to_string(),
1458 result: None,
1459 stop_reason: Some("max_tokens".to_string()),
1460 usage: Usage {
1461 input_tokens: 10,
1462 cached_input_tokens: 4,
1463 cache_creation_tokens: 2,
1464 output_tokens: 5,
1465 },
1466 total_cost_usd: serde_json::Number::from_f64(1.25),
1467 num_turns: 3,
1468 });
1469
1470 let json = serde_json::to_string(&event)?;
1471 let restored: ThreadEvent = serde_json::from_str(&json)?;
1472
1473 assert_eq!(restored, event);
1474 Ok(())
1475 }
1476
1477 #[test]
1478 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1479 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1480 thread_id: "thread-1".to_string(),
1481 trigger: CompactionTrigger::Recovery,
1482 mode: CompactionMode::Provider,
1483 original_message_count: 12,
1484 compacted_message_count: 5,
1485 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1486 });
1487
1488 let json = serde_json::to_string(&event)?;
1489 let restored: ThreadEvent = serde_json::from_str(&json)?;
1490
1491 assert_eq!(restored, event);
1492 Ok(())
1493 }
1494}