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