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.7.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 {
327 description: e.message.clone().into(),
328 });
329 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
330 }
331 _ => {}
332 }
333
334 span.end();
335 }
336 }
337
338 pub use OtelEmitter as PublicOtelEmitter;
339}
340
341#[cfg(feature = "telemetry-otel")]
342pub use otel_support::PublicOtelEmitter as OtelEmitter;
343
344#[cfg(feature = "schema-export")]
345pub mod schema {
346 use schemars::{Schema, schema_for};
347
348 use super::{ThreadEvent, VersionedThreadEvent};
349
350 pub fn thread_event_schema() -> Schema {
352 schema_for!(ThreadEvent)
353 }
354
355 pub fn versioned_thread_event_schema() -> Schema {
357 schema_for!(VersionedThreadEvent)
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
364#[serde(tag = "type")]
365pub enum ThreadEvent {
366 #[serde(rename = "thread.started")]
368 ThreadStarted(ThreadStartedEvent),
369 #[serde(rename = "thread.completed")]
371 ThreadCompleted(ThreadCompletedEvent),
372 #[serde(rename = "thread.compact_boundary")]
374 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
375 #[serde(rename = "turn.started")]
377 TurnStarted(TurnStartedEvent),
378 #[serde(rename = "turn.completed")]
380 TurnCompleted(TurnCompletedEvent),
381 #[serde(rename = "turn.failed")]
383 TurnFailed(TurnFailedEvent),
384 #[serde(rename = "item.started")]
386 ItemStarted(ItemStartedEvent),
387 #[serde(rename = "item.updated")]
389 ItemUpdated(ItemUpdatedEvent),
390 #[serde(rename = "item.completed")]
392 ItemCompleted(ItemCompletedEvent),
393 #[serde(rename = "plan.delta")]
395 PlanDelta(PlanDeltaEvent),
396 #[serde(rename = "error")]
398 Error(ThreadErrorEvent),
399 #[serde(other)]
402 Unknown,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
407pub struct ThreadStartedEvent {
408 pub thread_id: String,
410}
411
412#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
414#[serde(rename_all = "snake_case")]
415pub enum ThreadCompletionSubtype {
416 Success,
417 ErrorMaxTurns,
418 ErrorMaxBudgetUsd,
419 ErrorDuringExecution,
420 Cancelled,
421 #[serde(other)]
423 Unknown,
424}
425
426impl ThreadCompletionSubtype {
427 pub const fn as_str(&self) -> &'static str {
428 match self {
429 Self::Success => "success",
430 Self::ErrorMaxTurns => "error_max_turns",
431 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
432 Self::ErrorDuringExecution => "error_during_execution",
433 Self::Cancelled => "cancelled",
434 Self::Unknown => "unknown",
435 }
436 }
437
438 pub const fn is_success(self) -> bool {
439 matches!(self, Self::Success)
440 }
441}
442
443#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
444#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
445#[serde(rename_all = "snake_case")]
446pub enum CompactionTrigger {
447 Manual,
448 Auto,
449 Recovery,
450 ModelSwitch,
453 #[serde(other)]
455 Unknown,
456}
457
458impl CompactionTrigger {
459 pub const fn as_str(self) -> &'static str {
460 match self {
461 Self::Manual => "manual",
462 Self::Auto => "auto",
463 Self::Recovery => "recovery",
464 Self::ModelSwitch => "model_switch",
465 Self::Unknown => "unknown",
466 }
467 }
468}
469
470#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
472#[serde(rename_all = "snake_case")]
473pub enum CompactionMode {
474 Provider,
475 Local,
476 #[serde(other)]
478 Unknown,
479}
480
481impl CompactionMode {
482 pub const fn as_str(self) -> &'static str {
483 match self {
484 Self::Provider => "provider",
485 Self::Local => "local",
486 Self::Unknown => "unknown",
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
493pub struct ThreadCompletedEvent {
494 pub thread_id: String,
496 pub session_id: String,
498 pub subtype: ThreadCompletionSubtype,
500 pub outcome_code: String,
502 #[serde(skip_serializing_if = "Option::is_none")]
504 pub result: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
507 pub stop_reason: Option<String>,
508 pub usage: Usage,
510 #[serde(skip_serializing_if = "Option::is_none")]
512 pub total_cost_usd: Option<serde_json::Number>,
513 pub num_turns: usize,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
519pub struct ThreadCompactBoundaryEvent {
520 pub thread_id: String,
522 pub trigger: CompactionTrigger,
524 pub mode: CompactionMode,
526 pub original_message_count: usize,
528 pub compacted_message_count: usize,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub history_artifact_path: Option<String>,
533}
534
535#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
536#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
537pub struct TurnStartedEvent {}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
540#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
541pub struct TurnCompletedEvent {
542 pub usage: Usage,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
547#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
548pub struct TurnFailedEvent {
549 pub message: String,
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub usage: Option<Usage>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
558pub struct ThreadErrorEvent {
559 pub message: String,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
565pub struct Usage {
566 pub input_tokens: u64,
568 pub cached_input_tokens: u64,
570 pub cache_creation_tokens: u64,
572 pub output_tokens: u64,
574}
575
576impl Usage {
577 #[must_use]
582 pub fn uncached_input_tokens(&self) -> u64 {
583 self.input_tokens
584 .saturating_sub(self.cached_input_tokens)
585 .saturating_sub(self.cache_creation_tokens)
586 }
587
588 #[must_use]
591 pub fn cache_hit_rate(&self) -> Option<f64> {
592 if self.input_tokens == 0 {
593 return None;
594 }
595 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
596 }
597
598 #[must_use]
600 pub fn cache_summary(&self) -> String {
601 let total_input = self.input_tokens;
602 if total_input == 0 {
603 return "No input tokens recorded.".to_string();
604 }
605
606 let cached = self.cached_input_tokens;
607 let creation = self.cache_creation_tokens;
608 let uncached = self.uncached_input_tokens();
609 let rate = cached as f64 / total_input as f64 * 100.0;
610 format!(
611 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
612 {creation} cache-creation, {uncached} uncached"
613 )
614 }
615
616 pub fn add(&mut self, other: &Usage) {
618 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
619 self.cached_input_tokens = self
620 .cached_input_tokens
621 .saturating_add(other.cached_input_tokens);
622 self.cache_creation_tokens = self
623 .cache_creation_tokens
624 .saturating_add(other.cache_creation_tokens);
625 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
626 }
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
630#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
631pub struct ItemCompletedEvent {
632 pub item: ThreadItem,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
637#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
638pub struct ItemStartedEvent {
639 pub item: ThreadItem,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
644#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
645pub struct ItemUpdatedEvent {
646 pub item: ThreadItem,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
652pub struct PlanDeltaEvent {
653 pub thread_id: String,
655 pub turn_id: String,
657 pub item_id: String,
659 pub delta: String,
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
664#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
665pub struct ThreadItem {
666 pub id: String,
668 #[serde(flatten)]
670 pub details: ThreadItemDetails,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
675#[serde(tag = "type", rename_all = "snake_case")]
676pub enum ThreadItemDetails {
677 AgentMessage(AgentMessageItem),
679 Plan(PlanItem),
681 Reasoning(ReasoningItem),
683 CommandExecution(Box<CommandExecutionItem>),
685 ToolInvocation(ToolInvocationItem),
687 ToolOutput(ToolOutputItem),
689 FileChange(Box<FileChangeItem>),
691 McpToolCall(McpToolCallItem),
693 WebSearch(WebSearchItem),
695 Harness(HarnessEventItem),
697 Error(ErrorItem),
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
702#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
703pub struct AgentMessageItem {
704 pub text: String,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
709#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
710pub struct PlanItem {
711 pub text: String,
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
716#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
717pub struct ReasoningItem {
718 pub text: String,
720 #[serde(skip_serializing_if = "Option::is_none")]
722 pub stage: Option<String>,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
727#[serde(rename_all = "snake_case")]
728pub enum CommandExecutionStatus {
729 #[default]
731 Completed,
732 Failed,
734 InProgress,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
739#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
740pub struct CommandExecutionItem {
741 pub command: String,
743 #[serde(skip_serializing_if = "Option::is_none")]
745 pub arguments: Option<Value>,
746 #[serde(default)]
748 pub aggregated_output: String,
749 #[serde(skip_serializing_if = "Option::is_none")]
751 pub exit_code: Option<i32>,
752 pub status: CommandExecutionStatus,
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
757#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
758#[serde(rename_all = "snake_case")]
759pub enum ToolCallStatus {
760 #[default]
762 Completed,
763 Failed,
765 InProgress,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
770#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
771pub struct ToolInvocationItem {
772 pub tool_name: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
776 pub arguments: Option<Value>,
777 #[serde(skip_serializing_if = "Option::is_none")]
779 pub tool_call_id: Option<String>,
780 pub status: ToolCallStatus,
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
786pub struct ToolOutputItem {
787 pub call_id: String,
789 #[serde(skip_serializing_if = "Option::is_none")]
791 pub tool_call_id: Option<String>,
792 #[serde(skip_serializing_if = "Option::is_none")]
794 pub spool_path: Option<String>,
795 #[serde(default)]
797 pub output: String,
798 #[serde(skip_serializing_if = "Option::is_none")]
800 pub exit_code: Option<i32>,
801 pub status: ToolCallStatus,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
806#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
807pub struct FileChangeItem {
808 pub changes: Vec<FileUpdateChange>,
810 pub status: PatchApplyStatus,
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
815#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
816pub struct FileUpdateChange {
817 pub path: String,
819 pub kind: PatchChangeKind,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
824#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
825#[serde(rename_all = "snake_case")]
826pub enum PatchApplyStatus {
827 Completed,
829 Failed,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
835#[serde(rename_all = "snake_case")]
836pub enum PatchChangeKind {
837 Add,
839 Delete,
841 Update,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
846#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
847pub struct McpToolCallItem {
848 pub tool_name: String,
850 #[serde(skip_serializing_if = "Option::is_none")]
852 pub arguments: Option<Value>,
853 #[serde(skip_serializing_if = "Option::is_none")]
855 pub result: Option<String>,
856 #[serde(skip_serializing_if = "Option::is_none")]
858 pub status: Option<McpToolCallStatus>,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
862#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
863#[serde(rename_all = "snake_case")]
864pub enum McpToolCallStatus {
865 Started,
867 Completed,
869 Failed,
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
874#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
875pub struct WebSearchItem {
876 pub query: String,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub provider: Option<String>,
881 #[serde(skip_serializing_if = "Option::is_none")]
883 pub results: Option<Vec<String>>,
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 HarnessEventKind {
890 PlanningStarted,
891 PlanningCompleted,
892 ContinuationStarted,
893 ContinuationSkipped,
894 BlockedHandoffWritten,
895 EvaluationStarted,
896 EvaluationPassed,
897 EvaluationFailed,
898 RevisionStarted,
899 EscalationTriggered,
900 EscalationBypassed,
901 VerificationStarted,
902 VerificationPassed,
903 VerificationFailed,
904 ErrorRecovered,
906 ToolRetryAttempted,
908 ToolLatencyRecorded,
910 SnapshotCreated,
912 SnapshotRestored,
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
917#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
918pub struct HarnessEventItem {
919 pub event: HarnessEventKind,
921 #[serde(skip_serializing_if = "Option::is_none")]
923 pub message: Option<String>,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 pub command: Option<String>,
927 #[serde(skip_serializing_if = "Option::is_none")]
929 pub path: Option<String>,
930 #[serde(skip_serializing_if = "Option::is_none")]
932 pub exit_code: Option<i32>,
933 #[serde(skip_serializing_if = "Option::is_none")]
935 pub attempt: Option<u32>,
936 #[serde(skip_serializing_if = "Option::is_none")]
938 pub error_category: Option<String>,
939 #[serde(skip_serializing_if = "Option::is_none")]
941 pub duration_ms: Option<u64>,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
946pub struct ErrorItem {
947 pub message: String,
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use std::error::Error;
955
956 #[test]
957 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
958 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
959 usage: Usage {
960 input_tokens: 1,
961 cached_input_tokens: 2,
962 cache_creation_tokens: 0,
963 output_tokens: 3,
964 },
965 });
966
967 let json = serde_json::to_string(&event)?;
968 let restored: ThreadEvent = serde_json::from_str(&json)?;
969
970 assert_eq!(restored, event);
971 Ok(())
972 }
973
974 #[test]
975 fn usage_uncached_input_tokens_saturates() {
976 let usage = Usage {
977 input_tokens: 1_000,
978 cached_input_tokens: 800,
979 cache_creation_tokens: 100,
980 output_tokens: 50,
981 };
982 assert_eq!(usage.uncached_input_tokens(), 100);
983
984 let inconsistent = Usage {
985 input_tokens: 100,
986 cached_input_tokens: 150,
987 cache_creation_tokens: 0,
988 output_tokens: 0,
989 };
990 assert_eq!(inconsistent.uncached_input_tokens(), 0);
991
992 let inconsistent_with_creation = Usage {
993 input_tokens: 100,
994 cached_input_tokens: 80,
995 cache_creation_tokens: 50,
996 output_tokens: 0,
997 };
998 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
999 }
1000
1001 #[test]
1002 fn usage_cache_hit_rate() {
1003 assert_eq!(Usage::default().cache_hit_rate(), None);
1004
1005 let usage = Usage {
1006 input_tokens: 1_000,
1007 cached_input_tokens: 750,
1008 cache_creation_tokens: 0,
1009 output_tokens: 0,
1010 };
1011 let rate = usage.cache_hit_rate().expect("rate");
1012 assert!((rate - 0.75).abs() < f64::EPSILON);
1013 }
1014
1015 #[test]
1016 fn usage_cache_summary_formats() {
1017 assert_eq!(
1018 Usage::default().cache_summary(),
1019 "No input tokens recorded."
1020 );
1021
1022 let usage = Usage {
1023 input_tokens: 1_000,
1024 cached_input_tokens: 800,
1025 cache_creation_tokens: 100,
1026 output_tokens: 50,
1027 };
1028 assert_eq!(
1029 usage.cache_summary(),
1030 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1031 );
1032 }
1033
1034 #[test]
1035 fn usage_add_accumulates_all_fields_with_saturation() {
1036 let mut total = Usage {
1037 input_tokens: 100,
1038 cached_input_tokens: 20,
1039 cache_creation_tokens: 5,
1040 output_tokens: 10,
1041 };
1042 total.add(&Usage {
1043 input_tokens: 50,
1044 cached_input_tokens: 10,
1045 cache_creation_tokens: 2,
1046 output_tokens: 8,
1047 });
1048
1049 assert_eq!(total.input_tokens, 150);
1050 assert_eq!(total.cached_input_tokens, 30);
1051 assert_eq!(total.cache_creation_tokens, 7);
1052 assert_eq!(total.output_tokens, 18);
1053
1054 let mut saturating = Usage {
1055 input_tokens: u64::MAX,
1056 cached_input_tokens: u64::MAX,
1057 cache_creation_tokens: u64::MAX,
1058 output_tokens: u64::MAX,
1059 };
1060 saturating.add(&Usage {
1061 input_tokens: 1,
1062 cached_input_tokens: 1,
1063 cache_creation_tokens: 1,
1064 output_tokens: 1,
1065 });
1066 assert_eq!(saturating.input_tokens, u64::MAX);
1067 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1068 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1069 assert_eq!(saturating.output_tokens, u64::MAX);
1070 }
1071
1072 #[test]
1073 fn versioned_event_wraps_schema_version() {
1074 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
1075 thread_id: "abc".to_string(),
1076 });
1077
1078 let versioned = VersionedThreadEvent::new(event.clone());
1079
1080 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1081 assert_eq!(versioned.event, event);
1082 assert_eq!(versioned.into_event(), event);
1083 }
1084
1085 #[cfg(feature = "serde-json")]
1086 #[test]
1087 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1088 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1089 item: ThreadItem {
1090 id: "item-1".to_string(),
1091 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
1092 text: "hello".to_string(),
1093 }),
1094 },
1095 });
1096
1097 let payload = json::versioned_to_string(&event)?;
1098 let restored = json::versioned_from_str(&payload)?;
1099
1100 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1101 assert_eq!(restored.event, event);
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1107 for trigger in [
1108 CompactionTrigger::Manual,
1109 CompactionTrigger::Auto,
1110 CompactionTrigger::Recovery,
1111 CompactionTrigger::ModelSwitch,
1112 CompactionTrigger::Unknown,
1113 ] {
1114 let json = serde_json::to_string(&trigger).unwrap();
1115 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1116 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1117 assert_eq!(restored, trigger);
1118 }
1119 }
1120
1121 #[test]
1122 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1123 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1124 item: ThreadItem {
1125 id: "tool_1".to_string(),
1126 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1127 tool_name: "read_file".to_string(),
1128 arguments: Some(serde_json::json!({ "path": "README.md" })),
1129 tool_call_id: Some("tool_call_0".to_string()),
1130 status: ToolCallStatus::Completed,
1131 }),
1132 },
1133 });
1134
1135 let json = serde_json::to_string(&event)?;
1136 let restored: ThreadEvent = serde_json::from_str(&json)?;
1137
1138 assert_eq!(restored, event);
1139 Ok(())
1140 }
1141
1142 #[test]
1143 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1144 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1145 item: ThreadItem {
1146 id: "tool_1:output".to_string(),
1147 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1148 call_id: "tool_1".to_string(),
1149 tool_call_id: Some("tool_call_0".to_string()),
1150 spool_path: None,
1151 output: "done".to_string(),
1152 exit_code: Some(0),
1153 status: ToolCallStatus::Completed,
1154 }),
1155 },
1156 });
1157
1158 let json = serde_json::to_string(&event)?;
1159 let restored: ThreadEvent = serde_json::from_str(&json)?;
1160
1161 assert_eq!(restored, event);
1162 Ok(())
1163 }
1164
1165 #[test]
1166 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1167 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1168 item: ThreadItem {
1169 id: "harness_1".to_string(),
1170 details: ThreadItemDetails::Harness(HarnessEventItem {
1171 event: HarnessEventKind::VerificationFailed,
1172 message: Some("cargo check failed".to_string()),
1173 command: Some("cargo check".to_string()),
1174 path: None,
1175 exit_code: Some(101),
1176 attempt: None,
1177 error_category: None,
1178 duration_ms: None,
1179 }),
1180 },
1181 });
1182
1183 let json = serde_json::to_string(&event)?;
1184 let restored: ThreadEvent = serde_json::from_str(&json)?;
1185
1186 assert_eq!(restored, event);
1187 Ok(())
1188 }
1189
1190 #[test]
1191 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1192 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1193 thread_id: "thread-1".to_string(),
1194 session_id: "session-1".to_string(),
1195 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1196 outcome_code: "budget_limit_reached".to_string(),
1197 result: None,
1198 stop_reason: Some("max_tokens".to_string()),
1199 usage: Usage {
1200 input_tokens: 10,
1201 cached_input_tokens: 4,
1202 cache_creation_tokens: 2,
1203 output_tokens: 5,
1204 },
1205 total_cost_usd: serde_json::Number::from_f64(1.25),
1206 num_turns: 3,
1207 });
1208
1209 let json = serde_json::to_string(&event)?;
1210 let restored: ThreadEvent = serde_json::from_str(&json)?;
1211
1212 assert_eq!(restored, event);
1213 Ok(())
1214 }
1215
1216 #[test]
1217 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1218 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1219 thread_id: "thread-1".to_string(),
1220 trigger: CompactionTrigger::Recovery,
1221 mode: CompactionMode::Provider,
1222 original_message_count: 12,
1223 compacted_message_count: 5,
1224 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1225 });
1226
1227 let json = serde_json::to_string(&event)?;
1228 let restored: ThreadEvent = serde_json::from_str(&json)?;
1229
1230 assert_eq!(restored, event);
1231 Ok(())
1232 }
1233}