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