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]
353pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
354 let outer = classify_error_message(&err.to_string());
357 if outer != ErrorCategory::ExecutionError {
358 return outer;
359 }
360
361 if let Some(llm_error) = err.downcast_ref::<crate::llm::LLMError>() {
365 return ErrorCategory::from(llm_error);
366 }
367
368 for cause in err.chain().skip(1) {
369 let category = classify_error_message(&cause.to_string());
370 if category != ErrorCategory::ExecutionError {
371 return category;
372 }
373 }
374
375 ErrorCategory::ExecutionError
376}
377
378#[must_use]
387pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
388 err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
389}
390
391#[must_use]
393pub fn is_context_capacity_message(message: &str) -> bool {
394 let message = message.to_ascii_lowercase();
395 contains_any(
396 &message,
397 &[
398 "context_length_exceeded",
399 "context length exceeded",
400 "maximum context length",
401 "maximum context window",
402 "context window exceeded",
403 "context window is too small",
404 "exceeds the model's maximum context",
405 "exceeds model context",
406 "exceeds the maximum context",
407 "prompt is too long",
408 "input is too long",
409 "input token count exceeds",
410 "exceeds the maximum number of tokens",
411 "maximum input tokens",
412 "too many tokens in the prompt",
413 "request exceeds the context",
414 "context limit exceeded",
419 "context size exceeded",
420 "exceeds context",
421 "input tokens exceed",
422 "prompt tokens exceed",
423 "prompt token count exceeds",
424 "token limit exceeded",
425 "input too large",
426 "prompt too large",
427 ],
428 )
429}
430
431#[inline]
436#[must_use]
437pub fn classify_error_message(msg: &str) -> ErrorCategory {
438 let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
439 Cow::Owned(msg.to_ascii_lowercase())
440 } else {
441 Cow::Borrowed(msg)
442 };
443
444 if contains_any(
446 &msg,
447 &[
448 "policy violation",
449 "denied by policy",
450 "tool permission denied",
451 "safety validation failed",
452 "not allowed in planning workflow",
453 "only available when planning workflow is active",
454 "workspace boundary",
455 "blocked by policy",
456 ],
457 ) {
458 return ErrorCategory::PolicyViolation;
459 }
460
461 if contains_any(
463 &msg,
464 &[
465 "planning workflow",
466 "read-only permissions",
467 concat!("read-only ", "mode"),
468 "planning_policy_violation",
469 ],
470 ) {
471 return ErrorCategory::PlanningPolicyViolation;
472 }
473
474 if contains_any(
476 &msg,
477 &[
478 "invalid api key",
479 "authentication failed",
480 "unauthorized",
481 "401",
482 "invalid credentials",
483 ],
484 ) {
485 return ErrorCategory::Authentication;
486 }
487
488 if contains_any(
490 &msg,
491 &[
492 "weekly usage limit",
493 "daily usage limit",
494 "monthly spending limit",
495 "insufficient credits",
496 "quota exceeded",
497 "billing",
498 "payment required",
499 ],
500 ) {
501 return ErrorCategory::ResourceExhausted;
502 }
503
504 if contains_any(
506 &msg,
507 &[
508 "invalid argument",
509 "invalid parameters",
510 "invalid type",
511 "malformed",
512 "failed to parse arguments",
513 "failed to parse argument",
514 "missing required",
515 "at least one item is required",
516 "is required for",
517 "schema validation",
518 "argument validation failed",
519 "unknown field",
520 "unknown variant",
521 "expected struct",
522 "expected enum",
523 "type mismatch",
524 "must be an absolute path",
525 "not parseable",
526 "parseable as",
527 "invalid patch format",
535 "invalid patch hunk",
536 "invalid patch operation",
537 "cannot parse empty patch",
538 "patch does not contain",
539 "semantic patch anchor",
540 ],
541 ) {
542 return ErrorCategory::InvalidParameters;
543 }
544
545 if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
547 return ErrorCategory::ToolNotFound;
548 }
549
550 if contains_any(
552 &msg,
553 &[
554 "no such file",
555 "no such directory",
556 "file not found",
557 "directory not found",
558 "resource not found",
559 "path not found",
560 "does not exist",
561 "enoent",
562 ],
563 ) {
564 return ErrorCategory::ResourceNotFound;
565 }
566
567 if contains_any(
569 &msg,
570 &[
571 "permission denied",
572 "access denied",
573 "operation not permitted",
574 "eacces",
575 "eperm",
576 "forbidden",
577 "403",
578 ],
579 ) {
580 return ErrorCategory::PermissionDenied;
581 }
582
583 if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
585 return ErrorCategory::Cancelled;
586 }
587
588 if contains_any(&msg, &["circuit breaker", "circuit open"]) {
590 return ErrorCategory::CircuitOpen;
591 }
592
593 if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
595 return ErrorCategory::SandboxFailure;
596 }
597
598 if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
600 return ErrorCategory::RateLimit;
601 }
602
603 if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
605 return ErrorCategory::Timeout;
606 }
607
608 if contains_any(
610 &msg,
611 &[
612 "invalid response format: missing choices",
613 "invalid response format: missing message",
614 "missing choices in response",
615 "missing message in choice",
616 "no choices in response",
617 "invalid response from ",
618 "empty response body",
619 "response did not contain",
620 "unexpected response format",
621 "failed to parse response",
622 "stream disconnected",
628 "stream closed unexpectedly",
629 "incomplete stream",
630 "unexpected end of stream",
631 "truncated response",
632 "failed to decode stream",
633 "stream terminated",
634 ],
635 ) {
636 return ErrorCategory::ServiceUnavailable;
637 }
638
639 if contains_any(
641 &msg,
642 &[
643 "service unavailable",
644 "temporarily unavailable",
645 "internal server error",
646 "bad gateway",
647 "gateway timeout",
648 "overloaded",
649 "500",
650 "502",
651 "503",
652 "504",
653 ],
654 ) {
655 return ErrorCategory::ServiceUnavailable;
656 }
657
658 if contains_any(
660 &msg,
661 &[
662 "network",
663 "connection reset",
664 "connection refused",
665 "broken pipe",
666 "dns",
667 "name resolution",
668 "try again",
669 "retry later",
670 "upstream connect error",
671 "tls handshake",
672 "socket hang up",
673 "econnreset",
674 "etimedout",
675 "error decoding response body",
679 "connection closed before",
684 "stream reset",
685 "stream error received",
686 ],
687 ) {
688 return ErrorCategory::Network;
689 }
690
691 if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
693 return ErrorCategory::ResourceExhausted;
694 }
695
696 ErrorCategory::ExecutionError
698}
699
700#[inline]
705#[must_use]
706pub fn is_retryable_llm_error_message(msg: &str) -> bool {
707 let category = classify_error_message(msg);
708 category.is_retryable()
709}
710
711#[inline]
712fn contains_any(message: &str, markers: &[&str]) -> bool {
713 markers.iter().any(|marker| message.contains(marker))
714}
715
716impl From<&crate::llm::LLMError> for ErrorCategory {
721 fn from(err: &crate::llm::LLMError) -> Self {
722 match err {
723 crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
724 crate::llm::LLMError::RateLimit { metadata } => {
725 classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
726 }
727 crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
728 crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
729 crate::llm::LLMError::Provider { message, metadata } => {
730 let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
731 if metadata_category != ErrorCategory::ExecutionError {
732 return metadata_category;
733 }
734
735 if let Some(meta) = metadata
737 && let Some(status) = meta.status
738 {
739 return match status {
740 401 => ErrorCategory::Authentication,
741 403 => ErrorCategory::PermissionDenied,
742 404 => ErrorCategory::ResourceNotFound,
743 429 => ErrorCategory::RateLimit,
744 400 => ErrorCategory::InvalidParameters,
745 500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
746 408 => ErrorCategory::Timeout,
747 _ => classify_error_message(message),
748 };
749 }
750 classify_error_message(message)
752 }
753 }
754 }
755}
756
757fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
758 let Some(metadata) = metadata else {
759 return fallback;
760 };
761
762 let mut hint = String::new();
763 if let Some(code) = &metadata.code {
764 hint.push_str(code);
765 hint.push(' ');
766 }
767 if let Some(message) = &metadata.message {
768 hint.push_str(message);
769 hint.push(' ');
770 }
771 if let Some(status) = metadata.status {
772 let _ = write!(&mut hint, "{status}");
773 }
774
775 let classified = classify_error_message(&hint);
776 if classified == ErrorCategory::ExecutionError {
777 fallback
778 } else {
779 classified
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
790 fn policy_violation_takes_priority_over_permission() {
791 assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
792 }
793
794 #[test]
795 fn rate_limit_classified_correctly() {
796 assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
797 assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
798 }
799
800 #[test]
801 fn service_unavailable_is_classified() {
802 assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
803 }
804
805 #[test]
806 fn follow_up_stream_failures_are_retryable() {
807 for msg in [
810 "follow-up failed: stream disconnected mid-response",
811 "sse stream terminated before completion",
812 "incomplete stream while reading follow-up",
813 "connection closed before response completed",
814 "stream reset by peer during follow-up",
815 "h2 stream error received: internal error",
816 ] {
817 let category = classify_error_message(msg);
818 assert!(category.is_retryable(), "{msg} -> {category:?} should be retryable");
819 }
820 assert_eq!(classify_error_message("upstream stream file ready"), ErrorCategory::ExecutionError);
824 assert_eq!(classify_error_message("downstream error: 400 bad request"), ErrorCategory::ExecutionError);
825 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
826 }
827
828 #[test]
829 fn authentication_errors() {
830 assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
831 assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
832 }
833
834 #[test]
835 fn billing_errors_are_resource_exhausted() {
836 assert_eq!(
837 classify_error_message("you have reached your weekly usage limit"),
838 ErrorCategory::ResourceExhausted
839 );
840 assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
841 }
842
843 #[test]
844 fn timeout_errors() {
845 assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
846 assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
847 }
848
849 #[test]
850 fn network_errors() {
851 assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
852 assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
853 }
854
855 #[test]
856 fn tool_not_found() {
857 assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
858 }
859
860 #[test]
861 fn resource_not_found() {
862 assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
863 assert_eq!(
864 classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
865 ErrorCategory::ResourceNotFound
866 );
867 }
868
869 #[test]
870 fn patch_format_errors_are_invalid_parameters() {
871 assert_eq!(
876 classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
877 ErrorCategory::InvalidParameters
878 );
879 assert_eq!(
880 classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
881 ErrorCategory::InvalidParameters
882 );
883 assert_eq!(
884 classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
885 ErrorCategory::InvalidParameters
886 );
887 assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
888 assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
889 assert_eq!(
890 classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
891 ErrorCategory::InvalidParameters
892 );
893 }
894
895 #[test]
896 fn permission_denied() {
897 assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
898 }
899
900 #[test]
901 fn cancelled_operations() {
902 assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
903 }
904
905 #[test]
906 fn planning_policy_violation() {
907 assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
908 }
909
910 #[test]
911 fn sandbox_failure() {
912 assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
913 }
914
915 #[test]
916 fn unknown_error_is_execution_error() {
917 assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
918 }
919
920 #[test]
921 fn invalid_parameters() {
922 assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
923 assert_eq!(
924 classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
925 ErrorCategory::InvalidParameters
926 );
927 assert_eq!(
928 classify_error_message("at least one item is required for 'create'"),
929 ErrorCategory::InvalidParameters
930 );
931 assert_eq!(
932 classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
933 ErrorCategory::InvalidParameters
934 );
935 }
936
937 #[test]
940 fn classify_anyhow_prefers_typed_llm_error_under_context() {
941 let rate = anyhow::Error::new(crate::llm::LLMError::RateLimit { metadata: None }).context("compaction failed");
945 assert_eq!(classify_anyhow_error(&rate), ErrorCategory::RateLimit);
946
947 let auth = anyhow::Error::new(crate::llm::LLMError::Authentication {
948 message: "bad token".to_string(),
949 metadata: None,
950 })
951 .context("request failed");
952 assert_eq!(classify_anyhow_error(&auth), ErrorCategory::Authentication);
953 }
954
955 #[test]
956 fn classify_anyhow_reads_deeper_cause_chain() {
957 let inner = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection reset by peer");
960 let err = anyhow::Error::new(inner).context("failed to fetch resource");
961 assert_eq!(classify_anyhow_error(&err), ErrorCategory::Network);
962 }
963
964 #[test]
965 fn classify_anyhow_keeps_outermost_context_priority() {
966 let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
968 let err = anyhow::Error::new(inner).context("tool permission denied by policy");
969 assert_eq!(classify_anyhow_error(&err), ErrorCategory::PolicyViolation);
970 }
971
972 #[test]
973 fn classify_anyhow_unknown_stays_execution_error() {
974 let err = anyhow::anyhow!("something went wrong");
975 assert_eq!(classify_anyhow_error(&err), ErrorCategory::ExecutionError);
976 }
977
978 #[test]
981 fn retryable_categories() {
982 assert!(ErrorCategory::Network.is_retryable());
983 assert!(ErrorCategory::Timeout.is_retryable());
984 assert!(ErrorCategory::RateLimit.is_retryable());
985 assert!(ErrorCategory::ServiceUnavailable.is_retryable());
986 assert!(ErrorCategory::CircuitOpen.is_retryable());
987 }
988
989 #[test]
990 fn non_retryable_categories() {
991 assert!(!ErrorCategory::Authentication.is_retryable());
992 assert!(!ErrorCategory::InvalidParameters.is_retryable());
993 assert!(!ErrorCategory::PolicyViolation.is_retryable());
994 assert!(!ErrorCategory::ResourceExhausted.is_retryable());
995 assert!(!ErrorCategory::Cancelled.is_retryable());
996 }
997
998 #[test]
999 fn permanent_error_detection() {
1000 assert!(ErrorCategory::Authentication.is_permanent());
1001 assert!(ErrorCategory::PolicyViolation.is_permanent());
1002 assert!(!ErrorCategory::Network.is_permanent());
1003 assert!(!ErrorCategory::Timeout.is_permanent());
1004 }
1005
1006 #[test]
1007 fn llm_mistake_detection() {
1008 assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
1009 assert!(!ErrorCategory::Network.is_llm_mistake());
1010 assert!(!ErrorCategory::Timeout.is_llm_mistake());
1011 }
1012
1013 #[test]
1016 fn llm_error_authentication_converts() {
1017 let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
1018 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
1019 }
1020
1021 #[test]
1022 fn llm_error_rate_limit_converts() {
1023 let err = crate::llm::LLMError::RateLimit { metadata: None };
1024 assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
1025 }
1026
1027 #[test]
1028 fn llm_error_quota_exhaustion_converts() {
1029 let err = crate::llm::LLMError::RateLimit {
1030 metadata: Some(crate::llm::LLMErrorMetadata::new(
1031 "openai",
1032 Some(429),
1033 Some("insufficient_quota".to_string()),
1034 None,
1035 None,
1036 None,
1037 Some("quota exceeded".to_string()),
1038 )),
1039 };
1040
1041 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
1042 }
1043
1044 #[test]
1045 fn llm_error_network_converts() {
1046 let err = crate::llm::LLMError::Network {
1047 message: "connection refused".to_string(),
1048 metadata: None,
1049 };
1050 assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
1051 }
1052
1053 #[test]
1054 fn llm_error_provider_with_status_code() {
1055 use crate::llm::LLMErrorMetadata;
1056 let err = crate::llm::LLMError::Provider {
1057 message: "error".to_string(),
1058 metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
1059 };
1060 assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
1061 }
1062
1063 #[test]
1064 fn minimax_invalid_response_is_service_unavailable() {
1065 assert_eq!(
1066 classify_error_message("Invalid response from MiniMax: missing choices"),
1067 ErrorCategory::ServiceUnavailable
1068 );
1069 assert_eq!(
1070 classify_error_message("Invalid response format: missing message"),
1071 ErrorCategory::ServiceUnavailable
1072 );
1073 }
1074
1075 #[test]
1078 fn retryable_llm_messages() {
1079 assert!(is_retryable_llm_error_message("429 too many requests"));
1080 assert!(is_retryable_llm_error_message("500 internal server error"));
1081 assert!(is_retryable_llm_error_message("connection timeout"));
1082 assert!(is_retryable_llm_error_message("network error"));
1083 }
1084
1085 #[test]
1086 fn non_retryable_llm_messages() {
1087 assert!(!is_retryable_llm_error_message("invalid api key"));
1088 assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
1089 assert!(!is_retryable_llm_error_message("permission denied"));
1090 }
1091
1092 #[test]
1093 fn context_capacity_markers_are_specific() {
1094 assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
1095 assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
1096 assert!(!is_context_capacity_message("invalid request: context field is missing"));
1097 }
1098
1099 #[test]
1100 fn context_capacity_markers_cover_gateway_variants() {
1101 for msg in [
1105 "input tokens exceed context limit (956758 > 1000000)",
1106 "prompt tokens exceed the model context window",
1107 "context limit exceeded: too many input tokens",
1108 "request exceeds context size for anthropic/claude-sonnet-5",
1109 "token limit exceeded for prompt",
1110 "prompt too large for context window",
1111 ] {
1112 assert!(is_context_capacity_message(msg), "{msg} should be a capacity signal");
1113 }
1114 assert!(!is_context_capacity_message("upstream stream file ready"));
1117 assert!(!is_context_capacity_message("context limited to 5 tools"));
1120 }
1121
1122 #[test]
1125 fn recovery_suggestions_non_empty() {
1126 for cat in [
1127 ErrorCategory::Network,
1128 ErrorCategory::Timeout,
1129 ErrorCategory::RateLimit,
1130 ErrorCategory::Authentication,
1131 ErrorCategory::InvalidParameters,
1132 ErrorCategory::ToolNotFound,
1133 ErrorCategory::ResourceNotFound,
1134 ErrorCategory::PermissionDenied,
1135 ErrorCategory::PolicyViolation,
1136 ErrorCategory::ExecutionError,
1137 ] {
1138 assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
1139 }
1140 }
1141
1142 #[test]
1145 fn user_labels_are_non_empty() {
1146 assert!(!ErrorCategory::Network.user_label().is_empty());
1147 assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1148 }
1149
1150 #[test]
1153 fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1154 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1155 assert_eq!(guidance.len(), 1);
1156 assert_eq!(
1157 guidance[0],
1158 "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1159 );
1160 }
1161
1162 #[test]
1163 fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1164 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1165 assert_eq!(guidance.len(), 1);
1166 assert_eq!(
1167 guidance[0],
1168 "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1169 );
1170 }
1171
1172 #[test]
1173 fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1174 let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1175 assert_eq!(guidance.len(), 1);
1176 assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1177 }
1178
1179 #[test]
1180 fn auth_recovery_guidance_non_auth_category_returns_empty() {
1181 assert!(
1182 ErrorCategory::Network
1183 .auth_recovery_guidance("OpenAI", "openai", false, false)
1184 .is_empty()
1185 );
1186 assert!(
1187 ErrorCategory::Timeout
1188 .auth_recovery_guidance("OpenAI", "openai", false, false)
1189 .is_empty()
1190 );
1191 }
1192
1193 #[test]
1196 fn display_matches_user_label() {
1197 assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1198 }
1199}