vtcode_exec_events/
diagnostic.rs1use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
5#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6#[serde(rename_all = "snake_case")]
7pub enum DiagnosticSeverity {
8 Info,
9 Warning,
10 Error,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
15pub struct DiagnosticAction {
16 pub id: String,
18 pub label: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
23pub struct Diagnostic {
24 pub severity: DiagnosticSeverity,
25 pub code: String,
27 pub summary: String,
29 #[serde(default, skip_serializing_if = "String::is_empty")]
31 pub agent_details: String,
32 pub correlation_id: String,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub actions: Vec<DiagnosticAction>,
36}
37
38impl Diagnostic {
39 pub fn text(&self) -> String {
41 let mut text = self.summary.clone();
42 if !self.actions.is_empty() {
43 text.push_str("\nAvailable actions: ");
44 text.push_str(
45 &self
46 .actions
47 .iter()
48 .map(|action| action.label.as_str())
49 .collect::<Vec<_>>()
50 .join("; "),
51 );
52 }
53 if !self.agent_details.is_empty() {
54 text.push_str("\nDetails:\n");
55 text.push_str(&self.agent_details);
56 }
57 text
58 }
59}
60
61#[derive(Default)]
64pub struct DiagnosticProjection {
65 latest: std::collections::BTreeMap<String, (u64, Diagnostic)>,
66 sequence: u64,
67}
68
69impl DiagnosticProjection {
70 pub fn observe(&mut self, event: &crate::ThreadEvent) {
71 use crate::{ThreadEvent, ThreadItemDetails};
72 let item = match event {
73 ThreadEvent::ItemStarted(event) => &event.item,
74 ThreadEvent::ItemUpdated(event) => &event.item,
75 ThreadEvent::ItemCompleted(event) => &event.item,
76 _ => return,
77 };
78 let ThreadItemDetails::Reasoning(reasoning) = &item.details else {
79 return;
80 };
81 let Some(diagnostic) = &reasoning.diagnostic else {
82 return;
83 };
84 let _ = self
85 .latest
86 .insert(diagnostic.correlation_id.clone(), (self.sequence, diagnostic.as_ref().clone()));
87 self.sequence = self.sequence.saturating_add(1);
88 }
89
90 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
91 let mut ordered: Vec<_> = self.latest.into_values().collect();
92 ordered.sort_by_key(|(sequence, _)| *sequence);
93 ordered.into_iter().map(|(_, diagnostic)| diagnostic).collect()
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::{ReasoningItem, ThreadEvent};
101
102 #[test]
103 fn diagnostic_projection_keeps_latest_incidents_in_transition_order() {
104 use crate::{ItemCompletedEvent, ThreadItem, ThreadItemDetails};
105 let make = |id: &str, summary: &str| {
106 ThreadEvent::ItemCompleted(ItemCompletedEvent {
107 item: ThreadItem {
108 id: id.into(),
109 context: None,
110 details: ThreadItemDetails::Reasoning(Box::new(ReasoningItem {
111 text: summary.into(),
112 stage: Some("diagnosis".into()),
113 diagnostic: Some(Box::new(Diagnostic {
114 severity: DiagnosticSeverity::Warning,
115 code: "provider.retrying".into(),
116 summary: summary.into(),
117 agent_details: format!("evidence for {summary}"),
118 correlation_id: id.into(),
119 actions: Vec::new(),
120 })),
121 })),
122 },
123 })
124 };
125 let mut projection = DiagnosticProjection::default();
126 projection.observe(&make("request-a", "first"));
127 projection.observe(&make("request-b", "second"));
128 projection.observe(&make("request-a", "third"));
129 let diagnostics = projection.into_diagnostics();
130 assert_eq!(diagnostics.len(), 2);
131 assert_eq!(diagnostics[0].correlation_id, "request-b");
132 assert_eq!(diagnostics[1].summary, "third");
133 assert_eq!(diagnostics[1].agent_details, "evidence for third");
134 }
135
136 #[test]
137 fn diagnostic_legacy_reasoning_remains_readable() {
138 let old: ReasoningItem = serde_json::from_str(r#"{"text":"saved diagnosis","stage":"diagnosis"}"#).unwrap();
139 assert!(old.diagnostic.is_none());
140 let diagnostic = Diagnostic {
141 severity: DiagnosticSeverity::Warning,
142 code: "planning.awaiting_input".into(),
143 summary: "Planning needs your input.".into(),
144 agent_details: "attempt=3; remaining=0".into(),
145 correlation_id: "plan-7".into(),
146 actions: vec![DiagnosticAction {
147 id: "retry".into(),
148 label: "Retry from retained evidence".into(),
149 }],
150 };
151 let item = ReasoningItem {
152 text: diagnostic.summary.clone(),
153 stage: Some("diagnosis".into()),
154 diagnostic: Some(Box::new(diagnostic.clone())),
155 };
156 assert_eq!(serde_json::from_value::<ReasoningItem>(serde_json::to_value(&item).unwrap()).unwrap(), item);
157 assert!(diagnostic.text().contains("Details:\nattempt=3; remaining=0"));
158 assert!(size_of::<ThreadEvent>() <= 80);
159 }
160}