Skip to main content

orch/engine/runner/agents/
mod.rs

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