1use std::borrow::Cow;
22use std::fmt;
23use std::fmt::Write;
24use std::time::Duration;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum ErrorCategory {
30 Network,
33 Timeout,
35 RateLimit,
37 ServiceUnavailable,
39 CircuitOpen,
41
42 Authentication,
45 InvalidParameters,
47 ToolNotFound,
49 ResourceNotFound,
51 PermissionDenied,
53 PolicyViolation,
55 PlanningPolicyViolation,
57 SandboxFailure,
59 ResourceExhausted,
61 Cancelled,
63 ExecutionError,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Retryability {
70 Retryable {
72 max_attempts: u32,
74 backoff: BackoffStrategy,
76 },
77 NonRetryable,
79 RequiresIntervention,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BackoffStrategy {
86 Exponential { base: Duration, max: Duration },
88 Fixed(Duration),
90}
91
92impl ErrorCategory {
93 #[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 #[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 #[inline]
124 #[must_use]
125 pub const fn is_llm_mistake(&self) -> bool {
126 matches!(self, ErrorCategory::InvalidParameters)
127 }
128
129 #[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 #[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 #[must_use]
179 pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
180 match self {
181 ErrorCategory::Network => vec![
182 Cow::Borrowed("Check network connectivity"),
183 Cow::Borrowed("Retry the operation after a brief delay"),
184 Cow::Borrowed("Verify external service availability"),
185 ],
186 ErrorCategory::Timeout => vec![
187 Cow::Borrowed("Increase timeout values if appropriate"),
188 Cow::Borrowed("Break large operations into smaller chunks"),
189 Cow::Borrowed("Check system resources and performance"),
190 ],
191 ErrorCategory::RateLimit => vec![
192 Cow::Borrowed("Wait before retrying the request"),
193 Cow::Borrowed("Reduce request frequency"),
194 Cow::Borrowed("Check provider rate limit documentation"),
195 ],
196 ErrorCategory::ServiceUnavailable => vec![
197 Cow::Borrowed("The service is temporarily unavailable"),
198 Cow::Borrowed("Retry after a brief delay"),
199 Cow::Borrowed("Check service status page if available"),
200 ],
201 ErrorCategory::CircuitOpen => vec![
202 Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
203 Cow::Borrowed("Wait for the circuit breaker cooldown period"),
204 Cow::Borrowed("Try an alternative approach"),
205 ],
206 ErrorCategory::Authentication => vec![
207 Cow::Borrowed("Verify your API key or credentials"),
208 Cow::Borrowed("Check that your account is active and has sufficient permissions"),
209 Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
210 ],
211 ErrorCategory::InvalidParameters => vec![
212 Cow::Borrowed("Check parameter names and types against the tool schema"),
213 Cow::Borrowed("Ensure required parameters are provided"),
214 Cow::Borrowed("Verify parameter values are within acceptable ranges"),
215 ],
216 ErrorCategory::ToolNotFound => vec![
217 Cow::Borrowed("Verify the tool name is spelled correctly"),
218 Cow::Borrowed("Check if the tool is available in the current context"),
219 ],
220 ErrorCategory::ResourceNotFound => vec![
221 Cow::Borrowed("Verify file paths and resource locations"),
222 Cow::Borrowed("Check if files exist and are accessible"),
223 Cow::Borrowed("Use list_dir to explore available resources"),
224 ],
225 ErrorCategory::PermissionDenied => vec![
226 Cow::Borrowed("Check file permissions and access rights"),
227 Cow::Borrowed("Ensure workspace boundaries are respected"),
228 ],
229 ErrorCategory::PolicyViolation => vec![
230 Cow::Borrowed("Review workspace policies and restrictions"),
231 Cow::Borrowed("Use alternative tools that comply with policies"),
232 ],
233 ErrorCategory::PlanningPolicyViolation => vec![
234 Cow::Borrowed("This operation is not allowed in the Planning workflow with read-only permissions"),
235 Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
236 ],
237 ErrorCategory::SandboxFailure => vec![
238 Cow::Borrowed("The sandbox denied this operation"),
239 Cow::Borrowed("Check sandbox configuration and permissions"),
240 ],
241 ErrorCategory::ResourceExhausted => vec![
242 Cow::Borrowed("Check your account usage limits and billing status"),
243 Cow::Borrowed("Review resource consumption and optimize if possible"),
244 ],
245 ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
246 ErrorCategory::ExecutionError => vec![
247 Cow::Borrowed("Review error details for specific issues"),
248 Cow::Borrowed("Check tool documentation for known limitations"),
249 ],
250 }
251 }
252
253 #[must_use]
255 pub const fn user_label(&self) -> &'static str {
256 match self {
257 ErrorCategory::Network => "Network error",
258 ErrorCategory::Timeout => "Request timed out",
259 ErrorCategory::RateLimit => "Rate limit exceeded",
260 ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
261 ErrorCategory::CircuitOpen => "Tool temporarily disabled",
262 ErrorCategory::Authentication => "Authentication failed",
263 ErrorCategory::InvalidParameters => "Invalid parameters",
264 ErrorCategory::ToolNotFound => "Tool not found",
265 ErrorCategory::ResourceNotFound => "Resource not found",
266 ErrorCategory::PermissionDenied => "Permission denied",
267 ErrorCategory::PolicyViolation => "Blocked by policy",
268 ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
269 ErrorCategory::SandboxFailure => "Sandbox denied",
270 ErrorCategory::ResourceExhausted => "Resource limit reached",
271 ErrorCategory::Cancelled => "Operation cancelled",
272 ErrorCategory::ExecutionError => "Execution failed",
273 }
274 }
275
276 #[must_use]
286 pub fn auth_recovery_guidance(
287 &self,
288 provider_label: &str,
289 provider_key: &str,
290 is_managed_auth: bool,
291 has_stored_credential: bool,
292 ) -> Vec<String> {
293 if !matches!(self, ErrorCategory::Authentication) {
294 return vec![];
295 }
296
297 if is_managed_auth {
298 vec![format!(
299 "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
300 )]
301 } else if has_stored_credential {
302 vec![format!(
303 "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
304 )]
305 } else {
306 vec![format!(
307 "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
308 )]
309 }
310 }
311}
312
313impl fmt::Display for ErrorCategory {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 f.write_str(self.user_label())
316 }
317}
318
319#[must_use]
329pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
330 let msg = err.to_string().to_ascii_lowercase();
331 classify_error_message(&msg)
332}
333
334#[inline]
339#[must_use]
340pub fn classify_error_message(msg: &str) -> ErrorCategory {
341 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
342 Cow::Owned(msg.to_ascii_lowercase())
343 } else {
344 Cow::Borrowed(msg)
345 };
346
347 if contains_any(
349 &msg,
350 &[
351 "policy violation",
352 "denied by policy",
353 "tool permission denied",
354 "safety validation failed",
355 "not allowed in planning workflow",
356 "only available when planning workflow is active",
357 "workspace boundary",
358 "blocked by policy",
359 ],
360 ) {
361 return ErrorCategory::PolicyViolation;
362 }
363
364 if contains_any(
366 &msg,
367 &[
368 "planning workflow",
369 "read-only permissions",
370 concat!("read-only ", "mode"),
371 "planning_policy_violation",
372 ],
373 ) {
374 return ErrorCategory::PlanningPolicyViolation;
375 }
376
377 if contains_any(
379 &msg,
380 &[
381 "invalid api key",
382 "authentication failed",
383 "unauthorized",
384 "401",
385 "invalid credentials",
386 ],
387 ) {
388 return ErrorCategory::Authentication;
389 }
390
391 if contains_any(
393 &msg,
394 &[
395 "weekly usage limit",
396 "daily usage limit",
397 "monthly spending limit",
398 "insufficient credits",
399 "quota exceeded",
400 "billing",
401 "payment required",
402 ],
403 ) {
404 return ErrorCategory::ResourceExhausted;
405 }
406
407 if contains_any(
409 &msg,
410 &[
411 "invalid argument",
412 "invalid parameters",
413 "invalid type",
414 "malformed",
415 "failed to parse arguments",
416 "failed to parse argument",
417 "missing required",
418 "at least one item is required",
419 "is required for",
420 "schema validation",
421 "argument validation failed",
422 "unknown field",
423 "unknown variant",
424 "expected struct",
425 "expected enum",
426 "type mismatch",
427 "must be an absolute path",
428 "not parseable",
429 "parseable as",
430 "invalid patch format",
438 "invalid patch hunk",
439 "invalid patch operation",
440 "cannot parse empty patch",
441 "patch does not contain",
442 "semantic patch anchor",
443 ],
444 ) {
445 return ErrorCategory::InvalidParameters;
446 }
447
448 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
450 return ErrorCategory::ToolNotFound;
451 }
452
453 if contains_any(
455 &msg,
456 &[
457 "no such file",
458 "no such directory",
459 "file not found",
460 "directory not found",
461 "resource not found",
462 "path not found",
463 "does not exist",
464 "enoent",
465 ],
466 ) {
467 return ErrorCategory::ResourceNotFound;
468 }
469
470 if contains_any(
472 &msg,
473 &[
474 "permission denied",
475 "access denied",
476 "operation not permitted",
477 "eacces",
478 "eperm",
479 "forbidden",
480 "403",
481 ],
482 ) {
483 return ErrorCategory::PermissionDenied;
484 }
485
486 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
488 return ErrorCategory::Cancelled;
489 }
490
491 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
493 return ErrorCategory::CircuitOpen;
494 }
495
496 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
498 return ErrorCategory::SandboxFailure;
499 }
500
501 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
503 return ErrorCategory::RateLimit;
504 }
505
506 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
508 return ErrorCategory::Timeout;
509 }
510
511 if contains_any(
513 &msg,
514 &[
515 "invalid response format: missing choices",
516 "invalid response format: missing message",
517 "missing choices in response",
518 "missing message in choice",
519 "no choices in response",
520 "invalid response from ",
521 "empty response body",
522 "response did not contain",
523 "unexpected response format",
524 "failed to parse response",
525 ],
526 ) {
527 return ErrorCategory::ServiceUnavailable;
528 }
529
530 if contains_any(
532 &msg,
533 &[
534 "service unavailable",
535 "temporarily unavailable",
536 "internal server error",
537 "bad gateway",
538 "gateway timeout",
539 "overloaded",
540 "500",
541 "502",
542 "503",
543 "504",
544 ],
545 ) {
546 return ErrorCategory::ServiceUnavailable;
547 }
548
549 if contains_any(
551 &msg,
552 &[
553 "network",
554 "connection reset",
555 "connection refused",
556 "broken pipe",
557 "dns",
558 "name resolution",
559 "try again",
560 "retry later",
561 "upstream connect error",
562 "tls handshake",
563 "socket hang up",
564 "econnreset",
565 "etimedout",
566 "error decoding response body",
570 ],
571 ) {
572 return ErrorCategory::Network;
573 }
574
575 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
577 return ErrorCategory::ResourceExhausted;
578 }
579
580 ErrorCategory::ExecutionError
582}
583
584#[inline]
589#[must_use]
590pub fn is_retryable_llm_error_message(msg: &str) -> bool {
591 let category = classify_error_message(msg);
592 category.is_retryable()
593}
594
595#[inline]
596fn contains_any(message: &str, markers: &[&str]) -> bool {
597 markers.iter().any(|marker| message.contains(marker))
598}
599
600impl From<&crate::llm::LLMError> for ErrorCategory {
605 fn from(err: &crate::llm::LLMError) -> Self {
606 match err {
607 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
608 crate::llm::LLMError::RateLimit { metadata } => {
609 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
610 }
611 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
612 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
613 crate::llm::LLMError::Provider { message, metadata } => {
614 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
615 if metadata_category != ErrorCategory::ExecutionError {
616 return metadata_category;
617 }
618
619 if let Some(meta) = metadata
621 && let Some(status) = meta.status
622 {
623 return match status {
624 401 => ErrorCategory::Authentication,
625 403 => ErrorCategory::PermissionDenied,
626 404 => ErrorCategory::ResourceNotFound,
627 429 => ErrorCategory::RateLimit,
628 400 => ErrorCategory::InvalidParameters,
629 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
630 408 => ErrorCategory::Timeout,
631 _ => classify_error_message(message),
632 };
633 }
634 classify_error_message(message)
636 }
637 }
638 }
639}
640
641fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
642 let Some(metadata) = metadata else {
643 return fallback;
644 };
645
646 let mut hint = String::new();
647 if let Some(code) = &metadata.code {
648 hint.push_str(code);
649 hint.push(' ');
650 }
651 if let Some(message) = &metadata.message {
652 hint.push_str(message);
653 hint.push(' ');
654 }
655 if let Some(status) = metadata.status {
656 let _ = write!(&mut hint, "{status}");
657 }
658
659 let classified = classify_error_message(&hint);
660 if classified == ErrorCategory::ExecutionError {
661 fallback
662 } else {
663 classified
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
674 fn policy_violation_takes_priority_over_permission() {
675 assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
676 }
677
678 #[test]
679 fn rate_limit_classified_correctly() {
680 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
681 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
682 }
683
684 #[test]
685 fn service_unavailable_is_classified() {
686 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
687 }
688
689 #[test]
690 fn authentication_errors() {
691 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
692 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
693 }
694
695 #[test]
696 fn billing_errors_are_resource_exhausted() {
697 assert_eq!(
698 classify_error_message("you have reached your weekly usage limit"),
699 ErrorCategory::ResourceExhausted
700 );
701 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
702 }
703
704 #[test]
705 fn timeout_errors() {
706 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
707 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
708 }
709
710 #[test]
711 fn network_errors() {
712 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
713 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
714 }
715
716 #[test]
717 fn tool_not_found() {
718 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
719 }
720
721 #[test]
722 fn resource_not_found() {
723 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
724 assert_eq!(
725 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
726 ErrorCategory::ResourceNotFound
727 );
728 }
729
730 #[test]
731 fn patch_format_errors_are_invalid_parameters() {
732 assert_eq!(
737 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
738 ErrorCategory::InvalidParameters
739 );
740 assert_eq!(
741 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
742 ErrorCategory::InvalidParameters
743 );
744 assert_eq!(
745 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
746 ErrorCategory::InvalidParameters
747 );
748 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
749 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
750 assert_eq!(
751 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
752 ErrorCategory::InvalidParameters
753 );
754 }
755
756 #[test]
757 fn permission_denied() {
758 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
759 }
760
761 #[test]
762 fn cancelled_operations() {
763 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
764 }
765
766 #[test]
767 fn planning_policy_violation() {
768 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
769 }
770
771 #[test]
772 fn sandbox_failure() {
773 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
774 }
775
776 #[test]
777 fn unknown_error_is_execution_error() {
778 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
779 }
780
781 #[test]
782 fn invalid_parameters() {
783 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
784 assert_eq!(
785 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
786 ErrorCategory::InvalidParameters
787 );
788 assert_eq!(
789 classify_error_message("at least one item is required for 'create'"),
790 ErrorCategory::InvalidParameters
791 );
792 assert_eq!(
793 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
794 ErrorCategory::InvalidParameters
795 );
796 }
797
798 #[test]
801 fn retryable_categories() {
802 assert!(ErrorCategory::Network.is_retryable());
803 assert!(ErrorCategory::Timeout.is_retryable());
804 assert!(ErrorCategory::RateLimit.is_retryable());
805 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
806 assert!(ErrorCategory::CircuitOpen.is_retryable());
807 }
808
809 #[test]
810 fn non_retryable_categories() {
811 assert!(!ErrorCategory::Authentication.is_retryable());
812 assert!(!ErrorCategory::InvalidParameters.is_retryable());
813 assert!(!ErrorCategory::PolicyViolation.is_retryable());
814 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
815 assert!(!ErrorCategory::Cancelled.is_retryable());
816 }
817
818 #[test]
819 fn permanent_error_detection() {
820 assert!(ErrorCategory::Authentication.is_permanent());
821 assert!(ErrorCategory::PolicyViolation.is_permanent());
822 assert!(!ErrorCategory::Network.is_permanent());
823 assert!(!ErrorCategory::Timeout.is_permanent());
824 }
825
826 #[test]
827 fn llm_mistake_detection() {
828 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
829 assert!(!ErrorCategory::Network.is_llm_mistake());
830 assert!(!ErrorCategory::Timeout.is_llm_mistake());
831 }
832
833 #[test]
836 fn llm_error_authentication_converts() {
837 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
838 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
839 }
840
841 #[test]
842 fn llm_error_rate_limit_converts() {
843 let err = crate::llm::LLMError::RateLimit { metadata: None };
844 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
845 }
846
847 #[test]
848 fn llm_error_quota_exhaustion_converts() {
849 let err = crate::llm::LLMError::RateLimit {
850 metadata: Some(crate::llm::LLMErrorMetadata::new(
851 "openai",
852 Some(429),
853 Some("insufficient_quota".to_string()),
854 None,
855 None,
856 None,
857 Some("quota exceeded".to_string()),
858 )),
859 };
860
861 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
862 }
863
864 #[test]
865 fn llm_error_network_converts() {
866 let err = crate::llm::LLMError::Network {
867 message: "connection refused".to_string(),
868 metadata: None,
869 };
870 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
871 }
872
873 #[test]
874 fn llm_error_provider_with_status_code() {
875 use crate::llm::LLMErrorMetadata;
876 let err = crate::llm::LLMError::Provider {
877 message: "error".to_string(),
878 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
879 };
880 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
881 }
882
883 #[test]
884 fn minimax_invalid_response_is_service_unavailable() {
885 assert_eq!(
886 classify_error_message("Invalid response from MiniMax: missing choices"),
887 ErrorCategory::ServiceUnavailable
888 );
889 assert_eq!(
890 classify_error_message("Invalid response format: missing message"),
891 ErrorCategory::ServiceUnavailable
892 );
893 }
894
895 #[test]
898 fn retryable_llm_messages() {
899 assert!(is_retryable_llm_error_message("429 too many requests"));
900 assert!(is_retryable_llm_error_message("500 internal server error"));
901 assert!(is_retryable_llm_error_message("connection timeout"));
902 assert!(is_retryable_llm_error_message("network error"));
903 }
904
905 #[test]
906 fn non_retryable_llm_messages() {
907 assert!(!is_retryable_llm_error_message("invalid api key"));
908 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
909 assert!(!is_retryable_llm_error_message("permission denied"));
910 }
911
912 #[test]
915 fn recovery_suggestions_non_empty() {
916 for cat in [
917 ErrorCategory::Network,
918 ErrorCategory::Timeout,
919 ErrorCategory::RateLimit,
920 ErrorCategory::Authentication,
921 ErrorCategory::InvalidParameters,
922 ErrorCategory::ToolNotFound,
923 ErrorCategory::ResourceNotFound,
924 ErrorCategory::PermissionDenied,
925 ErrorCategory::PolicyViolation,
926 ErrorCategory::ExecutionError,
927 ] {
928 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
929 }
930 }
931
932 #[test]
935 fn user_labels_are_non_empty() {
936 assert!(!ErrorCategory::Network.user_label().is_empty());
937 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
938 }
939
940 #[test]
943 fn auth_recovery_guidance_no_credential_mentions_secret_add() {
944 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
945 assert_eq!(guidance.len(), 1);
946 assert_eq!(
947 guidance[0],
948 "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
949 );
950 }
951
952 #[test]
953 fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
954 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
955 assert_eq!(guidance.len(), 1);
956 assert_eq!(
957 guidance[0],
958 "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
959 );
960 }
961
962 #[test]
963 fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
964 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
965 assert_eq!(guidance.len(), 1);
966 assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
967 }
968
969 #[test]
970 fn auth_recovery_guidance_non_auth_category_returns_empty() {
971 assert!(
972 ErrorCategory::Network
973 .auth_recovery_guidance("OpenAI", "openai", false, false)
974 .is_empty()
975 );
976 assert!(
977 ErrorCategory::Timeout
978 .auth_recovery_guidance("OpenAI", "openai", false, false)
979 .is_empty()
980 );
981 }
982
983 #[test]
986 fn display_matches_user_label() {
987 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
988 }
989}