Skip to main content

orch/engine/runner/
response.rs

1//! Response collection + error classification.
2//!
3//! After the agent finishes (tmux session ends), this module:
4//! 1. Reads the output file
5//! 2. Parses the response JSON
6//! 3. Classifies errors (timeout, usage limit, auth, tooling)
7//! 4. Determines next action (success, reroute, needs_review)
8
9use crate::store;
10use crate::store::TaskStore;
11use std::collections::HashSet;
12use std::collections::hash_map::DefaultHasher;
13use std::fs;
14use std::fs::File;
15use std::hash::Hash;
16use std::hash::Hasher;
17use std::io::Read;
18use std::io::Seek;
19use std::io::SeekFrom;
20use std::path::{Path, PathBuf};
21use std::result;
22use std::sync::Arc;
23use std::thread;
24use std::time::Duration;
25use std::time::SystemTime;
26
27use tokio::fs as tokio_fs;
28use tokio::task;
29use tokio::time;
30// Cooldown tracking is implemented in `crate::engine::cooldown` (shared with the router).
31use crate::cli::ndjson;
32use crate::cmd_cache;
33use crate::engine;
34pub use crate::engine::cooldown::{
35    clear_expired_cooldowns, is_agent_in_cooldown, is_model_in_cooldown,
36    record_agent_failure_with_message, record_model_failure, set_agent_cooldown,
37};
38use crate::engine::router::config;
39use crate::home;
40use crate::parser::AgentResponse;
41use crate::store::MemoryEntry;
42use crate::store::Task;
43
44const JITTER_FACTOR: f64 = 0.3;
45
46/// Maximum output file size to read (10 MB).
47/// Prevents runaway outputs from consuming excessive memory.
48const MAX_OUTPUT_SIZE_BYTES: u64 = 10 * 1024 * 1024;
49
50/// Read a file with a size limit, returning truncated content if the file is too large.
51///
52/// This prevents runaway agent outputs from causing memory issues. If the file
53/// exceeds the limit, reads from the end of the file to capture the most recent
54/// output (which is typically where the result or error message is).
55fn read_file_with_limit<P: AsRef<Path>>(path: P, max_bytes: u64) -> anyhow::Result<String> {
56    let path = path.as_ref();
57    let metadata = fs::metadata(path)?;
58
59    if !metadata.is_file() {
60        anyhow::bail!("not a file: {}", path.display());
61    }
62
63    let file_size = metadata.len();
64
65    // If file is within limits, read the whole thing
66    if file_size <= max_bytes {
67        return fs::read_to_string(path).map_err(Into::into);
68    }
69
70    // File is too large - read from the end to get the most recent output
71    let mut file = File::open(path)?;
72    let start_pos = file_size.saturating_sub(max_bytes);
73    file.seek(SeekFrom::Start(start_pos))?;
74
75    let mut buffer = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(0));
76    file.read_to_end(&mut buffer)?;
77
78    // Try to convert to UTF-8, replacing invalid sequences
79    let content = String::from_utf8_lossy(&buffer);
80
81    // Log a warning about truncation
82    tracing::warn!(
83        path = %path.display(),
84        file_size = file_size,
85        max_bytes = max_bytes,
86        "output file truncated: exceeded size limit"
87    );
88
89    Ok(content.into_owned())
90}
91
92/// Calculate exponential backoff with jitter for fallback retries.
93/// Uses retry count from the reroute chain to determine the delay.
94///
95/// Delay = min(base * 2^retry_count, max) * (1 + jitter)
96pub(crate) fn calculate_backoff_delay(retry_count: usize) -> u64 {
97    let base_delay = config::retry_base_delay_ms();
98    let max_delay = config::retry_max_delay_ms();
99
100    // Exponential: base * 2^retry_count
101    let exponential =
102        base_delay * (2_u64.saturating_pow(u32::try_from(retry_count).unwrap_or(u32::MAX)));
103    let capped = exponential.min(max_delay);
104
105    // Add jitter: ±30% using a hash-based pseudo-random value.
106    // Mix system time (nanoseconds) with thread ID so concurrent retries on
107    // different threads get different delays even when called in the same
108    // microsecond, avoiding the thundering-herd problem.
109    #[expect(
110        clippy::cast_precision_loss,
111        clippy::cast_possible_truncation,
112        clippy::cast_sign_loss,
113        reason = "a jitter range in milliseconds, far below 2^52 and never negative"
114    )]
115    let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
116    let jitter = {
117        let mut h = DefaultHasher::new();
118        SystemTime::now().hash(&mut h);
119        thread::current().id().hash(&mut h);
120        h.finish() % (jitter_range * 2 + 1)
121    };
122
123    capped.saturating_sub(jitter_range) + jitter
124}
125
126fn fallback_retry_count(task: Option<&Task>) -> usize {
127    task.map_or(0, |t| {
128        let chain_retry_count = t
129            .limit_reroute_chain
130            .split(',')
131            .filter(|entry| !entry.is_empty())
132            .count();
133        let network_retry_count = usize::try_from(t.network_retries).unwrap_or(0);
134        chain_retry_count.max(network_retry_count)
135    })
136}
137
138/// Wait with exponential backoff before retrying with a fallback agent.
139/// Returns the delay applied in milliseconds.
140pub async fn wait_for_fallback_backoff(
141    task_id: &str,
142    store: &Option<Arc<TaskStore>>,
143    repo: &str,
144) -> u64 {
145    let task = store::opt_store_get_task(store, repo, task_id).await;
146
147    // Normal failover uses reroute chain length. NetworkError retries bypass
148    // failover, so track their own streak and use whichever retry source is larger.
149    let retry_count = fallback_retry_count(task.as_ref());
150
151    let delay = calculate_backoff_delay(retry_count);
152
153    if delay > 0 {
154        tracing::debug!(
155            task_id,
156            retry_count = retry_count,
157            delay_ms = delay,
158            "fallback backoff: waiting before retry"
159        );
160        time::sleep(Duration::from_millis(delay)).await;
161    }
162
163    delay
164}
165
166/// Outcome signal for the engine to update router weights.
167///
168/// Returned by the task runner so the engine can feed rate limit and
169/// success signals back to the router's weighted round-robin implementation.
170#[derive(Debug, Clone)]
171pub enum WeightSignal {
172    /// Agent completed a task successfully.
173    Success { agent: String },
174    /// Agent hit a rate limit / usage limit.
175    RateLimited { agent: String },
176    /// Task is blocked on child tasks (delegations). Do not trigger review agent.
177    Blocked,
178    /// Task was rerouted (silence detection, timeout, parse error) — reset to new for re-routing.
179    /// Distinct from None so the engine never converts a silent-reset task into needs_review.
180    Rerouted,
181    /// No weight-relevant signal (guard skipped task, unrecognised status, etc.)
182    None,
183}
184
185/// Read the agent's output file, trying multiple locations.
186///
187/// Checks per-task attempt directory first, then legacy flat paths.
188/// Output is truncated to `MAX_OUTPUT_SIZE_BYTES` to prevent runaway memory usage.
189pub async fn read_output_file(task_id: &str, primary_path: &Path, repo: &str) -> String {
190    // Primary: explicit output file (already points to attempt dir)
191    if let Some(content) = task::spawn_blocking({
192        let p = primary_path.to_path_buf();
193        move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
194    })
195    .await
196    .ok()
197    .and_then(result::Result::ok)
198        && !content.is_empty()
199    {
200        return content;
201    }
202
203    // Fallback: check all attempt dirs for this task (newest first)
204    if let Ok(task_dir) = home::task_dir_async(repo, task_id).await {
205        let attempts_dir = task_dir.join("attempts");
206        // Use async metadata for is_dir check
207        if tokio_fs::metadata(&attempts_dir)
208            .await
209            .is_ok_and(|m| m.is_dir())
210        {
211            let mut attempt_nums: Vec<u32> = task::spawn_blocking({
212                let a = attempts_dir.clone();
213                move || {
214                    match fs::read_dir(&a) {
215                        Ok(entries) => {
216                            let mut nums = Vec::new();
217                            for entry in entries {
218                                match entry {
219                                    Ok(e) => {
220                                        if let Some(n) =
221                                            e.file_name().to_str().and_then(|n| n.parse().ok())
222                                        {
223                                            nums.push(n);
224                                        }
225                                    }
226                                    Err(e) => tracing::warn!(
227                                        path = %a.display(),
228                                        err = %e,
229                                        "error reading attempt dir entry in output fallback"
230                                    ),
231                                }
232                            }
233                            nums
234                        }
235                        Err(e) => {
236                            tracing::warn!(path = %a.display(), err = %e, "failed to read attempts dir in output fallback");
237                            Vec::new()
238                        }
239                    }
240                }
241            })
242            .await
243            .unwrap_or_default();
244
245            attempt_nums.sort_unstable_by(|a, b| b.cmp(a)); // newest first
246            for n in attempt_nums {
247                let p = attempts_dir.join(n.to_string()).join("output.json");
248                if let Some(content) = task::spawn_blocking({
249                    let p = p.clone();
250                    move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
251                })
252                .await
253                .ok()
254                .and_then(result::Result::ok)
255                    && !content.is_empty()
256                {
257                    tracing::info!(task_id, path = %p.display(), "read output from attempt dir");
258                    return content;
259                }
260            }
261        }
262    }
263
264    // Legacy fallback locations
265    let state_dir = home::state_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
266
267    let mut fallbacks = vec![
268        PathBuf::from(format!("/tmp/output-{task_id}.json")),
269        state_dir.join(format!("output-{task_id}.json")),
270    ];
271
272    if let Ok(legacy_path) = home::state_file(&format!("output-{task_id}.json"))
273        && !fallbacks.contains(&legacy_path)
274    {
275        fallbacks.push(legacy_path);
276    }
277
278    for path in &fallbacks {
279        if let Some(content) = task::spawn_blocking({
280            let p = path.clone();
281            move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
282        })
283        .await
284        .ok()
285        .and_then(result::Result::ok)
286            && !content.is_empty()
287        {
288            tracing::info!(task_id, path = %path.display(), "read output from legacy fallback");
289            return content;
290        }
291    }
292
293    String::new()
294}
295
296/// Pick a fallback agent, avoiding agents already in the reroute chain and agents in cooldown.
297pub fn pick_fallback_agent(
298    current_agent: &str,
299    chain: &str,
300    available_agents: &[String],
301) -> Option<String> {
302    // Clear expired cooldowns first
303    clear_expired_cooldowns();
304
305    let chain_set: HashSet<&str> = if chain.is_empty() {
306        HashSet::new()
307    } else {
308        chain.split(',').collect()
309    };
310
311    // Prefer agents that are not in cooldown and are not heavily degraded
312    // from recent rate-limits. This prevents immediate failover to agents
313    // that have been heavily penalized and likely to fail as well.
314    for agent in available_agents {
315        if agent == current_agent || chain_set.contains(agent.as_str()) {
316            continue;
317        }
318        if is_agent_in_cooldown(agent) {
319            continue;
320        }
321        // Weight-based filtering was removed from this module because it used
322        // a default-constructed AgentWeights (dead code that always returned
323        // true). Keep failover behavior simple here: prefer the first agent
324        // that is not in cooldown and not in the reroute chain. If we later
325        // expose the router's AgentWeights snapshot to this module we can add
326        // weight-based avoidance back in a non-dead way.
327        return Some(agent.clone());
328    }
329
330    None
331}
332
333/// Retryable error types that should trigger agent failover.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum RetryableError {
336    /// Agent timed out.
337    Timeout,
338    /// Run exited without a result envelope
339    Interrupted,
340    /// Usage/rate limit hit.
341    UsageLimit,
342    /// Auth/billing error.
343    AuthError,
344    /// General failure (non-retryable but we still try fallback agents).
345    Failed,
346    /// Missing tooling - fallback might help if another agent has the tool.
347    MissingTooling,
348    /// A specific model was unavailable (e.g. "Model not found"). The agent
349    /// itself is fine — only the model is in cooldown. Failover may still pick
350    /// a different agent, but we must not bump the agent-level failure counter.
351    ModelUnavailable,
352}
353
354impl RetryableError {
355    /// Return a short classified string for this error type.
356    /// Used in DB rate-limit metrics and test assertions.
357    pub fn type_str(self) -> &'static str {
358        match self {
359            RetryableError::Timeout => "timeout",
360            RetryableError::Interrupted => "interrupted",
361            RetryableError::UsageLimit => "rate_limit",
362            RetryableError::AuthError => "auth_error",
363            RetryableError::ModelUnavailable => "model_unavailable",
364            RetryableError::Failed | RetryableError::MissingTooling => "failed",
365        }
366    }
367}
368
369/**
370 * Handle failover for any retryable error type. Returns "new" on reroute (or
371 * retryable exhaustion), "blocked" on hard exhaustion, "needs_review" when no
372 * fallback agents are available. `credit_recorded` means the caller already ran
373 * `record_credit_exhaustion`; the generic `failure_count:` counter must not be
374 * bumped here or the next ordinary failure starts at the extended backoff tier
375 * (issue #3652). Rate-limit event DB recording is done by the caller (mod.rs)
376 **/
377pub async fn handle_failover(
378    task_id: &str,
379    agent_name: &str,
380    error_type: RetryableError,
381    error_message: &str,
382    credit_recorded: bool,
383    store: &Option<Arc<TaskStore>>,
384    repo: &str,
385) -> String {
386    // Get the reroute chain
387    let chain = get_reroute_chain(task_id, store, repo).await;
388    let chain = update_reroute_chain(task_id, agent_name, &chain, store, repo).await;
389
390    // Get all available agents
391    let available: Vec<String> = engine::configured_agents()
392        .into_iter()
393        .filter(|a| cmd_cache::command_exists(a))
394        .collect();
395
396    // Check if we've exhausted all agents (build chain_set once)
397    let chain_set: HashSet<&str> = if chain.is_empty() {
398        HashSet::new()
399    } else {
400        chain.split(',').collect()
401    };
402    let is_exhausted = available.iter().all(|a| chain_set.contains(a.as_str()));
403
404    if is_exhausted {
405        // Retryable failures (rate limits, quota, model unavailable) will resolve once
406        // cooldowns expire — reset to "new" so the router picks it up again then.
407        // Hard failures (generic agent crash, missing tooling) won't improve on retry;
408        // block immediately with a clear reason rather than wasting 63 minutes in the
409        // review-refire loop before getting a misleading "review agent rebroadcast" message.
410        let is_retryable = matches!(
411            error_type,
412            RetryableError::UsageLimit
413                | RetryableError::AuthError
414                | RetryableError::ModelUnavailable
415                | RetryableError::Timeout
416                | RetryableError::Interrupted
417        );
418        let next_status = if is_retryable { "new" } else { "blocked" };
419
420        tracing::warn!(
421            task_id,
422            agent = agent_name,
423            error_type = ?error_type,
424            next_status,
425            "all agents exhausted"
426        );
427
428        let mut store_fields: Vec<(&str, serde_json::Value)> = Vec::new();
429
430        if is_retryable {
431            // Clear reroute chain so the next dispatch builds a fresh one rather than
432            // immediately seeing "all agents in chain" and exhausting again.
433            let msg = format!("{error_message} (all agents exhausted, will retry when available)");
434            store_fields.push(("last_error", serde_json::json!(msg)));
435            store_fields.push(("limit_reroute_chain", serde_json::json!("")));
436        } else {
437            let msg = format!("{error_message} (all agents exhausted)");
438            let block_reason = format!(
439                "all agents exhausted: {} — {}",
440                error_type.type_str(),
441                error_message
442            );
443            store_fields.push(("last_error", serde_json::json!(msg)));
444            store_fields.push(("block_reason", serde_json::json!(block_reason)));
445        }
446
447        if let Err(e) = store::store_set_result(store, repo, task_id, &store_fields).await {
448            tracing::warn!(task_id, err = %e, "failed to write agents_exhausted fields to store");
449        }
450
451        // Log reroute activity for exhausted agents
452        let details = serde_json::json!({
453            "failure_reason": error_type.type_str(),
454            "error_message": error_message,
455            "chain": chain,
456            "agents_exhausted": true,
457            "is_retryable": is_retryable,
458        });
459        store::store_log_activity(
460            store,
461            repo,
462            task_id,
463            "rerouted",
464            Some("in_progress"),
465            Some(next_status),
466            None::<&str>,
467            None::<&str>,
468            Some(&details),
469        )
470        .await;
471
472        return next_status.to_string();
473    }
474
475    // Pick a fallback agent
476    if let Some(next) = pick_fallback_agent(agent_name, &chain, &available) {
477        tracing::info!(
478            task_id,
479            from = agent_name,
480            to = next,
481            error_type = ?error_type,
482            "failover: switching to fallback agent"
483        );
484
485        // Timeouts and interrupts are not agent faults, skip the agent cooldown
486        if !matches!(
487            error_type,
488            RetryableError::MissingTooling
489                | RetryableError::ModelUnavailable
490                | RetryableError::Timeout
491                | RetryableError::Interrupted
492        ) {
493            // Credit errors skip the generic counter (already counted via
494            // credit_failure_count); the 120s cooldown below still applies
495            if !credit_recorded {
496                record_agent_failure_with_message(agent_name, error_message).await;
497            }
498
499            // Apply brief 120s cooldown on the failed agent so the router skips it
500            // on the next routing attempt. This prevents the router from immediately
501            // re-selecting the same agent after clearing both agent and model (#1492).
502            // Also skipped for ModelUnavailable: the agent is healthy.
503            set_agent_cooldown(agent_name, 120).await;
504        }
505
506        let msg = format!("{error_message}, clearing agent/model for re-route");
507        if let Err(e) = store::store_set_result(
508            store,
509            repo,
510            task_id,
511            &[
512                ("agent", serde_json::json!("")),
513                ("model", serde_json::json!("")),
514                ("last_error", serde_json::json!(msg)),
515            ],
516        )
517        .await
518        {
519            tracing::warn!(task_id, err = %e, "failed to write agent/model clear to store");
520        }
521
522        // Apply exponential backoff with jitter before retry.
523        // Return "new" (not "routed") so the task goes through Phase 3a routing
524        // again. This ensures the router actually re-selects a compatible agent+model
525        // via model_for_complexity() instead of bypassing routing and defaulting to
526        // claude in the dispatch loop (#1604).
527        wait_for_fallback_backoff(task_id, store, repo).await;
528
529        // Log reroute activity for successful failover
530        let details = serde_json::json!({
531            "failure_reason": error_type.type_str(),
532            "error_message": error_message,
533            "from_agent": agent_name,
534            "to_agent": next,
535            "chain": chain,
536        });
537        store::store_log_activity(
538            store,
539            repo,
540            task_id,
541            "rerouted",
542            Some("in_progress"),
543            Some("new"),
544            Some(&next),
545            None::<&str>,
546            Some(&details),
547        )
548        .await;
549
550        return "new".to_string();
551    }
552
553    // No fallback available
554    tracing::warn!(task_id, agent = agent_name, "no fallback agents available");
555
556    // Timeouts and interrupts are not agent faults, skip the agent cooldown
557    if !matches!(
558        error_type,
559        RetryableError::MissingTooling
560            | RetryableError::ModelUnavailable
561            | RetryableError::Timeout
562            | RetryableError::Interrupted
563    ) && !credit_recorded
564    {
565        record_agent_failure_with_message(agent_name, error_message).await;
566    }
567
568    let msg = format!("{error_message}, no fallback agents");
569    if let Err(e) = store::store_set_result(
570        store,
571        repo,
572        task_id,
573        &[("last_error", serde_json::json!(msg))],
574    )
575    .await
576    {
577        tracing::warn!(task_id, err = %e, "failed to write no_fallback last_error to store");
578    }
579
580    // Log reroute activity for no fallback available
581    let details = serde_json::json!({
582        "failure_reason": error_type.type_str(),
583        "error_message": error_message,
584        "chain": chain,
585        "no_fallback_available": true,
586    });
587    store::store_log_activity(
588        store,
589        repo,
590        task_id,
591        "rerouted",
592        Some("in_progress"),
593        Some("needs_review"),
594        None::<&str>,
595        None::<&str>,
596        Some(&details),
597    )
598    .await;
599
600    "needs_review".to_string()
601}
602
603/// Get the reroute chain from store.
604pub async fn get_reroute_chain(
605    task_id: &str,
606    store: &Option<Arc<TaskStore>>,
607    repo: &str,
608) -> String {
609    store::opt_store_get_task(store, repo, task_id)
610        .await
611        .map(|t| t.limit_reroute_chain)
612        .unwrap_or_default()
613        .trim()
614        .to_string()
615}
616
617/// Update the reroute chain in store.
618pub async fn update_reroute_chain(
619    task_id: &str,
620    current_agent: &str,
621    existing_chain: &str,
622    store: &Option<Arc<TaskStore>>,
623    repo: &str,
624) -> String {
625    let mut chain = existing_chain.to_string();
626    if chain.is_empty() {
627        chain = current_agent.to_string();
628    } else if !chain.split(',').any(|a| a == current_agent) {
629        chain = format!("{chain},{current_agent}");
630    }
631
632    if let Err(e) = store::store_set_result(
633        store,
634        repo,
635        task_id,
636        &[("limit_reroute_chain", serde_json::json!(chain))],
637    )
638    .await
639    {
640        tracing::warn!(task_id, err = %e, "failed to write limit_reroute_chain to store");
641    }
642    chain
643}
644
645/// Review response from the review agent.
646#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
647pub struct ReviewResponse {
648    /// Decision: "approve" | "request_changes"
649    pub decision: String,
650    /// Detailed review feedback.
651    pub notes: String,
652    /// Test results: "pass" | "fail" | "skipped"
653    pub test_results: Option<String>,
654    /// List of issues found.
655    pub issues: Vec<ReviewIssue>,
656}
657
658/// A single issue found during review.
659#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
660pub struct ReviewIssue {
661    /// File path.
662    pub file: String,
663    /// Line number (optional).
664    pub line: Option<u32>,
665    /// Severity: "error" | "warning"
666    pub severity: String,
667    /// Description of the issue.
668    pub description: String,
669}
670
671/// Parse a review response from already-unwrapped text.
672///
673/// Expects the raw result text (already extracted from the agent envelope
674/// by the agent-specific parser). Tries direct JSON parse, then markdown
675/// code block extraction.
676pub fn parse_review_response(text: &str) -> anyhow::Result<ReviewResponse> {
677    let resp = parse_review_json(text)?;
678    if matches!(resp.decision.as_str(), "approve" | "request_changes") {
679        return Ok(resp);
680    }
681
682    // Weak models echo the prompt template ("approve|request_changes"), strip it so it
683    // does not match the "request_changes" keyword
684    let stripped = text.replace("approve|request_changes", "");
685    infer_review_response_from_text(&stripped)
686        .ok_or_else(|| anyhow::anyhow!("invalid review decision: {}", resp.decision))
687}
688
689fn parse_review_json(text: &str) -> anyhow::Result<ReviewResponse> {
690    // Try direct JSON parse
691    if let Ok(resp) = serde_json::from_str::<ReviewResponse>(text) {
692        return Ok(resp);
693    }
694
695    // Try extracting JSON from markdown code blocks
696    if let Some(json_str) = extract_json_block(text)
697        && let Ok(resp) = serde_json::from_str::<ReviewResponse>(&json_str)
698    {
699        return Ok(resp);
700    }
701
702    if let Some(resp) = extract_review_response_object(text) {
703        return Ok(resp);
704    }
705
706    anyhow::bail!("failed to parse review response")
707}
708
709fn infer_review_response_from_text(text: &str) -> Option<ReviewResponse> {
710    let lower = text.to_ascii_lowercase();
711    let has_changes_requested =
712        lower.contains("changes requested") || lower.contains("request_changes");
713    if has_changes_requested {
714        return Some(ReviewResponse {
715            decision: "request_changes".to_string(),
716            notes: "Inferred decision from plain-text review output.".to_string(),
717            test_results: None,
718            issues: Vec::new(),
719        });
720    }
721
722    // Approval only from an explicit decision token. "approved" and CI-evidence
723    // phrases ("all checks passed") are not a decision, a negation list cannot cover them
724    let positive_approval = lower
725        .split(|c: char| !c.is_ascii_alphabetic())
726        .any(|word| word == "lgtm")
727        || lower.contains("decision is `approve`")
728        || lower.contains("decision: `approve`")
729        || lower.contains("decision is approve")
730        || lower.contains("decision: approve");
731    if positive_approval {
732        return Some(ReviewResponse {
733            decision: "approve".to_string(),
734            notes: "Inferred decision from plain-text review output.".to_string(),
735            test_results: None,
736            issues: Vec::new(),
737        });
738    }
739
740    None
741}
742
743/// Public entry point for keyword-based review response inference.
744///
745/// Used by `review.rs` when `parse_review_response` fails on the extracted text.
746pub fn infer_review_response(text: &str) -> Option<ReviewResponse> {
747    infer_review_response_from_text(text)
748}
749
750/// Extract the first valid JSON object from markdown code blocks.
751///
752/// Searches for ```json blocks and returns the first one whose content
753/// starts with `{`, skipping non-JSON code blocks.
754fn extract_json_block(text: &str) -> Option<String> {
755    let mut search_from = 0;
756    while let Some(start) = text[search_from..].find("```json") {
757        let abs_start = search_from + start;
758        let after_tag = abs_start + "```json".len();
759        let Some(rest) = text[after_tag..].strip_prefix('\n') else {
760            search_from = after_tag;
761            continue;
762        };
763        let content_start = text.len() - rest.len();
764        let Some(end_offset) = rest.find("```") else {
765            search_from = content_start;
766            continue;
767        };
768        let end = content_start + end_offset;
769        let content = text[content_start..end].trim();
770        if content.starts_with('{') {
771            return Some(content.to_string());
772        }
773        search_from = end + 3;
774    }
775    None
776}
777
778/// Extract a ReviewResponse JSON object from arbitrary text.
779///
780/// Scans for balanced JSON objects and tries to parse them as ReviewResponse.
781fn extract_review_response_object(text: &str) -> Option<ReviewResponse> {
782    let mut start: Option<usize> = None;
783    let mut depth: i32 = 0;
784    let mut in_string = false;
785    let mut escape = false;
786
787    for (idx, ch) in text.char_indices() {
788        if start.is_some() {
789            if in_string {
790                if escape {
791                    escape = false;
792                } else if ch == '\\' {
793                    escape = true;
794                } else if ch == '"' {
795                    in_string = false;
796                }
797                continue;
798            }
799
800            match ch {
801                '"' => in_string = true,
802                '{' => depth += 1,
803                '}' => {
804                    depth -= 1;
805                    if depth == 0 {
806                        debug_assert!(
807                            start.is_some(),
808                            "start must be Some when depth returns to 0"
809                        );
810                        let start_idx = start.unwrap_or(0);
811                        let candidate = &text[start_idx..=idx];
812                        if let Ok(resp) = serde_json::from_str::<ReviewResponse>(candidate) {
813                            return Some(resp);
814                        }
815                        start = None;
816                    }
817                }
818                _ => {}
819            }
820            continue;
821        }
822
823        if ch == '{' {
824            start = Some(idx);
825            depth = 1;
826            in_string = false;
827            escape = false;
828        }
829    }
830
831    None
832}
833
834/// Extract learnings and store as memory for future attempts.
835#[expect(
836    clippy::too_many_arguments,
837    reason = "a memory entry records the task, attempt, agent, model and outcome"
838)]
839pub async fn store_learnings_from_response(
840    task_id: &str,
841    attempt: u32,
842    agent: &str,
843    model: Option<&str>,
844    response: &AgentResponse,
845    error: Option<&str>,
846    store: &Option<Arc<TaskStore>>,
847    repo: &str,
848    raw_stdout: &str,
849) {
850    // Use agent self-reported files; fall back to NDJSON tool-call extraction when empty.
851    let files_modified = if response.files.is_empty() {
852        let extracted = ndjson::extract_files_changed(raw_stdout);
853        if !extracted.is_empty() {
854            tracing::debug!(
855                task_id,
856                count = extracted.len(),
857                "files_modified: fell back to NDJSON extraction (agent reported none)"
858            );
859        }
860        extracted
861    } else {
862        response.files.clone()
863    };
864
865    // Build the memory entry
866    let entry = MemoryEntry {
867        attempt,
868        agent: agent.to_string(),
869        model: model.map(String::from),
870        learnings: response.learnings.clone(),
871        error: error.map(String::from),
872        files_modified,
873        approach: response.summary.clone(),
874        timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
875    };
876
877    if let Some(st) = store
878        && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
879    {
880        match st.append_memory(store_id, &entry).await {
881            Err(e) => {
882                tracing::warn!(task_id, error = ?e, "failed to store memory");
883            }
884            _ => {
885                tracing::debug!(task_id, attempt, "stored memory for attempt");
886            }
887        }
888    }
889}
890
891/// Store a memory entry for a failed attempt (without a full AgentResponse).
892pub async fn store_failure_memory(
893    task_id: &str,
894    attempt: u32,
895    agent: &str,
896    model: Option<&str>,
897    error: &str,
898    store: &Option<Arc<TaskStore>>,
899    repo: &str,
900) {
901    let entry = MemoryEntry {
902        attempt,
903        agent: agent.to_string(),
904        model: model.map(String::from),
905        learnings: vec![],
906        error: Some(error.to_string()),
907        files_modified: vec![],
908        approach: String::new(),
909        timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
910    };
911
912    if let Some(st) = store
913        && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
914    {
915        match st.append_memory(store_id, &entry).await {
916            Err(e) => {
917                tracing::warn!(task_id, error = ?e, "failed to store failure memory");
918            }
919            _ => {
920                tracing::debug!(task_id, attempt, "stored failure memory");
921            }
922        }
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use std::env;
929    use std::process;
930
931    use super::*;
932    use crate::engine::cooldown;
933    use crate::engine::runner::agents::{AgentError, find_agent_result, patterns};
934    use crate::store::TaskStatus;
935    use serial_test::serial;
936
937    #[serial(cooldown_state)]
938    #[test]
939    fn calculate_backoff_delay_jitter_is_centered() {
940        // Run many samples and verify the range is [0.7*capped, 1.3*capped].
941        // We can't control `now` directly, but calling the function many times
942        // exercises different microsecond values and lets us check bounds.
943        let base = config::retry_base_delay_ms();
944        let max = config::retry_max_delay_ms();
945
946        // retry_count=0 → exponential = base, capped = base (assuming base < max)
947        let capped = base.min(max);
948        #[expect(
949            clippy::cast_precision_loss,
950            clippy::cast_possible_truncation,
951            clippy::cast_sign_loss,
952            reason = "mirrors the jitter range in calculate_backoff_delay"
953        )]
954        let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
955        let lo = capped.saturating_sub(jitter_range);
956        let hi = capped + jitter_range;
957
958        for _ in 0..200 {
959            let delay = calculate_backoff_delay(0);
960            assert!(
961                delay >= lo && delay <= hi,
962                "delay {delay} out of expected range [{lo}, {hi}]"
963            );
964        }
965    }
966
967    #[serial(cooldown_state)]
968    #[test]
969    fn fallback_retry_count_uses_larger_network_retry_streak() {
970        let task = Task {
971            id: 1,
972            external_id: None,
973            repo: "owner/repo".to_string(),
974            origin: "internal".to_string(),
975            title: "test".to_string(),
976            body: String::new(),
977            status: TaskStatus::New,
978            source: String::new(),
979            source_id: String::new(),
980            author: String::new(),
981            url: String::new(),
982            labels: vec![],
983            agent: None,
984            model: None,
985            complexity: "simple".to_string(),
986            estimate: 0,
987            route_reason: String::new(),
988            agent_profile: String::new(),
989            selected_skills: String::new(),
990            route_attempts: 0,
991            attempts: 0,
992            branch: String::new(),
993            worktree: String::new(),
994            worktree_cleaned: false,
995            owner_node: None,
996            summary: String::new(),
997            last_error: String::new(),
998            parent_id: None,
999            block_reason: None,
1000            pr_number: None,
1001            pr_review_context: String::new(),
1002            last_review_ts: String::new(),
1003            review_ts_map: "{}".to_string(),
1004            last_comment_review_ts: String::new(),
1005            merge_conflict_retries: 0,
1006            ci_merge_failures: 0,
1007            pr_create_failures: 0,
1008            push_failures: 0,
1009            network_retries: 3,
1010            review_agent_failures: 0,
1011            review_resets: 0,
1012            review_cycles: 0,
1013            review_invocations: 0,
1014            needs_review_refires: 0,
1015            input_tokens: 0,
1016            output_tokens: 0,
1017            input_cost_usd: 0.0,
1018            output_cost_usd: 0.0,
1019            total_cost_usd: 0.0,
1020            model_reroute_chain: String::new(),
1021            limit_reroute_chain: "claude".to_string(),
1022            memory: vec![],
1023            delegations: vec![],
1024            auto_unblock_count: 0,
1025            auto_unblock_last_at: String::new(),
1026            auto_unblock_last_reason: String::new(),
1027            ci_recovery_count: 0,
1028            no_code_reroutes: 0,
1029            no_code_last_agent: String::new(),
1030            created_at: String::new(),
1031            updated_at: String::new(),
1032        };
1033
1034        assert_eq!(fallback_retry_count(Some(&task)), 3);
1035    }
1036
1037    #[serial(cooldown_state)]
1038    #[test]
1039    fn retryable_type_str_returns_correct_labels() {
1040        assert_eq!(RetryableError::Timeout.type_str(), "timeout");
1041        assert_eq!(RetryableError::Interrupted.type_str(), "interrupted");
1042        assert_eq!(RetryableError::UsageLimit.type_str(), "rate_limit");
1043        assert_eq!(RetryableError::AuthError.type_str(), "auth_error");
1044        assert_eq!(RetryableError::Failed.type_str(), "failed");
1045        assert_eq!(RetryableError::MissingTooling.type_str(), "failed");
1046    }
1047
1048    #[serial(cooldown_state)]
1049    #[test]
1050    fn is_usage_limit_detects_rate_limit() {
1051        assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
1052        assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
1053        assert!(patterns::detect_rate_limit("quota exceeded for model").is_some());
1054        // context_length_exceeded is handled by detect_context_overflow, not detect_rate_limit
1055        assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
1056    }
1057
1058    #[serial(cooldown_state)]
1059    #[test]
1060    fn is_usage_limit_rejects_normal_text() {
1061        assert!(patterns::detect_rate_limit("task completed successfully").is_none());
1062        assert!(patterns::detect_rate_limit("").is_none());
1063    }
1064
1065    #[serial(cooldown_state)]
1066    #[test]
1067    fn is_auth_error_detects_common_patterns() {
1068        assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
1069        assert!(patterns::detect_auth_error("invalid api key provided").is_some());
1070        assert!(patterns::detect_auth_error("Your billing plan has expired").is_some());
1071        assert!(patterns::detect_auth_error("Error: 403 Forbidden").is_some());
1072    }
1073
1074    #[serial(cooldown_state)]
1075    #[test]
1076    fn is_auth_error_rejects_normal_text() {
1077        assert!(patterns::detect_auth_error("task completed successfully").is_none());
1078        assert!(patterns::detect_auth_error("").is_none());
1079    }
1080
1081    #[serial(cooldown_state)]
1082    #[test]
1083    fn detect_missing_tooling_finds_known_tools() {
1084        let result = patterns::detect_missing_tool("bun: command not found");
1085        assert!(result.is_some());
1086        if let AgentError::MissingTool { tool } = result.unwrap() {
1087            assert_eq!(tool, "bun");
1088        } else {
1089            panic!("expected MissingTool error");
1090        }
1091
1092        let result = patterns::detect_missing_tool("env: anchor: no such file");
1093        assert!(result.is_some());
1094        if let AgentError::MissingTool { tool } = result.unwrap() {
1095            assert_eq!(tool, "anchor");
1096        } else {
1097            panic!("expected MissingTool error");
1098        }
1099
1100        let result = patterns::detect_missing_tool("spawn docker enoent");
1101        assert!(result.is_some());
1102        if let AgentError::MissingTool { tool } = result.unwrap() {
1103            assert_eq!(tool, "docker");
1104        } else {
1105            panic!("expected MissingTool error");
1106        }
1107    }
1108
1109    #[serial(cooldown_state)]
1110    #[test]
1111    fn detect_missing_tooling_returns_none_for_normal() {
1112        assert!(patterns::detect_missing_tool("everything works fine").is_none());
1113        assert!(patterns::detect_missing_tool("").is_none());
1114    }
1115
1116    // ── Cooldown tests ────────────────────────────────────────────
1117
1118    #[serial(cooldown_state)]
1119    #[tokio::test]
1120    async fn record_and_check_agent_cooldown() {
1121        cooldown::reset_global_state().await;
1122        // Use unique names to avoid interference from other tests
1123        let agent = "test_cooldown_agent_1";
1124        assert!(!is_agent_in_cooldown(agent));
1125        record_agent_failure_with_message(agent, "").await;
1126        assert!(is_agent_in_cooldown(agent));
1127    }
1128
1129    #[serial(cooldown_state)]
1130    #[tokio::test]
1131    async fn record_and_check_model_cooldown() {
1132        cooldown::reset_global_state().await;
1133        let agent = "test_cooldown_agent_2";
1134        let model = "test_model_x";
1135        assert!(!is_model_in_cooldown(agent, model));
1136        record_model_failure(agent, model).await;
1137        assert!(is_model_in_cooldown(agent, model));
1138        // Different model should not be in cooldown
1139        assert!(!is_model_in_cooldown(agent, "other_model"));
1140    }
1141
1142    #[serial(cooldown_state)]
1143    #[tokio::test]
1144    async fn clear_expired_does_not_remove_fresh_entries() {
1145        cooldown::reset_global_state().await;
1146        let agent = "test_cooldown_agent_3";
1147        record_agent_failure_with_message(agent, "").await;
1148        clear_expired_cooldowns();
1149        // Should still be in cooldown (just recorded)
1150        assert!(is_agent_in_cooldown(agent));
1151    }
1152
1153    // Use fake agent names so other tests don't interfere.
1154    #[serial(cooldown_state)]
1155    #[test]
1156    fn pick_fallback_skips_current_agent() {
1157        let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1158        let result = pick_fallback_agent("test_agent_a", "", &available);
1159        assert_eq!(result, Some("test_agent_b".to_string()));
1160    }
1161
1162    #[serial(cooldown_state)]
1163    #[test]
1164    fn pick_fallback_skips_chain_agents() {
1165        let available = vec![
1166            "test_agent_a".to_string(),
1167            "test_agent_b".to_string(),
1168            "test_agent_c".to_string(),
1169        ];
1170        let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1171        assert_eq!(result, Some("test_agent_c".to_string()));
1172    }
1173
1174    #[serial(cooldown_state)]
1175    #[test]
1176    fn pick_fallback_returns_none_when_exhausted() {
1177        let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1178        let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1179        assert!(result.is_none());
1180    }
1181
1182    #[serial(cooldown_state)]
1183    #[test]
1184    fn weight_signal_variants() {
1185        let success = WeightSignal::Success {
1186            agent: "claude".to_string(),
1187        };
1188        let limited = WeightSignal::RateLimited {
1189            agent: "codex".to_string(),
1190        };
1191        let none = WeightSignal::None;
1192
1193        // Verify Debug trait
1194        assert!(format!("{success:?}").contains("claude"));
1195        assert!(format!("{limited:?}").contains("codex"));
1196        assert!(format!("{none:?}").contains("None"));
1197    }
1198
1199    #[serial(cooldown_state)]
1200    #[test]
1201    fn parse_review_response_direct_json() {
1202        let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1203        let resp = parse_review_response(json).unwrap();
1204        assert_eq!(resp.decision, "approve");
1205        assert_eq!(resp.notes, "LGTM");
1206        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1207    }
1208
1209    #[serial(cooldown_state)]
1210    #[test]
1211    fn parse_review_response_from_markdown() {
1212        let md = r#"Here is my review:
1213
1214```json
1215{"decision":"request_changes","notes":"Fix the bug","issues":[{"file":"src/main.rs","line":10,"severity":"error","description":"null deref"}]}
1216```
1217
1218That's all."#;
1219        let resp = parse_review_response(md).unwrap();
1220        assert_eq!(resp.decision, "request_changes");
1221        assert_eq!(resp.issues.len(), 1);
1222        assert_eq!(resp.issues[0].file, "src/main.rs");
1223    }
1224
1225    #[serial(cooldown_state)]
1226    #[test]
1227    fn parse_review_response_embedded_json_object() {
1228        let text = "Review complete.\n\n{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}\nThanks!";
1229        let resp = parse_review_response(text).unwrap();
1230        assert_eq!(resp.decision, "approve");
1231        assert_eq!(resp.notes, "LGTM");
1232    }
1233
1234    #[serial(cooldown_state)]
1235    #[test]
1236    fn parse_review_response_template_echo_infers_from_notes() {
1237        let json = r#"{"decision":"approve|request_changes","notes":"LGTM","issues":[]}"#;
1238        assert_eq!(parse_review_response(json).unwrap().decision, "approve");
1239
1240        let no_signal = r#"{"decision":"approve|request_changes","notes":"hmm","issues":[]}"#;
1241        assert!(parse_review_response(no_signal).is_err());
1242    }
1243
1244    #[serial(cooldown_state)]
1245    #[test]
1246    fn parse_review_response_invalid() {
1247        let result = parse_review_response("not json at all");
1248        assert!(result.is_err());
1249    }
1250
1251    #[serial(cooldown_state)]
1252    #[test]
1253    fn extract_json_block_basic() {
1254        let md = "text\n```json\n{\"key\": \"value\"}\n```\nmore";
1255        let result = extract_json_block(md);
1256        assert!(result.is_some());
1257        assert!(result.unwrap().contains("\"key\""));
1258    }
1259
1260    #[serial(cooldown_state)]
1261    #[test]
1262    fn extract_json_block_skips_non_json_blocks() {
1263        let md = "```json\nnot-json-array\n```\n\n```json\n{\"real\": true}\n```";
1264        let result = extract_json_block(md);
1265        assert!(result.is_some());
1266        assert!(result.unwrap().contains("\"real\""));
1267    }
1268
1269    #[serial(cooldown_state)]
1270    #[test]
1271    fn extract_json_block_none_when_missing() {
1272        assert!(extract_json_block("no code blocks here").is_none());
1273    }
1274
1275    #[serial(cooldown_state)]
1276    #[test]
1277    fn extract_json_block_skips_malformed_intermediate_blocks() {
1278        let md = "prefix ```json{\"broken\": true}\n\n```json\n{\"real\": true}\n```";
1279        let result = extract_json_block(md);
1280        assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1281    }
1282
1283    #[serial(cooldown_state)]
1284    #[test]
1285    fn extract_json_block_skips_multiple_malformed_intermediate_blocks() {
1286        let md = "prefix ```json{\"broken\": true}\nmid ```json[1, 2, 3]\n\n```json\n{\"real\": true}\n```";
1287        let result = extract_json_block(md);
1288        assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1289    }
1290
1291    // ── parse_review_response + infer_review_response (production path) ─
1292
1293    /// Helper that mirrors the production plain-text path in review.rs:
1294    /// parse_review_response → infer_review_response.
1295    fn parse_review_plain(text: &str) -> anyhow::Result<ReviewResponse> {
1296        parse_review_response(text).or_else(|_| {
1297            infer_review_response(text)
1298                .ok_or_else(|| anyhow::anyhow!("failed to parse review response"))
1299        })
1300    }
1301
1302    #[serial(cooldown_state)]
1303    #[test]
1304    fn parse_review_from_output_direct_json() {
1305        let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1306        let resp = parse_review_response(json).unwrap();
1307        assert_eq!(resp.decision, "approve");
1308    }
1309
1310    #[serial(cooldown_state)]
1311    #[test]
1312    fn parse_review_from_output_markdown() {
1313        let md = "Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}\n```\n";
1314        let resp = parse_review_response(md).unwrap();
1315        assert_eq!(resp.decision, "request_changes");
1316    }
1317
1318    #[serial(cooldown_state)]
1319    #[test]
1320    fn parse_review_from_output_plain_text_fallback() {
1321        let text = "The review is done. LGTM.";
1322        let resp = parse_review_plain(text).unwrap();
1323        assert_eq!(resp.decision, "approve");
1324        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1325    }
1326
1327    /// opencode NDJSON stream — text events use the `part.text` format.
1328    #[serial(cooldown_state)]
1329    #[test]
1330    fn parse_review_from_output_ndjson_part_format() {
1331        let ndjson = concat!(
1332            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1333            "\n",
1334            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Reviewing the PR...\n\n```json\n{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1335            "\n",
1336            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop"}}"#,
1337        );
1338        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1339        assert_eq!(resp.decision, "approve");
1340        assert_eq!(resp.notes, "All checks pass");
1341    }
1342
1343    /// opencode NDJSON stream — text events use the newer `text` directly-in-event format.
1344    #[serial(cooldown_state)]
1345    #[test]
1346    fn parse_review_from_output_ndjson_direct_text_format() {
1347        let ndjson = concat!(
1348            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1349            "\n",
1350            r#"{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"Here is my review:\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the bug\",\"test_results\":\"fail\",\"issues\":[]}\n```\n"}"#,
1351            "\n",
1352            r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1353        );
1354        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1355        assert_eq!(resp.decision, "request_changes");
1356        assert_eq!(resp.notes, "Fix the bug");
1357    }
1358
1359    /// opencode NDJSON with ReviewResponse JSON directly in a text event (no markdown wrapper).
1360    #[serial(cooldown_state)]
1361    #[test]
1362    fn parse_review_from_output_ndjson_direct_json_in_text_event() {
1363        let ndjson = concat!(
1364            r#"{"type":"step_start","timestamp":1000}"#,
1365            "\n",
1366            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1367            "\n",
1368            r#"{"type":"step_finish","timestamp":1002}"#,
1369        );
1370        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1371        assert_eq!(resp.decision, "approve");
1372    }
1373
1374    #[serial(cooldown_state)]
1375    #[test]
1376    fn parse_review_from_output_ndjson_prefers_final_json_text_event() {
1377        let ndjson = concat!(
1378            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on it..."}}"#,
1379            "\n",
1380            r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1381        );
1382        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1383        assert_eq!(resp.decision, "approve");
1384        assert_eq!(resp.notes, "LGTM");
1385    }
1386
1387    /// Concatenated text events produce a valid ReviewResponse.
1388    #[serial(cooldown_state)]
1389    #[test]
1390    fn parse_review_from_output_ndjson_concatenated_text() {
1391        let ndjson = concat!(
1392            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Here is my\n"}}"#,
1393            "\n",
1394            r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"review:\n\n```json\n{\"decision\":\"approve\",\"notes\":\"OK\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1395        );
1396        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1397        assert_eq!(resp.decision, "approve");
1398    }
1399
1400    /// codex NDJSON stream — agent_message item carries the ReviewResponse JSON.
1401    ///
1402    /// Real format from codex `exec --json` output (observed 2026-03-06):
1403    /// thread.started / turn.started / item.completed(reasoning) / item.completed(agent_message) / turn.completed
1404    #[serial(cooldown_state)]
1405    #[test]
1406    fn parse_review_from_output_codex_agent_message() {
1407        let ndjson = concat!(
1408            r#"{"type":"thread.started","thread_id":"t1"}"#,
1409            "\n",
1410            r#"{"type":"turn.started"}"#,
1411            "\n",
1412            r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Planning git commands..."}}"#,
1413            "\n",
1414            r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"git log --oneline -5","output":"abc123 fix: something"}}"#,
1415            "\n",
1416            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1417            "\n",
1418            r#"{"type":"turn.completed"}"#,
1419        );
1420        let resp = parse_via_agent_path("codex", ndjson).unwrap();
1421        assert_eq!(resp.decision, "approve");
1422        assert_eq!(resp.notes, "All checks pass");
1423    }
1424
1425    /// codex NDJSON with ReviewResponse in a markdown code block inside agent_message.
1426    #[serial(cooldown_state)]
1427    #[test]
1428    fn parse_review_from_output_codex_agent_message_markdown() {
1429        let ndjson = concat!(
1430            r#"{"type":"thread.started","thread_id":"t1"}"#,
1431            "\n",
1432            r#"{"type":"turn.started"}"#,
1433            "\n",
1434            r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Reviewing..."}}"#,
1435            "\n",
1436            r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the tests\",\"issues\":[]}\n```\n"}}"#,
1437            "\n",
1438            r#"{"type":"turn.completed"}"#,
1439        );
1440        let resp = parse_via_agent_path("codex", ndjson).unwrap();
1441        assert_eq!(resp.decision, "request_changes");
1442        assert_eq!(resp.notes, "Fix the tests");
1443    }
1444
1445    /// codex reasoning items must NOT be extracted as review text — only agent_message is used.
1446    #[serial(cooldown_state)]
1447    #[test]
1448    fn codex_skips_reasoning_items() {
1449        let ndjson = concat!(
1450            r#"{"type":"item.completed","item":{"type":"reasoning","text":"internal thoughts..."}}"#,
1451            "\n",
1452            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}}"#,
1453        );
1454        let resp = parse_via_agent_path("codex", ndjson).unwrap();
1455        assert_eq!(resp.decision, "approve");
1456        assert_eq!(resp.notes, "LGTM");
1457    }
1458
1459    /// Negation patterns must NOT trigger a false approval.
1460    #[serial(cooldown_state)]
1461    #[test]
1462    fn infer_review_response_negation_not_approved() {
1463        assert!(
1464            parse_review_plain("The changes were not approved.").is_err(),
1465            "\"not approved\" should not infer approval"
1466        );
1467    }
1468
1469    #[serial(cooldown_state)]
1470    #[test]
1471    fn infer_review_response_negation_unapproved() {
1472        assert!(
1473            parse_review_plain("Unapproved changes found in the diff.").is_err(),
1474            "\"unapproved\" should not infer approval"
1475        );
1476    }
1477
1478    #[serial(cooldown_state)]
1479    #[test]
1480    fn infer_review_response_negation_not_approving() {
1481        assert!(
1482            parse_review_plain("I am not approving this PR.").is_err(),
1483            "\"not approving\" should not infer approval"
1484        );
1485    }
1486
1487    #[serial(cooldown_state)]
1488    #[test]
1489    fn infer_review_response_negation_cannot_be_approved() {
1490        assert!(
1491            parse_review_plain("This PR cannot be approved until the race in tick.rs is fixed.")
1492                .is_err(),
1493            "\"cannot be approved\" should not infer approval"
1494        );
1495    }
1496
1497    #[serial(cooldown_state)]
1498    #[test]
1499    fn infer_review_response_negation_should_not_be_approved() {
1500        assert!(
1501            parse_review_plain("The diff breaks the build and should not be approved.").is_err(),
1502            "\"should not be approved\" should not infer approval"
1503        );
1504    }
1505
1506    #[serial(cooldown_state)]
1507    #[test]
1508    fn infer_review_response_negation_not_yet_approved() {
1509        assert!(
1510            parse_review_plain("This change is not yet approved.").is_err(),
1511            "\"not yet approved\" should not infer approval"
1512        );
1513    }
1514
1515    #[serial(cooldown_state)]
1516    #[test]
1517    fn infer_review_response_negation_wont_approve() {
1518        assert!(
1519            parse_review_plain("I won't approve this until tests cover the retry path.").is_err(),
1520            "\"won't approve\" should not infer approval"
1521        );
1522    }
1523
1524    #[serial(cooldown_state)]
1525    #[test]
1526    fn infer_review_response_bare_approved() {
1527        assert!(
1528            parse_review_plain("The PR was approved.").is_err(),
1529            "bare \"approved\" should not infer approval"
1530        );
1531    }
1532
1533    /// Positive plain-text approval signals must still work.
1534    #[serial(cooldown_state)]
1535    #[test]
1536    fn infer_review_response_lgtm() {
1537        let resp = parse_review_plain("LGTM, everything looks fine.").unwrap();
1538        assert_eq!(resp.decision, "approve");
1539    }
1540
1541    /// Evidence phrases are not a decision token
1542    #[serial(cooldown_state)]
1543    #[test]
1544    fn infer_review_response_looks_good() {
1545        assert!(
1546            parse_review_plain("Looks good to me!").is_err(),
1547            "\"looks good\" should not infer approval"
1548        );
1549    }
1550
1551    #[serial(cooldown_state)]
1552    #[test]
1553    fn infer_review_response_all_checks_passed() {
1554        assert!(
1555            parse_review_plain(
1556                "The background task completed but I already got the CI results directly. All checks passed — no action needed.",
1557            )
1558            .is_err(),
1559            "\"all checks passed\" should not infer approval"
1560        );
1561    }
1562
1563    #[serial(cooldown_state)]
1564    #[test]
1565    fn infer_review_response_checks_passed() {
1566        assert!(
1567            parse_review_plain("All checks passed (fmt ✓, clippy ✓, tests ✓).").is_err(),
1568            "\"all checks passed\" should not infer approval"
1569        );
1570    }
1571
1572    #[serial(cooldown_state)]
1573    #[test]
1574    fn infer_review_response_all_tests_passed() {
1575        assert!(
1576            parse_review_plain("All tests passed and the implementation looks correct.").is_err(),
1577            "\"all tests passed\" should not infer approval"
1578        );
1579    }
1580
1581    #[serial(cooldown_state)]
1582    #[test]
1583    fn infer_review_response_no_issues_found() {
1584        assert!(
1585            parse_review_plain("Review complete. No issues found in the diff.").is_err(),
1586            "\"no issues found\" should not infer approval"
1587        );
1588    }
1589
1590    /// Partial "tests passed" mid-sentence must NOT infer approval without "all" prefix.
1591    #[serial(cooldown_state)]
1592    #[test]
1593    fn infer_review_response_partial_tests_passed_no_approve() {
1594        let text = "The existing tests passed before this change but the new payment logic has a null-pointer risk on line 42.";
1595        assert!(parse_review_plain(text).is_err());
1596    }
1597
1598    /// Partial "checks passed" mid-sentence must NOT infer approval without "all" prefix.
1599    #[serial(cooldown_state)]
1600    #[test]
1601    fn infer_review_response_partial_checks_passed_no_approve() {
1602        let text = "CI checks passed for the base branch but this PR adds a broken path.";
1603        assert!(parse_review_plain(text).is_err());
1604    }
1605
1606    /// LLMs sometimes echo the JSON decision field value literally: "Decision is `approve`."
1607    #[serial(cooldown_state)]
1608    #[test]
1609    fn infer_review_response_decision_is_approve_backtick() {
1610        let text = "Already retrieved the output and completed the review above. The CI test check passed (3m11s) and all other checks are green. Decision is `approve`.";
1611        let resp = parse_review_plain(text).unwrap();
1612        assert_eq!(resp.decision, "approve");
1613    }
1614
1615    /// "Decision: approve" without backticks should also match.
1616    #[serial(cooldown_state)]
1617    #[test]
1618    fn infer_review_response_decision_colon_approve() {
1619        let text = "Reviewed the PR. No blocking issues found. Decision: approve";
1620        let resp = parse_review_plain(text).unwrap();
1621        assert_eq!(resp.decision, "approve");
1622    }
1623
1624    /// Bare backtick `approve` in a negative context must NOT infer approval.
1625    /// e.g. "do not `approve`" previously matched the removed `lower.contains("`approve`")` check.
1626    #[serial(cooldown_state)]
1627    #[test]
1628    fn infer_review_response_negation_do_not_approve_backtick() {
1629        let text = "There are unresolved issues — do not `approve` this PR until they are fixed.";
1630        assert!(
1631            parse_review_plain(text).is_err(),
1632            "negative context with backtick approve must not infer approval"
1633        );
1634    }
1635
1636    // ── review parsing via find_agent_result + parse_review_response ────
1637
1638    /// Helper that mirrors the production path in review.rs:
1639    /// find_agent_result → parse_review_response → infer_review_response.
1640    fn parse_via_agent_path(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1641        let text = find_agent_result(agent, output)
1642            .map(|r| r.result_text)
1643            .filter(|t| !t.is_empty())
1644            .unwrap_or_else(|| output.to_string());
1645        if let Ok(r) = parse_review_response(&text) {
1646            return Ok(r);
1647        }
1648        infer_review_response_from_text(&text)
1649            .ok_or_else(|| anyhow::anyhow!("failed to parse review response from {agent} output"))
1650    }
1651
1652    #[serial(cooldown_state)]
1653    #[test]
1654    fn agent_output_claude_ndjson_review() {
1655        let ndjson = concat!(
1656            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1657            "\n",
1658            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1659            "\n",
1660            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1661        );
1662        let resp = parse_via_agent_path("claude", ndjson).unwrap();
1663        assert_eq!(resp.decision, "approve");
1664    }
1665
1666    #[serial(cooldown_state)]
1667    #[test]
1668    fn agent_output_opencode_ndjson_review() {
1669        let ndjson = concat!(
1670            r#"{"type":"step_start","timestamp":1000}"#,
1671            "\n",
1672            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}"}}"#,
1673            "\n",
1674            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
1675        );
1676        let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1677        assert_eq!(resp.decision, "request_changes");
1678    }
1679
1680    #[serial(cooldown_state)]
1681    #[test]
1682    fn agent_output_codex_ndjson_review() {
1683        let ndjson = concat!(
1684            r#"{"type":"thread.started","thread_id":"t1"}"#,
1685            "\n",
1686            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All good\",\"issues\":[]}"}}"#,
1687            "\n",
1688            r#"{"type":"turn.completed"}"#,
1689        );
1690        let resp = parse_via_agent_path("codex", ndjson).unwrap();
1691        assert_eq!(resp.decision, "approve");
1692    }
1693
1694    #[serial(cooldown_state)]
1695    #[test]
1696    fn agent_output_plain_text_keyword_fallback() {
1697        let text = "All checks passed, LGTM.";
1698        let resp = parse_via_agent_path("claude", text).unwrap();
1699        assert_eq!(resp.decision, "approve");
1700    }
1701
1702    #[serial(cooldown_state)]
1703    #[test]
1704    fn agent_output_direct_json() {
1705        let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1706        let resp = parse_via_agent_path("opencode", json).unwrap();
1707        assert_eq!(resp.decision, "approve");
1708    }
1709
1710    /// Regression: plain text containing JSON fragments should NOT trigger false
1711    /// positives. The per-agent extractor returns None for non-NDJSON text,
1712    /// so we fall through to keyword inference instead of generic NDJSON parsing.
1713    #[serial(cooldown_state)]
1714    #[test]
1715    fn agent_output_text_with_json_fragment_no_false_positive() {
1716        let text = r#"I checked the file and found {"type":"text"} in the output. No issues."#;
1717        // This should NOT extract a review from the JSON fragment.
1718        // With the generic parser, this would match as a "text" NDJSON event.
1719        // With per-agent extraction, it returns None (not valid agent NDJSON).
1720        let result = parse_via_agent_path("claude", text);
1721        // Should either fail or infer from keywords, NOT parse the JSON fragment
1722        assert!(
1723            result.is_err() || result.as_ref().is_ok_and(|r| r.decision != "text"),
1724            "should not extract review from embedded JSON fragment"
1725        );
1726    }
1727
1728    /// Prose containing the word "decision" and a JSON-like fragment must not
1729    /// be misidentified as a review response by `extract_review_response_object`.
1730    #[serial(cooldown_state)]
1731    #[test]
1732    fn parse_review_response_ignores_prose_with_decision_word() {
1733        let text = r#"The decision was made. Here is a debug trace: {"trace":123}"#;
1734        assert!(
1735            parse_review_response(text).is_err(),
1736            "prose with 'decision' word and unrelated JSON should not parse as ReviewResponse"
1737        );
1738    }
1739
1740    /// Plain text with an embedded JSON object that has unrelated keys should
1741    /// not be extracted as a review response.
1742    #[serial(cooldown_state)]
1743    #[test]
1744    fn parse_review_response_ignores_embedded_json_without_decision_key() {
1745        let text = r#"Analysis complete. Config: {"timeout":30,"retries":3}. Done."#;
1746        assert!(
1747            parse_review_response(text).is_err(),
1748            "embedded JSON without 'decision' key should not parse as ReviewResponse"
1749        );
1750    }
1751
1752    // ── read_file_with_limit tests ───────────────────────────────────────────
1753
1754    #[serial(cooldown_state)]
1755    #[test]
1756    fn read_file_with_limit_reads_small_file_completely() {
1757        let temp_dir = env::temp_dir();
1758        let file_path = temp_dir.join(format!("orch_test_small_{}.txt", process::id()));
1759        let content = "Hello, World!";
1760        fs::write(&file_path, content).unwrap();
1761
1762        let result = read_file_with_limit(&file_path, 1024);
1763        assert!(result.is_ok());
1764        assert_eq!(result.unwrap(), content);
1765
1766        let _ = fs::remove_file(&file_path);
1767    }
1768
1769    #[serial(cooldown_state)]
1770    #[test]
1771    fn read_file_with_limit_truncates_large_file() {
1772        let temp_dir = env::temp_dir();
1773        let file_path = temp_dir.join(format!("orch_test_large_{}.txt", process::id()));
1774
1775        // Create a file larger than the limit (100 bytes)
1776        let large_content = "x".repeat(200);
1777        fs::write(&file_path, &large_content).unwrap();
1778
1779        // Read with a 50-byte limit
1780        let result = read_file_with_limit(&file_path, 50);
1781        assert!(result.is_ok());
1782
1783        let read_content = result.unwrap();
1784        // Should only read 50 bytes (the tail of the file)
1785        assert_eq!(read_content.len(), 50);
1786        assert_eq!(read_content, "x".repeat(50));
1787
1788        let _ = fs::remove_file(&file_path);
1789    }
1790
1791    #[serial(cooldown_state)]
1792    #[test]
1793    fn read_file_with_limit_handles_empty_file() {
1794        let temp_dir = env::temp_dir();
1795        let file_path = temp_dir.join(format!("orch_test_empty_{}.txt", process::id()));
1796        fs::write(&file_path, "").unwrap();
1797
1798        let result = read_file_with_limit(&file_path, 1024);
1799        assert!(result.is_ok());
1800        assert_eq!(result.unwrap(), "");
1801
1802        let _ = fs::remove_file(&file_path);
1803    }
1804
1805    #[serial(cooldown_state)]
1806    #[test]
1807    fn read_file_with_limit_handles_nonexistent_file() {
1808        let temp_dir = env::temp_dir();
1809        let file_path = temp_dir.join("orch_test_nonexistent_xyz123.txt");
1810
1811        let result = read_file_with_limit(&file_path, 1024);
1812        assert!(result.is_err());
1813    }
1814
1815    #[serial(cooldown_state)]
1816    #[test]
1817    fn read_file_with_limit_reads_exact_limit() {
1818        let temp_dir = env::temp_dir();
1819        let file_path = temp_dir.join(format!("orch_test_exact_{}.txt", process::id()));
1820
1821        // Create exactly 100 bytes
1822        let content = "y".repeat(100);
1823        fs::write(&file_path, &content).unwrap();
1824
1825        // Read with exactly 100-byte limit (should read all)
1826        let result = read_file_with_limit(&file_path, 100);
1827        assert!(result.is_ok());
1828        assert_eq!(result.unwrap().len(), 100);
1829
1830        let _ = fs::remove_file(&file_path);
1831    }
1832
1833    #[serial(cooldown_state)]
1834    #[test]
1835    fn read_file_with_limit_handles_unicode_truncation() {
1836        let temp_dir = env::temp_dir();
1837        let file_path = temp_dir.join(format!("orch_test_unicode_{}.txt", process::id()));
1838
1839        // Create content with multi-byte UTF-8 characters
1840        let emoji = "🎉".repeat(100); // Each emoji is 4 bytes
1841        fs::write(&file_path, &emoji).unwrap();
1842
1843        // Read with a limit that cuts into the emoji sequence
1844        let result = read_file_with_limit(&file_path, 50);
1845        assert!(result.is_ok());
1846
1847        let read_content = result.unwrap();
1848        // from_utf8_lossy should handle invalid sequences
1849        assert_ne!(read_content, "");
1850
1851        let _ = fs::remove_file(&file_path);
1852    }
1853
1854    // ── Fixture-based review integration tests ──────────────────────────────────
1855
1856    /// Full review pipeline: find_agent_result → parse_review_response → infer_review_response.
1857    fn parse_review_via_agent(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1858        let text = find_agent_result(agent, output)
1859            .map(|r| r.result_text)
1860            .filter(|t| !t.is_empty())
1861            .unwrap_or_else(|| output.to_string());
1862        if let Ok(r) = parse_review_response(&text) {
1863            return Ok(r);
1864        }
1865        infer_review_response(&text)
1866            .ok_or_else(|| anyhow::anyhow!("failed to parse review from {agent} output"))
1867    }
1868
1869    // ── Claude review fixtures ─────────────────────────────────────────────────
1870
1871    #[serial(cooldown_state)]
1872    #[test]
1873    fn fixture_claude_review_approve() {
1874        let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
1875        let resp = parse_review_via_agent("claude", raw).unwrap();
1876        assert_eq!(resp.decision, "approve");
1877        assert_eq!(resp.notes, "All checks pass");
1878        assert_eq!(resp.test_results.as_deref(), Some("pass"));
1879        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1880    }
1881
1882    #[serial(cooldown_state)]
1883    #[test]
1884    fn fixture_claude_review_request_changes() {
1885        let raw = include_str!("../../../tests/fixtures/review_claude_request_changes.jsonl");
1886        let resp = parse_review_via_agent("claude", raw).unwrap();
1887        assert_eq!(resp.decision, "request_changes");
1888        assert_eq!(resp.notes, "Fix the null deref on line 42");
1889        assert_eq!(resp.test_results.as_deref(), Some("fail"));
1890        assert_eq!(resp.issues.len(), 1);
1891        assert_eq!(resp.issues[0].file, "src/main.rs");
1892        assert_eq!(resp.issues[0].line, Some(42));
1893        assert_eq!(resp.issues[0].severity, "error");
1894    }
1895
1896    #[serial(cooldown_state)]
1897    #[test]
1898    fn fixture_claude_review_rate_limit_returns_error() {
1899        // find_claude_result on an is_error=true result returns Some with is_error=true.
1900        // The integration test verifies this is captured, not that it's a ReviewResponse.
1901        let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
1902        let result = find_agent_result("claude", raw);
1903        assert!(
1904            result.is_some(),
1905            "should find result even for error envelope"
1906        );
1907        let result = result.unwrap();
1908        assert!(result.is_error, "rate limit should be flagged as error");
1909        assert!(result.result_text.contains("rate limit"));
1910    }
1911
1912    // ── OpenCode review fixtures ────────────────────────────────────────────────
1913
1914    #[serial(cooldown_state)]
1915    #[test]
1916    fn fixture_opencode_review_approve() {
1917        let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
1918        let resp = parse_review_via_agent("opencode", raw).unwrap();
1919        assert_eq!(resp.decision, "approve");
1920        assert_eq!(resp.notes, "All checks pass");
1921        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1922    }
1923
1924    #[serial(cooldown_state)]
1925    #[test]
1926    fn fixture_opencode_review_request_changes() {
1927        let raw = include_str!("../../../tests/fixtures/review_opencode_request_changes.jsonl");
1928        let resp = parse_review_via_agent("opencode", raw).unwrap();
1929        assert_eq!(resp.decision, "request_changes");
1930        assert_eq!(resp.notes, "Fix the memory leak");
1931        assert_eq!(resp.test_results.as_deref(), Some("fail"));
1932        assert_eq!(resp.issues.len(), 1);
1933        assert_eq!(resp.issues[0].file, "src/lib.rs");
1934        assert_eq!(resp.issues[0].line, Some(100));
1935    }
1936
1937    #[serial(cooldown_state)]
1938    #[test]
1939    fn fixture_opencode_review_plain_text_approve() {
1940        // OpenCode with plain-text review (no JSON) — should infer approval via keywords.
1941        let raw = include_str!("../../../tests/fixtures/review_opencode_plain_text.jsonl");
1942        let resp = parse_review_via_agent("opencode", raw).unwrap();
1943        assert_eq!(resp.decision, "approve");
1944    }
1945
1946    // ── Codex review fixtures ────────────────────────────────────────────────────
1947
1948    #[serial(cooldown_state)]
1949    #[test]
1950    fn fixture_codex_review_approve() {
1951        let raw = include_str!("../../../tests/fixtures/review_codex_approve.jsonl");
1952        let resp = parse_review_via_agent("codex", raw).unwrap();
1953        assert_eq!(resp.decision, "approve");
1954        assert_eq!(resp.notes, "Clean implementation");
1955        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1956    }
1957
1958    #[serial(cooldown_state)]
1959    #[test]
1960    fn fixture_codex_review_request_changes() {
1961        let raw = include_str!("../../../tests/fixtures/review_codex_request_changes.jsonl");
1962        let resp = parse_review_via_agent("codex", raw).unwrap();
1963        assert_eq!(resp.decision, "request_changes");
1964        assert_eq!(resp.notes, "Race condition on line 15");
1965        assert_eq!(resp.issues.len(), 1);
1966        assert_eq!(resp.issues[0].file, "src/server.rs");
1967        assert_eq!(resp.issues[0].line, Some(15));
1968    }
1969
1970    #[serial(cooldown_state)]
1971    #[test]
1972    fn fixture_codex_review_plain_text_approve() {
1973        // Codex with plain-text review — should infer approval via "LGTM" keyword.
1974        let raw = include_str!("../../../tests/fixtures/review_codex_plain_text.jsonl");
1975        let resp = parse_review_via_agent("codex", raw).unwrap();
1976        assert_eq!(resp.decision, "approve");
1977    }
1978
1979    // ── Kimi review fixtures ────────────────────────────────────────────────────
1980
1981    #[serial(cooldown_state)]
1982    #[test]
1983    fn fixture_kimi_review_approve() {
1984        let raw = include_str!("../../../tests/fixtures/review_kimi_approve.jsonl");
1985        let resp = parse_review_via_agent("kimi", raw).unwrap();
1986        assert_eq!(resp.decision, "approve");
1987        assert_eq!(resp.notes, "LGTM");
1988        assert_eq!(resp.test_results.as_deref(), Some("pass"));
1989        assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1990    }
1991
1992    // ── MiniMax review fixtures ────────────────────────────────────────────────
1993
1994    #[serial(cooldown_state)]
1995    #[test]
1996    fn fixture_minimax_review_request_changes() {
1997        let raw = include_str!("../../../tests/fixtures/review_minimax_request_changes.jsonl");
1998        let resp = parse_review_via_agent("minimax", raw).unwrap();
1999        assert_eq!(resp.decision, "request_changes");
2000        assert_eq!(resp.notes, "Fix the bug");
2001        assert_eq!(resp.test_results.as_deref(), Some("fail"));
2002        assert_eq!(resp.issues.len(), 1);
2003        assert_eq!(resp.issues[0].file, "src/foo.rs");
2004        assert_eq!(resp.issues[0].line, Some(42));
2005        assert_eq!(resp.issues[0].severity, "error");
2006    }
2007
2008    // ── Cross-agent token/metadata extraction ───────────────────────────────────
2009
2010    /// Verify that find_agent_result extracts token counts from each agent's format.
2011    #[serial(cooldown_state)]
2012    #[test]
2013    fn find_agent_result_extracts_tokens_claude() {
2014        let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
2015        let result = find_agent_result("claude", raw).expect("should find result");
2016        assert_eq!(result.input_tokens, Some(200));
2017        assert_eq!(result.output_tokens, Some(30));
2018        assert!(result.cost_usd.is_none()); // review fixtures don't include cost
2019    }
2020
2021    #[serial(cooldown_state)]
2022    #[test]
2023    fn find_agent_result_extracts_tokens_opencode() {
2024        let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
2025        let result = find_agent_result("opencode", raw).expect("should find result");
2026        assert_eq!(result.input_tokens, Some(2900));
2027        assert_eq!(result.output_tokens, Some(100));
2028        assert_eq!(result.cost_usd, Some(0.002));
2029    }
2030
2031    /// Verify that find_agent_result returns None for empty/unparseable input.
2032    #[serial(cooldown_state)]
2033    #[test]
2034    fn find_agent_result_returns_none_for_empty() {
2035        assert!(find_agent_result("claude", "").is_none());
2036        assert!(find_agent_result("opencode", "").is_none());
2037        assert!(find_agent_result("codex", "").is_none());
2038        assert!(find_agent_result("kimi", "").is_none());
2039        assert!(find_agent_result("minimax", "").is_none());
2040    }
2041
2042    #[serial(cooldown_state)]
2043    #[test]
2044    fn find_agent_result_returns_none_for_plain_text() {
2045        assert!(find_agent_result("claude", "just some plain text").is_none());
2046        assert!(find_agent_result("codex", "just some plain text").is_none());
2047        // OpenCode is a special case — it can sometimes return a result for plain text.
2048        // This is acceptable because opencode's extract_ndjson_text falls back gracefully.
2049    }
2050
2051    // ── Error propagation via find_agent_result ─────────────────────────────────
2052
2053    /// Verify that find_agent_result surfaces rate-limit errors from Claude.
2054    #[serial(cooldown_state)]
2055    #[test]
2056    fn find_agent_result_propagates_claude_rate_limit() {
2057        let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
2058        let result = find_agent_result("claude", raw).expect("should find result");
2059        assert!(result.is_error, "should be flagged as error");
2060        assert!(result.result_text.contains("rate limit"));
2061    }
2062}