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 model backed by `gemini-3.1-pro`.
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",
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-flash-preview", AgentModel::Gemini36Flash),
233            ("gemini-3.5-flash", AgentModel::Gemini35FlashLite),
234            ("gemini-3.1-pro-preview", AgentModel::Gemini31Pro),
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" => 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");
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 current Gemini models expose their user-facing descriptions.
775    fn test_current_gemini_models_have_current_descriptions() {
776        // Arrange
777        let models = [
778            AgentModel::Gemini31Pro,
779            AgentModel::Gemini36Flash,
780            AgentModel::Gemini35FlashLite,
781        ];
782
783        // Act
784        let descriptions = models.map(|model| model.description());
785
786        // Assert
787        assert_eq!(
788            descriptions,
789            [
790                "Higher-quality Gemini model for deeper reasoning.",
791                "Fast Gemini model for agentic and multimodal tasks.",
792                "Lightweight Gemini model for fast, cost-conscious workloads.",
793            ]
794        );
795    }
796
797    #[test]
798    /// Ensures retired GPT model ids no longer parse as selectable models.
799    fn test_parse_model_rejects_retired_gpt_models() {
800        // Arrange
801        let codex_kind = AgentKind::Codex;
802
803        // Act
804        let parsed_gpt_54 = codex_kind.parse_model("gpt-5.4");
805        let parsed_gpt_54_mini = codex_kind.parse_model("gpt-5.4-mini");
806
807        // Assert
808        assert_eq!(parsed_gpt_54, None);
809        assert_eq!(parsed_gpt_54_mini, None);
810    }
811
812    #[test]
813    /// Ensures retired Claude aliases no longer parse as selectable
814    /// models.
815    fn test_parse_model_rejects_retired_claude_aliases() {
816        // Arrange
817        let claude_kind = AgentKind::Claude;
818
819        // Act
820        let parsed_opus_46 = claude_kind.parse_model("claude-opus-4-6");
821        let parsed_opus_47 = claude_kind.parse_model("claude-opus-4-7");
822        let parsed_sonnet_46 = claude_kind.parse_model("claude-sonnet-4-6");
823
824        // Assert
825        assert_eq!(parsed_opus_46, None);
826        assert_eq!(parsed_opus_47, None);
827        assert_eq!(parsed_sonnet_46, None);
828    }
829
830    #[test]
831    /// Ensures current Claude model ids parse as supported Claude models.
832    fn test_parse_model_parses_current_claude_models() {
833        // Arrange
834        let claude_kind = AgentKind::Claude;
835
836        // Act
837        let parsed_opus_5 = claude_kind.parse_model("claude-opus-5");
838        let parsed_sonnet_5 = claude_kind.parse_model("claude-sonnet-5");
839        let parsed_fable_5 = claude_kind.parse_model("claude-fable-5");
840        let parsed_haiku_45 = claude_kind.parse_model("claude-haiku-4-5-20251001");
841        let opus_5_id = AgentModel::ClaudeOpus5.as_str();
842        let opus_5_description = AgentModel::ClaudeOpus5.description();
843
844        // Assert
845        assert_eq!(parsed_opus_5, Some(AgentModel::ClaudeOpus5));
846        assert_eq!(parsed_sonnet_5, Some(AgentModel::ClaudeSonnet5));
847        assert_eq!(parsed_fable_5, Some(AgentModel::ClaudeFable5));
848        assert_eq!(parsed_haiku_45, Some(AgentModel::ClaudeHaiku4520251001));
849        assert_eq!(opus_5_id, "claude-opus-5");
850        assert_eq!(
851            opus_5_description,
852            "Latest Claude Opus model for complex tasks."
853        );
854    }
855
856    #[test]
857    /// Ensures the retirement registry maps retired ids to replacements and
858    /// leaves current ids unmapped.
859    fn test_retired_replacement_maps_only_retired_ids() {
860        // Arrange
861        let retired_ids = [
862            ("gemini-3-pro-preview", AgentModel::Gemini31Pro),
863            ("gemini-3-flash-preview", AgentModel::Gemini36Flash),
864            ("gemini-3.5-flash", AgentModel::Gemini35FlashLite),
865            ("gemini-3.1-pro-preview", AgentModel::Gemini31Pro),
866            (
867                "gemini-3.1-flash-lite-preview",
868                AgentModel::Gemini35FlashLite,
869            ),
870            ("claude-opus-4-8", AgentModel::ClaudeOpus5),
871            ("claude-opus-4-6", AgentModel::ClaudeOpus5),
872            ("claude-opus-4-7", AgentModel::ClaudeOpus5),
873            ("claude-sonnet-4-6", AgentModel::ClaudeSonnet5),
874            ("gpt-5.5", AgentModel::Gpt56Sol),
875            ("gpt-5.4-mini", AgentModel::Gpt56Luna),
876            ("gpt-5.4", AgentModel::Gpt56Sol),
877            ("gpt-5.3-codex", AgentModel::Gpt56Sol),
878            ("gpt-5.2-codex", AgentModel::Gpt53CodexSpark),
879        ];
880
881        // Act
882        let replacements =
883            retired_ids.map(|(retired_id, _)| AgentModel::retired_replacement(retired_id));
884        let selectable_parses = retired_ids.map(|(retired_id, _)| retired_id.parse::<AgentModel>());
885        let current_replacements = [
886            "gemini-3.1-pro",
887            "gemini-3.6-flash",
888            "gemini-3.5-flash-lite",
889            "claude-opus-5",
890        ]
891        .map(AgentModel::retired_replacement);
892        let unknown_replacement = AgentModel::retired_replacement("not-a-model");
893
894        // Assert
895        assert_eq!(
896            replacements,
897            retired_ids.map(|(_, replacement)| Some(replacement))
898        );
899        assert!(selectable_parses.iter().all(Result::is_err));
900        assert_eq!(current_replacements, [None; 4]);
901        assert_eq!(unknown_replacement, None);
902    }
903
904    #[test]
905    /// Ensures persisted model ids parse or migrate to supported
906    /// models.
907    fn test_parse_persisted_handles_supported_and_retired_models() {
908        // Arrange
909
910        // Act
911        let parsed_opus_46 = AgentModel::parse_persisted("claude-opus-4-6");
912        let parsed_opus_47 = AgentModel::parse_persisted("claude-opus-4-7");
913        let parsed_sonnet_46 = AgentModel::parse_persisted("claude-sonnet-4-6");
914        let parsed_sonnet_5 = AgentModel::parse_persisted("claude-sonnet-5");
915        let parsed_gpt_54_mini = AgentModel::parse_persisted("gpt-5.4-mini");
916        let parsed_gpt_54 = AgentModel::parse_persisted("gpt-5.4");
917        let parsed_gemini_36_flash = AgentModel::parse_persisted("gemini-3.6-flash");
918        let parsed_gemini_35_flash = AgentModel::parse_persisted("gemini-3.5-flash");
919        let parsed_gemini_3_flash_preview = AgentModel::parse_persisted("gemini-3-flash-preview");
920        let parsed_gemini_35_flash_lite = AgentModel::parse_persisted("gemini-3.5-flash-lite");
921        let parsed_gemini_31_pro_preview = AgentModel::parse_persisted("gemini-3.1-pro-preview");
922        let parsed_gemini_31_flash_lite_preview =
923            AgentModel::parse_persisted("gemini-3.1-flash-lite-preview");
924
925        // Assert
926        assert_eq!(parsed_opus_46, Ok(AgentModel::ClaudeOpus5));
927        assert_eq!(parsed_opus_47, Ok(AgentModel::ClaudeOpus5));
928        assert_eq!(parsed_sonnet_46, Ok(AgentModel::ClaudeSonnet5));
929        assert_eq!(parsed_sonnet_5, Ok(AgentModel::ClaudeSonnet5));
930        assert_eq!(parsed_gpt_54_mini, Ok(AgentModel::Gpt56Luna));
931        assert_eq!(parsed_gpt_54, Ok(AgentModel::Gpt56Sol));
932        assert_eq!(parsed_gemini_36_flash, Ok(AgentModel::Gemini36Flash));
933        assert_eq!(parsed_gemini_35_flash, Ok(AgentModel::Gemini35FlashLite));
934        assert_eq!(parsed_gemini_3_flash_preview, Ok(AgentModel::Gemini36Flash));
935        assert_eq!(
936            parsed_gemini_35_flash_lite,
937            Ok(AgentModel::Gemini35FlashLite)
938        );
939        assert_eq!(parsed_gemini_31_pro_preview, Ok(AgentModel::Gemini31Pro));
940        assert_eq!(
941            parsed_gemini_31_flash_lite_preview,
942            Ok(AgentModel::Gemini35FlashLite)
943        );
944    }
945
946    #[test]
947    /// Ensures persisted session agent values constrain model parsing instead
948    /// of deriving provider ownership from the model string.
949    fn test_parse_persisted_session_agent_model_prefers_saved_agent() {
950        // Arrange
951
952        // Act
953        let selection = parse_persisted_session_agent_model(Some("codex"), "gemini-3.6-flash");
954
955        // Assert
956        assert_eq!(selection.kind(), AgentKind::Codex);
957        assert_eq!(selection.model(), AgentKind::Codex.default_model());
958    }
959
960    #[test]
961    /// Ensures Antigravity-persisted sessions keep Antigravity ownership for
962    /// raw Gemini model ids shared with the direct Gemini backend.
963    fn test_parse_persisted_session_agent_model_preserves_antigravity_models() {
964        // Arrange
965
966        // Act
967        let selection =
968            parse_persisted_session_agent_model(Some("antigravity"), "gemini-3.6-flash");
969
970        // Assert
971        assert_eq!(selection.kind(), AgentKind::Antigravity);
972        assert_eq!(selection.model(), AgentModel::Gemini36Flash);
973    }
974
975    #[test]
976    /// Ensures current and retired Gemini models preserve the saved Google
977    /// provider while resolving to supported models.
978    fn test_parse_persisted_session_agent_model_resolves_saved_google_models() {
979        // Arrange
980        let persisted_selections = [
981            ("gemini", "gemini-3.5-flash-lite"),
982            ("gemini", "gemini-3.5-flash"),
983            ("antigravity", "gemini-3.5-flash-lite"),
984            ("antigravity", "gemini-3.5-flash"),
985        ];
986
987        // Act
988        let migrated_selections = persisted_selections.map(|(agent_value, model_value)| {
989            parse_persisted_session_agent_model(Some(agent_value), model_value)
990        });
991
992        // Assert
993        assert_eq!(
994            migrated_selections,
995            [
996                AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini35FlashLite),
997                AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini35FlashLite),
998                AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite),
999                AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite),
1000            ]
1001        );
1002    }
1003
1004    #[test]
1005    /// Ensures older persisted rows without `agent` still load through the
1006    /// legacy model-derived compatibility path.
1007    fn test_parse_persisted_session_agent_model_supports_legacy_rows() {
1008        // Arrange
1009
1010        // Act
1011        let selection = parse_persisted_session_agent_model(None, "claude-opus-4-6");
1012
1013        // Assert
1014        assert_eq!(selection.kind(), AgentKind::Claude);
1015        assert_eq!(selection.model(), AgentModel::ClaudeOpus5);
1016    }
1017
1018    #[test]
1019    /// Ensures older `gemini-*` rows without `agent` keep the post-removal
1020    /// Antigravity compatibility default.
1021    fn test_parse_persisted_session_agent_model_defaults_legacy_gemini_to_antigravity() {
1022        // Arrange
1023
1024        // Act
1025        let selection = parse_persisted_session_agent_model(None, "gemini-3.6-flash");
1026
1027        // Assert
1028        assert_eq!(selection.kind(), AgentKind::Antigravity);
1029        assert_eq!(selection.model(), AgentModel::Gemini36Flash);
1030    }
1031
1032    #[test]
1033    /// Ensures current and retired Gemini models in rows without a saved
1034    /// provider resolve through the legacy Antigravity compatibility path.
1035    fn test_parse_persisted_session_agent_model_resolves_legacy_gemini_rows() {
1036        // Arrange
1037
1038        // Act
1039        let migrated_flash = parse_persisted_session_agent_model(None, "gemini-3.5-flash");
1040        let loaded_flash_lite = parse_persisted_session_agent_model(None, "gemini-3.5-flash-lite");
1041
1042        // Assert
1043        assert_eq!(
1044            migrated_flash,
1045            AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite)
1046        );
1047        assert_eq!(
1048            loaded_flash_lite,
1049            AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35FlashLite)
1050        );
1051    }
1052
1053    #[test]
1054    /// Ensures reasoning-level parsing accepts all supported persisted values.
1055    fn test_reasoning_level_from_str_parses_supported_values() {
1056        // Arrange
1057
1058        // Act
1059        let low_level = "low".parse::<ReasoningLevel>();
1060        let medium_level = "medium".parse::<ReasoningLevel>();
1061        let high_level = "high".parse::<ReasoningLevel>();
1062        let xhigh_level = "xhigh".parse::<ReasoningLevel>();
1063        let max_level = "max".parse::<ReasoningLevel>();
1064
1065        // Assert
1066        assert_eq!(low_level, Ok(ReasoningLevel::Low));
1067        assert_eq!(medium_level, Ok(ReasoningLevel::Medium));
1068        assert_eq!(high_level, Ok(ReasoningLevel::High));
1069        assert_eq!(xhigh_level, Ok(ReasoningLevel::XHigh));
1070        assert_eq!(max_level, Ok(ReasoningLevel::Max));
1071    }
1072
1073    #[test]
1074    /// Ensures unsupported reasoning values return a parse error.
1075    fn test_reasoning_level_from_str_rejects_unknown_values() {
1076        // Arrange
1077
1078        // Act
1079        let parse_result = "minimal".parse::<ReasoningLevel>();
1080
1081        // Assert
1082        assert!(parse_result.is_err());
1083    }
1084
1085    #[test]
1086    /// Ensures Codex models are supported by Codex only.
1087    fn test_codex_models_are_supported_by_codex() {
1088        // Arrange
1089        let models = [
1090            AgentModel::Gpt56Sol,
1091            AgentModel::Gpt56Terra,
1092            AgentModel::Gpt56Luna,
1093            AgentModel::Gpt53CodexSpark,
1094        ];
1095
1096        // Act
1097        let supported = models.map(|model| AgentKind::Codex.supports_model(model));
1098        let unsupported = models.map(|model| AgentKind::Claude.supports_model(model));
1099
1100        // Assert
1101        assert_eq!(supported, [true; 4]);
1102        assert_eq!(unsupported, [false; 4]);
1103    }
1104
1105    #[test]
1106    /// Ensures Claude models are supported by Claude only.
1107    fn test_claude_models_are_supported_by_claude() {
1108        // Arrange
1109        let models = [
1110            AgentModel::ClaudeOpus5,
1111            AgentModel::ClaudeSonnet5,
1112            AgentModel::ClaudeFable5,
1113            AgentModel::ClaudeHaiku4520251001,
1114        ];
1115
1116        // Act
1117        let supported = models.map(|model| AgentKind::Claude.supports_model(model));
1118        let unsupported = models.map(|model| AgentKind::Codex.supports_model(model));
1119
1120        // Assert
1121        assert_eq!(supported, [true; 4]);
1122        assert_eq!(unsupported, [false; 4]);
1123    }
1124
1125    #[test]
1126    /// Ensures shared Gemini models are supported by both Google providers.
1127    fn test_gemini_models_are_supported_by_gemini_and_antigravity() {
1128        // Arrange
1129        let models = [
1130            AgentModel::Gemini31Pro,
1131            AgentModel::Gemini36Flash,
1132            AgentModel::Gemini35FlashLite,
1133        ];
1134
1135        // Act
1136        let antigravity_supported =
1137            models.map(|model| AgentKind::Antigravity.supports_model(model));
1138        let gemini_supported = models.map(|model| AgentKind::Gemini.supports_model(model));
1139        let codex_supported = models.map(|model| AgentKind::Codex.supports_model(model));
1140
1141        // Assert
1142        assert_eq!(antigravity_supported, [true; 3]);
1143        assert_eq!(gemini_supported, [true; 3]);
1144        assert_eq!(codex_supported, [false; 3]);
1145    }
1146
1147    #[test]
1148    /// Ensures only providers with a response-speed control report support.
1149    fn test_supports_speed_mode_covers_claude_and_codex_only() {
1150        // Arrange
1151        let kinds = [
1152            AgentKind::Claude,
1153            AgentKind::Codex,
1154            AgentKind::Gemini,
1155            AgentKind::Antigravity,
1156        ];
1157
1158        // Act
1159        let supported = kinds.map(AgentKind::supports_speed_mode);
1160
1161        // Assert
1162        assert_eq!(supported, [true, true, false, false]);
1163    }
1164
1165    #[test]
1166    /// Ensures Antigravity model selections pass raw Gemini ids to provider
1167    /// transports.
1168    fn test_antigravity_provider_model_str_returns_raw_gemini_model() {
1169        // Arrange
1170        let model = AgentModel::Gemini35FlashLite;
1171
1172        // Act
1173        let persisted_model = model.as_str();
1174        let provider_model = model.provider_model_str();
1175
1176        // Assert
1177        assert_eq!(persisted_model, "gemini-3.5-flash-lite");
1178        assert_eq!(provider_model, "gemini-3.5-flash-lite");
1179    }
1180
1181    #[test]
1182    /// Ensures `ReasoningLevel::claude()` maps all levels to the correct
1183    /// Claude `--effort` values, including the highest generic levels.
1184    fn test_reasoning_level_claude_maps_all_levels() {
1185        // Arrange / Act / Assert
1186        assert_eq!(ReasoningLevel::Low.claude(), "low");
1187        assert_eq!(ReasoningLevel::Medium.claude(), "medium");
1188        assert_eq!(ReasoningLevel::High.claude(), "high");
1189        assert_eq!(ReasoningLevel::XHigh.claude(), "max");
1190        assert_eq!(ReasoningLevel::Max.claude(), "max");
1191    }
1192
1193    #[test]
1194    /// Ensures Antigravity reasoning values stay within the CLI's accepted
1195    /// `low`, `medium`, and `high` effort levels.
1196    fn test_reasoning_level_antigravity_maps_all_levels() {
1197        // Arrange / Act / Assert
1198        assert_eq!(ReasoningLevel::Low.antigravity(), "low");
1199        assert_eq!(ReasoningLevel::Medium.antigravity(), "medium");
1200        assert_eq!(ReasoningLevel::High.antigravity(), "high");
1201        assert_eq!(ReasoningLevel::XHigh.antigravity(), "high");
1202        assert_eq!(ReasoningLevel::Max.antigravity(), "high");
1203    }
1204
1205    #[test]
1206    /// Ensures Codex reasoning values include the distinct `max` effort.
1207    fn test_reasoning_level_codex_maps_all_levels() {
1208        // Arrange / Act / Assert
1209        assert_eq!(ReasoningLevel::Low.codex(), "low");
1210        assert_eq!(ReasoningLevel::Medium.codex(), "medium");
1211        assert_eq!(ReasoningLevel::High.codex(), "high");
1212        assert_eq!(ReasoningLevel::XHigh.codex(), "xhigh");
1213        assert_eq!(ReasoningLevel::Max.codex(), "max");
1214    }
1215
1216    #[test]
1217    /// Ensures persisted reasoning identifiers stay stable even if provider
1218    /// transport names change in the future.
1219    fn test_reasoning_level_as_str_returns_stable_persisted_values() {
1220        // Arrange / Act / Assert
1221        assert_eq!(ReasoningLevel::Low.as_str(), "low");
1222        assert_eq!(ReasoningLevel::Medium.as_str(), "medium");
1223        assert_eq!(ReasoningLevel::High.as_str(), "high");
1224        assert_eq!(ReasoningLevel::XHigh.as_str(), "xhigh");
1225        assert_eq!(ReasoningLevel::Max.as_str(), "max");
1226    }
1227
1228    #[test]
1229    /// Ensures selectable-model ordering follows the provided provider order.
1230    fn test_selectable_models_for_agent_kinds_uses_provider_order() {
1231        // Arrange
1232        let agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1233
1234        // Act
1235        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
1236
1237        // Assert
1238        assert_eq!(
1239            selectable_models,
1240            vec![
1241                AgentModel::Gpt56Sol,
1242                AgentModel::Gpt56Terra,
1243                AgentModel::Gpt56Luna,
1244                AgentModel::Gpt53CodexSpark,
1245                AgentModel::Gemini31Pro,
1246                AgentModel::Gemini36Flash,
1247                AgentModel::Gemini35FlashLite,
1248            ]
1249        );
1250    }
1251
1252    #[test]
1253    /// Ensures shared Gemini models appear once even when both Google
1254    /// providers are available.
1255    fn test_selectable_models_for_agent_kinds_deduplicates_shared_models() {
1256        // Arrange
1257        let agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1258
1259        // Act
1260        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
1261
1262        // Assert
1263        assert_eq!(
1264            selectable_models,
1265            vec![
1266                AgentModel::Gemini31Pro,
1267                AgentModel::Gemini36Flash,
1268                AgentModel::Gemini35FlashLite,
1269            ]
1270        );
1271    }
1272
1273    #[test]
1274    /// Ensures unavailable models fall back to an available preferred model
1275    /// when possible.
1276    fn test_resolve_model_for_available_agent_kinds_prefers_available_fallback() {
1277        // Arrange
1278        let unavailable_model = AgentModel::ClaudeOpus5;
1279        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1280        let fallback_model = AgentModel::Gpt56Terra;
1281
1282        // Act
1283        let resolved_model = resolve_model_for_available_agent_kinds(
1284            unavailable_model,
1285            &available_agent_kinds,
1286            fallback_model,
1287        );
1288
1289        // Assert
1290        assert_eq!(resolved_model, AgentModel::Gpt56Terra);
1291    }
1292
1293    #[test]
1294    /// Ensures unavailable models fall back to the first available provider
1295    /// default when the preferred fallback is also unavailable.
1296    fn test_resolve_model_for_available_agent_kinds_uses_first_available_default() {
1297        // Arrange
1298        let unavailable_model = AgentModel::ClaudeOpus5;
1299        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1300        let unavailable_fallback_model = AgentModel::ClaudeSonnet5;
1301
1302        // Act
1303        let resolved_model = resolve_model_for_available_agent_kinds(
1304            unavailable_model,
1305            &available_agent_kinds,
1306            unavailable_fallback_model,
1307        );
1308
1309        // Assert
1310        assert_eq!(resolved_model, AgentKind::Codex.default_model());
1311    }
1312
1313    #[test]
1314    /// Ensures model-only settings preserve a preferred provider when it can
1315    /// run the selected model.
1316    fn test_resolve_agent_selection_for_model_preserves_preferred_shared_provider() {
1317        // Arrange
1318        let model = AgentModel::Gemini36Flash;
1319        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1320
1321        // Act
1322        let resolved_selection = resolve_agent_selection_for_model(
1323            model,
1324            AgentKind::Antigravity,
1325            &available_agent_kinds,
1326        );
1327
1328        // Assert
1329        assert_eq!(
1330            resolved_selection,
1331            AgentSelection::new(AgentKind::Antigravity, model)
1332        );
1333    }
1334
1335    #[test]
1336    /// Ensures model-only settings use available provider order when the
1337    /// preferred provider cannot run the selected model.
1338    fn test_resolve_agent_selection_for_model_uses_available_provider_order() {
1339        // Arrange
1340        let model = AgentModel::Gemini36Flash;
1341        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1342
1343        // Act
1344        let resolved_selection =
1345            resolve_agent_selection_for_model(model, AgentKind::Codex, &available_agent_kinds);
1346
1347        // Assert
1348        assert_eq!(
1349            resolved_selection,
1350            AgentSelection::new(AgentKind::Gemini, model)
1351        );
1352    }
1353
1354    #[test]
1355    /// Ensures prompt model selection keeps the current backend when it is
1356    /// still available locally.
1357    fn test_resolve_prompt_model_agent_kind_prefers_current_agent() {
1358        // Arrange
1359        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1360
1361        // Act
1362        let resolved_agent_kind =
1363            resolve_prompt_model_agent_kind(AgentKind::Codex, &available_agent_kinds);
1364
1365        // Assert
1366        assert_eq!(resolved_agent_kind, Some(AgentKind::Codex));
1367    }
1368
1369    #[test]
1370    /// Ensures prompt model selection falls back to the first locally
1371    /// available backend when the current backend is unavailable.
1372    fn test_resolve_prompt_model_agent_kind_uses_first_available_agent() {
1373        // Arrange
1374        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1375
1376        // Act
1377        let resolved_agent_kind =
1378            resolve_prompt_model_agent_kind(AgentKind::Claude, &available_agent_kinds);
1379
1380        // Assert
1381        assert_eq!(resolved_agent_kind, Some(AgentKind::Antigravity));
1382    }
1383}