1use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub mod atif;
18pub mod trace;
19
20pub const EVENT_SCHEMA_VERSION: &str = "0.4.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 = "schema-export")]
221pub mod schema {
222 use schemars::{Schema, schema_for};
223
224 use super::{ThreadEvent, VersionedThreadEvent};
225
226 pub fn thread_event_schema() -> Schema {
228 schema_for!(ThreadEvent)
229 }
230
231 pub fn versioned_thread_event_schema() -> Schema {
233 schema_for!(VersionedThreadEvent)
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
240#[serde(tag = "type")]
241pub enum ThreadEvent {
242 #[serde(rename = "thread.started")]
244 ThreadStarted(ThreadStartedEvent),
245 #[serde(rename = "thread.completed")]
247 ThreadCompleted(ThreadCompletedEvent),
248 #[serde(rename = "thread.compact_boundary")]
250 ThreadCompactBoundary(ThreadCompactBoundaryEvent),
251 #[serde(rename = "turn.started")]
253 TurnStarted(TurnStartedEvent),
254 #[serde(rename = "turn.completed")]
256 TurnCompleted(TurnCompletedEvent),
257 #[serde(rename = "turn.failed")]
259 TurnFailed(TurnFailedEvent),
260 #[serde(rename = "item.started")]
262 ItemStarted(ItemStartedEvent),
263 #[serde(rename = "item.updated")]
265 ItemUpdated(ItemUpdatedEvent),
266 #[serde(rename = "item.completed")]
268 ItemCompleted(ItemCompletedEvent),
269 #[serde(rename = "plan.delta")]
271 PlanDelta(PlanDeltaEvent),
272 #[serde(rename = "error")]
274 Error(ThreadErrorEvent),
275 #[serde(other)]
278 Unknown,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
282#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
283pub struct ThreadStartedEvent {
284 pub thread_id: String,
286}
287
288#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
289#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
290#[serde(rename_all = "snake_case")]
291pub enum ThreadCompletionSubtype {
292 Success,
293 ErrorMaxTurns,
294 ErrorMaxBudgetUsd,
295 ErrorDuringExecution,
296 Cancelled,
297 #[serde(other)]
299 Unknown,
300}
301
302impl ThreadCompletionSubtype {
303 pub const fn as_str(&self) -> &'static str {
304 match self {
305 Self::Success => "success",
306 Self::ErrorMaxTurns => "error_max_turns",
307 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
308 Self::ErrorDuringExecution => "error_during_execution",
309 Self::Cancelled => "cancelled",
310 Self::Unknown => "unknown",
311 }
312 }
313
314 pub const fn is_success(self) -> bool {
315 matches!(self, Self::Success)
316 }
317}
318
319#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
320#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
321#[serde(rename_all = "snake_case")]
322pub enum CompactionTrigger {
323 Manual,
324 Auto,
325 Recovery,
326 #[serde(other)]
328 Unknown,
329}
330
331impl CompactionTrigger {
332 pub const fn as_str(self) -> &'static str {
333 match self {
334 Self::Manual => "manual",
335 Self::Auto => "auto",
336 Self::Recovery => "recovery",
337 Self::Unknown => "unknown",
338 }
339 }
340}
341
342#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
343#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
344#[serde(rename_all = "snake_case")]
345pub enum CompactionMode {
346 Provider,
347 Local,
348 #[serde(other)]
350 Unknown,
351}
352
353impl CompactionMode {
354 pub const fn as_str(self) -> &'static str {
355 match self {
356 Self::Provider => "provider",
357 Self::Local => "local",
358 Self::Unknown => "unknown",
359 }
360 }
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
365pub struct ThreadCompletedEvent {
366 pub thread_id: String,
368 pub session_id: String,
370 pub subtype: ThreadCompletionSubtype,
372 pub outcome_code: String,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub result: Option<String>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub stop_reason: Option<String>,
380 pub usage: Usage,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub total_cost_usd: Option<serde_json::Number>,
385 pub num_turns: usize,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
391pub struct ThreadCompactBoundaryEvent {
392 pub thread_id: String,
394 pub trigger: CompactionTrigger,
396 pub mode: CompactionMode,
398 pub original_message_count: usize,
400 pub compacted_message_count: usize,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub history_artifact_path: Option<String>,
405}
406
407#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
408#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
409pub struct TurnStartedEvent {}
410
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
413pub struct TurnCompletedEvent {
414 pub usage: Usage,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
419#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
420pub struct TurnFailedEvent {
421 pub message: String,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub usage: Option<Usage>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
429#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
430pub struct ThreadErrorEvent {
431 pub message: String,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
436#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
437pub struct Usage {
438 pub input_tokens: u64,
440 pub cached_input_tokens: u64,
442 pub cache_creation_tokens: u64,
444 pub output_tokens: u64,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
449#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
450pub struct ItemCompletedEvent {
451 pub item: ThreadItem,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
457pub struct ItemStartedEvent {
458 pub item: ThreadItem,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
464pub struct ItemUpdatedEvent {
465 pub item: ThreadItem,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
470#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
471pub struct PlanDeltaEvent {
472 pub thread_id: String,
474 pub turn_id: String,
476 pub item_id: String,
478 pub delta: String,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
484pub struct ThreadItem {
485 pub id: String,
487 #[serde(flatten)]
489 pub details: ThreadItemDetails,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
493#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
494#[serde(tag = "type", rename_all = "snake_case")]
495pub enum ThreadItemDetails {
496 AgentMessage(AgentMessageItem),
498 Plan(PlanItem),
500 Reasoning(ReasoningItem),
502 CommandExecution(Box<CommandExecutionItem>),
504 ToolInvocation(ToolInvocationItem),
506 ToolOutput(ToolOutputItem),
508 FileChange(Box<FileChangeItem>),
510 McpToolCall(McpToolCallItem),
512 WebSearch(WebSearchItem),
514 Harness(HarnessEventItem),
516 Error(ErrorItem),
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
521#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
522pub struct AgentMessageItem {
523 pub text: String,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
529pub struct PlanItem {
530 pub text: String,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
536pub struct ReasoningItem {
537 pub text: String,
539 #[serde(skip_serializing_if = "Option::is_none")]
541 pub stage: Option<String>,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
545#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
546#[serde(rename_all = "snake_case")]
547pub enum CommandExecutionStatus {
548 #[default]
550 Completed,
551 Failed,
553 InProgress,
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
558#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
559pub struct CommandExecutionItem {
560 pub command: String,
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub arguments: Option<Value>,
565 #[serde(default)]
567 pub aggregated_output: String,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub exit_code: Option<i32>,
571 pub status: CommandExecutionStatus,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
576#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
577#[serde(rename_all = "snake_case")]
578pub enum ToolCallStatus {
579 #[default]
581 Completed,
582 Failed,
584 InProgress,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
589#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
590pub struct ToolInvocationItem {
591 pub tool_name: String,
593 #[serde(skip_serializing_if = "Option::is_none")]
595 pub arguments: Option<Value>,
596 #[serde(skip_serializing_if = "Option::is_none")]
598 pub tool_call_id: Option<String>,
599 pub status: ToolCallStatus,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
604#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
605pub struct ToolOutputItem {
606 pub call_id: String,
608 #[serde(skip_serializing_if = "Option::is_none")]
610 pub tool_call_id: Option<String>,
611 #[serde(skip_serializing_if = "Option::is_none")]
613 pub spool_path: Option<String>,
614 #[serde(default)]
616 pub output: String,
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub exit_code: Option<i32>,
620 pub status: ToolCallStatus,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
625#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
626pub struct FileChangeItem {
627 pub changes: Vec<FileUpdateChange>,
629 pub status: PatchApplyStatus,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
634#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
635pub struct FileUpdateChange {
636 pub path: String,
638 pub kind: PatchChangeKind,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
644#[serde(rename_all = "snake_case")]
645pub enum PatchApplyStatus {
646 Completed,
648 Failed,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
654#[serde(rename_all = "snake_case")]
655pub enum PatchChangeKind {
656 Add,
658 Delete,
660 Update,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
665#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
666pub struct McpToolCallItem {
667 pub tool_name: String,
669 #[serde(skip_serializing_if = "Option::is_none")]
671 pub arguments: Option<Value>,
672 #[serde(skip_serializing_if = "Option::is_none")]
674 pub result: Option<String>,
675 #[serde(skip_serializing_if = "Option::is_none")]
677 pub status: Option<McpToolCallStatus>,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
682#[serde(rename_all = "snake_case")]
683pub enum McpToolCallStatus {
684 Started,
686 Completed,
688 Failed,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
693#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
694pub struct WebSearchItem {
695 pub query: String,
697 #[serde(skip_serializing_if = "Option::is_none")]
699 pub provider: Option<String>,
700 #[serde(skip_serializing_if = "Option::is_none")]
702 pub results: Option<Vec<String>>,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
707#[serde(rename_all = "snake_case")]
708pub enum HarnessEventKind {
709 PlanningStarted,
710 PlanningCompleted,
711 ContinuationStarted,
712 ContinuationSkipped,
713 BlockedHandoffWritten,
714 EvaluationStarted,
715 EvaluationPassed,
716 EvaluationFailed,
717 RevisionStarted,
718 VerificationStarted,
719 VerificationPassed,
720 VerificationFailed,
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
724#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
725pub struct HarnessEventItem {
726 pub event: HarnessEventKind,
728 #[serde(skip_serializing_if = "Option::is_none")]
730 pub message: Option<String>,
731 #[serde(skip_serializing_if = "Option::is_none")]
733 pub command: Option<String>,
734 #[serde(skip_serializing_if = "Option::is_none")]
736 pub path: Option<String>,
737 #[serde(skip_serializing_if = "Option::is_none")]
739 pub exit_code: Option<i32>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
744pub struct ErrorItem {
745 pub message: String,
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use std::error::Error;
753
754 #[test]
755 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
756 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
757 usage: Usage {
758 input_tokens: 1,
759 cached_input_tokens: 2,
760 cache_creation_tokens: 0,
761 output_tokens: 3,
762 },
763 });
764
765 let json = serde_json::to_string(&event)?;
766 let restored: ThreadEvent = serde_json::from_str(&json)?;
767
768 assert_eq!(restored, event);
769 Ok(())
770 }
771
772 #[test]
773 fn versioned_event_wraps_schema_version() {
774 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
775 thread_id: "abc".to_string(),
776 });
777
778 let versioned = VersionedThreadEvent::new(event.clone());
779
780 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
781 assert_eq!(versioned.event, event);
782 assert_eq!(versioned.into_event(), event);
783 }
784
785 #[cfg(feature = "serde-json")]
786 #[test]
787 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
788 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
789 item: ThreadItem {
790 id: "item-1".to_string(),
791 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
792 text: "hello".to_string(),
793 }),
794 },
795 });
796
797 let payload = json::versioned_to_string(&event)?;
798 let restored = json::versioned_from_str(&payload)?;
799
800 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
801 assert_eq!(restored.event, event);
802 Ok(())
803 }
804
805 #[test]
806 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
807 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
808 item: ThreadItem {
809 id: "tool_1".to_string(),
810 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
811 tool_name: "read_file".to_string(),
812 arguments: Some(serde_json::json!({ "path": "README.md" })),
813 tool_call_id: Some("tool_call_0".to_string()),
814 status: ToolCallStatus::Completed,
815 }),
816 },
817 });
818
819 let json = serde_json::to_string(&event)?;
820 let restored: ThreadEvent = serde_json::from_str(&json)?;
821
822 assert_eq!(restored, event);
823 Ok(())
824 }
825
826 #[test]
827 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
828 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
829 item: ThreadItem {
830 id: "tool_1:output".to_string(),
831 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
832 call_id: "tool_1".to_string(),
833 tool_call_id: Some("tool_call_0".to_string()),
834 spool_path: None,
835 output: "done".to_string(),
836 exit_code: Some(0),
837 status: ToolCallStatus::Completed,
838 }),
839 },
840 });
841
842 let json = serde_json::to_string(&event)?;
843 let restored: ThreadEvent = serde_json::from_str(&json)?;
844
845 assert_eq!(restored, event);
846 Ok(())
847 }
848
849 #[test]
850 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
851 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
852 item: ThreadItem {
853 id: "harness_1".to_string(),
854 details: ThreadItemDetails::Harness(HarnessEventItem {
855 event: HarnessEventKind::VerificationFailed,
856 message: Some("cargo check failed".to_string()),
857 command: Some("cargo check".to_string()),
858 path: None,
859 exit_code: Some(101),
860 }),
861 },
862 });
863
864 let json = serde_json::to_string(&event)?;
865 let restored: ThreadEvent = serde_json::from_str(&json)?;
866
867 assert_eq!(restored, event);
868 Ok(())
869 }
870
871 #[test]
872 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
873 let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
874 thread_id: "thread-1".to_string(),
875 session_id: "session-1".to_string(),
876 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
877 outcome_code: "budget_limit_reached".to_string(),
878 result: None,
879 stop_reason: Some("max_tokens".to_string()),
880 usage: Usage {
881 input_tokens: 10,
882 cached_input_tokens: 4,
883 cache_creation_tokens: 2,
884 output_tokens: 5,
885 },
886 total_cost_usd: serde_json::Number::from_f64(1.25),
887 num_turns: 3,
888 });
889
890 let json = serde_json::to_string(&event)?;
891 let restored: ThreadEvent = serde_json::from_str(&json)?;
892
893 assert_eq!(restored, event);
894 Ok(())
895 }
896
897 #[test]
898 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
899 let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
900 thread_id: "thread-1".to_string(),
901 trigger: CompactionTrigger::Recovery,
902 mode: CompactionMode::Provider,
903 original_message_count: 12,
904 compacted_message_count: 5,
905 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
906 });
907
908 let json = serde_json::to_string(&event)?;
909 let restored: ThreadEvent = serde_json::from_str(&json)?;
910
911 assert_eq!(restored, event);
912 Ok(())
913 }
914}