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    ///
283    /// `has_stored_credential` distinguishes "no key at all" (needs `/secret add`)
284    /// from "key stored but rejected by the API" (needs verification).
285    #[must_use]
286    pub fn auth_recovery_guidance(
287        &self,
288        provider_label: &str,
289        provider_key: &str,
290        is_managed_auth: bool,
291        has_stored_credential: bool,
292    ) -> Vec<String> {
293        if !matches!(self, ErrorCategory::Authentication) {
294            return vec![];
295        }
296
297        if is_managed_auth {
298            vec![format!(
299                "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
300            )]
301        } else if has_stored_credential {
302            vec![format!(
303                "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
304            )]
305        } else {
306            vec![format!(
307                "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
308            )]
309        }
310    }
311}
312
313impl fmt::Display for ErrorCategory {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.write_str(self.user_label())
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Classify from anyhow::Error (string-based fallback for erased types)
321// ---------------------------------------------------------------------------
322
323/// Classify an `anyhow::Error` into a canonical `ErrorCategory`.
324///
325/// This uses string matching as a last resort when the original error type has
326/// been erased through `anyhow` wrapping. Typed conversions (e.g., `From<LLMError>`)
327/// should be preferred where the original error type is available.
328#[must_use]
329pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
330    let msg = err.to_string().to_ascii_lowercase();
331    classify_error_message(&msg)
332}
333
334/// Classify an error message string into an `ErrorCategory`.
335///
336/// Marker groups are checked in priority order to handle overlapping patterns
337/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
338#[inline]
339#[must_use]
340pub fn classify_error_message(msg: &str) -> ErrorCategory {
341    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
342        Cow::Owned(msg.to_ascii_lowercase())
343    } else {
344        Cow::Borrowed(msg)
345    };
346
347    // --- Priority 1: Policy violations (before permission checks) ---
348    if contains_any(
349        &msg,
350        &[
351            "policy violation",
352            "denied by policy",
353            "tool permission denied",
354            "safety validation failed",
355            "not allowed in planning workflow",
356            "only available when planning workflow is active",
357            "workspace boundary",
358            "blocked by policy",
359        ],
360    ) {
361        return ErrorCategory::PolicyViolation;
362    }
363
364    // --- Priority 2: Planning workflow violations ---
365    if contains_any(
366        &msg,
367        &[
368            "planning workflow",
369            "read-only permissions",
370            concat!("read-only ", "mode"),
371            "planning_policy_violation",
372        ],
373    ) {
374        return ErrorCategory::PlanningPolicyViolation;
375    }
376
377    // --- Priority 3: Authentication / Authorization ---
378    if contains_any(
379        &msg,
380        &[
381            "invalid api key",
382            "authentication failed",
383            "unauthorized",
384            "401",
385            "invalid credentials",
386        ],
387    ) {
388        return ErrorCategory::Authentication;
389    }
390
391    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
392    if contains_any(
393        &msg,
394        &[
395            "weekly usage limit",
396            "daily usage limit",
397            "monthly spending limit",
398            "insufficient credits",
399            "quota exceeded",
400            "billing",
401            "payment required",
402        ],
403    ) {
404        return ErrorCategory::ResourceExhausted;
405    }
406
407    // --- Priority 5: Invalid parameters ---
408    if contains_any(
409        &msg,
410        &[
411            "invalid argument",
412            "invalid parameters",
413            "invalid type",
414            "malformed",
415            "failed to parse arguments",
416            "failed to parse argument",
417            "missing required",
418            "at least one item is required",
419            "is required for",
420            "schema validation",
421            "argument validation failed",
422            "unknown field",
423            "unknown variant",
424            "expected struct",
425            "expected enum",
426            "type mismatch",
427            "must be an absolute path",
428            "not parseable",
429            "parseable as",
430            // Patch format errors — these are LLM argument mistakes (the model
431            // sent a malformed patch), not execution failures. Classifying them
432            // as InvalidParameters ensures they don't trip the circuit breaker
433            // (is_llm_mistake() == true) and get parameter-focused recovery
434            // suggestions. See checkpoint turn_615 for the failure this
435            // prevents: a unified-diff patch was classified as ExecutionError
436            // and got generic "check tool documentation" suggestions.
437            "invalid patch format",
438            "invalid patch hunk",
439            "invalid patch operation",
440            "cannot parse empty patch",
441            "patch does not contain",
442            "semantic patch anchor",
443        ],
444    ) {
445        return ErrorCategory::InvalidParameters;
446    }
447
448    // --- Priority 6: Tool not found ---
449    if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
450        return ErrorCategory::ToolNotFound;
451    }
452
453    // --- Priority 7: Resource not found ---
454    if contains_any(
455        &msg,
456        &[
457            "no such file",
458            "no such directory",
459            "file not found",
460            "directory not found",
461            "resource not found",
462            "path not found",
463            "does not exist",
464            "enoent",
465        ],
466    ) {
467        return ErrorCategory::ResourceNotFound;
468    }
469
470    // --- Priority 8: Permission denied (OS-level) ---
471    if contains_any(
472        &msg,
473        &[
474            "permission denied",
475            "access denied",
476            "operation not permitted",
477            "eacces",
478            "eperm",
479            "forbidden",
480            "403",
481        ],
482    ) {
483        return ErrorCategory::PermissionDenied;
484    }
485
486    // --- Priority 9: Cancellation ---
487    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
488        return ErrorCategory::Cancelled;
489    }
490
491    // --- Priority 10: Circuit breaker ---
492    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
493        return ErrorCategory::CircuitOpen;
494    }
495
496    // --- Priority 11: Sandbox ---
497    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
498        return ErrorCategory::SandboxFailure;
499    }
500
501    // --- Priority 12: Rate limiting (before general network) ---
502    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
503        return ErrorCategory::RateLimit;
504    }
505
506    // --- Priority 13: Timeout ---
507    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
508        return ErrorCategory::Timeout;
509    }
510
511    // --- Priority 14: Provider transient response-shape failures ---
512    if contains_any(
513        &msg,
514        &[
515            "invalid response format: missing choices",
516            "invalid response format: missing message",
517            "missing choices in response",
518            "missing message in choice",
519            "no choices in response",
520            "invalid response from ",
521            "empty response body",
522            "response did not contain",
523            "unexpected response format",
524            "failed to parse response",
525        ],
526    ) {
527        return ErrorCategory::ServiceUnavailable;
528    }
529
530    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
531    if contains_any(
532        &msg,
533        &[
534            "service unavailable",
535            "temporarily unavailable",
536            "internal server error",
537            "bad gateway",
538            "gateway timeout",
539            "overloaded",
540            "500",
541            "502",
542            "503",
543            "504",
544        ],
545    ) {
546        return ErrorCategory::ServiceUnavailable;
547    }
548
549    // --- Priority 16: Network (connectivity, DNS, transport) ---
550    if contains_any(
551        &msg,
552        &[
553            "network",
554            "connection reset",
555            "connection refused",
556            "broken pipe",
557            "dns",
558            "name resolution",
559            "try again",
560            "retry later",
561            "upstream connect error",
562            "tls handshake",
563            "socket hang up",
564            "econnreset",
565            "etimedout",
566            // reqwest surfaces truncated/aborted response streams as a body
567            // decode failure; Ollama (cloud) emits this on transient drops.
568            // It is a transport error, not a payload problem — retryable.
569            "error decoding response body",
570        ],
571    ) {
572        return ErrorCategory::Network;
573    }
574
575    // --- Priority 17: Resource exhausted (memory, disk) ---
576    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
577        return ErrorCategory::ResourceExhausted;
578    }
579
580    // --- Fallback ---
581    ErrorCategory::ExecutionError
582}
583
584/// Check if an LLM error message is retryable (used by the LLM request retry loop).
585///
586/// This is a focused classifier for LLM provider errors, combining
587/// non-retryable and retryable marker checks for the request retry path.
588#[inline]
589#[must_use]
590pub fn is_retryable_llm_error_message(msg: &str) -> bool {
591    let category = classify_error_message(msg);
592    category.is_retryable()
593}
594
595#[inline]
596fn contains_any(message: &str, markers: &[&str]) -> bool {
597    markers.iter().any(|marker| message.contains(marker))
598}
599
600// ---------------------------------------------------------------------------
601// Typed conversions from known error types
602// ---------------------------------------------------------------------------
603
604impl From<&crate::llm::LLMError> for ErrorCategory {
605    fn from(err: &crate::llm::LLMError) -> Self {
606        match err {
607            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
608            crate::llm::LLMError::RateLimit { metadata } => {
609                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
610            }
611            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
612            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
613            crate::llm::LLMError::Provider { message, metadata } => {
614                let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
615                if metadata_category != ErrorCategory::ExecutionError {
616                    return metadata_category;
617                }
618
619                // Check metadata status code first for precise classification
620                if let Some(meta) = metadata
621                    && let Some(status) = meta.status
622                {
623                    return match status {
624                        401 => ErrorCategory::Authentication,
625                        403 => ErrorCategory::PermissionDenied,
626                        404 => ErrorCategory::ResourceNotFound,
627                        429 => ErrorCategory::RateLimit,
628                        400 => ErrorCategory::InvalidParameters,
629                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
630                        408 => ErrorCategory::Timeout,
631                        _ => classify_error_message(message),
632                    };
633                }
634                // Fall back to message-based classification
635                classify_error_message(message)
636            }
637        }
638    }
639}
640
641fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
642    let Some(metadata) = metadata else {
643        return fallback;
644    };
645
646    let mut hint = String::new();
647    if let Some(code) = &metadata.code {
648        hint.push_str(code);
649        hint.push(' ');
650    }
651    if let Some(message) = &metadata.message {
652        hint.push_str(message);
653        hint.push(' ');
654    }
655    if let Some(status) = metadata.status {
656        let _ = write!(&mut hint, "{status}");
657    }
658
659    let classified = classify_error_message(&hint);
660    if classified == ErrorCategory::ExecutionError {
661        fallback
662    } else {
663        classified
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    // --- classify_error_message tests ---
672
673    #[test]
674    fn policy_violation_takes_priority_over_permission() {
675        assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
676    }
677
678    #[test]
679    fn rate_limit_classified_correctly() {
680        assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
681        assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
682    }
683
684    #[test]
685    fn service_unavailable_is_classified() {
686        assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
687    }
688
689    #[test]
690    fn authentication_errors() {
691        assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
692        assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
693    }
694
695    #[test]
696    fn billing_errors_are_resource_exhausted() {
697        assert_eq!(
698            classify_error_message("you have reached your weekly usage limit"),
699            ErrorCategory::ResourceExhausted
700        );
701        assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
702    }
703
704    #[test]
705    fn timeout_errors() {
706        assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
707        assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
708    }
709
710    #[test]
711    fn network_errors() {
712        assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
713        assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
714    }
715
716    #[test]
717    fn tool_not_found() {
718        assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
719    }
720
721    #[test]
722    fn resource_not_found() {
723        assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
724        assert_eq!(
725            classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
726            ErrorCategory::ResourceNotFound
727        );
728    }
729
730    #[test]
731    fn patch_format_errors_are_invalid_parameters() {
732        // Patch format errors are LLM argument mistakes, not execution
733        // failures. They must classify as InvalidParameters (is_llm_mistake
734        // == true, no circuit breaker trip) so the model gets parameter-
735        // focused recovery suggestions instead of generic "check tool docs".
736        assert_eq!(
737            classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
738            ErrorCategory::InvalidParameters
739        );
740        assert_eq!(
741            classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
742            ErrorCategory::InvalidParameters
743        );
744        assert_eq!(
745            classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
746            ErrorCategory::InvalidParameters
747        );
748        assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
749        assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
750        assert_eq!(
751            classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
752            ErrorCategory::InvalidParameters
753        );
754    }
755
756    #[test]
757    fn permission_denied() {
758        assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
759    }
760
761    #[test]
762    fn cancelled_operations() {
763        assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
764    }
765
766    #[test]
767    fn planning_policy_violation() {
768        assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
769    }
770
771    #[test]
772    fn sandbox_failure() {
773        assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
774    }
775
776    #[test]
777    fn unknown_error_is_execution_error() {
778        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
779    }
780
781    #[test]
782    fn invalid_parameters() {
783        assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
784        assert_eq!(
785            classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
786            ErrorCategory::InvalidParameters
787        );
788        assert_eq!(
789            classify_error_message("at least one item is required for 'create'"),
790            ErrorCategory::InvalidParameters
791        );
792        assert_eq!(
793            classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
794            ErrorCategory::InvalidParameters
795        );
796    }
797
798    // --- Retryability tests ---
799
800    #[test]
801    fn retryable_categories() {
802        assert!(ErrorCategory::Network.is_retryable());
803        assert!(ErrorCategory::Timeout.is_retryable());
804        assert!(ErrorCategory::RateLimit.is_retryable());
805        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
806        assert!(ErrorCategory::CircuitOpen.is_retryable());
807    }
808
809    #[test]
810    fn non_retryable_categories() {
811        assert!(!ErrorCategory::Authentication.is_retryable());
812        assert!(!ErrorCategory::InvalidParameters.is_retryable());
813        assert!(!ErrorCategory::PolicyViolation.is_retryable());
814        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
815        assert!(!ErrorCategory::Cancelled.is_retryable());
816    }
817
818    #[test]
819    fn permanent_error_detection() {
820        assert!(ErrorCategory::Authentication.is_permanent());
821        assert!(ErrorCategory::PolicyViolation.is_permanent());
822        assert!(!ErrorCategory::Network.is_permanent());
823        assert!(!ErrorCategory::Timeout.is_permanent());
824    }
825
826    #[test]
827    fn llm_mistake_detection() {
828        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
829        assert!(!ErrorCategory::Network.is_llm_mistake());
830        assert!(!ErrorCategory::Timeout.is_llm_mistake());
831    }
832
833    // --- LLM error conversion ---
834
835    #[test]
836    fn llm_error_authentication_converts() {
837        let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
838        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
839    }
840
841    #[test]
842    fn llm_error_rate_limit_converts() {
843        let err = crate::llm::LLMError::RateLimit { metadata: None };
844        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
845    }
846
847    #[test]
848    fn llm_error_quota_exhaustion_converts() {
849        let err = crate::llm::LLMError::RateLimit {
850            metadata: Some(crate::llm::LLMErrorMetadata::new(
851                "openai",
852                Some(429),
853                Some("insufficient_quota".to_string()),
854                None,
855                None,
856                None,
857                Some("quota exceeded".to_string()),
858            )),
859        };
860
861        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
862    }
863
864    #[test]
865    fn llm_error_network_converts() {
866        let err = crate::llm::LLMError::Network {
867            message: "connection refused".to_string(),
868            metadata: None,
869        };
870        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
871    }
872
873    #[test]
874    fn llm_error_provider_with_status_code() {
875        use crate::llm::LLMErrorMetadata;
876        let err = crate::llm::LLMError::Provider {
877            message: "error".to_string(),
878            metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
879        };
880        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
881    }
882
883    #[test]
884    fn minimax_invalid_response_is_service_unavailable() {
885        assert_eq!(
886            classify_error_message("Invalid response from MiniMax: missing choices"),
887            ErrorCategory::ServiceUnavailable
888        );
889        assert_eq!(
890            classify_error_message("Invalid response format: missing message"),
891            ErrorCategory::ServiceUnavailable
892        );
893    }
894
895    // --- is_retryable_llm_error_message ---
896
897    #[test]
898    fn retryable_llm_messages() {
899        assert!(is_retryable_llm_error_message("429 too many requests"));
900        assert!(is_retryable_llm_error_message("500 internal server error"));
901        assert!(is_retryable_llm_error_message("connection timeout"));
902        assert!(is_retryable_llm_error_message("network error"));
903    }
904
905    #[test]
906    fn non_retryable_llm_messages() {
907        assert!(!is_retryable_llm_error_message("invalid api key"));
908        assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
909        assert!(!is_retryable_llm_error_message("permission denied"));
910    }
911
912    // --- Recovery suggestions ---
913
914    #[test]
915    fn recovery_suggestions_non_empty() {
916        for cat in [
917            ErrorCategory::Network,
918            ErrorCategory::Timeout,
919            ErrorCategory::RateLimit,
920            ErrorCategory::Authentication,
921            ErrorCategory::InvalidParameters,
922            ErrorCategory::ToolNotFound,
923            ErrorCategory::ResourceNotFound,
924            ErrorCategory::PermissionDenied,
925            ErrorCategory::PolicyViolation,
926            ErrorCategory::ExecutionError,
927        ] {
928            assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
929        }
930    }
931
932    // --- User label ---
933
934    #[test]
935    fn user_labels_are_non_empty() {
936        assert!(!ErrorCategory::Network.user_label().is_empty());
937        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
938    }
939
940    // --- auth_recovery_guidance ---
941
942    #[test]
943    fn auth_recovery_guidance_no_credential_mentions_secret_add() {
944        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
945        assert_eq!(guidance.len(), 1);
946        assert_eq!(
947            guidance[0],
948            "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
949        );
950    }
951
952    #[test]
953    fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
954        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
955        assert_eq!(guidance.len(), 1);
956        assert_eq!(
957            guidance[0],
958            "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
959        );
960    }
961
962    #[test]
963    fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
964        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
965        assert_eq!(guidance.len(), 1);
966        assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
967    }
968
969    #[test]
970    fn auth_recovery_guidance_non_auth_category_returns_empty() {
971        assert!(
972            ErrorCategory::Network
973                .auth_recovery_guidance("OpenAI", "openai", false, false)
974                .is_empty()
975        );
976        assert!(
977            ErrorCategory::Timeout
978                .auth_recovery_guidance("OpenAI", "openai", false, false)
979                .is_empty()
980        );
981    }
982
983    // --- Display ---
984
985    #[test]
986    fn display_matches_user_label() {
987        assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
988    }
989}