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