Skip to main content

agentmux_srv/backend/
agent_runtime.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The runtime flags a pane's CLI is given, decided in srv.
5//!
6//! The runtime menu shows `agent:runtime` block meta; the process runs on
7//! `cmd:args`. The frontend keeps the two in step (`buildPaneArgs`), but
8//! `agent.open` — MCP `OpenAgent`, layouts, `agent.define` + open — builds a
9//! pane without the frontend. It wrote `cmd:args` from the catalog alone and no
10//! `agent:runtime`, so the pane's CLI ran on its own default model (Opus 5.5 /
11//! medium on current CLIs) while the menu fell back to Sonnet / high. A pane
12//! with a session on file spawns eagerly at open, before any message, so
13//! nothing ever corrected it.
14//! docs/retro/RETRO_RESUMED_AGENT_SPAWNS_WITHOUT_RUNTIME_FLAGS_2026_09_30.md,
15//! docs/reports/REPORT_AGENT_RUNTIME_BINDINGS_2026_09_30.md (G3).
16//!
17//! Two entry points, one decision, for the two providers whose model the menu
18//! wires (Claude, Codex):
19//!
20//! - [`seed_launch`] — a pane `agent.open` is creating.
21//! - [`with_runtime_flags`] — a pane that already exists, at the moment it
22//!   spawns. srv never reads `agent:runtime` otherwise, and a persistent
23//!   process never re-reads `cmd:args`, so a pane saved before the fixes (or
24//!   written by any path that forgets) would run on the CLI default for its
25//!   whole life. This is the backstop that makes srv authoritative at the one
26//!   moment it matters.
27//!
28//! The defaults are duplicated from the frontend by necessity and pinned to
29//! its by `providers/runtime-defaults-consistency.test.ts`.
30
31use serde::{Deserialize, Serialize};
32use serde_json::{json, Value};
33
34use crate::backend::obj::MetaMapType;
35use crate::backend::providers::default_model_for;
36
37/// `DEFAULT_RUNTIME_CONFIG.permissionMode` in `frontend/app/view/agent/types.ts`.
38pub(crate) const DEFAULT_PERMISSION_MODE: &str = "bypass";
39/// `DEFAULT_RUNTIME_CONFIG.effort` in `frontend/app/view/agent/types.ts`.
40pub(crate) const DEFAULT_EFFORT: &str = "high";
41
42/// Flags this module owns. Stripped from the catalog args so a base that ever
43/// grows one cannot double it.
44const OWNED_FLAGS: &[&str] = &["--model", "-m", "--effort"];
45
46/// What an agent process was actually spawned with: the runtime flags in its
47/// final argv. The Runtime menu shows what was REQUESTED (`agent:runtime`);
48/// this is what the process was GIVEN, so the menu can say when they differ.
49/// `None` for a flag the argv does not carry — the CLI then uses its own default.
50#[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
60/// The runtime flags in `args`. The LAST occurrence of a repeated flag wins,
61/// which is how the CLI is expected to read them (unverified; it matters only
62/// when `provider_flags` repeats a flag the runtime already set).
63/// `--dangerously-skip-permissions` reads as `bypass`; `--permission-mode X`
64/// as `X`.
65pub 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/// What the running CLI says it is ACTUALLY using, from its `get_settings`
107/// answer (`applied`): the alias on the command line resolved to a concrete
108/// model, and the effort in force. `None` for a field the CLI reports as null
109/// (Haiku has no effort).
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct EffectiveRuntime {
112    pub model: Option<String>,
113    pub effort: Option<String>,
114}
115
116/// Prefix of the `request_id` of our `get_settings` requests, so the answer can
117/// be told from the answers to anything else.
118pub const SETTINGS_REQUEST_PREFIX: &str = "agentmux-settings-";
119
120/// The control request asking a running Claude CLI what it is using. Observed
121/// on CLI 2.1.285 to be answered straight after spawn, with no account and no
122/// message (docs/specs/SPEC_RUNTIME_MENU_REMAINING_GAPS_2026_10_01.md §7.3).
123pub 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
132/// The effective runtime in a `get_settings` answer, or `None` for any other
133/// frame, an error answer (a CLI that has no `get_settings`) or one without
134/// `applied`. The shape is `{type: control_response, response: {subtype:
135/// success, request_id, response: {applied: {model, effort, ...}}}}`.
136pub 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/// The `agentruntime` event's payload (`mps_events::AgentRuntime`). An absent value is
163/// omitted, not null; what the CLI reported is sent only while a process runs.
164#[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
187/// The `agentruntime` event for one pane (persisted, so a menu that mounts late
188/// still gets the latest). `running` is `None` when no process is alive — then
189/// there is nothing for the menu to compare against. `effective` is what the
190/// CLI reported using, when it has (see [`EffectiveRuntime`]).
191pub 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    /// The argv to store as `cmd:args`.
223    pub cli_args: Vec<String>,
224    /// The value for `agent:runtime`, or `None` for a provider whose model the
225    /// menu does not wire (the pane keeps whatever the menu falls back to).
226    pub runtime: Option<Value>,
227}
228
229/// The value of `--flag value` or `--flag=value` in `flags`, if present; the last one when it repeats.
230fn flag_value<'a>(flags: &'a [String], names: &[&str]) -> Option<&'a str> {
231    // The LAST occurrence wins, as the CLI reads a repeated flag (observed on CLI
232    // 2.1.285: `--model opus --model haiku` sends Haiku, and the reverse sends Opus;
233    // the frontend's `modelFromFlags` agrees). Returning the first decided on Opus
234    // and handed Haiku an `--effort` it does not take.
235    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
254/// `base` without any of [`OWNED_FLAGS`] (and their values).
255fn 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/// What the user last picked in the Runtime menu for an agent (`last_runtime`
272/// on `db_agents`), already validated. Each setting is optional: only what was
273/// picked is here, and the rest keeps coming from the definition.
274#[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"];
283/// The flag a control-protocol (persistent) agent is launched with; its
284/// presence in the catalog args is what makes `bypass` become `default`.
285const CONTROL_PROTOCOL_FLAG: &str = "--permission-prompt-tool";
286
287impl Remembered {
288    /// Parse the stored JSON for `provider_id`. Only Claude is covered: its
289    /// model namespace is known here (`opus` / `sonnet` / `haiku` or a concrete
290    /// `claude-…` id), and anything else, or anything malformed, is dropped
291    /// setting by setting.
292    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
316/// `args` without the permission flags (`--permission-mode X`,
317/// `--dangerously-skip-permissions`).
318fn 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
334/// `flags` without the `names` flags (and their values), `--flag=value` too.
335fn 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
351/// Lay what the user last picked over the definition, the way the frontend's
352/// launch does (`resolveLaunchRuntime`): the remembered setting beats the
353/// definition's flag for it, and that flag is taken out of the pane's copy.
354///
355/// Returns `(base, pane_flags, permission_mode)`: the catalog args (their
356/// permission flags replaced when a mode is remembered), the flags to hand to
357/// [`seed_launch`] and to store as `agent:provider_flags`, and the mode the
358/// menu must show (`None` = leave what `seed_launch` says).
359pub(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    // The model that will run: remembered, else the definition's, else the default.
373    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    // A model that takes no `--effort` must not be left with the definition's.
388    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
410/// Build `cmd:args` and `agent:runtime` for a freshly opened pane.
411///
412/// `base` is the catalog argv for the pane's controller. `provider_flags` is
413/// the agent definition's own flags: they are appended last, exactly as the
414/// frontend does, and a `--model` / `--effort` among them is the agent's own
415/// choice — it becomes the runtime the menu shows and no second flag is added,
416/// so the two cannot disagree.
417pub(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    // Codex reads its prompt from stdin via a trailing `-`, so it must stay last
442    // however many flags land after the catalog args: held back here, put back
443    // after the definition's own flags below. (The spawn path re-normalizes it
444    // too, `build_codex_argv`, but the stored `cmd:args` should be right as is.)
445    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            // Haiku takes no `--effort` (the pinned CLI drops it; older CLIs forwarded
456            // it and the API answered 400); the frontend skips it the same way.
457            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
481/// Whether `--effort` is passed for `model`. Haiku takes none (the pinned CLI drops
482/// it; older CLIs forwarded it and the API answered HTTP 400 on
483/// every turn). Matches the model id as well as the alias, case-insensitively —
484/// this was `model == "haiku"`, so a concrete Haiku id still got the flag.
485/// Mirrors `modelTakesEffort` in frontend/app/view/agent/runtime-capabilities.ts.
486pub(crate) fn model_takes_effort(model: &str) -> bool {
487    !model.to_ascii_lowercase().contains("haiku")
488}
489
490/// Claude model names: the aliases and concrete ids. A pane carried over from
491/// before per-provider models can have one stored for a Codex agent; it must
492/// never reach Codex, which rejects it (frontend `buildRuntimeArgs.ts` guards the
493/// same way).
494fn is_claude_model(m: &str) -> bool {
495    matches!(m, "opus" | "sonnet" | "haiku") || m.starts_with("claude-")
496}
497
498/// `args` as stored in `cmd:args`, plus any runtime flag it lacks, taken from
499/// the pane's `agent:runtime` (or the defaults when it has none).
500///
501/// Never overrides a flag already present: the frontend's rebuild and
502/// [`seed_launch`] put them there, and a definition's own `--model` is the
503/// agent's choice. Leaves alone a provider the menu does not wire, an unknown
504/// provider, and an empty argv (nothing to extend). Idempotent.
505pub(crate) fn with_runtime_flags(meta: &MetaMapType, args: Vec<String>) -> Vec<String> {
506    if args.is_empty() {
507        return args;
508    }
509    // `agentProvider` is written from the definition's `provider` verbatim, and
510    // that may be a legacy alias ("claude-code", "codex-cli"). Resolve it, or a
511    // pane on an alias — exactly the old, pre-existing kind this backstop exists
512    // for — would silently get nothing (ReAgent P1 on #4116). Unknown → "".
513    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                // agy's own ids include `claude-sonnet-4-6`; only the bare
533                // Claude aliases, carried over from a Claude pane, are foreign.
534                "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                // The stdin marker `-` must end up last even when the stored
554                // argv has flags after it (provider_flags were appended past it).
555                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
570/// Write the three keys that must travel together into a new pane's meta:
571/// `cmd:args` (what runs), `agent:runtime` (what the menu shows) and
572/// `agent:provider_flags` (what the frontend's per-send rebuild reapplies — it
573/// starts from the catalog and would drop the definition's flags on the first
574/// send otherwise, #2872). One call, so no caller can write one without the
575/// others.
576pub(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        // a flag with no value is not a value
649        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        // a stale report must not outlive its process
684        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        // someone else's request id, an error answer, no `applied`, other frames
703        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    // ---- what the user last picked ----
734
735    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        // invalid settings are dropped on their own, the rest survive
750        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        // and seed_launch then shows exactly that, with no second flag
774        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        // remembered Haiku, definition's effort only
794        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        // a one-shot agent (no control protocol) gets the real bypass flag
819        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    // ReAgent P2 on #4152. A repeated flag: the LAST wins, as the CLI reads it
873    // (observed on 2.1.285) and as the frontend's modelFromFlags does. flag_value
874    // used to return the first, so `--model opus --model haiku` was decided as Opus
875    // (and given --effort) while the CLI ran Haiku, which takes no effort.
876    #[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        // and the other way round: the last model takes effort
891        let out = seed_launch("claude", s(&[]), "--model haiku --model opus");
892        assert_eq!(after(&out.cli_args, "--effort").as_deref(), Some("high"));
893
894        // a repeated --effort: the last one is the definition's choice
895        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            // seeded
909            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            // filled into a stored pane
916            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        // ReAgent P1 on #4114: a definition's own --model (flag_model is Some)
989        // skipped the old marker handling and left `-` mid-argv.
990        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        // ReAgent P1 on #4116: `agentProvider` is the definition's provider as typed.
1187        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    /// agy takes `--model` (SPEC_ANTIGRAVITY_HARNESS_REAL_CLI_2026_10_06.md).
1201    /// A bare Claude alias left in a pane's runtime is not an agy model; agy's
1202    /// own `claude-…` ids are.
1203    #[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        // No --effort: agy has one (low/medium/high), but the runtime menu does
1218        // not wire it for Antigravity yet.
1219        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        // not wired, unknown, no provider recorded, and an empty argv
1226        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}