Skip to main content

gate4agent_adapters/
session_options.rs

1use gate4agent_types::{
2    AdapterId, SessionOptionSelection, SessionOptionValue, SESSION_OPTION_ID_MAX_BYTES,
3    SESSION_OPTION_VALUES_MAX, SESSION_OPTION_VALUE_MAX_BYTES,
4};
5use std::collections::{BTreeMap, BTreeSet};
6use thiserror::Error;
7
8pub const SESSION_OPTION_CATALOG_REVISION: &str = "gate4agent-session-options/orca-d8629c4/v1";
9pub const SESSION_OPTION_MODELS_MAX: usize = 512;
10pub const SESSION_OPTION_MODEL_LIST_MAX_BYTES: usize = 1_048_576;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum SessionOptionCategory {
14    Model,
15    ThoughtLevel,
16    ModelConfig,
17    Mode,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct SessionOptionChoice {
22    pub value: String,
23    pub label: String,
24    pub description: Option<String>,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum SessionOptionKind {
29    Select {
30        choices: Vec<SessionOptionChoice>,
31        default_value: String,
32    },
33    Boolean {
34        default_value: bool,
35    },
36}
37
38impl SessionOptionKind {
39    fn default_value(&self) -> SessionOptionValue {
40        match self {
41            Self::Select { default_value, .. } => default_value.clone().into(),
42            Self::Boolean { default_value } => (*default_value).into(),
43        }
44    }
45
46    fn accepts(&self, value: &SessionOptionValue) -> bool {
47        matches!(
48            (self, value),
49            (Self::Select { .. }, SessionOptionValue::String(_))
50                | (Self::Boolean { .. }, SessionOptionValue::Boolean(_))
51        )
52    }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum SessionOptionLaunchApplication {
57    Flag { flag: String },
58    Config { flag: String, key: String },
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub enum SessionOptionArgumentOverride {
63    Flags(Vec<String>),
64    CodexReasoningEffort,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum SessionOptionInteractionDetection {
69    ClaudeModelSwitchConfirmation,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum SessionOptionMidSessionApplication {
74    Command {
75        command: String,
76        picker_command: Option<String>,
77        interaction_detection: Option<SessionOptionInteractionDetection>,
78    },
79    ToggleCommand {
80        command: String,
81    },
82    AgentPicker {
83        command: String,
84    },
85    Unsupported,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct SessionOptionApply {
90    pub launch: Option<SessionOptionLaunchApplication>,
91    pub argument_override: Option<SessionOptionArgumentOverride>,
92    pub composed_into_model: bool,
93    pub mid_session: SessionOptionMidSessionApplication,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct SessionOption {
98    pub id: String,
99    pub label: String,
100    pub description: Option<String>,
101    pub category: Option<SessionOptionCategory>,
102    pub kind: SessionOptionKind,
103    pub apply: SessionOptionApply,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct SessionOptionModel {
108    pub id: String,
109    pub label: String,
110    pub description: Option<String>,
111    pub is_default: Option<bool>,
112    pub options: Vec<SessionOption>,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct SessionOptionModelListSpec {
117    pub program: String,
118    pub args: Vec<String>,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct AgentSessionOptionCatalog {
123    pub adapter_id: AdapterId,
124    pub revision: String,
125    pub models: Vec<SessionOptionModel>,
126    pub model_apply: SessionOptionApply,
127    pub model_list: Option<SessionOptionModelListSpec>,
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct ResolvedSessionOptionLaunch {
132    pub args: Vec<String>,
133    pub applied: Option<SessionOptionSelection>,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub enum SessionOptionMidSessionPlan {
138    Noop,
139    Command {
140        command: String,
141        target: Option<SessionOptionValue>,
142        interaction_detection: Option<SessionOptionInteractionDetection>,
143        expected_choice_label: Option<String>,
144    },
145    AgentPicker {
146        command: String,
147    },
148}
149
150pub fn session_option_catalog(
151    adapter_id: &AdapterId,
152) -> Result<AgentSessionOptionCatalog, SessionOptionAdapterError> {
153    match adapter_id.as_str() {
154        "claude-code" => Ok(claude_catalog(adapter_id.clone())),
155        "codex" => Ok(codex_catalog(adapter_id.clone())),
156        "cursor" => Ok(cursor_catalog(adapter_id.clone())),
157        "grok" => Ok(grok_catalog(adapter_id.clone())),
158        "kimi" => Ok(kimi_catalog(adapter_id.clone())),
159        id => Err(SessionOptionAdapterError::UnsupportedAdapter(id.to_owned())),
160    }
161}
162
163/// Launch options for a model id the pinned catalog does not list (models discovered on the
164/// node at runtime). The provider-level flag shape still applies, so an effort selected for a
165/// discovered model is not silently dropped.
166fn unlisted_model_options(adapter_id: &AdapterId) -> Vec<SessionOption> {
167    match adapter_id.as_str() {
168        "claude-code" => vec![claude_effort(true)],
169        "codex" => vec![codex_effort(true)],
170        "grok" => vec![grok_effort()],
171        _ => Vec::new(),
172    }
173}
174
175pub fn resolve_session_option_launch(
176    adapter_id: &AdapterId,
177    selection: &SessionOptionSelection,
178    trailing_agent_args: &[String],
179) -> Result<ResolvedSessionOptionLaunch, SessionOptionAdapterError> {
180    let catalog = session_option_catalog(adapter_id)?;
181    let selection = normalize_selection(selection)?;
182    let unlisted;
183    let model = match find_model(&catalog, &selection.model) {
184        Some(model) => Some(model),
185        None if valid_dynamic_model_id(&selection.model) => {
186            let options = unlisted_model_options(adapter_id)
187                .into_iter()
188                .filter(|option| selection.values.contains_key(&option.id))
189                .collect::<Vec<_>>();
190            unlisted = model(&selection.model, &selection.model, false, options);
191            Some(&unlisted)
192        }
193        None => None,
194    };
195    let values = model
196        .map(|model| resolved_model_values(model, &selection))
197        .transpose()?
198        .unwrap_or_default();
199    let model_value = if adapter_id.as_str() == "cursor" {
200        compose_cursor_model(&selection.model, &values)
201    } else {
202        selection.model.clone()
203    };
204    let model_overridden = is_overridden(&catalog.model_apply, trailing_agent_args);
205    let mut args = launch_args(&catalog.model_apply, &model_value).unwrap_or_default();
206    let mut applied = (!model_overridden).then(|| SessionOptionSelection::new(&selection.model));
207
208    let Some(model) = model else {
209        return Ok(ResolvedSessionOptionLaunch { args, applied });
210    };
211    for option in &model.options {
212        let value = values
213            .get(&option.id)
214            .expect("resolved model values contain every catalog option");
215        if option.apply.composed_into_model {
216            if let Some(applied) = &mut applied {
217                applied.values.insert(option.id.clone(), value.clone());
218            }
219            continue;
220        }
221        if option.apply.launch.is_none() {
222            continue;
223        }
224        let option_args = launch_args(&option.apply, value_as_argument(value)?)
225            .expect("launch application was checked above");
226        args.extend(option_args);
227        if !model_overridden && !is_overridden(&option.apply, trailing_agent_args) {
228            if let Some(applied) = &mut applied {
229                applied.values.insert(option.id.clone(), value.clone());
230            }
231        }
232    }
233    Ok(ResolvedSessionOptionLaunch { args, applied })
234}
235
236pub fn plan_mid_session_option(
237    adapter_id: &AdapterId,
238    current: &SessionOptionSelection,
239    option_id: &str,
240    target: SessionOptionValue,
241) -> Result<SessionOptionMidSessionPlan, SessionOptionAdapterError> {
242    let catalog = session_option_catalog(adapter_id)?;
243    let current = normalize_selection(current)?;
244    validate_option_id(option_id)?;
245    let (apply, model_id, expected_choice_label) = if option_id == "model" {
246        let target_model =
247            target
248                .as_str()
249                .ok_or_else(|| SessionOptionAdapterError::InvalidValueType {
250                    option_id: option_id.to_owned(),
251                })?;
252        (
253            &catalog.model_apply,
254            target_model.to_owned(),
255            Some(
256                find_model(&catalog, target_model)
257                    .map(|model| model.label.clone())
258                    .unwrap_or_else(|| target_model.to_owned()),
259            ),
260        )
261    } else {
262        let model = find_model(&catalog, &current.model).ok_or_else(|| {
263            SessionOptionAdapterError::UnknownModel {
264                model_id: current.model.clone(),
265            }
266        })?;
267        let option = model
268            .options
269            .iter()
270            .find(|option| option.id == option_id)
271            .ok_or_else(|| SessionOptionAdapterError::UnknownOption {
272                option_id: option_id.to_owned(),
273            })?;
274        if !option.kind.accepts(&target) {
275            return Err(SessionOptionAdapterError::InvalidValueType {
276                option_id: option_id.to_owned(),
277            });
278        }
279        (&option.apply, current.model.clone(), None)
280    };
281
282    match &apply.mid_session {
283        SessionOptionMidSessionApplication::Command {
284            command,
285            interaction_detection,
286            ..
287        } => {
288            let value = value_as_command_atom(&target)?;
289            Ok(SessionOptionMidSessionPlan::Command {
290                command: format!("{command} {value}"),
291                target: Some(target),
292                interaction_detection: *interaction_detection,
293                expected_choice_label,
294            })
295        }
296        SessionOptionMidSessionApplication::ToggleCommand { command } => {
297            let current_value = current
298                .values
299                .get(option_id)
300                .and_then(SessionOptionValue::as_bool);
301            let target_value =
302                target
303                    .as_bool()
304                    .ok_or_else(|| SessionOptionAdapterError::InvalidValueType {
305                        option_id: option_id.to_owned(),
306                    })?;
307            match current_value {
308                Some(value) if value == target_value => Ok(SessionOptionMidSessionPlan::Noop),
309                Some(_) => Ok(SessionOptionMidSessionPlan::Command {
310                    command: command.clone(),
311                    target: Some(target),
312                    interaction_detection: None,
313                    expected_choice_label: None,
314                }),
315                None => Err(SessionOptionAdapterError::UnknownToggleBaseline {
316                    option_id: option_id.to_owned(),
317                }),
318            }
319        }
320        SessionOptionMidSessionApplication::AgentPicker { command } => {
321            Ok(SessionOptionMidSessionPlan::AgentPicker {
322                command: command.clone(),
323            })
324        }
325        SessionOptionMidSessionApplication::Unsupported if apply.composed_into_model => {
326            let model = find_model(&catalog, &model_id).ok_or_else(|| {
327                SessionOptionAdapterError::UnknownModel {
328                    model_id: model_id.clone(),
329                }
330            })?;
331            let mut values = resolved_model_values(model, &current)?;
332            values.insert(option_id.to_owned(), target.clone());
333            let composed = compose_cursor_model(&model_id, &values);
334            let SessionOptionMidSessionApplication::Command {
335                command,
336                interaction_detection,
337                ..
338            } = &catalog.model_apply.mid_session
339            else {
340                return Err(SessionOptionAdapterError::MidSessionUnsupported {
341                    option_id: option_id.to_owned(),
342                });
343            };
344            let composed_value = SessionOptionValue::from(composed);
345            Ok(SessionOptionMidSessionPlan::Command {
346                command: format!("{command} {}", value_as_command_atom(&composed_value)?),
347                target: Some(target),
348                interaction_detection: *interaction_detection,
349                expected_choice_label: None,
350            })
351        }
352        SessionOptionMidSessionApplication::Unsupported => {
353            Err(SessionOptionAdapterError::MidSessionUnsupported {
354                option_id: option_id.to_owned(),
355            })
356        }
357    }
358}
359
360pub fn plan_mid_session_action(
361    adapter_id: &AdapterId,
362    current: &SessionOptionSelection,
363    option_id: &str,
364) -> Result<SessionOptionMidSessionPlan, SessionOptionAdapterError> {
365    let catalog = session_option_catalog(adapter_id)?;
366    let current = normalize_selection(current)?;
367    validate_option_id(option_id)?;
368    let apply = if option_id == "model" {
369        &catalog.model_apply
370    } else {
371        let model = find_model(&catalog, &current.model).ok_or_else(|| {
372            SessionOptionAdapterError::UnknownModel {
373                model_id: current.model.clone(),
374            }
375        })?;
376        &model
377            .options
378            .iter()
379            .find(|option| option.id == option_id)
380            .ok_or_else(|| SessionOptionAdapterError::UnknownOption {
381                option_id: option_id.to_owned(),
382            })?
383            .apply
384    };
385    match &apply.mid_session {
386        SessionOptionMidSessionApplication::AgentPicker { command } => {
387            Ok(SessionOptionMidSessionPlan::AgentPicker {
388                command: command.clone(),
389            })
390        }
391        SessionOptionMidSessionApplication::ToggleCommand { command }
392            if !current.values.contains_key(option_id) =>
393        {
394            Ok(SessionOptionMidSessionPlan::Command {
395                command: command.clone(),
396                target: None,
397                interaction_detection: None,
398                expected_choice_label: None,
399            })
400        }
401        _ => Err(SessionOptionAdapterError::ActionUnavailable {
402            option_id: option_id.to_owned(),
403        }),
404    }
405}
406
407pub fn parse_session_option_models(
408    adapter_id: &AdapterId,
409    stdout: &str,
410) -> Result<Vec<SessionOptionModel>, SessionOptionAdapterError> {
411    let catalog = session_option_catalog(adapter_id)?;
412    if stdout.len() > SESSION_OPTION_MODEL_LIST_MAX_BYTES {
413        return Err(SessionOptionAdapterError::ModelListTooLarge);
414    }
415    if catalog.model_list.is_none() {
416        return Err(SessionOptionAdapterError::ModelListUnsupported(
417            adapter_id.as_str().to_owned(),
418        ));
419    }
420    let mut seen = BTreeSet::new();
421    let mut models = Vec::new();
422    for line in stdout.lines() {
423        let mut candidate = line.trim();
424        if let Some(rest) = candidate
425            .strip_prefix("- ")
426            .or_else(|| candidate.strip_prefix("* "))
427        {
428            candidate = rest.trim();
429        }
430        if let Some(index) = candidate.find(" (") {
431            if candidate.ends_with(')') {
432                candidate = &candidate[..index];
433            }
434        }
435        if !valid_dynamic_model_id(candidate)
436            || candidate.eq_ignore_ascii_case("models")
437            || !seen.insert(candidate.to_owned())
438        {
439            continue;
440        }
441        models.push(SessionOptionModel {
442            id: candidate.to_owned(),
443            label: if candidate == "auto" {
444                "Auto".to_owned()
445            } else {
446                candidate.to_owned()
447            },
448            description: None,
449            is_default: None,
450            options: Vec::new(),
451        });
452        if models.len() == SESSION_OPTION_MODELS_MAX {
453            break;
454        }
455    }
456    Ok(models)
457}
458
459pub fn merge_session_option_models(
460    seed: &[SessionOptionModel],
461    discovered: &[SessionOptionModel],
462) -> Vec<SessionOptionModel> {
463    let discovered_by_id = discovered
464        .iter()
465        .cloned()
466        .map(|model| (model.id.clone(), model))
467        .collect::<BTreeMap<_, _>>();
468    let seed_ids = seed
469        .iter()
470        .map(|model| model.id.as_str())
471        .collect::<BTreeSet<_>>();
472    let mut merged = Vec::new();
473    for model in seed {
474        let Some(live) = discovered_by_id.get(&model.id) else {
475            merged.push(model.clone());
476            continue;
477        };
478        merged.push(SessionOptionModel {
479            id: model.id.clone(),
480            label: live.label.clone(),
481            description: live
482                .description
483                .clone()
484                .or_else(|| model.description.clone()),
485            is_default: live.is_default.or(model.is_default),
486            options: model.options.clone(),
487        });
488    }
489    let mut appended = BTreeSet::new();
490    for model in discovered {
491        if !seed_ids.contains(model.id.as_str()) && appended.insert(model.id.as_str()) {
492            if let Some(model) = discovered_by_id.get(&model.id) {
493                merged.push(model.clone());
494            }
495        }
496    }
497    merged
498}
499
500fn normalize_selection(
501    selection: &SessionOptionSelection,
502) -> Result<SessionOptionSelection, SessionOptionAdapterError> {
503    let model = normalize_value(&selection.model)?;
504    if selection.values.len() > SESSION_OPTION_VALUES_MAX {
505        return Err(SessionOptionAdapterError::TooManyValues);
506    }
507    let mut values = BTreeMap::new();
508    for (id, value) in &selection.values {
509        validate_option_id(id)?;
510        if id == "model" {
511            return Err(SessionOptionAdapterError::ReservedModelOption);
512        }
513        let value = match value {
514            SessionOptionValue::String(value) => normalize_value(value)?.into(),
515            SessionOptionValue::Boolean(value) => (*value).into(),
516        };
517        values.insert(id.clone(), value);
518    }
519    Ok(SessionOptionSelection { model, values })
520}
521
522fn normalize_value(value: &str) -> Result<String, SessionOptionAdapterError> {
523    let value = value.trim();
524    if value.is_empty()
525        || value.len() > SESSION_OPTION_VALUE_MAX_BYTES
526        || value.chars().any(char::is_control)
527    {
528        return Err(SessionOptionAdapterError::InvalidValue);
529    }
530    Ok(value.to_owned())
531}
532
533fn validate_option_id(value: &str) -> Result<(), SessionOptionAdapterError> {
534    if value.is_empty()
535        || value.len() > SESSION_OPTION_ID_MAX_BYTES
536        || value.chars().any(char::is_control)
537    {
538        return Err(SessionOptionAdapterError::InvalidOptionId);
539    }
540    Ok(())
541}
542
543fn value_as_argument(value: &SessionOptionValue) -> Result<&str, SessionOptionAdapterError> {
544    value
545        .as_str()
546        .ok_or(SessionOptionAdapterError::InvalidValue)
547}
548
549fn value_as_command_atom(value: &SessionOptionValue) -> Result<&str, SessionOptionAdapterError> {
550    let value = value_as_argument(value)?;
551    if value.chars().any(char::is_whitespace) {
552        return Err(SessionOptionAdapterError::UnsafeCommandValue);
553    }
554    Ok(value)
555}
556
557fn resolved_model_values(
558    model: &SessionOptionModel,
559    selection: &SessionOptionSelection,
560) -> Result<BTreeMap<String, SessionOptionValue>, SessionOptionAdapterError> {
561    let mut values = BTreeMap::new();
562    for option in &model.options {
563        let value = selection
564            .values
565            .get(&option.id)
566            .cloned()
567            .unwrap_or_else(|| option.kind.default_value());
568        if !option.kind.accepts(&value) {
569            return Err(SessionOptionAdapterError::InvalidValueType {
570                option_id: option.id.clone(),
571            });
572        }
573        values.insert(option.id.clone(), value);
574    }
575    Ok(values)
576}
577
578fn launch_args(apply: &SessionOptionApply, value: &str) -> Option<Vec<String>> {
579    match apply.launch.as_ref()? {
580        SessionOptionLaunchApplication::Flag { flag } => Some(vec![flag.clone(), value.to_owned()]),
581        SessionOptionLaunchApplication::Config { flag, key } => {
582            Some(vec![flag.clone(), format!("{key}={value}")])
583        }
584    }
585}
586
587fn is_overridden(apply: &SessionOptionApply, tokens: &[String]) -> bool {
588    match &apply.argument_override {
589        Some(SessionOptionArgumentOverride::Flags(flags)) => has_flag(tokens, flags),
590        Some(SessionOptionArgumentOverride::CodexReasoningEffort) => {
591            has_flag(tokens, &["--reasoning-effort".to_owned()])
592                || tokens.iter().enumerate().any(|(index, token)| {
593                    let previous = index.checked_sub(1).and_then(|index| tokens.get(index));
594                    (token.starts_with("model_reasoning_effort=")
595                        && matches!(previous.map(String::as_str), Some("-c" | "--config")))
596                        || token.starts_with("-cmodel_reasoning_effort=")
597                        || token.starts_with("-c=model_reasoning_effort=")
598                        || token.starts_with("--config=model_reasoning_effort=")
599                })
600        }
601        None => false,
602    }
603}
604
605fn has_flag(tokens: &[String], flags: &[String]) -> bool {
606    tokens.iter().any(|token| {
607        flags.iter().any(|flag| {
608            token == flag
609                || token.starts_with(&format!("{flag}="))
610                || (flag.starts_with('-') && !flag.starts_with("--") && token.starts_with(flag))
611        })
612    })
613}
614
615fn find_model<'a>(
616    catalog: &'a AgentSessionOptionCatalog,
617    model_id: &str,
618) -> Option<&'a SessionOptionModel> {
619    catalog.models.iter().find(|model| model.id == model_id)
620}
621
622fn compose_cursor_model(model_id: &str, values: &BTreeMap<String, SessionOptionValue>) -> String {
623    if model_id == "auto" {
624        return model_id.to_owned();
625    }
626    if model_id.starts_with("claude-") {
627        let thinking = if values.get("thinking").and_then(SessionOptionValue::as_bool) == Some(true)
628        {
629            "-thinking"
630        } else {
631            ""
632        };
633        let effort = values
634            .get("effort")
635            .and_then(SessionOptionValue::as_str)
636            .map(|value| format!("-{value}"))
637            .unwrap_or_default();
638        return format!("{model_id}{thinking}{effort}");
639    }
640    let effort = values
641        .get("effort")
642        .and_then(SessionOptionValue::as_str)
643        .map(|value| format!("-{value}"))
644        .unwrap_or_default();
645    let fast = if values.get("fastMode").and_then(SessionOptionValue::as_bool) == Some(true) {
646        "-fast"
647    } else {
648        ""
649    };
650    format!("{model_id}{effort}{fast}")
651}
652
653fn valid_dynamic_model_id(value: &str) -> bool {
654    value
655        .as_bytes()
656        .first()
657        .is_some_and(u8::is_ascii_alphanumeric)
658        && value
659            .bytes()
660            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
661        && value.len() <= SESSION_OPTION_VALUE_MAX_BYTES
662}
663
664fn choice(value: &str, label: &str) -> SessionOptionChoice {
665    SessionOptionChoice {
666        value: value.to_owned(),
667        label: label.to_owned(),
668        description: None,
669    }
670}
671
672fn select_option(
673    id: &str,
674    label: &str,
675    category: SessionOptionCategory,
676    choices: &[(&str, &str)],
677    default_value: &str,
678    apply: SessionOptionApply,
679) -> SessionOption {
680    SessionOption {
681        id: id.to_owned(),
682        label: label.to_owned(),
683        description: None,
684        category: Some(category),
685        kind: SessionOptionKind::Select {
686            choices: choices
687                .iter()
688                .map(|(value, label)| choice(value, label))
689                .collect(),
690            default_value: default_value.to_owned(),
691        },
692        apply,
693    }
694}
695
696fn boolean_option(
697    id: &str,
698    label: &str,
699    category: SessionOptionCategory,
700    default_value: bool,
701    apply: SessionOptionApply,
702) -> SessionOption {
703    SessionOption {
704        id: id.to_owned(),
705        label: label.to_owned(),
706        description: None,
707        category: Some(category),
708        kind: SessionOptionKind::Boolean { default_value },
709        apply,
710    }
711}
712
713fn model(
714    id: &str,
715    label: &str,
716    is_default: bool,
717    options: Vec<SessionOption>,
718) -> SessionOptionModel {
719    SessionOptionModel {
720        id: id.to_owned(),
721        label: label.to_owned(),
722        description: None,
723        is_default: is_default.then_some(true),
724        options,
725    }
726}
727
728fn command_apply(command: &str) -> SessionOptionMidSessionApplication {
729    SessionOptionMidSessionApplication::Command {
730        command: command.to_owned(),
731        picker_command: None,
732        interaction_detection: None,
733    }
734}
735
736fn claude_effort(extended: bool) -> SessionOption {
737    let mut choices = vec![("low", "Low"), ("medium", "Medium"), ("high", "High")];
738    if extended {
739        choices.extend([("xhigh", "Extra high"), ("max", "Max")]);
740    }
741    select_option(
742        "effort",
743        "Effort",
744        SessionOptionCategory::ThoughtLevel,
745        &choices,
746        "high",
747        SessionOptionApply {
748            launch: Some(SessionOptionLaunchApplication::Flag {
749                flag: "--effort".to_owned(),
750            }),
751            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
752                "--effort".to_owned()
753            ])),
754            composed_into_model: false,
755            mid_session: command_apply("/effort"),
756        },
757    )
758}
759
760fn claude_catalog(adapter_id: AdapterId) -> AgentSessionOptionCatalog {
761    let fast_mode = || {
762        boolean_option(
763            "fastMode",
764            "Fast mode",
765            SessionOptionCategory::Mode,
766            false,
767            SessionOptionApply {
768                launch: None,
769                argument_override: None,
770                composed_into_model: false,
771                mid_session: SessionOptionMidSessionApplication::ToggleCommand {
772                    command: "/fast".to_owned(),
773                },
774            },
775        )
776    };
777    AgentSessionOptionCatalog {
778        adapter_id,
779        revision: SESSION_OPTION_CATALOG_REVISION.to_owned(),
780        models: vec![
781            model("fable", "Fable 5", false, vec![claude_effort(true)]),
782            model(
783                "opus",
784                "Opus 4.8",
785                false,
786                vec![claude_effort(true), fast_mode()],
787            ),
788            model("sonnet", "Sonnet 5", true, vec![claude_effort(true)]),
789            model("haiku", "Haiku", false, Vec::new()),
790        ],
791        model_apply: SessionOptionApply {
792            launch: Some(SessionOptionLaunchApplication::Flag {
793                flag: "--model".to_owned(),
794            }),
795            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
796                "--model".to_owned()
797            ])),
798            composed_into_model: false,
799            mid_session: SessionOptionMidSessionApplication::Command {
800                command: "/model".to_owned(),
801                picker_command: Some("/model".to_owned()),
802                interaction_detection: Some(
803                    SessionOptionInteractionDetection::ClaudeModelSwitchConfirmation,
804                ),
805            },
806        },
807        model_list: None,
808    }
809}
810
811fn codex_effort(include_extra_high: bool) -> SessionOption {
812    let mut choices = vec![
813        ("minimal", "Minimal"),
814        ("low", "Low"),
815        ("medium", "Medium"),
816        ("high", "High"),
817    ];
818    if include_extra_high {
819        choices.push(("xhigh", "Extra high"));
820    }
821    select_option(
822        "effort",
823        "Reasoning effort",
824        SessionOptionCategory::ThoughtLevel,
825        &choices,
826        "medium",
827        SessionOptionApply {
828            launch: Some(SessionOptionLaunchApplication::Config {
829                flag: "-c".to_owned(),
830                key: "model_reasoning_effort".to_owned(),
831            }),
832            argument_override: Some(SessionOptionArgumentOverride::CodexReasoningEffort),
833            composed_into_model: false,
834            mid_session: SessionOptionMidSessionApplication::AgentPicker {
835                command: "/model".to_owned(),
836            },
837        },
838    )
839}
840
841fn codex_catalog(adapter_id: AdapterId) -> AgentSessionOptionCatalog {
842    AgentSessionOptionCatalog {
843        adapter_id,
844        revision: SESSION_OPTION_CATALOG_REVISION.to_owned(),
845        models: vec![
846            model(
847                "gpt-5.6-sol",
848                "GPT-5.6 Sol",
849                false,
850                vec![codex_effort(true)],
851            ),
852            model(
853                "gpt-5.6-terra",
854                "GPT-5.6 Terra",
855                false,
856                vec![codex_effort(true)],
857            ),
858            model(
859                "gpt-5.6-luna",
860                "GPT-5.6 Luna",
861                false,
862                vec![codex_effort(false)],
863            ),
864            model("gpt-5.5", "GPT-5.5", false, vec![codex_effort(true)]),
865            model(
866                "gpt-5.2-codex",
867                "GPT-5.2 Codex",
868                false,
869                vec![codex_effort(true)],
870            ),
871        ],
872        model_apply: SessionOptionApply {
873            launch: Some(SessionOptionLaunchApplication::Flag {
874                flag: "-m".to_owned(),
875            }),
876            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
877                "-m".to_owned(),
878                "--model".to_owned(),
879            ])),
880            composed_into_model: false,
881            mid_session: SessionOptionMidSessionApplication::AgentPicker {
882                command: "/model".to_owned(),
883            },
884        },
885        model_list: None,
886    }
887}
888
889fn grok_effort() -> SessionOption {
890    // `grok --reasoning-effort <EFFORT>` (alias `--effort`), grok 1.0.46 `--help`.
891    select_option(
892        "effort",
893        "Reasoning effort",
894        SessionOptionCategory::ThoughtLevel,
895        &[("low", "Low"), ("medium", "Medium"), ("high", "High")],
896        "high",
897        SessionOptionApply {
898            launch: Some(SessionOptionLaunchApplication::Flag {
899                flag: "--reasoning-effort".to_owned(),
900            }),
901            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
902                "--reasoning-effort".to_owned(),
903                "--effort".to_owned(),
904            ])),
905            composed_into_model: false,
906            mid_session: SessionOptionMidSessionApplication::Unsupported,
907        },
908    )
909}
910
911fn grok_catalog(adapter_id: AdapterId) -> AgentSessionOptionCatalog {
912    AgentSessionOptionCatalog {
913        adapter_id,
914        revision: SESSION_OPTION_CATALOG_REVISION.to_owned(),
915        models: Vec::new(),
916        model_apply: SessionOptionApply {
917            launch: Some(SessionOptionLaunchApplication::Flag {
918                flag: "--model".to_owned(),
919            }),
920            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
921                "-m".to_owned(),
922                "--model".to_owned(),
923            ])),
924            composed_into_model: false,
925            mid_session: command_apply("/model"),
926        },
927        model_list: None,
928    }
929}
930
931fn kimi_catalog(adapter_id: AdapterId) -> AgentSessionOptionCatalog {
932    // `kimi -m/--model <alias>` (kimi-code 2.1.1); no reasoning-effort launch flag.
933    AgentSessionOptionCatalog {
934        adapter_id,
935        revision: SESSION_OPTION_CATALOG_REVISION.to_owned(),
936        models: Vec::new(),
937        model_apply: SessionOptionApply {
938            launch: Some(SessionOptionLaunchApplication::Flag {
939                flag: "--model".to_owned(),
940            }),
941            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
942                "-m".to_owned(),
943                "--model".to_owned(),
944            ])),
945            composed_into_model: false,
946            mid_session: command_apply("/model"),
947        },
948        model_list: None,
949    }
950}
951
952fn cursor_catalog(adapter_id: AdapterId) -> AgentSessionOptionCatalog {
953    let effort = || {
954        select_option(
955            "effort",
956            "Effort",
957            SessionOptionCategory::ThoughtLevel,
958            &[("low", "Low"), ("medium", "Medium"), ("high", "High")],
959            "high",
960            SessionOptionApply {
961                launch: None,
962                argument_override: None,
963                composed_into_model: true,
964                mid_session: SessionOptionMidSessionApplication::Unsupported,
965            },
966        )
967    };
968    AgentSessionOptionCatalog {
969        adapter_id,
970        revision: SESSION_OPTION_CATALOG_REVISION.to_owned(),
971        models: vec![
972            model("auto", "Auto", true, Vec::new()),
973            model(
974                "gpt-5.3-codex",
975                "GPT-5.3 Codex",
976                false,
977                vec![
978                    effort(),
979                    boolean_option(
980                        "fastMode",
981                        "Fast mode",
982                        SessionOptionCategory::Mode,
983                        false,
984                        SessionOptionApply {
985                            launch: None,
986                            argument_override: None,
987                            composed_into_model: true,
988                            mid_session: SessionOptionMidSessionApplication::Unsupported,
989                        },
990                    ),
991                ],
992            ),
993            model(
994                "claude-opus-4-8",
995                "Claude Opus 4.8",
996                false,
997                vec![
998                    boolean_option(
999                        "thinking",
1000                        "Thinking",
1001                        SessionOptionCategory::ModelConfig,
1002                        true,
1003                        SessionOptionApply {
1004                            launch: None,
1005                            argument_override: None,
1006                            composed_into_model: true,
1007                            mid_session: SessionOptionMidSessionApplication::Unsupported,
1008                        },
1009                    ),
1010                    effort(),
1011                ],
1012            ),
1013        ],
1014        model_apply: SessionOptionApply {
1015            launch: Some(SessionOptionLaunchApplication::Flag {
1016                flag: "--model".to_owned(),
1017            }),
1018            argument_override: Some(SessionOptionArgumentOverride::Flags(vec![
1019                "-m".to_owned(),
1020                "--model".to_owned(),
1021            ])),
1022            composed_into_model: false,
1023            mid_session: command_apply("/model"),
1024        },
1025        model_list: Some(SessionOptionModelListSpec {
1026            program: "cursor-agent".to_owned(),
1027            args: vec!["models".to_owned()],
1028        }),
1029    }
1030}
1031
1032#[derive(Clone, Debug, Error, Eq, PartialEq)]
1033pub enum SessionOptionAdapterError {
1034    #[error("session-option adapter is unavailable for {0}")]
1035    UnsupportedAdapter(String),
1036    #[error("session-option value is empty, contains controls, or exceeds its bound")]
1037    InvalidValue,
1038    #[error("session-option ID is empty, contains controls, or exceeds its bound")]
1039    InvalidOptionId,
1040    #[error("session-option selection cannot store a second model field")]
1041    ReservedModelOption,
1042    #[error("session-option selection exceeds the value count bound")]
1043    TooManyValues,
1044    #[error("session option {option_id} has the wrong value type")]
1045    InvalidValueType { option_id: String },
1046    #[error("session-option model {model_id} is not in the versioned catalog")]
1047    UnknownModel { model_id: String },
1048    #[error("unknown session option {option_id}")]
1049    UnknownOption { option_id: String },
1050    #[error("session-option command values must be single atoms")]
1051    UnsafeCommandValue,
1052    #[error("session option {option_id} has an unknown toggle baseline")]
1053    UnknownToggleBaseline { option_id: String },
1054    #[error("session option {option_id} cannot be changed mid-session")]
1055    MidSessionUnsupported { option_id: String },
1056    #[error("session option {option_id} has no value-less action")]
1057    ActionUnavailable { option_id: String },
1058    #[error("model-list parsing is unavailable for {0}")]
1059    ModelListUnsupported(String),
1060    #[error("model-list output exceeds the supported bound")]
1061    ModelListTooLarge,
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    fn id(value: &str) -> AdapterId {
1069        AdapterId::new(value).unwrap()
1070    }
1071
1072    fn choice_values(option: &SessionOption) -> Vec<&str> {
1073        let SessionOptionKind::Select { choices, .. } = &option.kind else {
1074            panic!("expected a select option");
1075        };
1076        choices.iter().map(|choice| choice.value.as_str()).collect()
1077    }
1078
1079    #[test]
1080    fn pinned_catalogs_keep_model_scoped_option_shapes() {
1081        let claude = session_option_catalog(&id("claude-code")).unwrap();
1082        assert_eq!(
1083            claude
1084                .models
1085                .iter()
1086                .find(|model| model.id == "opus")
1087                .unwrap()
1088                .options
1089                .iter()
1090                .map(|option| option.id.as_str())
1091                .collect::<Vec<_>>(),
1092            ["effort", "fastMode"]
1093        );
1094        assert!(claude
1095            .models
1096            .iter()
1097            .find(|model| model.id == "haiku")
1098            .unwrap()
1099            .options
1100            .is_empty());
1101        assert_eq!(claude.revision, SESSION_OPTION_CATALOG_REVISION);
1102
1103        let fable_effort = &claude
1104            .models
1105            .iter()
1106            .find(|model| model.id == "fable")
1107            .unwrap()
1108            .options[0];
1109        assert_eq!(
1110            choice_values(fable_effort),
1111            ["low", "medium", "high", "xhigh", "max"]
1112        );
1113
1114        let codex = session_option_catalog(&id("codex")).unwrap();
1115        let sol_effort = &codex
1116            .models
1117            .iter()
1118            .find(|model| model.id == "gpt-5.6-sol")
1119            .unwrap()
1120            .options[0];
1121        let luna_effort = &codex
1122            .models
1123            .iter()
1124            .find(|model| model.id == "gpt-5.6-luna")
1125            .unwrap()
1126            .options[0];
1127        assert_eq!(
1128            choice_values(sol_effort),
1129            ["minimal", "low", "medium", "high", "xhigh"]
1130        );
1131        assert_eq!(
1132            choice_values(luna_effort),
1133            ["minimal", "low", "medium", "high"]
1134        );
1135    }
1136
1137    #[test]
1138    fn pinned_catalog_model_inventories_are_exact() {
1139        for (adapter, expected) in [
1140            ("claude-code", vec!["fable", "opus", "sonnet", "haiku"]),
1141            (
1142                "codex",
1143                vec![
1144                    "gpt-5.6-sol",
1145                    "gpt-5.6-terra",
1146                    "gpt-5.6-luna",
1147                    "gpt-5.5",
1148                    "gpt-5.2-codex",
1149                ],
1150            ),
1151            ("cursor", vec!["auto", "gpt-5.3-codex", "claude-opus-4-8"]),
1152        ] {
1153            let actual = session_option_catalog(&id(adapter))
1154                .unwrap()
1155                .models
1156                .into_iter()
1157                .map(|model| model.id)
1158                .collect::<Vec<_>>();
1159            assert_eq!(actual, expected, "model inventory drift for {adapter}");
1160        }
1161    }
1162
1163    #[test]
1164    fn launch_resolution_matches_pinned_defaults_and_override_precedence() {
1165        let selected = SessionOptionSelection::new("opus")
1166            .with_value("effort", "xhigh")
1167            .with_value("fastMode", true);
1168        let resolved = resolve_session_option_launch(
1169            &id("claude-code"),
1170            &selected,
1171            &["--model".to_owned(), "haiku".to_owned()],
1172        )
1173        .unwrap();
1174        assert_eq!(resolved.args, ["--model", "opus", "--effort", "xhigh"]);
1175        assert!(resolved.applied.is_none());
1176
1177        let resolved = resolve_session_option_launch(
1178            &id("claude-code"),
1179            &SessionOptionSelection::new("sonnet"),
1180            &[],
1181        )
1182        .unwrap();
1183        assert_eq!(resolved.args, ["--model", "sonnet", "--effort", "high"]);
1184        assert_eq!(
1185            resolved.applied,
1186            Some(SessionOptionSelection::new("sonnet").with_value("effort", "high"))
1187        );
1188    }
1189
1190    #[test]
1191    fn codex_override_detection_preserves_only_truthful_applied_values() {
1192        let selected = SessionOptionSelection::new("gpt-5.6-sol").with_value("effort", "medium");
1193        let resolved = resolve_session_option_launch(
1194            &id("codex"),
1195            &selected,
1196            &["-c".to_owned(), "model_reasoning_effort=high".to_owned()],
1197        )
1198        .unwrap();
1199        assert_eq!(
1200            resolved.args,
1201            ["-m", "gpt-5.6-sol", "-c", "model_reasoning_effort=medium"]
1202        );
1203        assert_eq!(
1204            resolved.applied,
1205            Some(SessionOptionSelection::new("gpt-5.6-sol"))
1206        );
1207    }
1208
1209    #[test]
1210    fn cursor_composes_options_for_launch_and_live_model_commands() {
1211        let current = SessionOptionSelection::new("gpt-5.3-codex")
1212            .with_value("effort", "high")
1213            .with_value("fastMode", true);
1214        let resolved = resolve_session_option_launch(&id("cursor"), &current, &[]).unwrap();
1215        assert_eq!(resolved.args, ["--model", "gpt-5.3-codex-high-fast"]);
1216        assert_eq!(resolved.applied, Some(current.clone()));
1217
1218        assert_eq!(
1219            plan_mid_session_option(&id("cursor"), &current, "effort", "low".into(),).unwrap(),
1220            SessionOptionMidSessionPlan::Command {
1221                command: "/model gpt-5.3-codex-low-fast".to_owned(),
1222                target: Some("low".into()),
1223                interaction_detection: None,
1224                expected_choice_label: None,
1225            }
1226        );
1227    }
1228
1229    #[test]
1230    fn mid_session_plans_distinguish_commands_pickers_and_unknown_toggles() {
1231        let claude = SessionOptionSelection::new("opus").with_value("fastMode", false);
1232        assert!(matches!(
1233            plan_mid_session_option(&id("claude-code"), &claude, "model", "sonnet".into(),)
1234                .unwrap(),
1235            SessionOptionMidSessionPlan::Command {
1236                interaction_detection: Some(
1237                    SessionOptionInteractionDetection::ClaudeModelSwitchConfirmation
1238                ),
1239                ..
1240            }
1241        ));
1242        assert!(matches!(
1243            plan_mid_session_option(
1244                &id("codex"),
1245                &SessionOptionSelection::new("gpt-5.5"),
1246                "model",
1247                "gpt-5.6-sol".into(),
1248            )
1249            .unwrap(),
1250            SessionOptionMidSessionPlan::AgentPicker { .. }
1251        ));
1252        assert_eq!(
1253            plan_mid_session_option(&id("claude-code"), &claude, "fastMode", true.into(),).unwrap(),
1254            SessionOptionMidSessionPlan::Command {
1255                command: "/fast".to_owned(),
1256                target: Some(true.into()),
1257                interaction_detection: None,
1258                expected_choice_label: None,
1259            }
1260        );
1261        assert!(matches!(
1262            plan_mid_session_option(
1263                &id("claude-code"),
1264                &SessionOptionSelection::new("opus"),
1265                "fastMode",
1266                true.into(),
1267            ),
1268            Err(SessionOptionAdapterError::UnknownToggleBaseline { .. })
1269        ));
1270        assert_eq!(
1271            plan_mid_session_action(
1272                &id("claude-code"),
1273                &SessionOptionSelection::new("opus"),
1274                "fastMode",
1275            )
1276            .unwrap(),
1277            SessionOptionMidSessionPlan::Command {
1278                command: "/fast".to_owned(),
1279                target: None,
1280                interaction_detection: None,
1281                expected_choice_label: None,
1282            }
1283        );
1284    }
1285
1286    #[test]
1287    fn cursor_model_discovery_is_bounded_and_preserves_seed_option_shapes() {
1288        let catalog = session_option_catalog(&id("cursor")).unwrap();
1289        assert_eq!(
1290            catalog.model_list,
1291            Some(SessionOptionModelListSpec {
1292                program: "cursor-agent".to_owned(),
1293                args: vec!["models".to_owned()],
1294            })
1295        );
1296        let discovered = parse_session_option_models(
1297            &id("cursor"),
1298            "Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n- account-model\n",
1299        )
1300        .unwrap();
1301        assert_eq!(
1302            discovered
1303                .iter()
1304                .map(|model| model.id.as_str())
1305                .collect::<Vec<_>>(),
1306            ["auto", "gpt-5.3-codex", "account-model"]
1307        );
1308        let merged = merge_session_option_models(&catalog.models, &discovered);
1309        assert_eq!(
1310            merged
1311                .iter()
1312                .find(|model| model.id == "gpt-5.3-codex")
1313                .unwrap()
1314                .options
1315                .len(),
1316            2
1317        );
1318        assert_eq!(merged.last().unwrap().id, "account-model");
1319    }
1320
1321    #[test]
1322    fn unsupported_or_unsafe_values_fail_typed_without_fallback() {
1323        assert!(matches!(
1324            session_option_catalog(&id("omp")),
1325            Err(SessionOptionAdapterError::UnsupportedAdapter(_))
1326        ));
1327        let future = resolve_session_option_launch(
1328            &id("claude-code"),
1329            &SessionOptionSelection::new("opus").with_value("effort", "future-effort"),
1330            &[],
1331        )
1332        .unwrap();
1333        assert_eq!(
1334            future.args,
1335            ["--model", "opus", "--effort", "future-effort"]
1336        );
1337        assert!(matches!(
1338            plan_mid_session_option(
1339                &id("claude-code"),
1340                &SessionOptionSelection::new("opus"),
1341                "effort",
1342                "high now".into(),
1343            ),
1344            Err(SessionOptionAdapterError::UnsafeCommandValue)
1345        ));
1346    }
1347}
1348
1349#[cfg(test)]
1350mod fleet_launch_flags {
1351    use super::*;
1352
1353    fn args(adapter: &str, model: &str, effort: Option<&str>) -> Vec<String> {
1354        let mut sel = SessionOptionSelection::new(model);
1355        if let Some(e) = effort {
1356            sel = sel.with_value("effort", e);
1357        }
1358        resolve_session_option_launch(&AdapterId::new(adapter).unwrap(), &sel, &[]).unwrap().args
1359    }
1360
1361    #[test]
1362    fn every_fleet_provider_maps_model_and_effort_to_its_own_flags() {
1363        assert_eq!(args("claude-code", "opus", Some("high")), ["--model", "opus", "--effort", "high"]);
1364        assert_eq!(args("codex", "gpt-5.5", Some("high")), ["-m", "gpt-5.5", "-c", "model_reasoning_effort=high"]);
1365        assert_eq!(args("grok", "grok-4.6", Some("low")), ["--model", "grok-4.6", "--reasoning-effort", "low"]);
1366        assert_eq!(args("kimi", "kimi-k2", None), ["--model", "kimi-k2"]);
1367    }
1368
1369    #[test]
1370    fn discovered_unlisted_models_keep_the_selected_effort() {
1371        assert_eq!(args("codex", "gpt-5", Some("high")), ["-m", "gpt-5", "-c", "model_reasoning_effort=high"]);
1372        assert_eq!(args("claude-code", "claude-x", Some("max")), ["--model", "claude-x", "--effort", "max"]);
1373        // No effort selected: no effort flag forced on an unlisted model.
1374        assert_eq!(args("codex", "gpt-5", None), ["-m", "gpt-5"]);
1375    }
1376}