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