1use serde::{Deserialize, Serialize};
32use serde_json::{json, Value};
33
34use crate::backend::obj::MetaMapType;
35use crate::backend::providers::default_model_for;
36
37pub(crate) const DEFAULT_PERMISSION_MODE: &str = "bypass";
39pub(crate) const DEFAULT_EFFORT: &str = "high";
41
42const OWNED_FLAGS: &[&str] = &["--model", "-m", "--effort"];
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SpawnRuntime {
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub model: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub effort: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub permission_mode: Option<String>,
58}
59
60pub fn spawn_runtime_from_args(args: &[String]) -> SpawnRuntime {
66 let mut out = SpawnRuntime::default();
67 let mut i = 0;
68 while i < args.len() {
69 let a = args[i].as_str();
70 let next = || args.get(i + 1).cloned();
71 match a {
72 "--model" | "-m" => {
73 if let Some(v) = next() {
74 out.model = Some(v);
75 i += 1;
76 }
77 }
78 "--effort" => {
79 if let Some(v) = next() {
80 out.effort = Some(v);
81 i += 1;
82 }
83 }
84 "--permission-mode" => {
85 if let Some(v) = next() {
86 out.permission_mode = Some(v);
87 i += 1;
88 }
89 }
90 "--dangerously-skip-permissions" => out.permission_mode = Some("bypass".to_string()),
91 _ => {
92 if let Some(v) = a.strip_prefix("--model=") {
93 out.model = Some(v.to_string());
94 } else if let Some(v) = a.strip_prefix("--effort=") {
95 out.effort = Some(v.to_string());
96 } else if let Some(v) = a.strip_prefix("--permission-mode=") {
97 out.permission_mode = Some(v.to_string());
98 }
99 }
100 }
101 i += 1;
102 }
103 out
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct EffectiveRuntime {
112 pub model: Option<String>,
113 pub effort: Option<String>,
114}
115
116pub const SETTINGS_REQUEST_PREFIX: &str = "agentmux-settings-";
119
120pub fn settings_request_line() -> String {
124 json!({
125 "type": "control_request",
126 "request_id": format!("{SETTINGS_REQUEST_PREFIX}{}", uuid::Uuid::new_v4()),
127 "request": { "subtype": "get_settings" },
128 })
129 .to_string()
130}
131
132pub fn effective_from_control_response(frame: &Value) -> Option<EffectiveRuntime> {
137 if frame.get("type").and_then(Value::as_str) != Some("control_response") {
138 return None;
139 }
140 let resp = frame.get("response")?;
141 if resp.get("subtype").and_then(Value::as_str) != Some("success") {
142 return None;
143 }
144 if !resp
145 .get("request_id")
146 .and_then(Value::as_str)
147 .is_some_and(|id| id.starts_with(SETTINGS_REQUEST_PREFIX))
148 {
149 return None;
150 }
151 let applied = resp.get("response")?.get("applied")?.as_object()?;
152 let field = |k: &str| {
153 applied
154 .get(k)
155 .and_then(Value::as_str)
156 .filter(|v| !v.is_empty())
157 .map(str::to_string)
158 };
159 Some(EffectiveRuntime { model: field("model"), effort: field("effort") })
160}
161
162#[derive(Debug, Clone, PartialEq, serde::Serialize, ts_rs::TS)]
165#[ts(export, export_to = "../../../frontend/types/rpc/")]
166pub struct AgentRuntimeEvent {
167 pub blockid: String,
168 pub running: bool,
169 pub restart_pending: bool,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 #[ts(optional)]
172 pub model: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 #[ts(optional)]
175 pub effort: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 #[ts(optional)]
178 pub permission_mode: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 #[ts(optional)]
181 pub effective_model: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 #[ts(optional)]
184 pub effective_effort: Option<String>,
185}
186
187pub fn agent_runtime_event(
192 block_id: &str,
193 running: Option<&SpawnRuntime>,
194 restart_pending: bool,
195 effective: Option<&EffectiveRuntime>,
196) -> AgentRuntimeEvent {
197 let effective = effective.filter(|_| running.is_some());
198 AgentRuntimeEvent {
199 blockid: block_id.to_string(),
200 running: running.is_some(),
201 restart_pending,
202 model: running.and_then(|rt| rt.model.clone()),
203 effort: running.and_then(|rt| rt.effort.clone()),
204 permission_mode: running.and_then(|rt| rt.permission_mode.clone()),
205 effective_model: effective.and_then(|e| e.model.clone()),
206 effective_effort: effective.and_then(|e| e.effort.clone()),
207 }
208}
209
210pub fn publish_agent_runtime(
211 broker: &crate::backend::mps::Broker,
212 block_id: &str,
213 running: Option<&SpawnRuntime>,
214 restart_pending: bool,
215 effective: Option<&EffectiveRuntime>,
216) {
217 let payload = agent_runtime_event(block_id, running, restart_pending, effective);
218 broker.publish_event::<crate::backend::mps_events::AgentRuntime>(&payload, vec![format!("block:{block_id}")], 1);
219}
220
221pub(crate) struct SeededLaunch {
222 pub cli_args: Vec<String>,
224 pub runtime: Option<Value>,
227}
228
229fn flag_value<'a>(flags: &'a [String], names: &[&str]) -> Option<&'a str> {
231 let mut found = None;
236 let mut it = flags.iter();
237 while let Some(f) = it.next() {
238 for n in names {
239 if f == n {
240 if let Some(v) = it.next() {
241 found = Some(v.as_str());
242 }
243 break;
244 }
245 if let Some(v) = f.strip_prefix(&format!("{n}=")) {
246 found = Some(v);
247 break;
248 }
249 }
250 }
251 found
252}
253
254fn without_owned_flags(base: Vec<String>) -> Vec<String> {
256 let mut out = Vec::with_capacity(base.len());
257 let mut it = base.into_iter();
258 while let Some(a) = it.next() {
259 if OWNED_FLAGS.contains(&a.as_str()) {
260 it.next();
261 continue;
262 }
263 if OWNED_FLAGS.iter().any(|f| a.starts_with(&format!("{f}="))) {
264 continue;
265 }
266 out.push(a);
267 }
268 out
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub(crate) struct Remembered {
276 pub permission_mode: Option<String>,
277 pub model: Option<String>,
278 pub effort: Option<String>,
279}
280
281const PERMISSION_MODES: &[&str] = &["bypass", "auto", "acceptEdits", "plan", "default"];
282const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
283const CONTROL_PROTOCOL_FLAG: &str = "--permission-prompt-tool";
286
287impl Remembered {
288 pub(crate) fn parse(provider_id: &str, raw: &str) -> Remembered {
293 if provider_id != "claude" || raw.is_empty() {
294 return Remembered::default();
295 }
296 let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(raw) else {
297 return Remembered::default();
298 };
299 let get = |k: &str| obj.get(k).and_then(Value::as_str).filter(|v| !v.is_empty());
300 Remembered {
301 permission_mode: get("permissionMode")
302 .filter(|m| PERMISSION_MODES.contains(m))
303 .map(str::to_string),
304 effort: get("effort").filter(|e| EFFORT_LEVELS.contains(e)).map(str::to_string),
305 model: get("model")
306 .filter(|m| matches!(*m, "opus" | "sonnet" | "haiku") || m.starts_with("claude-"))
307 .map(str::to_string),
308 }
309 }
310
311 fn is_empty(&self) -> bool {
312 self.permission_mode.is_none() && self.model.is_none() && self.effort.is_none()
313 }
314}
315
316fn without_permission_flags(args: Vec<String>) -> Vec<String> {
319 let mut out = Vec::with_capacity(args.len());
320 let mut it = args.into_iter();
321 while let Some(a) = it.next() {
322 if a == "--permission-mode" {
323 it.next();
324 continue;
325 }
326 if a == "--dangerously-skip-permissions" || a.starts_with("--permission-mode=") {
327 continue;
328 }
329 out.push(a);
330 }
331 out
332}
333
334fn without_flags(flags: Vec<String>, names: &[&str]) -> Vec<String> {
336 let mut out = Vec::with_capacity(flags.len());
337 let mut it = flags.into_iter();
338 while let Some(f) = it.next() {
339 if names.contains(&f.as_str()) {
340 it.next();
341 continue;
342 }
343 if names.iter().any(|n| f.starts_with(&format!("{n}="))) {
344 continue;
345 }
346 out.push(f);
347 }
348 out
349}
350
351pub(crate) fn apply_remembered(
360 provider_id: &str,
361 base: Vec<String>,
362 provider_flags: &str,
363 remembered: &Remembered,
364) -> (Vec<String>, String, Option<String>) {
365 if provider_id != "claude" || remembered.is_empty() {
366 return (base, provider_flags.to_string(), None);
367 }
368 let mut flags: Vec<String> = provider_flags.split_whitespace().map(str::to_string).collect();
369 let mut base = base;
370 let mut front: Vec<String> = Vec::new();
371
372 let model = remembered
374 .model
375 .clone()
376 .or_else(|| flag_value(&flags, &["--model", "-m"]).map(str::to_string));
377 if let Some(m) = &remembered.model {
378 flags = without_flags(flags, &["--model", "-m"]);
379 front.extend(["--model".to_string(), m.clone()]);
380 }
381 if let Some(e) = &remembered.effort {
382 flags = without_flags(flags, &["--effort"]);
383 if model.as_deref().map_or(true, model_takes_effort) {
384 front.extend(["--effort".to_string(), e.clone()]);
385 }
386 }
387 if model.as_deref().is_some_and(|m| !model_takes_effort(m)) {
389 flags = without_flags(flags, &["--effort"]);
390 }
391
392 let mut mode_for_menu = None;
393 if let Some(mode) = &remembered.permission_mode {
394 let control = base.iter().any(|a| a == CONTROL_PROTOCOL_FLAG);
395 base = without_permission_flags(base);
396 flags = without_permission_flags(flags);
397 let wire = if control && mode == "bypass" { "default" } else { mode.as_str() };
398 if wire == "bypass" {
399 front.push("--dangerously-skip-permissions".to_string());
400 } else {
401 front.extend(["--permission-mode".to_string(), wire.to_string()]);
402 }
403 mode_for_menu = Some(mode.clone());
404 }
405
406 front.extend(flags);
407 (base, front.join(" "), mode_for_menu)
408}
409
410pub(crate) fn seed_launch(
418 provider_id: &str,
419 base: Vec<String>,
420 provider_flags: &str,
421) -> SeededLaunch {
422 let flags: Vec<String> = provider_flags
423 .split_whitespace()
424 .map(str::to_string)
425 .collect();
426 let Some(default_model) = default_model_for(provider_id) else {
427 let mut cli_args = base;
428 cli_args.extend(flags);
429 return SeededLaunch {
430 cli_args,
431 runtime: None,
432 };
433 };
434
435 let flag_model = flag_value(&flags, &["--model", "-m"]);
436 let flag_effort = flag_value(&flags, &["--effort"]);
437 let model = flag_model.unwrap_or(default_model).to_string();
438 let effort = flag_effort.unwrap_or(DEFAULT_EFFORT).to_string();
439
440 let mut cli_args = without_owned_flags(base);
441 let marker = if provider_id == "codex" && cli_args.last().map(String::as_str) == Some("-") {
446 cli_args.pop()
447 } else {
448 None
449 };
450 match provider_id {
451 "claude" => {
452 if flag_model.is_none() {
453 cli_args.extend(["--model".to_string(), model.clone()]);
454 }
455 if flag_effort.is_none() && model_takes_effort(&model) {
458 cli_args.extend(["--effort".to_string(), effort.clone()]);
459 }
460 }
461 "codex" | "antigravity" => {
462 if flag_model.is_none() {
463 cli_args.extend(["--model".to_string(), model.clone()]);
464 }
465 }
466 _ => {}
467 }
468 cli_args.extend(flags);
469 cli_args.extend(marker);
470
471 SeededLaunch {
472 cli_args,
473 runtime: Some(json!({
474 "permissionMode": DEFAULT_PERMISSION_MODE,
475 "model": model,
476 "effort": effort,
477 })),
478 }
479}
480
481pub(crate) fn model_takes_effort(model: &str) -> bool {
487 !model.to_ascii_lowercase().contains("haiku")
488}
489
490fn is_claude_model(m: &str) -> bool {
495 matches!(m, "opus" | "sonnet" | "haiku") || m.starts_with("claude-")
496}
497
498pub(crate) fn with_runtime_flags(meta: &MetaMapType, args: Vec<String>) -> Vec<String> {
506 if args.is_empty() {
507 return args;
508 }
509 let raw_provider = crate::backend::obj::meta_get_string(meta, "agentProvider", "");
514 let provider_id = crate::backend::providers::resolve_provider_alias(&raw_provider);
515 let Some(default_model) = default_model_for(provider_id) else {
516 return args;
517 };
518 let runtime = |key: &str| {
519 meta.get("agent:runtime")
520 .and_then(|r| r.get(key))
521 .and_then(Value::as_str)
522 .filter(|v| !v.is_empty())
523 .map(str::to_string)
524 };
525
526 let have_model = flag_value(&args, &["--model", "-m"]).map(str::to_string);
527 let have_effort = flag_value(&args, &["--effort"]).is_some();
528 let model = have_model.clone().unwrap_or_else(|| {
529 runtime("model")
530 .filter(|m| match provider_id {
531 "codex" => !is_claude_model(m),
532 "antigravity" => !matches!(m.as_str(), "opus" | "sonnet" | "haiku"),
535 _ => true,
536 })
537 .unwrap_or_else(|| default_model.to_string())
538 });
539
540 let mut out = args;
541 match provider_id {
542 "claude" => {
543 if have_model.is_none() {
544 out.extend(["--model".to_string(), model.clone()]);
545 }
546 if !have_effort && model_takes_effort(&model) {
547 let effort = runtime("effort").unwrap_or_else(|| DEFAULT_EFFORT.to_string());
548 out.extend(["--effort".to_string(), effort]);
549 }
550 }
551 "codex" => {
552 if have_model.is_none() {
553 let marker = out.iter().rposition(|a| a == "-").map(|i| out.remove(i));
556 out.extend(["--model".to_string(), model]);
557 out.extend(marker);
558 }
559 }
560 "antigravity" => {
561 if have_model.is_none() {
562 out.extend(["--model".to_string(), model]);
563 }
564 }
565 _ => {}
566 }
567 out
568}
569
570pub(crate) fn apply_to_meta(meta: &mut MetaMapType, seeded: SeededLaunch, provider_flags: &str) {
577 meta.insert("cmd:args".to_string(), json!(seeded.cli_args));
578 if let Some(runtime) = seeded.runtime {
579 meta.insert("agent:runtime".to_string(), runtime);
580 }
581 meta.insert("agent:provider_flags".to_string(), json!(provider_flags));
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 fn ss(v: &[&str]) -> Vec<String> {
589 v.iter().map(|x| x.to_string()).collect()
590 }
591
592 #[test]
593 fn the_spawn_runtime_is_read_from_the_final_argv() {
594 let rt = spawn_runtime_from_args(&ss(&[
595 "--input-format",
596 "stream-json",
597 "--permission-mode",
598 "default",
599 "--model",
600 "opus",
601 "--effort",
602 "max",
603 ]));
604 assert_eq!(rt.model.as_deref(), Some("opus"));
605 assert_eq!(rt.effort.as_deref(), Some("max"));
606 assert_eq!(rt.permission_mode.as_deref(), Some("default"));
607 }
608
609 #[test]
610 fn a_flag_the_argv_lacks_is_none_which_means_the_cli_default() {
611 let rt = spawn_runtime_from_args(&ss(&["--permission-mode", "default", "--resume", "sid"]));
612 assert_eq!(rt.model, None);
613 assert_eq!(rt.effort, None);
614 }
615
616 #[test]
617 fn every_spelling_of_a_flag_is_read_and_the_last_one_wins() {
618 assert_eq!(
619 spawn_runtime_from_args(&ss(&["--model=opus"]))
620 .model
621 .as_deref(),
622 Some("opus")
623 );
624 assert_eq!(
625 spawn_runtime_from_args(&ss(&["-m", "gpt-5.5"]))
626 .model
627 .as_deref(),
628 Some("gpt-5.5")
629 );
630 assert_eq!(
631 spawn_runtime_from_args(&ss(&["--effort=low"]))
632 .effort
633 .as_deref(),
634 Some("low")
635 );
636 assert_eq!(
637 spawn_runtime_from_args(&ss(&["--model", "sonnet", "--model", "haiku"]))
638 .model
639 .as_deref(),
640 Some("haiku")
641 );
642 assert_eq!(
643 spawn_runtime_from_args(&ss(&["--dangerously-skip-permissions"]))
644 .permission_mode
645 .as_deref(),
646 Some("bypass")
647 );
648 assert_eq!(spawn_runtime_from_args(&ss(&["--model"])).model, None);
650 }
651
652 #[test]
653 fn the_event_says_what_runs_and_whether_a_restart_is_coming() {
654 let rt = SpawnRuntime {
655 model: Some("sonnet".into()),
656 effort: None,
657 permission_mode: Some("default".into()),
658 };
659 let v = serde_json::to_value(agent_runtime_event("b1", Some(&rt), true, None)).unwrap();
660 assert_eq!(v["blockid"], "b1");
661 assert_eq!(v["running"], true);
662 assert_eq!(v["model"], "sonnet");
663 assert_eq!(v["permission_mode"], "default");
664 assert_eq!(v["restart_pending"], true);
665 assert!(
666 v.get("effort").is_none(),
667 "an absent flag is omitted, not null"
668 );
669
670 let none = serde_json::to_value(agent_runtime_event("b1", None, false, None)).unwrap();
671 assert_eq!(none["running"], false);
672 assert!(none.get("model").is_none());
673 assert!(v.get("effective_model").is_none(), "nothing reported yet");
674 }
675
676 #[test]
677 fn the_event_carries_what_the_cli_reported_only_while_a_process_runs() {
678 let rt = SpawnRuntime { model: Some("sonnet".into()), ..Default::default() };
679 let eff = EffectiveRuntime { model: Some("claude-sonnet-5-5".into()), effort: None };
680 let v = serde_json::to_value(agent_runtime_event("b1", Some(&rt), false, Some(&eff))).unwrap();
681 assert_eq!(v["effective_model"], "claude-sonnet-5-5");
682 assert!(v.get("effective_effort").is_none(), "a null effort (Haiku) is omitted");
683 let gone = serde_json::to_value(agent_runtime_event("b1", None, false, Some(&eff))).unwrap();
685 assert!(gone.get("effective_model").is_none());
686 }
687
688 #[test]
689 fn a_get_settings_answer_is_read_and_nothing_else_is() {
690 let ok = json!({"type":"control_response","response":{
691 "subtype":"success","request_id":"agentmux-settings-1",
692 "response":{"applied":{"model":"claude-opus-5-5","effort":"medium","advisor":null}}}});
693 assert_eq!(
694 effective_from_control_response(&ok),
695 Some(EffectiveRuntime { model: Some("claude-opus-5-5".into()), effort: Some("medium".into()) })
696 );
697 let haiku = json!({"type":"control_response","response":{
698 "subtype":"success","request_id":"agentmux-settings-2",
699 "response":{"applied":{"model":"claude-haiku-4-5-20251001","effort":null}}}});
700 assert_eq!(effective_from_control_response(&haiku).unwrap().effort, None);
701
702 for bad in [
704 json!({"type":"control_response","response":{"subtype":"success","request_id":"other-1","response":{"applied":{"model":"x"}}}}),
705 json!({"type":"control_response","response":{"subtype":"error","request_id":"agentmux-settings-3","error":"Unsupported"}}),
706 json!({"type":"control_response","response":{"subtype":"success","request_id":"agentmux-settings-4","response":{}}}),
707 json!({"type":"control_request","request_id":"agentmux-settings-5","request":{"subtype":"get_settings"}}),
708 json!({"type":"result"}),
709 ] {
710 assert_eq!(effective_from_control_response(&bad), None, "{bad}");
711 }
712 }
713
714 #[test]
715 fn the_settings_request_is_a_get_settings_control_request() {
716 let v: Value = serde_json::from_str(&settings_request_line()).unwrap();
717 assert_eq!(v["type"], "control_request");
718 assert_eq!(v["request"]["subtype"], "get_settings");
719 assert!(v["request_id"].as_str().unwrap().starts_with(SETTINGS_REQUEST_PREFIX));
720 assert_ne!(settings_request_line(), settings_request_line(), "each request has its own id");
721 }
722
723 fn s(v: &[&str]) -> Vec<String> {
724 v.iter().map(|x| x.to_string()).collect()
725 }
726 fn after(args: &[String], flag: &str) -> Option<String> {
727 args.iter()
728 .position(|a| a == flag)
729 .and_then(|i| args.get(i + 1))
730 .cloned()
731 }
732
733 fn remembered(json: &str) -> Remembered {
736 Remembered::parse("claude", json)
737 }
738 fn persistent_base() -> Vec<String> {
739 s(&["-p", "--input-format", "stream-json", "--permission-prompt-tool", "stdio", "--permission-mode", "default"])
740 }
741
742 #[test]
743 fn remembered_is_parsed_setting_by_setting_and_only_for_claude() {
744 let r = remembered(r#"{"model":"opus","effort":"xhigh","permissionMode":"plan"}"#);
745 assert_eq!(r.model.as_deref(), Some("opus"));
746 assert_eq!(r.effort.as_deref(), Some("xhigh"));
747 assert_eq!(r.permission_mode.as_deref(), Some("plan"));
748 assert_eq!(remembered(r#"{"model":"claude-fable-5-1"}"#).model.as_deref(), Some("claude-fable-5-1"));
749 let r = remembered(r#"{"model":"gpt-5","effort":"ludicrous","permissionMode":"plan"}"#);
751 assert_eq!((r.model, r.effort), (None, None));
752 assert_eq!(r.permission_mode.as_deref(), Some("plan"));
753 for bad in ["", "not json", "[]", "5", "null"] {
754 assert_eq!(remembered(bad), Remembered::default(), "{bad:?}");
755 }
756 assert_eq!(Remembered::parse("codex", r#"{"model":"opus"}"#), Remembered::default());
757 }
758
759 #[test]
760 fn nothing_remembered_changes_nothing() {
761 let (base, flags, mode) = apply_remembered("claude", persistent_base(), "--model opus --add-dir /x", &Remembered::default());
762 assert_eq!(base, persistent_base());
763 assert_eq!(flags, "--model opus --add-dir /x");
764 assert_eq!(mode, None);
765 }
766
767 #[test]
768 fn a_remembered_model_and_effort_beat_the_definitions_flags() {
769 let r = remembered(r#"{"model":"sonnet","effort":"low"}"#);
770 let (base, flags, mode) = apply_remembered("claude", persistent_base(), "--model opus --effort max --add-dir /x", &r);
771 assert_eq!(flags, "--model sonnet --effort low --add-dir /x");
772 assert_eq!(mode, None);
773 let out = seed_launch("claude", base, &flags);
775 let rt = out.runtime.unwrap();
776 assert_eq!((rt["model"].as_str(), rt["effort"].as_str()), (Some("sonnet"), Some("low")));
777 assert_eq!(out.cli_args.iter().filter(|a| *a == "--model").count(), 1);
778 assert_eq!(out.cli_args.iter().filter(|a| *a == "--effort").count(), 1);
779 }
780
781 #[test]
782 fn a_setting_not_picked_keeps_the_definitions_flag() {
783 let r = remembered(r#"{"model":"sonnet"}"#);
784 let (_, flags, _) = apply_remembered("claude", persistent_base(), "--model opus --effort max", &r);
785 assert_eq!(flags, "--model sonnet --effort max");
786 }
787
788 #[test]
789 fn haiku_is_never_left_with_an_effort() {
790 let r = remembered(r#"{"model":"haiku","effort":"high"}"#);
791 let (_, flags, _) = apply_remembered("claude", persistent_base(), "--effort max", &r);
792 assert_eq!(flags, "--model haiku");
793 let r = remembered(r#"{"model":"haiku"}"#);
795 let (_, flags, _) = apply_remembered("claude", persistent_base(), "--model opus --effort max", &r);
796 assert_eq!(flags, "--model haiku");
797 }
798
799 #[test]
800 fn a_remembered_mode_replaces_the_catalog_and_definition_mode_flags() {
801 let r = remembered(r#"{"permissionMode":"plan"}"#);
802 let (base, flags, mode) = apply_remembered("claude", persistent_base(), "--dangerously-skip-permissions --add-dir /x", &r);
803 assert!(!base.iter().any(|a| a == "--permission-mode"), "{base:?}");
804 assert_eq!(flags, "--permission-mode plan --add-dir /x");
805 assert_eq!(mode.as_deref(), Some("plan"));
806 let out = seed_launch("claude", base, &flags);
807 assert_eq!(after(&out.cli_args, "--permission-mode").as_deref(), Some("plan"));
808 assert_eq!(out.cli_args.iter().filter(|a| *a == "--permission-mode").count(), 1);
809 assert!(!out.cli_args.iter().any(|a| a == "--dangerously-skip-permissions"));
810 }
811
812 #[test]
813 fn bypass_on_a_control_protocol_agent_is_default_on_the_wire_but_bypass_in_the_menu() {
814 let r = remembered(r#"{"permissionMode":"bypass"}"#);
815 let (_, flags, mode) = apply_remembered("claude", persistent_base(), "", &r);
816 assert_eq!(flags, "--permission-mode default");
817 assert_eq!(mode.as_deref(), Some("bypass"));
818 let one_shot = s(&["-p", "--output-format", "stream-json"]);
820 let (_, flags, _) = apply_remembered("claude", one_shot, "", &r);
821 assert_eq!(flags, "--dangerously-skip-permissions");
822 }
823
824 #[test]
825 fn other_providers_are_left_alone() {
826 let r = Remembered { model: Some("opus".into()), ..Default::default() };
827 let (base, flags, mode) = apply_remembered("codex", s(&["exec", "-"]), "--x", &r);
828 assert_eq!((base, flags.as_str(), mode), (s(&["exec", "-"]), "--x", None));
829 }
830
831 #[test]
832 fn claude_gets_the_models_and_effort_its_menu_will_show() {
833 let out = seed_launch("claude", s(&["--permission-mode", "default"]), "");
834 assert_eq!(after(&out.cli_args, "--model").as_deref(), Some("sonnet"));
835 assert_eq!(after(&out.cli_args, "--effort").as_deref(), Some("high"));
836 let rt = out.runtime.unwrap();
837 assert_eq!(rt["model"], "sonnet");
838 assert_eq!(rt["effort"], "high");
839 assert_eq!(rt["permissionMode"], "bypass");
840 }
841
842 #[test]
843 fn the_args_and_the_runtime_always_agree() {
844 for (provider, flags) in [
845 ("claude", ""),
846 ("claude", "--model opus --effort max"),
847 ("claude", "--model=claude-fable-5-1"),
848 ("claude", "--model haiku"),
849 ("codex", ""),
850 ("codex", "--model gpt-5.4"),
851 ] {
852 let base = if provider == "codex" {
853 s(&["exec", "--json", "-"])
854 } else {
855 s(&["--permission-mode", "default"])
856 };
857 let out = seed_launch(provider, base, flags);
858 let rt = out.runtime.unwrap();
859 let in_args = out
860 .cli_args
861 .iter()
862 .enumerate()
863 .find_map(|(i, a)| match a.as_str() {
864 "--model" => out.cli_args.get(i + 1).cloned(),
865 _ => a.strip_prefix("--model=").map(str::to_string),
866 })
867 .unwrap();
868 assert_eq!(rt["model"], in_args, "{provider} {flags:?}");
869 }
870 }
871
872 #[test]
877 fn a_repeated_flag_is_decided_on_the_last_one_as_the_cli_reads_it() {
878 let out = seed_launch("claude", s(&[]), "--model opus --model haiku");
879 assert!(
880 !out.cli_args.iter().any(|a| a == "--effort"),
881 "{:?}",
882 out.cli_args
883 );
884 assert_eq!(out.runtime.unwrap()["model"], "haiku");
885
886 let meta = meta_with("claude", None);
887 let filled = with_runtime_flags(&meta, s(&["--model", "opus", "--model", "haiku"]));
888 assert!(!filled.iter().any(|a| a == "--effort"), "{filled:?}");
889
890 let out = seed_launch("claude", s(&[]), "--model haiku --model opus");
892 assert_eq!(after(&out.cli_args, "--effort").as_deref(), Some("high"));
893
894 let out = seed_launch("claude", s(&[]), "--effort low --effort max");
896 assert_eq!(out.runtime.unwrap()["effort"], "max");
897 }
898
899 #[test]
900 fn effort_is_for_models_that_take_it_whatever_the_haiku_id_looks_like() {
901 for haiku in [
902 "haiku",
903 "Haiku",
904 "claude-haiku-4-5",
905 "claude-haiku-4-5-20251001",
906 ] {
907 assert!(!model_takes_effort(haiku), "{haiku}");
908 let seeded = seed_launch("claude", s(&[]), &format!("--model {haiku}"));
910 assert!(
911 !seeded.cli_args.iter().any(|a| a == "--effort"),
912 "{haiku}: {:?}",
913 seeded.cli_args
914 );
915 let meta = meta_with("claude", Some(json!({"model": haiku})));
917 let filled = with_runtime_flags(&meta, s(&["-p"]));
918 assert!(
919 !filled.iter().any(|a| a == "--effort"),
920 "{haiku}: {filled:?}"
921 );
922 }
923 for other in [
924 "sonnet",
925 "opus",
926 "claude-sonnet-5-5",
927 "claude-opus-5-5",
928 "claude-fable-5-1",
929 ] {
930 assert!(model_takes_effort(other), "{other}");
931 }
932 }
933
934 #[test]
935 fn haiku_gets_no_effort_flag() {
936 let out = seed_launch("claude", s(&[]), "--model haiku");
937 assert!(
938 !out.cli_args.iter().any(|a| a == "--effort"),
939 "{:?}",
940 out.cli_args
941 );
942 }
943
944 #[test]
945 fn a_definitions_own_model_is_the_runtime_and_is_not_doubled() {
946 let out = seed_launch("claude", s(&[]), "--model opus --effort max");
947 assert_eq!(
948 out.cli_args.iter().filter(|a| *a == "--model").count(),
949 1,
950 "{:?}",
951 out.cli_args
952 );
953 assert_eq!(
954 out.cli_args.iter().filter(|a| *a == "--effort").count(),
955 1,
956 "{:?}",
957 out.cli_args
958 );
959 let rt = out.runtime.unwrap();
960 assert_eq!(rt["model"], "opus");
961 assert_eq!(rt["effort"], "max");
962 }
963
964 #[test]
965 fn provider_flags_stay_last_as_in_the_frontend() {
966 let out = seed_launch(
967 "claude",
968 s(&["--permission-mode", "default"]),
969 "--add-dir /tmp",
970 );
971 assert_eq!(
972 out.cli_args[out.cli_args.len() - 2..],
973 s(&["--add-dir", "/tmp"])
974 );
975 }
976
977 #[test]
978 fn codex_keeps_its_stdin_marker_last() {
979 let out = seed_launch("codex", s(&["exec", "--json", "-"]), "");
980 assert_eq!(
981 out.cli_args,
982 s(&["exec", "--json", "--model", "gpt-5.5", "-"])
983 );
984 }
985
986 #[test]
987 fn codex_keeps_its_stdin_marker_last_whatever_the_definition_adds() {
988 for flags in [
991 "--model gpt-5.4",
992 "-m gpt-5.4",
993 "--model=gpt-5.4",
994 "--add-dir /tmp",
995 "--model gpt-5.4 --add-dir /tmp",
996 "",
997 ] {
998 let out = seed_launch("codex", s(&["exec", "--json", "-"]), flags);
999 assert_eq!(
1000 out.cli_args.last().map(String::as_str),
1001 Some("-"),
1002 "{flags:?}: {:?}",
1003 out.cli_args
1004 );
1005 assert_eq!(
1006 out.cli_args.iter().filter(|a| *a == "-").count(),
1007 1,
1008 "{flags:?}: {:?}",
1009 out.cli_args
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn a_codex_definitions_model_is_the_runtime_with_the_marker_last() {
1016 let out = seed_launch("codex", s(&["exec", "--json", "-"]), "--model gpt-5.4");
1017 assert_eq!(
1018 out.cli_args,
1019 s(&["exec", "--json", "--model", "gpt-5.4", "-"])
1020 );
1021 assert_eq!(out.runtime.unwrap()["model"], "gpt-5.4");
1022 }
1023
1024 #[test]
1025 fn a_catalog_base_that_grows_a_model_flag_cannot_double_it() {
1026 let out = seed_launch(
1027 "claude",
1028 s(&["--model", "opus", "--permission-mode", "default"]),
1029 "",
1030 );
1031 assert_eq!(after(&out.cli_args, "--model").as_deref(), Some("sonnet"));
1032 assert_eq!(out.cli_args.iter().filter(|a| *a == "--model").count(), 1);
1033 }
1034
1035 #[test]
1036 fn a_provider_the_menu_does_not_wire_is_left_alone() {
1037 for id in [
1038 "gemini",
1039 "kimi",
1040 "qwen",
1041 "openclaw",
1042 "copilot",
1043 "pi",
1044 "muxcode",
1045 ] {
1046 let out = seed_launch(id, s(&["--yolo"]), "--x y");
1047 assert!(out.runtime.is_none(), "{id}");
1048 assert_eq!(out.cli_args, s(&["--yolo", "--x", "y"]), "{id}");
1049 }
1050 }
1051
1052 #[test]
1053 fn the_meta_a_pane_opens_with_has_all_three_keys_in_agreement() {
1054 let mut meta = MetaMapType::new();
1055 let seeded = seed_launch(
1056 "claude",
1057 s(&["--permission-mode", "default"]),
1058 "--model opus",
1059 );
1060 apply_to_meta(&mut meta, seeded, "--model opus");
1061 let args: Vec<String> = serde_json::from_value(meta["cmd:args"].clone()).unwrap();
1062 assert_eq!(after(&args, "--model").as_deref(), Some("opus"));
1063 assert_eq!(meta["agent:runtime"]["model"], "opus");
1064 assert_eq!(meta["agent:provider_flags"], "--model opus");
1065 }
1066
1067 #[test]
1068 fn a_provider_without_a_runtime_still_records_its_flags() {
1069 let mut meta = MetaMapType::new();
1070 apply_to_meta(
1071 &mut meta,
1072 seed_launch("gemini", s(&["--yolo"]), "--x y"),
1073 "--x y",
1074 );
1075 assert!(!meta.contains_key("agent:runtime"));
1076 assert_eq!(meta["agent:provider_flags"], "--x y");
1077 }
1078
1079 fn meta_with(provider: &str, runtime: Option<Value>) -> MetaMapType {
1080 let mut m = MetaMapType::new();
1081 m.insert("agentProvider".to_string(), json!(provider));
1082 if let Some(r) = runtime {
1083 m.insert("agent:runtime".to_string(), r);
1084 }
1085 m
1086 }
1087
1088 #[test]
1089 fn a_stored_pane_with_no_flags_gets_what_its_menu_shows() {
1090 let meta = meta_with(
1091 "claude",
1092 Some(json!({"model": "opus", "effort": "max", "permissionMode": "bypass"})),
1093 );
1094 let out = with_runtime_flags(&meta, s(&["--permission-mode", "default"]));
1095 assert_eq!(after(&out, "--model").as_deref(), Some("opus"));
1096 assert_eq!(after(&out, "--effort").as_deref(), Some("max"));
1097 }
1098
1099 #[test]
1100 fn a_pane_with_no_runtime_at_all_gets_the_defaults() {
1101 let out = with_runtime_flags(
1102 &meta_with("claude", None),
1103 s(&["--permission-mode", "default"]),
1104 );
1105 assert_eq!(after(&out, "--model").as_deref(), Some("sonnet"));
1106 assert_eq!(after(&out, "--effort").as_deref(), Some("high"));
1107 }
1108
1109 #[test]
1110 fn flags_already_present_are_never_overridden_or_doubled() {
1111 let meta = meta_with("claude", Some(json!({"model": "sonnet", "effort": "low"})));
1112 let args = s(&["--model", "opus", "--effort", "max"]);
1113 assert_eq!(with_runtime_flags(&meta, args.clone()), args);
1114 }
1115
1116 #[test]
1117 fn a_model_flag_without_an_effort_still_gets_the_runtimes_effort() {
1118 let meta = meta_with("claude", Some(json!({"model": "sonnet", "effort": "low"})));
1119 let out = with_runtime_flags(&meta, s(&["--model", "opus"]));
1120 assert_eq!(after(&out, "--model").as_deref(), Some("opus"));
1121 assert_eq!(after(&out, "--effort").as_deref(), Some("low"));
1122 }
1123
1124 #[test]
1125 fn haiku_gets_no_effort_whether_it_comes_from_the_args_or_the_runtime() {
1126 let from_args = with_runtime_flags(&meta_with("claude", None), s(&["--model", "haiku"]));
1127 assert!(!from_args.iter().any(|a| a == "--effort"), "{from_args:?}");
1128 let from_runtime = with_runtime_flags(
1129 &meta_with("claude", Some(json!({"model": "haiku"}))),
1130 s(&["-p"]),
1131 );
1132 assert!(
1133 !from_runtime.iter().any(|a| a == "--effort"),
1134 "{from_runtime:?}"
1135 );
1136 assert_eq!(after(&from_runtime, "--model").as_deref(), Some("haiku"));
1137 }
1138
1139 #[test]
1140 fn it_is_idempotent() {
1141 let meta = meta_with("claude", Some(json!({"model": "opus", "effort": "max"})));
1142 let once = with_runtime_flags(&meta, s(&["--permission-mode", "default"]));
1143 assert_eq!(with_runtime_flags(&meta, once.clone()), once);
1144 }
1145
1146 #[test]
1147 fn codex_gets_its_model_before_the_stdin_marker() {
1148 let meta = meta_with("codex", Some(json!({"model": "gpt-5.4"})));
1149 let out = with_runtime_flags(&meta, s(&["exec", "--json", "-"]));
1150 assert_eq!(out, s(&["exec", "--json", "--model", "gpt-5.4", "-"]));
1151 }
1152
1153 #[test]
1154 fn codex_keeps_its_stdin_marker_last_even_when_flags_sit_after_it() {
1155 let meta = meta_with("codex", Some(json!({"model": "gpt-5.4"})));
1156 let out = with_runtime_flags(&meta, s(&["exec", "--json", "-", "--add-dir", "/tmp"]));
1157 assert_eq!(
1158 out,
1159 s(&[
1160 "exec",
1161 "--json",
1162 "--add-dir",
1163 "/tmp",
1164 "--model",
1165 "gpt-5.4",
1166 "-"
1167 ])
1168 );
1169 }
1170
1171 #[test]
1172 fn a_claude_model_stored_on_a_codex_pane_never_reaches_codex() {
1173 for stale in ["opus", "sonnet", "haiku", "claude-sonnet-5-5"] {
1174 let meta = meta_with("codex", Some(json!({ "model": stale })));
1175 let out = with_runtime_flags(&meta, s(&["exec", "-"]));
1176 assert_eq!(
1177 after(&out, "--model").as_deref(),
1178 Some("gpt-5.5"),
1179 "{stale}"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn a_pane_recorded_under_a_legacy_provider_alias_is_still_filled() {
1186 for alias in ["claude-code", "claude_code"] {
1188 let meta = meta_with(alias, Some(json!({"model": "opus", "effort": "max"})));
1189 let out = with_runtime_flags(&meta, s(&["--permission-mode", "default"]));
1190 assert_eq!(after(&out, "--model").as_deref(), Some("opus"), "{alias}");
1191 assert_eq!(after(&out, "--effort").as_deref(), Some("max"), "{alias}");
1192 }
1193 let meta = meta_with("codex-cli", Some(json!({"model": "gpt-5.4"})));
1194 assert_eq!(
1195 with_runtime_flags(&meta, s(&["exec", "--json", "-"])),
1196 s(&["exec", "--json", "--model", "gpt-5.4", "-"])
1197 );
1198 }
1199
1200 #[test]
1204 fn antigravity_gets_its_model_and_never_a_bare_claude_alias() {
1205 let seeded = seed_launch("antigravity", s(&["--output-format", "stream-json"]), "");
1206 assert_eq!(after(&seeded.cli_args, "--model").as_deref(), Some("gemini-3.8-flash-medium"));
1207 assert_eq!(seeded.runtime.unwrap()["model"], "gemini-3.8-flash-medium");
1208
1209 let base = s(&["--output-format", "stream-json"]);
1210 let model = |m: &str| {
1211 let out = with_runtime_flags(&meta_with("antigravity", Some(json!({ "model": m }))), base.clone());
1212 after(&out, "--model")
1213 };
1214 assert_eq!(model("gemini-3.1-pro-high").as_deref(), Some("gemini-3.1-pro-high"));
1215 assert_eq!(model("claude-sonnet-4-6").as_deref(), Some("claude-sonnet-4-6"));
1216 assert_eq!(model("sonnet").as_deref(), Some("gemini-3.8-flash-medium"));
1217 let out = with_runtime_flags(&meta_with("antigravity", None), base.clone());
1220 assert!(!out.iter().any(|a| a == "--effort"), "{out:?}");
1221 }
1222
1223 #[test]
1224 fn it_leaves_alone_what_it_cannot_judge() {
1225 for provider in ["gemini", "kimi", "mystery", ""] {
1227 let args = s(&["--yolo"]);
1228 assert_eq!(
1229 with_runtime_flags(&meta_with(provider, None), args.clone()),
1230 args,
1231 "{provider:?}"
1232 );
1233 }
1234 assert!(with_runtime_flags(&meta_with("claude", None), vec![]).is_empty());
1235 }
1236}