1use crate::store;
10use crate::store::TaskStore;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14const JITTER_FACTOR: f64 = 0.3;
15
16const MAX_OUTPUT_SIZE_BYTES: u64 = 10 * 1024 * 1024;
19
20fn 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_size <= max_bytes {
40 return std::fs::read_to_string(path).map_err(|e| e.into());
41 }
42
43 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 let content = String::from_utf8_lossy(&buffer);
53
54 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
65pub(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 let exponential = base_delay * (2_u64.saturating_pow(retry_count as u32));
75 let capped = exponential.min(max_delay);
76
77 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
107pub 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 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#[derive(Debug, Clone)]
140pub enum WeightSignal {
141 Success { agent: String },
143 RateLimited { agent: String },
145 Blocked,
147 Rerouted,
150 None,
152}
153
154pub async fn read_output_file(task_id: &str, primary_path: &Path, repo: &str) -> String {
159 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 if let Ok(task_dir) = crate::home::task_dir_async(repo, task_id).await {
175 let attempts_dir = task_dir.join("attempts");
176 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)); 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 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
269pub 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
275pub fn pick_fallback_agent(
277 current_agent: &str,
278 chain: &str,
279 available_agents: &[String],
280) -> Option<String> {
281 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 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 return Some(agent.clone());
307 }
308
309 None
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum RetryableError {
315 Timeout,
317 Interrupted,
319 UsageLimit,
321 AuthError,
323 Failed,
325 MissingTooling,
327 ModelUnavailable,
331}
332
333impl RetryableError {
334 #[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
349pub 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 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 let available: Vec<String> = crate::engine::configured_agents()
372 .into_iter()
373 .filter(|a| crate::cmd_cache::command_exists(a))
374 .collect();
375
376 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 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 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 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 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 if !matches!(
467 error_type,
468 RetryableError::MissingTooling
469 | RetryableError::ModelUnavailable
470 | RetryableError::Timeout
471 | RetryableError::Interrupted
472 ) {
473 if !credit_recorded {
476 record_agent_failure_with_message(agent_name, error_message).await;
477 }
478
479 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 wait_for_fallback_backoff(task_id, store, repo).await;
508
509 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 tracing::warn!(task_id, agent = agent_name, "no fallback agents available");
535
536 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 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
583pub 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
597pub 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
627pub struct ReviewResponse {
628 pub decision: String,
630 pub notes: String,
632 pub test_results: Option<String>,
634 pub issues: Vec<ReviewIssue>,
636}
637
638#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
640pub struct ReviewIssue {
641 pub file: String,
643 pub line: Option<u32>,
645 pub severity: String,
647 pub description: String,
649}
650
651pub 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 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 if let Ok(resp) = serde_json::from_str::<ReviewResponse>(text) {
672 return Ok(resp);
673 }
674
675 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 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
723pub fn infer_review_response(text: &str) -> Option<ReviewResponse> {
727 infer_review_response_from_text(text)
728}
729
730fn 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
758fn 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#[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 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 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
865pub 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 let base = crate::engine::router::config::retry_base_delay_ms();
910 let max = crate::engine::router::config::retry_max_delay_ms();
911
912 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 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 #[serial(cooldown_state)]
1079 #[tokio::test]
1080 async fn record_and_check_agent_cooldown() {
1081 crate::engine::cooldown::reset_global_state().await;
1082 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 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 assert!(is_agent_in_cooldown(agent));
1111 }
1112
1113 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 let result = parse_via_agent_path("claude", text);
1682 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 #[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 #[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 #[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 let large_content = "x".repeat(200);
1738 std::fs::write(&file_path, &large_content).unwrap();
1739
1740 let result = read_file_with_limit(&file_path, 50);
1742 assert!(result.is_ok());
1743
1744 let read_content = result.unwrap();
1745 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 let content = "y".repeat(100);
1784 std::fs::write(&file_path, &content).unwrap();
1785
1786 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 let emoji = "🎉".repeat(100); std::fs::write(&file_path, &emoji).unwrap();
1803
1804 let result = read_file_with_limit(&file_path, 50);
1806 assert!(result.is_ok());
1807
1808 let read_content = result.unwrap();
1809 assert!(!read_content.is_empty());
1811
1812 let _ = std::fs::remove_file(&file_path);
1813 }
1814
1815 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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()); }
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 #[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 }
2011
2012 #[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}