Skip to main content

agentmux_srv/agents/
failure.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Classification of agent (Claude CLI) run failures into a small,
5//! stable taxonomy with a human-readable explanation.
6//!
7//! `classify()` is a **pure** function: it takes the exit code, the
8//! terminating signal, the tail of the child's stderr, and an optional
9//! terminal `result` frame, and returns an [`AgentFailure`]. It does no
10//! IO and is exhaustively unit-tested against the real Anthropic error
11//! phrasings, so the "why" behind a non-zero exit can be surfaced to the
12//! user instead of a bare `exit 1`.
13//!
14//! See `docs/specs/SPEC_AGENT_FAILURE_DIAGNOSTICS_2026_06_11.md`. This
15//! is the P1 (capture + classify) slice; live-stream emission and UI
16//! banners are P2/P3.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21/// Stable failure taxonomy. Wire format is snake_case to match the rest
22/// of the agent event surface (`frontend/types/srv-types.d.ts`).
23#[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    /// Server-side rate limit (HTTP 429). Transient, not the account quota.
28    RateLimited,
29    /// API overloaded (HTTP 529). Transient.
30    Overloaded,
31    /// Plan / usage / billing limit reached. Not transient.
32    UsageLimit,
33    /// Credentials rejected (HTTP 401).
34    Auth,
35    /// Conversation exceeded the model's context window.
36    ContextExceeded,
37    /// Hit the configured `--max-turns` cap.
38    MaxTurns,
39    /// Network / connectivity error reaching the API.
40    Network,
41    /// Killed by a signal (OOM killer or external stop).
42    Killed,
43    /// Exited cleanly but produced no final result.
44    NoOutput,
45    /// Could not launch the `claude` binary at all.
46    SpawnFailure,
47    /// Non-zero exit with no recognized cause.
48    UnknownNonZero,
49    /// The identity spawn gate refused: the pane's agent was deleted
50    /// (`SpawnGateError::AgentDeleted`, #3577). No process ran, and a retry
51    /// is refused identically — the pane is done.
52    AgentDeleted,
53    /// This agent is running in another AgentMux instance on this host, or
54    /// was just taken over by one
55    /// (`docs/specs/SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md`). No
56    /// process ran here; a Retry is refused identically while the other
57    /// instance holds it — the recovery is Take over.
58    LiveElsewhere,
59}
60
61/// A classified agent failure: the class, a user-facing title + detail,
62/// the raw exit evidence, the stderr tail, and whether a retry is
63/// worth attempting.
64#[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    /// Render the single-string explanation that becomes a run's
85    /// terminal error (drone `error` column, sidecar log, future agent
86    /// pane banner). Title + detail + exit evidence + retryable hint,
87    /// followed by the raw stderr tail so the original text is never
88    /// more than a glance away.
89    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
111/// Classify an agent run failure.
112///
113/// `exit_code` / `signal` come from the child's `ExitStatus`. `stderr`
114/// is the captured (capped) child stderr. `result_frame` is the
115/// terminal stream-json `result` object when one was seen — the CLI
116/// sometimes reports an error there while still exiting 0, so it is
117/// folded into the evidence.
118pub 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    // Explicit terminal-frame subtype: turn cap reached.
136    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    // Killed by a signal (OOM killer or external stop). 137 == 128 + SIGKILL(9).
153    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    // Keyword matches. Order matters: rate-limit and overload are checked
166    // before usage-limit because the server-side rate-limit message
167    // literally contains "not your usage limit".
168    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    // The identity spawn gate's own refusal (identity/resolver/errors.rs's
195    // `SpawnGateError::MissingCredentials` Display impl — a deliberate,
196    // spec-owned wording per SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md
197    // §2.2, not third-party CLI/API output, so matching on it is as stable
198    // as matching our own code). This never reaches the API at all — the
199    // spawn was refused before any CLI process existed to report a 401 —
200    // so it's checked separately from the 401 branch below with its own
201    // wording, but classifies the same way: no account bound is exactly as
202    // actionable as a rejected credential, and needs the same relogin UI.
203    // Before this, the message had no recognized keyword and fell through
204    // to `UnknownNonZero`, which offers only "Retry" — a retry can never
205    // succeed against a gate that blocks every respawn identically, so the
206    // agent pane got stuck showing a dead-end error with no way out.
207    // "in the armory" is the wording before the Armory became Connectors
208    // (SPEC_RETIRE_ARMORY_CONNECTORS_AND_KNOWLEDGE_PANES_2026_10_05.md §4.4);
209    // a failure recorded then still has to classify.
210    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        // codex P2 on PR #2413: this branch matches EVERY oauth-class
214        // provider's MissingCredentials refusal (Codex, Gemini, OpenClaw,
215        // Copilot — not just Claude), but a prior version of this message
216        // hardcoded "No Claude account is bound". Extract the provider id
217        // from the gate's own wording ("no credentials for {provider}: …")
218        // instead of assuming — falls back to generic phrasing if the
219        // extraction ever misses (wording drift in errors.rs), rather than
220        // asserting a specific, possibly wrong, provider name.
221        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    // A single-live-instance refusal (backend::agent_admission — our own
238    // wording, recognised by the same markers that module uses). Checked
239    // before the generic classes: its text is not provider output, and a
240    // Retry cannot succeed while the other instance holds the agent.
241    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    // The identity spawn gate's deleted-agent refusal (identity/resolver/
254    // errors.rs's `SpawnGateError::AgentDeleted` Display — our own wording).
255    // Its own class, never Retry: the gate refuses every respawn the same
256    // way, so a Retry would be the same dead end as MissingCredentials above.
257    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    // The identity spawn gate's ambient-home-dir refusal (identity/resolver/
270    // errors.rs's `SpawnGateError::AmbientHomeDirNotAllowed` Display impl —
271    // same reasoning as the MissingCredentials branch above: this never
272    // reaches the API, and a bare "Retry" would be a dead end since the
273    // gate blocks every respawn identically until the identity is rebound.
274    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    // The identity spawn gate's CLAUDE.md isolation-seed refusal
293    // (identity/resolver/errors.rs's `SpawnGateError::ClaudeMdSeedFailed`
294    // Display impl — same reasoning as the two branches above: this never
295    // reaches the API, so it needs its own title/detail instead of falling
296    // through to the generic UnknownNonZero fallback. ReAgent P2 on PR
297    // #2854 — added alongside AmbientHomeDirNotAllowed's sibling branch
298    // above but initially missed for this newer variant.
299    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    // Fallbacks.
398    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
452/// Pulls the provider id out of `SpawnGateError::MissingCredentials`'s own
453/// Display wording ("no credentials for {provider}: the bound account was
454/// deleted or is unresolvable. Bind an account for this provider in
455/// Connectors → Accounts." — identity/resolver/errors.rs). Returns `None` on any mismatch
456/// (e.g. that wording drifts) rather than guessing — callers fall back to
457/// generic phrasing instead of asserting a specific, possibly wrong,
458/// provider name.
459fn 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
472/// Pulls the provider id out of
473/// `SpawnGateError::AmbientHomeDirNotAllowed`'s own Display wording
474/// ("this agent's {provider} identity points directly at your personal …" —
475/// identity/resolver/errors.rs). Same "return None on any mismatch rather
476/// than guessing" contract as `extract_spawn_gate_provider` above.
477fn 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
490/// Pulls the provider id out of
491/// `SpawnGateError::ClaudeMdSeedFailed`'s own Display wording ("could not
492/// isolate this agent's {provider} config directory (…): …").
493fn 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
506/// Title-cases a raw provider id ("claude" -> "Claude") for the
507/// spawn-gate message. reagent P2 on PR #2413: the raw lowercase slug
508/// read poorly next to catalog.ts's real display names ("Claude Code",
509/// "Gemini CLI"); this doesn't reach for those (Rust has no access to
510/// the frontend catalog and duplicating/syncing the full display-name
511/// table across languages is disproportionate for one error string) —
512/// just enough prettification that the message reads as a proper noun.
513fn 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
521/// Best-effort error text from a terminal stream-json `result` frame:
522/// `error.message`, else a string `error`/`result`, else the `subtype`.
523fn 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
543/// The first entry of a result frame's `errors` list, where Claude Code puts
544/// its own failures before any turn ("No conversation found with session ID:
545/// …").
546pub(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
550/// True if a terminal `result` frame represents a failure.
551pub(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
559/// Last `max_lines` non-empty lines of `s`, further capped to the last
560/// `max_chars` characters (char-safe — never slices mid-codepoint).
561fn 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
579/// True if `hay` mentions HTTP status `code` in a status-like context
580/// (e.g. "http 429", "status 429", "error 429", "[429]"). Anchoring on a
581/// context word avoids the false positives a bare substring would hit —
582/// an ISO date like `2026-04-29` contains "429". (reagent P2 on #1353.)
583fn 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        // The exact string this work was motivated by.
610        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        // Guard: the rate-limit message contains "usage limit"; it must
624        // win as RateLimited (checked first), not UsageLimit.
625        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        // "2026-04-29" contains "429"; "2026-04-01" -> "401";
637        // "2026-05-29" -> "529". A bare substring match would misclassify
638        // these; anchored matching must not. (reagent P2 on #1353.)
639        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        // In-band 401: claude wraps the error in a synthetic assistant message
728        // with `"error":"authentication_failed"`. The inband_text is appended
729        // to the stderr tail before classify() is called, so it must match.
730        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        // "API Error: 401" lowercased → "api error: 401" → matches "error: 401".
749        let f = classify(Some(0), None, "api error: 401 invalid authentication credentials", None);
750        assert_eq!(f.code, FailureClass::Auth);
751    }
752
753    /// #3577: the gate's deleted-agent refusal gets its own class and no
754    /// Retry — a retry is refused identically, a dead end.
755    #[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    /// SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 Phase 2: a refusal because
767    /// the agent runs elsewhere gets its own class — the row offers Take
768    /// over, never a Retry that is refused identically.
769    #[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        // Wrapped the way run_agent_turn persists it.
778        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        // Exact frame shape agent_handlers/input.rs emits for a
788        // SpawnGateError::MissingCredentials refusal (identity/resolver/
789        // errors.rs's Display impl) — before this branch existed, this
790        // frame had no recognized keyword and fell through to
791        // UnknownNonZero, which offers only "Retry" against a gate that
792        // blocks every respawn identically: a permanently stuck pane with
793        // no relogin affordance.
794        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        // A failure recorded while the message still said "in the Armory".
808        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        // codex P2 on PR #2413: this branch matches EVERY oauth-class
822        // provider's refusal, not just Claude's — a prior version
823        // hardcoded "No Claude account is bound" regardless of which
824        // provider the gate actually named.
825        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        // docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md
840        // — same "must not fall through to a dead-end Retry" reasoning as
841        // the MissingCredentials branch above, for the sibling
842        // AmbientHomeDirNotAllowed gate refusal (identity/resolver/errors.rs).
843        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        // docs/specs/SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md — ReAgent P2
873        // on PR #2854: this newer SpawnGateError variant had no
874        // classification branch, so it fell through to the generic
875        // UnknownNonZero "Agent failed" instead of the guidance its own
876        // Display text already carries. Same "must not fall through to a
877        // dead-end Retry" reasoning as its MissingCredentials/
878        // AmbientHomeDirNotAllowed siblings above.
879        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        // The gate's OTHER error variant — a task-join/panic failure, not a
946        // credentials problem — must not be swept into the same match.
947        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        // CLI reported an overload on stdout but exited 0 — must still
967        // classify, not fall through to NoOutput.
968        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
1012/// The one line of `text` that carries a single-live-instance refusal, with
1013/// any `[AgentMux] ` prefix removed — the row's detail. Falls back to the
1014/// whole trimmed text.
1015fn 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}