1#![allow(missing_docs)]
2use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub mod atif;
19pub mod trace;
20
21pub const EVENT_SCHEMA_VERSION: &str = "0.7.0";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
28pub struct VersionedThreadEvent {
29 pub schema_version: String,
31 pub event: ThreadEvent,
33}
34
35impl VersionedThreadEvent {
36 pub fn new(event: ThreadEvent) -> Self {
39 Self {
40 schema_version: EVENT_SCHEMA_VERSION.to_string(),
41 event,
42 }
43 }
44
45 pub fn into_event(self) -> ThreadEvent {
47 self.event
48 }
49}
50
51impl From<ThreadEvent> for VersionedThreadEvent {
52 fn from(event: ThreadEvent) -> Self {
53 Self::new(event)
54 }
55}
56
57pub trait EventEmitter {
59 fn emit(&mut self, event: &ThreadEvent);
61}
62
63impl<F> EventEmitter for F
64where
65 F: FnMut(&ThreadEvent),
66{
67 fn emit(&mut self, event: &ThreadEvent) {
68 self(event);
69 }
70}
71
72#[cfg(feature = "serde-json")]
74pub mod json {
75 use super::{ThreadEvent, VersionedThreadEvent};
76
77 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
79 serde_json::to_value(event)
80 }
81
82 pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
84 serde_json::to_string(event)
85 }
86
87 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
89 serde_json::from_str(payload)
90 }
91
92 pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
94 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
95 }
96
97 pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
99 serde_json::from_str(payload)
100 }
101}
102
103#[cfg(feature = "telemetry-log")]
104mod log_support {
105 use log::Level;
106
107 use super::{EventEmitter, ThreadEvent, json};
108
109 #[derive(Debug, Clone)]
111 pub struct LogEmitter {
112 level: Level,
113 }
114
115 impl LogEmitter {
116 pub fn new(level: Level) -> Self {
118 Self { level }
119 }
120 }
121
122 impl Default for LogEmitter {
123 fn default() -> Self {
124 Self { level: Level::Info }
125 }
126 }
127
128 impl EventEmitter for LogEmitter {
129 fn emit(&mut self, event: &ThreadEvent) {
130 if log::log_enabled!(self.level) {
131 match json::to_string(event) {
132 Ok(serialized) => log::log!(self.level, "{serialized}"),
133 Err(err) => log::log!(
134 self.level,
135 "failed to serialize vtcode exec event for logging: {err}"
136 ),
137 }
138 }
139 }
140 }
141
142 pub use LogEmitter as PublicLogEmitter;
143}
144
145#[cfg(feature = "telemetry-log")]
146pub use log_support::PublicLogEmitter as LogEmitter;
147
148#[cfg(feature = "telemetry-tracing")]
149mod tracing_support {
150 use tracing::Level;
151
152 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
153
154 #[derive(Debug, Clone)]
156 pub struct TracingEmitter {
157 level: Level,
158 }
159
160 impl TracingEmitter {
161 pub fn new(level: Level) -> Self {
163 Self { level }
164 }
165 }
166
167 impl Default for TracingEmitter {
168 fn default() -> Self {
169 Self { level: Level::INFO }
170 }
171 }
172
173 impl EventEmitter for TracingEmitter {
174 fn emit(&mut self, event: &ThreadEvent) {
175 match self.level {
176 Level::TRACE => tracing::event!(
177 target: "vtcode_exec_events",
178 Level::TRACE,
179 schema_version = EVENT_SCHEMA_VERSION,
180 event = ?VersionedThreadEvent::new(event.clone()),
181 "vtcode_exec_event"
182 ),
183 Level::DEBUG => tracing::event!(
184 target: "vtcode_exec_events",
185 Level::DEBUG,
186 schema_version = EVENT_SCHEMA_VERSION,
187 event = ?VersionedThreadEvent::new(event.clone()),
188 "vtcode_exec_event"
189 ),
190 Level::INFO => tracing::event!(
191 target: "vtcode_exec_events",
192 Level::INFO,
193 schema_version = EVENT_SCHEMA_VERSION,
194 event = ?VersionedThreadEvent::new(event.clone()),
195 "vtcode_exec_event"
196 ),
197 Level::WARN => tracing::event!(
198 target: "vtcode_exec_events",
199 Level::WARN,
200 schema_version = EVENT_SCHEMA_VERSION,
201 event = ?VersionedThreadEvent::new(event.clone()),
202 "vtcode_exec_event"
203 ),
204 Level::ERROR => tracing::event!(
205 target: "vtcode_exec_events",
206 Level::ERROR,
207 schema_version = EVENT_SCHEMA_VERSION,
208 event = ?VersionedThreadEvent::new(event.clone()),
209 "vtcode_exec_event"
210 ),
211 }
212 }
213 }
214
215 pub use TracingEmitter as PublicTracingEmitter;
216}
217
218#[cfg(feature = "telemetry-tracing")]
219pub use tracing_support::PublicTracingEmitter as TracingEmitter;
220
221#[cfg(feature = "telemetry-otel")]
222mod otel_support {
223 use opentelemetry::KeyValue;
224 use opentelemetry::trace::{Span, Status, Tracer};
225
226 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
227
228 pub struct OtelEmitter<T: Tracer> {
245 tracer: T,
246 }
247
248 impl<T: Tracer> OtelEmitter<T> {
249 pub fn new(tracer: T) -> Self {
250 Self { tracer }
251 }
252 }
253
254 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
255 fn emit(&mut self, event: &ThreadEvent) {
256 let span_name = match event {
257 ThreadEvent::ThreadStarted(_) => "thread.started",
258 ThreadEvent::ThreadCompleted(_) => "thread.completed",
259 ThreadEvent::TurnStarted(_) => "turn.started",
260 ThreadEvent::TurnCompleted(_) => "turn.completed",
261 ThreadEvent::TurnFailed(_) => "turn.failed",
262 ThreadEvent::ItemStarted(_) => "item.started",
263 ThreadEvent::ItemUpdated(_) => "item.updated",
264 ThreadEvent::ItemCompleted(_) => "item.completed",
265 ThreadEvent::Error(_) => "error",
266 _ => "event",
267 };
268
269 let mut span = self.tracer.start(span_name);
270
271 match event {
272 ThreadEvent::ThreadStarted(e) => {
273 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
274 }
275 ThreadEvent::ThreadCompleted(e) => {
276 if let Some(ref cost) = e.total_cost_usd {
277 span.set_attribute(KeyValue::new(
278 "total_cost_usd",
279 cost.as_f64().unwrap_or(0.0),
280 ));
281 }
282 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
283 span.set_attribute(KeyValue::new(
284 "output_tokens",
285 e.usage.output_tokens as i64,
286 ));
287 span.set_attribute(KeyValue::new(
288 "completion_subtype",
289 e.subtype.as_str().to_string(),
290 ));
291 }
292 ThreadEvent::TurnCompleted(e) => {
293 span.set_attribute(KeyValue::new(
294 "turn_input_tokens",
295 e.usage.input_tokens as i64,
296 ));
297 span.set_attribute(KeyValue::new(
298 "turn_output_tokens",
299 e.usage.output_tokens as i64,
300 ));
301 }
302 ThreadEvent::ItemCompleted(e) => {
303 if let ThreadItemDetails::Harness(harness) = &e.item.details {
304 span.set_attribute(KeyValue::new(
305 "harness_event",
306 format!("{:?}", harness.event),
307 ));
308 if let Some(ref msg) = harness.message {
309 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
310 }
311 if let Some(ref path) = harness.path {
312 span.set_attribute(KeyValue::new("harness_path", path.clone()));
313 }
314 if let Some(dur) = harness.duration_ms {
315 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
316 }
317 let mut event_attrs =
318 vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
319 if let Some(ref msg) = harness.message {
320 event_attrs.push(KeyValue::new("message", msg.clone()));
321 }
322 span.add_event("harness_event", event_attrs);
323 }
324 }
325 ThreadEvent::Error(e) => {
326 span.set_status(Status::Error {
327 description: e.message.clone().into(),
328 });
329 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
330 }
331 _ => {}
332 }
333
334 span.end();
335 }
336 }
337
338 pub use OtelEmitter as PublicOtelEmitter;
339}
340
341#[cfg(feature = "telemetry-otel")]
342pub use otel_support::PublicOtelEmitter as OtelEmitter;
343
344#[cfg(feature = "schema-export")]
345pub mod schema {
346 use schemars::{Schema, schema_for};
347
348 use super::{ThreadEvent, VersionedThreadEvent};
349
350 pub fn thread_event_schema() -> Schema {
352 schema_for!(ThreadEvent)
353 }
354
355 pub fn versioned_thread_event_schema() -> Schema {
357 schema_for!(VersionedThreadEvent)
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
364#[serde(tag = "type")]
365pub enum ThreadEvent {
366 #[serde(rename = "thread.started")]
368 ThreadStarted(ThreadStartedEvent),
369 #[serde(rename = "thread.completed")]
371 ThreadCompleted(ThreadCompletedEvent),
372 #[serde(rename = "thread.compact_boundary")]
374 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
375 #[serde(rename = "turn.started")]
377 TurnStarted(TurnStartedEvent),
378 #[serde(rename = "turn.completed")]
380 TurnCompleted(TurnCompletedEvent),
381 #[serde(rename = "turn.failed")]
383 TurnFailed(TurnFailedEvent),
384 #[serde(rename = "item.started")]
386 ItemStarted(ItemStartedEvent),
387 #[serde(rename = "item.updated")]
389 ItemUpdated(ItemUpdatedEvent),
390 #[serde(rename = "item.completed")]
392 ItemCompleted(ItemCompletedEvent),
393 #[serde(rename = "plan.delta")]
395 PlanDelta(PlanDeltaEvent),
396 #[serde(rename = "error")]
398 Error(ThreadErrorEvent),
399 #[serde(other)]
402 Unknown,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
407pub struct ThreadStartedEvent {
408 pub thread_id: String,
410}
411
412#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
414#[serde(rename_all = "snake_case")]
415pub enum ThreadCompletionSubtype {
416 Success,
417 ErrorMaxTurns,
418 ErrorMaxBudgetUsd,
419 ErrorDuringExecution,
420 Cancelled,
421 #[serde(other)]
423 Unknown,
424}
425
426impl ThreadCompletionSubtype {
427 pub const fn as_str(&self) -> &'static str {
428 match self {
429 Self::Success => "success",
430 Self::ErrorMaxTurns => "error_max_turns",
431 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
432 Self::ErrorDuringExecution => "error_during_execution",
433 Self::Cancelled => "cancelled",
434 Self::Unknown => "unknown",
435 }
436 }
437
438 pub const fn is_success(self) -> bool {
439 matches!(self, Self::Success)
440 }
441}
442
443#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
444#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
445#[serde(rename_all = "snake_case")]
446pub enum CompactionTrigger {
447 Manual,
448 Auto,
449 Recovery,
450 ModelSwitch,
453 #[serde(other)]
455 Unknown,
456}
457
458impl CompactionTrigger {
459 pub const fn as_str(self) -> &'static str {
460 match self {
461 Self::Manual => "manual",
462 Self::Auto => "auto",
463 Self::Recovery => "recovery",
464 Self::ModelSwitch => "model_switch",
465 Self::Unknown => "unknown",
466 }
467 }
468}
469
470#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
472#[serde(rename_all = "snake_case")]
473pub enum CompactionMode {
474 Provider,
475 Local,
476 #[serde(other)]
478 Unknown,
479}
480
481impl CompactionMode {
482 pub const fn as_str(self) -> &'static str {
483 match self {
484 Self::Provider => "provider",
485 Self::Local => "local",
486 Self::Unknown => "unknown",
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
493pub struct ThreadCompletedEvent {
494 pub thread_id: String,
496 pub session_id: String,
498 pub subtype: ThreadCompletionSubtype,
500 pub outcome_code: String,
502 #[serde(skip_serializing_if = "Option::is_none")]
504 pub result: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
507 pub stop_reason: Option<String>,
508 pub usage: Usage,
510 #[serde(skip_serializing_if = "Option::is_none")]
512 pub total_cost_usd: Option<serde_json::Number>,
513 pub num_turns: usize,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
519pub struct ThreadCompactBoundaryEvent {
520 pub thread_id: String,
522 pub trigger: CompactionTrigger,
524 pub mode: CompactionMode,
526 pub original_message_count: usize,
528 pub compacted_message_count: usize,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub history_artifact_path: Option<String>,
533}
534
535#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
536#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
537pub struct TurnStartedEvent {}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
540#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
541pub struct TurnCompletedEvent {
542 pub usage: Usage,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
547#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
548pub struct TurnFailedEvent {
549 pub message: String,
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub usage: Option<Usage>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
558pub struct ThreadErrorEvent {
559 pub message: String,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
565pub struct Usage {
566 pub input_tokens: u64,
568 pub cached_input_tokens: u64,
570 pub cache_creation_tokens: u64,
572 pub output_tokens: u64,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
578pub struct ItemCompletedEvent {
579 pub item: ThreadItem,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
585pub struct ItemStartedEvent {
586 pub item: ThreadItem,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct ItemUpdatedEvent {
593 pub item: ThreadItem,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
599pub struct PlanDeltaEvent {
600 pub thread_id: String,
602 pub turn_id: String,
604 pub item_id: String,
606 pub delta: String,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
611#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
612pub struct ThreadItem {
613 pub id: String,
615 #[serde(flatten)]
617 pub details: ThreadItemDetails,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
621#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
622#[serde(tag = "type", rename_all = "snake_case")]
623pub enum ThreadItemDetails {
624 AgentMessage(AgentMessageItem),
626 Plan(PlanItem),
628 Reasoning(ReasoningItem),
630 CommandExecution(Box<CommandExecutionItem>),
632 ToolInvocation(ToolInvocationItem),
634 ToolOutput(ToolOutputItem),
636 FileChange(Box<FileChangeItem>),
638 McpToolCall(McpToolCallItem),
640 WebSearch(WebSearchItem),
642 Harness(HarnessEventItem),
644 Error(ErrorItem),
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
650pub struct AgentMessageItem {
651 pub text: String,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
656#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
657pub struct PlanItem {
658 pub text: String,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664pub struct ReasoningItem {
665 pub text: String,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub stage: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
673#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
674#[serde(rename_all = "snake_case")]
675pub enum CommandExecutionStatus {
676 #[default]
678 Completed,
679 Failed,
681 InProgress,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
687pub struct CommandExecutionItem {
688 pub command: String,
690 #[serde(skip_serializing_if = "Option::is_none")]
692 pub arguments: Option<Value>,
693 #[serde(default)]
695 pub aggregated_output: String,
696 #[serde(skip_serializing_if = "Option::is_none")]
698 pub exit_code: Option<i32>,
699 pub status: CommandExecutionStatus,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
704#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
705#[serde(rename_all = "snake_case")]
706pub enum ToolCallStatus {
707 #[default]
709 Completed,
710 Failed,
712 InProgress,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
717#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
718pub struct ToolInvocationItem {
719 pub tool_name: String,
721 #[serde(skip_serializing_if = "Option::is_none")]
723 pub arguments: Option<Value>,
724 #[serde(skip_serializing_if = "Option::is_none")]
726 pub tool_call_id: Option<String>,
727 pub status: ToolCallStatus,
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
732#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
733pub struct ToolOutputItem {
734 pub call_id: String,
736 #[serde(skip_serializing_if = "Option::is_none")]
738 pub tool_call_id: Option<String>,
739 #[serde(skip_serializing_if = "Option::is_none")]
741 pub spool_path: Option<String>,
742 #[serde(default)]
744 pub output: String,
745 #[serde(skip_serializing_if = "Option::is_none")]
747 pub exit_code: Option<i32>,
748 pub status: ToolCallStatus,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
753#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
754pub struct FileChangeItem {
755 pub changes: Vec<FileUpdateChange>,
757 pub status: PatchApplyStatus,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
762#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
763pub struct FileUpdateChange {
764 pub path: String,
766 pub kind: PatchChangeKind,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
771#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
772#[serde(rename_all = "snake_case")]
773pub enum PatchApplyStatus {
774 Completed,
776 Failed,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
782#[serde(rename_all = "snake_case")]
783pub enum PatchChangeKind {
784 Add,
786 Delete,
788 Update,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
793#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
794pub struct McpToolCallItem {
795 pub tool_name: String,
797 #[serde(skip_serializing_if = "Option::is_none")]
799 pub arguments: Option<Value>,
800 #[serde(skip_serializing_if = "Option::is_none")]
802 pub result: Option<String>,
803 #[serde(skip_serializing_if = "Option::is_none")]
805 pub status: Option<McpToolCallStatus>,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
809#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
810#[serde(rename_all = "snake_case")]
811pub enum McpToolCallStatus {
812 Started,
814 Completed,
816 Failed,
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
821#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
822pub struct WebSearchItem {
823 pub query: String,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub provider: Option<String>,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 pub results: Option<Vec<String>>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
835#[serde(rename_all = "snake_case")]
836pub enum HarnessEventKind {
837 PlanningStarted,
838 PlanningCompleted,
839 ContinuationStarted,
840 ContinuationSkipped,
841 BlockedHandoffWritten,
842 EvaluationStarted,
843 EvaluationPassed,
844 EvaluationFailed,
845 RevisionStarted,
846 EscalationTriggered,
847 EscalationBypassed,
848 VerificationStarted,
849 VerificationPassed,
850 VerificationFailed,
851 ErrorRecovered,
853 ToolRetryAttempted,
855 ToolLatencyRecorded,
857 SnapshotCreated,
859 SnapshotRestored,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
864#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
865pub struct HarnessEventItem {
866 pub event: HarnessEventKind,
868 #[serde(skip_serializing_if = "Option::is_none")]
870 pub message: Option<String>,
871 #[serde(skip_serializing_if = "Option::is_none")]
873 pub command: Option<String>,
874 #[serde(skip_serializing_if = "Option::is_none")]
876 pub path: Option<String>,
877 #[serde(skip_serializing_if = "Option::is_none")]
879 pub exit_code: Option<i32>,
880 #[serde(skip_serializing_if = "Option::is_none")]
882 pub attempt: Option<u32>,
883 #[serde(skip_serializing_if = "Option::is_none")]
885 pub error_category: Option<String>,
886 #[serde(skip_serializing_if = "Option::is_none")]
888 pub duration_ms: Option<u64>,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
892#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
893pub struct ErrorItem {
894 pub message: String,
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use std::error::Error;
902
903 #[test]
904 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
905 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
906 usage: Usage {
907 input_tokens: 1,
908 cached_input_tokens: 2,
909 cache_creation_tokens: 0,
910 output_tokens: 3,
911 },
912 });
913
914 let json = serde_json::to_string(&event)?;
915 let restored: ThreadEvent = serde_json::from_str(&json)?;
916
917 assert_eq!(restored, event);
918 Ok(())
919 }
920
921 #[test]
922 fn versioned_event_wraps_schema_version() {
923 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
924 thread_id: "abc".to_string(),
925 });
926
927 let versioned = VersionedThreadEvent::new(event.clone());
928
929 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
930 assert_eq!(versioned.event, event);
931 assert_eq!(versioned.into_event(), event);
932 }
933
934 #[cfg(feature = "serde-json")]
935 #[test]
936 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
937 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
938 item: ThreadItem {
939 id: "item-1".to_string(),
940 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
941 text: "hello".to_string(),
942 }),
943 },
944 });
945
946 let payload = json::versioned_to_string(&event)?;
947 let restored = json::versioned_from_str(&payload)?;
948
949 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
950 assert_eq!(restored.event, event);
951 Ok(())
952 }
953
954 #[test]
955 fn compaction_trigger_serializes_snake_case_and_round_trips() {
956 for trigger in [
957 CompactionTrigger::Manual,
958 CompactionTrigger::Auto,
959 CompactionTrigger::Recovery,
960 CompactionTrigger::ModelSwitch,
961 CompactionTrigger::Unknown,
962 ] {
963 let json = serde_json::to_string(&trigger).unwrap();
964 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
965 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
966 assert_eq!(restored, trigger);
967 }
968 }
969
970 #[test]
971 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
972 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
973 item: ThreadItem {
974 id: "tool_1".to_string(),
975 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
976 tool_name: "read_file".to_string(),
977 arguments: Some(serde_json::json!({ "path": "README.md" })),
978 tool_call_id: Some("tool_call_0".to_string()),
979 status: ToolCallStatus::Completed,
980 }),
981 },
982 });
983
984 let json = serde_json::to_string(&event)?;
985 let restored: ThreadEvent = serde_json::from_str(&json)?;
986
987 assert_eq!(restored, event);
988 Ok(())
989 }
990
991 #[test]
992 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
993 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
994 item: ThreadItem {
995 id: "tool_1:output".to_string(),
996 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
997 call_id: "tool_1".to_string(),
998 tool_call_id: Some("tool_call_0".to_string()),
999 spool_path: None,
1000 output: "done".to_string(),
1001 exit_code: Some(0),
1002 status: ToolCallStatus::Completed,
1003 }),
1004 },
1005 });
1006
1007 let json = serde_json::to_string(&event)?;
1008 let restored: ThreadEvent = serde_json::from_str(&json)?;
1009
1010 assert_eq!(restored, event);
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1016 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1017 item: ThreadItem {
1018 id: "harness_1".to_string(),
1019 details: ThreadItemDetails::Harness(HarnessEventItem {
1020 event: HarnessEventKind::VerificationFailed,
1021 message: Some("cargo check failed".to_string()),
1022 command: Some("cargo check".to_string()),
1023 path: None,
1024 exit_code: Some(101),
1025 attempt: None,
1026 error_category: None,
1027 duration_ms: None,
1028 }),
1029 },
1030 });
1031
1032 let json = serde_json::to_string(&event)?;
1033 let restored: ThreadEvent = serde_json::from_str(&json)?;
1034
1035 assert_eq!(restored, event);
1036 Ok(())
1037 }
1038
1039 #[test]
1040 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1041 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1042 thread_id: "thread-1".to_string(),
1043 session_id: "session-1".to_string(),
1044 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1045 outcome_code: "budget_limit_reached".to_string(),
1046 result: None,
1047 stop_reason: Some("max_tokens".to_string()),
1048 usage: Usage {
1049 input_tokens: 10,
1050 cached_input_tokens: 4,
1051 cache_creation_tokens: 2,
1052 output_tokens: 5,
1053 },
1054 total_cost_usd: serde_json::Number::from_f64(1.25),
1055 num_turns: 3,
1056 });
1057
1058 let json = serde_json::to_string(&event)?;
1059 let restored: ThreadEvent = serde_json::from_str(&json)?;
1060
1061 assert_eq!(restored, event);
1062 Ok(())
1063 }
1064
1065 #[test]
1066 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1067 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1068 thread_id: "thread-1".to_string(),
1069 trigger: CompactionTrigger::Recovery,
1070 mode: CompactionMode::Provider,
1071 original_message_count: 12,
1072 compacted_message_count: 5,
1073 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1074 });
1075
1076 let json = serde_json::to_string(&event)?;
1077 let restored: ThreadEvent = serde_json::from_str(&json)?;
1078
1079 assert_eq!(restored, event);
1080 Ok(())
1081 }
1082}