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