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
4use crate::ui_protocol::CommunicationStyle;
5
6/// Apply the existing secret redaction to every externally supplied field.
7/// Call before emitting a `ThreadEvent`, not merely when drawing its UI.
8pub 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
22/// Adapt legacy styled messages without dropping evidence after the summary.
23/// Correlation identity belongs to the producer; this adapter does not authorize actions.
24pub 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
53/// Early-startup/headless diagnostic boundary before a thread event sink exists.
54/// Formatting and redaction finish before stderr is touched.
55pub 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
67/// Render a canonical diagnostic before any terminal UI is available.
68pub fn stderr_diagnostic(diagnostic: &Diagnostic) {
69    eprintln!("{}", redact_diagnostic(diagnostic).text());
70}
71
72/// Session-local observer shared by clones of one inline handle.
73pub 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    /// Void UI APIs report observer failures visibly and through sanitized telemetry.
131    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
149/// Legacy calls represent separate incidents. Producers updating one incident
150/// supply an explicit correlation identity through `Diagnostic` instead.
151pub fn legacy_ui_diagnostic(severity: DiagnosticSeverity, text: &str) -> Diagnostic {
152    legacy_diagnostic(severity, text, format!("legacy-{:032x}", rand::random::<u128>()))
153}
154
155/// Diagnostic codes that carry routine agent-internal failure evidence: the
156/// agent loop consumes them, but they need no main-transcript row under the
157/// default style. Everything else fails open to visible.
158const ROUTINE_SILENT_CODES: &[&str] = &[
159    // Agent-internal tool failure (preflight/validation/retry); the owning
160    // tool row keeps its failed state and Review keeps the evidence.
161    "tool.execution_failed",
162    // Transient provider retry progress; the spinner already covers it.
163    "provider.retrying",
164    // A recovered retry is good news, not an incident.
165    "provider.retry_succeeded",
166];
167
168/// Whether a diagnostic earns a main-transcript row under `style`.
169///
170/// Contract: persistence is never gated by this — callers persist the
171/// canonical event first and consult this only at paint time. Unknown codes
172/// fail open to visible; only the classified routine codes above (plus
173/// `tool.command_failed` under `Quiet`) go silent.
174/// `tool.policy_denied`, `tool.cancelled`, `provider.retry_stopped`, billing,
175/// planning, MCP, and `runtime.*` incidents stay visible in every style.
176#[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        // `resolve` maps `Unknown` to `Balanced`; this arm is unreachable but
183        // keeps the decision total if the mapping ever changes.
184        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        // Asymmetric matrix: every style sees both a silent and a visible code
237        // except Verbose, which is the deliberate show-everything escape hatch.
238        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            // Fail-open: an unknown future code is visible everywhere.
258            (CommunicationStyle::Balanced, "tool.something_new", true),
259            (CommunicationStyle::Quiet, "tool.something_new", true),
260            // An unknown future style degrades to Balanced, never to silence.
261            (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}