1use std::collections::HashSet;
11use std::fmt::Write as _;
12use std::future::Future;
13use std::path::PathBuf;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use chrono::{DateTime, Utc};
19
20use crate::codexbar::AgentLimit;
21
22use super::config::{Config, ProviderEntry, ResolvedAgent};
23use super::config_loader::{ConfigError, load_config};
24use super::sleep::sleep_until;
25
26pub type ProbeFuture<'a> =
28 Pin<Box<dyn Future<Output = Result<AgentLimit, Box<dyn std::error::Error>>> + 'a>>;
29
30pub trait LimitProbe {
33 fn probe<'a>(
34 &'a mut self,
35 entry: &'a ProviderEntry,
36 resolved: &'a ResolvedAgent,
37 ) -> ProbeFuture<'a>;
38}
39
40#[derive(Debug, thiserror::Error)]
41#[error("All providers are rate-limited; earliest reset at {0:?}")]
42pub struct AllAgentsLimitedError(pub Option<DateTime<Utc>>);
43
44#[derive(Debug, thiserror::Error)]
45#[error("{0}")]
46pub struct NoMatchingAgentError(pub String);
47
48#[derive(Debug, thiserror::Error)]
49pub enum ResolveError {
50 #[error(transparent)]
51 AllLimited(#[from] AllAgentsLimitedError),
52 #[error(transparent)]
53 NoMatching(#[from] NoMatchingAgentError),
54 #[error(transparent)]
55 Config(#[from] ConfigError),
56 #[error("Resolution canceled")]
57 Canceled,
58}
59
60#[derive(Debug, Clone)]
61pub struct ResolveOptions {
62 pub mode_key: String,
63 pub provider_filter: Option<String>,
64 pub config_path: Option<PathBuf>,
65 pub config: Option<Config>,
66 pub exclude_providers: Vec<String>,
67 pub no_wait: bool,
68 pub max_rescans: u32,
69 pub quiet: bool,
70}
71
72impl Default for ResolveOptions {
73 fn default() -> Self {
74 Self {
75 mode_key: "build".to_string(),
76 provider_filter: None,
77 config_path: None,
78 config: None,
79 exclude_providers: Vec::new(),
80 no_wait: false,
81 max_rescans: 1,
82 quiet: false,
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
88pub struct PollOptions {
89 pub mode_key: String,
90 pub provider_filter: Option<String>,
91 pub config_path: Option<PathBuf>,
92 pub config: Option<Config>,
93 pub exclude_providers: Vec<String>,
94 pub interval_ms: u64,
95 pub cancel: Option<Arc<AtomicBool>>,
96}
97
98impl Default for PollOptions {
99 fn default() -> Self {
100 Self {
101 mode_key: "build".to_string(),
102 provider_filter: None,
103 config_path: None,
104 config: None,
105 exclude_providers: Vec::new(),
106 interval_ms: 60_000,
107 cancel: None,
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
113pub struct Candidate {
114 pub priority: i32,
115 pub order: usize,
116 pub entry_index: usize,
117 pub resolved: ResolvedAgent,
118}
119
120pub const SUPPORTED_SDK_KINDS: &[&str] = &["pi", "claude-terminal"];
128
129#[must_use]
130pub fn is_supported_sdk(sdk: &str) -> bool {
131 SUPPORTED_SDK_KINDS.contains(&sdk)
132}
133
134#[must_use]
138pub fn unsupported_sdk_providers(cfg: &Config) -> Vec<(String, String)> {
139 cfg.providers
140 .iter()
141 .filter(|p| !is_supported_sdk(&p.sdk))
142 .map(|p| (p.provider.clone(), p.sdk.clone()))
143 .collect()
144}
145
146#[must_use]
147pub fn build_candidates(
148 cfg: &Config,
149 mode_key: &str,
150 provider_filter: Option<&str>,
151 exclude: &[String],
152) -> Vec<Candidate> {
153 let exclude_set: HashSet<&str> = exclude.iter().map(String::as_str).collect();
154 let mut out: Vec<Candidate> = cfg
155 .providers
156 .iter()
157 .enumerate()
158 .filter_map(|(i, entry)| {
159 if !is_supported_sdk(&entry.sdk) {
162 return None;
163 }
164 if let Some(p) = provider_filter
165 && entry.provider != p
166 {
167 return None;
168 }
169 if exclude_set.contains(entry.provider.as_str()) {
170 return None;
171 }
172 let model = entry.models.get(mode_key)?;
173 let priority = model.priority.or(entry.priority).unwrap_or(0);
174 let skills = cfg.resolve_skills(entry);
175 let resolved = ResolvedAgent {
176 provider: entry.provider.clone(),
177 model_id: model.model.clone(),
178 mode_key: mode_key.to_string(),
179 sdk: entry.sdk.clone(),
180 api: entry.api.clone(),
181 skills,
182 };
183 Some(Candidate {
184 priority,
185 order: entry.order,
186 entry_index: i,
187 resolved,
188 })
189 })
190 .collect();
191 out.sort_by(|a, b| b.priority.cmp(&a.priority).then(a.order.cmp(&b.order)));
192 out
193}
194
195#[derive(Debug, PartialEq, Eq)]
196pub enum ScanOutcome {
197 Available {
198 idx: usize,
199 },
200 AllLimited {
201 reset_time: Option<DateTime<Utc>>,
202 },
203 NoAgents {
206 probe_errors: Vec<(String, String)>,
207 },
208}
209
210pub async fn scan(
212 candidates: &[Candidate],
213 entries: &[ProviderEntry],
214 probe: &mut dyn LimitProbe,
215) -> ScanOutcome {
216 if candidates.is_empty() {
217 return ScanOutcome::NoAgents {
218 probe_errors: Vec::new(),
219 };
220 }
221 let mut limited: Vec<Option<DateTime<Utc>>> = Vec::new();
222 let mut probe_errors: Vec<(String, String)> = Vec::new();
223 for (idx, c) in candidates.iter().enumerate() {
224 let entry = &entries[c.entry_index];
225 match probe.probe(entry, &c.resolved).await {
226 Ok(AgentLimit::NotLimited) => return ScanOutcome::Available { idx },
227 Ok(AgentLimit::Limited { reset_time }) => limited.push(reset_time),
228 Err(e) => probe_errors.push((entry.provider.clone(), e.to_string())),
229 }
230 }
231 if limited.is_empty() {
232 ScanOutcome::NoAgents { probe_errors }
233 } else {
234 ScanOutcome::AllLimited {
235 reset_time: limited.into_iter().flatten().min(),
236 }
237 }
238}
239
240fn format_probe_errors(errors: &[(String, String)]) -> String {
241 let mut s = String::from("No available providers");
242 if !errors.is_empty() {
243 s.push_str(" (probe failures: ");
244 for (i, (provider, msg)) in errors.iter().enumerate() {
245 if i > 0 {
246 s.push_str("; ");
247 }
248 let _ = write!(s, "{provider}: {msg}");
249 }
250 s.push(')');
251 }
252 s
253}
254
255pub async fn resolve_agent(
264 opts: ResolveOptions,
265 probe: &mut dyn LimitProbe,
266) -> Result<ResolvedAgent, ResolveError> {
267 let config = match opts.config.clone() {
268 Some(c) => c,
269 None => load_config(opts.config_path.as_deref())?,
270 };
271 let candidates = build_candidates(
272 &config,
273 &opts.mode_key,
274 opts.provider_filter.as_deref(),
275 &opts.exclude_providers,
276 );
277 if candidates.is_empty() {
278 let msg = if let Some(p) = &opts.provider_filter {
279 format!("No provider \"{}\" defines models.{}", p, opts.mode_key)
280 } else {
281 format!("No providers define models.{}", opts.mode_key)
282 };
283 return Err(NoMatchingAgentError(msg).into());
284 }
285
286 let mut rescans: u32 = 0;
287 loop {
288 match scan(&candidates, &config.providers, probe).await {
289 ScanOutcome::Available { idx } => return Ok(candidates[idx].resolved.clone()),
290 ScanOutcome::NoAgents { probe_errors } => {
291 return Err(NoMatchingAgentError(format_probe_errors(&probe_errors)).into());
292 }
293 ScanOutcome::AllLimited { reset_time } => {
294 if opts.no_wait || rescans >= opts.max_rescans {
295 return Err(AllAgentsLimitedError(reset_time).into());
296 }
297 if let Some(when) = reset_time {
298 sleep_until(when, opts.quiet).await;
299 }
300 rescans = rescans.saturating_add(1);
301 }
302 }
303 }
304}
305
306pub async fn poll_for_agent(
314 opts: PollOptions,
315 probe: &mut dyn LimitProbe,
316) -> Result<ResolvedAgent, ResolveError> {
317 let config = match opts.config.clone() {
318 Some(c) => c,
319 None => load_config(opts.config_path.as_deref())?,
320 };
321 let candidates = build_candidates(
322 &config,
323 &opts.mode_key,
324 opts.provider_filter.as_deref(),
325 &opts.exclude_providers,
326 );
327 if candidates.is_empty() {
328 let msg = if let Some(p) = &opts.provider_filter {
329 format!("No provider \"{}\" defines models.{}", p, opts.mode_key)
330 } else {
331 format!("No providers define models.{}", opts.mode_key)
332 };
333 return Err(NoMatchingAgentError(msg).into());
334 }
335 loop {
336 if let Some(c) = &opts.cancel
337 && c.load(Ordering::SeqCst)
338 {
339 return Err(ResolveError::Canceled);
340 }
341 match scan(&candidates, &config.providers, probe).await {
342 ScanOutcome::Available { idx } => return Ok(candidates[idx].resolved.clone()),
343 ScanOutcome::NoAgents { probe_errors } => {
344 return Err(NoMatchingAgentError(format_probe_errors(&probe_errors)).into());
345 }
346 ScanOutcome::AllLimited { .. } => {
347 let interval = opts.interval_ms.max(1);
348 let until_ms = i64::try_from(interval).unwrap_or(i64::MAX);
349 let until = Utc::now() + chrono::Duration::milliseconds(until_ms);
350 sleep_until(until, true).await;
351 }
352 }
353 }
354}
355
356#[must_use]
366pub fn codexbar_provider_name(sdk: &str, provider: &str) -> String {
367 match sdk {
368 "claude-terminal" => "claude".to_string(),
369 _ => provider.to_string(),
370 }
371}
372
373pub struct CodexBarProbe;
377
378impl LimitProbe for CodexBarProbe {
379 fn probe<'a>(
380 &'a mut self,
381 entry: &'a ProviderEntry,
382 resolved: &'a ResolvedAgent,
383 ) -> ProbeFuture<'a> {
384 Box::pin(async move {
385 let provider = codexbar_provider_name(&resolved.sdk, &entry.provider);
386 match crate::codexbar::check_limit(&provider).await {
387 Ok(limit) => Ok(limit),
388 Err(_) => Ok(AgentLimit::NotLimited),
393 }
394 })
395 }
396}
397
398pub async fn resolve_agent_with_codexbar(
404 opts: ResolveOptions,
405) -> Result<ResolvedAgent, ResolveError> {
406 let mut probe = CodexBarProbe;
407 resolve_agent(opts, &mut probe).await
408}
409
410#[cfg(test)]
411#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
412mod tests {
413 use std::collections::HashMap;
414
415 use super::*;
416 use crate::sdk::config::{ModelEntry, ProviderApi, ProviderEntry, SkillsConfig};
417 use indexmap::IndexMap;
418
419 fn entry(
420 key: &str,
421 provider: &str,
422 priority: Option<i32>,
423 models: &[(&str, &str, Option<i32>)],
424 ) -> ProviderEntry {
425 let mut m = IndexMap::new();
426 for (k, model, pri) in models {
427 m.insert(
428 (*k).to_string(),
429 ModelEntry {
430 model: (*model).to_string(),
431 priority: *pri,
432 },
433 );
434 }
435 ProviderEntry {
436 key: key.to_string(),
437 order: 0,
438 provider: provider.to_string(),
439 sdk: "pi".to_string(),
440 priority,
441 api: None,
442 skills: None,
443 models: m,
444 }
445 }
446
447 fn cfg(providers: Vec<ProviderEntry>) -> Config {
448 let providers = providers
449 .into_iter()
450 .enumerate()
451 .map(|(i, mut e)| {
452 e.order = i;
453 e
454 })
455 .collect();
456 Config {
457 providers,
458 skills: None,
459 }
460 }
461
462 struct MockProbe {
463 outcomes: HashMap<String, AgentLimit>,
464 }
465
466 impl LimitProbe for MockProbe {
467 fn probe<'a>(
468 &'a mut self,
469 entry: &'a ProviderEntry,
470 _resolved: &'a ResolvedAgent,
471 ) -> ProbeFuture<'a> {
472 let provider = entry.provider.clone();
473 let outcome = self
474 .outcomes
475 .get(&provider)
476 .cloned()
477 .unwrap_or(AgentLimit::NotLimited);
478 Box::pin(async move { Ok(outcome) })
479 }
480 }
481
482 #[test]
483 fn build_candidates_sorts_priority_desc_then_order_asc() {
484 let c = cfg(vec![
485 entry("a", "a", Some(1), &[("build", "x", None)]),
486 entry("b", "b", Some(3), &[("build", "y", None)]),
487 entry("c", "c", Some(3), &[("build", "z", None)]),
488 entry("d", "d", None, &[("plan", "p", None)]),
489 ]);
490 let candidates = build_candidates(&c, "build", None, &[]);
491 let providers: Vec<&str> = candidates
492 .iter()
493 .map(|c| c.resolved.provider.as_str())
494 .collect();
495 assert_eq!(providers, vec!["b", "c", "a"]);
496 }
497
498 #[test]
499 fn build_candidates_uses_model_priority_over_provider() {
500 let c = cfg(vec![
501 entry("a", "a", Some(1), &[("build", "x", Some(10))]),
502 entry("b", "b", Some(5), &[("build", "y", None)]),
503 ]);
504 let candidates = build_candidates(&c, "build", None, &[]);
505 assert_eq!(candidates[0].resolved.provider, "a");
506 }
507
508 #[test]
509 fn provider_filter_restricts_to_matching_provider() {
510 let c = cfg(vec![
511 entry("a", "a", Some(1), &[("build", "x", None)]),
512 entry("b", "b", Some(2), &[("build", "y", None)]),
513 ]);
514 let candidates = build_candidates(&c, "build", Some("a"), &[]);
515 assert_eq!(candidates.len(), 1);
516 assert_eq!(candidates[0].resolved.provider, "a");
517 }
518
519 #[test]
520 fn exclude_filters_out_providers() {
521 let c = cfg(vec![
522 entry("a", "a", Some(1), &[("build", "x", None)]),
523 entry("b", "b", Some(2), &[("build", "y", None)]),
524 ]);
525 let exclude = vec!["b".to_string()];
526 let candidates = build_candidates(&c, "build", None, &exclude);
527 assert_eq!(candidates.len(), 1);
528 assert_eq!(candidates[0].resolved.provider, "a");
529 }
530
531 #[test]
532 fn resolved_agent_carries_api_and_skills() {
533 let mut e = entry("zai", "zai", None, &[("build", "zai/glm-5.1", None)]);
534 e.api = Some(ProviderApi {
535 key: Some("sk-test".into()),
536 endpoint: Some("https://api.example.com".into()),
537 });
538 e.skills = Some(SkillsConfig {
539 include_claude: Some(false),
540 });
541 let c = cfg(vec![e]);
542 let candidates = build_candidates(&c, "build", None, &[]);
543 let r = &candidates[0].resolved;
544 assert_eq!(r.model_id, "zai/glm-5.1");
545 assert_eq!(
546 r.api.as_ref().and_then(|a| a.key.as_deref()),
547 Some("sk-test")
548 );
549 assert!(!r.skills.include_claude);
550 }
551
552 #[tokio::test(flavor = "current_thread")]
553 async fn resolve_returns_highest_priority_not_limited() {
554 let c = cfg(vec![
555 entry("a", "a", Some(1), &[("build", "x", None)]),
556 entry("b", "b", Some(3), &[("build", "y", None)]),
557 entry("c", "c", Some(2), &[("build", "z", None)]),
558 ]);
559 let mut limits = HashMap::new();
560 limits.insert(
561 "b".to_string(),
562 AgentLimit::Limited {
563 reset_time: Some(Utc::now() + chrono::Duration::seconds(60)),
564 },
565 );
566 let mut probe = MockProbe { outcomes: limits };
567 let opts = ResolveOptions {
568 config: Some(c),
569 no_wait: true,
570 ..Default::default()
571 };
572 let resolved = resolve_agent(opts, &mut probe).await.expect("resolve");
573 assert_eq!(resolved.provider, "c");
574 }
575
576 #[tokio::test(flavor = "current_thread")]
577 async fn resolve_returns_no_matching_for_unknown_mode() {
578 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
579 let mut probe = MockProbe {
580 outcomes: HashMap::new(),
581 };
582 let opts = ResolveOptions {
583 config: Some(c),
584 mode_key: "plan".to_string(),
585 ..Default::default()
586 };
587 let err = resolve_agent(opts, &mut probe)
588 .await
589 .expect_err("should fail");
590 assert!(matches!(err, ResolveError::NoMatching(_)));
591 }
592
593 #[tokio::test(flavor = "current_thread")]
594 async fn resolve_throws_all_limited_when_no_wait() {
595 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
596 let mut limits = HashMap::new();
597 limits.insert(
598 "a".to_string(),
599 AgentLimit::Limited {
600 reset_time: Some(Utc::now() + chrono::Duration::seconds(60)),
601 },
602 );
603 let mut probe = MockProbe { outcomes: limits };
604 let opts = ResolveOptions {
605 config: Some(c),
606 no_wait: true,
607 ..Default::default()
608 };
609 let err = resolve_agent(opts, &mut probe)
610 .await
611 .expect_err("should fail");
612 assert!(matches!(err, ResolveError::AllLimited(_)));
613 }
614
615 #[test]
616 fn codexbar_provider_name_aliases_claude_terminal() {
617 assert_eq!(
619 codexbar_provider_name("claude-terminal", "claude"),
620 "claude"
621 );
622 assert_eq!(codexbar_provider_name("pi", "zai"), "zai");
624 assert_eq!(codexbar_provider_name("pi", "codex"), "codex");
625 }
626
627 fn entry_with_sdk(
632 key: &str,
633 provider: &str,
634 sdk: &str,
635 models: &[(&str, &str, Option<i32>)],
636 ) -> ProviderEntry {
637 let mut e = entry(key, provider, None, models);
638 e.sdk = sdk.to_string();
639 e
640 }
641
642 #[test]
643 fn build_candidates_filters_out_unsupported_sdks() {
644 let c = cfg(vec![
645 entry_with_sdk("claude", "claude", "claude", &[("build", "opus", None)]),
646 entry_with_sdk("zai", "zai", "pi", &[("build", "anthropic/zai", None)]),
647 entry_with_sdk("codex", "codex", "codex", &[("build", "gpt", None)]),
648 ]);
649 let candidates = build_candidates(&c, "build", None, &[]);
650 let providers: Vec<&str> = candidates
651 .iter()
652 .map(|c| c.resolved.provider.as_str())
653 .collect();
654 assert_eq!(providers, vec!["zai"]);
655 }
656
657 #[test]
658 fn unsupported_sdk_providers_lists_non_pi_entries() {
659 let c = cfg(vec![
660 entry_with_sdk("claude", "claude", "claude", &[("build", "opus", None)]),
661 entry_with_sdk("zai", "zai", "pi", &[("build", "z", None)]),
662 entry_with_sdk("codex", "codex", "codex", &[("build", "gpt", None)]),
663 entry_with_sdk("copilot", "copilot", "copilot", &[("build", "x", None)]),
664 ]);
665 let mut list = unsupported_sdk_providers(&c);
666 list.sort();
667 assert_eq!(
668 list,
669 vec![
670 ("claude".to_string(), "claude".to_string()),
671 ("codex".to_string(), "codex".to_string()),
672 ("copilot".to_string(), "copilot".to_string()),
673 ],
674 );
675 }
676
677 #[test]
678 fn unsupported_sdk_providers_empty_when_all_pi() {
679 let c = cfg(vec![
680 entry_with_sdk("a", "a", "pi", &[("build", "anthropic/x", None)]),
681 entry_with_sdk("b", "b", "pi", &[("build", "openai/y", None)]),
682 ]);
683 assert!(unsupported_sdk_providers(&c).is_empty());
684 }
685
686 #[test]
687 fn is_supported_sdk_accepts_pi_and_claude_terminal() {
688 assert!(is_supported_sdk("pi"));
689 assert!(is_supported_sdk("claude-terminal"));
690 assert!(!is_supported_sdk("claude"));
691 assert!(!is_supported_sdk("codex"));
692 assert!(!is_supported_sdk(""));
693 }
694
695 #[tokio::test(flavor = "current_thread")]
700 async fn poll_for_agent_returns_canceled_when_signal_preflipped() {
701 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
703 let mut probe = MockProbe {
704 outcomes: HashMap::new(),
705 };
706 let opts = PollOptions {
707 config: Some(c),
708 cancel: Some(Arc::new(AtomicBool::new(true))),
709 ..Default::default()
710 };
711 let err = poll_for_agent(opts, &mut probe)
712 .await
713 .expect_err("should be canceled");
714 assert!(matches!(err, ResolveError::Canceled), "got: {err:?}");
715 }
716
717 #[tokio::test(flavor = "current_thread")]
718 async fn poll_for_agent_returns_available_when_provider_clear() {
719 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
720 let mut probe = MockProbe {
721 outcomes: HashMap::new(),
722 };
723 let opts = PollOptions {
724 config: Some(c),
725 ..Default::default()
726 };
727 let resolved = poll_for_agent(opts, &mut probe).await.expect("ok");
728 assert_eq!(resolved.provider, "a");
729 }
730
731 #[tokio::test(flavor = "current_thread")]
732 async fn poll_for_agent_no_matching_when_provider_filter_misses() {
733 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
734 let mut probe = MockProbe {
735 outcomes: HashMap::new(),
736 };
737 let opts = PollOptions {
738 config: Some(c),
739 provider_filter: Some("nope".to_string()),
740 ..Default::default()
741 };
742 let err = poll_for_agent(opts, &mut probe)
743 .await
744 .expect_err("should fail");
745 assert!(matches!(err, ResolveError::NoMatching(_)), "got: {err:?}");
746 }
747
748 #[tokio::test(flavor = "current_thread")]
753 async fn scan_no_agents_carries_probe_error_messages() {
754 struct AlwaysErr;
755 impl LimitProbe for AlwaysErr {
756 fn probe<'a>(
757 &'a mut self,
758 entry: &'a ProviderEntry,
759 _resolved: &'a ResolvedAgent,
760 ) -> ProbeFuture<'a> {
761 let p = entry.provider.clone();
762 Box::pin(async move {
763 let msg: Box<dyn std::error::Error> = format!("boom: {p}").into();
764 Err(msg)
765 })
766 }
767 }
768
769 let c = cfg(vec![
770 entry("a", "a", Some(1), &[("build", "x", None)]),
771 entry("b", "b", Some(2), &[("build", "y", None)]),
772 ]);
773 let candidates = build_candidates(&c, "build", None, &[]);
774 let mut probe = AlwaysErr;
775 let outcome = scan(&candidates, &c.providers, &mut probe).await;
776 match outcome {
777 ScanOutcome::NoAgents { probe_errors } => {
778 assert_eq!(probe_errors.len(), 2);
779 assert!(
780 probe_errors
781 .iter()
782 .any(|(p, m)| p == "a" && m.contains("boom: a"))
783 );
784 assert!(
785 probe_errors
786 .iter()
787 .any(|(p, m)| p == "b" && m.contains("boom: b"))
788 );
789 }
790 other => panic!("unexpected outcome: {other:?}"),
791 }
792 }
793
794 #[tokio::test(flavor = "current_thread")]
795 async fn resolve_agent_surfaces_probe_errors_in_message() {
796 struct AlwaysErr;
797 impl LimitProbe for AlwaysErr {
798 fn probe<'a>(
799 &'a mut self,
800 _entry: &'a ProviderEntry,
801 _resolved: &'a ResolvedAgent,
802 ) -> ProbeFuture<'a> {
803 Box::pin(async move {
804 let msg: Box<dyn std::error::Error> = "cookie read failed".into();
805 Err(msg)
806 })
807 }
808 }
809
810 let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
811 let mut probe = AlwaysErr;
812 let opts = ResolveOptions {
813 config: Some(c),
814 ..Default::default()
815 };
816 let err = resolve_agent(opts, &mut probe)
817 .await
818 .expect_err("should fail");
819 let msg = err.to_string();
820 assert!(msg.contains("cookie read failed"), "got: {msg}");
821 assert!(msg.contains("probe failures"), "got: {msg}");
822 }
823}