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