1#![allow(
2 missing_docs,
3 dead_code,
4 unused_imports,
5 reason = "Intentional compatibility, platform, or test-only suppression."
6)]
7use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub mod atif;
24pub mod trace;
25
26pub const EVENT_SCHEMA_VERSION: &str = "0.15.0";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
33pub struct VersionedThreadEvent {
34 schema_version: String,
36 event: ThreadEvent,
38}
39
40impl VersionedThreadEvent {
41 pub fn new(event: ThreadEvent) -> Self {
44 Self {
45 schema_version: EVENT_SCHEMA_VERSION.to_string(),
46 event,
47 }
48 }
49
50 pub fn into_event(self) -> ThreadEvent {
52 self.event
53 }
54}
55
56impl From<ThreadEvent> for VersionedThreadEvent {
57 fn from(event: ThreadEvent) -> Self {
58 Self::new(event)
59 }
60}
61
62pub trait EventEmitter {
64 fn emit(&mut self, event: &ThreadEvent);
66}
67
68impl<F> EventEmitter for F
69where
70 F: FnMut(&ThreadEvent),
71{
72 fn emit(&mut self, event: &ThreadEvent) {
73 self(event);
74 }
75}
76
77#[cfg(feature = "serde-json")]
79pub(crate) mod json {
80 use super::{ThreadEvent, VersionedThreadEvent};
81
82 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
84 serde_json::to_value(event)
85 }
86
87 pub(crate) fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
89 serde_json::to_string(event)
90 }
91
92 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
94 serde_json::from_str(payload)
95 }
96
97 pub(crate) fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
99 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
100 }
101
102 pub(crate) fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
104 serde_json::from_str(payload)
105 }
106}
107
108#[cfg(feature = "telemetry-log")]
109mod log_support {
110 use log::Level;
111
112 use super::{EventEmitter, ThreadEvent, json};
113
114 #[derive(Debug, Clone)]
116 pub struct LogEmitter {
117 level: Level,
118 }
119
120 impl LogEmitter {
121 pub fn new(level: Level) -> Self {
123 Self { level }
124 }
125 }
126
127 impl Default for LogEmitter {
128 fn default() -> Self {
129 Self { level: Level::Info }
130 }
131 }
132
133 impl EventEmitter for LogEmitter {
134 fn emit(&mut self, event: &ThreadEvent) {
135 if log::log_enabled!(self.level) {
136 match json::to_string(event) {
137 Ok(serialized) => log::log!(self.level, "{serialized}"),
138 Err(err) => log::log!(self.level, "failed to serialize vtcode exec event for logging: {err}"),
139 }
140 }
141 }
142 }
143
144 pub use LogEmitter as PublicLogEmitter;
145}
146
147#[cfg(feature = "telemetry-log")]
148pub use log_support::PublicLogEmitter as LogEmitter;
149
150#[cfg(feature = "telemetry-tracing")]
151mod tracing_support {
152 use tracing::Level;
153
154 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
155
156 #[derive(Debug, Clone)]
158 pub struct TracingEmitter {
159 level: Level,
160 }
161
162 impl TracingEmitter {
163 pub fn new(level: Level) -> Self {
165 Self { level }
166 }
167 }
168
169 impl Default for TracingEmitter {
170 fn default() -> Self {
171 Self { level: Level::INFO }
172 }
173 }
174
175 impl EventEmitter for TracingEmitter {
176 fn emit(&mut self, event: &ThreadEvent) {
177 match self.level {
178 Level::TRACE => tracing::event!(
179 target: "vtcode_exec_events",
180 Level::TRACE,
181 schema_version = EVENT_SCHEMA_VERSION,
182 event = ?VersionedThreadEvent::new(event.clone()),
183 "vtcode_exec_event"
184 ),
185 Level::DEBUG => tracing::event!(
186 target: "vtcode_exec_events",
187 Level::DEBUG,
188 schema_version = EVENT_SCHEMA_VERSION,
189 event = ?VersionedThreadEvent::new(event.clone()),
190 "vtcode_exec_event"
191 ),
192 Level::INFO => tracing::event!(
193 target: "vtcode_exec_events",
194 Level::INFO,
195 schema_version = EVENT_SCHEMA_VERSION,
196 event = ?VersionedThreadEvent::new(event.clone()),
197 "vtcode_exec_event"
198 ),
199 Level::WARN => tracing::event!(
200 target: "vtcode_exec_events",
201 Level::WARN,
202 schema_version = EVENT_SCHEMA_VERSION,
203 event = ?VersionedThreadEvent::new(event.clone()),
204 "vtcode_exec_event"
205 ),
206 Level::ERROR => tracing::event!(
207 target: "vtcode_exec_events",
208 Level::ERROR,
209 schema_version = EVENT_SCHEMA_VERSION,
210 event = ?VersionedThreadEvent::new(event.clone()),
211 "vtcode_exec_event"
212 ),
213 }
214 }
215 }
216
217 pub use TracingEmitter as PublicTracingEmitter;
218}
219
220#[cfg(feature = "telemetry-tracing")]
221pub use tracing_support::PublicTracingEmitter as TracingEmitter;
222
223#[cfg(feature = "telemetry-otel")]
224mod otel_support {
225 use opentelemetry::KeyValue;
226 use opentelemetry::trace::{Span, Status, Tracer};
227
228 use super::{EventEmitter, ThreadEvent, ThreadItemDetails};
229
230 pub struct OtelEmitter<T: Tracer> {
246 tracer: T,
247 }
248
249 impl<T: Tracer> OtelEmitter<T> {
250 pub fn new(tracer: T) -> Self {
251 Self { tracer }
252 }
253 }
254
255 impl<T: Tracer> EventEmitter for OtelEmitter<T> {
256 fn emit(&mut self, event: &ThreadEvent) {
257 let span_name = match event {
258 ThreadEvent::ThreadStarted(_) => "thread.started",
259 ThreadEvent::ThreadCompleted(_) => "thread.completed",
260 ThreadEvent::ContextReset(_) => "context.reset",
261 ThreadEvent::TurnStarted(_) => "turn.started",
262 ThreadEvent::TurnCompleted(_) => "turn.completed",
263 ThreadEvent::TurnFailed(_) => "turn.failed",
264 ThreadEvent::ItemStarted(_) => "item.started",
265 ThreadEvent::ItemUpdated(_) => "item.updated",
266 ThreadEvent::ItemCompleted(_) => "item.completed",
267 ThreadEvent::Error(_) => "error",
268 _ => "event",
269 };
270
271 let mut span = self.tracer.start(span_name);
272
273 match event {
274 ThreadEvent::ThreadStarted(e) => {
275 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
276 }
277 ThreadEvent::ThreadCompleted(e) => {
278 if let Some(ref cost) = e.total_cost_usd {
279 span.set_attribute(KeyValue::new("total_cost_usd", cost.as_f64().unwrap_or(0.0)));
280 }
281 span.set_attribute(KeyValue::new(
282 "input_tokens",
283 i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
284 ));
285 span.set_attribute(KeyValue::new(
286 "output_tokens",
287 i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
288 ));
289 span.set_attribute(KeyValue::new("completion_subtype", e.subtype.as_str().to_string()));
290 }
291 ThreadEvent::ContextReset(e) => {
292 span.set_attribute(KeyValue::new("thread_id", e.thread_id.clone()));
293 span.set_attribute(KeyValue::new("turn_id", e.turn_id.clone()));
294 span.set_attribute(KeyValue::new("plan_preserved", e.plan_preserved));
295 span.set_attribute(KeyValue::new(
296 "previous_context_usage_percent",
297 e.previous_context_usage_percent as i64,
298 ));
299 span.set_attribute(KeyValue::new("tool_budget_reset", e.tool_budget_reset));
300 }
301 ThreadEvent::TurnCompleted(e) => {
302 span.set_attribute(KeyValue::new(
303 "turn_input_tokens",
304 i64::try_from(e.usage.input_tokens).unwrap_or(i64::MAX),
305 ));
306 span.set_attribute(KeyValue::new(
307 "turn_output_tokens",
308 i64::try_from(e.usage.output_tokens).unwrap_or(i64::MAX),
309 ));
310 }
311 ThreadEvent::ItemCompleted(e) => {
312 if let ThreadItemDetails::Harness(harness) = &e.item.details {
313 span.set_attribute(KeyValue::new("harness_event", format!("{:?}", harness.event)));
314 if let Some(ref msg) = harness.message {
315 span.set_attribute(KeyValue::new("harness_message", msg.clone()));
316 }
317 if let Some(ref path) = harness.path {
318 span.set_attribute(KeyValue::new("harness_path", path.clone()));
319 }
320 if let Some(dur) = harness.duration_ms {
321 span.set_attribute(KeyValue::new("duration_ms", i64::try_from(dur).unwrap_or(i64::MAX)));
322 }
323 let mut event_attrs = vec![KeyValue::new("event_kind", format!("{:?}", harness.event))];
324 if let Some(ref msg) = harness.message {
325 event_attrs.push(KeyValue::new("message", msg.clone()));
326 }
327 span.add_event("harness_event", event_attrs);
328 }
329 }
330 ThreadEvent::Error(e) => {
331 span.set_status(Status::Error { description: e.message.clone().into() });
332 span.set_attribute(KeyValue::new("error_message", e.message.clone()));
333 }
334 _ => {}
335 }
336
337 span.end();
338 }
339 }
340
341 pub use OtelEmitter as PublicOtelEmitter;
342}
343
344#[cfg(feature = "telemetry-otel")]
345pub use otel_support::PublicOtelEmitter as OtelEmitter;
346
347#[cfg(feature = "schema-export")]
348pub mod schema {
349 use schemars::{Schema, schema_for};
350
351 use super::{ThreadEvent, VersionedThreadEvent};
352
353 pub fn thread_event_schema() -> Schema {
355 schema_for!(ThreadEvent)
356 }
357
358 pub fn versioned_thread_event_schema() -> Schema {
360 schema_for!(VersionedThreadEvent)
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
366#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
367#[serde(tag = "type")]
368pub enum ThreadEvent {
369 #[serde(rename = "thread.started")]
371 ThreadStarted(ThreadStartedEvent),
372 #[serde(rename = "thread.completed")]
374 ThreadCompleted(Box<ThreadCompletedEvent>),
375 #[serde(rename = "thread.compact_boundary")]
377 ThreadCompactBoundary(Box<ThreadCompactBoundaryEvent>),
378 #[serde(rename = "context.reset")]
380 ContextReset(ContextResetEvent),
381 #[serde(rename = "turn.started")]
383 TurnStarted(TurnStartedEvent),
384 #[serde(rename = "turn.completed")]
386 TurnCompleted(TurnCompletedEvent),
387 #[serde(rename = "turn.failed")]
389 TurnFailed(TurnFailedEvent),
390 #[serde(rename = "turn.blocked")]
394 TurnBlocked(Box<TurnBlockedEvent>),
395 #[serde(rename = "item.started")]
397 ItemStarted(ItemStartedEvent),
398 #[serde(rename = "item.updated")]
400 ItemUpdated(ItemUpdatedEvent),
401 #[serde(rename = "item.completed")]
403 ItemCompleted(ItemCompletedEvent),
404 #[serde(rename = "permission.requested")]
406 PermissionRequested(PermissionRequestedEvent),
407 #[serde(rename = "permission.resolved")]
409 PermissionResolved(PermissionResolvedEvent),
410 #[serde(rename = "interjected")]
412 Interjected(InterjectedEvent),
413 #[serde(rename = "plan.delta")]
415 PlanDelta(Box<PlanDeltaEvent>),
416 #[serde(rename = "plan.approval.requested")]
418 PlanApprovalRequested(PlanApprovalRequestedEvent),
419 #[serde(rename = "plan.approval.resolved")]
421 PlanApprovalResolved(PlanApprovalResolvedEvent),
422 #[serde(rename = "error")]
424 Error(ThreadErrorEvent),
425 #[serde(other)]
428 Unknown,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
432#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
433pub struct ThreadStartedEvent {
434 pub thread_id: String,
436}
437
438#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
439#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
440#[serde(rename_all = "snake_case")]
441pub enum ThreadCompletionSubtype {
442 Success,
443 ErrorMaxTurns,
444 ErrorMaxBudgetUsd,
445 ErrorDuringExecution,
446 Cancelled,
447 #[serde(other)]
449 Unknown,
450}
451
452impl ThreadCompletionSubtype {
453 pub const fn as_str(&self) -> &'static str {
454 match self {
455 Self::Success => "success",
456 Self::ErrorMaxTurns => "error_max_turns",
457 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
458 Self::ErrorDuringExecution => "error_during_execution",
459 Self::Cancelled => "cancelled",
460 Self::Unknown => "unknown",
461 }
462 }
463
464 pub const fn is_success(self) -> bool {
465 matches!(self, Self::Success)
466 }
467}
468
469#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
470#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
471#[serde(rename_all = "snake_case")]
472pub enum CompactionTrigger {
473 Manual,
474 Auto,
475 Recovery,
476 ModelSwitch,
479 #[serde(other)]
481 Unknown,
482}
483
484impl CompactionTrigger {
485 pub const fn as_str(self) -> &'static str {
486 match self {
487 Self::Manual => "manual",
488 Self::Auto => "auto",
489 Self::Recovery => "recovery",
490 Self::ModelSwitch => "model_switch",
491 Self::Unknown => "unknown",
492 }
493 }
494}
495
496#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
497#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
498#[serde(rename_all = "snake_case")]
499pub enum CompactionMode {
500 Provider,
501 Local,
502 #[serde(other)]
504 Unknown,
505}
506
507impl CompactionMode {
508 pub const fn as_str(self) -> &'static str {
509 match self {
510 Self::Provider => "provider",
511 Self::Local => "local",
512 Self::Unknown => "unknown",
513 }
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
519pub struct ThreadCompletedEvent {
520 pub thread_id: String,
522 pub session_id: String,
524 pub subtype: ThreadCompletionSubtype,
526 pub outcome_code: String,
528 #[serde(skip_serializing_if = "Option::is_none")]
530 pub result: Option<String>,
531 #[serde(skip_serializing_if = "Option::is_none")]
533 pub stop_reason: Option<String>,
534 pub usage: Usage,
536 #[serde(skip_serializing_if = "Option::is_none")]
538 pub total_cost_usd: Option<serde_json::Number>,
539 pub num_turns: usize,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
544#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
545pub struct ThreadCompactBoundaryEvent {
546 pub thread_id: String,
548 pub trigger: CompactionTrigger,
550 pub mode: CompactionMode,
552 pub original_message_count: usize,
554 pub compacted_message_count: usize,
556 #[serde(skip_serializing_if = "Option::is_none")]
558 pub history_artifact_path: Option<String>,
559 #[serde(skip_serializing_if = "Option::is_none")]
561 pub previous_segment_id: Option<String>,
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub new_segment_id: Option<String>,
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub previous_prefix_hash: Option<String>,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub new_prefix_hash: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub previous_catalog_hash: Option<String>,
574 #[serde(skip_serializing_if = "Option::is_none")]
576 pub new_catalog_hash: Option<String>,
577}
578
579#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
581#[serde(rename_all = "snake_case")]
582pub enum ContextResetTrigger {
583 PlanApproval,
585 #[serde(other)]
587 Unknown,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct ContextResetEvent {
593 pub thread_id: String,
595 pub turn_id: String,
597 pub trigger: ContextResetTrigger,
599 pub plan_preserved: bool,
601 pub previous_context_usage_percent: u8,
603 pub tool_budget_reset: bool,
605}
606
607#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
608#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
609pub struct TurnStartedEvent {
610 #[serde(skip_serializing_if = "Option::is_none")]
614 token_breakdown: Option<TokenBreakdown>,
615}
616
617#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
619#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
620pub struct TokenBreakdown {
621 system_prompt_tokens: u64,
623 tool_schema_tokens: u64,
625 instruction_file_tokens: u64,
627 message_history_tokens: u64,
629 cache_read_tokens: u64,
631 cache_write_tokens: u64,
633 cache_miss_tokens: u64,
635 #[serde(skip_serializing_if = "Option::is_none")]
637 subagent_bootstrap_tokens: Option<u64>,
638}
639
640pub const MAX_IN_PROGRESS_EXEC_SESSIONS: usize = 4;
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
647pub struct TurnCompletedEvent {
648 pub usage: Usage,
650 #[serde(
655 default,
656 skip_serializing_if = "Vec::is_empty",
657 deserialize_with = "deserialize_null_as_default"
658 )]
659 pub in_progress_exec_sessions: Vec<String>,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664pub struct TurnFailedEvent {
665 pub message: String,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub usage: Option<Usage>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
674pub struct TurnBlockedEvent {
675 pub message: String,
677 #[serde(skip_serializing_if = "Option::is_none")]
679 pub last_tool: Option<String>,
680 #[serde(default)]
682 pub blocked_streak: usize,
683 #[serde(default)]
685 pub blocked_total: usize,
686 #[serde(default)]
688 pub consecutive_cap: usize,
689 #[serde(default)]
691 pub total_cap: usize,
692 #[serde(default)]
694 pub recovery_active: bool,
695 #[serde(skip_serializing_if = "Option::is_none")]
697 pub usage: Option<Usage>,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
701#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
702pub struct ThreadErrorEvent {
703 pub message: String,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
709pub struct Usage {
710 #[serde(default, deserialize_with = "deserialize_null_as_default")]
712 pub input_tokens: u64,
713 #[serde(default, deserialize_with = "deserialize_null_as_default")]
715 pub cached_input_tokens: u64,
716 #[serde(default, deserialize_with = "deserialize_null_as_default")]
718 pub cache_creation_tokens: u64,
719 #[serde(default, deserialize_with = "deserialize_null_as_default")]
721 pub output_tokens: u64,
722}
723
724pub fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
731where
732 D: serde::Deserializer<'de>,
733 T: Deserialize<'de> + Default,
734{
735 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
736}
737
738impl Usage {
739 #[must_use]
744 fn uncached_input_tokens(&self) -> u64 {
745 self.input_tokens
746 .saturating_sub(self.cached_input_tokens)
747 .saturating_sub(self.cache_creation_tokens)
748 }
749
750 #[must_use]
753 pub fn cache_hit_rate(&self) -> Option<f64> {
754 if self.input_tokens == 0 {
755 return None;
756 }
757 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
758 }
759
760 #[must_use]
762 pub fn cache_summary(&self) -> String {
763 let total_input = self.input_tokens;
764 if total_input == 0 {
765 return "No input tokens recorded.".to_string();
766 }
767
768 let cached = self.cached_input_tokens;
769 let creation = self.cache_creation_tokens;
770 let uncached = self.uncached_input_tokens();
771 let rate = cached as f64 / total_input as f64 * 100.0;
772 format!(
773 "Cache: {cached} cached / {total_input} total input ({rate:.1}% hit rate), \
774 {creation} cache-creation, {uncached} uncached"
775 )
776 }
777
778 pub fn add(&mut self, other: &Usage) {
780 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
781 self.cached_input_tokens = self.cached_input_tokens.saturating_add(other.cached_input_tokens);
782 self.cache_creation_tokens = self.cache_creation_tokens.saturating_add(other.cache_creation_tokens);
783 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
784 }
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
789pub struct ItemCompletedEvent {
790 pub item: ThreadItem,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
796pub struct ItemStartedEvent {
797 pub item: ThreadItem,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
802#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
803pub struct ItemUpdatedEvent {
804 pub item: ThreadItem,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
809#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
810pub struct PlanDeltaEvent {
811 pub thread_id: String,
813 pub turn_id: String,
815 pub item_id: String,
817 pub delta: String,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
822#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
823pub struct PlanApprovalRequestedEvent {
824 pub thread_id: String,
826 pub turn_id: String,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 pub plan_file: Option<String>,
831}
832
833#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
835#[serde(rename_all = "snake_case")]
836pub enum PlanApprovalDecision {
837 Execute,
839 AutoAccept,
841 FreshContext,
843 Revise,
845 Cancel,
847 SwitchBuild,
849 SwitchAuto,
851 #[serde(other)]
853 Unknown,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
857#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
858pub struct PlanApprovalResolvedEvent {
859 pub thread_id: String,
861 pub turn_id: String,
863 pub decision: PlanApprovalDecision,
865 pub automatic: bool,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
870#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
871pub struct ThreadItem {
872 pub id: String,
874 #[serde(flatten)]
876 pub details: ThreadItemDetails,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881#[serde(tag = "type", rename_all = "snake_case")]
882pub enum ThreadItemDetails {
883 AgentMessage(AgentMessageItem),
885 Plan(PlanItem),
887 Reasoning(ReasoningItem),
889 CommandExecution(Box<CommandExecutionItem>),
891 ToolInvocation(Box<ToolInvocationItem>),
893 ToolOutput(Box<ToolOutputItem>),
895 FileChange(Box<FileChangeItem>),
897 McpToolCall(Box<McpToolCallItem>),
899 WebSearch(Box<WebSearchItem>),
901 Harness(Box<HarnessEventItem>),
903 Error(ErrorItem),
905}
906
907#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
908#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
909pub struct AgentMessageItem {
910 pub text: String,
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
915#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
916pub struct PlanItem {
917 pub text: String,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
922#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
923pub struct ReasoningItem {
924 pub text: String,
926 #[serde(skip_serializing_if = "Option::is_none")]
929 pub stage: Option<String>,
930}
931
932#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
933#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
934#[serde(rename_all = "snake_case")]
935pub enum CommandExecutionStatus {
936 #[default]
938 Completed,
939 Failed,
941 InProgress,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
946#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
947pub struct CommandExecutionItem {
948 pub command: String,
950 #[serde(skip_serializing_if = "Option::is_none")]
952 pub arguments: Option<Value>,
953 #[serde(default)]
955 pub aggregated_output: String,
956 #[serde(skip_serializing_if = "Option::is_none")]
958 pub exit_code: Option<i32>,
959 pub status: CommandExecutionStatus,
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
964#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
965#[serde(rename_all = "snake_case")]
966pub enum ToolCallStatus {
967 #[default]
969 Completed,
970 Failed,
972 InProgress,
974}
975
976#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
985#[serde(rename_all = "snake_case")]
986pub enum ToolOutcome {
987 #[default]
989 Success,
990 Error,
992 PermissionRejected,
994 PermissionCancelled,
996 Followup,
998 HookDenied,
1000 InvalidTool,
1002 Cancelled,
1004}
1005
1006impl ToolOutcome {
1007 #[must_use]
1008 pub const fn is_terminal(self) -> bool {
1009 !matches!(self, Self::Followup)
1010 }
1011}
1012
1013#[must_use]
1020#[allow(
1021 clippy::unreachable,
1022 reason = "Intentional compatibility, platform, or test-only suppression."
1023)]
1024pub fn tool_outcome_from_status(status: &ToolCallStatus) -> ToolOutcome {
1025 match status {
1026 ToolCallStatus::Completed => ToolOutcome::Success,
1027 ToolCallStatus::Failed => ToolOutcome::Error,
1028 ToolCallStatus::InProgress => unreachable!("InProgress status passed to completion event"),
1029 }
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1033#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1034pub struct ToolInvocationItem {
1035 pub tool_name: String,
1037 #[serde(skip_serializing_if = "Option::is_none")]
1039 pub arguments: Option<Value>,
1040 #[serde(skip_serializing_if = "Option::is_none")]
1042 pub tool_call_id: Option<String>,
1043 pub status: ToolCallStatus,
1045 #[serde(skip_serializing_if = "Option::is_none")]
1047 pub outcome: Option<ToolOutcome>,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1051#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1052pub struct ToolOutputItem {
1053 pub call_id: String,
1055 #[serde(skip_serializing_if = "Option::is_none")]
1057 pub tool_call_id: Option<String>,
1058 #[serde(skip_serializing_if = "Option::is_none")]
1060 pub spool_path: Option<String>,
1061 #[serde(default)]
1063 pub output: String,
1064 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub exit_code: Option<i32>,
1067 pub status: ToolCallStatus,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1072#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1073pub struct FileChangeItem {
1074 pub changes: Vec<FileUpdateChange>,
1076 pub status: PatchApplyStatus,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub unified_diff: Option<String>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub additions: Option<u64>,
1087 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub deletions: Option<u64>,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1093#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1094pub struct FileUpdateChange {
1095 pub path: String,
1097 pub kind: PatchChangeKind,
1099}
1100
1101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1102#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1103#[serde(rename_all = "snake_case")]
1104pub enum PatchApplyStatus {
1105 Completed,
1107 Failed,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1113#[serde(rename_all = "snake_case")]
1114pub enum PatchChangeKind {
1115 Add,
1117 Delete,
1119 Update,
1121}
1122
1123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1124#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1125pub struct McpToolCallItem {
1126 pub tool_name: String,
1128 #[serde(skip_serializing_if = "Option::is_none")]
1130 pub arguments: Option<Value>,
1131 #[serde(skip_serializing_if = "Option::is_none")]
1133 pub result: Option<String>,
1134 #[serde(skip_serializing_if = "Option::is_none")]
1136 pub status: Option<McpToolCallStatus>,
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1140#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1141#[serde(rename_all = "snake_case")]
1142pub enum McpToolCallStatus {
1143 Started,
1145 Completed,
1147 Failed,
1149}
1150
1151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1152#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1153pub struct WebSearchItem {
1154 pub query: String,
1156 #[serde(skip_serializing_if = "Option::is_none")]
1158 pub provider: Option<String>,
1159 #[serde(skip_serializing_if = "Option::is_none")]
1161 pub results: Option<Vec<String>>,
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1165#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1166#[serde(rename_all = "snake_case")]
1167pub enum HarnessEventKind {
1168 PlanningStarted,
1169 PlanningCompleted,
1170 ContinuationStarted,
1171 ContinuationSkipped,
1172 TurnBlocked,
1175 BlockedRecoveryStarted,
1177 BlockedRecoveryFinished,
1179 BlockedHandoffWritten,
1180 BlockedHandoffResolved,
1183 EvaluationStarted,
1184 EvaluationPassed,
1185 EvaluationFailed,
1186 RevisionStarted,
1187 EscalationTriggered,
1188 EscalationBypassed,
1189 VerificationStarted,
1190 VerificationPassed,
1191 VerificationFailed,
1192 ErrorRecovered,
1194 ToolRetryAttempted,
1196 ToolLatencyRecorded,
1198 SnapshotCreated,
1200 SnapshotRestored,
1202 SessionToolLimitIncreased,
1205 ToolLoopLimitIncreased,
1207}
1208
1209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1210#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1211#[serde(rename_all = "snake_case")]
1212pub enum PermissionDecision {
1213 Allow,
1214 Deny,
1215 Cancelled,
1216 Followup,
1217}
1218
1219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1220#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1221pub struct PermissionRequestedEvent {
1222 pub tool_name: String,
1224}
1225
1226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1227#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1228pub struct PermissionResolvedEvent {
1229 pub tool_name: String,
1231 pub decision: PermissionDecision,
1233 pub wait_ms: u64,
1235}
1236
1237#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1238#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1239#[serde(rename_all = "snake_case")]
1240pub enum InterjectionSource {
1241 Direct,
1242 Queue,
1243}
1244
1245#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1246#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1247#[serde(rename_all = "snake_case")]
1248pub enum RedirectKind {
1249 Interjection,
1250}
1251
1252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1253#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1254pub struct InterjectedEvent {
1255 pub source: InterjectionSource,
1257 pub image_count: u32,
1259 pub redirect_kind: RedirectKind,
1262}
1263
1264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1265#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1266pub struct HarnessEventItem {
1267 pub event: HarnessEventKind,
1269 #[serde(skip_serializing_if = "Option::is_none")]
1271 pub message: Option<String>,
1272 #[serde(skip_serializing_if = "Option::is_none")]
1274 pub command: Option<String>,
1275 #[serde(skip_serializing_if = "Option::is_none")]
1277 pub path: Option<String>,
1278 #[serde(skip_serializing_if = "Option::is_none")]
1280 pub exit_code: Option<i32>,
1281 #[serde(skip_serializing_if = "Option::is_none")]
1283 pub attempt: Option<u32>,
1284 #[serde(skip_serializing_if = "Option::is_none")]
1286 pub error_category: Option<String>,
1287 #[serde(skip_serializing_if = "Option::is_none")]
1289 pub duration_ms: Option<u64>,
1290}
1291
1292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1293#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1294pub struct ErrorItem {
1295 pub message: String,
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use std::error::Error;
1303 use std::mem::size_of;
1304
1305 #[test]
1310 fn thread_event_stays_compact() {
1311 assert!(
1312 size_of::<ThreadEvent>() <= 80,
1313 "ThreadEvent grew to {} bytes; box new large payloads instead of inlining them",
1314 size_of::<ThreadEvent>()
1315 );
1316 }
1317
1318 #[test]
1321 fn boxed_thread_item_details_payloads_stay_boxed() {
1322 assert!(size_of::<Option<Box<CommandExecutionItem>>>() < size_of::<Option<CommandExecutionItem>>());
1323 assert!(size_of::<Option<Box<ToolInvocationItem>>>() < size_of::<Option<ToolInvocationItem>>());
1324 assert!(size_of::<Option<Box<ToolOutputItem>>>() < size_of::<Option<ToolOutputItem>>());
1325 assert!(size_of::<Option<Box<FileChangeItem>>>() < size_of::<Option<FileChangeItem>>());
1326 assert!(size_of::<Option<Box<McpToolCallItem>>>() < size_of::<Option<McpToolCallItem>>());
1327 assert!(size_of::<Option<Box<WebSearchItem>>>() < size_of::<Option<WebSearchItem>>());
1328 assert!(size_of::<Option<Box<HarnessEventItem>>>() < size_of::<Option<HarnessEventItem>>());
1329 }
1330
1331 #[test]
1332 fn file_change_item_optional_diff_fields_round_trip() -> Result<(), Box<dyn Error>> {
1333 let legacy_json = r#"{
1335 "changes": [{"path": "src/main.rs", "kind": "add"}],
1336 "status": "completed"
1337 }"#;
1338 let legacy: FileChangeItem = serde_json::from_str(legacy_json)?;
1339 assert!(legacy.unified_diff.is_none());
1340 assert!(legacy.additions.is_none());
1341 assert!(legacy.deletions.is_none());
1342
1343 let legacy_reserialized = serde_json::to_value(&legacy)?;
1345 assert!(legacy_reserialized.get("unified_diff").is_none());
1346 assert!(legacy_reserialized.get("additions").is_none());
1347 assert!(legacy_reserialized.get("deletions").is_none());
1348
1349 let populated = FileChangeItem {
1351 changes: legacy.changes.clone(),
1352 status: PatchApplyStatus::Completed,
1353 unified_diff: Some("diff --git a/x b/x\n".to_string()),
1354 additions: Some(3),
1355 deletions: Some(1),
1356 };
1357 let json = serde_json::to_string(&populated)?;
1358 let restored: FileChangeItem = serde_json::from_str(&json)?;
1359 assert_eq!(restored, populated);
1360 Ok(())
1361 }
1362
1363 #[test]
1364 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1365 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1366 usage: Usage {
1367 input_tokens: 1,
1368 cached_input_tokens: 2,
1369 cache_creation_tokens: 0,
1370 output_tokens: 3,
1371 },
1372 in_progress_exec_sessions: Vec::new(),
1373 });
1374
1375 let json = serde_json::to_string(&event)?;
1376 let restored: ThreadEvent = serde_json::from_str(&json)?;
1377
1378 assert_eq!(restored, event);
1379 Ok(())
1380 }
1381
1382 #[test]
1383 fn turn_blocked_event_round_trip() -> Result<(), Box<dyn Error>> {
1384 let event = ThreadEvent::TurnBlocked(Box::new(TurnBlockedEvent {
1385 message: "Blocked tool-call limit reached after 3 consecutive blocked calls.".to_string(),
1386 last_tool: Some("exec_command".to_string()),
1387 blocked_streak: 4,
1388 blocked_total: 4,
1389 consecutive_cap: 3,
1390 total_cap: 6,
1391 recovery_active: false,
1392 usage: None,
1393 }));
1394
1395 let json = serde_json::to_string(&event)?;
1396 assert!(json.contains("turn.blocked"));
1397 let restored: ThreadEvent = serde_json::from_str(&json)?;
1398 assert_eq!(restored, event);
1399
1400 let legacy = serde_json::json!({"type": "turn.blocked", "message": "blocked"});
1402 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1403 assert!(matches!(parsed, ThreadEvent::TurnBlocked(_)));
1404 Ok(())
1405 }
1406
1407 #[test]
1408 fn turn_completed_in_progress_sessions_default_empty_and_omitted() -> Result<(), Box<dyn Error>> {
1409 let legacy = serde_json::json!({
1411 "type": "turn.completed",
1412 "usage": {"input_tokens": 1, "cached_input_tokens": 0, "cache_creation_tokens": 0, "output_tokens": 2}
1413 });
1414 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1415 let ThreadEvent::TurnCompleted(completed) = parsed else {
1416 panic!("expected turn.completed");
1417 };
1418 assert!(completed.in_progress_exec_sessions.is_empty());
1419
1420 let json = serde_json::to_value(ThreadEvent::TurnCompleted(completed))?;
1422 assert!(json.get("in_progress_exec_sessions").is_none());
1423
1424 let null_field = serde_json::json!({
1426 "type": "turn.completed",
1427 "usage": {"input_tokens": 0, "cached_input_tokens": 0, "cache_creation_tokens": 0, "output_tokens": 0},
1428 "in_progress_exec_sessions": null
1429 });
1430 let parsed_null: ThreadEvent = serde_json::from_value(null_field)?;
1431 let ThreadEvent::TurnCompleted(null_completed) = parsed_null else {
1432 panic!("expected turn.completed");
1433 };
1434 assert!(null_completed.in_progress_exec_sessions.is_empty());
1435 Ok(())
1436 }
1437
1438 #[test]
1439 fn turn_completed_in_progress_sessions_round_trip_and_bound() -> Result<(), Box<dyn Error>> {
1440 assert_eq!(MAX_IN_PROGRESS_EXEC_SESSIONS, 4);
1441 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1442 usage: Usage::default(),
1443 in_progress_exec_sessions: vec!["run-1".to_string(), "run-2".to_string()],
1444 });
1445 let json = serde_json::to_string(&event)?;
1446 assert!(json.contains("in_progress_exec_sessions"));
1447 let restored: ThreadEvent = serde_json::from_str(&json)?;
1448 assert_eq!(restored, event);
1449 Ok(())
1450 }
1451
1452 #[test]
1453 fn usage_uncached_input_tokens_saturates() {
1454 let usage = Usage {
1455 input_tokens: 1_000,
1456 cached_input_tokens: 800,
1457 cache_creation_tokens: 100,
1458 output_tokens: 50,
1459 };
1460 assert_eq!(usage.uncached_input_tokens(), 100);
1461
1462 let inconsistent = Usage {
1463 input_tokens: 100,
1464 cached_input_tokens: 150,
1465 cache_creation_tokens: 0,
1466 output_tokens: 0,
1467 };
1468 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1469
1470 let inconsistent_with_creation = Usage {
1471 input_tokens: 100,
1472 cached_input_tokens: 80,
1473 cache_creation_tokens: 50,
1474 output_tokens: 0,
1475 };
1476 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1477 }
1478
1479 #[test]
1480 fn usage_cache_hit_rate() {
1481 assert_eq!(Usage::default().cache_hit_rate(), None);
1482
1483 let usage = Usage {
1484 input_tokens: 1_000,
1485 cached_input_tokens: 750,
1486 cache_creation_tokens: 0,
1487 output_tokens: 0,
1488 };
1489 let rate = usage.cache_hit_rate().expect("rate");
1490 assert!((rate - 0.75).abs() < f64::EPSILON);
1491 }
1492
1493 #[test]
1494 fn usage_cache_summary_formats() {
1495 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1496
1497 let usage = Usage {
1498 input_tokens: 1_000,
1499 cached_input_tokens: 800,
1500 cache_creation_tokens: 100,
1501 output_tokens: 50,
1502 };
1503 assert_eq!(
1504 usage.cache_summary(),
1505 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1506 );
1507 }
1508
1509 #[test]
1510 fn usage_add_accumulates_all_fields_with_saturation() {
1511 let mut total = Usage {
1512 input_tokens: 100,
1513 cached_input_tokens: 20,
1514 cache_creation_tokens: 5,
1515 output_tokens: 10,
1516 };
1517 total.add(&Usage {
1518 input_tokens: 50,
1519 cached_input_tokens: 10,
1520 cache_creation_tokens: 2,
1521 output_tokens: 8,
1522 });
1523
1524 assert_eq!(total.input_tokens, 150);
1525 assert_eq!(total.cached_input_tokens, 30);
1526 assert_eq!(total.cache_creation_tokens, 7);
1527 assert_eq!(total.output_tokens, 18);
1528
1529 let mut saturating = Usage {
1530 input_tokens: u64::MAX,
1531 cached_input_tokens: u64::MAX,
1532 cache_creation_tokens: u64::MAX,
1533 output_tokens: u64::MAX,
1534 };
1535 saturating.add(&Usage {
1536 input_tokens: 1,
1537 cached_input_tokens: 1,
1538 cache_creation_tokens: 1,
1539 output_tokens: 1,
1540 });
1541 assert_eq!(saturating.input_tokens, u64::MAX);
1542 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1543 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1544 assert_eq!(saturating.output_tokens, u64::MAX);
1545 }
1546
1547 #[test]
1548 fn versioned_event_wraps_schema_version() {
1549 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1550
1551 let versioned = VersionedThreadEvent::new(event.clone());
1552
1553 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1554 assert_eq!(versioned.event, event);
1555 assert_eq!(versioned.into_event(), event);
1556 }
1557
1558 #[test]
1559 fn plan_approval_events_round_trip_with_decision() {
1560 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1561 thread_id: "thread-1".to_string(),
1562 turn_id: "turn-2".to_string(),
1563 plan_file: Some(".vtcode/plans/change.md".to_string()),
1564 });
1565 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1566 thread_id: "thread-1".to_string(),
1567 turn_id: "turn-3".to_string(),
1568 decision: PlanApprovalDecision::AutoAccept,
1569 automatic: false,
1570 });
1571
1572 for event in [requested, resolved] {
1573 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1574 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1575 assert_eq!(restored, event);
1576 }
1577 }
1578
1579 #[test]
1580 fn context_reset_event_round_trips_with_handoff_metadata() {
1581 let event = ThreadEvent::ContextReset(ContextResetEvent {
1582 thread_id: "thread-1".to_string(),
1583 turn_id: "turn-3".to_string(),
1584 trigger: ContextResetTrigger::PlanApproval,
1585 plan_preserved: true,
1586 previous_context_usage_percent: 7,
1587 tool_budget_reset: true,
1588 });
1589
1590 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1591 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1592 assert_eq!(restored, event);
1593 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1594 }
1595
1596 #[test]
1597 fn plan_approval_decision_uses_stable_wire_names() {
1598 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1599 thread_id: "thread-1".to_string(),
1600 turn_id: "turn-1".to_string(),
1601 decision: PlanApprovalDecision::SwitchBuild,
1602 automatic: false,
1603 });
1604
1605 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1606 assert_eq!(serialized["type"], "plan.approval.resolved");
1607 assert_eq!(serialized["decision"], "switch_build");
1608 }
1609
1610 #[test]
1611 fn plan_approval_decision_is_forward_compatible() {
1612 let payload = serde_json::json!({
1613 "type": "plan.approval.resolved",
1614 "thread_id": "thread-1",
1615 "turn_id": "turn-1",
1616 "decision": "future_decision",
1617 "automatic": true,
1618 });
1619 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1620 assert!(matches!(
1621 event,
1622 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1623 decision: PlanApprovalDecision::Unknown,
1624 automatic: true,
1625 ..
1626 })
1627 ));
1628 }
1629
1630 #[cfg(feature = "serde-json")]
1631 #[test]
1632 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1633 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1634 item: ThreadItem {
1635 id: "item-1".to_string(),
1636 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1637 },
1638 });
1639
1640 let payload = json::versioned_to_string(&event)?;
1641 let restored = json::versioned_from_str(&payload)?;
1642
1643 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1644 assert_eq!(restored.event, event);
1645 Ok(())
1646 }
1647
1648 #[test]
1649 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1650 for trigger in [
1651 CompactionTrigger::Manual,
1652 CompactionTrigger::Auto,
1653 CompactionTrigger::Recovery,
1654 CompactionTrigger::ModelSwitch,
1655 CompactionTrigger::Unknown,
1656 ] {
1657 let json = serde_json::to_string(&trigger).unwrap();
1658 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1659 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1660 assert_eq!(restored, trigger);
1661 }
1662 }
1663
1664 #[test]
1665 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1666 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1667 item: ThreadItem {
1668 id: "tool_1".to_string(),
1669 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1670 tool_name: "read_file".to_string(),
1671 arguments: Some(serde_json::json!({ "path": "README.md" })),
1672 tool_call_id: Some("tool_call_0".to_string()),
1673 status: ToolCallStatus::Completed,
1674 outcome: None,
1675 })),
1676 },
1677 });
1678
1679 let json = serde_json::to_string(&event)?;
1680 let restored: ThreadEvent = serde_json::from_str(&json)?;
1681
1682 assert_eq!(restored, event);
1683 Ok(())
1684 }
1685
1686 #[test]
1687 fn tool_outcome_serializes_snake_case() {
1688 for outcome in [
1689 ToolOutcome::Success,
1690 ToolOutcome::Error,
1691 ToolOutcome::PermissionRejected,
1692 ToolOutcome::PermissionCancelled,
1693 ToolOutcome::Followup,
1694 ToolOutcome::HookDenied,
1695 ToolOutcome::InvalidTool,
1696 ToolOutcome::Cancelled,
1697 ] {
1698 let json = serde_json::to_string(&outcome).unwrap();
1699 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1700 assert_eq!(restored, outcome);
1701 }
1702 }
1703
1704 #[test]
1705 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1706 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1707 item: ThreadItem {
1708 id: "tool_1".to_string(),
1709 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1710 tool_name: "exec_command".to_string(),
1711 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1712 tool_call_id: Some("tool_call_0".to_string()),
1713 status: ToolCallStatus::Failed,
1714 outcome: Some(ToolOutcome::PermissionRejected),
1715 })),
1716 },
1717 });
1718
1719 let json = serde_json::to_string(&event)?;
1720 let restored: ThreadEvent = serde_json::from_str(&json)?;
1721
1722 assert_eq!(restored, event);
1723 Ok(())
1724 }
1725
1726 #[test]
1727 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1728 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1729 item: ThreadItem {
1730 id: "tool_1:output".to_string(),
1731 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1732 call_id: "tool_1".to_string(),
1733 tool_call_id: Some("tool_call_0".to_string()),
1734 spool_path: None,
1735 output: "done".to_string(),
1736 exit_code: Some(0),
1737 status: ToolCallStatus::Completed,
1738 })),
1739 },
1740 });
1741
1742 let json = serde_json::to_string(&event)?;
1743 let restored: ThreadEvent = serde_json::from_str(&json)?;
1744
1745 assert_eq!(restored, event);
1746 Ok(())
1747 }
1748
1749 #[test]
1750 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1751 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1752 item: ThreadItem {
1753 id: "harness_1".to_string(),
1754 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1755 event: HarnessEventKind::VerificationFailed,
1756 message: Some("cargo check failed".to_string()),
1757 command: Some("cargo check".to_string()),
1758 path: None,
1759 exit_code: Some(101),
1760 attempt: None,
1761 error_category: None,
1762 duration_ms: None,
1763 })),
1764 },
1765 });
1766
1767 let json = serde_json::to_string(&event)?;
1768 let restored: ThreadEvent = serde_json::from_str(&json)?;
1769
1770 assert_eq!(restored, event);
1771 Ok(())
1772 }
1773
1774 #[test]
1775 fn blocked_handoff_resolved_uses_stable_wire_name() -> Result<(), Box<dyn Error>> {
1776 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1777 item: ThreadItem {
1778 id: "harness_resolved".to_string(),
1779 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1780 event: HarnessEventKind::BlockedHandoffResolved,
1781 message: Some("resolved".to_string()),
1782 command: None,
1783 path: None,
1784 exit_code: None,
1785 attempt: None,
1786 error_category: None,
1787 duration_ms: None,
1788 })),
1789 },
1790 });
1791
1792 let value = serde_json::to_value(&event)?;
1793 assert_eq!(value["item"]["event"], "blocked_handoff_resolved");
1794
1795 let restored: ThreadEvent = serde_json::from_value(value)?;
1796 assert_eq!(restored, event);
1797 Ok(())
1798 }
1799
1800 #[test]
1801 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1802 let event = ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1803 thread_id: "thread-1".to_string(),
1804 session_id: "session-1".to_string(),
1805 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1806 outcome_code: "budget_limit_reached".to_string(),
1807 result: None,
1808 stop_reason: Some("max_tokens".to_string()),
1809 usage: Usage {
1810 input_tokens: 10,
1811 cached_input_tokens: 4,
1812 cache_creation_tokens: 2,
1813 output_tokens: 5,
1814 },
1815 total_cost_usd: serde_json::Number::from_f64(1.25),
1816 num_turns: 3,
1817 }));
1818
1819 let json = serde_json::to_string(&event)?;
1820 let restored: ThreadEvent = serde_json::from_str(&json)?;
1821
1822 assert_eq!(restored, event);
1823 Ok(())
1824 }
1825
1826 #[test]
1827 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1828 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1829 thread_id: "thread-1".to_string(),
1830 trigger: CompactionTrigger::Recovery,
1831 mode: CompactionMode::Provider,
1832 original_message_count: 12,
1833 compacted_message_count: 5,
1834 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1835 previous_segment_id: Some("segment-0001".to_string()),
1836 new_segment_id: Some("segment-0002".to_string()),
1837 previous_prefix_hash: Some("prefix-before".to_string()),
1838 new_prefix_hash: Some("prefix-after".to_string()),
1839 previous_catalog_hash: Some("catalog-before".to_string()),
1840 new_catalog_hash: Some("catalog-after".to_string()),
1841 }));
1842
1843 let json = serde_json::to_string(&event)?;
1844 let restored: ThreadEvent = serde_json::from_str(&json)?;
1845
1846 assert_eq!(restored, event);
1847 Ok(())
1848 }
1849
1850 #[test]
1851 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1852 let payload = r#"{
1853 "type":"thread.compact_boundary",
1854 "thread_id":"thread-1",
1855 "trigger":"recovery",
1856 "mode":"provider",
1857 "original_message_count":12,
1858 "compacted_message_count":5
1859 }"#;
1860
1861 let restored: ThreadEvent = serde_json::from_str(payload)?;
1862 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1863 panic!("expected thread.compact_boundary event");
1864 };
1865
1866 assert_eq!(event.thread_id, "thread-1");
1867 assert_eq!(event.history_artifact_path, None);
1868 assert_eq!(event.previous_segment_id, None);
1869 assert_eq!(event.new_segment_id, None);
1870 assert_eq!(event.previous_prefix_hash, None);
1871 assert_eq!(event.new_prefix_hash, None);
1872 assert_eq!(event.previous_catalog_hash, None);
1873 assert_eq!(event.new_catalog_hash, None);
1874 Ok(())
1875 }
1876}