Skip to main content

orch/engine/runner/agents/
mod.rs

1//! Per-agent runner trait, error types, and agent registry.
2//!
3//! Each agent (Claude, Codex, OpenCode, Grok) has a different CLI invocation,
4//! output format, and error pattern. This module defines a common trait
5//! so the runner can delegate parsing and error classification to the
6//! correct implementation.
7
8pub mod claude;
9pub mod codex;
10pub mod grok;
11pub mod kimi;
12pub mod minimax;
13pub mod opencode;
14
15use crate::parser::AgentResponse;
16use std::path::PathBuf;
17use std::time::Duration;
18
19/// Unified permission rules that each agent translates into its native flags.
20///
21/// Instead of hardcoding `--permission-mode bypassPermissions` for Claude and
22/// `--sandbox workspace-write` for Codex, we define a single set of rules and
23/// let each `AgentRunner` format them for its CLI.
24#[derive(Debug, Clone)]
25pub struct PermissionRules {
26    /// Whether the agent runs fully autonomous (no user prompts).
27    pub autonomous: bool,
28    /// Sandbox level for filesystem access.
29    pub sandbox: SandboxLevel,
30    /// Tool patterns to disallow (e.g., `["Bash(rm *)", "Bash(rm -*)"]`).
31    pub disallowed_tools: Vec<String>,
32    /// Tools to allow (whitelist). Overrides `disallowed_tools` when non-empty.
33    ///
34    /// Items are either agent-native tool names (e.g., "Edit", "Write", "Bash")
35    /// or CLI command names (e.g., "git", "npm") which get translated to
36    /// agent-specific patterns (e.g., `Bash(git*)` for Claude).
37    pub allowed_tools: Vec<String>,
38    /// Paths where the agent is allowed to edit files (worktree path).
39    /// When set, Edit/Write tools are scoped to these paths only.
40    /// Set dynamically per invocation (not from config).
41    pub allowed_edit_paths: Vec<PathBuf>,
42    /// Restrict to orchestration-only operations — denies Read, Glob, Grep,
43    /// and Bash commands for browsing/reading files (ls, cat, find, etc.).
44    /// Agents translate this into their native permission model.
45    #[allow(dead_code)]
46    pub deny_read_only: bool,
47    /// Additional directories that must be writable (e.g. the git common dir
48    /// when running inside a worktree whose `.git` metadata lives outside the
49    /// sandbox root). Codex translates these into `--add-dir` flags; other
50    /// agents ignore the field.
51    pub extra_writable_dirs: Vec<PathBuf>,
52}
53
54/// Sandbox level — how much filesystem access the agent gets.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SandboxLevel {
57    /// Agent can only write within the workspace/worktree.
58    WorkspaceWrite,
59    /// Agent has full filesystem access (dangerous).
60    FullAccess,
61    /// No sandboxing (orch manages isolation externally).
62    None,
63}
64
65impl Default for PermissionRules {
66    fn default() -> Self {
67        Self {
68            autonomous: true,
69            sandbox: SandboxLevel::WorkspaceWrite,
70            disallowed_tools: vec![
71                "Bash(rm *)".to_string(),
72                "Bash(rm -*)".to_string(),
73                "Bash(git push*)".to_string(),
74            ],
75            allowed_tools: vec![],
76            allowed_edit_paths: vec![],
77            deny_read_only: false,
78            extra_writable_dirs: vec![],
79        }
80    }
81}
82
83impl PermissionRules {
84    /// Load permission rules from config, falling back to defaults.
85    pub fn from_config() -> Self {
86        let mut rules = Self::default();
87
88        if let Ok(mode) = crate::config::get("workflow.permissions.mode") {
89            rules.autonomous = mode != "supervised";
90        }
91
92        if let Ok(sandbox) = crate::config::get("workflow.permissions.sandbox") {
93            rules.sandbox = match sandbox.as_str() {
94                "full-access" | "danger-full-access" => SandboxLevel::FullAccess,
95                "none" => SandboxLevel::None,
96                _ => SandboxLevel::WorkspaceWrite,
97            };
98        }
99
100        if let Ok(tools) = crate::config::get("workflow.disallowed_tools") {
101            let parsed: Vec<String> = tools
102                .split(',')
103                .map(|s| s.trim().to_string())
104                .filter(|s| !s.is_empty())
105                .collect();
106            if !parsed.is_empty() {
107                rules.disallowed_tools = parsed;
108            }
109        }
110
111        if let Ok(tools) = crate::config::get_list("workflow.allowed_tools") {
112            rules.allowed_tools = tools;
113        }
114
115        rules
116    }
117
118    /// Restriction preset for control sessions: denies Read, Glob, Grep, and
119    /// Bash commands that read files or list directories (ls, cat, find, etc.).
120    ///
121    /// The agent remains autonomous but is prevented from browsing or reading
122    /// arbitrary files on the host machine. Translation into agent-native flags
123    /// is handled by each `AgentRunner::build_command()` implementation.
124    pub fn deny_read_only() -> Self {
125        Self {
126            autonomous: true,
127            sandbox: SandboxLevel::WorkspaceWrite,
128            disallowed_tools: vec![
129                "Bash(rm *)".to_string(),
130                "Bash(rm -*)".to_string(),
131                "Bash(git push*)".to_string(),
132                "Bash(ls *)".to_string(),
133                "Bash(ls)".to_string(),
134                "Bash(find *)".to_string(),
135                "Bash(cat *)".to_string(),
136                "Bash(head *)".to_string(),
137                "Bash(tail *)".to_string(),
138                "Bash(fzf *)".to_string(),
139                "Bash(less *)".to_string(),
140                "Bash(more *)".to_string(),
141                "Read".to_string(),
142                "Glob".to_string(),
143                "Grep".to_string(),
144            ],
145            allowed_tools: vec![],
146            allowed_edit_paths: vec![],
147            deny_read_only: true,
148            extra_writable_dirs: vec![],
149        }
150    }
151}
152
153/// Structured result from per-agent NDJSON extraction.
154///
155/// Each agent emits a different NDJSON format. The per-agent `find_result`
156/// functions parse agent-specific envelopes and return a unified `AgentResult`
157/// so callers (review pipeline, response handler) don't need format knowledge.
158#[derive(Debug, Clone)]
159#[allow(dead_code)] // fields read by tests and future phases
160pub struct AgentResult {
161    /// Whether the agent reported an error (e.g. `is_error: true` in Claude).
162    pub is_error: bool,
163    /// The extracted result text (inner content, stripped of envelope).
164    pub result_text: String,
165    /// Input tokens consumed (if reported by the agent).
166    pub input_tokens: Option<u64>,
167    /// Output tokens consumed (if reported by the agent).
168    pub output_tokens: Option<u64>,
169    /// Total cost in USD (if reported by the agent).
170    pub cost_usd: Option<f64>,
171    /// Wall-clock duration in milliseconds (if reported by the agent).
172    pub duration_ms: Option<u64>,
173    /// True when the provider cut the response short by exceeding its
174    /// output/reasoning token budget (e.g. opencode's `step_finish` event
175    /// reporting `reason: "length"`), as opposed to producing malformed
176    /// output. Callers use this to distinguish a token-budget fluke from a
177    /// genuinely format-incapable model.
178    pub truncated_by_length: bool,
179}
180
181/// Concatenate text from all assistant-turn messages in the agent's NDJSON
182/// output stream.  Used as an intermediate fallback when the `type:result`
183/// envelope text is not valid AgentResponse JSON but an earlier assistant
184/// message in the conversation might contain it.
185pub fn collect_assistant_messages_text(_agent: &str, ndjson: &str) -> String {
186    claude::collect_assistant_messages_text(ndjson)
187}
188
189/// Dispatch to the appropriate per-agent result extractor.
190///
191/// Returns `None` if no structured result could be found in the output
192/// (e.g. plain text, empty output, or unrecognized format).
193pub fn find_agent_result(agent: &str, ndjson: &str) -> Option<AgentResult> {
194    match agent {
195        "claude" | "kimi" | "minimax" => claude::find_claude_result(ndjson),
196        "grok" => grok::find_grok_result(ndjson),
197        "opencode" => opencode::find_opencode_result(ndjson),
198        "codex" => codex::find_codex_result(ndjson),
199        _ => claude::find_claude_result(ndjson),
200    }
201}
202
203/// Parsed response from an agent, including metadata extracted from the
204/// agent-specific output envelope.
205#[derive(Debug, Clone)]
206pub struct ParsedResponse {
207    /// Normalized task response (status, summary, accomplished, etc.)
208    pub response: AgentResponse,
209    /// Input tokens consumed (if reported by the agent).
210    pub input_tokens: Option<u64>,
211    /// Output tokens consumed (if reported by the agent).
212    pub output_tokens: Option<u64>,
213    /// Wall-clock duration in milliseconds (if reported by the agent).
214    pub duration_ms: Option<u64>,
215}
216
217/// Agent-specific error with enough detail for autonomous recovery.
218#[derive(Debug, Clone)]
219pub enum AgentError {
220    /// Rate/usage limit — reroute to different agent, cooldown current.
221    RateLimit { message: String },
222    /// Auth/billing/API key error — switch agent entirely.
223    Auth { message: String },
224    /// Requested model not available — try different model, then switch agent.
225    ModelUnavailable { message: String, model: String },
226    /// Context window exceeded — truncate and retry, then switch agent.
227    ContextOverflow { message: String },
228    /// Agent timed out — retry once, then switch agent.
229    Timeout { elapsed: Duration },
230    /// Required tool/binary missing from the environment.
231    MissingTool { tool: String },
232    /// Sandbox or filesystem permission denied.
233    PermissionDenied { message: String },
234    /// Agent is waiting for interactive input (e.g., 1Password, SSH).
235    WaitingForInput { message: String },
236    /// The agent's session ID is no longer valid (session expired or was cleared).
237    ///
238    /// Returned when Claude reports "No conversation found with session ID: <uuid>".
239    /// The caller should reset the stored UUID and retry with a fresh `--session-id`.
240    StaleSession { session_id: String },
241    /// Agent returned unparseable output.
242    InvalidResponse { raw: String },
243    /// Agent self-reported a failure in its response.
244    AgentFailed { message: String },
245    /// Transient network connectivity error — retry same agent, no failover.
246    NetworkError { message: String },
247    /// Thinking blocks cannot be modified (Claude extended-thinking + tool-use 400).
248    ///
249    /// Transient API incompatibility inside the Claude CLI's multi-turn tool-use
250    /// loop — the Anthropic API rejects the request when thinking blocks are altered
251    /// between turns. Not a model capacity problem: does NOT increment failure counts
252    /// or apply cooldowns. The task is rerouted to a different agent.
253    ThinkingBlockConflict { message: String },
254    /// Unclassified error.
255    Unknown { exit_code: i32, message: String },
256}
257
258impl std::fmt::Display for AgentError {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        match self {
261            Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
262            Self::Auth { message } => write!(f, "auth error: {message}"),
263            Self::ModelUnavailable { message, model } => {
264                write!(f, "model unavailable ({model}): {message}")
265            }
266            Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
267            Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
268            Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
269            Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
270            Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
271            Self::StaleSession { session_id } => {
272                write!(f, "stale session (session expired): {session_id}")
273            }
274            Self::InvalidResponse { raw } => {
275                let end = truncate_at_char_boundary(raw, 200);
276                write!(f, "invalid response: {}", &raw[..end])
277            }
278            Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
279            Self::NetworkError { message } => write!(f, "network error: {message}"),
280            Self::ThinkingBlockConflict { message } => {
281                write!(f, "thinking block conflict: {message}")
282            }
283            Self::Unknown { exit_code, message } => {
284                write!(f, "unknown error (exit {exit_code}): {message}")
285            }
286        }
287    }
288}
289
290impl std::error::Error for AgentError {}
291
292/// Return the variant name of an `AgentError` as a static string,
293/// for structured logging and `result.json` output.
294pub fn error_class_name(err: &AgentError) -> &'static str {
295    match err {
296        AgentError::RateLimit { .. } => "RateLimit",
297        AgentError::Auth { .. } => "Auth",
298        AgentError::ModelUnavailable { .. } => "ModelUnavailable",
299        AgentError::ContextOverflow { .. } => "ContextOverflow",
300        AgentError::Timeout { .. } => "Timeout",
301        AgentError::MissingTool { .. } => "MissingTool",
302        AgentError::PermissionDenied { .. } => "PermissionDenied",
303        AgentError::WaitingForInput { .. } => "WaitingForInput",
304        AgentError::StaleSession { .. } => "StaleSession",
305        AgentError::InvalidResponse { .. } => "InvalidResponse",
306        AgentError::AgentFailed { .. } => "AgentFailed",
307        AgentError::NetworkError { .. } => "NetworkError",
308        AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
309        AgentError::Unknown { .. } => "Unknown",
310    }
311}
312
313/// Extract file paths from free-form text.
314///
315/// Matches common source file patterns like `src/foo/bar.rs`, `tests/x.py`,
316/// `path/to/file.ts`, etc. Deduplicates and returns them sorted.
317fn extract_file_paths(text: &str) -> Vec<String> {
318    // Match path-like tokens: optional leading slash or no slash, at least one
319    // directory segment or a plain filename, with a recognised extension.
320    let extensions = [
321        "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
322        "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
323    ];
324    let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
325    for token in text.split_whitespace() {
326        // Strip surrounding punctuation (backticks, quotes, parens, commas).
327        // We intentionally do NOT strip '.' here because it is part of file extensions.
328        let token = token.trim_matches(|c: char| {
329            matches!(
330                c,
331                '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
332            )
333        });
334        // Strip a lone trailing dot that ends a sentence (e.g. "src/foo.rs.").
335        // Only strip if the result still has a recognised extension — this avoids
336        // accidentally stripping the final dot of "src/foo.rs" when there is none.
337        let token = if let Some(without) = token.strip_suffix('.') {
338            let still_has_ext = extensions
339                .iter()
340                .any(|ext| without.ends_with(&format!(".{ext}")));
341            if still_has_ext {
342                without
343            } else {
344                token
345            }
346        } else {
347            token
348        };
349        // Must contain at least one slash (directory separator) or look like a
350        // root-relative path, and end with a known extension.
351        let has_ext = extensions
352            .iter()
353            .any(|ext| token.ends_with(&format!(".{ext}")));
354        if !has_ext {
355            continue;
356        }
357        // Accept tokens with at least one path separator, or starting with a
358        // known top-level directory name common in Rust/JS/Python projects.
359        let path_like = token.contains('/')
360            || token.starts_with("src")
361            || token.starts_with("tests")
362            || token.starts_with("lib")
363            || token.starts_with("bin")
364            || token.starts_with("prompts")
365            || token.starts_with("migrations");
366        if path_like && token.len() >= 4 && !token.contains("://") {
367            found.insert(token.to_string());
368        }
369    }
370    found.into_iter().collect()
371}
372
373/// Split free-form text into accomplished and remaining bullet lists.
374///
375/// Lines under "remaining", "todo", "next steps", "still needed" headings are
376/// classified as `remaining`; all other bullet lines go into `accomplished`.
377fn extract_bullet_sections(text: &str) -> (Vec<String>, Vec<String>) {
378    let remaining_headers = [
379        "remaining",
380        "todo",
381        "to do",
382        "next steps",
383        "still needed",
384        "left to do",
385        "not done",
386        "outstanding",
387    ];
388
389    let mut accomplished: Vec<String> = vec![];
390    let mut remaining: Vec<String> = vec![];
391    let mut in_remaining_section = false;
392
393    for line in text.lines() {
394        let trimmed = line.trim();
395        if trimmed.is_empty() {
396            continue;
397        }
398        let lower = trimmed.to_lowercase();
399
400        // Detect section headings: only lines with explicit heading markers.
401        // Require trailing ':' or '#'-prefix to avoid misclassifying continuation
402        // lines (e.g. explanatory text after a bullet) as headings, which would
403        // incorrectly reset `in_remaining_section`.
404        let is_heading = trimmed.ends_with(':') || trimmed.starts_with('#');
405
406        if is_heading {
407            in_remaining_section = remaining_headers.iter().any(|h| lower.contains(h));
408            continue;
409        }
410
411        // Bullet lines: -, *, or numbered (1., 2.)
412        let is_bullet = trimmed.starts_with('-')
413            || trimmed.starts_with('*')
414            || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.');
415
416        if !is_bullet {
417            continue;
418        }
419
420        // Strip leading bullet character(s).
421        let content = trimmed
422            .trim_start_matches(|c: char| c == '-' || c == '*' || c.is_ascii_digit() || c == '.')
423            .trim()
424            .to_string();
425
426        if content.is_empty() {
427            continue;
428        }
429
430        if in_remaining_section {
431            remaining.push(content);
432        } else {
433            accomplished.push(content);
434        }
435    }
436
437    (accomplished, remaining)
438}
439
440/// Detect if text is a CLI/arg-parser diagnostic rather than an agent response.
441/// CLI diagnostics indicate process startup failure, not an agent answer.
442fn is_cli_parser_diagnostic(text: &str) -> bool {
443    let lower = text.to_lowercase();
444
445    // Clap-style error messages
446    if lower.contains("error: unexpected argument") {
447        return true;
448    }
449
450    // Usage/help text patterns
451    if text.lines().any(|line| {
452        let trimmed = line.trim();
453        trimmed.starts_with("Usage:") || trimmed.starts_with("usage:")
454    }) {
455        return true;
456    }
457
458    // Clap help/info hints
459    if lower.contains("for more information, try '--help'") {
460        return true;
461    }
462
463    // Clap "tip" messages about passing values
464    if lower.contains("tip: to pass '") {
465        return true;
466    }
467
468    false
469}
470
471/// Detect whether `text` looks like an NDJSON event stream emitted by an
472/// agent CLI (opencode/claude/codex). Returns true when the first non-empty
473/// line parses as a JSON object containing a `type` field with one of the
474/// known event-type strings.
475///
476/// This guard prevents raw NDJSON from being interpreted as agent prose and
477/// leaking into notification summaries.
478fn looks_like_ndjson_event_stream(text: &str) -> bool {
479    let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
480        return false;
481    };
482
483    if !first_line.starts_with('{') {
484        return false;
485    }
486
487    let Ok(value) = serde_json::from_str::<serde_json::Value>(first_line) else {
488        return false;
489    };
490
491    let Some(event_type) = value.get("type").and_then(|v| v.as_str()) else {
492        return false;
493    };
494
495    matches!(
496        event_type,
497        "step_start"
498            | "step_finish"
499            | "tool_use"
500            | "tool_result"
501            | "assistant"
502            | "system"
503            | "message"
504            | "text"
505    )
506}
507
508/// Build a synthetic agent response from plain text when structured parsing fails.
509///
510/// Returns `None` for empty/whitespace-only text so callers can preserve the
511/// original `InvalidResponse` behavior.
512pub fn synthesize_response_from_text(text: &str) -> Option<AgentResponse> {
513    let trimmed = text.trim();
514    if trimmed.is_empty() {
515        return None;
516    }
517
518    // Detect CLI/arg-parser diagnostics and reject them. These indicate the agent
519    // process failed to start properly (e.g. invalid flag), not that the agent
520    // produced a free-form answer. Clap-based CLIs emit patterns like:
521    //   "error: unexpected argument '--flag' found"
522    //   "Usage: command [OPTIONS]"
523    //   "For more information, try '--help'"
524    //   "tip: to pass '...' as a value, use '-- ...'"
525    if is_cli_parser_diagnostic(trimmed) {
526        return None;
527    }
528
529    // Detect NDJSON event streams (opencode/claude/codex stdout) and reject
530    // them. Raw NDJSON masquerading as prose would otherwise leak into
531    // notification summaries — the "completed" substring inside tool state
532    // JSON triggers a false "done" classification and the first 500 bytes of
533    // NDJSON become the user-visible summary.
534    if looks_like_ndjson_event_stream(trimmed) {
535        return None;
536    }
537
538    let lower = trimmed.to_lowercase();
539    // Also accept plain-language confirmations of performed actions (issue/PR
540    // creation, comments, etc.) as a successful "done" signal. This prevents
541    // tasks from being retried when an agent reports success in free-form
542    // text instead of returning structured JSON.
543    // Returns true when `word` appears as a whole word in `text`
544    // (not preceded or followed by an ASCII alphanumeric character).
545    fn contains_word(text: &str, word: &str) -> bool {
546        let mut start = 0;
547        while let Some(pos) = text[start..].find(word) {
548            let abs = start + pos;
549            let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
550            let after_ok = abs + word.len() >= text.len()
551                || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
552            if before_ok && after_ok {
553                return true;
554            }
555            start = abs + 1;
556            if start >= text.len() {
557                break;
558            }
559        }
560        false
561    }
562
563    // Negation phrases that override a "done/completed" match.
564    let looks_negative = [
565        "not done",
566        "not yet done",
567        "not complete",
568        "not yet complete",
569        "not completed",
570        "not yet completed",
571        "not committed",
572        "incomplete",
573        "undone",
574    ]
575    .iter()
576    .any(|needle| lower.contains(needle));
577
578    // Strong explicit completion phrases. These are high-confidence indicators
579    // that the agent performed an action (filed issues, created commits, etc.).
580    let explicit_done = [
581        "nothing to",
582        "nothing to do",
583        "nothing to execute",
584        "no positions",
585        "no open positions",
586        "no trades",
587        "no trade",
588        "no action needed",
589        "no changes to commit",
590        "completed",
591        // Action/issue completion phrases
592        "filed",
593        "filed issue",
594        "filed issues",
595        "created issue",
596        "created issues",
597        "opened issue",
598        "opened issues",
599        "issue created",
600        "issues created",
601        "issues filed",
602        "task created",
603        "posted comment",
604        "comment posted",
605        "changes committed",
606        "commit created",
607        "fixed.",
608        "has been fixed",
609        "the fix has been applied",
610        "the fix has already been applied",
611        // Agent completion phrases (regression: #1362/#1363)
612        "the fix is complete",
613        "fix is working",
614        "all tests pass",
615        "tests pass.",
616    ]
617    .iter()
618    .any(|needle| lower.contains(needle));
619
620    // Conservative sentence-level heuristics for single-word signals like "done".
621    // Accept "done" only when it appears in a short, self-contained sentence
622    // without hedging language ("let me", "checking", "will", etc.). This
623    // reduces false-positives from exploratory prose that mentions "done"
624    // as part of a planning or investigatory sentence.
625    let mut done_confident = false;
626    for sentence in trimmed
627        .split(['.', '!', '?', '\n'])
628        .map(|s| s.trim())
629        .filter(|s| !s.is_empty())
630    {
631        let s_lower = sentence.to_lowercase();
632        // short sentence heuristic
633        let word_count = sentence.split_whitespace().count();
634        let hedging = [
635            "let",
636            "let me",
637            "check",
638            "checking",
639            "will",
640            "i'll",
641            "i will",
642            "trying",
643            "try",
644            "investigat",
645            "maybe",
646            "could",
647            "should",
648            "might",
649            "if ",
650            "plan",
651            "planning",
652        ];
653        let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
654
655        if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
656            done_confident = true;
657            break;
658        }
659
660        // Commit/concrete action heuristics: require explicit commit language
661        // rather than opportunistic mentions. Examples: "I've committed the changes",
662        // "changes committed", or "commit created".
663        if s_lower.contains("committed") || s_lower.contains("commit created") {
664            // Accept only if sentence also suggests the changes were actually written
665            if s_lower.contains("changes")
666                || s_lower.contains("i've")
667                || s_lower.contains("i have")
668                || s_lower.contains("the commit")
669            {
670                done_confident = true;
671                break;
672            }
673        }
674
675        // Past-tense action verb at sentence start indicates a completed action
676        // (e.g. "Added detect_rate_limit patterns", "Fixed the nil-pointer bug").
677        // Accept only for short sentences without hedging language to avoid matching
678        // prose like "I added a note that this might fail later".
679        let past_tense_actions = [
680            "added ",
681            "fixed ",
682            "updated ",
683            "implemented ",
684            "created ",
685            "removed ",
686            "changed ",
687            "deleted ",
688            "applied ",
689            "resolved ",
690            "patched ",
691            "refactored ",
692            "merged ",
693            "pushed ",
694            "deployed ",
695        ];
696        if !contains_hedge
697            && word_count <= 12
698            && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
699        {
700            done_confident = true;
701            break;
702        }
703    }
704
705    let looks_done = !looks_negative && (explicit_done || done_confident);
706
707    let looks_error = [
708        "error:",
709        "failed to",
710        "cannot",
711        "permission denied",
712        "unable to",
713    ]
714    .iter()
715    .any(|needle| lower.contains(needle));
716
717    let status = if looks_error {
718        "needs_review"
719    } else if looks_done {
720        "done"
721    } else if looks_negative {
722        // Agent explicitly stated the task is not complete — surface for human review.
723        "needs_review"
724    } else {
725        // Text is ambiguous: no error signal, no completion signal, no explicit negation.
726        // This happens when agents emit exploratory or startup text (e.g. "Let's go!",
727        // "I need to find where...") and exit without producing structured JSON.
728        // Returning None causes callers to treat this as an invalid response and reroute
729        // the task rather than falsely advancing it to needs_review / in_review.
730        return None;
731    };
732
733    let summary_end = truncate_at_char_boundary(trimmed, 500);
734    let summary = trimmed[..summary_end].to_string();
735    let error =
736        (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
737
738    // Extract structured fields from free-form text.
739    let files = extract_file_paths(trimmed);
740    let (accomplished, remaining) = extract_bullet_sections(trimmed);
741
742    Some(AgentResponse {
743        status: status.to_string(),
744        summary,
745        accomplished,
746        remaining,
747        files,
748        error,
749        input_tokens: None,
750        output_tokens: None,
751        learnings: vec![],
752        delegations: vec![],
753    })
754}
755
756/// Quote a string for safe insertion into a POSIX shell command.
757pub(crate) fn shell_single_quote(value: &str) -> String {
758    format!("'{}'", value.replace('\'', "'\\''"))
759}
760
761/// Find the largest byte index <= `max_bytes` that lies on a UTF-8 char
762/// boundary.  Used for safe string truncation in error messages.
763pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
764    if max_bytes >= s.len() {
765        return s.len();
766    }
767    // Walk backwards from max_bytes until we hit a char boundary
768    let mut end = max_bytes;
769    while end > 0 && !s.is_char_boundary(end) {
770        end -= 1;
771    }
772    end
773}
774
775/// One step of an agent's work, as `orch stream` shows it
776#[derive(Debug, Clone, PartialEq)]
777pub enum StreamEvent {
778    Started {
779        model: String,
780    },
781    Text(String),
782    Thinking(String),
783    /// `id` links the call to its result, empty when the agent has none
784    ToolCall {
785        id: String,
786        name: String,
787        input: serde_json::Value,
788    },
789    /// `name` is empty when only `id` names the call
790    ToolResult {
791        id: String,
792        name: String,
793        output: String,
794        is_error: bool,
795    },
796    Done(String),
797    Error(String),
798}
799
800/// String at a JSON pointer, empty when missing
801pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
802    v.pointer(pointer)
803        .and_then(serde_json::Value::as_str)
804        .unwrap_or("")
805}
806
807/// Error text from any of the shapes agents use: `message`, `error`, `error.message`, `error.data.message`
808pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
809    [
810        "/message",
811        "/error",
812        "/error/message",
813        "/error/data/message",
814    ]
815    .iter()
816    .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
817    .unwrap_or("unknown error")
818    .to_string()
819}
820
821/// Router stdout shape. Each runner reports its own
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum RouterTranscript {
824    /// Claude Messages NDJSON (`type:result`)
825    Messages,
826    /// OpenCode NDJSON (`type:text`, `step_*`)
827    OpenCode,
828    /// Stdout is used as-is
829    Raw,
830}
831
832/// Per-agent runner trait.
833///
834/// Each agent implements this to handle its specific CLI invocation,
835/// output parsing, and error classification.
836pub trait AgentRunner: Send + Sync {
837    /// Agent name (e.g., "claude", "codex", "opencode"). Used in tests.
838    #[cfg(test)]
839    fn name(&self) -> &str;
840
841    /// Build the CLI command string for the legacy tmux runner.
842    ///
843    /// The returned string is embedded into a shell command that captures
844    /// output in the tmux session. The `permissions` struct carries unified
845    /// rules that each agent translates to its own CLI flags.
846    fn build_command(
847        &self,
848        model: Option<&str>,
849        timeout_cmd: &str,
850        sys_file: &str,
851        msg_file: &str,
852        permissions: &PermissionRules,
853    ) -> String;
854
855    /// Parse raw stdout into a ParsedResponse.
856    ///
857    /// Returns `Ok(ParsedResponse)` on success, `Err(AgentError)` if the
858    /// output indicates an error or cannot be parsed.
859    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
860
861    /// Extract the raw inner text from an agent output envelope, without
862    /// attempting to parse it as an `AgentResponse`.
863    ///
864    /// Use this in the review pipeline so that a `ReviewResponse` JSON can be
865    /// recovered even when the text doesn't match the task `AgentResponse` schema.
866    ///
867    /// - For Claude/Kimi/MiniMax: strips the `--output-format stream-json` NDJSON
868    ///   wrapper and returns the `"result"` field of the final `"type":"result"` line.
869    /// - For Codex: returns the text of the last `agent_message` item.
870    /// - For OpenCode: concatenates all `text` events.
871    /// - Default: returns `raw` unchanged (safe fallback for unknown agents).
872    ///
873    /// Returns `Err(AgentError)` only for terminal errors (rate limit, auth, etc.)
874    /// that should abort the review pipeline entirely.
875    #[allow(dead_code)] // used by per-agent unit tests; production uses find_agent_result
876    fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
877        Ok(raw.to_string())
878    }
879
880    /// Transcript steps for one parsed line of this agent's NDJSON output
881    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent>;
882
883    /// Classify an error from exit code + stdout + stderr into an AgentError.
884    ///
885    /// Called when the agent process exits with a non-zero code, or when
886    /// parse_response fails to find a valid result.
887    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError;
888
889    /// Classify error with elapsed session time (for accurate timeout reporting).
890    fn classify_error_with_elapsed(
891        &self,
892        exit_code: i32,
893        stdout: &str,
894        stderr: &str,
895        elapsed_secs: Option<u64>,
896    ) -> AgentError {
897        let mut err = self.classify_error(exit_code, stdout, stderr);
898        if let AgentError::Timeout { elapsed } = &mut err {
899            if let Some(actual_elapsed) = elapsed_secs {
900                *elapsed = Duration::from_secs(actual_elapsed);
901            }
902        }
903        err
904    }
905
906    /// Models available for this agent (for intra-agent failover).
907    fn available_models(&self) -> Vec<String> {
908        vec![]
909    }
910
911    /// Free/fallback models for last-resort failover.
912    fn free_models(&self) -> Vec<String> {
913        vec![]
914    }
915
916    /// Router stdout shape. The router matches on this instead of agent names
917    fn router_transcript(&self) -> RouterTranscript {
918        RouterTranscript::Raw
919    }
920
921    /// Build a minimal CLI command for LLM-based routing.
922    ///
923    /// The command should run the agent with `prompt` as the sole task and
924    /// return structured (JSON or NDJSON) output on stdout. Used by the router
925    /// to classify tasks without launching a full agent session.
926    fn router_command(
927        &self,
928        prompt: &str,
929        model: Option<&str>,
930    ) -> anyhow::Result<tokio::process::Command>;
931}
932
933/// Agents whose CLI takes `--session-id` for a new session and `--resume` to continue it
934pub fn supports_session(agent: &str) -> bool {
935    matches!(agent, "claude" | "kimi" | "minimax" | "grok")
936}
937
938/// Get the appropriate AgentRunner implementation for an agent name.
939pub fn get_runner(agent_name: &str) -> Box<dyn AgentRunner> {
940    // Agents that are known to be claude-compatible (same NDJSON output format)
941    const CLAUDE_COMPATIBLE_AGENTS: &[&str] = &["claude", "kimi", "minimax", "olm", "glm"];
942
943    match agent_name {
944        "claude" => Box::new(claude::ClaudeRunner::new(agent_name)),
945        "kimi" => Box::new(kimi::KimiClaudeRunner::new()),
946        "minimax" => Box::new(minimax::MiniMaxClaudeRunner::new(agent_name)),
947        "codex" => Box::new(codex::CodexRunner),
948        "opencode" => Box::new(opencode::OpenCodeRunner::new()),
949        "grok" => Box::new(grok::GrokRunner),
950        // Unknown agents fall back to Claude-compatible parsing
951        other => {
952            // Only warn if the agent is not known to be claude-compatible
953            if !CLAUDE_COMPATIBLE_AGENTS.contains(&other) {
954                tracing::warn!(
955                    agent = other,
956                    "unknown agent, using claude-compatible runner"
957                );
958            }
959            Box::new(claude::ClaudeRunner::new(other))
960        }
961    }
962}
963
964/// Parse an NDJSON stream into a list of JSON values.
965///
966/// Shared by all agents that emit NDJSON output (Codex, OpenCode, etc.).
967/// Lines that are empty or unparseable are silently skipped with a debug log.
968///
969/// For fragmented streams where complete JSON objects span multiple lines,
970/// we also attempt to concatenate non-empty lines and parse them as a stream
971/// of JSON objects to handle edge cases where models emit partial NDJSON.
972pub(crate) fn parse_ndjson(raw: &str) -> Vec<serde_json::Value> {
973    // Fast path: try parsing each line individually (handles most cases)
974    let events: Vec<serde_json::Value> = raw
975        .lines()
976        .filter(|line| !line.trim().is_empty())
977        .filter_map(|line| match serde_json::from_str(line) {
978            Ok(val) => Some(val),
979            Err(e) => {
980                tracing::debug!(line, error = %e, "skipping unparseable NDJSON line");
981                None
982            }
983        })
984        .collect();
985
986    // If we got events, return them. Otherwise, try the accumulation fallback
987    // for edge cases where models emit fragmented JSON (e.g., partial objects
988    // that get split across lines, or streams with embedded newlines in strings).
989    if !events.is_empty() {
990        return events;
991    }
992
993    // Fallback: accumulate all non-empty, non-comment lines and try to extract
994    // JSON objects by scanning for balanced braces. This handles edge cases like:
995    // - Closing fence inside JSON strings: {"text": "```json\n{\"status\": \"done\"}\n```"}
996    // - Fragmented NDJSON where incomplete objects span multiple lines
997    // - Model outputs with embedded newlines in text fields
998    let acc: String = raw
999        .lines()
1000        .filter(|line| {
1001            let t = line.trim();
1002            !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
1003        })
1004        .collect::<Vec<_>>()
1005        .join(" ");
1006
1007    if acc.is_empty() {
1008        return Vec::new();
1009    }
1010
1011    // Fallback: try to parse the accumulated text directly as a JSON value.
1012    // This handles multi-line JSON objects that don't fit on single lines.
1013    if let Ok(val) = serde_json::from_str::<serde_json::Value>(acc.trim()) {
1014        // Check if stripping ALL whitespace makes it a single JSON object with
1015        // the same content. If so, the input was plain multi-line JSON (not
1016        // fragmented NDJSON), and we should return empty to preserve original
1017        // behavior — callers then treat it as plain text.
1018        let stripped: String = acc.chars().filter(|c| !c.is_ascii_whitespace()).collect();
1019        if let Ok(stripped_val) = serde_json::from_str::<serde_json::Value>(&stripped) {
1020            // Only return empty if the stripped version parses identically,
1021            // meaning whitespace was insignificant. If they differ, whitespace
1022            // matters and this is likely fragmented input.
1023            if stripped_val == val {
1024                return Vec::new();
1025            }
1026        }
1027        return vec![val];
1028    }
1029
1030    // Direct parse failed — try to extract JSON objects by scanning for balanced
1031    // braces. This handles cases like embedded JSON within text:
1032    // `{"text": "```json\n{\"status\": \"done\"}\n```"}`
1033    extract_json_objects(&acc)
1034}
1035
1036/// Extract complete JSON objects from a string by scanning for balanced braces.
1037///
1038/// This is a fallback for fragmented NDJSON streams where complete objects
1039/// don't appear on single lines. Returns all parseable objects found.
1040fn extract_json_objects(text: &str) -> Vec<serde_json::Value> {
1041    let mut results = Vec::new();
1042    let mut start: Option<usize> = None;
1043    let mut depth: i32 = 0;
1044    let mut in_string = false;
1045    let mut escape = false;
1046
1047    for (idx, ch) in text.char_indices() {
1048        if let Some(start_idx) = start {
1049            if in_string {
1050                if escape {
1051                    escape = false;
1052                } else if ch == '\\' {
1053                    escape = true;
1054                } else if ch == '"' {
1055                    in_string = false;
1056                }
1057                continue;
1058            }
1059
1060            match ch {
1061                '"' => in_string = true,
1062                '{' => depth += 1,
1063                '}' => {
1064                    depth -= 1;
1065                    if depth == 0 {
1066                        let candidate = &text[start_idx..=idx];
1067                        if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
1068                            results.push(val);
1069                        }
1070                        start = None;
1071                    }
1072                }
1073                _ => {}
1074            }
1075            continue;
1076        }
1077
1078        if ch == '{' {
1079            start = Some(idx);
1080            depth = 1;
1081            in_string = false;
1082            escape = false;
1083        }
1084    }
1085
1086    results
1087}
1088
1089/// Generate a complete, delegating `AgentRunner` impl for a wrapper struct.
1090///
1091/// Use this for runner types that delegate every `AgentRunner` method to an
1092/// inner field (e.g., `KimiClaudeRunner → MiniMaxClaudeRunner`).
1093///
1094/// ```ignore
1095/// delegate_agent_runner!(KimiClaudeRunner, inner);
1096/// ```
1097macro_rules! delegate_agent_runner {
1098    ($runner:ty, $inner:ident) => {
1099        impl AgentRunner for $runner {
1100            #[cfg(test)]
1101            fn name(&self) -> &str {
1102                self.$inner.name()
1103            }
1104
1105            fn build_command(
1106                &self,
1107                model: Option<&str>,
1108                timeout_cmd: &str,
1109                sys_file: &str,
1110                msg_file: &str,
1111                permissions: &PermissionRules,
1112            ) -> String {
1113                self.$inner
1114                    .build_command(model, timeout_cmd, sys_file, msg_file, permissions)
1115            }
1116
1117            fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
1118                self.$inner.parse_response(raw)
1119            }
1120
1121            fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
1122                self.$inner.extract_text(raw)
1123            }
1124
1125            fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
1126                self.$inner.classify_error(exit_code, stdout, stderr)
1127            }
1128
1129            fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
1130                self.$inner.stream_events(event)
1131            }
1132
1133            fn router_transcript(&self) -> crate::engine::runner::agents::RouterTranscript {
1134                self.$inner.router_transcript()
1135            }
1136
1137            fn router_command(
1138                &self,
1139                prompt: &str,
1140                model: Option<&str>,
1141            ) -> anyhow::Result<tokio::process::Command> {
1142                self.$inner.router_command(prompt, model)
1143            }
1144        }
1145    };
1146}
1147
1148pub(crate) use delegate_agent_runner;
1149
1150/// Shared error pattern detection utilities used by multiple agent runners.
1151pub(crate) mod patterns {
1152    use super::AgentError;
1153    use std::time::Duration;
1154
1155    /// Returns true if the pattern found at `lower_pos` (byte offset in lowercase text)
1156    /// is at a word boundary in the original `text` — i.e., surrounded by non-identifier
1157    /// chars. Used to suppress false positives from identifiers like `record_rate_limit_returns_id`.
1158    fn is_word_boundary(text: &str, lower_pos: usize, pattern: &str) -> bool {
1159        let after_match = lower_pos + pattern.len();
1160        let prev_is_boundary = lower_pos == 0
1161            || !text[..lower_pos]
1162                .chars()
1163                .last()
1164                .is_some_and(|c| c.is_alphanumeric() || c == '_');
1165        let next_is_boundary = after_match >= text.len()
1166            || !text[after_match..]
1167                .chars()
1168                .next()
1169                .is_some_and(|c| c.is_alphanumeric() || c == '_');
1170        prev_is_boundary && next_is_boundary
1171    }
1172
1173    fn lower_offset_to_original_byte(text: &str, lower: &str, lower_pos: usize) -> usize {
1174        let char_idx = lower[..lower_pos].chars().count();
1175        text.char_indices()
1176            .nth(char_idx)
1177            .map_or(text.len(), |(i, _)| i)
1178    }
1179
1180    fn line_bounds(text: &str, byte_pos: usize) -> (usize, usize) {
1181        let start = text[..byte_pos].rfind('\n').map_or(0, |idx| idx + 1);
1182        let end = text[byte_pos..]
1183            .find('\n')
1184            .map_or(text.len(), |idx| byte_pos + idx);
1185        (start, end)
1186    }
1187
1188    fn is_markdown_table_cell(text: &str, byte_pos: usize, pattern: &str) -> bool {
1189        let (line_start, line_end) = line_bounds(text, byte_pos);
1190        let line = &text[line_start..line_end];
1191        let trimmed = line.trim_start();
1192        if !(trimmed.starts_with('|')
1193            || trimmed.starts_with("+|")
1194            || trimmed.starts_with("-|")
1195            || trimmed.starts_with(">|"))
1196        {
1197            return false;
1198        }
1199        if line.matches('|').count() < 2 {
1200            return false;
1201        }
1202
1203        let rel_pos = byte_pos.saturating_sub(line_start);
1204        let after_match = rel_pos + pattern.len();
1205        let prev_is_pipe = line[..rel_pos]
1206            .chars()
1207            .rev()
1208            .find(|c| !c.is_whitespace())
1209            .is_some_and(|c| c == '|');
1210        let next_is_pipe = line[after_match..]
1211            .chars()
1212            .find(|c| !c.is_whitespace())
1213            .is_some_and(|c| c == '|');
1214        prev_is_pipe && next_is_pipe
1215    }
1216
1217    /// Check for rate limit / usage limit patterns in text.
1218    pub fn detect_rate_limit(text: &str) -> Option<AgentError> {
1219        let lower = text.to_lowercase();
1220        let patterns = [
1221            "rate limit",
1222            "rate_limit",
1223            "ratelimit",
1224            "rate increased too quickly", // Alibaba / Qwen via OpenCode
1225            "too many requests",
1226            "usage limit",
1227            "session limit", // catches "You've hit your session limit · resets …"
1228            "weekly limit",  // catches "You've hit your weekly limit · resets …"
1229            "quota exceeded",
1230            "monthly quota", // GitHub Copilot: "You have exceeded your monthly quota"
1231            "overloaded",
1232            "capacity",
1233            "throttled",
1234            "insufficient_quota",
1235            "tokens_exceeded",
1236            "you've hit your usage limit",
1237            "resourceexhausted", // Nvidia / gRPC provider capacity
1238            "worker local total request limit reached", // Nvidia nemotron via OpenCode
1239        ];
1240        // Find the earliest valid match position so we can extract context around
1241        // the actual error message rather than the tail (which may be unrelated
1242        // JSON or markdown work product). Map the byte offset from `lower` back
1243        // to `text` via char-count to avoid using a lowercased byte index on the
1244        // original string (Unicode case-folding can change byte lengths, e.g.
1245        // İ→i̇).
1246        let match_info = patterns
1247            .iter()
1248            .filter_map(|pattern| lower.find(pattern).map(|lower_pos| (*pattern, lower_pos)))
1249            .filter_map(|(pattern, lower_pos)| {
1250                let byte_pos = lower_offset_to_original_byte(text, &lower, lower_pos);
1251                if matches!(pattern, "rate_limit" | "ratelimit")
1252                    && !is_word_boundary(text, byte_pos, pattern)
1253                {
1254                    return None;
1255                }
1256
1257                if matches!(pattern, "rate_limit" | "ratelimit")
1258                    && is_markdown_table_cell(text, byte_pos, pattern)
1259                {
1260                    return None;
1261                }
1262                Some((pattern, byte_pos))
1263            })
1264            .min_by_key(|(_, byte_pos)| *byte_pos);
1265        let has_429 = lower.contains("429");
1266        // HTTP 529 is used by Cloudflare for rate limiting. Only match in HTTP status
1267        // contexts (e.g. "HTTP 529", "status: 529") to avoid false positives from bare
1268        // numbers like line numbers, port numbers, or file sizes.
1269        let has_529 = lower.contains("http 529")
1270            || lower.contains("529 service")
1271            || (lower.find(": 529").is_some_and(|pos| {
1272                !lower[pos + 5..]
1273                    .chars()
1274                    .next()
1275                    .is_some_and(|c| c.is_ascii_digit())
1276            }));
1277
1278        if match_info.is_some() || has_429 || has_529 {
1279            let message = if let Some((_, pos)) = match_info {
1280                extract_context_around(text, pos, 300)
1281            } else {
1282                safe_tail(text, 300).to_string()
1283            };
1284            return Some(AgentError::RateLimit { message });
1285        }
1286        None
1287    }
1288
1289    /// Extract up to `window` bytes of context centred around `byte_pos`.
1290    ///
1291    /// `byte_pos` must be a valid byte offset in `text` (not derived from a
1292    /// transformed copy).  Both ends are snapped to char boundaries to avoid
1293    /// splitting multi-byte codepoints.
1294    fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1295        let half = window / 2;
1296        let start = byte_pos.saturating_sub(half);
1297        let end = (byte_pos + window).min(text.len());
1298        // Align both ends to char boundaries.
1299        let mut s = start;
1300        while s < text.len() && !text.is_char_boundary(s) {
1301            s += 1;
1302        }
1303        let mut e = end;
1304        while e < text.len() && !text.is_char_boundary(e) {
1305            e += 1;
1306        }
1307        text[s..e].to_string()
1308    }
1309
1310    /// Returns true if `lower` contains the HTTP status `code` (e.g. "401") as a
1311    /// standalone number — not as part of a larger digit sequence like `4010292`.
1312    ///
1313    /// Matches:
1314    ///   - `"http 401"` / `"http/1.1 401"`
1315    ///   - `"401 unauthorized"` / `"401\n"` / `"401"` at end-of-string
1316    ///   - `": 401"` when not immediately followed by another digit
1317    pub fn contains_http_status(lower: &str, code: &str) -> bool {
1318        // Fast prefix checks that are inherently unambiguous.
1319        if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1320            return true;
1321        }
1322        // ": NNN" — only accept when the next character is not a digit.
1323        let needle = format!(": {code}");
1324        let mut start = 0;
1325        while let Some(rel) = lower[start..].find(needle.as_str()) {
1326            let after = start + rel + needle.len();
1327            match lower[after..].chars().next() {
1328                Some(c) if c.is_ascii_digit() => {
1329                    // Part of a longer number (e.g. ": 4010292") — skip.
1330                    start = after;
1331                }
1332                _ => return true,
1333            }
1334        }
1335        false
1336    }
1337
1338    /// Check for auth / billing error patterns in text.
1339    ///
1340    /// Returns the line containing the match (not the tail) so the stored
1341    /// error message is the actual error string rather than trailing output
1342    /// (e.g. NDJSON session metadata that agents emit at exit).
1343    pub fn detect_auth_error(text: &str) -> Option<AgentError> {
1344        let lower = text.to_lowercase();
1345        let patterns = [
1346            "unauthorized",
1347            "authentication required",
1348            "invalid api",
1349            "invalid key",
1350            "invalid token",
1351            "auth fail",
1352            "no api key",
1353            "no token",
1354            "expired key",
1355            "expired token",
1356            "expired plan",
1357            // Use specific billing-related phrases instead of the bare word
1358            // "billing" which produced false positives when agents mentioned
1359            // billing in non-error contexts (e.g. reviews discussing billing).
1360            "billing plan",
1361            "billing error",
1362            "billing suspended",
1363            "billing expired",
1364            "billing limit",
1365            "billing failed",
1366            "billing cycle exhausted",
1367            "insufficient credit",
1368            "insufficient balance",
1369            "credit balance too low",
1370            "payment required",
1371        ];
1372        // Use standalone check for HTTP status codes (same rationale as
1373        // detect_rate_limit: avoid matching port numbers, line numbers, etc.).
1374        let http_401 = contains_http_status(&lower, "401");
1375        let http_403 = contains_http_status(&lower, "403");
1376        let http_407 = contains_http_status(&lower, "407");
1377        let proxy_auth_required = lower.contains("proxy authentication required");
1378        if !patterns.iter().any(|p| lower.contains(p))
1379            && !http_401
1380            && !http_403
1381            && !http_407
1382            && !proxy_auth_required
1383        {
1384            return None;
1385        }
1386        // Find the first auth error match position to extract its line.
1387        let first_match_pos =
1388            patterns
1389                .iter()
1390                .filter_map(|p| lower.find(p))
1391                .min()
1392                .map(|lower_pos| {
1393                    let char_idx = lower[..lower_pos].chars().count();
1394                    text.char_indices()
1395                        .nth(char_idx)
1396                        .map_or(text.len(), |(i, _)| i)
1397                });
1398        // Also check HTTP status codes for standalone matches (not caught by
1399        // pattern strings like "401" is too short to add as a pattern).
1400        let http_401_match_pos = if http_401 {
1401            lower.find("401 ").or_else(|| lower.find(": 401"))
1402        } else {
1403            None
1404        };
1405        let http_403_match_pos = if http_403 {
1406            lower.find("403 ").or_else(|| lower.find(": 403"))
1407        } else {
1408            None
1409        };
1410        let http_407_match_pos = if http_407 {
1411            lower.find("407 ").or_else(|| lower.find(": 407"))
1412        } else {
1413            None
1414        };
1415        let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1416            .into_iter()
1417            .flatten()
1418            .min()
1419            .and_then(|rel| {
1420                let char_idx = lower[..rel].chars().count();
1421                text.char_indices().nth(char_idx).map(|(i, _)| i)
1422            });
1423        let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1424        let message = if let Some(pos) = match_pos {
1425            find_line_containing(text, pos)
1426        } else {
1427            // Pattern matched but couldn't find position (e.g. 401 standalone).
1428            // Fall back to scanning last 50 lines for the error line.
1429            find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1430        };
1431        Some(AgentError::Auth { message })
1432    }
1433
1434    /// Returns the full line containing `byte_pos` in `text`.
1435    fn find_line_containing(text: &str, byte_pos: usize) -> String {
1436        let start = text[..byte_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
1437        let end = text[byte_pos..]
1438            .find('\n')
1439            .map(|i| byte_pos + i)
1440            .unwrap_or(text.len());
1441        text[start..end].trim().to_string()
1442    }
1443
1444    /// Scans the last 50 lines of `text` for a line containing an auth error
1445    /// pattern (401, unauthorized, invalid api, etc.). Used as fallback when
1446    /// match position is unavailable.
1447    fn find_auth_line_scan(text: &str) -> Option<String> {
1448        for line in text.lines().rev().take(50) {
1449            let line_lower = line.to_lowercase();
1450            if contains_http_status(&line_lower, "401")
1451                || contains_http_status(&line_lower, "403")
1452                || contains_http_status(&line_lower, "407")
1453                || line_lower.contains("unauthorized")
1454                || line_lower.contains("authentication required")
1455                || line_lower.contains("invalid api")
1456                || line_lower.contains("invalid key")
1457                || line_lower.contains("invalid token")
1458                || line_lower.contains("auth fail")
1459                || line_lower.contains("no api key")
1460                || line_lower.contains("no token")
1461                || line_lower.contains("expired key")
1462                || line_lower.contains("expired token")
1463                || line_lower.contains("expired plan")
1464                || line_lower.contains("billing plan")
1465                || line_lower.contains("billing error")
1466                || line_lower.contains("billing suspended")
1467                || line_lower.contains("billing expired")
1468                || line_lower.contains("billing limit")
1469                || line_lower.contains("billing failed")
1470                || line_lower.contains("billing cycle exhausted")
1471                || line_lower.contains("insufficient credit")
1472                || line_lower.contains("credit balance too low")
1473                || line_lower.contains("payment required")
1474                || line_lower.contains("proxy authentication required")
1475            {
1476                return Some(line.trim().to_string());
1477            }
1478        }
1479        None
1480    }
1481
1482    /// Check for transient network connectivity errors.
1483    pub fn detect_network_error(text: &str) -> Option<AgentError> {
1484        let lower = text.to_lowercase();
1485        let patterns = [
1486            "connectionrefused",
1487            "connection refused",
1488            "unable to connect",
1489            "econnrefused",
1490            "network unreachable",
1491            // Socket / HTTP transport errors (Node fetch / undici / http module)
1492            "socket connection was closed",
1493            "socket hang up",
1494            "econnreset",
1495            "connection reset",
1496            "etimedout",
1497            "connect etimedout",
1498            "aborterror",
1499            "the operation was aborted",
1500            "network request failed",
1501            "fetch failed",
1502            // Upstream HTTP-layer timeouts from model provider APIs (e.g. opencode/nemotron)
1503            "upstream idle timeout",
1504            "streaming response failed",
1505            "upstream request failed",
1506            // Provider-side mid-stream disconnects (e.g. kimi/claude API errors)
1507            "stalled mid-stream",
1508            "connection closed mid-response",
1509        ];
1510        // Map the byte offset from `lower` back to `text` via char-count (same
1511        // rationale as detect_rate_limit: Unicode case-folding can change byte lengths).
1512        let match_pos = patterns
1513            .iter()
1514            .filter_map(|p| lower.find(p))
1515            .min()
1516            .map(|lower_pos| {
1517                let char_idx = lower[..lower_pos].chars().count();
1518                text.char_indices()
1519                    .nth(char_idx)
1520                    .map_or(text.len(), |(i, _)| i)
1521            });
1522        if let Some(pos) = match_pos {
1523            return Some(AgentError::NetworkError {
1524                message: extract_context_around(text, pos, 300),
1525            });
1526        }
1527        None
1528    }
1529
1530    /// Check for a plain-text "model not found" / "model unavailable" error.
1531    ///
1532    /// Mirrors the model-unavailable detection already present in codex.rs's
1533    /// and opencode.rs's structured NDJSON classifiers (`classify_message`,
1534    /// `classify_opencode_message`), for the case where the same failure
1535    /// surfaces as raw stdout/stderr text instead of a recognizable NDJSON
1536    /// event. Without this, the message falls through to `AgentError::Unknown`,
1537    /// which bumps the agent-wide failure cooldown instead of the
1538    /// model-scoped one that `ModelUnavailable` is exempted into.
1539    pub fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1540        let lower = text.to_lowercase();
1541        let is_model_unavailable = lower.contains("model metadata")
1542            || lower.contains("no endpoints found")
1543            || (lower.contains("model")
1544                && (lower.contains("not found")
1545                    || lower.contains("not supported")
1546                    || lower.contains("does not exist")
1547                    || lower.contains("deprecated")
1548                    || lower.contains("unavailable for free")
1549                    || lower.contains("not available in your country")
1550                    || lower.contains("not available in your region")));
1551        if !is_model_unavailable {
1552            return None;
1553        }
1554        let model = extract_quoted(text, '`')
1555            .or_else(|| extract_quoted(text, '\''))
1556            .unwrap_or_default();
1557        Some(AgentError::ModelUnavailable {
1558            message: safe_tail(text, 300).to_string(),
1559            model,
1560        })
1561    }
1562
1563    /// Extract text between the first matching pair of `quote` characters.
1564    fn extract_quoted(text: &str, quote: char) -> Option<String> {
1565        let start = text.find(quote)?;
1566        let rest = &text[start + quote.len_utf8()..];
1567        let end = rest.find(quote)?;
1568        Some(rest[..end].to_string())
1569    }
1570
1571    /// Check for missing worktree / working directory setup failures.
1572    pub fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1573        let lower = text.to_lowercase();
1574        let patterns = ["worktree directory does not exist"];
1575        if patterns.iter().any(|p| lower.contains(p)) {
1576            return Some(AgentError::AgentFailed {
1577                message: safe_tail(text, 300).to_string(),
1578            });
1579        }
1580        None
1581    }
1582
1583    /// Check for context overflow patterns.
1584    pub fn detect_context_overflow(text: &str) -> Option<AgentError> {
1585        let lower = text.to_lowercase();
1586        let patterns = [
1587            "context_length_exceeded",
1588            "context length exceeded",
1589            "maximum context length",
1590            "too many tokens",
1591            "token limit",
1592            "context overflow",
1593        ];
1594        if patterns.iter().any(|p| lower.contains(p)) {
1595            return Some(AgentError::ContextOverflow {
1596                message: safe_tail(text, 300).to_string(),
1597            });
1598        }
1599        None
1600    }
1601
1602    /// Check for missing tooling patterns. Returns the tool name.
1603    pub fn detect_missing_tool(text: &str) -> Option<AgentError> {
1604        let known_tools = [
1605            "bun",
1606            "node",
1607            "npm",
1608            "pnpm",
1609            "yarn",
1610            "deno",
1611            "tsc",
1612            "eslint",
1613            "prettier",
1614            "jest",
1615            "vitest",
1616            "cargo",
1617            "rustc",
1618            "go",
1619            "python",
1620            "python3",
1621            "pip",
1622            "pip3",
1623            "uv",
1624            "poetry",
1625            "pytest",
1626            "ruff",
1627            "black",
1628            "mypy",
1629            "make",
1630            "cmake",
1631            "ninja",
1632            "just",
1633            "bats",
1634            "docker",
1635            "docker-compose",
1636            "podman",
1637            "kubectl",
1638            "helm",
1639            "terraform",
1640            "anchor",
1641            "avm",
1642            "solana",
1643            "solana-test-validator",
1644        ];
1645
1646        let lower = text.to_lowercase();
1647        for tool in &known_tools {
1648            if lower.contains(&format!("{tool}: command not found"))
1649                || lower.contains(&format!("command not found: {tool}"))
1650                || lower.contains(&format!("{tool}: not found"))
1651                || lower.contains(&format!("env: {tool}: no such file"))
1652                || lower.contains(&format!("spawn {tool} enoent"))
1653            {
1654                return Some(AgentError::MissingTool {
1655                    tool: tool.to_string(),
1656                });
1657            }
1658        }
1659        None
1660    }
1661
1662    /// Check for permission/sandbox denied patterns.
1663    pub fn detect_permission_denied(text: &str) -> Option<AgentError> {
1664        let lower = text.to_lowercase();
1665        let patterns = [
1666            "permission denied",
1667            "rejected permission",
1668            "permissionerror",
1669            "operation not permitted",
1670            "sandbox violation",
1671            "access denied",
1672            "eacces",
1673            "eperm",
1674            "read-only file system",
1675            "not writable",
1676            "disallowed tool",
1677        ];
1678        if patterns.iter().any(|p| lower.contains(p)) {
1679            return Some(AgentError::PermissionDenied {
1680                message: safe_tail(text, 300).to_string(),
1681            });
1682        }
1683        None
1684    }
1685
1686    /// Check for interactive input prompts (1Password, SSH passphrase, etc.).
1687    pub fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1688        let lower = text.to_lowercase();
1689        let patterns = [
1690            "enter passphrase",
1691            "1password",
1692            "op signin",
1693            "ssh passphrase",
1694            "password:",
1695        ];
1696        let auth_required_with_interactive_context = lower.contains("authentication required")
1697            && (lower.contains("ssh")
1698                || lower.contains("passphrase")
1699                || lower.contains("1password")
1700                || lower.contains("op signin"));
1701        if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1702            return Some(AgentError::WaitingForInput {
1703                message: safe_tail(text, 300).to_string(),
1704            });
1705        }
1706        None
1707    }
1708
1709    /// Check for the Claude 400 "thinking blocks cannot be modified" API error.
1710    ///
1711    /// Occurs when Claude CLI reconstructs a multi-turn tool-use request and the
1712    /// Anthropic API rejects it because a thinking block was altered between turns.
1713    /// Classified separately to avoid penalising the model with a failure count.
1714    pub fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1715        // Match the exact phrasing the Anthropic API emits (backtick-quoted names,
1716        // case-sensitive — the API always uses this exact string).
1717        if text.contains(
1718            "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1719        ) {
1720            let message = text
1721                .lines()
1722                .find(|l| l.contains("cannot be modified"))
1723                .map(|l| l.trim().to_string())
1724                .unwrap_or_else(|| safe_tail(text, 300).to_string());
1725            return Some(AgentError::ThinkingBlockConflict { message });
1726        }
1727        None
1728    }
1729
1730    /// Check for a stale/expired Claude session ID.
1731    ///
1732    /// Claude returns `"No conversation found with session ID: <uuid>"` when
1733    /// the session UUID stored in orch's KV has expired server-side. The caller
1734    /// should reset the stored UUID and retry with a fresh `--session-id`.
1735    pub fn detect_stale_session(text: &str) -> Option<AgentError> {
1736        // Case-insensitive match; extract the UUID if present.
1737        // We search the lowercased copy for the pattern, then find the
1738        // corresponding byte offset in the *original* text via char_indices so
1739        // we never use a byte index from `lower` (whose byte length may differ
1740        // from `text` after Unicode case-folding, e.g. ß→ss, İ→i̇).
1741        let needle = "no conversation found with session id";
1742        let lower = text.to_lowercase();
1743        if let Some(lower_pos) = lower.find(needle) {
1744            // Map the byte offset in `lower` back to a char index, then find
1745            // the same char index in `text`.  Because to_lowercase() maps each
1746            // char to one or more chars, the char count up to the match may
1747            // differ between `lower` and `text`, so we count chars in `lower`.
1748            let char_idx = lower[..lower_pos].chars().count();
1749            let pos = text
1750                .char_indices()
1751                .nth(char_idx)
1752                .map(|(i, _)| i)
1753                .unwrap_or(text.len());
1754            // Try to extract the UUID that follows the colon.
1755            let after = &text[pos..];
1756            let session_id = after
1757                .split_once(':')
1758                .map(|(_, rest)| rest.trim())
1759                // Take only the UUID portion (up to whitespace or end)
1760                .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1761                .unwrap_or_default();
1762            return Some(AgentError::StaleSession { session_id });
1763        }
1764        None
1765    }
1766
1767    /// Exit code returned by `timeout(1)` when the child is killed.
1768    const TIMEOUT_EXIT_CODE: i32 = 124;
1769
1770    /// Default assumed timeout duration when we only have the exit code.
1771    const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1772
1773    /// How many bytes from the end of combined stdout+stderr we scan for
1774    /// CLI-style error patterns that should come from the process tail.
1775    ///
1776    /// Rationale: real CLI error messages (rate limits, auth failures, network
1777    /// failures) almost always appear at the end of the process output.
1778    /// Scanning only the tail avoids false-positives from agent work product
1779    /// (code, diffs, commit messages) which may mention "quota",
1780    /// "unauthorized", HTTP status codes, etc. 3000 bytes is a conservative
1781    /// window large enough to capture multi-line error dumps but small enough
1782    /// to exclude most long-form agent outputs.
1783    pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1784
1785    /// How many bytes from the start of combined stdout+stderr we scan for
1786    /// rate limit patterns that appear as early system events.
1787    ///
1788    /// Some rate limit errors (e.g., Claude session limit) appear as
1789    /// `type:system` NDJSON events early in the stream, not in the tail.
1790    /// Scanning the head catches these before they get pushed out of the
1791    /// tail window by subsequent agent output.
1792    pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1793
1794    /// Run all pattern detectors against combined stdout+stderr.
1795    /// Returns the first matching AgentError, or a generic Unknown.
1796    pub fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1797        if exit_code == TIMEOUT_EXIT_CODE {
1798            return AgentError::Timeout {
1799                elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1800            };
1801        }
1802
1803        // Exit 0 with empty output is a silent failure (common with GitHub Copilot
1804        // models in opencode). Return Unknown with a deterministic message so the
1805        // caller (fallback.rs) detects it and applies model cooldown + free-model
1806        // retry instead of treating it as a generic success.
1807        if exit_code == 0 && text.trim().is_empty() {
1808            return AgentError::Unknown {
1809                exit_code,
1810                message: "empty-output-exit0".to_string(),
1811            };
1812        }
1813
1814        if let Some(e) = detect_missing_tool(text) {
1815            return e;
1816        }
1817        if let Some(e) = detect_worktree_missing(text) {
1818            return e;
1819        }
1820        if let Some(e) = detect_thinking_block_conflict(text) {
1821            return e;
1822        }
1823        // Also scan the head of the combined output for rate limit patterns.
1824        // Some rate limit errors (e.g., Claude session limit) appear as
1825        // `type:system` NDJSON events early in the stream, not in the tail.
1826        // Scanning the head catches these before they get pushed out of the
1827        // tail window by subsequent agent output.
1828        let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1829        if let Some(e) = detect_rate_limit(scan_head) {
1830            return e;
1831        }
1832        // Only scan the tail of the combined output for CLI-style error
1833        // patterns. The full output may contain agent work product (code,
1834        // diffs, commit messages) that incidentally mentions rate limiting,
1835        // authorization, HTTP status codes, or context/auth errors. Real
1836        // process errors appear at the end of the output. Scan a bounded tail
1837        // (`RATE_LIMIT_SCAN_TAIL_BYTES`) to reduce false positives.
1838        let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1839        if let Some(e) = detect_context_overflow(scan_tail) {
1840            return e;
1841        }
1842        if let Some(e) = detect_rate_limit(scan_tail) {
1843            return e;
1844        }
1845        if let Some(e) = detect_model_unavailable(scan_tail) {
1846            return e;
1847        }
1848        if let Some(e) = detect_network_error(scan_tail) {
1849            return e;
1850        }
1851        if let Some(e) = detect_auth_error(scan_tail) {
1852            return e;
1853        }
1854        if let Some(e) = detect_waiting_for_input(scan_tail) {
1855            return e;
1856        }
1857        if let Some(e) = detect_permission_denied(scan_tail) {
1858            return e;
1859        }
1860        if let Some(e) = detect_stale_session(text) {
1861            return e;
1862        }
1863
1864        AgentError::Unknown {
1865            exit_code,
1866            message: safe_tail(text, 300).to_string(),
1867        }
1868    }
1869
1870    /// Safely extract the last `max_bytes` of a string, respecting UTF-8 boundaries.
1871    pub fn safe_tail(text: &str, max_bytes: usize) -> &str {
1872        if text.len() <= max_bytes {
1873            return text;
1874        }
1875        let start = text.len() - max_bytes;
1876        // Walk forward to find a char boundary
1877        let mut idx = start;
1878        while idx < text.len() && !text.is_char_boundary(idx) {
1879            idx += 1;
1880        }
1881        &text[idx..]
1882    }
1883
1884    /// Safely extract the first `max_bytes` of a string, respecting UTF-8 boundaries.
1885    pub fn safe_head(text: &str, max_bytes: usize) -> &str {
1886        if text.len() <= max_bytes {
1887            return text;
1888        }
1889        let mut idx = max_bytes;
1890        // Walk backward to find a char boundary
1891        while idx > 0 && !text.is_char_boundary(idx) {
1892            idx -= 1;
1893        }
1894        &text[..idx]
1895    }
1896
1897    /// Scan NDJSON events for errors using agent-specific extraction and classification.
1898    ///
1899    /// `extract_message` is called for each event to extract an error message string.
1900    /// If it returns `Some`, the message is classified via `classify`. The most
1901    /// specific error is kept — any typed error (RateLimit, Auth, etc.) takes
1902    /// precedence over a generic `AgentFailed`.
1903    pub fn detect_ndjson_error(
1904        events: &[serde_json::Value],
1905        extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1906        classify: impl Fn(&str) -> AgentError,
1907    ) -> Option<AgentError> {
1908        let mut best: Option<AgentError> = None;
1909
1910        for event in events {
1911            if let Some(message) = extract_message(event) {
1912                let err = classify(&message);
1913                // Priority (highest wins): RateLimit / Auth / other > NetworkError > AgentFailed
1914                // A transient reconnect mid-stream must not obscure a subsequent billing/rate error.
1915                let upgrade = match &best {
1916                    None => true,
1917                    Some(AgentError::AgentFailed { .. }) => true,
1918                    Some(AgentError::NetworkError { .. }) => !matches!(
1919                        err,
1920                        AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1921                    ),
1922                    Some(_) => false,
1923                };
1924                if upgrade {
1925                    best = Some(err);
1926                }
1927            }
1928        }
1929
1930        best
1931    }
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936    use super::*;
1937
1938    #[test]
1939    fn get_runner_returns_correct_types() {
1940        assert_eq!(get_runner("claude").name(), "claude");
1941        assert_eq!(get_runner("kimi").name(), "kimi");
1942        assert_eq!(get_runner("minimax").name(), "minimax");
1943        assert_eq!(get_runner("codex").name(), "codex");
1944        assert_eq!(get_runner("opencode").name(), "opencode");
1945        assert_eq!(get_runner("grok").name(), "grok");
1946        assert_eq!(
1947            get_runner("claude").router_transcript(),
1948            RouterTranscript::Messages
1949        );
1950        assert_eq!(
1951            get_runner("kimi").router_transcript(),
1952            RouterTranscript::Messages
1953        );
1954        assert_eq!(
1955            get_runner("minimax").router_transcript(),
1956            RouterTranscript::Messages
1957        );
1958        assert_eq!(
1959            get_runner("grok").router_transcript(),
1960            RouterTranscript::Messages
1961        );
1962        assert_eq!(
1963            get_runner("glm").router_transcript(),
1964            RouterTranscript::Messages
1965        );
1966        assert_eq!(
1967            get_runner("olm").router_transcript(),
1968            RouterTranscript::Messages
1969        );
1970        assert_eq!(
1971            get_runner("opencode").router_transcript(),
1972            RouterTranscript::OpenCode
1973        );
1974        assert_eq!(
1975            get_runner("codex").router_transcript(),
1976            RouterTranscript::Raw
1977        );
1978        assert!(supports_session("grok"));
1979        assert!(!supports_session("codex"));
1980        // olm and glm are claude-compatible and should return correct name
1981        assert_eq!(get_runner("olm").name(), "olm");
1982        assert_eq!(get_runner("glm").name(), "glm");
1983        // Unknown falls back to claude-compatible
1984        assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
1985    }
1986
1987    /// Regression test: known claude-compatible agents (including glm) must not
1988    /// emit "unknown agent" warnings when get_runner() is called.
1989    #[test]
1990    fn get_runner_no_warning_for_claude_compatible_agents() {
1991        use std::sync::{Arc, Mutex};
1992        use tracing::Level;
1993        use tracing_subscriber::util::SubscriberInitExt;
1994        use tracing_subscriber::EnvFilter;
1995
1996        let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1997        let output_clone = output.clone();
1998
1999        struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2000        impl std::io::Write for CaptureWriter {
2001            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2002                self.0.lock().unwrap().extend_from_slice(buf);
2003                Ok(buf.len())
2004            }
2005            fn flush(&mut self) -> std::io::Result<()> {
2006                Ok(())
2007            }
2008        }
2009        impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2010            type Writer = CaptureWriter;
2011            fn make_writer(&'a self) -> Self::Writer {
2012                CaptureWriter(self.0.clone())
2013            }
2014        }
2015
2016        tracing_subscriber::fmt()
2017            .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2018            .with_writer(CaptureWriter(output_clone))
2019            .with_ansi(false)
2020            .finish()
2021            .init();
2022
2023        for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2024            get_runner(agent);
2025        }
2026
2027        let captured = {
2028            let guard = output.lock().unwrap();
2029            String::from_utf8_lossy(&guard).to_string()
2030        };
2031        assert!(
2032            !captured.contains("unknown agent"),
2033            "known claude-compatible agents must not emit 'unknown agent' warnings; got: {}",
2034            captured
2035        );
2036    }
2037
2038    #[test]
2039    fn agent_error_display() {
2040        let e = AgentError::RateLimit {
2041            message: "429 Too Many Requests".to_string(),
2042        };
2043        assert!(e.to_string().contains("rate limit"));
2044
2045        let e = AgentError::Timeout {
2046            elapsed: Duration::from_secs(1800),
2047        };
2048        assert!(e.to_string().contains("1800"));
2049    }
2050
2051    #[test]
2052    fn pattern_detect_rate_limit() {
2053        assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2054        assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2055        assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2056        assert!(patterns::detect_rate_limit(
2057            "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2058        )
2059        .is_some());
2060        assert!(patterns::detect_rate_limit(
2061            "Upstream error from Alibaba: Request rate increased too quickly. \
2062             To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2063        ).is_some());
2064        assert!(patterns::detect_rate_limit("all good").is_none());
2065    }
2066
2067    #[test]
2068    fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2069        // Regression test for issue #3283: Claude "weekly limit" errors were
2070        // classified as `failed` instead of `rate_limit` because "weekly limit"
2071        // was not in the detect_rate_limit pattern list.
2072        let system_event = r#"{"type":"system","subtype":"error","error":{"type":"weekly_limit","message":"You've hit your weekly limit · resets Jun 9 at 1am (America/Sao_Paulo)"}}"#;
2073        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2074            .repeat(200);
2075        let text = format!("{system_event}\n{work_product}");
2076        assert!(
2077            text.len() > 3000,
2078            "test output must exceed tail window to be meaningful"
2079        );
2080        let err = patterns::classify_from_text(1, &text);
2081        assert!(
2082            matches!(err, AgentError::RateLimit { .. }),
2083            "weekly limit as early system event must be detected via head scan, got: {err:?}"
2084        );
2085        let msg = err.to_string();
2086        assert!(
2087            msg.contains("weekly limit"),
2088            "error message should reference weekly limit, got: {msg}"
2089        );
2090    }
2091
2092    #[test]
2093    fn detect_rate_limit_github_copilot_monthly_quota() {
2094        // Regression test for issue #3302: "You have exceeded your monthly quota"
2095        // was not matched because detect_rate_limit only checked "quota exceeded"
2096        // (reversed word order), causing 4 immediate retries with no cooldown.
2097        let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2098        assert!(
2099            matches!(err, Some(AgentError::RateLimit { .. })),
2100            "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2101        );
2102    }
2103
2104    #[test]
2105    fn detect_rate_limit_nvidia_resourceexhausted() {
2106        // Regression test for issue #3361: Nvidia "ResourceExhausted: Worker local total
2107        // request limit reached" was classified as generic `failed` instead of `rate_limit`,
2108        // bypassing the cooldown path and weakening routing health signals.
2109        let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2110        let err = patterns::detect_rate_limit(opencode_error);
2111        assert!(
2112            matches!(err, Some(AgentError::RateLimit { .. })),
2113            "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2114        );
2115
2116        // Both sub-patterns must match independently.
2117        assert!(
2118            patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2119            "bare ResourceExhausted must match"
2120        );
2121        assert!(
2122            patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2123                .is_some(),
2124            "worker local total request limit reached must match"
2125        );
2126
2127        // classify_from_text must also route this to RateLimit (exercises the tail-scan path).
2128        let err = patterns::classify_from_text(1, opencode_error);
2129        assert!(
2130            matches!(err, AgentError::RateLimit { .. }),
2131            "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2132        );
2133    }
2134
2135    #[test]
2136    fn detect_rate_limit_message_contains_context_not_tail() {
2137        // Simulate a long claude NDJSON output where the rate limit message is in
2138        // the middle and usage stats JSON is at the tail.
2139        let padding = "x".repeat(5000);
2140        let text = format!(
2141            "{padding}you've reached your usage limit for this billing cycle{padding}\
2142             {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2143        );
2144        let err = patterns::detect_rate_limit(&text).expect("should detect");
2145        let msg = err.to_string();
2146        assert!(
2147            msg.contains("usage limit"),
2148            "message should contain the actual error, got: {msg}"
2149        );
2150        assert!(
2151            !msg.contains("web_fetch_requests"),
2152            "message should not be the tail JSON, got: {msg}"
2153        );
2154    }
2155
2156    #[test]
2157    fn pattern_detect_rate_limit_529_not_bare() {
2158        // Bare "529" in various contexts should NOT trigger rate limit detection.
2159        // These are false positive cases we want to avoid.
2160        assert!(
2161            patterns::detect_rate_limit("error on line 529").is_none(),
2162            "bare line number 529 should not match"
2163        );
2164        assert!(
2165            patterns::detect_rate_limit("file size 529 bytes").is_none(),
2166            "bare file size 529 should not match"
2167        );
2168        assert!(
2169            patterns::detect_rate_limit("port :5290 in config").is_none(),
2170            "port number containing 529 should not match"
2171        );
2172        assert!(
2173            patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2174            "duration containing 529 should not match"
2175        );
2176        // But HTTP 529 status codes SHOULD match.
2177        assert!(
2178            patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2179            "HTTP 529 status should match"
2180        );
2181        assert!(
2182            patterns::detect_rate_limit("http 529").is_some(),
2183            "http 529 should match"
2184        );
2185        assert!(
2186            patterns::detect_rate_limit("529 service unavailable").is_some(),
2187            "529 service unavailable should match"
2188        );
2189        assert!(
2190            patterns::detect_rate_limit("status: 529").is_some(),
2191            "status: 529 should match"
2192        );
2193    }
2194
2195    #[test]
2196    fn pattern_detect_auth() {
2197        assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2198        assert!(patterns::detect_auth_error("HTTP 401").is_some());
2199        assert!(patterns::detect_auth_error("error: 401").is_some());
2200        assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2201        assert!(patterns::detect_auth_error("HTTP 403").is_some());
2202        assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2203        assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2204        assert!(patterns::detect_auth_error("invalid api key").is_some());
2205        assert!(patterns::detect_auth_error("billing expired").is_some());
2206        assert!(patterns::detect_auth_error("task done").is_none());
2207        // Bare numbers in JSON must NOT trigger auth classification.
2208        assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2209        assert!(patterns::detect_auth_error(
2210            "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2211        )
2212        .is_none());
2213        // JSON field with colon: "duration_api_ms": 4010292 — contains ": 401" as substring.
2214        assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2215        assert!(patterns::detect_auth_error(
2216            r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2217        )
2218        .is_none());
2219        // ": 401" at end-of-string or followed by non-digit must still match.
2220        assert!(patterns::detect_auth_error("status: 401").is_some());
2221        assert!(patterns::detect_auth_error("error: 403").is_some());
2222    }
2223
2224    #[test]
2225    fn pattern_detect_auth_insufficient_balance() {
2226        // Regression test for issue #3642: "insufficient balance" (and by extension
2227        // "402 Payment Required: insufficient balance") must be detected as an auth
2228        // error so it routes through the credit exhaustion path, not the generic 5-min
2229        // backoff. This ensures the #3642 cooldown behavior is properly triggered.
2230        assert!(
2231            patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2232            "insufficient balance must be detected as auth error"
2233        );
2234        assert!(
2235            patterns::detect_auth_error("insufficient balance").is_some(),
2236            "bare insufficient balance must be detected as auth error"
2237        );
2238        // The stored message should contain the matched pattern.
2239        let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2240            .expect("should detect auth error");
2241        let AgentError::Auth { message } = err else {
2242            panic!("expected Auth, got {err:?}");
2243        };
2244        assert!(
2245            message.to_lowercase().contains("insufficient balance"),
2246            "stored message should contain the matched pattern, got: {message}"
2247        );
2248        // The message should be the line containing the auth error, not the tail.
2249        assert!(
2250            message.lines().count() <= 2,
2251            "message should be the auth line, not the full tail: {}",
2252            message.len()
2253        );
2254    }
2255
2256    #[test]
2257    fn auth_error_message_contains_match_not_tail() {
2258        // Simulate real agent output: auth error appears early, NDJSON session
2259        // metadata at the end. Stored message must be the error line, not the
2260        // trailing JSON that agents emit at exit.
2261        let text = "some output\nunauthorized: your api key has expired\nx".repeat(400)
2262            + r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#;
2263        let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2264        let AgentError::Auth { message } = err else {
2265            panic!("expected Auth, got {err:?}");
2266        };
2267        assert!(
2268            message.to_lowercase().contains("unauthorized"),
2269            "stored message should contain the matched pattern, got: {message}"
2270        );
2271        assert!(
2272            !message.contains("outputTokens"),
2273            "stored message must not be the trailing NDJSON, got: {message}"
2274        );
2275        // The message should be the line containing the auth error, not the tail.
2276        assert!(
2277            message.lines().count() <= 2,
2278            "message should be the auth line, not the full tail: {}",
2279            message.len()
2280        );
2281    }
2282
2283    #[test]
2284    fn pattern_detect_network_error() {
2285        assert!(patterns::detect_network_error("connection refused").is_some());
2286        assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2287        assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2288        assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2289        assert!(patterns::detect_network_error("network unreachable").is_some());
2290        assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2291        assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2292        assert!(patterns::detect_network_error("Streaming response failed").is_some());
2293        assert!(patterns::detect_network_error("Upstream request failed").is_some());
2294        assert!(patterns::detect_network_error("all systems operational").is_none());
2295    }
2296
2297    #[test]
2298    fn network_error_message_contains_match_not_tail() {
2299        // Simulate real agent output: error appears early, unrelated stats JSON at the end.
2300        let prefix = "connection refused connecting to api.anthropic.com:443 ";
2301        let suffix = "x".repeat(2000)
2302            + r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#;
2303        let text = format!("{prefix}{suffix}");
2304
2305        let err = patterns::detect_network_error(&text).expect("should detect network error");
2306        let AgentError::NetworkError { message } = err else {
2307            panic!("expected NetworkError, got {err:?}");
2308        };
2309        assert!(
2310            message.to_lowercase().contains("connection refused"),
2311            "stored message should contain the matched pattern, got: {message}"
2312        );
2313        assert!(
2314            !message.contains("web_fetch_requests"),
2315            "stored message must not be the unrelated stats tail, got: {message}"
2316        );
2317    }
2318
2319    #[test]
2320    fn pattern_detect_context_overflow() {
2321        assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2322        assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2323        assert!(patterns::detect_context_overflow("success").is_none());
2324    }
2325
2326    #[test]
2327    fn pattern_detect_worktree_missing() {
2328        assert!(
2329            patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2330                .is_some()
2331        );
2332        assert!(patterns::detect_worktree_missing("all good").is_none());
2333    }
2334
2335    #[test]
2336    fn pattern_detect_missing_tool() {
2337        assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2338        assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2339        assert!(patterns::detect_missing_tool("everything works").is_none());
2340    }
2341
2342    #[test]
2343    fn pattern_detect_permission_denied() {
2344        assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2345        assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2346        assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2347        assert!(patterns::detect_permission_denied("task completed").is_none());
2348    }
2349
2350    #[test]
2351    fn pattern_detect_waiting_for_input() {
2352        assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2353        assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2354        assert!(
2355            patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2356                .is_some()
2357        );
2358        assert!(
2359            patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2360        );
2361        assert!(patterns::detect_waiting_for_input("done").is_none());
2362    }
2363
2364    #[test]
2365    fn pattern_detect_stale_session() {
2366        // Exact error message from Claude
2367        let err = patterns::detect_stale_session(
2368            "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2369        );
2370        assert!(err.is_some(), "should detect stale session");
2371        if let Some(AgentError::StaleSession { session_id }) = err {
2372            assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2373        }
2374
2375        // Case-insensitive
2376        let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2377        assert!(err.is_some(), "should detect lowercase variant");
2378
2379        // Non-matching text
2380        assert!(patterns::detect_stale_session("all good").is_none());
2381        assert!(patterns::detect_stale_session("conversation started").is_none());
2382
2383        // Multi-byte UTF-8 characters before the match must not panic.
2384        // German ß lowercases to "ss" (1 byte → 2 bytes), so using a byte
2385        // offset from `lower` directly into `text` would be wrong.
2386        let multibyte = "Straße: No conversation found with session ID: dead-beef";
2387        let err = patterns::detect_stale_session(multibyte);
2388        assert!(
2389            err.is_some(),
2390            "should detect stale session after multi-byte chars"
2391        );
2392        if let Some(AgentError::StaleSession { session_id }) = err {
2393            assert_eq!(session_id, "dead-beef");
2394        }
2395    }
2396
2397    #[test]
2398    fn stale_session_display() {
2399        let e = AgentError::StaleSession {
2400            session_id: "abc-123".to_string(),
2401        };
2402        assert!(e.to_string().contains("stale session"));
2403        assert!(e.to_string().contains("abc-123"));
2404    }
2405
2406    #[test]
2407    fn classify_from_text_timeout() {
2408        let err = patterns::classify_from_text(124, "");
2409        assert!(matches!(err, AgentError::Timeout { .. }));
2410    }
2411
2412    #[test]
2413    fn classify_from_text_missing_tool_before_rate_limit() {
2414        // Missing tool takes priority over rate limit patterns
2415        let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2416        assert!(matches!(err, AgentError::MissingTool { .. }));
2417    }
2418
2419    #[test]
2420    fn classify_from_text_worktree_missing() {
2421        let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2422        assert!(matches!(err, AgentError::AgentFailed { .. }));
2423    }
2424
2425    #[test]
2426    fn classify_from_text_model_not_found_backtick() {
2427        // Issue #3604: plain-text "model not found" errors (no structured
2428        // NDJSON event) must classify as ModelUnavailable, not fall through to
2429        // Unknown, so the agent-wide cooldown is not bumped for a bad model name.
2430        let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2431            (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2432            or you do not have access to it.)";
2433        let err = patterns::classify_from_text(1, text);
2434        match err {
2435            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2436            other => panic!("expected ModelUnavailable, got: {other:?}"),
2437        }
2438    }
2439
2440    #[test]
2441    fn classify_from_text_model_metadata_not_found() {
2442        let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2443            metadata; this can degrade performance and cause issues.";
2444        let err = patterns::classify_from_text(1, text);
2445        match err {
2446            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2447            other => panic!("expected ModelUnavailable, got: {other:?}"),
2448        }
2449    }
2450
2451    #[test]
2452    fn classify_from_text_no_false_positive_from_work_product() {
2453        // Regression test for issue #1292: agent output containing rate-limit
2454        // keywords in code/diffs must not be misclassified as a rate limit error.
2455        let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2456             - Added copilot quota patterns to `parse_retry_at`\n\
2457             fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2458             quota exceeded handling for billing cycle\n"
2459            .repeat(50); // ~200 lines of work product — well over 3000 chars
2460        let actual_error = "\nerror: command not found: cargo\n";
2461        let text = format!("{work_product}{actual_error}");
2462        let err = patterns::classify_from_text(1, &text);
2463        assert!(
2464            !matches!(err, AgentError::RateLimit { .. }),
2465            "work product keywords must not trigger rate limit, got: {err:?}"
2466        );
2467    }
2468
2469    #[test]
2470    fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2471        // A real rate-limit error printed at the end of the output must still
2472        // be detected, even if work product precedes it.
2473        let padding = "normal agent work output ".repeat(100);
2474        let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2475        let err = patterns::classify_from_text(1, &text);
2476        assert!(
2477            matches!(err, AgentError::RateLimit { .. }),
2478            "real rate limit at tail must be detected, got: {err:?}"
2479        );
2480    }
2481
2482    #[test]
2483    fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2484        let padding = "normal agent work output ".repeat(250);
2485        let text = format!(
2486            "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2487        );
2488        let err = patterns::classify_from_text(1, &text);
2489        assert!(
2490            !matches!(err, AgentError::RateLimit { .. }),
2491            "markdown table cell must not trigger rate limit, got: {err:?}"
2492        );
2493    }
2494
2495    #[test]
2496    fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2497        // Regression test: non-ASCII characters before `rate_limit` that change
2498        // byte length when lowercased (e.g. İ 2→3 bytes, fl 3→2 bytes) must not
2499        // cause is_word_boundary to index text at an invalid byte offset.
2500        let text = "Aflαβrate_limit error: too many requests";
2501        let err = patterns::classify_from_text(1, text);
2502        assert!(
2503            matches!(err, AgentError::RateLimit { .. }),
2504            "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2505        );
2506    }
2507
2508    #[test]
2509    fn classify_from_text_detects_session_limit_as_early_system_event() {
2510        // Regression test for issue #3272: Claude session limit errors appear as
2511        // `type:system` NDJSON events early in the stream, not as `type:result`
2512        // lines. When the agent produces significant output after the error, the
2513        // system event is pushed out of the tail window and missed by tail-only
2514        // scanning. The head scan must catch it.
2515        let system_event = r#"{"type":"system","subtype":"error","error":{"type":"session_limit","message":"You've hit your session limit · resets 4:10am (America/Sao_Paulo)"}}"#;
2516        // Generate enough work product (>3000 bytes) to push the system event
2517        // out of the tail window.
2518        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2519            .repeat(200);
2520        let text = format!("{system_event}\n{work_product}");
2521        // Verify the output is large enough that the event is not in the tail.
2522        assert!(
2523            text.len() > 3000,
2524            "test output must exceed tail window to be meaningful"
2525        );
2526        let err = patterns::classify_from_text(1, &text);
2527        assert!(
2528            matches!(err, AgentError::RateLimit { .. }),
2529            "session limit as early system event must be detected via head scan, got: {err:?}"
2530        );
2531        // The message should contain the actual error, not unrelated tail content.
2532        let msg = err.to_string();
2533        assert!(
2534            msg.contains("session limit"),
2535            "error message should contain the rate limit reason, got: {msg}"
2536        );
2537    }
2538
2539    #[test]
2540    fn classify_from_text_no_false_positive_auth_from_work_product() {
2541        // Regression test for issue #2126: auth keywords in agent work product
2542        // must not trigger auth classification when the real failure is
2543        // unrelated and appears at the end of the output.
2544        let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2545             // handle 403 forbidden errors\n\
2546             // billing checks happen elsewhere\n"
2547            .repeat(80);
2548        let actual_error = "\nerror: command not found: cargo\n";
2549        let text = format!("{work_product}{actual_error}");
2550        let err = patterns::classify_from_text(1, &text);
2551        assert!(
2552            !matches!(err, AgentError::Auth { .. }),
2553            "work product auth keywords must not trigger auth classification, got: {err:?}"
2554        );
2555        assert!(
2556            matches!(err, AgentError::MissingTool { .. }),
2557            "the real tail error should still win, got: {err:?}"
2558        );
2559    }
2560
2561    #[test]
2562    fn classify_from_text_still_detects_real_auth_at_tail() {
2563        let padding = "normal agent work output ".repeat(100);
2564        let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2565        let err = patterns::classify_from_text(1, &text);
2566        assert!(
2567            matches!(err, AgentError::Auth { .. }),
2568            "real auth error at tail must be detected, got: {err:?}"
2569        );
2570    }
2571
2572    #[test]
2573    fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2574        // Regression test for issue #2205: context overflow keywords in agent
2575        // work product must not trigger ContextOverflow when the real failure is
2576        // unrelated and appears at the end of the output.
2577        let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2578             // Ensure we don't exceed the token limit per request\n\
2579             fn check_token_limit(tokens: usize) { ... }\n\
2580             if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2581             // Note: too many tokens in the prompt causes context_length_exceeded\n"
2582            .repeat(80);
2583        let actual_error = "\nerror: command not found: cargo\n";
2584        let text = format!("{work_product}{actual_error}");
2585        let err = patterns::classify_from_text(1, &text);
2586        assert!(
2587            !matches!(err, AgentError::ContextOverflow { .. }),
2588            "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2589        );
2590        assert!(
2591            matches!(err, AgentError::MissingTool { .. }),
2592            "the real tail error should still win, got: {err:?}"
2593        );
2594    }
2595
2596    #[test]
2597    fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2598        let padding = "normal agent work output ".repeat(100);
2599        let text = format!("{padding}\nError: context_length_exceeded\n");
2600        let err = patterns::classify_from_text(1, &text);
2601        assert!(
2602            matches!(err, AgentError::ContextOverflow { .. }),
2603            "real context overflow at tail must be detected, got: {err:?}"
2604        );
2605    }
2606
2607    #[test]
2608    fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2609        // Regression test for issue #2205: permission-related keywords in agent
2610        // work product must not trigger PermissionDenied when the real failure
2611        // is unrelated and appears at the end of the output.
2612        let work_product = "match fs::metadata(path) {\n\
2613             Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2614                 bail!(\"access denied: {path}\");\n\
2615             }\n\
2616             // Check if directory is writable\n\
2617             if !path.is_writable() { ... }\n\
2618             }\n"
2619        .repeat(80);
2620        let actual_error = "\nerror: command not found: cargo\n";
2621        let text = format!("{work_product}{actual_error}");
2622        let err = patterns::classify_from_text(1, &text);
2623        assert!(
2624            !matches!(err, AgentError::PermissionDenied { .. }),
2625            "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2626        );
2627        assert!(
2628            matches!(err, AgentError::MissingTool { .. }),
2629            "the real tail error should still win, got: {err:?}"
2630        );
2631    }
2632
2633    #[test]
2634    fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2635        let padding = "normal agent work output ".repeat(100);
2636        let text = format!("{padding}\npermission denied: /etc/hosts\n");
2637        let err = patterns::classify_from_text(1, &text);
2638        assert!(
2639            matches!(err, AgentError::PermissionDenied { .. }),
2640            "real permission denied at tail must be detected, got: {err:?}"
2641        );
2642    }
2643
2644    #[test]
2645    fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2646        // Regression test for issue #2205: input-request keywords in agent work
2647        // product must not trigger WaitingForInput when the real failure is
2648        // unrelated and appears at the end of the output.
2649        let work_product = "// Handle password prompts gracefully\n\
2650             if prompt.contains(\"password:\") {\n\
2651                 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2652             }\n\
2653             // SSH agent will ask for the deploy key passphrase\n\
2654             fn handle_ssh_passphrase() { ... }\n\
2655             )\n"
2656        .repeat(80);
2657        let actual_error = "\nerror: command not found: cargo\n";
2658        let text = format!("{work_product}{actual_error}");
2659        let err = patterns::classify_from_text(1, &text);
2660        assert!(
2661            !matches!(err, AgentError::WaitingForInput { .. }),
2662            "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2663        );
2664        assert!(
2665            matches!(err, AgentError::MissingTool { .. }),
2666            "the real tail error should still win, got: {err:?}"
2667        );
2668    }
2669
2670    #[test]
2671    fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2672        let padding = "normal agent work output ".repeat(100);
2673        let text = format!("{padding}\nEnter passphrase for key:\n");
2674        let err = patterns::classify_from_text(1, &text);
2675        assert!(
2676            matches!(err, AgentError::WaitingForInput { .. }),
2677            "real waiting for input at tail must be detected, got: {err:?}"
2678        );
2679    }
2680
2681    #[test]
2682    fn classify_from_text_prefers_auth_over_permission_denied() {
2683        let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2684        assert!(
2685            matches!(err, AgentError::Auth { .. }),
2686            "auth errors must outrank permission denied, got: {err:?}"
2687        );
2688    }
2689
2690    #[test]
2691    fn classify_from_text_prefers_auth_over_waiting_for_input() {
2692        let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2693        assert!(
2694            matches!(err, AgentError::Auth { .. }),
2695            "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2696        );
2697    }
2698
2699    #[test]
2700    fn classify_from_text_detects_stale_session() {
2701        // Regression test for issue #1800: classify_from_text must call
2702        // detect_stale_session to catch "No conversation found with session ID"
2703        // errors. Without this, the error would be misclassified as Unknown and
2704        // the control.rs recovery code would not trigger.
2705        let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2706        let err = patterns::classify_from_text(1, text);
2707        assert!(
2708            matches!(err, AgentError::StaleSession { .. }),
2709            "stale session must be detected via classify_from_text, got: {err:?}"
2710        );
2711        // Verify the Display format matches what control.rs expects
2712        assert!(
2713            err.to_string().contains("stale session"),
2714            "error display must contain 'stale session' for recovery code to trigger"
2715        );
2716    }
2717
2718    #[test]
2719    fn classify_from_text_detects_socket_closed() {
2720        // Regression test for issue #3045: "socket connection was closed" must be
2721        // classified as NetworkError, not Unknown.
2722        let text = "API Error: The socket connection was closed unexpectedly. \
2723             For more information, pass `verbose: true` in the second argument to fetch()";
2724        let err = patterns::classify_from_text(1, text);
2725        assert!(
2726            matches!(err, AgentError::NetworkError { .. }),
2727            "socket closed must be NetworkError, got: {err:?}"
2728        );
2729    }
2730
2731    #[test]
2732    fn classify_from_text_detects_socket_hang_up() {
2733        let err = patterns::classify_from_text(1, "Error: socket hang up");
2734        assert!(
2735            matches!(err, AgentError::NetworkError { .. }),
2736            "socket hang up must be NetworkError, got: {err:?}"
2737        );
2738    }
2739
2740    #[test]
2741    fn classify_from_text_detects_fetch_failed() {
2742        let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2743        assert!(
2744            matches!(err, AgentError::NetworkError { .. }),
2745            "fetch failed must be NetworkError, got: {err:?}"
2746        );
2747    }
2748
2749    #[test]
2750    fn classify_from_text_detects_econnreset() {
2751        let err = patterns::classify_from_text(1, "read ECONNRESET");
2752        assert!(
2753            matches!(err, AgentError::NetworkError { .. }),
2754            "ECONNRESET must be NetworkError, got: {err:?}"
2755        );
2756    }
2757
2758    #[test]
2759    fn classify_from_text_detects_stalled_mid_stream() {
2760        // Regression test for issue #3541: kimi's "Response stalled mid-stream" must be
2761        // classified as NetworkError, not Unknown/AgentFailed.
2762        let err = patterns::classify_from_text(
2763            1,
2764            "API Error: Response stalled mid-stream. The response above may be incomplete.",
2765        );
2766        assert!(
2767            matches!(err, AgentError::NetworkError { .. }),
2768            "stalled mid-stream must be NetworkError, got: {err:?}"
2769        );
2770    }
2771
2772    #[test]
2773    fn classify_from_text_detects_connection_closed_mid_response() {
2774        // Regression test for issue #3541: claude's "Connection closed mid-response" must be
2775        // classified as NetworkError, not Unknown/AgentFailed.
2776        let err = patterns::classify_from_text(
2777            1,
2778            "API Error: Connection closed mid-response. The response above may be incomplete.",
2779        );
2780        assert!(
2781            matches!(err, AgentError::NetworkError { .. }),
2782            "connection closed mid-response must be NetworkError, got: {err:?}"
2783        );
2784    }
2785
2786    #[test]
2787    fn synthesize_response_marks_done_for_plain_text_no_op() {
2788        let response = synthesize_response_from_text(
2789            "No open positions, no trade executions, no conditions change. Full cash, holding.",
2790        )
2791        .unwrap();
2792
2793        assert_eq!(response.status, "done");
2794        assert_eq!(
2795            response.summary,
2796            "No open positions, no trade executions, no conditions change. Full cash, holding."
2797        );
2798        assert!(response.error.is_none());
2799    }
2800
2801    #[test]
2802    fn synthesize_response_marks_needs_review_for_plain_text_error() {
2803        let response =
2804            synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2805
2806        assert_eq!(response.status, "needs_review");
2807        assert_eq!(
2808            response.error.as_deref(),
2809            Some("agent returned plain text instead of JSON")
2810        );
2811    }
2812
2813    #[test]
2814    fn synthesize_response_rejects_empty_text() {
2815        assert!(synthesize_response_from_text("   \n\t  ").is_none());
2816    }
2817
2818    /// Regression: raw NDJSON event streams (opencode/claude/codex stdout)
2819    /// must be rejected by the synthesizer. Without the guard, the
2820    /// "completed" substring inside tool state JSON triggers a false "done"
2821    /// classification and 500 bytes of raw NDJSON leak into the
2822    /// notification summary.
2823    #[test]
2824    fn synthesize_response_rejects_ndjson_event_stream() {
2825        let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2826{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2827{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2828        assert!(
2829            synthesize_response_from_text(ndjson).is_none(),
2830            "raw NDJSON must not be interpreted as agent prose"
2831        );
2832
2833        let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2834        assert!(
2835            synthesize_response_from_text(single).is_none(),
2836            "single-line NDJSON event must also be rejected"
2837        );
2838    }
2839
2840    #[test]
2841    fn synthesize_response_accepts_json_without_event_type() {
2842        // A JSON-like blob that does NOT match a known NDJSON event type
2843        // should still be allowed through to the regular synthesis path
2844        // (so the guard does not over-reach).
2845        let text = "The task is complete. Filed issue #1234 successfully.";
2846        let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2847        assert_eq!(response.status, "done");
2848    }
2849
2850    // Regression: opencode/nemotron-3-super-free and similar models sometimes exit
2851    // with code 0 after emitting only exploratory or startup text (e.g. "Let's go!",
2852    // planning prose) without completing any work.  The synthesis must return None for
2853    // such ambiguous text so the runner treats the run as an invalid response and
2854    // reroutes the task instead of falsely advancing it to needs_review / in_review.
2855    // (Issues #2404, #2653, #2666)
2856    #[test]
2857    fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2858        // Short greeting — not an error, not done, not an explicit negation.
2859        assert!(
2860            synthesize_response_from_text("Let's go!").is_none(),
2861            "startup greeting must not synthesize as needs_review"
2862        );
2863        // Exploratory planning prose — agent is thinking out loud, not reporting completion.
2864        assert!(
2865            synthesize_response_from_text(
2866                "I need to find where the stream command is implemented. \
2867                 Looking at the CLI structure, it seems like the stream functionality \
2868                 might be in the task module or another file. Let me check the CLI \
2869                 command structure. First, let me look at the task module since stream \
2870                 is related to tasks."
2871            )
2872            .is_none(),
2873            "exploratory prose must not synthesize as needs_review"
2874        );
2875    }
2876
2877    #[test]
2878    fn synthesize_response_does_not_mark_done_for_incomplete() {
2879        let response = synthesize_response_from_text("The task is incomplete").unwrap();
2880        assert_eq!(
2881            response.status, "needs_review",
2882            "\"incomplete\" should not match \"complete\""
2883        );
2884    }
2885
2886    #[test]
2887    fn synthesize_response_does_not_mark_done_for_not_done() {
2888        let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2889        assert_eq!(
2890            response.status, "needs_review",
2891            "\"not done\" should not match bare \"done\""
2892        );
2893    }
2894
2895    #[test]
2896    fn synthesize_response_does_not_mark_done_for_not_complete() {
2897        let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2898        assert_eq!(
2899            response.status, "needs_review",
2900            "\"not complete\" should not match \"complete\""
2901        );
2902    }
2903
2904    #[test]
2905    fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2906        let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2907        assert_eq!(
2908            response.status, "needs_review",
2909            "\"not yet completed\" should not match \"completed\""
2910        );
2911    }
2912
2913    #[test]
2914    fn synthesize_response_marks_done_for_completed() {
2915        let response =
2916            synthesize_response_from_text("Task has been completed successfully").unwrap();
2917        assert_eq!(response.status, "done");
2918    }
2919
2920    #[test]
2921    fn synthesize_response_marks_done_for_word_boundary_done() {
2922        let response = synthesize_response_from_text("All changes are done.").unwrap();
2923        assert_eq!(response.status, "done");
2924    }
2925
2926    #[test]
2927    fn synthesize_response_marks_done_for_issue_creation_text() {
2928        let response =
2929            synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2930                .unwrap();
2931
2932        assert_eq!(response.status, "done");
2933        assert!(response.error.is_none());
2934        assert!(response
2935            .summary
2936            .contains("Filed 3 high-value GitHub issues"));
2937    }
2938
2939    #[test]
2940    fn synthesize_response_marks_done_for_committed() {
2941        let response =
2942            synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2943        assert_eq!(response.status, "done");
2944    }
2945
2946    #[test]
2947    fn synthesize_response_rejects_exploratory_prose() {
2948        // Regression test: ensure exploratory analysis that mentions "let me check"
2949        // or planning language is NOT synthesized into a "done" completion.
2950        // With the ambiguous-text fix, this text correctly returns None (treated as
2951        // an invalid response / reroute signal) rather than synthesizing needs_review.
2952        // Crucially, it must NOT be marked "done".
2953        let exploratory = "I explored the codebase and found several .expect()/.unwrap() uses.\n\nLet me check each occurrence directly and run the tests to be sure. I may commit fixes afterwards.";
2954        match synthesize_response_from_text(exploratory) {
2955            None => {
2956                // Ambiguous exploratory prose correctly returns None — not marked done.
2957            }
2958            Some(resp) => {
2959                assert_ne!(
2960                    resp.status, "done",
2961                    "Exploratory prose must not be auto-marked done"
2962                );
2963            }
2964        }
2965    }
2966
2967    #[test]
2968    fn synthesize_response_marks_done_for_commit_created() {
2969        let response = synthesize_response_from_text(
2970            "Commit created locally. The push is being blocked by permissions.",
2971        )
2972        .unwrap();
2973        assert_eq!(response.status, "done");
2974    }
2975
2976    #[test]
2977    fn synthesize_response_does_not_mark_done_for_not_committed() {
2978        let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
2979        assert_eq!(
2980            response.status, "needs_review",
2981            "\"not committed\" should not match \"committed\""
2982        );
2983    }
2984
2985    // ── Regression: #1362/#1363 — agent text misclassified as needs_review ──
2986
2987    #[test]
2988    fn synthesize_response_marks_done_for_fix_is_complete() {
2989        // Exact agent output from task 30203 (issue #1363).
2990        // "The fix is complete" was misclassified as needs_review because
2991        // the pattern list had "completed" but not "complete".
2992        let response = synthesize_response_from_text(
2993            "All 73 cooldown tests pass. The fix is complete.\n\n\
2994             **Summary of changes:**\n\n\
2995             1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
2996             `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
2997        )
2998        .unwrap();
2999        assert_eq!(
3000            response.status, "done",
3001            "\"The fix is complete\" must be classified as done, not needs_review"
3002        );
3003    }
3004
3005    #[test]
3006    fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3007        // Exact agent output from task 30204 (issue #1362).
3008        // "All tests pass. The fix is clean and working." was misclassified
3009        // as needs_review because none of the looks_done patterns matched.
3010        let response = synthesize_response_from_text(
3011            "All tests pass. The fix is clean and working.\n\n\
3012             **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3013             - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3014             - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3015        )
3016        .unwrap();
3017        assert_eq!(
3018            response.status, "done",
3019            "\"All tests pass. The fix is clean and working.\" must be classified as done"
3020        );
3021    }
3022
3023    // ── parse_ndjson edge cases ───────────────────────────────────
3024
3025    #[test]
3026    fn parse_ndjson_handles_standard_lines() {
3027        let raw = r#"{"type":"text","text":"hello"}
3028{"type":"text","text":"world"}"#;
3029        let events = parse_ndjson(raw);
3030        assert_eq!(events.len(), 2);
3031        assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3032        assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3033    }
3034
3035    #[test]
3036    fn parse_ndjson_skips_empty_lines() {
3037        let raw = r#"{"type":"text"}
3038
3039{"type":"text"}
3040"#;
3041        let events = parse_ndjson(raw);
3042        assert_eq!(events.len(), 2);
3043    }
3044
3045    #[test]
3046    fn parse_ndjson_skips_malformed_lines() {
3047        let raw = r#"{"type":"valid"}
3048not json at all
3049{"type":"also_valid"}"#;
3050        let events = parse_ndjson(raw);
3051        assert_eq!(events.len(), 2);
3052    }
3053
3054    #[test]
3055    fn parse_ndjson_fallback_extracts_embedded_json() {
3056        // Edge case: JSON with closing fence inside string field
3057        let raw =
3058            r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3059        let events = parse_ndjson(raw);
3060        // Should have extracted the embedded JSON object
3061        assert!(
3062            !events.is_empty(),
3063            "should extract JSON objects via fallback"
3064        );
3065    }
3066
3067    #[test]
3068    fn parse_ndjson_fallback_handles_fragmented_objects() {
3069        // Edge case: JSON object spread across multiple lines (fragmented NDJSON)
3070        let raw = r#"{"type": "text
3071", "content": "hello
3072world"}"#;
3073        let events = parse_ndjson(raw);
3074        // Should still extract the JSON via fallback
3075        assert!(!events.is_empty(), "should handle fragmented JSON");
3076    }
3077
3078    // ── PermissionRules defaults ────────────────────────────────
3079
3080    #[test]
3081    fn permission_rules_default_is_autonomous() {
3082        let rules = PermissionRules::default();
3083        assert!(rules.autonomous);
3084    }
3085
3086    #[test]
3087    fn permission_rules_default_sandbox_is_workspace_write() {
3088        let rules = PermissionRules::default();
3089        assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3090    }
3091
3092    #[test]
3093    fn permission_rules_default_disallows_rm() {
3094        let rules = PermissionRules::default();
3095        assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3096        assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3097    }
3098
3099    #[test]
3100    fn permission_rules_default_no_allowed_edit_paths() {
3101        let rules = PermissionRules::default();
3102        assert!(rules.allowed_edit_paths.is_empty());
3103    }
3104
3105    // ── Permission translation across all agents ────────────────
3106
3107    /// Test that all agents handle the default permission rules consistently.
3108    #[test]
3109    fn all_agents_handle_default_permissions() {
3110        let perms = PermissionRules::default();
3111        let sys = "/tmp/sys.md";
3112        let msg = "/tmp/msg.md";
3113
3114        // Claude: should have bypassPermissions and rm disallowed
3115        let claude = get_runner("claude");
3116        let cmd = claude.build_command(None, "", sys, msg, &perms);
3117        assert!(
3118            cmd.contains("--permission-mode bypassPermissions"),
3119            "claude default: expected bypassPermissions, got: {cmd}"
3120        );
3121        assert!(
3122            cmd.contains("Bash(rm *)"),
3123            "claude default: expected rm disallowed, got: {cmd}"
3124        );
3125
3126        // Codex: autonomous + workspace-write → --sandbox workspace-write -c 'approval_policy="never"'
3127        let codex = get_runner("codex");
3128        let cmd = codex.build_command(None, "", sys, msg, &perms);
3129        assert!(
3130            cmd.contains("--sandbox workspace-write"),
3131            "codex default: expected --sandbox workspace-write, got: {cmd}"
3132        );
3133        assert!(
3134            cmd.contains(r#"-c 'approval_policy="never"'"#),
3135            "codex default: expected -c approval_policy=never, got: {cmd}"
3136        );
3137        assert!(
3138            !cmd.contains("--ask-for-approval"),
3139            "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3140        );
3141        assert!(
3142            !cmd.contains("--full-auto"),
3143            "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3144        );
3145
3146        // OpenCode: autonomous dispatch passes --dangerously-skip-permissions
3147        // and writes no config file (relies on opencode's built-in flag).
3148        let opencode = get_runner("opencode");
3149        let cmd = opencode.build_command(None, "", sys, msg, &perms);
3150        assert!(
3151            cmd.contains("--dangerously-skip-permissions"),
3152            "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3153        );
3154        assert!(
3155            !cmd.contains("opencode.json"),
3156            "opencode default: should not write a config file, got: {cmd}"
3157        );
3158        assert!(
3159            !cmd.contains("XDG_CONFIG_HOME"),
3160            "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3161        );
3162        assert!(
3163            !cmd.contains(".orch-opencode"),
3164            "opencode default: should not reference .orch-opencode, got: {cmd}"
3165        );
3166    }
3167
3168    /// Test supervised mode translates correctly for each agent.
3169    #[test]
3170    fn all_agents_handle_supervised_mode() {
3171        let perms = PermissionRules {
3172            autonomous: false,
3173            sandbox: SandboxLevel::WorkspaceWrite,
3174            disallowed_tools: vec![],
3175            allowed_tools: vec![],
3176            allowed_edit_paths: vec![],
3177            deny_read_only: false,
3178            extra_writable_dirs: vec![],
3179        };
3180        let sys = "/tmp/sys.md";
3181        let msg = "/tmp/msg.md";
3182
3183        // Claude: supervised → acceptEdits
3184        let claude = get_runner("claude");
3185        let cmd = claude.build_command(None, "", sys, msg, &perms);
3186        assert!(
3187            cmd.contains("--permission-mode acceptEdits"),
3188            "claude supervised: expected acceptEdits, got: {cmd}"
3189        );
3190
3191        // Codex: supervised → on-request
3192        let codex = get_runner("codex");
3193        let cmd = codex.build_command(None, "", sys, msg, &perms);
3194        assert!(
3195            cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3196            "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3197        );
3198        assert!(
3199            !cmd.contains("--ask-for-approval"),
3200            "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3201        );
3202    }
3203
3204    /// Test full access sandbox translates correctly for each agent.
3205    #[test]
3206    fn all_agents_handle_full_access_sandbox() {
3207        let perms = PermissionRules {
3208            autonomous: true,
3209            sandbox: SandboxLevel::FullAccess,
3210            disallowed_tools: vec![],
3211            allowed_tools: vec![],
3212            allowed_edit_paths: vec![],
3213            deny_read_only: false,
3214            extra_writable_dirs: vec![],
3215        };
3216        let sys = "/tmp/sys.md";
3217        let msg = "/tmp/msg.md";
3218
3219        // Claude: ignores sandbox level (no --sandbox flag)
3220        let claude = get_runner("claude");
3221        let cmd = claude.build_command(None, "", sys, msg, &perms);
3222        assert!(
3223            !cmd.contains("--sandbox"),
3224            "claude full_access: should have no sandbox flag, got: {cmd}"
3225        );
3226
3227        // Codex: full access → --dangerously-bypass-approvals-and-sandbox
3228        let codex = get_runner("codex");
3229        let cmd = codex.build_command(None, "", sys, msg, &perms);
3230        assert!(
3231            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3232            "codex full_access: expected dangerously-bypass, got: {cmd}"
3233        );
3234    }
3235
3236    /// SandboxLevel::None with autonomous uses --sandbox workspace-write (same as WorkspaceWrite).
3237    #[test]
3238    fn codex_sandbox_none_uses_workspace_write() {
3239        let perms = PermissionRules {
3240            autonomous: true,
3241            sandbox: SandboxLevel::None,
3242            disallowed_tools: vec![],
3243            allowed_tools: vec![],
3244            allowed_edit_paths: vec![],
3245            deny_read_only: false,
3246            extra_writable_dirs: vec![],
3247        };
3248        let codex = get_runner("codex");
3249        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3250        assert!(
3251            cmd.contains("--sandbox workspace-write"),
3252            "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3253        );
3254        assert!(
3255            cmd.contains(r#"-c 'approval_policy="never"'"#),
3256            "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3257        );
3258        assert!(
3259            !cmd.contains("--ask-for-approval"),
3260            "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3261        );
3262        assert!(
3263            !cmd.contains("--full-auto"),
3264            "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3265        );
3266    }
3267
3268    /// Test that allowed_edit_paths scopes Edit/Write when used with allowed_tools.
3269    #[test]
3270    fn claude_allowed_edit_paths_scope_edit_write() {
3271        let perms = PermissionRules {
3272            autonomous: true,
3273            sandbox: SandboxLevel::WorkspaceWrite,
3274            disallowed_tools: vec![],
3275            allowed_tools: vec![
3276                "Edit".to_string(),
3277                "Write".to_string(),
3278                "Read".to_string(),
3279                "Bash".to_string(),
3280            ],
3281            allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3282            deny_read_only: false,
3283            extra_writable_dirs: vec![],
3284        };
3285        let claude = get_runner("claude");
3286        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3287
3288        // Edit/Write should be scoped to worktree path
3289        assert!(
3290            cmd.contains("Edit(/home/user/worktree/*)"),
3291            "missing scoped Edit, got: {cmd}"
3292        );
3293        assert!(
3294            cmd.contains("Write(/home/user/worktree/*)"),
3295            "missing scoped Write, got: {cmd}"
3296        );
3297        // Read should be unrestricted
3298        assert!(cmd.contains("Read"), "missing Read");
3299    }
3300
3301    /// Test that blocked paths don't affect Codex (it uses sandbox isolation).
3302    #[test]
3303    fn codex_ignores_allowed_edit_paths() {
3304        let perms = PermissionRules {
3305            autonomous: true,
3306            sandbox: SandboxLevel::WorkspaceWrite,
3307            disallowed_tools: vec![],
3308            allowed_tools: vec![],
3309            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3310            deny_read_only: false,
3311            extra_writable_dirs: vec![],
3312        };
3313        let codex = get_runner("codex");
3314        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3315        assert!(
3316            !cmd.contains("/home/user/project"),
3317            "codex: should not reference blocked paths, got: {cmd}"
3318        );
3319    }
3320
3321    /// Test that blocked paths don't affect OpenCode.
3322    #[test]
3323    fn opencode_ignores_allowed_edit_paths() {
3324        let perms = PermissionRules {
3325            autonomous: true,
3326            sandbox: SandboxLevel::WorkspaceWrite,
3327            disallowed_tools: vec![],
3328            allowed_tools: vec![],
3329            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3330            deny_read_only: false,
3331            extra_writable_dirs: vec![],
3332        };
3333        let opencode = get_runner("opencode");
3334        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3335        assert!(
3336            !cmd.contains("/home/user/project"),
3337            "opencode: should not reference blocked paths, got: {cmd}"
3338        );
3339    }
3340
3341    /// Test that disallowed tools merge correctly for Claude (defaults + custom).
3342    #[test]
3343    fn claude_merges_disallowed_tools() {
3344        let perms = PermissionRules {
3345            autonomous: true,
3346            sandbox: SandboxLevel::WorkspaceWrite,
3347            disallowed_tools: vec![
3348                "Bash(rm *)".to_string(),
3349                "Bash(sudo *)".to_string(),
3350                "WebFetch".to_string(),
3351            ],
3352            allowed_tools: vec![],
3353            allowed_edit_paths: vec![],
3354            deny_read_only: false,
3355            extra_writable_dirs: vec![],
3356        };
3357        let claude = get_runner("claude");
3358        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3359        assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3360        assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3361        assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3362    }
3363
3364    /// Test that Codex ignores disallowed tools (has no such flag).
3365    #[test]
3366    fn codex_ignores_disallowed_tools() {
3367        let perms = PermissionRules {
3368            autonomous: true,
3369            sandbox: SandboxLevel::WorkspaceWrite,
3370            disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3371            allowed_tools: vec![],
3372            allowed_edit_paths: vec![],
3373            deny_read_only: false,
3374            extra_writable_dirs: vec![],
3375        };
3376        let codex = get_runner("codex");
3377        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3378        assert!(
3379            !cmd.contains("disallow"),
3380            "codex: should not have disallow flags, got: {cmd}"
3381        );
3382    }
3383
3384    /// Test Claude with empty disallowed tools produces no --disallowedTools flag.
3385    #[test]
3386    fn claude_no_disallowed_flag_when_empty() {
3387        let perms = PermissionRules {
3388            autonomous: true,
3389            sandbox: SandboxLevel::WorkspaceWrite,
3390            disallowed_tools: vec![],
3391            allowed_tools: vec![],
3392            allowed_edit_paths: vec![],
3393            deny_read_only: false,
3394            extra_writable_dirs: vec![],
3395        };
3396        let claude = get_runner("claude");
3397        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3398        assert!(
3399            !cmd.contains("--disallowedTools"),
3400            "claude: should have no disallowed flag when empty, got: {cmd}"
3401        );
3402    }
3403
3404    /// Test Kimi/MiniMax aliases inherit Claude permission translation.
3405    #[test]
3406    fn kimi_minimax_use_claude_permissions() {
3407        let perms = PermissionRules {
3408            autonomous: false,
3409            sandbox: SandboxLevel::WorkspaceWrite,
3410            disallowed_tools: vec!["Bash(rm *)".to_string()],
3411            allowed_tools: vec![],
3412            allowed_edit_paths: vec![],
3413            deny_read_only: false,
3414            extra_writable_dirs: vec![],
3415        };
3416        let sys = "/tmp/sys.md";
3417        let msg = "/tmp/msg.md";
3418
3419        for agent in &["kimi", "minimax"] {
3420            let runner = get_runner(agent);
3421            let cmd = runner.build_command(None, "", sys, msg, &perms);
3422            assert!(
3423                cmd.contains("--permission-mode acceptEdits"),
3424                "{agent}: expected acceptEdits"
3425            );
3426            assert!(
3427                cmd.contains("Bash(rm *)"),
3428                "{agent}: expected rm disallowed"
3429            );
3430        }
3431    }
3432
3433    /// Test kimi/minimax get --allowedTools when allowed_tools is set.
3434    #[test]
3435    fn kimi_minimax_use_allowed_tools() {
3436        let perms = PermissionRules {
3437            autonomous: true,
3438            sandbox: SandboxLevel::WorkspaceWrite,
3439            disallowed_tools: vec![],
3440            allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3441            allowed_edit_paths: vec![],
3442            deny_read_only: false,
3443            extra_writable_dirs: vec![],
3444        };
3445
3446        for agent in &["kimi", "minimax"] {
3447            let runner = get_runner(agent);
3448            let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3449            assert!(
3450                cmd.contains("--allowedTools"),
3451                "{agent}: expected --allowedTools, got: {cmd}"
3452            );
3453            assert!(
3454                cmd.contains("Bash(git *)"),
3455                "{agent}: expected Bash(git *) pattern, got: {cmd}"
3456            );
3457        }
3458    }
3459
3460    // ── Allowed tools across agents ─────────────────────────────
3461
3462    /// Test that allowed_tools generates --allowedTools for Claude.
3463    #[test]
3464    fn claude_allowed_tools_generates_allowedtools_flag() {
3465        let perms = PermissionRules {
3466            autonomous: true,
3467            sandbox: SandboxLevel::WorkspaceWrite,
3468            disallowed_tools: vec!["Bash(rm *)".to_string()],
3469            allowed_tools: vec![
3470                "Edit".to_string(),
3471                "Write".to_string(),
3472                "Read".to_string(),
3473                "Bash".to_string(),
3474                "git".to_string(),
3475            ],
3476            allowed_edit_paths: vec![],
3477            deny_read_only: false,
3478            extra_writable_dirs: vec![],
3479        };
3480
3481        let claude = get_runner("claude");
3482        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3483        assert!(
3484            cmd.contains("--allowedTools"),
3485            "expected --allowedTools, got: {cmd}"
3486        );
3487        assert!(
3488            !cmd.contains("--disallowedTools"),
3489            "should not have --disallowedTools when allowed_tools is set"
3490        );
3491    }
3492
3493    /// Test that codex/opencode ignore allowed_tools (no equivalent flag).
3494    #[test]
3495    fn codex_opencode_ignore_allowed_tools() {
3496        let perms = PermissionRules {
3497            autonomous: true,
3498            sandbox: SandboxLevel::WorkspaceWrite,
3499            disallowed_tools: vec![],
3500            allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3501            allowed_edit_paths: vec![],
3502            deny_read_only: false,
3503            extra_writable_dirs: vec![],
3504        };
3505
3506        let codex = get_runner("codex");
3507        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3508        assert!(
3509            !cmd.contains("--allowedTools"),
3510            "codex should not have --allowedTools, got: {cmd}"
3511        );
3512
3513        let opencode = get_runner("opencode");
3514        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3515        assert!(
3516            !cmd.contains("--allowedTools"),
3517            "opencode should not have --allowedTools, got: {cmd}"
3518        );
3519    }
3520
3521    // ── Integration: from_config() → agent translation ──────────
3522
3523    /// Integration test: verify from_config() loads real config and agents
3524    /// translate it correctly. Requires ~/.orch/config.yml to exist.
3525    #[test]
3526    #[ignore]
3527    fn integration_from_config_to_agent_commands() {
3528        let perms = PermissionRules::from_config();
3529
3530        // Verify defaults are reasonable
3531        assert!(
3532            perms.autonomous,
3533            "from_config: expected autonomous=true by default"
3534        );
3535
3536        // Verify each agent can build a command from the config-loaded rules
3537        for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3538            let runner = get_runner(agent_name);
3539            let cmd =
3540                runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3541            assert!(
3542                !cmd.is_empty(),
3543                "{agent_name}: build_command returned empty string"
3544            );
3545            assert!(
3546                cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3547                "{agent_name}: command should reference agent binary, got: {cmd}"
3548            );
3549        }
3550    }
3551
3552    /// Integration test: verify from_config() handles supervised mode.
3553    /// Requires config with workflow.permissions.mode = "supervised".
3554    #[test]
3555    #[ignore]
3556    fn integration_supervised_config_translates_correctly() {
3557        // This test requires config to have:
3558        // workflow:
3559        //   permissions:
3560        //     mode: supervised
3561        let perms = PermissionRules::from_config();
3562
3563        let claude = get_runner("claude");
3564        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3565
3566        if perms.autonomous {
3567            assert!(
3568                cmd.contains("bypassPermissions"),
3569                "autonomous config → bypassPermissions"
3570            );
3571        } else {
3572            assert!(
3573                cmd.contains("acceptEdits"),
3574                "supervised config → acceptEdits"
3575            );
3576        }
3577
3578        let codex = get_runner("codex");
3579        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3580
3581        if perms.autonomous {
3582            // autonomous codex uses --sandbox workspace-write -c 'approval_policy="never"'
3583            assert!(
3584                cmd.contains("--sandbox workspace-write"),
3585                "autonomous config → --sandbox workspace-write, got: {cmd}"
3586            );
3587            assert!(
3588                cmd.contains(r#"-c 'approval_policy="never"'"#),
3589                "autonomous config → -c approval_policy=never, got: {cmd}"
3590            );
3591        } else {
3592            assert!(
3593                cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3594                "supervised config → -c approval_policy=on-request, got: {cmd}"
3595            );
3596        }
3597        assert!(
3598            !cmd.contains("--ask-for-approval"),
3599            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3600        );
3601    }
3602
3603    // ── deny_read_only preset ───────────────────────────────────
3604
3605    /// Test that deny_read_only() sets the right disallowed tools for control sessions.
3606    #[test]
3607    fn deny_read_only_blocks_reading_tools() {
3608        let perms = PermissionRules::deny_read_only();
3609
3610        let read_tools = [
3611            "Bash(ls *)",
3612            "Bash(ls)",
3613            "Bash(find *)",
3614            "Bash(cat *)",
3615            "Bash(head *)",
3616            "Bash(tail *)",
3617            "Bash(fzf *)",
3618            "Bash(less *)",
3619            "Bash(more *)",
3620            "Read",
3621            "Glob",
3622            "Grep",
3623        ];
3624
3625        for tool in read_tools {
3626            assert!(
3627                perms.disallowed_tools.contains(&tool.to_string()),
3628                "deny_read_only should block {tool}, got: {:?}",
3629                perms.disallowed_tools
3630            );
3631        }
3632    }
3633
3634    /// Test that deny_read_only() produces correct Claude command for control session.
3635    #[test]
3636    fn deny_read_only_claude_command() {
3637        let perms = PermissionRules::deny_read_only();
3638        let claude = get_runner("claude");
3639        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3640
3641        // Should contain Read, Glob, Grep disallow patterns
3642        assert!(
3643            cmd.contains("Read"),
3644            "should block Read for control session"
3645        );
3646        assert!(
3647            cmd.contains("Glob"),
3648            "should block Glob for control session"
3649        );
3650        assert!(
3651            cmd.contains("Grep"),
3652            "should block Grep for control session"
3653        );
3654        // Should block ls
3655        assert!(
3656            cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3657            "should block ls"
3658        );
3659        // Should still have bypassPermissions (autonomous)
3660        assert!(
3661            cmd.contains("--permission-mode bypassPermissions"),
3662            "should be autonomous (bypassPermissions)"
3663        );
3664    }
3665
3666    // --- synthesize_response_from_text structured extraction ---
3667
3668    #[test]
3669    fn synthesize_extracts_file_paths() {
3670        let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3671        let resp = synthesize_response_from_text(text).unwrap();
3672        assert!(
3673            resp.files
3674                .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3675            "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3676            resp.files
3677        );
3678        assert!(
3679            resp.files.contains(&"tests/integration.rs".to_string()),
3680            "expected tests/integration.rs in files, got: {:?}",
3681            resp.files
3682        );
3683    }
3684
3685    #[test]
3686    fn synthesize_extracts_accomplished_bullets() {
3687        let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3688        let resp = synthesize_response_from_text(text).unwrap();
3689        assert_eq!(resp.status, "done");
3690        assert!(
3691            resp.accomplished
3692                .iter()
3693                .any(|a| a.contains("Added logging")),
3694            "expected accomplished bullet, got: {:?}",
3695            resp.accomplished
3696        );
3697        assert!(
3698            resp.accomplished
3699                .iter()
3700                .any(|a| a.contains("Updated migration")),
3701            "expected accomplished bullet, got: {:?}",
3702            resp.accomplished
3703        );
3704    }
3705
3706    #[test]
3707    fn synthesize_extracts_remaining_section() {
3708        let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3709        let resp = synthesize_response_from_text(text).unwrap();
3710        assert!(
3711            resp.remaining
3712                .iter()
3713                .any(|r| r.contains("Write unit tests")),
3714            "expected remaining item, got: {:?}",
3715            resp.remaining
3716        );
3717        assert!(
3718            resp.remaining.iter().any(|r| r.contains("Update docs")),
3719            "expected remaining item, got: {:?}",
3720            resp.remaining
3721        );
3722        assert!(
3723            resp.accomplished.iter().any(|a| a.contains("Patched")),
3724            "expected accomplished item, got: {:?}",
3725            resp.accomplished
3726        );
3727    }
3728
3729    #[test]
3730    fn synthesize_fixed_phrase_is_done() {
3731        let text = "Fixed. The bug no longer reproduces.";
3732        let resp = synthesize_response_from_text(text).unwrap();
3733        assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3734    }
3735
3736    // ── detect_rate_limit false-positive guard ────────────────────────────
3737    // Regression: NDJSON telemetry blobs can contain "429" as a token count
3738    // (e.g. "outputTokens":429). `detect_rate_limit` should still flag those
3739    // because it cannot distinguish context without the terminal_reason guard.
3740    // The guard lives in review.rs, but here we verify the raw detector
3741    // behaviour so callers know they must apply the guard themselves.
3742    #[test]
3743    fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3744        // This SHOULD return Some — the generic detector has no context.
3745        // The caller (review.rs) is responsible for suppressing it when
3746        // terminal_reason:completed is present.
3747        let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3748        assert!(
3749            patterns::detect_rate_limit(payload).is_some(),
3750            "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3751        );
3752    }
3753
3754    // Verify the guard logic that review.rs uses: when raw NDJSON has
3755    // terminal_reason:completed without is_error:true, the result of
3756    // detect_rate_limit should be suppressed.
3757    #[test]
3758    fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3759        let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3760        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3761            && !raw_output.contains("\"is_error\":true");
3762        // Apply the same guard that review.rs now uses.
3763        let detected = (!ndjson_completed)
3764            .then(|| patterns::detect_rate_limit(raw_output))
3765            .flatten();
3766        assert!(
3767            detected.is_none(),
3768            "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3769        );
3770    }
3771
3772    // Verify the guard does NOT suppress a real rate-limit error.
3773    #[test]
3774    fn terminal_reason_completed_guard_passes_real_rate_limit() {
3775        // Real rate-limit output has no terminal_reason:completed.
3776        let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3777        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3778            && !raw_output.contains("\"is_error\":true");
3779        let detected = (!ndjson_completed)
3780            .then(|| patterns::detect_rate_limit(raw_output))
3781            .flatten();
3782        assert!(
3783            detected.is_some(),
3784            "guard must NOT suppress a real rate-limit error"
3785        );
3786    }
3787
3788    #[test]
3789    fn synthesize_no_false_positive_file_paths() {
3790        // URLs and version strings should not appear in files list.
3791        // The text is ambiguous (no error, no done signal, no explicit negation) so
3792        // synthesize_response_from_text returns None — no file extraction happens.
3793        let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3794        match synthesize_response_from_text(text) {
3795            None => {} // Ambiguous text correctly returns None — no files extracted.
3796            Some(resp) => {
3797                assert!(
3798                    resp.files.is_empty(),
3799                    "expected no file paths extracted from URL text, got: {:?}",
3800                    resp.files
3801                );
3802            }
3803        }
3804    }
3805
3806    // Regression: issue #3197 — CLI arg-parser diagnostics were being synthesized as
3807    // agent responses. When a process (e.g. codex) fails with an invalid CLI flag, it
3808    // exits non-zero with a clap/help diagnostic on stderr. This should be rejected
3809    // (return None) rather than synthesized as a "needs_review" response.
3810    #[test]
3811    fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3812        // Real clap error from the bug report (codex exit 141)
3813        let text = "error: unexpected argument '--ask-for-approval' found\n  \
3814                    tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3815                    Usage: codex exec [OPTIONS] [PROMPT]";
3816        assert!(
3817            synthesize_response_from_text(text).is_none(),
3818            "CLI parser diagnostic must not synthesize as agent response"
3819        );
3820    }
3821
3822    #[test]
3823    fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3824        let text = "Usage: mycommand [OPTIONS] <arg>";
3825        assert!(
3826            synthesize_response_from_text(text).is_none(),
3827            "Usage line must not synthesize as agent response"
3828        );
3829    }
3830
3831    #[test]
3832    fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3833        let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3834        assert!(
3835            synthesize_response_from_text(text).is_none(),
3836            "Help hint must not synthesize as agent response"
3837        );
3838    }
3839
3840    #[test]
3841    fn synthesize_rejects_cli_parser_diagnostics_tip() {
3842        let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3843        assert!(
3844            synthesize_response_from_text(text).is_none(),
3845            "Clap tip must not synthesize as agent response"
3846        );
3847    }
3848
3849    #[test]
3850    fn synthesize_accepts_agent_error_messages_that_mention_error() {
3851        // Agent returning plain text that happens to contain "error:" but is an actual
3852        // agent message (e.g. summarizing what went wrong) should still synthesize.
3853        let text = "I tried to run the command but got an error: permission denied on that file.";
3854        let response =
3855            synthesize_response_from_text(text).expect("agent error message should synthesize");
3856        assert_eq!(response.status, "needs_review");
3857        assert!(response.error.is_some());
3858    }
3859
3860    // Regression: Issue #3197 — exact clap error from bug report.
3861    // When codex fails with an invalid CLI flag (e.g., --ask-for-approval with old codex versions),
3862    // it should return None, not synthesize as "needs_review". Without this fix, the run
3863    // would be marked as success and advance to in_review, wasting resources.
3864    #[test]
3865    fn synthesize_rejects_exact_codex_cli_error() {
3866        let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n  \
3867                                 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3868                                 Usage: codex exec [OPTIONS] [PROMPT]";
3869        assert!(
3870            synthesize_response_from_text(stderr_from_codex).is_none(),
3871            "exact codex CLI error from #3197 must not synthesize as agent response"
3872        );
3873    }
3874}