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