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 #[serde(other)]
452 Unknown,
453}
454
455impl CompactionTrigger {
456 pub const fn as_str(self) -> &'static str {
457 match self {
458 Self::Manual => "manual",
459 Self::Auto => "auto",
460 Self::Recovery => "recovery",
461 Self::Unknown => "unknown",
462 }
463 }
464}
465
466#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
467#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
468#[serde(rename_all = "snake_case")]
469pub enum CompactionMode {
470 Provider,
471 Local,
472 #[serde(other)]
474 Unknown,
475}
476
477impl CompactionMode {
478 pub const fn as_str(self) -> &'static str {
479 match self {
480 Self::Provider => "provider",
481 Self::Local => "local",
482 Self::Unknown => "unknown",
483 }
484 }
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
488#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
489pub struct ThreadCompletedEvent {
490 pub thread_id: String,
492 pub session_id: String,
494 pub subtype: ThreadCompletionSubtype,
496 pub outcome_code: String,
498 #[serde(skip_serializing_if = "Option::is_none")]
500 pub result: Option<String>,
501 #[serde(skip_serializing_if = "Option::is_none")]
503 pub stop_reason: Option<String>,
504 pub usage: Usage,
506 #[serde(skip_serializing_if = "Option::is_none")]
508 pub total_cost_usd: Option<serde_json::Number>,
509 pub num_turns: usize,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
514#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
515pub struct ThreadCompactBoundaryEvent {
516 pub thread_id: String,
518 pub trigger: CompactionTrigger,
520 pub mode: CompactionMode,
522 pub original_message_count: usize,
524 pub compacted_message_count: usize,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub history_artifact_path: Option<String>,
529}
530
531#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
533pub struct TurnStartedEvent {}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
537pub struct TurnCompletedEvent {
538 pub usage: Usage,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
544pub struct TurnFailedEvent {
545 pub message: String,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub usage: Option<Usage>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
553#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
554pub struct ThreadErrorEvent {
555 pub message: String,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
560#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
561pub struct Usage {
562 pub input_tokens: u64,
564 pub cached_input_tokens: u64,
566 pub cache_creation_tokens: u64,
568 pub output_tokens: u64,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
574pub struct ItemCompletedEvent {
575 pub item: ThreadItem,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
581pub struct ItemStartedEvent {
582 pub item: ThreadItem,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
587#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
588pub struct ItemUpdatedEvent {
589 pub item: ThreadItem,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
595pub struct PlanDeltaEvent {
596 pub thread_id: String,
598 pub turn_id: String,
600 pub item_id: String,
602 pub delta: String,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
607#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
608pub struct ThreadItem {
609 pub id: String,
611 #[serde(flatten)]
613 pub details: ThreadItemDetails,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
618#[serde(tag = "type", rename_all = "snake_case")]
619pub enum ThreadItemDetails {
620 AgentMessage(AgentMessageItem),
622 Plan(PlanItem),
624 Reasoning(ReasoningItem),
626 CommandExecution(Box<CommandExecutionItem>),
628 ToolInvocation(ToolInvocationItem),
630 ToolOutput(ToolOutputItem),
632 FileChange(Box<FileChangeItem>),
634 McpToolCall(McpToolCallItem),
636 WebSearch(WebSearchItem),
638 Harness(HarnessEventItem),
640 Error(ErrorItem),
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
646pub struct AgentMessageItem {
647 pub text: String,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
652#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
653pub struct PlanItem {
654 pub text: String,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
660pub struct ReasoningItem {
661 pub text: String,
663 #[serde(skip_serializing_if = "Option::is_none")]
665 pub stage: Option<String>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
669#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
670#[serde(rename_all = "snake_case")]
671pub enum CommandExecutionStatus {
672 #[default]
674 Completed,
675 Failed,
677 InProgress,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
683pub struct CommandExecutionItem {
684 pub command: String,
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub arguments: Option<Value>,
689 #[serde(default)]
691 pub aggregated_output: String,
692 #[serde(skip_serializing_if = "Option::is_none")]
694 pub exit_code: Option<i32>,
695 pub status: CommandExecutionStatus,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
700#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
701#[serde(rename_all = "snake_case")]
702pub enum ToolCallStatus {
703 #[default]
705 Completed,
706 Failed,
708 InProgress,
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
713#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
714pub struct ToolInvocationItem {
715 pub tool_name: String,
717 #[serde(skip_serializing_if = "Option::is_none")]
719 pub arguments: Option<Value>,
720 #[serde(skip_serializing_if = "Option::is_none")]
722 pub tool_call_id: Option<String>,
723 pub status: ToolCallStatus,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
728#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
729pub struct ToolOutputItem {
730 pub call_id: String,
732 #[serde(skip_serializing_if = "Option::is_none")]
734 pub tool_call_id: Option<String>,
735 #[serde(skip_serializing_if = "Option::is_none")]
737 pub spool_path: Option<String>,
738 #[serde(default)]
740 pub output: String,
741 #[serde(skip_serializing_if = "Option::is_none")]
743 pub exit_code: Option<i32>,
744 pub status: ToolCallStatus,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
750pub struct FileChangeItem {
751 pub changes: Vec<FileUpdateChange>,
753 pub status: PatchApplyStatus,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
758#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
759pub struct FileUpdateChange {
760 pub path: String,
762 pub kind: PatchChangeKind,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
767#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
768#[serde(rename_all = "snake_case")]
769pub enum PatchApplyStatus {
770 Completed,
772 Failed,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
777#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
778#[serde(rename_all = "snake_case")]
779pub enum PatchChangeKind {
780 Add,
782 Delete,
784 Update,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
789#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
790pub struct McpToolCallItem {
791 pub tool_name: String,
793 #[serde(skip_serializing_if = "Option::is_none")]
795 pub arguments: Option<Value>,
796 #[serde(skip_serializing_if = "Option::is_none")]
798 pub result: Option<String>,
799 #[serde(skip_serializing_if = "Option::is_none")]
801 pub status: Option<McpToolCallStatus>,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
805#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
806#[serde(rename_all = "snake_case")]
807pub enum McpToolCallStatus {
808 Started,
810 Completed,
812 Failed,
814}
815
816#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
817#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
818pub struct WebSearchItem {
819 pub query: String,
821 #[serde(skip_serializing_if = "Option::is_none")]
823 pub provider: Option<String>,
824 #[serde(skip_serializing_if = "Option::is_none")]
826 pub results: Option<Vec<String>>,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
831#[serde(rename_all = "snake_case")]
832pub enum HarnessEventKind {
833 PlanningStarted,
834 PlanningCompleted,
835 ContinuationStarted,
836 ContinuationSkipped,
837 BlockedHandoffWritten,
838 EvaluationStarted,
839 EvaluationPassed,
840 EvaluationFailed,
841 RevisionStarted,
842 EscalationTriggered,
843 EscalationBypassed,
844 VerificationStarted,
845 VerificationPassed,
846 VerificationFailed,
847 ErrorRecovered,
849 ToolRetryAttempted,
851 ToolLatencyRecorded,
853 SnapshotCreated,
855 SnapshotRestored,
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
861pub struct HarnessEventItem {
862 pub event: HarnessEventKind,
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub message: Option<String>,
867 #[serde(skip_serializing_if = "Option::is_none")]
869 pub command: Option<String>,
870 #[serde(skip_serializing_if = "Option::is_none")]
872 pub path: Option<String>,
873 #[serde(skip_serializing_if = "Option::is_none")]
875 pub exit_code: Option<i32>,
876 #[serde(skip_serializing_if = "Option::is_none")]
878 pub attempt: Option<u32>,
879 #[serde(skip_serializing_if = "Option::is_none")]
881 pub error_category: Option<String>,
882 #[serde(skip_serializing_if = "Option::is_none")]
884 pub duration_ms: Option<u64>,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
888#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
889pub struct ErrorItem {
890 pub message: String,
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use std::error::Error;
898
899 #[test]
900 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
901 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
902 usage: Usage {
903 input_tokens: 1,
904 cached_input_tokens: 2,
905 cache_creation_tokens: 0,
906 output_tokens: 3,
907 },
908 });
909
910 let json = serde_json::to_string(&event)?;
911 let restored: ThreadEvent = serde_json::from_str(&json)?;
912
913 assert_eq!(restored, event);
914 Ok(())
915 }
916
917 #[test]
918 fn versioned_event_wraps_schema_version() {
919 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
920 thread_id: "abc".to_string(),
921 });
922
923 let versioned = VersionedThreadEvent::new(event.clone());
924
925 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
926 assert_eq!(versioned.event, event);
927 assert_eq!(versioned.into_event(), event);
928 }
929
930 #[cfg(feature = "serde-json")]
931 #[test]
932 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
933 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
934 item: ThreadItem {
935 id: "item-1".to_string(),
936 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
937 text: "hello".to_string(),
938 }),
939 },
940 });
941
942 let payload = json::versioned_to_string(&event)?;
943 let restored = json::versioned_from_str(&payload)?;
944
945 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
946 assert_eq!(restored.event, event);
947 Ok(())
948 }
949
950 #[test]
951 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
952 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
953 item: ThreadItem {
954 id: "tool_1".to_string(),
955 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
956 tool_name: "read_file".to_string(),
957 arguments: Some(serde_json::json!({ "path": "README.md" })),
958 tool_call_id: Some("tool_call_0".to_string()),
959 status: ToolCallStatus::Completed,
960 }),
961 },
962 });
963
964 let json = serde_json::to_string(&event)?;
965 let restored: ThreadEvent = serde_json::from_str(&json)?;
966
967 assert_eq!(restored, event);
968 Ok(())
969 }
970
971 #[test]
972 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
973 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
974 item: ThreadItem {
975 id: "tool_1:output".to_string(),
976 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
977 call_id: "tool_1".to_string(),
978 tool_call_id: Some("tool_call_0".to_string()),
979 spool_path: None,
980 output: "done".to_string(),
981 exit_code: Some(0),
982 status: ToolCallStatus::Completed,
983 }),
984 },
985 });
986
987 let json = serde_json::to_string(&event)?;
988 let restored: ThreadEvent = serde_json::from_str(&json)?;
989
990 assert_eq!(restored, event);
991 Ok(())
992 }
993
994 #[test]
995 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
996 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
997 item: ThreadItem {
998 id: "harness_1".to_string(),
999 details: ThreadItemDetails::Harness(HarnessEventItem {
1000 event: HarnessEventKind::VerificationFailed,
1001 message: Some("cargo check failed".to_string()),
1002 command: Some("cargo check".to_string()),
1003 path: None,
1004 exit_code: Some(101),
1005 attempt: None,
1006 error_category: None,
1007 duration_ms: None,
1008 }),
1009 },
1010 });
1011
1012 let json = serde_json::to_string(&event)?;
1013 let restored: ThreadEvent = serde_json::from_str(&json)?;
1014
1015 assert_eq!(restored, event);
1016 Ok(())
1017 }
1018
1019 #[test]
1020 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1021 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1022 thread_id: "thread-1".to_string(),
1023 session_id: "session-1".to_string(),
1024 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1025 outcome_code: "budget_limit_reached".to_string(),
1026 result: None,
1027 stop_reason: Some("max_tokens".to_string()),
1028 usage: Usage {
1029 input_tokens: 10,
1030 cached_input_tokens: 4,
1031 cache_creation_tokens: 2,
1032 output_tokens: 5,
1033 },
1034 total_cost_usd: serde_json::Number::from_f64(1.25),
1035 num_turns: 3,
1036 });
1037
1038 let json = serde_json::to_string(&event)?;
1039 let restored: ThreadEvent = serde_json::from_str(&json)?;
1040
1041 assert_eq!(restored, event);
1042 Ok(())
1043 }
1044
1045 #[test]
1046 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1047 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1048 thread_id: "thread-1".to_string(),
1049 trigger: CompactionTrigger::Recovery,
1050 mode: CompactionMode::Provider,
1051 original_message_count: 12,
1052 compacted_message_count: 5,
1053 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1054 });
1055
1056 let json = serde_json::to_string(&event)?;
1057 let restored: ThreadEvent = serde_json::from_str(&json)?;
1058
1059 assert_eq!(restored, event);
1060 Ok(())
1061 }
1062}