返回 CodeWhale
tests.rs
根目录 / crates / tui / src / error_taxonomy / tests.rs
1 use super::*;
2
3 #[test]
4 fn model_output_truncated_classifies_as_invalid_input_not_tool() {
5 // The turn-level "Model output truncated" error is a provider/model
6 // condition, not a tool failure: it must land in the same bucket as
7 // `LlmError::ModelError` so the exec termination classifier reduces it
8 // to `RunTerminationReason::ModelError` (never Resolved).
9 assert_eq!(
10 classify_error_message(
11 "Model output truncated: provider stop reason `max_output_tokens`; no complete response or tool call was accepted."
12 ),
13 ErrorCategory::InvalidInput
14 );
15 assert_eq!(
16 classify_error_message(
17 "Model output truncated: provider stop reason `max_tokens`; no complete response or tool call was accepted."
18 ),
19 ErrorCategory::InvalidInput
20 );
21 assert_eq!(
22 classify_error_message(
23 "Model response incomplete: provider stop reason `content_filter`; no complete response or tool call was accepted."
24 ),
25 ErrorCategory::InvalidInput
26 );
27 }
28
29 #[test]
30 fn raw_rate_and_quota_phrases_remain_coarse_rate_limit_diagnostics() {
31 for message in [
32 "Rate limit reached for gpt-4",
33 "Too Many Requests",
34 "HTTP 429 from upstream",
35 "Your quota has been exceeded",
36 "Authorization failed: You've reached your usage limit for this billing cycle",
37 ] {
38 assert_eq!(classify_error_message(message), ErrorCategory::RateLimit);
39 }
40 }
41
42 #[test]
43 fn typed_llm_quota_envelope_is_non_recoverable_and_distinct_from_rate_limit() {
44 let envelope = ErrorEnvelope::from(LlmError::from_http_response(
45 429,
46 r#"{"error":{"code":"insufficient_quota"}}"#,
47 ));
48 assert_eq!(envelope.category, ErrorCategory::RateLimit);
49 assert_eq!(envelope.severity, ErrorSeverity::Error);
50 assert!(!envelope.recoverable);
51 assert_eq!(envelope.code, "llm_quota_exhausted");
52 }
53
54 #[test]
55 fn llm_auth_error_envelope_renders_context_without_secret() {
56 let api_key = "tp-secret-token-plan-value";
57 let envelope = ErrorEnvelope::from(LlmError::from_http_response_with_request_context(
58 401,
59 &format!("Invalid API Key: {api_key}"),
60 Some("Xiaomi MiMo"),
61 Some("https://token-plan-sgp.xiaomimimo.com/v1"),
62 Some("mimo-v2.5"),
63 Some("env"),
64 Some(api_key),
65 ));
66 assert_eq!(envelope.category, ErrorCategory::Authentication);
67 assert_eq!(envelope.severity, ErrorSeverity::Critical);
68 assert!(!envelope.recoverable);
69 for expected in [
70 "provider: Xiaomi MiMo",
71 "base URL authority: token-plan-sgp.xiaomimimo.com",
72 "model: mimo-v2.5",
73 "key source: env",
74 "key fingerprint: tp-... (len=26)",
75 "key type: Xiaomi MiMo Token Plan key",
76 ] {
77 assert!(envelope.message.contains(expected));
78 }
79 assert!(!envelope.message.contains(api_key));
80 assert!(!envelope.message.contains("secret-token-plan-value"));
81 }
82
83 #[test]
84 fn model_not_exist_rejection_is_a_terminal_invalid_input_error() {
85 // The reported incident: a provider switch left GLM-5.3 selected on a
86 // Model Studio route; the next message failed with
87 // `Model error: Model not exist.` and the transcript rendered it as a
88 // dismissable *warning* because the stringified error fell through to
89 // `Internal` + `recoverable`. A wrong-model rejection is terminal for
90 // the request: it must classify as InvalidInput so it renders at Error
91 // severity and never as recoverable noise.
92 for message in [
93 "Model error: Model not exist.",
94 "Model not exist",
95 "Model not found: glm-5.3 on this endpoint",
96 "Unknown model identifier",
97 "Invalid model: qwen-flash",
98 ] {
99 let envelope = ErrorEnvelope::classify(message.to_string(), true);
100 assert_eq!(
101 envelope.category,
102 ErrorCategory::InvalidInput,
103 "message must classify as InvalidInput: {message}"
104 );
105 assert_eq!(
106 envelope.severity,
107 ErrorSeverity::Error,
108 "wrong-model rejections must render at Error severity: {message}"
109 );
110 // `recoverable` governs offline-mode semantics, not transcript
111 // severity: a wrong-model rejection keeps the session online so the
112 // operator can repair the route.
113 assert!(envelope.recoverable, "session stays online: {message}");
114 }
115 }
116
117 #[test]
118 fn typed_llm_error_preserves_terminal_severity_across_boundary() {
119 // Even where the typed error survives to the boundary (the turn loop's
120 // stream-initiation and mid-stream paths), `envelope_for_llm_error`
121 // must keep the typed contract instead of re-classifying the string
122 // with `recoverable = true`.
123 let typed: anyhow::Error =
124 crate::llm_client::LlmError::ModelError("Model not exist.".to_string()).into();
125 let envelope = envelope_for_llm_error(typed, "Model error: Model not exist.".to_string());
126 assert_eq!(envelope.category, ErrorCategory::InvalidInput);
127 assert_eq!(envelope.severity, ErrorSeverity::Error);
128 assert_eq!(envelope.code, "llm_model_error");
129 assert_eq!(envelope.message, "Model error: Model not exist.");
130
131 // Untyped errors keep the legacy string fallback.
132 let untyped: anyhow::Error = anyhow::anyhow!("stream read error: connection reset");
133 let envelope = envelope_for_llm_error(untyped, "stream read error: connection reset".into());
134 assert_eq!(envelope.category, ErrorCategory::Network);
135 assert!(envelope.recoverable);
136 }
137
137 lines RUST