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(std::convert::Into::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(usize::try_from(max_bytes).unwrap_or(0));
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 =
75 base_delay * (2_u64.saturating_pow(u32::try_from(retry_count).unwrap_or(u32::MAX)));
76 let capped = exponential.min(max_delay);
77
78 #[expect(
83 clippy::cast_precision_loss,
84 clippy::cast_possible_truncation,
85 clippy::cast_sign_loss,
86 reason = "a jitter range in milliseconds, far below 2^52 and never negative"
87 )]
88 let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
89 let jitter = {
90 use std::collections::hash_map::DefaultHasher;
91 use std::hash::{Hash, Hasher};
92 let mut h = DefaultHasher::new();
93 std::time::SystemTime::now().hash(&mut h);
94 std::thread::current().id().hash(&mut h);
95 h.finish() % (jitter_range * 2 + 1)
96 };
97
98 capped.saturating_sub(jitter_range) + jitter
99}
100
101fn fallback_retry_count(task: Option<&crate::store::Task>) -> usize {
102 task.map_or(0, |t| {
103 let chain_retry_count = t
104 .limit_reroute_chain
105 .split(',')
106 .filter(|entry| !entry.is_empty())
107 .count();
108 let network_retry_count = usize::try_from(t.network_retries).unwrap_or(0);
109 chain_retry_count.max(network_retry_count)
110 })
111}
112
113pub async fn wait_for_fallback_backoff(
116 task_id: &str,
117 store: &Option<Arc<TaskStore>>,
118 repo: &str,
119) -> u64 {
120 let task = store::opt_store_get_task(store, repo, task_id).await;
121
122 let retry_count = fallback_retry_count(task.as_ref());
125
126 let delay = calculate_backoff_delay(retry_count);
127
128 if delay > 0 {
129 tracing::debug!(
130 task_id,
131 retry_count = retry_count,
132 delay_ms = delay,
133 "fallback backoff: waiting before retry"
134 );
135 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
136 }
137
138 delay
139}
140
141#[derive(Debug, Clone)]
146pub enum WeightSignal {
147 Success { agent: String },
149 RateLimited { agent: String },
151 Blocked,
153 Rerouted,
156 None,
158}
159
160pub async fn read_output_file(task_id: &str, primary_path: &Path, repo: &str) -> String {
165 if let Some(content) = tokio::task::spawn_blocking({
167 let p = primary_path.to_path_buf();
168 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
169 })
170 .await
171 .ok()
172 .and_then(std::result::Result::ok)
173 && !content.is_empty()
174 {
175 return content;
176 }
177
178 if let Ok(task_dir) = crate::home::task_dir_async(repo, task_id).await {
180 let attempts_dir = task_dir.join("attempts");
181 if tokio::fs::metadata(&attempts_dir)
183 .await
184 .is_ok_and(|m| m.is_dir())
185 {
186 let mut attempt_nums: Vec<u32> = tokio::task::spawn_blocking({
187 let a = attempts_dir.clone();
188 move || {
189 match std::fs::read_dir(&a) {
190 Ok(entries) => {
191 let mut nums = Vec::new();
192 for entry in entries {
193 match entry {
194 Ok(e) => {
195 if let Some(n) =
196 e.file_name().to_str().and_then(|n| n.parse().ok())
197 {
198 nums.push(n);
199 }
200 }
201 Err(e) => tracing::warn!(
202 path = %a.display(),
203 err = %e,
204 "error reading attempt dir entry in output fallback"
205 ),
206 }
207 }
208 nums
209 }
210 Err(e) => {
211 tracing::warn!(path = %a.display(), err = %e, "failed to read attempts dir in output fallback");
212 Vec::new()
213 }
214 }
215 }
216 })
217 .await
218 .unwrap_or_default();
219
220 attempt_nums.sort_unstable_by(|a, b| b.cmp(a)); for n in attempt_nums {
222 let p = attempts_dir.join(n.to_string()).join("output.json");
223 if let Some(content) = tokio::task::spawn_blocking({
224 let p = p.clone();
225 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
226 })
227 .await
228 .ok()
229 .and_then(std::result::Result::ok)
230 && !content.is_empty()
231 {
232 tracing::info!(task_id, path = %p.display(), "read output from attempt dir");
233 return content;
234 }
235 }
236 }
237 }
238
239 let state_dir = crate::home::state_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
241
242 let mut fallbacks = vec![
243 PathBuf::from(format!("/tmp/output-{task_id}.json")),
244 state_dir.join(format!("output-{task_id}.json")),
245 ];
246
247 if let Ok(legacy_path) = crate::home::state_file(&format!("output-{task_id}.json"))
248 && !fallbacks.contains(&legacy_path)
249 {
250 fallbacks.push(legacy_path);
251 }
252
253 for path in &fallbacks {
254 if let Some(content) = tokio::task::spawn_blocking({
255 let p = path.clone();
256 move || read_file_with_limit(p, MAX_OUTPUT_SIZE_BYTES)
257 })
258 .await
259 .ok()
260 .and_then(std::result::Result::ok)
261 && !content.is_empty()
262 {
263 tracing::info!(task_id, path = %path.display(), "read output from legacy fallback");
264 return content;
265 }
266 }
267
268 String::new()
269}
270
271pub use crate::engine::cooldown::{
273 clear_expired_cooldowns, is_agent_in_cooldown, is_model_in_cooldown,
274 record_agent_failure_with_message, record_model_failure, set_agent_cooldown,
275};
276
277pub fn pick_fallback_agent(
279 current_agent: &str,
280 chain: &str,
281 available_agents: &[String],
282) -> Option<String> {
283 clear_expired_cooldowns();
285
286 let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
287 std::collections::HashSet::new()
288 } else {
289 chain.split(',').collect()
290 };
291
292 for agent in available_agents {
296 if agent == current_agent || chain_set.contains(agent.as_str()) {
297 continue;
298 }
299 if is_agent_in_cooldown(agent) {
300 continue;
301 }
302 return Some(agent.clone());
309 }
310
311 None
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum RetryableError {
317 Timeout,
319 Interrupted,
321 UsageLimit,
323 AuthError,
325 Failed,
327 MissingTooling,
329 ModelUnavailable,
333}
334
335impl RetryableError {
336 pub fn type_str(self) -> &'static str {
339 match self {
340 RetryableError::Timeout => "timeout",
341 RetryableError::Interrupted => "interrupted",
342 RetryableError::UsageLimit => "rate_limit",
343 RetryableError::AuthError => "auth_error",
344 RetryableError::ModelUnavailable => "model_unavailable",
345 RetryableError::Failed | RetryableError::MissingTooling => "failed",
346 }
347 }
348}
349
350pub async fn handle_failover(
359 task_id: &str,
360 agent_name: &str,
361 error_type: RetryableError,
362 error_message: &str,
363 credit_recorded: bool,
364 store: &Option<Arc<TaskStore>>,
365 repo: &str,
366) -> String {
367 let chain = get_reroute_chain(task_id, store, repo).await;
369 let chain = update_reroute_chain(task_id, agent_name, &chain, store, repo).await;
370
371 let available: Vec<String> = crate::engine::configured_agents()
373 .into_iter()
374 .filter(|a| crate::cmd_cache::command_exists(a))
375 .collect();
376
377 let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
379 std::collections::HashSet::new()
380 } else {
381 chain.split(',').collect()
382 };
383 let is_exhausted = available.iter().all(|a| chain_set.contains(a.as_str()));
384
385 if is_exhausted {
386 let is_retryable = matches!(
392 error_type,
393 RetryableError::UsageLimit
394 | RetryableError::AuthError
395 | RetryableError::ModelUnavailable
396 | RetryableError::Timeout
397 | RetryableError::Interrupted
398 );
399 let next_status = if is_retryable { "new" } else { "blocked" };
400
401 tracing::warn!(
402 task_id,
403 agent = agent_name,
404 error_type = ?error_type,
405 next_status,
406 "all agents exhausted"
407 );
408
409 let mut store_fields: Vec<(&str, serde_json::Value)> = Vec::new();
410
411 if is_retryable {
412 let msg = format!("{error_message} (all agents exhausted, will retry when available)");
415 store_fields.push(("last_error", serde_json::json!(msg)));
416 store_fields.push(("limit_reroute_chain", serde_json::json!("")));
417 } else {
418 let msg = format!("{error_message} (all agents exhausted)");
419 let block_reason = format!(
420 "all agents exhausted: {} — {}",
421 error_type.type_str(),
422 error_message
423 );
424 store_fields.push(("last_error", serde_json::json!(msg)));
425 store_fields.push(("block_reason", serde_json::json!(block_reason)));
426 }
427
428 if let Err(e) = store::store_set_result(store, repo, task_id, &store_fields).await {
429 tracing::warn!(task_id, err = %e, "failed to write agents_exhausted fields to store");
430 }
431
432 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 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 if !matches!(
468 error_type,
469 RetryableError::MissingTooling
470 | RetryableError::ModelUnavailable
471 | RetryableError::Timeout
472 | RetryableError::Interrupted
473 ) {
474 if !credit_recorded {
477 record_agent_failure_with_message(agent_name, error_message).await;
478 }
479
480 set_agent_cooldown(agent_name, 120).await;
485 }
486
487 let msg = format!("{error_message}, clearing agent/model for re-route");
488 if let Err(e) = store::store_set_result(
489 store,
490 repo,
491 task_id,
492 &[
493 ("agent", serde_json::json!("")),
494 ("model", serde_json::json!("")),
495 ("last_error", serde_json::json!(msg)),
496 ],
497 )
498 .await
499 {
500 tracing::warn!(task_id, err = %e, "failed to write agent/model clear to store");
501 }
502
503 wait_for_fallback_backoff(task_id, store, repo).await;
509
510 let details = serde_json::json!({
512 "failure_reason": error_type.type_str(),
513 "error_message": error_message,
514 "from_agent": agent_name,
515 "to_agent": next,
516 "chain": chain,
517 });
518 crate::store::store_log_activity(
519 store,
520 repo,
521 task_id,
522 "rerouted",
523 Some("in_progress"),
524 Some("new"),
525 Some(&next),
526 None::<&str>,
527 Some(&details),
528 )
529 .await;
530
531 return "new".to_string();
532 }
533
534 tracing::warn!(task_id, agent = agent_name, "no fallback agents available");
536
537 if !matches!(
539 error_type,
540 RetryableError::MissingTooling
541 | RetryableError::ModelUnavailable
542 | RetryableError::Timeout
543 | RetryableError::Interrupted
544 ) && !credit_recorded
545 {
546 record_agent_failure_with_message(agent_name, error_message).await;
547 }
548
549 let msg = format!("{error_message}, no fallback agents");
550 if let Err(e) = store::store_set_result(
551 store,
552 repo,
553 task_id,
554 &[("last_error", serde_json::json!(msg))],
555 )
556 .await
557 {
558 tracing::warn!(task_id, err = %e, "failed to write no_fallback last_error to store");
559 }
560
561 let details = serde_json::json!({
563 "failure_reason": error_type.type_str(),
564 "error_message": error_message,
565 "chain": chain,
566 "no_fallback_available": true,
567 });
568 crate::store::store_log_activity(
569 store,
570 repo,
571 task_id,
572 "rerouted",
573 Some("in_progress"),
574 Some("needs_review"),
575 None::<&str>,
576 None::<&str>,
577 Some(&details),
578 )
579 .await;
580
581 "needs_review".to_string()
582}
583
584pub async fn get_reroute_chain(
586 task_id: &str,
587 store: &Option<Arc<TaskStore>>,
588 repo: &str,
589) -> String {
590 store::opt_store_get_task(store, repo, task_id)
591 .await
592 .map(|t| t.limit_reroute_chain)
593 .unwrap_or_default()
594 .trim()
595 .to_string()
596}
597
598pub async fn update_reroute_chain(
600 task_id: &str,
601 current_agent: &str,
602 existing_chain: &str,
603 store: &Option<Arc<TaskStore>>,
604 repo: &str,
605) -> String {
606 let mut chain = existing_chain.to_string();
607 if chain.is_empty() {
608 chain = current_agent.to_string();
609 } else if !chain.split(',').any(|a| a == current_agent) {
610 chain = format!("{chain},{current_agent}");
611 }
612
613 if let Err(e) = store::store_set_result(
614 store,
615 repo,
616 task_id,
617 &[("limit_reroute_chain", serde_json::json!(chain))],
618 )
619 .await
620 {
621 tracing::warn!(task_id, err = %e, "failed to write limit_reroute_chain to store");
622 }
623 chain
624}
625
626#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
628pub struct ReviewResponse {
629 pub decision: String,
631 pub notes: String,
633 pub test_results: Option<String>,
635 pub issues: Vec<ReviewIssue>,
637}
638
639#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
641pub struct ReviewIssue {
642 pub file: String,
644 pub line: Option<u32>,
646 pub severity: String,
648 pub description: String,
650}
651
652pub fn parse_review_response(text: &str) -> anyhow::Result<ReviewResponse> {
658 let resp = parse_review_json(text)?;
659 if matches!(resp.decision.as_str(), "approve" | "request_changes") {
660 return Ok(resp);
661 }
662
663 let stripped = text.replace("approve|request_changes", "");
666 infer_review_response_from_text(&stripped)
667 .ok_or_else(|| anyhow::anyhow!("invalid review decision: {}", resp.decision))
668}
669
670fn parse_review_json(text: &str) -> anyhow::Result<ReviewResponse> {
671 if let Ok(resp) = serde_json::from_str::<ReviewResponse>(text) {
673 return Ok(resp);
674 }
675
676 if let Some(json_str) = extract_json_block(text)
678 && let Ok(resp) = serde_json::from_str::<ReviewResponse>(&json_str)
679 {
680 return Ok(resp);
681 }
682
683 if let Some(resp) = extract_review_response_object(text) {
684 return Ok(resp);
685 }
686
687 anyhow::bail!("failed to parse review response")
688}
689
690fn infer_review_response_from_text(text: &str) -> Option<ReviewResponse> {
691 let lower = text.to_ascii_lowercase();
692 let has_changes_requested =
693 lower.contains("changes requested") || lower.contains("request_changes");
694 if has_changes_requested {
695 return Some(ReviewResponse {
696 decision: "request_changes".to_string(),
697 notes: "Inferred decision from plain-text review output.".to_string(),
698 test_results: None,
699 issues: Vec::new(),
700 });
701 }
702
703 let positive_approval = lower
706 .split(|c: char| !c.is_ascii_alphabetic())
707 .any(|word| word == "lgtm")
708 || lower.contains("decision is `approve`")
709 || lower.contains("decision: `approve`")
710 || lower.contains("decision is approve")
711 || lower.contains("decision: approve");
712 if positive_approval {
713 return Some(ReviewResponse {
714 decision: "approve".to_string(),
715 notes: "Inferred decision from plain-text review output.".to_string(),
716 test_results: None,
717 issues: Vec::new(),
718 });
719 }
720
721 None
722}
723
724pub fn infer_review_response(text: &str) -> Option<ReviewResponse> {
728 infer_review_response_from_text(text)
729}
730
731fn extract_json_block(text: &str) -> Option<String> {
736 let mut search_from = 0;
737 while let Some(start) = text[search_from..].find("```json") {
738 let abs_start = search_from + start;
739 let after_tag = abs_start + "```json".len();
740 let Some(rest) = text[after_tag..].strip_prefix('\n') else {
741 search_from = after_tag;
742 continue;
743 };
744 let content_start = text.len() - rest.len();
745 let Some(end_offset) = rest.find("```") else {
746 search_from = content_start;
747 continue;
748 };
749 let end = content_start + end_offset;
750 let content = text[content_start..end].trim();
751 if content.starts_with('{') {
752 return Some(content.to_string());
753 }
754 search_from = end + 3;
755 }
756 None
757}
758
759fn extract_review_response_object(text: &str) -> Option<ReviewResponse> {
763 let mut start: Option<usize> = None;
764 let mut depth: i32 = 0;
765 let mut in_string = false;
766 let mut escape = false;
767
768 for (idx, ch) in text.char_indices() {
769 if start.is_some() {
770 if in_string {
771 if escape {
772 escape = false;
773 } else if ch == '\\' {
774 escape = true;
775 } else if ch == '"' {
776 in_string = false;
777 }
778 continue;
779 }
780
781 match ch {
782 '"' => in_string = true,
783 '{' => depth += 1,
784 '}' => {
785 depth -= 1;
786 if depth == 0 {
787 debug_assert!(
788 start.is_some(),
789 "start must be Some when depth returns to 0"
790 );
791 let start_idx = start.unwrap_or(0);
792 let candidate = &text[start_idx..=idx];
793 if let Ok(resp) = serde_json::from_str::<ReviewResponse>(candidate) {
794 return Some(resp);
795 }
796 start = None;
797 }
798 }
799 _ => {}
800 }
801 continue;
802 }
803
804 if ch == '{' {
805 start = Some(idx);
806 depth = 1;
807 in_string = false;
808 escape = false;
809 }
810 }
811
812 None
813}
814
815#[expect(
817 clippy::too_many_arguments,
818 reason = "a memory entry records the task, attempt, agent, model and outcome"
819)]
820pub async fn store_learnings_from_response(
821 task_id: &str,
822 attempt: u32,
823 agent: &str,
824 model: Option<&str>,
825 response: &crate::parser::AgentResponse,
826 error: Option<&str>,
827 store: &Option<Arc<TaskStore>>,
828 repo: &str,
829 raw_stdout: &str,
830) {
831 let files_modified = if response.files.is_empty() {
833 let extracted = crate::cli::ndjson::extract_files_changed(raw_stdout);
834 if !extracted.is_empty() {
835 tracing::debug!(
836 task_id,
837 count = extracted.len(),
838 "files_modified: fell back to NDJSON extraction (agent reported none)"
839 );
840 }
841 extracted
842 } else {
843 response.files.clone()
844 };
845
846 let entry = crate::store::MemoryEntry {
848 attempt,
849 agent: agent.to_string(),
850 model: model.map(String::from),
851 learnings: response.learnings.clone(),
852 error: error.map(String::from),
853 files_modified,
854 approach: response.summary.clone(),
855 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
856 };
857
858 if let Some(st) = store
859 && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
860 {
861 match st.append_memory(store_id, &entry).await {
862 Err(e) => {
863 tracing::warn!(task_id, error = ?e, "failed to store memory");
864 }
865 _ => {
866 tracing::debug!(task_id, attempt, "stored memory for attempt");
867 }
868 }
869 }
870}
871
872pub async fn store_failure_memory(
874 task_id: &str,
875 attempt: u32,
876 agent: &str,
877 model: Option<&str>,
878 error: &str,
879 store: &Option<Arc<TaskStore>>,
880 repo: &str,
881) {
882 let entry = crate::store::MemoryEntry {
883 attempt,
884 agent: agent.to_string(),
885 model: model.map(String::from),
886 learnings: vec![],
887 error: Some(error.to_string()),
888 files_modified: vec![],
889 approach: String::new(),
890 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
891 };
892
893 if let Some(st) = store
894 && let Ok(Some(store_id)) = st.resolve_task_id(repo, task_id).await
895 {
896 match st.append_memory(store_id, &entry).await {
897 Err(e) => {
898 tracing::warn!(task_id, error = ?e, "failed to store failure memory");
899 }
900 _ => {
901 tracing::debug!(task_id, attempt, "stored failure memory");
902 }
903 }
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::engine::runner::agents::{AgentError, find_agent_result, patterns};
911 use serial_test::serial;
912
913 #[serial(cooldown_state)]
914 #[test]
915 fn calculate_backoff_delay_jitter_is_centered() {
916 let base = crate::engine::router::config::retry_base_delay_ms();
920 let max = crate::engine::router::config::retry_max_delay_ms();
921
922 let capped = base.min(max);
924 #[expect(
925 clippy::cast_precision_loss,
926 clippy::cast_possible_truncation,
927 clippy::cast_sign_loss,
928 reason = "mirrors the jitter range in calculate_backoff_delay"
929 )]
930 let jitter_range = (capped as f64 * JITTER_FACTOR) as u64;
931 let lo = capped.saturating_sub(jitter_range);
932 let hi = capped + jitter_range;
933
934 for _ in 0..200 {
935 let delay = calculate_backoff_delay(0);
936 assert!(
937 delay >= lo && delay <= hi,
938 "delay {delay} out of expected range [{lo}, {hi}]"
939 );
940 }
941 }
942
943 #[serial(cooldown_state)]
944 #[test]
945 fn fallback_retry_count_uses_larger_network_retry_streak() {
946 let task = crate::store::Task {
947 id: 1,
948 external_id: None,
949 repo: "owner/repo".to_string(),
950 origin: "internal".to_string(),
951 title: "test".to_string(),
952 body: String::new(),
953 status: crate::store::TaskStatus::New,
954 source: String::new(),
955 source_id: String::new(),
956 author: String::new(),
957 url: String::new(),
958 labels: vec![],
959 agent: None,
960 model: None,
961 complexity: "simple".to_string(),
962 estimate: 0,
963 route_reason: String::new(),
964 agent_profile: String::new(),
965 selected_skills: String::new(),
966 route_attempts: 0,
967 attempts: 0,
968 branch: String::new(),
969 worktree: String::new(),
970 worktree_cleaned: false,
971 owner_node: None,
972 summary: String::new(),
973 last_error: String::new(),
974 parent_id: None,
975 block_reason: None,
976 pr_number: None,
977 pr_review_context: String::new(),
978 last_review_ts: String::new(),
979 review_ts_map: "{}".to_string(),
980 last_comment_review_ts: String::new(),
981 merge_conflict_retries: 0,
982 ci_merge_failures: 0,
983 pr_create_failures: 0,
984 push_failures: 0,
985 network_retries: 3,
986 review_agent_failures: 0,
987 review_resets: 0,
988 review_cycles: 0,
989 review_invocations: 0,
990 needs_review_refires: 0,
991 input_tokens: 0,
992 output_tokens: 0,
993 input_cost_usd: 0.0,
994 output_cost_usd: 0.0,
995 total_cost_usd: 0.0,
996 model_reroute_chain: String::new(),
997 limit_reroute_chain: "claude".to_string(),
998 memory: vec![],
999 delegations: vec![],
1000 auto_unblock_count: 0,
1001 auto_unblock_last_at: String::new(),
1002 auto_unblock_last_reason: String::new(),
1003 ci_recovery_count: 0,
1004 no_code_reroutes: 0,
1005 no_code_last_agent: String::new(),
1006 created_at: String::new(),
1007 updated_at: String::new(),
1008 };
1009
1010 assert_eq!(fallback_retry_count(Some(&task)), 3);
1011 }
1012
1013 #[serial(cooldown_state)]
1014 #[test]
1015 fn retryable_type_str_returns_correct_labels() {
1016 assert_eq!(RetryableError::Timeout.type_str(), "timeout");
1017 assert_eq!(RetryableError::Interrupted.type_str(), "interrupted");
1018 assert_eq!(RetryableError::UsageLimit.type_str(), "rate_limit");
1019 assert_eq!(RetryableError::AuthError.type_str(), "auth_error");
1020 assert_eq!(RetryableError::Failed.type_str(), "failed");
1021 assert_eq!(RetryableError::MissingTooling.type_str(), "failed");
1022 }
1023
1024 #[serial(cooldown_state)]
1025 #[test]
1026 fn is_usage_limit_detects_rate_limit() {
1027 assert!(patterns::detect_rate_limit("Error: rate limit exceeded").is_some());
1028 assert!(patterns::detect_rate_limit("HTTP 429 Too Many Requests").is_some());
1029 assert!(patterns::detect_rate_limit("quota exceeded for model").is_some());
1030 assert!(patterns::detect_context_overflow("context_length_exceeded").is_some());
1032 }
1033
1034 #[serial(cooldown_state)]
1035 #[test]
1036 fn is_usage_limit_rejects_normal_text() {
1037 assert!(patterns::detect_rate_limit("task completed successfully").is_none());
1038 assert!(patterns::detect_rate_limit("").is_none());
1039 }
1040
1041 #[serial(cooldown_state)]
1042 #[test]
1043 fn is_auth_error_detects_common_patterns() {
1044 assert!(patterns::detect_auth_error("401 Unauthorized").is_some());
1045 assert!(patterns::detect_auth_error("invalid api key provided").is_some());
1046 assert!(patterns::detect_auth_error("Your billing plan has expired").is_some());
1047 assert!(patterns::detect_auth_error("Error: 403 Forbidden").is_some());
1048 }
1049
1050 #[serial(cooldown_state)]
1051 #[test]
1052 fn is_auth_error_rejects_normal_text() {
1053 assert!(patterns::detect_auth_error("task completed successfully").is_none());
1054 assert!(patterns::detect_auth_error("").is_none());
1055 }
1056
1057 #[serial(cooldown_state)]
1058 #[test]
1059 fn detect_missing_tooling_finds_known_tools() {
1060 let result = patterns::detect_missing_tool("bun: command not found");
1061 assert!(result.is_some());
1062 if let AgentError::MissingTool { tool } = result.unwrap() {
1063 assert_eq!(tool, "bun");
1064 } else {
1065 panic!("expected MissingTool error");
1066 }
1067
1068 let result = patterns::detect_missing_tool("env: anchor: no such file");
1069 assert!(result.is_some());
1070 if let AgentError::MissingTool { tool } = result.unwrap() {
1071 assert_eq!(tool, "anchor");
1072 } else {
1073 panic!("expected MissingTool error");
1074 }
1075
1076 let result = patterns::detect_missing_tool("spawn docker enoent");
1077 assert!(result.is_some());
1078 if let AgentError::MissingTool { tool } = result.unwrap() {
1079 assert_eq!(tool, "docker");
1080 } else {
1081 panic!("expected MissingTool error");
1082 }
1083 }
1084
1085 #[serial(cooldown_state)]
1086 #[test]
1087 fn detect_missing_tooling_returns_none_for_normal() {
1088 assert!(patterns::detect_missing_tool("everything works fine").is_none());
1089 assert!(patterns::detect_missing_tool("").is_none());
1090 }
1091
1092 #[serial(cooldown_state)]
1095 #[tokio::test]
1096 async fn record_and_check_agent_cooldown() {
1097 crate::engine::cooldown::reset_global_state().await;
1098 let agent = "test_cooldown_agent_1";
1100 assert!(!is_agent_in_cooldown(agent));
1101 record_agent_failure_with_message(agent, "").await;
1102 assert!(is_agent_in_cooldown(agent));
1103 }
1104
1105 #[serial(cooldown_state)]
1106 #[tokio::test]
1107 async fn record_and_check_model_cooldown() {
1108 crate::engine::cooldown::reset_global_state().await;
1109 let agent = "test_cooldown_agent_2";
1110 let model = "test_model_x";
1111 assert!(!is_model_in_cooldown(agent, model));
1112 record_model_failure(agent, model).await;
1113 assert!(is_model_in_cooldown(agent, model));
1114 assert!(!is_model_in_cooldown(agent, "other_model"));
1116 }
1117
1118 #[serial(cooldown_state)]
1119 #[tokio::test]
1120 async fn clear_expired_does_not_remove_fresh_entries() {
1121 crate::engine::cooldown::reset_global_state().await;
1122 let agent = "test_cooldown_agent_3";
1123 record_agent_failure_with_message(agent, "").await;
1124 clear_expired_cooldowns();
1125 assert!(is_agent_in_cooldown(agent));
1127 }
1128
1129 #[serial(cooldown_state)]
1131 #[test]
1132 fn pick_fallback_skips_current_agent() {
1133 let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1134 let result = pick_fallback_agent("test_agent_a", "", &available);
1135 assert_eq!(result, Some("test_agent_b".to_string()));
1136 }
1137
1138 #[serial(cooldown_state)]
1139 #[test]
1140 fn pick_fallback_skips_chain_agents() {
1141 let available = vec![
1142 "test_agent_a".to_string(),
1143 "test_agent_b".to_string(),
1144 "test_agent_c".to_string(),
1145 ];
1146 let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1147 assert_eq!(result, Some("test_agent_c".to_string()));
1148 }
1149
1150 #[serial(cooldown_state)]
1151 #[test]
1152 fn pick_fallback_returns_none_when_exhausted() {
1153 let available = vec!["test_agent_a".to_string(), "test_agent_b".to_string()];
1154 let result = pick_fallback_agent("test_agent_a", "test_agent_a,test_agent_b", &available);
1155 assert!(result.is_none());
1156 }
1157
1158 #[serial(cooldown_state)]
1159 #[test]
1160 fn weight_signal_variants() {
1161 let success = WeightSignal::Success {
1162 agent: "claude".to_string(),
1163 };
1164 let limited = WeightSignal::RateLimited {
1165 agent: "codex".to_string(),
1166 };
1167 let none = WeightSignal::None;
1168
1169 assert!(format!("{success:?}").contains("claude"));
1171 assert!(format!("{limited:?}").contains("codex"));
1172 assert!(format!("{none:?}").contains("None"));
1173 }
1174
1175 #[serial(cooldown_state)]
1176 #[test]
1177 fn parse_review_response_direct_json() {
1178 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1179 let resp = parse_review_response(json).unwrap();
1180 assert_eq!(resp.decision, "approve");
1181 assert_eq!(resp.notes, "LGTM");
1182 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1183 }
1184
1185 #[serial(cooldown_state)]
1186 #[test]
1187 fn parse_review_response_from_markdown() {
1188 let md = r#"Here is my review:
1189
1190```json
1191{"decision":"request_changes","notes":"Fix the bug","issues":[{"file":"src/main.rs","line":10,"severity":"error","description":"null deref"}]}
1192```
1193
1194That's all."#;
1195 let resp = parse_review_response(md).unwrap();
1196 assert_eq!(resp.decision, "request_changes");
1197 assert_eq!(resp.issues.len(), 1);
1198 assert_eq!(resp.issues[0].file, "src/main.rs");
1199 }
1200
1201 #[serial(cooldown_state)]
1202 #[test]
1203 fn parse_review_response_embedded_json_object() {
1204 let text = "Review complete.\n\n{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}\nThanks!";
1205 let resp = parse_review_response(text).unwrap();
1206 assert_eq!(resp.decision, "approve");
1207 assert_eq!(resp.notes, "LGTM");
1208 }
1209
1210 #[serial(cooldown_state)]
1211 #[test]
1212 fn parse_review_response_template_echo_infers_from_notes() {
1213 let json = r#"{"decision":"approve|request_changes","notes":"LGTM","issues":[]}"#;
1214 assert_eq!(parse_review_response(json).unwrap().decision, "approve");
1215
1216 let no_signal = r#"{"decision":"approve|request_changes","notes":"hmm","issues":[]}"#;
1217 assert!(parse_review_response(no_signal).is_err());
1218 }
1219
1220 #[serial(cooldown_state)]
1221 #[test]
1222 fn parse_review_response_invalid() {
1223 let result = parse_review_response("not json at all");
1224 assert!(result.is_err());
1225 }
1226
1227 #[serial(cooldown_state)]
1228 #[test]
1229 fn extract_json_block_basic() {
1230 let md = "text\n```json\n{\"key\": \"value\"}\n```\nmore";
1231 let result = extract_json_block(md);
1232 assert!(result.is_some());
1233 assert!(result.unwrap().contains("\"key\""));
1234 }
1235
1236 #[serial(cooldown_state)]
1237 #[test]
1238 fn extract_json_block_skips_non_json_blocks() {
1239 let md = "```json\nnot-json-array\n```\n\n```json\n{\"real\": true}\n```";
1240 let result = extract_json_block(md);
1241 assert!(result.is_some());
1242 assert!(result.unwrap().contains("\"real\""));
1243 }
1244
1245 #[serial(cooldown_state)]
1246 #[test]
1247 fn extract_json_block_none_when_missing() {
1248 assert!(extract_json_block("no code blocks here").is_none());
1249 }
1250
1251 #[serial(cooldown_state)]
1252 #[test]
1253 fn extract_json_block_skips_malformed_intermediate_blocks() {
1254 let md = "prefix ```json{\"broken\": true}\n\n```json\n{\"real\": true}\n```";
1255 let result = extract_json_block(md);
1256 assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1257 }
1258
1259 #[serial(cooldown_state)]
1260 #[test]
1261 fn extract_json_block_skips_multiple_malformed_intermediate_blocks() {
1262 let md = "prefix ```json{\"broken\": true}\nmid ```json[1, 2, 3]\n\n```json\n{\"real\": true}\n```";
1263 let result = extract_json_block(md);
1264 assert_eq!(result.as_deref(), Some("{\"real\": true}"));
1265 }
1266
1267 fn parse_review_plain(text: &str) -> anyhow::Result<ReviewResponse> {
1272 parse_review_response(text).or_else(|_| {
1273 infer_review_response(text)
1274 .ok_or_else(|| anyhow::anyhow!("failed to parse review response"))
1275 })
1276 }
1277
1278 #[serial(cooldown_state)]
1279 #[test]
1280 fn parse_review_from_output_direct_json() {
1281 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1282 let resp = parse_review_response(json).unwrap();
1283 assert_eq!(resp.decision, "approve");
1284 }
1285
1286 #[serial(cooldown_state)]
1287 #[test]
1288 fn parse_review_from_output_markdown() {
1289 let md = "Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}\n```\n";
1290 let resp = parse_review_response(md).unwrap();
1291 assert_eq!(resp.decision, "request_changes");
1292 }
1293
1294 #[serial(cooldown_state)]
1295 #[test]
1296 fn parse_review_from_output_plain_text_fallback() {
1297 let text = "The review is done. LGTM.";
1298 let resp = parse_review_plain(text).unwrap();
1299 assert_eq!(resp.decision, "approve");
1300 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1301 }
1302
1303 #[serial(cooldown_state)]
1305 #[test]
1306 fn parse_review_from_output_ndjson_part_format() {
1307 let ndjson = concat!(
1308 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1309 "\n",
1310 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Reviewing the PR...\n\n```json\n{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1311 "\n",
1312 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop"}}"#,
1313 );
1314 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1315 assert_eq!(resp.decision, "approve");
1316 assert_eq!(resp.notes, "All checks pass");
1317 }
1318
1319 #[serial(cooldown_state)]
1321 #[test]
1322 fn parse_review_from_output_ndjson_direct_text_format() {
1323 let ndjson = concat!(
1324 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1325 "\n",
1326 r#"{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"Here is my review:\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the bug\",\"test_results\":\"fail\",\"issues\":[]}\n```\n"}"#,
1327 "\n",
1328 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1329 );
1330 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1331 assert_eq!(resp.decision, "request_changes");
1332 assert_eq!(resp.notes, "Fix the bug");
1333 }
1334
1335 #[serial(cooldown_state)]
1337 #[test]
1338 fn parse_review_from_output_ndjson_direct_json_in_text_event() {
1339 let ndjson = concat!(
1340 r#"{"type":"step_start","timestamp":1000}"#,
1341 "\n",
1342 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1343 "\n",
1344 r#"{"type":"step_finish","timestamp":1002}"#,
1345 );
1346 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1347 assert_eq!(resp.decision, "approve");
1348 }
1349
1350 #[serial(cooldown_state)]
1351 #[test]
1352 fn parse_review_from_output_ndjson_prefers_final_json_text_event() {
1353 let ndjson = concat!(
1354 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on it..."}}"#,
1355 "\n",
1356 r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1357 );
1358 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1359 assert_eq!(resp.decision, "approve");
1360 assert_eq!(resp.notes, "LGTM");
1361 }
1362
1363 #[serial(cooldown_state)]
1365 #[test]
1366 fn parse_review_from_output_ndjson_concatenated_text() {
1367 let ndjson = concat!(
1368 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Here is my\n"}}"#,
1369 "\n",
1370 r#"{"type":"text","timestamp":1002,"part":{"type":"text","text":"review:\n\n```json\n{\"decision\":\"approve\",\"notes\":\"OK\",\"test_results\":\"pass\",\"issues\":[]}\n```\n"}}"#,
1371 );
1372 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1373 assert_eq!(resp.decision, "approve");
1374 }
1375
1376 #[serial(cooldown_state)]
1381 #[test]
1382 fn parse_review_from_output_codex_agent_message() {
1383 let ndjson = concat!(
1384 r#"{"type":"thread.started","thread_id":"t1"}"#,
1385 "\n",
1386 r#"{"type":"turn.started"}"#,
1387 "\n",
1388 r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Planning git commands..."}}"#,
1389 "\n",
1390 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"git log --oneline -5","output":"abc123 fix: something"}}"#,
1391 "\n",
1392 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All checks pass\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1393 "\n",
1394 r#"{"type":"turn.completed"}"#,
1395 );
1396 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1397 assert_eq!(resp.decision, "approve");
1398 assert_eq!(resp.notes, "All checks pass");
1399 }
1400
1401 #[serial(cooldown_state)]
1403 #[test]
1404 fn parse_review_from_output_codex_agent_message_markdown() {
1405 let ndjson = concat!(
1406 r#"{"type":"thread.started","thread_id":"t1"}"#,
1407 "\n",
1408 r#"{"type":"turn.started"}"#,
1409 "\n",
1410 r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Reviewing..."}}"#,
1411 "\n",
1412 r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Review complete.\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the tests\",\"issues\":[]}\n```\n"}}"#,
1413 "\n",
1414 r#"{"type":"turn.completed"}"#,
1415 );
1416 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1417 assert_eq!(resp.decision, "request_changes");
1418 assert_eq!(resp.notes, "Fix the tests");
1419 }
1420
1421 #[serial(cooldown_state)]
1423 #[test]
1424 fn codex_skips_reasoning_items() {
1425 let ndjson = concat!(
1426 r#"{"type":"item.completed","item":{"type":"reasoning","text":"internal thoughts..."}}"#,
1427 "\n",
1428 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}}"#,
1429 );
1430 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1431 assert_eq!(resp.decision, "approve");
1432 assert_eq!(resp.notes, "LGTM");
1433 }
1434
1435 #[serial(cooldown_state)]
1437 #[test]
1438 fn infer_review_response_negation_not_approved() {
1439 assert!(
1440 parse_review_plain("The changes were not approved.").is_err(),
1441 "\"not approved\" should not infer approval"
1442 );
1443 }
1444
1445 #[serial(cooldown_state)]
1446 #[test]
1447 fn infer_review_response_negation_unapproved() {
1448 assert!(
1449 parse_review_plain("Unapproved changes found in the diff.").is_err(),
1450 "\"unapproved\" should not infer approval"
1451 );
1452 }
1453
1454 #[serial(cooldown_state)]
1455 #[test]
1456 fn infer_review_response_negation_not_approving() {
1457 assert!(
1458 parse_review_plain("I am not approving this PR.").is_err(),
1459 "\"not approving\" should not infer approval"
1460 );
1461 }
1462
1463 #[serial(cooldown_state)]
1464 #[test]
1465 fn infer_review_response_negation_cannot_be_approved() {
1466 assert!(
1467 parse_review_plain("This PR cannot be approved until the race in tick.rs is fixed.")
1468 .is_err(),
1469 "\"cannot be approved\" should not infer approval"
1470 );
1471 }
1472
1473 #[serial(cooldown_state)]
1474 #[test]
1475 fn infer_review_response_negation_should_not_be_approved() {
1476 assert!(
1477 parse_review_plain("The diff breaks the build and should not be approved.").is_err(),
1478 "\"should not be approved\" should not infer approval"
1479 );
1480 }
1481
1482 #[serial(cooldown_state)]
1483 #[test]
1484 fn infer_review_response_negation_not_yet_approved() {
1485 assert!(
1486 parse_review_plain("This change is not yet approved.").is_err(),
1487 "\"not yet approved\" should not infer approval"
1488 );
1489 }
1490
1491 #[serial(cooldown_state)]
1492 #[test]
1493 fn infer_review_response_negation_wont_approve() {
1494 assert!(
1495 parse_review_plain("I won't approve this until tests cover the retry path.").is_err(),
1496 "\"won't approve\" should not infer approval"
1497 );
1498 }
1499
1500 #[serial(cooldown_state)]
1501 #[test]
1502 fn infer_review_response_bare_approved() {
1503 assert!(
1504 parse_review_plain("The PR was approved.").is_err(),
1505 "bare \"approved\" should not infer approval"
1506 );
1507 }
1508
1509 #[serial(cooldown_state)]
1511 #[test]
1512 fn infer_review_response_lgtm() {
1513 let resp = parse_review_plain("LGTM, everything looks fine.").unwrap();
1514 assert_eq!(resp.decision, "approve");
1515 }
1516
1517 #[serial(cooldown_state)]
1519 #[test]
1520 fn infer_review_response_looks_good() {
1521 assert!(
1522 parse_review_plain("Looks good to me!").is_err(),
1523 "\"looks good\" should not infer approval"
1524 );
1525 }
1526
1527 #[serial(cooldown_state)]
1528 #[test]
1529 fn infer_review_response_all_checks_passed() {
1530 assert!(
1531 parse_review_plain(
1532 "The background task completed but I already got the CI results directly. All checks passed — no action needed.",
1533 )
1534 .is_err(),
1535 "\"all checks passed\" should not infer approval"
1536 );
1537 }
1538
1539 #[serial(cooldown_state)]
1540 #[test]
1541 fn infer_review_response_checks_passed() {
1542 assert!(
1543 parse_review_plain("All checks passed (fmt ✓, clippy ✓, tests ✓).").is_err(),
1544 "\"all checks passed\" should not infer approval"
1545 );
1546 }
1547
1548 #[serial(cooldown_state)]
1549 #[test]
1550 fn infer_review_response_all_tests_passed() {
1551 assert!(
1552 parse_review_plain("All tests passed and the implementation looks correct.").is_err(),
1553 "\"all tests passed\" should not infer approval"
1554 );
1555 }
1556
1557 #[serial(cooldown_state)]
1558 #[test]
1559 fn infer_review_response_no_issues_found() {
1560 assert!(
1561 parse_review_plain("Review complete. No issues found in the diff.").is_err(),
1562 "\"no issues found\" should not infer approval"
1563 );
1564 }
1565
1566 #[serial(cooldown_state)]
1568 #[test]
1569 fn infer_review_response_partial_tests_passed_no_approve() {
1570 let text = "The existing tests passed before this change but the new payment logic has a null-pointer risk on line 42.";
1571 assert!(parse_review_plain(text).is_err());
1572 }
1573
1574 #[serial(cooldown_state)]
1576 #[test]
1577 fn infer_review_response_partial_checks_passed_no_approve() {
1578 let text = "CI checks passed for the base branch but this PR adds a broken path.";
1579 assert!(parse_review_plain(text).is_err());
1580 }
1581
1582 #[serial(cooldown_state)]
1584 #[test]
1585 fn infer_review_response_decision_is_approve_backtick() {
1586 let text = "Already retrieved the output and completed the review above. The CI test check passed (3m11s) and all other checks are green. Decision is `approve`.";
1587 let resp = parse_review_plain(text).unwrap();
1588 assert_eq!(resp.decision, "approve");
1589 }
1590
1591 #[serial(cooldown_state)]
1593 #[test]
1594 fn infer_review_response_decision_colon_approve() {
1595 let text = "Reviewed the PR. No blocking issues found. Decision: approve";
1596 let resp = parse_review_plain(text).unwrap();
1597 assert_eq!(resp.decision, "approve");
1598 }
1599
1600 #[serial(cooldown_state)]
1603 #[test]
1604 fn infer_review_response_negation_do_not_approve_backtick() {
1605 let text = "There are unresolved issues — do not `approve` this PR until they are fixed.";
1606 assert!(
1607 parse_review_plain(text).is_err(),
1608 "negative context with backtick approve must not infer approval"
1609 );
1610 }
1611
1612 fn parse_via_agent_path(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1617 use crate::engine::runner::agents::find_agent_result;
1618 let text = find_agent_result(agent, output)
1619 .map(|r| r.result_text)
1620 .filter(|t| !t.is_empty())
1621 .unwrap_or_else(|| output.to_string());
1622 if let Ok(r) = parse_review_response(&text) {
1623 return Ok(r);
1624 }
1625 infer_review_response_from_text(&text)
1626 .ok_or_else(|| anyhow::anyhow!("failed to parse review response from {agent} output"))
1627 }
1628
1629 #[serial(cooldown_state)]
1630 #[test]
1631 fn agent_output_claude_ndjson_review() {
1632 let ndjson = concat!(
1633 r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1634 "\n",
1635 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1636 "\n",
1637 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1638 );
1639 let resp = parse_via_agent_path("claude", ndjson).unwrap();
1640 assert_eq!(resp.decision, "approve");
1641 }
1642
1643 #[serial(cooldown_state)]
1644 #[test]
1645 fn agent_output_opencode_ndjson_review() {
1646 let ndjson = concat!(
1647 r#"{"type":"step_start","timestamp":1000}"#,
1648 "\n",
1649 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"request_changes\",\"notes\":\"Fix it\",\"issues\":[]}"}}"#,
1650 "\n",
1651 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
1652 );
1653 let resp = parse_via_agent_path("opencode", ndjson).unwrap();
1654 assert_eq!(resp.decision, "request_changes");
1655 }
1656
1657 #[serial(cooldown_state)]
1658 #[test]
1659 fn agent_output_codex_ndjson_review() {
1660 let ndjson = concat!(
1661 r#"{"type":"thread.started","thread_id":"t1"}"#,
1662 "\n",
1663 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"All good\",\"issues\":[]}"}}"#,
1664 "\n",
1665 r#"{"type":"turn.completed"}"#,
1666 );
1667 let resp = parse_via_agent_path("codex", ndjson).unwrap();
1668 assert_eq!(resp.decision, "approve");
1669 }
1670
1671 #[serial(cooldown_state)]
1672 #[test]
1673 fn agent_output_plain_text_keyword_fallback() {
1674 let text = "All checks passed, LGTM.";
1675 let resp = parse_via_agent_path("claude", text).unwrap();
1676 assert_eq!(resp.decision, "approve");
1677 }
1678
1679 #[serial(cooldown_state)]
1680 #[test]
1681 fn agent_output_direct_json() {
1682 let json = r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#;
1683 let resp = parse_via_agent_path("opencode", json).unwrap();
1684 assert_eq!(resp.decision, "approve");
1685 }
1686
1687 #[serial(cooldown_state)]
1691 #[test]
1692 fn agent_output_text_with_json_fragment_no_false_positive() {
1693 let text = r#"I checked the file and found {"type":"text"} in the output. No issues."#;
1694 let result = parse_via_agent_path("claude", text);
1698 assert!(
1700 result.is_err() || result.as_ref().is_ok_and(|r| r.decision != "text"),
1701 "should not extract review from embedded JSON fragment"
1702 );
1703 }
1704
1705 #[serial(cooldown_state)]
1708 #[test]
1709 fn parse_review_response_ignores_prose_with_decision_word() {
1710 let text = r#"The decision was made. Here is a debug trace: {"trace":123}"#;
1711 assert!(
1712 parse_review_response(text).is_err(),
1713 "prose with 'decision' word and unrelated JSON should not parse as ReviewResponse"
1714 );
1715 }
1716
1717 #[serial(cooldown_state)]
1720 #[test]
1721 fn parse_review_response_ignores_embedded_json_without_decision_key() {
1722 let text = r#"Analysis complete. Config: {"timeout":30,"retries":3}. Done."#;
1723 assert!(
1724 parse_review_response(text).is_err(),
1725 "embedded JSON without 'decision' key should not parse as ReviewResponse"
1726 );
1727 }
1728
1729 #[serial(cooldown_state)]
1732 #[test]
1733 fn read_file_with_limit_reads_small_file_completely() {
1734 let temp_dir = std::env::temp_dir();
1735 let file_path = temp_dir.join(format!("orch_test_small_{}.txt", std::process::id()));
1736 let content = "Hello, World!";
1737 std::fs::write(&file_path, content).unwrap();
1738
1739 let result = read_file_with_limit(&file_path, 1024);
1740 assert!(result.is_ok());
1741 assert_eq!(result.unwrap(), content);
1742
1743 let _ = std::fs::remove_file(&file_path);
1744 }
1745
1746 #[serial(cooldown_state)]
1747 #[test]
1748 fn read_file_with_limit_truncates_large_file() {
1749 let temp_dir = std::env::temp_dir();
1750 let file_path = temp_dir.join(format!("orch_test_large_{}.txt", std::process::id()));
1751
1752 let large_content = "x".repeat(200);
1754 std::fs::write(&file_path, &large_content).unwrap();
1755
1756 let result = read_file_with_limit(&file_path, 50);
1758 assert!(result.is_ok());
1759
1760 let read_content = result.unwrap();
1761 assert_eq!(read_content.len(), 50);
1763 assert_eq!(read_content, "x".repeat(50));
1764
1765 let _ = std::fs::remove_file(&file_path);
1766 }
1767
1768 #[serial(cooldown_state)]
1769 #[test]
1770 fn read_file_with_limit_handles_empty_file() {
1771 let temp_dir = std::env::temp_dir();
1772 let file_path = temp_dir.join(format!("orch_test_empty_{}.txt", std::process::id()));
1773 std::fs::write(&file_path, "").unwrap();
1774
1775 let result = read_file_with_limit(&file_path, 1024);
1776 assert!(result.is_ok());
1777 assert_eq!(result.unwrap(), "");
1778
1779 let _ = std::fs::remove_file(&file_path);
1780 }
1781
1782 #[serial(cooldown_state)]
1783 #[test]
1784 fn read_file_with_limit_handles_nonexistent_file() {
1785 let temp_dir = std::env::temp_dir();
1786 let file_path = temp_dir.join("orch_test_nonexistent_xyz123.txt");
1787
1788 let result = read_file_with_limit(&file_path, 1024);
1789 assert!(result.is_err());
1790 }
1791
1792 #[serial(cooldown_state)]
1793 #[test]
1794 fn read_file_with_limit_reads_exact_limit() {
1795 let temp_dir = std::env::temp_dir();
1796 let file_path = temp_dir.join(format!("orch_test_exact_{}.txt", std::process::id()));
1797
1798 let content = "y".repeat(100);
1800 std::fs::write(&file_path, &content).unwrap();
1801
1802 let result = read_file_with_limit(&file_path, 100);
1804 assert!(result.is_ok());
1805 assert_eq!(result.unwrap().len(), 100);
1806
1807 let _ = std::fs::remove_file(&file_path);
1808 }
1809
1810 #[serial(cooldown_state)]
1811 #[test]
1812 fn read_file_with_limit_handles_unicode_truncation() {
1813 let temp_dir = std::env::temp_dir();
1814 let file_path = temp_dir.join(format!("orch_test_unicode_{}.txt", std::process::id()));
1815
1816 let emoji = "🎉".repeat(100); std::fs::write(&file_path, &emoji).unwrap();
1819
1820 let result = read_file_with_limit(&file_path, 50);
1822 assert!(result.is_ok());
1823
1824 let read_content = result.unwrap();
1825 assert_ne!(read_content, "");
1827
1828 let _ = std::fs::remove_file(&file_path);
1829 }
1830
1831 fn parse_review_via_agent(agent: &str, output: &str) -> anyhow::Result<ReviewResponse> {
1835 let text = find_agent_result(agent, output)
1836 .map(|r| r.result_text)
1837 .filter(|t| !t.is_empty())
1838 .unwrap_or_else(|| output.to_string());
1839 if let Ok(r) = parse_review_response(&text) {
1840 return Ok(r);
1841 }
1842 infer_review_response(&text)
1843 .ok_or_else(|| anyhow::anyhow!("failed to parse review from {agent} output"))
1844 }
1845
1846 #[serial(cooldown_state)]
1849 #[test]
1850 fn fixture_claude_review_approve() {
1851 let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
1852 let resp = parse_review_via_agent("claude", raw).unwrap();
1853 assert_eq!(resp.decision, "approve");
1854 assert_eq!(resp.notes, "All checks pass");
1855 assert_eq!(resp.test_results.as_deref(), Some("pass"));
1856 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1857 }
1858
1859 #[serial(cooldown_state)]
1860 #[test]
1861 fn fixture_claude_review_request_changes() {
1862 let raw = include_str!("../../../tests/fixtures/review_claude_request_changes.jsonl");
1863 let resp = parse_review_via_agent("claude", raw).unwrap();
1864 assert_eq!(resp.decision, "request_changes");
1865 assert_eq!(resp.notes, "Fix the null deref on line 42");
1866 assert_eq!(resp.test_results.as_deref(), Some("fail"));
1867 assert_eq!(resp.issues.len(), 1);
1868 assert_eq!(resp.issues[0].file, "src/main.rs");
1869 assert_eq!(resp.issues[0].line, Some(42));
1870 assert_eq!(resp.issues[0].severity, "error");
1871 }
1872
1873 #[serial(cooldown_state)]
1874 #[test]
1875 fn fixture_claude_review_rate_limit_returns_error() {
1876 let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
1879 let result = find_agent_result("claude", raw);
1880 assert!(
1881 result.is_some(),
1882 "should find result even for error envelope"
1883 );
1884 let result = result.unwrap();
1885 assert!(result.is_error, "rate limit should be flagged as error");
1886 assert!(result.result_text.contains("rate limit"));
1887 }
1888
1889 #[serial(cooldown_state)]
1892 #[test]
1893 fn fixture_opencode_review_approve() {
1894 let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
1895 let resp = parse_review_via_agent("opencode", raw).unwrap();
1896 assert_eq!(resp.decision, "approve");
1897 assert_eq!(resp.notes, "All checks pass");
1898 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1899 }
1900
1901 #[serial(cooldown_state)]
1902 #[test]
1903 fn fixture_opencode_review_request_changes() {
1904 let raw = include_str!("../../../tests/fixtures/review_opencode_request_changes.jsonl");
1905 let resp = parse_review_via_agent("opencode", raw).unwrap();
1906 assert_eq!(resp.decision, "request_changes");
1907 assert_eq!(resp.notes, "Fix the memory leak");
1908 assert_eq!(resp.test_results.as_deref(), Some("fail"));
1909 assert_eq!(resp.issues.len(), 1);
1910 assert_eq!(resp.issues[0].file, "src/lib.rs");
1911 assert_eq!(resp.issues[0].line, Some(100));
1912 }
1913
1914 #[serial(cooldown_state)]
1915 #[test]
1916 fn fixture_opencode_review_plain_text_approve() {
1917 let raw = include_str!("../../../tests/fixtures/review_opencode_plain_text.jsonl");
1919 let resp = parse_review_via_agent("opencode", raw).unwrap();
1920 assert_eq!(resp.decision, "approve");
1921 }
1922
1923 #[serial(cooldown_state)]
1926 #[test]
1927 fn fixture_codex_review_approve() {
1928 let raw = include_str!("../../../tests/fixtures/review_codex_approve.jsonl");
1929 let resp = parse_review_via_agent("codex", raw).unwrap();
1930 assert_eq!(resp.decision, "approve");
1931 assert_eq!(resp.notes, "Clean implementation");
1932 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1933 }
1934
1935 #[serial(cooldown_state)]
1936 #[test]
1937 fn fixture_codex_review_request_changes() {
1938 let raw = include_str!("../../../tests/fixtures/review_codex_request_changes.jsonl");
1939 let resp = parse_review_via_agent("codex", raw).unwrap();
1940 assert_eq!(resp.decision, "request_changes");
1941 assert_eq!(resp.notes, "Race condition on line 15");
1942 assert_eq!(resp.issues.len(), 1);
1943 assert_eq!(resp.issues[0].file, "src/server.rs");
1944 assert_eq!(resp.issues[0].line, Some(15));
1945 }
1946
1947 #[serial(cooldown_state)]
1948 #[test]
1949 fn fixture_codex_review_plain_text_approve() {
1950 let raw = include_str!("../../../tests/fixtures/review_codex_plain_text.jsonl");
1952 let resp = parse_review_via_agent("codex", raw).unwrap();
1953 assert_eq!(resp.decision, "approve");
1954 }
1955
1956 #[serial(cooldown_state)]
1959 #[test]
1960 fn fixture_kimi_review_approve() {
1961 let raw = include_str!("../../../tests/fixtures/review_kimi_approve.jsonl");
1962 let resp = parse_review_via_agent("kimi", raw).unwrap();
1963 assert_eq!(resp.decision, "approve");
1964 assert_eq!(resp.notes, "LGTM");
1965 assert_eq!(resp.test_results.as_deref(), Some("pass"));
1966 assert!(resp.issues.is_empty(), "{:?}", resp.issues);
1967 }
1968
1969 #[serial(cooldown_state)]
1972 #[test]
1973 fn fixture_minimax_review_request_changes() {
1974 let raw = include_str!("../../../tests/fixtures/review_minimax_request_changes.jsonl");
1975 let resp = parse_review_via_agent("minimax", raw).unwrap();
1976 assert_eq!(resp.decision, "request_changes");
1977 assert_eq!(resp.notes, "Fix the bug");
1978 assert_eq!(resp.test_results.as_deref(), Some("fail"));
1979 assert_eq!(resp.issues.len(), 1);
1980 assert_eq!(resp.issues[0].file, "src/foo.rs");
1981 assert_eq!(resp.issues[0].line, Some(42));
1982 assert_eq!(resp.issues[0].severity, "error");
1983 }
1984
1985 #[serial(cooldown_state)]
1989 #[test]
1990 fn find_agent_result_extracts_tokens_claude() {
1991 let raw = include_str!("../../../tests/fixtures/review_claude_approve.jsonl");
1992 let result = find_agent_result("claude", raw).expect("should find result");
1993 assert_eq!(result.input_tokens, Some(200));
1994 assert_eq!(result.output_tokens, Some(30));
1995 assert!(result.cost_usd.is_none()); }
1997
1998 #[serial(cooldown_state)]
1999 #[test]
2000 fn find_agent_result_extracts_tokens_opencode() {
2001 let raw = include_str!("../../../tests/fixtures/review_opencode_approve.jsonl");
2002 let result = find_agent_result("opencode", raw).expect("should find result");
2003 assert_eq!(result.input_tokens, Some(2900));
2004 assert_eq!(result.output_tokens, Some(100));
2005 assert_eq!(result.cost_usd, Some(0.002));
2006 }
2007
2008 #[serial(cooldown_state)]
2010 #[test]
2011 fn find_agent_result_returns_none_for_empty() {
2012 assert!(find_agent_result("claude", "").is_none());
2013 assert!(find_agent_result("opencode", "").is_none());
2014 assert!(find_agent_result("codex", "").is_none());
2015 assert!(find_agent_result("kimi", "").is_none());
2016 assert!(find_agent_result("minimax", "").is_none());
2017 }
2018
2019 #[serial(cooldown_state)]
2020 #[test]
2021 fn find_agent_result_returns_none_for_plain_text() {
2022 assert!(find_agent_result("claude", "just some plain text").is_none());
2023 assert!(find_agent_result("codex", "just some plain text").is_none());
2024 }
2027
2028 #[serial(cooldown_state)]
2032 #[test]
2033 fn find_agent_result_propagates_claude_rate_limit() {
2034 let raw = include_str!("../../../tests/fixtures/review_claude_rate_limit.jsonl");
2035 let result = find_agent_result("claude", raw).expect("should find result");
2036 assert!(result.is_error, "should be flagged as error");
2037 assert!(result.result_text.contains("rate limit"));
2038 }
2039}