vtcode_commons/
diagnostics.rs1pub use vtcode_exec_events::{Diagnostic, DiagnosticAction, DiagnosticSeverity};
3
4pub fn redact_diagnostic(diagnostic: &Diagnostic) -> Diagnostic {
7 use crate::sanitizer::redact_secrets;
8 let mut redacted = diagnostic.clone();
9 redacted.code = redact_secrets(redacted.code);
10 redacted.summary = redact_secrets(redacted.summary);
11 redacted.agent_details = redact_secrets(redacted.agent_details);
12 redacted.correlation_id = redact_secrets(redacted.correlation_id);
13 for action in &mut redacted.actions {
14 action.id = redact_secrets(std::mem::take(&mut action.id));
15 action.label = redact_secrets(std::mem::take(&mut action.label));
16 }
17 redacted
18}
19
20pub fn legacy_diagnostic(severity: DiagnosticSeverity, text: &str, correlation_id: String) -> Diagnostic {
23 let text = crate::sanitizer::redact_secrets(text.to_owned());
24 let mut lines = text.trim().splitn(2, '\n');
25 let first = lines.next().unwrap_or_default();
26 let (summary, details) = if first.len() <= 180 {
27 (first.to_owned(), lines.next().unwrap_or_default().to_owned())
28 } else {
29 let summary = match severity {
30 DiagnosticSeverity::Info => "Additional information is available. Expand Details for the full message.",
31 DiagnosticSeverity::Warning => "A warning needs attention. Expand Details for the full message.",
32 DiagnosticSeverity::Error => "An error needs attention. Expand Details for the full message.",
33 };
34 (summary.to_owned(), text.trim().to_owned())
35 };
36 let code = match severity {
37 DiagnosticSeverity::Info => "runtime.info",
38 DiagnosticSeverity::Warning => "runtime.warning",
39 DiagnosticSeverity::Error => "runtime.error",
40 };
41 Diagnostic {
42 severity,
43 code: code.into(),
44 summary,
45 agent_details: details,
46 correlation_id,
47 actions: Vec::new(),
48 }
49}
50
51pub fn stderr(arguments: std::fmt::Arguments<'_>) {
54 let text = arguments.to_string();
55 let lower = text.to_ascii_lowercase();
56 let severity = if lower.contains("warn") || lower.contains("cancel") {
57 DiagnosticSeverity::Warning
58 } else {
59 DiagnosticSeverity::Error
60 };
61 let diagnostic = legacy_diagnostic(severity, &text, "startup".into());
62 stderr_diagnostic(&diagnostic);
63}
64
65pub fn stderr_diagnostic(diagnostic: &Diagnostic) {
67 eprintln!("{}", redact_diagnostic(diagnostic).text());
68}
69
70pub type DiagnosticSink = std::sync::Arc<dyn Fn(&Diagnostic) -> anyhow::Result<()> + Send + Sync>;
72
73#[derive(Clone, Default)]
74pub struct DiagnosticObserver(std::sync::Arc<std::sync::RwLock<DiagnosticObserverState>>);
75
76#[derive(Default)]
77struct DiagnosticObserverState {
78 sink: Option<DiagnosticSink>,
79 recent: std::collections::VecDeque<Diagnostic>,
80}
81
82impl std::fmt::Debug for DiagnosticObserver {
83 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 formatter.write_str("DiagnosticObserver")
85 }
86}
87
88impl DiagnosticObserver {
89 pub fn set_sink(&self, sink: DiagnosticSink) -> anyhow::Result<()> {
90 let mut observer = self
91 .0
92 .write()
93 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
94 observer.sink = Some(sink);
95 observer.recent.clear();
96 Ok(())
97 }
98
99 fn notify(&self, diagnostic: &Diagnostic) -> anyhow::Result<()> {
100 let sink = {
101 let observer = self
102 .0
103 .read()
104 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
105 if observer.recent.iter().any(|previous| previous == diagnostic) {
106 return Ok(());
107 }
108 observer.sink.clone()
109 };
110 let Some(sink) = sink else {
111 return Ok(());
112 };
113 sink(diagnostic)?;
114 let mut observer = self
115 .0
116 .write()
117 .map_err(|error| anyhow::anyhow!("diagnostic observer lock poisoned: {error}"))?;
118 observer
119 .recent
120 .retain(|previous| previous.correlation_id != diagnostic.correlation_id);
121 if observer.recent.len() == 128 {
122 let _ = observer.recent.pop_front();
123 }
124 observer.recent.push_back(diagnostic.clone());
125 Ok(())
126 }
127
128 pub fn prepare(&self, diagnostic: &Diagnostic) -> Diagnostic {
130 let mut diagnostic = redact_diagnostic(diagnostic);
131 let result = self.notify(&diagnostic);
132 if let Err(error) = result {
133 let message = crate::sanitizer::sanitize_provider_diagnostic(error.to_string());
134 tracing::warn!(error = %message, "canonical diagnostic persistence failed");
135 if !diagnostic.agent_details.is_empty() {
136 diagnostic.agent_details.push_str("\n\n");
137 }
138 diagnostic
139 .agent_details
140 .push_str("Diagnostic persistence failed; this message may not be saved.\n");
141 diagnostic.agent_details.push_str(&message);
142 }
143 diagnostic
144 }
145}
146
147pub fn legacy_ui_diagnostic(severity: DiagnosticSeverity, text: &str) -> Diagnostic {
150 legacy_diagnostic(severity, text, format!("legacy-{:032x}", rand::random::<u128>()))
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 #[test]
157 fn repeated_legacy_failures_are_distinct_incidents_but_explicit_incidents_deduplicate() {
158 let observer = DiagnosticObserver::default();
159 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
160 let output = captured.clone();
161 observer
162 .set_sink(std::sync::Arc::new(move |diagnostic| {
163 output.lock().unwrap().push(diagnostic.clone());
164 Ok(())
165 }))
166 .unwrap();
167 let mut ids = Vec::new();
168 for _ in 0..2 {
169 let diagnostic = legacy_ui_diagnostic(DiagnosticSeverity::Warning, "Clipboard unavailable");
170 ids.push(observer.prepare(&diagnostic).correlation_id);
171 }
172 assert_ne!(ids[0], ids[1], "a repeated command failure is a new incident");
173 let explicit = legacy_diagnostic(DiagnosticSeverity::Warning, "Retrying", "request-1".into());
174 assert_eq!(observer.prepare(&explicit), explicit);
175 assert_eq!(observer.prepare(&explicit), explicit);
176 assert_eq!(captured.lock().unwrap().len(), 3);
177 }
178 #[test]
179 fn diagnostic_redacts_summary_details_and_actions() {
180 let diagnostic = Diagnostic {
181 severity: DiagnosticSeverity::Error,
182 code: "provider.failure".into(),
183 summary: "Authorization: Bearer secret-summary-value".into(),
184 agent_details: "password=secret-detail-value".into(),
185 correlation_id: "request-2".into(),
186 actions: vec![DiagnosticAction {
187 id: "retry".into(),
188 label: "api_key=secret-action-value".into(),
189 }],
190 };
191 let text = serde_json::to_string(&redact_diagnostic(&diagnostic)).unwrap();
192 for secret in ["secret-summary-value", "secret-detail-value", "secret-action-value"] {
193 assert!(!text.contains(secret), "leaked {secret}");
194 }
195 assert!(text.contains("provider.failure"));
196 assert!(text.contains("request-2"));
197 }
198 #[test]
199 fn diagnostic_long_message_preserves_complete_command_and_short_message_body() {
200 let command =
201 format!("Run `/a/very/long/path/{} --exact` to recover.\nExpected: exit zero.", "segment/".repeat(40));
202 let diagnostic = legacy_diagnostic(DiagnosticSeverity::Error, &command, "incident-7".into());
203 assert_eq!(diagnostic.summary, "An error needs attention. Expand Details for the full message.");
204 assert_eq!(diagnostic.agent_details, command);
205 let short =
206 legacy_diagnostic(DiagnosticSeverity::Warning, "Retry needs input.\nAttempts: 3", "incident-8".into());
207 assert_eq!(short.summary, "Retry needs input.");
208 assert_eq!(short.agent_details, "Attempts: 3");
209 }
210}