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    pub deny_read_only: bool,
46    /// Additional directories that must be writable (e.g. the git common dir
47    /// when running inside a worktree whose `.git` metadata lives outside the
48    /// sandbox root). Codex translates these into `--add-dir` flags; other
49    /// agents ignore the field.
50    pub extra_writable_dirs: Vec<PathBuf>,
51}
52
53/// Sandbox level — how much filesystem access the agent gets.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SandboxLevel {
56    /// Agent can only write within the workspace/worktree.
57    WorkspaceWrite,
58    /// Agent has full filesystem access (dangerous).
59    FullAccess,
60    /// No sandboxing (orch manages isolation externally).
61    None,
62    /// The OS sandbox from `workflow.sandbox: os` runs the agent, so the agent's own sandbox is off
63    Os,
64}
65
66impl Default for PermissionRules {
67    fn default() -> Self {
68        Self {
69            autonomous: true,
70            sandbox: SandboxLevel::WorkspaceWrite,
71            disallowed_tools: vec![
72                "Bash(rm *)".to_string(),
73                "Bash(rm -*)".to_string(),
74                "Bash(git push*)".to_string(),
75            ],
76            allowed_tools: vec![],
77            allowed_edit_paths: vec![],
78            deny_read_only: false,
79            extra_writable_dirs: vec![],
80        }
81    }
82}
83
84impl PermissionRules {
85    /// Load permission rules from config, falling back to defaults.
86    pub fn from_config() -> Self {
87        let mut rules = Self::default();
88
89        if let Ok(mode) = crate::config::get("workflow.permissions.mode") {
90            rules.autonomous = mode != "supervised";
91        }
92
93        if let Ok(sandbox) = crate::config::get("workflow.permissions.sandbox") {
94            rules.sandbox = match sandbox.as_str() {
95                "full-access" | "danger-full-access" => SandboxLevel::FullAccess,
96                "none" => SandboxLevel::None,
97                _ => SandboxLevel::WorkspaceWrite,
98            };
99        }
100
101        if let Ok(tools) = crate::config::get("workflow.disallowed_tools") {
102            let parsed: Vec<String> = tools
103                .split(',')
104                .map(|s| s.trim().to_string())
105                .filter(|s| !s.is_empty())
106                .collect();
107            if !parsed.is_empty() {
108                rules.disallowed_tools = parsed;
109            }
110        }
111
112        if let Ok(tools) = crate::config::get_list("workflow.allowed_tools") {
113            rules.allowed_tools = tools;
114        }
115
116        rules
117    }
118
119    /// Restriction preset for control sessions: denies Read, Glob, Grep, and
120    /// Bash commands that read files or list directories (ls, cat, find, etc.).
121    ///
122    /// The agent remains autonomous but is prevented from browsing or reading
123    /// arbitrary files on the host machine. Translation into agent-native flags
124    /// is handled by each `AgentRunner::build_command()` implementation.
125    pub fn deny_read_only() -> Self {
126        Self {
127            autonomous: true,
128            sandbox: SandboxLevel::WorkspaceWrite,
129            disallowed_tools: vec![
130                "Bash(rm *)".to_string(),
131                "Bash(rm -*)".to_string(),
132                "Bash(git push*)".to_string(),
133                "Bash(ls *)".to_string(),
134                "Bash(ls)".to_string(),
135                "Bash(find *)".to_string(),
136                "Bash(cat *)".to_string(),
137                "Bash(head *)".to_string(),
138                "Bash(tail *)".to_string(),
139                "Bash(fzf *)".to_string(),
140                "Bash(less *)".to_string(),
141                "Bash(more *)".to_string(),
142                "Read".to_string(),
143                "Glob".to_string(),
144                "Grep".to_string(),
145            ],
146            allowed_tools: vec![],
147            allowed_edit_paths: vec![],
148            deny_read_only: true,
149            extra_writable_dirs: vec![],
150        }
151    }
152}
153
154/// Structured result from per-agent NDJSON extraction.
155///
156/// Each agent emits a different NDJSON format. The per-agent `find_result`
157/// functions parse agent-specific envelopes and return a unified `AgentResult`
158/// so callers (review pipeline, response handler) don't need format knowledge.
159#[derive(Debug, Clone)]
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        "grok" => grok::find_grok_result(ndjson),
196        "opencode" => opencode::find_opencode_result(ndjson),
197        "codex" => codex::find_codex_result(ndjson),
198        // claude, kimi, minimax and unknown agents all emit the claude format
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 hit the task time limit. Retry without cooling the model
229    Timeout { elapsed: Duration },
230    /// Process exited with no result envelope, usually a kill rather than a model fault
231    Interrupted { message: String },
232    /// Required tool/binary missing from the environment.
233    MissingTool { tool: String },
234    /// Sandbox or filesystem permission denied.
235    PermissionDenied { message: String },
236    /// Agent is waiting for interactive input (e.g., 1Password, SSH).
237    WaitingForInput { message: String },
238    /// The agent's session ID is no longer valid (session expired or was cleared).
239    ///
240    /// Returned when Claude reports "No conversation found with session ID: <uuid>".
241    /// The caller should reset the stored UUID and retry with a fresh `--session-id`.
242    StaleSession { session_id: String },
243    /// Agent returned unparseable output.
244    InvalidResponse { raw: String },
245    /// Agent self-reported a failure in its response.
246    AgentFailed { message: String },
247    /// Transient network connectivity error — retry same agent, no failover.
248    NetworkError { message: String },
249    /// Thinking blocks cannot be modified (Claude extended-thinking + tool-use 400).
250    ///
251    /// Transient API incompatibility inside the Claude CLI's multi-turn tool-use
252    /// loop — the Anthropic API rejects the request when thinking blocks are altered
253    /// between turns. Not a model capacity problem: does NOT increment failure counts
254    /// or apply cooldowns. The task is rerouted to a different agent.
255    ThinkingBlockConflict { message: String },
256    /// Unclassified error.
257    Unknown { exit_code: i32, message: String },
258}
259
260impl std::fmt::Display for AgentError {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        match self {
263            Self::RateLimit { message, .. } => write!(f, "rate limit: {message}"),
264            Self::Auth { message } => write!(f, "auth error: {message}"),
265            Self::ModelUnavailable { message, model } => {
266                write!(f, "model unavailable ({model}): {message}")
267            }
268            Self::ContextOverflow { message, .. } => write!(f, "context overflow: {message}"),
269            Self::Timeout { elapsed } => write!(f, "timeout after {}s", elapsed.as_secs()),
270            Self::Interrupted { message } => write!(f, "interrupted: {message}"),
271            Self::MissingTool { tool } => write!(f, "missing tool: {tool}"),
272            Self::PermissionDenied { message } => write!(f, "permission denied: {message}"),
273            Self::WaitingForInput { message } => write!(f, "waiting for input: {message}"),
274            Self::StaleSession { session_id } => {
275                write!(f, "stale session (session expired): {session_id}")
276            }
277            Self::InvalidResponse { raw } => {
278                let end = truncate_at_char_boundary(raw, 200);
279                write!(f, "invalid response: {}", &raw[..end])
280            }
281            Self::AgentFailed { message } => write!(f, "agent failed: {message}"),
282            Self::NetworkError { message } => write!(f, "network error: {message}"),
283            Self::ThinkingBlockConflict { message } => {
284                write!(f, "thinking block conflict: {message}")
285            }
286            Self::Unknown { exit_code, message } => {
287                write!(f, "unknown error (exit {exit_code}): {message}")
288            }
289        }
290    }
291}
292
293impl std::error::Error for AgentError {}
294
295/// Return the variant name of an `AgentError` as a static string,
296/// for structured logging and `result.json` output.
297pub fn error_class_name(err: &AgentError) -> &'static str {
298    match err {
299        AgentError::RateLimit { .. } => "RateLimit",
300        AgentError::Auth { .. } => "Auth",
301        AgentError::ModelUnavailable { .. } => "ModelUnavailable",
302        AgentError::ContextOverflow { .. } => "ContextOverflow",
303        AgentError::Timeout { .. } => "Timeout",
304        AgentError::Interrupted { .. } => "Interrupted",
305        AgentError::MissingTool { .. } => "MissingTool",
306        AgentError::PermissionDenied { .. } => "PermissionDenied",
307        AgentError::WaitingForInput { .. } => "WaitingForInput",
308        AgentError::StaleSession { .. } => "StaleSession",
309        AgentError::InvalidResponse { .. } => "InvalidResponse",
310        AgentError::AgentFailed { .. } => "AgentFailed",
311        AgentError::NetworkError { .. } => "NetworkError",
312        AgentError::ThinkingBlockConflict { .. } => "ThinkingBlockConflict",
313        AgentError::Unknown { .. } => "Unknown",
314    }
315}
316
317/// Extract file paths from free-form text.
318///
319/// Matches common source file patterns like `src/foo/bar.rs`, `tests/x.py`,
320/// `path/to/file.ts`, etc. Deduplicates and returns them sorted.
321fn extract_file_paths(text: &str) -> Vec<String> {
322    // Match path-like tokens: optional leading slash or no slash, at least one
323    // directory segment or a plain filename, with a recognised extension.
324    let extensions = [
325        "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh",
326        "md", "toml", "yaml", "yml", "json", "sql", "html", "css", "swift", "kt",
327    ];
328    let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
329    for token in text.split_whitespace() {
330        // Strip surrounding punctuation (backticks, quotes, parens, commas).
331        // We intentionally do NOT strip '.' here because it is part of file extensions.
332        let token = token.trim_matches(|c: char| {
333            matches!(
334                c,
335                '`' | '\'' | '"' | '(' | ')' | '[' | ']' | ',' | ':' | ';'
336            )
337        });
338        // Strip a lone trailing dot that ends a sentence (e.g. "src/foo.rs.").
339        // Only strip if the result still has a recognised extension — this avoids
340        // accidentally stripping the final dot of "src/foo.rs" when there is none.
341        let token = if let Some(without) = token.strip_suffix('.') {
342            let still_has_ext = extensions
343                .iter()
344                .any(|ext| without.ends_with(&format!(".{ext}")));
345            if still_has_ext { without } else { token }
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    // Returns true when `word` appears as a whole word in `text`
514    // (not preceded or followed by an ASCII alphanumeric character).
515    fn contains_word(text: &str, word: &str) -> bool {
516        let mut start = 0;
517        while let Some(pos) = text[start..].find(word) {
518            let abs = start + pos;
519            let before_ok = abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
520            let after_ok = abs + word.len() >= text.len()
521                || !text.as_bytes()[abs + word.len()].is_ascii_alphanumeric();
522            if before_ok && after_ok {
523                return true;
524            }
525            start = abs + 1;
526            if start >= text.len() {
527                break;
528            }
529        }
530        false
531    }
532
533    let trimmed = text.trim();
534    if trimmed.is_empty() {
535        return None;
536    }
537
538    // Detect CLI/arg-parser diagnostics and reject them. These indicate the agent
539    // process failed to start properly (e.g. invalid flag), not that the agent
540    // produced a free-form answer. Clap-based CLIs emit patterns like:
541    //   "error: unexpected argument '--flag' found"
542    //   "Usage: command [OPTIONS]"
543    //   "For more information, try '--help'"
544    //   "tip: to pass '...' as a value, use '-- ...'"
545    if is_cli_parser_diagnostic(trimmed) {
546        return None;
547    }
548
549    // Detect NDJSON event streams (opencode/claude/codex stdout) and reject
550    // them. Raw NDJSON masquerading as prose would otherwise leak into
551    // notification summaries — the "completed" substring inside tool state
552    // JSON triggers a false "done" classification and the first 500 bytes of
553    // NDJSON become the user-visible summary.
554    if looks_like_ndjson_event_stream(trimmed) {
555        return None;
556    }
557
558    let lower = trimmed.to_lowercase();
559    // Also accept plain-language confirmations of performed actions (issue/PR
560    // creation, comments, etc.) as a successful "done" signal. This prevents
561    // tasks from being retried when an agent reports success in free-form
562    // text instead of returning structured JSON.
563
564    // Negation phrases that override a "done/completed" match.
565    let looks_negative = [
566        "not done",
567        "not yet done",
568        "not complete",
569        "not yet complete",
570        "not completed",
571        "not yet completed",
572        "not committed",
573        "incomplete",
574        "undone",
575    ]
576    .iter()
577    .any(|needle| lower.contains(needle));
578
579    // Strong explicit completion phrases. These are high-confidence indicators
580    // that the agent performed an action (filed issues, created commits, etc.).
581    let explicit_done = [
582        "nothing to",
583        "nothing to do",
584        "nothing to execute",
585        "no positions",
586        "no open positions",
587        "no trades",
588        "no trade",
589        "no action needed",
590        "no changes to commit",
591        "completed",
592        // Action/issue completion phrases
593        "filed",
594        "filed issue",
595        "filed issues",
596        "created issue",
597        "created issues",
598        "opened issue",
599        "opened issues",
600        "issue created",
601        "issues created",
602        "issues filed",
603        "task created",
604        "posted comment",
605        "comment posted",
606        "changes committed",
607        "commit created",
608        "fixed.",
609        "has been fixed",
610        "the fix has been applied",
611        "the fix has already been applied",
612        // Agent completion phrases (regression: #1362/#1363)
613        "the fix is complete",
614        "fix is working",
615        "all tests pass",
616        "tests pass.",
617    ]
618    .iter()
619    .any(|needle| lower.contains(needle));
620
621    // Conservative sentence-level heuristics for single-word signals like "done".
622    // Accept "done" only when it appears in a short, self-contained sentence
623    // without hedging language ("let me", "checking", "will", etc.). This
624    // reduces false-positives from exploratory prose that mentions "done"
625    // as part of a planning or investigatory sentence.
626    let mut done_confident = false;
627    for sentence in trimmed
628        .split(['.', '!', '?', '\n'])
629        .map(str::trim)
630        .filter(|s| !s.is_empty())
631    {
632        let s_lower = sentence.to_lowercase();
633        // short sentence heuristic
634        let word_count = sentence.split_whitespace().count();
635        let hedging = [
636            "let",
637            "let me",
638            "check",
639            "checking",
640            "will",
641            "i'll",
642            "i will",
643            "trying",
644            "try",
645            "investigat",
646            "maybe",
647            "could",
648            "should",
649            "might",
650            "if ",
651            "plan",
652            "planning",
653        ];
654        let contains_hedge = hedging.iter().any(|h| s_lower.contains(h));
655
656        if contains_word(&s_lower, "done") && word_count <= 8 && !contains_hedge {
657            done_confident = true;
658            break;
659        }
660
661        // Commit/concrete action heuristics: require explicit commit language
662        // rather than opportunistic mentions. Examples: "I've committed the changes",
663        // "changes committed", or "commit created".
664        if s_lower.contains("committed") || s_lower.contains("commit created") {
665            // Accept only if sentence also suggests the changes were actually written
666            if s_lower.contains("changes")
667                || s_lower.contains("i've")
668                || s_lower.contains("i have")
669                || s_lower.contains("the commit")
670            {
671                done_confident = true;
672                break;
673            }
674        }
675
676        // Past-tense action verb at sentence start indicates a completed action
677        // (e.g. "Added detect_rate_limit patterns", "Fixed the nil-pointer bug").
678        // Accept only for short sentences without hedging language to avoid matching
679        // prose like "I added a note that this might fail later".
680        let past_tense_actions = [
681            "added ",
682            "fixed ",
683            "updated ",
684            "implemented ",
685            "created ",
686            "removed ",
687            "changed ",
688            "deleted ",
689            "applied ",
690            "resolved ",
691            "patched ",
692            "refactored ",
693            "merged ",
694            "pushed ",
695            "deployed ",
696        ];
697        if !contains_hedge
698            && word_count <= 12
699            && past_tense_actions.iter().any(|v| s_lower.starts_with(v))
700        {
701            done_confident = true;
702            break;
703        }
704    }
705
706    let looks_done = !looks_negative && (explicit_done || done_confident);
707
708    let looks_error = [
709        "error:",
710        "failed to",
711        "cannot",
712        "permission denied",
713        "unable to",
714    ]
715    .iter()
716    .any(|needle| lower.contains(needle));
717
718    let status = if looks_error {
719        "needs_review"
720    } else if looks_done {
721        "done"
722    } else if looks_negative {
723        // Agent explicitly stated the task is not complete — surface for human review.
724        "needs_review"
725    } else {
726        // Text is ambiguous: no error signal, no completion signal, no explicit negation.
727        // This happens when agents emit exploratory or startup text (e.g. "Let's go!",
728        // "I need to find where...") and exit without producing structured JSON.
729        // Returning None causes callers to treat this as an invalid response and reroute
730        // the task rather than falsely advancing it to needs_review / in_review.
731        return None;
732    };
733
734    let summary_end = truncate_at_char_boundary(trimmed, 500);
735    let summary = trimmed[..summary_end].to_string();
736    let error =
737        (status == "needs_review").then(|| "agent returned plain text instead of JSON".to_string());
738
739    // Extract structured fields from free-form text.
740    let files = extract_file_paths(trimmed);
741    let (accomplished, remaining) = extract_bullet_sections(trimmed);
742
743    Some(AgentResponse {
744        status: status.to_string(),
745        summary,
746        accomplished,
747        remaining,
748        files,
749        error,
750        input_tokens: None,
751        output_tokens: None,
752        learnings: vec![],
753        delegations: vec![],
754    })
755}
756
757/// Quote a string for safe insertion into a POSIX shell command.
758pub(crate) fn shell_single_quote(value: &str) -> String {
759    format!("'{}'", value.replace('\'', "'\\''"))
760}
761
762/// Find the largest byte index <= `max_bytes` that lies on a UTF-8 char
763/// boundary.  Used for safe string truncation in error messages.
764pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
765    if max_bytes >= s.len() {
766        return s.len();
767    }
768    // Walk backwards from max_bytes until we hit a char boundary
769    let mut end = max_bytes;
770    while end > 0 && !s.is_char_boundary(end) {
771        end -= 1;
772    }
773    end
774}
775
776/// One step of an agent's work, as `orch stream` shows it
777#[derive(Debug, Clone, PartialEq)]
778pub enum StreamEvent {
779    Started {
780        model: String,
781    },
782    Text(String),
783    Thinking(String),
784    /// `id` links the call to its result, empty when the agent has none
785    ToolCall {
786        id: String,
787        name: String,
788        input: serde_json::Value,
789    },
790    /// `name` is empty when only `id` names the call
791    ToolResult {
792        id: String,
793        name: String,
794        output: String,
795        is_error: bool,
796    },
797    Done(String),
798    Error(String),
799}
800
801/// String at a JSON pointer, empty when missing
802pub(crate) fn json_str<'a>(v: &'a serde_json::Value, pointer: &str) -> &'a str {
803    v.pointer(pointer)
804        .and_then(serde_json::Value::as_str)
805        .unwrap_or("")
806}
807
808/// Error text from any of the shapes agents use: `message`, `error`, `error.message`, `error.data.message`
809pub(crate) fn json_error_message(v: &serde_json::Value) -> String {
810    [
811        "/message",
812        "/error",
813        "/error/message",
814        "/error/data/message",
815    ]
816    .iter()
817    .find_map(|p| v.pointer(p).and_then(serde_json::Value::as_str))
818    .unwrap_or("unknown error")
819    .to_string()
820}
821
822/// Router stdout shape. Each runner reports its own
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824pub enum RouterTranscript {
825    /// Claude Messages NDJSON (`type:result`)
826    Messages,
827    /// OpenCode NDJSON (`type:text`, `step_*`)
828    OpenCode,
829    /// Stdout is used as-is
830    Raw,
831}
832
833/// Per-agent runner trait.
834///
835/// Each agent implements this to handle its specific CLI invocation,
836/// output parsing, and error classification.
837pub trait AgentRunner: Send + Sync {
838    /// Agent name (e.g., "claude", "codex", "opencode"). Used in tests.
839    #[cfg(test)]
840    fn name(&self) -> &str;
841
842    /// Build the CLI command string for the legacy tmux runner.
843    ///
844    /// The returned string is embedded into a shell command that captures
845    /// output in the tmux session. The `permissions` struct carries unified
846    /// rules that each agent translates to its own CLI flags.
847    fn build_command(
848        &self,
849        model: Option<&str>,
850        timeout_cmd: &str,
851        sys_file: &str,
852        msg_file: &str,
853        permissions: &PermissionRules,
854    ) -> String;
855
856    /// Parse raw stdout into a ParsedResponse.
857    ///
858    /// Returns `Ok(ParsedResponse)` on success, `Err(AgentError)` if the
859    /// output indicates an error or cannot be parsed.
860    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError>;
861
862    /// Extract the raw inner text from an agent output envelope, without
863    /// attempting to parse it as an `AgentResponse`.
864    ///
865    /// Use this in the review pipeline so that a `ReviewResponse` JSON can be
866    /// recovered even when the text doesn't match the task `AgentResponse` schema.
867    ///
868    /// - For Claude/Kimi/MiniMax: strips the `--output-format stream-json` NDJSON
869    ///   wrapper and returns the `"result"` field of the final `"type":"result"` line.
870    /// - For Codex: returns the text of the last `agent_message` item.
871    /// - For OpenCode: concatenates all `text` events.
872    /// - Default: returns `raw` unchanged (safe fallback for unknown agents).
873    ///
874    /// Returns `Err(AgentError)` only for terminal errors (rate limit, auth, etc.)
875    /// that should abort the review pipeline entirely.
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            && let Some(actual_elapsed) = elapsed_secs
900        {
901            *elapsed = Duration::from_secs(actual_elapsed);
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(crate) 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 alphanumeric, so `"outputTokens":429` still
1266        // matches and a UUID group like `4295` or `-429c-` does not
1267        let pos_429 = find_standalone_digits(&lower, "429");
1268        let has_429 = pos_429.is_some();
1269        // HTTP 529 is used by Cloudflare for rate limiting. Only match in HTTP status
1270        // contexts (e.g. "HTTP 529", "status: 529") to avoid false positives from bare
1271        // numbers like line numbers, port numbers, or file sizes.
1272        let has_529 = lower.contains("http 529")
1273            || lower.contains("529 service")
1274            || (lower.find(": 529").is_some_and(|pos| {
1275                !lower[pos + 5..]
1276                    .chars()
1277                    .next()
1278                    .is_some_and(|c| c.is_ascii_digit())
1279            }));
1280
1281        if match_info.is_some() || has_429 || has_529 {
1282            let message = if let Some((_, pos)) = match_info {
1283                extract_context_around(text, pos, 300)
1284            } else if let Some(pos) = pos_429 {
1285                extract_context_around(text, lower_offset_to_original_byte(text, &lower, pos), 300)
1286            } else {
1287                safe_tail(text, 300).to_string()
1288            };
1289            return Some(AgentError::RateLimit { message });
1290        }
1291        None
1292    }
1293
1294    /// Extract up to `window` bytes of context centred around `byte_pos`.
1295    ///
1296    /// `byte_pos` must be a valid byte offset in `text` (not derived from a
1297    /// transformed copy).  Both ends are snapped to char boundaries to avoid
1298    /// splitting multi-byte codepoints.
1299    fn extract_context_around(text: &str, byte_pos: usize, window: usize) -> String {
1300        let half = window / 2;
1301        let start = byte_pos.saturating_sub(half);
1302        let end = (byte_pos + window).min(text.len());
1303        // Align both ends to char boundaries.
1304        let mut s = start;
1305        while s < text.len() && !text.is_char_boundary(s) {
1306            s += 1;
1307        }
1308        let mut e = end;
1309        while e < text.len() && !text.is_char_boundary(e) {
1310            e += 1;
1311        }
1312        text[s..e].to_string()
1313    }
1314
1315    /// Byte offset of the first `digits` in `lower` with a non-alphanumeric (or string edge)
1316    /// neighbour on both sides
1317    fn find_standalone_digits(lower: &str, digits: &str) -> Option<usize> {
1318        let mut start = 0;
1319        while let Some(rel) = lower[start..].find(digits) {
1320            let pos = start + rel;
1321            let before_ok = pos == 0
1322                || !lower[..pos]
1323                    .chars()
1324                    .next_back()
1325                    .is_some_and(|c| c.is_ascii_alphanumeric());
1326            let after = pos + digits.len();
1327            let after_ok = after >= lower.len()
1328                || !lower[after..]
1329                    .chars()
1330                    .next()
1331                    .is_some_and(|c| c.is_ascii_alphanumeric());
1332            if before_ok && after_ok {
1333                return Some(pos);
1334            }
1335            start = pos + 1;
1336        }
1337        None
1338    }
1339
1340    /// Returns true if `lower` contains the HTTP status `code` (e.g. "401") as a
1341    /// standalone number — not as part of a larger digit sequence like `4010292`.
1342    ///
1343    /// Matches:
1344    ///   - `"http 401"` / `"http/1.1 401"`
1345    ///   - `"401 unauthorized"` / `"401\n"` / `"401"` at end-of-string
1346    ///   - `": 401"` when not immediately followed by another digit
1347    pub(crate) fn contains_http_status(lower: &str, code: &str) -> bool {
1348        // Fast prefix checks that are inherently unambiguous.
1349        if lower.contains(&format!("http {code}")) || lower.contains(&format!("{code} ")) {
1350            return true;
1351        }
1352        // ": NNN" — only accept when the next character is not a digit.
1353        let needle = format!(": {code}");
1354        let mut start = 0;
1355        while let Some(rel) = lower[start..].find(needle.as_str()) {
1356            let after = start + rel + needle.len();
1357            match lower[after..].chars().next() {
1358                Some(c) if c.is_ascii_digit() => {
1359                    // Part of a longer number (e.g. ": 4010292") — skip.
1360                    start = after;
1361                }
1362                _ => return true,
1363            }
1364        }
1365        false
1366    }
1367
1368    /// Check for auth / billing error patterns in text.
1369    ///
1370    /// Returns the line containing the match (not the tail) so the stored
1371    /// error message is the actual error string rather than trailing output
1372    /// (e.g. NDJSON session metadata that agents emit at exit).
1373    pub(crate) fn detect_auth_error(text: &str) -> Option<AgentError> {
1374        let lower = text.to_lowercase();
1375        let patterns = [
1376            "unauthorized",
1377            "authentication required",
1378            "invalid api",
1379            "invalid key",
1380            "invalid token",
1381            "auth fail",
1382            "no api key",
1383            "no token",
1384            "expired key",
1385            "expired token",
1386            "expired plan",
1387            // Use specific billing-related phrases instead of the bare word
1388            // "billing" which produced false positives when agents mentioned
1389            // billing in non-error contexts (e.g. reviews discussing billing).
1390            "billing plan",
1391            "billing error",
1392            "billing suspended",
1393            "billing expired",
1394            "billing limit",
1395            "billing failed",
1396            "billing cycle exhausted",
1397            "insufficient credit",
1398            "insufficient balance",
1399            "credit balance too low",
1400            "payment required",
1401        ];
1402        // Use standalone check for HTTP status codes (same rationale as
1403        // detect_rate_limit: avoid matching port numbers, line numbers, etc.).
1404        let http_401 = contains_http_status(&lower, "401");
1405        let http_403 = contains_http_status(&lower, "403");
1406        let http_407 = contains_http_status(&lower, "407");
1407        let proxy_auth_required = lower.contains("proxy authentication required");
1408        if !patterns.iter().any(|p| lower.contains(p))
1409            && !http_401
1410            && !http_403
1411            && !http_407
1412            && !proxy_auth_required
1413        {
1414            return None;
1415        }
1416        // Find the first auth error match position to extract its line.
1417        let first_match_pos =
1418            patterns
1419                .iter()
1420                .filter_map(|p| lower.find(p))
1421                .min()
1422                .map(|lower_pos| {
1423                    let char_idx = lower[..lower_pos].chars().count();
1424                    text.char_indices()
1425                        .nth(char_idx)
1426                        .map_or(text.len(), |(i, _)| i)
1427                });
1428        // Also check HTTP status codes for standalone matches (not caught by
1429        // pattern strings like "401" is too short to add as a pattern).
1430        let http_401_match_pos = if http_401 {
1431            lower.find("401 ").or_else(|| lower.find(": 401"))
1432        } else {
1433            None
1434        };
1435        let http_403_match_pos = if http_403 {
1436            lower.find("403 ").or_else(|| lower.find(": 403"))
1437        } else {
1438            None
1439        };
1440        let http_407_match_pos = if http_407 {
1441            lower.find("407 ").or_else(|| lower.find(": 407"))
1442        } else {
1443            None
1444        };
1445        let http_pos = [http_401_match_pos, http_403_match_pos, http_407_match_pos]
1446            .into_iter()
1447            .flatten()
1448            .min()
1449            .and_then(|rel| {
1450                let char_idx = lower[..rel].chars().count();
1451                text.char_indices().nth(char_idx).map(|(i, _)| i)
1452            });
1453        let match_pos = [first_match_pos, http_pos].into_iter().flatten().min();
1454        let message = if let Some(pos) = match_pos {
1455            find_line_containing(text, pos)
1456        } else {
1457            // Pattern matched but couldn't find position (e.g. 401 standalone).
1458            // Fall back to scanning last 50 lines for the error line.
1459            find_auth_line_scan(text).unwrap_or_else(|| safe_tail(text, 300).to_string())
1460        };
1461        Some(AgentError::Auth { message })
1462    }
1463
1464    /// Returns the full line containing `byte_pos` in `text`.
1465    fn find_line_containing(text: &str, byte_pos: usize) -> String {
1466        let start = text[..byte_pos].rfind('\n').map_or(0, |i| i + 1);
1467        let end = text[byte_pos..]
1468            .find('\n')
1469            .map_or(text.len(), |i| byte_pos + i);
1470        text[start..end].trim().to_string()
1471    }
1472
1473    /// Scans the last 50 lines of `text` for a line containing an auth error
1474    /// pattern (401, unauthorized, invalid api, etc.). Used as fallback when
1475    /// match position is unavailable.
1476    fn find_auth_line_scan(text: &str) -> Option<String> {
1477        for line in text.lines().rev().take(50) {
1478            let line_lower = line.to_lowercase();
1479            if contains_http_status(&line_lower, "401")
1480                || contains_http_status(&line_lower, "403")
1481                || contains_http_status(&line_lower, "407")
1482                || line_lower.contains("unauthorized")
1483                || line_lower.contains("authentication required")
1484                || line_lower.contains("invalid api")
1485                || line_lower.contains("invalid key")
1486                || line_lower.contains("invalid token")
1487                || line_lower.contains("auth fail")
1488                || line_lower.contains("no api key")
1489                || line_lower.contains("no token")
1490                || line_lower.contains("expired key")
1491                || line_lower.contains("expired token")
1492                || line_lower.contains("expired plan")
1493                || line_lower.contains("billing plan")
1494                || line_lower.contains("billing error")
1495                || line_lower.contains("billing suspended")
1496                || line_lower.contains("billing expired")
1497                || line_lower.contains("billing limit")
1498                || line_lower.contains("billing failed")
1499                || line_lower.contains("billing cycle exhausted")
1500                || line_lower.contains("insufficient credit")
1501                || line_lower.contains("credit balance too low")
1502                || line_lower.contains("payment required")
1503                || line_lower.contains("proxy authentication required")
1504            {
1505                return Some(line.trim().to_string());
1506            }
1507        }
1508        None
1509    }
1510
1511    /// Check for transient network connectivity errors.
1512    pub(crate) fn detect_network_error(text: &str) -> Option<AgentError> {
1513        let lower = text.to_lowercase();
1514        let patterns = [
1515            "connectionrefused",
1516            "connection refused",
1517            "unable to connect",
1518            "econnrefused",
1519            "network unreachable",
1520            // Socket / HTTP transport errors (Node fetch / undici / http module)
1521            "socket connection was closed",
1522            "socket hang up",
1523            "econnreset",
1524            "connection reset",
1525            "etimedout",
1526            "connect etimedout",
1527            "aborterror",
1528            "the operation was aborted",
1529            "network request failed",
1530            "fetch failed",
1531            // Upstream HTTP-layer timeouts from model provider APIs (e.g. opencode/nemotron)
1532            "upstream idle timeout",
1533            "streaming response failed",
1534            "upstream request failed",
1535            // Provider-side mid-stream disconnects (e.g. kimi/claude API errors)
1536            "stalled mid-stream",
1537            "connection closed mid-response",
1538        ];
1539        // Map the byte offset from `lower` back to `text` via char-count (same
1540        // rationale as detect_rate_limit: Unicode case-folding can change byte lengths).
1541        let match_pos = patterns
1542            .iter()
1543            .filter_map(|p| lower.find(p))
1544            .min()
1545            .map(|lower_pos| {
1546                let char_idx = lower[..lower_pos].chars().count();
1547                text.char_indices()
1548                    .nth(char_idx)
1549                    .map_or(text.len(), |(i, _)| i)
1550            });
1551        if let Some(pos) = match_pos {
1552            return Some(AgentError::NetworkError {
1553                message: extract_context_around(text, pos, 300),
1554            });
1555        }
1556        None
1557    }
1558
1559    /// Check for a plain-text "model not found" / "model unavailable" error.
1560    ///
1561    /// Mirrors the model-unavailable detection already present in codex.rs's
1562    /// and opencode.rs's structured NDJSON classifiers (`classify_message`,
1563    /// `classify_opencode_message`), for the case where the same failure
1564    /// surfaces as raw stdout/stderr text instead of a recognizable NDJSON
1565    /// event. Without this, the message falls through to `AgentError::Unknown`,
1566    /// which bumps the agent-wide failure cooldown instead of the
1567    /// model-scoped one that `ModelUnavailable` is exempted into.
1568    pub(crate) fn detect_model_unavailable(text: &str) -> Option<AgentError> {
1569        let lower = text.to_lowercase();
1570        let is_model_unavailable = lower.contains("model metadata")
1571            || lower.contains("no endpoints found")
1572            || (lower.contains("model")
1573                && (lower.contains("not found")
1574                    || lower.contains("not supported")
1575                    || lower.contains("does not exist")
1576                    || lower.contains("deprecated")
1577                    || lower.contains("unavailable for free")
1578                    || lower.contains("not available in your country")
1579                    || lower.contains("not available in your region")));
1580        if !is_model_unavailable {
1581            return None;
1582        }
1583        let model = extract_quoted(text, '`')
1584            .or_else(|| extract_quoted(text, '\''))
1585            .unwrap_or_default();
1586        Some(AgentError::ModelUnavailable {
1587            message: safe_tail(text, 300).to_string(),
1588            model,
1589        })
1590    }
1591
1592    /// Extract text between the first matching pair of `quote` characters.
1593    fn extract_quoted(text: &str, quote: char) -> Option<String> {
1594        let start = text.find(quote)?;
1595        let rest = &text[start + quote.len_utf8()..];
1596        let end = rest.find(quote)?;
1597        Some(rest[..end].to_string())
1598    }
1599
1600    /// Check for missing worktree / working directory setup failures.
1601    pub(crate) fn detect_worktree_missing(text: &str) -> Option<AgentError> {
1602        let lower = text.to_lowercase();
1603        let patterns = ["worktree directory does not exist"];
1604        if patterns.iter().any(|p| lower.contains(p)) {
1605            return Some(AgentError::AgentFailed {
1606                message: safe_tail(text, 300).to_string(),
1607            });
1608        }
1609        None
1610    }
1611
1612    /// Check for context overflow patterns.
1613    pub(crate) fn detect_context_overflow(text: &str) -> Option<AgentError> {
1614        let lower = text.to_lowercase();
1615        let patterns = [
1616            "context_length_exceeded",
1617            "context length exceeded",
1618            "maximum context length",
1619            "too many tokens",
1620            "token limit",
1621            "context overflow",
1622        ];
1623        if patterns.iter().any(|p| lower.contains(p)) {
1624            return Some(AgentError::ContextOverflow {
1625                message: safe_tail(text, 300).to_string(),
1626            });
1627        }
1628        None
1629    }
1630
1631    /// Check for missing tooling patterns. Returns the tool name.
1632    pub(crate) fn detect_missing_tool(text: &str) -> Option<AgentError> {
1633        let known_tools = [
1634            "bun",
1635            "node",
1636            "npm",
1637            "pnpm",
1638            "yarn",
1639            "deno",
1640            "tsc",
1641            "eslint",
1642            "prettier",
1643            "jest",
1644            "vitest",
1645            "cargo",
1646            "rustc",
1647            "go",
1648            "python",
1649            "python3",
1650            "pip",
1651            "pip3",
1652            "uv",
1653            "poetry",
1654            "pytest",
1655            "ruff",
1656            "black",
1657            "mypy",
1658            "make",
1659            "cmake",
1660            "ninja",
1661            "just",
1662            "bats",
1663            "docker",
1664            "docker-compose",
1665            "podman",
1666            "kubectl",
1667            "helm",
1668            "terraform",
1669            "anchor",
1670            "avm",
1671            "solana",
1672            "solana-test-validator",
1673        ];
1674
1675        let lower = text.to_lowercase();
1676        for tool in &known_tools {
1677            if lower.contains(&format!("{tool}: command not found"))
1678                || lower.contains(&format!("command not found: {tool}"))
1679                || lower.contains(&format!("{tool}: not found"))
1680                || lower.contains(&format!("env: {tool}: no such file"))
1681                || lower.contains(&format!("spawn {tool} enoent"))
1682            {
1683                return Some(AgentError::MissingTool {
1684                    tool: tool.to_string(),
1685                });
1686            }
1687        }
1688        None
1689    }
1690
1691    /// Check for permission/sandbox denied patterns.
1692    pub(crate) fn detect_permission_denied(text: &str) -> Option<AgentError> {
1693        let lower = text.to_lowercase();
1694        let patterns = [
1695            "permission denied",
1696            "rejected permission",
1697            "permissionerror",
1698            "operation not permitted",
1699            "sandbox violation",
1700            "access denied",
1701            "eacces",
1702            "eperm",
1703            "read-only file system",
1704            "not writable",
1705            "disallowed tool",
1706        ];
1707        if patterns.iter().any(|p| lower.contains(p)) {
1708            return Some(AgentError::PermissionDenied {
1709                message: safe_tail(text, 300).to_string(),
1710            });
1711        }
1712        None
1713    }
1714
1715    /// Check for interactive input prompts (1Password, SSH passphrase, etc.).
1716    pub(crate) fn detect_waiting_for_input(text: &str) -> Option<AgentError> {
1717        let lower = text.to_lowercase();
1718        let patterns = [
1719            "enter passphrase",
1720            "1password",
1721            "op signin",
1722            "ssh passphrase",
1723            "password:",
1724        ];
1725        let auth_required_with_interactive_context = lower.contains("authentication required")
1726            && (lower.contains("ssh")
1727                || lower.contains("passphrase")
1728                || lower.contains("1password")
1729                || lower.contains("op signin"));
1730        if patterns.iter().any(|p| lower.contains(p)) || auth_required_with_interactive_context {
1731            return Some(AgentError::WaitingForInput {
1732                message: safe_tail(text, 300).to_string(),
1733            });
1734        }
1735        None
1736    }
1737
1738    /// Check for the Claude 400 "thinking blocks cannot be modified" API error.
1739    ///
1740    /// Occurs when Claude CLI reconstructs a multi-turn tool-use request and the
1741    /// Anthropic API rejects it because a thinking block was altered between turns.
1742    /// Classified separately to avoid penalising the model with a failure count.
1743    pub(crate) fn detect_thinking_block_conflict(text: &str) -> Option<AgentError> {
1744        // Match the exact phrasing the Anthropic API emits (backtick-quoted names,
1745        // case-sensitive — the API always uses this exact string).
1746        if text.contains(
1747            "thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified",
1748        ) {
1749            let message = text
1750                .lines()
1751                .find(|l| l.contains("cannot be modified")).map_or_else(|| safe_tail(text, 300).to_string(), |l| l.trim().to_string());
1752            return Some(AgentError::ThinkingBlockConflict { message });
1753        }
1754        None
1755    }
1756
1757    /// Check for a stale/expired Claude session ID.
1758    ///
1759    /// Claude returns `"No conversation found with session ID: <uuid>"` when
1760    /// the session UUID stored in orch's KV has expired server-side. The caller
1761    /// should reset the stored UUID and retry with a fresh `--session-id`.
1762    pub(crate) fn detect_stale_session(text: &str) -> Option<AgentError> {
1763        // Case-insensitive match; extract the UUID if present.
1764        // We search the lowercased copy for the pattern, then find the
1765        // corresponding byte offset in the *original* text via char_indices so
1766        // we never use a byte index from `lower` (whose byte length may differ
1767        // from `text` after Unicode case-folding, e.g. ß→ss, İ→i̇).
1768        let needle = "no conversation found with session id";
1769        let lower = text.to_lowercase();
1770        if let Some(lower_pos) = lower.find(needle) {
1771            // Map the byte offset in `lower` back to a char index, then find
1772            // the same char index in `text`.  Because to_lowercase() maps each
1773            // char to one or more chars, the char count up to the match may
1774            // differ between `lower` and `text`, so we count chars in `lower`.
1775            let char_idx = lower[..lower_pos].chars().count();
1776            let pos = text
1777                .char_indices()
1778                .nth(char_idx)
1779                .map_or(text.len(), |(i, _)| i);
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(crate) 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(crate) 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(crate) 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(crate) 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 | Some(AgentError::AgentFailed { .. }) => true,
1943                    Some(AgentError::NetworkError { .. }) => !matches!(
1944                        err,
1945                        AgentError::NetworkError { .. } | AgentError::AgentFailed { .. }
1946                    ),
1947                    Some(_) => false,
1948                };
1949                if upgrade {
1950                    best = Some(err);
1951                }
1952            }
1953        }
1954
1955        best
1956    }
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961    use super::*;
1962
1963    #[test]
1964    fn get_runner_returns_correct_types() {
1965        assert_eq!(get_runner("claude").name(), "claude");
1966        assert_eq!(get_runner("kimi").name(), "kimi");
1967        assert_eq!(get_runner("minimax").name(), "minimax");
1968        assert_eq!(get_runner("codex").name(), "codex");
1969        assert_eq!(get_runner("opencode").name(), "opencode");
1970        assert_eq!(get_runner("grok").name(), "grok");
1971        assert_eq!(
1972            get_runner("claude").router_transcript(),
1973            RouterTranscript::Messages
1974        );
1975        assert_eq!(
1976            get_runner("kimi").router_transcript(),
1977            RouterTranscript::Messages
1978        );
1979        assert_eq!(
1980            get_runner("minimax").router_transcript(),
1981            RouterTranscript::Messages
1982        );
1983        assert_eq!(
1984            get_runner("grok").router_transcript(),
1985            RouterTranscript::Messages
1986        );
1987        assert_eq!(
1988            get_runner("glm").router_transcript(),
1989            RouterTranscript::Messages
1990        );
1991        assert_eq!(
1992            get_runner("olm").router_transcript(),
1993            RouterTranscript::Messages
1994        );
1995        assert_eq!(
1996            get_runner("opencode").router_transcript(),
1997            RouterTranscript::OpenCode
1998        );
1999        assert_eq!(
2000            get_runner("codex").router_transcript(),
2001            RouterTranscript::Raw
2002        );
2003        assert!(supports_session("grok"));
2004        assert!(!supports_session("codex"));
2005        // olm and glm are claude-compatible and should return correct name
2006        assert_eq!(get_runner("olm").name(), "olm");
2007        assert_eq!(get_runner("glm").name(), "glm");
2008        // Unknown falls back to claude-compatible
2009        assert_eq!(get_runner("unknown-agent").name(), "unknown-agent");
2010    }
2011
2012    /// Regression test: known claude-compatible agents (including glm) must not
2013    /// emit "unknown agent" warnings when get_runner() is called.
2014    #[test]
2015    fn get_runner_no_warning_for_claude_compatible_agents() {
2016        use std::sync::{Arc, Mutex};
2017        use tracing::Level;
2018        use tracing_subscriber::EnvFilter;
2019        use tracing_subscriber::util::SubscriberInitExt;
2020
2021        struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
2022        impl std::io::Write for CaptureWriter {
2023            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2024                self.0.lock().unwrap().extend_from_slice(buf);
2025                Ok(buf.len())
2026            }
2027            fn flush(&mut self) -> std::io::Result<()> {
2028                Ok(())
2029            }
2030        }
2031        impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CaptureWriter {
2032            type Writer = CaptureWriter;
2033            fn make_writer(&'a self) -> Self::Writer {
2034                CaptureWriter(std::sync::Arc::clone(&self.0))
2035            }
2036        }
2037
2038        let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
2039        let output_clone = std::sync::Arc::clone(&output);
2040
2041        tracing_subscriber::fmt()
2042            .with_env_filter(EnvFilter::from_default_env().add_directive(Level::WARN.into()))
2043            .with_writer(CaptureWriter(output_clone))
2044            .with_ansi(false)
2045            .finish()
2046            .init();
2047
2048        for agent in &["claude", "kimi", "minimax", "olm", "glm", "grok"] {
2049            get_runner(agent);
2050        }
2051
2052        let captured = {
2053            let guard = output.lock().unwrap();
2054            String::from_utf8_lossy(&guard).to_string()
2055        };
2056        assert!(
2057            !captured.contains("unknown agent"),
2058            "known claude-compatible agents must not emit 'unknown agent' warnings; got: {captured}"
2059        );
2060    }
2061
2062    #[test]
2063    fn agent_error_display() {
2064        let e = AgentError::RateLimit {
2065            message: "429 Too Many Requests".to_string(),
2066        };
2067        assert!(e.to_string().contains("rate limit"));
2068
2069        let e = AgentError::Timeout {
2070            elapsed: Duration::from_mins(30),
2071        };
2072        assert!(e.to_string().contains("1800"));
2073    }
2074
2075    #[test]
2076    fn pattern_detect_rate_limit() {
2077        assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
2078        assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
2079        assert!(patterns::detect_rate_limit("You've hit your usage limit").is_some());
2080        assert!(
2081            patterns::detect_rate_limit(
2082                "You've hit your session limit · resets 10:30am (America/Sao_Paulo)"
2083            )
2084            .is_some()
2085        );
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!(
2236            patterns::detect_auth_error(
2237                "API Error: Unable to connect to API (ConnectionRefused) duration_api_ms 4010292"
2238            )
2239            .is_none()
2240        );
2241        // JSON field with colon: "duration_api_ms": 4010292 — contains ": 401" as substring.
2242        assert!(patterns::detect_auth_error(r#""duration_api_ms": 4010292"#).is_none());
2243        assert!(
2244            patterns::detect_auth_error(
2245                r#"{"error":"ConnectionRefused","duration_api_ms": 4010292}"#
2246            )
2247            .is_none()
2248        );
2249        // ": 401" at end-of-string or followed by non-digit must still match.
2250        assert!(patterns::detect_auth_error("status: 401").is_some());
2251        assert!(patterns::detect_auth_error("error: 403").is_some());
2252    }
2253
2254    #[test]
2255    fn pattern_detect_auth_insufficient_balance() {
2256        // Regression test for issue #3642: "insufficient balance" (and by extension
2257        // "402 Payment Required: insufficient balance") must be detected as an auth
2258        // error so it routes through the credit exhaustion path, not the generic 5-min
2259        // backoff. This ensures the #3642 cooldown behavior is properly triggered.
2260        assert!(
2261            patterns::detect_auth_error("402 Payment Required: insufficient balance").is_some(),
2262            "insufficient balance must be detected as auth error"
2263        );
2264        assert!(
2265            patterns::detect_auth_error("insufficient balance").is_some(),
2266            "bare insufficient balance must be detected as auth error"
2267        );
2268        // The stored message should contain the matched pattern.
2269        let err = patterns::detect_auth_error("402 Payment Required: insufficient balance")
2270            .expect("should detect auth error");
2271        let AgentError::Auth { message } = err else {
2272            panic!("expected Auth, got {err:?}");
2273        };
2274        assert!(
2275            message.to_lowercase().contains("insufficient balance"),
2276            "stored message should contain the matched pattern, got: {message}"
2277        );
2278        // The message should be the line containing the auth error, not the tail.
2279        assert!(
2280            message.lines().count() <= 2,
2281            "message should be the auth line, not the full tail: {}",
2282            message.len()
2283        );
2284    }
2285
2286    #[test]
2287    fn auth_error_message_contains_match_not_tail() {
2288        // Simulate real agent output: auth error appears early, NDJSON session
2289        // metadata at the end. Stored message must be the error line, not the
2290        // trailing JSON that agents emit at exit.
2291        let mut text = "some output\nunauthorized: your api key has expired\nx".repeat(400);
2292        text.push_str(r#","outputTokens":744,"cacheReadInputTokens":344785,"uuid":"86d13b1f-..."#);
2293        let err = patterns::detect_auth_error(&text).expect("should detect auth error");
2294        let AgentError::Auth { message } = err else {
2295            panic!("expected Auth, got {err:?}");
2296        };
2297        assert!(
2298            message.to_lowercase().contains("unauthorized"),
2299            "stored message should contain the matched pattern, got: {message}"
2300        );
2301        assert!(
2302            !message.contains("outputTokens"),
2303            "stored message must not be the trailing NDJSON, got: {message}"
2304        );
2305        // The message should be the line containing the auth error, not the tail.
2306        assert!(
2307            message.lines().count() <= 2,
2308            "message should be the auth line, not the full tail: {}",
2309            message.len()
2310        );
2311    }
2312
2313    #[test]
2314    fn pattern_detect_network_error() {
2315        assert!(patterns::detect_network_error("connection refused").is_some());
2316        assert!(patterns::detect_network_error("ConnectionRefused").is_some());
2317        assert!(patterns::detect_network_error("Unable to connect to API").is_some());
2318        assert!(patterns::detect_network_error("ECONNREFUSED").is_some());
2319        assert!(patterns::detect_network_error("network unreachable").is_some());
2320        assert!(patterns::detect_network_error("Upstream idle timeout exceeded").is_some());
2321        assert!(patterns::detect_network_error("upstream idle timeout").is_some());
2322        assert!(patterns::detect_network_error("Streaming response failed").is_some());
2323        assert!(patterns::detect_network_error("Upstream request failed").is_some());
2324        assert!(patterns::detect_network_error("all systems operational").is_none());
2325    }
2326
2327    #[test]
2328    fn network_error_message_contains_match_not_tail() {
2329        // Simulate real agent output: error appears early, unrelated stats JSON at the end.
2330        let prefix = "connection refused connecting to api.anthropic.com:443 ";
2331        let mut suffix = "x".repeat(2000);
2332        suffix.push_str(r#"":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0}"#);
2333        let text = format!("{prefix}{suffix}");
2334
2335        let err = patterns::detect_network_error(&text).expect("should detect network error");
2336        let AgentError::NetworkError { message } = err else {
2337            panic!("expected NetworkError, got {err:?}");
2338        };
2339        assert!(
2340            message.to_lowercase().contains("connection refused"),
2341            "stored message should contain the matched pattern, got: {message}"
2342        );
2343        assert!(
2344            !message.contains("web_fetch_requests"),
2345            "stored message must not be the unrelated stats tail, got: {message}"
2346        );
2347    }
2348
2349    #[test]
2350    fn pattern_detect_context_overflow() {
2351        assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
2352        assert!(patterns::detect_context_overflow("too many tokens in prompt").is_some());
2353        assert!(patterns::detect_context_overflow("success").is_none());
2354    }
2355
2356    #[test]
2357    fn pattern_detect_worktree_missing() {
2358        assert!(
2359            patterns::detect_worktree_missing("worktree directory does not exist: /tmp/wt")
2360                .is_some()
2361        );
2362        assert!(patterns::detect_worktree_missing("all good").is_none());
2363    }
2364
2365    #[test]
2366    fn pattern_detect_missing_tool() {
2367        assert!(patterns::detect_missing_tool("bun: command not found").is_some());
2368        assert!(patterns::detect_missing_tool("env: anchor: no such file").is_some());
2369        assert!(patterns::detect_missing_tool("everything works").is_none());
2370    }
2371
2372    #[test]
2373    fn pattern_detect_permission_denied() {
2374        assert!(patterns::detect_permission_denied("permission denied: /etc/hosts").is_some());
2375        assert!(patterns::detect_permission_denied("sandbox violation detected").is_some());
2376        assert!(patterns::detect_permission_denied("disallowed tool: Bash(rm *)").is_some());
2377        assert!(patterns::detect_permission_denied("task completed").is_none());
2378    }
2379
2380    #[test]
2381    fn pattern_detect_waiting_for_input() {
2382        assert!(patterns::detect_waiting_for_input("Enter passphrase for key").is_some());
2383        assert!(patterns::detect_waiting_for_input("1Password CLI required").is_some());
2384        assert!(
2385            patterns::detect_waiting_for_input("SSH authentication required for deploy key")
2386                .is_some()
2387        );
2388        assert!(
2389            patterns::detect_waiting_for_input("HTTP 407 Proxy Authentication Required").is_none()
2390        );
2391        assert!(patterns::detect_waiting_for_input("done").is_none());
2392    }
2393
2394    #[test]
2395    fn pattern_detect_stale_session() {
2396        // Exact error message from Claude
2397        let err = patterns::detect_stale_session(
2398            "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c",
2399        );
2400        assert!(err.is_some(), "should detect stale session");
2401        if let Some(AgentError::StaleSession { session_id }) = err {
2402            assert_eq!(session_id, "2be572c4-57bb-4f86-bc03-b593c329177c");
2403        }
2404
2405        // Case-insensitive
2406        let err = patterns::detect_stale_session("no conversation found with session id: abc-123");
2407        assert!(err.is_some(), "should detect lowercase variant");
2408
2409        // Non-matching text
2410        assert!(patterns::detect_stale_session("all good").is_none());
2411        assert!(patterns::detect_stale_session("conversation started").is_none());
2412
2413        // Multi-byte UTF-8 characters before the match must not panic.
2414        // German ß lowercases to "ss" (1 byte → 2 bytes), so using a byte
2415        // offset from `lower` directly into `text` would be wrong.
2416        let multibyte = "Straße: No conversation found with session ID: dead-beef";
2417        let err = patterns::detect_stale_session(multibyte);
2418        assert!(
2419            err.is_some(),
2420            "should detect stale session after multi-byte chars"
2421        );
2422        if let Some(AgentError::StaleSession { session_id }) = err {
2423            assert_eq!(session_id, "dead-beef");
2424        }
2425    }
2426
2427    #[test]
2428    fn stale_session_display() {
2429        let e = AgentError::StaleSession {
2430            session_id: "abc-123".to_string(),
2431        };
2432        assert!(e.to_string().contains("stale session"));
2433        assert!(e.to_string().contains("abc-123"));
2434    }
2435
2436    #[test]
2437    fn classify_from_text_timeout() {
2438        let err = patterns::classify_from_text(124, "");
2439        assert!(matches!(err, AgentError::Timeout { .. }));
2440    }
2441
2442    #[test]
2443    fn classify_from_text_missing_tool_before_rate_limit() {
2444        // Missing tool takes priority over rate limit patterns
2445        let err = patterns::classify_from_text(1, "bun: command not found rate limit");
2446        assert!(matches!(err, AgentError::MissingTool { .. }));
2447    }
2448
2449    #[test]
2450    fn classify_from_text_worktree_missing() {
2451        let err = patterns::classify_from_text(1, "worktree directory does not exist: /tmp/wt");
2452        assert!(matches!(err, AgentError::AgentFailed { .. }));
2453    }
2454
2455    #[test]
2456    fn classify_from_text_model_not_found_backtick() {
2457        // Issue #3604: plain-text "model not found" errors (no structured
2458        // NDJSON event) must classify as ModelUnavailable, not fall through to
2459        // Unknown, so the agent-wide cooldown is not bumped for a bad model name.
2460        let text = "codex failed: model unavailable (gpt-5.5): Reconnecting... 2/5 \
2461            (unexpected status 404 Not Found: The model `gpt-5.5` does not exist \
2462            or you do not have access to it.)";
2463        let err = patterns::classify_from_text(1, text);
2464        match err {
2465            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.5"),
2466            other => panic!("expected ModelUnavailable, got: {other:?}"),
2467        }
2468    }
2469
2470    #[test]
2471    fn classify_from_text_model_metadata_not_found() {
2472        let text = "Model metadata for `gpt-5.4` not found. Defaulting to fallback \
2473            metadata; this can degrade performance and cause issues.";
2474        let err = patterns::classify_from_text(1, text);
2475        match err {
2476            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "gpt-5.4"),
2477            other => panic!("expected ModelUnavailable, got: {other:?}"),
2478        }
2479    }
2480
2481    #[test]
2482    fn classify_from_text_no_false_positive_from_work_product() {
2483        // Regression test for issue #1292: agent output containing rate-limit
2484        // keywords in code/diffs must not be misclassified as a rate limit error.
2485        let work_product = "Added `is_copilot_model(model) -> bool` helper\n\
2486             - Added copilot quota patterns to `parse_retry_at`\n\
2487             fn detect_rate_limit(text: &str) -> Option<AgentError> {\n\
2488             quota exceeded handling for billing cycle\n"
2489            .repeat(50); // ~200 lines of work product — well over 3000 chars
2490        let actual_error = "\nerror: command not found: cargo\n";
2491        let text = format!("{work_product}{actual_error}");
2492        let err = patterns::classify_from_text(1, &text);
2493        assert!(
2494            !matches!(err, AgentError::RateLimit { .. }),
2495            "work product keywords must not trigger rate limit, got: {err:?}"
2496        );
2497    }
2498
2499    #[test]
2500    fn classify_from_text_still_detects_real_rate_limit_at_tail() {
2501        // A real rate-limit error printed at the end of the output must still
2502        // be detected, even if work product precedes it.
2503        let padding = "normal agent work output ".repeat(100);
2504        let text = format!("{padding}\nError: rate limit exceeded (429 Too Many Requests)\n");
2505        let err = patterns::classify_from_text(1, &text);
2506        assert!(
2507            matches!(err, AgentError::RateLimit { .. }),
2508            "real rate limit at tail must be detected, got: {err:?}"
2509        );
2510    }
2511
2512    #[test]
2513    fn classify_from_text_ignores_markdown_rate_limit_table_at_tail() {
2514        let padding = "normal agent work output ".repeat(250);
2515        let text = format!(
2516            "{padding}\n+| agent | model | outcome | count |\n+| codex | gpt-5.4 | failed | 1 |\n+| kimi | opus | rate_limit | 1 |\n"
2517        );
2518        let err = patterns::classify_from_text(1, &text);
2519        assert!(
2520            !matches!(err, AgentError::RateLimit { .. }),
2521            "markdown table cell must not trigger rate limit, got: {err:?}"
2522        );
2523    }
2524
2525    #[test]
2526    fn classify_from_text_rate_limit_non_ascii_prefix_does_not_panic() {
2527        // Regression test: non-ASCII characters before `rate_limit` that change
2528        // byte length when lowercased (e.g. İ 2→3 bytes, fl 3→2 bytes) must not
2529        // cause is_word_boundary to index text at an invalid byte offset.
2530        let text = "Aflαβrate_limit error: too many requests";
2531        let err = patterns::classify_from_text(1, text);
2532        assert!(
2533            matches!(err, AgentError::RateLimit { .. }),
2534            "real rate_limit should still be detected with non-ASCII prefix, got: {err:?}"
2535        );
2536    }
2537
2538    #[test]
2539    fn classify_from_text_detects_session_limit_as_early_system_event() {
2540        // Regression test for issue #3272: Claude session limit errors appear as
2541        // `type:system` NDJSON events early in the stream, not as `type:result`
2542        // lines. When the agent produces significant output after the error, the
2543        // system event is pushed out of the tail window and missed by tail-only
2544        // scanning. The head scan must catch it.
2545        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)"}}"#;
2546        // Generate enough work product (>3000 bytes) to push the system event
2547        // out of the tail window.
2548        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Working on the task...\"}]}}\n"
2549            .repeat(200);
2550        let text = format!("{system_event}\n{work_product}");
2551        // Verify the output is large enough that the event is not in the tail.
2552        assert!(
2553            text.len() > 3000,
2554            "test output must exceed tail window to be meaningful"
2555        );
2556        let err = patterns::classify_from_text(1, &text);
2557        assert!(
2558            matches!(err, AgentError::RateLimit { .. }),
2559            "session limit as early system event must be detected via head scan, got: {err:?}"
2560        );
2561        // The message should contain the actual error, not unrelated tail content.
2562        let msg = err.to_string();
2563        assert!(
2564            msg.contains("session limit"),
2565            "error message should contain the rate limit reason, got: {msg}"
2566        );
2567    }
2568
2569    #[test]
2570    fn classify_from_text_no_false_positive_auth_from_work_product() {
2571        // Regression test for issue #2126: auth keywords in agent work product
2572        // must not trigger auth classification when the real failure is
2573        // unrelated and appears at the end of the output.
2574        let work_product = "if response.status == 401 { return Err(\"unauthorized\") }\n\
2575             // handle 403 forbidden errors\n\
2576             // billing checks happen elsewhere\n"
2577            .repeat(80);
2578        let actual_error = "\nerror: command not found: cargo\n";
2579        let text = format!("{work_product}{actual_error}");
2580        let err = patterns::classify_from_text(1, &text);
2581        assert!(
2582            !matches!(err, AgentError::Auth { .. }),
2583            "work product auth keywords must not trigger auth classification, got: {err:?}"
2584        );
2585        assert!(
2586            matches!(err, AgentError::MissingTool { .. }),
2587            "the real tail error should still win, got: {err:?}"
2588        );
2589    }
2590
2591    #[test]
2592    fn classify_from_text_still_detects_real_auth_at_tail() {
2593        let padding = "normal agent work output ".repeat(100);
2594        let text = format!("{padding}\nHTTP 401 Unauthorized\n");
2595        let err = patterns::classify_from_text(1, &text);
2596        assert!(
2597            matches!(err, AgentError::Auth { .. }),
2598            "real auth error at tail must be detected, got: {err:?}"
2599        );
2600    }
2601
2602    #[test]
2603    fn classify_from_text_no_false_positive_context_overflow_from_work_product() {
2604        // Regression test for issue #2205: context overflow keywords in agent
2605        // work product must not trigger ContextOverflow when the real failure is
2606        // unrelated and appears at the end of the output.
2607        let work_product = "const MAX_TOKEN_LIMIT = 10000;\n\
2608             // Ensure we don't exceed the token limit per request\n\
2609             fn check_token_limit(tokens: usize) { ... }\n\
2610             if total_tokens > MAX_TOKEN_LIMIT { ... }\n\
2611             // Note: too many tokens in the prompt causes context_length_exceeded\n"
2612            .repeat(80);
2613        let actual_error = "\nerror: command not found: cargo\n";
2614        let text = format!("{work_product}{actual_error}");
2615        let err = patterns::classify_from_text(1, &text);
2616        assert!(
2617            !matches!(err, AgentError::ContextOverflow { .. }),
2618            "work product context keywords must not trigger ContextOverflow, got: {err:?}"
2619        );
2620        assert!(
2621            matches!(err, AgentError::MissingTool { .. }),
2622            "the real tail error should still win, got: {err:?}"
2623        );
2624    }
2625
2626    #[test]
2627    fn classify_from_text_still_detects_real_context_overflow_at_tail() {
2628        let padding = "normal agent work output ".repeat(100);
2629        let text = format!("{padding}\nError: context_length_exceeded\n");
2630        let err = patterns::classify_from_text(1, &text);
2631        assert!(
2632            matches!(err, AgentError::ContextOverflow { .. }),
2633            "real context overflow at tail must be detected, got: {err:?}"
2634        );
2635    }
2636
2637    #[test]
2638    fn classify_from_text_no_false_positive_permission_denied_from_work_product() {
2639        // Regression test for issue #2205: permission-related keywords in agent
2640        // work product must not trigger PermissionDenied when the real failure
2641        // is unrelated and appears at the end of the output.
2642        let work_product = "match fs::metadata(path) {\n\
2643             Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {\n\
2644                 bail!(\"access denied: {path}\");\n\
2645             }\n\
2646             // Check if directory is writable\n\
2647             if !path.is_writable() { ... }\n\
2648             }\n"
2649        .repeat(80);
2650        let actual_error = "\nerror: command not found: cargo\n";
2651        let text = format!("{work_product}{actual_error}");
2652        let err = patterns::classify_from_text(1, &text);
2653        assert!(
2654            !matches!(err, AgentError::PermissionDenied { .. }),
2655            "work product permission keywords must not trigger PermissionDenied, got: {err:?}"
2656        );
2657        assert!(
2658            matches!(err, AgentError::MissingTool { .. }),
2659            "the real tail error should still win, got: {err:?}"
2660        );
2661    }
2662
2663    #[test]
2664    fn classify_from_text_still_detects_real_permission_denied_at_tail() {
2665        let padding = "normal agent work output ".repeat(100);
2666        let text = format!("{padding}\npermission denied: /etc/hosts\n");
2667        let err = patterns::classify_from_text(1, &text);
2668        assert!(
2669            matches!(err, AgentError::PermissionDenied { .. }),
2670            "real permission denied at tail must be detected, got: {err:?}"
2671        );
2672    }
2673
2674    #[test]
2675    fn classify_from_text_no_false_positive_waiting_for_input_from_work_product() {
2676        // Regression test for issue #2205: input-request keywords in agent work
2677        // product must not trigger WaitingForInput when the real failure is
2678        // unrelated and appears at the end of the output.
2679        let work_product = "// Handle password prompts gracefully\n\
2680             if prompt.contains(\"password:\") {\n\
2681                 log::warn!(\"Password prompt detected — skipping interactive step\");\n\
2682             }\n\
2683             // SSH agent will ask for the deploy key passphrase\n\
2684             fn handle_ssh_passphrase() { ... }\n\
2685             )\n"
2686        .repeat(80);
2687        let actual_error = "\nerror: command not found: cargo\n";
2688        let text = format!("{work_product}{actual_error}");
2689        let err = patterns::classify_from_text(1, &text);
2690        assert!(
2691            !matches!(err, AgentError::WaitingForInput { .. }),
2692            "work product input keywords must not trigger WaitingForInput, got: {err:?}"
2693        );
2694        assert!(
2695            matches!(err, AgentError::MissingTool { .. }),
2696            "the real tail error should still win, got: {err:?}"
2697        );
2698    }
2699
2700    #[test]
2701    fn classify_from_text_still_detects_real_waiting_for_input_at_tail() {
2702        let padding = "normal agent work output ".repeat(100);
2703        let text = format!("{padding}\nEnter passphrase for key:\n");
2704        let err = patterns::classify_from_text(1, &text);
2705        assert!(
2706            matches!(err, AgentError::WaitingForInput { .. }),
2707            "real waiting for input at tail must be detected, got: {err:?}"
2708        );
2709    }
2710
2711    #[test]
2712    fn classify_from_text_prefers_auth_over_permission_denied() {
2713        let err = patterns::classify_from_text(1, "HTTP 403 access denied: invalid token");
2714        assert!(
2715            matches!(err, AgentError::Auth { .. }),
2716            "auth errors must outrank permission denied, got: {err:?}"
2717        );
2718    }
2719
2720    #[test]
2721    fn classify_from_text_prefers_auth_over_waiting_for_input() {
2722        let err = patterns::classify_from_text(1, "HTTP 407 Proxy Authentication Required");
2723        assert!(
2724            matches!(err, AgentError::Auth { .. }),
2725            "HTTP auth failures must not be treated as waiting for input, got: {err:?}"
2726        );
2727    }
2728
2729    #[test]
2730    fn classify_from_text_detects_stale_session() {
2731        // Regression test for issue #1800: classify_from_text must call
2732        // detect_stale_session to catch "No conversation found with session ID"
2733        // errors. Without this, the error would be misclassified as Unknown and
2734        // the control.rs recovery code would not trigger.
2735        let text = "No conversation found with session ID: 2be572c4-57bb-4f86-bc03-b593c329177c";
2736        let err = patterns::classify_from_text(1, text);
2737        assert!(
2738            matches!(err, AgentError::StaleSession { .. }),
2739            "stale session must be detected via classify_from_text, got: {err:?}"
2740        );
2741        // Verify the Display format matches what control.rs expects
2742        assert!(
2743            err.to_string().contains("stale session"),
2744            "error display must contain 'stale session' for recovery code to trigger"
2745        );
2746    }
2747
2748    #[test]
2749    fn classify_from_text_detects_socket_closed() {
2750        // Regression test for issue #3045: "socket connection was closed" must be
2751        // classified as NetworkError, not Unknown.
2752        let text = "API Error: The socket connection was closed unexpectedly. \
2753             For more information, pass `verbose: true` in the second argument to fetch()";
2754        let err = patterns::classify_from_text(1, text);
2755        assert!(
2756            matches!(err, AgentError::NetworkError { .. }),
2757            "socket closed must be NetworkError, got: {err:?}"
2758        );
2759    }
2760
2761    #[test]
2762    fn classify_from_text_detects_socket_hang_up() {
2763        let err = patterns::classify_from_text(1, "Error: socket hang up");
2764        assert!(
2765            matches!(err, AgentError::NetworkError { .. }),
2766            "socket hang up must be NetworkError, got: {err:?}"
2767        );
2768    }
2769
2770    #[test]
2771    fn classify_from_text_detects_fetch_failed() {
2772        let err = patterns::classify_from_text(1, "TypeError: fetch failed");
2773        assert!(
2774            matches!(err, AgentError::NetworkError { .. }),
2775            "fetch failed must be NetworkError, got: {err:?}"
2776        );
2777    }
2778
2779    #[test]
2780    fn classify_from_text_detects_econnreset() {
2781        let err = patterns::classify_from_text(1, "read ECONNRESET");
2782        assert!(
2783            matches!(err, AgentError::NetworkError { .. }),
2784            "ECONNRESET must be NetworkError, got: {err:?}"
2785        );
2786    }
2787
2788    #[test]
2789    fn classify_from_text_detects_stalled_mid_stream() {
2790        // Regression test for issue #3541: kimi's "Response stalled mid-stream" must be
2791        // classified as NetworkError, not Unknown/AgentFailed.
2792        let err = patterns::classify_from_text(
2793            1,
2794            "API Error: Response stalled mid-stream. The response above may be incomplete.",
2795        );
2796        assert!(
2797            matches!(err, AgentError::NetworkError { .. }),
2798            "stalled mid-stream must be NetworkError, got: {err:?}"
2799        );
2800    }
2801
2802    #[test]
2803    fn classify_from_text_detects_connection_closed_mid_response() {
2804        // Regression test for issue #3541: claude's "Connection closed mid-response" must be
2805        // classified as NetworkError, not Unknown/AgentFailed.
2806        let err = patterns::classify_from_text(
2807            1,
2808            "API Error: Connection closed mid-response. The response above may be incomplete.",
2809        );
2810        assert!(
2811            matches!(err, AgentError::NetworkError { .. }),
2812            "connection closed mid-response must be NetworkError, got: {err:?}"
2813        );
2814    }
2815
2816    #[test]
2817    fn synthesize_response_marks_done_for_plain_text_no_op() {
2818        let response = synthesize_response_from_text(
2819            "No open positions, no trade executions, no conditions change. Full cash, holding.",
2820        )
2821        .unwrap();
2822
2823        assert_eq!(response.status, "done");
2824        assert_eq!(
2825            response.summary,
2826            "No open positions, no trade executions, no conditions change. Full cash, holding."
2827        );
2828        assert!(response.error.is_none());
2829    }
2830
2831    #[test]
2832    fn synthesize_response_marks_needs_review_for_plain_text_error() {
2833        let response =
2834            synthesize_response_from_text("Failed to update branch: permission denied").unwrap();
2835
2836        assert_eq!(response.status, "needs_review");
2837        assert_eq!(
2838            response.error.as_deref(),
2839            Some("agent returned plain text instead of JSON")
2840        );
2841    }
2842
2843    #[test]
2844    fn synthesize_response_rejects_empty_text() {
2845        assert!(synthesize_response_from_text("   \n\t  ").is_none());
2846    }
2847
2848    /// Regression: raw NDJSON event streams (opencode/claude/codex stdout)
2849    /// must be rejected by the synthesizer. Without the guard, the
2850    /// "completed" substring inside tool state JSON triggers a false "done"
2851    /// classification and 500 bytes of raw NDJSON leak into the
2852    /// notification summary.
2853    #[test]
2854    fn synthesize_response_rejects_ndjson_event_stream() {
2855        let ndjson = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
2856{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"echo completed"}}
2857{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
2858        assert!(
2859            synthesize_response_from_text(ndjson).is_none(),
2860            "raw NDJSON must not be interpreted as agent prose"
2861        );
2862
2863        let single = r#"{"type":"step_finish","reason":"stop","completed":true}"#;
2864        assert!(
2865            synthesize_response_from_text(single).is_none(),
2866            "single-line NDJSON event must also be rejected"
2867        );
2868    }
2869
2870    #[test]
2871    fn synthesize_response_accepts_json_without_event_type() {
2872        // A JSON-like blob that does NOT match a known NDJSON event type
2873        // should still be allowed through to the regular synthesis path
2874        // (so the guard does not over-reach).
2875        let text = "The task is complete. Filed issue #1234 successfully.";
2876        let response = synthesize_response_from_text(text).expect("plain prose should synthesize");
2877        assert_eq!(response.status, "done");
2878    }
2879
2880    // Regression: opencode/nemotron-3-super-free and similar models sometimes exit
2881    // with code 0 after emitting only exploratory or startup text (e.g. "Let's go!",
2882    // planning prose) without completing any work.  The synthesis must return None for
2883    // such ambiguous text so the runner treats the run as an invalid response and
2884    // reroutes the task instead of falsely advancing it to needs_review / in_review.
2885    // (Issues #2404, #2653, #2666)
2886    #[test]
2887    fn synthesize_response_returns_none_for_ambiguous_startup_text() {
2888        // Short greeting — not an error, not done, not an explicit negation.
2889        assert!(
2890            synthesize_response_from_text("Let's go!").is_none(),
2891            "startup greeting must not synthesize as needs_review"
2892        );
2893        // Exploratory planning prose — agent is thinking out loud, not reporting completion.
2894        assert!(
2895            synthesize_response_from_text(
2896                "I need to find where the stream command is implemented. \
2897                 Looking at the CLI structure, it seems like the stream functionality \
2898                 might be in the task module or another file. Let me check the CLI \
2899                 command structure. First, let me look at the task module since stream \
2900                 is related to tasks."
2901            )
2902            .is_none(),
2903            "exploratory prose must not synthesize as needs_review"
2904        );
2905    }
2906
2907    #[test]
2908    fn synthesize_response_does_not_mark_done_for_incomplete() {
2909        let response = synthesize_response_from_text("The task is incomplete").unwrap();
2910        assert_eq!(
2911            response.status, "needs_review",
2912            "\"incomplete\" should not match \"complete\""
2913        );
2914    }
2915
2916    #[test]
2917    fn synthesize_response_does_not_mark_done_for_not_done() {
2918        let response = synthesize_response_from_text("Changes are not done yet").unwrap();
2919        assert_eq!(
2920            response.status, "needs_review",
2921            "\"not done\" should not match bare \"done\""
2922        );
2923    }
2924
2925    #[test]
2926    fn synthesize_response_does_not_mark_done_for_not_complete() {
2927        let response = synthesize_response_from_text("Still in progress, not complete").unwrap();
2928        assert_eq!(
2929            response.status, "needs_review",
2930            "\"not complete\" should not match \"complete\""
2931        );
2932    }
2933
2934    #[test]
2935    fn synthesize_response_does_not_mark_done_for_not_yet_completed() {
2936        let response = synthesize_response_from_text("The task is not yet completed").unwrap();
2937        assert_eq!(
2938            response.status, "needs_review",
2939            "\"not yet completed\" should not match \"completed\""
2940        );
2941    }
2942
2943    #[test]
2944    fn synthesize_response_marks_done_for_completed() {
2945        let response =
2946            synthesize_response_from_text("Task has been completed successfully").unwrap();
2947        assert_eq!(response.status, "done");
2948    }
2949
2950    #[test]
2951    fn synthesize_response_marks_done_for_word_boundary_done() {
2952        let response = synthesize_response_from_text("All changes are done.").unwrap();
2953        assert_eq!(response.status, "done");
2954    }
2955
2956    #[test]
2957    fn synthesize_response_marks_done_for_issue_creation_text() {
2958        let response =
2959            synthesize_response_from_text("Filed 3 high-value GitHub issues: #1037, #1038, #1039")
2960                .unwrap();
2961
2962        assert_eq!(response.status, "done");
2963        assert!(response.error.is_none());
2964        assert!(
2965            response
2966                .summary
2967                .contains("Filed 3 high-value GitHub issues")
2968        );
2969    }
2970
2971    #[test]
2972    fn synthesize_response_marks_done_for_committed() {
2973        let response =
2974            synthesize_response_from_text("I've committed the changes to the repository").unwrap();
2975        assert_eq!(response.status, "done");
2976    }
2977
2978    #[test]
2979    fn synthesize_response_rejects_exploratory_prose() {
2980        // Regression test: ensure exploratory analysis that mentions "let me check"
2981        // or planning language is NOT synthesized into a "done" completion.
2982        // With the ambiguous-text fix, this text correctly returns None (treated as
2983        // an invalid response / reroute signal) rather than synthesizing needs_review.
2984        // Crucially, it must NOT be marked "done".
2985        let exploratory = "I explored the codebase and found several .expect()/.unwrap() uses.\n\nLet me check each occurrence directly and run the tests to be sure. I may commit fixes afterwards.";
2986        match synthesize_response_from_text(exploratory) {
2987            None => {
2988                // Ambiguous exploratory prose correctly returns None — not marked done.
2989            }
2990            Some(resp) => {
2991                assert_ne!(
2992                    resp.status, "done",
2993                    "Exploratory prose must not be auto-marked done"
2994                );
2995            }
2996        }
2997    }
2998
2999    #[test]
3000    fn synthesize_response_marks_done_for_commit_created() {
3001        let response = synthesize_response_from_text(
3002            "Commit created locally. The push is being blocked by permissions.",
3003        )
3004        .unwrap();
3005        assert_eq!(response.status, "done");
3006    }
3007
3008    #[test]
3009    fn synthesize_response_does_not_mark_done_for_not_committed() {
3010        let response = synthesize_response_from_text("The changes are not committed yet").unwrap();
3011        assert_eq!(
3012            response.status, "needs_review",
3013            "\"not committed\" should not match \"committed\""
3014        );
3015    }
3016
3017    // ── Regression: #1362/#1363 — agent text misclassified as needs_review ──
3018
3019    #[test]
3020    fn synthesize_response_marks_done_for_fix_is_complete() {
3021        // Exact agent output from task 30203 (issue #1363).
3022        // "The fix is complete" was misclassified as needs_review because
3023        // the pattern list had "completed" but not "complete".
3024        let response = synthesize_response_from_text(
3025            "All 73 cooldown tests pass. The fix is complete.\n\n\
3026             **Summary of changes:**\n\n\
3027             1. **`src/store/kv.rs`** — Added `kv_increment()` method that uses a single \
3028             `INSERT … ON CONFLICT … DO UPDATE … RETURNING` SQL statement.",
3029        )
3030        .unwrap();
3031        assert_eq!(
3032            response.status, "done",
3033            "\"The fix is complete\" must be classified as done, not needs_review"
3034        );
3035    }
3036
3037    #[test]
3038    fn synthesize_response_marks_done_for_tests_pass_fix_working() {
3039        // Exact agent output from task 30204 (issue #1362).
3040        // "All tests pass. The fix is clean and working." was misclassified
3041        // as needs_review because none of the looks_done patterns matched.
3042        let response = synthesize_response_from_text(
3043            "All tests pass. The fix is clean and working.\n\n\
3044             **Summary of changes** to `src/engine/runner/response.rs:37`:\n\n\
3045             - **Before:** `capped + jitter` → range `[capped, 1.6*capped]`\n\
3046             - **After:** `capped.saturating_sub(jitter_range) + jitter` → range `[0.7*capped, 1.3*capped]`",
3047        )
3048        .unwrap();
3049        assert_eq!(
3050            response.status, "done",
3051            "\"All tests pass. The fix is clean and working.\" must be classified as done"
3052        );
3053    }
3054
3055    // ── parse_ndjson edge cases ───────────────────────────────────
3056
3057    #[test]
3058    fn parse_ndjson_handles_standard_lines() {
3059        let raw = r#"{"type":"text","text":"hello"}
3060{"type":"text","text":"world"}"#;
3061        let events = parse_ndjson(raw);
3062        assert_eq!(events.len(), 2);
3063        assert_eq!(events[0].get("type").unwrap().as_str(), Some("text"));
3064        assert_eq!(events[1].get("type").unwrap().as_str(), Some("text"));
3065    }
3066
3067    #[test]
3068    fn parse_ndjson_skips_empty_lines() {
3069        let raw = r#"{"type":"text"}
3070
3071{"type":"text"}
3072"#;
3073        let events = parse_ndjson(raw);
3074        assert_eq!(events.len(), 2);
3075    }
3076
3077    #[test]
3078    fn parse_ndjson_skips_malformed_lines() {
3079        let raw = r#"{"type":"valid"}
3080not json at all
3081{"type":"also_valid"}"#;
3082        let events = parse_ndjson(raw);
3083        assert_eq!(events.len(), 2);
3084    }
3085
3086    #[test]
3087    fn parse_ndjson_fallback_extracts_embedded_json() {
3088        // Edge case: JSON with closing fence inside string field
3089        let raw =
3090            r#"text: {"text": "here is the result: ```json\n{\"status\":\"done\"}\n```"} more"#;
3091        let events = parse_ndjson(raw);
3092        // Should have extracted the embedded JSON object
3093        assert!(
3094            !events.is_empty(),
3095            "should extract JSON objects via fallback"
3096        );
3097    }
3098
3099    #[test]
3100    fn parse_ndjson_fallback_handles_fragmented_objects() {
3101        // Edge case: JSON object spread across multiple lines (fragmented NDJSON)
3102        let raw = r#"{"type": "text
3103", "content": "hello
3104world"}"#;
3105        let events = parse_ndjson(raw);
3106        // Should still extract the JSON via fallback
3107        assert!(!events.is_empty(), "should handle fragmented JSON");
3108    }
3109
3110    // ── PermissionRules defaults ────────────────────────────────
3111
3112    #[test]
3113    fn permission_rules_default_is_autonomous() {
3114        let rules = PermissionRules::default();
3115        assert!(rules.autonomous);
3116    }
3117
3118    #[test]
3119    fn permission_rules_default_sandbox_is_workspace_write() {
3120        let rules = PermissionRules::default();
3121        assert_eq!(rules.sandbox, SandboxLevel::WorkspaceWrite);
3122    }
3123
3124    #[test]
3125    fn permission_rules_default_disallows_rm() {
3126        let rules = PermissionRules::default();
3127        assert!(rules.disallowed_tools.contains(&"Bash(rm *)".to_string()));
3128        assert!(rules.disallowed_tools.contains(&"Bash(rm -*)".to_string()));
3129    }
3130
3131    #[test]
3132    fn permission_rules_default_no_allowed_edit_paths() {
3133        let rules = PermissionRules::default();
3134        assert!(
3135            rules.allowed_edit_paths.is_empty(),
3136            "{:?}",
3137            rules.allowed_edit_paths
3138        );
3139    }
3140
3141    // ── Permission translation across all agents ────────────────
3142
3143    /// Test that all agents handle the default permission rules consistently.
3144    #[test]
3145    fn all_agents_handle_default_permissions() {
3146        let perms = PermissionRules::default();
3147        let sys = "/tmp/sys.md";
3148        let msg = "/tmp/msg.md";
3149
3150        // Claude: should have bypassPermissions and rm disallowed
3151        let claude = get_runner("claude");
3152        let cmd = claude.build_command(None, "", sys, msg, &perms);
3153        assert!(
3154            cmd.contains("--permission-mode bypassPermissions"),
3155            "claude default: expected bypassPermissions, got: {cmd}"
3156        );
3157        assert!(
3158            cmd.contains("Bash(rm *)"),
3159            "claude default: expected rm disallowed, got: {cmd}"
3160        );
3161
3162        // Codex: autonomous + workspace-write → --sandbox workspace-write -c 'approval_policy="never"'
3163        let codex = get_runner("codex");
3164        let cmd = codex.build_command(None, "", sys, msg, &perms);
3165        assert!(
3166            cmd.contains("--sandbox workspace-write"),
3167            "codex default: expected --sandbox workspace-write, got: {cmd}"
3168        );
3169        assert!(
3170            cmd.contains(r#"-c 'approval_policy="never"'"#),
3171            "codex default: expected -c approval_policy=never, got: {cmd}"
3172        );
3173        assert!(
3174            !cmd.contains("--ask-for-approval"),
3175            "codex default: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3176        );
3177        assert!(
3178            !cmd.contains("--full-auto"),
3179            "codex default: --full-auto is deprecated and must not appear, got: {cmd}"
3180        );
3181
3182        // OpenCode: autonomous dispatch passes --dangerously-skip-permissions
3183        // and writes no config file (relies on opencode's built-in flag).
3184        let opencode = get_runner("opencode");
3185        let cmd = opencode.build_command(None, "", sys, msg, &perms);
3186        assert!(
3187            cmd.contains("--dangerously-skip-permissions"),
3188            "opencode default: should pass --dangerously-skip-permissions, got: {cmd}"
3189        );
3190        assert!(
3191            !cmd.contains("opencode.json"),
3192            "opencode default: should not write a config file, got: {cmd}"
3193        );
3194        assert!(
3195            !cmd.contains("XDG_CONFIG_HOME"),
3196            "opencode default: should not override XDG_CONFIG_HOME, got: {cmd}"
3197        );
3198        assert!(
3199            !cmd.contains(".orch-opencode"),
3200            "opencode default: should not reference .orch-opencode, got: {cmd}"
3201        );
3202    }
3203
3204    /// Test supervised mode translates correctly for each agent.
3205    #[test]
3206    fn all_agents_handle_supervised_mode() {
3207        let perms = PermissionRules {
3208            autonomous: false,
3209            sandbox: SandboxLevel::WorkspaceWrite,
3210            disallowed_tools: vec![],
3211            allowed_tools: vec![],
3212            allowed_edit_paths: vec![],
3213            deny_read_only: false,
3214            extra_writable_dirs: vec![],
3215        };
3216        let sys = "/tmp/sys.md";
3217        let msg = "/tmp/msg.md";
3218
3219        // Claude: supervised → acceptEdits
3220        let claude = get_runner("claude");
3221        let cmd = claude.build_command(None, "", sys, msg, &perms);
3222        assert!(
3223            cmd.contains("--permission-mode acceptEdits"),
3224            "claude supervised: expected acceptEdits, got: {cmd}"
3225        );
3226
3227        // Codex: supervised → on-request
3228        let codex = get_runner("codex");
3229        let cmd = codex.build_command(None, "", sys, msg, &perms);
3230        assert!(
3231            cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3232            "codex supervised: expected -c approval_policy=on-request, got: {cmd}"
3233        );
3234        assert!(
3235            !cmd.contains("--ask-for-approval"),
3236            "codex supervised: --ask-for-approval was removed in 0.133.0 and must not appear, got: {cmd}"
3237        );
3238    }
3239
3240    /// Test full access sandbox translates correctly for each agent.
3241    #[test]
3242    fn all_agents_handle_full_access_sandbox() {
3243        let perms = PermissionRules {
3244            autonomous: true,
3245            sandbox: SandboxLevel::FullAccess,
3246            disallowed_tools: vec![],
3247            allowed_tools: vec![],
3248            allowed_edit_paths: vec![],
3249            deny_read_only: false,
3250            extra_writable_dirs: vec![],
3251        };
3252        let sys = "/tmp/sys.md";
3253        let msg = "/tmp/msg.md";
3254
3255        // Claude: ignores sandbox level (no --sandbox flag)
3256        let claude = get_runner("claude");
3257        let cmd = claude.build_command(None, "", sys, msg, &perms);
3258        assert!(
3259            !cmd.contains("--sandbox"),
3260            "claude full_access: should have no sandbox flag, got: {cmd}"
3261        );
3262
3263        // Codex: full access → --dangerously-bypass-approvals-and-sandbox
3264        let codex = get_runner("codex");
3265        let cmd = codex.build_command(None, "", sys, msg, &perms);
3266        assert!(
3267            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
3268            "codex full_access: expected dangerously-bypass, got: {cmd}"
3269        );
3270    }
3271
3272    /// SandboxLevel::None with autonomous uses --sandbox workspace-write (same as WorkspaceWrite).
3273    #[test]
3274    fn codex_sandbox_none_uses_workspace_write() {
3275        let perms = PermissionRules {
3276            autonomous: true,
3277            sandbox: SandboxLevel::None,
3278            disallowed_tools: vec![],
3279            allowed_tools: vec![],
3280            allowed_edit_paths: vec![],
3281            deny_read_only: false,
3282            extra_writable_dirs: vec![],
3283        };
3284        let codex = get_runner("codex");
3285        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3286        assert!(
3287            cmd.contains("--sandbox workspace-write"),
3288            "codex sandbox::none + autonomous: should use --sandbox workspace-write, got: {cmd}"
3289        );
3290        assert!(
3291            cmd.contains(r#"-c 'approval_policy="never"'"#),
3292            "codex sandbox::none + autonomous: should use -c approval_policy=never, got: {cmd}"
3293        );
3294        assert!(
3295            !cmd.contains("--ask-for-approval"),
3296            "codex sandbox::none: --ask-for-approval was removed in 0.133.0, got: {cmd}"
3297        );
3298        assert!(
3299            !cmd.contains("--full-auto"),
3300            "codex sandbox::none: --full-auto is deprecated and must not appear, got: {cmd}"
3301        );
3302    }
3303
3304    /// Test that allowed_edit_paths scopes Edit/Write when used with allowed_tools.
3305    #[test]
3306    fn claude_allowed_edit_paths_scope_edit_write() {
3307        let perms = PermissionRules {
3308            autonomous: true,
3309            sandbox: SandboxLevel::WorkspaceWrite,
3310            disallowed_tools: vec![],
3311            allowed_tools: vec![
3312                "Edit".to_string(),
3313                "Write".to_string(),
3314                "Read".to_string(),
3315                "Bash".to_string(),
3316            ],
3317            allowed_edit_paths: vec![PathBuf::from("/home/user/worktree")],
3318            deny_read_only: false,
3319            extra_writable_dirs: vec![],
3320        };
3321        let claude = get_runner("claude");
3322        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3323
3324        // Edit/Write should be scoped to worktree path
3325        assert!(
3326            cmd.contains("Edit(/home/user/worktree/*)"),
3327            "missing scoped Edit, got: {cmd}"
3328        );
3329        assert!(
3330            cmd.contains("Write(/home/user/worktree/*)"),
3331            "missing scoped Write, got: {cmd}"
3332        );
3333        // Read should be unrestricted
3334        assert!(cmd.contains("Read"), "missing Read");
3335    }
3336
3337    /// Test that blocked paths don't affect Codex (it uses sandbox isolation).
3338    #[test]
3339    fn codex_ignores_allowed_edit_paths() {
3340        let perms = PermissionRules {
3341            autonomous: true,
3342            sandbox: SandboxLevel::WorkspaceWrite,
3343            disallowed_tools: vec![],
3344            allowed_tools: vec![],
3345            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3346            deny_read_only: false,
3347            extra_writable_dirs: vec![],
3348        };
3349        let codex = get_runner("codex");
3350        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3351        assert!(
3352            !cmd.contains("/home/user/project"),
3353            "codex: should not reference blocked paths, got: {cmd}"
3354        );
3355    }
3356
3357    /// Test that blocked paths don't affect OpenCode.
3358    #[test]
3359    fn opencode_ignores_allowed_edit_paths() {
3360        let perms = PermissionRules {
3361            autonomous: true,
3362            sandbox: SandboxLevel::WorkspaceWrite,
3363            disallowed_tools: vec![],
3364            allowed_tools: vec![],
3365            allowed_edit_paths: vec![PathBuf::from("/home/user/project")],
3366            deny_read_only: false,
3367            extra_writable_dirs: vec![],
3368        };
3369        let opencode = get_runner("opencode");
3370        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3371        assert!(
3372            !cmd.contains("/home/user/project"),
3373            "opencode: should not reference blocked paths, got: {cmd}"
3374        );
3375    }
3376
3377    /// Test that disallowed tools merge correctly for Claude (defaults + custom).
3378    #[test]
3379    fn claude_merges_disallowed_tools() {
3380        let perms = PermissionRules {
3381            autonomous: true,
3382            sandbox: SandboxLevel::WorkspaceWrite,
3383            disallowed_tools: vec![
3384                "Bash(rm *)".to_string(),
3385                "Bash(sudo *)".to_string(),
3386                "WebFetch".to_string(),
3387            ],
3388            allowed_tools: vec![],
3389            allowed_edit_paths: vec![],
3390            deny_read_only: false,
3391            extra_writable_dirs: vec![],
3392        };
3393        let claude = get_runner("claude");
3394        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3395        assert!(cmd.contains("Bash(rm *)"), "missing rm disallow");
3396        assert!(cmd.contains("Bash(sudo *)"), "missing sudo disallow");
3397        assert!(cmd.contains("WebFetch"), "missing WebFetch disallow");
3398    }
3399
3400    /// Test that Codex ignores disallowed tools (has no such flag).
3401    #[test]
3402    fn codex_ignores_disallowed_tools() {
3403        let perms = PermissionRules {
3404            autonomous: true,
3405            sandbox: SandboxLevel::WorkspaceWrite,
3406            disallowed_tools: vec!["Bash(rm *)".to_string(), "WebFetch".to_string()],
3407            allowed_tools: vec![],
3408            allowed_edit_paths: vec![],
3409            deny_read_only: false,
3410            extra_writable_dirs: vec![],
3411        };
3412        let codex = get_runner("codex");
3413        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3414        assert!(
3415            !cmd.contains("disallow"),
3416            "codex: should not have disallow flags, got: {cmd}"
3417        );
3418    }
3419
3420    /// Test Claude with empty disallowed tools produces no --disallowedTools flag.
3421    #[test]
3422    fn claude_no_disallowed_flag_when_empty() {
3423        let perms = PermissionRules {
3424            autonomous: true,
3425            sandbox: SandboxLevel::WorkspaceWrite,
3426            disallowed_tools: vec![],
3427            allowed_tools: vec![],
3428            allowed_edit_paths: vec![],
3429            deny_read_only: false,
3430            extra_writable_dirs: vec![],
3431        };
3432        let claude = get_runner("claude");
3433        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3434        assert!(
3435            !cmd.contains("--disallowedTools"),
3436            "claude: should have no disallowed flag when empty, got: {cmd}"
3437        );
3438    }
3439
3440    /// Test Kimi/MiniMax aliases inherit Claude permission translation.
3441    #[test]
3442    fn kimi_minimax_use_claude_permissions() {
3443        let perms = PermissionRules {
3444            autonomous: false,
3445            sandbox: SandboxLevel::WorkspaceWrite,
3446            disallowed_tools: vec!["Bash(rm *)".to_string()],
3447            allowed_tools: vec![],
3448            allowed_edit_paths: vec![],
3449            deny_read_only: false,
3450            extra_writable_dirs: vec![],
3451        };
3452        let sys = "/tmp/sys.md";
3453        let msg = "/tmp/msg.md";
3454
3455        for agent in &["kimi", "minimax"] {
3456            let runner = get_runner(agent);
3457            let cmd = runner.build_command(None, "", sys, msg, &perms);
3458            assert!(
3459                cmd.contains("--permission-mode acceptEdits"),
3460                "{agent}: expected acceptEdits"
3461            );
3462            assert!(
3463                cmd.contains("Bash(rm *)"),
3464                "{agent}: expected rm disallowed"
3465            );
3466        }
3467    }
3468
3469    /// Test kimi/minimax get --allowedTools when allowed_tools is set.
3470    #[test]
3471    fn kimi_minimax_use_allowed_tools() {
3472        let perms = PermissionRules {
3473            autonomous: true,
3474            sandbox: SandboxLevel::WorkspaceWrite,
3475            disallowed_tools: vec![],
3476            allowed_tools: vec!["Edit".to_string(), "Bash".to_string(), "git".to_string()],
3477            allowed_edit_paths: vec![],
3478            deny_read_only: false,
3479            extra_writable_dirs: vec![],
3480        };
3481
3482        for agent in &["kimi", "minimax"] {
3483            let runner = get_runner(agent);
3484            let cmd = runner.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3485            assert!(
3486                cmd.contains("--allowedTools"),
3487                "{agent}: expected --allowedTools, got: {cmd}"
3488            );
3489            assert!(
3490                cmd.contains("Bash(git *)"),
3491                "{agent}: expected Bash(git *) pattern, got: {cmd}"
3492            );
3493        }
3494    }
3495
3496    // ── Allowed tools across agents ─────────────────────────────
3497
3498    /// Test that allowed_tools generates --allowedTools for Claude.
3499    #[test]
3500    fn claude_allowed_tools_generates_allowedtools_flag() {
3501        let perms = PermissionRules {
3502            autonomous: true,
3503            sandbox: SandboxLevel::WorkspaceWrite,
3504            disallowed_tools: vec!["Bash(rm *)".to_string()],
3505            allowed_tools: vec![
3506                "Edit".to_string(),
3507                "Write".to_string(),
3508                "Read".to_string(),
3509                "Bash".to_string(),
3510                "git".to_string(),
3511            ],
3512            allowed_edit_paths: vec![],
3513            deny_read_only: false,
3514            extra_writable_dirs: vec![],
3515        };
3516
3517        let claude = get_runner("claude");
3518        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3519        assert!(
3520            cmd.contains("--allowedTools"),
3521            "expected --allowedTools, got: {cmd}"
3522        );
3523        assert!(
3524            !cmd.contains("--disallowedTools"),
3525            "should not have --disallowedTools when allowed_tools is set"
3526        );
3527    }
3528
3529    /// Test that codex/opencode ignore allowed_tools (no equivalent flag).
3530    #[test]
3531    fn codex_opencode_ignore_allowed_tools() {
3532        let perms = PermissionRules {
3533            autonomous: true,
3534            sandbox: SandboxLevel::WorkspaceWrite,
3535            disallowed_tools: vec![],
3536            allowed_tools: vec!["Edit".to_string(), "git".to_string()],
3537            allowed_edit_paths: vec![],
3538            deny_read_only: false,
3539            extra_writable_dirs: vec![],
3540        };
3541
3542        let codex = get_runner("codex");
3543        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3544        assert!(
3545            !cmd.contains("--allowedTools"),
3546            "codex should not have --allowedTools, got: {cmd}"
3547        );
3548
3549        let opencode = get_runner("opencode");
3550        let cmd = opencode.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3551        assert!(
3552            !cmd.contains("--allowedTools"),
3553            "opencode should not have --allowedTools, got: {cmd}"
3554        );
3555    }
3556
3557    // ── Integration: from_config() → agent translation ──────────
3558
3559    /// Integration test: verify from_config() loads real config and agents
3560    /// translate it correctly. Requires ~/.orch/config.yml to exist.
3561    #[test]
3562    #[ignore = "reads the operator config"]
3563    fn integration_from_config_to_agent_commands() {
3564        let perms = PermissionRules::from_config();
3565
3566        // Verify defaults are reasonable
3567        assert!(
3568            perms.autonomous,
3569            "from_config: expected autonomous=true by default"
3570        );
3571
3572        // Verify each agent can build a command from the config-loaded rules
3573        for agent_name in &["claude", "codex", "opencode", "kimi", "minimax"] {
3574            let runner = get_runner(agent_name);
3575            let cmd =
3576                runner.build_command(None, "timeout 1800", "/tmp/sys.md", "/tmp/msg.md", &perms);
3577            assert!(
3578                !cmd.is_empty(),
3579                "{agent_name}: build_command returned empty string"
3580            );
3581            assert!(
3582                cmd.contains(agent_name) || *agent_name == "kimi" || *agent_name == "minimax",
3583                "{agent_name}: command should reference agent binary, got: {cmd}"
3584            );
3585        }
3586    }
3587
3588    /// Integration test: verify from_config() handles supervised mode.
3589    /// Requires config with workflow.permissions.mode = "supervised".
3590    #[test]
3591    #[ignore = "reads the operator config"]
3592    fn integration_supervised_config_translates_correctly() {
3593        // This test requires config to have:
3594        // workflow:
3595        //   permissions:
3596        //     mode: supervised
3597        let perms = PermissionRules::from_config();
3598
3599        let claude = get_runner("claude");
3600        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3601
3602        if perms.autonomous {
3603            assert!(
3604                cmd.contains("bypassPermissions"),
3605                "autonomous config → bypassPermissions"
3606            );
3607        } else {
3608            assert!(
3609                cmd.contains("acceptEdits"),
3610                "supervised config → acceptEdits"
3611            );
3612        }
3613
3614        let codex = get_runner("codex");
3615        let cmd = codex.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3616
3617        if perms.autonomous {
3618            // autonomous codex uses --sandbox workspace-write -c 'approval_policy="never"'
3619            assert!(
3620                cmd.contains("--sandbox workspace-write"),
3621                "autonomous config → --sandbox workspace-write, got: {cmd}"
3622            );
3623            assert!(
3624                cmd.contains(r#"-c 'approval_policy="never"'"#),
3625                "autonomous config → -c approval_policy=never, got: {cmd}"
3626            );
3627        } else {
3628            assert!(
3629                cmd.contains(r#"-c 'approval_policy="on-request"'"#),
3630                "supervised config → -c approval_policy=on-request, got: {cmd}"
3631            );
3632        }
3633        assert!(
3634            !cmd.contains("--ask-for-approval"),
3635            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
3636        );
3637    }
3638
3639    // ── deny_read_only preset ───────────────────────────────────
3640
3641    /// Test that deny_read_only() sets the right disallowed tools for control sessions.
3642    #[test]
3643    fn deny_read_only_blocks_reading_tools() {
3644        let perms = PermissionRules::deny_read_only();
3645
3646        let read_tools = [
3647            "Bash(ls *)",
3648            "Bash(ls)",
3649            "Bash(find *)",
3650            "Bash(cat *)",
3651            "Bash(head *)",
3652            "Bash(tail *)",
3653            "Bash(fzf *)",
3654            "Bash(less *)",
3655            "Bash(more *)",
3656            "Read",
3657            "Glob",
3658            "Grep",
3659        ];
3660
3661        for tool in read_tools {
3662            assert!(
3663                perms.disallowed_tools.contains(&tool.to_string()),
3664                "deny_read_only should block {tool}, got: {:?}",
3665                perms.disallowed_tools
3666            );
3667        }
3668    }
3669
3670    /// Test that deny_read_only() produces correct Claude command for control session.
3671    #[test]
3672    fn deny_read_only_claude_command() {
3673        let perms = PermissionRules::deny_read_only();
3674        let claude = get_runner("claude");
3675        let cmd = claude.build_command(None, "", "/tmp/sys.md", "/tmp/msg.md", &perms);
3676
3677        // Should contain Read, Glob, Grep disallow patterns
3678        assert!(
3679            cmd.contains("Read"),
3680            "should block Read for control session"
3681        );
3682        assert!(
3683            cmd.contains("Glob"),
3684            "should block Glob for control session"
3685        );
3686        assert!(
3687            cmd.contains("Grep"),
3688            "should block Grep for control session"
3689        );
3690        // Should block ls
3691        assert!(
3692            cmd.contains("Bash(ls") || cmd.contains("Bash(ls *)"),
3693            "should block ls"
3694        );
3695        // Should still have bypassPermissions (autonomous)
3696        assert!(
3697            cmd.contains("--permission-mode bypassPermissions"),
3698            "should be autonomous (bypassPermissions)"
3699        );
3700    }
3701
3702    // --- synthesize_response_from_text structured extraction ---
3703
3704    #[test]
3705    fn synthesize_extracts_file_paths() {
3706        let text = "Fixed the issue by modifying src/engine/runner/agents/mod.rs and tests/integration.rs.";
3707        let resp = synthesize_response_from_text(text).unwrap();
3708        assert!(
3709            resp.files
3710                .contains(&"src/engine/runner/agents/mod.rs".to_string()),
3711            "expected src/engine/runner/agents/mod.rs in files, got: {:?}",
3712            resp.files
3713        );
3714        assert!(
3715            resp.files.contains(&"tests/integration.rs".to_string()),
3716            "expected tests/integration.rs in files, got: {:?}",
3717            resp.files
3718        );
3719    }
3720
3721    #[test]
3722    fn synthesize_extracts_accomplished_bullets() {
3723        let text = "All tests pass.\n- Added logging to src/foo.rs\n- Updated migration file";
3724        let resp = synthesize_response_from_text(text).unwrap();
3725        assert_eq!(resp.status, "done");
3726        assert!(
3727            resp.accomplished
3728                .iter()
3729                .any(|a| a.contains("Added logging")),
3730            "expected accomplished bullet, got: {:?}",
3731            resp.accomplished
3732        );
3733        assert!(
3734            resp.accomplished
3735                .iter()
3736                .any(|a| a.contains("Updated migration")),
3737            "expected accomplished bullet, got: {:?}",
3738            resp.accomplished
3739        );
3740    }
3741
3742    #[test]
3743    fn synthesize_extracts_remaining_section() {
3744        let text = "Completed the main fix.\n- Patched src/lib.rs\n\nRemaining:\n- Write unit tests\n- Update docs";
3745        let resp = synthesize_response_from_text(text).unwrap();
3746        assert!(
3747            resp.remaining
3748                .iter()
3749                .any(|r| r.contains("Write unit tests")),
3750            "expected remaining item, got: {:?}",
3751            resp.remaining
3752        );
3753        assert!(
3754            resp.remaining.iter().any(|r| r.contains("Update docs")),
3755            "expected remaining item, got: {:?}",
3756            resp.remaining
3757        );
3758        assert!(
3759            resp.accomplished.iter().any(|a| a.contains("Patched")),
3760            "expected accomplished item, got: {:?}",
3761            resp.accomplished
3762        );
3763    }
3764
3765    #[test]
3766    fn synthesize_fixed_phrase_is_done() {
3767        let text = "Fixed. The bug no longer reproduces.";
3768        let resp = synthesize_response_from_text(text).unwrap();
3769        assert_eq!(resp.status, "done", "expected done for 'Fixed.' text");
3770    }
3771
3772    // ── detect_rate_limit false-positive guard ────────────────────────────
3773    // Regression: NDJSON telemetry blobs can contain "429" as a token count
3774    // (e.g. "outputTokens":429). `detect_rate_limit` should still flag those
3775    // because it cannot distinguish context without the terminal_reason guard.
3776    // The guard lives in review.rs, but here we verify the raw detector
3777    // behaviour so callers know they must apply the guard themselves.
3778    #[test]
3779    fn detect_rate_limit_fires_on_bare_429_in_token_count() {
3780        // This SHOULD return Some — the generic detector has no context.
3781        // The caller (review.rs) is responsible for suppressing it when
3782        // terminal_reason:completed is present.
3783        let payload = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3784        assert!(
3785            patterns::detect_rate_limit(payload).is_some(),
3786            "detect_rate_limit must still fire on bare 429 so callers apply the guard"
3787        );
3788    }
3789
3790    #[test]
3791    fn detect_rate_limit_ignores_429_inside_longer_number() {
3792        let uuid = r#"{"type":"hook","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b"}"#;
3793        assert!(
3794            patterns::detect_rate_limit(uuid).is_none(),
3795            "429 inside a UUID digit run must not match"
3796        );
3797    }
3798
3799    #[test]
3800    fn detect_rate_limit_ignores_429_in_uuid_hex_group() {
3801        let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
3802        assert!(patterns::detect_rate_limit(hook).is_none());
3803    }
3804
3805    #[test]
3806    fn detect_rate_limit_429_message_comes_from_matched_line() {
3807        let filler = "unrelated agent prose that is long enough to fill the window. ".repeat(20);
3808        let text = format!("HTTP 429 Too Many Requests\n{filler}");
3809        match patterns::detect_rate_limit(&text) {
3810            Some(AgentError::RateLimit { message }) => assert!(message.contains("429")),
3811            other => panic!("expected RateLimit, got {other:?}"),
3812        }
3813    }
3814
3815    // Verify the guard logic that review.rs uses: when raw NDJSON has
3816    // terminal_reason:completed without is_error:true, the result of
3817    // detect_rate_limit should be suppressed.
3818    #[test]
3819    fn terminal_reason_completed_guard_suppresses_false_rate_limit() {
3820        let raw_output = r#"{"type":"result","terminal_reason":"completed","outputTokens":429,"is_error":false}"#;
3821        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3822            && !raw_output.contains("\"is_error\":true");
3823        // Apply the same guard that review.rs now uses.
3824        let detected = (!ndjson_completed)
3825            .then(|| patterns::detect_rate_limit(raw_output))
3826            .flatten();
3827        assert!(
3828            detected.is_none(),
3829            "guard must suppress rate-limit false positive from token-count 429 when terminal_reason:completed"
3830        );
3831    }
3832
3833    // Verify the guard does NOT suppress a real rate-limit error.
3834    #[test]
3835    fn terminal_reason_completed_guard_passes_real_rate_limit() {
3836        // Real rate-limit output has no terminal_reason:completed.
3837        let raw_output = "Error: 429 Too Many Requests — you have exceeded your quota";
3838        let ndjson_completed = raw_output.contains("\"terminal_reason\":\"completed\"")
3839            && !raw_output.contains("\"is_error\":true");
3840        let detected = (!ndjson_completed)
3841            .then(|| patterns::detect_rate_limit(raw_output))
3842            .flatten();
3843        assert!(
3844            detected.is_some(),
3845            "guard must NOT suppress a real rate-limit error"
3846        );
3847    }
3848
3849    #[test]
3850    fn synthesize_no_false_positive_file_paths() {
3851        // URLs and version strings should not appear in files list.
3852        // The text is ambiguous (no error, no done signal, no explicit negation) so
3853        // synthesize_response_from_text returns None — no file extraction happens.
3854        let text = "See https://example.com/foo.rs for details. Version 1.2.3 is fine.";
3855        match synthesize_response_from_text(text) {
3856            None => {} // Ambiguous text correctly returns None — no files extracted.
3857            Some(resp) => {
3858                assert!(
3859                    resp.files.is_empty(),
3860                    "expected no file paths extracted from URL text, got: {:?}",
3861                    resp.files
3862                );
3863            }
3864        }
3865    }
3866
3867    // Regression: issue #3197 — CLI arg-parser diagnostics were being synthesized as
3868    // agent responses. When a process (e.g. codex) fails with an invalid CLI flag, it
3869    // exits non-zero with a clap/help diagnostic on stderr. This should be rejected
3870    // (return None) rather than synthesized as a "needs_review" response.
3871    #[test]
3872    fn synthesize_rejects_cli_parser_diagnostics_unexpected_argument() {
3873        // Real clap error from the bug report (codex exit 141)
3874        let text = "error: unexpected argument '--ask-for-approval' found\n  \
3875                    tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3876                    Usage: codex exec [OPTIONS] [PROMPT]";
3877        assert!(
3878            synthesize_response_from_text(text).is_none(),
3879            "CLI parser diagnostic must not synthesize as agent response"
3880        );
3881    }
3882
3883    #[test]
3884    fn synthesize_rejects_cli_parser_diagnostics_usage_line() {
3885        let text = "Usage: mycommand [OPTIONS] <arg>";
3886        assert!(
3887            synthesize_response_from_text(text).is_none(),
3888            "Usage line must not synthesize as agent response"
3889        );
3890    }
3891
3892    #[test]
3893    fn synthesize_rejects_cli_parser_diagnostics_help_hint() {
3894        let text = "Error: unknown flag --foo\nFor more information, try '--help'";
3895        assert!(
3896            synthesize_response_from_text(text).is_none(),
3897            "Help hint must not synthesize as agent response"
3898        );
3899    }
3900
3901    #[test]
3902    fn synthesize_rejects_cli_parser_diagnostics_tip() {
3903        let text = "error: bad value\ntip: to pass '--flag' as a value, use '-- --flag'";
3904        assert!(
3905            synthesize_response_from_text(text).is_none(),
3906            "Clap tip must not synthesize as agent response"
3907        );
3908    }
3909
3910    #[test]
3911    fn synthesize_accepts_agent_error_messages_that_mention_error() {
3912        // Agent returning plain text that happens to contain "error:" but is an actual
3913        // agent message (e.g. summarizing what went wrong) should still synthesize.
3914        let text = "I tried to run the command but got an error: permission denied on that file.";
3915        let response =
3916            synthesize_response_from_text(text).expect("agent error message should synthesize");
3917        assert_eq!(response.status, "needs_review");
3918        assert!(response.error.is_some());
3919    }
3920
3921    // Regression: Issue #3197 — exact clap error from bug report.
3922    // When codex fails with an invalid CLI flag (e.g., --ask-for-approval with old codex versions),
3923    // it should return None, not synthesize as "needs_review". Without this fix, the run
3924    // would be marked as success and advance to in_review, wasting resources.
3925    #[test]
3926    fn synthesize_rejects_exact_codex_cli_error() {
3927        let stderr_from_codex = "error: unexpected argument '--ask-for-approval' found\n  \
3928                                 tip: to pass '--ask-for-approval' as a value, use '-- --ask-for-approval'\n\
3929                                 Usage: codex exec [OPTIONS] [PROMPT]";
3930        assert!(
3931            synthesize_response_from_text(stderr_from_codex).is_none(),
3932            "exact codex CLI error from #3197 must not synthesize as agent response"
3933        );
3934    }
3935}