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.16.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 BackgroundSubprocessCompleted,
1209}
1210
1211#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1212#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1213#[serde(rename_all = "snake_case")]
1214pub enum PermissionDecision {
1215 Allow,
1216 Deny,
1217 Cancelled,
1218 Followup,
1219}
1220
1221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1222#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1223pub struct PermissionRequestedEvent {
1224 pub tool_name: String,
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1229#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1230pub struct PermissionResolvedEvent {
1231 pub tool_name: String,
1233 pub decision: PermissionDecision,
1235 pub wait_ms: u64,
1237}
1238
1239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1240#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1241#[serde(rename_all = "snake_case")]
1242pub enum InterjectionSource {
1243 Direct,
1244 Queue,
1245}
1246
1247#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1249#[serde(rename_all = "snake_case")]
1250pub enum RedirectKind {
1251 Interjection,
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1255#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1256pub struct InterjectedEvent {
1257 pub source: InterjectionSource,
1259 pub image_count: u32,
1261 pub redirect_kind: RedirectKind,
1264}
1265
1266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1267#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1268pub struct HarnessEventItem {
1269 pub event: HarnessEventKind,
1271 #[serde(skip_serializing_if = "Option::is_none")]
1273 pub message: Option<String>,
1274 #[serde(skip_serializing_if = "Option::is_none")]
1276 pub command: Option<String>,
1277 #[serde(skip_serializing_if = "Option::is_none")]
1279 pub path: Option<String>,
1280 #[serde(skip_serializing_if = "Option::is_none")]
1282 pub exit_code: Option<i32>,
1283 #[serde(skip_serializing_if = "Option::is_none")]
1285 pub attempt: Option<u32>,
1286 #[serde(skip_serializing_if = "Option::is_none")]
1288 pub error_category: Option<String>,
1289 #[serde(skip_serializing_if = "Option::is_none")]
1291 pub duration_ms: Option<u64>,
1292 #[serde(skip_serializing_if = "Option::is_none")]
1294 pub task_id: Option<String>,
1295 #[serde(skip_serializing_if = "Option::is_none")]
1297 pub session_id: Option<String>,
1298 #[serde(skip_serializing_if = "Option::is_none")]
1300 pub exec_session_id: Option<String>,
1301 #[serde(skip_serializing_if = "Option::is_none")]
1303 pub status: Option<String>,
1304 #[serde(skip_serializing_if = "Option::is_none")]
1306 pub transcript_path: Option<String>,
1307 #[serde(skip_serializing_if = "Option::is_none")]
1309 pub archive_path: Option<String>,
1310}
1311
1312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1313#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1314pub struct ErrorItem {
1315 pub message: String,
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322 use std::error::Error;
1323 use std::mem::size_of;
1324
1325 #[test]
1330 fn thread_event_stays_compact() {
1331 assert!(
1332 size_of::<ThreadEvent>() <= 80,
1333 "ThreadEvent grew to {} bytes; box new large payloads instead of inlining them",
1334 size_of::<ThreadEvent>()
1335 );
1336 }
1337
1338 #[test]
1341 fn boxed_thread_item_details_payloads_stay_boxed() {
1342 assert!(size_of::<Option<Box<CommandExecutionItem>>>() < size_of::<Option<CommandExecutionItem>>());
1343 assert!(size_of::<Option<Box<ToolInvocationItem>>>() < size_of::<Option<ToolInvocationItem>>());
1344 assert!(size_of::<Option<Box<ToolOutputItem>>>() < size_of::<Option<ToolOutputItem>>());
1345 assert!(size_of::<Option<Box<FileChangeItem>>>() < size_of::<Option<FileChangeItem>>());
1346 assert!(size_of::<Option<Box<McpToolCallItem>>>() < size_of::<Option<McpToolCallItem>>());
1347 assert!(size_of::<Option<Box<WebSearchItem>>>() < size_of::<Option<WebSearchItem>>());
1348 assert!(size_of::<Option<Box<HarnessEventItem>>>() < size_of::<Option<HarnessEventItem>>());
1349 }
1350
1351 #[test]
1352 fn file_change_item_optional_diff_fields_round_trip() -> Result<(), Box<dyn Error>> {
1353 let legacy_json = r#"{
1355 "changes": [{"path": "src/main.rs", "kind": "add"}],
1356 "status": "completed"
1357 }"#;
1358 let legacy: FileChangeItem = serde_json::from_str(legacy_json)?;
1359 assert!(legacy.unified_diff.is_none());
1360 assert!(legacy.additions.is_none());
1361 assert!(legacy.deletions.is_none());
1362
1363 let legacy_reserialized = serde_json::to_value(&legacy)?;
1365 assert!(legacy_reserialized.get("unified_diff").is_none());
1366 assert!(legacy_reserialized.get("additions").is_none());
1367 assert!(legacy_reserialized.get("deletions").is_none());
1368
1369 let populated = FileChangeItem {
1371 changes: legacy.changes.clone(),
1372 status: PatchApplyStatus::Completed,
1373 unified_diff: Some("diff --git a/x b/x\n".to_string()),
1374 additions: Some(3),
1375 deletions: Some(1),
1376 };
1377 let json = serde_json::to_string(&populated)?;
1378 let restored: FileChangeItem = serde_json::from_str(&json)?;
1379 assert_eq!(restored, populated);
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
1385 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1386 usage: Usage {
1387 input_tokens: 1,
1388 cached_input_tokens: 2,
1389 cache_creation_tokens: 0,
1390 output_tokens: 3,
1391 },
1392 in_progress_exec_sessions: Vec::new(),
1393 });
1394
1395 let json = serde_json::to_string(&event)?;
1396 let restored: ThreadEvent = serde_json::from_str(&json)?;
1397
1398 assert_eq!(restored, event);
1399 Ok(())
1400 }
1401
1402 #[test]
1403 fn turn_blocked_event_round_trip() -> Result<(), Box<dyn Error>> {
1404 let event = ThreadEvent::TurnBlocked(Box::new(TurnBlockedEvent {
1405 message: "Blocked tool-call limit reached after 3 consecutive blocked calls.".to_string(),
1406 last_tool: Some("exec_command".to_string()),
1407 blocked_streak: 4,
1408 blocked_total: 4,
1409 consecutive_cap: 3,
1410 total_cap: 6,
1411 recovery_active: false,
1412 usage: None,
1413 }));
1414
1415 let json = serde_json::to_string(&event)?;
1416 assert!(json.contains("turn.blocked"));
1417 let restored: ThreadEvent = serde_json::from_str(&json)?;
1418 assert_eq!(restored, event);
1419
1420 let legacy = serde_json::json!({"type": "turn.blocked", "message": "blocked"});
1422 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1423 assert!(matches!(parsed, ThreadEvent::TurnBlocked(_)));
1424 Ok(())
1425 }
1426
1427 #[test]
1428 fn turn_completed_in_progress_sessions_default_empty_and_omitted() -> Result<(), Box<dyn Error>> {
1429 let legacy = serde_json::json!({
1431 "type": "turn.completed",
1432 "usage": {"input_tokens": 1, "cached_input_tokens": 0, "cache_creation_tokens": 0, "output_tokens": 2}
1433 });
1434 let parsed: ThreadEvent = serde_json::from_value(legacy)?;
1435 let ThreadEvent::TurnCompleted(completed) = parsed else {
1436 panic!("expected turn.completed");
1437 };
1438 assert!(completed.in_progress_exec_sessions.is_empty());
1439
1440 let json = serde_json::to_value(ThreadEvent::TurnCompleted(completed))?;
1442 assert!(json.get("in_progress_exec_sessions").is_none());
1443
1444 let null_field = serde_json::json!({
1446 "type": "turn.completed",
1447 "usage": {"input_tokens": 0, "cached_input_tokens": 0, "cache_creation_tokens": 0, "output_tokens": 0},
1448 "in_progress_exec_sessions": null
1449 });
1450 let parsed_null: ThreadEvent = serde_json::from_value(null_field)?;
1451 let ThreadEvent::TurnCompleted(null_completed) = parsed_null else {
1452 panic!("expected turn.completed");
1453 };
1454 assert!(null_completed.in_progress_exec_sessions.is_empty());
1455 Ok(())
1456 }
1457
1458 #[test]
1459 fn turn_completed_in_progress_sessions_round_trip_and_bound() -> Result<(), Box<dyn Error>> {
1460 assert_eq!(MAX_IN_PROGRESS_EXEC_SESSIONS, 4);
1461 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1462 usage: Usage::default(),
1463 in_progress_exec_sessions: vec!["run-1".to_string(), "run-2".to_string()],
1464 });
1465 let json = serde_json::to_string(&event)?;
1466 assert!(json.contains("in_progress_exec_sessions"));
1467 let restored: ThreadEvent = serde_json::from_str(&json)?;
1468 assert_eq!(restored, event);
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn usage_uncached_input_tokens_saturates() {
1474 let usage = Usage {
1475 input_tokens: 1_000,
1476 cached_input_tokens: 800,
1477 cache_creation_tokens: 100,
1478 output_tokens: 50,
1479 };
1480 assert_eq!(usage.uncached_input_tokens(), 100);
1481
1482 let inconsistent = Usage {
1483 input_tokens: 100,
1484 cached_input_tokens: 150,
1485 cache_creation_tokens: 0,
1486 output_tokens: 0,
1487 };
1488 assert_eq!(inconsistent.uncached_input_tokens(), 0);
1489
1490 let inconsistent_with_creation = Usage {
1491 input_tokens: 100,
1492 cached_input_tokens: 80,
1493 cache_creation_tokens: 50,
1494 output_tokens: 0,
1495 };
1496 assert_eq!(inconsistent_with_creation.uncached_input_tokens(), 0);
1497 }
1498
1499 #[test]
1500 fn usage_cache_hit_rate() {
1501 assert_eq!(Usage::default().cache_hit_rate(), None);
1502
1503 let usage = Usage {
1504 input_tokens: 1_000,
1505 cached_input_tokens: 750,
1506 cache_creation_tokens: 0,
1507 output_tokens: 0,
1508 };
1509 let rate = usage.cache_hit_rate().expect("rate");
1510 assert!((rate - 0.75).abs() < f64::EPSILON);
1511 }
1512
1513 #[test]
1514 fn usage_cache_summary_formats() {
1515 assert_eq!(Usage::default().cache_summary(), "No input tokens recorded.");
1516
1517 let usage = Usage {
1518 input_tokens: 1_000,
1519 cached_input_tokens: 800,
1520 cache_creation_tokens: 100,
1521 output_tokens: 50,
1522 };
1523 assert_eq!(
1524 usage.cache_summary(),
1525 "Cache: 800 cached / 1000 total input (80.0% hit rate), 100 cache-creation, 100 uncached"
1526 );
1527 }
1528
1529 #[test]
1530 fn usage_add_accumulates_all_fields_with_saturation() {
1531 let mut total = Usage {
1532 input_tokens: 100,
1533 cached_input_tokens: 20,
1534 cache_creation_tokens: 5,
1535 output_tokens: 10,
1536 };
1537 total.add(&Usage {
1538 input_tokens: 50,
1539 cached_input_tokens: 10,
1540 cache_creation_tokens: 2,
1541 output_tokens: 8,
1542 });
1543
1544 assert_eq!(total.input_tokens, 150);
1545 assert_eq!(total.cached_input_tokens, 30);
1546 assert_eq!(total.cache_creation_tokens, 7);
1547 assert_eq!(total.output_tokens, 18);
1548
1549 let mut saturating = Usage {
1550 input_tokens: u64::MAX,
1551 cached_input_tokens: u64::MAX,
1552 cache_creation_tokens: u64::MAX,
1553 output_tokens: u64::MAX,
1554 };
1555 saturating.add(&Usage {
1556 input_tokens: 1,
1557 cached_input_tokens: 1,
1558 cache_creation_tokens: 1,
1559 output_tokens: 1,
1560 });
1561 assert_eq!(saturating.input_tokens, u64::MAX);
1562 assert_eq!(saturating.cached_input_tokens, u64::MAX);
1563 assert_eq!(saturating.cache_creation_tokens, u64::MAX);
1564 assert_eq!(saturating.output_tokens, u64::MAX);
1565 }
1566
1567 #[test]
1568 fn versioned_event_wraps_schema_version() {
1569 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "abc".to_string() });
1570
1571 let versioned = VersionedThreadEvent::new(event.clone());
1572
1573 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
1574 assert_eq!(versioned.event, event);
1575 assert_eq!(versioned.into_event(), event);
1576 }
1577
1578 #[test]
1579 fn plan_approval_events_round_trip_with_decision() {
1580 let requested = ThreadEvent::PlanApprovalRequested(PlanApprovalRequestedEvent {
1581 thread_id: "thread-1".to_string(),
1582 turn_id: "turn-2".to_string(),
1583 plan_file: Some(".vtcode/plans/change.md".to_string()),
1584 });
1585 let resolved = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1586 thread_id: "thread-1".to_string(),
1587 turn_id: "turn-3".to_string(),
1588 decision: PlanApprovalDecision::AutoAccept,
1589 automatic: false,
1590 });
1591
1592 for event in [requested, resolved] {
1593 let serialized = serde_json::to_string(&event).expect("serialize plan approval event");
1594 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize plan approval event");
1595 assert_eq!(restored, event);
1596 }
1597 }
1598
1599 #[test]
1600 fn context_reset_event_round_trips_with_handoff_metadata() {
1601 let event = ThreadEvent::ContextReset(ContextResetEvent {
1602 thread_id: "thread-1".to_string(),
1603 turn_id: "turn-3".to_string(),
1604 trigger: ContextResetTrigger::PlanApproval,
1605 plan_preserved: true,
1606 previous_context_usage_percent: 7,
1607 tool_budget_reset: true,
1608 });
1609
1610 let serialized = serde_json::to_string(&event).expect("serialize context reset event");
1611 let restored: ThreadEvent = serde_json::from_str(&serialized).expect("deserialize context reset event");
1612 assert_eq!(restored, event);
1613 assert_eq!(serde_json::to_value(event).expect("wire value")["type"], "context.reset");
1614 }
1615
1616 #[test]
1617 fn plan_approval_decision_uses_stable_wire_names() {
1618 let event = ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1619 thread_id: "thread-1".to_string(),
1620 turn_id: "turn-1".to_string(),
1621 decision: PlanApprovalDecision::SwitchBuild,
1622 automatic: false,
1623 });
1624
1625 let serialized = serde_json::to_value(event).expect("serialize plan approval decision");
1626 assert_eq!(serialized["type"], "plan.approval.resolved");
1627 assert_eq!(serialized["decision"], "switch_build");
1628 }
1629
1630 #[test]
1631 fn plan_approval_decision_is_forward_compatible() {
1632 let payload = serde_json::json!({
1633 "type": "plan.approval.resolved",
1634 "thread_id": "thread-1",
1635 "turn_id": "turn-1",
1636 "decision": "future_decision",
1637 "automatic": true,
1638 });
1639 let event: ThreadEvent = serde_json::from_value(payload).expect("future decision should deserialize");
1640 assert!(matches!(
1641 event,
1642 ThreadEvent::PlanApprovalResolved(PlanApprovalResolvedEvent {
1643 decision: PlanApprovalDecision::Unknown,
1644 automatic: true,
1645 ..
1646 })
1647 ));
1648 }
1649
1650 #[cfg(feature = "serde-json")]
1651 #[test]
1652 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
1653 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1654 item: ThreadItem {
1655 id: "item-1".to_string(),
1656 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "hello".to_string() }),
1657 },
1658 });
1659
1660 let payload = json::versioned_to_string(&event)?;
1661 let restored = json::versioned_from_str(&payload)?;
1662
1663 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
1664 assert_eq!(restored.event, event);
1665 Ok(())
1666 }
1667
1668 #[test]
1669 fn compaction_trigger_serializes_snake_case_and_round_trips() {
1670 for trigger in [
1671 CompactionTrigger::Manual,
1672 CompactionTrigger::Auto,
1673 CompactionTrigger::Recovery,
1674 CompactionTrigger::ModelSwitch,
1675 CompactionTrigger::Unknown,
1676 ] {
1677 let json = serde_json::to_string(&trigger).unwrap();
1678 assert_eq!(json, format!("\"{}\"", trigger.as_str()));
1679 let restored: CompactionTrigger = serde_json::from_str(&json).unwrap();
1680 assert_eq!(restored, trigger);
1681 }
1682 }
1683
1684 #[test]
1685 fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
1686 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1687 item: ThreadItem {
1688 id: "tool_1".to_string(),
1689 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1690 tool_name: "read_file".to_string(),
1691 arguments: Some(serde_json::json!({ "path": "README.md" })),
1692 tool_call_id: Some("tool_call_0".to_string()),
1693 status: ToolCallStatus::Completed,
1694 outcome: None,
1695 })),
1696 },
1697 });
1698
1699 let json = serde_json::to_string(&event)?;
1700 let restored: ThreadEvent = serde_json::from_str(&json)?;
1701
1702 assert_eq!(restored, event);
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn tool_outcome_serializes_snake_case() {
1708 for outcome in [
1709 ToolOutcome::Success,
1710 ToolOutcome::Error,
1711 ToolOutcome::PermissionRejected,
1712 ToolOutcome::PermissionCancelled,
1713 ToolOutcome::Followup,
1714 ToolOutcome::HookDenied,
1715 ToolOutcome::InvalidTool,
1716 ToolOutcome::Cancelled,
1717 ] {
1718 let json = serde_json::to_string(&outcome).unwrap();
1719 let restored: ToolOutcome = serde_json::from_str(&json).unwrap();
1720 assert_eq!(restored, outcome);
1721 }
1722 }
1723
1724 #[test]
1725 fn tool_invocation_outcome_round_trip() -> Result<(), Box<dyn Error>> {
1726 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1727 item: ThreadItem {
1728 id: "tool_1".to_string(),
1729 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1730 tool_name: "exec_command".to_string(),
1731 arguments: Some(serde_json::json!({ "command": ["pwd"] })),
1732 tool_call_id: Some("tool_call_0".to_string()),
1733 status: ToolCallStatus::Failed,
1734 outcome: Some(ToolOutcome::PermissionRejected),
1735 })),
1736 },
1737 });
1738
1739 let json = serde_json::to_string(&event)?;
1740 let restored: ThreadEvent = serde_json::from_str(&json)?;
1741
1742 assert_eq!(restored, event);
1743 Ok(())
1744 }
1745
1746 #[test]
1747 fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
1748 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1749 item: ThreadItem {
1750 id: "tool_1:output".to_string(),
1751 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1752 call_id: "tool_1".to_string(),
1753 tool_call_id: Some("tool_call_0".to_string()),
1754 spool_path: None,
1755 output: "done".to_string(),
1756 exit_code: Some(0),
1757 status: ToolCallStatus::Completed,
1758 })),
1759 },
1760 });
1761
1762 let json = serde_json::to_string(&event)?;
1763 let restored: ThreadEvent = serde_json::from_str(&json)?;
1764
1765 assert_eq!(restored, event);
1766 Ok(())
1767 }
1768
1769 #[test]
1770 fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
1771 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1772 item: ThreadItem {
1773 id: "harness_1".to_string(),
1774 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1775 event: HarnessEventKind::VerificationFailed,
1776 message: Some("cargo check failed".to_string()),
1777 command: Some("cargo check".to_string()),
1778 path: None,
1779 exit_code: Some(101),
1780 attempt: None,
1781 error_category: None,
1782 duration_ms: None,
1783 task_id: None,
1784 session_id: None,
1785 exec_session_id: None,
1786 status: None,
1787 transcript_path: None,
1788 archive_path: None,
1789 })),
1790 },
1791 });
1792
1793 let json = serde_json::to_string(&event)?;
1794 let restored: ThreadEvent = serde_json::from_str(&json)?;
1795
1796 assert_eq!(restored, event);
1797 Ok(())
1798 }
1799
1800 #[test]
1801 fn background_completion_harness_item_preserves_terminal_identity() -> Result<(), Box<dyn Error>> {
1802 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1803 item: ThreadItem {
1804 id: "background-completion:task:exec:0".to_string(),
1805 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1806 event: HarnessEventKind::BackgroundSubprocessCompleted,
1807 message: Some("Background subprocess completed successfully".to_string()),
1808 command: None,
1809 path: None,
1810 exit_code: Some(0),
1811 attempt: None,
1812 error_category: None,
1813 duration_ms: None,
1814 task_id: Some("task".to_string()),
1815 session_id: Some("child-session".to_string()),
1816 exec_session_id: Some("exec-session".to_string()),
1817 status: Some("stopped".to_string()),
1818 transcript_path: Some("/tmp/transcript.jsonl".to_string()),
1819 archive_path: Some("/tmp/archive.json".to_string()),
1820 })),
1821 },
1822 });
1823
1824 let value = serde_json::to_value(&event)?;
1825 assert_eq!(value["item"]["event"], "background_subprocess_completed");
1826 assert_eq!(value["item"]["task_id"], "task");
1827 assert_eq!(value["item"]["exec_session_id"], "exec-session");
1828 assert_eq!(value["item"]["status"], "stopped");
1829
1830 let restored: ThreadEvent = serde_json::from_value(value)?;
1831 assert_eq!(restored, event);
1832 Ok(())
1833 }
1834
1835 #[test]
1836 fn blocked_handoff_resolved_uses_stable_wire_name() -> Result<(), Box<dyn Error>> {
1837 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
1838 item: ThreadItem {
1839 id: "harness_resolved".to_string(),
1840 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
1841 event: HarnessEventKind::BlockedHandoffResolved,
1842 message: Some("resolved".to_string()),
1843 command: None,
1844 path: None,
1845 exit_code: None,
1846 attempt: None,
1847 error_category: None,
1848 duration_ms: None,
1849 task_id: None,
1850 session_id: None,
1851 exec_session_id: None,
1852 status: None,
1853 transcript_path: None,
1854 archive_path: None,
1855 })),
1856 },
1857 });
1858
1859 let value = serde_json::to_value(&event)?;
1860 assert_eq!(value["item"]["event"], "blocked_handoff_resolved");
1861
1862 let restored: ThreadEvent = serde_json::from_value(value)?;
1863 assert_eq!(restored, event);
1864 Ok(())
1865 }
1866
1867 #[test]
1868 fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
1869 let event = ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1870 thread_id: "thread-1".to_string(),
1871 session_id: "session-1".to_string(),
1872 subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
1873 outcome_code: "budget_limit_reached".to_string(),
1874 result: None,
1875 stop_reason: Some("max_tokens".to_string()),
1876 usage: Usage {
1877 input_tokens: 10,
1878 cached_input_tokens: 4,
1879 cache_creation_tokens: 2,
1880 output_tokens: 5,
1881 },
1882 total_cost_usd: serde_json::Number::from_f64(1.25),
1883 num_turns: 3,
1884 }));
1885
1886 let json = serde_json::to_string(&event)?;
1887 let restored: ThreadEvent = serde_json::from_str(&json)?;
1888
1889 assert_eq!(restored, event);
1890 Ok(())
1891 }
1892
1893 #[test]
1894 fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
1895 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1896 thread_id: "thread-1".to_string(),
1897 trigger: CompactionTrigger::Recovery,
1898 mode: CompactionMode::Provider,
1899 original_message_count: 12,
1900 compacted_message_count: 5,
1901 history_artifact_path: Some("/tmp/history.jsonl".to_string()),
1902 previous_segment_id: Some("segment-0001".to_string()),
1903 new_segment_id: Some("segment-0002".to_string()),
1904 previous_prefix_hash: Some("prefix-before".to_string()),
1905 new_prefix_hash: Some("prefix-after".to_string()),
1906 previous_catalog_hash: Some("catalog-before".to_string()),
1907 new_catalog_hash: Some("catalog-after".to_string()),
1908 }));
1909
1910 let json = serde_json::to_string(&event)?;
1911 let restored: ThreadEvent = serde_json::from_str(&json)?;
1912
1913 assert_eq!(restored, event);
1914 Ok(())
1915 }
1916
1917 #[test]
1918 fn compact_boundary_deserializes_legacy_payload_without_segment_metadata() -> Result<(), Box<dyn Error>> {
1919 let payload = r#"{
1920 "type":"thread.compact_boundary",
1921 "thread_id":"thread-1",
1922 "trigger":"recovery",
1923 "mode":"provider",
1924 "original_message_count":12,
1925 "compacted_message_count":5
1926 }"#;
1927
1928 let restored: ThreadEvent = serde_json::from_str(payload)?;
1929 let ThreadEvent::ThreadCompactBoundary(event) = restored else {
1930 panic!("expected thread.compact_boundary event");
1931 };
1932
1933 assert_eq!(event.thread_id, "thread-1");
1934 assert_eq!(event.history_artifact_path, None);
1935 assert_eq!(event.previous_segment_id, None);
1936 assert_eq!(event.new_segment_id, None);
1937 assert_eq!(event.previous_prefix_hash, None);
1938 assert_eq!(event.new_prefix_hash, None);
1939 assert_eq!(event.previous_catalog_hash, None);
1940 assert_eq!(event.new_catalog_hash, None);
1941 Ok(())
1942 }
1943}