Skip to main content

vtcode_commons/
error_category.rs

1#![expect(
2    clippy::let_underscore_must_use,
3    reason = "The category formatter intentionally ignores infallible formatting results."
4)]
5
6//! Unified error categorization system for consistent error classification across VT Code.
7//!
8//! This module provides a single canonical `ErrorCategory` enum that unifies the
9//! previously separate classification systems in `registry::error` (8-variant `ToolErrorType`)
10//! and `unified_error` (16-variant `UnifiedErrorKind`). Both systems now map through
11//! this shared taxonomy for consistent retry decisions and error handling.
12//!
13//! # Error Categories
14//!
15//! Errors are divided into **retryable** (transient) and **non-retryable** (permanent)
16//! categories, with sub-classifications for specific handling strategies.
17//!
18//! # Design Decisions
19//!
20//! - String-based fallback is preserved only for `anyhow::Error` chains where the
21//!   original type is erased. Typed `From` conversions are preferred.
22//! - Policy violations are explicitly separated from OS-level permission denials.
23//! - Rate limiting is a distinct category (not merged with network errors).
24//! - Circuit breaker open is categorized separately for recovery flow routing.
25
26use std::borrow::Cow;
27use std::fmt;
28use std::fmt::Write;
29use std::time::Duration;
30
31/// Canonical error category used throughout VT Code for consistent
32/// retry decisions, user-facing messages, and error handling strategies.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub enum ErrorCategory {
35    // === Retryable (Transient) ===
36    /// Network connectivity issue (connection reset, DNS failure, etc.)
37    Network,
38    /// Request timed out or deadline exceeded
39    Timeout,
40    /// Rate limit exceeded (HTTP 429, provider throttling)
41    RateLimit,
42    /// External service temporarily unavailable (HTTP 5xx)
43    ServiceUnavailable,
44    /// Circuit breaker is open for this tool/service
45    CircuitOpen,
46
47    // === Non-Retryable (Permanent) ===
48    /// Authentication or authorization failure (invalid API key, expired token)
49    Authentication,
50    /// Invalid parameters, arguments, or schema validation failure
51    InvalidParameters,
52    /// Tool not found or unavailable
53    ToolNotFound,
54    /// Resource not found (file, directory, path does not exist)
55    ResourceNotFound,
56    /// OS-level permission denied (file permissions, EACCES, EPERM)
57    PermissionDenied,
58    /// Policy violation (workspace boundary, tool deny policy, safety gate)
59    PolicyViolation,
60    /// Planning workflow violation (mutating tool without read-only capabilities)
61    PlanningPolicyViolation,
62    /// Sandbox execution failure
63    SandboxFailure,
64    /// Resource exhausted (quota, billing, spending limit, disk, memory)
65    ResourceExhausted,
66    /// User cancelled the operation
67    Cancelled,
68    /// General execution error (catch-all for unclassified failures)
69    ExecutionError,
70}
71
72/// Describes whether and how an error can be retried.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Retryability {
75    /// Error is transient and may succeed on retry.
76    Retryable {
77        /// Suggested maximum retry attempts.
78        max_attempts: u32,
79        /// Suggested backoff strategy.
80        backoff: BackoffStrategy,
81    },
82    /// Error is permanent and should NOT be retried.
83    NonRetryable,
84    /// Error requires human intervention before proceeding.
85    RequiresIntervention,
86}
87
88/// Backoff strategy for retryable errors.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum BackoffStrategy {
91    /// Exponential backoff with base delay and maximum cap.
92    Exponential { base: Duration, max: Duration },
93    /// Fixed delay between retries (e.g., for rate-limited APIs with Retry-After).
94    Fixed(Duration),
95}
96
97impl ErrorCategory {
98    /// Return the category as a `&'static str`, avoiding the temporary
99    /// `String` allocation that `to_string()` (via `Display`) would create.
100    /// Equivalent to `user_label()` but named to match the conventional
101    /// `as_str()` accessor used across the codebase.
102    #[inline]
103    #[must_use]
104    pub const fn as_str(&self) -> &'static str {
105        self.user_label()
106    }
107
108    /// Whether this error category is safe to retry.
109    #[inline]
110    #[must_use]
111    pub const fn is_retryable(&self) -> bool {
112        matches!(
113            self,
114            ErrorCategory::Network
115                | ErrorCategory::Timeout
116                | ErrorCategory::RateLimit
117                | ErrorCategory::ServiceUnavailable
118                | ErrorCategory::CircuitOpen
119        )
120    }
121
122    /// Whether this category should count toward circuit breaker transitions.
123    #[inline]
124    #[must_use]
125    pub const fn should_trip_circuit_breaker(&self) -> bool {
126        matches!(
127            self,
128            ErrorCategory::Network
129                | ErrorCategory::Timeout
130                | ErrorCategory::RateLimit
131                | ErrorCategory::ServiceUnavailable
132                | ErrorCategory::ExecutionError
133        )
134    }
135
136    /// Whether this error is an LLM argument mistake (should not count toward
137    /// circuit breaker thresholds).
138    #[inline]
139    #[must_use]
140    const fn is_llm_mistake(&self) -> bool {
141        matches!(self, ErrorCategory::InvalidParameters)
142    }
143
144    /// Whether this error represents a permanent, non-recoverable condition.
145    #[inline]
146    #[must_use]
147    pub const fn is_permanent(&self) -> bool {
148        matches!(
149            self,
150            ErrorCategory::Authentication
151                | ErrorCategory::PolicyViolation
152                | ErrorCategory::PlanningPolicyViolation
153                | ErrorCategory::ResourceExhausted
154        )
155    }
156
157    /// Get the recommended retryability for this error category.
158    #[must_use]
159    pub fn retryability(&self) -> Retryability {
160        match self {
161            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
162                max_attempts: 3,
163                backoff: BackoffStrategy::Exponential {
164                    base: Duration::from_millis(500),
165                    max: Duration::from_secs(10),
166                },
167            },
168            ErrorCategory::Timeout => Retryability::Retryable {
169                max_attempts: 2,
170                backoff: BackoffStrategy::Exponential {
171                    base: Duration::from_millis(1000),
172                    max: Duration::from_secs(15),
173                },
174            },
175            ErrorCategory::RateLimit => Retryability::Retryable {
176                max_attempts: 3,
177                backoff: BackoffStrategy::Exponential {
178                    base: Duration::from_secs(1),
179                    max: Duration::from_secs(30),
180                },
181            },
182            ErrorCategory::CircuitOpen => Retryability::Retryable {
183                max_attempts: 1,
184                backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
185            },
186            ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
187            _ => Retryability::NonRetryable,
188        }
189    }
190
191    /// Get recovery suggestions for this error category.
192    /// Returns static strings to avoid allocation.
193    #[must_use]
194    pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
195        match self {
196            ErrorCategory::Network => vec![
197                Cow::Borrowed("Check network connectivity"),
198                Cow::Borrowed("Retry the operation after a brief delay"),
199                Cow::Borrowed("Verify external service availability"),
200            ],
201            ErrorCategory::Timeout => vec![
202                Cow::Borrowed("Increase timeout values if appropriate"),
203                Cow::Borrowed("Break large operations into smaller chunks"),
204                Cow::Borrowed("Check system resources and performance"),
205            ],
206            ErrorCategory::RateLimit => vec![
207                Cow::Borrowed("Wait before retrying the request"),
208                Cow::Borrowed("Reduce request frequency"),
209                Cow::Borrowed("Check provider rate limit documentation"),
210            ],
211            ErrorCategory::ServiceUnavailable => vec![
212                Cow::Borrowed("The service is temporarily unavailable"),
213                Cow::Borrowed("Retry after a brief delay"),
214                Cow::Borrowed("Check service status page if available"),
215            ],
216            ErrorCategory::CircuitOpen => vec![
217                Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
218                Cow::Borrowed("Wait for the circuit breaker cooldown period"),
219                Cow::Borrowed("Try an alternative approach"),
220            ],
221            ErrorCategory::Authentication => vec![
222                Cow::Borrowed("Verify your API key or credentials"),
223                Cow::Borrowed("Check that your account is active and has sufficient permissions"),
224                Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
225            ],
226            ErrorCategory::InvalidParameters => vec![
227                Cow::Borrowed("Check parameter names and types against the tool schema"),
228                Cow::Borrowed("Ensure required parameters are provided"),
229                Cow::Borrowed("Verify parameter values are within acceptable ranges"),
230            ],
231            ErrorCategory::ToolNotFound => vec![
232                Cow::Borrowed("Verify the tool name is spelled correctly"),
233                Cow::Borrowed("Check if the tool is available in the current context"),
234            ],
235            ErrorCategory::ResourceNotFound => vec![
236                Cow::Borrowed("Verify file paths and resource locations"),
237                Cow::Borrowed("Check if files exist and are accessible"),
238                Cow::Borrowed("Use list_dir to explore available resources"),
239            ],
240            ErrorCategory::PermissionDenied => vec![
241                Cow::Borrowed("Check file permissions and access rights"),
242                Cow::Borrowed("Ensure workspace boundaries are respected"),
243            ],
244            ErrorCategory::PolicyViolation => vec![
245                Cow::Borrowed("Review workspace policies and restrictions"),
246                Cow::Borrowed("Use alternative tools that comply with policies"),
247            ],
248            ErrorCategory::PlanningPolicyViolation => vec![
249                Cow::Borrowed("This operation is not allowed in the Planning workflow with read-only permissions"),
250                Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
251            ],
252            ErrorCategory::SandboxFailure => vec![
253                Cow::Borrowed("The sandbox denied this operation"),
254                Cow::Borrowed("Check sandbox configuration and permissions"),
255            ],
256            ErrorCategory::ResourceExhausted => vec![
257                Cow::Borrowed("Check your account usage limits and billing status"),
258                Cow::Borrowed("Review resource consumption and optimize if possible"),
259            ],
260            ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
261            ErrorCategory::ExecutionError => vec![
262                Cow::Borrowed("Review error details for specific issues"),
263                Cow::Borrowed("Check tool documentation for known limitations"),
264            ],
265        }
266    }
267
268    /// Get a concise, user-friendly label for this error category.
269    #[must_use]
270    pub const fn user_label(&self) -> &'static str {
271        match self {
272            ErrorCategory::Network => "Network error",
273            ErrorCategory::Timeout => "Request timed out",
274            ErrorCategory::RateLimit => "Rate limit exceeded",
275            ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
276            ErrorCategory::CircuitOpen => "Tool temporarily disabled",
277            ErrorCategory::Authentication => "Authentication failed",
278            ErrorCategory::InvalidParameters => "Invalid parameters",
279            ErrorCategory::ToolNotFound => "Tool not found",
280            ErrorCategory::ResourceNotFound => "Resource not found",
281            ErrorCategory::PermissionDenied => "Permission denied",
282            ErrorCategory::PolicyViolation => "Blocked by policy",
283            ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
284            ErrorCategory::SandboxFailure => "Sandbox denied",
285            ErrorCategory::ResourceExhausted => "Resource limit reached",
286            ErrorCategory::Cancelled => "Operation cancelled",
287            ErrorCategory::ExecutionError => "Execution failed",
288        }
289    }
290
291    /// Build actionable guidance for authentication errors.
292    ///
293    /// Returns a single combined line directing the user to `/secret` (API-key
294    /// providers) or `/login` (managed-auth providers). Env-var guidance is
295    /// intentionally omitted — secure storage via `/secret` is the canonical
296    /// path and env vars are a fallback only.
297    ///
298    /// `has_stored_credential` distinguishes "no key at all" (needs `/secret add`)
299    /// from "key stored but rejected by the API" (needs verification).
300    #[must_use]
301    pub fn auth_recovery_guidance(
302        &self,
303        provider_label: &str,
304        provider_key: &str,
305        is_managed_auth: bool,
306        has_stored_credential: bool,
307    ) -> Vec<String> {
308        if !matches!(self, ErrorCategory::Authentication) {
309            return vec![];
310        }
311
312        if is_managed_auth {
313            vec![format!(
314                "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
315            )]
316        } else if has_stored_credential {
317            vec![format!(
318                "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
319            )]
320        } else {
321            vec![format!(
322                "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
323            )]
324        }
325    }
326}
327
328impl fmt::Display for ErrorCategory {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.write_str(self.user_label())
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Classify from anyhow::Error (string-based fallback for erased types)
336// ---------------------------------------------------------------------------
337
338/// Classify an `anyhow::Error` into a canonical `ErrorCategory`.
339///
340/// This uses string matching as a last resort when the original error type has
341/// been erased through `anyhow` wrapping. Typed conversions (e.g., `From<LLMError>`)
342/// should be preferred where the original error type is available.
343#[must_use]
344pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
345    let msg = err.to_string().to_ascii_lowercase();
346    classify_error_message(&msg)
347}
348
349/// Return whether an error explicitly reports that the provider rejected the
350/// request because its input context is too large.
351///
352/// Context-capacity failures are intentionally kept separate from
353/// [`ErrorCategory::InvalidParameters`]: they are not model argument mistakes
354/// and a bounded history compaction can make the same request valid. The
355/// marker set is deliberately specific so ordinary validation messages that
356/// merely mention a context field do not enter recovery.
357#[must_use]
358pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
359    err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
360}
361
362/// Return whether a provider error message identifies an input-context limit.
363#[must_use]
364pub fn is_context_capacity_message(message: &str) -> bool {
365    let message = message.to_ascii_lowercase();
366    contains_any(
367        &message,
368        &[
369            "context_length_exceeded",
370            "context length exceeded",
371            "maximum context length",
372            "maximum context window",
373            "context window exceeded",
374            "context window is too small",
375            "exceeds the model's maximum context",
376            "exceeds model context",
377            "exceeds the maximum context",
378            "prompt is too long",
379            "input is too long",
380            "input token count exceeds",
381            "exceeds the maximum number of tokens",
382            "maximum input tokens",
383            "too many tokens in the prompt",
384            "request exceeds the context",
385        ],
386    )
387}
388
389/// Classify an error message string into an `ErrorCategory`.
390///
391/// Marker groups are checked in priority order to handle overlapping patterns
392/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
393#[inline]
394#[must_use]
395pub fn classify_error_message(msg: &str) -> ErrorCategory {
396    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
397        Cow::Owned(msg.to_ascii_lowercase())
398    } else {
399        Cow::Borrowed(msg)
400    };
401
402    // --- Priority 1: Policy violations (before permission checks) ---
403    if contains_any(
404        &msg,
405        &[
406            "policy violation",
407            "denied by policy",
408            "tool permission denied",
409            "safety validation failed",
410            "not allowed in planning workflow",
411            "only available when planning workflow is active",
412            "workspace boundary",
413            "blocked by policy",
414        ],
415    ) {
416        return ErrorCategory::PolicyViolation;
417    }
418
419    // --- Priority 2: Planning workflow violations ---
420    if contains_any(
421        &msg,
422        &[
423            "planning workflow",
424            "read-only permissions",
425            concat!("read-only ", "mode"),
426            "planning_policy_violation",
427        ],
428    ) {
429        return ErrorCategory::PlanningPolicyViolation;
430    }
431
432    // --- Priority 3: Authentication / Authorization ---
433    if contains_any(
434        &msg,
435        &[
436            "invalid api key",
437            "authentication failed",
438            "unauthorized",
439            "401",
440            "invalid credentials",
441        ],
442    ) {
443        return ErrorCategory::Authentication;
444    }
445
446    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
447    if contains_any(
448        &msg,
449        &[
450            "weekly usage limit",
451            "daily usage limit",
452            "monthly spending limit",
453            "insufficient credits",
454            "quota exceeded",
455            "billing",
456            "payment required",
457        ],
458    ) {
459        return ErrorCategory::ResourceExhausted;
460    }
461
462    // --- Priority 5: Invalid parameters ---
463    if contains_any(
464        &msg,
465        &[
466            "invalid argument",
467            "invalid parameters",
468            "invalid type",
469            "malformed",
470            "failed to parse arguments",
471            "failed to parse argument",
472            "missing required",
473            "at least one item is required",
474            "is required for",
475            "schema validation",
476            "argument validation failed",
477            "unknown field",
478            "unknown variant",
479            "expected struct",
480            "expected enum",
481            "type mismatch",
482            "must be an absolute path",
483            "not parseable",
484            "parseable as",
485            // Patch format errors — these are LLM argument mistakes (the model
486            // sent a malformed patch), not execution failures. Classifying them
487            // as InvalidParameters ensures they don't trip the circuit breaker
488            // (is_llm_mistake() == true) and get parameter-focused recovery
489            // suggestions. See checkpoint turn_615 for the failure this
490            // prevents: a unified-diff patch was classified as ExecutionError
491            // and got generic "check tool documentation" suggestions.
492            "invalid patch format",
493            "invalid patch hunk",
494            "invalid patch operation",
495            "cannot parse empty patch",
496            "patch does not contain",
497            "semantic patch anchor",
498        ],
499    ) {
500        return ErrorCategory::InvalidParameters;
501    }
502
503    // --- Priority 6: Tool not found ---
504    if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
505        return ErrorCategory::ToolNotFound;
506    }
507
508    // --- Priority 7: Resource not found ---
509    if contains_any(
510        &msg,
511        &[
512            "no such file",
513            "no such directory",
514            "file not found",
515            "directory not found",
516            "resource not found",
517            "path not found",
518            "does not exist",
519            "enoent",
520        ],
521    ) {
522        return ErrorCategory::ResourceNotFound;
523    }
524
525    // --- Priority 8: Permission denied (OS-level) ---
526    if contains_any(
527        &msg,
528        &[
529            "permission denied",
530            "access denied",
531            "operation not permitted",
532            "eacces",
533            "eperm",
534            "forbidden",
535            "403",
536        ],
537    ) {
538        return ErrorCategory::PermissionDenied;
539    }
540
541    // --- Priority 9: Cancellation ---
542    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
543        return ErrorCategory::Cancelled;
544    }
545
546    // --- Priority 10: Circuit breaker ---
547    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
548        return ErrorCategory::CircuitOpen;
549    }
550
551    // --- Priority 11: Sandbox ---
552    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
553        return ErrorCategory::SandboxFailure;
554    }
555
556    // --- Priority 12: Rate limiting (before general network) ---
557    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
558        return ErrorCategory::RateLimit;
559    }
560
561    // --- Priority 13: Timeout ---
562    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
563        return ErrorCategory::Timeout;
564    }
565
566    // --- Priority 14: Provider transient response-shape failures ---
567    if contains_any(
568        &msg,
569        &[
570            "invalid response format: missing choices",
571            "invalid response format: missing message",
572            "missing choices in response",
573            "missing message in choice",
574            "no choices in response",
575            "invalid response from ",
576            "empty response body",
577            "response did not contain",
578            "unexpected response format",
579            "failed to parse response",
580        ],
581    ) {
582        return ErrorCategory::ServiceUnavailable;
583    }
584
585    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
586    if contains_any(
587        &msg,
588        &[
589            "service unavailable",
590            "temporarily unavailable",
591            "internal server error",
592            "bad gateway",
593            "gateway timeout",
594            "overloaded",
595            "500",
596            "502",
597            "503",
598            "504",
599        ],
600    ) {
601        return ErrorCategory::ServiceUnavailable;
602    }
603
604    // --- Priority 16: Network (connectivity, DNS, transport) ---
605    if contains_any(
606        &msg,
607        &[
608            "network",
609            "connection reset",
610            "connection refused",
611            "broken pipe",
612            "dns",
613            "name resolution",
614            "try again",
615            "retry later",
616            "upstream connect error",
617            "tls handshake",
618            "socket hang up",
619            "econnreset",
620            "etimedout",
621            // reqwest surfaces truncated/aborted response streams as a body
622            // decode failure; Ollama (cloud) emits this on transient drops.
623            // It is a transport error, not a payload problem — retryable.
624            "error decoding response body",
625        ],
626    ) {
627        return ErrorCategory::Network;
628    }
629
630    // --- Priority 17: Resource exhausted (memory, disk) ---
631    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
632        return ErrorCategory::ResourceExhausted;
633    }
634
635    // --- Fallback ---
636    ErrorCategory::ExecutionError
637}
638
639/// Check if an LLM error message is retryable (used by the LLM request retry loop).
640///
641/// This is a focused classifier for LLM provider errors, combining
642/// non-retryable and retryable marker checks for the request retry path.
643#[inline]
644#[must_use]
645pub fn is_retryable_llm_error_message(msg: &str) -> bool {
646    let category = classify_error_message(msg);
647    category.is_retryable()
648}
649
650#[inline]
651fn contains_any(message: &str, markers: &[&str]) -> bool {
652    markers.iter().any(|marker| message.contains(marker))
653}
654
655// ---------------------------------------------------------------------------
656// Typed conversions from known error types
657// ---------------------------------------------------------------------------
658
659impl From<&crate::llm::LLMError> for ErrorCategory {
660    fn from(err: &crate::llm::LLMError) -> Self {
661        match err {
662            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
663            crate::llm::LLMError::RateLimit { metadata } => {
664                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
665            }
666            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
667            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
668            crate::llm::LLMError::Provider { message, metadata } => {
669                let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
670                if metadata_category != ErrorCategory::ExecutionError {
671                    return metadata_category;
672                }
673
674                // Check metadata status code first for precise classification
675                if let Some(meta) = metadata
676                    && let Some(status) = meta.status
677                {
678                    return match status {
679                        401 => ErrorCategory::Authentication,
680                        403 => ErrorCategory::PermissionDenied,
681                        404 => ErrorCategory::ResourceNotFound,
682                        429 => ErrorCategory::RateLimit,
683                        400 => ErrorCategory::InvalidParameters,
684                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
685                        408 => ErrorCategory::Timeout,
686                        _ => classify_error_message(message),
687                    };
688                }
689                // Fall back to message-based classification
690                classify_error_message(message)
691            }
692        }
693    }
694}
695
696fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
697    let Some(metadata) = metadata else {
698        return fallback;
699    };
700
701    let mut hint = String::new();
702    if let Some(code) = &metadata.code {
703        hint.push_str(code);
704        hint.push(' ');
705    }
706    if let Some(message) = &metadata.message {
707        hint.push_str(message);
708        hint.push(' ');
709    }
710    if let Some(status) = metadata.status {
711        let _ = write!(&mut hint, "{status}");
712    }
713
714    let classified = classify_error_message(&hint);
715    if classified == ErrorCategory::ExecutionError {
716        fallback
717    } else {
718        classified
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    // --- classify_error_message tests ---
727
728    #[test]
729    fn policy_violation_takes_priority_over_permission() {
730        assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
731    }
732
733    #[test]
734    fn rate_limit_classified_correctly() {
735        assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
736        assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
737    }
738
739    #[test]
740    fn service_unavailable_is_classified() {
741        assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
742    }
743
744    #[test]
745    fn authentication_errors() {
746        assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
747        assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
748    }
749
750    #[test]
751    fn billing_errors_are_resource_exhausted() {
752        assert_eq!(
753            classify_error_message("you have reached your weekly usage limit"),
754            ErrorCategory::ResourceExhausted
755        );
756        assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
757    }
758
759    #[test]
760    fn timeout_errors() {
761        assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
762        assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
763    }
764
765    #[test]
766    fn network_errors() {
767        assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
768        assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
769    }
770
771    #[test]
772    fn tool_not_found() {
773        assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
774    }
775
776    #[test]
777    fn resource_not_found() {
778        assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
779        assert_eq!(
780            classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
781            ErrorCategory::ResourceNotFound
782        );
783    }
784
785    #[test]
786    fn patch_format_errors_are_invalid_parameters() {
787        // Patch format errors are LLM argument mistakes, not execution
788        // failures. They must classify as InvalidParameters (is_llm_mistake
789        // == true, no circuit breaker trip) so the model gets parameter-
790        // focused recovery suggestions instead of generic "check tool docs".
791        assert_eq!(
792            classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
793            ErrorCategory::InvalidParameters
794        );
795        assert_eq!(
796            classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
797            ErrorCategory::InvalidParameters
798        );
799        assert_eq!(
800            classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
801            ErrorCategory::InvalidParameters
802        );
803        assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
804        assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
805        assert_eq!(
806            classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
807            ErrorCategory::InvalidParameters
808        );
809    }
810
811    #[test]
812    fn permission_denied() {
813        assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
814    }
815
816    #[test]
817    fn cancelled_operations() {
818        assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
819    }
820
821    #[test]
822    fn planning_policy_violation() {
823        assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
824    }
825
826    #[test]
827    fn sandbox_failure() {
828        assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
829    }
830
831    #[test]
832    fn unknown_error_is_execution_error() {
833        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
834    }
835
836    #[test]
837    fn invalid_parameters() {
838        assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
839        assert_eq!(
840            classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
841            ErrorCategory::InvalidParameters
842        );
843        assert_eq!(
844            classify_error_message("at least one item is required for 'create'"),
845            ErrorCategory::InvalidParameters
846        );
847        assert_eq!(
848            classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
849            ErrorCategory::InvalidParameters
850        );
851    }
852
853    // --- Retryability tests ---
854
855    #[test]
856    fn retryable_categories() {
857        assert!(ErrorCategory::Network.is_retryable());
858        assert!(ErrorCategory::Timeout.is_retryable());
859        assert!(ErrorCategory::RateLimit.is_retryable());
860        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
861        assert!(ErrorCategory::CircuitOpen.is_retryable());
862    }
863
864    #[test]
865    fn non_retryable_categories() {
866        assert!(!ErrorCategory::Authentication.is_retryable());
867        assert!(!ErrorCategory::InvalidParameters.is_retryable());
868        assert!(!ErrorCategory::PolicyViolation.is_retryable());
869        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
870        assert!(!ErrorCategory::Cancelled.is_retryable());
871    }
872
873    #[test]
874    fn permanent_error_detection() {
875        assert!(ErrorCategory::Authentication.is_permanent());
876        assert!(ErrorCategory::PolicyViolation.is_permanent());
877        assert!(!ErrorCategory::Network.is_permanent());
878        assert!(!ErrorCategory::Timeout.is_permanent());
879    }
880
881    #[test]
882    fn llm_mistake_detection() {
883        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
884        assert!(!ErrorCategory::Network.is_llm_mistake());
885        assert!(!ErrorCategory::Timeout.is_llm_mistake());
886    }
887
888    // --- LLM error conversion ---
889
890    #[test]
891    fn llm_error_authentication_converts() {
892        let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
893        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
894    }
895
896    #[test]
897    fn llm_error_rate_limit_converts() {
898        let err = crate::llm::LLMError::RateLimit { metadata: None };
899        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
900    }
901
902    #[test]
903    fn llm_error_quota_exhaustion_converts() {
904        let err = crate::llm::LLMError::RateLimit {
905            metadata: Some(crate::llm::LLMErrorMetadata::new(
906                "openai",
907                Some(429),
908                Some("insufficient_quota".to_string()),
909                None,
910                None,
911                None,
912                Some("quota exceeded".to_string()),
913            )),
914        };
915
916        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
917    }
918
919    #[test]
920    fn llm_error_network_converts() {
921        let err = crate::llm::LLMError::Network {
922            message: "connection refused".to_string(),
923            metadata: None,
924        };
925        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
926    }
927
928    #[test]
929    fn llm_error_provider_with_status_code() {
930        use crate::llm::LLMErrorMetadata;
931        let err = crate::llm::LLMError::Provider {
932            message: "error".to_string(),
933            metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
934        };
935        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
936    }
937
938    #[test]
939    fn minimax_invalid_response_is_service_unavailable() {
940        assert_eq!(
941            classify_error_message("Invalid response from MiniMax: missing choices"),
942            ErrorCategory::ServiceUnavailable
943        );
944        assert_eq!(
945            classify_error_message("Invalid response format: missing message"),
946            ErrorCategory::ServiceUnavailable
947        );
948    }
949
950    // --- is_retryable_llm_error_message ---
951
952    #[test]
953    fn retryable_llm_messages() {
954        assert!(is_retryable_llm_error_message("429 too many requests"));
955        assert!(is_retryable_llm_error_message("500 internal server error"));
956        assert!(is_retryable_llm_error_message("connection timeout"));
957        assert!(is_retryable_llm_error_message("network error"));
958    }
959
960    #[test]
961    fn non_retryable_llm_messages() {
962        assert!(!is_retryable_llm_error_message("invalid api key"));
963        assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
964        assert!(!is_retryable_llm_error_message("permission denied"));
965    }
966
967    #[test]
968    fn context_capacity_markers_are_specific() {
969        assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
970        assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
971        assert!(!is_context_capacity_message("invalid request: context field is missing"));
972    }
973
974    // --- Recovery suggestions ---
975
976    #[test]
977    fn recovery_suggestions_non_empty() {
978        for cat in [
979            ErrorCategory::Network,
980            ErrorCategory::Timeout,
981            ErrorCategory::RateLimit,
982            ErrorCategory::Authentication,
983            ErrorCategory::InvalidParameters,
984            ErrorCategory::ToolNotFound,
985            ErrorCategory::ResourceNotFound,
986            ErrorCategory::PermissionDenied,
987            ErrorCategory::PolicyViolation,
988            ErrorCategory::ExecutionError,
989        ] {
990            assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
991        }
992    }
993
994    // --- User label ---
995
996    #[test]
997    fn user_labels_are_non_empty() {
998        assert!(!ErrorCategory::Network.user_label().is_empty());
999        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1000    }
1001
1002    // --- auth_recovery_guidance ---
1003
1004    #[test]
1005    fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1006        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1007        assert_eq!(guidance.len(), 1);
1008        assert_eq!(
1009            guidance[0],
1010            "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1011        );
1012    }
1013
1014    #[test]
1015    fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1016        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1017        assert_eq!(guidance.len(), 1);
1018        assert_eq!(
1019            guidance[0],
1020            "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1021        );
1022    }
1023
1024    #[test]
1025    fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1026        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1027        assert_eq!(guidance.len(), 1);
1028        assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1029    }
1030
1031    #[test]
1032    fn auth_recovery_guidance_non_auth_category_returns_empty() {
1033        assert!(
1034            ErrorCategory::Network
1035                .auth_recovery_guidance("OpenAI", "openai", false, false)
1036                .is_empty()
1037        );
1038        assert!(
1039            ErrorCategory::Timeout
1040                .auth_recovery_guidance("OpenAI", "openai", false, false)
1041                .is_empty()
1042        );
1043    }
1044
1045    // --- Display ---
1046
1047    #[test]
1048    fn display_matches_user_label() {
1049        assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1050    }
1051}