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 = match error {
480        LLMError::Authentication { metadata, .. }
481        | LLMError::RateLimit { metadata }
482        | LLMError::InvalidRequest { metadata, .. }
483        | LLMError::Network { metadata, .. }
484        | LLMError::Provider { metadata, .. } => metadata.as_deref(),
485    }?;
486
487    let mut context = Vec::new();
488    if let Some(code) = metadata.code.as_deref() {
489        context.push(format!("provider_code={}", sanitize_provider_diagnostic(code.as_bytes())));
490    }
491    if let Some(message) = metadata.message.as_deref() {
492        context.push(format!("provider_message={}", sanitize_provider_diagnostic(message.as_bytes())));
493    }
494    (!context.is_empty()).then(|| context.join(", "))
495}
496
497fn llm_retry_after(error: &LLMError) -> Option<std::time::Duration> {
498    let metadata = match error {
499        LLMError::Authentication { metadata, .. }
500        | LLMError::RateLimit { metadata }
501        | LLMError::InvalidRequest { metadata, .. }
502        | LLMError::Network { metadata, .. }
503        | LLMError::Provider { metadata, .. } => metadata.as_ref(),
504    }?;
505
506    retry_after_from_llm_metadata(metadata)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::llm::provider::LLMErrorMetadata;
513    use crate::tools::unified_error::DebugContext;
514
515    #[test]
516    fn test_error_creation() {
517        let err = VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument");
518        assert_eq!(err.category, ErrorCategory::InvalidParameters);
519        assert_eq!(err.code, ErrorCode::InvalidArgument);
520        assert_eq!(err.message, "Invalid argument");
521    }
522
523    #[test]
524    fn test_error_with_context() {
525        let err =
526            VtCodeError::input(ErrorCode::InvalidArgument, "Invalid argument").with_context("While parsing user input");
527        assert_eq!(err.context, Some("While parsing user input".to_string()));
528    }
529
530    #[test]
531    fn test_error_with_source() {
532        let io_err = std::io::Error::other("IO error");
533        let err = VtCodeError::system(ErrorCode::IoError, "File operation failed").with_source(io_err);
534        assert!(err.source.is_some());
535    }
536
537    #[test]
538    fn test_error_category_display() {
539        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "Connection failed");
540        let display = format!("{err}");
541        assert!(display.contains("Network error"));
542        assert!(display.contains("Connection failed"));
543    }
544
545    #[test]
546    fn test_error_serialization_skips_source() {
547        let io_err = std::io::Error::other("IO error");
548        let err = VtCodeError::system(ErrorCode::IoError, "File operation failed")
549            .with_context("While reading config")
550            .with_source(io_err);
551
552        let json = serde_json::to_string(&err).expect("vtcode error should serialize");
553        assert!(json.contains("\"message\":\"File operation failed\""));
554        assert!(json.contains("\"context\":\"While reading config\""));
555        assert!(!json.contains("source"));
556    }
557
558    #[test]
559    fn test_error_with_retry_after() {
560        let err = VtCodeError::network(ErrorCode::RateLimited, "rate limit")
561            .with_retry_after(std::time::Duration::from_secs(2));
562        assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
563    }
564
565    #[test]
566    fn test_llm_error_conversion_preserves_retry_after() {
567        let err = LLMError::RateLimit {
568            metadata: Some(LLMErrorMetadata::new(
569                "OpenAI",
570                Some(429),
571                Some("rate_limit".to_string()),
572                Some("req-1".to_string()),
573                None,
574                Some("3".to_string()),
575                Some("try again later".to_string()),
576            )),
577        };
578
579        let converted = VtCodeError::from(err);
580        assert_eq!(converted.category, ErrorCategory::RateLimit);
581        assert_eq!(converted.code, ErrorCode::RateLimited);
582        assert_eq!(converted.retry_after(), Some(std::time::Duration::from_secs(3)));
583    }
584
585    #[test]
586    fn test_llm_metadata_marks_converted_error_as_misconfiguration() {
587        let err = LLMError::Provider {
588            message: "provider request failed".to_string(),
589            metadata: Some(LLMErrorMetadata::new(
590                "OpenAI",
591                Some(404),
592                Some("model_not_found".to_string()),
593                None,
594                None,
595                None,
596                Some("The requested model does not exist".to_string()),
597            )),
598        };
599
600        let converted = VtCodeError::from(err);
601        assert!(converted.is_misconfiguration());
602        assert!(converted.message.contains("Check settings/config first"));
603        assert!(
604            converted
605                .context
606                .as_deref()
607                .is_some_and(|context| context.contains("provider_code=model_not_found"))
608        );
609    }
610
611    #[test]
612    fn test_llm_error_conversion_preserves_fractional_retry_after() {
613        let err = LLMError::RateLimit {
614            metadata: Some(LLMErrorMetadata::new(
615                "OpenAI",
616                Some(429),
617                Some("rate_limit".to_string()),
618                Some("req-1".to_string()),
619                None,
620                Some("0.5".to_string()),
621                Some("try again later".to_string()),
622            )),
623        };
624
625        let converted = VtCodeError::from(err);
626        assert_eq!(converted.retry_after(), Some(std::time::Duration::from_millis(500)));
627    }
628
629    #[test]
630    fn test_llm_quota_exhaustion_uses_resource_exhausted_code() {
631        let err = LLMError::RateLimit {
632            metadata: Some(LLMErrorMetadata::new(
633                "OpenAI",
634                Some(429),
635                Some("insufficient_quota".to_string()),
636                None,
637                None,
638                None,
639                Some("quota exceeded".to_string()),
640            )),
641        };
642
643        let converted = VtCodeError::from(err);
644        assert_eq!(converted.category, ErrorCategory::ResourceExhausted);
645        assert_eq!(converted.code, ErrorCode::ResourceUnavailable);
646    }
647
648    #[test]
649    fn test_unified_tool_error_conversion_preserves_context() {
650        let err = UnifiedToolError::new(UnifiedErrorKind::Network, "network down").with_context(DebugContext {
651            tool_name: "read_file".to_string(),
652            invocation_id: Some("inv-1".to_string()),
653            attempt: 2,
654            metadata: vec![("duration_ms".to_string(), "1500".to_string())],
655        });
656
657        let converted = VtCodeError::from(err);
658        assert_eq!(converted.category, ErrorCategory::Network);
659        assert_eq!(converted.code, ErrorCode::ConnectionFailed);
660        assert!(converted.context.as_deref().is_some_and(|ctx| ctx.contains("tool=read_file")));
661    }
662
663    #[test]
664    fn test_unified_tool_source_marks_converted_error_as_misconfiguration() {
665        let err = UnifiedToolError::new(UnifiedErrorKind::Network, "provider request failed")
666            .with_source(anyhow::anyhow!("unknown model 'gpt-99' in agent.model"));
667
668        let converted = VtCodeError::from(err);
669        assert!(converted.is_misconfiguration());
670        assert!(converted.message.contains("Check settings/config first"));
671    }
672
673    #[test]
674    fn test_tool_execution_error_conversion_uses_original_context() {
675        let err = ToolExecutionError::with_original_error(
676            "command_session".to_string(),
677            ToolErrorType::Timeout,
678            "Tool execution failed".to_string(),
679            "timed out waiting for process".to_string(),
680        );
681
682        let converted = VtCodeError::from(err);
683        assert_eq!(converted.category, ErrorCategory::Timeout);
684        assert_eq!(converted.code, ErrorCode::Timeout);
685        assert!(
686            converted
687                .context
688                .as_deref()
689                .is_some_and(|ctx| ctx.contains("original_error=timed out waiting for process"))
690        );
691    }
692
693    #[test]
694    fn test_misconfiguration_guidance_for_auth() {
695        let err = VtCodeError::new(
696            ErrorCategory::Authentication,
697            ErrorCode::AuthenticationFailed,
698            "Authentication failed: invalid api key",
699        );
700        assert!(err.is_misconfiguration());
701        let guided = err.with_misconfiguration_guidance();
702        assert!(guided.message.contains("Check settings/config first"));
703        assert!(guided.message.contains("before retrying"));
704    }
705
706    #[test]
707    fn test_misconfiguration_guidance_for_config_code() {
708        let err = VtCodeError::config(ErrorCode::ConfigInvalid, "custom_providers[x]: `base_url` must not be empty");
709        assert!(err.is_misconfiguration());
710    }
711
712    #[test]
713    fn test_transient_has_no_misconfiguration() {
714        let err = VtCodeError::network(ErrorCode::ConnectionFailed, "connection reset by peer");
715        assert!(!err.is_misconfiguration());
716        let guided = err.with_misconfiguration_guidance();
717        assert!(!guided.message.contains("Check settings/config first"));
718    }
719
720    #[test]
721    fn test_misconfiguration_is_idempotent() {
722        let err = VtCodeError::new(ErrorCategory::Authentication, ErrorCode::AuthenticationFailed, "bad key")
723            .with_misconfiguration_guidance()
724            .with_misconfiguration_guidance();
725        assert_eq!(err.message.matches("Correct the configuration before retrying.").count(), 1);
726    }
727
728    #[test]
729    fn test_misconfiguration_guidance_does_not_echo_secrets() {
730        let secret = concat!("sk-", "test1234567890abcdef");
731        let err = VtCodeError::new(
732            ErrorCategory::Authentication,
733            ErrorCode::AuthenticationFailed,
734            format!("Authentication failed: invalid api key {secret}"),
735        );
736        let guidance = err.misconfiguration_guidance().expect("auth must match");
737        assert!(!guidance.user_message().contains(secret));
738    }
739
740    #[test]
741    fn test_misconfiguration_guidance_stays_visible_for_large_errors() {
742        let mut message = "x".repeat(8 * 1024);
743        message.push_str(" invalid api key");
744        let guided = VtCodeError::execution(ErrorCode::Unexpected, message).with_misconfiguration_guidance();
745        assert!(guided.message.contains("Check settings/config first"));
746        assert!(guided.to_string().contains("Correct the configuration before retrying."));
747    }
748}