1use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
24#[ts(export, export_to = "../../../frontend/types/rpc/")]
25#[serde(rename_all = "snake_case")]
26pub enum FailureClass {
27 RateLimited,
29 Overloaded,
31 UsageLimit,
33 Auth,
35 ContextExceeded,
37 MaxTurns,
39 Network,
41 Killed,
43 NoOutput,
45 SpawnFailure,
47 UnknownNonZero,
49 AgentDeleted,
53 LiveElsewhere,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
65#[ts(export, export_to = "../../../frontend/types/rpc/")]
66#[serde(rename_all = "camelCase")]
67pub struct AgentFailure {
68 pub code: FailureClass,
69 pub title: String,
70 pub detail: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 #[ts(optional)]
73 pub exit_code: Option<i32>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 #[ts(optional)]
76 pub signal: Option<i32>,
77 #[serde(skip_serializing_if = "String::is_empty")]
78 #[ts(as = "Option<String>", optional)]
79 pub stderr_tail: String,
80 pub retryable: bool,
81}
82
83impl AgentFailure {
84 pub fn explain(&self) -> String {
90 let mut s = self.title.clone();
91 if !self.detail.is_empty() {
92 s.push_str(" — ");
93 s.push_str(&self.detail);
94 }
95 match (self.signal, self.exit_code) {
96 (Some(sig), _) => s.push_str(&format!(" [signal {sig}]")),
97 (None, Some(code)) => s.push_str(&format!(" [exit {code}]")),
98 (None, None) => {}
99 }
100 if self.retryable {
101 s.push_str(" (retryable)");
102 }
103 if !self.stderr_tail.is_empty() {
104 s.push_str("\n--- claude stderr (tail) ---\n");
105 s.push_str(&self.stderr_tail);
106 }
107 s
108 }
109}
110
111pub fn classify(
119 exit_code: Option<i32>,
120 signal: Option<i32>,
121 stderr: &str,
122 result_frame: Option<&Value>,
123) -> AgentFailure {
124 let frame_reported_error = result_frame.is_some_and(is_error_result_frame);
125 let frame_text = result_frame.map(frame_error_text).unwrap_or_default();
126
127 let combined = if frame_text.is_empty() {
128 stderr.to_string()
129 } else {
130 format!("{stderr}\n{frame_text}")
131 };
132 let tail = tail_lines(&combined, 24, 1600);
133 let hay = combined.to_ascii_lowercase();
134
135 if result_frame
137 .and_then(|f| f.get("subtype"))
138 .and_then(|v| v.as_str())
139 .is_some_and(|s| s.contains("max_turns"))
140 {
141 return build(
142 FailureClass::MaxTurns,
143 "Hit the turn limit",
144 "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
145 false,
146 exit_code,
147 signal,
148 &tail,
149 );
150 }
151
152 if signal.is_some() || exit_code == Some(137) {
154 return build(
155 FailureClass::Killed,
156 "Agent process was killed",
157 "Terminated by a signal — most often the OS out-of-memory killer or an external stop. Reduce concurrent load / memory pressure and retry.",
158 false,
159 exit_code,
160 signal,
161 &tail,
162 );
163 }
164
165 if hay.contains("rate limited")
169 || hay.contains("temporarily limiting requests")
170 || hay.contains("rate_limit")
171 || mentions_http_status(&hay, "429")
172 {
173 return build(
174 FailureClass::RateLimited,
175 "Rate-limited by the API",
176 "Server-side rate limit — transient, and not your account quota. Retry shortly; consider lowering parallelism.",
177 true,
178 exit_code,
179 signal,
180 &tail,
181 );
182 }
183 if hay.contains("overloaded") || mentions_http_status(&hay, "529") {
184 return build(
185 FailureClass::Overloaded,
186 "API temporarily overloaded",
187 "The API reported it is overloaded (HTTP 529). Transient — retry with backoff.",
188 true,
189 exit_code,
190 signal,
191 &tail,
192 );
193 }
194 if hay.contains("bind an account for this provider in connectors")
211 || hay.contains("bind an account for this provider in the armory")
212 {
213 let provider_phrase = extract_spawn_gate_provider(&combined)
222 .map(|p| format!("No {} account", capitalize_provider(&p)))
223 .unwrap_or_else(|| "No account".to_string());
224 return build(
225 FailureClass::Auth,
226 "No account linked",
227 &format!(
228 "{provider_phrase} is bound to this agent — the spawn was refused before it could run. \
229 Sign in to link one."
230 ),
231 false,
232 exit_code,
233 signal,
234 &tail,
235 );
236 }
237 if crate::backend::agent_admission::is_admission_refusal(&combined) {
242 let taken_over = hay.contains("was taken over by another agentmux instance");
243 return build(
244 FailureClass::LiveElsewhere,
245 if taken_over { "Taken over by another AgentMux instance" } else { "Running in another AgentMux instance" },
246 frame_or_stderr_line(&combined),
247 false,
248 exit_code,
249 signal,
250 &tail,
251 );
252 }
253 if hay.contains("was deleted, so this pane cannot start") {
258 return build(
259 FailureClass::AgentDeleted,
260 "Agent was deleted",
261 "This pane's agent was deleted, so it cannot start. Close the pane, \
262 or go back to the picker and launch another agent in it.",
263 false,
264 exit_code,
265 signal,
266 &tail,
267 );
268 }
269 if hay.contains("instead of an isolated agentmux account") {
275 let provider_phrase = extract_ambient_home_provider(&combined)
276 .map(|p| format!("This agent's {} identity", capitalize_provider(&p)))
277 .unwrap_or_else(|| "This agent's identity".to_string());
278 return build(
279 FailureClass::Auth,
280 "Identity points at your personal login",
281 &format!(
282 "{provider_phrase} is bound directly to your personal CLI login \
283 directory, which AgentMux no longer allows. Re-bind it to an \
284 isolated account in Connectors \u{2192} Accounts, then retry."
285 ),
286 false,
287 exit_code,
288 signal,
289 &tail,
290 );
291 }
292 if hay.contains("refusing to spawn with an unprotected config dir") {
300 let provider_phrase = extract_claude_md_seed_provider(&combined)
301 .map(|p| format!("This agent's {} config directory", capitalize_provider(&p)))
302 .unwrap_or_else(|| "This agent's config directory".to_string());
303 return build(
304 FailureClass::Auth,
305 "Could not isolate this agent's config",
306 &format!(
307 "{provider_phrase} could not be prepared for isolated use — the spawn was \
308 refused rather than risk it silently reading your personal CLI config. \
309 Retry, and check the directory's permissions if it persists."
310 ),
311 false,
312 exit_code,
313 signal,
314 &tail,
315 );
316 }
317 if hay.contains("authentication_error")
318 || hay.contains("authentication_failed")
319 || hay.contains("invalid authentication")
320 || hay.contains("invalid api key")
321 || hay.contains("invalid x-api-key")
322 || hay.contains("unauthorized")
323 || hay.contains("please run /login")
324 || mentions_http_status(&hay, "401")
325 {
326 return build(
327 FailureClass::Auth,
328 "Not authenticated",
329 "The API rejected the credentials (HTTP 401). Re-authenticate via the Identity tab or `claude /login`.",
330 false,
331 exit_code,
332 signal,
333 &tail,
334 );
335 }
336 if hay.contains("model_context_window_exceeded")
337 || hay.contains("prompt is too long")
338 || (hay.contains("context") && (hay.contains("exceed") || hay.contains("window") || hay.contains("too long")))
339 {
340 return build(
341 FailureClass::ContextExceeded,
342 "Context window exceeded",
343 "The conversation exceeded the model's context window. Clear or compact the agent's history and retry.",
344 false,
345 exit_code,
346 signal,
347 &tail,
348 );
349 }
350 if hay.contains("max turns") || hay.contains("max-turns") || hay.contains("maximum number of turns") {
351 return build(
352 FailureClass::MaxTurns,
353 "Hit the turn limit",
354 "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
355 false,
356 exit_code,
357 signal,
358 &tail,
359 );
360 }
361 if hay.contains("usage limit")
362 || hay.contains("out of credits")
363 || hay.contains("quota")
364 || hay.contains("billing")
365 || hay.contains("insufficient")
366 {
367 return build(
368 FailureClass::UsageLimit,
369 "Usage limit reached",
370 "Your plan or usage limit appears to be reached. Check plan / billing — retrying won't help until it resets.",
371 false,
372 exit_code,
373 signal,
374 &tail,
375 );
376 }
377 if hay.contains("apiconnectionerror")
378 || hay.contains("connection error")
379 || hay.contains("could not resolve")
380 || hay.contains("econnreset")
381 || hay.contains("network")
382 || hay.contains("timed out")
383 || hay.contains("timeout")
384 || hay.contains("dns")
385 {
386 return build(
387 FailureClass::Network,
388 "Network error reaching the API",
389 "A network/connection error occurred talking to the API. Check connectivity and retry.",
390 true,
391 exit_code,
392 signal,
393 &tail,
394 );
395 }
396
397 if frame_reported_error {
399 return build(
400 FailureClass::UnknownNonZero,
401 "Agent reported an error",
402 "The agent reported a terminal error with no recognized cause. The raw output tail is included below.",
403 false,
404 exit_code,
405 signal,
406 &tail,
407 );
408 }
409 if exit_code == Some(0) {
410 return build(
411 FailureClass::NoOutput,
412 "Agent produced no output",
413 "The agent exited cleanly (code 0) but never produced a final result. Inspect the transcript / sidecar log.",
414 false,
415 exit_code,
416 signal,
417 &tail,
418 );
419 }
420 build(
421 FailureClass::UnknownNonZero,
422 "Agent failed",
423 "The agent exited with a non-zero status and no recognized error. The raw stderr tail is included below.",
424 false,
425 exit_code,
426 signal,
427 &tail,
428 )
429}
430
431#[allow(clippy::too_many_arguments)]
432fn build(
433 code: FailureClass,
434 title: &str,
435 detail: &str,
436 retryable: bool,
437 exit_code: Option<i32>,
438 signal: Option<i32>,
439 tail: &str,
440) -> AgentFailure {
441 AgentFailure {
442 code,
443 title: title.to_string(),
444 detail: detail.to_string(),
445 exit_code,
446 signal,
447 stderr_tail: tail.to_string(),
448 retryable,
449 }
450}
451
452fn extract_spawn_gate_provider(combined: &str) -> Option<String> {
460 let marker = "no credentials for ";
461 let start = combined.find(marker)? + marker.len();
462 let rest = &combined[start..];
463 let end = rest.find(':')?;
464 let provider = rest[..end].trim();
465 if provider.is_empty() {
466 None
467 } else {
468 Some(provider.to_string())
469 }
470}
471
472fn extract_ambient_home_provider(combined: &str) -> Option<String> {
478 let marker = "this agent's ";
479 let start = combined.find(marker)? + marker.len();
480 let rest = &combined[start..];
481 let end = rest.find(" identity points directly")?;
482 let provider = rest[..end].trim();
483 if provider.is_empty() {
484 None
485 } else {
486 Some(provider.to_string())
487 }
488}
489
490fn extract_claude_md_seed_provider(combined: &str) -> Option<String> {
494 let marker = "could not isolate this agent's ";
495 let start = combined.find(marker)? + marker.len();
496 let rest = &combined[start..];
497 let end = rest.find(" config directory")?;
498 let provider = rest[..end].trim();
499 if provider.is_empty() {
500 None
501 } else {
502 Some(provider.to_string())
503 }
504}
505
506fn capitalize_provider(id: &str) -> String {
514 let mut chars = id.chars();
515 match chars.next() {
516 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
517 None => id.to_string(),
518 }
519}
520
521fn frame_error_text(frame: &Value) -> String {
524 if let Some(msg) = frame.pointer("/error/message").and_then(|v| v.as_str()) {
525 return msg.to_string();
526 }
527 if let Some(err) = frame.get("error").and_then(|v| v.as_str()) {
528 return err.to_string();
529 }
530 if let Some(res) = frame.get("result").and_then(|v| v.as_str()) {
531 return res.to_string();
532 }
533 if let Some(err) = first_cli_error(frame) {
534 return err.to_string();
535 }
536 frame
537 .get("subtype")
538 .and_then(|v| v.as_str())
539 .unwrap_or_default()
540 .to_string()
541}
542
543pub(crate) fn first_cli_error(frame: &Value) -> Option<&str> {
547 frame.get("errors")?.as_array()?.iter().find_map(|e| e.as_str().filter(|s| !s.is_empty()))
548}
549
550pub(crate) fn is_error_result_frame(frame: &Value) -> bool {
552 frame.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)
553 || frame
554 .get("subtype")
555 .and_then(|v| v.as_str())
556 .is_some_and(|s| s.starts_with("error"))
557}
558
559fn tail_lines(s: &str, max_lines: usize, max_chars: usize) -> String {
562 let mut lines: Vec<&str> = s
563 .lines()
564 .map(|l| l.trim_end())
565 .filter(|l| !l.is_empty())
566 .collect();
567 if lines.len() > max_lines {
568 lines = lines.split_off(lines.len() - max_lines);
569 }
570 let joined = lines.join("\n");
571 let n = joined.chars().count();
572 if n > max_chars {
573 joined.chars().skip(n - max_chars).collect()
574 } else {
575 joined
576 }
577}
578
579fn mentions_http_status(hay: &str, code: &str) -> bool {
584 [
585 format!("http {code}"),
586 format!("http/1.1 {code}"),
587 format!("http/2 {code}"),
588 format!("status {code}"),
589 format!("status: {code}"),
590 format!("status code {code}"),
591 format!("code {code}"),
592 format!("code: {code}"),
593 format!("error {code}"),
594 format!("error: {code}"),
595 format!("[{code}]"),
596 format!("({code})"),
597 ]
598 .iter()
599 .any(|p| hay.contains(p.as_str()))
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use serde_json::json;
606
607 #[test]
608 fn rate_limit_incident_string_classifies_retryable() {
609 let stderr =
611 "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited";
612 let f = classify(Some(1), None, stderr, None);
613 assert_eq!(f.code, FailureClass::RateLimited);
614 assert!(f.retryable);
615 let e = f.explain();
616 assert!(e.contains("Rate-limited"), "explain: {e}");
617 assert!(e.contains("retryable"), "explain: {e}");
618 assert!(e.to_lowercase().contains("rate limited"), "explain: {e}");
619 }
620
621 #[test]
622 fn not_your_usage_limit_does_not_classify_as_usage_limit() {
623 let f = classify(
626 Some(1),
627 None,
628 "temporarily limiting requests (not your usage limit) · Rate limited",
629 None,
630 );
631 assert_eq!(f.code, FailureClass::RateLimited);
632 }
633
634 #[test]
635 fn iso_date_in_stderr_does_not_false_positive() {
636 for stderr in [
640 "panic at 2026-04-29T10:00:00: unexpected eof",
641 "build 2026-04-01 failed: assertion",
642 "ts=2026-05-29 fatal: segfault",
643 ] {
644 let f = classify(Some(2), None, stderr, None);
645 assert_eq!(
646 f.code,
647 FailureClass::UnknownNonZero,
648 "stderr {stderr:?} must not match an HTTP status"
649 );
650 }
651 }
652
653 #[test]
654 fn anchored_http_status_still_classifies() {
655 assert_eq!(
656 classify(Some(1), None, "Error: HTTP 429 Too Many Requests", None).code,
657 FailureClass::RateLimited
658 );
659 assert_eq!(
660 classify(Some(1), None, "request failed with status 401", None).code,
661 FailureClass::Auth
662 );
663 }
664
665 #[test]
666 fn overloaded_is_retryable() {
667 let f = classify(Some(1), None, "overloaded_error: please retry", None);
668 assert_eq!(f.code, FailureClass::Overloaded);
669 assert!(f.retryable);
670 }
671
672 #[test]
673 fn invalid_api_key_is_auth_not_retryable() {
674 let f = classify(Some(1), None, "authentication_error: Invalid API key provided", None);
675 assert_eq!(f.code, FailureClass::Auth);
676 assert!(!f.retryable);
677 }
678
679 #[test]
680 fn sigkill_is_killed() {
681 let f = classify(None, Some(9), "", None);
682 assert_eq!(f.code, FailureClass::Killed);
683 assert_eq!(f.signal, Some(9));
684 assert!(f.explain().contains("[signal 9]"));
685 }
686
687 #[test]
688 fn exit_137_is_killed() {
689 let f = classify(Some(137), None, "", None);
690 assert_eq!(f.code, FailureClass::Killed);
691 }
692
693 #[test]
694 fn context_window_exceeded_classifies() {
695 let f = classify(
696 Some(1),
697 None,
698 "prompt is too long: 1200000 tokens > 200000 maximum context window",
699 None,
700 );
701 assert_eq!(f.code, FailureClass::ContextExceeded);
702 }
703
704 #[test]
705 fn network_error_is_retryable() {
706 let f = classify(Some(1), None, "APIConnectionError: Connection error.", None);
707 assert_eq!(f.code, FailureClass::Network);
708 assert!(f.retryable);
709 }
710
711 #[test]
712 fn clean_exit_no_output_is_no_output() {
713 let f = classify(Some(0), None, "", None);
714 assert_eq!(f.code, FailureClass::NoOutput);
715 assert!(!f.retryable);
716 }
717
718 #[test]
719 fn unknown_nonzero_is_default() {
720 let f = classify(Some(2), None, "something unexpected happened", None);
721 assert_eq!(f.code, FailureClass::UnknownNonZero);
722 assert!(f.explain().contains("[exit 2]"));
723 }
724
725 #[test]
726 fn authentication_failed_string_is_auth() {
727 let f = classify(
731 Some(0),
732 None,
733 "authentication_failed Failed to authenticate. API Error: 401 Invalid authentication credentials",
734 None,
735 );
736 assert_eq!(f.code, FailureClass::Auth);
737 assert!(!f.retryable);
738 }
739
740 #[test]
741 fn invalid_authentication_credentials_is_auth() {
742 let f = classify(Some(0), None, "invalid authentication credentials", None);
743 assert_eq!(f.code, FailureClass::Auth);
744 }
745
746 #[test]
747 fn api_error_colon_401_is_auth() {
748 let f = classify(Some(0), None, "api error: 401 invalid authentication credentials", None);
750 assert_eq!(f.code, FailureClass::Auth);
751 }
752
753 #[test]
756 fn spawn_gate_agent_deleted_is_its_own_class_and_not_retryable() {
757 let msg = crate::identity::resolver::SpawnGateError::AgentDeleted {
758 agent_id: "uid-x".to_string(),
759 }
760 .to_string();
761 let f = classify(None, None, &msg, None);
762 assert_eq!(f.code, FailureClass::AgentDeleted);
763 assert!(!f.retryable);
764 }
765
766 #[test]
770 fn a_single_live_instance_refusal_is_its_own_class() {
771 let msg = crate::backend::agent_admission::denied_message("Agent3", None);
772 let f = classify(None, None, &msg, None);
773 assert_eq!(f.code, FailureClass::LiveElsewhere);
774 assert_eq!(f.title, "Running in another AgentMux instance");
775 assert_eq!(f.detail, msg, "the detail is the refusal itself, naming the holder");
776 assert!(!f.retryable);
777 let framed = format!("[AgentMux] {msg}");
779 assert_eq!(classify(None, None, &framed, None).code, FailureClass::LiveElsewhere);
780 let taken = "Agent3 was taken over by another AgentMux instance on this host (channel x).";
781 assert_eq!(classify(None, None, taken, None).title, "Taken over by another AgentMux instance");
782 assert_eq!(serde_json::to_value(FailureClass::LiveElsewhere).unwrap(), "live_elsewhere");
783 }
784
785 #[test]
786 fn spawn_gate_missing_credentials_is_auth_not_unknown() {
787 let frame = json!({
795 "type": "result",
796 "is_error": true,
797 "subtype": "error_during_execution",
798 "error": { "message": "[AgentMux] no credentials for claude: the bound account was deleted or is unresolvable. Bind an account for this provider in Connectors → Accounts." }
799 });
800 let f = classify(Some(1), None, "", Some(&frame));
801 assert_eq!(f.code, FailureClass::Auth);
802 assert!(f.detail.contains("Claude"), "detail: {}", f.detail);
803 }
804
805 #[test]
806 fn spawn_gate_missing_credentials_from_before_the_armory_split_still_classifies() {
807 let frame = json!({
809 "type": "result",
810 "is_error": true,
811 "subtype": "error_during_execution",
812 "error": { "message": "[AgentMux] no credentials for codex: the bound account was deleted or is unresolvable. Bind an account for this provider in the Armory." }
813 });
814 let f = classify(Some(1), None, "", Some(&frame));
815 assert_eq!(f.code, FailureClass::Auth);
816 assert!(f.detail.contains("Codex"), "detail: {}", f.detail);
817 }
818
819 #[test]
820 fn spawn_gate_missing_credentials_names_the_actual_provider_not_claude() {
821 let frame = json!({
826 "type": "result",
827 "is_error": true,
828 "subtype": "error_during_execution",
829 "error": { "message": "[AgentMux] no credentials for gemini: the bound account was deleted or is unresolvable. Bind an account for this provider in Connectors → Accounts." }
830 });
831 let f = classify(Some(1), None, "", Some(&frame));
832 assert_eq!(f.code, FailureClass::Auth);
833 assert!(f.detail.contains("Gemini"), "detail: {}", f.detail);
834 assert!(!f.detail.contains("Claude"), "detail: {}", f.detail);
835 }
836
837 #[test]
838 fn spawn_gate_ambient_home_dir_is_auth_not_unknown() {
839 let frame = json!({
844 "type": "result",
845 "is_error": true,
846 "subtype": "error_during_execution",
847 "error": { "message": "[AgentMux] this agent's claude identity points directly at your personal claude config directory (C:\\Users\\user\\.claude) instead of an isolated AgentMux account — AgentMux no longer allows spawning an agent against your own global CLI login. Re-bind this identity to an isolated account in Connectors \u{2192} Accounts (delete the current claude account and log in again to create a fresh, isolated one), then retry." }
848 });
849 let f = classify(Some(1), None, "", Some(&frame));
850 assert_eq!(f.code, FailureClass::Auth);
851 assert!(!f.retryable, "a bare retry can never succeed against this gate");
852 assert!(f.detail.contains("Claude"), "detail: {}", f.detail);
853 assert!(f.title.to_lowercase().contains("identity"), "title: {}", f.title);
854 }
855
856 #[test]
857 fn spawn_gate_ambient_home_dir_names_the_actual_provider_not_claude() {
858 let frame = json!({
859 "type": "result",
860 "is_error": true,
861 "subtype": "error_during_execution",
862 "error": { "message": "[AgentMux] this agent's codex identity points directly at your personal codex config directory (/home/user/.codex) instead of an isolated AgentMux account — AgentMux no longer allows spawning an agent against your own global CLI login. Re-bind this identity to an isolated account in Connectors \u{2192} Accounts (delete the current codex account and log in again to create a fresh, isolated one), then retry." }
863 });
864 let f = classify(Some(1), None, "", Some(&frame));
865 assert_eq!(f.code, FailureClass::Auth);
866 assert!(f.detail.contains("Codex"), "detail: {}", f.detail);
867 assert!(!f.detail.contains("Claude"), "detail: {}", f.detail);
868 }
869
870 #[test]
871 fn spawn_gate_claude_md_seed_failed_is_auth_not_unknown() {
872 let frame = json!({
880 "type": "result",
881 "is_error": true,
882 "subtype": "error_during_execution",
883 "error": { "message": "[AgentMux] could not isolate this agent's claude config directory (C:\\Users\\user\\.agentmux\\shared\\identities\\id-1\\claude): permission denied. Refusing to spawn with an unprotected config dir — retry, and check the directory's permissions if it persists." }
884 });
885 let f = classify(Some(1), None, "", Some(&frame));
886 assert_eq!(f.code, FailureClass::Auth);
887 assert!(!f.retryable, "a bare retry may not succeed against a persistent permissions issue");
888 assert!(f.detail.contains("Claude"), "detail: {}", f.detail);
889 assert!(f.title.to_lowercase().contains("isolate"), "title: {}", f.title);
890 }
891
892 #[test]
893 fn spawn_gate_claude_md_seed_failed_names_the_actual_provider_not_claude() {
894 let frame = json!({
895 "type": "result",
896 "is_error": true,
897 "subtype": "error_during_execution",
898 "error": { "message": "[AgentMux] could not isolate this agent's codex config directory (/home/user/.agentmux/shared/providers/codex): permission denied. Refusing to spawn with an unprotected config dir — retry, and check the directory's permissions if it persists." }
899 });
900 let f = classify(Some(1), None, "", Some(&frame));
901 assert_eq!(f.code, FailureClass::Auth);
902 assert!(f.detail.contains("Codex"), "detail: {}", f.detail);
903 assert!(!f.detail.contains("Claude"), "detail: {}", f.detail);
904 }
905
906 #[test]
907 fn extract_claude_md_seed_provider_reads_the_provider_out_of_the_gate_wording() {
908 assert_eq!(
909 extract_claude_md_seed_provider(
910 "could not isolate this agent's gemini config directory (/x): some io error."
911 ),
912 Some("gemini".to_string()),
913 );
914 }
915
916 #[test]
917 fn extract_claude_md_seed_provider_returns_none_on_wording_mismatch() {
918 assert_eq!(extract_claude_md_seed_provider("some unrelated error text"), None);
919 }
920
921 #[test]
922 fn extract_ambient_home_provider_reads_the_provider_out_of_the_gate_wording() {
923 assert_eq!(
924 extract_ambient_home_provider(
925 "this agent's gemini identity points directly at your personal gemini config directory (/x)."
926 ),
927 Some("gemini".to_string()),
928 );
929 }
930
931 #[test]
932 fn extract_ambient_home_provider_returns_none_on_wording_mismatch() {
933 assert_eq!(extract_ambient_home_provider("some unrelated error text"), None);
934 }
935
936 #[test]
937 fn capitalize_provider_title_cases_the_raw_id() {
938 assert_eq!(capitalize_provider("claude"), "Claude");
939 assert_eq!(capitalize_provider("gemini"), "Gemini");
940 assert_eq!(capitalize_provider(""), "");
941 }
942
943 #[test]
944 fn spawn_gate_injection_unavailable_is_not_auth() {
945 let frame = json!({
948 "type": "result",
949 "is_error": true,
950 "subtype": "error_during_execution",
951 "error": { "message": "[AgentMux] credential injection could not run (panic); the spawn was refused rather than falling back to the global CLI login. Retry, and check `muxlog auth` if it persists." }
952 });
953 let f = classify(Some(1), None, "", Some(&frame));
954 assert_eq!(f.code, FailureClass::UnknownNonZero);
955 }
956
957 #[test]
958 fn result_frame_max_turns_subtype() {
959 let frame = json!({ "type": "result", "is_error": true, "subtype": "error_max_turns" });
960 let f = classify(Some(0), None, "", Some(&frame));
961 assert_eq!(f.code, FailureClass::MaxTurns);
962 }
963
964 #[test]
965 fn result_frame_error_message_feeds_classification() {
966 let frame = json!({
969 "type": "result",
970 "is_error": true,
971 "subtype": "error_during_execution",
972 "error": { "message": "overloaded_error: upstream busy" }
973 });
974 let f = classify(Some(0), None, "", Some(&frame));
975 assert_eq!(f.code, FailureClass::Overloaded);
976 }
977
978 #[test]
979 fn tail_lines_keeps_last_lines_char_safe() {
980 let s = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
981 let t = tail_lines(&s, 5, 1000);
982 assert_eq!(t.lines().count(), 5);
983 assert!(t.contains("line99"));
984 assert!(!t.contains("line0\n"));
985 }
986
987 #[test]
988 fn extract_spawn_gate_provider_reads_the_provider_out_of_the_gate_wording() {
989 assert_eq!(
990 extract_spawn_gate_provider(
991 "no credentials for codex: the bound account was deleted or is unresolvable."
992 ),
993 Some("codex".to_string()),
994 );
995 }
996
997 #[test]
998 fn extract_spawn_gate_provider_returns_none_on_wording_mismatch() {
999 assert_eq!(extract_spawn_gate_provider("some unrelated error text"), None);
1000 }
1001
1002 #[test]
1003 fn serializes_snake_case_code_camel_fields() {
1004 let f = classify(Some(1), None, "Rate limited", None);
1005 let v = serde_json::to_value(&f).unwrap();
1006 assert_eq!(v["code"], json!("rate_limited"));
1007 assert_eq!(v["retryable"], json!(true));
1008 assert!(v.get("stderrTail").is_some(), "camelCase stderrTail expected: {v}");
1009 }
1010}
1011
1012fn frame_or_stderr_line(text: &str) -> &str {
1016 text.lines()
1017 .find(|l| crate::backend::agent_admission::is_admission_refusal(l))
1018 .map(|l| l.trim().trim_start_matches("[AgentMux]").trim())
1019 .unwrap_or_else(|| text.trim())
1020}