1use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Via {
21 Config(String),
23 Modes,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Reading {
31 Asks,
33 DecidesWithoutYou,
35 NotKnown,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ModeProfile {
42 pub agent: &'static str,
44 pub agent_info: (&'static str, &'static str),
46 pub config_id: Option<&'static str>,
48 pub unconfigured_start: &'static str,
50 pub ask_first: Option<&'static str>,
52 pub values: &'static [(&'static str, Option<&'static str>)],
54 pub guard: Option<(&'static str, &'static str)>,
57 pub launch_env: Option<(&'static str, &'static str)>,
61 pub recorded: bool,
64}
65
66impl ModeProfile {
67 #[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 #[must_use]
79 pub fn reading(&self, value: &str) -> (Reading, Option<String>) {
80 match self.values.iter().find(|(v, _)| *v == value) {
81 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
99pub 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#[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
131pub const PROFILES: &[ModeProfile] = &[
134 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 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 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 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 recorded: false,
211 },
212 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 recorded: false,
228 },
229];
230
231fn 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#[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#[must_use]
267pub fn launch_profile(agent: &str) -> Option<&'static ModeProfile> {
268 PROFILES.iter().find(|p| p.agent == agent)
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct Advertised {
275 pub via: Via,
277 pub current: String,
279 pub values: Vec<(String, String)>,
281}
282
283impl Advertised {
284 #[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 _ => None,
308 }
309 }
310 }
311 }
312
313 #[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 #[must_use]
324 pub fn offers(&self, value: &str) -> bool {
325 self.values.iter().any(|(v, _)| v == value)
326 }
327
328 #[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#[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 if option["type"] != "select" {
354 return None;
355 }
356 let mut values = Vec::new();
357 for o in option["options"].as_array()? {
358 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum Why {
396 AskedForAskFirst,
398 AsksAlready,
400 YouChose,
402 ChoseUnoffered,
404 YouSetIt,
407 Refused,
409 NoAnswer,
411 NoAskFirst,
413 NoneStated,
415 Unprofiled,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct ModePlan {
422 pub request: Option<String>,
424 pub why: Why,
426}
427
428impl ModePlan {
429 #[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#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum Outcome {
476 Confirmed(String),
478 Refused(String),
480 NoAnswer,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Statement {
488 pub agent: String,
490 pub mode: Option<String>,
492 pub name: Option<String>,
494 pub reading: Reading,
496 pub decides: Option<String>,
498 pub why: Why,
500 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 #[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 #[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#[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 (¤t, profile) {
607 (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 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 (¤t, 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 assert!(profile_for(name, "2.1.1", &json!([]), &json!(null)).is_some());
745 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 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 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 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 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 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 #[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}