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