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