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!(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> {
242 tracer: T,
243 }
244
245 impl<T: Tracer> OtelEmitter<T> {
246 pub fn new(tracer: T) -> Self {
247 Self { tracer }
248 }
249 }
250
251 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
252 fn emit(&mut self, event: &ThreadEvent) {
253 let span_name = match event {
254 ThreadEvent::ThreadStarted(_) => "thread.started",
255 ThreadEvent::ThreadCompleted(_) => "thread.completed",
256 ThreadEvent::TurnStarted(_) => "turn.started",
257 ThreadEvent::TurnCompleted(_) => "turn.completed",
258 ThreadEvent::TurnFailed(_) => "turn.failed",
259 ThreadEvent::ItemStarted(_) => "item.started",
260 ThreadEvent::ItemUpdated(_) => "item.updated",
261 ThreadEvent::ItemCompleted(_) => "item.completed",
262 ThreadEvent::Error(_) => "error",
263 _ => "event",
264 };
265
266 let mut span = self.tracer.start(span_name);
267
268 match event {
269 ThreadEvent::ThreadStarted(e) => {
270 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
271 }
272 ThreadEvent::ThreadCompleted(e) => {
273 if let Some(ref cost) = e.total_cost_usd {
274 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
275 }
276 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
277 span.set_attribute(KeyValue::new("output_tokens", e.usage.output_tokens as i64));
278 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
279 }
280 ThreadEvent::TurnCompleted(e) => {
281 span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
282 span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
283 }
284 ThreadEvent::ItemCompleted(e) => {
285 if let ThreadItemDetails::Harness(harness) = &e.item.details {
286 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
287 if let Some(ref msg) = harness.message {
288 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
289 }
290 if let Some(ref path) = harness.path {
291 span.set_attribute(KeyValue::new("harness_path", path.clone()));
292 }
293 if let Some(dur) = harness.duration_ms {
294 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
295 }
296 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
297 if let Some(ref msg) = harness.message {
298 event_attrs.push(KeyValue::new("message", msg.clone()));
299 }
300 span.add_event("harness_event", event_attrs);
301 }
302 }
303 ThreadEvent::Error(e) => {
304 span.set_status(Status::Error { description: e.message.clone().into() });
305 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
306 }
307 _ => {}
308 }
309
310 span.end();
311 }
312 }
313
314 pub use OtelEmitter as PublicOtelEmitter;
315}
316
317#[cfg(feature = "telemetry-otel")]
318pub use otel_support::PublicOtelEmitter as OtelEmitter;
319
320#[cfg(feature = "schema-export")]
321pub mod schema {
322 use schemars::{Schema, schema_for};
323
324 use super::{ThreadEvent, VersionedThreadEvent};
325
326 pub fn thread_event_schema() -> Schema {
328 schema_for!(ThreadEvent)
329 }
330
331 pub fn versioned_thread_event_schema() -> Schema {
333 schema_for!(VersionedThreadEvent)
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
340#[serde(tag = "type")]
341pub enum ThreadEvent {
342 #[serde(rename = "thread.started")]
344 ThreadStarted(ThreadStartedEvent),
345 #[serde(rename = "thread.completed")]
347 ThreadCompleted(ThreadCompletedEvent),
348 #[serde(rename = "thread.compact_boundary")]
350 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
351 #[serde(rename = "turn.started")]
353 TurnStarted(TurnStartedEvent),
354 #[serde(rename = "turn.completed")]
356 TurnCompleted(TurnCompletedEvent),
357 #[serde(rename = "turn.failed")]
359 TurnFailed(TurnFailedEvent),
360 #[serde(rename = "item.started")]
362 ItemStarted(ItemStartedEvent),
363 #[serde(rename = "item.updated")]
365 ItemUpdated(ItemUpdatedEvent),
366 #[serde(rename = "item.completed")]
368 ItemCompleted(ItemCompletedEvent),
369 #[serde(rename = "permission.requested")]
371 PermissionRequested(PermissionRequestedEvent),
372 #[serde(rename = "permission.resolved")]
374 PermissionResolved(PermissionResolvedEvent),
375 #[serde(rename = "interjected")]
377 Interjected(InterjectedEvent),
378 #[serde(rename = "plan.delta")]
380 PlanDelta(PlanDeltaEvent),
381 #[serde(rename = "error")]
383 Error(ThreadErrorEvent),
384 #[serde(other)]
387 Unknown,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
392pub struct ThreadStartedEvent {
393 pub thread_id: String,
395}
396
397#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
398#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
399#[serde(rename_all = "snake_case")]
400pub enum ThreadCompletionSubtype {
401 Success,
402 ErrorMaxTurns,
403 ErrorMaxBudgetUsd,
404 ErrorDuringExecution,
405 Cancelled,
406 #[serde(other)]
408 Unknown,
409}
410
411impl ThreadCompletionSubtype {
412 pub const fn as_str(&self) -> &'static str {
413 match self {
414 Self::Success => "success",
415 Self::ErrorMaxTurns => "error_max_turns",
416 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
417 Self::ErrorDuringExecution => "error_during_execution",
418 Self::Cancelled => "cancelled",
419 Self::Unknown => "unknown",
420 }
421 }
422
423 pub const fn is_success(self) -> bool {
424 matches!(self, Self::Success)
425 }
426}
427
428#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
429#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
430#[serde(rename_all = "snake_case")]
431pub enum CompactionTrigger {
432 Manual,
433 Auto,
434 Recovery,
435 ModelSwitch,
438 #[serde(other)]
440 Unknown,
441}
442
443impl CompactionTrigger {
444 pub const fn as_str(self) -> &'static str {
445 match self {
446 Self::Manual => "manual",
447 Self::Auto => "auto",
448 Self::Recovery => "recovery",
449 Self::ModelSwitch => "model_switch",
450 Self::Unknown => "unknown",
451 }
452 }
453}
454
455#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
457#[serde(rename_all = "snake_case")]
458pub enum CompactionMode {
459 Provider,
460 Local,
461 #[serde(other)]
463 Unknown,
464}
465
466impl CompactionMode {
467 pub const fn as_str(self) -> &'static str {
468 match self {
469 Self::Provider => "provider",
470 Self::Local => "local",
471 Self::Unknown => "unknown",
472 }
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
477#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
478pub struct ThreadCompletedEvent {
479 pub thread_id: String,
481 pub session_id: String,
483 pub subtype: ThreadCompletionSubtype,
485 pub outcome_code: String,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub result: Option<String>,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub stop_reason: Option<String>,
493 pub usage: Usage,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub total_cost_usd: Option<serde_json::Number>,
498 pub num_turns: usize,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
504pub struct ThreadCompactBoundaryEvent {
505 pub thread_id: String,
507 pub trigger: CompactionTrigger,
509 pub mode: CompactionMode,
511 pub original_message_count: usize,
513 pub compacted_message_count: usize,
515 #[serde(skip_serializing_if = "Option::is_none")]
517 pub history_artifact_path: Option<String>,
518}
519
520#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
521#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
522pub struct TurnStartedEvent {
523 #[serde(skip_serializing_if = "Option::is_none")]
527 pub token_breakdown: Option<TokenBreakdown>,
528}
529
530#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
533pub struct TokenBreakdown {
534 pub system_prompt_tokens: u64,
536 pub tool_schema_tokens: u64,
538 pub instruction_file_tokens: u64,
540 pub message_history_tokens: u64,
542 pub cache_read_tokens: u64,
544 pub cache_write_tokens: u64,
546 pub cache_miss_tokens: u64,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 pub subagent_bootstrap_tokens: Option<u64>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
554#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
555pub struct TurnCompletedEvent {
556 pub usage: Usage,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
561#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
562pub struct TurnFailedEvent {
563 pub message: String,
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub usage: Option<Usage>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
572pub struct ThreadErrorEvent {
573 pub message: String,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
579pub struct Usage {
580 pub input_tokens: u64,
582 pub cached_input_tokens: u64,
584 pub cache_creation_tokens: u64,
586 pub output_tokens: u64,
588}
589
590impl Usage {
591 #[must_use]
596 pub fn uncached_input_tokens(&self) -> u64 {
597 self.input_tokens
598 .saturating_sub(self.cached_input_tokens)
599 .saturating_sub(self.cache_creation_tokens)
600 }
601
602 #[must_use]
605 pub fn cache_hit_rate(&self) -> Option<f64> {
606 if self.input_tokens == 0 {
607 return None;
608 }
609 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
610 }
611
612 #[must_use]
614 pub fn cache_summary(&self) -> String {
615 let total_input = self.input_tokens;
616 if total_input == 0 {
617 return "No input tokens recorded.".to_string();
618 }
619
620 let cached = self.cached_input_tokens;
621 let creation = self.cache_creation_tokens;
622 let uncached = self.uncached_input_tokens();
623 let rate = cached as f64 / total_input as f64 * 100.0;
624 format!(
625 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
626 {creation} cache-creation, {uncached} uncached"
627 )
628 }
629
630 pub fn add(&mut self, other: &Usage) {
632 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
633 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
634 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
635 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
636 }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
640#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
641pub struct ItemCompletedEvent {
642 pub item: ThreadItem,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
648pub struct ItemStartedEvent {
649 pub item: ThreadItem,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
654#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
655pub struct ItemUpdatedEvent {
656 pub item: ThreadItem,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
661#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
662pub struct PlanDeltaEvent {
663 pub thread_id: String,
665 pub turn_id: String,
667 pub item_id: String,
669 pub delta: String,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
675pub struct ThreadItem {
676 pub id: String,
678 #[serde(flatten)]
680 pub details: ThreadItemDetails,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
684#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
685#[serde(tag = "type", rename_all = "snake_case")]
686pub enum ThreadItemDetails {
687 AgentMessage(AgentMessageItem),
689 Plan(PlanItem),
691 Reasoning(ReasoningItem),
693 CommandExecution(Box<CommandExecutionItem>),
695 ToolInvocation(ToolInvocationItem),
697 ToolOutput(ToolOutputItem),
699 FileChange(Box<FileChangeItem>),
701 McpToolCall(McpToolCallItem),
703 WebSearch(WebSearchItem),
705 Harness(HarnessEventItem),
707 Error(ErrorItem),
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct AgentMessageItem {
714 pub text: String,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
719#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
720pub struct PlanItem {
721 pub text: String,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
727pub struct ReasoningItem {
728 pub text: String,
730 #[serde(skip_serializing_if = "Option::is_none")]
732 pub stage: Option<String>,
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
737#[serde(rename_all = "snake_case")]
738pub enum CommandExecutionStatus {
739 #[default]
741 Completed,
742 Failed,
744 InProgress,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
750pub struct CommandExecutionItem {
751 pub command: String,
753 #[serde(skip_serializing_if = "Option::is_none")]
755 pub arguments: Option<Value>,
756 #[serde(default)]
758 pub aggregated_output: String,
759 #[serde(skip_serializing_if = "Option::is_none")]
761 pub exit_code: Option<i32>,
762 pub status: CommandExecutionStatus,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
767#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
768#[serde(rename_all = "snake_case")]
769pub enum ToolCallStatus {
770 #[default]
772 Completed,
773 Failed,
775 InProgress,
777}
778
779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
787#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
788#[serde(rename_all = "snake_case")]
789pub enum ToolOutcome {
790 #[default]
792 Success,
793 Error,
795 PermissionRejected,
797 PermissionCancelled,
799 Followup,
801 HookDenied,
803 InvalidTool,
805 Cancelled,
807}
808
809impl ToolOutcome {
810 #[must_use]
811 pub const fn is_terminal(self) -> bool {
812 !matches!(self, Self::Followup)
813 }
814}
815
816#[must_use]
823#[allow(clippy::unreachable)]
824pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
825 match status {
826 ToolCallStatus::Completed => ToolOutcome::Success,
827 ToolCallStatus::Failed => ToolOutcome::Error,
828 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
829 }
830}
831
832#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
834pub struct ToolInvocationItem {
835 pub tool_name: String,
837 #[serde(skip_serializing_if = "Option::is_none")]
839 pub arguments: Option<Value>,
840 #[serde(skip_serializing_if = "Option::is_none")]
842 pub tool_call_id: Option<String>,
843 pub status: ToolCallStatus,
845 #[serde(skip_serializing_if = "Option::is_none")]
847 pub outcome: Option<ToolOutcome>,
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
851#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
852pub struct ToolOutputItem {
853 pub call_id: String,
855 #[serde(skip_serializing_if = "Option::is_none")]
857 pub tool_call_id: Option<String>,
858 #[serde(skip_serializing_if = "Option::is_none")]
860 pub spool_path: Option<String>,
861 #[serde(default)]
863 pub output: String,
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub exit_code: Option<i32>,
867 pub status: ToolCallStatus,
869}
870
871#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
872#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
873pub struct FileChangeItem {
874 pub changes: Vec<FileUpdateChange>,
876 pub status: PatchApplyStatus,
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
881#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
882pub struct FileUpdateChange {
883 pub path: String,
885 pub kind: PatchChangeKind,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
890#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
891#[serde(rename_all = "snake_case")]
892pub enum PatchApplyStatus {
893 Completed,
895 Failed,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
900#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
901#[serde(rename_all = "snake_case")]
902pub enum PatchChangeKind {
903 Add,
905 Delete,
907 Update,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
913pub struct McpToolCallItem {
914 pub tool_name: String,
916 #[serde(skip_serializing_if = "Option::is_none")]
918 pub arguments: Option<Value>,
919 #[serde(skip_serializing_if = "Option::is_none")]
921 pub result: Option<String>,
922 #[serde(skip_serializing_if = "Option::is_none")]
924 pub status: Option<McpToolCallStatus>,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
928#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
929#[serde(rename_all = "snake_case")]
930pub enum McpToolCallStatus {
931 Started,
933 Completed,
935 Failed,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
940#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
941pub struct WebSearchItem {
942 pub query: String,
944 #[serde(skip_serializing_if = "Option::is_none")]
946 pub provider: Option<String>,
947 #[serde(skip_serializing_if = "Option::is_none")]
949 pub results: Option<Vec<String>>,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
954#[serde(rename_all = "snake_case")]
955pub enum HarnessEventKind {
956 PlanningStarted,
957 PlanningCompleted,
958 ContinuationStarted,
959 ContinuationSkipped,
960 BlockedHandoffWritten,
961 EvaluationStarted,
962 EvaluationPassed,
963 EvaluationFailed,
964 RevisionStarted,
965 EscalationTriggered,
966 EscalationBypassed,
967 VerificationStarted,
968 VerificationPassed,
969 VerificationFailed,
970 ErrorRecovered,
972 ToolRetryAttempted,
974 ToolLatencyRecorded,
976 SnapshotCreated,
978 SnapshotRestored,
980}
981
982#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
983#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
984#[serde(rename_all = "snake_case")]
985pub enum PermissionDecision {
986 Allow,
987 Deny,
988 Cancelled,
989 Followup,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
994pub struct PermissionRequestedEvent {
995 pub tool_name: String,
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1000#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1001pub struct PermissionResolvedEvent {
1002 pub tool_name: String,
1004 pub decision: PermissionDecision,
1006 pub wait_ms: u64,
1008}
1009
1010#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1011#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1012#[serde(rename_all = "snake_case")]
1013pub enum InterjectionSource {
1014 Direct,
1015 Queue,
1016}
1017
1018#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1019#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1020#[serde(rename_all = "snake_case")]
1021pub enum RedirectKind {
1022 Interjection,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1026#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1027pub struct InterjectedEvent {
1028 pub source: InterjectionSource,
1030 pub image_count: u32,
1032 pub redirect_kind: RedirectKind,
1035}
1036
1037#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1038#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1039pub struct HarnessEventItem {
1040 pub event: HarnessEventKind,
1042 #[serde(skip_serializing_if = "Option::is_none")]
1044 pub message: Option<String>,
1045 #[serde(skip_serializing_if = "Option::is_none")]
1047 pub command: Option<String>,
1048 #[serde(skip_serializing_if = "Option::is_none")]
1050 pub path: Option<String>,
1051 #[serde(skip_serializing_if = "Option::is_none")]
1053 pub exit_code: Option<i32>,
1054 #[serde(skip_serializing_if = "Option::is_none")]
1056 pub attempt: Option<u32>,
1057 #[serde(skip_serializing_if = "Option::is_none")]
1059 pub error_category: Option<String>,
1060 #[serde(skip_serializing_if = "Option::is_none")]
1062 pub duration_ms: Option<u64>,
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1067pub struct ErrorItem {
1068 pub message: String,
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use std::error::Error;
1076
1077 #[test]
1078 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1079 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1080 usage: Usage {
1081 input_tokens: 1,
1082 cached_input_tokens: 2,
1083 cache_creation_tokens: 0,
1084 output_tokens: 3,
1085 },
1086 });
1087
1088 let json = serde_json::to_string(&event)?;
1089 let restored: ThreadEvent = serde_json::from_str(&json)?;
1090
1091 assert_eq!(restored, event);
1092 Ok(())
1093 }
1094
1095 #[test]
1096 fn usage_uncached_input_tokens_saturates() {
1097 let usage = Usage {
1098 input_tokens: 1_000,
1099 cached_input_tokens: 800,
1100 cache_creation_tokens: 100,
1101 output_tokens: 50,
1102 };
1103 assert_eq!(usage.uncached_input_tokens(), 100);
1104
1105 let inconsistent = Usage {
1106 input_tokens: 100,
1107 cached_input_tokens: 150,
1108 cache_creation_tokens: 0,
1109 output_tokens: 0,
1110 };
1111 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1112
1113 let inconsistent_with_creation = Usage {
1114 input_tokens: 100,
1115 cached_input_tokens: 80,
1116 cache_creation_tokens: 50,
1117 output_tokens: 0,
1118 };
1119 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1120 }
1121
1122 #[test]
1123 fn usage_cache_hit_rate() {
1124 assert_eq!(Usage::default().cache_hit_rate(), None);
1125
1126 let usage = Usage {
1127 input_tokens: 1_000,
1128 cached_input_tokens: 750,
1129 cache_creation_tokens: 0,
1130 output_tokens: 0,
1131 };
1132 let rate = usage.cache_hit_rate().expect("rate");
1133 assert!((rate - 0.75).abs() < f64::EPSILON);
1134 }
1135
1136 #[test]
1137 fn usage_cache_summary_formats() {
1138 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1139
1140 let usage = Usage {
1141 input_tokens: 1_000,
1142 cached_input_tokens: 800,
1143 cache_creation_tokens: 100,
1144 output_tokens: 50,
1145 };
1146 assert_eq!(
1147 usage.cache_summary(),
1148 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1149 );
1150 }
1151
1152 #[test]
1153 fn usage_add_accumulates_all_fields_with_saturation() {
1154 let mut total = Usage {
1155 input_tokens: 100,
1156 cached_input_tokens: 20,
1157 cache_creation_tokens: 5,
1158 output_tokens: 10,
1159 };
1160 total.add(&Usage {
1161 input_tokens: 50,
1162 cached_input_tokens: 10,
1163 cache_creation_tokens: 2,
1164 output_tokens: 8,
1165 });
1166
1167 assert_eq!(total.input_tokens, 150);
1168 assert_eq!(total.cached_input_tokens, 30);
1169 assert_eq!(total.cache_creation_tokens, 7);
1170 assert_eq!(total.output_tokens, 18);
1171
1172 let mut saturating = Usage {
1173 input_tokens: u64::MAX,
1174 cached_input_tokens: u64::MAX,
1175 cache_creation_tokens: u64::MAX,
1176 output_tokens: u64::MAX,
1177 };
1178 saturating.add(&Usage {
1179 input_tokens: 1,
1180 cached_input_tokens: 1,
1181 cache_creation_tokens: 1,
1182 output_tokens: 1,
1183 });
1184 assert_eq!(saturating.input_tokens, u64::MAX);
1185 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1186 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1187 assert_eq!(saturating.output_tokens, u64::MAX);
1188 }
1189
1190 #[test]
1191 fn versioned_event_wraps_schema_version() {
1192 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1193
1194 let versioned = VersionedThreadEvent::new(event.clone());
1195
1196 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1197 assert_eq!(versioned.event, event);
1198 assert_eq!(versioned.into_event(), event);
1199 }
1200
1201 #[cfg(feature = "serde-json")]
1202 #[test]
1203 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1204 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1205 item: ThreadItem {
1206 id: "item-1".to_string(),
1207 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1208 },
1209 });
1210
1211 let payload = json::versioned_to_string(&event)?;
1212 let restored = json::versioned_from_str(&payload)?;
1213
1214 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1215 assert_eq!(restored.event, event);
1216 Ok(())
1217 }
1218
1219 #[test]
1220 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1221 for trigger in [
1222 CompactionTrigger::Manual,
1223 CompactionTrigger::Auto,
1224 CompactionTrigger::Recovery,
1225 CompactionTrigger::ModelSwitch,
1226 CompactionTrigger::Unknown,
1227 ] {
1228 let json = serde_json::to_string(&trigger).unwrap();
1229 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1230 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1231 assert_eq!(restored, trigger);
1232 }
1233 }
1234
1235 #[test]
1236 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1237 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1238 item: ThreadItem {
1239 id: "tool_1".to_string(),
1240 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1241 tool_name: "read_file".to_string(),
1242 arguments: Some(serde_json::json!({ "path": "README.md" })),
1243 tool_call_id: Some("tool_call_0".to_string()),
1244 status: ToolCallStatus::Completed,
1245 outcome: None,
1246 }),
1247 },
1248 });
1249
1250 let json = serde_json::to_string(&event)?;
1251 let restored: ThreadEvent = serde_json::from_str(&json)?;
1252
1253 assert_eq!(restored, event);
1254 Ok(())
1255 }
1256
1257 #[test]
1258 fn tool_outcome_serializes_snake_case() {
1259 for outcome in [
1260 ToolOutcome::Success,
1261 ToolOutcome::Error,
1262 ToolOutcome::PermissionRejected,
1263 ToolOutcome::PermissionCancelled,
1264 ToolOutcome::Followup,
1265 ToolOutcome::HookDenied,
1266 ToolOutcome::InvalidTool,
1267 ToolOutcome::Cancelled,
1268 ] {
1269 let json = serde_json::to_string(&outcome).unwrap();
1270 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1271 assert_eq!(restored, outcome);
1272 }
1273 }
1274
1275 #[test]
1276 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1277 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1278 item: ThreadItem {
1279 id: "tool_1".to_string(),
1280 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1281 tool_name: "exec_command".to_string(),
1282 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1283 tool_call_id: Some("tool_call_0".to_string()),
1284 status: ToolCallStatus::Failed,
1285 outcome: Some(ToolOutcome::PermissionRejected),
1286 }),
1287 },
1288 });
1289
1290 let json = serde_json::to_string(&event)?;
1291 let restored: ThreadEvent = serde_json::from_str(&json)?;
1292
1293 assert_eq!(restored, event);
1294 Ok(())
1295 }
1296
1297 #[test]
1298 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1299 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1300 item: ThreadItem {
1301 id: "tool_1:output".to_string(),
1302 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1303 call_id: "tool_1".to_string(),
1304 tool_call_id: Some("tool_call_0".to_string()),
1305 spool_path: None,
1306 output: "done".to_string(),
1307 exit_code: Some(0),
1308 status: ToolCallStatus::Completed,
1309 }),
1310 },
1311 });
1312
1313 let json = serde_json::to_string(&event)?;
1314 let restored: ThreadEvent = serde_json::from_str(&json)?;
1315
1316 assert_eq!(restored, event);
1317 Ok(())
1318 }
1319
1320 #[test]
1321 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1322 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1323 item: ThreadItem {
1324 id: "harness_1".to_string(),
1325 details: ThreadItemDetails::Harness(HarnessEventItem {
1326 event: HarnessEventKind::VerificationFailed,
1327 message: Some("cargo check failed".to_string()),
1328 command: Some("cargo check".to_string()),
1329 path: None,
1330 exit_code: Some(101),
1331 attempt: None,
1332 error_category: None,
1333 duration_ms: None,
1334 }),
1335 },
1336 });
1337
1338 let json = serde_json::to_string(&event)?;
1339 let restored: ThreadEvent = serde_json::from_str(&json)?;
1340
1341 assert_eq!(restored, event);
1342 Ok(())
1343 }
1344
1345 #[test]
1346 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1347 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1348 thread_id: "thread-1".to_string(),
1349 session_id: "session-1".to_string(),
1350 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1351 outcome_code: "budget_limit_reached".to_string(),
1352 result: None,
1353 stop_reason: Some("max_tokens".to_string()),
1354 usage: Usage {
1355 input_tokens: 10,
1356 cached_input_tokens: 4,
1357 cache_creation_tokens: 2,
1358 output_tokens: 5,
1359 },
1360 total_cost_usd: serde_json::Number::from_f64(1.25),
1361 num_turns: 3,
1362 });
1363
1364 let json = serde_json::to_string(&event)?;
1365 let restored: ThreadEvent = serde_json::from_str(&json)?;
1366
1367 assert_eq!(restored, event);
1368 Ok(())
1369 }
1370
1371 #[test]
1372 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1373 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1374 thread_id: "thread-1".to_string(),
1375 trigger: CompactionTrigger::Recovery,
1376 mode: CompactionMode::Provider,
1377 original_message_count: 12,
1378 compacted_message_count: 5,
1379 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1380 });
1381
1382 let json = serde_json::to_string(&event)?;
1383 let restored: ThreadEvent = serde_json::from_str(&json)?;
1384
1385 assert_eq!(restored, event);
1386 Ok(())
1387 }
1388}