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],
147}
148
149fn repository_writer(inv: &Invocation<'_>) -> bool {
150 inv.allow_write
151 && (matches!(
152 inv.node,
153 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
154 ) || inv.node == crate::queue::CHAT_NODE)
155}
156
157fn repository_seat(inv: &Invocation<'_>) -> bool {
158 !matches!(
164 inv.node,
165 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
166 )
167}
168
169pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
176 let mut roots = Vec::new();
177 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
178 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
179 if !common.starts_with(&cwd) {
180 roots.push(common);
181 }
182 }
183 for name in ["questions", "notifications"] {
187 let dir = home.join(name);
188 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
189 roots.push(std::fs::canonicalize(dir)?);
190 }
191 Ok(roots)
192}
193
194fn editing_access_note(
196 kind: AgentKind,
197 unsandboxed: bool,
198 base: Option<&crate::run::FetchedBase>,
199) -> String {
200 let mut note = String::from(
201 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
202 );
203 if let Some(base) = base {
204 note.push_str(&format!(
205 "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",
206 base.remote, base.branch, base.fetched_at, base.commit,
207 ));
208 } else {
209 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");
210 }
211 if kind != AgentKind::Codex || unsandboxed {
213 return note;
214 }
215 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");
216 note
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226pub struct Quota {
227 #[serde(default)]
229 pub reset: Option<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239pub struct Dropped {
240 pub why: String,
242 pub output_tokens: u64,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CommandEvidence {
262 pub id: String,
264 pub description: String,
266 pub exit_code: Option<i32>,
268 pub result_summary: String,
270 pub source: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct AgentOutput {
277 pub text: String,
279 pub exit_code: Option<i32>,
281 pub timed_out: bool,
283 pub duration_ms: u64,
285 pub artifacts: Vec<String>,
287 #[serde(default)]
291 pub quota: Option<Quota>,
292 #[serde(default)]
296 pub dropped: Option<Dropped>,
297 #[serde(default)]
301 pub commands: Vec<CommandEvidence>,
302 #[serde(default)]
307 pub context_tokens: Option<u64>,
308}
309
310impl AgentOutput {
311 pub fn usable(&self) -> bool {
313 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
314 }
315
316 pub fn quota_exhausted(&self) -> bool {
318 self.quota.is_some()
319 }
320
321 pub fn work_undelivered(&self) -> bool {
326 self.dropped.is_some()
327 }
328}
329
330#[derive(Debug)]
333pub(crate) struct ExecutionInterrupted(pub String);
334
335impl std::fmt::Display for ExecutionInterrupted {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 write!(f, "execution interrupted: {}", self.0)
338 }
339}
340
341impl std::error::Error for ExecutionInterrupted {}
342
343const PIPE_GRACE: Duration = Duration::from_secs(3);
348
349pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
351
352pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
362where
363 R: tokio::io::AsyncRead + Unpin + Send + 'static,
364{
365 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
366 let Some(mut pipe) = pipe else {
367 return (buf, None);
368 };
369 let sink = Arc::clone(&buf);
370 let handle = tokio::spawn(async move {
371 let mut chunk = [0u8; 8192];
372 loop {
373 match pipe.read(&mut chunk).await {
374 Ok(0) | Err(_) => break,
375 Ok(n) => {
376 if let Ok(mut guard) = sink.lock() {
377 guard.extend_from_slice(&chunk[..n]);
378 }
379 }
380 }
381 }
382 });
383 (buf, Some(handle))
384}
385
386pub(crate) async fn collect(
391 buf: &Captured,
392 handle: Option<tokio::task::JoinHandle<()>>,
393 grace: Duration,
394) -> String {
395 if let Some(handle) = handle {
396 if tokio::time::timeout(grace, handle).await.is_err() {
397 tracing::debug!("a pipe is still held open after the child exited");
398 }
399 }
400 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
401 String::from_utf8_lossy(&bytes).into_owned()
402}
403
404pub async fn invoke(
406 spec: &AgentSpec,
407 seat: &mut SeatState,
408 inv: &Invocation<'_>,
409) -> Result<AgentOutput> {
410 let lease = if repository_seat(inv) {
415 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
416 .await?
417 } else {
418 None
419 };
420 let inv = &Invocation {
421 timeout: spec.turn_timeout(inv.timeout),
422 ..inv.clone()
423 };
424 let mut outputs = Vec::new();
425 if repository_seat(inv) {
426 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
427 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
428 })? {
429 outputs.push(guard);
430 }
431 if let Some(cache) = inv.cache_dir {
432 outputs.push(crate::retention::OutputLease::cache(
433 &crate::run::home(),
434 cache,
435 false,
436 )?);
437 }
438 }
439
440 let mut prompt = inv.prompt.to_owned();
441 if inv.node == crate::queue::CHAT_NODE {
442 prompt.push_str("\n\n");
443 prompt.push_str(crate::prompt::CHAT_FOLLOW_THROUGH);
444 }
445 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
446 prompt.push_str(&format!("\n\nRepository coordination: the launcher holds the exclusive repository lock at {} for this invocation. Do not acquire a second lock or release/delete it. Repository access remains read-only.", lease.path.display()));
447 }
448 let home = repository_writer(inv).then(crate::run::home);
451 let mut writable = inv.writable.to_vec();
452 if let Some(home) = &home {
453 for root in worktree_writable_roots(inv.cwd, home).await? {
454 if !writable.contains(&root) {
455 writable.push(root);
456 }
457 }
458 }
459 if repository_writer(inv) {
460 prompt.push_str("\n\n");
461 prompt.push_str(&editing_access_note(
462 spec.kind,
463 inv.unsandboxed,
464 inv.fetched_base,
465 ));
466 }
467 let inv = &Invocation {
468 writable: &writable,
469 prompt: &prompt,
470 ..inv.clone()
471 };
472 tokio::fs::create_dir_all(inv.artifacts)
473 .await
474 .with_context(|| format!("create {}", inv.artifacts.display()))?;
475 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
476 tokio::fs::write(&prompt_path, inv.prompt)
477 .await
478 .with_context(|| format!("write {}", prompt_path.display()))?;
479
480 let plan = build_command(spec, seat, inv, &prompt_path)?;
481 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
482 tokio::fs::write(
484 inv.artifacts.join(format!("{}.access.json", inv.stem)),
485 serde_json::to_vec_pretty(&serde_json::json!({
486 "launcher_version": env!("CARGO_PKG_VERSION"),
487 "kind": spec.kind,
488 "allow_write": inv.allow_write,
489 "unsandboxed": inv.unsandboxed,
490 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
491 Some(false)
492 } else { None },
493 "writable_roots": inv.writable,
494 "fetched_base": inv.fetched_base,
495 }))?,
496 )
497 .await
498 .context("write seat access evidence")?;
499
500 let started = Instant::now();
501 let child_path = spec
504 .env
505 .iter()
506 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
507 .map(|(_, v)| std::ffi::OsString::from(v))
508 .or_else(|| std::env::var_os("PATH"))
509 .unwrap_or_default();
510 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
511 || plan.argv[0].clone().into(),
512 std::path::PathBuf::into_os_string,
513 );
514 let mut cmd = Command::new(program);
515 cmd.args(&plan.argv[1..])
516 .current_dir(inv.cwd)
517 .envs(&spec.env)
518 .env("MAGI_SEAT", &seat.key)
519 .env("MAGI_TURN", seat.turns.to_string())
520 .env("MAGI_RUN", inv.run)
521 .env("MAGI_NODE", inv.node)
522 .env("MAGI_PROMPT_FILE", &prompt_path)
523 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
524 .env("GIT_TERMINAL_PROMPT", "0")
525 .stdin(if plan.stdin.is_some() {
526 Stdio::piped()
527 } else {
528 Stdio::null()
529 })
530 .stdout(Stdio::piped())
531 .stderr(Stdio::piped())
532 .kill_on_drop(true)
533 .quiet();
536 if let Some(home) = home {
537 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
540 }
541 if let Some(cache) = inv.cache_dir {
542 cmd.env("CARGO_TARGET_DIR", cache);
545 } else {
546 cmd.env_remove("CARGO_TARGET_DIR");
554 }
555
556 #[cfg(any(unix, windows))]
557 let _chat_controls = {
558 use crate::chat_control::bridge::{self, Bridge};
559 bridge::clear_environment(&mut cmd);
560 let bridge = if inv.node == crate::queue::CHAT_NODE {
561 crate::run::try_home().and_then(|home| {
562 match Bridge::start(&home, inv.run, inv.artifacts) {
563 Ok(bridge) => Some(bridge),
564 Err(error) => {
565 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
566 None
567 }
568 }
569 })
570 } else {
571 None
572 };
573 if let Some(bridge) = &bridge {
574 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
578 bridge.attach(&mut cmd);
579 }
580 bridge
581 };
582
583 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
584 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
585 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
589 tokio::spawn(async move {
590 sink.write_all(body.as_bytes()).await.ok();
591 sink.shutdown().await.ok();
592 });
593 }
594
595 let (out_buf, out_reader) = drain(running.child().stdout.take());
612 let (err_buf, err_reader) = drain(running.child().stderr.take());
613
614 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
615 .ok()
616 .map_or(0, |state| state.config.disk.min_free_bytes);
617 let mut paths = vec![inv.cwd.to_owned()];
618 if let Some(cache) = inv.cache_dir {
619 paths.push(cache.to_owned());
620 }
621 let mut pressure = None;
622 let waited = tokio::select! {
623 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
624 reason = crate::retention::emergency(paths, min_free) => {
625 pressure = Some(reason);
626 None
627 }
628 };
629 let (code, timed_out) = match waited {
630 Some(Ok(res)) => {
631 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
632 (status.code(), false)
633 }
634 Some(Err(_)) | None => {
635 if let Some(reason) = &pressure {
636 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
637 } else {
638 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
639 }
640 if !running.coordinated() {
642 running
643 .child()
644 .kill()
645 .await
646 .context("stop timed-out seat")?;
647 }
648 (None, true)
649 }
650 };
651 running.finish().await;
652
653 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
658 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
659
660 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
661 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
662 tokio::fs::write(&out_path, &stdout).await.ok();
663 tokio::fs::write(&err_path, &stderr).await.ok();
664 if let Some(reason) = pressure {
665 return Err(
666 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
667 .context(ExecutionInterrupted(reason)),
668 );
669 }
670
671 let mut extracted = extract(spec.kind, &stdout);
672 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
673 extracted.quota = agy_quota(&stdout, &stderr);
674 }
675 if let Some(quota) = &extracted.quota {
676 extracted.dropped = None;
679 tracing::warn!(
680 seat = %seat.key,
681 agent = %spec.id,
682 reset = ?quota.reset,
683 "agent is out of quota"
684 );
685 }
686 if let Some(session) = extracted.session {
687 match spec.kind {
688 AgentKind::Claude => seat.claude_session = Some(session),
689 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
690 seat.captured_session = Some(session);
691 }
692 AgentKind::Command => {}
693 }
694 }
695 if let Some(status) = &extracted.status
696 && !status.eq_ignore_ascii_case("success")
697 {
698 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
699 }
700 let text = if extracted.text.trim().is_empty() {
701 if stdout.trim().is_empty() {
703 stderr.trim().to_owned()
704 } else {
705 stdout.trim().to_owned()
706 }
707 } else {
708 extracted.text
709 };
710 seat.turns += 1;
711
712 Ok(AgentOutput {
713 text,
714 exit_code: code,
715 timed_out,
716 duration_ms: started.elapsed().as_millis() as u64,
717 artifacts: vec![
718 file_name(&prompt_path),
719 file_name(&out_path),
720 file_name(&err_path),
721 ],
722 quota: extracted.quota,
723 dropped: extracted.dropped,
724 commands: extracted.commands,
725 context_tokens: extracted.context_tokens,
726 })
727}
728
729fn file_name(p: &Path) -> String {
730 p.file_name()
731 .unwrap_or_default()
732 .to_string_lossy()
733 .into_owned()
734}
735
736#[derive(Debug)]
738struct Plan {
739 argv: Vec<String>,
740 stdin: Option<String>,
741}
742
743impl Plan {
744 fn logged_argv(&self) -> Vec<&str> {
747 let mut settings_value = false;
748 self.argv
749 .iter()
750 .map(|arg| {
751 if settings_value {
752 settings_value = false;
753 "[redacted]"
754 } else if arg == "--settings" {
755 settings_value = true;
756 arg.as_str()
757 } else if arg.starts_with("--settings=") {
758 "--settings=[redacted]"
759 } else {
760 arg.as_str()
761 }
762 })
763 .collect()
764 }
765}
766
767fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
778 if matches!(kind, AgentKind::Antigravity) {
779 return format!("@{}", prompt_path.display());
780 }
781 format!(
782 "Read the file at {} and follow every instruction in it exactly. That \
783 file is your complete task description; this message contains nothing \
784 else.",
785 prompt_path.display()
786 )
787}
788
789fn build_command(
790 spec: &AgentSpec,
791 seat: &SeatState,
792 inv: &Invocation<'_>,
793 prompt_path: &Path,
794) -> Result<Plan> {
795 let mut argv: Vec<String> = Vec::new();
796 let mut stdin: Option<String> = None;
797 let delivery = spec.delivery();
798 let resuming = has_session(spec.kind, seat, inv.sessions);
799
800 match spec.kind {
801 AgentKind::Claude => {
802 argv.push("claude".to_owned());
807 argv.push("-p".to_owned());
808 argv.push("--output-format".to_owned());
809 argv.push("json".to_owned());
810 if let Some(m) = &spec.model {
811 argv.push("--model".to_owned());
812 argv.push(m.clone());
813 }
814 if inv.sessions {
815 let uuid = seat
816 .claude_session
817 .as_deref()
818 .context("claude seat is missing its session uuid")?;
819 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
820 argv.push(uuid.to_owned());
821 }
822 argv.push("--permission-mode".to_owned());
823 argv.push("bypassPermissions".to_owned());
824 if !inv.allow_write {
825 argv.push("--disallowed-tools".to_owned());
826 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
827 }
828 }
829 AgentKind::Opencode => {
830 argv.push("opencode".to_owned());
834 argv.push("run".to_owned());
835 argv.push("--format".to_owned());
836 argv.push("json".to_owned());
837 argv.push("--dir".to_owned());
838 argv.push(inv.cwd.to_string_lossy().into_owned());
839 argv.push("--auto".to_owned());
848 if let Some(m) = &spec.model {
849 argv.push("-m".to_owned());
850 argv.push(m.clone());
851 }
852 if resuming {
853 argv.push("-s".to_owned());
854 argv.push(
855 seat.captured_session
856 .clone()
857 .expect("has_session checked the id is present"),
858 );
859 }
860 }
861 AgentKind::Antigravity => {
862 argv.push("agy".to_owned());
863 argv.push("--output-format".to_owned());
864 argv.push("json".to_owned());
865 argv.push("--print-timeout".to_owned());
868 argv.push(format!("{}s", inv.timeout.as_secs()));
869 argv.push("--mode".to_owned());
870 argv.push(
871 if inv.allow_write {
872 "accept-edits"
873 } else {
874 "plan"
875 }
876 .to_owned(),
877 );
878 if inv.allow_write {
879 argv.push("--dangerously-skip-permissions".to_owned());
880 }
881 if let Some(m) = &spec.model {
882 argv.push("--model".to_owned());
883 argv.push(m.clone());
884 }
885 if resuming {
886 argv.push("--conversation".to_owned());
887 argv.push(
888 seat.captured_session
889 .clone()
890 .expect("has_session checked the id is present"),
891 );
892 }
893 let mut add_dirs: Vec<String> = Vec::new();
903 if delivery == Delivery::File || !inv.attachments.is_empty() {
904 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
905 }
906 for path in inv.attachments {
907 let Some(parent) = path.parent() else {
908 continue;
909 };
910 if parent.starts_with(inv.artifacts) {
911 continue;
912 }
913 let dir = parent.to_string_lossy().into_owned();
914 if !add_dirs.contains(&dir) {
915 add_dirs.push(dir);
916 }
917 }
918 if inv.allow_write {
919 for path in inv.writable {
920 let dir = path.to_string_lossy().into_owned();
921 if !add_dirs.contains(&dir) {
922 add_dirs.push(dir);
923 }
924 }
925 }
926 for dir in add_dirs {
927 argv.push("--add-dir".to_owned());
928 argv.push(dir);
929 }
930 }
931 AgentKind::Codex => {
932 argv.push("codex".to_owned());
938 argv.push("exec".to_owned());
939 argv.push("--json".to_owned());
940 argv.push("--skip-git-repo-check".to_owned());
943 argv.push("-C".to_owned());
944 argv.push(inv.cwd.to_string_lossy().into_owned());
945 if inv.allow_write && inv.unsandboxed {
954 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
955 } else {
956 argv.push("--sandbox".to_owned());
957 argv.push(
958 if inv.allow_write {
959 "workspace-write"
960 } else {
961 "read-only"
962 }
963 .to_owned(),
964 );
965 argv.push("-c".to_owned());
968 argv.push("approval_policy=\"never\"".to_owned());
969 if repository_writer(inv) {
970 argv.push("-c".to_owned());
971 argv.push("sandbox_workspace_write.network_access=false".to_owned());
972 }
973 if inv.allow_write && !inv.writable.is_empty() {
974 let roots: Vec<String> = inv
975 .writable
976 .iter()
977 .map(|p| p.to_string_lossy().into_owned())
978 .collect();
979 argv.push("-c".to_owned());
980 argv.push(format!(
981 "sandbox_workspace_write.writable_roots={}",
982 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
983 ));
984 }
985 }
986 if let Some(m) = &spec.model {
987 argv.push("-m".to_owned());
988 argv.push(m.clone());
989 }
990 if resuming {
996 argv.push("resume".to_owned());
997 argv.push(
998 seat.captured_session
999 .clone()
1000 .expect("has_session checked the id is present"),
1001 );
1002 }
1003 }
1004 AgentKind::Omp => {
1005 argv.push("omp".to_owned());
1009 argv.push("-p".to_owned());
1010 argv.push("--mode=json".to_owned());
1011 argv.push("--auto-approve".to_owned());
1021 if let Some(m) = &spec.model {
1022 argv.push("--model".to_owned());
1023 argv.push(m.clone());
1024 }
1025 if resuming {
1031 argv.push("--resume".to_owned());
1032 argv.push(
1033 seat.captured_session
1034 .clone()
1035 .expect("has_session checked the id is present"),
1036 );
1037 }
1038 }
1039 AgentKind::Command => {
1040 if spec.command.is_empty() {
1045 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1046 }
1047 let vars: BTreeMap<&str, String> = BTreeMap::from([
1048 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1049 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1050 ("{label}", seat.key.clone()),
1051 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1052 ]);
1053 for raw in &spec.command {
1054 let mut arg = raw.clone();
1055 for (k, v) in &vars {
1056 if arg.contains(k) {
1057 arg = arg.replace(k, v);
1058 }
1059 }
1060 argv.push(arg);
1061 }
1062 }
1063 }
1064
1065 argv.extend(spec.extra_args.iter().cloned());
1066
1067 if spec.kind == AgentKind::Antigravity {
1070 argv.push("-p".to_owned());
1071 }
1072 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1075 argv.push("-".to_owned());
1076 }
1077 match delivery {
1078 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1079 argv.push(pointer(spec.kind, prompt_path));
1081 }
1082 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1083 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1084 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1085 }
1086
1087 Ok(Plan { argv, stdin })
1088}
1089
1090#[derive(Debug, Default)]
1092struct Extracted {
1093 text: String,
1094 session: Option<String>,
1095 status: Option<String>,
1096 quota: Option<Quota>,
1097 dropped: Option<Dropped>,
1098 commands: Vec<CommandEvidence>,
1099 context_tokens: Option<u64>,
1100}
1101
1102fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1104 let mut extracted = extract_answer(kind, stdout);
1105 extracted.context_tokens = context_tokens(kind, stdout);
1106 extracted
1107}
1108
1109fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1111 v.get(key).and_then(serde_json::Value::as_u64)
1112}
1113
1114fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1119 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1120 let base = first(input)?;
1121 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1122 Some(base.saturating_add(cached))
1123}
1124
1125fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1146 use serde_json::Value;
1147 let lines = || {
1148 stdout
1149 .lines()
1150 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1151 };
1152 match kind {
1153 AgentKind::Claude => None,
1154 AgentKind::Opencode => lines()
1155 .filter(|v| {
1156 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1158 || v.get("part")
1159 .and_then(|p| p.get("type"))
1160 .and_then(|t| t.as_str())
1161 == Some("step-finish")
1162 })
1163 .filter_map(|v| {
1164 let tokens = v.get("part")?.get("tokens")?;
1165 let base = uint(tokens, "input")?;
1166 let cache = tokens.get("cache");
1167 let cached = ["read", "write"]
1168 .iter()
1169 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1170 .sum::<u64>();
1171 Some(base.saturating_add(cached))
1172 })
1173 .next_back(),
1174 AgentKind::Antigravity => None,
1175 AgentKind::Codex => None,
1176 AgentKind::Omp => lines()
1177 .flat_map(|v| {
1178 match v.get("type").and_then(|t| t.as_str()) {
1181 Some("agent_end") => v
1182 .get("messages")
1183 .and_then(|m| m.as_array())
1184 .cloned()
1185 .unwrap_or_default(),
1186 Some("turn_end") | Some("message_end") => {
1187 v.get("message").cloned().into_iter().collect()
1188 }
1189 _ => Vec::new(),
1190 }
1191 })
1192 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1193 .filter_map(|m| {
1194 input_side(
1195 m.get("usage")?,
1196 &["input", "input_tokens"],
1197 &[
1198 "cacheRead",
1199 "cacheWrite",
1200 "cache_read_tokens",
1201 "cache_write_tokens",
1202 ],
1203 )
1204 })
1205 .next_back(),
1206 AgentKind::Command => None,
1207 }
1208}
1209
1210pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1213 extract_answer(kind, stdout).text
1214}
1215
1216fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1217 match kind {
1218 AgentKind::Claude => {
1219 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1220 return Extracted {
1221 text: stdout.trim().to_owned(),
1222 ..Extracted::default()
1223 };
1224 };
1225 Extracted {
1226 text: v
1227 .get("result")
1228 .and_then(|r| r.as_str())
1229 .unwrap_or_default()
1230 .to_owned(),
1231 session: v
1232 .get("session_id")
1233 .and_then(|s| s.as_str())
1234 .map(str::to_owned),
1235 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1236 if e {
1237 "error".to_owned()
1238 } else {
1239 "success".to_owned()
1240 }
1241 }),
1242 quota: claude_quota(&v),
1243 dropped: None,
1246 commands: Vec::new(),
1247 context_tokens: None,
1248 }
1249 }
1250 AgentKind::Opencode => {
1251 let mut text = String::new();
1253 let mut session = None;
1254 for line in stdout.lines() {
1255 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1256 continue;
1257 };
1258 if session.is_none() {
1259 session = v
1260 .get("sessionID")
1261 .and_then(|s| s.as_str())
1262 .map(str::to_owned);
1263 }
1264 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1265 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1266 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1267 {
1268 if !text.is_empty() {
1269 text.push('\n');
1270 }
1271 text.push_str(t);
1272 }
1273 }
1274 Extracted {
1275 text,
1276 session,
1277 status: None,
1278 quota: None,
1279 dropped: None,
1280 commands: Vec::new(),
1281 context_tokens: None,
1282 }
1283 }
1284 AgentKind::Antigravity => {
1285 let obj = stdout
1288 .lines()
1289 .rev()
1290 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1291 let Some(v) = obj else {
1292 return Extracted {
1293 text: stdout.trim().to_owned(),
1294 ..Extracted::default()
1295 };
1296 };
1297 Extracted {
1298 text: v
1299 .get("response")
1300 .and_then(|r| r.as_str())
1301 .unwrap_or_default()
1302 .trim()
1303 .to_owned(),
1304 session: v
1305 .get("conversation_id")
1306 .and_then(|s| s.as_str())
1307 .map(str::to_owned),
1308 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1309 quota: None,
1310 dropped: dropped_stream(&v),
1311 commands: Vec::new(),
1312 context_tokens: None,
1313 }
1314 }
1315 AgentKind::Codex => {
1316 let mut text = String::new();
1338 let mut session = None;
1339 let mut status = None;
1340 let mut commands = Vec::new();
1341 for line in stdout.lines() {
1342 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1343 continue;
1344 };
1345 match v.get("type").and_then(|t| t.as_str()) {
1346 Some("thread.started") => {
1347 session = v
1348 .get("thread_id")
1349 .and_then(|s| s.as_str())
1350 .map(str::to_owned);
1351 }
1352 Some("item.completed") => {
1353 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1354 match item.get("type").and_then(|t| t.as_str()) {
1355 Some("agent_message") => {
1356 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1357 text = t.trim().to_owned();
1358 }
1359 }
1360 Some("command_execution") => {
1361 commands.push(command_evidence(item));
1362 }
1363 _ => {}
1364 }
1365 }
1366 Some("turn.completed") => status = Some("success".to_owned()),
1367 Some("turn.failed") => status = Some("error".to_owned()),
1368 _ => {}
1369 }
1370 }
1371 Extracted {
1372 text,
1373 session,
1374 status,
1375 quota: None,
1376 dropped: None,
1377 commands,
1378 context_tokens: None,
1379 }
1380 }
1381 AgentKind::Omp => {
1382 let mut text = String::new();
1403 let mut session = None;
1404 for line in stdout.lines() {
1405 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1406 continue;
1407 };
1408 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1409 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1410 continue;
1411 }
1412 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1416 {
1417 Some("agent_end") => v
1418 .get("messages")
1419 .and_then(|m| m.as_array())
1420 .map(|m| m.iter().collect())
1421 .unwrap_or_default(),
1422 Some("turn_end") | Some("message_end") => {
1423 v.get("message").into_iter().collect()
1424 }
1425 _ => continue,
1426 };
1427 for message in messages {
1428 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1429 continue;
1430 }
1431 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1432 continue;
1433 };
1434 for part in parts {
1435 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1436 continue;
1437 }
1438 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1439 && !t.trim().is_empty()
1440 {
1441 text = t.trim().to_owned();
1442 }
1443 }
1444 }
1445 }
1446 Extracted {
1447 text,
1448 session,
1449 status: None,
1450 quota: None,
1451 dropped: None,
1452 commands: Vec::new(),
1453 context_tokens: None,
1454 }
1455 }
1456 AgentKind::Command => {
1457 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1462 let quota = parsed.as_ref().and_then(claude_quota);
1463 let dropped = parsed.as_ref().and_then(dropped_stream);
1466 Extracted {
1467 text: stdout.trim().to_owned(),
1468 session: None,
1469 status: None,
1470 quota,
1471 dropped,
1472 commands: Vec::new(),
1473 context_tokens: None,
1474 }
1475 }
1476 }
1477}
1478
1479fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1486 let description = match item.get("command") {
1487 Some(serde_json::Value::String(s)) => s.clone(),
1488 Some(serde_json::Value::Array(parts)) => parts
1489 .iter()
1490 .filter_map(|p| p.as_str())
1491 .collect::<Vec<_>>()
1492 .join(" "),
1493 _ => String::new(),
1494 };
1495 let result_summary = item
1496 .get("aggregated_output")
1497 .and_then(|o| o.as_str())
1498 .map(|s| tail_chars(s.trim(), 400))
1499 .unwrap_or_default();
1500 CommandEvidence {
1501 id: item
1502 .get("id")
1503 .and_then(|s| s.as_str())
1504 .unwrap_or_default()
1505 .to_owned(),
1506 description,
1507 exit_code: item
1508 .get("exit_code")
1509 .and_then(serde_json::Value::as_i64)
1510 .map(|e| e as i32),
1511 result_summary,
1512 source: "codex".to_owned(),
1513 }
1514}
1515
1516fn tail_chars(s: &str, max: usize) -> String {
1518 let count = s.chars().count();
1519 if count <= max {
1520 return s.to_owned();
1521 }
1522 s.chars().skip(count - max).collect()
1523}
1524
1525fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1532 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1533 if !is_err {
1534 return None;
1535 }
1536 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1537 let status = v.get("api_error_status");
1538 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1539 || status.and_then(serde_json::Value::as_str) == Some("429"))
1540 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1541 if !usage_limit && !result.to_lowercase().contains("session limit") {
1542 return None;
1543 }
1544 let reset = result
1547 .split("resets ")
1548 .nth(1)
1549 .map(str::trim)
1550 .filter(|s| !s.is_empty())
1551 .map(str::to_owned);
1552 Some(Quota { reset })
1553}
1554
1555fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1570 let from_stdout = stdout
1571 .lines()
1572 .rev()
1573 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1574 .and_then(|v| {
1575 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1576 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1577 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1578 .then(|| error.to_owned())
1579 });
1580 let from_stderr = || {
1581 stderr.lines().find_map(|l| {
1582 let v: serde_json::Value =
1583 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1584 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1585 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1586 (exhausted && code).then(|| {
1587 v.get("short_error")
1588 .and_then(|e| e.as_str())
1589 .unwrap_or_default()
1590 .to_owned()
1591 })
1592 })
1593 };
1594 let text = from_stdout.or_else(from_stderr)?;
1595 let reset = text
1596 .split_once("Resets ")
1597 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1598 .filter(|s| !s.is_empty())
1599 .map(str::to_owned);
1600 Some(Quota { reset })
1601}
1602
1603fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1634 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1635 if !status.eq_ignore_ascii_case("error") {
1636 return None;
1637 }
1638 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1639 if !response.trim().is_empty() {
1640 return None;
1642 }
1643 let produced = v
1644 .get("usage")
1645 .and_then(|u| u.get("output_tokens"))
1646 .and_then(serde_json::Value::as_u64)
1647 .unwrap_or(0);
1648 if produced == 0 {
1649 return None;
1651 }
1652 Some(Dropped {
1653 why: v
1654 .get("error")
1655 .and_then(|e| e.as_str())
1656 .unwrap_or("the CLI ended the stream without delivering its answer")
1657 .trim()
1658 .to_owned(),
1659 output_tokens: produced,
1660 })
1661}
1662
1663pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1665 let mut missing = Vec::new();
1666 for s in specs {
1667 let program = match s.kind {
1668 AgentKind::Command => s.command.first().map(String::as_str),
1669 other => other.program(),
1670 };
1671 if let Some(p) = program
1672 && !crate::config::which(p)
1673 && !Path::new(p).is_file()
1674 && !missing.iter().any(|m: &String| m == p)
1675 {
1676 missing.push(p.to_owned());
1677 }
1678 }
1679 missing
1680}
1681
1682pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1684 run_dir.join("artifacts")
1685}
1686
1687pub fn installed(spec: &AgentSpec) -> bool {
1689 spec.kind.program().is_none_or(crate::config::which)
1692}
1693
1694pub fn pick(
1716 agents: &[AgentSpec],
1717 want: Option<&str>,
1718 available: &dyn Fn(&AgentSpec) -> bool,
1719) -> Result<AgentSpec> {
1720 if let Some(id) = want {
1721 let spec = agents
1722 .iter()
1723 .find(|a| a.id == id)
1724 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1725 if !available(spec) {
1726 bail!(
1727 "agent `{}` needs `{}` on PATH; install it or pass a different \
1728 --agent",
1729 spec.id,
1730 spec.kind.program().unwrap_or("its command")
1731 );
1732 }
1733 return Ok(spec.clone());
1734 }
1735
1736 if agents.is_empty() {
1737 bail!(
1738 "the agent roster is empty, so there is nobody to ask: install one \
1739 of claude, opencode or agy - magi derives a roster from what is on \
1740 PATH - or add an [[agents]] entry to magi.toml."
1741 );
1742 }
1743
1744 if let Some(spec) = agents
1745 .iter()
1746 .find(|a| a.kind == AgentKind::Claude && available(a))
1747 {
1748 return Ok(spec.clone());
1749 }
1750
1751 agents
1752 .iter()
1753 .find(|a| available(a))
1754 .cloned()
1755 .with_context(|| {
1756 let missing = agents
1757 .iter()
1758 .filter_map(|a| a.kind.program())
1759 .collect::<Vec<_>>()
1760 .join(", ");
1761 format!(
1762 "no agent in the roster can be run here: install one of \
1763 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1764 you do have"
1765 )
1766 })
1767}
1768
1769pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1782 let mut spec = spec.clone();
1783 if spec.kind == AgentKind::Claude {
1784 let mut existing = None;
1787 let mut args = spec.extra_args.iter().enumerate();
1788 while let Some((index, arg)) = args.next() {
1789 if arg == "--settings" {
1790 let (value_index, value) = args.next().context("--settings requires a value")?;
1791 existing = Some((value_index, value.as_str(), false));
1792 } else if let Some(value) = arg.strip_prefix("--settings=") {
1793 existing = Some((index, value, true));
1794 }
1795 }
1796 let mut settings = if let Some((_, value, _)) = existing {
1797 let json = if value.trim_start().starts_with('{') {
1798 value.to_owned()
1799 } else {
1800 std::fs::read_to_string(cwd.join(value))
1801 .context("cannot read existing seat --settings file")?
1802 };
1803 serde_json::from_str::<serde_json::Value>(&json)
1804 .context("cannot parse existing seat --settings JSON")?
1805 } else {
1806 serde_json::json!({})
1807 };
1808 settings
1809 .as_object_mut()
1810 .context("seat --settings must be a JSON object")?
1811 .insert(
1812 "language".to_owned(),
1813 serde_json::json!(crate::lang::name(github_language)),
1814 );
1815 let settings = settings.to_string();
1816 if let Some((index, _, equals)) = existing {
1817 spec.extra_args[index] = if equals {
1818 format!("--settings={settings}")
1819 } else {
1820 settings
1821 };
1822 } else {
1823 spec.extra_args.extend(["--settings".to_owned(), settings]);
1824 }
1825 }
1826 Ok(spec)
1827}
1828
1829pub fn pick_chain(
1837 agents: &[AgentSpec],
1838 choice: Option<&AgentChoice>,
1839 available: &dyn Fn(&AgentSpec) -> bool,
1840 role: &str,
1841) -> Result<Vec<AgentSpec>> {
1842 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1843 if wanted.is_empty() {
1844 return pick(agents, None, available)
1845 .map(|s| vec![s])
1846 .with_context(|| format!("choose an agent for the {role} role"));
1847 }
1848 let mut chain: Vec<AgentSpec> = Vec::new();
1849 for id in wanted {
1850 if chain.iter().any(|s| s.id == id) {
1851 continue;
1852 }
1853 match pick(agents, Some(id), available) {
1854 Ok(spec) => chain.push(spec),
1855 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1856 }
1857 }
1858 if chain.is_empty() {
1859 bail!(
1860 "[roles] {role} names no agent that can run here; the roster has {}",
1861 ids(agents)
1862 );
1863 }
1864 Ok(chain)
1865}
1866
1867pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1873 match outcome {
1874 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1875 Err(_) => true,
1876 Ok(out) => output_advances(out),
1877 }
1878}
1879
1880pub fn output_advances(out: &AgentOutput) -> bool {
1884 out.quota_exhausted() || !out.usable()
1885}
1886
1887fn ids(agents: &[AgentSpec]) -> String {
1888 if agents.is_empty() {
1889 return "no agents at all".to_owned();
1890 }
1891 agents
1892 .iter()
1893 .map(|a| a.id.clone())
1894 .collect::<Vec<_>>()
1895 .join(", ")
1896}
1897
1898#[cfg(test)]
1899mod tests {
1900 use super::*;
1901
1902 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1903
1904 fn command_helper(mode: &str) -> AgentSpec {
1907 crate::run::pin_test_home();
1909 AgentSpec {
1910 id: "helper".to_owned(),
1911 kind: AgentKind::Command,
1912 model: None,
1913 command: vec![
1914 std::env::current_exe()
1915 .expect("locate test helper")
1916 .to_string_lossy()
1917 .into_owned(),
1918 "--exact".to_owned(),
1919 "agent::tests::command_agent_test_helper".to_owned(),
1920 "--nocapture".to_owned(),
1921 ],
1922 extra_args: Vec::new(),
1923 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1924 prompt_delivery: None,
1925 max_turn_secs: None,
1926 }
1927 }
1928
1929 #[test]
1930 fn command_agent_test_helper() {
1931 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1932 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1933 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1934 Ok("no-cache") => println!(
1935 "{}",
1936 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1937 ),
1938 Ok("ignore-stdin") => println!("done"),
1939 Ok("chatty-sleep") => {
1940 println!("i-said-something");
1941 std::thread::sleep(Duration::from_secs(30));
1942 }
1943 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1944 Ok(other) => panic!("unknown command helper mode {other}"),
1945 Err(_) => {}
1946 }
1947 }
1948
1949 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1950 AgentSpec {
1951 id: "a".to_owned(),
1952 kind,
1953 model: model.map(str::to_owned),
1954 command: vec!["echo".to_owned(), "{label}".to_owned()],
1955 extra_args: Vec::new(),
1956 env: BTreeMap::new(),
1957 prompt_delivery: None,
1958 max_turn_secs: None,
1959 }
1960 }
1961
1962 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1963 Invocation {
1964 coordination: None,
1965 fetched_base: None,
1966 cwd,
1967 prompt: "do the thing",
1968 timeout: Duration::from_secs(900),
1969 allow_write,
1970 unsandboxed: false,
1971 sessions: true,
1972 artifacts: art,
1973 stem: "t",
1974 run: "test-run",
1975 node: "test",
1976 cache_dir: None,
1977 attachments: &[],
1978 writable: &[],
1979 }
1980 }
1981
1982 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1983 build_command(
1984 &spec(kind, None),
1985 seat,
1986 &inv(Path::new("."), Path::new("/art"), allow_write),
1987 Path::new("/art/p.md"),
1988 )
1989 .unwrap()
1990 }
1991
1992 #[test]
1993 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1994 let missing = editing_access_note(AgentKind::Codex, false, None);
1995 assert!(missing.contains("No successful launcher fetch evidence"));
1996 let base = crate::run::FetchedBase {
1997 remote: "origin".into(),
1998 branch: "main".into(),
1999 commit: "0123456789abcdef".into(),
2000 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
2001 };
2002 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
2003 for expected in [
2004 "origin/main",
2005 "0123456789abcdef",
2006 "2026-10-18T00:00:00Z",
2007 "Do not fetch again",
2008 "launcher saves eligible edits",
2009 "Specification questions remain valid",
2010 ] {
2011 assert!(note.contains(expected), "missing {expected}");
2012 }
2013 for kind in [
2014 AgentKind::Codex,
2015 AgentKind::Claude,
2016 AgentKind::Opencode,
2017 AgentKind::Antigravity,
2018 AgentKind::Omp,
2019 AgentKind::Command,
2020 ] {
2021 for unsandboxed in [false, true] {
2022 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
2023 assert!(scoped.contains(&base.commit));
2024 assert!(scoped.contains("Do not fetch again"));
2025 let restricted = kind == AgentKind::Codex && !unsandboxed;
2026 for claim in [
2027 "Network access is restricted",
2028 "protected Git administration paths",
2029 "launcher saves eligible edits",
2030 "unavailable sandbox permissions",
2031 ] {
2032 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
2033 assert_eq!(
2034 editing_access_note(kind, unsandboxed, None).contains(claim),
2035 restricted,
2036 "{kind:?}/{unsandboxed} without fetch evidence",
2037 );
2038 }
2039 }
2040 }
2041 let roundtrip: crate::run::FetchedBase =
2042 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2043 assert_eq!(
2044 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
2045 note
2046 );
2047 }
2048
2049 #[test]
2050 fn editing_network_policy_is_explicit_on_initial_and_resume() {
2051 for resumed in [false, true] {
2052 let mut seat = SeatState::new("impl-A", "a", 7);
2053 if resumed {
2054 seat.turns = 1;
2055 seat.captured_session = Some("fixture-thread".into());
2056 }
2057 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2058 invocation.node = "implement";
2059 let plan = build_command(
2060 &spec(AgentKind::Codex, None),
2061 &seat,
2062 &invocation,
2063 Path::new("/art/p.md"),
2064 )
2065 .unwrap();
2066 let policy = plan
2067 .argv
2068 .iter()
2069 .position(|s| s == "sandbox_workspace_write.network_access=false")
2070 .unwrap();
2071 if resumed {
2072 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
2073 }
2074 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2075 invocation.node = "deputy";
2076 let deputy = build_command(
2077 &spec(AgentKind::Codex, None),
2078 &seat,
2079 &invocation,
2080 Path::new("/art/p.md"),
2081 )
2082 .unwrap();
2083 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
2084 assert!(
2085 !plan_for(AgentKind::Codex, &seat, false)
2086 .argv
2087 .iter()
2088 .any(|s| s.contains("writable_roots"))
2089 );
2090 }
2091 }
2092
2093 #[test]
2094 fn claude_mints_then_resumes_the_same_uuid() {
2095 let mut seat = SeatState::new("judge-1", "a", 7);
2096 let uuid = seat.claude_session.clone().unwrap();
2097 let first = plan_for(AgentKind::Claude, &seat, true);
2098 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2099 assert!(!first.argv.iter().any(|a| a == "--resume"));
2100
2101 seat.turns = 1;
2102 let second = plan_for(AgentKind::Claude, &seat, true);
2103 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2104 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2105 }
2106
2107 #[test]
2108 fn github_bound_claude_seats_override_the_operators_answer_language() {
2109 let seat = SeatState::new("change-summary", "a", 7);
2110 let art = Path::new("/art");
2111 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2112 let claude =
2113 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2114 let plan = build_command(
2115 &claude,
2116 &seat,
2117 &inv(Path::new("."), art, false),
2118 Path::new("/art/p.md"),
2119 )
2120 .unwrap();
2121 let settings = format!(r#"{{"language":"{name}"}}"#);
2122 assert!(
2123 plan.argv
2124 .windows(2)
2125 .any(|w| w == ["--settings", settings.as_str()]),
2126 "{:?}",
2127 plan.argv
2128 );
2129 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2132 }
2133 for kind in [
2134 AgentKind::Codex,
2135 AgentKind::Opencode,
2136 AgentKind::Antigravity,
2137 AgentKind::Omp,
2138 ] {
2139 assert!(
2140 for_github_text(&spec(kind, None), "en", Path::new("."))
2141 .unwrap()
2142 .extra_args
2143 .is_empty()
2144 );
2145 }
2146 }
2147
2148 #[test]
2149 fn github_text_preserves_existing_settings() {
2150 let dir = tempfile::tempdir().unwrap();
2151 let original = serde_json::json!({
2152 "language": "Japanese",
2153 "env": { "SEAT_MODE": "configured" },
2154 "apiKeyHelper": "credential-helper",
2155 "hooks": { "Stop": [] }
2156 });
2157 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2158 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2159 for equals in [false, true] {
2160 let mut spec = spec(AgentKind::Claude, None);
2161 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2163 if equals {
2164 spec.extra_args.push(format!("--settings={value}"));
2165 } else {
2166 spec.extra_args
2167 .extend(["--settings".to_owned(), value.clone()]);
2168 }
2169 spec.extra_args.push("--verbose".to_owned());
2170 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2171 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2172 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2173 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2174 let value = &updated.extra_args[updated.extra_args.len() - 2];
2175 let actual: serde_json::Value =
2176 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2177 .unwrap();
2178 let mut expected = original.clone();
2179 expected["language"] = serde_json::json!("English");
2180 assert_eq!(actual, expected);
2181 }
2182 }
2183 let on_disk: serde_json::Value = serde_json::from_str(
2184 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2185 )
2186 .unwrap();
2187 assert_eq!(on_disk, original);
2188 }
2189
2190 #[test]
2191 fn github_text_settings_are_redacted_from_command_logging() {
2192 let dir = tempfile::tempdir().unwrap();
2193 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2194 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2195 for equals in [false, true] {
2196 let mut spec = spec(AgentKind::Claude, None);
2197 spec.extra_args =
2198 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2199 if equals {
2200 spec.extra_args
2201 .push("--settings=./seat-settings.json".to_owned());
2202 } else {
2203 spec.extra_args
2204 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2205 }
2206 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2207 let plan = build_command(
2208 &spec,
2209 &SeatState::new("change-summary", "a", 7),
2210 &inv(dir.path(), dir.path(), false),
2211 &dir.path().join("prompt.md"),
2212 )
2213 .unwrap();
2214 let actual = plan.argv.last().unwrap();
2216 let actual: serde_json::Value =
2217 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2218 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2219 assert_eq!(actual["language"], "English");
2220 let logged = format!("{:?}", plan.logged_argv());
2221 assert!(!logged.contains("secret"));
2222 assert!(!logged.contains("AUTH_TOKEN"));
2223 assert!(logged.contains("[redacted]"));
2224 assert_eq!(plan.logged_argv()[0], "claude");
2225 assert!(plan.logged_argv().contains(&"--output-format"));
2226 }
2227 }
2228
2229 #[test]
2230 fn github_text_rejects_unreadable_or_invalid_settings() {
2231 let dir = tempfile::tempdir().unwrap();
2232 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2233 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2234 for args in [
2235 vec!["--settings"],
2236 vec!["--settings", "missing.json"],
2237 vec!["--settings=bad.json"],
2238 vec!["--settings", "array.json"],
2239 ] {
2240 let mut spec = spec(AgentKind::Claude, None);
2241 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2242 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2243 }
2244 }
2245
2246 #[test]
2247 fn read_only_seats_cannot_edit() {
2248 let seat = SeatState::new("judge-1", "a", 7);
2249 let claude = plan_for(AgentKind::Claude, &seat, false);
2250 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2251 assert!(
2252 !plan_for(AgentKind::Claude, &seat, true)
2253 .argv
2254 .iter()
2255 .any(|a| a == "--disallowed-tools")
2256 );
2257
2258 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2259 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2260 assert!(
2261 !agy.argv
2262 .iter()
2263 .any(|a| a == "--dangerously-skip-permissions")
2264 );
2265 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2266 assert!(
2267 agy_rw
2268 .argv
2269 .windows(2)
2270 .any(|w| w == ["--mode", "accept-edits"])
2271 );
2272 assert!(
2273 agy_rw
2274 .argv
2275 .iter()
2276 .any(|a| a == "--dangerously-skip-permissions")
2277 );
2278 let agy_prompt = agy_rw
2283 .argv
2284 .iter()
2285 .position(|a| a == "-p")
2286 .map(|i| agy_rw.argv[i + 1].clone())
2287 .expect("agy takes its prompt with -p");
2288 assert!(
2289 agy_prompt.starts_with('@'),
2290 "agy must get a file reference, got {agy_prompt:?}"
2291 );
2292 assert!(
2293 !agy_prompt.contains("Read the file at"),
2294 "the prose pointer is for CLIs with no file syntax"
2295 );
2296
2297 for allow_write in [false, true] {
2302 assert!(
2303 plan_for(AgentKind::Opencode, &seat, allow_write)
2304 .argv
2305 .iter()
2306 .any(|a| a == "--auto"),
2307 "opencode needs --auto even to read (allow_write = {allow_write})"
2308 );
2309 }
2310 }
2311
2312 #[test]
2315 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2316 let seat = SeatState::new("deputy-x", "a", 7);
2317 let roots = [PathBuf::from("/data/questions")];
2318 let mk = |allow_write: bool| {
2319 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2320 i.writable = &roots;
2321 build_command(
2322 &spec(AgentKind::Codex, None),
2323 &seat,
2324 &i,
2325 Path::new("/art/p.md"),
2326 )
2327 .unwrap()
2328 };
2329 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2330 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2331 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2332 }
2333
2334 #[test]
2335 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2336 let mut i = inv(Path::new("."), Path::new("/art"), true);
2337 for node in [
2338 "implement",
2339 "fix",
2340 "graft",
2341 "gate-fix",
2342 "land",
2343 "rebase",
2344 "chat",
2345 ] {
2346 i.node = node;
2347 assert!(repository_writer(&i), "{node}");
2348 i.allow_write = false;
2349 assert!(!repository_writer(&i), "{node}");
2350 i.allow_write = true;
2351 }
2352 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2353 i.node = node;
2354 assert!(!repository_writer(&i), "{node}");
2355 }
2356 i.node = "chat";
2357 let roots = [PathBuf::from("/questions")];
2358 i.writable = &roots;
2359 assert!(
2360 repository_writer(&i),
2361 "explicit roots do not change the role"
2362 );
2363 assert!(
2364 !repository_seat(&i),
2365 "chat turns bypass repository scheduling"
2366 );
2367 }
2368
2369 #[test]
2370 fn coordination_classification_is_independent_of_write_grants() {
2371 let roots = [PathBuf::from("/questions")];
2372 for node in [
2373 crate::queue::CHAT_NODE,
2374 "implement",
2375 "fix",
2376 "graft",
2377 "gate-fix",
2378 "land",
2379 "rebase",
2380 "review",
2381 "judge",
2382 "advise",
2383 "deputy",
2384 "waiter",
2385 "conduct",
2386 "dupes",
2387 "talk-title",
2388 ] {
2389 for allow_write in [false, true] {
2390 for writable in [&[][..], &roots[..]] {
2391 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2392 i.node = node;
2393 i.writable = writable;
2394 assert_eq!(
2395 repository_seat(&i),
2396 !matches!(
2397 node,
2398 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2399 ),
2400 "{node}, allow_write={allow_write}, writable={writable:?}"
2401 );
2402 if node == crate::queue::CHAT_NODE {
2403 assert_eq!(repository_writer(&i), allow_write);
2404 }
2405 }
2406 }
2407 }
2408 }
2409
2410 #[test]
2411 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2412 let roots = [
2413 PathBuf::from("/shared/git"),
2414 PathBuf::from("/state/questions"),
2415 ];
2416 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2417 for resumed in [false, true] {
2418 let mut seat = SeatState::new("impl-A", "a", 7);
2419 if resumed {
2420 seat.turns = 1;
2421 seat.captured_session = Some("session-id".into());
2422 }
2423 for allow_write in [false, true] {
2424 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2425 i.writable = &roots;
2426 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2427 .unwrap();
2428 for root in &roots {
2429 if kind == AgentKind::Antigravity {
2430 assert_eq!(
2431 plan.argv.windows(2).any(|w| {
2432 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2433 }),
2434 allow_write,
2435 "resumed={resumed}: {:?}",
2436 plan.argv
2437 );
2438 }
2439 assert_eq!(
2440 plan.argv
2441 .iter()
2442 .any(|arg| arg.contains(root.to_str().unwrap())),
2443 allow_write,
2444 "{kind:?}, resumed={resumed}: {:?}",
2445 plan.argv
2446 );
2447 }
2448 if kind == AgentKind::Codex && resumed {
2449 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2450 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2451 }
2452 }
2453 }
2454 }
2455 }
2456
2457 #[test]
2458 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2459 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2460 let mut seat = SeatState::new("talk", "a", 7);
2461 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2462 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2463 i.unsandboxed = unsandboxed;
2464 build_command(
2465 &spec(AgentKind::Codex, None),
2466 seat,
2467 &i,
2468 Path::new("/art/p.md"),
2469 )
2470 .unwrap()
2471 };
2472
2473 let first = build(&seat, true, true);
2475 assert!(first.argv.iter().any(|a| a == BYPASS));
2476 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2477 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2478
2479 seat.captured_session = Some("thread-1".to_owned());
2481 seat.turns = 1;
2482 let resumed = build(&seat, true, true);
2483 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2484 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2485
2486 let ro = build(&seat, false, true);
2488 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2489 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2490 }
2491
2492 #[test]
2493 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2494 let mut seat = SeatState::new("judge-1", "a", 7);
2495
2496 let ro = plan_for(AgentKind::Codex, &seat, false);
2500 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2501 let rw = plan_for(AgentKind::Codex, &seat, true);
2502 assert!(
2503 rw.argv
2504 .windows(2)
2505 .any(|w| w == ["--sandbox", "workspace-write"])
2506 );
2507 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2510 ro_flagged.unsandboxed = true;
2511 let ro_flagged = build_command(
2512 &spec(AgentKind::Codex, None),
2513 &seat,
2514 &ro_flagged,
2515 Path::new("/art/p.md"),
2516 )
2517 .unwrap();
2518 assert!(
2519 ro_flagged
2520 .argv
2521 .windows(2)
2522 .any(|w| w == ["--sandbox", "read-only"])
2523 );
2524 for p in [&ro, &rw, &ro_flagged] {
2525 assert!(
2526 !p.argv
2527 .iter()
2528 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2529 "the bypass defeats the only enforced read-only mode we have"
2530 );
2531 assert!(
2533 p.argv
2534 .windows(2)
2535 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2536 "an unattended seat that asks for approval blocks until timeout"
2537 );
2538 }
2539
2540 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2542 assert_eq!(
2543 ro.argv.last().map(String::as_str),
2544 Some("-"),
2545 "without the `-` argument codex waits for a prompt it never gets"
2546 );
2547
2548 seat.turns = 1;
2552 assert!(!has_session(AgentKind::Codex, &seat, true));
2553 assert!(
2554 !plan_for(AgentKind::Codex, &seat, true)
2555 .argv
2556 .iter()
2557 .any(|a| a == "resume")
2558 );
2559 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2560 let resumed = plan_for(AgentKind::Codex, &seat, true);
2561 let at = resumed
2562 .argv
2563 .iter()
2564 .position(|a| a == "resume")
2565 .expect("resumes by subcommand");
2566 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2567 assert!(
2568 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2569 "every option precedes the subcommand"
2570 );
2571 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2572 }
2573
2574 #[test]
2577 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2578 let mut seat = SeatState::new("review-1", "a", 7);
2579
2580 let first = plan_for(AgentKind::Omp, &seat, false);
2584 assert!(first.argv.iter().any(|a| a == "-p"));
2585 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2586 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2587 assert!(
2588 !first.argv.iter().any(|a| a == "do the thing"),
2589 "the prompt reached argv, where Windows caps it"
2590 );
2591
2592 for allow_write in [false, true] {
2598 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2599 assert!(
2600 p.argv.iter().any(|a| a == "--auto-approve"),
2601 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2602 );
2603 assert!(
2604 !p.argv
2605 .iter()
2606 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2607 "nothing ever asks for the bypass"
2608 );
2609 }
2610
2611 seat.turns = 1;
2614 assert!(!has_session(AgentKind::Omp, &seat, true));
2615 assert!(
2616 !plan_for(AgentKind::Omp, &seat, true)
2617 .argv
2618 .iter()
2619 .any(|a| a == "--resume")
2620 );
2621 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2622 let resumed = plan_for(AgentKind::Omp, &seat, true);
2623 assert!(
2624 resumed
2625 .argv
2626 .windows(2)
2627 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2628 "a captured id is what makes the next turn a resume"
2629 );
2630 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2633 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2635 }
2636
2637 #[test]
2642 fn omp_takes_the_answer_without_an_agent_end_line() {
2643 let stream = concat!(
2644 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2645 "\n",
2646 r#"{"type":"agent_start"}"#,
2647 "\n",
2648 r#"{"type":"turn_start"}"#,
2649 "\n",
2650 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2651 "\n",
2652 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2653 "\n",
2654 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2655 "\n",
2656 );
2657 let out = extract(AgentKind::Omp, stream);
2658 assert_eq!(
2659 out.text, "{\"vote\":\"approve\"}",
2660 "the last assistant text block is the answer even with no agent_end"
2661 );
2662 assert_eq!(
2663 out.session.as_deref(),
2664 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2665 );
2666 }
2667
2668 #[test]
2672 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2673 let stream = concat!(
2674 r#"{"type":"session","version":3,"id":"s1"}"#,
2675 "\n",
2676 "{\"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問題ありません。\"}]}]}",
2677 "\n",
2678 );
2679 let out = extract(AgentKind::Omp, stream);
2680 assert_eq!(
2681 out.text, "## 判定\n\n問題ありません。",
2682 "the narration is not the answer, and non-ASCII survives intact"
2683 );
2684 assert_eq!(out.session.as_deref(), Some("s1"));
2685 }
2686
2687 #[test]
2690 fn omp_skips_non_json_lines() {
2691 let stream = concat!(
2692 "Warning: some omp notice\n",
2693 r#"{"type":"session","version":3,"id":"s2"}"#,
2694 "\n",
2695 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2696 "\n",
2697 "trailing junk",
2698 "\n",
2699 );
2700 let out = extract(AgentKind::Omp, stream);
2701 assert_eq!(out.text, "the answer");
2702 assert_eq!(out.session.as_deref(), Some("s2"));
2703 }
2704
2705 #[test]
2707 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2708 let stream = concat!(
2709 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2710 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2711 "\n",
2712 r#"{"type":"turn.started"}"#,
2713 "\n",
2714 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2715 "\n",
2716 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2717 "\n",
2718 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2719 "\n",
2720 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2721 "\n",
2722 );
2723 let out = extract(AgentKind::Codex, stream);
2724 assert_eq!(
2725 out.text, "{\"verdict\": \"ok\"}",
2726 "the last agent message is the answer; earlier ones narrate"
2727 );
2728 assert_eq!(
2729 out.session.as_deref(),
2730 Some("01a07440-4545-7492-85c1-024e3259a90a")
2731 );
2732 assert_eq!(out.status.as_deref(), Some("success"));
2733
2734 let failed = concat!(
2735 r#"{"type":"thread.started","thread_id":"t1"}"#,
2736 "\n",
2737 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2738 "\n",
2739 );
2740 assert_eq!(
2741 extract(AgentKind::Codex, failed).status.as_deref(),
2742 Some("error")
2743 );
2744 }
2745
2746 #[test]
2747 fn captured_sessions_resume_only_once_reported() {
2748 let mut seat = SeatState::new("impl-A", "a", 7);
2749 seat.turns = 1;
2750 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2751 assert!(!has_session(kind, &seat, true));
2752 let p = plan_for(kind, &seat, true);
2753 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2754 }
2755
2756 seat.captured_session = Some("sid".to_owned());
2757 assert!(has_session(AgentKind::Opencode, &seat, true));
2758 assert!(
2759 plan_for(AgentKind::Opencode, &seat, true)
2760 .argv
2761 .windows(2)
2762 .any(|w| w == ["-s", "sid"])
2763 );
2764 assert!(
2765 plan_for(AgentKind::Antigravity, &seat, true)
2766 .argv
2767 .windows(2)
2768 .any(|w| w == ["--conversation", "sid"])
2769 );
2770 }
2771
2772 #[test]
2773 fn sessions_disabled_never_resumes() {
2774 let mut seat = SeatState::new("impl-A", "a", 7);
2775 seat.turns = 3;
2776 seat.captured_session = Some("sid".to_owned());
2777 for kind in [
2778 AgentKind::Claude,
2779 AgentKind::Opencode,
2780 AgentKind::Antigravity,
2781 ] {
2782 assert!(!has_session(kind, &seat, false));
2783 }
2784 }
2785
2786 #[test]
2787 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2788 let seat = SeatState::new("judge-1", "a", 7);
2789 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2790 let p = plan_for(kind, &seat, false);
2791 assert!(
2792 p.argv.iter().all(|a| a != "do the thing"),
2793 "{kind:?} put the prompt on the command line"
2794 );
2795 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2796 }
2797 let p = plan_for(AgentKind::Antigravity, &seat, false);
2799 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2800 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2801 assert!(p.stdin.is_none());
2802 }
2803
2804 #[test]
2805 fn agy_print_timeout_tracks_the_node_budget() {
2806 let seat = SeatState::new("impl-A", "a", 7);
2807 let p = build_command(
2808 &spec(AgentKind::Antigravity, None),
2809 &seat,
2810 &Invocation {
2811 coordination: None,
2812 fetched_base: None,
2813 cwd: Path::new("."),
2814 prompt: "p",
2815 timeout: Duration::from_secs(3600),
2816 allow_write: true,
2817 unsandboxed: false,
2818 sessions: true,
2819 artifacts: Path::new("/art"),
2820 stem: "t",
2821 run: "test-run",
2822 node: "test",
2823 cache_dir: None,
2824 attachments: &[],
2825 writable: &[],
2826 },
2827 Path::new("/art/p.md"),
2828 )
2829 .unwrap();
2830 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2831 }
2832
2833 #[test]
2840 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2841 let mut s = spec(AgentKind::Antigravity, None);
2842 s.prompt_delivery = Some(Delivery::Argv);
2843 let seat = SeatState::new("talk", "a", 7);
2844 let atts = [PathBuf::from("/art/attachments/abc.png")];
2845
2846 let without = build_command(
2847 &s,
2848 &seat,
2849 &Invocation {
2850 attachments: &[],
2851 writable: &[],
2852 ..inv(Path::new("."), Path::new("/art"), true)
2853 },
2854 Path::new("/art/p.md"),
2855 )
2856 .unwrap();
2857 assert!(
2858 !without.argv.iter().any(|a| a == "--add-dir"),
2859 "no attachment, no reason to widen the sandbox: {without:?}"
2860 );
2861
2862 let with = build_command(
2863 &s,
2864 &seat,
2865 &Invocation {
2866 attachments: &atts,
2867 writable: &[],
2868 ..inv(Path::new("."), Path::new("/art"), true)
2869 },
2870 Path::new("/art/p.md"),
2871 )
2872 .unwrap();
2873 assert!(
2874 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2875 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2876 );
2877 }
2878
2879 #[test]
2885 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2886 let seat = SeatState::new("plan", "a", 7);
2887 let atts = [
2888 PathBuf::from("/art/attachments/own.png"),
2889 PathBuf::from("/other-chat/attachments/inherited.png"),
2890 ];
2891
2892 let p = build_command(
2893 &spec(AgentKind::Antigravity, None),
2894 &seat,
2895 &Invocation {
2896 attachments: &atts,
2897 writable: &[],
2898 ..inv(Path::new("."), Path::new("/art"), true)
2899 },
2900 Path::new("/art/p.md"),
2901 )
2902 .unwrap();
2903
2904 assert!(
2905 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2906 "this conversation's own artifacts dir must still be granted: {p:?}"
2907 );
2908 assert!(
2909 p.argv
2910 .windows(2)
2911 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2912 "the inherited attachment's own directory must be granted too: {p:?}"
2913 );
2914 }
2915
2916 #[test]
2917 fn command_agents_get_placeholders_substituted() {
2918 let seat = SeatState::new("impl-A", "a", 7);
2919 let p = plan_for(AgentKind::Command, &seat, true);
2920 assert_eq!(p.argv[0], "echo");
2921 assert_eq!(p.argv[1], "impl-A");
2922 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2923 }
2924
2925 #[test]
2926 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2927 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2929 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2930 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2931 let out = extract(AgentKind::Claude, stdout);
2932 let quota = out.quota.as_ref().expect("rate limit must be detected");
2933 assert_eq!(
2934 quota.reset.as_deref(),
2935 Some("4:50am (Asia/Tokyo)"),
2936 "reset time read from the body"
2937 );
2938 }
2939
2940 #[test]
2941 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2942 let out = extract(
2943 AgentKind::Claude,
2944 r#"{"is_error":true,"result":"session limit reached"}"#,
2945 );
2946 let quota = out.quota.expect("rate limit detected without a reset");
2947 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2948 }
2949
2950 #[test]
2951 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2952 for kind in [AgentKind::Claude, AgentKind::Command] {
2953 for reply in [
2954 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2955 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2956 ] {
2957 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2958 }
2959 for reply in [
2960 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2961 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2962 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2963 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2964 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2965 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2966 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2967 ] {
2968 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2969 }
2970 }
2971 }
2972
2973 #[test]
2974 fn ordinary_failures_are_never_quota() {
2975 let claude_fail = extract(
2977 AgentKind::Claude,
2978 r#"{"is_error":true,"result":"account does not exist"}"#,
2979 );
2980 assert!(claude_fail.quota.is_none());
2981
2982 let cmd_fail = extract(AgentKind::Command, "boom");
2984 assert!(cmd_fail.quota.is_none());
2985
2986 let success = extract(
2988 AgentKind::Command,
2989 r#"{"is_error":false,"result":"session limit is fine"}"#,
2990 );
2991 assert!(success.quota.is_none());
2992 }
2993
2994 #[test]
3002 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
3003 let stream = concat!(
3004 r#"{"type":"thread.started","thread_id":"t1"}"#,
3005 "\n",
3006 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"}}"#,
3007 "\n",
3008 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"}}"#,
3009 "\n",
3010 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
3011 "\n",
3012 r#"{"type":"turn.completed"}"#,
3013 "\n",
3014 );
3015 let out = extract(AgentKind::Codex, stream);
3016 assert_eq!(out.text, "Both tests in the target pass.");
3017 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
3018
3019 let paired = &out.commands[0];
3020 assert_eq!(paired.id, "item49");
3021 assert_eq!(paired.exit_code, Some(1));
3022 assert!(paired.description.contains("graph_cached_gate"));
3023 assert!(paired.result_summary.contains("1 failed"));
3024
3025 let solo = &out.commands[1];
3026 assert_eq!(solo.exit_code, Some(0));
3027
3028 assert!(
3032 out.commands
3033 .iter()
3034 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
3035 "a failed run of the actual target must still be visible: {:?}",
3036 out.commands
3037 );
3038 }
3039
3040 #[test]
3041 fn command_agent_can_carry_the_claude_quota_shape() {
3042 let out = extract(
3043 AgentKind::Command,
3044 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
3045 );
3046 assert!(
3047 out.quota.is_some(),
3048 "a wrapper emitting the claude shape counts as quota"
3049 );
3050 }
3051
3052 #[test]
3053 fn claude_json_result_is_extracted() {
3054 let out = extract(
3055 AgentKind::Claude,
3056 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3057 );
3058 assert_eq!(out.text, "all done");
3059 assert_eq!(out.session.as_deref(), Some("abc"));
3060 assert_eq!(out.status.as_deref(), Some("success"));
3061 }
3062
3063 #[test]
3078 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3079 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"}"#;
3080 let out = extract(AgentKind::Claude, stdout);
3081 assert_eq!(out.status.as_deref(), Some("success"));
3082 assert!(out.quota.is_none());
3083 assert!(!out.text.trim().is_empty());
3084
3085 let agent_out = AgentOutput {
3086 text: out.text.clone(),
3087 exit_code: Some(0),
3088 timed_out: false,
3089 duration_ms: 500,
3090 artifacts: Vec::new(),
3091 quota: out.quota,
3092 dropped: out.dropped,
3093 commands: out.commands,
3094 context_tokens: None,
3095 };
3096 assert!(
3097 agent_out.usable(),
3098 "the CLI turn itself ended cleanly and must read as usable"
3099 );
3100 assert!(
3101 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3102 "a clean CLI turn is not proof the node's own report ever arrived"
3103 );
3104 }
3105
3106 #[test]
3107 fn opencode_event_stream_is_concatenated() {
3108 let stream = concat!(
3109 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3110 "\n",
3111 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3112 "\n",
3113 "garbage line\n",
3114 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3115 "\n"
3116 );
3117 let out = extract(AgentKind::Opencode, stream);
3118 assert_eq!(out.text, "first\nsecond");
3119 assert_eq!(out.session.as_deref(), Some("ses_1"));
3120 }
3121
3122 #[test]
3123 fn agy_json_survives_a_leading_warning_line() {
3124 let stdout = concat!(
3125 "warning: --mode plan has no effect while slash commands are disabled.\n",
3126 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3127 "\n"
3128 );
3129 let out = extract(AgentKind::Antigravity, stdout);
3130 assert_eq!(out.text, "persimmon");
3131 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3132 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3133 }
3134
3135 const AGY_DROPPED: &str = concat!(
3142 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3143 r#""response":"","error":"the connection to the agent was interrupted before "#,
3144 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3145 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3146 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3147 r#""total_tokens":274380}}"#
3148 );
3149
3150 const AGY_QUOTA_OUT: &str = concat!(
3152 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3153 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3154 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3155 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3156 );
3157 const AGY_QUOTA_ERR: &str = concat!(
3158 "error: Individual quota reached. Resets in 1h2m49s.\n",
3159 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3160 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3161 r#""retryable":true}"#,
3162 "\n"
3163 );
3164
3165 #[test]
3166 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3167 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3168 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3169 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3170 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3171 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3173 assert!(stderr_only.reset.is_none());
3174 }
3175
3176 #[test]
3177 fn ordinary_agy_failures_are_not_a_quota() {
3178 assert!(agy_quota(AGY_DROPPED, "").is_none());
3179 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3180 assert!(
3181 agy_quota(
3182 "",
3183 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3184 )
3185 .is_none()
3186 );
3187 assert!(
3188 agy_quota(
3189 "",
3190 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3191 )
3192 .is_none()
3193 );
3194 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3195 }
3196
3197 #[test]
3198 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3199 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3200 let dropped = out.dropped.expect("recognised as undelivered work");
3201 assert_eq!(dropped.output_tokens, 14267);
3202 assert!(
3203 dropped.why.contains("subscriber fell behind"),
3204 "the CLI's own words are kept for the record: {}",
3205 dropped.why
3206 );
3207 assert_eq!(
3210 out.session.as_deref(),
3211 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3212 );
3213 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3214 }
3215
3216 #[test]
3217 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3218 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3222 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3223
3224 let answered = concat!(
3227 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3228 r#""usage":{"output_tokens":10}}"#
3229 );
3230 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3231
3232 let ok = concat!(
3234 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3235 r#""usage":{"output_tokens":10}}"#
3236 );
3237 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3238 }
3239
3240 #[test]
3241 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3242 let out = AgentOutput {
3243 text: String::new(),
3244 exit_code: Some(1),
3245 timed_out: false,
3246 duration_ms: 431_194,
3247 artifacts: Vec::new(),
3248 quota: None,
3249 dropped: Some(Dropped {
3250 why: "subscriber fell behind updates".to_owned(),
3251 output_tokens: 14267,
3252 }),
3253 commands: Vec::new(),
3254 context_tokens: None,
3255 };
3256 assert!(!out.usable());
3257 assert!(out.work_undelivered());
3258 assert!(!out.quota_exhausted());
3261 }
3262
3263 #[test]
3264 fn non_json_stdout_falls_back_to_raw_text() {
3265 let out = extract(AgentKind::Antigravity, "plain answer\n");
3266 assert_eq!(out.text, "plain answer");
3267 assert!(out.session.is_none());
3268 }
3269
3270 #[tokio::test]
3271 async fn command_agent_round_trip_writes_artifacts() {
3272 let dir = tempfile::tempdir().unwrap();
3273 let art = dir.path().join("artifacts");
3274 let mut seat = SeatState::new("impl-A", "a", 7);
3275 let s = command_helper("reply");
3276 let out = invoke(
3277 &s,
3278 &mut seat,
3279 &Invocation {
3280 coordination: None,
3281 fetched_base: None,
3282 cwd: dir.path(),
3283 prompt: "unused",
3284 timeout: Duration::from_secs(30),
3285 allow_write: true,
3286 unsandboxed: false,
3287 sessions: true,
3288 artifacts: &art,
3289 stem: "impl-A",
3290 run: "test-run",
3291 node: "test",
3292 cache_dir: None,
3293 attachments: &[],
3294 writable: &[],
3295 },
3296 )
3297 .await
3298 .unwrap();
3299 assert!(out.usable(), "{out:?}");
3300 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3301 assert_eq!(seat.turns, 1);
3302 assert!(art.join("impl-A.prompt.md").is_file());
3303 assert!(art.join("impl-A.out").is_file());
3304 }
3305
3306 #[tokio::test]
3307 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3308 crate::run::pin_test_home();
3309 let dir = tempfile::tempdir().unwrap();
3313 let cache = dir.path().join("magi-cache");
3314 let mut seat = SeatState::new("impl-A", "a", 7);
3315 let s = command_helper("cache");
3316 let out = invoke(
3317 &s,
3318 &mut seat,
3319 &Invocation {
3320 coordination: None,
3321 fetched_base: None,
3322 cwd: dir.path(),
3323 prompt: "unused",
3324 timeout: Duration::from_secs(30),
3325 allow_write: true,
3326 unsandboxed: false,
3327 sessions: true,
3328 artifacts: &dir.path().join("artifacts"),
3329 stem: "cache",
3330 run: "test-run",
3331 node: "test",
3332 cache_dir: Some(&cache),
3333 attachments: &[],
3334 writable: &[],
3335 },
3336 )
3337 .await
3338 .unwrap();
3339 assert!(out.usable(), "{out:?}");
3340 assert!(
3341 out.text.contains(cache.to_string_lossy().as_ref()),
3342 "the seat must see CARGO_TARGET_DIR = the shared cache"
3343 );
3344 }
3345
3346 #[tokio::test]
3347 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3348 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3356 unsafe {
3361 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3362 }
3363 let dir = tempfile::tempdir().unwrap();
3364 let mut seat = SeatState::new("review-1", "a", 7);
3365 let s = command_helper("no-cache");
3366 let result = invoke(
3367 &s,
3368 &mut seat,
3369 &Invocation {
3370 coordination: None,
3371 fetched_base: None,
3372 cwd: dir.path(),
3373 prompt: "unused",
3374 timeout: Duration::from_secs(30),
3375 allow_write: false,
3376 unsandboxed: false,
3377 sessions: true,
3378 artifacts: &dir.path().join("artifacts"),
3379 stem: "no-cache",
3380 run: "test-run",
3381 node: "test",
3382 cache_dir: None,
3383 attachments: &[],
3384 writable: &[],
3385 },
3386 )
3387 .await;
3388 unsafe {
3393 match &previous {
3394 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3395 None => std::env::remove_var("CARGO_TARGET_DIR"),
3396 }
3397 }
3398 let out = result.unwrap();
3399 assert!(out.usable(), "{out:?}");
3400 assert!(
3401 out.text.contains("ABSENT"),
3402 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3403 out.text
3404 );
3405 }
3406
3407 #[tokio::test]
3408 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3409 let dir = tempfile::tempdir().unwrap();
3410 let mut seat = SeatState::new("impl-A", "a", 7);
3411 let s = command_helper("ignore-stdin");
3414 let big = "x".repeat(1_000_000);
3415 let out = invoke(
3416 &s,
3417 &mut seat,
3418 &Invocation {
3419 coordination: None,
3420 fetched_base: None,
3421 cwd: dir.path(),
3422 prompt: &big,
3423 timeout: Duration::from_secs(60),
3424 allow_write: true,
3425 unsandboxed: false,
3426 sessions: true,
3427 artifacts: &dir.path().join("artifacts"),
3428 stem: "big",
3429 run: "test-run",
3430 node: "test",
3431 cache_dir: None,
3432 attachments: &[],
3433 writable: &[],
3434 },
3435 )
3436 .await
3437 .unwrap();
3438 assert!(out.usable(), "{out:?}");
3439 assert!(out.text.contains("done"), "{}", out.text);
3440 }
3441
3442 #[tokio::test]
3443 async fn agent_turn_cap_limits_a_direct_invocation() {
3444 let dir = tempfile::tempdir().unwrap();
3445 let artifacts = dir.path().join("artifacts");
3446 let mut seat = SeatState::new("impl-A", "helper", 7);
3447 let mut s = command_helper("sleep");
3448 s.max_turn_secs = Some(2);
3449 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3450 .await
3451 .unwrap();
3452 assert!(out.timed_out, "{out:?}");
3453 assert!(out.duration_ms < 20_000, "{out:?}");
3456 }
3457
3458 #[tokio::test]
3459 async fn timeout_is_reported_not_hung() {
3460 let dir = tempfile::tempdir().unwrap();
3461 let mut seat = SeatState::new("impl-A", "a", 7);
3462 let s = command_helper("sleep");
3463 let out = invoke(
3464 &s,
3465 &mut seat,
3466 &Invocation {
3467 coordination: None,
3468 fetched_base: None,
3469 cwd: dir.path(),
3470 prompt: "unused",
3471 timeout: Duration::from_millis(300),
3472 allow_write: true,
3473 unsandboxed: false,
3474 sessions: true,
3475 artifacts: &dir.path().join("artifacts"),
3476 stem: "slow",
3477 run: "test-run",
3478 node: "test",
3479 cache_dir: None,
3480 attachments: &[],
3481 writable: &[],
3482 },
3483 )
3484 .await
3485 .unwrap();
3486 assert!(out.timed_out);
3487 assert!(!out.usable());
3488 }
3489
3490 #[tokio::test]
3491 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3492 let dir = tempfile::tempdir().unwrap();
3498 let artifacts = dir.path().join("artifacts");
3499 let mut seat = SeatState::new("impl-A", "a", 7);
3500 let s = command_helper("chatty-sleep");
3501 let out = invoke(
3502 &s,
3503 &mut seat,
3504 &Invocation {
3505 coordination: None,
3506 fetched_base: None,
3507 cwd: dir.path(),
3508 prompt: "unused",
3509 timeout: Duration::from_secs(10),
3514 allow_write: true,
3515 unsandboxed: false,
3516 sessions: true,
3517 artifacts: &artifacts,
3518 stem: "chatty",
3519 run: "test-run",
3520 node: "test",
3521 cache_dir: None,
3522 attachments: &[],
3523 writable: &[],
3524 },
3525 )
3526 .await
3527 .unwrap();
3528
3529 assert!(out.timed_out, "{out:?}");
3530 assert!(!out.usable(), "a cut-off answer is still not an answer");
3531 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3532 assert!(
3533 recorded.contains("i-said-something"),
3534 "the artifact must keep what arrived before the kill, got {recorded:?}"
3535 );
3536 assert!(
3537 out.text.contains("i-said-something"),
3538 "and the graph must be able to see it too, got {:?}",
3539 out.text
3540 );
3541 }
3542
3543 #[test]
3544 fn missing_programs_reports_command_binaries() {
3545 let mut s = spec(AgentKind::Command, None);
3546 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3547 assert_eq!(
3548 missing_programs(&[s]),
3549 ["definitely-not-a-real-binary-xyz".to_owned()]
3550 );
3551 }
3552
3553 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3554 AgentSpec {
3555 id: id.to_owned(),
3556 kind,
3557 model: None,
3558 command: Vec::new(),
3559 extra_args: Vec::new(),
3560 env: BTreeMap::new(),
3561 prompt_delivery: None,
3562 max_turn_secs: None,
3563 }
3564 }
3565
3566 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3569 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3570 }
3571
3572 #[test]
3573 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3574 let agents = [
3575 pick_spec("oc", AgentKind::Opencode),
3576 pick_spec("opus", AgentKind::Claude),
3577 pick_spec("agy", AgentKind::Antigravity),
3578 ];
3579 let got = pick(&agents, None, &without(&[])).expect("a pick");
3580 assert_eq!(got.id, "opus");
3581 }
3582
3583 #[test]
3584 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3585 let agents = [
3586 pick_spec("opus", AgentKind::Claude),
3587 pick_spec("oc", AgentKind::Opencode),
3588 pick_spec("agy", AgentKind::Antigravity),
3589 ];
3590 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3591 assert_eq!(got.id, "agy");
3592 }
3593
3594 #[test]
3595 fn pick_on_an_empty_roster_says_what_to_install() {
3596 let msg = pick(&[], None, &without(&[]))
3597 .expect_err("nobody to ask")
3598 .to_string();
3599 assert!(msg.contains("roster is empty"), "{msg}");
3600 assert!(msg.contains("claude"), "{msg}");
3601 assert!(msg.contains("magi.toml"), "{msg}");
3602 }
3603
3604 #[test]
3605 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3606 let agents = [
3607 pick_spec("opus", AgentKind::Claude),
3608 pick_spec("oc", AgentKind::Opencode),
3609 ];
3610 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3611 let msg = format!("{err:#}");
3612 assert!(msg.contains("claude"), "{msg}");
3613 assert!(msg.contains("opencode"), "{msg}");
3614 }
3615
3616 #[test]
3617 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3618 let agents = [
3619 pick_spec("opus", AgentKind::Claude),
3620 pick_spec("oc", AgentKind::Opencode),
3621 ];
3622 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3623 assert_eq!(got.id, "oc");
3624 }
3625
3626 #[test]
3627 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3628 let agents = [
3629 pick_spec("opus", AgentKind::Claude),
3630 pick_spec("oc", AgentKind::Opencode),
3631 ];
3632 let msg = pick(&agents, Some("gemini"), &without(&[]))
3633 .expect_err("no such agent")
3634 .to_string();
3635 assert!(msg.contains("gemini"), "{msg}");
3636 assert!(msg.contains("opus, oc"), "{msg}");
3637 }
3638
3639 #[test]
3640 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3641 let agents = [
3642 pick_spec("opus", AgentKind::Claude),
3643 pick_spec("oc", AgentKind::Opencode),
3644 ];
3645 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3646 .expect_err("must not silently substitute another model")
3647 .to_string();
3648 assert!(msg.contains("opencode"), "{msg}");
3649 assert!(msg.contains("--agent"), "{msg}");
3650 }
3651
3652 fn named(id: &str) -> AgentSpec {
3653 AgentSpec {
3654 id: id.to_owned(),
3655 ..spec(AgentKind::Command, None)
3656 }
3657 }
3658
3659 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3660 AgentOutput {
3661 text: text.to_owned(),
3662 exit_code: Some(exit),
3663 timed_out: false,
3664 duration_ms: 0,
3665 artifacts: Vec::new(),
3666 quota: quota.then_some(Quota { reset: None }),
3667 dropped: None,
3668 commands: Vec::new(),
3669 context_tokens: None,
3670 }
3671 }
3672
3673 #[test]
3674 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3675 let agents = [named("a"), named("b"), named("c")];
3676 let all = |_: &AgentSpec| true;
3677 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3678 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3679 assert_eq!(
3680 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3681 ["c", "a"]
3682 );
3683
3684 let one = AgentChoice::from("b");
3685 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3686 assert_eq!(got.len(), 1);
3687 assert_eq!(got[0].id, "b");
3688
3689 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3690 assert_eq!(got.len(), 1, "unset keeps pick's default");
3691 assert_eq!(got[0].id, "a");
3692 let empty = AgentChoice::Chain(Vec::new());
3693 assert_eq!(
3694 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3695 "a"
3696 );
3697 }
3698
3699 #[test]
3700 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3701 let agents = [named("a"), named("b")];
3702 let not_a = |s: &AgentSpec| s.id != "a";
3703 let chain = AgentChoice::Chain(
3704 ["a", "ghost", "b", "b"]
3705 .iter()
3706 .map(|s| (*s).to_owned())
3707 .collect(),
3708 );
3709 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3710 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3711
3712 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3713 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3714 assert_eq!(
3715 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3716 ["b", "a"]
3717 );
3718 }
3719
3720 #[test]
3721 fn a_chain_with_nothing_runnable_names_the_role() {
3722 let agents = [named("a")];
3723 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3724 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3725 .unwrap_err()
3726 .to_string();
3727 assert!(err.contains("conductor"), "{err}");
3728 }
3729
3730 #[test]
3731 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3732 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3733 assert!(chain_advances(&Ok(output("limit", 0, true))));
3734 assert!(chain_advances(&Ok(output("", 0, false))));
3735 assert!(chain_advances(&Ok(output("x", 1, false))));
3736 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3737 }
3738
3739 #[test]
3740 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3741 for out in [
3742 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3743 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3744 r#"{"result":"ok"}"#,
3745 "not json",
3746 ] {
3747 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3748 }
3749 }
3750
3751 #[test]
3752 fn opencode_context_tokens_take_the_last_step_finish() {
3753 let out = concat!(
3754 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3755 "\n",
3756 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3757 "\n",
3758 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3759 "\n"
3760 );
3761 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3762 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3763 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3764 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3765 }
3766
3767 #[test]
3768 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3769 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3770 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3771 }
3772
3773 #[test]
3774 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3775 let out = concat!(
3776 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3777 "\n"
3778 );
3779 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3780 }
3781
3782 #[test]
3783 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3784 let out = concat!(
3785 r#"{"type":"session","id":"s"}"#,
3786 "\n",
3787 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3788 "\n",
3789 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3790 "\n"
3791 );
3792 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3793 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3794 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3795 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3796 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3797 }
3798
3799 #[test]
3800 fn command_agents_report_no_context_tokens() {
3801 let out = r#"{"usage":{"input_tokens":5}}"#;
3802 assert_eq!(context_tokens(AgentKind::Command, out), None);
3803 }
3804}