use std::collections::HashSet;
use std::fmt::Write as _;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Utc};
use crate::codexbar::AgentLimit;
use super::config::{Config, ProviderEntry, ResolvedAgent};
use super::config_loader::{ConfigError, load_config};
use super::sleep::sleep_until;
pub type ProbeFuture<'a> =
Pin<Box<dyn Future<Output = Result<AgentLimit, Box<dyn std::error::Error>>> + 'a>>;
pub trait LimitProbe {
fn probe<'a>(
&'a mut self,
entry: &'a ProviderEntry,
resolved: &'a ResolvedAgent,
) -> ProbeFuture<'a>;
}
#[derive(Debug, thiserror::Error)]
#[error("All providers are rate-limited; earliest reset at {0:?}")]
pub struct AllAgentsLimitedError(pub Option<DateTime<Utc>>);
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct NoMatchingAgentError(pub String);
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error(transparent)]
AllLimited(#[from] AllAgentsLimitedError),
#[error(transparent)]
NoMatching(#[from] NoMatchingAgentError),
#[error(transparent)]
Config(#[from] ConfigError),
#[error("Resolution canceled")]
Canceled,
}
#[derive(Debug, Clone)]
pub struct ResolveOptions {
pub mode_key: String,
pub provider_filter: Option<String>,
pub config_path: Option<PathBuf>,
pub config: Option<Config>,
pub exclude_providers: Vec<String>,
pub no_wait: bool,
pub max_rescans: u32,
pub quiet: bool,
pub require_tools: bool,
}
impl Default for ResolveOptions {
fn default() -> Self {
Self {
mode_key: "build".to_string(),
provider_filter: None,
config_path: None,
config: None,
exclude_providers: Vec::new(),
no_wait: false,
max_rescans: 1,
quiet: false,
require_tools: false,
}
}
}
#[derive(Debug, Clone)]
pub struct PollOptions {
pub mode_key: String,
pub provider_filter: Option<String>,
pub config_path: Option<PathBuf>,
pub config: Option<Config>,
pub exclude_providers: Vec<String>,
pub interval_ms: u64,
pub cancel: Option<Arc<AtomicBool>>,
pub require_tools: bool,
}
impl Default for PollOptions {
fn default() -> Self {
Self {
mode_key: "build".to_string(),
provider_filter: None,
config_path: None,
config: None,
exclude_providers: Vec::new(),
interval_ms: 60_000,
cancel: None,
require_tools: false,
}
}
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub priority: i32,
pub order: usize,
pub entry_index: usize,
pub resolved: ResolvedAgent,
}
pub const SUPPORTED_SDK_KINDS: &[&str] = &["pi", "claude", "claude-terminal", "claude-headless"];
#[must_use]
pub fn is_supported_sdk(sdk: &str) -> bool {
SUPPORTED_SDK_KINDS.contains(&sdk)
}
#[must_use]
pub fn sdk_supports_tools(sdk: &str) -> bool {
sdk == "pi" || sdk == "claude"
}
fn filter_candidates_for_tools(
mut candidates: Vec<Candidate>,
mode_key: &str,
provider_filter: Option<&str>,
require_tools: bool,
) -> Result<Vec<Candidate>, NoMatchingAgentError> {
let dropped_for_tools = if require_tools {
let before = candidates.len();
candidates.retain(|c| sdk_supports_tools(&c.resolved.sdk));
before - candidates.len()
} else {
0
};
if candidates.is_empty() {
let msg = if dropped_for_tools > 0 {
format!(
"No tool-capable (pi / claude) provider defines models.{mode_key}; {dropped_for_tools} candidate(s) were excluded because custom tools are not supported on claude-terminal/claude-headless"
)
} else if let Some(p) = provider_filter {
format!("No provider \"{p}\" defines models.{mode_key}")
} else {
format!("No providers define models.{mode_key}")
};
return Err(NoMatchingAgentError(msg));
}
Ok(candidates)
}
#[must_use]
pub fn unsupported_sdk_providers(cfg: &Config) -> Vec<(String, String)> {
cfg.providers
.iter()
.filter(|p| !is_supported_sdk(&p.sdk))
.map(|p| (p.provider.clone(), p.sdk.clone()))
.collect()
}
#[must_use]
pub fn build_candidates(
cfg: &Config,
mode_key: &str,
provider_filter: Option<&str>,
exclude: &[String],
) -> Vec<Candidate> {
let exclude_set: HashSet<&str> = exclude.iter().map(String::as_str).collect();
let mut out: Vec<Candidate> = cfg
.providers
.iter()
.enumerate()
.filter_map(|(i, entry)| {
if !is_supported_sdk(&entry.sdk) {
return None;
}
if let Some(p) = provider_filter
&& entry.provider != p
{
return None;
}
if exclude_set.contains(entry.provider.as_str()) {
return None;
}
let model = entry.models.get(mode_key)?;
let priority = model.priority.or(entry.priority).unwrap_or(0);
let skills = cfg.resolve_skills(entry);
let retry = cfg.resolve_retry(entry);
let env = cfg.resolve_env(entry);
let resolved = ResolvedAgent {
provider: entry.provider.clone(),
model_id: model.model.clone(),
mode_key: mode_key.to_string(),
sdk: entry.sdk.clone(),
api: entry.api.clone(),
skills,
retry,
env,
};
Some(Candidate {
priority,
order: entry.order,
entry_index: i,
resolved,
})
})
.collect();
out.sort_by(|a, b| b.priority.cmp(&a.priority).then(a.order.cmp(&b.order)));
out
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScanOutcome {
Available {
idx: usize,
},
AllLimited {
reset_time: Option<DateTime<Utc>>,
},
NoAgents {
probe_errors: Vec<(String, String)>,
},
}
pub async fn scan(
candidates: &[Candidate],
entries: &[ProviderEntry],
probe: &mut dyn LimitProbe,
) -> ScanOutcome {
if candidates.is_empty() {
return ScanOutcome::NoAgents {
probe_errors: Vec::new(),
};
}
let mut limited: Vec<Option<DateTime<Utc>>> = Vec::new();
let mut probe_errors: Vec<(String, String)> = Vec::new();
for (idx, c) in candidates.iter().enumerate() {
let entry = &entries[c.entry_index];
match probe.probe(entry, &c.resolved).await {
Ok(AgentLimit::NotLimited) => return ScanOutcome::Available { idx },
Ok(AgentLimit::Limited { reset_time }) => limited.push(reset_time),
Err(e) => probe_errors.push((entry.provider.clone(), e.to_string())),
}
}
if limited.is_empty() {
ScanOutcome::NoAgents { probe_errors }
} else {
ScanOutcome::AllLimited {
reset_time: limited.into_iter().flatten().min(),
}
}
}
fn format_probe_errors(errors: &[(String, String)]) -> String {
let mut s = String::from("No available providers");
if !errors.is_empty() {
s.push_str(" (probe failures: ");
for (i, (provider, msg)) in errors.iter().enumerate() {
if i > 0 {
s.push_str("; ");
}
let _ = write!(s, "{provider}: {msg}");
}
s.push(')');
}
s
}
pub async fn resolve_agent(
opts: ResolveOptions,
probe: &mut dyn LimitProbe,
) -> Result<ResolvedAgent, ResolveError> {
let config = match opts.config.clone() {
Some(c) => c,
None => load_config(opts.config_path.as_deref())?,
};
let candidates = filter_candidates_for_tools(
build_candidates(
&config,
&opts.mode_key,
opts.provider_filter.as_deref(),
&opts.exclude_providers,
),
&opts.mode_key,
opts.provider_filter.as_deref(),
opts.require_tools,
)?;
let mut rescans: u32 = 0;
loop {
match scan(&candidates, &config.providers, probe).await {
ScanOutcome::Available { idx } => return Ok(candidates[idx].resolved.clone()),
ScanOutcome::NoAgents { probe_errors } => {
return Err(NoMatchingAgentError(format_probe_errors(&probe_errors)).into());
}
ScanOutcome::AllLimited { reset_time } => {
if opts.no_wait || rescans >= opts.max_rescans {
return Err(AllAgentsLimitedError(reset_time).into());
}
if let Some(when) = reset_time {
sleep_until(when, opts.quiet).await;
}
rescans = rescans.saturating_add(1);
}
}
}
}
pub async fn poll_for_agent(
opts: PollOptions,
probe: &mut dyn LimitProbe,
) -> Result<ResolvedAgent, ResolveError> {
let config = match opts.config.clone() {
Some(c) => c,
None => load_config(opts.config_path.as_deref())?,
};
let candidates = filter_candidates_for_tools(
build_candidates(
&config,
&opts.mode_key,
opts.provider_filter.as_deref(),
&opts.exclude_providers,
),
&opts.mode_key,
opts.provider_filter.as_deref(),
opts.require_tools,
)?;
loop {
if let Some(c) = &opts.cancel
&& c.load(Ordering::SeqCst)
{
return Err(ResolveError::Canceled);
}
match scan(&candidates, &config.providers, probe).await {
ScanOutcome::Available { idx } => return Ok(candidates[idx].resolved.clone()),
ScanOutcome::NoAgents { probe_errors } => {
return Err(NoMatchingAgentError(format_probe_errors(&probe_errors)).into());
}
ScanOutcome::AllLimited { reset_time } => {
if let Some(when) = reset_time {
sleep_until(when, true).await;
} else {
let interval = opts.interval_ms.max(1);
let until_ms = i64::try_from(interval).unwrap_or(i64::MAX);
let until = Utc::now() + chrono::Duration::milliseconds(until_ms);
sleep_until(until, true).await;
}
}
}
}
}
#[must_use]
pub fn codexbar_provider_name(sdk: &str, provider: &str) -> String {
match sdk {
"claude" | "claude-terminal" | "claude-headless" => "claude".to_string(),
_ => provider.to_string(),
}
}
pub struct CodexBarProbe;
impl LimitProbe for CodexBarProbe {
fn probe<'a>(
&'a mut self,
entry: &'a ProviderEntry,
resolved: &'a ResolvedAgent,
) -> ProbeFuture<'a> {
Box::pin(async move {
let provider = codexbar_provider_name(&resolved.sdk, &entry.provider);
match crate::codexbar::check_limit(&provider).await {
Ok(limit) => Ok(limit),
Err(e) => {
tracing::warn!(
provider = provider,
error = %e,
"codexbar limit check failed; treating provider as not limited"
);
Ok(AgentLimit::NotLimited)
}
}
})
}
}
pub async fn resolve_agent_with_codexbar(
opts: ResolveOptions,
) -> Result<ResolvedAgent, ResolveError> {
let mut probe = CodexBarProbe;
resolve_agent(opts, &mut probe).await
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::sdk::config::{ModelEntry, ProviderApi, ProviderEntry, RetryConfig, SkillsConfig};
use indexmap::IndexMap;
fn entry(
key: &str,
provider: &str,
priority: Option<i32>,
models: &[(&str, &str, Option<i32>)],
) -> ProviderEntry {
let mut m = IndexMap::new();
for (k, model, pri) in models {
m.insert(
(*k).to_string(),
ModelEntry {
model: (*model).to_string(),
priority: *pri,
},
);
}
ProviderEntry {
key: key.to_string(),
order: 0,
provider: provider.to_string(),
sdk: "pi".to_string(),
priority,
api: None,
skills: None,
retry: None,
env: None,
models: m,
}
}
fn cfg(providers: Vec<ProviderEntry>) -> Config {
let providers = providers
.into_iter()
.enumerate()
.map(|(i, mut e)| {
e.order = i;
e
})
.collect();
Config {
providers,
skills: None,
retry: None,
env: None,
}
}
struct MockProbe {
outcomes: HashMap<String, AgentLimit>,
}
impl LimitProbe for MockProbe {
fn probe<'a>(
&'a mut self,
entry: &'a ProviderEntry,
_resolved: &'a ResolvedAgent,
) -> ProbeFuture<'a> {
let provider = entry.provider.clone();
let outcome = self
.outcomes
.get(&provider)
.cloned()
.unwrap_or(AgentLimit::NotLimited);
Box::pin(async move { Ok(outcome) })
}
}
#[test]
fn build_candidates_sorts_priority_desc_then_order_asc() {
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", None)]),
entry("b", "b", Some(3), &[("build", "y", None)]),
entry("c", "c", Some(3), &[("build", "z", None)]),
entry("d", "d", None, &[("plan", "p", None)]),
]);
let candidates = build_candidates(&c, "build", None, &[]);
let providers: Vec<&str> = candidates
.iter()
.map(|c| c.resolved.provider.as_str())
.collect();
assert_eq!(providers, vec!["b", "c", "a"]);
}
#[test]
fn build_candidates_uses_model_priority_over_provider() {
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", Some(10))]),
entry("b", "b", Some(5), &[("build", "y", None)]),
]);
let candidates = build_candidates(&c, "build", None, &[]);
assert_eq!(candidates[0].resolved.provider, "a");
}
#[test]
fn provider_filter_restricts_to_matching_provider() {
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", None)]),
entry("b", "b", Some(2), &[("build", "y", None)]),
]);
let candidates = build_candidates(&c, "build", Some("a"), &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].resolved.provider, "a");
}
#[test]
fn exclude_filters_out_providers() {
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", None)]),
entry("b", "b", Some(2), &[("build", "y", None)]),
]);
let exclude = vec!["b".to_string()];
let candidates = build_candidates(&c, "build", None, &exclude);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].resolved.provider, "a");
}
#[test]
fn resolved_agent_carries_api_and_skills() {
let mut e = entry("zai", "zai", None, &[("build", "zai/glm-5.1", None)]);
e.api = Some(ProviderApi {
key: Some("sk-test".into()),
endpoint: Some("https://api.example.com".into()),
});
e.skills = Some(SkillsConfig {
include_claude: Some(false),
});
let c = cfg(vec![e]);
let candidates = build_candidates(&c, "build", None, &[]);
let r = &candidates[0].resolved;
assert_eq!(r.model_id, "zai/glm-5.1");
assert_eq!(
r.api.as_ref().and_then(|a| a.key.as_deref()),
Some("sk-test")
);
assert!(!r.skills.include_claude);
}
#[test]
fn resolved_agent_carries_root_retry_config() {
let mut e = entry("kimi", "kimi", None, &[("build", "kimi/k1", None)]);
e.retry = None;
let c = Config {
providers: vec![e],
skills: None,
retry: Some(RetryConfig {
retry_client_errors: true,
..RetryConfig::default()
}),
env: None,
};
let candidates = build_candidates(&c, "build", None, &[]);
assert!(candidates[0].resolved.retry.retry_client_errors);
}
#[test]
fn resolved_agent_carries_provider_retry_config_over_root() {
let mut e = entry("kimi", "kimi", None, &[("build", "kimi/k1", None)]);
e.retry = Some(RetryConfig {
enabled: false,
..RetryConfig::default()
});
let c = Config {
providers: vec![e],
skills: None,
retry: Some(RetryConfig {
enabled: true,
..RetryConfig::default()
}),
env: None,
};
let candidates = build_candidates(&c, "build", None, &[]);
assert!(!candidates[0].resolved.retry.enabled);
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_returns_highest_priority_not_limited() {
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", None)]),
entry("b", "b", Some(3), &[("build", "y", None)]),
entry("c", "c", Some(2), &[("build", "z", None)]),
]);
let mut limits = HashMap::new();
limits.insert(
"b".to_string(),
AgentLimit::Limited {
reset_time: Some(Utc::now() + chrono::Duration::seconds(60)),
},
);
let mut probe = MockProbe { outcomes: limits };
let opts = ResolveOptions {
config: Some(c),
no_wait: true,
..Default::default()
};
let resolved = resolve_agent(opts, &mut probe).await.expect("resolve");
assert_eq!(resolved.provider, "c");
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_returns_no_matching_for_unknown_mode() {
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = ResolveOptions {
config: Some(c),
mode_key: "plan".to_string(),
..Default::default()
};
let err = resolve_agent(opts, &mut probe)
.await
.expect_err("should fail");
assert!(matches!(err, ResolveError::NoMatching(_)));
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_throws_all_limited_when_no_wait() {
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut limits = HashMap::new();
limits.insert(
"a".to_string(),
AgentLimit::Limited {
reset_time: Some(Utc::now() + chrono::Duration::seconds(60)),
},
);
let mut probe = MockProbe { outcomes: limits };
let opts = ResolveOptions {
config: Some(c),
no_wait: true,
..Default::default()
};
let err = resolve_agent(opts, &mut probe)
.await
.expect_err("should fail");
assert!(matches!(err, ResolveError::AllLimited(_)));
}
#[test]
fn codexbar_provider_name_aliases_claude_family() {
assert_eq!(codexbar_provider_name("claude", "claude"), "claude");
assert_eq!(
codexbar_provider_name("claude-terminal", "claude"),
"claude"
);
assert_eq!(
codexbar_provider_name("claude-headless", "claude"),
"claude"
);
assert_eq!(codexbar_provider_name("pi", "zai"), "zai");
assert_eq!(codexbar_provider_name("pi", "codex"), "codex");
}
fn entry_with_sdk(
key: &str,
provider: &str,
sdk: &str,
models: &[(&str, &str, Option<i32>)],
) -> ProviderEntry {
let mut e = entry(key, provider, None, models);
e.sdk = sdk.to_string();
e
}
#[test]
fn build_candidates_filters_out_unsupported_sdks() {
let c = cfg(vec![
entry_with_sdk("kimi", "kimi", "kimi", &[("build", "k", None)]),
entry_with_sdk("zai", "zai", "pi", &[("build", "anthropic/zai", None)]),
entry_with_sdk("codex", "codex", "codex", &[("build", "gpt", None)]),
]);
let candidates = build_candidates(&c, "build", None, &[]);
let providers: Vec<&str> = candidates
.iter()
.map(|c| c.resolved.provider.as_str())
.collect();
assert_eq!(providers, vec!["zai"]);
}
#[test]
fn unsupported_sdk_providers_lists_non_pi_entries() {
let c = cfg(vec![
entry_with_sdk("kimi", "kimi", "kimi", &[("build", "k", None)]),
entry_with_sdk("zai", "zai", "pi", &[("build", "z", None)]),
entry_with_sdk("codex", "codex", "codex", &[("build", "gpt", None)]),
entry_with_sdk("copilot", "copilot", "copilot", &[("build", "x", None)]),
]);
let mut list = unsupported_sdk_providers(&c);
list.sort();
assert_eq!(
list,
vec![
("codex".to_string(), "codex".to_string()),
("copilot".to_string(), "copilot".to_string()),
("kimi".to_string(), "kimi".to_string()),
],
);
}
#[test]
fn unsupported_sdk_providers_empty_when_all_pi() {
let c = cfg(vec![
entry_with_sdk("a", "a", "pi", &[("build", "anthropic/x", None)]),
entry_with_sdk("b", "b", "pi", &[("build", "openai/y", None)]),
]);
assert!(unsupported_sdk_providers(&c).is_empty());
}
#[test]
fn is_supported_sdk_accepts_pi_claude_and_claude_terminal_and_headless() {
assert!(is_supported_sdk("pi"));
assert!(is_supported_sdk("claude"));
assert!(is_supported_sdk("claude-terminal"));
assert!(is_supported_sdk("claude-headless"));
assert!(!is_supported_sdk("codex"));
assert!(!is_supported_sdk(""));
}
#[test]
fn sdk_supports_tools_only_pi_and_claude() {
assert!(sdk_supports_tools("pi"));
assert!(sdk_supports_tools("claude"));
assert!(!sdk_supports_tools("claude-terminal"));
assert!(!sdk_supports_tools("claude-headless"));
assert!(!sdk_supports_tools(""));
}
#[tokio::test(flavor = "current_thread")]
async fn require_tools_drops_claude_terminal_candidates() {
let c = cfg(vec![
entry_with_sdk(
"claude",
"claude",
"claude-terminal",
&[("build", "opus", None)],
),
entry_with_sdk("zai", "zai", "pi", &[("build", "anthropic/zai", None)]),
]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = ResolveOptions {
config: Some(c),
require_tools: true,
..Default::default()
};
let resolved = resolve_agent(opts, &mut probe).await.expect("resolve");
assert_eq!(resolved.provider, "zai");
assert_eq!(resolved.sdk, "pi");
}
#[tokio::test(flavor = "current_thread")]
async fn require_tools_errors_when_only_claude_terminal() {
let c = cfg(vec![entry_with_sdk(
"claude",
"claude",
"claude-terminal",
&[("build", "opus", None)],
)]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = ResolveOptions {
config: Some(c),
require_tools: true,
..Default::default()
};
let err = resolve_agent(opts, &mut probe)
.await
.expect_err("should fail");
assert!(matches!(err, ResolveError::NoMatching(_)), "got: {err:?}");
let msg = err.to_string();
assert!(msg.contains("claude-terminal"), "got: {msg}");
assert!(msg.contains("custom tools"), "got: {msg}");
}
#[tokio::test(flavor = "current_thread")]
async fn require_tools_false_keeps_claude_terminal() {
let c = cfg(vec![
entry_with_sdk(
"claude",
"claude",
"claude-terminal",
&[("build", "opus", None)],
),
entry_with_sdk("zai", "zai", "pi", &[("build", "anthropic/zai", None)]),
]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = ResolveOptions {
config: Some(c),
..Default::default()
};
let resolved = resolve_agent(opts, &mut probe).await.expect("resolve");
assert_eq!(resolved.provider, "claude");
assert_eq!(resolved.sdk, "claude-terminal");
}
#[tokio::test(flavor = "current_thread")]
async fn poll_for_agent_require_tools_drops_claude_terminal() {
let c = cfg(vec![
entry_with_sdk(
"claude",
"claude",
"claude-terminal",
&[("build", "opus", None)],
),
entry_with_sdk("zai", "zai", "pi", &[("build", "anthropic/zai", None)]),
]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = PollOptions {
config: Some(c),
require_tools: true,
..Default::default()
};
let resolved = poll_for_agent(opts, &mut probe).await.expect("ok");
assert_eq!(resolved.provider, "zai");
}
#[tokio::test(flavor = "current_thread")]
async fn poll_for_agent_returns_canceled_when_signal_preflipped() {
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = PollOptions {
config: Some(c),
cancel: Some(Arc::new(AtomicBool::new(true))),
..Default::default()
};
let err = poll_for_agent(opts, &mut probe)
.await
.expect_err("should be canceled");
assert!(matches!(err, ResolveError::Canceled), "got: {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn poll_for_agent_returns_available_when_provider_clear() {
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = PollOptions {
config: Some(c),
..Default::default()
};
let resolved = poll_for_agent(opts, &mut probe).await.expect("ok");
assert_eq!(resolved.provider, "a");
}
#[tokio::test(flavor = "current_thread")]
async fn poll_for_agent_no_matching_when_provider_filter_misses() {
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut probe = MockProbe {
outcomes: HashMap::new(),
};
let opts = PollOptions {
config: Some(c),
provider_filter: Some("nope".to_string()),
..Default::default()
};
let err = poll_for_agent(opts, &mut probe)
.await
.expect_err("should fail");
assert!(matches!(err, ResolveError::NoMatching(_)), "got: {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn scan_no_agents_carries_probe_error_messages() {
struct AlwaysErr;
impl LimitProbe for AlwaysErr {
fn probe<'a>(
&'a mut self,
entry: &'a ProviderEntry,
_resolved: &'a ResolvedAgent,
) -> ProbeFuture<'a> {
let p = entry.provider.clone();
Box::pin(async move {
let msg: Box<dyn std::error::Error> = format!("boom: {p}").into();
Err(msg)
})
}
}
let c = cfg(vec![
entry("a", "a", Some(1), &[("build", "x", None)]),
entry("b", "b", Some(2), &[("build", "y", None)]),
]);
let candidates = build_candidates(&c, "build", None, &[]);
let mut probe = AlwaysErr;
let outcome = scan(&candidates, &c.providers, &mut probe).await;
match outcome {
ScanOutcome::NoAgents { probe_errors } => {
assert_eq!(probe_errors.len(), 2);
assert!(
probe_errors
.iter()
.any(|(p, m)| p == "a" && m.contains("boom: a"))
);
assert!(
probe_errors
.iter()
.any(|(p, m)| p == "b" && m.contains("boom: b"))
);
}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_agent_surfaces_probe_errors_in_message() {
struct AlwaysErr;
impl LimitProbe for AlwaysErr {
fn probe<'a>(
&'a mut self,
_entry: &'a ProviderEntry,
_resolved: &'a ResolvedAgent,
) -> ProbeFuture<'a> {
Box::pin(async move {
let msg: Box<dyn std::error::Error> = "cookie read failed".into();
Err(msg)
})
}
}
let c = cfg(vec![entry("a", "a", Some(1), &[("build", "x", None)])]);
let mut probe = AlwaysErr;
let opts = ResolveOptions {
config: Some(c),
..Default::default()
};
let err = resolve_agent(opts, &mut probe)
.await
.expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("cookie read failed"), "got: {msg}");
assert!(msg.contains("probe failures"), "got: {msg}");
}
}