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, ¤t.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, ¤t)?;
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, ¤t.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"), ¤t, &[]).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"), ¤t, "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}