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 model backed by `gpt-5.5`.
88    Gpt55,
89    /// Fast Gemini preview model backed by `gemini-3-flash-preview`.
90    Gemini3FlashPreview,
91    /// Fast Gemini model backed by `gemini-3.5-flash`.
92    Gemini35Flash,
93    /// Lightweight Gemini preview model backed by
94    /// `gemini-3.1-flash-lite-preview`.
95    Gemini31FlashLitePreview,
96    /// Higher-quality Gemini preview model backed by `gemini-3.1-pro-preview`.
97    Gemini31ProPreview,
98    /// Smaller Codex model backed by `gpt-5.4-mini`.
99    Gpt54Mini,
100    /// Codex spark model backed by `gpt-5.3-codex-spark`.
101    Gpt53CodexSpark,
102    /// Claude Opus model backed by `claude-opus-4-8`.
103    ClaudeOpus48,
104    /// Claude Sonnet model backed by `claude-sonnet-5`.
105    ClaudeSonnet5,
106    /// Claude Fable model backed by `claude-fable-5`.
107    ClaudeFable5,
108    /// Claude Haiku model backed by `claude-haiku-4-5-20251001`.
109    ClaudeHaiku4520251001,
110}
111
112/// Session-level agent selection that keeps provider kind and model together.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct AgentSelection {
115    kind: AgentKind,
116    model: AgentModel,
117}
118
119/// Supported reasoning-effort levels for task execution.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum ReasoningLevel {
122    /// Low reasoning effort for faster responses.
123    Low,
124    /// Medium reasoning effort.
125    Medium,
126    /// High reasoning effort for deeper reasoning.
127    #[default]
128    High,
129    /// Extra-high reasoning effort for deeper analysis.
130    XHigh,
131}
132
133impl AgentSelection {
134    /// Creates a coherent session agent selection.
135    ///
136    /// If `model` is not supported by `kind`, the provider's default model is
137    /// used so the selection never carries unrelated provider/model values.
138    #[must_use]
139    pub fn new(kind: AgentKind, model: AgentModel) -> Self {
140        let model = if kind.supports_model(model) {
141            model
142        } else {
143            kind.default_model()
144        };
145
146        Self { kind, model }
147    }
148
149    /// Returns the selected agent provider kind.
150    #[must_use]
151    pub fn kind(self) -> AgentKind {
152        self.kind
153    }
154
155    /// Returns the selected agent model.
156    #[must_use]
157    pub fn model(self) -> AgentModel {
158        self.model
159    }
160}
161
162/// Human-readable metadata for slash-menu selectable items.
163pub trait AgentSelectionMetadata {
164    /// Returns a stable item name shown in menus.
165    fn name(&self) -> &'static str;
166
167    /// Returns a short descriptive subtitle shown in menus.
168    fn description(&self) -> &'static str;
169}
170
171impl AgentModel {
172    /// Returns the stable wire/model identifier used in persistence and CLI
173    /// invocations.
174    pub fn as_str(self) -> &'static str {
175        match self {
176            Self::Gpt55 => "gpt-5.5",
177            Self::Gemini3FlashPreview => "gemini-3-flash-preview",
178            Self::Gemini35Flash => "gemini-3.5-flash",
179            Self::Gemini31FlashLitePreview => "gemini-3.1-flash-lite-preview",
180            Self::Gemini31ProPreview => "gemini-3.1-pro-preview",
181            Self::Gpt54Mini => "gpt-5.4-mini",
182            Self::Gpt53CodexSpark => "gpt-5.3-codex-spark",
183            Self::ClaudeOpus48 => "claude-opus-4-8",
184            Self::ClaudeSonnet5 => "claude-sonnet-5",
185            Self::ClaudeFable5 => "claude-fable-5",
186            Self::ClaudeHaiku4520251001 => "claude-haiku-4-5-20251001",
187        }
188    }
189
190    /// Returns the model identifier passed to provider transports.
191    ///
192    /// Antigravity and Gemini use the same raw Gemini CLI model ids; callers
193    /// route those ids through the provider stored on [`AgentSelection`].
194    pub fn provider_model_str(self) -> &'static str {
195        self.as_str()
196    }
197
198    /// Parses one persisted model identifier and upgrades retired aliases.
199    ///
200    /// Stored retired Claude Opus, Claude Sonnet, and Codex aliases are
201    /// migrated forward so existing projects and sessions continue loading
202    /// after model removals.
203    /// Raw `gemini-*` ids parse to shared Gemini model variants; persisted
204    /// session agents decide whether Gemini or Antigravity owns the session.
205    ///
206    /// # Errors
207    /// Returns an error when `value` is not a known current or retired model
208    /// identifier.
209    pub fn parse_persisted(value: &str) -> Result<Self, String> {
210        match value {
211            "claude-opus-4-6" | "claude-opus-4-7" => Ok(Self::ClaudeOpus48),
212            "claude-sonnet-4-6" => Ok(Self::ClaudeSonnet5),
213            "gpt-5.4" => Ok(Self::Gpt55),
214            _ => value.parse(),
215        }
216    }
217}
218
219/// Parses one persisted session agent/model pair without deriving the agent
220/// from the model when the saved agent kind is available.
221///
222/// Existing databases did not persist `agent`, so rows with a missing or
223/// invalid agent value fall back to a compatibility provider inferred from
224/// the persisted model string. Ambiguous raw `gemini-*` rows default to
225/// Antigravity because direct Gemini support had been removed before the new
226/// `agent` column was introduced.
227pub fn parse_persisted_session_agent_model(
228    agent_value: Option<&str>,
229    model_value: &str,
230) -> AgentSelection {
231    let parsed_agent = agent_value
232        .filter(|value| !value.trim().is_empty())
233        .and_then(|value| value.parse::<AgentKind>().ok());
234
235    if let Some(agent_kind) = parsed_agent {
236        return parse_model_for_persisted_agent(agent_kind, model_value);
237    }
238
239    let model = AgentModel::parse_persisted(model_value)
240        .unwrap_or_else(|_| AgentKind::Antigravity.default_model());
241    let agent_kind = legacy_agent_kind_for_model_value(model_value, model);
242
243    AgentSelection::new(agent_kind, model)
244}
245
246/// Parses a persisted model using the already persisted agent kind as the
247/// source of truth for provider ownership.
248fn parse_model_for_persisted_agent(agent_kind: AgentKind, model_value: &str) -> AgentSelection {
249    if let Some(model) = agent_kind.parse_model(model_value) {
250        return AgentSelection::new(agent_kind, model);
251    }
252
253    if let Ok(model) = AgentModel::parse_persisted(model_value)
254        && agent_kind.supports_model(model)
255    {
256        return AgentSelection::new(agent_kind, model);
257    }
258
259    AgentSelection::new(agent_kind, agent_kind.default_model())
260}
261
262/// Returns the provider used for legacy rows that predate explicit session
263/// agent persistence.
264fn legacy_agent_kind_for_model_value(model_value: &str, model: AgentModel) -> AgentKind {
265    if model_value.starts_with("claude-") {
266        return AgentKind::Claude;
267    }
268
269    if model_value.starts_with("gpt-") {
270        return AgentKind::Codex;
271    }
272
273    if model_value.starts_with("gemini-") {
274        return AgentKind::Antigravity;
275    }
276
277    AgentKind::ALL
278        .iter()
279        .copied()
280        .find(|agent_kind| agent_kind.supports_model(model))
281        .unwrap_or(AgentKind::Antigravity)
282}
283
284/// Returns all selectable models owned by the provided agent kinds in stable
285/// settings and slash-menu order.
286#[must_use]
287pub fn selectable_models_for_agent_kinds(agent_kinds: &[AgentKind]) -> Vec<AgentModel> {
288    let mut models = Vec::new();
289    for model in agent_kinds
290        .iter()
291        .flat_map(|agent_kind| agent_kind.models())
292        .copied()
293    {
294        if !models.contains(&model) {
295            models.push(model);
296        }
297    }
298
299    models
300}
301
302/// Resolves one model against the currently available agent kinds.
303///
304/// When `model` is unsupported by every available provider, this prefers
305/// `fallback_model` when any available provider supports it and otherwise falls
306/// back to the first available provider default in `agent_kinds`. When no
307/// providers are available, it returns `fallback_model` unchanged.
308#[must_use]
309pub fn resolve_model_for_available_agent_kinds(
310    model: AgentModel,
311    agent_kinds: &[AgentKind],
312    fallback_model: AgentModel,
313) -> AgentModel {
314    if agent_kinds
315        .iter()
316        .any(|agent_kind| agent_kind.supports_model(model))
317    {
318        return model;
319    }
320
321    if agent_kinds
322        .iter()
323        .any(|agent_kind| agent_kind.supports_model(fallback_model))
324    {
325        return fallback_model;
326    }
327
328    agent_kinds
329        .first()
330        .copied()
331        .map_or(fallback_model, AgentKind::default_model)
332}
333
334/// Resolves a provider kind that can run `model` from the available provider
335/// list.
336///
337/// Shared Gemini model ids can be run by both Gemini and Antigravity. This
338/// helper uses the order of `agent_kinds` as the tie-breaker and returns
339/// `fallback_agent_kind` when no available provider supports `model`.
340#[must_use]
341pub fn resolve_agent_kind_for_model(
342    model: AgentModel,
343    agent_kinds: &[AgentKind],
344    fallback_agent_kind: AgentKind,
345) -> AgentKind {
346    agent_kinds
347        .iter()
348        .copied()
349        .find(|agent_kind| agent_kind.supports_model(model))
350        .unwrap_or(fallback_agent_kind)
351}
352
353/// Resolves an [`AgentSelection`] for a model-only setting.
354///
355/// `preferred_agent_kind` is kept when it can run `model`, which preserves the
356/// current session provider for shared Gemini model ids. Otherwise the
357/// available provider order decides the owning provider, falling back to
358/// `preferred_agent_kind` when no available provider supports the model.
359#[must_use]
360pub fn resolve_agent_selection_for_model(
361    model: AgentModel,
362    preferred_agent_kind: AgentKind,
363    agent_kinds: &[AgentKind],
364) -> AgentSelection {
365    let agent_kind = if preferred_agent_kind.supports_model(model) {
366        preferred_agent_kind
367    } else {
368        resolve_agent_kind_for_model(model, agent_kinds, preferred_agent_kind)
369    };
370
371    AgentSelection::new(agent_kind, model)
372}
373
374/// Resolves the agent kind used for prompt-side `/model` selection.
375///
376/// This preserves `session_agent_kind` when that backend is still available
377/// and otherwise falls back to the first available backend. When no backends
378/// are available, it returns `None`.
379#[must_use]
380pub fn resolve_prompt_model_agent_kind(
381    session_agent_kind: AgentKind,
382    agent_kinds: &[AgentKind],
383) -> Option<AgentKind> {
384    if agent_kinds.contains(&session_agent_kind) {
385        return Some(session_agent_kind);
386    }
387
388    agent_kinds.first().copied()
389}
390
391impl ReasoningLevel {
392    /// All selectable reasoning-effort levels in UI display order.
393    pub const ALL: [Self; 4] = [Self::Low, Self::Medium, Self::High, Self::XHigh];
394
395    /// Returns the stable persisted identifier for this level.
396    ///
397    /// This value is stored in the database and remains independent from any
398    /// provider-specific transport string changes.
399    pub fn as_str(self) -> &'static str {
400        match self {
401            Self::Low => "low",
402            Self::Medium => "medium",
403            Self::High => "high",
404            Self::XHigh => "xhigh",
405        }
406    }
407
408    /// Returns the Codex reasoning-effort identifier for this level.
409    pub fn codex(self) -> &'static str {
410        match self {
411            Self::Low => "low",
412            Self::Medium => "medium",
413            Self::High => "high",
414            Self::XHigh => "xhigh",
415        }
416    }
417
418    /// Returns the Claude `--effort` value for this level.
419    ///
420    /// Maps `XHigh` to `"max"`, which is currently only supported on
421    /// `claude-opus-4-8`. The Claude CLI enforces this restriction and will
422    /// surface an error for other models.
423    pub fn claude(self) -> &'static str {
424        match self {
425            Self::Low => "low",
426            Self::Medium => "medium",
427            Self::High => "high",
428            Self::XHigh => "max",
429        }
430    }
431
432    /// Returns a short UI description for this reasoning level.
433    pub fn description(self) -> &'static str {
434        match self {
435            Self::Low => "Fastest responses with lighter reasoning.",
436            Self::Medium => "Balanced speed and reasoning depth.",
437            Self::High => "Deeper reasoning for tougher tasks.",
438            Self::XHigh => "Maximum reasoning effort for the hardest tasks.",
439        }
440    }
441}
442
443impl FromStr for ReasoningLevel {
444    type Err = String;
445
446    fn from_str(value: &str) -> Result<Self, Self::Err> {
447        match value {
448            "low" => Ok(Self::Low),
449            "medium" => Ok(Self::Medium),
450            "high" => Ok(Self::High),
451            "xhigh" => Ok(Self::XHigh),
452            other => Err(format!("unknown reasoning level: {other}")),
453        }
454    }
455}
456
457impl FromStr for AgentModel {
458    type Err = String;
459
460    fn from_str(value: &str) -> Result<Self, Self::Err> {
461        match value {
462            "gemini-3-flash-preview" => Ok(Self::Gemini3FlashPreview),
463            "gemini-3.5-flash" => Ok(Self::Gemini35Flash),
464            "gemini-3.1-flash-lite-preview" => Ok(Self::Gemini31FlashLitePreview),
465            "gemini-3.1-pro-preview" => Ok(Self::Gemini31ProPreview),
466            "gpt-5.5" => Ok(Self::Gpt55),
467            "gpt-5.4-mini" => Ok(Self::Gpt54Mini),
468            "gpt-5.3-codex-spark" => Ok(Self::Gpt53CodexSpark),
469            "claude-opus-4-8" => Ok(Self::ClaudeOpus48),
470            "claude-sonnet-5" => Ok(Self::ClaudeSonnet5),
471            "claude-fable-5" => Ok(Self::ClaudeFable5),
472            "claude-haiku-4-5-20251001" => Ok(Self::ClaudeHaiku4520251001),
473            other => Err(format!("unknown model: {other}")),
474        }
475    }
476}
477
478impl AgentSelectionMetadata for AgentModel {
479    fn name(&self) -> &'static str {
480        (*self).provider_model_str()
481    }
482
483    fn description(&self) -> &'static str {
484        match self {
485            Self::Gemini31ProPreview => "Higher-quality Gemini model for deeper reasoning.",
486            Self::Gemini35Flash => "Fast Gemini model for current Flash workloads.",
487            Self::Gemini31FlashLitePreview => {
488                "Lightweight Gemini model for fast, cost-conscious iterations."
489            }
490            Self::Gemini3FlashPreview => "Fast Gemini model for quick iterations.",
491            Self::Gpt55 => "Newer Codex model with stronger coding performance when available.",
492            Self::Gpt54Mini => "Small, fast Codex model for simpler coding tasks.",
493            Self::Gpt53CodexSpark => "Codex spark model for quick coding iterations.",
494            Self::ClaudeOpus48 => "Latest Claude Opus model for complex tasks.",
495            Self::ClaudeSonnet5 => "Balanced Claude model for quality and latency.",
496            Self::ClaudeFable5 => "Claude Fable model for creative, narrative-heavy tasks.",
497            Self::ClaudeHaiku4520251001 => "Fast Claude model for lighter tasks.",
498        }
499    }
500}
501
502impl AgentKind {
503    /// All available agent kinds, in display order.
504    pub const ALL: &[AgentKind] = &[
505        AgentKind::Gemini,
506        AgentKind::Antigravity,
507        AgentKind::Claude,
508        AgentKind::Codex,
509    ];
510
511    /// Returns the provider CLI executable name.
512    pub fn executable_name(self) -> &'static str {
513        match self {
514            Self::Antigravity => "agy",
515            Self::Gemini => "gemini",
516            Self::Claude => "claude",
517            Self::Codex => "codex",
518        }
519    }
520
521    /// Returns the default model for this agent kind.
522    pub fn default_model(self) -> AgentModel {
523        match self {
524            Self::Antigravity | Self::Gemini => AgentModel::Gemini31ProPreview,
525            Self::Claude => AgentModel::ClaudeFable5,
526            Self::Codex => AgentModel::Gpt55,
527        }
528    }
529
530    /// Returns the model string when it belongs to this agent kind.
531    pub fn model_str(self, model: AgentModel) -> Option<&'static str> {
532        if !self.supports_model(model) {
533            return None;
534        }
535
536        Some(model.as_str())
537    }
538
539    /// Returns the curated model list for this agent kind.
540    pub fn models(self) -> &'static [AgentModel] {
541        const ANTIGRAVITY_MODELS: &[AgentModel] = &[
542            AgentModel::Gemini31ProPreview,
543            AgentModel::Gemini35Flash,
544            AgentModel::Gemini31FlashLitePreview,
545            AgentModel::Gemini3FlashPreview,
546        ];
547        const GEMINI_MODELS: &[AgentModel] = &[
548            AgentModel::Gemini31ProPreview,
549            AgentModel::Gemini35Flash,
550            AgentModel::Gemini31FlashLitePreview,
551            AgentModel::Gemini3FlashPreview,
552        ];
553        const CLAUDE_MODELS: &[AgentModel] = &[
554            AgentModel::ClaudeFable5,
555            AgentModel::ClaudeOpus48,
556            AgentModel::ClaudeSonnet5,
557            AgentModel::ClaudeHaiku4520251001,
558        ];
559        const CODEX_MODELS: &[AgentModel] = &[
560            AgentModel::Gpt55,
561            AgentModel::Gpt54Mini,
562            AgentModel::Gpt53CodexSpark,
563        ];
564
565        match self {
566            Self::Antigravity => ANTIGRAVITY_MODELS,
567            Self::Gemini => GEMINI_MODELS,
568            Self::Claude => CLAUDE_MODELS,
569            Self::Codex => CODEX_MODELS,
570        }
571    }
572
573    /// Parses a provider-specific model string for this agent kind.
574    pub fn parse_model(self, value: &str) -> Option<AgentModel> {
575        let model = value.parse::<AgentModel>().ok()?;
576        if !self.supports_model(model) {
577            return None;
578        }
579
580        Some(model)
581    }
582
583    /// Returns whether this provider can run the given model.
584    pub fn supports_model(self, model: AgentModel) -> bool {
585        self.models().contains(&model)
586    }
587}
588
589impl AgentSelectionMetadata for AgentKind {
590    fn name(&self) -> &'static str {
591        match self {
592            Self::Antigravity => "antigravity",
593            Self::Gemini => "gemini",
594            Self::Claude => "claude",
595            Self::Codex => "codex",
596        }
597    }
598
599    fn description(&self) -> &'static str {
600        match self {
601            Self::Antigravity => "Google Antigravity CLI agent.",
602            Self::Gemini => "Google Gemini CLI agent.",
603            Self::Claude => "Anthropic Claude Code agent.",
604            Self::Codex => "OpenAI Codex CLI agent.",
605        }
606    }
607}
608
609impl AgentSelectionMetadata for ReasoningLevel {
610    fn name(&self) -> &'static str {
611        (*self).as_str()
612    }
613
614    fn description(&self) -> &'static str {
615        (*self).description()
616    }
617}
618
619impl fmt::Display for AgentKind {
620    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621        write!(f, "{}", self.name())
622    }
623}
624
625impl FromStr for AgentKind {
626    type Err = String;
627
628    fn from_str(s: &str) -> Result<Self, Self::Err> {
629        match s.to_lowercase().as_str() {
630            "antigravity" | "agy" => Ok(Self::Antigravity),
631            "gemini" => Ok(Self::Gemini),
632            "claude" => Ok(Self::Claude),
633            "codex" => Ok(Self::Codex),
634            other => Err(format!("unknown agent kind: {other}")),
635        }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    /// Ensures model parsing is constrained to the selected provider.
645    fn test_parse_model_returns_none_for_models_from_other_providers() {
646        // Arrange
647        let claude_kind = AgentKind::Claude;
648        let antigravity_model = AgentModel::Gemini3FlashPreview.as_str();
649
650        // Act
651        let parsed_model = claude_kind.parse_model(antigravity_model);
652
653        // Assert
654        assert_eq!(parsed_model, None);
655    }
656
657    #[test]
658    /// Ensures `gpt-5.5` parses as a Codex model.
659    fn test_parse_model_parses_gpt_55() {
660        // Arrange
661        let codex_kind = AgentKind::Codex;
662
663        // Act
664        let parsed_model = codex_kind.parse_model("gpt-5.5");
665
666        // Assert
667        assert_eq!(parsed_model, Some(AgentModel::Gpt55));
668    }
669
670    #[test]
671    /// Ensures Antigravity parses raw Gemini model ids as shared Gemini model
672    /// selections.
673    fn test_parse_model_parses_antigravity() {
674        // Arrange
675        let antigravity_kind = AgentKind::Antigravity;
676
677        // Act
678        let parsed_model = antigravity_kind.parse_model("gemini-3.5-flash");
679
680        // Assert
681        assert_eq!(parsed_model, Some(AgentModel::Gemini35Flash));
682    }
683
684    #[test]
685    /// Ensures direct Gemini parses the same raw Gemini model ids as
686    /// Antigravity.
687    fn test_parse_model_parses_direct_gemini() {
688        // Arrange
689        let gemini_kind = AgentKind::Gemini;
690
691        // Act
692        let parsed_model = gemini_kind.parse_model("gemini-3.5-flash");
693
694        // Assert
695        assert_eq!(parsed_model, Some(AgentModel::Gemini35Flash));
696    }
697
698    #[test]
699    /// Ensures retired `gpt-5.4` no longer parses as a selectable model.
700    fn test_parse_model_rejects_retired_gpt_54() {
701        // Arrange
702        let codex_kind = AgentKind::Codex;
703
704        // Act
705        let parsed_gpt_54 = codex_kind.parse_model("gpt-5.4");
706
707        // Assert
708        assert_eq!(parsed_gpt_54, None);
709    }
710
711    #[test]
712    /// Ensures `gpt-5.4-mini` parses as a Codex model.
713    fn test_parse_model_parses_gpt_54_mini() {
714        // Arrange
715        let codex_kind = AgentKind::Codex;
716
717        // Act
718        let parsed_model = codex_kind.parse_model("gpt-5.4-mini");
719
720        // Assert
721        assert_eq!(parsed_model, Some(AgentModel::Gpt54Mini));
722    }
723
724    #[test]
725    /// Ensures retired Claude aliases no longer parse as selectable
726    /// models.
727    fn test_parse_model_rejects_retired_claude_aliases() {
728        // Arrange
729        let claude_kind = AgentKind::Claude;
730
731        // Act
732        let parsed_opus_46 = claude_kind.parse_model("claude-opus-4-6");
733        let parsed_opus_47 = claude_kind.parse_model("claude-opus-4-7");
734        let parsed_sonnet_46 = claude_kind.parse_model("claude-sonnet-4-6");
735
736        // Assert
737        assert_eq!(parsed_opus_46, None);
738        assert_eq!(parsed_opus_47, None);
739        assert_eq!(parsed_sonnet_46, None);
740    }
741
742    #[test]
743    /// Ensures current Claude model ids parse as supported Claude models.
744    fn test_parse_model_parses_current_claude_models() {
745        // Arrange
746        let claude_kind = AgentKind::Claude;
747
748        // Act
749        let parsed_sonnet_5 = claude_kind.parse_model("claude-sonnet-5");
750        let parsed_fable_5 = claude_kind.parse_model("claude-fable-5");
751        let parsed_haiku_45 = claude_kind.parse_model("claude-haiku-4-5-20251001");
752
753        // Assert
754        assert_eq!(parsed_sonnet_5, Some(AgentModel::ClaudeSonnet5));
755        assert_eq!(parsed_fable_5, Some(AgentModel::ClaudeFable5));
756        assert_eq!(parsed_haiku_45, Some(AgentModel::ClaudeHaiku4520251001));
757    }
758
759    #[test]
760    /// Ensures persisted model ids parse or migrate to supported
761    /// models.
762    fn test_parse_persisted_handles_supported_and_retired_models() {
763        // Arrange
764
765        // Act
766        let parsed_opus_46 = AgentModel::parse_persisted("claude-opus-4-6");
767        let parsed_opus_47 = AgentModel::parse_persisted("claude-opus-4-7");
768        let parsed_sonnet_46 = AgentModel::parse_persisted("claude-sonnet-4-6");
769        let parsed_sonnet_5 = AgentModel::parse_persisted("claude-sonnet-5");
770        let parsed_gpt_54 = AgentModel::parse_persisted("gpt-5.4");
771        let parsed_gemini_35_flash = AgentModel::parse_persisted("gemini-3.5-flash");
772
773        // Assert
774        assert_eq!(parsed_opus_46, Ok(AgentModel::ClaudeOpus48));
775        assert_eq!(parsed_opus_47, Ok(AgentModel::ClaudeOpus48));
776        assert_eq!(parsed_sonnet_46, Ok(AgentModel::ClaudeSonnet5));
777        assert_eq!(parsed_sonnet_5, Ok(AgentModel::ClaudeSonnet5));
778        assert_eq!(parsed_gpt_54, Ok(AgentModel::Gpt55));
779        assert_eq!(parsed_gemini_35_flash, Ok(AgentModel::Gemini35Flash));
780    }
781
782    #[test]
783    /// Ensures persisted session agent values constrain model parsing instead
784    /// of deriving provider ownership from the model string.
785    fn test_parse_persisted_session_agent_model_prefers_saved_agent() {
786        // Arrange
787
788        // Act
789        let selection = parse_persisted_session_agent_model(Some("codex"), "gemini-3.5-flash");
790
791        // Assert
792        assert_eq!(selection.kind(), AgentKind::Codex);
793        assert_eq!(selection.model(), AgentKind::Codex.default_model());
794    }
795
796    #[test]
797    /// Ensures Antigravity-persisted sessions keep Antigravity ownership for
798    /// raw Gemini model ids shared with the direct Gemini backend.
799    fn test_parse_persisted_session_agent_model_preserves_antigravity_models() {
800        // Arrange
801
802        // Act
803        let selection =
804            parse_persisted_session_agent_model(Some("antigravity"), "gemini-3.5-flash");
805
806        // Assert
807        assert_eq!(selection.kind(), AgentKind::Antigravity);
808        assert_eq!(selection.model(), AgentModel::Gemini35Flash);
809    }
810
811    #[test]
812    /// Ensures older persisted rows without `agent` still load through the
813    /// legacy model-derived compatibility path.
814    fn test_parse_persisted_session_agent_model_supports_legacy_rows() {
815        // Arrange
816
817        // Act
818        let selection = parse_persisted_session_agent_model(None, "claude-opus-4-6");
819
820        // Assert
821        assert_eq!(selection.kind(), AgentKind::Claude);
822        assert_eq!(selection.model(), AgentModel::ClaudeOpus48);
823    }
824
825    #[test]
826    /// Ensures older `gemini-*` rows without `agent` keep the post-removal
827    /// Antigravity compatibility default.
828    fn test_parse_persisted_session_agent_model_defaults_legacy_gemini_to_antigravity() {
829        // Arrange
830
831        // Act
832        let selection = parse_persisted_session_agent_model(None, "gemini-3.5-flash");
833
834        // Assert
835        assert_eq!(selection.kind(), AgentKind::Antigravity);
836        assert_eq!(selection.model(), AgentModel::Gemini35Flash);
837    }
838
839    #[test]
840    /// Ensures reasoning-level parsing accepts all supported persisted values.
841    fn test_reasoning_level_from_str_parses_supported_values() {
842        // Arrange
843
844        // Act
845        let low_level = "low".parse::<ReasoningLevel>();
846        let medium_level = "medium".parse::<ReasoningLevel>();
847        let high_level = "high".parse::<ReasoningLevel>();
848        let xhigh_level = "xhigh".parse::<ReasoningLevel>();
849
850        // Assert
851        assert_eq!(low_level, Ok(ReasoningLevel::Low));
852        assert_eq!(medium_level, Ok(ReasoningLevel::Medium));
853        assert_eq!(high_level, Ok(ReasoningLevel::High));
854        assert_eq!(xhigh_level, Ok(ReasoningLevel::XHigh));
855    }
856
857    #[test]
858    /// Ensures unsupported reasoning values return a parse error.
859    fn test_reasoning_level_from_str_rejects_unknown_values() {
860        // Arrange
861
862        // Act
863        let parse_result = "minimal".parse::<ReasoningLevel>();
864
865        // Assert
866        assert!(parse_result.is_err());
867    }
868
869    #[test]
870    /// Ensures Codex models are supported by Codex only.
871    fn test_codex_models_are_supported_by_codex() {
872        // Arrange
873        let models = [
874            AgentModel::Gpt55,
875            AgentModel::Gpt54Mini,
876            AgentModel::Gpt53CodexSpark,
877        ];
878
879        // Act
880        let supported = models.map(|model| AgentKind::Codex.supports_model(model));
881        let unsupported = models.map(|model| AgentKind::Claude.supports_model(model));
882
883        // Assert
884        assert_eq!(supported, [true; 3]);
885        assert_eq!(unsupported, [false; 3]);
886    }
887
888    #[test]
889    /// Ensures Claude models are supported by Claude only.
890    fn test_claude_models_are_supported_by_claude() {
891        // Arrange
892        let models = [
893            AgentModel::ClaudeOpus48,
894            AgentModel::ClaudeSonnet5,
895            AgentModel::ClaudeFable5,
896            AgentModel::ClaudeHaiku4520251001,
897        ];
898
899        // Act
900        let supported = models.map(|model| AgentKind::Claude.supports_model(model));
901        let unsupported = models.map(|model| AgentKind::Codex.supports_model(model));
902
903        // Assert
904        assert_eq!(supported, [true; 4]);
905        assert_eq!(unsupported, [false; 4]);
906    }
907
908    #[test]
909    /// Ensures shared Gemini models are supported by both Google providers.
910    fn test_gemini_models_are_supported_by_gemini_and_antigravity() {
911        // Arrange
912        let models = [
913            AgentModel::Gemini31ProPreview,
914            AgentModel::Gemini35Flash,
915            AgentModel::Gemini31FlashLitePreview,
916            AgentModel::Gemini3FlashPreview,
917        ];
918
919        // Act
920        let antigravity_supported =
921            models.map(|model| AgentKind::Antigravity.supports_model(model));
922        let gemini_supported = models.map(|model| AgentKind::Gemini.supports_model(model));
923        let codex_supported = models.map(|model| AgentKind::Codex.supports_model(model));
924
925        // Assert
926        assert_eq!(antigravity_supported, [true; 4]);
927        assert_eq!(gemini_supported, [true; 4]);
928        assert_eq!(codex_supported, [false; 4]);
929    }
930
931    #[test]
932    /// Ensures Antigravity model selections pass raw Gemini ids to provider
933    /// transports.
934    fn test_antigravity_provider_model_str_returns_raw_gemini_model() {
935        // Arrange
936        let model = AgentModel::Gemini31FlashLitePreview;
937
938        // Act
939        let persisted_model = model.as_str();
940        let provider_model = model.provider_model_str();
941
942        // Assert
943        assert_eq!(persisted_model, "gemini-3.1-flash-lite-preview");
944        assert_eq!(provider_model, "gemini-3.1-flash-lite-preview");
945    }
946
947    #[test]
948    /// Ensures `ReasoningLevel::claude()` maps all levels to the correct
949    /// Claude `--effort` values, including `XHigh` → `"max"`.
950    fn test_reasoning_level_claude_maps_all_levels() {
951        // Arrange / Act / Assert
952        assert_eq!(ReasoningLevel::Low.claude(), "low");
953        assert_eq!(ReasoningLevel::Medium.claude(), "medium");
954        assert_eq!(ReasoningLevel::High.claude(), "high");
955        assert_eq!(ReasoningLevel::XHigh.claude(), "max");
956    }
957
958    #[test]
959    /// Ensures persisted reasoning identifiers stay stable even if provider
960    /// transport names change in the future.
961    fn test_reasoning_level_as_str_returns_stable_persisted_values() {
962        // Arrange / Act / Assert
963        assert_eq!(ReasoningLevel::Low.as_str(), "low");
964        assert_eq!(ReasoningLevel::Medium.as_str(), "medium");
965        assert_eq!(ReasoningLevel::High.as_str(), "high");
966        assert_eq!(ReasoningLevel::XHigh.as_str(), "xhigh");
967    }
968
969    #[test]
970    /// Ensures selectable-model ordering follows the provided provider order.
971    fn test_selectable_models_for_agent_kinds_uses_provider_order() {
972        // Arrange
973        let agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
974
975        // Act
976        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
977
978        // Assert
979        assert_eq!(
980            selectable_models,
981            vec![
982                AgentModel::Gpt55,
983                AgentModel::Gpt54Mini,
984                AgentModel::Gpt53CodexSpark,
985                AgentModel::Gemini31ProPreview,
986                AgentModel::Gemini35Flash,
987                AgentModel::Gemini31FlashLitePreview,
988                AgentModel::Gemini3FlashPreview,
989            ]
990        );
991    }
992
993    #[test]
994    /// Ensures shared Gemini models appear once even when both Google
995    /// providers are available.
996    fn test_selectable_models_for_agent_kinds_deduplicates_shared_models() {
997        // Arrange
998        let agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
999
1000        // Act
1001        let selectable_models = selectable_models_for_agent_kinds(&agent_kinds);
1002
1003        // Assert
1004        assert_eq!(
1005            selectable_models,
1006            vec![
1007                AgentModel::Gemini31ProPreview,
1008                AgentModel::Gemini35Flash,
1009                AgentModel::Gemini31FlashLitePreview,
1010                AgentModel::Gemini3FlashPreview,
1011            ]
1012        );
1013    }
1014
1015    #[test]
1016    /// Ensures unavailable models fall back to an available preferred model
1017    /// when possible.
1018    fn test_resolve_model_for_available_agent_kinds_prefers_available_fallback() {
1019        // Arrange
1020        let unavailable_model = AgentModel::ClaudeOpus48;
1021        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1022        let fallback_model = AgentModel::Gpt55;
1023
1024        // Act
1025        let resolved_model = resolve_model_for_available_agent_kinds(
1026            unavailable_model,
1027            &available_agent_kinds,
1028            fallback_model,
1029        );
1030
1031        // Assert
1032        assert_eq!(resolved_model, AgentModel::Gpt55);
1033    }
1034
1035    #[test]
1036    /// Ensures unavailable models fall back to the first available provider
1037    /// default when the preferred fallback is also unavailable.
1038    fn test_resolve_model_for_available_agent_kinds_uses_first_available_default() {
1039        // Arrange
1040        let unavailable_model = AgentModel::ClaudeOpus48;
1041        let available_agent_kinds = [AgentKind::Codex, AgentKind::Antigravity];
1042        let unavailable_fallback_model = AgentModel::ClaudeSonnet5;
1043
1044        // Act
1045        let resolved_model = resolve_model_for_available_agent_kinds(
1046            unavailable_model,
1047            &available_agent_kinds,
1048            unavailable_fallback_model,
1049        );
1050
1051        // Assert
1052        assert_eq!(resolved_model, AgentKind::Codex.default_model());
1053    }
1054
1055    #[test]
1056    /// Ensures model-only settings preserve a preferred provider when it can
1057    /// run the selected model.
1058    fn test_resolve_agent_selection_for_model_preserves_preferred_shared_provider() {
1059        // Arrange
1060        let model = AgentModel::Gemini35Flash;
1061        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1062
1063        // Act
1064        let resolved_selection = resolve_agent_selection_for_model(
1065            model,
1066            AgentKind::Antigravity,
1067            &available_agent_kinds,
1068        );
1069
1070        // Assert
1071        assert_eq!(
1072            resolved_selection,
1073            AgentSelection::new(AgentKind::Antigravity, model)
1074        );
1075    }
1076
1077    #[test]
1078    /// Ensures model-only settings use available provider order when the
1079    /// preferred provider cannot run the selected model.
1080    fn test_resolve_agent_selection_for_model_uses_available_provider_order() {
1081        // Arrange
1082        let model = AgentModel::Gemini35Flash;
1083        let available_agent_kinds = [AgentKind::Gemini, AgentKind::Antigravity];
1084
1085        // Act
1086        let resolved_selection =
1087            resolve_agent_selection_for_model(model, AgentKind::Codex, &available_agent_kinds);
1088
1089        // Assert
1090        assert_eq!(
1091            resolved_selection,
1092            AgentSelection::new(AgentKind::Gemini, model)
1093        );
1094    }
1095
1096    #[test]
1097    /// Ensures prompt model selection keeps the current backend when it is
1098    /// still available locally.
1099    fn test_resolve_prompt_model_agent_kind_prefers_current_agent() {
1100        // Arrange
1101        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1102
1103        // Act
1104        let resolved_agent_kind =
1105            resolve_prompt_model_agent_kind(AgentKind::Codex, &available_agent_kinds);
1106
1107        // Assert
1108        assert_eq!(resolved_agent_kind, Some(AgentKind::Codex));
1109    }
1110
1111    #[test]
1112    /// Ensures prompt model selection falls back to the first locally
1113    /// available backend when the current backend is unavailable.
1114    fn test_resolve_prompt_model_agent_kind_uses_first_available_agent() {
1115        // Arrange
1116        let available_agent_kinds = [AgentKind::Antigravity, AgentKind::Codex];
1117
1118        // Act
1119        let resolved_agent_kind =
1120            resolve_prompt_model_agent_kind(AgentKind::Claude, &available_agent_kinds);
1121
1122        // Assert
1123        assert_eq!(resolved_agent_kind, Some(AgentKind::Antigravity));
1124    }
1125}