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