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