1pub use vtcode_exec_events::{Diagnostic, DiagnosticAction, DiagnosticSeverity};
3
4use crate::ui_protocol::CommunicationStyle;
5
6pub fn redact_diagnostic(diagnostic: &Diagnostic) -> Diagnostic {
9 use crate::sanitizer::redact_secrets;
10 let mut redacted = diagnostic.clone();
11 redacted.code = redact_secrets(redacted.code);
12 redacted.summary = redact_secrets(redacted.summary);
13 redacted.agent_details = redact_secrets(redacted.agent_details);
14 redacted.correlation_id = redact_secrets(redacted.correlation_id);
15 for action in &mut redacted.actions {
16 action.id = redact_secrets(std::mem::take(&mut action.id));
17 action.label = redact_secrets(std::mem::take(&mut action.label));
18 }
19 redacted
20}
21
22pub fn legacy_diagnostic(severity: DiagnosticSeverity, text: &str, correlation_id: String) -> Diagnostic {
25 let text = crate::sanitizer::redact_secrets(text.to_owned());
26 let mut lines = text.trim().splitn(2, '\n');
27 let first = lines.next().unwrap_or_default();
28 let (summary, details) = if first.len() <= 180 {
29 (first.to_owned(), lines.next().unwrap_or_default().to_owned())
30 } else {
31 let summary = match severity {
32 DiagnosticSeverity::Info => "Additional information is available. Expand Details for the full message.",
33 DiagnosticSeverity::Warning => "A warning needs attention. Expand Details for the full message.",
34 DiagnosticSeverity::Error => "An error needs attention. Expand Details for the full message.",
35 };
36 (summary.to_owned(), text.trim().to_owned())
37 };
38 let code = match severity {
39 DiagnosticSeverity::Info => "runtime.info",
40 DiagnosticSeverity::Warning => "runtime.warning",
41 DiagnosticSeverity::Error => "runtime.error",
42 };
43 Diagnostic {
44 severity,
45 code: code.into(),
46 summary,
47 agent_details: details,
48 correlation_id,
49 actions: Vec::new(),
50 }
51}
52
53pub fn stderr(arguments: std::fmt::Arguments<'_>) {
56 let text = arguments.to_string();
57 let lower = text.to_ascii_lowercase();
58 let severity = if lower.contains("warn") || lower.contains("cancel") {
59 DiagnosticSeverity::Warning
60 } else {
61 DiagnosticSeverity::Error
62 };
63 let diagnostic = legacy_diagnostic(severity, &text, "startup".into());
64 stderr_diagnostic(&diagnostic);
65}
66
67pub fn stderr_diagnostic(diagnostic: &Diagnostic) {
69 eprintln!("{}", redact_diagnostic(diagnostic).text());
70}
71
72pub type DiagnosticSink = std::sync::Arc<dyn Fn(&Diagnostic) -> anyhow::Result<()> + Send + Sync>;
74
75#[derive(Clone, Default)]
76pub struct DiagnosticObserver(std::sync::Arc<std::sync::RwLock<DiagnosticObserverState>>);
77
78#[derive(Default)]
79struct DiagnosticObserverState {
80 sink: Option<DiagnosticSink>,
81 recent: std::collections::VecDeque<Diagnostic>,
82}
83
84impl std::fmt::Debug for DiagnosticObserver {
85 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 formatter.write_str("DiagnosticObserver")
87 }
88}
89
90impl DiagnosticObserver {
91 pub fn set_sink(&self, sink: DiagnosticSink) -> anyhow::Result<()> {
92 let mut observer = self
93 .0
94 .write()
95 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
96 observer.sink = Some(sink);
97 observer.recent.clear();
98 Ok(())
99 }
100
101 fn notify(&self, diagnostic: &Diagnostic) -> anyhow::Result<()> {
102 let sink = {
103 let observer = self
104 .0
105 .read()
106 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
107 if observer.recent.iter().any(|previous| previous == diagnostic) {
108 return Ok(());
109 }
110 observer.sink.clone()
111 };
112 let Some(sink) = sink else {
113 return Ok(());
114 };
115 sink(diagnostic)?;
116 let mut observer = self
117 .0
118 .write()
119 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
120 observer
121 .recent
122 .retain(|previous| previous.correlation_id != diagnostic.correlation_id);
123 if observer.recent.len() == 128 {
124 let _ = observer.recent.pop_front();
125 }
126 observer.recent.push_back(diagnostic.clone());
127 Ok(())
128 }
129
130 pub fn prepare(&self, diagnostic: &Diagnostic) -> Diagnostic {
132 let mut diagnostic = redact_diagnostic(diagnostic);
133 let result = self.notify(&diagnostic);
134 if let Err(error) = result {
135 let message = crate::sanitizer::sanitize_provider_diagnostic(error.to_string());
136 tracing::warn!(error = %message, "canonical diagnostic persistence failed");
137 if !diagnostic.agent_details.is_empty() {
138 diagnostic.agent_details.push_str("\n\n");
139 }
140 diagnostic
141 .agent_details
142 .push_str("Diagnostic persistence failed; this message may not be saved.\n");
143 diagnostic.agent_details.push_str(&message);
144 }
145 diagnostic
146 }
147}
148
149pub fn legacy_ui_diagnostic(severity: DiagnosticSeverity, text: &str) -> Diagnostic {
152 legacy_diagnostic(severity, text, format!("legacy-{:032x}", rand::random::<u128>()))
153}
154
155const ROUTINE_SILENT_CODES: &[&str] = &[
159 "tool.execution_failed",
162 "provider.retrying",
164 "provider.retry_succeeded",
166];
167
168#[must_use]
177pub fn diagnostic_is_user_visible(style: CommunicationStyle, code: &str) -> bool {
178 match style.resolve() {
179 CommunicationStyle::Verbose => true,
180 CommunicationStyle::Balanced => !ROUTINE_SILENT_CODES.contains(&code),
181 CommunicationStyle::Quiet => code != "tool.command_failed" && !ROUTINE_SILENT_CODES.contains(&code),
182 CommunicationStyle::Unknown => !ROUTINE_SILENT_CODES.contains(&code),
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 #[test]
192 fn repeated_legacy_failures_are_distinct_incidents_but_explicit_incidents_deduplicate() {
193 let observer = DiagnosticObserver::default();
194 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
195 let output = captured.clone();
196 observer
197 .set_sink(std::sync::Arc::new(move |diagnostic| {
198 output.lock().unwrap().push(diagnostic.clone());
199 Ok(())
200 }))
201 .unwrap();
202 let mut ids = Vec::new();
203 for _ in 0..2 {
204 let diagnostic = legacy_ui_diagnostic(DiagnosticSeverity::Warning, "Clipboard unavailable");
205 ids.push(observer.prepare(&diagnostic).correlation_id);
206 }
207 assert_ne!(ids[0], ids[1], "a repeated command failure is a new incident");
208 let explicit = legacy_diagnostic(DiagnosticSeverity::Warning, "Retrying", "request-1".into());
209 assert_eq!(observer.prepare(&explicit), explicit);
210 assert_eq!(observer.prepare(&explicit), explicit);
211 assert_eq!(captured.lock().unwrap().len(), 3);
212 }
213 #[test]
214 fn diagnostic_redacts_summary_details_and_actions() {
215 let diagnostic = Diagnostic {
216 severity: DiagnosticSeverity::Error,
217 code: "provider.failure".into(),
218 summary: "Authorization: Bearer secret-summary-value".into(),
219 agent_details: "password=secret-detail-value".into(),
220 correlation_id: "request-2".into(),
221 actions: vec![DiagnosticAction {
222 id: "retry".into(),
223 label: "api_key=secret-action-value".into(),
224 }],
225 };
226 let text = serde_json::to_string(&redact_diagnostic(&diagnostic)).unwrap();
227 for secret in ["secret-summary-value", "secret-detail-value", "secret-action-value"] {
228 assert!(!text.contains(secret), "leaked {secret}");
229 }
230 assert!(text.contains("provider.failure"));
231 assert!(text.contains("request-2"));
232 }
233 #[test]
234 fn communication_style_visibility_silences_only_routine_codes() {
235 use crate::ui_protocol::CommunicationStyle;
236 for (style, code, visible) in [
239 (CommunicationStyle::Balanced, "tool.execution_failed", false),
240 (CommunicationStyle::Balanced, "provider.retrying", false),
241 (CommunicationStyle::Balanced, "provider.retry_succeeded", false),
242 (CommunicationStyle::Balanced, "tool.command_failed", true),
243 (CommunicationStyle::Balanced, "tool.policy_denied", true),
244 (CommunicationStyle::Balanced, "tool.cancelled", true),
245 (CommunicationStyle::Balanced, "provider.retry_stopped", true),
246 (CommunicationStyle::Balanced, "provider.stream_billing_advisory", true),
247 (CommunicationStyle::Balanced, "planning.awaiting_input", true),
248 (CommunicationStyle::Balanced, "mcp.catalog_failed", true),
249 (CommunicationStyle::Balanced, "runtime.error", true),
250 (CommunicationStyle::Quiet, "tool.execution_failed", false),
251 (CommunicationStyle::Quiet, "tool.command_failed", false),
252 (CommunicationStyle::Quiet, "tool.policy_denied", true),
253 (CommunicationStyle::Quiet, "provider.retry_stopped", true),
254 (CommunicationStyle::Verbose, "tool.execution_failed", true),
255 (CommunicationStyle::Verbose, "provider.retrying", true),
256 (CommunicationStyle::Verbose, "tool.command_failed", true),
257 (CommunicationStyle::Balanced, "tool.something_new", true),
259 (CommunicationStyle::Quiet, "tool.something_new", true),
260 (CommunicationStyle::Unknown, "tool.execution_failed", false),
262 (CommunicationStyle::Unknown, "tool.command_failed", true),
263 ] {
264 assert_eq!(
265 diagnostic_is_user_visible(style, code),
266 visible,
267 "{style:?} must treat {code} as visible={visible}"
268 );
269 }
270 }
271
272 #[test]
273 fn communication_style_parse_round_trips_and_falls_back_to_unknown() {
274 use crate::ui_protocol::CommunicationStyle;
275 for (text, expected) in [
276 ("balanced", CommunicationStyle::Balanced),
277 (" Quiet ", CommunicationStyle::Quiet),
278 ("VERBOSE", CommunicationStyle::Verbose),
279 ("", CommunicationStyle::Unknown),
280 ("terse", CommunicationStyle::Unknown),
281 ] {
282 assert_eq!(CommunicationStyle::parse(text), expected, "parse({text:?})");
283 }
284 assert_eq!(CommunicationStyle::Unknown.resolve(), CommunicationStyle::Balanced);
285 assert_eq!(CommunicationStyle::default(), CommunicationStyle::Balanced);
286 assert_eq!(
287 serde_json::from_value::<CommunicationStyle>(serde_json::json!("verbose")).unwrap(),
288 CommunicationStyle::Verbose
289 );
290 assert_eq!(
291 serde_json::from_value::<CommunicationStyle>(serde_json::json!("future-style")).unwrap(),
292 CommunicationStyle::Unknown
293 );
294 }
295
296 #[test]
297 fn diagnostic_long_message_preserves_complete_command_and_short_message_body() {
298 let command =
299 format!("Run `/a/very/long/path/{} --exact` to recover.\nExpected: exit zero.", "segment/".repeat(40));
300 let diagnostic = legacy_diagnostic(DiagnosticSeverity::Error, &command, "incident-7".into());
301 assert_eq!(diagnostic.summary, "An error needs attention. Expand Details for the full message.");
302 assert_eq!(diagnostic.agent_details, command);
303 let short =
304 legacy_diagnostic(DiagnosticSeverity::Warning, "Retry needs input.\nAttempts: 3", "incident-8".into());
305 assert_eq!(short.summary, "Retry needs input.");
306 assert_eq!(short.agent_details, "Attempts: 3");
307 }
308}