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