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