use std::fmt;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Agent {
Claude,
Codex,
Copilot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSupport {
Minted,
Printed,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Caps {
pub session: SessionSupport,
pub fork: bool,
pub events: bool,
pub native_system: bool,
pub schema: SchemaSupport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaSupport {
Inline,
File,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Permission {
#[default]
ReadOnly,
Plan,
Edit,
Auto,
Bypass,
}
pub const NETWORK_ENV: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum EnvPolicy {
#[default]
Minimal,
Inherit,
Only(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Format {
Text,
Json,
#[default]
Stream,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Continue {
#[default]
New,
NewWith(String),
Resume(String),
Fork(String),
}
#[derive(Debug, Clone)]
pub struct Plan {
pub bin: String,
pub prompt: String,
pub system: Option<String>,
pub model: Option<String>,
pub effort: Option<String>,
pub permission: Permission,
pub format: Format,
pub cont: Continue,
pub stdin_prompt: bool,
pub schema: Option<String>,
pub schema_file: Option<String>,
}
pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024;
pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024;
impl Agent {
pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot];
#[must_use]
pub fn id(self) -> &'static str {
match self {
Agent::Claude => "claude-code",
Agent::Codex => "codex",
Agent::Copilot => "copilot",
}
}
#[must_use]
pub fn bin(self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Copilot => "copilot",
}
}
#[must_use]
pub fn auth_status_argv(self) -> Option<&'static [&'static str]> {
match self {
Agent::Claude => Some(&["auth", "status", "--json"]),
Agent::Codex => Some(&["login", "status"]),
Agent::Copilot => None,
}
}
#[must_use]
pub fn auth_env_vars(self) -> &'static [&'static str] {
match self {
Agent::Claude => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
Agent::Codex => &["CODEX_API_KEY", "OPENAI_API_KEY"],
Agent::Copilot => &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
}
}
#[must_use]
pub fn login_hint(self) -> &'static str {
match self {
Agent::Claude => {
"run `claude` and use /login, or `claude setup-token` for a \
long-lived token"
}
Agent::Codex => "run `codex login`",
Agent::Copilot => "run `copilot login`",
}
}
#[must_use]
pub fn verified_version(self) -> crate::Version {
let (major, minor, patch) = match self {
Agent::Claude => (2, 1, 212),
Agent::Codex => (0, 145, 0),
Agent::Copilot => (1, 0, 75),
};
crate::Version {
major,
minor,
patch,
}
}
#[must_use]
pub fn install_hint(self) -> &'static str {
match self {
Agent::Claude => "npm install -g @anthropic-ai/claude-code",
Agent::Codex => "npm install -g @openai/codex",
Agent::Copilot => "npm install -g @github/copilot",
}
}
#[must_use]
pub fn essential_env(self) -> Vec<&'static str> {
const BASE: &[&str] = &[
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL",
];
const WINDOWS: &[&str] = &[
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"SystemRoot",
"SystemDrive",
"TEMP",
"TMP",
"PATHEXT",
"ComSpec",
];
let agent: &[&str] = match self {
Agent::Claude => &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CONFIG_DIR",
],
Agent::Codex => &[
"CODEX_HOME",
"CODEX_API_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
],
Agent::Copilot => &[
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"XDG_CONFIG_HOME",
],
};
BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
}
#[must_use]
pub fn caps(self) -> Caps {
match self {
Agent::Claude => Caps {
session: SessionSupport::Minted,
fork: true,
events: true,
native_system: true,
schema: SchemaSupport::Inline,
},
Agent::Codex => Caps {
session: SessionSupport::Printed,
fork: false,
events: true,
native_system: false,
schema: SchemaSupport::File,
},
Agent::Copilot => Caps {
session: SessionSupport::Minted,
fork: false,
events: true,
native_system: false,
schema: SchemaSupport::None,
},
}
}
#[must_use]
pub fn session_format(self) -> Option<Format> {
match self.caps().session {
SessionSupport::Minted | SessionSupport::Printed => Some(match self {
Agent::Claude => Format::Json,
Agent::Codex | Agent::Copilot => Format::Stream,
}),
SessionSupport::None => None,
}
}
#[must_use]
pub fn format_carries_session(self, format: Format) -> bool {
self.session_format().is_some() && format != Format::Text
}
fn check(self, plan: &Plan) -> Result<()> {
let caps = self.caps();
if matches!(plan.cont, Continue::Fork(_)) && !caps.fork {
return Err(Error::Unsupported {
agent: self,
what: "forking a session headlessly",
});
}
if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted {
return Err(Error::Unsupported {
agent: self,
what: "assigning a session id up front",
});
}
if plan.schema.is_some() && caps.schema == SchemaSupport::None {
return Err(Error::Unsupported {
agent: self,
what: "constraining its answer to a JSON schema",
});
}
if plan.format == Format::Stream && !caps.events {
return Err(Error::Unsupported {
agent: self,
what: "a structured event stream",
});
}
Ok(())
}
pub fn argv(self, plan: &Plan) -> Result<Vec<String>> {
Ok(self
.typed_argv(plan)?
.into_iter()
.map(|arg| arg.value)
.collect())
}
pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
self.check(plan)?;
Ok(match self {
Agent::Claude => argv_claude(plan),
Agent::Codex => argv_codex(plan),
Agent::Copilot => argv_copilot(plan),
})
}
#[must_use]
pub fn effective_prompt(self, plan: &Plan) -> String {
match (&plan.system, self.caps().native_system) {
(Some(system), false) => format!("{system}\n\n{}", plan.prompt),
_ => plan.prompt.clone(),
}
}
}
impl fmt::Display for Agent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.id())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Sensitivity {
Public,
Prompt,
SessionId,
Unchecked,
}
#[derive(Debug, Clone)]
pub(crate) struct Arg {
pub(crate) value: String,
pub(crate) sensitivity: Sensitivity,
}
pub(crate) struct Argv(Vec<Arg>);
impl Argv {
fn new(bin: &str) -> Self {
Self(vec![Arg {
value: bin.to_string(),
sensitivity: Sensitivity::Public,
}])
}
fn push(&mut self, value: impl Into<String>, sensitivity: Sensitivity) -> &mut Self {
self.0.push(Arg {
value: value.into(),
sensitivity,
});
self
}
fn bare(&mut self, flag: &str) -> &mut Self {
self.push(flag, Sensitivity::Public)
}
fn pair(&mut self, flag: &str, value: impl AsRef<str>) -> &mut Self {
self.bare(flag).push(value.as_ref(), Sensitivity::Public)
}
fn secret(&mut self, flag: &str, value: impl AsRef<str>, kind: Sensitivity) -> &mut Self {
self.bare(flag).push(value.as_ref(), kind)
}
fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self {
if let Some(value) = value {
self.pair(flag, value);
}
self
}
fn arg(&mut self, value: impl Into<String>) -> &mut Self {
self.push(value, Sensitivity::Public)
}
fn arg_sensitive(&mut self, value: impl Into<String>, kind: Sensitivity) -> &mut Self {
self.push(value, kind)
}
fn done(&mut self) -> Vec<Arg> {
std::mem::take(&mut self.0)
}
}
fn claude_mode(p: Permission) -> &'static str {
match p {
Permission::ReadOnly => "dontAsk",
Permission::Plan => "plan",
Permission::Edit => "acceptEdits",
Permission::Auto => "auto",
Permission::Bypass => "bypassPermissions",
}
}
fn argv_claude(plan: &Plan) -> Vec<Arg> {
let mut a = Argv::new(&plan.bin);
a.bare("-p");
if plan.stdin_prompt {
a.pair("--input-format", "text");
} else {
a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt);
}
a.pair("--permission-mode", claude_mode(plan.permission));
if plan.permission == Permission::ReadOnly {
a.bare("--disallowedTools");
for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] {
a.arg(tool);
}
}
a.opt("--model", plan.model.as_ref());
a.opt("--effort", plan.effort.as_ref());
if let Some(system) = &plan.system {
a.secret("--append-system-prompt", system, Sensitivity::Prompt);
}
match &plan.cont {
Continue::New => {}
Continue::NewWith(id) => {
a.secret("--session-id", id, Sensitivity::SessionId);
}
Continue::Resume(id) => {
a.secret("--resume", id, Sensitivity::SessionId);
}
Continue::Fork(id) => {
a.secret("--resume", id, Sensitivity::SessionId)
.bare("--fork-session");
}
}
a.pair(
"--output-format",
match plan.format {
Format::Text => "text",
Format::Json => "json",
Format::Stream => "stream-json",
},
);
if let Some(schema) = &plan.schema {
a.secret("--json-schema", schema, Sensitivity::Prompt);
}
if plan.format == Format::Stream {
a.bare("--verbose");
a.bare("--include-partial-messages");
}
a.done()
}
fn argv_codex(plan: &Plan) -> Vec<Arg> {
let mut a = Argv::new(&plan.bin);
a.bare("exec");
if let Continue::Resume(id) = &plan.cont {
a.bare("resume")
.arg_sensitive(id.clone(), Sensitivity::SessionId);
}
a.bare("--skip-git-repo-check");
let resuming = matches!(plan.cont, Continue::Resume(_));
let sandbox = match plan.permission {
Permission::Bypass => None,
Permission::ReadOnly | Permission::Plan => Some("read-only"),
Permission::Edit | Permission::Auto => Some("workspace-write"),
};
match (sandbox, resuming) {
(None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
(Some(mode), false) => a.pair("--sandbox", mode),
(Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
};
a.opt("--model", plan.model.as_ref());
if let Some(effort) = plan.effort.as_ref() {
a.pair("-c", format!("model_reasoning_effort={effort}"));
}
if plan.schema.is_some() {
a.pair(
"--output-schema",
plan.schema_file.as_deref().unwrap_or("<schema-file>"),
);
}
if plan.format != Format::Text {
a.bare("--json");
}
if plan.stdin_prompt {
a.arg("-");
} else {
a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
}
a.done()
}
fn argv_copilot(plan: &Plan) -> Vec<Arg> {
let mut a = Argv::new(&plan.bin);
if !plan.stdin_prompt {
a.secret(
"-p",
Agent::Copilot.effective_prompt(plan),
Sensitivity::Prompt,
);
}
a.bare("--allow-all-tools").bare("--no-ask-user");
match plan.permission {
Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
Permission::Edit => a.bare("--deny-tool=shell"),
Permission::Plan => a.pair("--mode", "plan"),
};
a.opt("--model", plan.model.as_ref());
a.opt("--effort", plan.effort.as_ref());
match &plan.cont {
Continue::NewWith(id) | Continue::Resume(id) => {
a.secret("--session-id", id, Sensitivity::SessionId);
}
Continue::New | Continue::Fork(_) => {}
}
a.pair(
"--output-format",
if plan.format == Format::Text {
"text"
} else {
"json"
},
);
a.done()
}
#[cfg(test)]
mod tests {
use super::*;
fn plan(bin: &str) -> Plan {
Plan {
bin: bin.into(),
prompt: "hi".into(),
system: None,
model: None,
effort: None,
permission: Permission::ReadOnly,
format: Format::Json,
cont: Continue::New,
stdin_prompt: false,
schema: None,
schema_file: None,
}
}
fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
agent.argv(plan).expect("plan is supported")
}
fn pos(a: &[String], needle: &str) -> Option<usize> {
a.iter().position(|s| s == needle)
}
#[test]
fn claude_builds_print_mode_with_format_and_permission() {
let a = argv(Agent::Claude, &plan("claude"));
assert_eq!(a[0..3], ["claude", "-p", "hi"]);
assert!(pos(&a, "--permission-mode").is_some());
assert!(a.contains(&"dontAsk".to_string()));
assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
}
#[test]
fn claude_read_only_removes_the_mutating_tools() {
let a = argv(Agent::Claude, &plan("claude"));
let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
assert_eq!(
&a[at + 1..at + 5],
["Bash", "Edit", "Write", "NotebookEdit"]
);
}
#[test]
fn effort_reaches_each_cli_the_way_that_cli_takes_it() {
let mut p = plan("x");
p.effort = Some("xhigh".into());
let claude = argv(Agent::Claude, &p);
assert_eq!(claude[pos(&claude, "--effort").unwrap() + 1], "xhigh");
let copilot = argv(Agent::Copilot, &p);
assert_eq!(copilot[pos(&copilot, "--effort").unwrap() + 1], "xhigh");
let codex = argv(Agent::Codex, &p);
assert!(
pos(&codex, "--effort").is_none(),
"codex has no effort flag: {codex:?}"
);
assert!(
codex
.windows(2)
.any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=xhigh"),
"codex takes it as a config override: {codex:?}"
);
}
#[test]
fn no_effort_means_no_flag() {
let p = plan("x");
for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
let a = argv(agent, &p);
assert!(pos(&a, "--effort").is_none(), "{agent}: {a:?}");
assert!(
!a.iter()
.any(|arg| arg.starts_with("model_reasoning_effort")),
"{agent}: {a:?}"
);
}
}
#[test]
fn claude_bypass_does_not_deny_tools() {
let mut p = plan("claude");
p.permission = Permission::Bypass;
let a = argv(Agent::Claude, &p);
assert!(a.contains(&"bypassPermissions".to_string()));
assert!(pos(&a, "--disallowedTools").is_none());
}
#[test]
fn claude_stream_format_adds_verbose_but_json_does_not() {
let mut p = plan("claude");
p.format = Format::Stream;
assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
p.format = Format::Json;
assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
}
#[test]
fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
let mut p = plan("claude");
p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
let a = argv(Agent::Claude, &p);
assert_eq!(
a[pos(&a, "--session-id").unwrap() + 1],
"11111111-2222-3333-4444-555555555555"
);
assert!(pos(&a, "--resume").is_none());
p.cont = Continue::Resume("sess-1".into());
let a = argv(Agent::Claude, &p);
assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
assert!(!a.contains(&"--fork-session".to_string()));
}
#[test]
fn claude_fork_resumes_and_branches() {
let mut p = plan("claude");
p.cont = Continue::Fork("sess-1".into());
let a = argv(Agent::Claude, &p);
assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
assert!(a.contains(&"--fork-session".to_string()));
}
#[test]
fn claude_keeps_the_system_prompt_on_its_own_flag() {
let mut p = plan("claude");
p.system = Some("be terse".into());
let a = argv(Agent::Claude, &p);
assert_eq!(
a[pos(&a, "--append-system-prompt").unwrap() + 1],
"be terse"
);
assert!(a.contains(&"hi".to_string()));
}
#[test]
fn claude_stdin_prompt_leaves_the_argv() {
let mut p = plan("claude");
p.stdin_prompt = true;
let a = argv(Agent::Claude, &p);
assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
}
#[test]
fn codex_resume_is_a_subcommand_and_prompt_is_last() {
let mut p = plan("codex");
p.cont = Continue::Resume("thread-9".into());
let a = argv(Agent::Codex, &p);
assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
assert_eq!(a.last().unwrap(), "hi");
}
#[test]
fn the_minimal_environment_carries_no_proxy_variables() {
for agent in Agent::ALL {
let essential = agent.essential_env();
for name in NETWORK_ENV {
assert!(
!essential.contains(name),
"{agent} would pass {name} through EnvPolicy::Minimal"
);
}
}
}
#[test]
fn every_agent_asks_for_the_verified_floor() {
for agent in Agent::ALL {
let essential = agent.essential_env();
for name in ["PATH", "HOME", "USER"] {
assert!(essential.contains(&name), "{agent} omits {name}");
}
}
}
#[test]
fn agents_do_not_request_each_others_credentials() {
let claude = Agent::Claude.essential_env();
assert!(claude.contains(&"ANTHROPIC_API_KEY"));
assert!(!claude.contains(&"OPENAI_API_KEY"));
assert!(!claude.contains(&"GH_TOKEN"));
let codex = Agent::Codex.essential_env();
assert!(codex.contains(&"OPENAI_API_KEY"));
assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
}
#[test]
fn every_agent_forwards_the_callers_model_verbatim() {
for agent in Agent::ALL {
let mut p = plan(agent.bin());
p.model = Some("some-model-9".into());
let a = argv(agent, &p);
let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
}
}
#[test]
fn no_model_means_no_model_flag() {
for agent in Agent::ALL {
let p = plan(agent.bin());
assert!(p.model.is_none());
let a = argv(agent, &p);
assert!(
pos(&a, "--model").is_none(),
"{agent} invented a model: {a:?}"
);
}
}
#[test]
fn codex_always_waives_the_git_repo_check() {
for cont in [Continue::New, Continue::Resume("t-1".into())] {
let mut p = plan("codex");
p.cont = cont.clone();
assert!(
argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
"{cont:?} must still run outside a repo"
);
}
}
#[test]
fn each_agent_takes_a_schema_in_its_own_shape() {
let schema = r#"{"type":"object"}"#;
let mut claude = plan("claude");
claude.schema = Some(schema.into());
let a = argv(Agent::Claude, &claude);
assert_eq!(
a[pos(&a, "--json-schema").unwrap() + 1],
schema,
"claude takes it inline"
);
let mut codex = plan("codex");
codex.schema = Some(schema.into());
codex.schema_file = Some("/tmp/s.json".into());
let a = argv(Agent::Codex, &codex);
assert_eq!(
a[pos(&a, "--output-schema").unwrap() + 1],
"/tmp/s.json",
"codex takes a path, never the schema itself"
);
assert!(!a.iter().any(|arg| arg.contains("\"type\"")));
}
#[test]
fn copilot_refuses_a_schema_rather_than_answering_in_prose() {
let mut p = plan("copilot");
p.schema = Some(r#"{"type":"object"}"#.into());
assert!(matches!(
Agent::Copilot.argv(&p),
Err(Error::Unsupported { .. })
));
}
#[test]
fn a_codex_schema_preview_shows_a_placeholder_path() {
let mut p = plan("codex");
p.schema = Some(r#"{"type":"object"}"#.into());
let a = argv(Agent::Codex, &p);
assert_eq!(a[pos(&a, "--output-schema").unwrap() + 1], "<schema-file>");
}
#[test]
fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
let mut fresh = plan("codex");
fresh.permission = Permission::ReadOnly;
let a = argv(Agent::Codex, &fresh);
assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
assert!(pos(&a, "-c").is_none());
let mut resumed = fresh.clone();
resumed.cont = Continue::Resume("thread-9".into());
let a = argv(Agent::Codex, &resumed);
assert!(
pos(&a, "--sandbox").is_none(),
"resume rejects --sandbox: {a:?}"
);
assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
}
#[test]
fn codex_bypass_uses_the_same_flag_on_both_paths() {
for cont in [Continue::New, Continue::Resume("t".into())] {
let mut p = plan("codex");
p.permission = Permission::Bypass;
p.cont = cont.clone();
let a = argv(Agent::Codex, &p);
assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
}
}
#[test]
fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
let mut p = plan("codex");
p.system = Some("be terse".into());
let a = argv(Agent::Codex, &p);
assert_eq!(a.last().unwrap(), "be terse\n\nhi");
}
#[test]
fn codex_maps_each_posture_to_a_sandbox() {
for (perm, expect) in [
(Permission::ReadOnly, "read-only"),
(Permission::Plan, "read-only"),
(Permission::Edit, "workspace-write"),
(Permission::Auto, "workspace-write"),
] {
let mut p = plan("codex");
p.permission = perm;
let a = argv(Agent::Codex, &p);
assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
}
let mut p = plan("codex");
p.permission = Permission::Bypass;
let a = argv(Agent::Codex, &p);
assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(pos(&a, "--sandbox").is_none());
}
#[test]
fn copilot_drops_dash_p_when_the_prompt_is_piped() {
let mut p = plan("copilot");
p.stdin_prompt = true;
let a = argv(Agent::Copilot, &p);
assert!(
!a.contains(&"-p".to_string()),
"a -p value shadows the pipe"
);
assert!(!a.contains(&"hi".to_string()));
}
#[test]
fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
let a = argv(Agent::Copilot, &plan("copilot"));
assert!(a.contains(&"--deny-tool=shell".to_string()));
assert!(a.contains(&"--deny-tool=write".to_string()));
assert!(
!a.iter().any(|s| s == "--deny-tool"),
"a bare --deny-tool would drop its value: {a:?}"
);
}
#[test]
fn copilot_always_allows_tools_and_silences_the_ask_tool() {
for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
let mut p = plan("copilot");
p.permission = permission;
let a = argv(Agent::Copilot, &p);
assert!(
a.contains(&"--allow-all-tools".to_string()),
"{permission:?}"
);
assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
}
}
#[test]
fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
for cont in [
Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
] {
let mut p = plan("copilot");
p.cont = cont.clone();
let a = argv(Agent::Copilot, &p);
assert_eq!(
a[pos(&a, "--session-id").unwrap() + 1],
"11111111-2222-3333-4444-555555555555",
"{cont:?}"
);
}
}
#[test]
fn unsupported_capabilities_are_refused_not_downgraded() {
for agent in [Agent::Codex, Agent::Copilot] {
let mut p = plan(agent.bin());
p.cont = Continue::Fork("s".into());
assert!(
matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
"{agent} must refuse a fork rather than resume linearly"
);
}
let mut p = plan("codex");
p.cont = Continue::NewWith("id".into());
assert!(matches!(
Agent::Codex.argv(&p),
Err(Error::Unsupported { .. })
));
}
#[test]
fn every_agent_has_a_format_that_carries_its_session_id() {
assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
}
#[test]
fn the_minting_agents_are_claude_and_copilot() {
let minting: Vec<_> = Agent::ALL
.into_iter()
.filter(|a| a.caps().session == SessionSupport::Minted)
.collect();
assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
}
}