Skip to main content

seher/sdk/
resolve.rs

1//! Provider resolution engine.
2//!
3//! Mirrors `seher-ts/packages/sdk/src/sdk/resolve.ts`:
4//!  * Candidate list = providers that define `models[mode_key]`, filtered by
5//!    `provider`/`exclude`, sorted by `priority` desc then YAML `order` asc.
6//!  * Probe each in order via [`CodexBarProbe`] (the external `codexbar` binary);
7//!    first non-limited wins. If all limited and `!no_wait`/within `max_rescans`,
8//!    sleep until earliest reset and rescan.
9
10use 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
26/// Boxed probe future returned by [`LimitProbe::probe`].
27pub type ProbeFuture<'a> =
28    Pin<Box<dyn Future<Output = Result<AgentLimit, Box<dyn std::error::Error>>> + 'a>>;
29
30/// Trait for "ask whether this provider candidate is at-limit." Production uses
31/// [`CodexBarProbe`] (the external `codexbar` binary); tests inject mocks.
32pub 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
120/// Supported `sdk` values that can actually be executed by this implementation.
121///
122/// `pi_agent_rust` is the in-process execution engine; `claude-terminal` drives
123/// the local `claude` CLI via tmux. Providers tagged with other seher-ts-only SDK
124/// kinds (`claude`, `codex`, `copilot`, `cursor`, `kimi`, `opencode`) cannot be
125/// run here. The config still accepts them (so the same `config.yaml` works in
126/// both implementations); they are silently filtered out of the candidate list.
127pub 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/// Enumerate `(provider, sdk)` pairs from the YAML config whose `sdk` value is
135/// not executable by this implementation. Use to print a one-time warning at
136/// startup. Returns an empty Vec when every provider is runnable.
137#[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            // Drop providers whose `sdk` isn't executable here (e.g. seher-ts
160            // entries with `sdk: claude`). The same config.yaml is portable.
161            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    /// No candidates were available — either the list was empty, or every probe
204    /// errored. `probe_errors` records the latter so callers can surface root cause.
205    NoAgents {
206        probe_errors: Vec<(String, String)>,
207    },
208}
209
210/// Probe each candidate in order; return the first non-limited, otherwise summarise.
211pub 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
255/// Resolve the highest-priority non-limited provider for `mode_key`.
256///
257/// # Errors
258///
259/// Returns [`ResolveError::NoMatching`] when no provider defines the mode key
260/// (or all error out during probing), [`ResolveError::AllLimited`] when every
261/// candidate is at-limit and `no_wait`/`max_rescans` are exhausted, or
262/// [`ResolveError::Config`] on config-loading failures.
263pub 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
306/// Like [`resolve_agent`] but loops forever (until cancelled), sleeping
307/// `interval_ms` between scans when every candidate is at-limit.
308///
309/// # Errors
310///
311/// Returns [`ResolveError::Canceled`] when the cancel signal flips,
312/// [`ResolveError::NoMatching`] / [`ResolveError::Config`] on config issues.
313pub 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// ---------------------------------------------------------------------------
357// CodexBar-based probe (mirrors seher-ts, the production limit checker)
358// ---------------------------------------------------------------------------
359
360/// Map a YAML `(sdk, provider)` pair to the provider name codexbar expects.
361///
362/// Mirrors seher-ts `codexbarProviderName`: the `claude-terminal` SDK drives the
363/// Claude CLI which authenticates as the `claude` account, so it shares that
364/// account's codexbar quota.
365#[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
373/// Limit probe backed by the external `codexbar` binary. This is the production
374/// probe: it mirrors seher-ts, where limit determination is delegated entirely
375/// to `CodexBar` rather than per-provider cookie/API checks.
376pub 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                // A missing codexbar entry (community providers), an absent
389                // binary, or a transient spawn/timeout failure all mean "we
390                // can't prove this provider is limited" — treat it as available
391                // so resolution proceeds rather than dropping the provider.
392                Err(_) => Ok(AgentLimit::NotLimited),
393            }
394        })
395    }
396}
397
398/// Convenience wrapper: build a [`CodexBarProbe`] and run [`resolve_agent`].
399///
400/// # Errors
401///
402/// Same as [`resolve_agent`].
403pub 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        // claude-terminal shares the `claude` codexbar account.
618        assert_eq!(
619            codexbar_provider_name("claude-terminal", "claude"),
620            "claude"
621        );
622        // Any other sdk passes the provider name through unchanged.
623        assert_eq!(codexbar_provider_name("pi", "zai"), "zai");
624        assert_eq!(codexbar_provider_name("pi", "codex"), "codex");
625    }
626
627    // -----------------------------------------------------------------------
628    // sdk-filter (non-pi providers are excluded from candidates)
629    // -----------------------------------------------------------------------
630
631    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    // -----------------------------------------------------------------------
696    // poll_for_agent cancel-signal handling
697    // -----------------------------------------------------------------------
698
699    #[tokio::test(flavor = "current_thread")]
700    async fn poll_for_agent_returns_canceled_when_signal_preflipped() {
701        // cancel is already true before the first poll iteration → must short-circuit.
702        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    // -----------------------------------------------------------------------
749    // scan probe-error propagation
750    // -----------------------------------------------------------------------
751
752    #[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}