1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentChoice, AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Invocation<'a> {
96 pub coordination: Option<&'a crate::config::Coordination>,
98 pub fetched_base: Option<&'a crate::run::FetchedBase>,
100 pub cwd: &'a Path,
103 pub prompt: &'a str,
105 pub timeout: Duration,
107 pub allow_write: bool,
109 pub unsandboxed: bool,
115 pub sessions: bool,
117 pub artifacts: &'a Path,
119 pub stem: &'a str,
121 pub run: &'a str,
125 pub node: &'a str,
129 pub cache_dir: Option<&'a Path>,
135 pub attachments: &'a [PathBuf],
142 pub writable: &'a [PathBuf],
149}
150
151fn repository_writer(inv: &Invocation<'_>) -> bool {
152 inv.allow_write
153 && (matches!(
154 inv.node,
155 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
156 ) || (inv.node == crate::queue::CHAT_NODE && !consulted_chat(inv)))
157}
158
159fn consulted_chat(inv: &Invocation<'_>) -> bool {
160 inv.node == crate::queue::CHAT_NODE && inv.allow_write && !inv.writable.is_empty()
163}
164
165fn repository_seat(inv: &Invocation<'_>) -> bool {
166 !matches!(
172 inv.node,
173 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
174 )
175}
176
177pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
184 let mut roots = Vec::new();
185 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
186 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
187 if !common.starts_with(&cwd) {
188 roots.push(common);
189 }
190 }
191 for name in ["questions", "notifications"] {
195 let dir = home.join(name);
196 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
197 roots.push(std::fs::canonicalize(dir)?);
198 }
199 Ok(roots)
200}
201
202fn editing_access_note(
204 kind: AgentKind,
205 unsandboxed: bool,
206 base: Option<&crate::run::FetchedBase>,
207) -> String {
208 let mut note = String::from(
209 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
210 );
211 if let Some(base) = base {
212 note.push_str(&format!(
213 "The launcher successfully fetched `{}/{}` at {} and pinned the run base to `{}`. Use this exact local commit as the verified fetched base; this is a snapshot, not a claim that the remote has not moved since. Do not fetch again as a prerequisite, move the base, or refresh it on resume. Base updates belong to the launcher's existing base-sync path.\n",
214 base.remote, base.branch, base.fetched_at, base.commit,
215 ));
216 } else {
217 note.push_str("No successful launcher fetch evidence is supplied for this invocation. Do not describe local tracking refs as freshly fetched. If fresh remote evidence is essential, report an infrastructure blocker requiring a launcher fetch and a new prepared turn.\n");
218 }
219 if kind != AgentKind::Codex || unsandboxed {
221 return note;
222 }
223 note.push_str("Network access is restricted by the seat sandbox. A shared Git directory grant does not guarantee access to protected Git administration paths. Use local status, diff and log to inspect the checkout. If add/commit is denied, leave the implementation files in this assigned worktree: the launcher saves eligible edits after the seat exits. Do not repeatedly retry denied fetches or ask the owner to approve unavailable sandbox permissions. If an essential operation (including rebase continuation or dependency download) is still impossible, stop with an actionable infrastructure report naming the operation, target and error; the operator must repair/deploy the launcher environment before a single new attempt. Do not use bypass flags, change permissions, edit another checkout, or claim a network failure proves host DNS is broken. Specification questions remain valid.\n");
224 note
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234pub struct Quota {
235 #[serde(default)]
237 pub reset: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
247pub struct Dropped {
248 pub why: String,
250 pub output_tokens: u64,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct CommandEvidence {
270 pub id: String,
272 pub description: String,
274 pub exit_code: Option<i32>,
276 pub result_summary: String,
278 pub source: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct AgentOutput {
285 pub text: String,
287 pub exit_code: Option<i32>,
289 pub timed_out: bool,
291 pub duration_ms: u64,
293 pub artifacts: Vec<String>,
295 #[serde(default)]
299 pub quota: Option<Quota>,
300 #[serde(default)]
304 pub dropped: Option<Dropped>,
305 #[serde(default)]
309 pub commands: Vec<CommandEvidence>,
310 #[serde(default)]
315 pub context_tokens: Option<u64>,
316}
317
318impl AgentOutput {
319 pub fn usable(&self) -> bool {
321 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
322 }
323
324 pub fn quota_exhausted(&self) -> bool {
326 self.quota.is_some()
327 }
328
329 pub fn work_undelivered(&self) -> bool {
334 self.dropped.is_some()
335 }
336}
337
338const PIPE_GRACE: Duration = Duration::from_secs(3);
343
344type Captured = Arc<Mutex<Vec<u8>>>;
346
347fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
357where
358 R: tokio::io::AsyncRead + Unpin + Send + 'static,
359{
360 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
361 let Some(mut pipe) = pipe else {
362 return (buf, None);
363 };
364 let sink = Arc::clone(&buf);
365 let handle = tokio::spawn(async move {
366 let mut chunk = [0u8; 8192];
367 loop {
368 match pipe.read(&mut chunk).await {
369 Ok(0) | Err(_) => break,
370 Ok(n) => {
371 if let Ok(mut guard) = sink.lock() {
372 guard.extend_from_slice(&chunk[..n]);
373 }
374 }
375 }
376 }
377 });
378 (buf, Some(handle))
379}
380
381async fn collect(
386 buf: &Captured,
387 handle: Option<tokio::task::JoinHandle<()>>,
388 grace: Duration,
389) -> String {
390 if let Some(handle) = handle {
391 if tokio::time::timeout(grace, handle).await.is_err() {
392 tracing::debug!("a pipe is still held open after the child exited");
393 }
394 }
395 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
396 String::from_utf8_lossy(&bytes).into_owned()
397}
398
399pub async fn invoke(
401 spec: &AgentSpec,
402 seat: &mut SeatState,
403 inv: &Invocation<'_>,
404) -> Result<AgentOutput> {
405 let lease = if repository_seat(inv) {
409 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
410 .await?
411 } else {
412 None
413 };
414 let prompt = if consulted_chat(inv) {
415 Some(format!(
416 "{}\n\nCurrent turn scope: answer the supplied consultation or authorized hold using the conversation and supplied question context and supported magi control-plane commands only. This overrides repository investigation permissions in the standing briefing or earlier turns. No repository lease is held: do not read repository files or history, fetch, run tests, or edit repository files. If repository investigation is needed, report that it must wait for a normal chat turn after the consultation is resolved.",
417 inv.prompt
418 ))
419 } else {
420 lease.as_ref().filter(|lease| lease.delegated).map(|lease| format!(
421 "Repository coordination: the launcher holds the exclusive repository lock at {} on behalf of this read-only seat for this entire invocation. Do not acquire a second lock or release/delete that file. Your filesystem access remains read-only.\n\n{}",
422 lease.path.display(), inv.prompt
423 ))
424 };
425 let home = repository_writer(inv).then(crate::run::home);
428 let mut writable = inv.writable.to_vec();
429 if let Some(home) = &home {
430 for root in worktree_writable_roots(inv.cwd, home).await? {
431 if !writable.contains(&root) {
432 writable.push(root);
433 }
434 }
435 }
436 let mut prompt = prompt.unwrap_or_else(|| inv.prompt.to_owned());
437 if repository_writer(inv) {
438 prompt.push_str("\n\n");
439 prompt.push_str(&editing_access_note(
440 spec.kind,
441 inv.unsandboxed,
442 inv.fetched_base,
443 ));
444 }
445 let inv = &Invocation {
446 writable: &writable,
447 prompt: &prompt,
448 ..inv.clone()
449 };
450 tokio::fs::create_dir_all(inv.artifacts)
451 .await
452 .with_context(|| format!("create {}", inv.artifacts.display()))?;
453 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
454 tokio::fs::write(&prompt_path, inv.prompt)
455 .await
456 .with_context(|| format!("write {}", prompt_path.display()))?;
457
458 let plan = build_command(spec, seat, inv, &prompt_path)?;
459 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
460 tokio::fs::write(
462 inv.artifacts.join(format!("{}.access.json", inv.stem)),
463 serde_json::to_vec_pretty(&serde_json::json!({
464 "launcher_version": env!("CARGO_PKG_VERSION"),
465 "kind": spec.kind,
466 "allow_write": inv.allow_write,
467 "unsandboxed": inv.unsandboxed,
468 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
469 Some(false)
470 } else { None },
471 "writable_roots": inv.writable,
472 "fetched_base": inv.fetched_base,
473 }))?,
474 )
475 .await
476 .context("write seat access evidence")?;
477
478 let started = Instant::now();
479 let child_path = spec
482 .env
483 .iter()
484 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
485 .map(|(_, v)| std::ffi::OsString::from(v))
486 .or_else(|| std::env::var_os("PATH"))
487 .unwrap_or_default();
488 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
489 || plan.argv[0].clone().into(),
490 std::path::PathBuf::into_os_string,
491 );
492 let mut cmd = Command::new(program);
493 cmd.args(&plan.argv[1..])
494 .current_dir(inv.cwd)
495 .envs(&spec.env)
496 .env("MAGI_SEAT", &seat.key)
497 .env("MAGI_TURN", seat.turns.to_string())
498 .env("MAGI_RUN", inv.run)
499 .env("MAGI_NODE", inv.node)
500 .env("MAGI_PROMPT_FILE", &prompt_path)
501 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
502 .env("GIT_TERMINAL_PROMPT", "0")
503 .stdin(if plan.stdin.is_some() {
504 Stdio::piped()
505 } else {
506 Stdio::null()
507 })
508 .stdout(Stdio::piped())
509 .stderr(Stdio::piped())
510 .kill_on_drop(true)
511 .quiet();
514 if let Some(home) = home {
515 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
518 }
519 if let Some(cache) = inv.cache_dir {
520 cmd.env("CARGO_TARGET_DIR", cache);
523 } else {
524 cmd.env_remove("CARGO_TARGET_DIR");
532 }
533
534 #[cfg(unix)]
535 if lease.is_some() {
536 cmd.process_group(0);
537 }
538 let child = cmd
539 .spawn()
540 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
541 let mut running = crate::seat_process::Running::new(child, lease);
543 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
547 tokio::spawn(async move {
548 sink.write_all(body.as_bytes()).await.ok();
549 sink.shutdown().await.ok();
550 });
551 }
552
553 let (out_buf, out_reader) = drain(running.child().stdout.take());
570 let (err_buf, err_reader) = drain(running.child().stderr.take());
571
572 let (code, timed_out) = match tokio::time::timeout(inv.timeout, running.child().wait()).await {
573 Ok(res) => {
574 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
575 (status.code(), false)
576 }
577 Err(_) => {
578 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
579 if !running.coordinated() {
581 running
582 .child()
583 .kill()
584 .await
585 .context("stop timed-out seat")?;
586 }
587 (None, true)
588 }
589 };
590 running.finish().await;
591
592 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
597 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
598
599 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
600 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
601 tokio::fs::write(&out_path, &stdout).await.ok();
602 tokio::fs::write(&err_path, &stderr).await.ok();
603
604 let mut extracted = extract(spec.kind, &stdout);
605 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
606 extracted.quota = agy_quota(&stdout, &stderr);
607 }
608 if let Some(quota) = &extracted.quota {
609 extracted.dropped = None;
612 tracing::warn!(
613 seat = %seat.key,
614 agent = %spec.id,
615 reset = ?quota.reset,
616 "agent is out of quota"
617 );
618 }
619 if let Some(session) = extracted.session {
620 match spec.kind {
621 AgentKind::Claude => seat.claude_session = Some(session),
622 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
623 seat.captured_session = Some(session);
624 }
625 AgentKind::Command => {}
626 }
627 }
628 if let Some(status) = &extracted.status
629 && !status.eq_ignore_ascii_case("success")
630 {
631 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
632 }
633 let text = if extracted.text.trim().is_empty() {
634 if stdout.trim().is_empty() {
636 stderr.trim().to_owned()
637 } else {
638 stdout.trim().to_owned()
639 }
640 } else {
641 extracted.text
642 };
643 seat.turns += 1;
644
645 Ok(AgentOutput {
646 text,
647 exit_code: code,
648 timed_out,
649 duration_ms: started.elapsed().as_millis() as u64,
650 artifacts: vec![
651 file_name(&prompt_path),
652 file_name(&out_path),
653 file_name(&err_path),
654 ],
655 quota: extracted.quota,
656 dropped: extracted.dropped,
657 commands: extracted.commands,
658 context_tokens: extracted.context_tokens,
659 })
660}
661
662fn file_name(p: &Path) -> String {
663 p.file_name()
664 .unwrap_or_default()
665 .to_string_lossy()
666 .into_owned()
667}
668
669#[derive(Debug)]
671struct Plan {
672 argv: Vec<String>,
673 stdin: Option<String>,
674}
675
676fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
687 if matches!(kind, AgentKind::Antigravity) {
688 return format!("@{}", prompt_path.display());
689 }
690 format!(
691 "Read the file at {} and follow every instruction in it exactly. That \
692 file is your complete task description; this message contains nothing \
693 else.",
694 prompt_path.display()
695 )
696}
697
698fn build_command(
699 spec: &AgentSpec,
700 seat: &SeatState,
701 inv: &Invocation<'_>,
702 prompt_path: &Path,
703) -> Result<Plan> {
704 let mut argv: Vec<String> = Vec::new();
705 let mut stdin: Option<String> = None;
706 let delivery = spec.delivery();
707 let resuming = has_session(spec.kind, seat, inv.sessions);
708
709 match spec.kind {
710 AgentKind::Claude => {
711 argv.push("claude".to_owned());
716 argv.push("-p".to_owned());
717 argv.push("--output-format".to_owned());
718 argv.push("json".to_owned());
719 if let Some(m) = &spec.model {
720 argv.push("--model".to_owned());
721 argv.push(m.clone());
722 }
723 if inv.sessions {
724 let uuid = seat
725 .claude_session
726 .as_deref()
727 .context("claude seat is missing its session uuid")?;
728 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
729 argv.push(uuid.to_owned());
730 }
731 argv.push("--permission-mode".to_owned());
732 argv.push("bypassPermissions".to_owned());
733 if !inv.allow_write {
734 argv.push("--disallowed-tools".to_owned());
735 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
736 }
737 }
738 AgentKind::Opencode => {
739 argv.push("opencode".to_owned());
743 argv.push("run".to_owned());
744 argv.push("--format".to_owned());
745 argv.push("json".to_owned());
746 argv.push("--dir".to_owned());
747 argv.push(inv.cwd.to_string_lossy().into_owned());
748 argv.push("--auto".to_owned());
757 if let Some(m) = &spec.model {
758 argv.push("-m".to_owned());
759 argv.push(m.clone());
760 }
761 if resuming {
762 argv.push("-s".to_owned());
763 argv.push(
764 seat.captured_session
765 .clone()
766 .expect("has_session checked the id is present"),
767 );
768 }
769 }
770 AgentKind::Antigravity => {
771 argv.push("agy".to_owned());
772 argv.push("--output-format".to_owned());
773 argv.push("json".to_owned());
774 argv.push("--print-timeout".to_owned());
777 argv.push(format!("{}s", inv.timeout.as_secs()));
778 argv.push("--mode".to_owned());
779 argv.push(
780 if inv.allow_write {
781 "accept-edits"
782 } else {
783 "plan"
784 }
785 .to_owned(),
786 );
787 if inv.allow_write {
788 argv.push("--dangerously-skip-permissions".to_owned());
789 }
790 if let Some(m) = &spec.model {
791 argv.push("--model".to_owned());
792 argv.push(m.clone());
793 }
794 if resuming {
795 argv.push("--conversation".to_owned());
796 argv.push(
797 seat.captured_session
798 .clone()
799 .expect("has_session checked the id is present"),
800 );
801 }
802 let mut add_dirs: Vec<String> = Vec::new();
812 if delivery == Delivery::File || !inv.attachments.is_empty() {
813 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
814 }
815 for path in inv.attachments {
816 let Some(parent) = path.parent() else {
817 continue;
818 };
819 if parent.starts_with(inv.artifacts) {
820 continue;
821 }
822 let dir = parent.to_string_lossy().into_owned();
823 if !add_dirs.contains(&dir) {
824 add_dirs.push(dir);
825 }
826 }
827 if inv.allow_write {
828 for path in inv.writable {
829 let dir = path.to_string_lossy().into_owned();
830 if !add_dirs.contains(&dir) {
831 add_dirs.push(dir);
832 }
833 }
834 }
835 for dir in add_dirs {
836 argv.push("--add-dir".to_owned());
837 argv.push(dir);
838 }
839 }
840 AgentKind::Codex => {
841 argv.push("codex".to_owned());
847 argv.push("exec".to_owned());
848 argv.push("--json".to_owned());
849 argv.push("--skip-git-repo-check".to_owned());
852 argv.push("-C".to_owned());
853 argv.push(inv.cwd.to_string_lossy().into_owned());
854 if inv.allow_write && inv.unsandboxed {
863 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
864 } else {
865 argv.push("--sandbox".to_owned());
866 argv.push(
867 if inv.allow_write {
868 "workspace-write"
869 } else {
870 "read-only"
871 }
872 .to_owned(),
873 );
874 argv.push("-c".to_owned());
877 argv.push("approval_policy=\"never\"".to_owned());
878 if repository_writer(inv) {
879 argv.push("-c".to_owned());
880 argv.push("sandbox_workspace_write.network_access=false".to_owned());
881 }
882 if inv.allow_write && !inv.writable.is_empty() {
883 let roots: Vec<String> = inv
884 .writable
885 .iter()
886 .map(|p| p.to_string_lossy().into_owned())
887 .collect();
888 argv.push("-c".to_owned());
889 argv.push(format!(
890 "sandbox_workspace_write.writable_roots={}",
891 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
892 ));
893 }
894 }
895 if let Some(m) = &spec.model {
896 argv.push("-m".to_owned());
897 argv.push(m.clone());
898 }
899 if resuming {
905 argv.push("resume".to_owned());
906 argv.push(
907 seat.captured_session
908 .clone()
909 .expect("has_session checked the id is present"),
910 );
911 }
912 }
913 AgentKind::Omp => {
914 argv.push("omp".to_owned());
918 argv.push("-p".to_owned());
919 argv.push("--mode=json".to_owned());
920 argv.push("--auto-approve".to_owned());
930 if let Some(m) = &spec.model {
931 argv.push("--model".to_owned());
932 argv.push(m.clone());
933 }
934 if resuming {
940 argv.push("--resume".to_owned());
941 argv.push(
942 seat.captured_session
943 .clone()
944 .expect("has_session checked the id is present"),
945 );
946 }
947 }
948 AgentKind::Command => {
949 if spec.command.is_empty() {
954 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
955 }
956 let vars: BTreeMap<&str, String> = BTreeMap::from([
957 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
958 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
959 ("{label}", seat.key.clone()),
960 ("{session}", seat.claude_session.clone().unwrap_or_default()),
961 ]);
962 for raw in &spec.command {
963 let mut arg = raw.clone();
964 for (k, v) in &vars {
965 if arg.contains(k) {
966 arg = arg.replace(k, v);
967 }
968 }
969 argv.push(arg);
970 }
971 }
972 }
973
974 argv.extend(spec.extra_args.iter().cloned());
975
976 if spec.kind == AgentKind::Antigravity {
979 argv.push("-p".to_owned());
980 }
981 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
984 argv.push("-".to_owned());
985 }
986 match delivery {
987 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
988 argv.push(pointer(spec.kind, prompt_path));
990 }
991 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
992 Delivery::Argv => argv.push(inv.prompt.to_owned()),
993 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
994 }
995
996 Ok(Plan { argv, stdin })
997}
998
999#[derive(Debug, Default)]
1001struct Extracted {
1002 text: String,
1003 session: Option<String>,
1004 status: Option<String>,
1005 quota: Option<Quota>,
1006 dropped: Option<Dropped>,
1007 commands: Vec<CommandEvidence>,
1008 context_tokens: Option<u64>,
1009}
1010
1011fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1013 let mut extracted = extract_answer(kind, stdout);
1014 extracted.context_tokens = context_tokens(kind, stdout);
1015 extracted
1016}
1017
1018fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1020 v.get(key).and_then(serde_json::Value::as_u64)
1021}
1022
1023fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1028 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1029 let base = first(input)?;
1030 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1031 Some(base.saturating_add(cached))
1032}
1033
1034fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1055 use serde_json::Value;
1056 let lines = || {
1057 stdout
1058 .lines()
1059 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1060 };
1061 match kind {
1062 AgentKind::Claude => None,
1063 AgentKind::Opencode => lines()
1064 .filter(|v| {
1065 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1067 || v.get("part")
1068 .and_then(|p| p.get("type"))
1069 .and_then(|t| t.as_str())
1070 == Some("step-finish")
1071 })
1072 .filter_map(|v| {
1073 let tokens = v.get("part")?.get("tokens")?;
1074 let base = uint(tokens, "input")?;
1075 let cache = tokens.get("cache");
1076 let cached = ["read", "write"]
1077 .iter()
1078 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1079 .sum::<u64>();
1080 Some(base.saturating_add(cached))
1081 })
1082 .next_back(),
1083 AgentKind::Antigravity => None,
1084 AgentKind::Codex => None,
1085 AgentKind::Omp => lines()
1086 .flat_map(|v| {
1087 match v.get("type").and_then(|t| t.as_str()) {
1090 Some("agent_end") => v
1091 .get("messages")
1092 .and_then(|m| m.as_array())
1093 .cloned()
1094 .unwrap_or_default(),
1095 Some("turn_end") | Some("message_end") => {
1096 v.get("message").cloned().into_iter().collect()
1097 }
1098 _ => Vec::new(),
1099 }
1100 })
1101 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1102 .filter_map(|m| {
1103 input_side(
1104 m.get("usage")?,
1105 &["input", "input_tokens"],
1106 &[
1107 "cacheRead",
1108 "cacheWrite",
1109 "cache_read_tokens",
1110 "cache_write_tokens",
1111 ],
1112 )
1113 })
1114 .next_back(),
1115 AgentKind::Command => None,
1116 }
1117}
1118
1119pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1122 extract_answer(kind, stdout).text
1123}
1124
1125fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1126 match kind {
1127 AgentKind::Claude => {
1128 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1129 return Extracted {
1130 text: stdout.trim().to_owned(),
1131 ..Extracted::default()
1132 };
1133 };
1134 Extracted {
1135 text: v
1136 .get("result")
1137 .and_then(|r| r.as_str())
1138 .unwrap_or_default()
1139 .to_owned(),
1140 session: v
1141 .get("session_id")
1142 .and_then(|s| s.as_str())
1143 .map(str::to_owned),
1144 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1145 if e {
1146 "error".to_owned()
1147 } else {
1148 "success".to_owned()
1149 }
1150 }),
1151 quota: claude_quota(&v),
1152 dropped: None,
1155 commands: Vec::new(),
1156 context_tokens: None,
1157 }
1158 }
1159 AgentKind::Opencode => {
1160 let mut text = String::new();
1162 let mut session = None;
1163 for line in stdout.lines() {
1164 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1165 continue;
1166 };
1167 if session.is_none() {
1168 session = v
1169 .get("sessionID")
1170 .and_then(|s| s.as_str())
1171 .map(str::to_owned);
1172 }
1173 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1174 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1175 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1176 {
1177 if !text.is_empty() {
1178 text.push('\n');
1179 }
1180 text.push_str(t);
1181 }
1182 }
1183 Extracted {
1184 text,
1185 session,
1186 status: None,
1187 quota: None,
1188 dropped: None,
1189 commands: Vec::new(),
1190 context_tokens: None,
1191 }
1192 }
1193 AgentKind::Antigravity => {
1194 let obj = stdout
1197 .lines()
1198 .rev()
1199 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1200 let Some(v) = obj else {
1201 return Extracted {
1202 text: stdout.trim().to_owned(),
1203 ..Extracted::default()
1204 };
1205 };
1206 Extracted {
1207 text: v
1208 .get("response")
1209 .and_then(|r| r.as_str())
1210 .unwrap_or_default()
1211 .trim()
1212 .to_owned(),
1213 session: v
1214 .get("conversation_id")
1215 .and_then(|s| s.as_str())
1216 .map(str::to_owned),
1217 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1218 quota: None,
1219 dropped: dropped_stream(&v),
1220 commands: Vec::new(),
1221 context_tokens: None,
1222 }
1223 }
1224 AgentKind::Codex => {
1225 let mut text = String::new();
1247 let mut session = None;
1248 let mut status = None;
1249 let mut commands = Vec::new();
1250 for line in stdout.lines() {
1251 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1252 continue;
1253 };
1254 match v.get("type").and_then(|t| t.as_str()) {
1255 Some("thread.started") => {
1256 session = v
1257 .get("thread_id")
1258 .and_then(|s| s.as_str())
1259 .map(str::to_owned);
1260 }
1261 Some("item.completed") => {
1262 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1263 match item.get("type").and_then(|t| t.as_str()) {
1264 Some("agent_message") => {
1265 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1266 text = t.trim().to_owned();
1267 }
1268 }
1269 Some("command_execution") => {
1270 commands.push(command_evidence(item));
1271 }
1272 _ => {}
1273 }
1274 }
1275 Some("turn.completed") => status = Some("success".to_owned()),
1276 Some("turn.failed") => status = Some("error".to_owned()),
1277 _ => {}
1278 }
1279 }
1280 Extracted {
1281 text,
1282 session,
1283 status,
1284 quota: None,
1285 dropped: None,
1286 commands,
1287 context_tokens: None,
1288 }
1289 }
1290 AgentKind::Omp => {
1291 let mut text = String::new();
1312 let mut session = None;
1313 for line in stdout.lines() {
1314 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1315 continue;
1316 };
1317 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1318 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1319 continue;
1320 }
1321 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1325 {
1326 Some("agent_end") => v
1327 .get("messages")
1328 .and_then(|m| m.as_array())
1329 .map(|m| m.iter().collect())
1330 .unwrap_or_default(),
1331 Some("turn_end") | Some("message_end") => {
1332 v.get("message").into_iter().collect()
1333 }
1334 _ => continue,
1335 };
1336 for message in messages {
1337 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1338 continue;
1339 }
1340 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1341 continue;
1342 };
1343 for part in parts {
1344 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1345 continue;
1346 }
1347 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1348 && !t.trim().is_empty()
1349 {
1350 text = t.trim().to_owned();
1351 }
1352 }
1353 }
1354 }
1355 Extracted {
1356 text,
1357 session,
1358 status: None,
1359 quota: None,
1360 dropped: None,
1361 commands: Vec::new(),
1362 context_tokens: None,
1363 }
1364 }
1365 AgentKind::Command => {
1366 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1371 let quota = parsed.as_ref().and_then(claude_quota);
1372 let dropped = parsed.as_ref().and_then(dropped_stream);
1375 Extracted {
1376 text: stdout.trim().to_owned(),
1377 session: None,
1378 status: None,
1379 quota,
1380 dropped,
1381 commands: Vec::new(),
1382 context_tokens: None,
1383 }
1384 }
1385 }
1386}
1387
1388fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1395 let description = match item.get("command") {
1396 Some(serde_json::Value::String(s)) => s.clone(),
1397 Some(serde_json::Value::Array(parts)) => parts
1398 .iter()
1399 .filter_map(|p| p.as_str())
1400 .collect::<Vec<_>>()
1401 .join(" "),
1402 _ => String::new(),
1403 };
1404 let result_summary = item
1405 .get("aggregated_output")
1406 .and_then(|o| o.as_str())
1407 .map(|s| tail_chars(s.trim(), 400))
1408 .unwrap_or_default();
1409 CommandEvidence {
1410 id: item
1411 .get("id")
1412 .and_then(|s| s.as_str())
1413 .unwrap_or_default()
1414 .to_owned(),
1415 description,
1416 exit_code: item
1417 .get("exit_code")
1418 .and_then(serde_json::Value::as_i64)
1419 .map(|e| e as i32),
1420 result_summary,
1421 source: "codex".to_owned(),
1422 }
1423}
1424
1425fn tail_chars(s: &str, max: usize) -> String {
1427 let count = s.chars().count();
1428 if count <= max {
1429 return s.to_owned();
1430 }
1431 s.chars().skip(count - max).collect()
1432}
1433
1434fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1441 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1442 if !is_err {
1443 return None;
1444 }
1445 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1446 let status = v.get("api_error_status");
1447 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1448 || status.and_then(serde_json::Value::as_str) == Some("429"))
1449 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1450 if !usage_limit && !result.to_lowercase().contains("session limit") {
1451 return None;
1452 }
1453 let reset = result
1456 .split("resets ")
1457 .nth(1)
1458 .map(str::trim)
1459 .filter(|s| !s.is_empty())
1460 .map(str::to_owned);
1461 Some(Quota { reset })
1462}
1463
1464fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1479 let from_stdout = stdout
1480 .lines()
1481 .rev()
1482 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1483 .and_then(|v| {
1484 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1485 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1486 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1487 .then(|| error.to_owned())
1488 });
1489 let from_stderr = || {
1490 stderr.lines().find_map(|l| {
1491 let v: serde_json::Value =
1492 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1493 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1494 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1495 (exhausted && code).then(|| {
1496 v.get("short_error")
1497 .and_then(|e| e.as_str())
1498 .unwrap_or_default()
1499 .to_owned()
1500 })
1501 })
1502 };
1503 let text = from_stdout.or_else(from_stderr)?;
1504 let reset = text
1505 .split_once("Resets ")
1506 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1507 .filter(|s| !s.is_empty())
1508 .map(str::to_owned);
1509 Some(Quota { reset })
1510}
1511
1512fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1543 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1544 if !status.eq_ignore_ascii_case("error") {
1545 return None;
1546 }
1547 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1548 if !response.trim().is_empty() {
1549 return None;
1551 }
1552 let produced = v
1553 .get("usage")
1554 .and_then(|u| u.get("output_tokens"))
1555 .and_then(serde_json::Value::as_u64)
1556 .unwrap_or(0);
1557 if produced == 0 {
1558 return None;
1560 }
1561 Some(Dropped {
1562 why: v
1563 .get("error")
1564 .and_then(|e| e.as_str())
1565 .unwrap_or("the CLI ended the stream without delivering its answer")
1566 .trim()
1567 .to_owned(),
1568 output_tokens: produced,
1569 })
1570}
1571
1572pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1574 let mut missing = Vec::new();
1575 for s in specs {
1576 let program = match s.kind {
1577 AgentKind::Command => s.command.first().map(String::as_str),
1578 other => other.program(),
1579 };
1580 if let Some(p) = program
1581 && !crate::config::which(p)
1582 && !Path::new(p).is_file()
1583 && !missing.iter().any(|m: &String| m == p)
1584 {
1585 missing.push(p.to_owned());
1586 }
1587 }
1588 missing
1589}
1590
1591pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1593 run_dir.join("artifacts")
1594}
1595
1596pub fn installed(spec: &AgentSpec) -> bool {
1598 spec.kind.program().is_none_or(crate::config::which)
1601}
1602
1603pub fn pick(
1625 agents: &[AgentSpec],
1626 want: Option<&str>,
1627 available: &dyn Fn(&AgentSpec) -> bool,
1628) -> Result<AgentSpec> {
1629 if let Some(id) = want {
1630 let spec = agents
1631 .iter()
1632 .find(|a| a.id == id)
1633 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1634 if !available(spec) {
1635 bail!(
1636 "agent `{}` needs `{}` on PATH; install it or pass a different \
1637 --agent",
1638 spec.id,
1639 spec.kind.program().unwrap_or("its command")
1640 );
1641 }
1642 return Ok(spec.clone());
1643 }
1644
1645 if agents.is_empty() {
1646 bail!(
1647 "the agent roster is empty, so there is nobody to ask: install one \
1648 of claude, opencode or agy - magi derives a roster from what is on \
1649 PATH - or add an [[agents]] entry to magi.toml."
1650 );
1651 }
1652
1653 if let Some(spec) = agents
1654 .iter()
1655 .find(|a| a.kind == AgentKind::Claude && available(a))
1656 {
1657 return Ok(spec.clone());
1658 }
1659
1660 agents
1661 .iter()
1662 .find(|a| available(a))
1663 .cloned()
1664 .with_context(|| {
1665 let missing = agents
1666 .iter()
1667 .filter_map(|a| a.kind.program())
1668 .collect::<Vec<_>>()
1669 .join(", ");
1670 format!(
1671 "no agent in the roster can be run here: install one of \
1672 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1673 you do have"
1674 )
1675 })
1676}
1677
1678pub fn pick_chain(
1686 agents: &[AgentSpec],
1687 choice: Option<&AgentChoice>,
1688 available: &dyn Fn(&AgentSpec) -> bool,
1689 role: &str,
1690) -> Result<Vec<AgentSpec>> {
1691 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1692 if wanted.is_empty() {
1693 return pick(agents, None, available)
1694 .map(|s| vec![s])
1695 .with_context(|| format!("choose an agent for the {role} role"));
1696 }
1697 let mut chain: Vec<AgentSpec> = Vec::new();
1698 for id in wanted {
1699 if chain.iter().any(|s| s.id == id) {
1700 continue;
1701 }
1702 match pick(agents, Some(id), available) {
1703 Ok(spec) => chain.push(spec),
1704 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1705 }
1706 }
1707 if chain.is_empty() {
1708 bail!(
1709 "[roles] {role} names no agent that can run here; the roster has {}",
1710 ids(agents)
1711 );
1712 }
1713 Ok(chain)
1714}
1715
1716pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1722 match outcome {
1723 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1724 Err(_) => true,
1725 Ok(out) => output_advances(out),
1726 }
1727}
1728
1729pub fn output_advances(out: &AgentOutput) -> bool {
1733 out.quota_exhausted() || !out.usable()
1734}
1735
1736fn ids(agents: &[AgentSpec]) -> String {
1737 if agents.is_empty() {
1738 return "no agents at all".to_owned();
1739 }
1740 agents
1741 .iter()
1742 .map(|a| a.id.clone())
1743 .collect::<Vec<_>>()
1744 .join(", ")
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750
1751 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1752
1753 fn command_helper(mode: &str) -> AgentSpec {
1756 AgentSpec {
1757 id: "helper".to_owned(),
1758 kind: AgentKind::Command,
1759 model: None,
1760 command: vec![
1761 std::env::current_exe()
1762 .expect("locate test helper")
1763 .to_string_lossy()
1764 .into_owned(),
1765 "--exact".to_owned(),
1766 "agent::tests::command_agent_test_helper".to_owned(),
1767 "--nocapture".to_owned(),
1768 ],
1769 extra_args: Vec::new(),
1770 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1771 prompt_delivery: None,
1772 }
1773 }
1774
1775 #[test]
1776 fn command_agent_test_helper() {
1777 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1778 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1779 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1780 Ok("no-cache") => println!(
1781 "{}",
1782 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1783 ),
1784 Ok("ignore-stdin") => println!("done"),
1785 Ok("chatty-sleep") => {
1786 println!("i-said-something");
1787 std::thread::sleep(Duration::from_secs(30));
1788 }
1789 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1790 Ok(other) => panic!("unknown command helper mode {other}"),
1791 Err(_) => {}
1792 }
1793 }
1794
1795 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1796 AgentSpec {
1797 id: "a".to_owned(),
1798 kind,
1799 model: model.map(str::to_owned),
1800 command: vec!["echo".to_owned(), "{label}".to_owned()],
1801 extra_args: Vec::new(),
1802 env: BTreeMap::new(),
1803 prompt_delivery: None,
1804 }
1805 }
1806
1807 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1808 Invocation {
1809 coordination: None,
1810 fetched_base: None,
1811 cwd,
1812 prompt: "do the thing",
1813 timeout: Duration::from_secs(900),
1814 allow_write,
1815 unsandboxed: false,
1816 sessions: true,
1817 artifacts: art,
1818 stem: "t",
1819 run: "test-run",
1820 node: "test",
1821 cache_dir: None,
1822 attachments: &[],
1823 writable: &[],
1824 }
1825 }
1826
1827 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1828 build_command(
1829 &spec(kind, None),
1830 seat,
1831 &inv(Path::new("."), Path::new("/art"), allow_write),
1832 Path::new("/art/p.md"),
1833 )
1834 .unwrap()
1835 }
1836
1837 #[test]
1838 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1839 let missing = editing_access_note(AgentKind::Codex, false, None);
1840 assert!(missing.contains("No successful launcher fetch evidence"));
1841 let base = crate::run::FetchedBase {
1842 remote: "origin".into(),
1843 branch: "main".into(),
1844 commit: "0123456789abcdef".into(),
1845 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1846 };
1847 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
1848 for expected in [
1849 "origin/main",
1850 "0123456789abcdef",
1851 "2026-10-18T00:00:00Z",
1852 "Do not fetch again",
1853 "launcher saves eligible edits",
1854 "Specification questions remain valid",
1855 ] {
1856 assert!(note.contains(expected), "missing {expected}");
1857 }
1858 for kind in [
1859 AgentKind::Codex,
1860 AgentKind::Claude,
1861 AgentKind::Opencode,
1862 AgentKind::Antigravity,
1863 AgentKind::Omp,
1864 AgentKind::Command,
1865 ] {
1866 for unsandboxed in [false, true] {
1867 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
1868 assert!(scoped.contains(&base.commit));
1869 assert!(scoped.contains("Do not fetch again"));
1870 let restricted = kind == AgentKind::Codex && !unsandboxed;
1871 for claim in [
1872 "Network access is restricted",
1873 "protected Git administration paths",
1874 "launcher saves eligible edits",
1875 "unavailable sandbox permissions",
1876 ] {
1877 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
1878 assert_eq!(
1879 editing_access_note(kind, unsandboxed, None).contains(claim),
1880 restricted,
1881 "{kind:?}/{unsandboxed} without fetch evidence",
1882 );
1883 }
1884 }
1885 }
1886 let roundtrip: crate::run::FetchedBase =
1887 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
1888 assert_eq!(
1889 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
1890 note
1891 );
1892 }
1893
1894 #[test]
1895 fn editing_network_policy_is_explicit_on_initial_and_resume() {
1896 for resumed in [false, true] {
1897 let mut seat = SeatState::new("impl-A", "a", 7);
1898 if resumed {
1899 seat.turns = 1;
1900 seat.captured_session = Some("fixture-thread".into());
1901 }
1902 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
1903 invocation.node = "implement";
1904 let plan = build_command(
1905 &spec(AgentKind::Codex, None),
1906 &seat,
1907 &invocation,
1908 Path::new("/art/p.md"),
1909 )
1910 .unwrap();
1911 let policy = plan
1912 .argv
1913 .iter()
1914 .position(|s| s == "sandbox_workspace_write.network_access=false")
1915 .unwrap();
1916 if resumed {
1917 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
1918 }
1919 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
1920 invocation.node = "deputy";
1921 let deputy = build_command(
1922 &spec(AgentKind::Codex, None),
1923 &seat,
1924 &invocation,
1925 Path::new("/art/p.md"),
1926 )
1927 .unwrap();
1928 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
1929 assert!(
1930 !plan_for(AgentKind::Codex, &seat, false)
1931 .argv
1932 .iter()
1933 .any(|s| s.contains("writable_roots"))
1934 );
1935 }
1936 }
1937
1938 #[test]
1939 fn claude_mints_then_resumes_the_same_uuid() {
1940 let mut seat = SeatState::new("judge-1", "a", 7);
1941 let uuid = seat.claude_session.clone().unwrap();
1942 let first = plan_for(AgentKind::Claude, &seat, true);
1943 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1944 assert!(!first.argv.iter().any(|a| a == "--resume"));
1945
1946 seat.turns = 1;
1947 let second = plan_for(AgentKind::Claude, &seat, true);
1948 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1949 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1950 }
1951
1952 #[test]
1953 fn read_only_seats_cannot_edit() {
1954 let seat = SeatState::new("judge-1", "a", 7);
1955 let claude = plan_for(AgentKind::Claude, &seat, false);
1956 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1957 assert!(
1958 !plan_for(AgentKind::Claude, &seat, true)
1959 .argv
1960 .iter()
1961 .any(|a| a == "--disallowed-tools")
1962 );
1963
1964 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1965 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1966 assert!(
1967 !agy.argv
1968 .iter()
1969 .any(|a| a == "--dangerously-skip-permissions")
1970 );
1971 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1972 assert!(
1973 agy_rw
1974 .argv
1975 .windows(2)
1976 .any(|w| w == ["--mode", "accept-edits"])
1977 );
1978 assert!(
1979 agy_rw
1980 .argv
1981 .iter()
1982 .any(|a| a == "--dangerously-skip-permissions")
1983 );
1984 let agy_prompt = agy_rw
1989 .argv
1990 .iter()
1991 .position(|a| a == "-p")
1992 .map(|i| agy_rw.argv[i + 1].clone())
1993 .expect("agy takes its prompt with -p");
1994 assert!(
1995 agy_prompt.starts_with('@'),
1996 "agy must get a file reference, got {agy_prompt:?}"
1997 );
1998 assert!(
1999 !agy_prompt.contains("Read the file at"),
2000 "the prose pointer is for CLIs with no file syntax"
2001 );
2002
2003 for allow_write in [false, true] {
2008 assert!(
2009 plan_for(AgentKind::Opencode, &seat, allow_write)
2010 .argv
2011 .iter()
2012 .any(|a| a == "--auto"),
2013 "opencode needs --auto even to read (allow_write = {allow_write})"
2014 );
2015 }
2016 }
2017
2018 #[test]
2021 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2022 let seat = SeatState::new("deputy-x", "a", 7);
2023 let roots = [PathBuf::from("/data/questions")];
2024 let mk = |allow_write: bool| {
2025 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2026 i.writable = &roots;
2027 build_command(
2028 &spec(AgentKind::Codex, None),
2029 &seat,
2030 &i,
2031 Path::new("/art/p.md"),
2032 )
2033 .unwrap()
2034 };
2035 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2036 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2037 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2038 }
2039
2040 #[test]
2041 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2042 let mut i = inv(Path::new("."), Path::new("/art"), true);
2043 for node in [
2044 "implement",
2045 "fix",
2046 "graft",
2047 "gate-fix",
2048 "land",
2049 "rebase",
2050 "chat",
2051 ] {
2052 i.node = node;
2053 assert!(repository_writer(&i), "{node}");
2054 i.allow_write = false;
2055 assert!(!repository_writer(&i), "{node}");
2056 i.allow_write = true;
2057 }
2058 for node in ["deputy", "judge", "review", "bump", "conduct"] {
2059 i.node = node;
2060 assert!(!repository_writer(&i), "{node}");
2061 }
2062 i.node = "chat";
2063 let roots = [PathBuf::from("/questions")];
2064 i.writable = &roots;
2065 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2066 }
2067
2068 #[test]
2069 fn coordination_classification_is_independent_of_write_grants() {
2070 let roots = [PathBuf::from("/questions")];
2071 for node in [
2072 crate::queue::CHAT_NODE,
2073 "implement",
2074 "fix",
2075 "graft",
2076 "gate-fix",
2077 "land",
2078 "rebase",
2079 "review",
2080 "judge",
2081 "advise",
2082 "deputy",
2083 "waiter",
2084 "conduct",
2085 "dupes",
2086 ] {
2087 for allow_write in [false, true] {
2088 for writable in [&[][..], &roots[..]] {
2089 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2090 i.node = node;
2091 i.writable = writable;
2092 assert_eq!(
2093 repository_seat(&i),
2094 !matches!(
2095 node,
2096 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
2097 ),
2098 "{node}, allow_write={allow_write}, writable={writable:?}"
2099 );
2100 if node == crate::queue::CHAT_NODE {
2101 assert_eq!(repository_writer(&i), allow_write && writable.is_empty());
2102 }
2103 }
2104 }
2105 }
2106 }
2107
2108 #[test]
2109 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2110 let roots = [
2111 PathBuf::from("/shared/git"),
2112 PathBuf::from("/state/questions"),
2113 ];
2114 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2115 for resumed in [false, true] {
2116 let mut seat = SeatState::new("impl-A", "a", 7);
2117 if resumed {
2118 seat.turns = 1;
2119 seat.captured_session = Some("session-id".into());
2120 }
2121 for allow_write in [false, true] {
2122 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2123 i.writable = &roots;
2124 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2125 .unwrap();
2126 for root in &roots {
2127 if kind == AgentKind::Antigravity {
2128 assert_eq!(
2129 plan.argv.windows(2).any(|w| {
2130 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2131 }),
2132 allow_write,
2133 "resumed={resumed}: {:?}",
2134 plan.argv
2135 );
2136 }
2137 assert_eq!(
2138 plan.argv
2139 .iter()
2140 .any(|arg| arg.contains(root.to_str().unwrap())),
2141 allow_write,
2142 "{kind:?}, resumed={resumed}: {:?}",
2143 plan.argv
2144 );
2145 }
2146 if kind == AgentKind::Codex && resumed {
2147 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2148 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2149 }
2150 }
2151 }
2152 }
2153 }
2154
2155 #[test]
2156 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2157 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2158 let mut seat = SeatState::new("talk", "a", 7);
2159 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2160 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2161 i.unsandboxed = unsandboxed;
2162 build_command(
2163 &spec(AgentKind::Codex, None),
2164 seat,
2165 &i,
2166 Path::new("/art/p.md"),
2167 )
2168 .unwrap()
2169 };
2170
2171 let first = build(&seat, true, true);
2173 assert!(first.argv.iter().any(|a| a == BYPASS));
2174 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2175 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2176
2177 seat.captured_session = Some("thread-1".to_owned());
2179 seat.turns = 1;
2180 let resumed = build(&seat, true, true);
2181 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2182 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2183
2184 let ro = build(&seat, false, true);
2186 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2187 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2188 }
2189
2190 #[test]
2191 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2192 let mut seat = SeatState::new("judge-1", "a", 7);
2193
2194 let ro = plan_for(AgentKind::Codex, &seat, false);
2198 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2199 let rw = plan_for(AgentKind::Codex, &seat, true);
2200 assert!(
2201 rw.argv
2202 .windows(2)
2203 .any(|w| w == ["--sandbox", "workspace-write"])
2204 );
2205 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2208 ro_flagged.unsandboxed = true;
2209 let ro_flagged = build_command(
2210 &spec(AgentKind::Codex, None),
2211 &seat,
2212 &ro_flagged,
2213 Path::new("/art/p.md"),
2214 )
2215 .unwrap();
2216 assert!(
2217 ro_flagged
2218 .argv
2219 .windows(2)
2220 .any(|w| w == ["--sandbox", "read-only"])
2221 );
2222 for p in [&ro, &rw, &ro_flagged] {
2223 assert!(
2224 !p.argv
2225 .iter()
2226 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2227 "the bypass defeats the only enforced read-only mode we have"
2228 );
2229 assert!(
2231 p.argv
2232 .windows(2)
2233 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2234 "an unattended seat that asks for approval blocks until timeout"
2235 );
2236 }
2237
2238 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2240 assert_eq!(
2241 ro.argv.last().map(String::as_str),
2242 Some("-"),
2243 "without the `-` argument codex waits for a prompt it never gets"
2244 );
2245
2246 seat.turns = 1;
2250 assert!(!has_session(AgentKind::Codex, &seat, true));
2251 assert!(
2252 !plan_for(AgentKind::Codex, &seat, true)
2253 .argv
2254 .iter()
2255 .any(|a| a == "resume")
2256 );
2257 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2258 let resumed = plan_for(AgentKind::Codex, &seat, true);
2259 let at = resumed
2260 .argv
2261 .iter()
2262 .position(|a| a == "resume")
2263 .expect("resumes by subcommand");
2264 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2265 assert!(
2266 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2267 "every option precedes the subcommand"
2268 );
2269 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2270 }
2271
2272 #[test]
2275 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2276 let mut seat = SeatState::new("review-1", "a", 7);
2277
2278 let first = plan_for(AgentKind::Omp, &seat, false);
2282 assert!(first.argv.iter().any(|a| a == "-p"));
2283 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2284 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2285 assert!(
2286 !first.argv.iter().any(|a| a == "do the thing"),
2287 "the prompt reached argv, where Windows caps it"
2288 );
2289
2290 for allow_write in [false, true] {
2296 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2297 assert!(
2298 p.argv.iter().any(|a| a == "--auto-approve"),
2299 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2300 );
2301 assert!(
2302 !p.argv
2303 .iter()
2304 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2305 "nothing ever asks for the bypass"
2306 );
2307 }
2308
2309 seat.turns = 1;
2312 assert!(!has_session(AgentKind::Omp, &seat, true));
2313 assert!(
2314 !plan_for(AgentKind::Omp, &seat, true)
2315 .argv
2316 .iter()
2317 .any(|a| a == "--resume")
2318 );
2319 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2320 let resumed = plan_for(AgentKind::Omp, &seat, true);
2321 assert!(
2322 resumed
2323 .argv
2324 .windows(2)
2325 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2326 "a captured id is what makes the next turn a resume"
2327 );
2328 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2331 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2333 }
2334
2335 #[test]
2340 fn omp_takes_the_answer_without_an_agent_end_line() {
2341 let stream = concat!(
2342 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2343 "\n",
2344 r#"{"type":"agent_start"}"#,
2345 "\n",
2346 r#"{"type":"turn_start"}"#,
2347 "\n",
2348 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2349 "\n",
2350 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2351 "\n",
2352 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2353 "\n",
2354 );
2355 let out = extract(AgentKind::Omp, stream);
2356 assert_eq!(
2357 out.text, "{\"vote\":\"approve\"}",
2358 "the last assistant text block is the answer even with no agent_end"
2359 );
2360 assert_eq!(
2361 out.session.as_deref(),
2362 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2363 );
2364 }
2365
2366 #[test]
2370 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2371 let stream = concat!(
2372 r#"{"type":"session","version":3,"id":"s1"}"#,
2373 "\n",
2374 "{\"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問題ありません。\"}]}]}",
2375 "\n",
2376 );
2377 let out = extract(AgentKind::Omp, stream);
2378 assert_eq!(
2379 out.text, "## 判定\n\n問題ありません。",
2380 "the narration is not the answer, and non-ASCII survives intact"
2381 );
2382 assert_eq!(out.session.as_deref(), Some("s1"));
2383 }
2384
2385 #[test]
2388 fn omp_skips_non_json_lines() {
2389 let stream = concat!(
2390 "Warning: some omp notice\n",
2391 r#"{"type":"session","version":3,"id":"s2"}"#,
2392 "\n",
2393 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2394 "\n",
2395 "trailing junk",
2396 "\n",
2397 );
2398 let out = extract(AgentKind::Omp, stream);
2399 assert_eq!(out.text, "the answer");
2400 assert_eq!(out.session.as_deref(), Some("s2"));
2401 }
2402
2403 #[test]
2405 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2406 let stream = concat!(
2407 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2408 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2409 "\n",
2410 r#"{"type":"turn.started"}"#,
2411 "\n",
2412 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2413 "\n",
2414 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2415 "\n",
2416 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2417 "\n",
2418 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2419 "\n",
2420 );
2421 let out = extract(AgentKind::Codex, stream);
2422 assert_eq!(
2423 out.text, "{\"verdict\": \"ok\"}",
2424 "the last agent message is the answer; earlier ones narrate"
2425 );
2426 assert_eq!(
2427 out.session.as_deref(),
2428 Some("01a07440-4545-7492-85c1-024e3259a90a")
2429 );
2430 assert_eq!(out.status.as_deref(), Some("success"));
2431
2432 let failed = concat!(
2433 r#"{"type":"thread.started","thread_id":"t1"}"#,
2434 "\n",
2435 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2436 "\n",
2437 );
2438 assert_eq!(
2439 extract(AgentKind::Codex, failed).status.as_deref(),
2440 Some("error")
2441 );
2442 }
2443
2444 #[test]
2445 fn captured_sessions_resume_only_once_reported() {
2446 let mut seat = SeatState::new("impl-A", "a", 7);
2447 seat.turns = 1;
2448 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2449 assert!(!has_session(kind, &seat, true));
2450 let p = plan_for(kind, &seat, true);
2451 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2452 }
2453
2454 seat.captured_session = Some("sid".to_owned());
2455 assert!(has_session(AgentKind::Opencode, &seat, true));
2456 assert!(
2457 plan_for(AgentKind::Opencode, &seat, true)
2458 .argv
2459 .windows(2)
2460 .any(|w| w == ["-s", "sid"])
2461 );
2462 assert!(
2463 plan_for(AgentKind::Antigravity, &seat, true)
2464 .argv
2465 .windows(2)
2466 .any(|w| w == ["--conversation", "sid"])
2467 );
2468 }
2469
2470 #[test]
2471 fn sessions_disabled_never_resumes() {
2472 let mut seat = SeatState::new("impl-A", "a", 7);
2473 seat.turns = 3;
2474 seat.captured_session = Some("sid".to_owned());
2475 for kind in [
2476 AgentKind::Claude,
2477 AgentKind::Opencode,
2478 AgentKind::Antigravity,
2479 ] {
2480 assert!(!has_session(kind, &seat, false));
2481 }
2482 }
2483
2484 #[test]
2485 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2486 let seat = SeatState::new("judge-1", "a", 7);
2487 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2488 let p = plan_for(kind, &seat, false);
2489 assert!(
2490 p.argv.iter().all(|a| a != "do the thing"),
2491 "{kind:?} put the prompt on the command line"
2492 );
2493 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2494 }
2495 let p = plan_for(AgentKind::Antigravity, &seat, false);
2497 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2498 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2499 assert!(p.stdin.is_none());
2500 }
2501
2502 #[test]
2503 fn agy_print_timeout_tracks_the_node_budget() {
2504 let seat = SeatState::new("impl-A", "a", 7);
2505 let p = build_command(
2506 &spec(AgentKind::Antigravity, None),
2507 &seat,
2508 &Invocation {
2509 coordination: None,
2510 fetched_base: None,
2511 cwd: Path::new("."),
2512 prompt: "p",
2513 timeout: Duration::from_secs(3600),
2514 allow_write: true,
2515 unsandboxed: false,
2516 sessions: true,
2517 artifacts: Path::new("/art"),
2518 stem: "t",
2519 run: "test-run",
2520 node: "test",
2521 cache_dir: None,
2522 attachments: &[],
2523 writable: &[],
2524 },
2525 Path::new("/art/p.md"),
2526 )
2527 .unwrap();
2528 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2529 }
2530
2531 #[test]
2538 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2539 let mut s = spec(AgentKind::Antigravity, None);
2540 s.prompt_delivery = Some(Delivery::Argv);
2541 let seat = SeatState::new("talk", "a", 7);
2542 let atts = [PathBuf::from("/art/attachments/abc.png")];
2543
2544 let without = build_command(
2545 &s,
2546 &seat,
2547 &Invocation {
2548 attachments: &[],
2549 writable: &[],
2550 ..inv(Path::new("."), Path::new("/art"), true)
2551 },
2552 Path::new("/art/p.md"),
2553 )
2554 .unwrap();
2555 assert!(
2556 !without.argv.iter().any(|a| a == "--add-dir"),
2557 "no attachment, no reason to widen the sandbox: {without:?}"
2558 );
2559
2560 let with = build_command(
2561 &s,
2562 &seat,
2563 &Invocation {
2564 attachments: &atts,
2565 writable: &[],
2566 ..inv(Path::new("."), Path::new("/art"), true)
2567 },
2568 Path::new("/art/p.md"),
2569 )
2570 .unwrap();
2571 assert!(
2572 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2573 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2574 );
2575 }
2576
2577 #[test]
2583 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2584 let seat = SeatState::new("plan", "a", 7);
2585 let atts = [
2586 PathBuf::from("/art/attachments/own.png"),
2587 PathBuf::from("/other-chat/attachments/inherited.png"),
2588 ];
2589
2590 let p = build_command(
2591 &spec(AgentKind::Antigravity, None),
2592 &seat,
2593 &Invocation {
2594 attachments: &atts,
2595 writable: &[],
2596 ..inv(Path::new("."), Path::new("/art"), true)
2597 },
2598 Path::new("/art/p.md"),
2599 )
2600 .unwrap();
2601
2602 assert!(
2603 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2604 "this conversation's own artifacts dir must still be granted: {p:?}"
2605 );
2606 assert!(
2607 p.argv
2608 .windows(2)
2609 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2610 "the inherited attachment's own directory must be granted too: {p:?}"
2611 );
2612 }
2613
2614 #[test]
2615 fn command_agents_get_placeholders_substituted() {
2616 let seat = SeatState::new("impl-A", "a", 7);
2617 let p = plan_for(AgentKind::Command, &seat, true);
2618 assert_eq!(p.argv[0], "echo");
2619 assert_eq!(p.argv[1], "impl-A");
2620 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2621 }
2622
2623 #[test]
2624 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2625 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2627 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2628 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2629 let out = extract(AgentKind::Claude, stdout);
2630 let quota = out.quota.as_ref().expect("rate limit must be detected");
2631 assert_eq!(
2632 quota.reset.as_deref(),
2633 Some("4:50am (Asia/Tokyo)"),
2634 "reset time read from the body"
2635 );
2636 }
2637
2638 #[test]
2639 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2640 let out = extract(
2641 AgentKind::Claude,
2642 r#"{"is_error":true,"result":"session limit reached"}"#,
2643 );
2644 let quota = out.quota.expect("rate limit detected without a reset");
2645 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2646 }
2647
2648 #[test]
2649 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2650 for kind in [AgentKind::Claude, AgentKind::Command] {
2651 for reply in [
2652 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2653 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2654 ] {
2655 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2656 }
2657 for reply in [
2658 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2659 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2660 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2661 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2662 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2663 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2664 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2665 ] {
2666 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2667 }
2668 }
2669 }
2670
2671 #[test]
2672 fn ordinary_failures_are_never_quota() {
2673 let claude_fail = extract(
2675 AgentKind::Claude,
2676 r#"{"is_error":true,"result":"account does not exist"}"#,
2677 );
2678 assert!(claude_fail.quota.is_none());
2679
2680 let cmd_fail = extract(AgentKind::Command, "boom");
2682 assert!(cmd_fail.quota.is_none());
2683
2684 let success = extract(
2686 AgentKind::Command,
2687 r#"{"is_error":false,"result":"session limit is fine"}"#,
2688 );
2689 assert!(success.quota.is_none());
2690 }
2691
2692 #[test]
2700 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2701 let stream = concat!(
2702 r#"{"type":"thread.started","thread_id":"t1"}"#,
2703 "\n",
2704 r#"{"type":"item.completed","item":{"id":"item49","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate"],"exit_code":1,"aggregated_output":"test result: 1 passed; 1 failed"}}"#,
2705 "\n",
2706 r#"{"type":"item.completed","item":{"id":"item52","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate a_single_test"],"exit_code":0,"aggregated_output":"test result: 1 passed; 0 failed"}}"#,
2707 "\n",
2708 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2709 "\n",
2710 r#"{"type":"turn.completed"}"#,
2711 "\n",
2712 );
2713 let out = extract(AgentKind::Codex, stream);
2714 assert_eq!(out.text, "Both tests in the target pass.");
2715 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2716
2717 let paired = &out.commands[0];
2718 assert_eq!(paired.id, "item49");
2719 assert_eq!(paired.exit_code, Some(1));
2720 assert!(paired.description.contains("graph_cached_gate"));
2721 assert!(paired.result_summary.contains("1 failed"));
2722
2723 let solo = &out.commands[1];
2724 assert_eq!(solo.exit_code, Some(0));
2725
2726 assert!(
2730 out.commands
2731 .iter()
2732 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2733 "a failed run of the actual target must still be visible: {:?}",
2734 out.commands
2735 );
2736 }
2737
2738 #[test]
2739 fn command_agent_can_carry_the_claude_quota_shape() {
2740 let out = extract(
2741 AgentKind::Command,
2742 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2743 );
2744 assert!(
2745 out.quota.is_some(),
2746 "a wrapper emitting the claude shape counts as quota"
2747 );
2748 }
2749
2750 #[test]
2751 fn claude_json_result_is_extracted() {
2752 let out = extract(
2753 AgentKind::Claude,
2754 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2755 );
2756 assert_eq!(out.text, "all done");
2757 assert_eq!(out.session.as_deref(), Some("abc"));
2758 assert_eq!(out.status.as_deref(), Some("success"));
2759 }
2760
2761 #[test]
2776 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2777 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","stop_reason":"end_turn","result":"I'll pause here until the `cargo make check` background run reports back.","session_id":"11111111-1111-1111-1111-111111111111"}"#;
2778 let out = extract(AgentKind::Claude, stdout);
2779 assert_eq!(out.status.as_deref(), Some("success"));
2780 assert!(out.quota.is_none());
2781 assert!(!out.text.trim().is_empty());
2782
2783 let agent_out = AgentOutput {
2784 text: out.text.clone(),
2785 exit_code: Some(0),
2786 timed_out: false,
2787 duration_ms: 500,
2788 artifacts: Vec::new(),
2789 quota: out.quota,
2790 dropped: out.dropped,
2791 commands: out.commands,
2792 context_tokens: None,
2793 };
2794 assert!(
2795 agent_out.usable(),
2796 "the CLI turn itself ended cleanly and must read as usable"
2797 );
2798 assert!(
2799 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2800 "a clean CLI turn is not proof the node's own report ever arrived"
2801 );
2802 }
2803
2804 #[test]
2805 fn opencode_event_stream_is_concatenated() {
2806 let stream = concat!(
2807 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2808 "\n",
2809 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2810 "\n",
2811 "garbage line\n",
2812 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2813 "\n"
2814 );
2815 let out = extract(AgentKind::Opencode, stream);
2816 assert_eq!(out.text, "first\nsecond");
2817 assert_eq!(out.session.as_deref(), Some("ses_1"));
2818 }
2819
2820 #[test]
2821 fn agy_json_survives_a_leading_warning_line() {
2822 let stdout = concat!(
2823 "warning: --mode plan has no effect while slash commands are disabled.\n",
2824 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2825 "\n"
2826 );
2827 let out = extract(AgentKind::Antigravity, stdout);
2828 assert_eq!(out.text, "persimmon");
2829 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2830 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2831 }
2832
2833 const AGY_DROPPED: &str = concat!(
2840 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2841 r#""response":"","error":"the connection to the agent was interrupted before "#,
2842 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2843 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2844 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2845 r#""total_tokens":274380}}"#
2846 );
2847
2848 const AGY_QUOTA_OUT: &str = concat!(
2850 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2851 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2852 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2853 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2854 );
2855 const AGY_QUOTA_ERR: &str = concat!(
2856 "error: Individual quota reached. Resets in 1h2m49s.\n",
2857 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2858 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2859 r#""retryable":true}"#,
2860 "\n"
2861 );
2862
2863 #[test]
2864 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2865 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2866 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2867 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2868 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2869 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2871 assert!(stderr_only.reset.is_none());
2872 }
2873
2874 #[test]
2875 fn ordinary_agy_failures_are_not_a_quota() {
2876 assert!(agy_quota(AGY_DROPPED, "").is_none());
2877 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2878 assert!(
2879 agy_quota(
2880 "",
2881 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2882 )
2883 .is_none()
2884 );
2885 assert!(
2886 agy_quota(
2887 "",
2888 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2889 )
2890 .is_none()
2891 );
2892 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2893 }
2894
2895 #[test]
2896 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2897 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2898 let dropped = out.dropped.expect("recognised as undelivered work");
2899 assert_eq!(dropped.output_tokens, 14267);
2900 assert!(
2901 dropped.why.contains("subscriber fell behind"),
2902 "the CLI's own words are kept for the record: {}",
2903 dropped.why
2904 );
2905 assert_eq!(
2908 out.session.as_deref(),
2909 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2910 );
2911 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2912 }
2913
2914 #[test]
2915 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2916 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2920 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2921
2922 let answered = concat!(
2925 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2926 r#""usage":{"output_tokens":10}}"#
2927 );
2928 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2929
2930 let ok = concat!(
2932 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2933 r#""usage":{"output_tokens":10}}"#
2934 );
2935 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2936 }
2937
2938 #[test]
2939 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2940 let out = AgentOutput {
2941 text: String::new(),
2942 exit_code: Some(1),
2943 timed_out: false,
2944 duration_ms: 431_194,
2945 artifacts: Vec::new(),
2946 quota: None,
2947 dropped: Some(Dropped {
2948 why: "subscriber fell behind updates".to_owned(),
2949 output_tokens: 14267,
2950 }),
2951 commands: Vec::new(),
2952 context_tokens: None,
2953 };
2954 assert!(!out.usable());
2955 assert!(out.work_undelivered());
2956 assert!(!out.quota_exhausted());
2959 }
2960
2961 #[test]
2962 fn non_json_stdout_falls_back_to_raw_text() {
2963 let out = extract(AgentKind::Antigravity, "plain answer\n");
2964 assert_eq!(out.text, "plain answer");
2965 assert!(out.session.is_none());
2966 }
2967
2968 #[tokio::test]
2969 async fn command_agent_round_trip_writes_artifacts() {
2970 let dir = tempfile::tempdir().unwrap();
2971 let art = dir.path().join("artifacts");
2972 let mut seat = SeatState::new("impl-A", "a", 7);
2973 let s = command_helper("reply");
2974 let out = invoke(
2975 &s,
2976 &mut seat,
2977 &Invocation {
2978 coordination: None,
2979 fetched_base: None,
2980 cwd: dir.path(),
2981 prompt: "unused",
2982 timeout: Duration::from_secs(30),
2983 allow_write: true,
2984 unsandboxed: false,
2985 sessions: true,
2986 artifacts: &art,
2987 stem: "impl-A",
2988 run: "test-run",
2989 node: "test",
2990 cache_dir: None,
2991 attachments: &[],
2992 writable: &[],
2993 },
2994 )
2995 .await
2996 .unwrap();
2997 assert!(out.usable(), "{out:?}");
2998 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2999 assert_eq!(seat.turns, 1);
3000 assert!(art.join("impl-A.prompt.md").is_file());
3001 assert!(art.join("impl-A.out").is_file());
3002 }
3003
3004 #[tokio::test]
3005 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3006 let dir = tempfile::tempdir().unwrap();
3010 let cache = dir.path().join("magi-cache");
3011 let mut seat = SeatState::new("impl-A", "a", 7);
3012 let s = command_helper("cache");
3013 let out = invoke(
3014 &s,
3015 &mut seat,
3016 &Invocation {
3017 coordination: None,
3018 fetched_base: None,
3019 cwd: dir.path(),
3020 prompt: "unused",
3021 timeout: Duration::from_secs(30),
3022 allow_write: true,
3023 unsandboxed: false,
3024 sessions: true,
3025 artifacts: &dir.path().join("artifacts"),
3026 stem: "cache",
3027 run: "test-run",
3028 node: "test",
3029 cache_dir: Some(&cache),
3030 attachments: &[],
3031 writable: &[],
3032 },
3033 )
3034 .await
3035 .unwrap();
3036 assert!(out.usable(), "{out:?}");
3037 assert!(
3038 out.text.contains(cache.to_string_lossy().as_ref()),
3039 "the seat must see CARGO_TARGET_DIR = the shared cache"
3040 );
3041 }
3042
3043 #[tokio::test]
3044 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3045 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3053 unsafe {
3058 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3059 }
3060 let dir = tempfile::tempdir().unwrap();
3061 let mut seat = SeatState::new("review-1", "a", 7);
3062 let s = command_helper("no-cache");
3063 let result = invoke(
3064 &s,
3065 &mut seat,
3066 &Invocation {
3067 coordination: None,
3068 fetched_base: None,
3069 cwd: dir.path(),
3070 prompt: "unused",
3071 timeout: Duration::from_secs(30),
3072 allow_write: false,
3073 unsandboxed: false,
3074 sessions: true,
3075 artifacts: &dir.path().join("artifacts"),
3076 stem: "no-cache",
3077 run: "test-run",
3078 node: "test",
3079 cache_dir: None,
3080 attachments: &[],
3081 writable: &[],
3082 },
3083 )
3084 .await;
3085 unsafe {
3090 match &previous {
3091 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3092 None => std::env::remove_var("CARGO_TARGET_DIR"),
3093 }
3094 }
3095 let out = result.unwrap();
3096 assert!(out.usable(), "{out:?}");
3097 assert!(
3098 out.text.contains("ABSENT"),
3099 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3100 out.text
3101 );
3102 }
3103
3104 #[tokio::test]
3105 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3106 let dir = tempfile::tempdir().unwrap();
3107 let mut seat = SeatState::new("impl-A", "a", 7);
3108 let s = command_helper("ignore-stdin");
3111 let big = "x".repeat(1_000_000);
3112 let out = invoke(
3113 &s,
3114 &mut seat,
3115 &Invocation {
3116 coordination: None,
3117 fetched_base: None,
3118 cwd: dir.path(),
3119 prompt: &big,
3120 timeout: Duration::from_secs(60),
3121 allow_write: true,
3122 unsandboxed: false,
3123 sessions: true,
3124 artifacts: &dir.path().join("artifacts"),
3125 stem: "big",
3126 run: "test-run",
3127 node: "test",
3128 cache_dir: None,
3129 attachments: &[],
3130 writable: &[],
3131 },
3132 )
3133 .await
3134 .unwrap();
3135 assert!(out.usable(), "{out:?}");
3136 assert!(out.text.contains("done"), "{}", out.text);
3137 }
3138
3139 #[tokio::test]
3140 async fn timeout_is_reported_not_hung() {
3141 let dir = tempfile::tempdir().unwrap();
3142 let mut seat = SeatState::new("impl-A", "a", 7);
3143 let s = command_helper("sleep");
3144 let out = invoke(
3145 &s,
3146 &mut seat,
3147 &Invocation {
3148 coordination: None,
3149 fetched_base: None,
3150 cwd: dir.path(),
3151 prompt: "unused",
3152 timeout: Duration::from_millis(300),
3153 allow_write: true,
3154 unsandboxed: false,
3155 sessions: true,
3156 artifacts: &dir.path().join("artifacts"),
3157 stem: "slow",
3158 run: "test-run",
3159 node: "test",
3160 cache_dir: None,
3161 attachments: &[],
3162 writable: &[],
3163 },
3164 )
3165 .await
3166 .unwrap();
3167 assert!(out.timed_out);
3168 assert!(!out.usable());
3169 }
3170
3171 #[tokio::test]
3172 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3173 let dir = tempfile::tempdir().unwrap();
3179 let artifacts = dir.path().join("artifacts");
3180 let mut seat = SeatState::new("impl-A", "a", 7);
3181 let s = command_helper("chatty-sleep");
3182 let out = invoke(
3183 &s,
3184 &mut seat,
3185 &Invocation {
3186 coordination: None,
3187 fetched_base: None,
3188 cwd: dir.path(),
3189 prompt: "unused",
3190 timeout: Duration::from_secs(10),
3195 allow_write: true,
3196 unsandboxed: false,
3197 sessions: true,
3198 artifacts: &artifacts,
3199 stem: "chatty",
3200 run: "test-run",
3201 node: "test",
3202 cache_dir: None,
3203 attachments: &[],
3204 writable: &[],
3205 },
3206 )
3207 .await
3208 .unwrap();
3209
3210 assert!(out.timed_out, "{out:?}");
3211 assert!(!out.usable(), "a cut-off answer is still not an answer");
3212 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3213 assert!(
3214 recorded.contains("i-said-something"),
3215 "the artifact must keep what arrived before the kill, got {recorded:?}"
3216 );
3217 assert!(
3218 out.text.contains("i-said-something"),
3219 "and the graph must be able to see it too, got {:?}",
3220 out.text
3221 );
3222 }
3223
3224 #[test]
3225 fn missing_programs_reports_command_binaries() {
3226 let mut s = spec(AgentKind::Command, None);
3227 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3228 assert_eq!(
3229 missing_programs(&[s]),
3230 ["definitely-not-a-real-binary-xyz".to_owned()]
3231 );
3232 }
3233
3234 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3235 AgentSpec {
3236 id: id.to_owned(),
3237 kind,
3238 model: None,
3239 command: Vec::new(),
3240 extra_args: Vec::new(),
3241 env: BTreeMap::new(),
3242 prompt_delivery: None,
3243 }
3244 }
3245
3246 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3249 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3250 }
3251
3252 #[test]
3253 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3254 let agents = [
3255 pick_spec("oc", AgentKind::Opencode),
3256 pick_spec("opus", AgentKind::Claude),
3257 pick_spec("agy", AgentKind::Antigravity),
3258 ];
3259 let got = pick(&agents, None, &without(&[])).expect("a pick");
3260 assert_eq!(got.id, "opus");
3261 }
3262
3263 #[test]
3264 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3265 let agents = [
3266 pick_spec("opus", AgentKind::Claude),
3267 pick_spec("oc", AgentKind::Opencode),
3268 pick_spec("agy", AgentKind::Antigravity),
3269 ];
3270 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3271 assert_eq!(got.id, "agy");
3272 }
3273
3274 #[test]
3275 fn pick_on_an_empty_roster_says_what_to_install() {
3276 let msg = pick(&[], None, &without(&[]))
3277 .expect_err("nobody to ask")
3278 .to_string();
3279 assert!(msg.contains("roster is empty"), "{msg}");
3280 assert!(msg.contains("claude"), "{msg}");
3281 assert!(msg.contains("magi.toml"), "{msg}");
3282 }
3283
3284 #[test]
3285 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3286 let agents = [
3287 pick_spec("opus", AgentKind::Claude),
3288 pick_spec("oc", AgentKind::Opencode),
3289 ];
3290 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3291 let msg = format!("{err:#}");
3292 assert!(msg.contains("claude"), "{msg}");
3293 assert!(msg.contains("opencode"), "{msg}");
3294 }
3295
3296 #[test]
3297 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3298 let agents = [
3299 pick_spec("opus", AgentKind::Claude),
3300 pick_spec("oc", AgentKind::Opencode),
3301 ];
3302 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3303 assert_eq!(got.id, "oc");
3304 }
3305
3306 #[test]
3307 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3308 let agents = [
3309 pick_spec("opus", AgentKind::Claude),
3310 pick_spec("oc", AgentKind::Opencode),
3311 ];
3312 let msg = pick(&agents, Some("gemini"), &without(&[]))
3313 .expect_err("no such agent")
3314 .to_string();
3315 assert!(msg.contains("gemini"), "{msg}");
3316 assert!(msg.contains("opus, oc"), "{msg}");
3317 }
3318
3319 #[test]
3320 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3321 let agents = [
3322 pick_spec("opus", AgentKind::Claude),
3323 pick_spec("oc", AgentKind::Opencode),
3324 ];
3325 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3326 .expect_err("must not silently substitute another model")
3327 .to_string();
3328 assert!(msg.contains("opencode"), "{msg}");
3329 assert!(msg.contains("--agent"), "{msg}");
3330 }
3331
3332 fn named(id: &str) -> AgentSpec {
3333 AgentSpec {
3334 id: id.to_owned(),
3335 ..spec(AgentKind::Command, None)
3336 }
3337 }
3338
3339 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3340 AgentOutput {
3341 text: text.to_owned(),
3342 exit_code: Some(exit),
3343 timed_out: false,
3344 duration_ms: 0,
3345 artifacts: Vec::new(),
3346 quota: quota.then_some(Quota { reset: None }),
3347 dropped: None,
3348 commands: Vec::new(),
3349 context_tokens: None,
3350 }
3351 }
3352
3353 #[test]
3354 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3355 let agents = [named("a"), named("b"), named("c")];
3356 let all = |_: &AgentSpec| true;
3357 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3358 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3359 assert_eq!(
3360 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3361 ["c", "a"]
3362 );
3363
3364 let one = AgentChoice::from("b");
3365 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3366 assert_eq!(got.len(), 1);
3367 assert_eq!(got[0].id, "b");
3368
3369 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3370 assert_eq!(got.len(), 1, "unset keeps pick's default");
3371 assert_eq!(got[0].id, "a");
3372 let empty = AgentChoice::Chain(Vec::new());
3373 assert_eq!(
3374 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3375 "a"
3376 );
3377 }
3378
3379 #[test]
3380 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3381 let agents = [named("a"), named("b")];
3382 let not_a = |s: &AgentSpec| s.id != "a";
3383 let chain = AgentChoice::Chain(
3384 ["a", "ghost", "b", "b"]
3385 .iter()
3386 .map(|s| (*s).to_owned())
3387 .collect(),
3388 );
3389 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3390 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3391
3392 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3393 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3394 assert_eq!(
3395 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3396 ["b", "a"]
3397 );
3398 }
3399
3400 #[test]
3401 fn a_chain_with_nothing_runnable_names_the_role() {
3402 let agents = [named("a")];
3403 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3404 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3405 .unwrap_err()
3406 .to_string();
3407 assert!(err.contains("conductor"), "{err}");
3408 }
3409
3410 #[test]
3411 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3412 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3413 assert!(chain_advances(&Ok(output("limit", 0, true))));
3414 assert!(chain_advances(&Ok(output("", 0, false))));
3415 assert!(chain_advances(&Ok(output("x", 1, false))));
3416 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3417 }
3418
3419 #[test]
3420 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3421 for out in [
3422 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3423 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3424 r#"{"result":"ok"}"#,
3425 "not json",
3426 ] {
3427 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3428 }
3429 }
3430
3431 #[test]
3432 fn opencode_context_tokens_take_the_last_step_finish() {
3433 let out = concat!(
3434 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3435 "\n",
3436 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3437 "\n",
3438 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3439 "\n"
3440 );
3441 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3442 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3443 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3444 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3445 }
3446
3447 #[test]
3448 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3449 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3450 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3451 }
3452
3453 #[test]
3454 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3455 let out = concat!(
3456 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3457 "\n"
3458 );
3459 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3460 }
3461
3462 #[test]
3463 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3464 let out = concat!(
3465 r#"{"type":"session","id":"s"}"#,
3466 "\n",
3467 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3468 "\n",
3469 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3470 "\n"
3471 );
3472 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3473 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3474 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3475 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3476 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3477 }
3478
3479 #[test]
3480 fn command_agents_report_no_context_tokens() {
3481 let out = r#"{"usage":{"input_tokens":5}}"#;
3482 assert_eq!(context_tokens(AgentKind::Command, out), None);
3483 }
3484}