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]
823pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
824 match status {
825 ToolCallStatus::Completed => ToolOutcome::Success,
826 ToolCallStatus::Failed => ToolOutcome::Error,
827 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
828 }
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
832#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
833pub struct ToolInvocationItem {
834 pub tool_name: String,
836 #[serde(skip_serializing_if = "Option::is_none")]
838 pub arguments: Option<Value>,
839 #[serde(skip_serializing_if = "Option::is_none")]
841 pub tool_call_id: Option<String>,
842 pub status: ToolCallStatus,
844 #[serde(skip_serializing_if = "Option::is_none")]
846 pub outcome: Option<ToolOutcome>,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
850#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
851pub struct ToolOutputItem {
852 pub call_id: String,
854 #[serde(skip_serializing_if = "Option::is_none")]
856 pub tool_call_id: Option<String>,
857 #[serde(skip_serializing_if = "Option::is_none")]
859 pub spool_path: Option<String>,
860 #[serde(default)]
862 pub output: String,
863 #[serde(skip_serializing_if = "Option::is_none")]
865 pub exit_code: Option<i32>,
866 pub status: ToolCallStatus,
868}
869
870#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
871#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
872pub struct FileChangeItem {
873 pub changes: Vec<FileUpdateChange>,
875 pub status: PatchApplyStatus,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881pub struct FileUpdateChange {
882 pub path: String,
884 pub kind: PatchChangeKind,
886}
887
888#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
889#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
890#[serde(rename_all = "snake_case")]
891pub enum PatchApplyStatus {
892 Completed,
894 Failed,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
899#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
900#[serde(rename_all = "snake_case")]
901pub enum PatchChangeKind {
902 Add,
904 Delete,
906 Update,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
911#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
912pub struct McpToolCallItem {
913 pub tool_name: String,
915 #[serde(skip_serializing_if = "Option::is_none")]
917 pub arguments: Option<Value>,
918 #[serde(skip_serializing_if = "Option::is_none")]
920 pub result: Option<String>,
921 #[serde(skip_serializing_if = "Option::is_none")]
923 pub status: Option<McpToolCallStatus>,
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
927#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
928#[serde(rename_all = "snake_case")]
929pub enum McpToolCallStatus {
930 Started,
932 Completed,
934 Failed,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
939#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
940pub struct WebSearchItem {
941 pub query: String,
943 #[serde(skip_serializing_if = "Option::is_none")]
945 pub provider: Option<String>,
946 #[serde(skip_serializing_if = "Option::is_none")]
948 pub results: Option<Vec<String>>,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
952#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
953#[serde(rename_all = "snake_case")]
954pub enum HarnessEventKind {
955 PlanningStarted,
956 PlanningCompleted,
957 ContinuationStarted,
958 ContinuationSkipped,
959 BlockedHandoffWritten,
960 EvaluationStarted,
961 EvaluationPassed,
962 EvaluationFailed,
963 RevisionStarted,
964 EscalationTriggered,
965 EscalationBypassed,
966 VerificationStarted,
967 VerificationPassed,
968 VerificationFailed,
969 ErrorRecovered,
971 ToolRetryAttempted,
973 ToolLatencyRecorded,
975 SnapshotCreated,
977 SnapshotRestored,
979}
980
981#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
982#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
983#[serde(rename_all = "snake_case")]
984pub enum PermissionDecision {
985 Allow,
986 Deny,
987 Cancelled,
988 Followup,
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
992#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
993pub struct PermissionRequestedEvent {
994 pub tool_name: String,
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
999#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1000pub struct PermissionResolvedEvent {
1001 pub tool_name: String,
1003 pub decision: PermissionDecision,
1005 pub wait_ms: u64,
1007}
1008
1009#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1010#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1011#[serde(rename_all = "snake_case")]
1012pub enum InterjectionSource {
1013 Direct,
1014 Queue,
1015}
1016
1017#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1019#[serde(rename_all = "snake_case")]
1020pub enum RedirectKind {
1021 Interjection,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1025#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1026pub struct InterjectedEvent {
1027 pub source: InterjectionSource,
1029 pub image_count: u32,
1031 pub redirect_kind: RedirectKind,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1037#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1038pub struct HarnessEventItem {
1039 pub event: HarnessEventKind,
1041 #[serde(skip_serializing_if = "Option::is_none")]
1043 pub message: Option<String>,
1044 #[serde(skip_serializing_if = "Option::is_none")]
1046 pub command: Option<String>,
1047 #[serde(skip_serializing_if = "Option::is_none")]
1049 pub path: Option<String>,
1050 #[serde(skip_serializing_if = "Option::is_none")]
1052 pub exit_code: Option<i32>,
1053 #[serde(skip_serializing_if = "Option::is_none")]
1055 pub attempt: Option<u32>,
1056 #[serde(skip_serializing_if = "Option::is_none")]
1058 pub error_category: Option<String>,
1059 #[serde(skip_serializing_if = "Option::is_none")]
1061 pub duration_ms: Option<u64>,
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1066pub struct ErrorItem {
1067 pub message: String,
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074 use std::error::Error;
1075
1076 #[test]
1077 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1078 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1079 usage: Usage {
1080 input_tokens: 1,
1081 cached_input_tokens: 2,
1082 cache_creation_tokens: 0,
1083 output_tokens: 3,
1084 },
1085 });
1086
1087 let json = serde_json::to_string(&event)?;
1088 let restored: ThreadEvent = serde_json::from_str(&json)?;
1089
1090 assert_eq!(restored, event);
1091 Ok(())
1092 }
1093
1094 #[test]
1095 fn usage_uncached_input_tokens_saturates() {
1096 let usage = Usage {
1097 input_tokens: 1_000,
1098 cached_input_tokens: 800,
1099 cache_creation_tokens: 100,
1100 output_tokens: 50,
1101 };
1102 assert_eq!(usage.uncached_input_tokens(), 100);
1103
1104 let inconsistent = Usage {
1105 input_tokens: 100,
1106 cached_input_tokens: 150,
1107 cache_creation_tokens: 0,
1108 output_tokens: 0,
1109 };
1110 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1111
1112 let inconsistent_with_creation = Usage {
1113 input_tokens: 100,
1114 cached_input_tokens: 80,
1115 cache_creation_tokens: 50,
1116 output_tokens: 0,
1117 };
1118 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1119 }
1120
1121 #[test]
1122 fn usage_cache_hit_rate() {
1123 assert_eq!(Usage::default().cache_hit_rate(), None);
1124
1125 let usage = Usage {
1126 input_tokens: 1_000,
1127 cached_input_tokens: 750,
1128 cache_creation_tokens: 0,
1129 output_tokens: 0,
1130 };
1131 let rate = usage.cache_hit_rate().expect("rate");
1132 assert!((rate - 0.75).abs() < f64::EPSILON);
1133 }
1134
1135 #[test]
1136 fn usage_cache_summary_formats() {
1137 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1138
1139 let usage = Usage {
1140 input_tokens: 1_000,
1141 cached_input_tokens: 800,
1142 cache_creation_tokens: 100,
1143 output_tokens: 50,
1144 };
1145 assert_eq!(
1146 usage.cache_summary(),
1147 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1148 );
1149 }
1150
1151 #[test]
1152 fn usage_add_accumulates_all_fields_with_saturation() {
1153 let mut total = Usage {
1154 input_tokens: 100,
1155 cached_input_tokens: 20,
1156 cache_creation_tokens: 5,
1157 output_tokens: 10,
1158 };
1159 total.add(&Usage {
1160 input_tokens: 50,
1161 cached_input_tokens: 10,
1162 cache_creation_tokens: 2,
1163 output_tokens: 8,
1164 });
1165
1166 assert_eq!(total.input_tokens, 150);
1167 assert_eq!(total.cached_input_tokens, 30);
1168 assert_eq!(total.cache_creation_tokens, 7);
1169 assert_eq!(total.output_tokens, 18);
1170
1171 let mut saturating = Usage {
1172 input_tokens: u64::MAX,
1173 cached_input_tokens: u64::MAX,
1174 cache_creation_tokens: u64::MAX,
1175 output_tokens: u64::MAX,
1176 };
1177 saturating.add(&Usage {
1178 input_tokens: 1,
1179 cached_input_tokens: 1,
1180 cache_creation_tokens: 1,
1181 output_tokens: 1,
1182 });
1183 assert_eq!(saturating.input_tokens, u64::MAX);
1184 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1185 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1186 assert_eq!(saturating.output_tokens, u64::MAX);
1187 }
1188
1189 #[test]
1190 fn versioned_event_wraps_schema_version() {
1191 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1192
1193 let versioned = VersionedThreadEvent::new(event.clone());
1194
1195 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1196 assert_eq!(versioned.event, event);
1197 assert_eq!(versioned.into_event(), event);
1198 }
1199
1200 #[cfg(feature = "serde-json")]
1201 #[test]
1202 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1203 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1204 item: ThreadItem {
1205 id: "item-1".to_string(),
1206 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1207 },
1208 });
1209
1210 let payload = json::versioned_to_string(&event)?;
1211 let restored = json::versioned_from_str(&payload)?;
1212
1213 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1214 assert_eq!(restored.event, event);
1215 Ok(())
1216 }
1217
1218 #[test]
1219 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1220 for trigger in [
1221 CompactionTrigger::Manual,
1222 CompactionTrigger::Auto,
1223 CompactionTrigger::Recovery,
1224 CompactionTrigger::ModelSwitch,
1225 CompactionTrigger::Unknown,
1226 ] {
1227 let json = serde_json::to_string(&trigger).unwrap();
1228 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1229 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1230 assert_eq!(restored, trigger);
1231 }
1232 }
1233
1234 #[test]
1235 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1236 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1237 item: ThreadItem {
1238 id: "tool_1".to_string(),
1239 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1240 tool_name: "read_file".to_string(),
1241 arguments: Some(serde_json::json!({ "path": "README.md" })),
1242 tool_call_id: Some("tool_call_0".to_string()),
1243 status: ToolCallStatus::Completed,
1244 outcome: None,
1245 }),
1246 },
1247 });
1248
1249 let json = serde_json::to_string(&event)?;
1250 let restored: ThreadEvent = serde_json::from_str(&json)?;
1251
1252 assert_eq!(restored, event);
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn tool_outcome_serializes_snake_case() {
1258 for outcome in [
1259 ToolOutcome::Success,
1260 ToolOutcome::Error,
1261 ToolOutcome::PermissionRejected,
1262 ToolOutcome::PermissionCancelled,
1263 ToolOutcome::Followup,
1264 ToolOutcome::HookDenied,
1265 ToolOutcome::InvalidTool,
1266 ToolOutcome::Cancelled,
1267 ] {
1268 let json = serde_json::to_string(&outcome).unwrap();
1269 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1270 assert_eq!(restored, outcome);
1271 }
1272 }
1273
1274 #[test]
1275 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1276 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1277 item: ThreadItem {
1278 id: "tool_1".to_string(),
1279 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
1280 tool_name: "exec_command".to_string(),
1281 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1282 tool_call_id: Some("tool_call_0".to_string()),
1283 status: ToolCallStatus::Failed,
1284 outcome: Some(ToolOutcome::PermissionRejected),
1285 }),
1286 },
1287 });
1288
1289 let json = serde_json::to_string(&event)?;
1290 let restored: ThreadEvent = serde_json::from_str(&json)?;
1291
1292 assert_eq!(restored, event);
1293 Ok(())
1294 }
1295
1296 #[test]
1297 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1298 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1299 item: ThreadItem {
1300 id: "tool_1:output".to_string(),
1301 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
1302 call_id: "tool_1".to_string(),
1303 tool_call_id: Some("tool_call_0".to_string()),
1304 spool_path: None,
1305 output: "done".to_string(),
1306 exit_code: Some(0),
1307 status: ToolCallStatus::Completed,
1308 }),
1309 },
1310 });
1311
1312 let json = serde_json::to_string(&event)?;
1313 let restored: ThreadEvent = serde_json::from_str(&json)?;
1314
1315 assert_eq!(restored, event);
1316 Ok(())
1317 }
1318
1319 #[test]
1320 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1321 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1322 item: ThreadItem {
1323 id: "harness_1".to_string(),
1324 details: ThreadItemDetails::Harness(HarnessEventItem {
1325 event: HarnessEventKind::VerificationFailed,
1326 message: Some("cargo check failed".to_string()),
1327 command: Some("cargo check".to_string()),
1328 path: None,
1329 exit_code: Some(101),
1330 attempt: None,
1331 error_category: None,
1332 duration_ms: None,
1333 }),
1334 },
1335 });
1336
1337 let json = serde_json::to_string(&event)?;
1338 let restored: ThreadEvent = serde_json::from_str(&json)?;
1339
1340 assert_eq!(restored, event);
1341 Ok(())
1342 }
1343
1344 #[test]
1345 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1346 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1347 thread_id: "thread-1".to_string(),
1348 session_id: "session-1".to_string(),
1349 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1350 outcome_code: "budget_limit_reached".to_string(),
1351 result: None,
1352 stop_reason: Some("max_tokens".to_string()),
1353 usage: Usage {
1354 input_tokens: 10,
1355 cached_input_tokens: 4,
1356 cache_creation_tokens: 2,
1357 output_tokens: 5,
1358 },
1359 total_cost_usd: serde_json::Number::from_f64(1.25),
1360 num_turns: 3,
1361 });
1362
1363 let json = serde_json::to_string(&event)?;
1364 let restored: ThreadEvent = serde_json::from_str(&json)?;
1365
1366 assert_eq!(restored, event);
1367 Ok(())
1368 }
1369
1370 #[test]
1371 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1372 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1373 thread_id: "thread-1".to_string(),
1374 trigger: CompactionTrigger::Recovery,
1375 mode: CompactionMode::Provider,
1376 original_message_count: 12,
1377 compacted_message_count: 5,
1378 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1379 });
1380
1381 let json = serde_json::to_string(&event)?;
1382 let restored: ThreadEvent = serde_json::from_str(&json)?;
1383
1384 assert_eq!(restored, event);
1385 Ok(())
1386 }
1387}