1use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub mod atif;
18pub mod trace;
19
20pub const EVENT_SCHEMA_VERSION: &str = "0.7.0";
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
27pub struct VersionedThreadEvent {
28 pub schema_version: String,
30 pub event: ThreadEvent,
32}
33
34impl VersionedThreadEvent {
35 pub fn new(event: ThreadEvent) -> Self {
38 Self {
39 schema_version: EVENT_SCHEMA_VERSION.to_string(),
40 event,
41 }
42 }
43
44 pub fn into_event(self) -> ThreadEvent {
46 self.event
47 }
48}
49
50impl From<ThreadEvent> for VersionedThreadEvent {
51 fn from(event: ThreadEvent) -> Self {
52 Self::new(event)
53 }
54}
55
56pub trait EventEmitter {
58 fn emit(&mut self, event: &ThreadEvent);
60}
61
62impl<F> EventEmitter for F
63where
64 F: FnMut(&ThreadEvent),
65{
66 fn emit(&mut self, event: &ThreadEvent) {
67 self(event);
68 }
69}
70
71#[cfg(feature = "serde-json")]
73pub mod json {
74 use super::{ThreadEvent, VersionedThreadEvent};
75
76 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
78 serde_json::to_value(event)
79 }
80
81 pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
83 serde_json::to_string(event)
84 }
85
86 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
88 serde_json::from_str(payload)
89 }
90
91 pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
93 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
94 }
95
96 pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
98 serde_json::from_str(payload)
99 }
100}
101
102#[cfg(feature = "telemetry-log")]
103mod log_support {
104 use log::Level;
105
106 use super::{EventEmitter, ThreadEvent, json};
107
108 #[derive(Debug, Clone)]
110 pub struct LogEmitter {
111 level: Level,
112 }
113
114 impl LogEmitter {
115 pub fn new(level: Level) -> Self {
117 Self { level }
118 }
119 }
120
121 impl Default for LogEmitter {
122 fn default() -> Self {
123 Self { level: Level::Info }
124 }
125 }
126
127 impl EventEmitter for LogEmitter {
128 fn emit(&mut self, event: &ThreadEvent) {
129 if log::log_enabled!(self.level) {
130 match json::to_string(event) {
131 Ok(serialized) => log::log!(self.level, "{}", serialized),
132 Err(err) => log::log!(
133 self.level,
134 "failed to serialize vtcode exec event for logging: {err}"
135 ),
136 }
137 }
138 }
139 }
140
141 pub use LogEmitter as PublicLogEmitter;
142}
143
144#[cfg(feature = "telemetry-log")]
145pub use log_support::PublicLogEmitter as LogEmitter;
146
147#[cfg(feature = "telemetry-tracing")]
148mod tracing_support {
149 use tracing::Level;
150
151 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
152
153 #[derive(Debug, Clone)]
155 pub struct TracingEmitter {
156 level: Level,
157 }
158
159 impl TracingEmitter {
160 pub fn new(level: Level) -> Self {
162 Self { level }
163 }
164 }
165
166 impl Default for TracingEmitter {
167 fn default() -> Self {
168 Self { level: Level::INFO }
169 }
170 }
171
172 impl EventEmitter for TracingEmitter {
173 fn emit(&mut self, event: &ThreadEvent) {
174 match self.level {
175 Level::TRACE => tracing::event!(
176 target: "vtcode_exec_events",
177 Level::TRACE,
178 schema_version = EVENT_SCHEMA_VERSION,
179 event = ?VersionedThreadEvent::new(event.clone()),
180 "vtcode_exec_event"
181 ),
182 Level::DEBUG => tracing::event!(
183 target: "vtcode_exec_events",
184 Level::DEBUG,
185 schema_version = EVENT_SCHEMA_VERSION,
186 event = ?VersionedThreadEvent::new(event.clone()),
187 "vtcode_exec_event"
188 ),
189 Level::INFO => tracing::event!(
190 target: "vtcode_exec_events",
191 Level::INFO,
192 schema_version = EVENT_SCHEMA_VERSION,
193 event = ?VersionedThreadEvent::new(event.clone()),
194 "vtcode_exec_event"
195 ),
196 Level::WARN => tracing::event!(
197 target: "vtcode_exec_events",
198 Level::WARN,
199 schema_version = EVENT_SCHEMA_VERSION,
200 event = ?VersionedThreadEvent::new(event.clone()),
201 "vtcode_exec_event"
202 ),
203 Level::ERROR => tracing::event!(
204 target: "vtcode_exec_events",
205 Level::ERROR,
206 schema_version = EVENT_SCHEMA_VERSION,
207 event = ?VersionedThreadEvent::new(event.clone()),
208 "vtcode_exec_event"
209 ),
210 }
211 }
212 }
213
214 pub use TracingEmitter as PublicTracingEmitter;
215}
216
217#[cfg(feature = "telemetry-tracing")]
218pub use tracing_support::PublicTracingEmitter as TracingEmitter;
219
220#[cfg(feature = "telemetry-otel")]
221mod otel_support {
222 use opentelemetry::KeyValue;
223 use opentelemetry::trace::{Span, Status, Tracer};
224
225 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
226
227 pub struct OtelEmitter<T: Tracer> {
244 tracer: T,
245 }
246
247 impl<T: Tracer> OtelEmitter<T> {
248 pub fn new(tracer: T) -> Self {
249 Self { tracer }
250 }
251 }
252
253 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
254 fn emit(&mut self, event: &ThreadEvent) {
255 let span_name = match event {
256 ThreadEvent::ThreadStarted(_) => "thread.started",
257 ThreadEvent::ThreadCompleted(_) => "thread.completed",
258 ThreadEvent::TurnStarted(_) => "turn.started",
259 ThreadEvent::TurnCompleted(_) => "turn.completed",
260 ThreadEvent::TurnFailed(_) => "turn.failed",
261 ThreadEvent::ItemStarted(_) => "item.started",
262 ThreadEvent::ItemUpdated(_) => "item.updated",
263 ThreadEvent::ItemCompleted(_) => "item.completed",
264 ThreadEvent::Error(_) => "error",
265 _ => "event",
266 };
267
268 let mut span = self.tracer.start(span_name);
269
270 match event {
271 ThreadEvent::ThreadStarted(e) => {
272 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
273 }
274 ThreadEvent::ThreadCompleted(e) => {
275 if let Some(ref cost) = e.total_cost_usd {
276 span.set_attribute(KeyValue::new(
277 "total_cost_usd",
278 cost.as_f64().unwrap_or(0.0),
279 ));
280 }
281 span.set_attribute(KeyValue::new("input_tokens", e.usage.input_tokens as i64));
282 span.set_attribute(KeyValue::new(
283 "output_tokens",
284 e.usage.output_tokens as i64,
285 ));
286 span.set_attribute(KeyValue::new(
287 "completion_subtype",
288 e.subtype.as_str().to_string(),
289 ));
290 }
291 ThreadEvent::TurnCompleted(e) => {
292 span.set_attribute(KeyValue::new(
293 "turn_input_tokens",
294 e.usage.input_tokens as i64,
295 ));
296 span.set_attribute(KeyValue::new(
297 "turn_output_tokens",
298 e.usage.output_tokens as i64,
299 ));
300 }
301 ThreadEvent::ItemCompleted(e) => {
302 if let ThreadItemDetails::Harness(harness) = &e.item.details {
303 span.set_attribute(KeyValue::new(
304 "harness_event",
305 format!("{:?}", harness.event),
306 ));
307 if let Some(ref msg) = harness.message {
308 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
309 }
310 if let Some(ref path) = harness.path {
311 span.set_attribute(KeyValue::new("harness_path", path.clone()));
312 }
313 if let Some(dur) = harness.duration_ms {
314 span.set_attribute(KeyValue::new("duration_ms", dur as i64));
315 }
316 let mut event_attrs =
317 vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
318 if let Some(ref msg) = harness.message {
319 event_attrs.push(KeyValue::new("message", msg.clone()));
320 }
321 span.add_event("harness_event", event_attrs);
322 }
323 }
324 ThreadEvent::Error(e) => {
325 span.set_status(Status::Error {
326 description: e.message.clone().into(),
327 });
328 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
329 }
330 _ => {}
331 }
332
333 span.end();
334 }
335 }
336
337 pub use OtelEmitter as PublicOtelEmitter;
338}
339
340#[cfg(feature = "telemetry-otel")]
341pub use otel_support::PublicOtelEmitter as OtelEmitter;
342
343#[cfg(feature = "schema-export")]
344pub mod schema {
345 use schemars::{Schema, schema_for};
346
347 use super::{ThreadEvent, VersionedThreadEvent};
348
349 pub fn thread_event_schema() -> Schema {
351 schema_for!(ThreadEvent)
352 }
353
354 pub fn versioned_thread_event_schema() -> Schema {
356 schema_for!(VersionedThreadEvent)
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
363#[serde(tag = "type")]
364pub enum ThreadEvent {
365 #[serde(rename = "thread.started")]
367 ThreadStarted(ThreadStartedEvent),
368 #[serde(rename = "thread.completed")]
370 ThreadCompleted(ThreadCompletedEvent),
371 #[serde(rename = "thread.compact_boundary")]
373 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
374 #[serde(rename = "turn.started")]
376 TurnStarted(TurnStartedEvent),
377 #[serde(rename = "turn.completed")]
379 TurnCompleted(TurnCompletedEvent),
380 #[serde(rename = "turn.failed")]
382 TurnFailed(TurnFailedEvent),
383 #[serde(rename = "item.started")]
385 ItemStarted(ItemStartedEvent),
386 #[serde(rename = "item.updated")]
388 ItemUpdated(ItemUpdatedEvent),
389 #[serde(rename = "item.completed")]
391 ItemCompleted(ItemCompletedEvent),
392 #[serde(rename = "plan.delta")]
394 PlanDelta(PlanDeltaEvent),
395 #[serde(rename = "error")]
397 Error(ThreadErrorEvent),
398 #[serde(other)]
401 Unknown,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
405#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
406pub struct ThreadStartedEvent {
407 pub thread_id: String,
409}
410
411#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
413#[serde(rename_all = "snake_case")]
414pub enum ThreadCompletionSubtype {
415 Success,
416 ErrorMaxTurns,
417 ErrorMaxBudgetUsd,
418 ErrorDuringExecution,
419 Cancelled,
420 #[serde(other)]
422 Unknown,
423}
424
425impl ThreadCompletionSubtype {
426 pub const fn as_str(&self) -> &'static str {
427 match self {
428 Self::Success => "success",
429 Self::ErrorMaxTurns => "error_max_turns",
430 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
431 Self::ErrorDuringExecution => "error_during_execution",
432 Self::Cancelled => "cancelled",
433 Self::Unknown => "unknown",
434 }
435 }
436
437 pub const fn is_success(self) -> bool {
438 matches!(self, Self::Success)
439 }
440}
441
442#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
443#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
444#[serde(rename_all = "snake_case")]
445pub enum CompactionTrigger {
446 Manual,
447 Auto,
448 Recovery,
449 #[serde(other)]
451 Unknown,
452}
453
454impl CompactionTrigger {
455 pub const fn as_str(self) -> &'static str {
456 match self {
457 Self::Manual => "manual",
458 Self::Auto => "auto",
459 Self::Recovery => "recovery",
460 Self::Unknown => "unknown",
461 }
462 }
463}
464
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
466#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
467#[serde(rename_all = "snake_case")]
468pub enum CompactionMode {
469 Provider,
470 Local,
471 #[serde(other)]
473 Unknown,
474}
475
476impl CompactionMode {
477 pub const fn as_str(self) -> &'static str {
478 match self {
479 Self::Provider => "provider",
480 Self::Local => "local",
481 Self::Unknown => "unknown",
482 }
483 }
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
488pub struct ThreadCompletedEvent {
489 pub thread_id: String,
491 pub session_id: String,
493 pub subtype: ThreadCompletionSubtype,
495 pub outcome_code: String,
497 #[serde(skip_serializing_if = "Option::is_none")]
499 pub result: Option<String>,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub stop_reason: Option<String>,
503 pub usage: Usage,
505 #[serde(skip_serializing_if = "Option::is_none")]
507 pub total_cost_usd: Option<serde_json::Number>,
508 pub num_turns: usize,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
514pub struct ThreadCompactBoundaryEvent {
515 pub thread_id: String,
517 pub trigger: CompactionTrigger,
519 pub mode: CompactionMode,
521 pub original_message_count: usize,
523 pub compacted_message_count: usize,
525 #[serde(skip_serializing_if = "Option::is_none")]
527 pub history_artifact_path: Option<String>,
528}
529
530#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
531#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
532pub struct TurnStartedEvent {}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
536pub struct TurnCompletedEvent {
537 pub usage: Usage,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
543pub struct TurnFailedEvent {
544 pub message: String,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub usage: Option<Usage>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
552#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
553pub struct ThreadErrorEvent {
554 pub message: String,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
560pub struct Usage {
561 pub input_tokens: u64,
563 pub cached_input_tokens: u64,
565 pub cache_creation_tokens: u64,
567 pub output_tokens: u64,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
573pub struct ItemCompletedEvent {
574 pub item: ThreadItem,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
579#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
580pub struct ItemStartedEvent {
581 pub item: ThreadItem,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
587pub struct ItemUpdatedEvent {
588 pub item: ThreadItem,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
593#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
594pub struct PlanDeltaEvent {
595 pub thread_id: String,
597 pub turn_id: String,
599 pub item_id: String,
601 pub delta: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
607pub struct ThreadItem {
608 pub id: String,
610 #[serde(flatten)]
612 pub details: ThreadItemDetails,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
616#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
617#[serde(tag = "type", rename_all = "snake_case")]
618pub enum ThreadItemDetails {
619 AgentMessage(AgentMessageItem),
621 Plan(PlanItem),
623 Reasoning(ReasoningItem),
625 CommandExecution(Box<CommandExecutionItem>),
627 ToolInvocation(ToolInvocationItem),
629 ToolOutput(ToolOutputItem),
631 FileChange(Box<FileChangeItem>),
633 McpToolCall(McpToolCallItem),
635 WebSearch(WebSearchItem),
637 Harness(HarnessEventItem),
639 Error(ErrorItem),
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
644#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
645pub struct AgentMessageItem {
646 pub text: String,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
652pub struct PlanItem {
653 pub text: String,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
659pub struct ReasoningItem {
660 pub text: String,
662 #[serde(skip_serializing_if = "Option::is_none")]
664 pub stage: Option<String>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
668#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
669#[serde(rename_all = "snake_case")]
670pub enum CommandExecutionStatus {
671 #[default]
673 Completed,
674 Failed,
676 InProgress,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
682pub struct CommandExecutionItem {
683 pub command: String,
685 #[serde(skip_serializing_if = "Option::is_none")]
687 pub arguments: Option<Value>,
688 #[serde(default)]
690 pub aggregated_output: String,
691 #[serde(skip_serializing_if = "Option::is_none")]
693 pub exit_code: Option<i32>,
694 pub status: CommandExecutionStatus,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
700#[serde(rename_all = "snake_case")]
701pub enum ToolCallStatus {
702 #[default]
704 Completed,
705 Failed,
707 InProgress,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
712#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
713pub struct ToolInvocationItem {
714 pub tool_name: String,
716 #[serde(skip_serializing_if = "Option::is_none")]
718 pub arguments: Option<Value>,
719 #[serde(skip_serializing_if = "Option::is_none")]
721 pub tool_call_id: Option<String>,
722 pub status: ToolCallStatus,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
727#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
728pub struct ToolOutputItem {
729 pub call_id: String,
731 #[serde(skip_serializing_if = "Option::is_none")]
733 pub tool_call_id: Option<String>,
734 #[serde(skip_serializing_if = "Option::is_none")]
736 pub spool_path: Option<String>,
737 #[serde(default)]
739 pub output: String,
740 #[serde(skip_serializing_if = "Option::is_none")]
742 pub exit_code: Option<i32>,
743 pub status: ToolCallStatus,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
748#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
749pub struct FileChangeItem {
750 pub changes: Vec<FileUpdateChange>,
752 pub status: PatchApplyStatus,
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
757#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
758pub struct FileUpdateChange {
759 pub path: String,
761 pub kind: PatchChangeKind,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
766#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
767#[serde(rename_all = "snake_case")]
768pub enum PatchApplyStatus {
769 Completed,
771 Failed,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777#[serde(rename_all = "snake_case")]
778pub enum PatchChangeKind {
779 Add,
781 Delete,
783 Update,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
789pub struct McpToolCallItem {
790 pub tool_name: String,
792 #[serde(skip_serializing_if = "Option::is_none")]
794 pub arguments: Option<Value>,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub result: Option<String>,
798 #[serde(skip_serializing_if = "Option::is_none")]
800 pub status: Option<McpToolCallStatus>,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
804#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
805#[serde(rename_all = "snake_case")]
806pub enum McpToolCallStatus {
807 Started,
809 Completed,
811 Failed,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
817pub struct WebSearchItem {
818 pub query: String,
820 #[serde(skip_serializing_if = "Option::is_none")]
822 pub provider: Option<String>,
823 #[serde(skip_serializing_if = "Option::is_none")]
825 pub results: Option<Vec<String>>,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
830#[serde(rename_all = "snake_case")]
831pub enum HarnessEventKind {
832 PlanningStarted,
833 PlanningCompleted,
834 ContinuationStarted,
835 ContinuationSkipped,
836 BlockedHandoffWritten,
837 EvaluationStarted,
838 EvaluationPassed,
839 EvaluationFailed,
840 RevisionStarted,
841 EscalationTriggered,
842 EscalationBypassed,
843 VerificationStarted,
844 VerificationPassed,
845 VerificationFailed,
846 ErrorRecovered,
848 ToolRetryAttempted,
850 ToolLatencyRecorded,
852 SnapshotCreated,
854 SnapshotRestored,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
860pub struct HarnessEventItem {
861 pub event: HarnessEventKind,
863 #[serde(skip_serializing_if = "Option::is_none")]
865 pub message: Option<String>,
866 #[serde(skip_serializing_if = "Option::is_none")]
868 pub command: Option<String>,
869 #[serde(skip_serializing_if = "Option::is_none")]
871 pub path: Option<String>,
872 #[serde(skip_serializing_if = "Option::is_none")]
874 pub exit_code: Option<i32>,
875 #[serde(skip_serializing_if = "Option::is_none")]
877 pub attempt: Option<u32>,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub error_category: Option<String>,
881 #[serde(skip_serializing_if = "Option::is_none")]
883 pub duration_ms: Option<u64>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
887#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
888pub struct ErrorItem {
889 pub message: String,
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use std::error::Error;
897
898 #[test]
899 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
900 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
901 usage: Usage {
902 input_tokens: 1,
903 cached_input_tokens: 2,
904 cache_creation_tokens: 0,
905 output_tokens: 3,
906 },
907 });
908
909 let json = serde_json::to_string(&event)?;
910 let restored: ThreadEvent = serde_json::from_str(&json)?;
911
912 assert_eq!(restored, event);
913 Ok(())
914 }
915
916 #[test]
917 fn versioned_event_wraps_schema_version() {
918 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
919 thread_id: "abc".to_string(),
920 });
921
922 let versioned = VersionedThreadEvent::new(event.clone());
923
924 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
925 assert_eq!(versioned.event, event);
926 assert_eq!(versioned.into_event(), event);
927 }
928
929 #[cfg(feature = "serde-json")]
930 #[test]
931 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
932 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
933 item: ThreadItem {
934 id: "item-1".to_string(),
935 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
936 text: "hello".to_string(),
937 }),
938 },
939 });
940
941 let payload = json::versioned_to_string(&event)?;
942 let restored = json::versioned_from_str(&payload)?;
943
944 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
945 assert_eq!(restored.event, event);
946 Ok(())
947 }
948
949 #[test]
950 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
951 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
952 item: ThreadItem {
953 id: "tool_1".to_string(),
954 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
955 tool_name: "read_file".to_string(),
956 arguments: Some(serde_json::json!({ "path": "README.md" })),
957 tool_call_id: Some("tool_call_0".to_string()),
958 status: ToolCallStatus::Completed,
959 }),
960 },
961 });
962
963 let json = serde_json::to_string(&event)?;
964 let restored: ThreadEvent = serde_json::from_str(&json)?;
965
966 assert_eq!(restored, event);
967 Ok(())
968 }
969
970 #[test]
971 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
972 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
973 item: ThreadItem {
974 id: "tool_1:output".to_string(),
975 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
976 call_id: "tool_1".to_string(),
977 tool_call_id: Some("tool_call_0".to_string()),
978 spool_path: None,
979 output: "done".to_string(),
980 exit_code: Some(0),
981 status: ToolCallStatus::Completed,
982 }),
983 },
984 });
985
986 let json = serde_json::to_string(&event)?;
987 let restored: ThreadEvent = serde_json::from_str(&json)?;
988
989 assert_eq!(restored, event);
990 Ok(())
991 }
992
993 #[test]
994 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
995 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
996 item: ThreadItem {
997 id: "harness_1".to_string(),
998 details: ThreadItemDetails::Harness(HarnessEventItem {
999 event: HarnessEventKind::VerificationFailed,
1000 message: Some("cargo check failed".to_string()),
1001 command: Some("cargo check".to_string()),
1002 path: None,
1003 exit_code: Some(101),
1004 attempt: None,
1005 error_category: None,
1006 }),
1007 },
1008 });
1009
1010 let json = serde_json::to_string(&event)?;
1011 let restored: ThreadEvent = serde_json::from_str(&json)?;
1012
1013 assert_eq!(restored, event);
1014 Ok(())
1015 }
1016
1017 #[test]
1018 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1019 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
1020 thread_id: "thread-1".to_string(),
1021 session_id: "session-1".to_string(),
1022 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1023 outcome_code: "budget_limit_reached".to_string(),
1024 result: None,
1025 stop_reason: Some("max_tokens".to_string()),
1026 usage: Usage {
1027 input_tokens: 10,
1028 cached_input_tokens: 4,
1029 cache_creation_tokens: 2,
1030 output_tokens: 5,
1031 },
1032 total_cost_usd: serde_json::Number::from_f64(1.25),
1033 num_turns: 3,
1034 });
1035
1036 let json = serde_json::to_string(&event)?;
1037 let restored: ThreadEvent = serde_json::from_str(&json)?;
1038
1039 assert_eq!(restored, event);
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1045 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
1046 thread_id: "thread-1".to_string(),
1047 trigger: CompactionTrigger::Recovery,
1048 mode: CompactionMode::Provider,
1049 original_message_count: 12,
1050 compacted_message_count: 5,
1051 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1052 });
1053
1054 let json = serde_json::to_string(&event)?;
1055 let restored: ThreadEvent = serde_json::from_str(&json)?;
1056
1057 assert_eq!(restored, event);
1058 Ok(())
1059 }
1060}