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
163fn 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, ¤t.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, ¤t)?;
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, ¤t.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 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 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"), ¤t, &[]).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"), ¤t, "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 assert_eq!(args("codex", "gpt-5", None), ["-m", "gpt-5"]);
1375 }
1376}