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