1use crate::store;
8use crate::store::TaskStore;
9use std::sync::Arc;
10
11use super::{agents, response};
12
13pub(crate) fn summarize_rate_limit_error(raw_output: &str) -> String {
24 let last_api_retry_line = raw_output
28 .lines()
29 .rev()
30 .find(|line| line.contains("\"subtype\":\"api_retry\""));
31
32 if let Some(line) = last_api_retry_line {
33 return summarise_api_retry_line(line);
34 }
35
36 let api_retry_field_markers = ["\"error_status\":", "\"attempt\":", "\"retry_delay_ms\":"];
42 let field_line = raw_output
43 .lines()
44 .rev()
45 .find(|line| api_retry_field_markers.iter().any(|m| line.contains(m)));
46 if let Some(line) = field_line {
47 return summarise_api_retry_line(line);
48 }
49
50 const FIRST_LINE_CAP: usize = 120;
54 let first_line = raw_output.lines().next().unwrap_or(raw_output);
55 if first_line.len() > FIRST_LINE_CAP {
56 let cutoff = agents::truncate_at_char_boundary(first_line, FIRST_LINE_CAP);
57 let remaining = raw_output.len().saturating_sub(cutoff);
58 format!("{}... (+{} more chars)", &first_line[..cutoff], remaining)
59 } else {
60 first_line.to_string()
61 }
62}
63
64fn summarise_api_retry_line(line: &str) -> String {
69 let error_status =
70 extract_json_field(line, "\"error_status\":").unwrap_or_else(|| "unknown".to_string());
71 let attempt = extract_json_field(line, "\"attempt\":").unwrap_or_else(|| "unknown".to_string());
72 let retry_delay_ms =
73 extract_json_field(line, "\"retry_delay_ms\":").unwrap_or_else(|| "0".to_string());
74
75 let has_field = error_status != "unknown"
76 || attempt != "unknown"
77 || (retry_delay_ms != "0" && retry_delay_ms != "unknown");
78
79 if has_field {
80 let retry_delay_s = retry_delay_ms.parse::<f64>().unwrap_or(0.0) / 1000.0;
81 let retry_delay_s = retry_delay_s as u64;
82 format!(
83 "status={} after {} attempts (last delay {}s)",
84 error_status, attempt, retry_delay_s
85 )
86 } else {
87 let status_hint = extract_json_field(line, "\"status\":")
88 .or_else(|| extract_json_field(line, "\"error\":"))
89 .unwrap_or_else(|| "429".to_string());
90 format!("status={} (api_retry fragment)", status_hint)
91 }
92}
93
94fn extract_json_field(json: &str, field: &str) -> Option<String> {
96 json.find(field).and_then(|start| {
97 let value_start = start + field.len();
98 let value_str = &json[value_start..];
99
100 if let Some(rest) = value_str.strip_prefix('"') {
102 let end = rest.find('"')?; Some(rest[..end].to_string())
104 }
105 else {
107 let end = value_str
108 .find(|c: char| !c.is_ascii_digit() && c != '.')
109 .unwrap_or(value_str.len());
110 let num_str = &value_str[..end];
111 num_str
112 .strip_suffix(',')
113 .map(|s| s.to_string())
114 .or_else(|| Some(num_str.to_string()))
115 }
116 })
117}
118
119pub enum ErrorHandleResult {
121 EarlyReturn { status: String },
123 Continue { status: String, error: String },
129}
130
131#[allow(clippy::too_many_arguments)]
142async fn try_free_model_reroute(
143 task_id: &str,
144 agent_name: &str,
145 agent_runner: &dyn agents::AgentRunner,
146 exclude_models: &[&str],
147 check_cooldowns: bool,
148 set_agent: Option<&str>,
149 apply_backoff: bool,
150 reason_prefix: &str,
151 store: &Option<Arc<TaskStore>>,
152 repo: &str,
153) -> Option<ErrorHandleResult> {
154 let free = agent_runner.free_models();
155 if free.is_empty() {
156 return None;
157 }
158 let tried_models: String = store::opt_store_get_task(store, repo, task_id)
159 .await
160 .map(|t| t.model_reroute_chain)
161 .unwrap_or_default();
162 let tried_set: std::collections::HashSet<&str> =
163 tried_models.split(',').filter(|s| !s.is_empty()).collect();
164 let next_free = free.iter().find(|m| {
165 !exclude_models.contains(&m.as_str())
166 && !tried_set.contains(m.as_str())
167 && (!check_cooldowns
168 || (!crate::engine::cooldown::is_agent_in_cooldown(agent_name)
169 && !response::is_model_in_cooldown(agent_name, m)))
170 })?;
171 tracing::info!(task_id, model = %next_free, "{}", reason_prefix);
172 let new_tried = if tried_models.is_empty() {
173 next_free.clone()
174 } else {
175 format!("{tried_models},{next_free}")
176 };
177 let msg = format!("{reason_prefix} {next_free}");
178 let mut fields: Vec<(&str, serde_json::Value)> = vec![
179 ("model", serde_json::json!(next_free.to_string())),
180 ("model_reroute_chain", serde_json::json!(new_tried)),
181 ("last_error", serde_json::json!(msg)),
182 ];
183 if let Some(agent) = set_agent {
184 fields.push(("agent", serde_json::json!(agent)));
185 }
186 if let Err(e) = store::store_set_result(store, repo, task_id, &fields).await {
187 tracing::warn!(task_id, err = %e, "try_free_model_reroute: failed to persist model update — not rerouting");
188 return None; }
190 if apply_backoff {
191 response::wait_for_fallback_backoff(task_id, store, repo).await;
192 }
193 Some(ErrorHandleResult::EarlyReturn {
194 status: "new".to_string(),
195 })
196}
197
198async fn apply_vendor_reset_date(
203 agent_name: &str,
204 model_name: Option<&str>,
205 message: &str,
206) -> bool {
207 use crate::engine::cooldown::CreditExhaustionReason;
208 let Some(model) = model_name else {
209 return false;
210 };
211 match crate::engine::cooldown::detect_credit_exhaustion(message) {
212 None | Some(CreditExhaustionReason::BillingCycleExhausted) => {}
213 Some(_) => return false,
214 }
215 match crate::engine::cooldown::parse_retry_at(message) {
216 Some(until) if until > chrono::Utc::now().timestamp() => {
217 crate::engine::cooldown::set_model_cooldown_until(agent_name, model, until).await;
218 true
219 }
220 _ => false,
221 }
222}
223
224#[allow(clippy::too_many_arguments)]
230pub async fn handle_error(
231 task_id: &str,
232 agent_err: &agents::AgentError,
233 agent_name: &str,
234 agent_runner: &dyn agents::AgentRunner,
235 model_name: Option<&str>,
236 complexity: Option<&str>,
237 new_attempts: u32,
238 store: &Option<Arc<TaskStore>>,
239 repo: &str,
240) -> anyhow::Result<ErrorHandleResult> {
241 tracing::warn!(
242 task_id,
243 agent = agent_name,
244 error = %agent_err,
245 "agent error, attempting recovery"
246 );
247
248 let mut credit_recorded = false;
251 let (retryable, error_msg) = match agent_err {
252 agents::AgentError::RateLimit { message, .. } => {
253 let is_provider_returned_error = message
254 .to_ascii_lowercase()
255 .contains("provider returned error");
256 let mut billing_cycle_model_scoped = false;
276
277 if apply_vendor_reset_date(agent_name, model_name, message).await {
278 let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
279 credit_recorded = reason.is_some();
280 billing_cycle_model_scoped = reason
281 == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
282 } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
283 {
284 credit_recorded = true;
285 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
291 {
292 if let Some(model) = model_name {
293 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
294 .await;
295 billing_cycle_model_scoped = true;
296 } else {
297 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
298 }
299 } else {
300 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
301 }
302 } else if let (Some(model), Some(window_secs)) = (
303 model_name,
304 crate::engine::cooldown::parse_relative_usage_window(message),
305 ) {
306 crate::engine::cooldown::set_model_cooldown(
314 agent_name,
315 model,
316 window_secs,
317 "rate_limit_usage_window",
318 )
319 .await;
320 tracing::info!(
321 task_id,
322 agent = agent_name,
323 model,
324 window_secs,
325 "relative usage window detected: applying model-specific cooldown"
326 );
327 } else {
328 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
329 .await;
330 if let Some(model) = model_name {
332 if is_provider_returned_error {
333 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
336 .await;
337 } else {
338 response::record_model_failure(agent_name, model).await;
339 }
340 }
341 }
342 let retryable = if billing_cycle_model_scoped {
346 response::RetryableError::ModelUnavailable
347 } else {
348 response::RetryableError::UsageLimit
349 };
350 (
351 retryable,
352 format!(
353 "{agent_name} rate limit: {}",
354 summarize_rate_limit_error(message)
355 ),
356 )
357 }
358 agents::AgentError::Auth { message } => {
359 let mut billing_cycle_model_scoped = false;
364
365 if apply_vendor_reset_date(agent_name, model_name, message).await {
366 let reason = crate::engine::cooldown::detect_credit_exhaustion(message);
367 credit_recorded = reason.is_some();
368 billing_cycle_model_scoped = reason
369 == Some(crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted);
370 } else if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message)
371 {
372 credit_recorded = true;
373 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
376 {
377 if let Some(model) = model_name {
378 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
379 .await;
380 billing_cycle_model_scoped = true;
381 } else {
382 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
383 }
384 } else {
385 crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await;
386 }
387 } else {
388 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
389 .await;
390 if let Some(model) = model_name {
391 response::record_model_failure(agent_name, model).await;
392 }
393 }
394 let retryable = if billing_cycle_model_scoped {
397 response::RetryableError::ModelUnavailable
398 } else {
399 response::RetryableError::AuthError
400 };
401 (retryable, format!("{agent_name} auth error: {message}"))
402 }
403 agents::AgentError::Timeout { elapsed } => {
404 if let Some(model) = model_name {
409 response::record_model_failure(agent_name, model).await;
410 }
411 (
412 response::RetryableError::Timeout,
413 format!("{agent_name} timed out after {}s", elapsed.as_secs()),
414 )
415 }
416 agents::AgentError::MissingTool { tool } => (
417 response::RetryableError::MissingTooling,
418 format!("missing tool: {tool}"),
419 ),
420 agents::AgentError::ModelUnavailable { model, message } => {
421 let lower = message.to_lowercase();
424 let is_permanently_gone = lower.contains("not found")
425 || lower.contains("not supported")
426 || lower.contains("not available in your country")
427 || lower.contains("not available in your region");
428 if is_permanently_gone {
429 crate::engine::cooldown::record_persistent_model_failure(agent_name, model).await;
431 } else {
432 response::record_model_failure(agent_name, model).await;
434 }
435
436 let agent_cooled = crate::engine::cooldown::is_agent_in_cooldown(agent_name);
439 let models = agent_runner.available_models();
440 let current_model = model_name.unwrap_or("");
441 let next_model = if agent_cooled {
442 None
443 } else {
444 models.iter().find(|m| {
445 m.as_str() != current_model
446 && m.as_str() != model
447 && !response::is_model_in_cooldown(agent_name, m)
448 })
449 };
450 if let Some(next) = next_model {
451 tracing::info!(task_id, model = %next, "retrying with different model");
452 let msg = format!("model {model} unavailable, trying {next}");
453 if let Err(e) = store::store_set_result(
454 store,
455 repo,
456 task_id,
457 &[
458 ("model", serde_json::json!(next.to_string())),
459 ("last_error", serde_json::json!(msg)),
460 ],
461 )
462 .await
463 {
464 tracing::warn!(task_id, err = %e, "failed to write model failover to store");
465 }
466 response::wait_for_fallback_backoff(task_id, store, repo).await;
470 return Ok(ErrorHandleResult::EarlyReturn {
471 status: "routed".to_string(),
472 });
473 }
474
475 let is_simple = matches!(complexity, None | Some("simple"));
477 if is_simple {
478 if let Some(result) = try_free_model_reroute(
479 task_id,
480 agent_name,
481 agent_runner,
482 &[current_model, model.as_str()],
483 true,
484 None,
485 true,
486 &format!("model {model} unavailable, trying free model"),
487 store,
488 repo,
489 )
490 .await
491 {
492 return Ok(result);
493 }
494 }
495
496 (
497 response::RetryableError::ModelUnavailable,
498 format!("model {model} unavailable"),
499 )
500 }
501 agents::AgentError::ContextOverflow { message } => {
502 let msg = format!("{agent_name} context overflow: {message}");
508 if let Err(e) = store::store_set_result(
509 store,
510 repo,
511 task_id,
512 &[("last_error", serde_json::json!(msg))],
513 )
514 .await
515 {
516 tracing::warn!(task_id, err = %e, "failed to write context_overflow last_error to store");
517 }
518 return Ok(ErrorHandleResult::EarlyReturn {
519 status: "needs_review".to_string(),
520 });
521 }
522 agents::AgentError::WaitingForInput { message } => {
523 let msg = format!("waiting for input: {message}");
525 if let Err(e) = store::store_set_result(
526 store,
527 repo,
528 task_id,
529 &[("last_error", serde_json::json!(msg))],
530 )
531 .await
532 {
533 tracing::warn!(task_id, err = %e, "failed to write waiting_for_input last_error to store");
534 }
535 return Ok(ErrorHandleResult::EarlyReturn {
536 status: "needs_review".to_string(),
537 });
538 }
539 agents::AgentError::PermissionDenied { message } => (
540 response::RetryableError::Failed,
541 format!("permission denied: {message}"),
542 ),
543 agents::AgentError::InvalidResponse { .. } => {
544 if let Some(model) = model_name {
547 response::record_model_failure(agent_name, model).await;
548 }
549 (
550 response::RetryableError::Failed,
551 format!("{agent_name} invalid response"),
552 )
553 }
554 agents::AgentError::AgentFailed { message } => {
555 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
562 credit_recorded = true;
563 match model_name {
564 Some(model)
565 if reason
566 == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted =>
567 {
568 crate::engine::cooldown::record_persistent_model_failure(agent_name, model)
569 .await;
570 }
571 _ => crate::engine::cooldown::record_credit_exhaustion(agent_name, reason).await,
572 }
573 } else {
574 if let Some(model) = model_name {
575 response::record_model_failure(agent_name, model).await;
576 }
577 crate::engine::cooldown::record_agent_failure_with_message(agent_name, message)
578 .await;
579 }
580 (
581 response::RetryableError::Failed,
582 format!("{agent_name} failed: {message}"),
583 )
584 }
585 agents::AgentError::NetworkError { message } => {
586 const MAX_NETWORK_RETRIES: u64 = 8;
592 let msg = format!("{agent_name} network error: {message}");
593 let retry_count = match store::store_increment(store, repo, task_id, "network_retries")
594 .await
595 {
596 Ok(count) => {
597 tracing::info!(
598 task_id,
599 retry_count = count,
600 agent = agent_name,
601 "network retry scheduled"
602 );
603 count
604 }
605 Err(e) => {
606 tracing::warn!(
607 task_id,
608 error = %e,
609 "failed to increment network_retries — escalating to needs_review"
610 );
611 return Ok(ErrorHandleResult::Continue {
612 status: "needs_review".to_string(),
613 error: format!("{agent_name} network error (store unavailable): {message}"),
614 });
615 }
616 };
617 if let Err(e) = store::store_set_result(
618 store,
619 repo,
620 task_id,
621 &[("last_error", serde_json::json!(msg))],
622 )
623 .await
624 {
625 tracing::warn!(task_id, err = %e, "failed to write network_error last_error to store");
626 }
627 if retry_count >= MAX_NETWORK_RETRIES {
628 tracing::warn!(
629 task_id,
630 retry_count,
631 agent = agent_name,
632 "network retries exhausted, escalating to needs_review"
633 );
634 return Ok(ErrorHandleResult::Continue {
635 status: "needs_review".to_string(),
636 error: msg,
637 });
638 }
639 response::wait_for_fallback_backoff(task_id, store, repo).await;
641 return Ok(ErrorHandleResult::EarlyReturn {
642 status: "routed".to_string(),
643 });
644 }
645 agents::AgentError::StaleSession { session_id } => {
646 (
650 response::RetryableError::Failed,
651 format!("{agent_name} stale session: {session_id}"),
652 )
653 }
654 agents::AgentError::ThinkingBlockConflict { message } => {
655 (
661 response::RetryableError::ModelUnavailable,
662 format!("{agent_name} thinking block conflict: {message}"),
663 )
664 }
665 agents::AgentError::Unknown { exit_code, message } => {
666 if *exit_code == 0
672 && (message.is_empty()
673 || message == "empty-output-exit0"
674 || message.starts_with("empty-output-exit0:"))
675 {
676 if let Some(m) = model_name {
677 crate::engine::cooldown::record_persistent_model_failure(agent_name, m).await;
680 }
681 let current = model_name.unwrap_or("");
686 if let Some(result) = try_free_model_reroute(
687 task_id,
688 agent_name,
689 agent_runner,
690 &[current],
691 true,
692 Some("opencode"),
693 true,
694 "silent exit 0, retrying with free model",
695 store,
696 repo,
697 )
698 .await
699 {
700 return Ok(result);
701 }
702 }
703
704 let error_message = if *exit_code != 0 && message.contains("\"costUSD\":") {
707 format!(
709 "{} exited with non-zero status (cost telemetry in stdout, no error message)",
710 agent_name
711 )
712 } else {
713 format!("{agent_name} exit {exit_code}: {message}")
714 };
715
716 (response::RetryableError::Failed, error_message)
717 }
718 };
719
720 if retryable == response::RetryableError::Timeout {
724 if let Err(e) = store::store_set_result(
725 store,
726 repo,
727 task_id,
728 &[
729 ("agent", serde_json::json!("")),
730 ("last_error", serde_json::json!(error_msg.clone())),
731 ],
732 )
733 .await
734 {
735 tracing::warn!(task_id, err = %e, "failed to write timeout agent clear to store");
736 }
737 }
738
739 {
741 let error_type_str = match retryable {
742 response::RetryableError::UsageLimit => "rate",
743 response::RetryableError::AuthError => "auth",
744 _ => "error",
745 };
746 if let Some(ref s) = store {
747 if let Err(e) = s
748 .record_rate_limit(agent_name, error_type_str, Some(task_id))
749 .await
750 {
751 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
752 }
753 }
754 }
755
756 let chain = response::get_reroute_chain(task_id, store, repo).await;
758 let available: Vec<String> = crate::engine::configured_agents()
759 .into_iter()
760 .filter(|a| crate::cmd_cache::command_exists(a))
761 .collect();
762
763 let all_agents_tried = {
764 let chain_set: std::collections::HashSet<&str> = if chain.is_empty() {
765 std::collections::HashSet::new()
766 } else {
767 chain.split(',').collect()
768 };
769 !available
770 .iter()
771 .any(|a| a.as_str() != agent_name && !chain_set.contains(a.as_str()))
772 };
773
774 let is_simple = matches!(complexity, None | Some("simple"));
775 if all_agents_tried && is_simple {
776 if let Some(result) = try_free_model_reroute(
778 task_id,
779 agent_name,
780 agent_runner,
781 &[],
782 true, Some("opencode"),
784 false,
785 "all agents exhausted, trying free model",
786 store,
787 repo,
788 )
789 .await
790 {
791 return Ok(result);
792 }
793 }
794
795 let status = response::handle_failover(
796 task_id,
797 agent_name,
798 retryable,
799 &error_msg,
800 credit_recorded,
801 store,
802 repo,
803 )
804 .await;
805 match status.as_str() {
806 "new" => tracing::warn!(task_id, "failover exhausted (retryable), task reset to new"),
807 "blocked" => tracing::warn!(task_id, "failover exhausted (hard failure), task blocked"),
808 _ => {}
809 }
810
811 response::store_failure_memory(
813 task_id,
814 new_attempts,
815 agent_name,
816 model_name,
817 &error_msg,
818 store,
819 repo,
820 )
821 .await;
822
823 Ok(ErrorHandleResult::Continue {
824 status,
825 error: error_msg,
826 })
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use crate::engine::runner::agents::{AgentError, AgentRunner, ParsedResponse, PermissionRules};
833 use crate::parser::AgentResponse;
834 use serial_test::serial;
835
836 struct MockRunner {
837 free: Vec<String>,
838 }
839
840 struct MockRunnerWithModels {
841 models: Vec<String>,
842 }
843
844 impl AgentRunner for MockRunnerWithModels {
845 fn stream_events(
846 &self,
847 _event: &serde_json::Value,
848 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
849 vec![]
850 }
851
852 fn name(&self) -> &str {
853 "opencode"
854 }
855
856 fn build_command(
857 &self,
858 _model: Option<&str>,
859 _timeout_cmd: &str,
860 _sys_file: &str,
861 _msg_file: &str,
862 _permissions: &PermissionRules,
863 ) -> String {
864 String::new()
865 }
866
867 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
868 Ok(ParsedResponse {
869 response: AgentResponse {
870 status: "done".to_string(),
871 summary: String::new(),
872 accomplished: vec![],
873 remaining: vec![],
874 files: vec![],
875 error: None,
876 input_tokens: None,
877 output_tokens: None,
878 learnings: vec![],
879 delegations: vec![],
880 },
881 input_tokens: None,
882 output_tokens: None,
883 duration_ms: None,
884 })
885 }
886
887 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
888 AgentError::Unknown {
889 exit_code: 0,
890 message: String::new(),
891 }
892 }
893
894 fn available_models(&self) -> Vec<String> {
895 self.models.clone()
896 }
897
898 fn router_command(
899 &self,
900 _prompt: &str,
901 _model: Option<&str>,
902 ) -> anyhow::Result<tokio::process::Command> {
903 anyhow::bail!("not implemented")
904 }
905 }
906
907 impl AgentRunner for MockRunner {
908 fn stream_events(
909 &self,
910 _event: &serde_json::Value,
911 ) -> Vec<crate::engine::runner::agents::StreamEvent> {
912 vec![]
913 }
914
915 fn name(&self) -> &str {
916 "opencode"
917 }
918
919 fn build_command(
920 &self,
921 _model: Option<&str>,
922 _timeout_cmd: &str,
923 _sys_file: &str,
924 _msg_file: &str,
925 _permissions: &PermissionRules,
926 ) -> String {
927 String::new()
928 }
929
930 fn parse_response(&self, _raw: &str) -> Result<ParsedResponse, AgentError> {
931 Ok(ParsedResponse {
932 response: AgentResponse {
933 status: "done".to_string(),
934 summary: String::new(),
935 accomplished: vec![],
936 remaining: vec![],
937 files: vec![],
938 error: None,
939 input_tokens: None,
940 output_tokens: None,
941 learnings: vec![],
942 delegations: vec![],
943 },
944 input_tokens: None,
945 output_tokens: None,
946 duration_ms: None,
947 })
948 }
949
950 fn classify_error(&self, _exit_code: i32, _stdout: &str, _stderr: &str) -> AgentError {
951 AgentError::Unknown {
952 exit_code: 0,
953 message: String::new(),
954 }
955 }
956
957 fn free_models(&self) -> Vec<String> {
958 self.free.clone()
959 }
960
961 fn router_command(
962 &self,
963 _prompt: &str,
964 _model: Option<&str>,
965 ) -> anyhow::Result<tokio::process::Command> {
966 anyhow::bail!("not implemented")
967 }
968 }
969
970 #[serial(cooldown_state)]
976 #[tokio::test]
977 async fn rate_limit_sets_agent_cooldown_immediately() {
978 crate::engine::cooldown::reset_global_state().await;
979 let runner = MockRunner { free: vec![] };
980 let agent = "test-agent-1371-early-cooldown";
981
982 assert!(
984 !crate::engine::cooldown::is_agent_in_cooldown(agent),
985 "agent should not be in cooldown before handle_error"
986 );
987
988 let err = AgentError::RateLimit {
989 message: "429 Too Many Requests".to_string(),
990 };
991
992 let _result = handle_error(
994 "test-1371-a",
995 &err,
996 agent,
997 &runner,
998 Some("sonnet"),
999 Some("medium"),
1000 1,
1001 &None,
1002 "owner/repo",
1003 )
1004 .await
1005 .unwrap();
1006
1007 assert!(
1009 crate::engine::cooldown::is_agent_in_cooldown(agent),
1010 "agent should be in cooldown immediately after handle_error for RateLimit"
1011 );
1012 }
1013
1014 #[serial(cooldown_state)]
1021 #[tokio::test]
1022 async fn rate_limit_sets_both_agent_and_model_cooldowns() {
1023 crate::engine::cooldown::reset_global_state().await;
1024 let runner = MockRunner { free: vec![] };
1025 let agent = "test-agent-2153-model-cooldown";
1026 let model = "opencode/github-copilot/qwen3.6-plus-free";
1027
1028 assert!(
1030 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1031 "agent should not be in cooldown before handle_error"
1032 );
1033 assert!(
1034 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1035 "model should not be in cooldown before handle_error"
1036 );
1037
1038 let err = AgentError::RateLimit {
1039 message: "Upstream error from Alibaba: Request rate increased too quickly.".to_string(),
1040 };
1041
1042 let _result = handle_error(
1043 "test-2153-a",
1044 &err,
1045 agent,
1046 &runner,
1047 Some(model),
1048 Some("simple"),
1049 1,
1050 &None,
1051 "owner/repo",
1052 )
1053 .await
1054 .unwrap();
1055
1056 assert!(
1058 crate::engine::cooldown::is_agent_in_cooldown(agent),
1059 "agent should be in agent-level cooldown after RateLimit"
1060 );
1061 assert!(
1064 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1065 "model should be in model-level cooldown after RateLimit (fixes issue #2153)"
1066 );
1067 }
1068
1069 #[serial(cooldown_state)]
1071 #[tokio::test]
1072 async fn rate_limit_cooldown_is_model_specific() {
1073 crate::engine::cooldown::reset_global_state().await;
1074 let runner = MockRunner { free: vec![] };
1075 let agent = "test-agent-2153-model-specific";
1076 let cooled_model = "opencode/github-copilot/qwen3.6-plus-free";
1077 let other_model = "opencode/minimax-m2.5-free";
1078
1079 let err = AgentError::RateLimit {
1080 message: "rate limit exceeded".to_string(),
1081 };
1082
1083 let _result = handle_error(
1084 "test-2153-b",
1085 &err,
1086 agent,
1087 &runner,
1088 Some(cooled_model),
1089 Some("simple"),
1090 1,
1091 &None,
1092 "owner/repo",
1093 )
1094 .await
1095 .unwrap();
1096
1097 assert!(
1099 crate::engine::cooldown::is_model_in_cooldown(agent, cooled_model),
1100 "rate-limited model should be in cooldown"
1101 );
1102 assert!(
1104 !crate::engine::cooldown::is_model_in_cooldown(agent, other_model),
1105 "other models should not be cooled by a single model's rate limit"
1106 );
1107 }
1108
1109 #[serial(cooldown_state)]
1120 #[tokio::test]
1121 async fn credit_exhaustion_does_not_bump_generic_failure_count() {
1122 crate::engine::cooldown::reset_global_state().await;
1123 let store = std::sync::Arc::new(
1124 crate::store::TaskStore::open_memory()
1125 .await
1126 .expect("in-memory store"),
1127 );
1128 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
1129
1130 let runner = MockRunner { free: vec![] };
1131 let agent = "test-agent-3652-credit-no-bump";
1132
1133 let err = AgentError::RateLimit {
1134 message: "out of credits, please recharge your balance".to_string(),
1135 };
1136
1137 let _result = handle_error(
1138 "test-3652-a",
1139 &err,
1140 agent,
1141 &runner,
1142 None,
1143 Some("simple"),
1144 1,
1145 &Some(store.clone()),
1146 "owner/repo",
1147 )
1148 .await
1149 .unwrap();
1150
1151 let credit_count = store
1152 .kv_get(&format!("credit_failure_count:{agent}"))
1153 .await
1154 .unwrap();
1155 assert!(
1156 credit_count.is_some(),
1157 "credit_failure_count should be incremented by the credit error"
1158 );
1159 let generic_count = store
1160 .kv_get(&format!("failure_count:{agent}"))
1161 .await
1162 .unwrap();
1163 assert!(
1164 generic_count.is_none(),
1165 "failure_count must not be bumped by credit errors, got {generic_count:?}"
1166 );
1167 }
1168
1169 #[serial(cooldown_state)]
1177 #[tokio::test]
1178 async fn timeout_sets_model_specific_cooldown() {
1179 crate::engine::cooldown::reset_global_state().await;
1180 let runner = MockRunner { free: vec![] };
1181 let agent = "opencode";
1182 let model = "opencode/nemotron-3-ultra-free";
1183
1184 assert!(
1185 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1186 "model should not be in cooldown before handle_error"
1187 );
1188
1189 let err = AgentError::Timeout {
1190 elapsed: std::time::Duration::from_secs(1800),
1191 };
1192
1193 let _result = handle_error(
1194 "test-3576-timeout",
1195 &err,
1196 agent,
1197 &runner,
1198 Some(model),
1199 Some("simple"),
1200 1,
1201 &None,
1202 "owner/repo",
1203 )
1204 .await
1205 .unwrap();
1206
1207 assert!(
1208 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1209 "model should be in model-level cooldown after Timeout (fixes issue #3576)"
1210 );
1211 }
1212
1213 #[serial(cooldown_state)]
1226 #[tokio::test]
1227 async fn rate_limit_with_relative_window_sets_window_length_model_cooldown() {
1228 crate::engine::cooldown::reset_global_state().await;
1229 let runner = MockRunner { free: vec![] };
1230 let agent = "test-agent-3580-kimi";
1231 let model = "opus";
1232 let key = format!("{agent}:{model}");
1233
1234 let err = AgentError::RateLimit {
1235 message: "You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window expires.".to_string(),
1236 };
1237
1238 let _result = handle_error(
1239 "test-3580-a",
1240 &err,
1241 agent,
1242 &runner,
1243 Some(model),
1244 Some("medium"),
1245 1,
1246 &None,
1247 "owner/repo",
1248 )
1249 .await
1250 .unwrap();
1251
1252 let now = chrono::Utc::now().timestamp();
1253 let until = crate::engine::cooldown::cooldown_until(&key)
1254 .expect("relative usage window should set model cooldown");
1255 let remaining = until.saturating_sub(now);
1256
1257 assert!(
1258 remaining >= 7 * 86400 - 30,
1259 "expected ~7-day model cooldown from the parsed window, got {remaining}s"
1260 );
1261 assert_eq!(
1262 crate::engine::cooldown::cooldown_reason(&key).as_deref(),
1263 Some("rate_limit_usage_window"),
1264 "relative usage-window cooldown must be labeled as a rate limit, not silence"
1265 );
1266 }
1267
1268 #[serial(cooldown_state)]
1269 #[tokio::test]
1270 async fn provider_returned_error_sets_extended_model_cooldown() {
1271 crate::engine::cooldown::reset_global_state().await;
1272 let runner = MockRunner { free: vec![] };
1273 let agent = "opencode";
1274 let model = "opencode/nemotron-3-super-free";
1275 let key = format!("{agent}:{model}");
1276
1277 let err = AgentError::RateLimit {
1278 message: "Provider returned error".to_string(),
1279 };
1280
1281 let _result = handle_error(
1282 "test-2478-provider-cooldown",
1283 &err,
1284 agent,
1285 &runner,
1286 Some(model),
1287 Some("simple"),
1288 1,
1289 &None,
1290 "owner/repo",
1291 )
1292 .await
1293 .unwrap();
1294
1295 let now = chrono::Utc::now().timestamp();
1296 let until = crate::engine::cooldown::cooldown_until(&key)
1297 .expect("provider error should set model cooldown");
1298 let remaining = until.saturating_sub(now);
1299
1300 assert!(
1302 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1303 "expected ~4h model cooldown, got {remaining}s"
1304 );
1305 }
1306
1307 #[serial(cooldown_state)]
1308 #[tokio::test]
1309 async fn model_unavailable_sets_persistent_model_cooldown_for_codex_style() {
1310 crate::engine::cooldown::reset_global_state().await;
1311 let runner = MockRunner { free: vec![] };
1312 let agent = "codex";
1313 let model = "gpt-5.3-codex";
1314
1315 let err = crate::engine::runner::agents::AgentError::ModelUnavailable {
1317 model: model.to_string(),
1318 message: "{\"detail\":\"The 'gpt-5.3-codex' model is not supported\"}".to_string(),
1319 };
1320
1321 let _result = handle_error(
1322 "test-codex-model-unsupported",
1323 &err,
1324 agent,
1325 &runner,
1326 Some(model),
1327 Some("simple"),
1328 1,
1329 &None,
1330 "owner/repo",
1331 )
1332 .await
1333 .unwrap();
1334
1335 let key = format!("{}:{}", agent, model);
1337 let now = chrono::Utc::now().timestamp();
1338 let until =
1339 crate::engine::cooldown::cooldown_until(&key).expect("model cooldown should be set");
1340 let remaining = until.saturating_sub(now);
1341
1342 assert!(
1343 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1344 "expected persistent model cooldown (~4h) for codex model-unavailable, got {remaining}s"
1345 );
1346 }
1347
1348 async fn vendor_reset_remaining(agent: &str, model: &str, message: &str) -> i64 {
1349 crate::engine::cooldown::reset_global_state().await;
1350 let runner = MockRunner { free: vec![] };
1351 let err = crate::engine::runner::agents::AgentError::RateLimit {
1352 message: message.to_string(),
1353 };
1354 let _ = handle_error(
1355 "test-3649",
1356 &err,
1357 agent,
1358 &runner,
1359 Some(model),
1360 Some("simple"),
1361 1,
1362 &None,
1363 "owner/repo",
1364 )
1365 .await
1366 .unwrap();
1367 let until = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
1368 .expect("model cooldown should be set");
1369 until - chrono::Utc::now().timestamp()
1370 }
1371
1372 #[serial(cooldown_state)]
1373 #[tokio::test]
1374 async fn vendor_reset_date_overrides_weekly_window() {
1375 use chrono::Datelike;
1376 let t = chrono::Local::now() + chrono::Duration::days(2);
1377 let msg = format!(
1378 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
1379 t.format("%b"),
1380 t.day()
1381 );
1382 let remaining = vendor_reset_remaining("claude", "sonnet", &msg).await;
1383 assert!(
1384 remaining > 3600 && remaining < 3 * 86400,
1385 "expected ~2d cooldown, got {remaining}s"
1386 );
1387 }
1388
1389 #[serial(cooldown_state)]
1390 #[tokio::test]
1391 async fn vendor_reset_date_overrides_billing_cycle_backoff() {
1392 let t = chrono::Local::now() + chrono::Duration::days(3);
1393 let msg = format!(
1394 "You've hit your usage limit. Upgrade to Pro (https://x), or try again at {}.",
1395 t.format("%b %d, %Y %I:%M %p")
1396 );
1397 let remaining = vendor_reset_remaining("codex", "gpt-5.4", &msg).await;
1398 assert!(
1399 remaining > 2 * 86400 + 80000 - 3600 && remaining < 4 * 86400,
1400 "expected ~3d cooldown, got {remaining}s"
1401 );
1402 }
1403
1404 #[serial(cooldown_state)]
1405 #[tokio::test]
1406 async fn agent_failed_insufficient_balance_sets_credit_cooldown() {
1407 crate::engine::cooldown::reset_global_state().await;
1408 let runner = MockRunner { free: vec![] };
1409 let agent = "test-agent-3642-insufficient-balance";
1410
1411 assert!(
1413 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1414 "agent should not be in cooldown before handle_error"
1415 );
1416
1417 let err = agents::AgentError::AgentFailed {
1418 message: "402 Payment Required: insufficient balance".to_string(),
1419 };
1420
1421 let _result = handle_error(
1423 "test-3642-a",
1424 &err,
1425 agent,
1426 &runner,
1427 Some("sonnet"),
1428 Some("medium"),
1429 1,
1430 &None,
1431 "owner/repo",
1432 )
1433 .await
1434 .unwrap();
1435
1436 assert!(
1441 crate::engine::cooldown::is_agent_in_cooldown(agent),
1442 "agent should be in cooldown after handle_error for AgentFailed with insufficient balance"
1443 );
1444
1445 let until = crate::engine::cooldown::cooldown_until(agent)
1446 .expect("agent cooldown should be active");
1447 let remaining = until - chrono::Utc::now().timestamp();
1448 assert!(
1449 remaining >= crate::engine::cooldown::CREDIT_BACKOFF_BASE_SECS - 30,
1450 "credit cooldown should be at least the 1h base, got {remaining}s"
1451 );
1452 }
1453
1454 #[serial(cooldown_state)]
1455 #[tokio::test]
1456 async fn silent_exit0_retries_free_model_for_simple_complexity() {
1457 crate::engine::cooldown::reset_global_state().await;
1458 let runner = MockRunner {
1459 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1460 };
1461 let err = AgentError::Unknown {
1462 exit_code: 0,
1463 message: String::new(),
1464 };
1465
1466 let result = handle_error(
1468 "test-1112-a",
1469 &err,
1470 "opencode",
1471 &runner,
1472 Some("opencode/gpt-5.4-mini"),
1473 Some("simple"),
1474 1,
1475 &None,
1476 "owner/repo",
1477 )
1478 .await
1479 .unwrap();
1480
1481 assert!(
1482 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1483 "expected EarlyReturn{{status: new}} for simple complexity — free model should be tried first"
1484 );
1485 }
1486
1487 #[serial(cooldown_state)]
1488 #[tokio::test]
1489 async fn silent_exit0_retries_free_model_when_complexity_unknown() {
1490 crate::engine::cooldown::reset_global_state().await;
1491 let runner = MockRunner {
1492 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1493 };
1494 let err = AgentError::Unknown {
1495 exit_code: 0,
1496 message: String::new(),
1497 };
1498
1499 let result = handle_error(
1501 "test-1112-a2",
1502 &err,
1503 "opencode",
1504 &runner,
1505 Some("opencode/gpt-5.4-mini"),
1506 None,
1507 1,
1508 &None,
1509 "owner/repo",
1510 )
1511 .await
1512 .unwrap();
1513
1514 assert!(
1515 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1516 "expected EarlyReturn{{status: new}} for unknown complexity — free model should be tried first"
1517 );
1518 }
1519
1520 #[serial(cooldown_state)]
1521 #[tokio::test]
1522 async fn silent_exit0_retries_free_model_for_medium_complexity() {
1523 crate::engine::cooldown::reset_global_state().await;
1524 let runner = MockRunner {
1525 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1526 };
1527 let err = AgentError::Unknown {
1528 exit_code: 0,
1529 message: String::new(),
1530 };
1531
1532 let result = handle_error(
1534 "test-1195-medium",
1535 &err,
1536 "opencode",
1537 &runner,
1538 Some("opencode/copilot-gpt-5.4-mini"),
1539 Some("medium"),
1540 1,
1541 &None,
1542 "owner/repo",
1543 )
1544 .await
1545 .unwrap();
1546
1547 assert!(
1548 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1549 "expected EarlyReturn{{status: new}} for medium complexity — free model should be tried before failover"
1550 );
1551 }
1552
1553 #[serial(cooldown_state)]
1554 #[tokio::test]
1555 async fn silent_exit0_retries_free_model_for_complex_complexity() {
1556 crate::engine::cooldown::reset_global_state().await;
1557 let runner = MockRunner {
1558 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1559 };
1560 let err = AgentError::Unknown {
1561 exit_code: 0,
1562 message: String::new(),
1563 };
1564
1565 let result = handle_error(
1567 "test-1195-complex",
1568 &err,
1569 "opencode",
1570 &runner,
1571 Some("opencode/copilot-gpt-5.4-mini"),
1572 Some("complex"),
1573 1,
1574 &None,
1575 "owner/repo",
1576 )
1577 .await
1578 .unwrap();
1579
1580 assert!(
1581 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1582 "expected EarlyReturn{{status: new}} for complex complexity — free model should be tried before failover"
1583 );
1584 }
1585
1586 #[serial(cooldown_state)]
1589 #[tokio::test]
1590 async fn context_overflow_escalates_to_needs_review_immediately() {
1591 crate::engine::cooldown::reset_global_state().await;
1592 let runner = MockRunner { free: vec![] };
1593 let err = AgentError::ContextOverflow {
1594 message: "prompt is too long: 250000 tokens, max 200000".to_string(),
1595 };
1596
1597 let result = handle_error(
1598 "test-2129-a",
1599 &err,
1600 "claude",
1601 &runner,
1602 Some("sonnet"),
1603 Some("medium"),
1604 1,
1605 &None,
1606 "owner/repo",
1607 )
1608 .await
1609 .unwrap();
1610
1611 assert!(
1612 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "needs_review"),
1613 "context overflow must escalate directly to needs_review without cycling through fallback agents"
1614 );
1615 }
1616
1617 #[serial(cooldown_state)]
1620 #[tokio::test]
1621 async fn free_model_reroute_succeeds_without_store() {
1622 crate::engine::cooldown::reset_global_state().await;
1623 let runner = MockRunner {
1624 free: vec!["opencode/mimo-v2-omni-free".to_string()],
1625 };
1626 let err = AgentError::Unknown {
1627 exit_code: 0,
1628 message: String::new(),
1629 };
1630
1631 let result = handle_error(
1633 "test-2293-a",
1634 &err,
1635 "opencode",
1636 &runner,
1637 Some("opencode/gpt-5.4-mini"),
1638 Some("simple"),
1639 1,
1640 &None,
1641 "owner/repo",
1642 )
1643 .await
1644 .unwrap();
1645
1646 assert!(
1647 matches!(result, ErrorHandleResult::EarlyReturn { ref status } if status == "new"),
1648 "expected EarlyReturn{{status: new}} — store_set_result with None store should not prevent reroute"
1649 );
1650 }
1651
1652 #[serial(cooldown_state)]
1653 #[tokio::test]
1654 async fn silent_exit0_falls_through_when_no_free_models() {
1655 crate::engine::cooldown::reset_global_state().await;
1656 let runner = MockRunner { free: vec![] };
1657 let err = AgentError::Unknown {
1658 exit_code: 0,
1659 message: String::new(),
1660 };
1661
1662 let result = handle_error(
1663 "test-1112-b",
1664 &err,
1665 "opencode",
1666 &runner,
1667 Some("opencode/gpt-5.4-mini"),
1668 Some("simple"),
1669 1,
1670 &None,
1671 "owner/repo",
1672 )
1673 .await
1674 .unwrap();
1675
1676 assert!(
1677 matches!(result, ErrorHandleResult::Continue { .. }),
1678 "expected Continue (fallthrough to failover) when no free models available"
1679 );
1680 }
1681
1682 #[serial(cooldown_state)]
1688 #[tokio::test]
1689 async fn invalid_response_sets_model_cooldown() {
1690 crate::engine::cooldown::reset_global_state().await;
1691 let runner = MockRunner { free: vec![] };
1692 let agent = "opencode";
1693 let model = "opencode/nemotron-3-super-free";
1694
1695 assert!(
1697 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
1698 "model should not be in cooldown before handle_error"
1699 );
1700
1701 let err = AgentError::InvalidResponse {
1702 raw: "invalid json output".to_string(),
1703 };
1704
1705 let _result = handle_error(
1706 "test-2750-invalid-response",
1707 &err,
1708 agent,
1709 &runner,
1710 Some(model),
1711 Some("simple"),
1712 1,
1713 &None,
1714 "owner/repo",
1715 )
1716 .await
1717 .unwrap();
1718
1719 assert!(
1721 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1722 "model should be in cooldown after InvalidResponse (fixes issue #2750)"
1723 );
1724 }
1725
1726 #[serial(cooldown_state)]
1733 #[tokio::test]
1734 async fn billing_cycle_exhausted_with_known_model_sets_only_model_cooldown() {
1735 crate::engine::cooldown::reset_global_state().await;
1736 let runner = MockRunner { free: vec![] };
1737 let agent = "codex-3382-billing-cycle";
1738 let model = "gpt-5.5";
1739
1740 let err = AgentError::RateLimit {
1741 message: "rate limit: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro).".to_string(),
1742 };
1743
1744 let _result = handle_error(
1745 "test-3382-a",
1746 &err,
1747 agent,
1748 &runner,
1749 Some(model),
1750 Some("medium"),
1751 1,
1752 &None,
1753 "owner/repo",
1754 )
1755 .await
1756 .unwrap();
1757
1758 assert!(
1760 !crate::engine::cooldown::is_agent_in_cooldown(agent),
1761 "agent must NOT be in agent-level cooldown when BillingCycleExhausted has a known model (issue #3382)"
1762 );
1763 assert!(
1765 crate::engine::cooldown::is_model_in_cooldown(agent, model),
1766 "model must be in cooldown after BillingCycleExhausted (issue #3382)"
1767 );
1768 assert!(
1770 !crate::engine::cooldown::is_model_in_cooldown(agent, "gpt-5.4"),
1771 "other models on the same agent must not be cooled by a single model's billing exhaustion"
1772 );
1773 }
1774
1775 #[serial(cooldown_state)]
1776 #[tokio::test]
1777 async fn summarize_rate_limit_error_extracts_api_retry_info() {
1778 crate::engine::cooldown::reset_global_state().await;
1779 let raw_output = r#"some log lines
1780{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1781{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1782more logs"#;
1783
1784 let summarized = super::summarize_rate_limit_error(raw_output);
1785 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1786 }
1787
1788 #[serial(cooldown_state)]
1789 #[tokio::test]
1790 async fn summarize_rate_limit_error_returns_original_when_no_api_retry() {
1791 crate::engine::cooldown::reset_global_state().await;
1792 let raw_output = "some regular error message without api_retry JSON";
1793
1794 let summarized = super::summarize_rate_limit_error(raw_output);
1795 assert_eq!(summarized, raw_output);
1796 }
1797
1798 #[serial(cooldown_state)]
1799 #[tokio::test]
1800 async fn summarize_rate_limit_error_handles_malformed_json_gracefully() {
1801 crate::engine::cooldown::reset_global_state().await;
1802 let raw_output = r#"some logs
1803{"type":"system","subtype":"api_retry","attempt":"not_a_number","retry_delay_ms":invalid,"error_status":429}
1804more logs"#;
1805
1806 let summarized = super::summarize_rate_limit_error(raw_output);
1807 assert!(summarized.starts_with("status="));
1809 assert!(summarized.contains("after"));
1810 assert!(summarized.contains("attempts"));
1811 assert!(summarized.contains("(last delay"));
1812 }
1813
1814 #[serial(cooldown_state)]
1815 #[tokio::test]
1816 async fn summarize_rate_limit_error_works_with_single_line() {
1817 crate::engine::cooldown::reset_global_state().await;
1818 let raw_output = r#"{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":5000,"error_status":429,"error":"rate_limit"}"#;
1819
1820 let summarized = super::summarize_rate_limit_error(raw_output);
1821 assert_eq!(summarized, "status=429 after 3 attempts (last delay 5s)");
1822 }
1823
1824 #[serial(cooldown_state)]
1828 #[tokio::test]
1829 async fn extract_json_field_handles_quoted_strings() {
1830 crate::engine::cooldown::reset_global_state().await;
1831 let json = r#"{"error_status":429,"attempt":7,"retry_delay_ms":35162.66,"error":"rate_limit","session_id":"331c099b-..."}"#;
1832
1833 assert_eq!(
1834 super::extract_json_field(json, "\"error\":"),
1835 Some("rate_limit".to_string()),
1836 "quoted string field mid-object should be extracted correctly"
1837 );
1838 assert_eq!(
1839 super::extract_json_field(json, "\"session_id\":"),
1840 Some("331c099b-...".to_string()),
1841 "quoted string field at end of object (no trailing comma) should be extracted"
1842 );
1843 assert_eq!(
1845 super::extract_json_field(json, "\"error_status\":"),
1846 Some("429".to_string())
1847 );
1848 assert_eq!(
1849 super::extract_json_field(json, "\"attempt\":"),
1850 Some("7".to_string())
1851 );
1852 assert_eq!(
1853 super::extract_json_field(json, "\"retry_delay_ms\":"),
1854 Some("35162.66".to_string())
1855 );
1856 }
1857
1858 #[serial(cooldown_state)]
1861 #[tokio::test]
1862 async fn summarize_rate_limit_error_handles_truncated_fragment_with_subtype() {
1863 crate::engine::cooldown::reset_global_state().await;
1864 let raw_output = r#"some log context
1866,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit"
1867more context"#;
1868
1869 let summarized = super::summarize_rate_limit_error(raw_output);
1870 assert!(
1871 !summarized.contains("subtype"),
1872 "summarized output must not contain raw 'subtype' JSON fragment"
1873 );
1874 assert!(
1875 !summarized.contains("{"),
1876 "summarized output must not contain JSON braces"
1877 );
1878 assert!(
1879 summarized.starts_with("status="),
1880 "summarized output should start with 'status=' — got: {summarized}"
1881 );
1882 }
1886
1887 #[serial(cooldown_state)]
1889 #[tokio::test]
1890 async fn summarize_rate_limit_error_handles_fragment_with_extractable_fields() {
1891 crate::engine::cooldown::reset_global_state().await;
1892 let raw_output = r#"log output
1893,"subtype":"api_retry","attempt":5,"retry_delay_ms":20000,"error_status":429,"error":"rate_limit","session_id":"abc123","max_retries":10}
1894rest of output"#;
1895
1896 let summarized = super::summarize_rate_limit_error(raw_output);
1897 assert_eq!(summarized, "status=429 after 5 attempts (last delay 20s)");
1898 }
1899
1900 #[serial(cooldown_state)]
1902 #[tokio::test]
1903 async fn summarize_rate_limit_error_complete_json_still_works() {
1904 crate::engine::cooldown::reset_global_state().await;
1905 let raw_output = r#"some log lines
1906{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":17266.789,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1907{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":35162.66,"error_status":429,"error":"rate_limit","session_id":"331c099b-..."}
1908more logs"#;
1909
1910 let summarized = super::summarize_rate_limit_error(raw_output);
1911 assert_eq!(summarized, "status=429 after 7 attempts (last delay 35s)");
1912 }
1913
1914 #[serial(cooldown_state)]
1916 #[tokio::test]
1917 async fn summarize_rate_limit_error_no_api_retry_no_raw_passthrough() {
1918 crate::engine::cooldown::reset_global_state().await;
1919 let raw_output = "some regular error message without api_retry JSON that is quite long and should be truncated";
1920
1921 let summarized = super::summarize_rate_limit_error(raw_output);
1922 assert!(
1924 summarized.len() <= 140,
1925 "expected truncated first-line, got: {summarized}"
1926 );
1927 }
1928
1929 #[serial(cooldown_state)]
1931 #[tokio::test]
1932 async fn summarize_rate_limit_error_no_api_retry_short() {
1933 crate::engine::cooldown::reset_global_state().await;
1934 let raw_output = "rate limit exceeded";
1935
1936 let summarized = super::summarize_rate_limit_error(raw_output);
1937 assert_eq!(summarized, raw_output);
1938 }
1939
1940 #[serial(cooldown_state)]
1946 #[tokio::test]
1947 async fn model_not_found_applies_persistent_cooldown() {
1948 crate::engine::cooldown::reset_global_state().await;
1949 let runner = MockRunner { free: vec![] };
1950 let agent = "opencode-2941-not-found";
1951 let model = "github-copilot/claude-opus-4.6";
1952 let key = format!("{agent}:{model}");
1953
1954 let err = AgentError::ModelUnavailable {
1955 model: model.to_string(),
1956 message: "Model not found: github-copilot/claude-opus-4.6".to_string(),
1957 };
1958
1959 let _result = handle_error(
1960 "test-2941-a",
1961 &err,
1962 agent,
1963 &runner,
1964 Some(model),
1965 Some("complex"),
1966 1,
1967 &None,
1968 "owner/repo",
1969 )
1970 .await
1971 .unwrap();
1972
1973 let now = chrono::Utc::now().timestamp();
1974 let until = crate::engine::cooldown::cooldown_until(&key)
1975 .expect("model not found should set model cooldown");
1976 let remaining = until.saturating_sub(now);
1977
1978 assert!(
1981 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
1982 "expected ~4h (persistent) cooldown for 'Model not found', got {remaining}s — \
1983 may have applied transient 5min cooldown instead"
1984 );
1985 }
1986
1987 #[serial(cooldown_state)]
1994 #[tokio::test]
1995 async fn model_not_supported_applies_persistent_cooldown() {
1996 crate::engine::cooldown::reset_global_state().await;
1997 let runner = MockRunner { free: vec![] };
1998 let agent = "codex-3215-not-supported";
1999 let model = "gpt-5.2-codex";
2000 let key = format!("{agent}:{model}");
2001
2002 let err = AgentError::ModelUnavailable {
2003 model: model.to_string(),
2004 message: "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.".to_string(),
2005 };
2006
2007 let _result = handle_error(
2008 "test-3215-a",
2009 &err,
2010 agent,
2011 &runner,
2012 Some(model),
2013 Some("medium"),
2014 1,
2015 &None,
2016 "owner/repo",
2017 )
2018 .await
2019 .unwrap();
2020
2021 let now = chrono::Utc::now().timestamp();
2022 let until = crate::engine::cooldown::cooldown_until(&key)
2023 .expect("model not supported should set model cooldown");
2024 let remaining = until.saturating_sub(now);
2025
2026 assert!(
2027 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2028 "expected ~4h (persistent) cooldown for 'not supported', got {remaining}s — \
2029 may have applied transient 5min cooldown instead (issue #3215)"
2030 );
2031 }
2032
2033 #[serial(cooldown_state)]
2034 #[tokio::test]
2035 async fn model_not_available_in_country_applies_persistent_cooldown() {
2036 crate::engine::cooldown::reset_global_state().await;
2037 let runner = MockRunner { free: vec![] };
2038 let agent = "opencode-3650-country";
2039 let model = "some-region-locked-model";
2040 let key = format!("{agent}:{model}");
2041
2042 for message in [
2044 "This model is not available in your country.",
2045 "This model is not available in your region.",
2046 ] {
2047 crate::engine::cooldown::reset_global_state().await;
2048 let err = AgentError::ModelUnavailable {
2049 model: model.to_string(),
2050 message: message.to_string(),
2051 };
2052
2053 let _result = handle_error(
2054 "test-3650",
2055 &err,
2056 agent,
2057 &runner,
2058 Some(model),
2059 Some("medium"),
2060 1,
2061 &None,
2062 "owner/repo",
2063 )
2064 .await
2065 .unwrap();
2066
2067 let now = chrono::Utc::now().timestamp();
2068 let until = crate::engine::cooldown::cooldown_until(&key)
2069 .expect("region block should set model cooldown");
2070 let remaining = until.saturating_sub(now);
2071
2072 assert!(
2073 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
2074 "expected ~4h (persistent) cooldown for '{message}', got {remaining}s — \
2075 may have applied transient 5min cooldown instead (issue #3650)"
2076 );
2077 }
2078 }
2079
2080 #[serial(cooldown_state)]
2086 #[tokio::test]
2087 async fn thinking_block_conflict_does_not_set_cooldown() {
2088 crate::engine::cooldown::reset_global_state().await;
2089 let runner = MockRunner { free: vec![] };
2090 let agent = "claude-3204-thinking-block";
2091 let model = "opus";
2092
2093 assert!(
2094 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2095 "agent must not be in cooldown before handle_error"
2096 );
2097 assert!(
2098 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2099 "model must not be in cooldown before handle_error"
2100 );
2101
2102 let err = AgentError::ThinkingBlockConflict {
2103 message: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.".to_string(),
2104 };
2105
2106 let _result = handle_error(
2107 "test-3204-thinking",
2108 &err,
2109 agent,
2110 &runner,
2111 Some(model),
2112 Some("complex"),
2113 1,
2114 &None,
2115 "owner/repo",
2116 )
2117 .await
2118 .unwrap();
2119
2120 assert!(
2121 !crate::engine::cooldown::is_agent_in_cooldown(agent),
2122 "agent must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2123 );
2124 assert!(
2125 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
2126 "model must NOT be in cooldown after ThinkingBlockConflict (fixes issue #3204)"
2127 );
2128 }
2129
2130 #[serial(cooldown_state)]
2132 #[tokio::test]
2133 async fn model_unavailable_transient_applies_short_cooldown() {
2134 crate::engine::cooldown::reset_global_state().await;
2135 let runner = MockRunner { free: vec![] };
2136 let agent = "opencode-2941-transient";
2137 let model = "github-copilot/gpt-4o";
2138 let key = format!("{agent}:{model}");
2139
2140 let err = AgentError::ModelUnavailable {
2141 model: model.to_string(),
2142 message: "No endpoints found for github-copilot/gpt-4o.".to_string(),
2143 };
2144
2145 let _result = handle_error(
2146 "test-2941-b",
2147 &err,
2148 agent,
2149 &runner,
2150 Some(model),
2151 Some("medium"),
2152 1,
2153 &None,
2154 "owner/repo",
2155 )
2156 .await
2157 .unwrap();
2158
2159 let now = chrono::Utc::now().timestamp();
2160 let until = crate::engine::cooldown::cooldown_until(&key)
2161 .expect("transient model unavailability should set model cooldown");
2162 let remaining = until.saturating_sub(now);
2163
2164 assert!(
2167 remaining < crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2168 "transient unavailability should use short cooldown (<4h), got {remaining}s"
2169 );
2170 }
2171
2172 #[serial(cooldown_state)]
2173 #[tokio::test]
2174 async fn model_unavailable_does_not_reuse_same_agent_when_agent_cooled() {
2175 crate::engine::cooldown::reset_global_state().await;
2176 let agent = "codex-3620";
2177 let failing_model = "gpt-5.4";
2178 let other_model = "gpt-5.5";
2179 let runner = MockRunnerWithModels {
2180 models: vec![failing_model.to_string(), other_model.to_string()],
2181 };
2182
2183 crate::engine::cooldown::set_agent_cooldown(agent, 3600).await;
2184
2185 let err = AgentError::ModelUnavailable {
2186 model: failing_model.to_string(),
2187 message: "model gpt-5.4 unavailable".to_string(),
2188 };
2189
2190 let result = handle_error(
2191 "test-3620",
2192 &err,
2193 agent,
2194 &runner,
2195 Some(failing_model),
2196 Some("medium"),
2197 1,
2198 &None,
2199 "owner/repo",
2200 )
2201 .await
2202 .unwrap();
2203
2204 if let ErrorHandleResult::EarlyReturn { status } = result {
2205 assert_ne!(
2206 status, "routed",
2207 "agent-wide cooldown was defeated by same-agent model substitution"
2208 );
2209 }
2210 }
2211}