1use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12pub const EVENT_SCHEMA_VERSION: &str = "0.1.0";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
19pub struct VersionedThreadEvent {
20 pub schema_version: String,
22 pub event: ThreadEvent,
24}
25
26impl VersionedThreadEvent {
27 pub fn new(event: ThreadEvent) -> Self {
30 Self {
31 schema_version: EVENT_SCHEMA_VERSION.to_string(),
32 event,
33 }
34 }
35
36 pub fn into_event(self) -> ThreadEvent {
38 self.event
39 }
40}
41
42impl From<ThreadEvent> for VersionedThreadEvent {
43 fn from(event: ThreadEvent) -> Self {
44 Self::new(event)
45 }
46}
47
48pub trait EventEmitter {
50 fn emit(&mut self, event: &ThreadEvent);
52}
53
54impl<F> EventEmitter for F
55where
56 F: FnMut(&ThreadEvent),
57{
58 fn emit(&mut self, event: &ThreadEvent) {
59 self(event);
60 }
61}
62
63#[cfg(feature = "serde-json")]
65pub mod json {
66 use super::{ThreadEvent, VersionedThreadEvent};
67
68 pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
70 serde_json::to_value(event)
71 }
72
73 pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
75 serde_json::to_string(event)
76 }
77
78 pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
80 serde_json::from_str(payload)
81 }
82
83 pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
85 serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
86 }
87
88 pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
90 serde_json::from_str(payload)
91 }
92}
93
94#[cfg(feature = "telemetry-log")]
95mod log_support {
96 use log::Level;
97
98 use super::{EventEmitter, ThreadEvent, json};
99
100 #[derive(Debug, Clone)]
102 pub struct LogEmitter {
103 level: Level,
104 }
105
106 impl LogEmitter {
107 pub fn new(level: Level) -> Self {
109 Self { level }
110 }
111 }
112
113 impl Default for LogEmitter {
114 fn default() -> Self {
115 Self { level: Level::Info }
116 }
117 }
118
119 impl EventEmitter for LogEmitter {
120 fn emit(&mut self, event: &ThreadEvent) {
121 if log::log_enabled!(self.level) {
122 match json::to_string(event) {
123 Ok(serialized) => log::log!(self.level, "{}", serialized),
124 Err(err) => log::log!(
125 self.level,
126 "failed to serialize vtcode exec event for logging: {err}"
127 ),
128 }
129 }
130 }
131 }
132
133 pub use LogEmitter as PublicLogEmitter;
134}
135
136#[cfg(feature = "telemetry-log")]
137pub use log_support::PublicLogEmitter as LogEmitter;
138
139#[cfg(feature = "telemetry-tracing")]
140mod tracing_support {
141 use tracing::Level;
142
143 use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
144
145 #[derive(Debug, Clone)]
147 pub struct TracingEmitter {
148 level: Level,
149 }
150
151 impl TracingEmitter {
152 pub fn new(level: Level) -> Self {
154 Self { level }
155 }
156 }
157
158 impl Default for TracingEmitter {
159 fn default() -> Self {
160 Self { level: Level::INFO }
161 }
162 }
163
164 impl EventEmitter for TracingEmitter {
165 fn emit(&mut self, event: &ThreadEvent) {
166 match self.level {
167 Level::TRACE => tracing::event!(
168 target: "vtcode_exec_events",
169 Level::TRACE,
170 schema_version = EVENT_SCHEMA_VERSION,
171 event = ?VersionedThreadEvent::new(event.clone()),
172 "vtcode_exec_event"
173 ),
174 Level::DEBUG => tracing::event!(
175 target: "vtcode_exec_events",
176 Level::DEBUG,
177 schema_version = EVENT_SCHEMA_VERSION,
178 event = ?VersionedThreadEvent::new(event.clone()),
179 "vtcode_exec_event"
180 ),
181 Level::INFO => tracing::event!(
182 target: "vtcode_exec_events",
183 Level::INFO,
184 schema_version = EVENT_SCHEMA_VERSION,
185 event = ?VersionedThreadEvent::new(event.clone()),
186 "vtcode_exec_event"
187 ),
188 Level::WARN => tracing::event!(
189 target: "vtcode_exec_events",
190 Level::WARN,
191 schema_version = EVENT_SCHEMA_VERSION,
192 event = ?VersionedThreadEvent::new(event.clone()),
193 "vtcode_exec_event"
194 ),
195 Level::ERROR => tracing::event!(
196 target: "vtcode_exec_events",
197 Level::ERROR,
198 schema_version = EVENT_SCHEMA_VERSION,
199 event = ?VersionedThreadEvent::new(event.clone()),
200 "vtcode_exec_event"
201 ),
202 }
203 }
204 }
205
206 pub use TracingEmitter as PublicTracingEmitter;
207}
208
209#[cfg(feature = "telemetry-tracing")]
210pub use tracing_support::PublicTracingEmitter as TracingEmitter;
211
212#[cfg(feature = "schema-export")]
213pub mod schema {
214 use schemars::{schema::RootSchema, schema_for};
215
216 use super::{ThreadEvent, VersionedThreadEvent};
217
218 pub fn thread_event_schema() -> RootSchema {
220 schema_for!(ThreadEvent)
221 }
222
223 pub fn versioned_thread_event_schema() -> RootSchema {
225 schema_for!(VersionedThreadEvent)
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
232#[serde(tag = "type")]
233pub enum ThreadEvent {
234 #[serde(rename = "thread.started")]
236 ThreadStarted(ThreadStartedEvent),
237 #[serde(rename = "turn.started")]
239 TurnStarted(TurnStartedEvent),
240 #[serde(rename = "turn.completed")]
242 TurnCompleted(TurnCompletedEvent),
243 #[serde(rename = "turn.failed")]
245 TurnFailed(TurnFailedEvent),
246 #[serde(rename = "item.started")]
248 ItemStarted(ItemStartedEvent),
249 #[serde(rename = "item.updated")]
251 ItemUpdated(ItemUpdatedEvent),
252 #[serde(rename = "item.completed")]
254 ItemCompleted(ItemCompletedEvent),
255 #[serde(rename = "error")]
257 Error(ThreadErrorEvent),
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
262pub struct ThreadStartedEvent {
263 pub thread_id: String,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
268#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
269pub struct TurnStartedEvent {}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
273pub struct TurnCompletedEvent {
274 pub usage: Usage,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
280pub struct TurnFailedEvent {
281 pub message: String,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub usage: Option<Usage>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
290pub struct ThreadErrorEvent {
291 pub message: String,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
296#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
297pub struct Usage {
298 pub input_tokens: u64,
300 pub cached_input_tokens: u64,
302 pub output_tokens: u64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
308pub struct ItemCompletedEvent {
309 pub item: ThreadItem,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
315pub struct ItemStartedEvent {
316 pub item: ThreadItem,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
321#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
322pub struct ItemUpdatedEvent {
323 pub item: ThreadItem,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
329pub struct ThreadItem {
330 pub id: String,
332 #[serde(flatten)]
334 pub details: ThreadItemDetails,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
339#[serde(tag = "type", rename_all = "snake_case")]
340pub enum ThreadItemDetails {
341 AgentMessage(AgentMessageItem),
343 Reasoning(ReasoningItem),
345 CommandExecution(CommandExecutionItem),
347 FileChange(FileChangeItem),
349 McpToolCall(McpToolCallItem),
351 WebSearch(WebSearchItem),
353 Error(ErrorItem),
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
359pub struct AgentMessageItem {
360 pub text: String,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
366pub struct ReasoningItem {
367 pub text: String,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
372#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
373#[serde(rename_all = "snake_case")]
374pub enum CommandExecutionStatus {
375 #[default]
377 Completed,
378 Failed,
380 InProgress,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
385#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
386pub struct CommandExecutionItem {
387 pub command: String,
389 #[serde(default)]
391 pub aggregated_output: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub exit_code: Option<i32>,
395 pub status: CommandExecutionStatus,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
401pub struct FileChangeItem {
402 pub changes: Vec<FileUpdateChange>,
404 pub status: PatchApplyStatus,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
410pub struct FileUpdateChange {
411 pub path: String,
413 pub kind: PatchChangeKind,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
418#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
419#[serde(rename_all = "snake_case")]
420pub enum PatchApplyStatus {
421 Completed,
423 Failed,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
428#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
429#[serde(rename_all = "snake_case")]
430pub enum PatchChangeKind {
431 Add,
433 Delete,
435 Update,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
440#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
441pub struct McpToolCallItem {
442 pub tool_name: String,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub arguments: Option<Value>,
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub result: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub status: Option<McpToolCallStatus>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
457#[serde(rename_all = "snake_case")]
458pub enum McpToolCallStatus {
459 Started,
461 Completed,
463 Failed,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
469pub struct WebSearchItem {
470 pub query: String,
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub provider: Option<String>,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub results: Option<Vec<String>>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
481#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
482pub struct ErrorItem {
483 pub message: String,
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use std::error::Error;
491
492 #[test]
493 fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
494 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
495 usage: Usage {
496 input_tokens: 1,
497 cached_input_tokens: 2,
498 output_tokens: 3,
499 },
500 });
501
502 let json = serde_json::to_string(&event)?;
503 let restored: ThreadEvent = serde_json::from_str(&json)?;
504
505 assert_eq!(restored, event);
506 Ok(())
507 }
508
509 #[test]
510 fn versioned_event_wraps_schema_version() {
511 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
512 thread_id: "abc".to_string(),
513 });
514
515 let versioned = VersionedThreadEvent::new(event.clone());
516
517 assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
518 assert_eq!(versioned.event, event);
519 assert_eq!(versioned.into_event(), event);
520 }
521
522 #[cfg(feature = "serde-json")]
523 #[test]
524 fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
525 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
526 item: ThreadItem {
527 id: "item-1".to_string(),
528 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
529 text: "hello".to_string(),
530 }),
531 },
532 });
533
534 let payload = crate::json::versioned_to_string(&event)?;
535 let restored = crate::json::versioned_from_str(&payload)?;
536
537 assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
538 assert_eq!(restored.event, event);
539 Ok(())
540 }
541}