Skip to main content

ag_agent/model/
agent.rs

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