1#![allow(missing_docs, dead_code, unused_imports)]
2use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub mod atif;
19pub mod trace;
20
21pub const EVENT_SCHEMA_VERSION: &str = "0.8.0";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
28pub struct VersionedThreadEvent {
29 schema_version: String,
31 event: ThreadEvent,
33}
34
35impl VersionedThreadEvent {
36 pub fn new(event: ThreadEvent) -> Self {
39 Self {
40 schema_version: EVENT_SCHEMA_VERSION.to_string(),
41 event,
42 }
43 }
44
45 pub fn into_event(self) -> ThreadEvent {
47 self.event
48 }
49}
50
51impl From<ThreadEvent> for VersionedThreadEvent {
52 fn from(event: ThreadEvent) -> Self {
53 Self::new(event)
54 }
55}
56
57pub trait EventEmitter {
59 fn emit(&mut self, event: &ThreadEvent);
61}
62
63impl<F> EventEmitter for F
64where
65 F: FnMut(&ThreadEvent),
66{
67 fn emit(&mut self, event: &ThreadEvent) {
68 self(event);
69 }
70}
71
72#[cfg(feature = "serde-json")]
74pub(crate) mod json {
75 use super::{ThreadEvent, VersionedThreadEvent};
76
77 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
79 serde_json::to_value(event)
80 }
81
82 pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
84 serde_json::to_string(event)
85 }
86
87 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
89 serde_json::from_str(payload)
90 }
91
92 pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
94 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
95 }
96
97 pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
99 serde_json::from_str(payload)
100 }
101}
102
103#[cfg(feature = "telemetry-log")]
104mod log_support {
105 use log::Level;
106
107 use super::{EventEmitter, ThreadEvent, json};
108
109 #[derive(Debug, Clone)]
111 pub struct LogEmitter {
112 level: Level,
113 }
114
115 impl LogEmitter {
116 pub fn new(level: Level) -> Self {
118 Self { level }
119 }
120 }
121
122 impl Default for LogEmitter {
123 fn default() -> Self {
124 Self { level: Level::Info }
125 }
126 }
127
128 impl EventEmitter for LogEmitter {
129 fn emit(&mut self, event: &ThreadEvent) {
130 if log::log_enabled!(self.level) {
131 match json::to_string(event) {
132 Ok(serialized) => log::log!(self.level, "{serialized}"),
133 Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
134 }
135 }
136 }
137 }
138
139 pub use LogEmitter as PublicLogEmitter;
140}
141
142#[cfg(feature = "telemetry-log")]
143pub use log_support::PublicLogEmitter as LogEmitter;
144
145#[cfg(feature = "telemetry-tracing")]
146mod tracing_support {
147 use tracing::Level;
148
149 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
150
151 #[derive(Debug, Clone)]
153 pub struct TracingEmitter {
154 level: Level,
155 }
156
157 impl TracingEmitter {
158 pub fn new(level: Level) -> Self {
160 Self { level }
161 }
162 }
163
164 impl Default for TracingEmitter {
165 fn default() -> Self {
166 Self { level: Level::INFO }
167 }
168 }
169
170 impl EventEmitter for TracingEmitter {
171 fn emit(&mut self, event: &ThreadEvent) {
172 match self.level {
173 Level::TRACE => tracing::event!(
174 target: "vtcode_exec_events",
175 Level::TRACE,
176 schema_version = EVENT_SCHEMA_VERSION,
177 event = ?VersionedThreadEvent::new(event.clone()),
178 "vtcode_exec_event"
179 ),
180 Level::DEBUG => tracing::event!(
181 target: "vtcode_exec_events",
182 Level::DEBUG,
183 schema_version = EVENT_SCHEMA_VERSION,
184 event = ?VersionedThreadEvent::new(event.clone()),
185 "vtcode_exec_event"
186 ),
187 Level::INFO => tracing::event!(
188 target: "vtcode_exec_events",
189 Level::INFO,
190 schema_version = EVENT_SCHEMA_VERSION,
191 event = ?VersionedThreadEvent::new(event.clone()),
192 "vtcode_exec_event"
193 ),
194 Level::WARN => tracing::event!(
195 target: "vtcode_exec_events",
196 Level::WARN,
197 schema_version = EVENT_SCHEMA_VERSION,
198 event = ?VersionedThreadEvent::new(event.clone()),
199 "vtcode_exec_event"
200 ),
201 Level::ERROR => tracing::event!(
202 target: "vtcode_exec_events",
203 Level::ERROR,
204 schema_version = EVENT_SCHEMA_VERSION,
205 event = ?VersionedThreadEvent::new(event.clone()),
206 "vtcode_exec_event"
207 ),
208 }
209 }
210 }
211
212 pub use TracingEmitter as PublicTracingEmitter;
213}
214
215#[cfg(feature = "telemetry-tracing")]
216pub use tracing_support::PublicTracingEmitter as TracingEmitter;
217
218#[cfg(feature = "telemetry-otel")]
219mod otel_support {
220 use opentelemetry::KeyValue;
221 use opentelemetry::trace::{Span, Status, Tracer};
222
223 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
224
225 pub struct OtelEmitter<T: Tracer> {
241 tracer: T,
242 }
243
244 impl<T: Tracer> OtelEmitter<T> {
245 pub fn new(tracer: T) -> Self {
246 Self { tracer }
247 }
248 }
249
250 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
251 fn emit(&mut self, event: &ThreadEvent) {
252 let span_name = match event {
253 ThreadEvent::ThreadStarted(_) => "thread.started",
254 ThreadEvent::ThreadCompleted(_) => "thread.completed",
255 ThreadEvent::TurnStarted(_) => "turn.started",
256 ThreadEvent::TurnCompleted(_) => "turn.completed",
257 ThreadEvent::TurnFailed(_) => "turn.failed",
258 ThreadEvent::ItemStarted(_) => "item.started",
259 ThreadEvent::ItemUpdated(_) => "item.updated",
260 ThreadEvent::ItemCompleted(_) => "item.completed",
261 ThreadEvent::Error(_) => "error",
262 _ => "event",
263 };
264
265 let mut span = self.tracer.start(span_name);
266
267 match event {
268 ThreadEvent::ThreadStarted(e) => {
269 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
270 }
271 ThreadEvent::ThreadCompleted(e) => {
272 if let Some(ref cost) = e.total_cost_usd {
273 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
274 }
275 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
276 span.set_attribute(KeyValue::new("output_tokens", e.usage.output_tokens as i64));
277 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
278 }
279 ThreadEvent::TurnCompleted(e) => {
280 span.set_attribute(KeyValue::new("turn_input_tokens", e.usage.input_tokens as i64));
281 span.set_attribute(KeyValue::new("turn_output_tokens", e.usage.output_tokens as i64));
282 }
283 ThreadEvent::ItemCompleted(e) => {
284 if let ThreadItemDetails::Harness(harness) = &e.item.details {
285 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
286 if let Some(ref msg) = harness.message {
287 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
288 }
289 if let Some(ref path) = harness.path {
290 span.set_attribute(KeyValue::new("harness_path", path.clone()));
291 }
292 if let Some(dur) = harness.duration_ms {
293 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
294 }
295 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
296 if let Some(ref msg) = harness.message {
297 event_attrs.push(KeyValue::new("message", msg.clone()));
298 }
299 span.add_event("harness_event", event_attrs);
300 }
301 }
302 ThreadEvent::Error(e) => {
303 span.set_status(Status::Error { description: e.message.clone().into() });
304 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
305 }
306 _ => {}
307 }
308
309 span.end();
310 }
311 }
312
313 pub use OtelEmitter as PublicOtelEmitter;
314}
315
316#[cfg(feature = "telemetry-otel")]
317pub use otel_support::PublicOtelEmitter as OtelEmitter;
318
319#[cfg(feature = "schema-export")]
320pub mod schema {
321 use schemars::{Schema, schema_for};
322
323 use super::{ThreadEvent, VersionedThreadEvent};
324
325 pub fn thread_event_schema() -> Schema {
327 schema_for!(ThreadEvent)
328 }
329
330 pub fn versioned_thread_event_schema() -> Schema {
332 schema_for!(VersionedThreadEvent)
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
339#[serde(tag = "type")]
340pub enum ThreadEvent {
341 #[serde(rename = "thread.started")]
343 ThreadStarted(ThreadStartedEvent),
344 #[serde(rename = "thread.completed")]
346 ThreadCompleted(ThreadCompletedEvent),
347 #[serde(rename = "thread.compact_boundary")]
349 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
350 #[serde(rename = "turn.started")]
352 TurnStarted(TurnStartedEvent),
353 #[serde(rename = "turn.completed")]
355 TurnCompleted(TurnCompletedEvent),
356 #[serde(rename = "turn.failed")]
358 TurnFailed(TurnFailedEvent),
359 #[serde(rename = "item.started")]
361 ItemStarted(ItemStartedEvent),
362 #[serde(rename = "item.updated")]
364 ItemUpdated(ItemUpdatedEvent),
365 #[serde(rename = "item.completed")]
367 ItemCompleted(ItemCompletedEvent),
368 #[serde(rename = "permission.requested")]
370 PermissionRequested(PermissionRequestedEvent),
371 #[serde(rename = "permission.resolved")]
373 PermissionResolved(PermissionResolvedEvent),
374 #[serde(rename = "interjected")]
376 Interjected(InterjectedEvent),
377 #[serde(rename = "plan.delta")]
379 PlanDelta(PlanDeltaEvent),
380 #[serde(rename = "error")]
382 Error(ThreadErrorEvent),
383 #[serde(other)]
386 Unknown,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
391pub struct ThreadStartedEvent {
392 pub thread_id: String,
394}
395
396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
397#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
398#[serde(rename_all = "snake_case")]
399pub enum ThreadCompletionSubtype {
400 Success,
401 ErrorMaxTurns,
402 ErrorMaxBudgetUsd,
403 ErrorDuringExecution,
404 Cancelled,
405 #[serde(other)]
407 Unknown,
408}
409
410impl ThreadCompletionSubtype {
411 pub const fn as_str(&self) -> &'static str {
412 match self {
413 Self::Success => "success",
414 Self::ErrorMaxTurns => "error_max_turns",
415 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
416 Self::ErrorDuringExecution => "error_during_execution",
417 Self::Cancelled => "cancelled",
418 Self::Unknown => "unknown",
419 }
420 }
421
422 pub const fn is_success(self) -> bool {
423 matches!(self, Self::Success)
424 }
425}
426
427#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
428#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
429#[serde(rename_all = "snake_case")]
430pub enum CompactionTrigger {
431 Manual,
432 Auto,
433 Recovery,
434 ModelSwitch,
437 #[serde(other)]
439 Unknown,
440}
441
442impl CompactionTrigger {
443 pub const fn as_str(self) -> &'static str {
444 match self {
445 Self::Manual => "manual",
446 Self::Auto => "auto",
447 Self::Recovery => "recovery",
448 Self::ModelSwitch => "model_switch",
449 Self::Unknown => "unknown",
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
455#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
456#[serde(rename_all = "snake_case")]
457pub enum CompactionMode {
458 Provider,
459 Local,
460 #[serde(other)]
462 Unknown,
463}
464
465impl CompactionMode {
466 pub const fn as_str(self) -> &'static str {
467 match self {
468 Self::Provider => "provider",
469 Self::Local => "local",
470 Self::Unknown => "unknown",
471 }
472 }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
476#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
477pub struct ThreadCompletedEvent {
478 pub thread_id: String,
480 pub session_id: String,
482 pub subtype: ThreadCompletionSubtype,
484 pub outcome_code: String,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub result: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub stop_reason: Option<String>,
492 pub usage: Usage,
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub total_cost_usd: Option<serde_json::Number>,
497 pub num_turns: usize,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
502#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
503pub struct ThreadCompactBoundaryEvent {
504 pub thread_id: String,
506 pub trigger: CompactionTrigger,
508 pub mode: CompactionMode,
510 pub original_message_count: usize,
512 pub compacted_message_count: usize,
514 #[serde(skip_serializing_if = "Option::is_none")]
516 pub history_artifact_path: Option<String>,
517}
518
519#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
520#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
521pub struct TurnStartedEvent {
522 #[serde(skip_serializing_if = "Option::is_none")]
526 token_breakdown: Option<TokenBreakdown>,
527}
528
529#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
531#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
532pub struct TokenBreakdown {
533 system_prompt_tokens: u64,
535 tool_schema_tokens: u64,
537 instruction_file_tokens: u64,
539 message_history_tokens: u64,
541 cache_read_tokens: u64,
543 cache_write_tokens: u64,
545 cache_miss_tokens: u64,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 subagent_bootstrap_tokens: Option<u64>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
553#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
554pub struct TurnCompletedEvent {
555 pub usage: Usage,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
560#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
561pub struct TurnFailedEvent {
562 pub message: String,
564 #[serde(skip_serializing_if = "Option::is_none")]
566 pub usage: Option<Usage>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
570#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
571pub struct ThreadErrorEvent {
572 pub message: String,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
577#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
578pub struct Usage {
579 pub input_tokens: u64,
581 pub cached_input_tokens: u64,
583 pub cache_creation_tokens: u64,
585 pub output_tokens: u64,
587}
588
589impl Usage {
590 #[must_use]
595 fn uncached_input_tokens(&self) -> u64 {
596 self.input_tokens
597 .saturating_sub(self.cached_input_tokens)
598 .saturating_sub(self.cache_creation_tokens)
599 }
600
601 #[must_use]
604 pub fn cache_hit_rate(&self) -> Option<f64> {
605 if self.input_tokens == 0 {
606 return None;
607 }
608 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
609 }
610
611 #[must_use]
613 pub fn cache_summary(&self) -> String {
614 let total_input = self.input_tokens;
615 if total_input == 0 {
616 return "No input tokens recorded.".to_string();
617 }
618
619 let cached = self.cached_input_tokens;
620 let creation = self.cache_creation_tokens;
621 let uncached = self.uncached_input_tokens();
622 let rate = cached as f64 / total_input as f64 * 100.0;
623 format!(
624 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
625 {creation} cache-creation, {uncached} uncached"
626 )
627 }
628
629 pub fn add(&mut self, other: &Usage) {
631 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
632 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
633 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
634 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
635 }
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
640pub struct ItemCompletedEvent {
641 pub item: ThreadItem,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
647pub struct ItemStartedEvent {
648 pub item: ThreadItem,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
654pub struct ItemUpdatedEvent {
655 pub item: ThreadItem,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
660#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
661pub struct PlanDeltaEvent {
662 pub thread_id: String,
664 pub turn_id: String,
666 pub item_id: String,
668 pub delta: String,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
674pub struct ThreadItem {
675 pub id: String,
677 #[serde(flatten)]
679 pub details: ThreadItemDetails,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
683#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
684#[serde(tag = "type", rename_all = "snake_case")]
685pub enum ThreadItemDetails {
686 AgentMessage(AgentMessageItem),
688 Plan(PlanItem),
690 Reasoning(ReasoningItem),
692 CommandExecution(Box<CommandExecutionItem>),
694 ToolInvocation(ToolInvocationItem),
696 ToolOutput(ToolOutputItem),
698 FileChange(Box<FileChangeItem>),
700 McpToolCall(McpToolCallItem),
702 WebSearch(WebSearchItem),
704 Harness(HarnessEventItem),
706 Error(ErrorItem),
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
711#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
712pub struct AgentMessageItem {
713 pub text: String,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
718#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
719pub struct PlanItem {
720 pub text: String,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
725#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
726pub struct ReasoningItem {
727 pub text: String,
729 #[serde(skip_serializing_if = "Option::is_none")]
731 pub stage: Option<String>,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
736#[serde(rename_all = "snake_case")]
737pub enum CommandExecutionStatus {
738 #[default]
740 Completed,
741 Failed,
743 InProgress,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
748#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
749pub struct CommandExecutionItem {
750 pub command: String,
752 #[serde(skip_serializing_if = "Option::is_none")]
754 pub arguments: Option<Value>,
755 #[serde(default)]
757 pub aggregated_output: String,
758 #[serde(skip_serializing_if = "Option::is_none")]
760 pub exit_code: Option<i32>,
761 pub status: CommandExecutionStatus,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
766#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
767#[serde(rename_all = "snake_case")]
768pub enum ToolCallStatus {
769 #[default]
771 Completed,
772 Failed,
774 InProgress,
776}
777
778#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
786#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
787#[serde(rename_all = "snake_case")]
788pub enum ToolOutcome {
789 #[default]
791 Success,
792 Error,
794 PermissionRejected,
796 PermissionCancelled,
798 Followup,
800 HookDenied,
802 InvalidTool,
804 Cancelled,
806}
807
808impl ToolOutcome {
809 #[must_use]
810 pub const fn is_terminal(self) -> bool {
811 !matches!(self, Self::Followup)
812 }
813}
814
815#[must_use]
822#[allow(clippy::unreachable)]
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}