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