Skip to main content

vtcode_commons/
error_category.rs

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