Skip to main content

speccoder_runtime/acp/
modes.rs

1//! An attempt's permission mode: what the agent advertises, what speccoder
2//! may ask it for, and what the run says about it.
3//!
4//! speccoder asks for exactly one thing, and only when the person configured
5//! no mode (`[executor] mode`): the agent's own ask-first value, which sends
6//! *more* calls to speccoder's policy. A mode the person configured — in the
7//! agent's own settings, or as `mode` — is kept. Which value asks first, and
8//! where an adapter starts when nothing is configured, is recorded per
9//! adapter version from that adapter's own output ([`PROFILES`]); nothing
10//! here maps one vendor's mode onto another's. An agent left in a mode that
11//! decides without asking is said so on the run: its empty Needs You then
12//! proves nothing.
13//!
14//! Adapted from devplane's `acp::modes` (same owner, MIT OR Apache-2.0).
15
16use serde::{Deserialize, Serialize};
17
18/// How an agent advertises its mode.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Via {
21    /// A session config option of this id (`session/set_config_option`).
22    Config(String),
23    /// The older session modes (`session/set_mode`).
24    Modes,
25}
26
27/// Whether a mode asks before the agent acts.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Reading {
31    /// Calls reach speccoder as permission requests.
32    Asks,
33    /// The agent decides some calls itself.
34    DecidesWithoutYou,
35    /// No recording says.
36    NotKnown,
37}
38
39/// What one adapter version advertises, recorded from its own output.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ModeProfile {
42    /// The preset it belongs to.
43    pub agent: &'static str,
44    /// The `initialize` response's `agentInfo` name and version, exactly.
45    pub agent_info: (&'static str, &'static str),
46    /// `Some(id)` for a config option, `None` for session modes.
47    pub config_id: Option<&'static str>,
48    /// Where the adapter opens when nothing is configured.
49    pub unconfigured_start: &'static str,
50    /// The value that asks before acting.
51    pub ask_first: Option<&'static str>,
52    /// Each advertised value: `None` asks, `Some(what)` decides that alone.
53    pub values: &'static [(&'static str, Option<&'static str>)],
54    /// A second option that can turn asking off whatever the mode (its id,
55    /// and the value that keeps asking on).
56    pub guard: Option<(&'static str, &'static str)>,
57    /// What makes the agent ask at all, set where it starts (a variable
58    /// and its value) when the person configured no mode: an agent whose
59    /// modes do not govern permissions.
60    pub launch_env: Option<(&'static str, &'static str)>,
61    /// Its values were recorded from the adapter's own output; `false`
62    /// (built from its source): what they decide is not known.
63    pub recorded: bool,
64}
65
66impl ModeProfile {
67    /// The channel it advertises its mode on.
68    #[must_use]
69    pub fn via(&self) -> Via {
70        match self.config_id {
71            Some(id) => Via::Config(id.to_owned()),
72            None => Via::Modes,
73        }
74    }
75
76    /// How this value reads, and what it decides; *not known* for a value
77    /// the recording does not contain.
78    #[must_use]
79    pub fn reading(&self, value: &str) -> (Reading, Option<String>) {
80        match self.values.iter().find(|(v, _)| *v == value) {
81            // Built from source, not recorded: a value that would ask is
82            // not known to; one that decides is said as such.
83            Some((_, None)) if !self.recorded => (Reading::NotKnown, None),
84            Some((_, None)) => (Reading::Asks, None),
85            Some((_, Some(what))) => (Reading::DecidesWithoutYou, Some((*what).to_owned())),
86            None => (Reading::NotKnown, None),
87        }
88    }
89
90    fn offers_exactly(&self, ad: &Advertised) -> bool {
91        let mut recorded: Vec<&str> = self.values.iter().map(|(v, _)| *v).collect();
92        let mut live: Vec<&str> = ad.values.iter().map(|(v, _)| v.as_str()).collect();
93        recorded.sort_unstable();
94        live.sort_unstable();
95        recorded == live
96    }
97}
98
99/// OpenCode's permission rules, every one set to ask (its modes say what it
100/// works on, not whether it asks).
101/// Every tool OpenCode asks about, at the top level and for its agents
102/// (whose own rules would otherwise win).
103pub const OPENCODE_ASKS: &str = r#"{"permission":{"read":"ask","edit":"ask","bash":"ask","webfetch":"ask","websearch":"ask","codesearch":"ask","external_directory":"ask","glob":"ask","grep":"ask","list":"ask","task":"ask","lsp":"ask"},"agent":{"build":{"permission":{"read":"ask","edit":"ask","bash":"ask","webfetch":"ask","websearch":"ask","codesearch":"ask","external_directory":"ask","glob":"ask","grep":"ask","list":"ask","task":"ask","lsp":"ask"}},"plan":{"permission":{"read":"ask","edit":"ask","bash":"ask","webfetch":"ask","websearch":"ask","codesearch":"ask","external_directory":"ask","glob":"ask","grep":"ask","list":"ask","task":"ask","lsp":"ask"}}}}"#;
104
105/// A launch variable's value: ours merged over what the person's
106/// environment already sets (`existing`, JSON), ours winning — so their
107/// other settings stay and every permission still asks.
108#[must_use]
109pub fn launch_value(ours: &str, existing: Option<&str>) -> String {
110    fn merge(base: &mut serde_json::Value, over: &serde_json::Value) {
111        match (base, over) {
112            (serde_json::Value::Object(b), serde_json::Value::Object(o)) => {
113                for (k, v) in o {
114                    merge(b.entry(k.clone()).or_insert(serde_json::Value::Null), v);
115                }
116            }
117            (b, o) => *b = o.clone(),
118        }
119    }
120    let Some(mut base) = existing
121        .and_then(|e| serde_json::from_str::<serde_json::Value>(e).ok())
122        .filter(serde_json::Value::is_object)
123    else {
124        return ours.to_owned();
125    };
126    let over: serde_json::Value = serde_json::from_str(ours).unwrap_or_default();
127    merge(&mut base, &over);
128    base.to_string()
129}
130
131/// One profile per recorded adapter version, each value taken from that
132/// version's own advertisement (`tests/fixtures/acp/`).
133pub const PROFILES: &[ModeProfile] = &[
134    // Recorded here 2026-10-28: `agent` asked for an edit and a command;
135    // `autopilot` "enables allow-all"; `allow_all` starts `off`.
136    ModeProfile {
137        agent: "copilot",
138        agent_info: ("Copilot", "1.0.105"),
139        config_id: Some("mode"),
140        unconfigured_start: "https://agentclientprotocol.com/protocol/session-modes#agent",
141        ask_first: Some("https://agentclientprotocol.com/protocol/session-modes#agent"),
142        values: &[
143            (
144                "https://agentclientprotocol.com/protocol/session-modes#agent",
145                None,
146            ),
147            (
148                "https://agentclientprotocol.com/protocol/session-modes#plan",
149                None,
150            ),
151            (
152                "https://agentclientprotocol.com/protocol/session-modes#autopilot",
153                Some("everything (allow-all)"),
154            ),
155        ],
156        guard: Some(("allow_all", "off")),
157        launch_env: None,
158        recorded: true,
159    },
160    // Recorded here 2026-10-28 (2.1.4, again on 2.2.0): in `build` it ran a
161    // command and wrote a file without asking; with every permission rule
162    // set to `ask` it asked first.
163    ModeProfile {
164        agent: "opencode",
165        agent_info: ("OpenCode", "2.2.0"),
166        config_id: Some("mode"),
167        unconfigured_start: "build",
168        ask_first: None,
169        values: &[("build", None), ("plan", None)],
170        guard: None,
171        launch_env: Some(("OPENCODE_CONFIG_CONTENT", OPENCODE_ASKS)),
172        recorded: true,
173    },
174    // claude-agent-acp passes `default` to Claude Code when no
175    // `permissions.defaultMode` is set, so any other start was configured.
176    ModeProfile {
177        agent: "claude",
178        agent_info: ("@agentclientprotocol/claude-agent-acp", "0.85.1"),
179        config_id: Some("mode"),
180        unconfigured_start: "default",
181        ask_first: Some("default"),
182        values: &[
183            ("default", None),
184            ("acceptEdits", Some("edits")),
185            ("plan", None),
186            ("auto", Some("a classifier decides")),
187            ("bypassPermissions", Some("everything")),
188        ],
189        guard: None,
190        launch_env: None,
191        recorded: true,
192    },
193    // codex-acp starts every session in `agent` (Auto review, where a
194    // reviewer model answers approvals) unless `INITIAL_AGENT_MODE` is set.
195    ModeProfile {
196        agent: "codex",
197        agent_info: ("@agentclientprotocol/codex-acp", "2.1.1"),
198        config_id: Some("mode"),
199        unconfigured_start: "agent",
200        ask_first: Some("read-only"),
201        values: &[
202            ("read-only", None),
203            ("workspace-write", Some("workspace edits")),
204            ("agent", Some("a reviewer model decides")),
205            ("agent-full-access", Some("everything")),
206        ],
207        guard: None,
208        launch_env: None,
209        // Built from codex-acp's source (devplane): not recorded live.
210        recorded: false,
211    },
212    // Gemini CLI advertises session modes only.
213    ModeProfile {
214        agent: "gemini",
215        agent_info: ("gemini-cli", "0.62.0"),
216        config_id: None,
217        unconfigured_start: "default",
218        ask_first: Some("default"),
219        values: &[
220            ("default", None),
221            ("autoEdit", Some("edits")),
222            ("yolo", Some("everything")),
223        ],
224        guard: None,
225        launch_env: None,
226        // Built from Gemini CLI's source (devplane): not recorded live.
227        recorded: false,
228    },
229];
230
231/// The profiles of the agent that named itself `name`: exactly this
232/// version first, then any recorded version of it (which applies only when
233/// the live advertisement offers exactly its values — [`profile_for`]).
234fn candidates<'a>(
235    name: &'a str,
236    version: &'a str,
237) -> impl Iterator<Item = &'static ModeProfile> + 'a {
238    let exact = PROFILES
239        .iter()
240        .filter(move |p| p.agent_info == (name, version));
241    let others = PROFILES
242        .iter()
243        .filter(move |p| p.agent_info.0 == name && p.agent_info.1 != version);
244    exact.chain(others)
245}
246
247/// The profile for the agent that named itself so: its recording at exactly
248/// this version, or a recording of another version whose values the live
249/// set-up answer offers exactly — the same vocabulary, so the same reading.
250/// `None` is unprofiled: nothing is asked for.
251#[must_use]
252pub fn profile_for(
253    name: &str,
254    version: &str,
255    config_options: &serde_json::Value,
256    modes: &serde_json::Value,
257) -> Option<&'static ModeProfile> {
258    candidates(name, version).find(|p| {
259        p.agent_info.1 == version
260            || Advertised::read(config_options, modes, Some(p))
261                .is_some_and(|a| p.offers_exactly(&a))
262    })
263}
264
265/// The profile to start an agent with (its launch variables), by preset.
266#[must_use]
267pub fn launch_profile(agent: &str) -> Option<&'static ModeProfile> {
268    PROFILES.iter().find(|p| p.agent == agent)
269}
270
271/// The mode a set-up response advertised: through which channel, its
272/// current value, and every value with the agent's own name for it.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct Advertised {
275    /// The channel.
276    pub via: Via,
277    /// Its value now.
278    pub current: String,
279    /// Each value and the agent's name for it.
280    pub values: Vec<(String, String)>,
281}
282
283impl Advertised {
284    /// Reads a set-up response's `configOptions` and `modes` (as JSON). A
285    /// profiled agent is read only through its profile's channel — a missing
286    /// config option never falls back to modes. An unprofiled agent is read
287    /// from its single select option of category `mode`, else its modes.
288    #[must_use]
289    pub fn read(
290        config_options: &serde_json::Value,
291        modes: &serde_json::Value,
292        profile: Option<&ModeProfile>,
293    ) -> Option<Self> {
294        match profile.map(ModeProfile::via) {
295            Some(Via::Config(id)) => from_config(config_options, |o| o["id"] == id.as_str()),
296            Some(Via::Modes) => from_modes(modes),
297            None => {
298                let options = config_options.as_array().map_or(&[][..], Vec::as_slice);
299                let selects: Vec<&serde_json::Value> = options
300                    .iter()
301                    .filter(|o| o["category"] == "mode" && o["type"] == "select")
302                    .collect();
303                match selects.as_slice() {
304                    [one] => from_config(config_options, |o| o["id"] == one["id"]),
305                    [] if !options.iter().any(|o| o["category"] == "mode") => from_modes(modes),
306                    // Several, or only a boolean one: nothing is read.
307                    _ => None,
308                }
309            }
310        }
311    }
312
313    /// The agent's name for a value, or the value itself.
314    #[must_use]
315    pub fn name_of(&self, value: &str) -> String {
316        self.values
317            .iter()
318            .find(|(v, _)| v == value)
319            .map_or_else(|| value.to_owned(), |(_, n)| n.clone())
320    }
321
322    /// Whether it offers `value`.
323    #[must_use]
324    pub fn offers(&self, value: &str) -> bool {
325        self.values.iter().any(|(v, _)| v == value)
326    }
327
328    /// The current value of this mode option in a full option list (a
329    /// `session/set_config_option` answer).
330    #[must_use]
331    pub fn current_in(&self, config_options: &serde_json::Value) -> Option<String> {
332        let Via::Config(id) = &self.via else {
333            return None;
334        };
335        current_of(config_options, id)
336    }
337}
338
339/// The current value of option `id` in a list of config options.
340#[must_use]
341pub fn current_of(config_options: &serde_json::Value, id: &str) -> Option<String> {
342    config_options.as_array()?.iter().find(|o| o["id"] == id)?["currentValue"]
343        .as_str()
344        .map(str::to_owned)
345}
346
347fn from_config(
348    options: &serde_json::Value,
349    pick: impl Fn(&serde_json::Value) -> bool,
350) -> Option<Advertised> {
351    let option = options.as_array()?.iter().find(|o| pick(o))?;
352    // A boolean option is never requested.
353    if option["type"] != "select" {
354        return None;
355    }
356    let mut values = Vec::new();
357    for o in option["options"].as_array()? {
358        // Options may be grouped.
359        match o["options"].as_array() {
360            Some(group) => values.extend(group.iter().filter_map(value_of)),
361            None => values.extend(value_of(o)),
362        }
363    }
364    Some(Advertised {
365        via: Via::Config(option["id"].as_str()?.to_owned()),
366        current: option["currentValue"].as_str()?.to_owned(),
367        values,
368    })
369}
370
371fn value_of(o: &serde_json::Value) -> Option<(String, String)> {
372    let v = o["value"].as_str()?;
373    Some((v.to_owned(), o["name"].as_str().unwrap_or(v).to_owned()))
374}
375
376fn from_modes(modes: &serde_json::Value) -> Option<Advertised> {
377    let current = modes["currentModeId"].as_str()?;
378    Some(Advertised {
379        via: Via::Modes,
380        current: current.to_owned(),
381        values: modes["availableModes"]
382            .as_array()?
383            .iter()
384            .filter_map(|m| {
385                let id = m["id"].as_str()?;
386                Some((id.to_owned(), m["name"].as_str().unwrap_or(id).to_owned()))
387            })
388            .collect(),
389    })
390}
391
392/// Why an attempt is in the mode its statement names.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum Why {
396    /// The person set none; speccoder asked for the ask-first mode.
397    AskedForAskFirst,
398    /// The session opened in its ask-first mode; nothing was asked.
399    AsksAlready,
400    /// `[executor] mode` named this mode.
401    YouChose,
402    /// `[executor] mode` named a mode the agent does not offer.
403    ChoseUnoffered,
404    /// The session opened in a mode other than the adapter's unconfigured
405    /// start: the person configured it in the agent's own settings.
406    YouSetIt,
407    /// speccoder asked for the ask-first mode and the agent refused.
408    Refused,
409    /// speccoder asked for the ask-first mode and the agent did not answer.
410    NoAnswer,
411    /// The agent advertises no mode known to ask first.
412    NoAskFirst,
413    /// The agent advertises no mode at all.
414    NoneStated,
415    /// No recorded profile for this agent and version.
416    Unprofiled,
417}
418
419/// What speccoder does at set-up, and why.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct ModePlan {
422    /// The one value to request, if any.
423    pub request: Option<String>,
424    /// Why.
425    pub why: Why,
426}
427
428impl ModePlan {
429    /// The decision table; the first row that matches wins. Its only
430    /// sources for a requested value are the profile's ask-first value and
431    /// the person's own preference.
432    #[must_use]
433    pub fn of(
434        profile: Option<&ModeProfile>,
435        advertised: Option<&Advertised>,
436        preference: Option<&str>,
437    ) -> Self {
438        let none = |why| Self { request: None, why };
439        let Some(ad) = advertised else {
440            return none(Why::NoneStated);
441        };
442        if let Some(p) = preference {
443            return match (ad.offers(p), p == ad.current) {
444                (false, _) => none(Why::ChoseUnoffered),
445                (true, true) => none(Why::YouChose),
446                (true, false) => Self {
447                    request: Some(p.to_owned()),
448                    why: Why::YouChose,
449                },
450            };
451        }
452        let Some(profile) = profile else {
453            return none(Why::Unprofiled);
454        };
455        if profile.ask_first == Some(ad.current.as_str())
456            || (profile.ask_first.is_none() && profile.launch_env.is_some())
457        {
458            return none(Why::AsksAlready);
459        }
460        if ad.current != profile.unconfigured_start {
461            return none(Why::YouSetIt);
462        }
463        match profile.ask_first.filter(|a| ad.offers(a)) {
464            None => none(Why::NoAskFirst),
465            Some(a) => Self {
466                request: Some(a.to_owned()),
467                why: Why::AskedForAskFirst,
468            },
469        }
470    }
471}
472
473/// How the one request ended.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum Outcome {
476    /// The agent's value afterwards, which may differ from the one asked for.
477    Confirmed(String),
478    /// The agent's refusal.
479    Refused(String),
480    /// No answer in time.
481    NoAnswer,
482}
483
484/// What an attempt says about its permission mode: the mode in the agent's
485/// own words, whether it asks, and why it is the mode it is.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Statement {
488    /// The agent's name.
489    pub agent: String,
490    /// The agent's own id for the mode.
491    pub mode: Option<String>,
492    /// The agent's own name for it.
493    pub name: Option<String>,
494    /// Whether it asks.
495    pub reading: Reading,
496    /// What it decides without asking, when it does.
497    pub decides: Option<String>,
498    /// Why it is in it.
499    pub why: Why,
500    /// The refusal, or the preference the agent does not offer.
501    pub detail: Option<String>,
502}
503
504fn one_line(s: &str) -> String {
505    let line = s.split_whitespace().collect::<Vec<_>>().join(" ");
506    if line.chars().count() > 80 {
507        format!("{}…", line.chars().take(79).collect::<String>())
508    } else {
509        line
510    }
511}
512
513impl Statement {
514    /// The sentence the run shows. Agent text is clipped to one line.
515    #[must_use]
516    pub fn sentence(&self) -> String {
517        let agent = one_line(&self.agent);
518        let mode = one_line(
519            self.name
520                .as_deref()
521                .or(self.mode.as_deref())
522                .unwrap_or("an unnamed mode"),
523        );
524        let detail = one_line(self.detail.as_deref().unwrap_or(""));
525        let what = one_line(self.decides.as_deref().unwrap_or("what it decides"));
526        let deciding = self.reading == Reading::DecidesWithoutYou;
527        let tail = if deciding {
528            format!(" — decides without asking: {what}")
529        } else {
530            String::new()
531        };
532        match self.why {
533            Why::AskedForAskFirst if self.reading == Reading::Asks => {
534                format!("asked {agent} to ask before acting — you set no mode")
535            }
536            Why::AskedForAskFirst if self.reading == Reading::NotKnown => format!(
537                "asked {agent} to ask before acting ({mode}); not known whether it does — no recording of this version"
538            ),
539            Why::AskedForAskFirst => {
540                format!("asked {agent} to ask before acting — it is in {mode}{tail}")
541            }
542            Why::AsksAlready if deciding => format!("{agent} is in {mode}{tail}"),
543            Why::AsksAlready if self.reading == Reading::NotKnown => format!(
544                "{agent} is in {mode}; not known whether it asks — no recording of this version"
545            ),
546            Why::AsksAlready => format!("{agent} asks before acting: {mode}"),
547            Why::YouChose if self.reading == Reading::NotKnown => {
548                format!("you chose {mode} — not known whether {agent} asks")
549            }
550            Why::YouChose => format!("you chose {mode}{tail}"),
551            Why::ChoseUnoffered => {
552                format!("you chose {detail}; {agent} does not offer it — it is in {mode}{tail}")
553            }
554            Why::YouSetIt if deciding => {
555                format!("{agent} decides without asking: {mode} ({what}) — set in its own settings")
556            }
557            Why::YouSetIt if self.reading == Reading::NotKnown => {
558                format!("{agent} is in {mode} — set in its own settings; not known whether it asks")
559            }
560            Why::YouSetIt => format!("{agent} is in {mode} — set in its own settings"),
561            Why::Refused => format!(
562                "asked {agent} to ask before acting; it refused: {detail} — it is in {mode}{tail}"
563            ),
564            Why::NoAnswer => format!(
565                "asked {agent} to ask before acting; it did not answer — it is in {mode}{tail}"
566            ),
567            Why::NoAskFirst => {
568                format!("{agent} advertises no mode known to ask first; it is in {mode}{tail}")
569            }
570            Why::NoneStated => {
571                format!("{agent} states no permission mode — not known whether it asks")
572            }
573            Why::Unprofiled => format!("{agent} is in {mode} — not known whether it asks"),
574        }
575    }
576
577    /// Whether calls reach speccoder: `Some(false)` when the agent decides
578    /// some itself, `None` when not known.
579    #[must_use]
580    pub const fn asks(&self) -> Option<bool> {
581        match self.reading {
582            Reading::Asks => Some(true),
583            Reading::DecidesWithoutYou => Some(false),
584            Reading::NotKnown => None,
585        }
586    }
587}
588
589/// What the set-up came to, in a sentence's data. `config_options` is the
590/// latest full list the agent sent (for the profile's guard).
591#[must_use]
592pub fn statement(
593    agent: &str,
594    profile: Option<&ModeProfile>,
595    advertised: Option<&Advertised>,
596    plan: &ModePlan,
597    outcome: Option<&Outcome>,
598    preference: Option<&str>,
599    config_options: &serde_json::Value,
600) -> Statement {
601    let current = match (outcome, advertised) {
602        (Some(Outcome::Confirmed(v)), _) => Some(v.clone()),
603        (_, Some(ad)) => Some(ad.current.clone()),
604        _ => None,
605    };
606    let (mut reading, mut decides) = match (&current, profile) {
607        // A launch rule makes the agent ask only when speccoder set it.
608        (Some(_), Some(p)) if p.launch_env.is_some() && preference.is_some() => {
609            (Reading::NotKnown, None)
610        }
611        (Some(v), Some(p)) => p.reading(v),
612        _ => (Reading::NotKnown, None),
613    };
614    // A guard turned off decides whatever the mode says.
615    if let Some((id, asks)) = profile.and_then(|p| p.guard)
616        && let Some(now) = current_of(config_options, id)
617        && now != asks
618    {
619        reading = Reading::DecidesWithoutYou;
620        decides = Some(format!("everything ({id} is {now})"));
621    }
622    let (why, detail) = match outcome {
623        Some(Outcome::Refused(reason)) => (Why::Refused, Some(reason.clone())),
624        Some(Outcome::NoAnswer) => (Why::NoAnswer, None),
625        _ if plan.why == Why::ChoseUnoffered => {
626            (Why::ChoseUnoffered, preference.map(str::to_owned))
627        }
628        _ => (plan.why, None),
629    };
630    Statement {
631        agent: agent.to_owned(),
632        name: match (&current, advertised) {
633            (Some(v), Some(ad)) => Some(ad.name_of(v)),
634            _ => None,
635        },
636        mode: current,
637        reading,
638        decides,
639        why,
640        detail,
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use serde_json::json;
648
649    fn profile(agent: &str) -> &'static ModeProfile {
650        PROFILES
651            .iter()
652            .find(|p| p.agent == agent)
653            .unwrap_or_else(|| panic!("{agent}"))
654    }
655
656    fn ad(current: &str, values: &[&str]) -> Advertised {
657        Advertised {
658            via: Via::Config("mode".into()),
659            current: current.into(),
660            values: values
661                .iter()
662                .map(|v| ((*v).to_owned(), (*v).to_owned()))
663                .collect(),
664        }
665    }
666
667    const CODEX: &[&str] = &["read-only", "workspace-write", "agent", "agent-full-access"];
668
669    #[test]
670    fn the_plan_table_row_by_row() {
671        let p = Some(profile("codex"));
672        let start = ad("agent", CODEX);
673        let row = |prof, a: Option<&Advertised>, pref| ModePlan::of(prof, a, pref);
674        assert_eq!(row(p, None, None).why, Why::NoneStated);
675        let r = row(p, Some(&start), Some("nope"));
676        assert_eq!((r.request, r.why), (None, Why::ChoseUnoffered));
677        let r = row(p, Some(&start), Some("agent"));
678        assert_eq!((r.request, r.why), (None, Why::YouChose));
679        let r = row(p, Some(&start), Some("workspace-write"));
680        assert_eq!(
681            (r.request.as_deref(), r.why),
682            (Some("workspace-write"), Why::YouChose)
683        );
684        assert_eq!(row(None, Some(&start), None).why, Why::Unprofiled);
685        assert_eq!(
686            row(p, Some(&ad("read-only", CODEX)), None).why,
687            Why::AsksAlready
688        );
689        let r = row(p, Some(&ad("agent-full-access", CODEX)), None);
690        assert_eq!((r.request, r.why), (None, Why::YouSetIt));
691        let r = row(p, Some(&ad("agent", &["agent", "workspace-write"])), None);
692        assert_eq!((r.request, r.why), (None, Why::NoAskFirst));
693        let r = row(p, Some(&start), None);
694        assert_eq!(
695            (r.request.as_deref(), r.why),
696            (Some("read-only"), Why::AskedForAskFirst)
697        );
698    }
699
700    #[test]
701    fn never_requests_a_deciding_mode_unasked() {
702        for profile in PROFILES {
703            let values: Vec<&str> = profile.values.iter().map(|(v, _)| *v).collect();
704            for current in &values {
705                let a = Advertised {
706                    via: profile.via(),
707                    current: (*current).to_owned(),
708                    values: values
709                        .iter()
710                        .map(|v| ((*v).to_owned(), (*v).to_owned()))
711                        .collect(),
712                };
713                let plan = ModePlan::of(Some(profile), Some(&a), None);
714                assert!(
715                    plan.request.is_none() || plan.request.as_deref() == profile.ask_first,
716                    "{} at {current} would request {:?}",
717                    profile.agent,
718                    plan.request
719                );
720                if let Some(r) = &plan.request {
721                    let read = profile.reading(r).0;
722                    assert!(
723                        read == Reading::Asks || (!profile.recorded && read == Reading::NotKnown),
724                        "{}: {r} reads {read:?}",
725                        profile.agent
726                    );
727                }
728            }
729        }
730        assert_eq!(
731            ModePlan::of(None, Some(&ad("x", &["x", "y"])), None).request,
732            None
733        );
734    }
735
736    #[test]
737    fn another_version_is_profiled_only_by_the_same_vocabulary() {
738        let options = |values: &[&str], current: &str| {
739            json!([{ "id": "mode", "category": "mode", "type": "select", "currentValue": current,
740                     "options": values.iter().map(|v| json!({ "value": v, "name": v })).collect::<Vec<_>>() }])
741        };
742        let name = "@agentclientprotocol/codex-acp";
743        // The recorded version: its profile, whatever it offers.
744        assert!(profile_for(name, "2.1.1", &json!([]), &json!(null)).is_some());
745        // A newer one offering exactly the recorded values reads the same.
746        let same = options(CODEX, "agent");
747        assert_eq!(
748            profile_for(name, "2.3.0", &same, &json!(null)).map(|p| p.agent),
749            Some("codex")
750        );
751        // One that offers another vocabulary is unprofiled: nothing is asked.
752        let other = options(&["read-only", "agent", "yolo"], "agent");
753        assert_eq!(profile_for(name, "2.3.0", &other, &json!(null)), None);
754        assert_eq!(profile_for("Kimi", "1.0.0", &same, &json!(null)), None);
755    }
756
757    #[test]
758    fn statements_say_when_the_agent_decides_alone() {
759        let copilot = profile("copilot");
760        let agent = "https://agentclientprotocol.com/protocol/session-modes#agent";
761        let a = Advertised {
762            via: Via::Config("mode".into()),
763            current: agent.into(),
764            values: vec![(agent.into(), "Agent".into())],
765        };
766        let plan = ModePlan::of(Some(copilot), Some(&a), None);
767        assert_eq!(plan.why, Why::AsksAlready);
768        let off = json!([{ "id": "allow_all", "currentValue": "off" }]);
769        let s = statement(
770            "GitHub Copilot",
771            Some(copilot),
772            Some(&a),
773            &plan,
774            None,
775            None,
776            &off,
777        );
778        assert_eq!(s.asks(), Some(true));
779        assert_eq!(s.sentence(), "GitHub Copilot asks before acting: Agent");
780        // Allow-all on: whatever the mode, it decides alone.
781        let on = json!([{ "id": "allow_all", "currentValue": "on" }]);
782        let s = statement(
783            "GitHub Copilot",
784            Some(copilot),
785            Some(&a),
786            &plan,
787            None,
788            None,
789            &on,
790        );
791        assert_eq!(s.asks(), Some(false));
792        assert_eq!(
793            s.sentence(),
794            "GitHub Copilot is in Agent — decides without asking: everything (allow_all is on)"
795        );
796        // Codex asked for read-only; one that did not answer stays deciding.
797        let codex = profile("codex");
798        let c = ad("agent", CODEX);
799        let plan = ModePlan::of(Some(codex), Some(&c), None);
800        let s = statement(
801            "Codex",
802            Some(codex),
803            Some(&c),
804            &plan,
805            Some(&Outcome::NoAnswer),
806            None,
807            &json!([]),
808        );
809        assert_eq!(
810            s.sentence(),
811            "asked Codex to ask before acting; it did not answer — it is in agent — decides without asking: a reviewer model decides"
812        );
813        // OpenCode asks through its launch rule, unless a mode was chosen.
814        let open = profile("opencode");
815        let o = ad("build", &["build", "plan"]);
816        let plan = ModePlan::of(Some(open), Some(&o), None);
817        assert_eq!(
818            statement(
819                "OpenCode",
820                Some(open),
821                Some(&o),
822                &plan,
823                None,
824                None,
825                &json!([])
826            )
827            .asks(),
828            Some(true)
829        );
830        let plan = ModePlan::of(Some(open), Some(&o), Some("build"));
831        assert_eq!(
832            statement(
833                "OpenCode",
834                Some(open),
835                Some(&o),
836                &plan,
837                None,
838                Some("build"),
839                &json!([])
840            )
841            .asks(),
842            None
843        );
844    }
845
846    #[test]
847    fn opencode_asks_at_every_level_and_keeps_your_other_settings() {
848        let v: serde_json::Value =
849            serde_json::from_str(&launch_value(OPENCODE_ASKS, Some(r#"{"model":"x/y","permission":{"bash":"allow"},"agent":{"build":{"permission":{"edit":"allow"}}}}"#)))
850                .unwrap_or_default();
851        assert_eq!(v["model"], "x/y");
852        assert_eq!(v["permission"]["bash"], "ask");
853        assert_eq!(v["agent"]["build"]["permission"]["edit"], "ask");
854        assert_eq!(v["agent"]["plan"]["permission"]["grep"], "ask");
855        // Not JSON, or none: ours alone.
856        assert_eq!(launch_value(OPENCODE_ASKS, Some("nope")), OPENCODE_ASKS);
857        assert_eq!(launch_value(OPENCODE_ASKS, None), OPENCODE_ASKS);
858    }
859
860    #[test]
861    fn a_profile_built_from_source_does_not_say_its_agent_asks() {
862        let codex = profile("codex");
863        assert!(!codex.recorded);
864        assert_eq!(codex.reading("read-only").0, Reading::NotKnown);
865        assert_eq!(codex.reading("agent").0, Reading::DecidesWithoutYou);
866        assert_eq!(profile("claude").reading("default").0, Reading::Asks);
867    }
868
869    /// Every value a profile names is in its adapter's recorded set-up.
870    #[test]
871    fn every_profile_value_is_in_its_recording() {
872        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp");
873        for p in PROFILES {
874            let file = root.join(format!("{}@{}.json", p.agent, p.agent_info.1));
875            let text = std::fs::read_to_string(&file)
876                .unwrap_or_else(|e| panic!("{}: {e}", file.display()));
877            let rec: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
878            let info = &rec["initialize"]["agentInfo"];
879            assert_eq!(
880                (info["name"].as_str(), info["version"].as_str()),
881                (Some(p.agent_info.0), Some(p.agent_info.1)),
882                "{}",
883                p.agent
884            );
885            let setup = &rec["session/new"];
886            let a = Advertised::read(&setup["configOptions"], &setup["modes"], Some(p))
887                .unwrap_or_else(|| panic!("{}: no mode advertised", p.agent));
888            assert!(p.offers_exactly(&a), "{}: {:?}", p.agent, a.values);
889            assert_eq!(a.current, p.unconfigured_start, "{}", p.agent);
890            if let Some((id, asks)) = p.guard {
891                assert_eq!(
892                    current_of(&setup["configOptions"], id).as_deref(),
893                    Some(asks)
894                );
895            }
896        }
897    }
898}