1#![allow(missing_docs)]
2use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub mod atif;
19pub mod trace;
20
21pub const EVENT_SCHEMA_VERSION: &str = "0.8.0";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
28pub struct VersionedThreadEvent {
29 pub schema_version: String,
31 pub 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 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 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 fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
94 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
95 }
96
97 pub 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!(
134 self.level,
135 "failed to serialize vtcode exec event for logging: {err}"
136 ),
137 }
138 }
139 }
140 }
141
142 pub use LogEmitter as PublicLogEmitter;
143}
144
145#[cfg(feature = "telemetry-log")]
146pub use log_support::PublicLogEmitter as LogEmitter;
147
148#[cfg(feature = "telemetry-tracing")]
149mod tracing_support {
150 use tracing::Level;
151
152 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
153
154 #[derive(Debug, Clone)]
156 pub struct TracingEmitter {
157 level: Level,
158 }
159
160 impl TracingEmitter {
161 pub fn new(level: Level) -> Self {
163 Self { level }
164 }
165 }
166
167 impl Default for TracingEmitter {
168 fn default() -> Self {
169 Self { level: Level::INFO }
170 }
171 }
172
173 impl EventEmitter for TracingEmitter {
174 fn emit(&mut self, event: &ThreadEvent) {
175 match self.level {
176 Level::TRACE => tracing::event!(
177 target: "vtcode_exec_events",
178 Level::TRACE,
179 schema_version = EVENT_SCHEMA_VERSION,
180 event = ?VersionedThreadEvent::new(event.clone()),
181 "vtcode_exec_event"
182 ),
183 Level::DEBUG => tracing::event!(
184 target: "vtcode_exec_events",
185 Level::DEBUG,
186 schema_version = EVENT_SCHEMA_VERSION,
187 event = ?VersionedThreadEvent::new(event.clone()),
188 "vtcode_exec_event"
189 ),
190 Level::INFO => tracing::event!(
191 target: "vtcode_exec_events",
192 Level::INFO,
193 schema_version = EVENT_SCHEMA_VERSION,
194 event = ?VersionedThreadEvent::new(event.clone()),
195 "vtcode_exec_event"
196 ),
197 Level::WARN => tracing::event!(
198 target: "vtcode_exec_events",
199 Level::WARN,
200 schema_version = EVENT_SCHEMA_VERSION,
201 event = ?VersionedThreadEvent::new(event.clone()),
202 "vtcode_exec_event"
203 ),
204 Level::ERROR => tracing::event!(
205 target: "vtcode_exec_events",
206 Level::ERROR,
207 schema_version = EVENT_SCHEMA_VERSION,
208 event = ?VersionedThreadEvent::new(event.clone()),
209 "vtcode_exec_event"
210 ),
211 }
212 }
213 }
214
215 pub use TracingEmitter as PublicTracingEmitter;
216}
217
218#[cfg(feature = "telemetry-tracing")]
219pub use tracing_support::PublicTracingEmitter as TracingEmitter;
220
221#[cfg(feature = "telemetry-otel")]
222mod otel_support {
223 use opentelemetry::KeyValue;
224 use opentelemetry::trace::{Span, Status, Tracer};
225
226 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
227
228 pub struct OtelEmitter<T: Tracer> {
245 tracer: T,
246 }
247
248 impl<T: Tracer> OtelEmitter<T> {
249 pub fn new(tracer: T) -> Self {
250 Self { tracer }
251 }
252 }
253
254 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
255 fn emit(&mut self, event: &ThreadEvent) {
256 let span_name = match event {
257 ThreadEvent::ThreadStarted(_) => "thread.started",
258 ThreadEvent::ThreadCompleted(_) => "thread.completed",
259 ThreadEvent::TurnStarted(_) => "turn.started",
260 ThreadEvent::TurnCompleted(_) => "turn.completed",
261 ThreadEvent::TurnFailed(_) => "turn.failed",
262 ThreadEvent::ItemStarted(_) => "item.started",
263 ThreadEvent::ItemUpdated(_) => "item.updated",
264 ThreadEvent::ItemCompleted(_) => "item.completed",
265 ThreadEvent::Error(_) => "error",
266 _ => "event",
267 };
268
269 let mut span = self.tracer.start(span_name);
270
271 match event {
272 ThreadEvent::ThreadStarted(e) => {
273 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
274 }
275 ThreadEvent::ThreadCompleted(e) => {
276 if let Some(ref cost) = e.total_cost_usd {
277 span.set_attribute(KeyValue::new(
278 "total_cost_usd",
279 cost.as_f64().unwrap_or(0.0),
280 ));
281 }
282 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
283 span.set_attribute(KeyValue::new(
284 "output_tokens",
285 e.usage.output_tokens as i64,
286 ));
287 span.set_attribute(KeyValue::new(
288 "completion_subtype",
289 e.subtype.as_str().to_string(),
290 ));
291 }
292 ThreadEvent::TurnCompleted(e) => {
293 span.set_attribute(KeyValue::new(
294 "turn_input_tokens",
295 e.usage.input_tokens as i64,
296 ));
297 span.set_attribute(KeyValue::new(
298 "turn_output_tokens",
299 e.usage.output_tokens as i64,
300 ));
301 }
302 ThreadEvent::ItemCompleted(e) => {
303 if let ThreadItemDetails::Harness(harness) = &e.item.details {
304 span.set_attribute(KeyValue::new(
305 "harness_event",
306 format!("{:?}", harness.event),
307 ));
308 if let Some(ref msg) = harness.message {
309 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
310 }
311 if let Some(ref path) = harness.path {
312 span.set_attribute(KeyValue::new("harness_path", path.clone()));
313 }
314 if let Some(dur) = harness.duration_ms {
315 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
316 }
317 let mut event_attrs =
318 vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
319 if let Some(ref msg) = harness.message {
320 event_attrs.push(KeyValue::new("message", msg.clone()));
321 }
322 span.add_event("harness_event", event_attrs);
323 }
324 }
325 ThreadEvent::Error(e) => {
326 span.set_status(Status::Error { description: e.message.clone().into() });
327 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
328 }
329 _ => {}
330 }
331
332 span.end();
333 }
334 }
335
336 pub use OtelEmitter as PublicOtelEmitter;
337}
338
339#[cfg(feature = "telemetry-otel")]
340pub use otel_support::PublicOtelEmitter as OtelEmitter;
341
342#[cfg(feature = "schema-export")]
343pub mod schema {
344 use schemars::{Schema, schema_for};
345
346 use super::{ThreadEvent, VersionedThreadEvent};
347
348 pub fn thread_event_schema() -> Schema {
350 schema_for!(ThreadEvent)
351 }
352
353 pub fn versioned_thread_event_schema() -> Schema {
355 schema_for!(VersionedThreadEvent)
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
361#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
362#[serde(tag = "type")]
363pub enum ThreadEvent {
364 #[serde(rename = "thread.started")]
366 ThreadStarted(ThreadStartedEvent),
367 #[serde(rename = "thread.completed")]
369 ThreadCompleted(ThreadCompletedEvent),
370 #[serde(rename = "thread.compact_boundary")]
372 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
373 #[serde(rename = "turn.started")]
375 TurnStarted(TurnStartedEvent),
376 #[serde(rename = "turn.completed")]
378 TurnCompleted(TurnCompletedEvent),
379 #[serde(rename = "turn.failed")]
381 TurnFailed(TurnFailedEvent),
382 #[serde(rename = "item.started")]
384 ItemStarted(ItemStartedEvent),
385 #[serde(rename = "item.updated")]
387 ItemUpdated(ItemUpdatedEvent),
388 #[serde(rename = "item.completed")]
390 ItemCompleted(ItemCompletedEvent),
391 #[serde(rename = "plan.delta")]
393 PlanDelta(PlanDeltaEvent),
394 #[serde(rename = "error")]
396 Error(ThreadErrorEvent),
397 #[serde(other)]
400 Unknown,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
404#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
405pub struct ThreadStartedEvent {
406 pub thread_id: String,
408}
409
410#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
411#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
412#[serde(rename_all = "snake_case")]
413pub enum ThreadCompletionSubtype {
414 Success,
415 ErrorMaxTurns,
416 ErrorMaxBudgetUsd,
417 ErrorDuringExecution,
418 Cancelled,
419 #[serde(other)]
421 Unknown,
422}
423
424impl ThreadCompletionSubtype {
425 pub const fn as_str(&self) -> &'static str {
426 match self {
427 Self::Success => "success",
428 Self::ErrorMaxTurns => "error_max_turns",
429 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
430 Self::ErrorDuringExecution => "error_during_execution",
431 Self::Cancelled => "cancelled",
432 Self::Unknown => "unknown",
433 }
434 }
435
436 pub const fn is_success(self) -> bool {
437 matches!(self, Self::Success)
438 }
439}
440
441#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
443#[serde(rename_all = "snake_case")]
444pub enum CompactionTrigger {
445 Manual,
446 Auto,
447 Recovery,
448 ModelSwitch,
451 #[serde(other)]
453 Unknown,
454}
455
456impl CompactionTrigger {
457 pub const fn as_str(self) -> &'static str {
458 match self {
459 Self::Manual => "manual",
460 Self::Auto => "auto",
461 Self::Recovery => "recovery",
462 Self::ModelSwitch => "model_switch",
463 Self::Unknown => "unknown",
464 }
465 }
466}
467
468#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
469#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
470#[serde(rename_all = "snake_case")]
471pub enum CompactionMode {
472 Provider,
473 Local,
474 #[serde(other)]
476 Unknown,
477}
478
479impl CompactionMode {
480 pub const fn as_str(self) -> &'static str {
481 match self {
482 Self::Provider => "provider",
483 Self::Local => "local",
484 Self::Unknown => "unknown",
485 }
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
491pub struct ThreadCompletedEvent {
492 pub thread_id: String,
494 pub session_id: String,
496 pub subtype: ThreadCompletionSubtype,
498 pub outcome_code: String,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub result: Option<String>,
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub stop_reason: Option<String>,
506 pub usage: Usage,
508 #[serde(skip_serializing_if = "Option::is_none")]
510 pub total_cost_usd: Option<serde_json::Number>,
511 pub num_turns: usize,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
517pub struct ThreadCompactBoundaryEvent {
518 pub thread_id: String,
520 pub trigger: CompactionTrigger,
522 pub mode: CompactionMode,
524 pub original_message_count: usize,
526 pub compacted_message_count: usize,
528 #[serde(skip_serializing_if = "Option::is_none")]
530 pub history_artifact_path: Option<String>,
531}
532
533#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
534#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
535pub struct TurnStartedEvent {
536 #[serde(skip_serializing_if = "Option::is_none")]
540 pub token_breakdown: Option<TokenBreakdown>,
541}
542
543#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
545#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
546pub struct TokenBreakdown {
547 pub system_prompt_tokens: u64,
549 pub tool_schema_tokens: u64,
551 pub instruction_file_tokens: u64,
553 pub message_history_tokens: u64,
555 pub cache_read_tokens: u64,
557 pub cache_write_tokens: u64,
559 pub cache_miss_tokens: u64,
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub subagent_bootstrap_tokens: Option<u64>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
568pub struct TurnCompletedEvent {
569 pub usage: Usage,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
575pub struct TurnFailedEvent {
576 pub message: String,
578 #[serde(skip_serializing_if = "Option::is_none")]
580 pub usage: Option<Usage>,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
585pub struct ThreadErrorEvent {
586 pub message: String,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct Usage {
593 pub input_tokens: u64,
595 pub cached_input_tokens: u64,
597 pub cache_creation_tokens: u64,
599 pub output_tokens: u64,
601}
602
603impl Usage {
604 #[must_use]
609 pub fn uncached_input_tokens(&self) -> u64 {
610 self.input_tokens
611 .saturating_sub(self.cached_input_tokens)
612 .saturating_sub(self.cache_creation_tokens)
613 }
614
615 #[must_use]
618 pub fn cache_hit_rate(&self) -> Option<f64> {
619 if self.input_tokens == 0 {
620 return None;
621 }
622 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
623 }
624
625 #[must_use]
627 pub fn cache_summary(&self) -> String {
628 let total_input = self.input_tokens;
629 if total_input == 0 {
630 return "No input tokens recorded.".to_string();
631 }
632
633 let cached = self.cached_input_tokens;
634 let creation = self.cache_creation_tokens;
635 let uncached = self.uncached_input_tokens();
636 let rate = cached as f64 / total_input as f64 * 100.0;
637 format!(
638 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
639 {creation} cache-creation, {uncached} uncached"
640 )
641 }
642
643 pub fn add(&mut self, other: &Usage) {
645 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
646 self.cached_input_tokens =
647 self.cached_input_tokens.saturating_add(other.cached_input_tokens);
648 self.cache_creation_tokens =
649 self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
650 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
651 }
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
655#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
656pub struct ItemCompletedEvent {
657 pub item: ThreadItem,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
662#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
663pub struct ItemStartedEvent {
664 pub item: ThreadItem,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
669#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
670pub struct ItemUpdatedEvent {
671 pub item: ThreadItem,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
677pub struct PlanDeltaEvent {
678 pub thread_id: String,
680 pub turn_id: String,
682 pub item_id: String,
684 pub delta: String,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
689#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
690pub struct ThreadItem {
691 pub id: String,
693 #[serde(flatten)]
695 pub details: ThreadItemDetails,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
700#[serde(tag = "type", rename_all = "snake_case")]
701pub enum ThreadItemDetails {
702 AgentMessage(AgentMessageItem),
704 Plan(PlanItem),
706 Reasoning(ReasoningItem),
708 CommandExecution(Box<CommandExecutionItem>),
710 ToolInvocation(ToolInvocationItem),
712 ToolOutput(ToolOutputItem),
714 FileChange(Box<FileChangeItem>),
716 McpToolCall(McpToolCallItem),
718 WebSearch(WebSearchItem),
720 Harness(HarnessEventItem),
722 Error(ErrorItem),
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
727#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
728pub struct AgentMessageItem {
729 pub text: String,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
734#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
735pub struct PlanItem {
736 pub text: String,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
741#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
742pub struct ReasoningItem {
743 pub text: String,
745 #[serde(skip_serializing_if = "Option::is_none")]
747 pub stage: Option<String>,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
752#[serde(rename_all = "snake_case")]
753pub enum CommandExecutionStatus {
754 #[default]
756 Completed,
757 Failed,
759 InProgress,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
764#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
765pub struct CommandExecutionItem {
766 pub command: String,
768 #[serde(skip_serializing_if = "Option::is_none")]
770 pub arguments: Option<Value>,
771 #[serde(default)]
773 pub aggregated_output: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
776 pub exit_code: Option<i32>,
777 pub status: CommandExecutionStatus,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
782#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
783#[serde(rename_all = "snake_case")]
784pub enum ToolCallStatus {
785 #[default]
787 Completed,
788 Failed,
790 InProgress,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
796pub struct ToolInvocationItem {
797 pub tool_name: String,
799 #[serde(skip_serializing_if = "Option::is_none")]
801 pub arguments: Option<Value>,
802 #[serde(skip_serializing_if = "Option::is_none")]
804 pub tool_call_id: Option<String>,
805 pub status: ToolCallStatus,
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
810#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
811pub struct ToolOutputItem {
812 pub call_id: String,
814 #[serde(skip_serializing_if = "Option::is_none")]
816 pub tool_call_id: Option<String>,
817 #[serde(skip_serializing_if = "Option::is_none")]
819 pub spool_path: Option<String>,
820 #[serde(default)]
822 pub output: String,
823 #[serde(skip_serializing_if = "Option::is_none")]
825 pub exit_code: Option<i32>,
826 pub status: ToolCallStatus,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
831#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
832pub struct FileChangeItem {
833 pub changes: Vec<FileUpdateChange>,
835 pub status: PatchApplyStatus,
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
841pub struct FileUpdateChange {
842 pub path: String,
844 pub kind: PatchChangeKind,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
850#[serde(rename_all = "snake_case")]
851pub enum PatchApplyStatus {
852 Completed,
854 Failed,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
860#[serde(rename_all = "snake_case")]
861pub enum PatchChangeKind {
862 Add,
864 Delete,
866 Update,
868}
869
870#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
871#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
872pub struct McpToolCallItem {
873 pub tool_name: String,
875 #[serde(skip_serializing_if = "Option::is_none")]
877 pub arguments: Option<Value>,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub result: Option<String>,
881 #[serde(skip_serializing_if = "Option::is_none")]
883 pub status: Option<McpToolCallStatus>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
887#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
888#[serde(rename_all = "snake_case")]
889pub enum McpToolCallStatus {
890 Started,
892 Completed,
894 Failed,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
899#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
900pub struct WebSearchItem {
901 pub query: String,
903 #[serde(skip_serializing_if = "Option::is_none")]
905 pub provider: Option<String>,
906 #[serde(skip_serializing_if = "Option::is_none")]
908 pub results: Option<Vec<String>>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
913#[serde(rename_all = "snake_case")]
914pub enum HarnessEventKind {
915 PlanningStarted,
916 PlanningCompleted,
917 ContinuationStarted,
918 ContinuationSkipped,
919 BlockedHandoffWritten,
920 EvaluationStarted,
921 EvaluationPassed,
922 EvaluationFailed,
923 RevisionStarted,
924 EscalationTriggered,
925 EscalationBypassed,
926 VerificationStarted,
927 VerificationPassed,
928 VerificationFailed,
929 ErrorRecovered,
931 ToolRetryAttempted,
933 ToolLatencyRecorded,
935 SnapshotCreated,
937 SnapshotRestored,
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
942#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
943pub struct HarnessEventItem {
944 pub event: HarnessEventKind,
946 #[serde(skip_serializing_if = "Option::is_none")]
948 pub message: Option<String>,
949 #[serde(skip_serializing_if = "Option::is_none")]
951 pub command: Option<String>,
952 #[serde(skip_serializing_if = "Option::is_none")]
954 pub path: Option<String>,
955 #[serde(skip_serializing_if = "Option::is_none")]
957 pub exit_code: Option<i32>,
958 #[serde(skip_serializing_if = "Option::is_none")]
960 pub attempt: Option<u32>,
961 #[serde(skip_serializing_if = "Option::is_none")]
963 pub error_category: Option<String>,
964 #[serde(skip_serializing_if = "Option::is_none")]
966 pub duration_ms: Option<u64>,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
970#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
971pub struct ErrorItem {
972 pub message: String,
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use std::error::Error;
980
981 #[test]
982 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
983 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
984 usage: Usage {
985 input_tokens: 1,
986 cached_input_tokens: 2,
987 cache_creation_tokens: 0,
988 output_tokens: 3,
989 },
990 });
991
992 let json = serde_json::to_string(&event)?;
993 let restored: ThreadEvent = serde_json::from_str(&json)?;
994
995 assert_eq!(restored, event);
996 Ok(())
997 }
998
999 #[test]
1000 fn usage_uncached_input_tokens_saturates() {
1001 let usage = Usage {
1002 input_tokens: 1_000,
1003 cached_input_tokens: 800,
1004 cache_creation_tokens: 100,
1005 output_tokens: 50,
1006 };
1007 assert_eq!(usage.uncached_input_tokens(), 100);
1008
1009 let inconsistent = Usage {
1010 input_tokens: 100,
1011 cached_input_tokens: 150,
1012 cache_creation_tokens: 0,
1013 output_tokens: 0,
1014 };
1015 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1016
1017 let inconsistent_with_creation = Usage {
1018 input_tokens: 100,
1019 cached_input_tokens: 80,
1020 cache_creation_tokens: 50,
1021 output_tokens: 0,
1022 };
1023 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1024 }
1025
1026 #[test]
1027 fn usage_cache_hit_rate() {
1028 assert_eq!(Usage::default().cache_hit_rate(), None);
1029
1030 let usage = Usage {
1031 input_tokens: 1_000,
1032 cached_input_tokens: 750,
1033 cache_creation_tokens: 0,
1034 output_tokens: 0,
1035 };
1036 let rate = usage.cache_hit_rate().expect("rate");
1037 assert!((rate - 0.75).abs() < f64::EPSILON);
1038 }
1039
1040 #[test]
1041 fn usage_cache_summary_formats() {
1042 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1043
1044 let usage = Usage {
1045 input_tokens: 1_000,
1046 cached_input_tokens: 800,
1047 cache_creation_tokens: 100,
1048 output_tokens: 50,
1049 };
1050 assert_eq!(
1051 usage.cache_summary(),
1052 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1053 );
1054 }
1055
1056 #[test]
1057 fn usage_add_accumulates_all_fields_with_saturation() {
1058 let mut total = Usage {
1059 input_tokens: 100,
1060 cached_input_tokens: 20,
1061 cache_creation_tokens: 5,
1062 output_tokens: 10,
1063 };
1064 total.add(&Usage {
1065 input_tokens: 50,
1066 cached_input_tokens: 10,
1067 cache_creation_tokens: 2,
1068 output_tokens: 8,
1069 });
1070
1071 assert_eq!(total.input_tokens, 150);
1072 assert_eq!(total.cached_input_tokens, 30);
1073 assert_eq!(total.cache_creation_tokens, 7);
1074 assert_eq!(total.output_tokens, 18);
1075
1076 let mut saturating = Usage {
1077 input_tokens: u64::MAX,
1078 cached_input_tokens: u64::MAX,
1079 cache_creation_tokens: u64::MAX,
1080 output_tokens: u64::MAX,
1081 };
1082 saturating.add(&Usage {
1083 input_tokens: 1,
1084 cached_input_tokens: 1,
1085 cache_creation_tokens: 1,
1086 output_tokens: 1,
1087 });
1088 assert_eq!(saturating.input_tokens, u64::MAX);
1089 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1090 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1091 assert_eq!(saturating.output_tokens, u64::MAX);
1092 }
1093
1094 #[test]
1095 fn versioned_event_wraps_schema_version() {
1096 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1097
1098 let versioned = VersionedThreadEvent::new(event.clone());
1099
1100 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1101 assert_eq!(versioned.event, event);
1102 assert_eq!(versioned.into_event(), event);
1103 }
1104
1105 #[cfg(feature = "serde-json")]
1106 #[test]
1107 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1108 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1109 item: ThreadItem {
1110 id: "item-1".to_string(),
1111 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
1112 text: "hello".to_string(),
1113 }),
1114 },
1115 });
1116
1117 let payload = json::versioned_to_string(&event)?;
1118 let restored = json::versioned_from_str(&payload)?;
1119
1120 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1121 assert_eq!(restored.event, event);
1122 Ok(())
1123 }
1124
1125 #[test]
1126 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1127 for trigger in [
1128 CompactionTrigger::Manual,
1129 CompactionTrigger::Auto,
1130 CompactionTrigger::Recovery,
1131 CompactionTrigger::ModelSwitch,
1132 CompactionTrigger::Unknown,
1133 ] {
1134 let json = serde_json::to_string(&trigger).unwrap();
1135 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1136 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1137 assert_eq!(restored, trigger);
1138 }
1139 }
1140
1141 #[test]
1142 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1143 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1144 item: ThreadItem {
1145 id: "tool_1".to_string(),
1146 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1147 tool_name: "read_file".to_string(),
1148 arguments: Some(serde_json::json!({ "path": "README.md" })),
1149 tool_call_id: Some("tool_call_0".to_string()),
1150 status: ToolCallStatus::Completed,
1151 }),
1152 },
1153 });
1154
1155 let json = serde_json::to_string(&event)?;
1156 let restored: ThreadEvent = serde_json::from_str(&json)?;
1157
1158 assert_eq!(restored, event);
1159 Ok(())
1160 }
1161
1162 #[test]
1163 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1164 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1165 item: ThreadItem {
1166 id: "tool_1:output".to_string(),
1167 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1168 call_id: "tool_1".to_string(),
1169 tool_call_id: Some("tool_call_0".to_string()),
1170 spool_path: None,
1171 output: "done".to_string(),
1172 exit_code: Some(0),
1173 status: ToolCallStatus::Completed,
1174 }),
1175 },
1176 });
1177
1178 let json = serde_json::to_string(&event)?;
1179 let restored: ThreadEvent = serde_json::from_str(&json)?;
1180
1181 assert_eq!(restored, event);
1182 Ok(())
1183 }
1184
1185 #[test]
1186 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1187 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1188 item: ThreadItem {
1189 id: "harness_1".to_string(),
1190 details: ThreadItemDetails::Harness(HarnessEventItem {
1191 event: HarnessEventKind::VerificationFailed,
1192 message: Some("cargo check failed".to_string()),
1193 command: Some("cargo check".to_string()),
1194 path: None,
1195 exit_code: Some(101),
1196 attempt: None,
1197 error_category: None,
1198 duration_ms: None,
1199 }),
1200 },
1201 });
1202
1203 let json = serde_json::to_string(&event)?;
1204 let restored: ThreadEvent = serde_json::from_str(&json)?;
1205
1206 assert_eq!(restored, event);
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1212 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1213 thread_id: "thread-1".to_string(),
1214 session_id: "session-1".to_string(),
1215 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1216 outcome_code: "budget_limit_reached".to_string(),
1217 result: None,
1218 stop_reason: Some("max_tokens".to_string()),
1219 usage: Usage {
1220 input_tokens: 10,
1221 cached_input_tokens: 4,
1222 cache_creation_tokens: 2,
1223 output_tokens: 5,
1224 },
1225 total_cost_usd: serde_json::Number::from_f64(1.25),
1226 num_turns: 3,
1227 });
1228
1229 let json = serde_json::to_string(&event)?;
1230 let restored: ThreadEvent = serde_json::from_str(&json)?;
1231
1232 assert_eq!(restored, event);
1233 Ok(())
1234 }
1235
1236 #[test]
1237 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1238 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1239 thread_id: "thread-1".to_string(),
1240 trigger: CompactionTrigger::Recovery,
1241 mode: CompactionMode::Provider,
1242 original_message_count: 12,
1243 compacted_message_count: 5,
1244 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1245 });
1246
1247 let json = serde_json::to_string(&event)?;
1248 let restored: ThreadEvent = serde_json::from_str(&json)?;
1249
1250 assert_eq!(restored, event);
1251 Ok(())
1252 }
1253}