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