Skip to main content

vtcode_commons/
diagnostics.rs

1//! Sanitize the canonical payload before persistence, logging, and display.
2pub use vtcode_exec_events::{Diagnostic, DiagnosticAction, DiagnosticSeverity};
3
4/// Apply the existing secret redaction to every externally supplied field.
5/// Call before emitting a `ThreadEvent`, not merely when drawing its UI.
6pub 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
20/// Adapt legacy styled messages without dropping evidence after the summary.
21/// Correlation identity belongs to the producer; this adapter does not authorize actions.
22pub 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
51/// Early-startup/headless diagnostic boundary before a thread event sink exists.
52/// Formatting and redaction finish before stderr is touched.
53pub 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
65/// Render a canonical diagnostic before any terminal UI is available.
66pub fn stderr_diagnostic(diagnostic: &Diagnostic) {
67    eprintln!("{}", redact_diagnostic(diagnostic).text());
68}
69
70/// Session-local observer shared by clones of one inline handle.
71pub 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    /// Void UI APIs report observer failures visibly and through sanitized telemetry.
129    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
147/// Legacy calls represent separate incidents. Producers updating one incident
148/// supply an explicit correlation identity through `Diagnostic` instead.
149pub 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}