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