Skip to main content

vtcode_core/
error.rs

1//! Structured error handling for VT Code.
2//!
3//! Provides a VT Code-specific error envelope with machine-readable codes and
4//! contextual information while reusing the shared `vtcode_commons`
5//! classification system.
6
7use crate::llm::provider::LLMError;
8use crate::retry_after::retry_after_from_llm_metadata;
9use crate::tools::registry::{ToolErrorType, ToolExecutionError};
10use crate::tools::unified_error::{UnifiedErrorKind, UnifiedToolError};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13use vtcode_commons::sanitizer::sanitize_provider_diagnostic;
14pub use vtcode_commons::{
15    BackoffStrategy, ConfigGuidance, ErrorCategory, MisconfigurationKind, Retryability, detect_misconfiguration,
16    is_misconfiguration,
17};
18use vtcode_macros::DebugNoInline;
19
20/// Result type alias for VT Code operations.
21pub type Result<T> = std::result::Result<T, VtCodeError>;
22
23/// Core error type for VT Code operations.
24///
25/// Uses `thiserror::Error` for automatic `std::error::Error` implementation
26/// and provides clear error messages with context.
27///
28/// `Debug` is derived via [`DebugNoInline`] rather than `#[derive(Debug)]`: the
29/// envelope wraps an arbitrary `source` chain and is formatted on fan-out failure
30/// paths, so the built-in derive's implied `#[inline]` would inline the whole
31/// chain into every `{:?}` / `?err` site.
32#[derive(DebugNoInline, Error, Serialize, Deserialize)]
33#[error("{category}: {message}")]
34pub struct VtCodeError {
35    /// Error category for categorization and handling.
36    pub category: ErrorCategory,
37
38    /// Machine-readable error code.
39    pub code: ErrorCode,
40
41    /// Human-readable error message.
42    pub message: String,
43
44    /// Optional context for debugging.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub context: Option<String>,
47
48    /// Optional backoff hint for the next retry attempt in milliseconds.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub retry_after_ms: Option<u64>,
51
52    /// Optional source error for chained errors.
53    #[serde(skip)]
54    #[source]
55    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
56}
57
58/// Machine-readable error codes for precise error identification.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub enum ErrorCode {
61    // Input errors
62    InvalidArgument,
63    ValidationFailed,
64    ParseError,
65
66    // Execution errors
67    CommandFailed,
68    ToolExecutionFailed,
69    Timeout,
70
71    // Network errors
72    ConnectionFailed,
73    RequestFailed,
74    RateLimited,
75    ServiceUnavailable,
76
77    // LLM errors
78    AuthenticationFailed,
79    LLMProviderError,
80    TokenLimitExceeded,
81    ContextTooLong,
82
83    // Config errors
84    ConfigInvalid,
85    ConfigMissing,
86    ConfigParseFailed,
87
88    // Security errors
89    PermissionDenied,
90    PolicyViolation,
91    PlanningPolicyViolation,
92    SandboxViolation,
93    DotfileProtection,
94
95    // System errors
96    IoError,
97    OutOfMemory,
98    ResourceUnavailable,
99    ResourceNotFound,
100
101    // Internal errors
102    ToolNotFound,
103    CircuitOpen,
104    Cancelled,
105    Unexpected,
106    NotImplemented,
107}
108
109impl VtCodeError {
110    /// Create a new error with the given category, code, and message.
111    pub fn new<S: Into<String>>(category: ErrorCategory, code: ErrorCode, message: S) -> Self {
112        Self {
113            category,
114            code,
115            message: message.into(),
116            context: None,
117            retry_after_ms: None,
118            source: None,
119        }
120    }
121
122    /// Add context to the error.
123    pub fn with_context<S: Into<String>>(mut self, context: S) -> Self {
124        self.context = Some(context.into());
125        self
126    }
127
128    /// Add a retry-after hint to the error.
129    pub fn with_retry_after(mut self, retry_after: std::time::Duration) -> Self {
130        self.retry_after_ms = Some(retry_after.as_millis().min(u128::from(u64::MAX)) as u64);
131        self
132    }
133
134    /// Set the source error for error chaining.
135    pub fn with_source<E: std::error::Error + Send + Sync + 'static>(mut self, source: E) -> Self {
136        self.source = Some(Box::new(source));
137        self
138    }
139
140    /// Returns the retry-after hint as a duration when present.
141    pub fn retry_after(&self) -> Option<std::time::Duration> {
142        self.retry_after_ms.map(std::time::Duration::from_millis)
143    }
144
145    /// Returns whether the error can be retried safely.
146    pub const fn is_retryable(&self) -> bool {
147        self.category.is_retryable()
148    }
149
150    /// Returns the retry strategy for this error category.
151    pub fn retryability(&self) -> Retryability {
152        self.category.retryability()
153    }
154
155    /// Convenience method for input errors.
156    pub fn input<S: Into<String>>(code: ErrorCode, message: S) -> Self {
157        Self::new(ErrorCategory::InvalidParameters, code, message)
158    }
159
160    /// Convenience method for execution errors.
161    pub fn execution<S: Into<String>>(code: ErrorCode, message: S) -> Self {
162        Self::new(ErrorCategory::ExecutionError, code, message)
163    }
164
165    /// Convenience method for network errors.
166    pub fn network<S: Into<String>>(code: ErrorCode, message: S) -> Self {
167        Self::new(ErrorCategory::Network, code, message)
168    }
169
170    /// Convenience method for LLM errors.
171    pub fn llm<S: Into<String>>(code: ErrorCode, message: S) -> Self {
172        Self::new(ErrorCategory::ExecutionError, code, message)
173    }
174
175    /// Convenience method for config errors.
176    pub fn config<S: Into<String>>(code: ErrorCode, message: S) -> Self {
177        Self::new(ErrorCategory::InvalidParameters, code, message)
178    }
179
180    /// Convenience method for security errors.
181    pub fn security<S: Into<String>>(code: ErrorCode, message: S) -> Self {
182        Self::new(ErrorCategory::PolicyViolation, code, message)
183    }
184
185    /// Convenience method for system errors.
186    pub fn system<S: Into<String>>(code: ErrorCode, message: S) -> Self {
187        Self::new(ErrorCategory::ExecutionError, code, message)
188    }
189
190    /// Convenience method for internal errors.
191    pub fn internal<S: Into<String>>(code: ErrorCode, message: S) -> Self {
192        Self::new(ErrorCategory::ExecutionError, code, message)
193    }
194
195    /// Create an error from a canonical category using the default error code.
196    pub fn from_category<S: Into<String>>(category: ErrorCategory, message: S) -> Self {
197        Self::new(category, ErrorCode::from_category(category), message)
198    }
199
200    /// Check settings/config first: return guidance when this failure is
201    /// caused by user misconfiguration.
202    ///
203    /// Config error codes (`ConfigInvalid`/`ConfigMissing`/`ConfigParseFailed`)
204    /// are always misconfiguration. Otherwise the message plus context is
205    /// matched against config markers (credentials, model, provider,
206    /// `base_url`, `vtcode.toml`, MCP, sampling ranges). Transient failures
207    /// and LLM argument mistakes return `None`.
208    #[must_use]
209    pub fn misconfiguration_guidance(&self) -> Option<ConfigGuidance> {
210        if matches!(self.code, ErrorCode::ConfigInvalid | ErrorCode::ConfigMissing | ErrorCode::ConfigParseFailed) {
211            if let Some(guidance) = self.message_guidance() {
212                return Some(guidance);
213            }
214            return Some(ConfigGuidance {
215                kind: MisconfigurationKind::ConfigFile,
216                setting: "vtcode.toml",
217                location: "workspace / user / system config layers",
218                fix: std::borrow::Cow::Borrowed(
219                    "Invalid config file. Validate vtcode.toml syntax and fields, then retry.",
220                ),
221            });
222        }
223        self.message_guidance()
224    }
225
226    /// Whether this failure is user misconfiguration that must be fixed
227    /// before retrying.
228    #[must_use]
229    pub fn is_misconfiguration(&self) -> bool {
230        self.misconfiguration_guidance().is_some()
231    }
232
233    /// Attach config guidance to the error so it is visible in `Display`
234    /// (`{category}: {message}`) and does not get retried blindly.
235    /// Idempotent: does nothing when guidance is absent or already present.
236    #[must_use]
237    pub fn with_misconfiguration_guidance(mut self) -> Self {
238        let Some(guidance) = self.misconfiguration_guidance() else {
239            return self;
240        };
241        let suffix = guidance.user_message();
242        // Use the full terminal phrase as the idempotency marker; it is far
243        // less likely to collide with user content than the shorter prefix.
244        if self.message.contains("Correct the configuration before retrying.") {
245            return self;
246        }
247        // `context` is not part of thiserror's Display output, so keep the
248        // guidance in the visible message even when the provider returned a
249        // large diagnostic payload.
250        self.message.push(' ');
251        self.message.push_str(&suffix);
252        self
253    }
254
255    fn message_guidance(&self) -> Option<ConfigGuidance> {
256        let mut haystack = self.message.clone();
257        if let Some(context) = self.context.as_deref() {
258            haystack.push('\n');
259            haystack.push_str(context);
260        }
261        let mut source = self.source.as_deref().map(|source| source as &dyn std::error::Error);
262        while let Some(error) = source {
263            haystack.push('\n');
264            haystack.push_str(&error.to_string());
265            source = error.source();
266        }
267        detect_misconfiguration(self.category, &haystack)
268    }
269}
270
271impl ErrorCode {
272    /// Map a canonical error category to a default machine-readable code.
273    pub const fn from_category(category: ErrorCategory) -> Self {
274        match category {
275            ErrorCategory::Network => ErrorCode::ConnectionFailed,
276            ErrorCategory::Timeout => ErrorCode::Timeout,
277            ErrorCategory::RateLimit => ErrorCode::RateLimited,
278            ErrorCategory::ServiceUnavailable => ErrorCode::ServiceUnavailable,
279            ErrorCategory::CircuitOpen => ErrorCode::CircuitOpen,
280            ErrorCategory::Authentication => ErrorCode::AuthenticationFailed,
281            ErrorCategory::InvalidParameters => ErrorCode::InvalidArgument,
282            ErrorCategory::ToolNotFound => ErrorCode::ToolNotFound,
283            ErrorCategory::ResourceNotFound => ErrorCode::ResourceNotFound,
284            ErrorCategory::PermissionDenied => ErrorCode::PermissionDenied,
285            ErrorCategory::PolicyViolation => ErrorCode::PolicyViolation,
286            ErrorCategory::PlanningPolicyViolation => ErrorCode::PlanningPolicyViolation,
287            ErrorCategory::SandboxFailure => ErrorCode::SandboxViolation,
288            ErrorCategory::ResourceExhausted => ErrorCode::ResourceUnavailable,
289            ErrorCategory::Cancelled => ErrorCode::Cancelled,
290            ErrorCategory::ExecutionError => ErrorCode::Unexpected,
291        }
292    }
293
294    fn from_unified_kind(kind: UnifiedErrorKind) -> Self {
295        match kind {
296            UnifiedErrorKind::Timeout => ErrorCode::Timeout,
297            UnifiedErrorKind::Network => ErrorCode::ConnectionFailed,
298            UnifiedErrorKind::RateLimit => ErrorCode::RateLimited,
299            UnifiedErrorKind::ArgumentValidation => ErrorCode::ValidationFailed,
300            UnifiedErrorKind::ToolNotFound => ErrorCode::ToolNotFound,
301            UnifiedErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
302            UnifiedErrorKind::SandboxFailure => ErrorCode::SandboxViolation,
303            UnifiedErrorKind::InternalError => ErrorCode::Unexpected,
304            UnifiedErrorKind::CircuitOpen => ErrorCode::CircuitOpen,
305            UnifiedErrorKind::ResourceExhausted => ErrorCode::ResourceUnavailable,
306            UnifiedErrorKind::Cancelled => ErrorCode::Cancelled,
307            UnifiedErrorKind::PolicyViolation => ErrorCode::PolicyViolation,
308            UnifiedErrorKind::PlanningPolicyViolation => ErrorCode::PlanningPolicyViolation,
309            UnifiedErrorKind::ExecutionFailed | UnifiedErrorKind::Unknown => ErrorCode::ToolExecutionFailed,
310        }
311    }
312
313    fn from_tool_error_type(error_type: ToolErrorType) -> Self {
314        match error_type {
315            ToolErrorType::InvalidParameters => ErrorCode::ValidationFailed,
316            ToolErrorType::ToolNotFound => ErrorCode::ToolNotFound,
317            ToolErrorType::PermissionDenied => ErrorCode::PermissionDenied,
318            ToolErrorType::ResourceNotFound => ErrorCode::ResourceNotFound,
319            ToolErrorType::NetworkError => ErrorCode::ConnectionFailed,
320            ToolErrorType::Timeout => ErrorCode::Timeout,
321            ToolErrorType::ExecutionError => ErrorCode::ToolExecutionFailed,
322            ToolErrorType::PolicyViolation => ErrorCode::PolicyViolation,
323        }
324    }
325}
326
327// Implement conversions from common error types
328impl From<std::io::Error> for VtCodeError {
329    fn from(err: std::io::Error) -> Self {
330        VtCodeError::system(ErrorCode::IoError, err.to_string())
331            .with_source(err)
332            .with_misconfiguration_guidance()
333    }
334}
335
336impl From<serde_json::Error> for VtCodeError {
337    fn from(err: serde_json::Error) -> Self {
338        VtCodeError::config(ErrorCode::ConfigParseFailed, err.to_string())
339            .with_source(err)
340            .with_misconfiguration_guidance()
341    }
342}
343
344impl From<reqwest::Error> for VtCodeError {
345    fn from(err: reqwest::Error) -> Self {
346        let code = if err.is_timeout() {
347            ErrorCode::Timeout
348        } else if err.is_connect() {
349            ErrorCode::ConnectionFailed
350        } else {
351            ErrorCode::RequestFailed
352        };
353        VtCodeError::network(code, err.to_string())
354            .with_source(err)
355            .with_misconfiguration_guidance()
356    }
357}
358
359impl From<anyhow::Error> for VtCodeError {
360    fn from(err: anyhow::Error) -> Self {
361        let category = vtcode_commons::classify_anyhow_error(&err);
362        VtCodeError::new(category, ErrorCode::from_category(category), err.to_string())
363            .with_context(format!("{err:#}"))
364            .with_misconfiguration_guidance()
365    }
366}
367
368impl From<LLMError> for VtCodeError {
369    fn from(err: LLMError) -> Self {
370        let category = ErrorCategory::from(&err);
371        let code = match &err {
372            LLMError::Authentication { .. } => ErrorCode::AuthenticationFailed,
373            LLMError::RateLimit { .. } => {
374                if category == ErrorCategory::ResourceExhausted {
375                    ErrorCode::from_category(category)
376                } else {
377                    ErrorCode::RateLimited
378                }
379            }
380            LLMError::InvalidRequest { .. } => ErrorCode::ValidationFailed,
381            LLMError::Network { message, .. } => {
382                if vtcode_commons::classify_error_message(message) == ErrorCategory::Timeout {
383                    ErrorCode::Timeout
384                } else {
385                    ErrorCode::ConnectionFailed
386                }
387            }
388            LLMError::Provider { metadata, .. } => {
389                if category == ErrorCategory::ResourceExhausted {
390                    ErrorCode::from_category(category)
391                } else {
392                    metadata
393                        .as_ref()
394                        .and_then(|meta| meta.status)
395                        .map(|status| match status {
396                            408 => ErrorCode::Timeout,
397                            429 => ErrorCode::RateLimited,
398                            500 | 502 | 503 | 504 | 529 => ErrorCode::ServiceUnavailable,
399                            _ => ErrorCode::LLMProviderError,
400                        })
401                        .unwrap_or(ErrorCode::LLMProviderError)
402                }
403            }
404        };
405        let message = llm_error_message(&err);
406        let metadata_context = llm_metadata_context(&err);
407        let retry_after = llm_retry_after(&err);
408
409        let mut error = VtCodeError::new(category, code, message);
410        if let Some(context) = metadata_context {
411            error = error.with_context(context);
412        }
413        let error = error.with_source(err);
414        let error = if let Some(retry_after) = retry_after {
415            error.with_retry_after(retry_after)
416        } else {
417            error
418        };
419        error.with_misconfiguration_guidance()
420    }
421}
422
423impl From<UnifiedToolError> for VtCodeError {
424    fn from(err: UnifiedToolError) -> Self {
425        let mut error = VtCodeError::new(
426            ErrorCategory::from(err.kind),
427            ErrorCode::from_unified_kind(err.kind),
428            err.user_message.clone(),
429        );
430
431        if let Some(ctx) = &err.debug_context {
432            let mut metadata = vec![format!("tool={}", ctx.tool_name), format!("attempt={}", ctx.attempt)];
433            if let Some(invocation_id) = &ctx.invocation_id {
434                metadata.push(format!("invocation_id={invocation_id}"));
435            }
436            metadata.extend(ctx.metadata.iter().map(|(key, value)| format!("{key}={value}")));
437            error = error.with_context(metadata.join(", "));
438        }
439
440        error.with_source(err).with_misconfiguration_guidance()
441    }
442}
443
444impl From<ToolExecutionError> for VtCodeError {
445    fn from(err: ToolExecutionError) -> Self {
446        let category = ErrorCategory::from(err.error_type);
447        let mut error =
448            VtCodeError::new(category, ErrorCode::from_tool_error_type(err.error_type), err.message.clone());
449
450        let mut context_parts = Vec::new();
451        if let Some(original_error) = &err.original_error {
452            context_parts.push(format!("original_error={original_error}"));
453        }
454        if !err.recovery_suggestions.is_empty() {
455            context_parts.push(format!("recovery_suggestions={}", err.recovery_suggestions.join(" | ")));
456        }
457        if !context_parts.is_empty() {
458            error = error.with_context(context_parts.join(", "));
459        }
460
461        error.with_misconfiguration_guidance()
462    }
463}
464
465fn llm_error_message(error: &LLMError) -> String {
466    match error {
467        LLMError::Authentication { message, .. }
468        | LLMError::InvalidRequest { message, .. }
469        | LLMError::Network { message, .. }
470        | LLMError::Provider { message, .. } => message.clone(),
471        LLMError::RateLimit { metadata } => metadata
472            .as_ref()
473            .and_then(|meta| meta.message.clone())
474            .unwrap_or_else(|| "rate limit exceeded".to_string()),
475    }
476}
477
478fn llm_metadata_context(error: &LLMError) -> Option<String> {
479    let metadata = error.metadata()?;
480
481    let mut context = Vec::new();
482    if let Some(code) = metadata.code.as_deref() {
483        context.push(format!("provider_code={}", sanitize_provider_diagnostic(code.as_bytes())));
484    }
485    if let Some(message) = metadata.message.as_deref() {
486        context.push(format!("provider_message={}", sanitize_provider_diagnostic(message.as_bytes())));
487    }
488    (!context.is_empty()).then(|| context.join(", "))
489}
490
491fn llm_retry_after(error: &LLMError) -> Option<std::time::Duration> {
492    let metadata = error.metadata()?;
493
494    retry_after_from_llm_metadata(metadata)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::llm::provider::LLMErrorMetadata;
501    use crate::tools::unified_error::DebugContext;
502
503    #[test]
504    fn test_error_creation() {
505        let err = VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument");
506        assert_eq!(err.category, ErrorCategory::InvalidParameters);
507        assert_eq!(err.code, ErrorCode::InvalidArgument);
508        assert_eq!(err.message, "Invalid argument");
509    }
510
511    #[test]
512    fn test_error_with_context() {
513        let err =
514            VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument").with_context("While parsing user input");
515        assert_eq!(err.context, Some("While parsing user input".to_string()));
516    }
517
518    #[test]
519    fn test_error_with_source() {
520        let io_err = std::io::Error::other("IO error");
521        let err = VtCodeError::system(ErrorCode::IoError, "File operation failed").with_source(io_err);
522        assert!(err.source.is_some());
523    }
524
525    #[test]
526    fn test_error_category_display() {
527        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "Connection failed");
528        let display = format!("{err}");
529        assert!(display.contains("Network error"));
530        assert!(display.contains("Connection failed"));
531    }
532
533    #[test]
534    fn test_error_serialization_skips_source() {
535        let io_err = std::io::Error::other("IO error");
536        let err = VtCodeError::system(ErrorCode::IoError, "File operation failed")
537            .with_context("While reading config")
538            .with_source(io_err);
539
540        let json = serde_json::to_string(&err).expect("vtcode error should serialize");
541        assert!(json.contains("\"message\":\"File operation failed\""));
542        assert!(json.contains("\"context\":\"While reading config\""));
543        assert!(!json.contains("source"));
544    }
545
546    #[test]
547    fn test_error_with_retry_after() {
548        let err = VtCodeError::network(ErrorCode::RateLimited, "rate limit")
549            .with_retry_after(std::time::Duration::from_secs(2));
550        assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
551    }
552
553    #[test]
554    fn test_llm_error_conversion_preserves_retry_after() {
555        let err = LLMError::RateLimit {
556            metadata: Some(LLMErrorMetadata::new(
557                "OpenAI",
558                Some(429),
559                Some("rate_limit".to_string()),
560                Some("req-1".to_string()),
561                None,
562                Some("3".to_string()),
563                Some("try again later".to_string()),
564            )),
565        };
566
567        let converted = VtCodeError::from(err);
568        assert_eq!(converted.category, ErrorCategory::RateLimit);
569        assert_eq!(converted.code, ErrorCode::RateLimited);
570        assert_eq!(converted.retry_after(), Some(std::time::Duration::from_secs(3)));
571    }
572
573    #[test]
574    fn test_llm_metadata_marks_converted_error_as_misconfiguration() {
575        let err = LLMError::Provider {
576            message: "provider request failed".to_string(),
577            metadata: Some(LLMErrorMetadata::new(
578                "OpenAI",
579                Some(404),
580                Some("model_not_found".to_string()),
581                None,
582                None,
583                None,
584                Some("The requested model does not exist".to_string()),
585            )),
586        };
587
588        let converted = VtCodeError::from(err);
589        assert!(converted.is_misconfiguration());
590        assert!(converted.message.contains("Check settings/config first"));
591        assert!(
592            converted
593                .context
594                .as_deref()
595                .is_some_and(|context| context.contains("provider_code=model_not_found"))
596        );
597    }
598
599    #[test]
600    fn test_llm_error_conversion_preserves_fractional_retry_after() {
601        let err = LLMError::RateLimit {
602            metadata: Some(LLMErrorMetadata::new(
603                "OpenAI",
604                Some(429),
605                Some("rate_limit".to_string()),
606                Some("req-1".to_string()),
607                None,
608                Some("0.5".to_string()),
609                Some("try again later".to_string()),
610            )),
611        };
612
613        let converted = VtCodeError::from(err);
614        assert_eq!(converted.retry_after(), Some(std::time::Duration::from_millis(500)));
615    }
616
617    #[test]
618    fn test_llm_quota_exhaustion_uses_resource_exhausted_code() {
619        let err = LLMError::RateLimit {
620            metadata: Some(LLMErrorMetadata::new(
621                "OpenAI",
622                Some(429),
623                Some("insufficient_quota".to_string()),
624                None,
625                None,
626                None,
627                Some("quota exceeded".to_string()),
628            )),
629        };
630
631        let converted = VtCodeError::from(err);
632        assert_eq!(converted.category, ErrorCategory::ResourceExhausted);
633        assert_eq!(converted.code, ErrorCode::ResourceUnavailable);
634    }
635
636    #[test]
637    fn test_unified_tool_error_conversion_preserves_context() {
638        let err = UnifiedToolError::new(UnifiedErrorKind::Network, "network down").with_context(DebugContext {
639            tool_name: "read_file".to_string(),
640            invocation_id: Some("inv-1".to_string()),
641            attempt: 2,
642            metadata: vec![("duration_ms".to_string(), "1500".to_string())],
643        });
644
645        let converted = VtCodeError::from(err);
646        assert_eq!(converted.category, ErrorCategory::Network);
647        assert_eq!(converted.code, ErrorCode::ConnectionFailed);
648        assert!(converted.context.as_deref().is_some_and(|ctx| ctx.contains("tool=read_file")));
649    }
650
651    #[test]
652    fn test_unified_tool_source_marks_converted_error_as_misconfiguration() {
653        let err = UnifiedToolError::new(UnifiedErrorKind::Network, "provider request failed")
654            .with_source(anyhow::anyhow!("unknown model 'gpt-99' in agent.model"));
655
656        let converted = VtCodeError::from(err);
657        assert!(converted.is_misconfiguration());
658        assert!(converted.message.contains("Check settings/config first"));
659    }
660
661    #[test]
662    fn test_tool_execution_error_conversion_uses_original_context() {
663        let err = ToolExecutionError::with_original_error(
664            "command_session".to_string(),
665            ToolErrorType::Timeout,
666            "Tool execution failed".to_string(),
667            "timed out waiting for process".to_string(),
668        );
669
670        let converted = VtCodeError::from(err);
671        assert_eq!(converted.category, ErrorCategory::Timeout);
672        assert_eq!(converted.code, ErrorCode::Timeout);
673        assert!(
674            converted
675                .context
676                .as_deref()
677                .is_some_and(|ctx| ctx.contains("original_error=timed out waiting for process"))
678        );
679    }
680
681    #[test]
682    fn test_misconfiguration_guidance_for_auth() {
683        let err = VtCodeError::new(
684            ErrorCategory::Authentication,
685            ErrorCode::AuthenticationFailed,
686            "Authentication failed: invalid api key",
687        );
688        assert!(err.is_misconfiguration());
689        let guided = err.with_misconfiguration_guidance();
690        assert!(guided.message.contains("Check settings/config first"));
691        assert!(guided.message.contains("before retrying"));
692    }
693
694    #[test]
695    fn test_misconfiguration_guidance_for_config_code() {
696        let err = VtCodeError::config(ErrorCode::ConfigInvalid, "custom_providers[x]: `base_url` must not be empty");
697        assert!(err.is_misconfiguration());
698    }
699
700    #[test]
701    fn test_transient_has_no_misconfiguration() {
702        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "connection reset by peer");
703        assert!(!err.is_misconfiguration());
704        let guided = err.with_misconfiguration_guidance();
705        assert!(!guided.message.contains("Check settings/config first"));
706    }
707
708    #[test]
709    fn test_misconfiguration_is_idempotent() {
710        let err = VtCodeError::new(ErrorCategory::Authentication, ErrorCode::AuthenticationFailed, "bad key")
711            .with_misconfiguration_guidance()
712            .with_misconfiguration_guidance();
713        assert_eq!(err.message.matches("Correct the configuration before retrying.").count(), 1);
714    }
715
716    #[test]
717    fn test_misconfiguration_guidance_does_not_echo_secrets() {
718        let secret = concat!("sk-", "test1234567890abcdef");
719        let err = VtCodeError::new(
720            ErrorCategory::Authentication,
721            ErrorCode::AuthenticationFailed,
722            format!("Authentication failed: invalid api key {secret}"),
723        );
724        let guidance = err.misconfiguration_guidance().expect("auth must match");
725        assert!(!guidance.user_message().contains(secret));
726    }
727
728    #[test]
729    fn test_misconfiguration_guidance_stays_visible_for_large_errors() {
730        let mut message = "x".repeat(8 * 1024);
731        message.push_str(" invalid api key");
732        let guided = VtCodeError::execution(ErrorCode::Unexpected, message).with_misconfiguration_guidance();
733        assert!(guided.message.contains("Check settings/config first"));
734        assert!(guided.to_string().contains("Correct the configuration before retrying."));
735    }
736}