use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::process::Command;
use crate::config::{AgentKind, AgentSpec, Delivery};
use crate::proc::Quiet as _;
use crate::rng::SplitMix64;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeatState {
pub key: String,
pub agent: String,
pub turns: usize,
pub claude_session: Option<String>,
pub captured_session: Option<String>,
}
impl SeatState {
pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
Self {
key: key.to_owned(),
agent: agent.to_owned(),
turns: 0,
claude_session: Some(rng.uuid_v4()),
captured_session: None,
}
}
}
pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
if !sessions_enabled || seat.turns == 0 {
return false;
}
match kind {
AgentKind::Claude => seat.claude_session.is_some(),
AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
seat.captured_session.is_some()
}
AgentKind::Command => true,
}
}
#[derive(Debug)]
pub struct Invocation<'a> {
pub cwd: &'a Path,
pub prompt: &'a str,
pub timeout: Duration,
pub allow_write: bool,
pub sessions: bool,
pub artifacts: &'a Path,
pub stem: &'a str,
pub run: &'a str,
pub node: &'a str,
pub cache_dir: Option<&'a Path>,
pub attachments: &'a [PathBuf],
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Quota {
#[serde(default)]
pub reset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Dropped {
pub why: String,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentOutput {
pub text: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub duration_ms: u64,
pub artifacts: Vec<String>,
#[serde(default)]
pub quota: Option<Quota>,
#[serde(default)]
pub dropped: Option<Dropped>,
}
impl AgentOutput {
pub fn usable(&self) -> bool {
!self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
}
pub fn quota_exhausted(&self) -> bool {
self.quota.is_some()
}
pub fn work_undelivered(&self) -> bool {
self.dropped.is_some()
}
}
const PIPE_GRACE: Duration = Duration::from_secs(3);
type Captured = Arc<Mutex<Vec<u8>>>;
fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
let buf: Captured = Arc::new(Mutex::new(Vec::new()));
let Some(mut pipe) = pipe else {
return (buf, None);
};
let sink = Arc::clone(&buf);
let handle = tokio::spawn(async move {
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if let Ok(mut guard) = sink.lock() {
guard.extend_from_slice(&chunk[..n]);
}
}
}
}
});
(buf, Some(handle))
}
async fn collect(
buf: &Captured,
handle: Option<tokio::task::JoinHandle<()>>,
grace: Duration,
) -> String {
if let Some(handle) = handle {
if tokio::time::timeout(grace, handle).await.is_err() {
tracing::debug!("a pipe is still held open after the child exited");
}
}
let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
String::from_utf8_lossy(&bytes).into_owned()
}
pub async fn invoke(
spec: &AgentSpec,
seat: &mut SeatState,
inv: &Invocation<'_>,
) -> Result<AgentOutput> {
tokio::fs::create_dir_all(inv.artifacts)
.await
.with_context(|| format!("create {}", inv.artifacts.display()))?;
let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
tokio::fs::write(&prompt_path, inv.prompt)
.await
.with_context(|| format!("write {}", prompt_path.display()))?;
let plan = build_command(spec, seat, inv, &prompt_path)?;
tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
let started = Instant::now();
let mut cmd = Command::new(&plan.argv[0]);
cmd.args(&plan.argv[1..])
.current_dir(inv.cwd)
.envs(&spec.env)
.env("MAGI_SEAT", &seat.key)
.env("MAGI_TURN", seat.turns.to_string())
.env("MAGI_RUN", inv.run)
.env("MAGI_NODE", inv.node)
.env("MAGI_PROMPT_FILE", &prompt_path)
.env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(if plan.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.quiet();
if let Some(cache) = inv.cache_dir {
cmd.env("CARGO_TARGET_DIR", cache);
}
let mut child = cmd
.spawn()
.with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
tokio::spawn(async move {
sink.write_all(body.as_bytes()).await.ok();
sink.shutdown().await.ok();
});
}
let (out_buf, out_reader) = drain(child.stdout.take());
let (err_buf, err_reader) = drain(child.stderr.take());
let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
Ok(res) => {
let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
(status.code(), false)
}
Err(_) => {
tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
child.start_kill().ok();
(None, true)
}
};
let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
tokio::fs::write(&out_path, &stdout).await.ok();
tokio::fs::write(&err_path, &stderr).await.ok();
let extracted = extract(spec.kind, &stdout);
if let Some(session) = extracted.session {
match spec.kind {
AgentKind::Claude => seat.claude_session = Some(session),
AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
seat.captured_session = Some(session);
}
AgentKind::Command => {}
}
}
if let Some(status) = &extracted.status
&& !status.eq_ignore_ascii_case("success")
{
tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
}
let text = if extracted.text.trim().is_empty() {
if stdout.trim().is_empty() {
stderr.trim().to_owned()
} else {
stdout.trim().to_owned()
}
} else {
extracted.text
};
seat.turns += 1;
Ok(AgentOutput {
text,
exit_code: code,
timed_out,
duration_ms: started.elapsed().as_millis() as u64,
artifacts: vec![
file_name(&prompt_path),
file_name(&out_path),
file_name(&err_path),
],
quota: extracted.quota,
dropped: extracted.dropped,
})
}
fn file_name(p: &Path) -> String {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
#[derive(Debug)]
struct Plan {
argv: Vec<String>,
stdin: Option<String>,
}
fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
if matches!(kind, AgentKind::Antigravity) {
return format!("@{}", prompt_path.display());
}
format!(
"Read the file at {} and follow every instruction in it exactly. That \
file is your complete task description; this message contains nothing \
else.",
prompt_path.display()
)
}
fn build_command(
spec: &AgentSpec,
seat: &SeatState,
inv: &Invocation<'_>,
prompt_path: &Path,
) -> Result<Plan> {
let mut argv: Vec<String> = Vec::new();
let mut stdin: Option<String> = None;
let delivery = spec.delivery();
let resuming = has_session(spec.kind, seat, inv.sessions);
match spec.kind {
AgentKind::Claude => {
argv.push("claude".to_owned());
argv.push("-p".to_owned());
argv.push("--output-format".to_owned());
argv.push("json".to_owned());
if let Some(m) = &spec.model {
argv.push("--model".to_owned());
argv.push(m.clone());
}
if inv.sessions {
let uuid = seat
.claude_session
.as_deref()
.context("claude seat is missing its session uuid")?;
argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
argv.push(uuid.to_owned());
}
argv.push("--permission-mode".to_owned());
argv.push("bypassPermissions".to_owned());
if !inv.allow_write {
argv.push("--disallowed-tools".to_owned());
argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
}
}
AgentKind::Opencode => {
argv.push("opencode".to_owned());
argv.push("run".to_owned());
argv.push("--format".to_owned());
argv.push("json".to_owned());
argv.push("--dir".to_owned());
argv.push(inv.cwd.to_string_lossy().into_owned());
argv.push("--auto".to_owned());
if let Some(m) = &spec.model {
argv.push("-m".to_owned());
argv.push(m.clone());
}
if resuming {
argv.push("-s".to_owned());
argv.push(
seat.captured_session
.clone()
.expect("has_session checked the id is present"),
);
}
}
AgentKind::Antigravity => {
argv.push("agy".to_owned());
argv.push("--output-format".to_owned());
argv.push("json".to_owned());
argv.push("--print-timeout".to_owned());
argv.push(format!("{}s", inv.timeout.as_secs()));
argv.push("--mode".to_owned());
argv.push(
if inv.allow_write {
"accept-edits"
} else {
"plan"
}
.to_owned(),
);
if inv.allow_write {
argv.push("--dangerously-skip-permissions".to_owned());
}
if let Some(m) = &spec.model {
argv.push("--model".to_owned());
argv.push(m.clone());
}
if resuming {
argv.push("--conversation".to_owned());
argv.push(
seat.captured_session
.clone()
.expect("has_session checked the id is present"),
);
}
let mut add_dirs: Vec<String> = Vec::new();
if delivery == Delivery::File || !inv.attachments.is_empty() {
add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
}
for path in inv.attachments {
let Some(parent) = path.parent() else {
continue;
};
if parent.starts_with(inv.artifacts) {
continue;
}
let dir = parent.to_string_lossy().into_owned();
if !add_dirs.contains(&dir) {
add_dirs.push(dir);
}
}
for dir in add_dirs {
argv.push("--add-dir".to_owned());
argv.push(dir);
}
}
AgentKind::Codex => {
argv.push("codex".to_owned());
argv.push("exec".to_owned());
argv.push("--json".to_owned());
argv.push("--skip-git-repo-check".to_owned());
argv.push("-C".to_owned());
argv.push(inv.cwd.to_string_lossy().into_owned());
argv.push("--sandbox".to_owned());
argv.push(
if inv.allow_write {
"workspace-write"
} else {
"read-only"
}
.to_owned(),
);
argv.push("-c".to_owned());
argv.push("approval_policy=\"never\"".to_owned());
if let Some(m) = &spec.model {
argv.push("-m".to_owned());
argv.push(m.clone());
}
if resuming {
argv.push("resume".to_owned());
argv.push(
seat.captured_session
.clone()
.expect("has_session checked the id is present"),
);
}
}
AgentKind::Omp => {
argv.push("omp".to_owned());
argv.push("-p".to_owned());
argv.push("--mode=json".to_owned());
argv.push("--auto-approve".to_owned());
if let Some(m) = &spec.model {
argv.push("--model".to_owned());
argv.push(m.clone());
}
if resuming {
argv.push("--resume".to_owned());
argv.push(
seat.captured_session
.clone()
.expect("has_session checked the id is present"),
);
}
}
AgentKind::Command => {
if spec.command.is_empty() {
bail!("agent `{}` has kind = \"command\" but no command", spec.id);
}
let vars: BTreeMap<&str, String> = BTreeMap::from([
("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
("{cwd}", inv.cwd.to_string_lossy().into_owned()),
("{label}", seat.key.clone()),
("{session}", seat.claude_session.clone().unwrap_or_default()),
]);
for raw in &spec.command {
let mut arg = raw.clone();
for (k, v) in &vars {
if arg.contains(k) {
arg = arg.replace(k, v);
}
}
argv.push(arg);
}
}
}
argv.extend(spec.extra_args.iter().cloned());
if spec.kind == AgentKind::Antigravity {
argv.push("-p".to_owned());
}
if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
argv.push("-".to_owned());
}
match delivery {
Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
argv.push(pointer(spec.kind, prompt_path));
}
Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
Delivery::Argv => argv.push(inv.prompt.to_owned()),
Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
}
Ok(Plan { argv, stdin })
}
#[derive(Debug, Default)]
struct Extracted {
text: String,
session: Option<String>,
status: Option<String>,
quota: Option<Quota>,
dropped: Option<Dropped>,
}
fn extract(kind: AgentKind, stdout: &str) -> Extracted {
match kind {
AgentKind::Claude => {
let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
return Extracted {
text: stdout.trim().to_owned(),
..Extracted::default()
};
};
Extracted {
text: v
.get("result")
.and_then(|r| r.as_str())
.unwrap_or_default()
.to_owned(),
session: v
.get("session_id")
.and_then(|s| s.as_str())
.map(str::to_owned),
status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
if e {
"error".to_owned()
} else {
"success".to_owned()
}
}),
quota: claude_quota(&v),
dropped: None,
}
}
AgentKind::Opencode => {
let mut text = String::new();
let mut session = None;
for line in stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
if session.is_none() {
session = v
.get("sessionID")
.and_then(|s| s.as_str())
.map(str::to_owned);
}
let part = v.get("part").unwrap_or(&serde_json::Value::Null);
if part.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(t) = part.get("text").and_then(|t| t.as_str())
{
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
Extracted {
text,
session,
status: None,
quota: None,
dropped: None,
}
}
AgentKind::Antigravity => {
let obj = stdout
.lines()
.rev()
.find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
let Some(v) = obj else {
return Extracted {
text: stdout.trim().to_owned(),
..Extracted::default()
};
};
Extracted {
text: v
.get("response")
.and_then(|r| r.as_str())
.unwrap_or_default()
.trim()
.to_owned(),
session: v
.get("conversation_id")
.and_then(|s| s.as_str())
.map(str::to_owned),
status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
quota: None,
dropped: dropped_stream(&v),
}
}
AgentKind::Codex => {
let mut text = String::new();
let mut session = None;
let mut status = None;
for line in stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
match v.get("type").and_then(|t| t.as_str()) {
Some("thread.started") => {
session = v
.get("thread_id")
.and_then(|s| s.as_str())
.map(str::to_owned);
}
Some("item.completed") => {
let item = v.get("item").unwrap_or(&serde_json::Value::Null);
if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
&& let Some(t) = item.get("text").and_then(|t| t.as_str())
{
text = t.trim().to_owned();
}
}
Some("turn.completed") => status = Some("success".to_owned()),
Some("turn.failed") => status = Some("error".to_owned()),
_ => {}
}
}
Extracted {
text,
session,
status,
quota: None,
dropped: None,
}
}
AgentKind::Omp => {
let mut text = String::new();
let mut session = None;
for line in stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) == Some("session") {
session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
continue;
}
let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
{
Some("agent_end") => v
.get("messages")
.and_then(|m| m.as_array())
.map(|m| m.iter().collect())
.unwrap_or_default(),
Some("turn_end") | Some("message_end") => {
v.get("message").into_iter().collect()
}
_ => continue,
};
for message in messages {
if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
continue;
}
let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
continue;
};
for part in parts {
if part.get("type").and_then(|t| t.as_str()) != Some("text") {
continue;
}
if let Some(t) = part.get("text").and_then(|t| t.as_str())
&& !t.trim().is_empty()
{
text = t.trim().to_owned();
}
}
}
}
Extracted {
text,
session,
status: None,
quota: None,
dropped: None,
}
}
AgentKind::Command => {
let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
let quota = parsed.as_ref().and_then(claude_quota);
let dropped = parsed.as_ref().and_then(dropped_stream);
Extracted {
text: stdout.trim().to_owned(),
session: None,
status: None,
quota,
dropped,
}
}
}
}
fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
if !is_err {
return None;
}
let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
if !result.to_lowercase().contains("session limit") {
return None;
}
let reset = result
.split("resets ")
.nth(1)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
Some(Quota { reset })
}
fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
if !status.eq_ignore_ascii_case("error") {
return None;
}
let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
if !response.trim().is_empty() {
return None;
}
let produced = v
.get("usage")
.and_then(|u| u.get("output_tokens"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
if produced == 0 {
return None;
}
Some(Dropped {
why: v
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("the CLI ended the stream without delivering its answer")
.trim()
.to_owned(),
output_tokens: produced,
})
}
pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
let mut missing = Vec::new();
for s in specs {
let program = match s.kind {
AgentKind::Command => s.command.first().map(String::as_str),
other => other.program(),
};
if let Some(p) = program
&& !crate::config::which(p)
&& !Path::new(p).is_file()
&& !missing.iter().any(|m: &String| m == p)
{
missing.push(p.to_owned());
}
}
missing
}
pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
run_dir.join("artifacts")
}
pub fn installed(spec: &AgentSpec) -> bool {
spec.kind.program().is_none_or(crate::config::which)
}
pub fn pick(
agents: &[AgentSpec],
want: Option<&str>,
available: &dyn Fn(&AgentSpec) -> bool,
) -> Result<AgentSpec> {
if let Some(id) = want {
let spec = agents
.iter()
.find(|a| a.id == id)
.with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
if !available(spec) {
bail!(
"agent `{}` needs `{}` on PATH; install it or pass a different \
--agent",
spec.id,
spec.kind.program().unwrap_or("its command")
);
}
return Ok(spec.clone());
}
if agents.is_empty() {
bail!(
"the agent roster is empty, so there is nobody to ask: install one \
of claude, opencode or agy - magi derives a roster from what is on \
PATH - or add an [[agents]] entry to magi.toml."
);
}
if let Some(spec) = agents
.iter()
.find(|a| a.kind == AgentKind::Claude && available(a))
{
return Ok(spec.clone());
}
agents
.iter()
.find(|a| available(a))
.cloned()
.with_context(|| {
let missing = agents
.iter()
.filter_map(|a| a.kind.program())
.collect::<Vec<_>>()
.join(", ");
format!(
"no agent in the roster can be run here: install one of \
{missing}, or add an [[agents]] entry to magi.toml for a CLI \
you do have"
)
})
}
fn ids(agents: &[AgentSpec]) -> String {
if agents.is_empty() {
return "no agents at all".to_owned();
}
agents
.iter()
.map(|a| a.id.clone())
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
fn command_helper(mode: &str) -> AgentSpec {
AgentSpec {
id: "helper".to_owned(),
kind: AgentKind::Command,
model: None,
command: vec![
std::env::current_exe()
.expect("locate test helper")
.to_string_lossy()
.into_owned(),
"--exact".to_owned(),
"agent::tests::command_agent_test_helper".to_owned(),
"--nocapture".to_owned(),
],
extra_args: Vec::new(),
env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
prompt_delivery: None,
}
}
#[test]
fn command_agent_test_helper() {
match std::env::var(COMMAND_HELPER_MODE).as_deref() {
Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
Ok("ignore-stdin") => println!("done"),
Ok("chatty-sleep") => {
println!("i-said-something");
std::thread::sleep(Duration::from_secs(30));
}
Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
Ok(other) => panic!("unknown command helper mode {other}"),
Err(_) => {}
}
}
fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
AgentSpec {
id: "a".to_owned(),
kind,
model: model.map(str::to_owned),
command: vec!["echo".to_owned(), "{label}".to_owned()],
extra_args: Vec::new(),
env: BTreeMap::new(),
prompt_delivery: None,
}
}
fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
Invocation {
cwd,
prompt: "do the thing",
timeout: Duration::from_secs(900),
allow_write,
sessions: true,
artifacts: art,
stem: "t",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
}
}
fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
build_command(
&spec(kind, None),
seat,
&inv(Path::new("."), Path::new("/art"), allow_write),
Path::new("/art/p.md"),
)
.unwrap()
}
#[test]
fn claude_mints_then_resumes_the_same_uuid() {
let mut seat = SeatState::new("judge-1", "a", 7);
let uuid = seat.claude_session.clone().unwrap();
let first = plan_for(AgentKind::Claude, &seat, true);
assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
assert!(!first.argv.iter().any(|a| a == "--resume"));
seat.turns = 1;
let second = plan_for(AgentKind::Claude, &seat, true);
assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
assert!(!second.argv.iter().any(|a| a == "--session-id"));
}
#[test]
fn read_only_seats_cannot_edit() {
let seat = SeatState::new("judge-1", "a", 7);
let claude = plan_for(AgentKind::Claude, &seat, false);
assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
assert!(
!plan_for(AgentKind::Claude, &seat, true)
.argv
.iter()
.any(|a| a == "--disallowed-tools")
);
let agy = plan_for(AgentKind::Antigravity, &seat, false);
assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
assert!(
!agy.argv
.iter()
.any(|a| a == "--dangerously-skip-permissions")
);
let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
assert!(
agy_rw
.argv
.windows(2)
.any(|w| w == ["--mode", "accept-edits"])
);
assert!(
agy_rw
.argv
.iter()
.any(|a| a == "--dangerously-skip-permissions")
);
let agy_prompt = agy_rw
.argv
.iter()
.position(|a| a == "-p")
.map(|i| agy_rw.argv[i + 1].clone())
.expect("agy takes its prompt with -p");
assert!(
agy_prompt.starts_with('@'),
"agy must get a file reference, got {agy_prompt:?}"
);
assert!(
!agy_prompt.contains("Read the file at"),
"the prose pointer is for CLIs with no file syntax"
);
for allow_write in [false, true] {
assert!(
plan_for(AgentKind::Opencode, &seat, allow_write)
.argv
.iter()
.any(|a| a == "--auto"),
"opencode needs --auto even to read (allow_write = {allow_write})"
);
}
}
#[test]
fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
let mut seat = SeatState::new("judge-1", "a", 7);
let ro = plan_for(AgentKind::Codex, &seat, false);
assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
let rw = plan_for(AgentKind::Codex, &seat, true);
assert!(
rw.argv
.windows(2)
.any(|w| w == ["--sandbox", "workspace-write"])
);
for p in [&ro, &rw] {
assert!(
!p.argv
.iter()
.any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
"the bypass defeats the only enforced read-only mode we have"
);
assert!(
p.argv
.windows(2)
.any(|w| w == ["-c", "approval_policy=\"never\""]),
"an unattended seat that asks for approval blocks until timeout"
);
}
assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
assert_eq!(
ro.argv.last().map(String::as_str),
Some("-"),
"without the `-` argument codex waits for a prompt it never gets"
);
seat.turns = 1;
assert!(!has_session(AgentKind::Codex, &seat, true));
assert!(
!plan_for(AgentKind::Codex, &seat, true)
.argv
.iter()
.any(|a| a == "resume")
);
seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
let resumed = plan_for(AgentKind::Codex, &seat, true);
let at = resumed
.argv
.iter()
.position(|a| a == "resume")
.expect("resumes by subcommand");
assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
assert!(
resumed.argv[..at].iter().any(|a| a == "--sandbox"),
"every option precedes the subcommand"
);
assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
}
#[test]
fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
let mut seat = SeatState::new("review-1", "a", 7);
let first = plan_for(AgentKind::Omp, &seat, false);
assert!(first.argv.iter().any(|a| a == "-p"));
assert!(first.argv.iter().any(|a| a == "--mode=json"));
assert_eq!(first.stdin.as_deref(), Some("do the thing"));
assert!(
!first.argv.iter().any(|a| a == "do the thing"),
"the prompt reached argv, where Windows caps it"
);
for allow_write in [false, true] {
let p = plan_for(AgentKind::Omp, &seat, allow_write);
assert!(
p.argv.iter().any(|a| a == "--auto-approve"),
"omp needs --auto-approve even to read (allow_write = {allow_write})"
);
assert!(
!p.argv
.iter()
.any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
"nothing ever asks for the bypass"
);
}
seat.turns = 1;
assert!(!has_session(AgentKind::Omp, &seat, true));
assert!(
!plan_for(AgentKind::Omp, &seat, true)
.argv
.iter()
.any(|a| a == "--resume")
);
seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
let resumed = plan_for(AgentKind::Omp, &seat, true);
assert!(
resumed
.argv
.windows(2)
.any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
"a captured id is what makes the next turn a resume"
);
assert!(!resumed.argv.iter().any(|a| a == "--continue"));
assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
}
#[test]
fn omp_takes_the_answer_without_an_agent_end_line() {
let stream = concat!(
r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
"\n",
r#"{"type":"agent_start"}"#,
"\n",
r#"{"type":"turn_start"}"#,
"\n",
r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
"\n",
r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
"\n",
r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
"\n",
);
let out = extract(AgentKind::Omp, stream);
assert_eq!(
out.text, "{\"vote\":\"approve\"}",
"the last assistant text block is the answer even with no agent_end"
);
assert_eq!(
out.session.as_deref(),
Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
);
}
#[test]
fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
let stream = concat!(
r#"{"type":"session","version":3,"id":"s1"}"#,
"\n",
"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"review this\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Looking at the diff…\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"…\"},{\"type\":\"text\",\"text\":\"## 判定\\n\\n問題ありません。\"}]}]}",
"\n",
);
let out = extract(AgentKind::Omp, stream);
assert_eq!(
out.text, "## 判定\n\n問題ありません。",
"the narration is not the answer, and non-ASCII survives intact"
);
assert_eq!(out.session.as_deref(), Some("s1"));
}
#[test]
fn omp_skips_non_json_lines() {
let stream = concat!(
"Warning: some omp notice\n",
r#"{"type":"session","version":3,"id":"s2"}"#,
"\n",
r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
"\n",
"trailing junk",
"\n",
);
let out = extract(AgentKind::Omp, stream);
assert_eq!(out.text, "the answer");
assert_eq!(out.session.as_deref(), Some("s2"));
}
#[test]
fn codex_takes_the_last_agent_message_and_the_thread_id() {
let stream = concat!(
"2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
"\n",
r#"{"type":"turn.started"}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
"\n",
);
let out = extract(AgentKind::Codex, stream);
assert_eq!(
out.text, "{\"verdict\": \"ok\"}",
"the last agent message is the answer; earlier ones narrate"
);
assert_eq!(
out.session.as_deref(),
Some("01a07440-4545-7492-85c1-024e3259a90a")
);
assert_eq!(out.status.as_deref(), Some("success"));
let failed = concat!(
r#"{"type":"thread.started","thread_id":"t1"}"#,
"\n",
r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
"\n",
);
assert_eq!(
extract(AgentKind::Codex, failed).status.as_deref(),
Some("error")
);
}
#[test]
fn captured_sessions_resume_only_once_reported() {
let mut seat = SeatState::new("impl-A", "a", 7);
seat.turns = 1;
for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
assert!(!has_session(kind, &seat, true));
let p = plan_for(kind, &seat, true);
assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
}
seat.captured_session = Some("sid".to_owned());
assert!(has_session(AgentKind::Opencode, &seat, true));
assert!(
plan_for(AgentKind::Opencode, &seat, true)
.argv
.windows(2)
.any(|w| w == ["-s", "sid"])
);
assert!(
plan_for(AgentKind::Antigravity, &seat, true)
.argv
.windows(2)
.any(|w| w == ["--conversation", "sid"])
);
}
#[test]
fn sessions_disabled_never_resumes() {
let mut seat = SeatState::new("impl-A", "a", 7);
seat.turns = 3;
seat.captured_session = Some("sid".to_owned());
for kind in [
AgentKind::Claude,
AgentKind::Opencode,
AgentKind::Antigravity,
] {
assert!(!has_session(kind, &seat, false));
}
}
#[test]
fn long_prompts_never_reach_argv_for_file_delivery_clis() {
let seat = SeatState::new("judge-1", "a", 7);
for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
let p = plan_for(kind, &seat, false);
assert!(
p.argv.iter().all(|a| a != "do the thing"),
"{kind:?} put the prompt on the command line"
);
assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
}
let p = plan_for(AgentKind::Antigravity, &seat, false);
let at = p.argv.iter().position(|a| a == "-p").unwrap();
assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
assert!(p.stdin.is_none());
}
#[test]
fn agy_print_timeout_tracks_the_node_budget() {
let seat = SeatState::new("impl-A", "a", 7);
let p = build_command(
&spec(AgentKind::Antigravity, None),
&seat,
&Invocation {
cwd: Path::new("."),
prompt: "p",
timeout: Duration::from_secs(3600),
allow_write: true,
sessions: true,
artifacts: Path::new("/art"),
stem: "t",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
},
Path::new("/art/p.md"),
)
.unwrap();
assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
}
#[test]
fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
let mut s = spec(AgentKind::Antigravity, None);
s.prompt_delivery = Some(Delivery::Argv);
let seat = SeatState::new("talk", "a", 7);
let atts = [PathBuf::from("/art/attachments/abc.png")];
let without = build_command(
&s,
&seat,
&Invocation {
attachments: &[],
..inv(Path::new("."), Path::new("/art"), true)
},
Path::new("/art/p.md"),
)
.unwrap();
assert!(
!without.argv.iter().any(|a| a == "--add-dir"),
"no attachment, no reason to widen the sandbox: {without:?}"
);
let with = build_command(
&s,
&seat,
&Invocation {
attachments: &atts,
..inv(Path::new("."), Path::new("/art"), true)
},
Path::new("/art/p.md"),
)
.unwrap();
assert!(
with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
"an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
);
}
#[test]
fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
let seat = SeatState::new("plan", "a", 7);
let atts = [
PathBuf::from("/art/attachments/own.png"),
PathBuf::from("/other-chat/attachments/inherited.png"),
];
let p = build_command(
&spec(AgentKind::Antigravity, None),
&seat,
&Invocation {
attachments: &atts,
..inv(Path::new("."), Path::new("/art"), true)
},
Path::new("/art/p.md"),
)
.unwrap();
assert!(
p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
"this conversation's own artifacts dir must still be granted: {p:?}"
);
assert!(
p.argv
.windows(2)
.any(|w| w == ["--add-dir", "/other-chat/attachments"]),
"the inherited attachment's own directory must be granted too: {p:?}"
);
}
#[test]
fn command_agents_get_placeholders_substituted() {
let seat = SeatState::new("impl-A", "a", 7);
let p = plan_for(AgentKind::Command, &seat, true);
assert_eq!(p.argv[0], "echo");
assert_eq!(p.argv[1], "impl-A");
assert_eq!(p.stdin.as_deref(), Some("do the thing"));
}
#[test]
fn claude_rate_limit_is_detected_and_reset_read_when_present() {
let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
"result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
"session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
let out = extract(AgentKind::Claude, stdout);
let quota = out.quota.as_ref().expect("rate limit must be detected");
assert_eq!(
quota.reset.as_deref(),
Some("4:50am (Asia/Tokyo)"),
"reset time read from the body"
);
}
#[test]
fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
let out = extract(
AgentKind::Claude,
r#"{"is_error":true,"result":"session limit reached"}"#,
);
let quota = out.quota.expect("rate limit detected without a reset");
assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
}
#[test]
fn ordinary_failures_are_never_quota() {
let claude_fail = extract(
AgentKind::Claude,
r#"{"is_error":true,"result":"account does not exist"}"#,
);
assert!(claude_fail.quota.is_none());
let cmd_fail = extract(AgentKind::Command, "boom");
assert!(cmd_fail.quota.is_none());
let success = extract(
AgentKind::Command,
r#"{"is_error":false,"result":"session limit is fine"}"#,
);
assert!(success.quota.is_none());
}
#[test]
fn command_agent_can_carry_the_claude_quota_shape() {
let out = extract(
AgentKind::Command,
r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
);
assert!(
out.quota.is_some(),
"a wrapper emitting the claude shape counts as quota"
);
}
#[test]
fn claude_json_result_is_extracted() {
let out = extract(
AgentKind::Claude,
r#"{"result":"all done","session_id":"abc","is_error":false}"#,
);
assert_eq!(out.text, "all done");
assert_eq!(out.session.as_deref(), Some("abc"));
assert_eq!(out.status.as_deref(), Some("success"));
}
#[test]
fn opencode_event_stream_is_concatenated() {
let stream = concat!(
r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
"\n",
"garbage line\n",
r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
"\n"
);
let out = extract(AgentKind::Opencode, stream);
assert_eq!(out.text, "first\nsecond");
assert_eq!(out.session.as_deref(), Some("ses_1"));
}
#[test]
fn agy_json_survives_a_leading_warning_line() {
let stdout = concat!(
"warning: --mode plan has no effect while slash commands are disabled.\n",
r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
"\n"
);
let out = extract(AgentKind::Antigravity, stdout);
assert_eq!(out.text, "persimmon");
assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
assert_eq!(out.status.as_deref(), Some("SUCCESS"));
}
const AGY_DROPPED: &str = concat!(
r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
r#""response":"","error":"the connection to the agent was interrupted before "#,
r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
r#""total_tokens":274380}}"#
);
#[test]
fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
let out = extract(AgentKind::Antigravity, AGY_DROPPED);
let dropped = out.dropped.expect("recognised as undelivered work");
assert_eq!(dropped.output_tokens, 14267);
assert!(
dropped.why.contains("subscriber fell behind"),
"the CLI's own words are kept for the record: {}",
dropped.why
);
assert_eq!(
out.session.as_deref(),
Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
);
assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
}
#[test]
fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
let answered = concat!(
r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
r#""usage":{"output_tokens":10}}"#
);
assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
let ok = concat!(
r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
r#""usage":{"output_tokens":10}}"#
);
assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
}
#[test]
fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
let out = AgentOutput {
text: String::new(),
exit_code: Some(1),
timed_out: false,
duration_ms: 431_194,
artifacts: Vec::new(),
quota: None,
dropped: Some(Dropped {
why: "subscriber fell behind updates".to_owned(),
output_tokens: 14267,
}),
};
assert!(!out.usable());
assert!(out.work_undelivered());
assert!(!out.quota_exhausted());
}
#[test]
fn non_json_stdout_falls_back_to_raw_text() {
let out = extract(AgentKind::Antigravity, "plain answer\n");
assert_eq!(out.text, "plain answer");
assert!(out.session.is_none());
}
#[tokio::test]
async fn command_agent_round_trip_writes_artifacts() {
let dir = tempfile::tempdir().unwrap();
let art = dir.path().join("artifacts");
let mut seat = SeatState::new("impl-A", "a", 7);
let s = command_helper("reply");
let out = invoke(
&s,
&mut seat,
&Invocation {
cwd: dir.path(),
prompt: "unused",
timeout: Duration::from_secs(30),
allow_write: true,
sessions: true,
artifacts: &art,
stem: "impl-A",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
},
)
.await
.unwrap();
assert!(out.usable(), "{out:?}");
assert!(out.text.contains("hello impl-A"), "{}", out.text);
assert_eq!(seat.turns, 1);
assert!(art.join("impl-A.prompt.md").is_file());
assert!(art.join("impl-A.out").is_file());
}
#[tokio::test]
async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("magi-cache");
let mut seat = SeatState::new("impl-A", "a", 7);
let s = command_helper("cache");
let out = invoke(
&s,
&mut seat,
&Invocation {
cwd: dir.path(),
prompt: "unused",
timeout: Duration::from_secs(30),
allow_write: true,
sessions: true,
artifacts: &dir.path().join("artifacts"),
stem: "cache",
run: "test-run",
node: "test",
cache_dir: Some(&cache),
attachments: &[],
},
)
.await
.unwrap();
assert!(out.usable(), "{out:?}");
assert!(
out.text.contains(cache.to_string_lossy().as_ref()),
"the seat must see CARGO_TARGET_DIR = the shared cache"
);
}
#[tokio::test]
async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
let dir = tempfile::tempdir().unwrap();
let mut seat = SeatState::new("impl-A", "a", 7);
let s = command_helper("ignore-stdin");
let big = "x".repeat(1_000_000);
let out = invoke(
&s,
&mut seat,
&Invocation {
cwd: dir.path(),
prompt: &big,
timeout: Duration::from_secs(60),
allow_write: true,
sessions: true,
artifacts: &dir.path().join("artifacts"),
stem: "big",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
},
)
.await
.unwrap();
assert!(out.usable(), "{out:?}");
assert!(out.text.contains("done"), "{}", out.text);
}
#[tokio::test]
async fn timeout_is_reported_not_hung() {
let dir = tempfile::tempdir().unwrap();
let mut seat = SeatState::new("impl-A", "a", 7);
let s = command_helper("sleep");
let out = invoke(
&s,
&mut seat,
&Invocation {
cwd: dir.path(),
prompt: "unused",
timeout: Duration::from_millis(300),
allow_write: true,
sessions: true,
artifacts: &dir.path().join("artifacts"),
stem: "slow",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
},
)
.await
.unwrap();
assert!(out.timed_out);
assert!(!out.usable());
}
#[tokio::test]
async fn a_timeout_keeps_what_the_agent_had_already_printed() {
let dir = tempfile::tempdir().unwrap();
let artifacts = dir.path().join("artifacts");
let mut seat = SeatState::new("impl-A", "a", 7);
let s = command_helper("chatty-sleep");
let out = invoke(
&s,
&mut seat,
&Invocation {
cwd: dir.path(),
prompt: "unused",
timeout: Duration::from_secs(10),
allow_write: true,
sessions: true,
artifacts: &artifacts,
stem: "chatty",
run: "test-run",
node: "test",
cache_dir: None,
attachments: &[],
},
)
.await
.unwrap();
assert!(out.timed_out, "{out:?}");
assert!(!out.usable(), "a cut-off answer is still not an answer");
let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
assert!(
recorded.contains("i-said-something"),
"the artifact must keep what arrived before the kill, got {recorded:?}"
);
assert!(
out.text.contains("i-said-something"),
"and the graph must be able to see it too, got {:?}",
out.text
);
}
#[test]
fn missing_programs_reports_command_binaries() {
let mut s = spec(AgentKind::Command, None);
s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
assert_eq!(
missing_programs(&[s]),
["definitely-not-a-real-binary-xyz".to_owned()]
);
}
fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
AgentSpec {
id: id.to_owned(),
kind,
model: None,
command: Vec::new(),
extra_args: Vec::new(),
env: BTreeMap::new(),
prompt_delivery: None,
}
}
fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
move |a: &AgentSpec| !missing.contains(&a.id.as_str())
}
#[test]
fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
let agents = [
pick_spec("oc", AgentKind::Opencode),
pick_spec("opus", AgentKind::Claude),
pick_spec("agy", AgentKind::Antigravity),
];
let got = pick(&agents, None, &without(&[])).expect("a pick");
assert_eq!(got.id, "opus");
}
#[test]
fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
let agents = [
pick_spec("opus", AgentKind::Claude),
pick_spec("oc", AgentKind::Opencode),
pick_spec("agy", AgentKind::Antigravity),
];
let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
assert_eq!(got.id, "agy");
}
#[test]
fn pick_on_an_empty_roster_says_what_to_install() {
let msg = pick(&[], None, &without(&[]))
.expect_err("nobody to ask")
.to_string();
assert!(msg.contains("roster is empty"), "{msg}");
assert!(msg.contains("claude"), "{msg}");
assert!(msg.contains("magi.toml"), "{msg}");
}
#[test]
fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
let agents = [
pick_spec("opus", AgentKind::Claude),
pick_spec("oc", AgentKind::Opencode),
];
let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
let msg = format!("{err:#}");
assert!(msg.contains("claude"), "{msg}");
assert!(msg.contains("opencode"), "{msg}");
}
#[test]
fn an_explicitly_named_agent_wins_over_the_claude_preference() {
let agents = [
pick_spec("opus", AgentKind::Claude),
pick_spec("oc", AgentKind::Opencode),
];
let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
assert_eq!(got.id, "oc");
}
#[test]
fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
let agents = [
pick_spec("opus", AgentKind::Claude),
pick_spec("oc", AgentKind::Opencode),
];
let msg = pick(&agents, Some("gemini"), &without(&[]))
.expect_err("no such agent")
.to_string();
assert!(msg.contains("gemini"), "{msg}");
assert!(msg.contains("opus, oc"), "{msg}");
}
#[test]
fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
let agents = [
pick_spec("opus", AgentKind::Claude),
pick_spec("oc", AgentKind::Opencode),
];
let msg = pick(&agents, Some("oc"), &without(&["oc"]))
.expect_err("must not silently substitute another model")
.to_string();
assert!(msg.contains("opencode"), "{msg}");
assert!(msg.contains("--agent"), "{msg}");
}
}