Skip to main content

ag_agent/model/
agent.rs

1use std::fmt;
2use std::str::FromStr;
3
4/// Supported agent provider families.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum AgentKind {
7    /// Google Antigravity CLI/backend.
8    Antigravity,
9    /// Google Gemini CLI/backend.
10    Gemini,
11    /// Anthropic Claude Code CLI/backend.
12    Claude,
13    /// `OpenAI` Codex CLI/backend.
14    Codex,
15}
16
17/// One locally runnable agent CLI and the installed version refreshed at
18/// startup.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct AgentCliInfo {
21    /// Executable name used to launch the provider CLI.
22    pub executable_name: &'static str,
23    /// Agent provider family backed by the executable.
24    pub kind: AgentKind,
25    /// Current automatic update and version probe state for this executable.
26    pub version: AgentCliVersion,
27}
28
29/// Automatic update and version probe state for one locally runnable agent
30/// CLI.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AgentCliVersion {
33    /// Startup update plus version detection is still running in the
34    /// background.
35    Loading,
36    /// Version detection finished, but the executable did not report a usable
37    /// version.
38    Unknown,
39    /// Version detection finished with a parsed display value.
40    Value(String),
41}
42
43impl AgentCliInfo {
44    /// Creates one CLI availability row for a provider and optional version.
45    pub fn new(kind: AgentKind, version: Option<String>) -> Self {
46        Self {
47            executable_name: kind.executable_name(),
48            kind,
49            version: version.map_or(AgentCliVersion::Unknown, AgentCliVersion::Value),
50        }
51    }
52
53    /// Creates one CLI availability row whose update/version refresh is still
54    /// loading.
55    pub fn loading(kind: AgentKind) -> Self {
56        Self {
57            executable_name: kind.executable_name(),
58            kind,
59            version: AgentCliVersion::Loading,
60        }
61    }
62
63    /// Builds unknown-version CLI rows for an existing provider availability
64    /// list.
65    pub fn from_kinds(agent_kinds: &[AgentKind]) -> Vec<Self> {
66        agent_kinds
67            .iter()
68            .copied()
69            .map(|agent_kind| Self::new(agent_kind, None))
70            .collect()
71    }
72
73    /// Builds loading CLI rows for an existing provider availability list
74    /// while the background update/version refresh is running.
75    pub fn loading_from_kinds(agent_kinds: &[AgentKind]) -> Vec<Self> {
76        agent_kinds.iter().copied().map(Self::loading).collect()
77    }
78}
79
80/// Supported agent model names across all providers.
81///
82/// Gemini model ids are shared by the direct Gemini and Antigravity providers,
83/// so provider ownership lives on [`AgentSelection`] rather than on these
84/// variants.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum AgentModel {
87    /// Codex Sol model backed by `gpt-5.6-sol`.
88    Gpt56Sol,
89    /// Codex Terra model backed by `gpt-5.6-terra`.
90    Gpt56Terra,
91    /// Codex Luna model backed by `gpt-5.6-luna`.
92    Gpt56Luna,
93    /// Fast Gemini model backed by `gemini-3.7-flash`.
94    Gemini37Flash,
95    /// Lightweight Gemini model backed by `gemini-3.5-flash-lite`.
96    Gemini35FlashLite,
97    /// Higher-quality Gemini preview model backed by `gemini-3.1-pro-preview`.
98    Gemini31Pro,
99    /// Codex spark model backed by `gpt-5.3-codex-spark`.
100    Gpt53CodexSpark,
101    /// Claude Opus model backed by `claude-opus-5`.
102    ClaudeOpus5,
103    /// Claude Sonnet model backed by `claude-sonnet-5`.
104    ClaudeSonnet5,
105    /// Claude Fable model backed by `claude-fable-5`.
106    ClaudeFable5,
107    /// Claude Haiku model backed by `claude-haiku-4-5-20251001`.
108    ClaudeHaiku4520251001,
109}
110
111/// Session-level agent selection that keeps provider kind and model together.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct AgentSelection {
114    kind: AgentKind,
115    model: AgentModel,
116}
117
118/// Supported reasoning-effort levels for task execution.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum ReasoningLevel {
121    /// Low reasoning effort for faster responses.
122    Low,
123    /// Medium reasoning effort.
124    Medium,
125    /// High reasoning effort for deeper reasoning.
126    #[default]
127    High,
128    /// Extra-high reasoning effort for deeper analysis.
129    XHigh,
130    /// Maximum reasoning effort for the hardest tasks.
131    Max,
132}
133
134impl AgentSelection {
135    /// Creates a coherent session agent selection.
136    ///
137    /// If `model` is not supported by `kind`, the provider's default model is
138    /// used so the selection never carries unrelated provider/model values.
139    #[must_use]
140    pub fn new(kind: AgentKind, model: AgentModel) -> Self {
141        let model = if kind.supports_model(model) {
142            model
143        } else {
144            kind.default_model()
145        };
146
147        Self { kind, model }
148    }
149
150    /// Returns the selected agent provider kind.
151    #[must_use]
152    pub fn kind(self) -> AgentKind {
153        self.kind
154    }
155
156    /// Returns the selected agent model.
157    #[must_use]
158    pub fn model(self) -> AgentModel {
159        self.model
160    }
161}
162
163/// Human-readable metadata for slash-menu selectable items.
164pub trait AgentSelectionMetadata {
165    /// Returns a stable item name shown in menus.
166    fn name(&self) -> &'static str;
167
168    /// Returns a short descriptive subtitle shown in menus.
169    fn description(&self) -> &'static str;
170}
171
172impl AgentModel {
173    /// Returns the stable wire/model identifier used in persistence and CLI
174    /// invocations.
175    pub fn as_str(self) -> &'static str {
176        match self {
177            Self::Gpt56Sol => "gpt-5.6-sol",
178            Self::Gpt56Terra => "gpt-5.6-terra",
179            Self::Gpt56Luna => "gpt-5.6-luna",
180            Self::Gemini37Flash => "gemini-3.7-flash",
181            Self::Gemini35FlashLite => "gemini-3.5-flash-lite",
182            Self::Gemini31Pro => "gemini-3.1-pro-preview",
183            Self::Gpt53CodexSpark => "gpt-5.3-codex-spark",
184            Self::ClaudeOpus5 => "claude-opus-5",
185            Self::ClaudeSonnet5 => "claude-sonnet-5",
186            Self::ClaudeFable5 => "claude-fable-5",
187            Self::ClaudeHaiku4520251001 => "claude-haiku-4-5-20251001",
188        }
189    }
190
191    /// Returns the model identifier passed to provider transports.
192    ///
193    /// Antigravity and Gemini use the same raw Gemini CLI model ids; callers
194    /// route those ids through the provider stored on [`AgentSelection`].
195    pub fn provider_model_str(self) -> &'static str {
196        self.as_str()
197    }
198
199    /// Parses one persisted model identifier and upgrades retired ids to
200    /// their replacement models.
201    ///
202    /// Stored retired Gemini, Claude, and Codex ids are migrated forward
203    /// through [`AgentModel::retired_replacement`] so existing projects and
204    /// sessions continue loading after model retirements.
205    /// Raw `gemini-*` ids parse to shared Gemini model variants; persisted
206    /// session agents decide whether Gemini or Antigravity owns the session.
207    ///
208    /// # Errors
209    /// Returns an error when `value` is not a known current or retired model
210    /// identifier.
211    pub fn parse_persisted(value: &str) -> Result<Self, String> {
212        if let Some(replacement) = Self::retired_replacement(value) {
213            return Ok(replacement);
214        }
215
216        value.parse()
217    }
218
219    /// Returns the current replacement model for a retired persisted model
220    /// id, or `None` when `value` is not a retired id.
221    ///
222    /// This registry is the single source of truth for model retirement.
223    /// Retiring a model means removing its selectable [`AgentModel`] variant
224    /// and mapping its persisted id to the replacement model here. Retired
225    /// ids never appear in selectable model lists; they stay in the database
226    /// as history for finished sessions, while sessions that are still active
227    /// are switched to the replacement automatically.
228    #[must_use]
229    pub fn retired_replacement(value: &str) -> Option<Self> {
230        const RETIRED_MODEL_REPLACEMENTS: &[(&str, AgentModel)] = &[
231            ("gemini-3-pro-preview", AgentModel::Gemini31Pro),
232            ("gemini-3.1-pro", AgentModel::Gemini31Pro),
233            ("gemini-3-flash-preview", AgentModel::Gemini37Flash),
234            ("gemini-3.6-flash", AgentModel::Gemini37Flash),
235            ("gemini-3.5-flash", AgentModel::Gemini35FlashLite),
236            (
237                "gemini-3.1-flash-lite-preview",
238                AgentModel::Gemini35FlashLite,
239            ),
240            ("claude-opus-4-8", AgentModel::ClaudeOpus5),
241            ("claude-opus-4-6", AgentModel::ClaudeOpus5),
242            ("claude-opus-4-7", AgentModel::ClaudeOpus5),
243            ("claude-sonnet-4-6", AgentModel::ClaudeSonnet5),
244            ("gpt-5.5", AgentModel::Gpt56Sol),
245            ("gpt-5.4-mini", AgentModel::Gpt56Luna),
246            ("gpt-5.4", AgentModel::Gpt56Sol),
247            ("gpt-5.3-codex", AgentModel::Gpt56Sol),
248            ("gpt-5.2-codex", AgentModel::Gpt53CodexSpark),
249        ];
250
251        RETIRED_MODEL_REPLACEMENTS
252            .iter()
253            .find(|(retired_id, _)| *retired_id == value)
254            .map(|(_, replacement)| *replacement)
255    }
256}
257
258/// Parses one persisted session agent/model pair without deriving the agent
259/// from the model when the saved agent kind is available.
260///
261/// Existing databases did not persist `agent`, so rows with a missing or
262/// invalid agent value fall back to a compatibility provider inferred from
263/// the persisted model string. Ambiguous raw `gemini-*` rows default to
264/// Antigravity because direct Gemini support had been removed before the new
265/// `agent` column was introduced.
266pub fn parse_persisted_session_agent_model(
267    agent_value: Option<&str>,
268    model_value: &str,
269) -> AgentSelection {
270    let parsed_agent = agent_value
271        .filter(|value| !value.trim().is_empty())
272        .and_then(|value| value.parse::<AgentKind>().ok());
273
274    if let Some(agent_kind) = parsed_agent {
275        return parse_model_for_persisted_agent(agent_kind, model_value);
276    }
277
278    let model = AgentModel::parse_persisted(model_value)
279        .unwrap_or_else(|_| AgentKind::Antigravity.default_model());
280    let agent_kind = legacy_agent_kind_for_model_value(model_value, model);
281
282    AgentSelection::new(agent_kind, model)
283}
284
285/// Parses a persisted model using the already persisted agent kind as the
286/// source of truth for provider ownership.
287fn parse_model_for_persisted_agent(agent_kind: AgentKind, model_value: &str) -> AgentSelection {
288    if let Some(model) = agent_kind.parse_model(model_value) {
289        return AgentSelection::new(agent_kind, model);
290    }
291
292    if let Ok(model) = AgentModel::parse_persisted(model_value)
293        && agent_kind.supports_model(model)
294    {
295        return AgentSelection::new(agent_kind, model);
296    }
297
298    AgentSelection::new(agent_kind, agent_kind.default_model())
299}
300
301/// Returns the provider used for legacy rows that predate explicit session
302/// agent persistence.
303fn legacy_agent_kind_for_model_value(model_value: &str, model: AgentModel) -> AgentKind {
304    if model_value.starts_with("claude-") {
305        return AgentKind::Claude;
306    }
307
308    if model_value.starts_with("gpt-") {
309        return AgentKind::Codex;
310    }
311
312    if model_value.starts_with("gemini-") {
313        return AgentKind::Antigravity;
314    }
315
316    AgentKind::ALL
317        .iter()
318        .copied()
319        .find(|agent_kind| agent_kind.supports_model(model))
320        .unwrap_or(AgentKind::Antigravity)
321}
322
323/// Returns all selectable models owned by the provided agent kinds in stable
324/// settings and slash-menu order.
325#[must_use]
326pub fn selectable_models_for_agent_kinds(agent_kinds: &[AgentKind]) -> Vec<AgentModel> {
327    let mut models = Vec::new();
328    for model in agent_kinds
329        .iter()
330        .flat_map(|agent_kind| agent_kind.models())
331        .copied()
332    {
333        if !models.contains(&model) {
334            models.push(model);
335        }
336    }
337
338    models
339}
340
341/// Resolves one model against the currently available agent kinds.
342///
343/// When `model` is unsupported by every available provider, this prefers
344/// `fallback_model` when any available provider supports it and otherwise falls
345/// back to the first available provider default in `agent_kinds`. When no
346/// providers are available, it returns `fallback_model` unchanged.
347#[must_use]
348pub fn resolve_model_for_available_agent_kinds(
349    model: AgentModel,
350    agent_kinds: &[AgentKind],
351    fallback_model: AgentModel,
352) -> AgentModel {
353    if agent_kinds
354        .iter()
355        .any(|agent_kind| agent_kind.supports_model(model))
356    {
357        return model;
358    }
359
360    if agent_kinds
361        .iter()
362        .any(|agent_kind| agent_kind.supports_model(fallback_model))
363    {
364        return fallback_model;
365    }
366
367    agent_kinds
368        .first()
369        .copied()
370        .map_or(fallback_model, AgentKind::default_model)
371}
372
373/// Resolves a provider kind that can run `model` from the available provider
374/// list.
375///
376/// Shared Gemini model ids can be run by both Gemini and Antigravity. This
377/// helper uses the order of `agent_kinds` as the tie-breaker and returns
378/// `fallback_agent_kind` when no available provider supports `model`.
379#[must_use]
380pub fn resolve_agent_kind_for_model(
381    model: AgentModel,
382    agent_kinds: &[AgentKind],
383    fallback_agent_kind: AgentKind,
384) -> AgentKind {
385    agent_kinds
386        .iter()
387        .copied()
388        .find(|agent_kind| agent_kind.supports_model(model))
389        .unwrap_or(fallback_agent_kind)
390}
391
392/// Resolves an [`AgentSelection`] for a model-only setting.
393///
394/// `preferred_agent_kind` is kept when it can run `model`, which preserves the
395/// current session provider for shared Gemini model ids. Otherwise the
396/// available provider order decides the owning provider, falling back to
397/// `preferred_agent_kind` when no available provider supports the model.
398#[must_use]
399pub fn resolve_agent_selection_for_model(
400    model: AgentModel,
401    preferred_agent_kind: AgentKind,
402    agent_kinds: &[AgentKind],
403) -> AgentSelection {
404    let agent_kind = if preferred_agent_kind.supports_model(model) {
405        preferred_agent_kind
406    } else {
407        resolve_agent_kind_for_model(model, agent_kinds, preferred_agent_kind)
408    };
409
410    AgentSelection::new(agent_kind, model)
411}
412
413/// Resolves the agent kind used for prompt-side `/model` selection.
414///
415/// This preserves `session_agent_kind` when that backend is still available
416/// and otherwise falls back to the first available backend. When no backends
417/// are available, it returns `None`.
418#[must_use]
419pub fn resolve_prompt_model_agent_kind(
420    session_agent_kind: AgentKind,
421    agent_kinds: &[AgentKind],
422) -> Option<AgentKind> {
423    if agent_kinds.contains(&session_agent_kind) {
424        return Some(session_agent_kind);
425    }
426
427    agent_kinds.first().copied()
428}
429
430impl ReasoningLevel {
431    /// All selectable reasoning-effort levels in UI display order.
432    pub const ALL: [Self; 5] = [Self::Low, Self::Medium, Self::High, Self::XHigh, Self::Max];
433
434    /// Returns the stable persisted identifier for this level.
435    ///
436    /// This value is stored in the database and remains independent from any
437    /// provider-specific transport string changes.
438    pub fn as_str(self) -> &'static str {
439        match self {
440            Self::Low => "low",
441            Self::Medium => "medium",
442            Self::High => "high",
443            Self::XHigh => "xhigh",
444            Self::Max => "max",
445        }
446    }
447
448    /// Returns the Codex reasoning-effort identifier for this level.
449    pub fn codex(self) -> &'static str {
450        match self {
451            Self::Low => "low",
452            Self::Medium => "medium",
453            Self::High => "high",
454            Self::XHigh => "xhigh",
455            Self::Max => "max",
456        }
457    }
458
459    /// Returns the Antigravity `--effort` value for this level.
460    ///
461    /// Antigravity accepts `low`, `medium`, and `high`, so higher generic
462    /// reasoning levels map to its highest supported value.
463    pub fn antigravity(self) -> &'static str {
464        match self {
465            Self::Low => "low",
466            Self::Medium => "medium",
467            Self::High | Self::XHigh | Self::Max => "high",
468        }
469    }
470
471    /// Returns the Claude `--effort` value for this level.
472    ///
473    /// Maps `XHigh` and `Max` to `"max"`, which is currently only supported on
474    /// `claude-opus-5`. The Claude CLI enforces this
475    /// restriction and will surface an error for other models.
476    pub fn claude(self) -> &'static str {
477        match self {
478            Self::Low => "low",
479            Self::Medium => "medium",
480            Self::High => "high",
481            Self::XHigh | Self::Max => "max",
482        }
483    }
484
485    /// Returns a short UI description for this reasoning level.
486    pub fn description(self) -> &'static str {
487        match self {
488            Self::Low => "Fastest responses with lighter reasoning.",
489            Self::Medium => "Balanced speed and reasoning depth.",
490            Self::High => "Deeper reasoning for tougher tasks.",
491            Self::XHigh => "Extra-high reasoning for complex tasks.",
492            Self::Max => "Maximum reasoning effort for the hardest tasks.",
493        }
494    }
495}
496
497impl FromStr for ReasoningLevel {
498    type Err = String;
499
500    fn from_str(value: &str) -> Result<Self, Self::Err> {
501        match value {
502            "low" => Ok(Self::Low),
503            "medium" => Ok(Self::Medium),
504            "high" => Ok(Self::High),
505            "xhigh" => Ok(Self::XHigh),
506            "max" => Ok(Self::Max),
507            other => Err(format!("unknown reasoning level: {other}")),
508        }
509    }
510}
511
512impl FromStr for AgentModel {
513    type Err = String;
514
515    fn from_str(value: &str) -> Result<Self, Self::Err> {
516        match value {
517            "gemini-3.7-flash" => Ok(Self::Gemini37Flash),
518            "gemini-3.5-flash-lite" => Ok(Self::Gemini35FlashLite),
519            "gemini-3.1-pro-preview" => Ok(Self::Gemini31Pro),
520            "gpt-5.6-sol" => Ok(Self::Gpt56Sol),
521            "gpt-5.6-terra" => Ok(Self::Gpt56Terra),
522            "gpt-5.6-luna" => Ok(Self::Gpt56Luna),
523            "gpt-5.3-codex-spark" => Ok(Self::Gpt53CodexSpark),
524            "claude-opus-5" => Ok(Self::ClaudeOpus5),
525            "claude-sonnet-5" => Ok(Self::ClaudeSonnet5),
526            "claude-fable-5" => Ok(Self::ClaudeFable5),
527            "claude-haiku-4-5-20251001" => Ok(Self::ClaudeHaiku4520251001),
528            other => Err(format!("unknown model: {other}")),
529        }
530    }
531}
532
533impl AgentSelectionMetadata for AgentModel {
534    fn name(&self) -> &'static str {
535        (*self).provider_model_str()
536    }
537
538    fn description(&self) -> &'static str {
539        match self {
540            Self::Gemini31Pro => "Higher-quality Gemini model for deeper reasoning.",
541            Self::Gemini37Flash => "Fast Gemini model for agentic and multimodal tasks.",
542            Self::Gemini35FlashLite => {
543                "Lightweight Gemini model for fast, cost-conscious workloads."
544            }
545            Self::Gpt56Sol => "Newest Codex model for the strongest coding performance.",
546            Self::Gpt56Terra => "Current Codex model for balanced coding performance.",
547            Self::Gpt56Luna => "Current Codex model for lighter coding iterations.",
548            Self::Gpt53CodexSpark => "Codex spark model for quick coding iterations.",
549            Self::ClaudeOpus5 => "Latest Claude Opus model for complex tasks.",
550            Self::ClaudeSonnet5 => "Balanced Claude model for quality and latency.",
551            Self::ClaudeFable5 => "Claude Fable model for creative, narrative-heavy tasks.",
552            Self::ClaudeHaiku4520251001 => "Fast Claude model for lighter tasks.",
553        }
554    }
555}
556
557impl AgentKind {
558    /// All available agent kinds, in display order.
559    pub const ALL: &[AgentKind] = &[
560        AgentKind::Gemini,
561        AgentKind::Antigravity,
562        AgentKind::Claude,
563        AgentKind::Codex,
564    ];
565
566    /// Returns the provider CLI executable name.
567    pub fn executable_name(self) -> &'static str {
568        match self {
569            Self::Antigravity => "agy",
570            Self::Gemini => "gemini",
571            Self::Claude => "claude",
572            Self::Codex => "codex",
573        }
574    }
575
576    /// Returns the default model for this agent kind.
577    pub fn default_model(self) -> AgentModel {
578        match self {
579            Self::Antigravity | Self::Gemini => AgentModel::Gemini31Pro,
580            Self::Claude => AgentModel::ClaudeFable5,
581            Self::Codex => AgentModel::Gpt56Sol,
582        }
583    }
584
585    /// Returns the model string when it belongs to this agent kind.
586    pub fn model_str(self, model: AgentModel) -> Option<&'static str> {
587        if !self.supports_model(model) {
588            return None;
589        }
590
591        Some(model.as_str())
592    }
593
594    /// Returns the curated model list for this agent kind.
595    pub fn models(self) -> &'static [AgentModel] {
596        const ANTIGRAVITY_MODELS: &[AgentModel] = &[
597            AgentModel::Gemini31Pro,
598            AgentModel::Gemini37Flash,
599            AgentModel::Gemini35FlashLite,
600        ];
601        const GEMINI_MODELS: &[AgentModel] = &[
602            AgentModel::Gemini31Pro,
603            AgentModel::Gemini37Flash,
604            AgentModel::Gemini35FlashLite,
605        ];
606        const CLAUDE_MODELS: &[AgentModel] = &[
607            AgentModel::ClaudeFable5,
608            AgentModel::ClaudeOpus5,
609            AgentModel::ClaudeSonnet5,
610            AgentModel::ClaudeHaiku4520251001,
611        ];
612        const CODEX_MODELS: &[AgentModel] = &[
613            AgentModel::Gpt56Sol,
614            AgentModel::Gpt56Terra,
615            AgentModel::Gpt56Luna,
616            AgentModel::Gpt53CodexSpark,
617        ];
618
619        match self {
620            Self::Antigravity => ANTIGRAVITY_MODELS,
621            Self::Gemini => GEMINI_MODELS,
622            Self::Claude => CLAUDE_MODELS,
623            Self::Codex => CODEX_MODELS,
624        }
625    }
626
627    /// Parses a provider-specific model string for this agent kind.
628    pub fn parse_model(self, value: &str) -> Option<AgentModel> {
629        let model = value.parse::<AgentModel>().ok()?;
630        if !self.supports_model(model) {
631            return None;
632        }
633
634        Some(model)
635    }
636
637    /// Returns whether this provider can run the given model.
638    pub fn supports_model(self, model: AgentModel) -> bool {
639        self.models().contains(&model)
640    }
641
642    /// Returns whether this provider exposes a response-speed control.
643    ///
644    /// Claude and Codex accept a per-turn speed selection, so `/speed` offers
645    /// the picker and session surfaces display the active [`SpeedMode`].
646    /// Gemini and Antigravity have no equivalent control, so neither the
647    /// command nor the speed display is offered for them.
648    ///
649    /// [`SpeedMode`]: crate::model::session::SpeedMode
650    pub fn supports_speed_mode(self) -> bool {
651        matches!(self, Self::Claude | Self::Codex)
652    }
653}
654
655impl AgentSelectionMetadata for AgentKind {
656    fn name(&self) -> &'static str {
657        match self {
658            Self::Antigravity => "antigravity",
659            Self::Gemini => "gemini",
660            Self::Claude => "claude",
661            Self::Codex => "codex",
662        }
663    }
664
665    fn description(&self) -> &'static str {
666        match self {
667            Self::Antigravity => "Google Antigravity CLI agent.",
668            Self::Gemini => "Google Gemini CLI agent.",
669            Self::Claude => "Anthropic Claude Code agent.",
670            Self::Codex => "OpenAI Codex CLI agent.",
671        }
672    }
673}
674
675impl AgentSelectionMetadata for ReasoningLevel {
676    fn name(&self) -> &'static str {
677        (*self).as_str()
678    }
679
680    fn description(&self) -> &'static str {
681        (*self).description()
682    }
683}
684
685impl fmt::Display for AgentKind {
686    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
687        write!(f, "{}", self.name())
688    }
689}
690
691impl FromStr for AgentKind {
692    type Err = String;
693
694    fn from_str(s: &str) -> Result<Self, Self::Err> {
695        match s.to_lowercase().as_str() {
696            "antigravity" | "agy" => Ok(Self::Antigravity),
697            "gemini" => Ok(Self::Gemini),
698            "claude" => Ok(Self::Claude),
699            "codex" => Ok(Self::Codex),
700            other => Err(format!("unknown agent kind: {other}")),
701        }
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    /// Ensures model parsing is constrained to the selected provider.
711    fn test_parse_model_returns_none_for_models_from_other_providers() {
712        // Arrange
713        let claude_kind = AgentKind::Claude;
714        let antigravity_model = AgentModel::Gemini37Flash.as_str();
715
716        // Act
717        let parsed_model = claude_kind.parse_model(antigravity_model);
718
719        // Assert
720        assert_eq!(parsed_model, None);
721    }
722
723    #[test]
724    /// Ensures current GPT Codex model ids parse as Codex models.
725    fn test_parse_model_parses_current_codex_models() {
726        // Arrange
727        let codex_kind = AgentKind::Codex;
728
729        // Act
730        let parsed_sol = codex_kind.parse_model("gpt-5.6-sol");
731        let parsed_terra = codex_kind.parse_model("gpt-5.6-terra");
732        let parsed_luna = codex_kind.parse_model("gpt-5.6-luna");
733        let parsed_spark = codex_kind.parse_model("gpt-5.3-codex-spark");
734
735        // Assert
736        assert_eq!(parsed_sol, Some(AgentModel::Gpt56Sol));
737        assert_eq!(parsed_terra, Some(AgentModel::Gpt56Terra));
738        assert_eq!(parsed_luna, Some(AgentModel::Gpt56Luna));
739        assert_eq!(parsed_spark, Some(AgentModel::Gpt53CodexSpark));
740    }
741
742    #[test]
743    /// Ensures Antigravity parses raw Gemini model ids as shared Gemini model
744    /// selections.
745    fn test_parse_model_parses_antigravity() {
746        // Arrange
747        let antigravity_kind = AgentKind::Antigravity;
748
749        // Act
750        let parsed_pro = antigravity_kind.parse_model("gemini-3.1-pro-preview");
751        let parsed_flash_37 = antigravity_kind.parse_model("gemini-3.7-flash");
752        let parsed_flash_35_lite = antigravity_kind.parse_model("gemini-3.5-flash-lite");
753
754        // Assert
755        assert_eq!(parsed_pro, Some(AgentModel::Gemini31Pro));
756        assert_eq!(parsed_flash_37, Some(AgentModel::Gemini37Flash));
757        assert_eq!(parsed_flash_35_lite, Some(AgentModel::Gemini35FlashLite));
758    }
759
760    #[test]
761    /// Ensures direct Gemini parses the same raw Gemini model ids as
762    /// Antigravity.
763    fn test_parse_model_parses_direct_gemini() {
764        // Arrange
765        let gemini_kind = AgentKind::Gemini;
766
767        // Act
768        let parsed_model = gemini_kind.parse_model("gemini-3.7-flash");
769
770        // Assert
771        assert_eq!(parsed_model, Some(AgentModel::Gemini37Flash));
772    }
773
774    #[test]
775    /// Ensures both Google providers reject the invalid non-preview Gemini
776    /// 3.1 Pro identifier.
777    fn test_parse_model_rejects_non_preview_gemini_31_pro() {
778        // Arrange
779        let agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
780
781        // Act
782        let parsed_models = agent_kinds.map(|kind| kind.parse_model("gemini-3.1-pro"));
783
784        // Assert
785        assert_eq!(parsed_models, [None; 2]);
786    }
787
788    #[test]
789    /// Ensures current Gemini models expose their user-facing descriptions.
790    fn test_current_gemini_models_have_current_descriptions() {
791        // Arrange
792        let models = [
793            AgentModel::Gemini31Pro,
794            AgentModel::Gemini37Flash,
795            AgentModel::Gemini35FlashLite,
796        ];
797
798        // Act
799        let descriptions = models.map(|model| model.description());
800
801        // Assert
802        assert_eq!(
803            descriptions,
804            [
805                "Higher-quality Gemini model for deeper reasoning.",
806                "Fast Gemini model for agentic and multimodal tasks.",
807                "Lightweight Gemini model for fast, cost-conscious workloads.",
808            ]
809        );
810    }
811
812    #[test]
813    /// Ensures retired GPT model ids no longer parse as selectable models.
814    fn test_parse_model_rejects_retired_gpt_models() {
815        // Arrange
816        let codex_kind = AgentKind::Codex;
817
818        // Act
819        let parsed_gpt_54 = codex_kind.parse_model("gpt-5.4");
820        let parsed_gpt_54_mini = codex_kind.parse_model("gpt-5.4-mini");
821
822        // Assert
823        assert_eq!(parsed_gpt_54, None);
824        assert_eq!(parsed_gpt_54_mini, None);
825    }
826
827    #[test]
828    /// Ensures retired Claude aliases no longer parse as selectable
829    /// models.
830    fn test_parse_model_rejects_retired_claude_aliases() {
831        // Arrange
832        let claude_kind = AgentKind::Claude;
833
834        // Act
835        let parsed_opus_46 = claude_kind.parse_model("claude-opus-4-6");
836        let parsed_opus_47 = claude_kind.parse_model("claude-opus-4-7");
837        let parsed_sonnet_46 = claude_kind.parse_model("claude-sonnet-4-6");
838
839        // Assert
840        assert_eq!(parsed_opus_46, None);
841        assert_eq!(parsed_opus_47, None);
842        assert_eq!(parsed_sonnet_46, None);
843    }
844
845    #[test]
846    /// Ensures current Claude model ids parse as supported Claude models.
847    fn test_parse_model_parses_current_claude_models() {
848        // Arrange
849        let claude_kind = AgentKind::Claude;
850
851        // Act
852        let parsed_opus_5 = claude_kind.parse_model("claude-opus-5");
853        let parsed_sonnet_5 = claude_kind.parse_model("claude-sonnet-5");
854        let parsed_fable_5 = claude_kind.parse_model("claude-fable-5");
855        let parsed_haiku_45 = claude_kind.parse_model("claude-haiku-4-5-20251001");
856        let opus_5_id = AgentModel::ClaudeOpus5.as_str();
857        let opus_5_description = AgentModel::ClaudeOpus5.description();
858
859        // Assert
860        assert_eq!(parsed_opus_5, Some(AgentModel::ClaudeOpus5));
861        assert_eq!(parsed_sonnet_5, Some(AgentModel::ClaudeSonnet5));
862        assert_eq!(parsed_fable_5, Some(AgentModel::ClaudeFable5));
863        assert_eq!(parsed_haiku_45, Some(AgentModel::ClaudeHaiku4520251001));
864        assert_eq!(opus_5_id, "claude-opus-5");
865        assert_eq!(
866            opus_5_description,
867            "Latest Claude Opus model for complex tasks."
868        );
869    }
870
871    #[test]
872    /// Ensures the retirement registry maps retired ids to replacements and
873    /// leaves current ids unmapped.
874    fn test_retired_replacement_maps_only_retired_ids() {
875        // Arrange
876        let retired_ids = [
877            ("gemini-3-pro-preview", AgentModel::Gemini31Pro),
878            ("gemini-3.1-pro", AgentModel::Gemini31Pro),
879            ("gemini-3-flash-preview", AgentModel::Gemini37Flash),
880            ("gemini-3.6-flash", AgentModel::Gemini37Flash),
881            ("gemini-3.5-flash", AgentModel::Gemini35FlashLite),
882            (
883                "gemini-3.1-flash-lite-preview",
884                AgentModel::Gemini35FlashLite,
885            ),
886            ("claude-opus-4-8", AgentModel::ClaudeOpus5),
887            ("claude-opus-4-6", AgentModel::ClaudeOpus5),
888            ("claude-opus-4-7", AgentModel::ClaudeOpus5),
889            ("claude-sonnet-4-6", AgentModel::ClaudeSonnet5),
890            ("gpt-5.5", AgentModel::Gpt56Sol),
891            ("gpt-5.4-mini", AgentModel::Gpt56Luna),
892            ("gpt-5.4", AgentModel::Gpt56Sol),
893            ("gpt-5.3-codex", AgentModel::Gpt56Sol),
894            ("gpt-5.2-codex", AgentModel::Gpt53CodexSpark),
895        ];
896
897        // Act
898        let replacements =
899            retired_ids.map(|(retired_id, _)| AgentModel::retired_replacement(retired_id));
900        let selectable_parses = retired_ids.map(|(retired_id, _)| retired_id.parse::<AgentModel>());
901        let current_replacements = [
902            "gemini-3.1-pro-preview",
903            "gemini-3.7-flash",
904            "gemini-3.5-flash-lite",
905            "claude-opus-5",
906        ]
907        .map(AgentModel::retired_replacement);
908        let unknown_replacement = AgentModel::retired_replacement("not-a-model");
909
910        // Assert
911        assert_eq!(
912            replacements,
913            retired_ids.map(|(_, replacement)| Some(replacement))
914        );
915        assert!(selectable_parses.iter().all(Result::is_err));
916        assert_eq!(current_replacements, [None; 4]);
917        assert_eq!(unknown_replacement, None);
918    }
919
920    #[test]
921    /// Ensures persisted model ids parse or migrate to supported
922    /// models.
923    fn test_parse_persisted_handles_supported_and_retired_models() {
924        // Arrange
925
926        // Act
927        let parsed_opus_46 = AgentModel::parse_persisted("claude-opus-4-6");
928        let parsed_opus_47 = AgentModel::parse_persisted("claude-opus-4-7");
929        let parsed_sonnet_46 = AgentModel::parse_persisted("claude-sonnet-4-6");
930        let parsed_sonnet_5 = AgentModel::parse_persisted("claude-sonnet-5");
931        let parsed_gpt_54_mini = AgentModel::parse_persisted("gpt-5.4-mini");
932        let parsed_gpt_54 = AgentModel::parse_persisted("gpt-5.4");
933        let parsed_gemini_37_flash = AgentModel::parse_persisted("gemini-3.7-flash");
934        let parsed_gemini_36_flash = AgentModel::parse_persisted("gemini-3.6-flash");
935        let parsed_gemini_35_flash = AgentModel::parse_persisted("gemini-3.5-flash");
936        let parsed_gemini_3_flash_preview = AgentModel::parse_persisted("gemini-3-flash-preview");
937        let parsed_gemini_35_flash_lite = AgentModel::parse_persisted("gemini-3.5-flash-lite");
938        let parsed_gemini_31_pro = AgentModel::parse_persisted("gemini-3.1-pro");
939        let parsed_gemini_31_pro_preview = AgentModel::parse_persisted("gemini-3.1-pro-preview");
940        let parsed_gemini_31_flash_lite_preview =
941            AgentModel::parse_persisted("gemini-3.1-flash-lite-preview");
942
943        // Assert
944        assert_eq!(parsed_opus_46, Ok(AgentModel::ClaudeOpus5));
945        assert_eq!(parsed_opus_47, Ok(AgentModel::ClaudeOpus5));
946        assert_eq!(parsed_sonnet_46, Ok(AgentModel::ClaudeSonnet5));
947        assert_eq!(parsed_sonnet_5, Ok(AgentModel::ClaudeSonnet5));
948        assert_eq!(parsed_gpt_54_mini, Ok(AgentModel::Gpt56Luna));
949        assert_eq!(parsed_gpt_54, Ok(AgentModel::Gpt56Sol));
950        assert_eq!(parsed_gemini_37_flash, Ok(AgentModel::Gemini37Flash));
951        assert_eq!(parsed_gemini_36_flash, Ok(AgentModel::Gemini37Flash));
952        assert_eq!(parsed_gemini_35_flash, Ok(AgentModel::Gemini35FlashLite));
953        assert_eq!(parsed_gemini_3_flash_preview, Ok(AgentModel::Gemini37Flash));
954        assert_eq!(
955            parsed_gemini_35_flash_lite,
956            Ok(AgentModel::Gemini35FlashLite)
957        );
958        assert_eq!(parsed_gemini_31_pro, Ok(AgentModel::Gemini31Pro));
959        assert_eq!(parsed_gemini_31_pro_preview, Ok(AgentModel::Gemini31Pro));
960        assert_eq!(
961            parsed_gemini_31_flash_lite_preview,
962            Ok(AgentModel::Gemini35FlashLite)
963        );
964    }
965
966    #[test]
967    /// Ensures persisted session agent values constrain model parsing instead
968    /// of deriving provider ownership from the model string.
969    fn test_parse_persisted_session_agent_model_prefers_saved_agent() {
970        // Arrange
971
972        // Act
973        let selection = parse_persisted_session_agent_model(Some("codex"), "gemini-3.7-flash");
974
975        // Assert
976        assert_eq!(selection.kind(), AgentKind::Codex);
977        assert_eq!(selection.model(), AgentKind::Codex.default_model());
978    }
979
980    #[test]
981    /// Ensures Antigravity-persisted sessions keep Antigravity ownership for
982    /// raw Gemini model ids shared with the direct Gemini backend.
983    fn test_parse_persisted_session_agent_model_preserves_antigravity_models() {
984        // Arrange
985
986        // Act
987        let selection =
988            parse_persisted_session_agent_model(Some("antigravity"), "gemini-3.7-flash");
989
990        // Assert
991        assert_eq!(selection.kind(), AgentKind::Antigravity);
992        assert_eq!(selection.model(), AgentModel::Gemini37Flash);
993    }
994
995    #[test]
996    /// Ensures current and retired Gemini models preserve the saved Google
997    /// provider while resolving to supported models.
998    fn test_parse_persisted_session_agent_model_resolves_saved_google_models() {
999        // Arrange
1000        let persisted_selections = [
1001            ("gemini", "gemini-3.5-flash-lite"),
1002            ("gemini", "gemini-3.5-flash"),
1003            ("antigravity", "gemini-3.5-flash-lite"),
1004            ("antigravity", "gemini-3.5-flash"),
1005        ];
1006
1007        // Act
1008        let migrated_selections = persisted_selections.map(|(agent_value, model_value)| {
1009            parse_persisted_session_agent_model(Some(agent_value), model_value)
1010        });
1011
1012        // Assert
1013        assert_eq!(
1014            migrated_selections,
1015            [
1016                AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini35FlashLite),
1017                AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini35FlashLite),
1018                AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite),
1019                AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite),
1020            ]
1021        );
1022    }
1023
1024    #[test]
1025    /// Ensures older persisted rows without `agent` still load through the
1026    /// legacy model-derived compatibility path.
1027    fn test_parse_persisted_session_agent_model_supports_legacy_rows() {
1028        // Arrange
1029
1030        // Act
1031        let selection = parse_persisted_session_agent_model(None, "claude-opus-4-6");
1032
1033        // Assert
1034        assert_eq!(selection.kind(), AgentKind::Claude);
1035        assert_eq!(selection.model(), AgentModel::ClaudeOpus5);
1036    }
1037
1038    #[test]
1039    /// Ensures older `gemini-*` rows without `agent` keep the post-removal
1040    /// Antigravity compatibility default.
1041    fn test_parse_persisted_session_agent_model_defaults_legacy_gemini_to_antigravity() {
1042        // Arrange
1043
1044        // Act
1045        let selection = parse_persisted_session_agent_model(None, "gemini-3.7-flash");
1046
1047        // Assert
1048        assert_eq!(selection.kind(), AgentKind::Antigravity);
1049        assert_eq!(selection.model(), AgentModel::Gemini37Flash);
1050    }
1051
1052    #[test]
1053    /// Ensures current and retired Gemini models in rows without a saved
1054    /// provider resolve through the legacy Antigravity compatibility path.
1055    fn test_parse_persisted_session_agent_model_resolves_legacy_gemini_rows() {
1056        // Arrange
1057
1058        // Act
1059        let migrated_flash = parse_persisted_session_agent_model(None, "gemini-3.5-flash");
1060        let loaded_flash_lite = parse_persisted_session_agent_model(None, "gemini-3.5-flash-lite");
1061
1062        // Assert
1063        assert_eq!(
1064            migrated_flash,
1065            AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite)
1066        );
1067        assert_eq!(
1068            loaded_flash_lite,
1069            AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite)
1070        );
1071    }
1072
1073    #[test]
1074    /// Ensures reasoning-level parsing accepts all supported persisted values.
1075    fn test_reasoning_level_from_str_parses_supported_values() {
1076        // Arrange
1077
1078        // Act
1079        let low_level = "low".parse::<ReasoningLevel>();
1080        let medium_level = "medium".parse::<ReasoningLevel>();
1081        let high_level = "high".parse::<ReasoningLevel>();
1082        let xhigh_level = "xhigh".parse::<ReasoningLevel>();
1083        let max_level = "max".parse::<ReasoningLevel>();
1084
1085        // Assert
1086        assert_eq!(low_level, Ok(ReasoningLevel::Low));
1087        assert_eq!(medium_level, Ok(ReasoningLevel::Medium));
1088        assert_eq!(high_level, Ok(ReasoningLevel::High));
1089        assert_eq!(xhigh_level, Ok(ReasoningLevel::XHigh));
1090        assert_eq!(max_level, Ok(ReasoningLevel::Max));
1091    }
1092
1093    #[test]
1094    /// Ensures unsupported reasoning values return a parse error.
1095    fn test_reasoning_level_from_str_rejects_unknown_values() {
1096        // Arrange
1097
1098        // Act
1099        let parse_result = "minimal".parse::<ReasoningLevel>();
1100
1101        // Assert
1102        assert!(parse_result.is_err());
1103    }
1104
1105    #[test]
1106    /// Ensures Codex models are supported by Codex only.
1107    fn test_codex_models_are_supported_by_codex() {
1108        // Arrange
1109        let models = [
1110            AgentModel::Gpt56Sol,
1111            AgentModel::Gpt56Terra,
1112            AgentModel::Gpt56Luna,
1113            AgentModel::Gpt53CodexSpark,
1114        ];
1115
1116        // Act
1117        let supported = models.map(|model| AgentKind::Codex.supports_model(model));
1118        let unsupported = models.map(|model| AgentKind::Claude.supports_model(model));
1119
1120        // Assert
1121        assert_eq!(supported, [true; 4]);
1122        assert_eq!(unsupported, [false; 4]);
1123    }
1124
1125    #[test]
1126    /// Ensures Claude models are supported by Claude only.
1127    fn test_claude_models_are_supported_by_claude() {
1128        // Arrange
1129        let models = [
1130            AgentModel::ClaudeOpus5,
1131            AgentModel::ClaudeSonnet5,
1132            AgentModel::ClaudeFable5,
1133            AgentModel::ClaudeHaiku4520251001,
1134        ];
1135
1136        // Act
1137        let supported = models.map(|model| AgentKind::Claude.supports_model(model));
1138        let unsupported = models.map(|model| AgentKind::Codex.supports_model(model));
1139
1140        // Assert
1141        assert_eq!(supported, [true; 4]);
1142        assert_eq!(unsupported, [false; 4]);
1143    }
1144
1145    #[test]
1146    /// Ensures shared Gemini models are supported by both Google providers.
1147    fn test_gemini_models_are_supported_by_gemini_and_antigravity() {
1148        // Arrange
1149        let models = [
1150            AgentModel::Gemini31Pro,
1151            AgentModel::Gemini37Flash,
1152            AgentModel::Gemini35FlashLite,
1153        ];
1154
1155        // Act
1156        let antigravity_supported =
1157            models.map(|model| AgentKind::Antigravity.supports_model(model));
1158        let gemini_supported = models.map(|model| AgentKind::Gemini.supports_model(model));
1159        let codex_supported = models.map(|model| AgentKind::Codex.supports_model(model));
1160
1161        // Assert
1162        assert_eq!(antigravity_supported, [true; 3]);
1163        assert_eq!(gemini_supported, [true; 3]);
1164        assert_eq!(codex_supported, [false; 3]);
1165    }
1166
1167    #[test]
1168    /// Ensures only providers with a response-speed control report support.
1169    fn test_supports_speed_mode_covers_claude_and_codex_only() {
1170        // Arrange
1171        let kinds = [
1172            AgentKind::Claude,
1173            AgentKind::Codex,
1174            AgentKind::Gemini,
1175            AgentKind::Antigravity,
1176        ];
1177
1178        // Act
1179        let supported = kinds.map(AgentKind::supports_speed_mode);
1180
1181        // Assert
1182        assert_eq!(supported, [true, true, false, false]);
1183    }
1184
1185    #[test]
1186    /// Ensures Antigravity model selections pass raw Gemini ids to provider
1187    /// transports.
1188    fn test_antigravity_provider_model_str_returns_raw_gemini_model() {
1189        // Arrange
1190        let model = AgentModel::Gemini35FlashLite;
1191
1192        // Act
1193        let persisted_model = model.as_str();
1194        let provider_model = model.provider_model_str();
1195
1196        // Assert
1197        assert_eq!(persisted_model, "gemini-3.5-flash-lite");
1198        assert_eq!(provider_model, "gemini-3.5-flash-lite");
1199    }
1200
1201    #[test]
1202    /// Ensures `ReasoningLevel::claude()` maps all levels to the correct
1203    /// Claude `--effort` values, including the highest generic levels.
1204    fn test_reasoning_level_claude_maps_all_levels() {
1205        // Arrange / Act / Assert
1206        assert_eq!(ReasoningLevel::Low.claude(), "low");
1207        assert_eq!(ReasoningLevel::Medium.claude(), "medium");
1208        assert_eq!(ReasoningLevel::High.claude(), "high");
1209        assert_eq!(ReasoningLevel::XHigh.claude(), "max");
1210        assert_eq!(ReasoningLevel::Max.claude(), "max");
1211    }
1212
1213    #[test]
1214    /// Ensures Antigravity reasoning values stay within the CLI's accepted
1215    /// `low`, `medium`, and `high` effort levels.
1216    fn test_reasoning_level_antigravity_maps_all_levels() {
1217        // Arrange / Act / Assert
1218        assert_eq!(ReasoningLevel::Low.antigravity(), "low");
1219        assert_eq!(ReasoningLevel::Medium.antigravity(), "medium");
1220        assert_eq!(ReasoningLevel::High.antigravity(), "high");
1221        assert_eq!(ReasoningLevel::XHigh.antigravity(), "high");
1222        assert_eq!(ReasoningLevel::Max.antigravity(), "high");
1223    }
1224
1225    #[test]
1226    /// Ensures Codex reasoning values include the distinct `max` effort.
1227    fn test_reasoning_level_codex_maps_all_levels() {
1228        // Arrange / Act / Assert
1229        assert_eq!(ReasoningLevel::Low.codex(), "low");
1230        assert_eq!(ReasoningLevel::Medium.codex(), "medium");
1231        assert_eq!(ReasoningLevel::High.codex(), "high");
1232        assert_eq!(ReasoningLevel::XHigh.codex(), "xhigh");
1233        assert_eq!(ReasoningLevel::Max.codex(), "max");
1234    }
1235
1236    #[test]
1237    /// Ensures persisted reasoning identifiers stay stable even if provider
1238    /// transport names change in the future.
1239    fn test_reasoning_level_as_str_returns_stable_persisted_values() {
1240        // Arrange / Act / Assert
1241        assert_eq!(ReasoningLevel::Low.as_str(), "low");
1242        assert_eq!(ReasoningLevel::Medium.as_str(), "medium");
1243        assert_eq!(ReasoningLevel::High.as_str(), "high");
1244        assert_eq!(ReasoningLevel::XHigh.as_str(), "xhigh");
1245        assert_eq!(ReasoningLevel::Max.as_str(), "max");
1246    }
1247
1248    #[test]
1249    /// Ensures selectable-model ordering follows the provided provider order.
1250    fn test_selectable_models_for_agent_kinds_uses_provider_order() {
1251        // Arrange
1252        let agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1253
1254        // Act
1255        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
1256
1257        // Assert
1258        assert_eq!(
1259            selectable_models,
1260            vec![
1261                AgentModel::Gpt56Sol,
1262                AgentModel::Gpt56Terra,
1263                AgentModel::Gpt56Luna,
1264                AgentModel::Gpt53CodexSpark,
1265                AgentModel::Gemini31Pro,
1266                AgentModel::Gemini37Flash,
1267                AgentModel::Gemini35FlashLite,
1268            ]
1269        );
1270    }
1271
1272    #[test]
1273    /// Ensures shared Gemini models appear once even when both Google
1274    /// providers are available.
1275    fn test_selectable_models_for_agent_kinds_deduplicates_shared_models() {
1276        // Arrange
1277        let agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1278
1279        // Act
1280        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
1281
1282        // Assert
1283        assert_eq!(
1284            selectable_models,
1285            vec![
1286                AgentModel::Gemini31Pro,
1287                AgentModel::Gemini37Flash,
1288                AgentModel::Gemini35FlashLite,
1289            ]
1290        );
1291    }
1292
1293    #[test]
1294    /// Ensures unavailable models fall back to an available preferred model
1295    /// when possible.
1296    fn test_resolve_model_for_available_agent_kinds_prefers_available_fallback() {
1297        // Arrange
1298        let unavailable_model = AgentModel::ClaudeOpus5;
1299        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1300        let fallback_model = AgentModel::Gpt56Terra;
1301
1302        // Act
1303        let resolved_model = resolve_model_for_available_agent_kinds(
1304            unavailable_model,
1305            &available_agent_kinds,
1306            fallback_model,
1307        );
1308
1309        // Assert
1310        assert_eq!(resolved_model, AgentModel::Gpt56Terra);
1311    }
1312
1313    #[test]
1314    /// Ensures unavailable models fall back to the first available provider
1315    /// default when the preferred fallback is also unavailable.
1316    fn test_resolve_model_for_available_agent_kinds_uses_first_available_default() {
1317        // Arrange
1318        let unavailable_model = AgentModel::ClaudeOpus5;
1319        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1320        let unavailable_fallback_model = AgentModel::ClaudeSonnet5;
1321
1322        // Act
1323        let resolved_model = resolve_model_for_available_agent_kinds(
1324            unavailable_model,
1325            &available_agent_kinds,
1326            unavailable_fallback_model,
1327        );
1328
1329        // Assert
1330        assert_eq!(resolved_model, AgentKind::Codex.default_model());
1331    }
1332
1333    #[test]
1334    /// Ensures model-only settings preserve a preferred provider when it can
1335    /// run the selected model.
1336    fn test_resolve_agent_selection_for_model_preserves_preferred_shared_provider() {
1337        // Arrange
1338        let model = AgentModel::Gemini37Flash;
1339        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1340
1341        // Act
1342        let resolved_selection = resolve_agent_selection_for_model(
1343            model,
1344            AgentKind::Antigravity,
1345            &available_agent_kinds,
1346        );
1347
1348        // Assert
1349        assert_eq!(
1350            resolved_selection,
1351            AgentSelection::new(AgentKind::Antigravity, model)
1352        );
1353    }
1354
1355    #[test]
1356    /// Ensures model-only settings use available provider order when the
1357    /// preferred provider cannot run the selected model.
1358    fn test_resolve_agent_selection_for_model_uses_available_provider_order() {
1359        // Arrange
1360        let model = AgentModel::Gemini37Flash;
1361        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1362
1363        // Act
1364        let resolved_selection =
1365            resolve_agent_selection_for_model(model, AgentKind::Codex, &available_agent_kinds);
1366
1367        // Assert
1368        assert_eq!(
1369            resolved_selection,
1370            AgentSelection::new(AgentKind::Gemini, model)
1371        );
1372    }
1373
1374    #[test]
1375    /// Ensures prompt model selection keeps the current backend when it is
1376    /// still available locally.
1377    fn test_resolve_prompt_model_agent_kind_prefers_current_agent() {
1378        // Arrange
1379        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1380
1381        // Act
1382        let resolved_agent_kind =
1383            resolve_prompt_model_agent_kind(AgentKind::Codex, &available_agent_kinds);
1384
1385        // Assert
1386        assert_eq!(resolved_agent_kind, Some(AgentKind::Codex));
1387    }
1388
1389    #[test]
1390    /// Ensures prompt model selection falls back to the first locally
1391    /// available backend when the current backend is unavailable.
1392    fn test_resolve_prompt_model_agent_kind_uses_first_available_agent() {
1393        // Arrange
1394        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1395
1396        // Act
1397        let resolved_agent_kind =
1398            resolve_prompt_model_agent_kind(AgentKind::Claude, &available_agent_kinds);
1399
1400        // Assert
1401        assert_eq!(resolved_agent_kind, Some(AgentKind::Antigravity));
1402    }
1403}