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 a digit, so `"outputTokens":429` still
1272        // matches and a UUID digit run like `4295` does not
1273        let has_429 = contains_standalone_digits(&lower, "429");
1274        // HTTP 529 is used by Cloudflare for rate limiting. Only match in HTTP status
1275        // contexts (e.g. "HTTP 529", "status: 529") to avoid false positives from bare
1276        // numbers like line numbers, port numbers, or file sizes.
1277        let has_529 = lower.contains("http 529")
1278            || lower.contains("529 service")
1279            || (lower.find(": 529").is_some_and(|pos| {
1280                !lower[pos + 5..]
1281                    .chars()
1282                    .next()
1283                    .is_some_and(|c| c.is_ascii_digit())
1284            }));
1285
1286        if match_info.is_some() || has_429 || has_529 {
1287            let message = if let Some((_, pos)) = match_info {
1288                extract_context_around(text, pos, 300)
1289            } else {
1290                safe_tail(text, 300).to_string()
1291            };
1292            return Some(AgentError::RateLimit { message });
1293        }
1294        None
1295    }
1296
1297    /// Extract up to `window` bytes of context centred around `byte_pos`.
1298    ///
1299    /// `byte_pos` must be a valid byte offset in `text` (not derived from a
1300    /// transformed copy).  Both ends are snapped to char boundaries to avoid
1301    /// splitting multi-byte codepoints.
1302    fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1303        let half = window / 2;
1304        let start = byte_pos.saturating_sub(half);
1305        let end = (byte_pos + window).min(text.len());
1306        // Align both ends to char boundaries.
1307        let mut s = start;
1308        while s < text.len() && !text.is_char_boundary(s) {
1309            s += 1;
1310        }
1311        let mut e = end;
1312        while e < text.len() && !text.is_char_boundary(e) {
1313            e += 1;
1314        }
1315        text[s..e].to_string()
1316    }
1317
1318    /// True when `digits` appears in `lower` with a non-digit (or string edge) on both sides
1319    fn contains_standalone_digits(lower: &str, digits: &str) -> bool {
1320        let mut start = 0;
1321        while let Some(rel) = lower[start..].find(digits) {
1322            let pos = start + rel;
1323            let before_ok = pos == 0
1324                || !lower[..pos]
1325                    .chars()
1326                    .next_back()
1327                    .is_some_and(|c| c.is_ascii_digit());
1328            let after = pos + digits.len();
1329            let after_ok = after >= lower.len()
1330                || !lower[after..]
1331                    .chars()
1332                    .next()
1333                    .is_some_and(|c| c.is_ascii_digit());
1334            if before_ok && after_ok {
1335                return true;
1336            }
1337            start = pos + 1;
1338        }
1339        false
1340    }
1341
1342    /// Returns true if `lower` contains the HTTP status `code` (e.g. "401") as a
1343    /// standalone number — not as part of a larger digit sequence like `4010292`.
1344    ///
1345    /// Matches:
1346    ///   - `"http 401"` / `"http/1.1 401"`
1347    ///   - `"401 unauthorized"` / `"401\n"` / `"401"` at end-of-string
1348    ///   - `": 401"` when not immediately followed by another digit
1349    pub fn contains_http_status(lower: &str, code: &str) -> bool {
1350        // Fast prefix checks that are inherently unambiguous.
1351        if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1352            return true;
1353        }
1354        // ": NNN" — only accept when the next character is not a digit.
1355        let needle = format!(": {code}");
1356        let mut start = 0;
1357        while let Some(rel) = lower[start..].find(needle.as_str()) {
1358            let after = start + rel + needle.len();
1359            match lower[after..].chars().next() {
1360                Some(c) if c.is_ascii_digit() => {
1361                    // Part of a longer number (e.g. ": 4010292") — skip.
1362                    start = after;
1363                }
1364                _ => return true,
1365            }
1366        }
1367        false
1368    }
1369
1370    /// Check for auth / billing error patterns in text.
1371    ///
1372    /// Returns the line containing the match (not the tail) so the stored
1373    /// error message is the actual error string rather than trailing output
1374    /// (e.g. NDJSON session metadata that agents emit at exit).
1375    pub fn detect_auth_error(text: &str) -> Option<AgentError> {
1376        let lower = text.to_lowercase();
1377        let patterns = [
1378            "unauthorized",
1379            "authentication required",
1380            "invalid api",
1381            "invalid key",
1382            "invalid token",
1383            "auth fail",
1384            "no api key",
1385            "no token",
1386            "expired key",
1387            "expired token",
1388            "expired plan",
1389            // Use specific billing-related phrases instead of the bare word
1390            // "billing" which produced false positives when agents mentioned
1391            // billing in non-error contexts (e.g. reviews discussing billing).
1392            "billing plan",
1393            "billing error",
1394            "billing suspended",
1395            "billing expired",
1396            "billing limit",
1397            "billing failed",
1398            "billing cycle exhausted",
1399            "insufficient credit",
1400            "insufficient balance",
1401            "credit balance too low",
1402            "payment required",
1403        ];
1404        // Use standalone check for HTTP status codes (same rationale as
1405        // detect_rate_limit: avoid matching port numbers, line numbers, etc.).
1406        let http_401 = contains_http_status(&lower, "401");
1407        let http_403 = contains_http_status(&lower, "403");
1408        let http_407 = contains_http_status(&lower, "407");
1409        let proxy_auth_required = lower.contains("proxy authentication required");
1410        if !patterns.iter().any(|p| lower.contains(p))
1411            && !http_401
1412            && !http_403
1413            && !http_407
1414            && !proxy_auth_required
1415        {
1416            return None;
1417        }
1418        // Find the first auth error match position to extract its line.
1419        let first_match_pos =
1420            patterns
1421                .iter()
1422                .filter_map(|p| lower.find(p))
1423                .min()
1424                .map(|lower_pos| {
1425                    let char_idx = lower[..lower_pos].chars().count();
1426                    text.char_indices()
1427                        .nth(char_idx)
1428                        .map_or(text.len(), |(i, _)| i)
1429                });
1430        // Also check HTTP status codes for standalone matches (not caught by
1431        // pattern strings like "401" is too short to add as a pattern).
1432        let http_401_match_pos = if http_401 {
1433            lower.find("401 ").or_else(|| lower.find(": 401"))
1434        } else {
1435            None
1436        };
1437        let http_403_match_pos = if http_403 {
1438            lower.find("403 ").or_else(|| lower.find(": 403"))
1439        } else {
1440            None
1441        };
1442        let http_407_match_pos = if http_407 {
1443            lower.find("407 ").or_else(|| lower.find(": 407"))
1444        } else {
1445            None
1446        };
1447        let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1448            .into_iter()
1449            .flatten()
1450            .min()
1451            .and_then(|rel| {
1452                let char_idx = lower[..rel].chars().count();
1453                text.char_indices().nth(char_idx).map(|(i, _)| i)
1454            });
1455        let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1456        let message = if let Some(pos) = match_pos {
1457            find_line_containing(text, pos)
1458        } else {
1459            // Pattern matched but couldn't find position (e.g. 401 standalone).
1460            // Fall back to scanning last 50 lines for the error line.
1461            find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1462        };
1463        Some(AgentError::Auth { message })
1464    }
1465
1466    /// Returns the full line containing `byte_pos` in `text`.
1467    fn find_line_containing(text: &str, byte_pos: usize) -> String {
1468        let start = text[..byte_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
1469        let end = text[byte_pos..]
1470            .find('\n')
1471            .map(|i| byte_pos + i)
1472            .unwrap_or(text.len());
1473        text[start..end].trim().to_string()
1474    }
1475
1476    /// Scans the last 50 lines of `text` for a line containing an auth error
1477    /// pattern (401, unauthorized, invalid api, etc.). Used as fallback when
1478    /// match position is unavailable.
1479    fn find_auth_line_scan(text: &str) -> Option<String> {
1480        for line in text.lines().rev().take(50) {
1481            let line_lower = line.to_lowercase();
1482            if contains_http_status(&line_lower, "401")
1483                || contains_http_status(&line_lower, "403")
1484                || contains_http_status(&line_lower, "407")
1485                || line_lower.contains("unauthorized")
1486                || line_lower.contains("authentication required")
1487                || line_lower.contains("invalid api")
1488                || line_lower.contains("invalid key")
1489                || line_lower.contains("invalid token")
1490                || line_lower.contains("auth fail")
1491                || line_lower.contains("no api key")
1492                || line_lower.contains("no token")
1493                || line_lower.contains("expired key")
1494                || line_lower.contains("expired token")
1495                || line_lower.contains("expired plan")
1496                || line_lower.contains("billing plan")
1497                || line_lower.contains("billing error")
1498                || line_lower.contains("billing suspended")
1499                || line_lower.contains("billing expired")
1500                || line_lower.contains("billing limit")
1501                || line_lower.contains("billing failed")
1502                || line_lower.contains("billing cycle exhausted")
1503                || line_lower.contains("insufficient credit")
1504                || line_lower.contains("credit balance too low")
1505                || line_lower.contains("payment required")
1506                || line_lower.contains("proxy authentication required")
1507            {
1508                return Some(line.trim().to_string());
1509            }
1510        }
1511        None
1512    }
1513
1514    /// Check for transient network connectivity errors.
1515    pub fn detect_network_error(text: &str) -> Option<AgentError> {
1516        let lower = text.to_lowercase();
1517        let patterns = [
1518            "connectionrefused",
1519            "connection refused",
1520            "unable to connect",
1521            "econnrefused",
1522            "network unreachable",
1523            // Socket / HTTP transport errors (Node fetch / undici / http module)
1524            "socket connection was closed",
1525            "socket hang up",
1526            "econnreset",
1527            "connection reset",
1528            "etimedout",
1529            "connect etimedout",
1530            "aborterror",
1531            "the operation was aborted",
1532            "network request failed",
1533            "fetch failed",
1534            // Upstream HTTP-layer timeouts from model provider APIs (e.g. opencode/nemotron)
1535            "upstream idle timeout",
1536            "streaming response failed",
1537            "upstream request failed",
1538            // Provider-side mid-stream disconnects (e.g. kimi/claude API errors)
1539            "stalled mid-stream",
1540            "connection closed mid-response",
1541        ];
1542        // Map the byte offset from `lower` back to `text` via char-count (same
1543        // rationale as detect_rate_limit: Unicode case-folding can change byte lengths).
1544        let match_pos = patterns
1545            .iter()
1546            .filter_map(|p| lower.find(p))
1547            .min()
1548            .map(|lower_pos| {
1549                let char_idx = lower[..lower_pos].chars().count();
1550                text.char_indices()
1551                    .nth(char_idx)
1552                    .map_or(text.len(), |(i, _)| i)
1553            });
1554        if let Some(pos) = match_pos {
1555            return Some(AgentError::NetworkError {
1556                message: extract_context_around(text, pos, 300),
1557            });
1558        }
1559        None
1560    }
1561
1562    /// Check for a plain-text "model not found" / "model unavailable" error.
1563    ///
1564    /// Mirrors the model-unavailable detection already present in codex.rs's
1565    /// and opencode.rs's structured NDJSON classifiers (`classify_message`,
1566    /// `classify_opencode_message`), for the case where the same failure
1567    /// surfaces as raw stdout/stderr text instead of a recognizable NDJSON
1568    /// event. Without this, the message falls through to `AgentError::Unknown`,
1569    /// which bumps the agent-wide failure cooldown instead of the
1570    /// model-scoped one that `ModelUnavailable` is exempted into.
1571    pub fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1572        let lower = text.to_lowercase();
1573        let is_model_unavailable = lower.contains("model metadata")
1574            || lower.contains("no endpoints found")
1575            || (lower.contains("model")
1576                && (lower.contains("not found")
1577                    || lower.contains("not supported")
1578                    || lower.contains("does not exist")
1579                    || lower.contains("deprecated")
1580                    || lower.contains("unavailable for free")
1581                    || lower.contains("not available in your country")
1582                    || lower.contains("not available in your region")));
1583        if !is_model_unavailable {
1584            return None;
1585        }
1586        let model = extract_quoted(text, '`')
1587            .or_else(|| extract_quoted(text, '\''))
1588            .unwrap_or_default();
1589        Some(AgentError::ModelUnavailable {
1590            message: safe_tail(text, 300).to_string(),
1591            model,
1592        })
1593    }
1594
1595    /// Extract text between the first matching pair of `quote` characters.
1596    fn extract_quoted(text: &str, quote: char) -> Option<String> {
1597        let start = text.find(quote)?;
1598        let rest = &text[start + quote.len_utf8()..];
1599        let end = rest.find(quote)?;
1600        Some(rest[..end].to_string())
1601    }
1602
1603    /// Check for missing worktree / working directory setup failures.
1604    pub fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1605        let lower = text.to_lowercase();
1606        let patterns = ["worktree directory does not exist"];
1607        if patterns.iter().any(|p| lower.contains(p)) {
1608            return Some(AgentError::AgentFailed {
1609                message: safe_tail(text, 300).to_string(),
1610            });
1611        }
1612        None
1613    }
1614
1615    /// Check for context overflow patterns.
1616    pub fn detect_context_overflow(text: &str) -> Option<AgentError> {
1617        let lower = text.to_lowercase();
1618        let patterns = [
1619            "context_length_exceeded",
1620            "context length exceeded",
1621            "maximum context length",
1622            "too many tokens",
1623            "token limit",
1624            "context overflow",
1625        ];
1626        if patterns.iter().any(|p| lower.contains(p)) {
1627            return Some(AgentError::ContextOverflow {
1628                message: safe_tail(text, 300).to_string(),
1629            });
1630        }
1631        None
1632    }
1633
1634    /// Check for missing tooling patterns. Returns the tool name.
1635    pub fn detect_missing_tool(text: &str) -> Option<AgentError> {
1636        let known_tools = [
1637            "bun",
1638            "node",
1639            "npm",
1640            "pnpm",
1641            "yarn",
1642            "deno",
1643            "tsc",
1644            "eslint",
1645            "prettier",
1646            "jest",
1647            "vitest",
1648            "cargo",
1649            "rustc",
1650            "go",
1651            "python",
1652            "python3",
1653            "pip",
1654            "pip3",
1655            "uv",
1656            "poetry",
1657            "pytest",
1658            "ruff",
1659            "black",
1660            "mypy",
1661            "make",
1662            "cmake",
1663            "ninja",
1664            "just",
1665            "bats",
1666            "docker",
1667            "docker-compose",
1668            "podman",
1669            "kubectl",
1670            "helm",
1671            "terraform",
1672            "anchor",
1673            "avm",
1674            "solana",
1675            "solana-test-validator",
1676        ];
1677
1678        let lower = text.to_lowercase();
1679        for tool in &known_tools {
1680            if lower.contains(&format!("{tool}: command not found"))
1681                || lower.contains(&format!("command not found: {tool}"))
1682                || lower.contains(&format!("{tool}: not found"))
1683                || lower.contains(&format!("env: {tool}: no such file"))
1684                || lower.contains(&format!("spawn {tool} enoent"))
1685            {
1686                return Some(AgentError::MissingTool {
1687                    tool: tool.to_string(),
1688                });
1689            }
1690        }
1691        None
1692    }
1693
1694    /// Check for permission/sandbox denied patterns.
1695    pub fn detect_permission_denied(text: &str) -> Option<AgentError> {
1696        let lower = text.to_lowercase();
1697        let patterns = [
1698            "permission denied",
1699            "rejected permission",
1700            "permissionerror",
1701            "operation not permitted",
1702            "sandbox violation",
1703            "access denied",
1704            "eacces",
1705            "eperm",
1706            "read-only file system",
1707            "not writable",
1708            "disallowed tool",
1709        ];
1710        if patterns.iter().any(|p| lower.contains(p)) {
1711            return Some(AgentError::PermissionDenied {
1712                message: safe_tail(text, 300).to_string(),
1713            });
1714        }
1715        None
1716    }
1717
1718    /// Check for interactive input prompts (1Password, SSH passphrase, etc.).
1719    pub fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1720        let lower = text.to_lowercase();
1721        let patterns = [
1722            "enter passphrase",
1723            "1password",
1724            "op signin",
1725            "ssh passphrase",
1726            "password:",
1727        ];
1728        let auth_required_with_interactive_context = lower.contains("authentication required")
1729            && (lower.contains("ssh")
1730                || lower.contains("passphrase")
1731                || lower.contains("1password")
1732                || lower.contains("op signin"));
1733        if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1734            return Some(AgentError::WaitingForInput {
1735                message: safe_tail(text, 300).to_string(),
1736            });
1737        }
1738        None
1739    }
1740
1741    /// Check for the Claude 400 "thinking blocks cannot be modified" API error.
1742    ///
1743    /// Occurs when Claude CLI reconstructs a multi-turn tool-use request and the
1744    /// Anthropic API rejects it because a thinking block was altered between turns.
1745    /// Classified separately to avoid penalising the model with a failure count.
1746    pub fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1747        // Match the exact phrasing the Anthropic API emits (backtick-quoted names,
1748        // case-sensitive — the API always uses this exact string).
1749        if text.contains(
1750            "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1751        ) {
1752            let message = text
1753                .lines()
1754                .find(|l| l.contains("cannot be modified"))
1755                .map(|l| l.trim().to_string())
1756                .unwrap_or_else(|| safe_tail(text, 300).to_string());
1757            return Some(AgentError::ThinkingBlockConflict { message });
1758        }
1759        None
1760    }
1761
1762    /// Check for a stale/expired Claude session ID.
1763    ///
1764    /// Claude returns `"No conversation found with session ID: <uuid>"` when
1765    /// the session UUID stored in orch's KV has expired server-side. The caller
1766    /// should reset the stored UUID and retry with a fresh `--session-id`.
1767    pub fn detect_stale_session(text: &str) -> Option<AgentError> {
1768        // Case-insensitive match; extract the UUID if present.
1769        // We search the lowercased copy for the pattern, then find the
1770        // corresponding byte offset in the *original* text via char_indices so
1771        // we never use a byte index from `lower` (whose byte length may differ
1772        // from `text` after Unicode case-folding, e.g. ß→ss, İ→i̇).
1773        let needle = "no conversation found with session id";
1774        let lower = text.to_lowercase();
1775        if let Some(lower_pos) = lower.find(needle) {
1776            // Map the byte offset in `lower` back to a char index, then find
1777            // the same char index in `text`.  Because to_lowercase() maps each
1778            // char to one or more chars, the char count up to the match may
1779            // differ between `lower` and `text`, so we count chars in `lower`.
1780            let char_idx = lower[..lower_pos].chars().count();
1781            let pos = text
1782                .char_indices()
1783                .nth(char_idx)
1784                .map(|(i, _)| i)
1785                .unwrap_or(text.len());
1786            // Try to extract the UUID that follows the colon.
1787            let after = &text[pos..];
1788            let session_id = after
1789                .split_once(':')
1790                .map(|(_, rest)| rest.trim())
1791                // Take only the UUID portion (up to whitespace or end)
1792                .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
1793                .unwrap_or_default();
1794            return Some(AgentError::StaleSession { session_id });
1795        }
1796        None
1797    }
1798
1799    /// Exit code returned by `timeout(1)` when the child is killed.
1800    const TIMEOUT_EXIT_CODE: i32 = 124;
1801
1802    /// Default assumed timeout duration when we only have the exit code.
1803    const DEFAULT_TIMEOUT_SECS: u64 = 1800;
1804
1805    /// How many bytes from the end of combined stdout+stderr we scan for
1806    /// CLI-style error patterns that should come from the process tail.
1807    ///
1808    /// Rationale: real CLI error messages (rate limits, auth failures, network
1809    /// failures) almost always appear at the end of the process output.
1810    /// Scanning only the tail avoids false-positives from agent work product
1811    /// (code, diffs, commit messages) which may mention "quota",
1812    /// "unauthorized", HTTP status codes, etc. 3000 bytes is a conservative
1813    /// window large enough to capture multi-line error dumps but small enough
1814    /// to exclude most long-form agent outputs.
1815    pub(crate) const RATE_LIMIT_SCAN_TAIL_BYTES: usize = 3000;
1816
1817    /// How many bytes from the start of combined stdout+stderr we scan for
1818    /// rate limit patterns that appear as early system events.
1819    ///
1820    /// Some rate limit errors (e.g., Claude session limit) appear as
1821    /// `type:system` NDJSON events early in the stream, not in the tail.
1822    /// Scanning the head catches these before they get pushed out of the
1823    /// tail window by subsequent agent output.
1824    pub(crate) const RATE_LIMIT_SCAN_HEAD_BYTES: usize = 3000;
1825
1826    /// Run all pattern detectors against combined stdout+stderr.
1827    /// Returns the first matching AgentError, or a generic Unknown.
1828    pub fn classify_from_text(exit_code: i32, text: &str) -> AgentError {
1829        if exit_code == TIMEOUT_EXIT_CODE {
1830            return AgentError::Timeout {
1831                elapsed: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
1832            };
1833        }
1834
1835        // Exit 0 with empty output is a silent failure (common with GitHub Copilot
1836        // models in opencode). Return Unknown with a deterministic message so the
1837        // caller (fallback.rs) detects it and applies model cooldown + free-model
1838        // retry instead of treating it as a generic success.
1839        if exit_code == 0 && text.trim().is_empty() {
1840            return AgentError::Unknown {
1841                exit_code,
1842                message: "empty-output-exit0".to_string(),
1843            };
1844        }
1845
1846        if let Some(e) = detect_missing_tool(text) {
1847            return e;
1848        }
1849        if let Some(e) = detect_worktree_missing(text) {
1850            return e;
1851        }
1852        if let Some(e) = detect_thinking_block_conflict(text) {
1853            return e;
1854        }
1855        // Also scan the head of the combined output for rate limit patterns.
1856        // Some rate limit errors (e.g., Claude session limit) appear as
1857        // `type:system` NDJSON events early in the stream, not in the tail.
1858        // Scanning the head catches these before they get pushed out of the
1859        // tail window by subsequent agent output.
1860        let scan_head = safe_head(text, RATE_LIMIT_SCAN_HEAD_BYTES);
1861        if let Some(e) = detect_rate_limit(scan_head) {
1862            return e;
1863        }
1864        // Only scan the tail of the combined output for CLI-style error
1865        // patterns. The full output may contain agent work product (code,
1866        // diffs, commit messages) that incidentally mentions rate limiting,
1867        // authorization, HTTP status codes, or context/auth errors. Real
1868        // process errors appear at the end of the output. Scan a bounded tail
1869        // (`RATE_LIMIT_SCAN_TAIL_BYTES`) to reduce false positives.
1870        let scan_tail = safe_tail(text, RATE_LIMIT_SCAN_TAIL_BYTES);
1871        if let Some(e) = detect_context_overflow(scan_tail) {
1872            return e;
1873        }
1874        if let Some(e) = detect_rate_limit(scan_tail) {
1875            return e;
1876        }
1877        if let Some(e) = detect_model_unavailable(scan_tail) {
1878            return e;
1879        }
1880        if let Some(e) = detect_network_error(scan_tail) {
1881            return e;
1882        }
1883        if let Some(e) = detect_auth_error(scan_tail) {
1884            return e;
1885        }
1886        if let Some(e) = detect_waiting_for_input(scan_tail) {
1887            return e;
1888        }
1889        if let Some(e) = detect_permission_denied(scan_tail) {
1890            return e;
1891        }
1892        if let Some(e) = detect_stale_session(text) {
1893            return e;
1894        }
1895
1896        AgentError::Unknown {
1897            exit_code,
1898            message: safe_tail(text, 300).to_string(),
1899        }
1900    }
1901
1902    /// Safely extract the last `max_bytes` of a string, respecting UTF-8 boundaries.
1903    pub fn safe_tail(text: &str, max_bytes: usize) -> &str {
1904        if text.len() <= max_bytes {
1905            return text;
1906        }
1907        let start = text.len() - max_bytes;
1908        // Walk forward to find a char boundary
1909        let mut idx = start;
1910        while idx < text.len() && !text.is_char_boundary(idx) {
1911            idx += 1;
1912        }
1913        &text[idx..]
1914    }
1915
1916    /// Safely extract the first `max_bytes` of a string, respecting UTF-8 boundaries.
1917    pub fn safe_head(text: &str, max_bytes: usize) -> &str {
1918        if text.len() <= max_bytes {
1919            return text;
1920        }
1921        let mut idx = max_bytes;
1922        // Walk backward to find a char boundary
1923        while idx > 0 && !text.is_char_boundary(idx) {
1924            idx -= 1;
1925        }
1926        &text[..idx]
1927    }
1928
1929    /// Scan NDJSON events for errors using agent-specific extraction and classification.
1930    ///
1931    /// `extract_message` is called for each event to extract an error message string.
1932    /// If it returns `Some`, the message is classified via `classify`. The most
1933    /// specific error is kept — any typed error (RateLimit, Auth, etc.) takes
1934    /// precedence over a generic `AgentFailed`.
1935    pub fn detect_ndjson_error(
1936        events: &[serde_json::Value],
1937        extract_message: impl Fn(&serde_json::Value) -> Option<String>,
1938        classify: impl Fn(&str) -> AgentError,
1939    ) -> Option<AgentError> {
1940        let mut best: Option<AgentError> = None;
1941
1942        for event in events {
1943            if let Some(message) = extract_message(event) {
1944                let err = classify(&message);
1945                // Priority (highest wins): RateLimit / Auth / other > NetworkError > AgentFailed
1946                // A transient reconnect mid-stream must not obscure a subsequent billing/rate error.
1947                let upgrade = match &best {
1948                    None => true,
1949                    Some(AgentError::AgentFailed { .. }) => true,
1950                    Some(AgentError::NetworkError { .. }) => !matches!(
1951                        err,
1952                        AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1953                    ),
1954                    Some(_) => false,
1955                };
1956                if upgrade {
1957                    best = Some(err);
1958                }
1959            }
1960        }
1961
1962        best
1963    }
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968    use super::*;
1969
1970    #[test]
1971    fn get_runner_returns_correct_types() {
1972        assert_eq!(get_runner("claude").name(), "claude");
1973        assert_eq!(get_runner("kimi").name(), "kimi");
1974        assert_eq!(get_runner("minimax").name(), "minimax");
1975        assert_eq!(get_runner("codex").name(), "codex");
1976        assert_eq!(get_runner("opencode").name(), "opencode");
1977        assert_eq!(get_runner("grok").name(), "grok");
1978        assert_eq!(
1979            get_runner("claude").router_transcript(),
1980            RouterTranscript::Messages
1981        );
1982        assert_eq!(
1983            get_runner("kimi").router_transcript(),
1984            RouterTranscript::Messages
1985        );
1986        assert_eq!(
1987            get_runner("minimax").router_transcript(),
1988            RouterTranscript::Messages
1989        );
1990        assert_eq!(
1991            get_runner("grok").router_transcript(),
1992            RouterTranscript::Messages
1993        );
1994        assert_eq!(
1995            get_runner("glm").router_transcript(),
1996            RouterTranscript::Messages
1997        );
1998        assert_eq!(
1999            get_runner("olm").router_transcript(),
2000            RouterTranscript::Messages
2001        );
2002        assert_eq!(
2003            get_runner("opencode").router_transcript(),
2004            RouterTranscript::OpenCode
2005        );
2006        assert_eq!(
2007            get_runner("codex").router_transcript(),
2008            RouterTranscript::Raw
2009        );
2010        assert!(supports_session("grok"));
2011        assert!(!supports_session("codex"));
2012        // olm and glm are claude-compatible and should return correct name
2013        assert_eq!(get_runner("olm").name(), "olm");
2014        assert_eq!(get_runner("glm").name(), "glm");
2015        // Unknown falls back to claude-compatible
2016        assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
2017    }
2018
2019    /// Regression test: known claude-compatible agents (including glm) must not
2020    /// emit "unknown agent" warnings when get_runner() is called.
2021    #[test]
2022    fn get_runner_no_warning_for_claude_compatible_agents() {
2023        use std::sync::{Arc, Mutex};
2024        use tracing::Level;
2025        use tracing_subscriber::util::SubscriberInitExt;
2026        use tracing_subscriber::EnvFilter;
2027
2028        let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
2029        let output_clone = output.clone();
2030
2031        struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2032        impl std::io::Write for CaptureWriter {
2033            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2034                self.0.lock().unwrap().extend_from_slice(buf);
2035                Ok(buf.len())
2036            }
2037            fn flush(&mut self) -> std::io::Result<()> {
2038                Ok(())
2039            }
2040        }
2041        impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2042            type Writer = CaptureWriter;
2043            fn make_writer(&'a self) -> Self::Writer {
2044                CaptureWriter(self.0.clone())
2045            }
2046        }
2047
2048        tracing_subscriber::fmt()
2049            .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2050            .with_writer(CaptureWriter(output_clone))
2051            .with_ansi(false)
2052            .finish()
2053            .init();
2054
2055        for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2056            get_runner(agent);
2057        }
2058
2059        let captured = {
2060            let guard = output.lock().unwrap();
2061            String::from_utf8_lossy(&guard).to_string()
2062        };
2063        assert!(
2064            !captured.contains("unknown agent"),
2065            "known claude-compatible agents must not emit 'unknown agent' warnings; got: {}",
2066            captured
2067        );
2068    }
2069
2070    #[test]
2071    fn agent_error_display() {
2072        let e = AgentError::RateLimit {
2073            message: "429 Too Many Requests".to_string(),
2074        };
2075        assert!(e.to_string().contains("rate limit"));
2076
2077        let e = AgentError::Timeout {
2078            elapsed: Duration::from_secs(1800),
2079        };
2080        assert!(e.to_string().contains("1800"));
2081    }
2082
2083    #[test]
2084    fn pattern_detect_rate_limit() {
2085        assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2086        assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2087        assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2088        assert!(patterns::detect_rate_limit(
2089            "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2090        )
2091        .is_some());
2092        assert!(patterns::detect_rate_limit(
2093            "Upstream error from Alibaba: Request rate increased too quickly. \
2094             To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
2095        ).is_some());
2096        assert!(patterns::detect_rate_limit("all good").is_none());
2097    }
2098
2099    #[test]
2100    fn classify_from_text_detects_weekly_limit_as_rate_limit() {
2101        // Regression test for issue #3283: Claude "weekly limit" errors were
2102        // classified as `failed` instead of `rate_limit` because "weekly limit"
2103        // was not in the detect_rate_limit pattern list.
2104        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)"}}"#;
2105        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2106            .repeat(200);
2107        let text = format!("{system_event}\n{work_product}");
2108        assert!(
2109            text.len() > 3000,
2110            "test output must exceed tail window to be meaningful"
2111        );
2112        let err = patterns::classify_from_text(1, &text);
2113        assert!(
2114            matches!(err, AgentError::RateLimit { .. }),
2115            "weekly limit as early system event must be detected via head scan, got: {err:?}"
2116        );
2117        let msg = err.to_string();
2118        assert!(
2119            msg.contains("weekly limit"),
2120            "error message should reference weekly limit, got: {msg}"
2121        );
2122    }
2123
2124    #[test]
2125    fn detect_rate_limit_github_copilot_monthly_quota() {
2126        // Regression test for issue #3302: "You have exceeded your monthly quota"
2127        // was not matched because detect_rate_limit only checked "quota exceeded"
2128        // (reversed word order), causing 4 immediate retries with no cooldown.
2129        let err = patterns::detect_rate_limit("You have exceeded your monthly quota");
2130        assert!(
2131            matches!(err, Some(AgentError::RateLimit { .. })),
2132            "monthly quota exhaustion must be detected as rate limit, got: {err:?}"
2133        );
2134    }
2135
2136    #[test]
2137    fn detect_rate_limit_nvidia_resourceexhausted() {
2138        // Regression test for issue #3361: Nvidia "ResourceExhausted: Worker local total
2139        // request limit reached" was classified as generic `failed` instead of `rate_limit`,
2140        // bypassing the cooldown path and weakening routing health signals.
2141        let opencode_error = r#"opencode agent error: agent failed: "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (42/32)""#;
2142        let err = patterns::detect_rate_limit(opencode_error);
2143        assert!(
2144            matches!(err, Some(AgentError::RateLimit { .. })),
2145            "Nvidia ResourceExhausted must be detected as rate limit, got: {err:?}"
2146        );
2147
2148        // Both sub-patterns must match independently.
2149        assert!(
2150            patterns::detect_rate_limit("ResourceExhausted: some capacity error").is_some(),
2151            "bare ResourceExhausted must match"
2152        );
2153        assert!(
2154            patterns::detect_rate_limit("Worker local total request limit reached (10/8)")
2155                .is_some(),
2156            "worker local total request limit reached must match"
2157        );
2158
2159        // classify_from_text must also route this to RateLimit (exercises the tail-scan path).
2160        let err = patterns::classify_from_text(1, opencode_error);
2161        assert!(
2162            matches!(err, AgentError::RateLimit { .. }),
2163            "classify_from_text must produce RateLimit for Nvidia ResourceExhausted, got: {err:?}"
2164        );
2165    }
2166
2167    #[test]
2168    fn detect_rate_limit_message_contains_context_not_tail() {
2169        // Simulate a long claude NDJSON output where the rate limit message is in
2170        // the middle and usage stats JSON is at the tail.
2171        let padding = "x".repeat(5000);
2172        let text = format!(
2173            "{padding}you've reached your usage limit for this billing cycle{padding}\
2174             {{\"web_fetch_requests\":0,\"service_tier\":\"standard\"}}"
2175        );
2176        let err = patterns::detect_rate_limit(&text).expect("should detect");
2177        let msg = err.to_string();
2178        assert!(
2179            msg.contains("usage limit"),
2180            "message should contain the actual error, got: {msg}"
2181        );
2182        assert!(
2183            !msg.contains("web_fetch_requests"),
2184            "message should not be the tail JSON, got: {msg}"
2185        );
2186    }
2187
2188    #[test]
2189    fn pattern_detect_rate_limit_529_not_bare() {
2190        // Bare "529" in various contexts should NOT trigger rate limit detection.
2191        // These are false positive cases we want to avoid.
2192        assert!(
2193            patterns::detect_rate_limit("error on line 529").is_none(),
2194            "bare line number 529 should not match"
2195        );
2196        assert!(
2197            patterns::detect_rate_limit("file size 529 bytes").is_none(),
2198            "bare file size 529 should not match"
2199        );
2200        assert!(
2201            patterns::detect_rate_limit("port :5290 in config").is_none(),
2202            "port number containing 529 should not match"
2203        );
2204        assert!(
2205            patterns::detect_rate_limit("timeout after 5290ms").is_none(),
2206            "duration containing 529 should not match"
2207        );
2208        // But HTTP 529 status codes SHOULD match.
2209        assert!(
2210            patterns::detect_rate_limit("HTTP 529 Service Unavailable").is_some(),
2211            "HTTP 529 status should match"
2212        );
2213        assert!(
2214            patterns::detect_rate_limit("http 529").is_some(),
2215            "http 529 should match"
2216        );
2217        assert!(
2218            patterns::detect_rate_limit("529 service unavailable").is_some(),
2219            "529 service unavailable should match"
2220        );
2221        assert!(
2222            patterns::detect_rate_limit("status: 529").is_some(),
2223            "status: 529 should match"
2224        );
2225    }
2226
2227    #[test]
2228    fn pattern_detect_auth() {
2229        assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
2230        assert!(patterns::detect_auth_error("HTTP 401").is_some());
2231        assert!(patterns::detect_auth_error("error: 401").is_some());
2232        assert!(patterns::detect_auth_error("403 Forbidden").is_some());
2233        assert!(patterns::detect_auth_error("HTTP 403").is_some());
2234        assert!(patterns::detect_auth_error("HTTP 407 Authentication Required").is_some());
2235        assert!(patterns::detect_auth_error("407 Proxy Authentication Required").is_some());
2236        assert!(patterns::detect_auth_error("invalid api key").is_some());
2237        assert!(patterns::detect_auth_error("billing expired").is_some());
2238        assert!(patterns::detect_auth_error("task done").is_none());
2239        // Bare numbers in JSON must NOT trigger auth classification.
2240        assert!(patterns::detect_auth_error("duration_api_ms 4010292").is_none());
2241        assert!(patterns::detect_auth_error(
2242            "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2243        )
2244        .is_none());
2245        // JSON field with colon: "duration_api_ms": 4010292 — contains ": 401" as substring.
2246        assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2247        assert!(patterns::detect_auth_error(
2248            r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2249        )
2250        .is_none());
2251        // ": 401" at end-of-string or followed by non-digit must still match.
2252        assert!(patterns::detect_auth_error("status: 401").is_some());
2253        assert!(patterns::detect_auth_error("error: 403").is_some());
2254    }
2255
2256    #[test]
2257    fn pattern_detect_auth_insufficient_balance() {
2258        // Regression test for issue #3642: "insufficient balance" (and by extension
2259        // "402 Payment Required: insufficient balance") must be detected as an auth
2260        // error so it routes through the credit exhaustion path, not the generic 5-min
2261        // backoff. This ensures the #3642 cooldown behavior is properly triggered.
2262        assert!(
2263            patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2264            "insufficient balance must be detected as auth error"
2265        );
2266        assert!(
2267            patterns::detect_auth_error("insufficient balance").is_some(),
2268            "bare insufficient balance must be detected as auth error"
2269        );
2270        // The stored message should contain the matched pattern.
2271        let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2272            .expect("should detect auth error");
2273        let AgentError::Auth { message } = err else {
2274            panic!("expected Auth, got {err:?}");
2275        };
2276        assert!(
2277            message.to_lowercase().contains("insufficient balance"),
2278            "stored message should contain the matched pattern, got: {message}"
2279        );
2280        // The message should be the line containing the auth error, not the tail.
2281        assert!(
2282            message.lines().count() <= 2,
2283            "message should be the auth line, not the full tail: {}",
2284            message.len()
2285        );
2286    }
2287
2288    #[test]
2289    fn auth_error_message_contains_match_not_tail() {
2290        // Simulate real agent output: auth error appears early, NDJSON session
2291        // metadata at the end. Stored message must be the error line, not the
2292        // trailing JSON that agents emit at exit.
2293        let text = "some output\nunauthorized: your api key has expired\nx".repeat(400)
2294            + r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#;
2295        let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2296        let AgentError::Auth { message } = err else {
2297            panic!("expected Auth, got {err:?}");
2298        };
2299        assert!(
2300            message.to_lowercase().contains("unauthorized"),
2301            "stored message should contain the matched pattern, got: {message}"
2302        );
2303        assert!(
2304            !message.contains("outputTokens"),
2305            "stored message must not be the trailing NDJSON, got: {message}"
2306        );
2307        // The message should be the line containing the auth error, not the tail.
2308        assert!(
2309            message.lines().count() <= 2,
2310            "message should be the auth line, not the full tail: {}",
2311            message.len()
2312        );
2313    }
2314
2315    #[test]
2316    fn pattern_detect_network_error() {
2317        assert!(patterns::detect_network_error("connection refused").is_some());
2318        assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2319        assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2320        assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2321        assert!(patterns::detect_network_error("network unreachable").is_some());
2322        assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2323        assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2324        assert!(patterns::detect_network_error("Streaming response failed").is_some());
2325        assert!(patterns::detect_network_error("Upstream request failed").is_some());
2326        assert!(patterns::detect_network_error("all systems operational").is_none());
2327    }
2328
2329    #[test]
2330    fn network_error_message_contains_match_not_tail() {
2331        // Simulate real agent output: error appears early, unrelated stats JSON at the end.
2332        let prefix = "connection refused connecting to api.anthropic.com:443 ";
2333        let suffix = "x".repeat(2000)
2334            + r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#;
2335        let text = format!("{prefix}{suffix}");
2336
2337        let err = patterns::detect_network_error(&text).expect("should detect network error");
2338        let AgentError::NetworkError { message } = err else {
2339            panic!("expected NetworkError, got {err:?}");
2340        };
2341        assert!(
2342            message.to_lowercase().contains("connection refused"),
2343            "stored message should contain the matched pattern, got: {message}"
2344        );
2345        assert!(
2346            !message.contains("web_fetch_requests"),
2347            "stored message must not be the unrelated stats tail, got: {message}"
2348        );
2349    }
2350
2351    #[test]
2352    fn pattern_detect_context_overflow() {
2353        assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2354        assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2355        assert!(patterns::detect_context_overflow("success").is_none());
2356    }
2357
2358    #[test]
2359    fn pattern_detect_worktree_missing() {
2360        assert!(
2361            patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2362                .is_some()
2363        );
2364        assert!(patterns::detect_worktree_missing("all good").is_none());
2365    }
2366
2367    #[test]
2368    fn pattern_detect_missing_tool() {
2369        assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2370        assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2371        assert!(patterns::detect_missing_tool("everything works").is_none());
2372    }
2373
2374    #[test]
2375    fn pattern_detect_permission_denied() {
2376        assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2377        assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2378        assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2379        assert!(patterns::detect_permission_denied("task completed").is_none());
2380    }
2381
2382    #[test]
2383    fn pattern_detect_waiting_for_input() {
2384        assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2385        assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2386        assert!(
2387            patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2388                .is_some()
2389        );
2390        assert!(
2391            patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2392        );
2393        assert!(patterns::detect_waiting_for_input("done").is_none());
2394    }
2395
2396    #[test]
2397    fn pattern_detect_stale_session() {
2398        // Exact error message from Claude
2399        let err = patterns::detect_stale_session(
2400            "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2401        );
2402        assert!(err.is_some(), "should detect stale session");
2403        if let Some(AgentError::StaleSession { session_id }) = err {
2404            assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2405        }
2406
2407        // Case-insensitive
2408        let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2409        assert!(err.is_some(), "should detect lowercase variant");
2410
2411        // Non-matching text
2412        assert!(patterns::detect_stale_session("all good").is_none());
2413        assert!(patterns::detect_stale_session("conversation started").is_none());
2414
2415        // Multi-byte UTF-8 characters before the match must not panic.
2416        // German ß lowercases to "ss" (1 byte → 2 bytes), so using a byte
2417        // offset from `lower` directly into `text` would be wrong.
2418        let multibyte = "Straße: No conversation found with session ID: dead-beef";
2419        let err = patterns::detect_stale_session(multibyte);
2420        assert!(
2421            err.is_some(),
2422            "should detect stale session after multi-byte chars"
2423        );
2424        if let Some(AgentError::StaleSession { session_id }) = err {
2425            assert_eq!(session_id, "dead-beef");
2426        }
2427    }
2428
2429    #[test]
2430    fn stale_session_display() {
2431        let e = AgentError::StaleSession {
2432            session_id: "abc-123".to_string(),
2433        };
2434        assert!(e.to_string().contains("stale session"));
2435        assert!(e.to_string().contains("abc-123"));
2436    }
2437
2438    #[test]
2439    fn classify_from_text_timeout() {
2440        let err = patterns::classify_from_text(124, "");
2441        assert!(matches!(err, AgentError::Timeout { .. }));
2442    }
2443
2444    #[test]
2445    fn classify_from_text_missing_tool_before_rate_limit() {
2446        // Missing tool takes priority over rate limit patterns
2447        let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2448        assert!(matches!(err, AgentError::MissingTool { .. }));
2449    }
2450
2451    #[test]
2452    fn classify_from_text_worktree_missing() {
2453        let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2454        assert!(matches!(err, AgentError::AgentFailed { .. }));
2455    }
2456
2457    #[test]
2458    fn classify_from_text_model_not_found_backtick() {
2459        // Issue #3604: plain-text "model not found" errors (no structured
2460        // NDJSON event) must classify as ModelUnavailable, not fall through to
2461        // Unknown, so the agent-wide cooldown is not bumped for a bad model name.
2462        let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2463            (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2464            or you do not have access to it.)";
2465        let err = patterns::classify_from_text(1, text);
2466        match err {
2467            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2468            other => panic!("expected ModelUnavailable, got: {other:?}"),
2469        }
2470    }
2471
2472    #[test]
2473    fn classify_from_text_model_metadata_not_found() {
2474        let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2475            metadata; this can degrade performance and cause issues.";
2476        let err = patterns::classify_from_text(1, text);
2477        match err {
2478            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2479            other => panic!("expected ModelUnavailable, got: {other:?}"),
2480        }
2481    }
2482
2483    #[test]
2484    fn classify_from_text_no_false_positive_from_work_product() {
2485        // Regression test for issue #1292: agent output containing rate-limit
2486        // keywords in code/diffs must not be misclassified as a rate limit error.
2487        let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2488             - Added copilot quota patterns to `parse_retry_at`\n\
2489             fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2490             quota exceeded handling for billing cycle\n"
2491            .repeat(50); // ~200 lines of work product — well over 3000 chars
2492        let actual_error = "\nerror: command not found: cargo\n";
2493        let text = format!("{work_product}{actual_error}");
2494        let err = patterns::classify_from_text(1, &text);
2495        assert!(
2496            !matches!(err, AgentError::RateLimit { .. }),
2497            "work product keywords must not trigger rate limit, got: {err:?}"
2498        );
2499    }
2500
2501    #[test]
2502    fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2503        // A real rate-limit error printed at the end of the output must still
2504        // be detected, even if work product precedes it.
2505        let padding = "normal agent work output ".repeat(100);
2506        let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2507        let err = patterns::classify_from_text(1, &text);
2508        assert!(
2509            matches!(err, AgentError::RateLimit { .. }),
2510            "real rate limit at tail must be detected, got: {err:?}"
2511        );
2512    }
2513
2514    #[test]
2515    fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2516        let padding = "normal agent work output ".repeat(250);
2517        let text = format!(
2518            "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2519        );
2520        let err = patterns::classify_from_text(1, &text);
2521        assert!(
2522            !matches!(err, AgentError::RateLimit { .. }),
2523            "markdown table cell must not trigger rate limit, got: {err:?}"
2524        );
2525    }
2526
2527    #[test]
2528    fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2529        // Regression test: non-ASCII characters before `rate_limit` that change
2530        // byte length when lowercased (e.g. İ 2→3 bytes, fl 3→2 bytes) must not
2531        // cause is_word_boundary to index text at an invalid byte offset.
2532        let text = "Aflαβrate_limit error: too many requests";
2533        let err = patterns::classify_from_text(1, text);
2534        assert!(
2535            matches!(err, AgentError::RateLimit { .. }),
2536            "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2537        );
2538    }
2539
2540    #[test]
2541    fn classify_from_text_detects_session_limit_as_early_system_event() {
2542        // Regression test for issue #3272: Claude session limit errors appear as
2543        // `type:system` NDJSON events early in the stream, not as `type:result`
2544        // lines. When the agent produces significant output after the error, the
2545        // system event is pushed out of the tail window and missed by tail-only
2546        // scanning. The head scan must catch it.
2547        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)"}}"#;
2548        // Generate enough work product (>3000 bytes) to push the system event
2549        // out of the tail window.
2550        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2551            .repeat(200);
2552        let text = format!("{system_event}\n{work_product}");
2553        // Verify the output is large enough that the event is not in the tail.
2554        assert!(
2555            text.len() > 3000,
2556            "test output must exceed tail window to be meaningful"
2557        );
2558        let err = patterns::classify_from_text(1, &text);
2559        assert!(
2560            matches!(err, AgentError::RateLimit { .. }),
2561            "session limit as early system event must be detected via head scan, got: {err:?}"
2562        );
2563        // The message should contain the actual error, not unrelated tail content.
2564        let msg = err.to_string();
2565        assert!(
2566            msg.contains("session limit"),
2567            "error message should contain the rate limit reason, got: {msg}"
2568        );
2569    }
2570
2571    #[test]
2572    fn classify_from_text_no_false_positive_auth_from_work_product() {
2573        // Regression test for issue #2126: auth keywords in agent work product
2574        // must not trigger auth classification when the real failure is
2575        // unrelated and appears at the end of the output.
2576        let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2577             // handle 403 forbidden errors\n\
2578             // billing checks happen elsewhere\n"
2579            .repeat(80);
2580        let actual_error = "\nerror: command not found: cargo\n";
2581        let text = format!("{work_product}{actual_error}");
2582        let err = patterns::classify_from_text(1, &text);
2583        assert!(
2584            !matches!(err, AgentError::Auth { .. }),
2585            "work product auth keywords must not trigger auth classification, got: {err:?}"
2586        );
2587        assert!(
2588            matches!(err, AgentError::MissingTool { .. }),
2589            "the real tail error should still win, got: {err:?}"
2590        );
2591    }
2592
2593    #[test]
2594    fn classify_from_text_still_detects_real_auth_at_tail() {
2595        let padding = "normal agent work output ".repeat(100);
2596        let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2597        let err = patterns::classify_from_text(1, &text);
2598        assert!(
2599            matches!(err, AgentError::Auth { .. }),
2600            "real auth error at tail must be detected, got: {err:?}"
2601        );
2602    }
2603
2604    #[test]
2605    fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2606        // Regression test for issue #2205: context overflow keywords in agent
2607        // work product must not trigger ContextOverflow when the real failure is
2608        // unrelated and appears at the end of the output.
2609        let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2610             // Ensure we don't exceed the token limit per request\n\
2611             fn check_token_limit(tokens: usize) { ... }\n\
2612             if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2613             // Note: too many tokens in the prompt causes context_length_exceeded\n"
2614            .repeat(80);
2615        let actual_error = "\nerror: command not found: cargo\n";
2616        let text = format!("{work_product}{actual_error}");
2617        let err = patterns::classify_from_text(1, &text);
2618        assert!(
2619            !matches!(err, AgentError::ContextOverflow { .. }),
2620            "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2621        );
2622        assert!(
2623            matches!(err, AgentError::MissingTool { .. }),
2624            "the real tail error should still win, got: {err:?}"
2625        );
2626    }
2627
2628    #[test]
2629    fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2630        let padding = "normal agent work output ".repeat(100);
2631        let text = format!("{padding}\nError: context_length_exceeded\n");
2632        let err = patterns::classify_from_text(1, &text);
2633        assert!(
2634            matches!(err, AgentError::ContextOverflow { .. }),
2635            "real context overflow at tail must be detected, got: {err:?}"
2636        );
2637    }
2638
2639    #[test]
2640    fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2641        // Regression test for issue #2205: permission-related keywords in agent
2642        // work product must not trigger PermissionDenied when the real failure
2643        // is unrelated and appears at the end of the output.
2644        let work_product = "match fs::metadata(path) {\n\
2645             Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2646                 bail!(\"access denied: {path}\");\n\
2647             }\n\
2648             // Check if directory is writable\n\
2649             if !path.is_writable() { ... }\n\
2650             }\n"
2651        .repeat(80);
2652        let actual_error = "\nerror: command not found: cargo\n";
2653        let text = format!("{work_product}{actual_error}");
2654        let err = patterns::classify_from_text(1, &text);
2655        assert!(
2656            !matches!(err, AgentError::PermissionDenied { .. }),
2657            "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2658        );
2659        assert!(
2660            matches!(err, AgentError::MissingTool { .. }),
2661            "the real tail error should still win, got: {err:?}"
2662        );
2663    }
2664
2665    #[test]
2666    fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2667        let padding = "normal agent work output ".repeat(100);
2668        let text = format!("{padding}\npermission denied: /etc/hosts\n");
2669        let err = patterns::classify_from_text(1, &text);
2670        assert!(
2671            matches!(err, AgentError::PermissionDenied { .. }),
2672            "real permission denied at tail must be detected, got: {err:?}"
2673        );
2674    }
2675
2676    #[test]
2677    fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2678        // Regression test for issue #2205: input-request keywords in agent work
2679        // product must not trigger WaitingForInput when the real failure is
2680        // unrelated and appears at the end of the output.
2681        let work_product = "// Handle password prompts gracefully\n\
2682             if prompt.contains(\"password:\") {\n\
2683                 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2684             }\n\
2685             // SSH agent will ask for the deploy key passphrase\n\
2686             fn handle_ssh_passphrase() { ... }\n\
2687             )\n"
2688        .repeat(80);
2689        let actual_error = "\nerror: command not found: cargo\n";
2690        let text = format!("{work_product}{actual_error}");
2691        let err = patterns::classify_from_text(1, &text);
2692        assert!(
2693            !matches!(err, AgentError::WaitingForInput { .. }),
2694            "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2695        );
2696        assert!(
2697            matches!(err, AgentError::MissingTool { .. }),
2698            "the real tail error should still win, got: {err:?}"
2699        );
2700    }
2701
2702    #[test]
2703    fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2704        let padding = "normal agent work output ".repeat(100);
2705        let text = format!("{padding}\nEnter passphrase for key:\n");
2706        let err = patterns::classify_from_text(1, &text);
2707        assert!(
2708            matches!(err, AgentError::WaitingForInput { .. }),
2709            "real waiting for input at tail must be detected, got: {err:?}"
2710        );
2711    }
2712
2713    #[test]
2714    fn classify_from_text_prefers_auth_over_permission_denied() {
2715        let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2716        assert!(
2717            matches!(err, AgentError::Auth { .. }),
2718            "auth errors must outrank permission denied, got: {err:?}"
2719        );
2720    }
2721
2722    #[test]
2723    fn classify_from_text_prefers_auth_over_waiting_for_input() {
2724        let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2725        assert!(
2726            matches!(err, AgentError::Auth { .. }),
2727            "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2728        );
2729    }
2730
2731    #[test]
2732    fn classify_from_text_detects_stale_session() {
2733        // Regression test for issue #1800: classify_from_text must call
2734        // detect_stale_session to catch "No conversation found with session ID"
2735        // errors. Without this, the error would be misclassified as Unknown and
2736        // the control.rs recovery code would not trigger.
2737        let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2738        let err = patterns::classify_from_text(1, text);
2739        assert!(
2740            matches!(err, AgentError::StaleSession { .. }),
2741            "stale session must be detected via classify_from_text, got: {err:?}"
2742        );
2743        // Verify the Display format matches what control.rs expects
2744        assert!(
2745            err.to_string().contains("stale session"),
2746            "error display must contain 'stale session' for recovery code to trigger"
2747        );
2748    }
2749
2750    #[test]
2751    fn classify_from_text_detects_socket_closed() {
2752        // Regression test for issue #3045: "socket connection was closed" must be
2753        // classified as NetworkError, not Unknown.
2754        let text = "API Error: The socket connection was closed unexpectedly. \
2755             For more information, pass `verbose: true` in the second argument to fetch()";
2756        let err = patterns::classify_from_text(1, text);
2757        assert!(
2758            matches!(err, AgentError::NetworkError { .. }),
2759            "socket closed must be NetworkError, got: {err:?}"
2760        );
2761    }
2762
2763    #[test]
2764    fn classify_from_text_detects_socket_hang_up() {
2765        let err = patterns::classify_from_text(1, "Error: socket hang up");
2766        assert!(
2767            matches!(err, AgentError::NetworkError { .. }),
2768            "socket hang up must be NetworkError, got: {err:?}"
2769        );
2770    }
2771
2772    #[test]
2773    fn classify_from_text_detects_fetch_failed() {
2774        let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2775        assert!(
2776            matches!(err, AgentError::NetworkError { .. }),
2777            "fetch failed must be NetworkError, got: {err:?}"
2778        );
2779    }
2780
2781    #[test]
2782    fn classify_from_text_detects_econnreset() {
2783        let err = patterns::classify_from_text(1, "read ECONNRESET");
2784        assert!(
2785            matches!(err, AgentError::NetworkError { .. }),
2786            "ECONNRESET must be NetworkError, got: {err:?}"
2787        );
2788    }
2789
2790    #[test]
2791    fn classify_from_text_detects_stalled_mid_stream() {
2792        // Regression test for issue #3541: kimi's "Response stalled mid-stream" must be
2793        // classified as NetworkError, not Unknown/AgentFailed.
2794        let err = patterns::classify_from_text(
2795            1,
2796            "API Error: Response stalled mid-stream. The response above may be incomplete.",
2797        );
2798        assert!(
2799            matches!(err, AgentError::NetworkError { .. }),
2800            "stalled mid-stream must be NetworkError, got: {err:?}"
2801        );
2802    }
2803
2804    #[test]
2805    fn classify_from_text_detects_connection_closed_mid_response() {
2806        // Regression test for issue #3541: claude's "Connection closed mid-response" must be
2807        // classified as NetworkError, not Unknown/AgentFailed.
2808        let err = patterns::classify_from_text(
2809            1,
2810            "API Error: Connection closed mid-response. The response above may be incomplete.",
2811        );
2812        assert!(
2813            matches!(err, AgentError::NetworkError { .. }),
2814            "connection closed mid-response must be NetworkError, got: {err:?}"
2815        );
2816    }
2817
2818    #[test]
2819    fn synthesize_response_marks_done_for_plain_text_no_op() {
2820        let response = synthesize_response_from_text(
2821            "No open positions, no trade executions, no conditions change. Full cash, holding.",
2822        )
2823        .unwrap();
2824
2825        assert_eq!(response.status, "done");
2826        assert_eq!(
2827            response.summary,
2828            "No open positions, no trade executions, no conditions change. Full cash, holding."
2829        );
2830        assert!(response.error.is_none());
2831    }
2832
2833    #[test]
2834    fn synthesize_response_marks_needs_review_for_plain_text_error() {
2835        let response =
2836            synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2837
2838        assert_eq!(response.status, "needs_review");
2839        assert_eq!(
2840            response.error.as_deref(),
2841            Some("agent returned plain text instead of JSON")
2842        );
2843    }
2844
2845    #[test]
2846    fn synthesize_response_rejects_empty_text() {
2847        assert!(synthesize_response_from_text("   \n\t  ").is_none());
2848    }
2849
2850    /// Regression: raw NDJSON event streams (opencode/claude/codex stdout)
2851    /// must be rejected by the synthesizer. Without the guard, the
2852    /// "completed" substring inside tool state JSON triggers a false "done"
2853    /// classification and 500 bytes of raw NDJSON leak into the
2854    /// notification summary.
2855    #[test]
2856    fn synthesize_response_rejects_ndjson_event_stream() {
2857        let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2858{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2859{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2860        assert!(
2861            synthesize_response_from_text(ndjson).is_none(),
2862            "raw NDJSON must not be interpreted as agent prose"
2863        );
2864
2865        let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2866        assert!(
2867            synthesize_response_from_text(single).is_none(),
2868            "single-line NDJSON event must also be rejected"
2869        );
2870    }
2871
2872    #[test]
2873    fn synthesize_response_accepts_json_without_event_type() {
2874        // A JSON-like blob that does NOT match a known NDJSON event type
2875        // should still be allowed through to the regular synthesis path
2876        // (so the guard does not over-reach).
2877        let text = "The task is complete. Filed issue #1234 successfully.";
2878        let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2879        assert_eq!(response.status, "done");
2880    }
2881
2882    // Regression: opencode/nemotron-3-super-free and similar models sometimes exit
2883    // with code 0 after emitting only exploratory or startup text (e.g. "Let's go!",
2884    // planning prose) without completing any work.  The synthesis must return None for
2885    // such ambiguous text so the runner treats the run as an invalid response and
2886    // reroutes the task instead of falsely advancing it to needs_review / in_review.
2887    // (Issues #2404, #2653, #2666)
2888    #[test]
2889    fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2890        // Short greeting — not an error, not done, not an explicit negation.
2891        assert!(
2892            synthesize_response_from_text("Let's go!").is_none(),
2893            "startup greeting must not synthesize as needs_review"
2894        );
2895        // Exploratory planning prose — agent is thinking out loud, not reporting completion.
2896        assert!(
2897            synthesize_response_from_text(
2898                "I need to find where the stream command is implemented. \
2899                 Looking at the CLI structure, it seems like the stream functionality \
2900                 might be in the task module or another file. Let me check the CLI \
2901                 command structure. First, let me look at the task module since stream \
2902                 is related to tasks."
2903            )
2904            .is_none(),
2905            "exploratory prose must not synthesize as needs_review"
2906        );
2907    }
2908
2909    #[test]
2910    fn synthesize_response_does_not_mark_done_for_incomplete() {
2911        let response = synthesize_response_from_text("The task is incomplete").unwrap();
2912        assert_eq!(
2913            response.status, "needs_review",
2914            "\"incomplete\" should not match \"complete\""
2915        );
2916    }
2917
2918    #[test]
2919    fn synthesize_response_does_not_mark_done_for_not_done() {
2920        let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2921        assert_eq!(
2922            response.status, "needs_review",
2923            "\"not done\" should not match bare \"done\""
2924        );
2925    }
2926
2927    #[test]
2928    fn synthesize_response_does_not_mark_done_for_not_complete() {
2929        let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2930        assert_eq!(
2931            response.status, "needs_review",
2932            "\"not complete\" should not match \"complete\""
2933        );
2934    }
2935
2936    #[test]
2937    fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2938        let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2939        assert_eq!(
2940            response.status, "needs_review",
2941            "\"not yet completed\" should not match \"completed\""
2942        );
2943    }
2944
2945    #[test]
2946    fn synthesize_response_marks_done_for_completed() {
2947        let response =
2948            synthesize_response_from_text("Task has been completed successfully").unwrap();
2949        assert_eq!(response.status, "done");
2950    }
2951
2952    #[test]
2953    fn synthesize_response_marks_done_for_word_boundary_done() {
2954        let response = synthesize_response_from_text("All changes are done.").unwrap();
2955        assert_eq!(response.status, "done");
2956    }
2957
2958    #[test]
2959    fn synthesize_response_marks_done_for_issue_creation_text() {
2960        let response =
2961            synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2962                .unwrap();
2963
2964        assert_eq!(response.status, "done");
2965        assert!(response.error.is_none());
2966        assert!(response
2967            .summary
2968            .contains("Filed 3 high-value GitHub issues"));
2969    }
2970
2971    #[test]
2972    fn synthesize_response_marks_done_for_committed() {
2973        let response =
2974            synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2975        assert_eq!(response.status, "done");
2976    }
2977
2978    #[test]
2979    fn synthesize_response_rejects_exploratory_prose() {
2980        // Regression test: ensure exploratory analysis that mentions "let me check"
2981        // or planning language is NOT synthesized into a "done" completion.
2982        // With the ambiguous-text fix, this text correctly returns None (treated as
2983        // an invalid response / reroute signal) rather than synthesizing needs_review.
2984        // Crucially, it must NOT be marked "done".
2985        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.";
2986        match synthesize_response_from_text(exploratory) {
2987            None => {
2988                // Ambiguous exploratory prose correctly returns None — not marked done.
2989            }
2990            Some(resp) => {
2991                assert_ne!(
2992                    resp.status, "done",
2993                    "Exploratory prose must not be auto-marked done"
2994                );
2995            }
2996        }
2997    }
2998
2999    #[test]
3000    fn synthesize_response_marks_done_for_commit_created() {
3001        let response = synthesize_response_from_text(
3002            "Commit created locally. The push is being blocked by permissions.",
3003        )
3004        .unwrap();
3005        assert_eq!(response.status, "done");
3006    }
3007
3008    #[test]
3009    fn synthesize_response_does_not_mark_done_for_not_committed() {
3010        let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
3011        assert_eq!(
3012            response.status, "needs_review",
3013            "\"not committed\" should not match \"committed\""
3014        );
3015    }
3016
3017    // ── Regression: #1362/#1363 — agent text misclassified as needs_review ──
3018
3019    #[test]
3020    fn synthesize_response_marks_done_for_fix_is_complete() {
3021        // Exact agent output from task 30203 (issue #1363).
3022        // "The fix is complete" was misclassified as needs_review because
3023        // the pattern list had "completed" but not "complete".
3024        let response = synthesize_response_from_text(
3025            "All 73 cooldown tests pass. The fix is complete.\n\n\
3026             **Summary of changes:**\n\n\
3027             1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
3028             `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
3029        )
3030        .unwrap();
3031        assert_eq!(
3032            response.status, "done",
3033            "\"The fix is complete\" must be classified as done, not needs_review"
3034        );
3035    }
3036
3037    #[test]
3038    fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3039        // Exact agent output from task 30204 (issue #1362).
3040        // "All tests pass. The fix is clean and working." was misclassified
3041        // as needs_review because none of the looks_done patterns matched.
3042        let response = synthesize_response_from_text(
3043            "All tests pass. The fix is clean and working.\n\n\
3044             **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3045             - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3046             - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3047        )
3048        .unwrap();
3049        assert_eq!(
3050            response.status, "done",
3051            "\"All tests pass. The fix is clean and working.\" must be classified as done"
3052        );
3053    }
3054
3055    // ── parse_ndjson edge cases ───────────────────────────────────
3056
3057    #[test]
3058    fn parse_ndjson_handles_standard_lines() {
3059        let raw = r#"{"type":"text","text":"hello"}
3060{"type":"text","text":"world"}"#;
3061        let events = parse_ndjson(raw);
3062        assert_eq!(events.len(), 2);
3063        assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3064        assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3065    }
3066
3067    #[test]
3068    fn parse_ndjson_skips_empty_lines() {
3069        let raw = r#"{"type":"text"}
3070
3071{"type":"text"}
3072"#;
3073        let events = parse_ndjson(raw);
3074        assert_eq!(events.len(), 2);
3075    }
3076
3077    #[test]
3078    fn parse_ndjson_skips_malformed_lines() {
3079        let raw = r#"{"type":"valid"}
3080not json at all
3081{"type":"also_valid"}"#;
3082        let events = parse_ndjson(raw);
3083        assert_eq!(events.len(), 2);
3084    }
3085
3086    #[test]
3087    fn parse_ndjson_fallback_extracts_embedded_json() {
3088        // Edge case: JSON with closing fence inside string field
3089        let raw =
3090            r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3091        let events = parse_ndjson(raw);
3092        // Should have extracted the embedded JSON object
3093        assert!(
3094            !events.is_empty(),
3095            "should extract JSON objects via fallback"
3096        );
3097    }
3098
3099    #[test]
3100    fn parse_ndjson_fallback_handles_fragmented_objects() {
3101        // Edge case: JSON object spread across multiple lines (fragmented NDJSON)
3102        let raw = r#"{"type": "text
3103", "content": "hello
3104world"}"#;
3105        let events = parse_ndjson(raw);
3106        // Should still extract the JSON via fallback
3107        assert!(!events.is_empty(), "should handle fragmented JSON");
3108    }
3109
3110    // ── PermissionRules defaults ────────────────────────────────
3111
3112    #[test]
3113    fn permission_rules_default_is_autonomous() {
3114        let rules = PermissionRules::default();
3115        assert!(rules.autonomous);
3116    }
3117
3118    #[test]
3119    fn permission_rules_default_sandbox_is_workspace_write() {
3120        let rules = PermissionRules::default();
3121        assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3122    }
3123
3124    #[test]
3125    fn permission_rules_default_disallows_rm() {
3126        let rules = PermissionRules::default();
3127        assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3128        assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3129    }
3130
3131    #[test]
3132    fn permission_rules_default_no_allowed_edit_paths() {
3133        let rules = PermissionRules::default();
3134        assert!(rules.allowed_edit_paths.is_empty());
3135    }
3136
3137    // ── Permission translation across all agents ────────────────
3138
3139    /// Test that all agents handle the default permission rules consistently.
3140    #[test]
3141    fn all_agents_handle_default_permissions() {
3142        let perms = PermissionRules::default();
3143        let sys = "/tmp/sys.md";
3144        let msg = "/tmp/msg.md";
3145
3146        // Claude: should have bypassPermissions and rm disallowed
3147        let claude = get_runner("claude");
3148        let cmd = claude.build_command(None, "", sys, msg, &perms);
3149        assert!(
3150            cmd.contains("--permission-mode bypassPermissions"),
3151            "claude default: expected bypassPermissions, got: {cmd}"
3152        );
3153        assert!(
3154            cmd.contains("Bash(rm *)"),
3155            "claude default: expected rm disallowed, got: {cmd}"
3156        );
3157
3158        // Codex: autonomous + workspace-write → --sandbox workspace-write -c 'approval_policy="never"'
3159        let codex = get_runner("codex");
3160        let cmd = codex.build_command(None, "", sys, msg, &perms);
3161        assert!(
3162            cmd.contains("--sandbox workspace-write"),
3163            "codex default: expected --sandbox workspace-write, got: {cmd}"
3164        );
3165        assert!(
3166            cmd.contains(r#"-c 'approval_policy="never"'"#),
3167            "codex default: expected -c approval_policy=never, got: {cmd}"
3168        );
3169        assert!(
3170            !cmd.contains("--ask-for-approval"),
3171            "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3172        );
3173        assert!(
3174            !cmd.contains("--full-auto"),
3175            "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3176        );
3177
3178        // OpenCode: autonomous dispatch passes --dangerously-skip-permissions
3179        // and writes no config file (relies on opencode's built-in flag).
3180        let opencode = get_runner("opencode");
3181        let cmd = opencode.build_command(None, "", sys, msg, &perms);
3182        assert!(
3183            cmd.contains("--dangerously-skip-permissions"),
3184            "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3185        );
3186        assert!(
3187            !cmd.contains("opencode.json"),
3188            "opencode default: should not write a config file, got: {cmd}"
3189        );
3190        assert!(
3191            !cmd.contains("XDG_CONFIG_HOME"),
3192            "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3193        );
3194        assert!(
3195            !cmd.contains(".orch-opencode"),
3196            "opencode default: should not reference .orch-opencode, got: {cmd}"
3197        );
3198    }
3199
3200    /// Test supervised mode translates correctly for each agent.
3201    #[test]
3202    fn all_agents_handle_supervised_mode() {
3203        let perms = PermissionRules {
3204            autonomous: false,
3205            sandbox: SandboxLevel::WorkspaceWrite,
3206            disallowed_tools: vec![],
3207            allowed_tools: vec![],
3208            allowed_edit_paths: vec![],
3209            deny_read_only: false,
3210            extra_writable_dirs: vec![],
3211        };
3212        let sys = "/tmp/sys.md";
3213        let msg = "/tmp/msg.md";
3214
3215        // Claude: supervised → acceptEdits
3216        let claude = get_runner("claude");
3217        let cmd = claude.build_command(None, "", sys, msg, &perms);
3218        assert!(
3219            cmd.contains("--permission-mode acceptEdits"),
3220            "claude supervised: expected acceptEdits, got: {cmd}"
3221        );
3222
3223        // Codex: supervised → on-request
3224        let codex = get_runner("codex");
3225        let cmd = codex.build_command(None, "", sys, msg, &perms);
3226        assert!(
3227            cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3228            "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3229        );
3230        assert!(
3231            !cmd.contains("--ask-for-approval"),
3232            "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3233        );
3234    }
3235
3236    /// Test full access sandbox translates correctly for each agent.
3237    #[test]
3238    fn all_agents_handle_full_access_sandbox() {
3239        let perms = PermissionRules {
3240            autonomous: true,
3241            sandbox: SandboxLevel::FullAccess,
3242            disallowed_tools: vec![],
3243            allowed_tools: vec![],
3244            allowed_edit_paths: vec![],
3245            deny_read_only: false,
3246            extra_writable_dirs: vec![],
3247        };
3248        let sys = "/tmp/sys.md";
3249        let msg = "/tmp/msg.md";
3250
3251        // Claude: ignores sandbox level (no --sandbox flag)
3252        let claude = get_runner("claude");
3253        let cmd = claude.build_command(None, "", sys, msg, &perms);
3254        assert!(
3255            !cmd.contains("--sandbox"),
3256            "claude full_access: should have no sandbox flag, got: {cmd}"
3257        );
3258
3259        // Codex: full access → --dangerously-bypass-approvals-and-sandbox
3260        let codex = get_runner("codex");
3261        let cmd = codex.build_command(None, "", sys, msg, &perms);
3262        assert!(
3263            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3264            "codex full_access: expected dangerously-bypass, got: {cmd}"
3265        );
3266    }
3267
3268    /// SandboxLevel::None with autonomous uses --sandbox workspace-write (same as WorkspaceWrite).
3269    #[test]
3270    fn codex_sandbox_none_uses_workspace_write() {
3271        let perms = PermissionRules {
3272            autonomous: true,
3273            sandbox: SandboxLevel::None,
3274            disallowed_tools: vec![],
3275            allowed_tools: vec![],
3276            allowed_edit_paths: vec![],
3277            deny_read_only: false,
3278            extra_writable_dirs: vec![],
3279        };
3280        let codex = get_runner("codex");
3281        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3282        assert!(
3283            cmd.contains("--sandbox workspace-write"),
3284            "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3285        );
3286        assert!(
3287            cmd.contains(r#"-c 'approval_policy="never"'"#),
3288            "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3289        );
3290        assert!(
3291            !cmd.contains("--ask-for-approval"),
3292            "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3293        );
3294        assert!(
3295            !cmd.contains("--full-auto"),
3296            "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3297        );
3298    }
3299
3300    /// Test that allowed_edit_paths scopes Edit/Write when used with allowed_tools.
3301    #[test]
3302    fn claude_allowed_edit_paths_scope_edit_write() {
3303        let perms = PermissionRules {
3304            autonomous: true,
3305            sandbox: SandboxLevel::WorkspaceWrite,
3306            disallowed_tools: vec![],
3307            allowed_tools: vec![
3308                "Edit".to_string(),
3309                "Write".to_string(),
3310                "Read".to_string(),
3311                "Bash".to_string(),
3312            ],
3313            allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3314            deny_read_only: false,
3315            extra_writable_dirs: vec![],
3316        };
3317        let claude = get_runner("claude");
3318        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3319
3320        // Edit/Write should be scoped to worktree path
3321        assert!(
3322            cmd.contains("Edit(/home/user/worktree/*)"),
3323            "missing scoped Edit, got: {cmd}"
3324        );
3325        assert!(
3326            cmd.contains("Write(/home/user/worktree/*)"),
3327            "missing scoped Write, got: {cmd}"
3328        );
3329        // Read should be unrestricted
3330        assert!(cmd.contains("Read"), "missing Read");
3331    }
3332
3333    /// Test that blocked paths don't affect Codex (it uses sandbox isolation).
3334    #[test]
3335    fn codex_ignores_allowed_edit_paths() {
3336        let perms = PermissionRules {
3337            autonomous: true,
3338            sandbox: SandboxLevel::WorkspaceWrite,
3339            disallowed_tools: vec![],
3340            allowed_tools: vec![],
3341            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3342            deny_read_only: false,
3343            extra_writable_dirs: vec![],
3344        };
3345        let codex = get_runner("codex");
3346        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3347        assert!(
3348            !cmd.contains("/home/user/project"),
3349            "codex: should not reference blocked paths, got: {cmd}"
3350        );
3351    }
3352
3353    /// Test that blocked paths don't affect OpenCode.
3354    #[test]
3355    fn opencode_ignores_allowed_edit_paths() {
3356        let perms = PermissionRules {
3357            autonomous: true,
3358            sandbox: SandboxLevel::WorkspaceWrite,
3359            disallowed_tools: vec![],
3360            allowed_tools: vec![],
3361            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3362            deny_read_only: false,
3363            extra_writable_dirs: vec![],
3364        };
3365        let opencode = get_runner("opencode");
3366        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3367        assert!(
3368            !cmd.contains("/home/user/project"),
3369            "opencode: should not reference blocked paths, got: {cmd}"
3370        );
3371    }
3372
3373    /// Test that disallowed tools merge correctly for Claude (defaults + custom).
3374    #[test]
3375    fn claude_merges_disallowed_tools() {
3376        let perms = PermissionRules {
3377            autonomous: true,
3378            sandbox: SandboxLevel::WorkspaceWrite,
3379            disallowed_tools: vec![
3380                "Bash(rm *)".to_string(),
3381                "Bash(sudo *)".to_string(),
3382                "WebFetch".to_string(),
3383            ],
3384            allowed_tools: vec![],
3385            allowed_edit_paths: vec![],
3386            deny_read_only: false,
3387            extra_writable_dirs: vec![],
3388        };
3389        let claude = get_runner("claude");
3390        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3391        assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3392        assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3393        assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3394    }
3395
3396    /// Test that Codex ignores disallowed tools (has no such flag).
3397    #[test]
3398    fn codex_ignores_disallowed_tools() {
3399        let perms = PermissionRules {
3400            autonomous: true,
3401            sandbox: SandboxLevel::WorkspaceWrite,
3402            disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3403            allowed_tools: vec![],
3404            allowed_edit_paths: vec![],
3405            deny_read_only: false,
3406            extra_writable_dirs: vec![],
3407        };
3408        let codex = get_runner("codex");
3409        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3410        assert!(
3411            !cmd.contains("disallow"),
3412            "codex: should not have disallow flags, got: {cmd}"
3413        );
3414    }
3415
3416    /// Test Claude with empty disallowed tools produces no --disallowedTools flag.
3417    #[test]
3418    fn claude_no_disallowed_flag_when_empty() {
3419        let perms = PermissionRules {
3420            autonomous: true,
3421            sandbox: SandboxLevel::WorkspaceWrite,
3422            disallowed_tools: vec![],
3423            allowed_tools: vec![],
3424            allowed_edit_paths: vec![],
3425            deny_read_only: false,
3426            extra_writable_dirs: vec![],
3427        };
3428        let claude = get_runner("claude");
3429        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3430        assert!(
3431            !cmd.contains("--disallowedTools"),
3432            "claude: should have no disallowed flag when empty, got: {cmd}"
3433        );
3434    }
3435
3436    /// Test Kimi/MiniMax aliases inherit Claude permission translation.
3437    #[test]
3438    fn kimi_minimax_use_claude_permissions() {
3439        let perms = PermissionRules {
3440            autonomous: false,
3441            sandbox: SandboxLevel::WorkspaceWrite,
3442            disallowed_tools: vec!["Bash(rm *)".to_string()],
3443            allowed_tools: vec![],
3444            allowed_edit_paths: vec![],
3445            deny_read_only: false,
3446            extra_writable_dirs: vec![],
3447        };
3448        let sys = "/tmp/sys.md";
3449        let msg = "/tmp/msg.md";
3450
3451        for agent in &["kimi", "minimax"] {
3452            let runner = get_runner(agent);
3453            let cmd = runner.build_command(None, "", sys, msg, &perms);
3454            assert!(
3455                cmd.contains("--permission-mode acceptEdits"),
3456                "{agent}: expected acceptEdits"
3457            );
3458            assert!(
3459                cmd.contains("Bash(rm *)"),
3460                "{agent}: expected rm disallowed"
3461            );
3462        }
3463    }
3464
3465    /// Test kimi/minimax get --allowedTools when allowed_tools is set.
3466    #[test]
3467    fn kimi_minimax_use_allowed_tools() {
3468        let perms = PermissionRules {
3469            autonomous: true,
3470            sandbox: SandboxLevel::WorkspaceWrite,
3471            disallowed_tools: vec![],
3472            allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3473            allowed_edit_paths: vec![],
3474            deny_read_only: false,
3475            extra_writable_dirs: vec![],
3476        };
3477
3478        for agent in &["kimi", "minimax"] {
3479            let runner = get_runner(agent);
3480            let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3481            assert!(
3482                cmd.contains("--allowedTools"),
3483                "{agent}: expected --allowedTools, got: {cmd}"
3484            );
3485            assert!(
3486                cmd.contains("Bash(git *)"),
3487                "{agent}: expected Bash(git *) pattern, got: {cmd}"
3488            );
3489        }
3490    }
3491
3492    // ── Allowed tools across agents ─────────────────────────────
3493
3494    /// Test that allowed_tools generates --allowedTools for Claude.
3495    #[test]
3496    fn claude_allowed_tools_generates_allowedtools_flag() {
3497        let perms = PermissionRules {
3498            autonomous: true,
3499            sandbox: SandboxLevel::WorkspaceWrite,
3500            disallowed_tools: vec!["Bash(rm *)".to_string()],
3501            allowed_tools: vec![
3502                "Edit".to_string(),
3503                "Write".to_string(),
3504                "Read".to_string(),
3505                "Bash".to_string(),
3506                "git".to_string(),
3507            ],
3508            allowed_edit_paths: vec![],
3509            deny_read_only: false,
3510            extra_writable_dirs: vec![],
3511        };
3512
3513        let claude = get_runner("claude");
3514        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3515        assert!(
3516            cmd.contains("--allowedTools"),
3517            "expected --allowedTools, got: {cmd}"
3518        );
3519        assert!(
3520            !cmd.contains("--disallowedTools"),
3521            "should not have --disallowedTools when allowed_tools is set"
3522        );
3523    }
3524
3525    /// Test that codex/opencode ignore allowed_tools (no equivalent flag).
3526    #[test]
3527    fn codex_opencode_ignore_allowed_tools() {
3528        let perms = PermissionRules {
3529            autonomous: true,
3530            sandbox: SandboxLevel::WorkspaceWrite,
3531            disallowed_tools: vec![],
3532            allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3533            allowed_edit_paths: vec![],
3534            deny_read_only: false,
3535            extra_writable_dirs: vec![],
3536        };
3537
3538        let codex = get_runner("codex");
3539        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3540        assert!(
3541            !cmd.contains("--allowedTools"),
3542            "codex should not have --allowedTools, got: {cmd}"
3543        );
3544
3545        let opencode = get_runner("opencode");
3546        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3547        assert!(
3548            !cmd.contains("--allowedTools"),
3549            "opencode should not have --allowedTools, got: {cmd}"
3550        );
3551    }
3552
3553    // ── Integration: from_config() → agent translation ──────────
3554
3555    /// Integration test: verify from_config() loads real config and agents
3556    /// translate it correctly. Requires ~/.orch/config.yml to exist.
3557    #[test]
3558    #[ignore]
3559    fn integration_from_config_to_agent_commands() {
3560        let perms = PermissionRules::from_config();
3561
3562        // Verify defaults are reasonable
3563        assert!(
3564            perms.autonomous,
3565            "from_config: expected autonomous=true by default"
3566        );
3567
3568        // Verify each agent can build a command from the config-loaded rules
3569        for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3570            let runner = get_runner(agent_name);
3571            let cmd =
3572                runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3573            assert!(
3574                !cmd.is_empty(),
3575                "{agent_name}: build_command returned empty string"
3576            );
3577            assert!(
3578                cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3579                "{agent_name}: command should reference agent binary, got: {cmd}"
3580            );
3581        }
3582    }
3583
3584    /// Integration test: verify from_config() handles supervised mode.
3585    /// Requires config with workflow.permissions.mode = "supervised".
3586    #[test]
3587    #[ignore]
3588    fn integration_supervised_config_translates_correctly() {
3589        // This test requires config to have:
3590        // workflow:
3591        //   permissions:
3592        //     mode: supervised
3593        let perms = PermissionRules::from_config();
3594
3595        let claude = get_runner("claude");
3596        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3597
3598        if perms.autonomous {
3599            assert!(
3600                cmd.contains("bypassPermissions"),
3601                "autonomous config → bypassPermissions"
3602            );
3603        } else {
3604            assert!(
3605                cmd.contains("acceptEdits"),
3606                "supervised config → acceptEdits"
3607            );
3608        }
3609
3610        let codex = get_runner("codex");
3611        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3612
3613        if perms.autonomous {
3614            // autonomous codex uses --sandbox workspace-write -c 'approval_policy="never"'
3615            assert!(
3616                cmd.contains("--sandbox workspace-write"),
3617                "autonomous config → --sandbox workspace-write, got: {cmd}"
3618            );
3619            assert!(
3620                cmd.contains(r#"-c 'approval_policy="never"'"#),
3621                "autonomous config → -c approval_policy=never, got: {cmd}"
3622            );
3623        } else {
3624            assert!(
3625                cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3626                "supervised config → -c approval_policy=on-request, got: {cmd}"
3627            );
3628        }
3629        assert!(
3630            !cmd.contains("--ask-for-approval"),
3631            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3632        );
3633    }
3634
3635    // ── deny_read_only preset ───────────────────────────────────
3636
3637    /// Test that deny_read_only() sets the right disallowed tools for control sessions.
3638    #[test]
3639    fn deny_read_only_blocks_reading_tools() {
3640        let perms = PermissionRules::deny_read_only();
3641
3642        let read_tools = [
3643            "Bash(ls *)",
3644            "Bash(ls)",
3645            "Bash(find *)",
3646            "Bash(cat *)",
3647            "Bash(head *)",
3648            "Bash(tail *)",
3649            "Bash(fzf *)",
3650            "Bash(less *)",
3651            "Bash(more *)",
3652            "Read",
3653            "Glob",
3654            "Grep",
3655        ];
3656
3657        for tool in read_tools {
3658            assert!(
3659                perms.disallowed_tools.contains(&tool.to_string()),
3660                "deny_read_only should block {tool}, got: {:?}",
3661                perms.disallowed_tools
3662            );
3663        }
3664    }
3665
3666    /// Test that deny_read_only() produces correct Claude command for control session.
3667    #[test]
3668    fn deny_read_only_claude_command() {
3669        let perms = PermissionRules::deny_read_only();
3670        let claude = get_runner("claude");
3671        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3672
3673        // Should contain Read, Glob, Grep disallow patterns
3674        assert!(
3675            cmd.contains("Read"),
3676            "should block Read for control session"
3677        );
3678        assert!(
3679            cmd.contains("Glob"),
3680            "should block Glob for control session"
3681        );
3682        assert!(
3683            cmd.contains("Grep"),
3684            "should block Grep for control session"
3685        );
3686        // Should block ls
3687        assert!(
3688            cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3689            "should block ls"
3690        );
3691        // Should still have bypassPermissions (autonomous)
3692        assert!(
3693            cmd.contains("--permission-mode bypassPermissions"),
3694            "should be autonomous (bypassPermissions)"
3695        );
3696    }
3697
3698    // --- synthesize_response_from_text structured extraction ---
3699
3700    #[test]
3701    fn synthesize_extracts_file_paths() {
3702        let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3703        let resp = synthesize_response_from_text(text).unwrap();
3704        assert!(
3705            resp.files
3706                .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3707            "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3708            resp.files
3709        );
3710        assert!(
3711            resp.files.contains(&"tests/integration.rs".to_string()),
3712            "expected tests/integration.rs in files, got: {:?}",
3713            resp.files
3714        );
3715    }
3716
3717    #[test]
3718    fn synthesize_extracts_accomplished_bullets() {
3719        let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3720        let resp = synthesize_response_from_text(text).unwrap();
3721        assert_eq!(resp.status, "done");
3722        assert!(
3723            resp.accomplished
3724                .iter()
3725                .any(|a| a.contains("Added logging")),
3726            "expected accomplished bullet, got: {:?}",
3727            resp.accomplished
3728        );
3729        assert!(
3730            resp.accomplished
3731                .iter()
3732                .any(|a| a.contains("Updated migration")),
3733            "expected accomplished bullet, got: {:?}",
3734            resp.accomplished
3735        );
3736    }
3737
3738    #[test]
3739    fn synthesize_extracts_remaining_section() {
3740        let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3741        let resp = synthesize_response_from_text(text).unwrap();
3742        assert!(
3743            resp.remaining
3744                .iter()
3745                .any(|r| r.contains("Write unit tests")),
3746            "expected remaining item, got: {:?}",
3747            resp.remaining
3748        );
3749        assert!(
3750            resp.remaining.iter().any(|r| r.contains("Update docs")),
3751            "expected remaining item, got: {:?}",
3752            resp.remaining
3753        );
3754        assert!(
3755            resp.accomplished.iter().any(|a| a.contains("Patched")),
3756            "expected accomplished item, got: {:?}",
3757            resp.accomplished
3758        );
3759    }
3760
3761    #[test]
3762    fn synthesize_fixed_phrase_is_done() {
3763        let text = "Fixed. The bug no longer reproduces.";
3764        let resp = synthesize_response_from_text(text).unwrap();
3765        assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3766    }
3767
3768    // ── detect_rate_limit false-positive guard ────────────────────────────
3769    // Regression: NDJSON telemetry blobs can contain "429" as a token count
3770    // (e.g. "outputTokens":429). `detect_rate_limit` should still flag those
3771    // because it cannot distinguish context without the terminal_reason guard.
3772    // The guard lives in review.rs, but here we verify the raw detector
3773    // behaviour so callers know they must apply the guard themselves.
3774    #[test]
3775    fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3776        // This SHOULD return Some — the generic detector has no context.
3777        // The caller (review.rs) is responsible for suppressing it when
3778        // terminal_reason:completed is present.
3779        let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3780        assert!(
3781            patterns::detect_rate_limit(payload).is_some(),
3782            "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3783        );
3784    }
3785
3786    #[test]
3787    fn detect_rate_limit_ignores_429_inside_longer_number() {
3788        let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
3789        assert!(
3790            patterns::detect_rate_limit(uuid).is_none(),
3791            "429 inside a UUID digit run must not match"
3792        );
3793    }
3794
3795    // Verify the guard logic that review.rs uses: when raw NDJSON has
3796    // terminal_reason:completed without is_error:true, the result of
3797    // detect_rate_limit should be suppressed.
3798    #[test]
3799    fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3800        let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3801        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3802            && !raw_output.contains("\"is_error\":true");
3803        // Apply the same guard that review.rs now uses.
3804        let detected = (!ndjson_completed)
3805            .then(|| patterns::detect_rate_limit(raw_output))
3806            .flatten();
3807        assert!(
3808            detected.is_none(),
3809            "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3810        );
3811    }
3812
3813    // Verify the guard does NOT suppress a real rate-limit error.
3814    #[test]
3815    fn terminal_reason_completed_guard_passes_real_rate_limit() {
3816        // Real rate-limit output has no terminal_reason:completed.
3817        let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3818        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3819            && !raw_output.contains("\"is_error\":true");
3820        let detected = (!ndjson_completed)
3821            .then(|| patterns::detect_rate_limit(raw_output))
3822            .flatten();
3823        assert!(
3824            detected.is_some(),
3825            "guard must NOT suppress a real rate-limit error"
3826        );
3827    }
3828
3829    #[test]
3830    fn synthesize_no_false_positive_file_paths() {
3831        // URLs and version strings should not appear in files list.
3832        // The text is ambiguous (no error, no done signal, no explicit negation) so
3833        // synthesize_response_from_text returns None — no file extraction happens.
3834        let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3835        match synthesize_response_from_text(text) {
3836            None => {} // Ambiguous text correctly returns None — no files extracted.
3837            Some(resp) => {
3838                assert!(
3839                    resp.files.is_empty(),
3840                    "expected no file paths extracted from URL text, got: {:?}",
3841                    resp.files
3842                );
3843            }
3844        }
3845    }
3846
3847    // Regression: issue #3197 — CLI arg-parser diagnostics were being synthesized as
3848    // agent responses. When a process (e.g. codex) fails with an invalid CLI flag, it
3849    // exits non-zero with a clap/help diagnostic on stderr. This should be rejected
3850    // (return None) rather than synthesized as a "needs_review" response.
3851    #[test]
3852    fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3853        // Real clap error from the bug report (codex exit 141)
3854        let text = "error: unexpected argument '--ask-for-approval' found\n  \
3855                    tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3856                    Usage: codex exec [OPTIONS] [PROMPT]";
3857        assert!(
3858            synthesize_response_from_text(text).is_none(),
3859            "CLI parser diagnostic must not synthesize as agent response"
3860        );
3861    }
3862
3863    #[test]
3864    fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3865        let text = "Usage: mycommand [OPTIONS] <arg>";
3866        assert!(
3867            synthesize_response_from_text(text).is_none(),
3868            "Usage line must not synthesize as agent response"
3869        );
3870    }
3871
3872    #[test]
3873    fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3874        let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3875        assert!(
3876            synthesize_response_from_text(text).is_none(),
3877            "Help hint must not synthesize as agent response"
3878        );
3879    }
3880
3881    #[test]
3882    fn synthesize_rejects_cli_parser_diagnostics_tip() {
3883        let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3884        assert!(
3885            synthesize_response_from_text(text).is_none(),
3886            "Clap tip must not synthesize as agent response"
3887        );
3888    }
3889
3890    #[test]
3891    fn synthesize_accepts_agent_error_messages_that_mention_error() {
3892        // Agent returning plain text that happens to contain "error:" but is an actual
3893        // agent message (e.g. summarizing what went wrong) should still synthesize.
3894        let text = "I tried to run the command but got an error: permission denied on that file.";
3895        let response =
3896            synthesize_response_from_text(text).expect("agent error message should synthesize");
3897        assert_eq!(response.status, "needs_review");
3898        assert!(response.error.is_some());
3899    }
3900
3901    // Regression: Issue #3197 — exact clap error from bug report.
3902    // When codex fails with an invalid CLI flag (e.g., --ask-for-approval with old codex versions),
3903    // it should return None, not synthesize as "needs_review". Without this fix, the run
3904    // would be marked as success and advance to in_review, wasting resources.
3905    #[test]
3906    fn synthesize_rejects_exact_codex_cli_error() {
3907        let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n  \
3908                                 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3909                                 Usage: codex exec [OPTIONS] [PROMPT]";
3910        assert!(
3911            synthesize_response_from_text(stderr_from_codex).is_none(),
3912            "exact codex CLI error from #3197 must not synthesize as agent response"
3913        );
3914    }
3915}