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,
112 pub sessions: bool,
114 pub artifacts: &'a Path,
116 pub stem: &'a str,
118 pub run: &'a str,
122 pub node: &'a str,
126 pub cache_dir: Option<&'a Path>,
132 pub attachments: &'a [PathBuf],
139 pub writable: &'a [PathBuf],
146}
147
148fn repository_writer(inv: &Invocation<'_>) -> bool {
157 inv.allow_write
158 && (matches!(
159 inv.node,
160 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
161 ) || inv.node == crate::queue::CHAT_NODE)
162}
163
164fn repository_seat(inv: &Invocation<'_>) -> bool {
165 !matches!(
171 inv.node,
172 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
173 )
174}
175
176pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
183 let mut roots = Vec::new();
184 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
185 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
186 if !common.starts_with(&cwd) {
187 roots.push(common);
188 }
189 }
190 for name in ["questions", "notifications"] {
194 let dir = home.join(name);
195 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
196 roots.push(std::fs::canonicalize(dir)?);
197 }
198 Ok(roots)
199}
200
201fn editing_access_note(base: Option<&crate::run::FetchedBase>) -> String {
203 let mut note = String::from(
204 "Launcher repository access contract (applies to this turn and overrides earlier fetch instructions):\n",
205 );
206 if let Some(base) = base {
207 note.push_str(&format!(
208 "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",
209 base.remote, base.branch, base.fetched_at, base.commit,
210 ));
211 } else {
212 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");
213 }
214 note
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct Quota {
225 #[serde(default)]
227 pub cause: QuotaCause,
228 #[serde(default)]
230 pub agent: String,
231 #[serde(default)]
233 pub reset: Option<String>,
234}
235
236#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum QuotaCause {
240 #[default]
242 UsageLimit,
243 Authentication,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct Dropped {
255 pub why: String,
257 pub output_tokens: u64,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct CommandEvidence {
277 pub id: String,
279 pub description: String,
281 pub exit_code: Option<i32>,
283 pub result_summary: String,
285 pub source: String,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct AgentOutput {
292 pub text: String,
294 pub exit_code: Option<i32>,
296 pub timed_out: bool,
298 pub duration_ms: u64,
300 pub artifacts: Vec<String>,
302 #[serde(default)]
306 pub quota: Option<Quota>,
307 #[serde(default)]
311 pub dropped: Option<Dropped>,
312 #[serde(default)]
316 pub commands: Vec<CommandEvidence>,
317 #[serde(default)]
322 pub context_tokens: Option<u64>,
323}
324
325impl AgentOutput {
326 pub fn usable(&self) -> bool {
328 !self.quota_exhausted()
329 && !self.timed_out
330 && self.exit_code == Some(0)
331 && !self.text.trim().is_empty()
332 }
333
334 pub fn quota_exhausted(&self) -> bool {
336 self.quota.is_some()
337 }
338
339 pub fn work_undelivered(&self) -> bool {
344 self.dropped.is_some()
345 }
346}
347
348#[derive(Debug)]
351pub(crate) struct ExecutionInterrupted(pub String);
352
353impl std::fmt::Display for ExecutionInterrupted {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 write!(f, "execution interrupted: {}", self.0)
356 }
357}
358
359impl std::error::Error for ExecutionInterrupted {}
360
361const PIPE_GRACE: Duration = Duration::from_secs(3);
366
367pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
369
370pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
380where
381 R: tokio::io::AsyncRead + Unpin + Send + 'static,
382{
383 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
384 let Some(mut pipe) = pipe else {
385 return (buf, None);
386 };
387 let sink = Arc::clone(&buf);
388 let handle = tokio::spawn(async move {
389 let mut chunk = [0u8; 8192];
390 loop {
391 match pipe.read(&mut chunk).await {
392 Ok(0) | Err(_) => break,
393 Ok(n) => {
394 if let Ok(mut guard) = sink.lock() {
395 guard.extend_from_slice(&chunk[..n]);
396 }
397 }
398 }
399 }
400 });
401 (buf, Some(handle))
402}
403
404pub(crate) async fn collect(
409 buf: &Captured,
410 handle: Option<tokio::task::JoinHandle<()>>,
411 grace: Duration,
412) -> String {
413 if let Some(handle) = handle {
414 if tokio::time::timeout(grace, handle).await.is_err() {
415 tracing::debug!("a pipe is still held open after the child exited");
416 }
417 }
418 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
419 String::from_utf8_lossy(&bytes).into_owned()
420}
421
422pub async fn invoke(
424 spec: &AgentSpec,
425 seat: &mut SeatState,
426 inv: &Invocation<'_>,
427) -> Result<AgentOutput> {
428 let lease = if repository_seat(inv) {
433 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
434 .await?
435 } else {
436 None
437 };
438 let inv = &Invocation {
439 timeout: spec.turn_timeout(inv.timeout),
440 ..inv.clone()
441 };
442 let mut outputs = Vec::new();
443 if repository_seat(inv) {
444 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
445 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
446 })? {
447 outputs.push(guard);
448 }
449 if let Some(cache) = inv.cache_dir {
450 outputs.push(crate::retention::OutputLease::cache(
451 &crate::run::home(),
452 cache,
453 false,
454 )?);
455 }
456 }
457
458 let mut prompt = inv.prompt.to_owned();
459 if inv.node == crate::queue::CHAT_NODE {
460 prompt.push_str("\n\n");
461 prompt.push_str(crate::prompt::CHAT_FOLLOW_THROUGH);
462 }
463 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
464 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()));
465 }
466 let home = repository_writer(inv).then(crate::run::home);
469 let mut writable = inv.writable.to_vec();
470 if let Some(home) = &home {
471 for root in worktree_writable_roots(inv.cwd, home).await? {
472 if !writable.contains(&root) {
473 writable.push(root);
474 }
475 }
476 }
477 if repository_writer(inv) {
478 prompt.push_str("\n\n");
479 prompt.push_str(&editing_access_note(inv.fetched_base));
480 }
481 let inv = &Invocation {
482 writable: &writable,
483 prompt: &prompt,
484 ..inv.clone()
485 };
486 tokio::fs::create_dir_all(inv.artifacts)
487 .await
488 .with_context(|| format!("create {}", inv.artifacts.display()))?;
489 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
490 tokio::fs::write(&prompt_path, inv.prompt)
491 .await
492 .with_context(|| format!("write {}", prompt_path.display()))?;
493
494 let plan = build_command(spec, seat, inv, &prompt_path)?;
495 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
496 tokio::fs::write(
498 inv.artifacts.join(format!("{}.access.json", inv.stem)),
499 serde_json::to_vec_pretty(&serde_json::json!({
500 "launcher_version": env!("CARGO_PKG_VERSION"),
501 "kind": spec.kind,
502 "allow_write": inv.allow_write,
503 "unsandboxed": repository_writer(inv),
504 "writable_roots": inv.writable,
505 "fetched_base": inv.fetched_base,
506 }))?,
507 )
508 .await
509 .context("write seat access evidence")?;
510
511 let started = Instant::now();
512 let child_path = spec
515 .env
516 .iter()
517 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
518 .map(|(_, v)| std::ffi::OsString::from(v))
519 .or_else(|| std::env::var_os("PATH"))
520 .unwrap_or_default();
521 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
522 || plan.argv[0].clone().into(),
523 std::path::PathBuf::into_os_string,
524 );
525 let mut cmd = Command::new(program);
526 cmd.args(&plan.argv[1..])
527 .current_dir(inv.cwd)
528 .envs(&spec.env)
529 .env("MAGI_SEAT", &seat.key)
530 .env("MAGI_TURN", seat.turns.to_string())
531 .env("MAGI_RUN", inv.run)
532 .env("MAGI_NODE", inv.node)
533 .env("MAGI_PROMPT_FILE", &prompt_path)
534 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
535 .env("GIT_TERMINAL_PROMPT", "0")
536 .stdin(if plan.stdin.is_some() {
537 Stdio::piped()
538 } else {
539 Stdio::null()
540 })
541 .stdout(Stdio::piped())
542 .stderr(Stdio::piped())
543 .kill_on_drop(true)
544 .quiet();
547 if let Some(home) = home {
548 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
551 }
552 if let Some(cache) = inv.cache_dir {
553 cmd.env("CARGO_TARGET_DIR", cache);
556 } else {
557 cmd.env_remove("CARGO_TARGET_DIR");
565 }
566
567 #[cfg(any(unix, windows))]
568 let _chat_controls = {
569 use crate::chat_control::bridge::{self, Bridge};
570 bridge::clear_environment(&mut cmd);
571 let bridge = if inv.node == crate::queue::CHAT_NODE {
572 crate::run::try_home().and_then(|home| {
573 match Bridge::start(&home, inv.run, inv.artifacts) {
574 Ok(bridge) => Some(bridge),
575 Err(error) => {
576 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
577 None
578 }
579 }
580 })
581 } else {
582 None
583 };
584 if let Some(bridge) = &bridge {
585 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
589 bridge.attach(&mut cmd);
590 }
591 bridge
592 };
593
594 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
595 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
596 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
600 tokio::spawn(async move {
601 sink.write_all(body.as_bytes()).await.ok();
602 sink.shutdown().await.ok();
603 });
604 }
605
606 let (out_buf, out_reader) = drain(running.child().stdout.take());
623 let (err_buf, err_reader) = drain(running.child().stderr.take());
624
625 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
626 .ok()
627 .map_or(0, |state| state.config.disk.min_free_bytes);
628 let mut paths = vec![inv.cwd.to_owned()];
629 if let Some(cache) = inv.cache_dir {
630 paths.push(cache.to_owned());
631 }
632 let mut pressure = None;
633 let waited = tokio::select! {
634 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
635 reason = crate::retention::emergency(paths, min_free) => {
636 pressure = Some(reason);
637 None
638 }
639 };
640 let (code, timed_out) = match waited {
641 Some(Ok(res)) => {
642 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
643 (status.code(), false)
644 }
645 Some(Err(_)) | None => {
646 if let Some(reason) = &pressure {
647 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
648 } else {
649 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
650 }
651 if !running.coordinated() {
653 running
654 .child()
655 .kill()
656 .await
657 .context("stop timed-out seat")?;
658 }
659 (None, true)
660 }
661 };
662 running.finish().await;
663
664 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
669 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
670
671 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
672 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
673 tokio::fs::write(&out_path, &stdout).await.ok();
674 tokio::fs::write(&err_path, &stderr).await.ok();
675 if let Some(reason) = pressure {
676 return Err(
677 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
678 .context(ExecutionInterrupted(reason)),
679 );
680 }
681
682 let mut extracted = extract(spec.kind, &stdout);
683 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
684 extracted.quota = agy_quota(&stdout, &stderr);
685 }
686 if let Some(quota) = &mut extracted.quota {
687 quota.agent.clone_from(&spec.id);
688 if quota.cause == QuotaCause::Authentication {
689 let _ = crate::notices::Notices::open().raise(crate::notices::Notice::warn(
690 &format!("agent-authentication:{}", spec.id),
691 format!("Agent `{}` cannot authenticate. Re-authenticate this agent, then resume the held task.", spec.id),
692 ));
693 }
694 extracted.dropped = None;
697 tracing::warn!(
698 seat = %seat.key,
699 agent = %spec.id,
700 reset = ?quota.reset,
701 cause = ?quota.cause,
702 "agent needs machine-side recovery"
703 );
704 }
705 if let Some(session) = extracted.session {
706 match spec.kind {
707 AgentKind::Claude => seat.claude_session = Some(session),
708 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
709 seat.captured_session = Some(session);
710 }
711 AgentKind::Command => {}
712 }
713 }
714 if let Some(status) = &extracted.status
715 && !status.eq_ignore_ascii_case("success")
716 {
717 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
718 }
719 let text = if extracted.text.trim().is_empty() {
720 if stdout.trim().is_empty() {
722 stderr.trim().to_owned()
723 } else {
724 stdout.trim().to_owned()
725 }
726 } else {
727 extracted.text
728 };
729 seat.turns += 1;
730
731 Ok(AgentOutput {
732 text,
733 exit_code: code,
734 timed_out,
735 duration_ms: started.elapsed().as_millis() as u64,
736 artifacts: vec![
737 file_name(&prompt_path),
738 file_name(&out_path),
739 file_name(&err_path),
740 ],
741 quota: extracted.quota,
742 dropped: extracted.dropped,
743 commands: extracted.commands,
744 context_tokens: extracted.context_tokens,
745 })
746}
747
748fn file_name(p: &Path) -> String {
749 p.file_name()
750 .unwrap_or_default()
751 .to_string_lossy()
752 .into_owned()
753}
754
755#[derive(Debug)]
757struct Plan {
758 argv: Vec<String>,
759 stdin: Option<String>,
760}
761
762impl Plan {
763 fn logged_argv(&self) -> Vec<&str> {
766 let mut settings_value = false;
767 self.argv
768 .iter()
769 .map(|arg| {
770 if settings_value {
771 settings_value = false;
772 "[redacted]"
773 } else if arg == "--settings" {
774 settings_value = true;
775 arg.as_str()
776 } else if arg.starts_with("--settings=") {
777 "--settings=[redacted]"
778 } else {
779 arg.as_str()
780 }
781 })
782 .collect()
783 }
784}
785
786fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
797 if matches!(kind, AgentKind::Antigravity) {
798 return format!("@{}", prompt_path.display());
799 }
800 format!(
801 "Read the file at {} and follow every instruction in it exactly. That \
802 file is your complete task description; this message contains nothing \
803 else.",
804 prompt_path.display()
805 )
806}
807
808fn build_command(
809 spec: &AgentSpec,
810 seat: &SeatState,
811 inv: &Invocation<'_>,
812 prompt_path: &Path,
813) -> Result<Plan> {
814 let mut argv: Vec<String> = Vec::new();
815 let mut stdin: Option<String> = None;
816 let delivery = spec.delivery();
817 let resuming = has_session(spec.kind, seat, inv.sessions);
818
819 match spec.kind {
820 AgentKind::Claude => {
821 argv.push("claude".to_owned());
826 argv.push("-p".to_owned());
827 argv.push("--output-format".to_owned());
828 argv.push("json".to_owned());
829 if let Some(m) = &spec.model {
830 argv.push("--model".to_owned());
831 argv.push(m.clone());
832 }
833 if inv.sessions {
834 let uuid = seat
835 .claude_session
836 .as_deref()
837 .context("claude seat is missing its session uuid")?;
838 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
839 argv.push(uuid.to_owned());
840 }
841 argv.push("--permission-mode".to_owned());
842 argv.push("bypassPermissions".to_owned());
843 if !inv.allow_write {
844 argv.push("--disallowed-tools".to_owned());
845 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
846 }
847 }
848 AgentKind::Opencode => {
849 argv.push("opencode".to_owned());
853 argv.push("run".to_owned());
854 argv.push("--format".to_owned());
855 argv.push("json".to_owned());
856 argv.push("--dir".to_owned());
857 argv.push(inv.cwd.to_string_lossy().into_owned());
858 argv.push("--auto".to_owned());
867 if let Some(m) = &spec.model {
868 argv.push("-m".to_owned());
869 argv.push(m.clone());
870 }
871 if resuming {
872 argv.push("-s".to_owned());
873 argv.push(
874 seat.captured_session
875 .clone()
876 .expect("has_session checked the id is present"),
877 );
878 }
879 }
880 AgentKind::Antigravity => {
881 argv.push("agy".to_owned());
882 argv.push("--output-format".to_owned());
883 argv.push("json".to_owned());
884 argv.push("--print-timeout".to_owned());
887 argv.push(format!("{}s", inv.timeout.as_secs()));
888 argv.push("--mode".to_owned());
889 argv.push(
890 if inv.allow_write {
891 "accept-edits"
892 } else {
893 "plan"
894 }
895 .to_owned(),
896 );
897 if inv.allow_write {
898 argv.push("--dangerously-skip-permissions".to_owned());
899 }
900 if let Some(m) = &spec.model {
901 argv.push("--model".to_owned());
902 argv.push(m.clone());
903 }
904 if resuming {
905 argv.push("--conversation".to_owned());
906 argv.push(
907 seat.captured_session
908 .clone()
909 .expect("has_session checked the id is present"),
910 );
911 }
912 let mut add_dirs: Vec<String> = Vec::new();
922 if delivery == Delivery::File || !inv.attachments.is_empty() {
923 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
924 }
925 for path in inv.attachments {
926 let Some(parent) = path.parent() else {
927 continue;
928 };
929 if parent.starts_with(inv.artifacts) {
930 continue;
931 }
932 let dir = parent.to_string_lossy().into_owned();
933 if !add_dirs.contains(&dir) {
934 add_dirs.push(dir);
935 }
936 }
937 if inv.allow_write {
938 for path in inv.writable {
939 let dir = path.to_string_lossy().into_owned();
940 if !add_dirs.contains(&dir) {
941 add_dirs.push(dir);
942 }
943 }
944 }
945 for dir in add_dirs {
946 argv.push("--add-dir".to_owned());
947 argv.push(dir);
948 }
949 }
950 AgentKind::Codex => {
951 argv.push("codex".to_owned());
957 argv.push("exec".to_owned());
958 argv.push("--json".to_owned());
959 argv.push("--skip-git-repo-check".to_owned());
962 argv.push("-C".to_owned());
963 argv.push(inv.cwd.to_string_lossy().into_owned());
964 if repository_writer(inv) {
974 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
975 } else {
976 argv.push("--sandbox".to_owned());
977 argv.push(
978 if inv.allow_write {
979 "workspace-write"
980 } else {
981 "read-only"
982 }
983 .to_owned(),
984 );
985 argv.push("-c".to_owned());
988 argv.push("approval_policy=\"never\"".to_owned());
989 if inv.allow_write && !inv.writable.is_empty() {
990 let roots: Vec<String> = inv
991 .writable
992 .iter()
993 .map(|p| p.to_string_lossy().into_owned())
994 .collect();
995 argv.push("-c".to_owned());
996 argv.push(format!(
997 "sandbox_workspace_write.writable_roots={}",
998 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
999 ));
1000 }
1001 }
1002 if let Some(m) = &spec.model {
1003 argv.push("-m".to_owned());
1004 argv.push(m.clone());
1005 }
1006 if resuming {
1012 argv.push("resume".to_owned());
1013 argv.push(
1014 seat.captured_session
1015 .clone()
1016 .expect("has_session checked the id is present"),
1017 );
1018 }
1019 }
1020 AgentKind::Omp => {
1021 argv.push("omp".to_owned());
1025 argv.push("-p".to_owned());
1026 argv.push("--mode=json".to_owned());
1027 argv.push("--auto-approve".to_owned());
1037 if let Some(m) = &spec.model {
1038 argv.push("--model".to_owned());
1039 argv.push(m.clone());
1040 }
1041 if resuming {
1047 argv.push("--resume".to_owned());
1048 argv.push(
1049 seat.captured_session
1050 .clone()
1051 .expect("has_session checked the id is present"),
1052 );
1053 }
1054 }
1055 AgentKind::Command => {
1056 if spec.command.is_empty() {
1061 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1062 }
1063 let vars: BTreeMap<&str, String> = BTreeMap::from([
1064 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1065 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1066 ("{label}", seat.key.clone()),
1067 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1068 ]);
1069 for raw in &spec.command {
1070 let mut arg = raw.clone();
1071 for (k, v) in &vars {
1072 if arg.contains(k) {
1073 arg = arg.replace(k, v);
1074 }
1075 }
1076 argv.push(arg);
1077 }
1078 }
1079 }
1080
1081 argv.extend(spec.extra_args.iter().cloned());
1082
1083 if spec.kind == AgentKind::Antigravity {
1086 argv.push("-p".to_owned());
1087 }
1088 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1091 argv.push("-".to_owned());
1092 }
1093 match delivery {
1094 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1095 argv.push(pointer(spec.kind, prompt_path));
1097 }
1098 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1099 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1100 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1101 }
1102
1103 Ok(Plan { argv, stdin })
1104}
1105
1106#[derive(Debug, Default)]
1108struct Extracted {
1109 text: String,
1110 session: Option<String>,
1111 status: Option<String>,
1112 quota: Option<Quota>,
1113 dropped: Option<Dropped>,
1114 commands: Vec<CommandEvidence>,
1115 context_tokens: Option<u64>,
1116}
1117
1118fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1120 let mut extracted = extract_answer(kind, stdout);
1121 extracted.context_tokens = context_tokens(kind, stdout);
1122 extracted
1123}
1124
1125fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1127 v.get(key).and_then(serde_json::Value::as_u64)
1128}
1129
1130fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1135 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1136 let base = first(input)?;
1137 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1138 Some(base.saturating_add(cached))
1139}
1140
1141fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1162 use serde_json::Value;
1163 let lines = || {
1164 stdout
1165 .lines()
1166 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1167 };
1168 match kind {
1169 AgentKind::Claude => None,
1170 AgentKind::Opencode => lines()
1171 .filter(|v| {
1172 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1174 || v.get("part")
1175 .and_then(|p| p.get("type"))
1176 .and_then(|t| t.as_str())
1177 == Some("step-finish")
1178 })
1179 .filter_map(|v| {
1180 let tokens = v.get("part")?.get("tokens")?;
1181 let base = uint(tokens, "input")?;
1182 let cache = tokens.get("cache");
1183 let cached = ["read", "write"]
1184 .iter()
1185 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1186 .sum::<u64>();
1187 Some(base.saturating_add(cached))
1188 })
1189 .next_back(),
1190 AgentKind::Antigravity => None,
1191 AgentKind::Codex => None,
1192 AgentKind::Omp => lines()
1193 .flat_map(|v| {
1194 match v.get("type").and_then(|t| t.as_str()) {
1197 Some("agent_end") => v
1198 .get("messages")
1199 .and_then(|m| m.as_array())
1200 .cloned()
1201 .unwrap_or_default(),
1202 Some("turn_end") | Some("message_end") => {
1203 v.get("message").cloned().into_iter().collect()
1204 }
1205 _ => Vec::new(),
1206 }
1207 })
1208 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1209 .filter_map(|m| {
1210 input_side(
1211 m.get("usage")?,
1212 &["input", "input_tokens"],
1213 &[
1214 "cacheRead",
1215 "cacheWrite",
1216 "cache_read_tokens",
1217 "cache_write_tokens",
1218 ],
1219 )
1220 })
1221 .next_back(),
1222 AgentKind::Command => None,
1223 }
1224}
1225
1226pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1229 extract_answer(kind, stdout).text
1230}
1231
1232fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1233 match kind {
1234 AgentKind::Claude => {
1235 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1236 return Extracted {
1237 text: stdout.trim().to_owned(),
1238 ..Extracted::default()
1239 };
1240 };
1241 Extracted {
1242 text: v
1243 .get("result")
1244 .and_then(|r| r.as_str())
1245 .unwrap_or_default()
1246 .to_owned(),
1247 session: v
1248 .get("session_id")
1249 .and_then(|s| s.as_str())
1250 .map(str::to_owned),
1251 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1252 if e {
1253 "error".to_owned()
1254 } else {
1255 "success".to_owned()
1256 }
1257 }),
1258 quota: claude_quota(&v),
1259 dropped: None,
1262 commands: Vec::new(),
1263 context_tokens: None,
1264 }
1265 }
1266 AgentKind::Opencode => {
1267 let mut text = String::new();
1269 let mut session = None;
1270 for line in stdout.lines() {
1271 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1272 continue;
1273 };
1274 if session.is_none() {
1275 session = v
1276 .get("sessionID")
1277 .and_then(|s| s.as_str())
1278 .map(str::to_owned);
1279 }
1280 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1281 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1282 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1283 {
1284 if !text.is_empty() {
1285 text.push('\n');
1286 }
1287 text.push_str(t);
1288 }
1289 }
1290 Extracted {
1291 text,
1292 session,
1293 status: None,
1294 quota: None,
1295 dropped: None,
1296 commands: Vec::new(),
1297 context_tokens: None,
1298 }
1299 }
1300 AgentKind::Antigravity => {
1301 let obj = stdout
1304 .lines()
1305 .rev()
1306 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1307 let Some(v) = obj else {
1308 return Extracted {
1309 text: stdout.trim().to_owned(),
1310 ..Extracted::default()
1311 };
1312 };
1313 Extracted {
1314 text: v
1315 .get("response")
1316 .and_then(|r| r.as_str())
1317 .unwrap_or_default()
1318 .trim()
1319 .to_owned(),
1320 session: v
1321 .get("conversation_id")
1322 .and_then(|s| s.as_str())
1323 .map(str::to_owned),
1324 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1325 quota: None,
1326 dropped: dropped_stream(&v),
1327 commands: Vec::new(),
1328 context_tokens: None,
1329 }
1330 }
1331 AgentKind::Codex => {
1332 let mut text = String::new();
1354 let mut session = None;
1355 let mut status = None;
1356 let mut commands = Vec::new();
1357 for line in stdout.lines() {
1358 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1359 continue;
1360 };
1361 match v.get("type").and_then(|t| t.as_str()) {
1362 Some("thread.started") => {
1363 session = v
1364 .get("thread_id")
1365 .and_then(|s| s.as_str())
1366 .map(str::to_owned);
1367 }
1368 Some("item.completed") => {
1369 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1370 match item.get("type").and_then(|t| t.as_str()) {
1371 Some("agent_message") => {
1372 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1373 text = t.trim().to_owned();
1374 }
1375 }
1376 Some("command_execution") => {
1377 commands.push(command_evidence(item));
1378 }
1379 _ => {}
1380 }
1381 }
1382 Some("turn.completed") => status = Some("success".to_owned()),
1383 Some("turn.failed") => status = Some("error".to_owned()),
1384 _ => {}
1385 }
1386 }
1387 Extracted {
1388 text,
1389 session,
1390 status,
1391 quota: codex_quota(stdout),
1392 dropped: None,
1393 commands,
1394 context_tokens: None,
1395 }
1396 }
1397 AgentKind::Omp => {
1398 let mut text = String::new();
1419 let mut session = None;
1420 for line in stdout.lines() {
1421 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1422 continue;
1423 };
1424 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1425 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1426 continue;
1427 }
1428 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1432 {
1433 Some("agent_end") => v
1434 .get("messages")
1435 .and_then(|m| m.as_array())
1436 .map(|m| m.iter().collect())
1437 .unwrap_or_default(),
1438 Some("turn_end") | Some("message_end") => {
1439 v.get("message").into_iter().collect()
1440 }
1441 _ => continue,
1442 };
1443 for message in messages {
1444 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1445 continue;
1446 }
1447 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1448 continue;
1449 };
1450 for part in parts {
1451 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1452 continue;
1453 }
1454 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1455 && !t.trim().is_empty()
1456 {
1457 text = t.trim().to_owned();
1458 }
1459 }
1460 }
1461 }
1462 Extracted {
1463 text,
1464 session,
1465 status: None,
1466 quota: None,
1467 dropped: None,
1468 commands: Vec::new(),
1469 context_tokens: None,
1470 }
1471 }
1472 AgentKind::Command => {
1473 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1478 let quota = parsed.as_ref().and_then(claude_quota);
1479 let dropped = parsed.as_ref().and_then(dropped_stream);
1482 Extracted {
1483 text: stdout.trim().to_owned(),
1484 session: None,
1485 status: None,
1486 quota,
1487 dropped,
1488 commands: Vec::new(),
1489 context_tokens: None,
1490 }
1491 }
1492 }
1493}
1494
1495fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1502 let description = match item.get("command") {
1503 Some(serde_json::Value::String(s)) => s.clone(),
1504 Some(serde_json::Value::Array(parts)) => parts
1505 .iter()
1506 .filter_map(|p| p.as_str())
1507 .collect::<Vec<_>>()
1508 .join(" "),
1509 _ => String::new(),
1510 };
1511 let result_summary = item
1512 .get("aggregated_output")
1513 .and_then(|o| o.as_str())
1514 .map(|s| tail_chars(s.trim(), 400))
1515 .unwrap_or_default();
1516 CommandEvidence {
1517 id: item
1518 .get("id")
1519 .and_then(|s| s.as_str())
1520 .unwrap_or_default()
1521 .to_owned(),
1522 description,
1523 exit_code: item
1524 .get("exit_code")
1525 .and_then(serde_json::Value::as_i64)
1526 .map(|e| e as i32),
1527 result_summary,
1528 source: "codex".to_owned(),
1529 }
1530}
1531
1532fn tail_chars(s: &str, max: usize) -> String {
1534 let count = s.chars().count();
1535 if count <= max {
1536 return s.to_owned();
1537 }
1538 s.chars().skip(count - max).collect()
1539}
1540
1541fn codex_quota(stdout: &str) -> Option<Quota> {
1544 let mut message = None;
1545 let mut failed = false;
1546 for line in stdout.lines() {
1547 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1548 continue;
1549 };
1550 match v.get("type").and_then(serde_json::Value::as_str) {
1551 Some("error") => {
1552 if let Some(m) = v.get("message").and_then(serde_json::Value::as_str)
1553 && m.starts_with("You've hit your usage limit. Visit https://chatgpt.com/settings/usage to purchase more credits")
1554 {
1555 message = Some(m.to_owned());
1556 }
1557 }
1558 Some("turn.failed") => failed = true,
1559 _ => {}
1560 }
1561 }
1562 if !failed {
1563 return None;
1564 }
1565 let message = message?;
1566 Some(Quota {
1567 reset: message
1568 .split_once("try again at ")
1569 .map(|(_, hint)| hint.trim().trim_end_matches('.').trim().to_owned())
1570 .filter(|hint| !hint.is_empty()),
1571 cause: QuotaCause::UsageLimit,
1572 agent: String::new(),
1573 })
1574}
1575
1576fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1583 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1584 if !is_err {
1585 return None;
1586 }
1587 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1588 if v.get("terminal_reason").and_then(serde_json::Value::as_str) == Some("api_error")
1589 && result.starts_with("Failed to authenticate:")
1590 && result.contains("OAuth session expired and could not be refreshed")
1591 {
1592 return Some(Quota {
1593 reset: None,
1594 cause: QuotaCause::Authentication,
1595 agent: String::new(),
1596 });
1597 }
1598 let status = v.get("api_error_status");
1599 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1600 || status.and_then(serde_json::Value::as_str) == Some("429"))
1601 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1602 if !usage_limit && !result.to_lowercase().contains("session limit") {
1603 return None;
1604 }
1605 let reset = result
1608 .split("resets ")
1609 .nth(1)
1610 .map(str::trim)
1611 .filter(|s| !s.is_empty())
1612 .map(str::to_owned);
1613 Some(Quota {
1614 reset,
1615 cause: QuotaCause::UsageLimit,
1616 agent: String::new(),
1617 })
1618}
1619
1620fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1635 let from_stdout = stdout
1636 .lines()
1637 .rev()
1638 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1639 .and_then(|v| {
1640 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1641 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1642 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1643 .then(|| error.to_owned())
1644 });
1645 let from_stderr = || {
1646 stderr.lines().find_map(|l| {
1647 let v: serde_json::Value =
1648 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1649 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1650 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1651 (exhausted && code).then(|| {
1652 v.get("short_error")
1653 .and_then(|e| e.as_str())
1654 .unwrap_or_default()
1655 .to_owned()
1656 })
1657 })
1658 };
1659 let text = from_stdout.or_else(from_stderr)?;
1660 let reset = text
1661 .split_once("Resets ")
1662 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1663 .filter(|s| !s.is_empty())
1664 .map(str::to_owned);
1665 Some(Quota {
1666 reset,
1667 cause: QuotaCause::UsageLimit,
1668 agent: String::new(),
1669 })
1670}
1671
1672fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1703 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1704 if !status.eq_ignore_ascii_case("error") {
1705 return None;
1706 }
1707 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1708 if !response.trim().is_empty() {
1709 return None;
1711 }
1712 let produced = v
1713 .get("usage")
1714 .and_then(|u| u.get("output_tokens"))
1715 .and_then(serde_json::Value::as_u64)
1716 .unwrap_or(0);
1717 if produced == 0 {
1718 return None;
1720 }
1721 Some(Dropped {
1722 why: v
1723 .get("error")
1724 .and_then(|e| e.as_str())
1725 .unwrap_or("the CLI ended the stream without delivering its answer")
1726 .trim()
1727 .to_owned(),
1728 output_tokens: produced,
1729 })
1730}
1731
1732pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1734 let mut missing = Vec::new();
1735 for s in specs {
1736 let program = match s.kind {
1737 AgentKind::Command => s.command.first().map(String::as_str),
1738 other => other.program(),
1739 };
1740 if let Some(p) = program
1741 && !crate::config::which(p)
1742 && !Path::new(p).is_file()
1743 && !missing.iter().any(|m: &String| m == p)
1744 {
1745 missing.push(p.to_owned());
1746 }
1747 }
1748 missing
1749}
1750
1751pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1753 run_dir.join("artifacts")
1754}
1755
1756pub fn installed(spec: &AgentSpec) -> bool {
1758 spec.kind.program().is_none_or(crate::config::which)
1761}
1762
1763pub fn pick(
1785 agents: &[AgentSpec],
1786 want: Option<&str>,
1787 available: &dyn Fn(&AgentSpec) -> bool,
1788) -> Result<AgentSpec> {
1789 if let Some(id) = want {
1790 let spec = agents
1791 .iter()
1792 .find(|a| a.id == id)
1793 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1794 if !available(spec) {
1795 bail!(
1796 "agent `{}` needs `{}` on PATH; install it or pass a different \
1797 --agent",
1798 spec.id,
1799 spec.kind.program().unwrap_or("its command")
1800 );
1801 }
1802 return Ok(spec.clone());
1803 }
1804
1805 if agents.is_empty() {
1806 bail!(
1807 "the agent roster is empty, so there is nobody to ask: install one \
1808 of claude, opencode or agy - magi derives a roster from what is on \
1809 PATH - or add an [[agents]] entry to magi.toml."
1810 );
1811 }
1812
1813 if let Some(spec) = agents
1814 .iter()
1815 .find(|a| a.kind == AgentKind::Claude && available(a))
1816 {
1817 return Ok(spec.clone());
1818 }
1819
1820 agents
1821 .iter()
1822 .find(|a| available(a))
1823 .cloned()
1824 .with_context(|| {
1825 let missing = agents
1826 .iter()
1827 .filter_map(|a| a.kind.program())
1828 .collect::<Vec<_>>()
1829 .join(", ");
1830 format!(
1831 "no agent in the roster can be run here: install one of \
1832 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1833 you do have"
1834 )
1835 })
1836}
1837
1838pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1851 let mut spec = spec.clone();
1852 if spec.kind == AgentKind::Claude {
1853 let mut existing = None;
1856 let mut args = spec.extra_args.iter().enumerate();
1857 while let Some((index, arg)) = args.next() {
1858 if arg == "--settings" {
1859 let (value_index, value) = args.next().context("--settings requires a value")?;
1860 existing = Some((value_index, value.as_str(), false));
1861 } else if let Some(value) = arg.strip_prefix("--settings=") {
1862 existing = Some((index, value, true));
1863 }
1864 }
1865 let mut settings = if let Some((_, value, _)) = existing {
1866 let json = if value.trim_start().starts_with('{') {
1867 value.to_owned()
1868 } else {
1869 std::fs::read_to_string(cwd.join(value))
1870 .context("cannot read existing seat --settings file")?
1871 };
1872 serde_json::from_str::<serde_json::Value>(&json)
1873 .context("cannot parse existing seat --settings JSON")?
1874 } else {
1875 serde_json::json!({})
1876 };
1877 settings
1878 .as_object_mut()
1879 .context("seat --settings must be a JSON object")?
1880 .insert(
1881 "language".to_owned(),
1882 serde_json::json!(crate::lang::name(github_language)),
1883 );
1884 let settings = settings.to_string();
1885 if let Some((index, _, equals)) = existing {
1886 spec.extra_args[index] = if equals {
1887 format!("--settings={settings}")
1888 } else {
1889 settings
1890 };
1891 } else {
1892 spec.extra_args.extend(["--settings".to_owned(), settings]);
1893 }
1894 }
1895 Ok(spec)
1896}
1897
1898pub fn pick_chain(
1906 agents: &[AgentSpec],
1907 choice: Option<&AgentChoice>,
1908 available: &dyn Fn(&AgentSpec) -> bool,
1909 role: &str,
1910) -> Result<Vec<AgentSpec>> {
1911 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1912 if wanted.is_empty() {
1913 return pick(agents, None, available)
1914 .map(|s| vec![s])
1915 .with_context(|| format!("choose an agent for the {role} role"));
1916 }
1917 let mut chain: Vec<AgentSpec> = Vec::new();
1918 for id in wanted {
1919 if chain.iter().any(|s| s.id == id) {
1920 continue;
1921 }
1922 match pick(agents, Some(id), available) {
1923 Ok(spec) => chain.push(spec),
1924 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1925 }
1926 }
1927 if chain.is_empty() {
1928 bail!(
1929 "[roles] {role} names no agent that can run here; the roster has {}",
1930 ids(agents)
1931 );
1932 }
1933 Ok(chain)
1934}
1935
1936pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1942 match outcome {
1943 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1944 Err(_) => true,
1945 Ok(out) => output_advances(out),
1946 }
1947}
1948
1949pub fn output_advances(out: &AgentOutput) -> bool {
1953 out.quota_exhausted() || !out.usable()
1954}
1955
1956fn ids(agents: &[AgentSpec]) -> String {
1957 if agents.is_empty() {
1958 return "no agents at all".to_owned();
1959 }
1960 agents
1961 .iter()
1962 .map(|a| a.id.clone())
1963 .collect::<Vec<_>>()
1964 .join(", ")
1965}
1966
1967#[cfg(test)]
1968mod tests {
1969 use super::*;
1970
1971 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1972
1973 fn command_helper(mode: &str) -> AgentSpec {
1976 crate::run::pin_test_home();
1978 AgentSpec {
1979 id: "helper".to_owned(),
1980 kind: AgentKind::Command,
1981 model: None,
1982 command: vec![
1983 std::env::current_exe()
1984 .expect("locate test helper")
1985 .to_string_lossy()
1986 .into_owned(),
1987 "--exact".to_owned(),
1988 "agent::tests::command_agent_test_helper".to_owned(),
1989 "--nocapture".to_owned(),
1990 ],
1991 extra_args: Vec::new(),
1992 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1993 prompt_delivery: None,
1994 max_turn_secs: None,
1995 }
1996 }
1997
1998 #[test]
1999 fn command_agent_test_helper() {
2000 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
2001 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
2002 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
2003 Ok("no-cache") => println!(
2004 "{}",
2005 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
2006 ),
2007 Ok("ignore-stdin") => println!("done"),
2008 Ok("chatty-sleep") => {
2009 println!("i-said-something");
2010 std::thread::sleep(Duration::from_secs(30));
2011 }
2012 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
2013 Ok(other) => panic!("unknown command helper mode {other}"),
2014 Err(_) => {}
2015 }
2016 }
2017
2018 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
2019 AgentSpec {
2020 id: "a".to_owned(),
2021 kind,
2022 model: model.map(str::to_owned),
2023 command: vec!["echo".to_owned(), "{label}".to_owned()],
2024 extra_args: Vec::new(),
2025 env: BTreeMap::new(),
2026 prompt_delivery: None,
2027 max_turn_secs: None,
2028 }
2029 }
2030
2031 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
2032 Invocation {
2033 coordination: None,
2034 fetched_base: None,
2035 cwd,
2036 prompt: "do the thing",
2037 timeout: Duration::from_secs(900),
2038 allow_write,
2039 sessions: true,
2040 artifacts: art,
2041 stem: "t",
2042 run: "test-run",
2043 node: "test",
2044 cache_dir: None,
2045 attachments: &[],
2046 writable: &[],
2047 }
2048 }
2049
2050 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
2051 build_command(
2052 &spec(kind, None),
2053 seat,
2054 &inv(Path::new("."), Path::new("/art"), allow_write),
2055 Path::new("/art/p.md"),
2056 )
2057 .unwrap()
2058 }
2059
2060 #[test]
2061 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
2062 let missing = editing_access_note(None);
2063 assert!(missing.contains("No successful launcher fetch evidence"));
2064 let base = crate::run::FetchedBase {
2065 remote: "origin".into(),
2066 branch: "main".into(),
2067 commit: "0123456789abcdef".into(),
2068 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
2069 };
2070 let note = editing_access_note(Some(&base));
2071 for expected in [
2072 "origin/main",
2073 "0123456789abcdef",
2074 "2026-10-18T00:00:00Z",
2075 "Do not fetch again",
2076 ] {
2077 assert!(note.contains(expected), "missing {expected}");
2078 }
2079 for text in [&missing, ¬e] {
2082 assert!(!text.to_lowercase().contains("sandbox"), "{text}");
2083 assert!(!text.contains("Network access is restricted"), "{text}");
2084 }
2085 let roundtrip: crate::run::FetchedBase =
2086 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2087 assert_eq!(editing_access_note(Some(&roundtrip)), note);
2088 }
2089
2090 #[test]
2091 fn codex_editing_seats_bypass_the_sandbox_on_initial_and_resume() {
2092 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2093 let roots = [PathBuf::from("/shared/git"), PathBuf::from("/questions")];
2094 for resumed in [false, true] {
2095 let mut seat = SeatState::new("impl-A", "a", 7);
2096 if resumed {
2097 seat.turns = 1;
2098 seat.captured_session = Some("fixture-thread".into());
2099 }
2100 let build = |invocation: &Invocation<'_>| {
2101 build_command(
2102 &spec(AgentKind::Codex, None),
2103 &seat,
2104 invocation,
2105 Path::new("/art/p.md"),
2106 )
2107 .unwrap()
2108 };
2109 for node in [
2110 "implement",
2111 "fix",
2112 "graft",
2113 "gate-fix",
2114 "land",
2115 "rebase",
2116 crate::queue::CHAT_NODE,
2117 ] {
2118 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2119 invocation.node = node;
2120 invocation.writable = &roots;
2121 let plan = build(&invocation);
2122 let at = plan.argv.iter().position(|s| s == BYPASS);
2123 assert!(at.is_some(), "{node}: {:?}", plan.argv);
2124 if resumed {
2125 let resume = plan.argv.iter().position(|s| s == "resume").unwrap();
2126 assert!(at.unwrap() < resume, "{node}: {:?}", plan.argv);
2127 }
2128 assert!(!plan.argv.iter().any(|s| s == "--sandbox"), "{node}");
2130 assert!(!plan.argv.iter().any(|s| s == "-c"), "{node}");
2131 }
2132 for (node, write) in [
2133 ("review", false),
2134 ("judge", false),
2135 ("advise", false),
2136 ("implement", false),
2137 ("fix", false),
2138 ("dupes", false),
2139 ("conduct", false),
2140 ("waiter", false),
2141 ("talk-title", false),
2142 (crate::queue::CHAT_NODE, false),
2143 ("deputy", true),
2144 ] {
2145 let mut invocation = inv(Path::new("."), Path::new("/art"), write);
2146 invocation.node = node;
2147 let plan = build(&invocation);
2148 assert!(!plan.argv.iter().any(|s| s == BYPASS), "{node}");
2149 let mode = if write {
2150 "workspace-write"
2151 } else {
2152 "read-only"
2153 };
2154 assert!(
2155 plan.argv.windows(2).any(|w| w == ["--sandbox", mode]),
2156 "{node}: {:?}",
2157 plan.argv
2158 );
2159 assert!(
2160 plan.argv
2161 .windows(2)
2162 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2163 "{node}"
2164 );
2165 }
2166 }
2167 }
2168
2169 #[test]
2170 fn claude_mints_then_resumes_the_same_uuid() {
2171 let mut seat = SeatState::new("judge-1", "a", 7);
2172 let uuid = seat.claude_session.clone().unwrap();
2173 let first = plan_for(AgentKind::Claude, &seat, true);
2174 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2175 assert!(!first.argv.iter().any(|a| a == "--resume"));
2176
2177 seat.turns = 1;
2178 let second = plan_for(AgentKind::Claude, &seat, true);
2179 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2180 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2181 }
2182
2183 #[test]
2184 fn github_bound_claude_seats_override_the_operators_answer_language() {
2185 let seat = SeatState::new("change-summary", "a", 7);
2186 let art = Path::new("/art");
2187 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2188 let claude =
2189 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2190 let plan = build_command(
2191 &claude,
2192 &seat,
2193 &inv(Path::new("."), art, false),
2194 Path::new("/art/p.md"),
2195 )
2196 .unwrap();
2197 let settings = format!(r#"{{"language":"{name}"}}"#);
2198 assert!(
2199 plan.argv
2200 .windows(2)
2201 .any(|w| w == ["--settings", settings.as_str()]),
2202 "{:?}",
2203 plan.argv
2204 );
2205 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2208 }
2209 for kind in [
2210 AgentKind::Codex,
2211 AgentKind::Opencode,
2212 AgentKind::Antigravity,
2213 AgentKind::Omp,
2214 ] {
2215 assert!(
2216 for_github_text(&spec(kind, None), "en", Path::new("."))
2217 .unwrap()
2218 .extra_args
2219 .is_empty()
2220 );
2221 }
2222 }
2223
2224 #[test]
2225 fn github_text_preserves_existing_settings() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let original = serde_json::json!({
2228 "language": "Japanese",
2229 "env": { "SEAT_MODE": "configured" },
2230 "apiKeyHelper": "credential-helper",
2231 "hooks": { "Stop": [] }
2232 });
2233 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2234 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2235 for equals in [false, true] {
2236 let mut spec = spec(AgentKind::Claude, None);
2237 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2239 if equals {
2240 spec.extra_args.push(format!("--settings={value}"));
2241 } else {
2242 spec.extra_args
2243 .extend(["--settings".to_owned(), value.clone()]);
2244 }
2245 spec.extra_args.push("--verbose".to_owned());
2246 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2247 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2248 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2249 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2250 let value = &updated.extra_args[updated.extra_args.len() - 2];
2251 let actual: serde_json::Value =
2252 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2253 .unwrap();
2254 let mut expected = original.clone();
2255 expected["language"] = serde_json::json!("English");
2256 assert_eq!(actual, expected);
2257 }
2258 }
2259 let on_disk: serde_json::Value = serde_json::from_str(
2260 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2261 )
2262 .unwrap();
2263 assert_eq!(on_disk, original);
2264 }
2265
2266 #[test]
2267 fn github_text_settings_are_redacted_from_command_logging() {
2268 let dir = tempfile::tempdir().unwrap();
2269 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2270 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2271 for equals in [false, true] {
2272 let mut spec = spec(AgentKind::Claude, None);
2273 spec.extra_args =
2274 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2275 if equals {
2276 spec.extra_args
2277 .push("--settings=./seat-settings.json".to_owned());
2278 } else {
2279 spec.extra_args
2280 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2281 }
2282 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2283 let plan = build_command(
2284 &spec,
2285 &SeatState::new("change-summary", "a", 7),
2286 &inv(dir.path(), dir.path(), false),
2287 &dir.path().join("prompt.md"),
2288 )
2289 .unwrap();
2290 let actual = plan.argv.last().unwrap();
2292 let actual: serde_json::Value =
2293 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2294 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2295 assert_eq!(actual["language"], "English");
2296 let logged = format!("{:?}", plan.logged_argv());
2297 assert!(!logged.contains("secret"));
2298 assert!(!logged.contains("AUTH_TOKEN"));
2299 assert!(logged.contains("[redacted]"));
2300 assert_eq!(plan.logged_argv()[0], "claude");
2301 assert!(plan.logged_argv().contains(&"--output-format"));
2302 }
2303 }
2304
2305 #[test]
2306 fn github_text_rejects_unreadable_or_invalid_settings() {
2307 let dir = tempfile::tempdir().unwrap();
2308 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2309 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2310 for args in [
2311 vec!["--settings"],
2312 vec!["--settings", "missing.json"],
2313 vec!["--settings=bad.json"],
2314 vec!["--settings", "array.json"],
2315 ] {
2316 let mut spec = spec(AgentKind::Claude, None);
2317 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2318 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2319 }
2320 }
2321
2322 #[test]
2323 fn read_only_seats_cannot_edit() {
2324 let seat = SeatState::new("judge-1", "a", 7);
2325 let claude = plan_for(AgentKind::Claude, &seat, false);
2326 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2327 assert!(
2328 !plan_for(AgentKind::Claude, &seat, true)
2329 .argv
2330 .iter()
2331 .any(|a| a == "--disallowed-tools")
2332 );
2333
2334 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2335 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2336 assert!(
2337 !agy.argv
2338 .iter()
2339 .any(|a| a == "--dangerously-skip-permissions")
2340 );
2341 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2342 assert!(
2343 agy_rw
2344 .argv
2345 .windows(2)
2346 .any(|w| w == ["--mode", "accept-edits"])
2347 );
2348 assert!(
2349 agy_rw
2350 .argv
2351 .iter()
2352 .any(|a| a == "--dangerously-skip-permissions")
2353 );
2354 let agy_prompt = agy_rw
2359 .argv
2360 .iter()
2361 .position(|a| a == "-p")
2362 .map(|i| agy_rw.argv[i + 1].clone())
2363 .expect("agy takes its prompt with -p");
2364 assert!(
2365 agy_prompt.starts_with('@'),
2366 "agy must get a file reference, got {agy_prompt:?}"
2367 );
2368 assert!(
2369 !agy_prompt.contains("Read the file at"),
2370 "the prose pointer is for CLIs with no file syntax"
2371 );
2372
2373 for allow_write in [false, true] {
2378 assert!(
2379 plan_for(AgentKind::Opencode, &seat, allow_write)
2380 .argv
2381 .iter()
2382 .any(|a| a == "--auto"),
2383 "opencode needs --auto even to read (allow_write = {allow_write})"
2384 );
2385 }
2386 }
2387
2388 #[test]
2391 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2392 let seat = SeatState::new("deputy-x", "a", 7);
2393 let roots = [PathBuf::from("/data/questions")];
2394 let mk = |allow_write: bool| {
2395 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2396 i.writable = &roots;
2397 build_command(
2398 &spec(AgentKind::Codex, None),
2399 &seat,
2400 &i,
2401 Path::new("/art/p.md"),
2402 )
2403 .unwrap()
2404 };
2405 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2406 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2407 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2408 }
2409
2410 #[test]
2411 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2412 let mut i = inv(Path::new("."), Path::new("/art"), true);
2413 for node in [
2414 "implement",
2415 "fix",
2416 "graft",
2417 "gate-fix",
2418 "land",
2419 "rebase",
2420 "chat",
2421 ] {
2422 i.node = node;
2423 assert!(repository_writer(&i), "{node}");
2424 i.allow_write = false;
2425 assert!(!repository_writer(&i), "{node}");
2426 i.allow_write = true;
2427 }
2428 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2429 i.node = node;
2430 assert!(!repository_writer(&i), "{node}");
2431 }
2432 i.node = "chat";
2433 let roots = [PathBuf::from("/questions")];
2434 i.writable = &roots;
2435 assert!(
2436 repository_writer(&i),
2437 "explicit roots do not change the role"
2438 );
2439 assert!(
2440 !repository_seat(&i),
2441 "chat turns bypass repository scheduling"
2442 );
2443 }
2444
2445 #[test]
2446 fn coordination_classification_is_independent_of_write_grants() {
2447 let roots = [PathBuf::from("/questions")];
2448 for node in [
2449 crate::queue::CHAT_NODE,
2450 "implement",
2451 "fix",
2452 "graft",
2453 "gate-fix",
2454 "land",
2455 "rebase",
2456 "review",
2457 "judge",
2458 "advise",
2459 "deputy",
2460 "waiter",
2461 "conduct",
2462 "dupes",
2463 "talk-title",
2464 ] {
2465 for allow_write in [false, true] {
2466 for writable in [&[][..], &roots[..]] {
2467 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2468 i.node = node;
2469 i.writable = writable;
2470 assert_eq!(
2471 repository_seat(&i),
2472 !matches!(
2473 node,
2474 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2475 ),
2476 "{node}, allow_write={allow_write}, writable={writable:?}"
2477 );
2478 if node == crate::queue::CHAT_NODE {
2479 assert_eq!(repository_writer(&i), allow_write);
2480 }
2481 }
2482 }
2483 }
2484 }
2485
2486 #[test]
2487 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2488 let roots = [
2489 PathBuf::from("/shared/git"),
2490 PathBuf::from("/state/questions"),
2491 ];
2492 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2493 for resumed in [false, true] {
2494 let mut seat = SeatState::new("impl-A", "a", 7);
2495 if resumed {
2496 seat.turns = 1;
2497 seat.captured_session = Some("session-id".into());
2498 }
2499 for allow_write in [false, true] {
2500 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2501 i.writable = &roots;
2502 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2503 .unwrap();
2504 for root in &roots {
2505 if kind == AgentKind::Antigravity {
2506 assert_eq!(
2507 plan.argv.windows(2).any(|w| {
2508 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2509 }),
2510 allow_write,
2511 "resumed={resumed}: {:?}",
2512 plan.argv
2513 );
2514 }
2515 assert_eq!(
2516 plan.argv
2517 .iter()
2518 .any(|arg| arg.contains(root.to_str().unwrap())),
2519 allow_write,
2520 "{kind:?}, resumed={resumed}: {:?}",
2521 plan.argv
2522 );
2523 }
2524 if kind == AgentKind::Codex && resumed {
2525 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2526 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2527 }
2528 }
2529 }
2530 }
2531 }
2532
2533 #[test]
2534 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2535 let mut seat = SeatState::new("judge-1", "a", 7);
2536
2537 let ro = plan_for(AgentKind::Codex, &seat, false);
2543 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2544 let rw = plan_for(AgentKind::Codex, &seat, true);
2545 assert!(
2546 rw.argv
2547 .windows(2)
2548 .any(|w| w == ["--sandbox", "workspace-write"])
2549 );
2550 for p in [&ro, &rw] {
2551 assert!(
2552 !p.argv
2553 .iter()
2554 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2555 "the bypass defeats the only enforced read-only mode we have"
2556 );
2557 assert!(
2559 p.argv
2560 .windows(2)
2561 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2562 "an unattended seat that asks for approval blocks until timeout"
2563 );
2564 }
2565
2566 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2568 assert_eq!(
2569 ro.argv.last().map(String::as_str),
2570 Some("-"),
2571 "without the `-` argument codex waits for a prompt it never gets"
2572 );
2573
2574 seat.turns = 1;
2578 assert!(!has_session(AgentKind::Codex, &seat, true));
2579 assert!(
2580 !plan_for(AgentKind::Codex, &seat, true)
2581 .argv
2582 .iter()
2583 .any(|a| a == "resume")
2584 );
2585 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2586 let resumed = plan_for(AgentKind::Codex, &seat, true);
2587 let at = resumed
2588 .argv
2589 .iter()
2590 .position(|a| a == "resume")
2591 .expect("resumes by subcommand");
2592 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2593 assert!(
2594 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2595 "every option precedes the subcommand"
2596 );
2597 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2598 }
2599
2600 #[test]
2603 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2604 let mut seat = SeatState::new("review-1", "a", 7);
2605
2606 let first = plan_for(AgentKind::Omp, &seat, false);
2610 assert!(first.argv.iter().any(|a| a == "-p"));
2611 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2612 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2613 assert!(
2614 !first.argv.iter().any(|a| a == "do the thing"),
2615 "the prompt reached argv, where Windows caps it"
2616 );
2617
2618 for allow_write in [false, true] {
2624 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2625 assert!(
2626 p.argv.iter().any(|a| a == "--auto-approve"),
2627 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2628 );
2629 assert!(
2630 !p.argv
2631 .iter()
2632 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2633 "nothing ever asks for the bypass"
2634 );
2635 }
2636
2637 seat.turns = 1;
2640 assert!(!has_session(AgentKind::Omp, &seat, true));
2641 assert!(
2642 !plan_for(AgentKind::Omp, &seat, true)
2643 .argv
2644 .iter()
2645 .any(|a| a == "--resume")
2646 );
2647 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2648 let resumed = plan_for(AgentKind::Omp, &seat, true);
2649 assert!(
2650 resumed
2651 .argv
2652 .windows(2)
2653 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2654 "a captured id is what makes the next turn a resume"
2655 );
2656 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2659 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2661 }
2662
2663 #[test]
2668 fn omp_takes_the_answer_without_an_agent_end_line() {
2669 let stream = concat!(
2670 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2671 "\n",
2672 r#"{"type":"agent_start"}"#,
2673 "\n",
2674 r#"{"type":"turn_start"}"#,
2675 "\n",
2676 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2677 "\n",
2678 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2679 "\n",
2680 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2681 "\n",
2682 );
2683 let out = extract(AgentKind::Omp, stream);
2684 assert_eq!(
2685 out.text, "{\"vote\":\"approve\"}",
2686 "the last assistant text block is the answer even with no agent_end"
2687 );
2688 assert_eq!(
2689 out.session.as_deref(),
2690 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2691 );
2692 }
2693
2694 #[test]
2698 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2699 let stream = concat!(
2700 r#"{"type":"session","version":3,"id":"s1"}"#,
2701 "\n",
2702 "{\"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問題ありません。\"}]}]}",
2703 "\n",
2704 );
2705 let out = extract(AgentKind::Omp, stream);
2706 assert_eq!(
2707 out.text, "## 判定\n\n問題ありません。",
2708 "the narration is not the answer, and non-ASCII survives intact"
2709 );
2710 assert_eq!(out.session.as_deref(), Some("s1"));
2711 }
2712
2713 #[test]
2716 fn omp_skips_non_json_lines() {
2717 let stream = concat!(
2718 "Warning: some omp notice\n",
2719 r#"{"type":"session","version":3,"id":"s2"}"#,
2720 "\n",
2721 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2722 "\n",
2723 "trailing junk",
2724 "\n",
2725 );
2726 let out = extract(AgentKind::Omp, stream);
2727 assert_eq!(out.text, "the answer");
2728 assert_eq!(out.session.as_deref(), Some("s2"));
2729 }
2730
2731 #[test]
2733 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2734 let stream = concat!(
2735 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2736 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2737 "\n",
2738 r#"{"type":"turn.started"}"#,
2739 "\n",
2740 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2741 "\n",
2742 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2743 "\n",
2744 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2745 "\n",
2746 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2747 "\n",
2748 );
2749 let out = extract(AgentKind::Codex, stream);
2750 assert_eq!(
2751 out.text, "{\"verdict\": \"ok\"}",
2752 "the last agent message is the answer; earlier ones narrate"
2753 );
2754 assert_eq!(
2755 out.session.as_deref(),
2756 Some("01a07440-4545-7492-85c1-024e3259a90a")
2757 );
2758 assert_eq!(out.status.as_deref(), Some("success"));
2759
2760 let failed = concat!(
2761 r#"{"type":"thread.started","thread_id":"t1"}"#,
2762 "\n",
2763 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2764 "\n",
2765 );
2766 assert_eq!(
2767 extract(AgentKind::Codex, failed).status.as_deref(),
2768 Some("error")
2769 );
2770 }
2771
2772 #[test]
2773 fn captured_sessions_resume_only_once_reported() {
2774 let mut seat = SeatState::new("impl-A", "a", 7);
2775 seat.turns = 1;
2776 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2777 assert!(!has_session(kind, &seat, true));
2778 let p = plan_for(kind, &seat, true);
2779 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2780 }
2781
2782 seat.captured_session = Some("sid".to_owned());
2783 assert!(has_session(AgentKind::Opencode, &seat, true));
2784 assert!(
2785 plan_for(AgentKind::Opencode, &seat, true)
2786 .argv
2787 .windows(2)
2788 .any(|w| w == ["-s", "sid"])
2789 );
2790 assert!(
2791 plan_for(AgentKind::Antigravity, &seat, true)
2792 .argv
2793 .windows(2)
2794 .any(|w| w == ["--conversation", "sid"])
2795 );
2796 }
2797
2798 #[test]
2799 fn sessions_disabled_never_resumes() {
2800 let mut seat = SeatState::new("impl-A", "a", 7);
2801 seat.turns = 3;
2802 seat.captured_session = Some("sid".to_owned());
2803 for kind in [
2804 AgentKind::Claude,
2805 AgentKind::Opencode,
2806 AgentKind::Antigravity,
2807 ] {
2808 assert!(!has_session(kind, &seat, false));
2809 }
2810 }
2811
2812 #[test]
2813 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2814 let seat = SeatState::new("judge-1", "a", 7);
2815 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2816 let p = plan_for(kind, &seat, false);
2817 assert!(
2818 p.argv.iter().all(|a| a != "do the thing"),
2819 "{kind:?} put the prompt on the command line"
2820 );
2821 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2822 }
2823 let p = plan_for(AgentKind::Antigravity, &seat, false);
2825 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2826 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2827 assert!(p.stdin.is_none());
2828 }
2829
2830 #[test]
2831 fn agy_print_timeout_tracks_the_node_budget() {
2832 let seat = SeatState::new("impl-A", "a", 7);
2833 let p = build_command(
2834 &spec(AgentKind::Antigravity, None),
2835 &seat,
2836 &Invocation {
2837 coordination: None,
2838 fetched_base: None,
2839 cwd: Path::new("."),
2840 prompt: "p",
2841 timeout: Duration::from_secs(3600),
2842 allow_write: true,
2843 sessions: true,
2844 artifacts: Path::new("/art"),
2845 stem: "t",
2846 run: "test-run",
2847 node: "test",
2848 cache_dir: None,
2849 attachments: &[],
2850 writable: &[],
2851 },
2852 Path::new("/art/p.md"),
2853 )
2854 .unwrap();
2855 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2856 }
2857
2858 #[test]
2865 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2866 let mut s = spec(AgentKind::Antigravity, None);
2867 s.prompt_delivery = Some(Delivery::Argv);
2868 let seat = SeatState::new("talk", "a", 7);
2869 let atts = [PathBuf::from("/art/attachments/abc.png")];
2870
2871 let without = build_command(
2872 &s,
2873 &seat,
2874 &Invocation {
2875 attachments: &[],
2876 writable: &[],
2877 ..inv(Path::new("."), Path::new("/art"), true)
2878 },
2879 Path::new("/art/p.md"),
2880 )
2881 .unwrap();
2882 assert!(
2883 !without.argv.iter().any(|a| a == "--add-dir"),
2884 "no attachment, no reason to widen the sandbox: {without:?}"
2885 );
2886
2887 let with = build_command(
2888 &s,
2889 &seat,
2890 &Invocation {
2891 attachments: &atts,
2892 writable: &[],
2893 ..inv(Path::new("."), Path::new("/art"), true)
2894 },
2895 Path::new("/art/p.md"),
2896 )
2897 .unwrap();
2898 assert!(
2899 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2900 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2901 );
2902 }
2903
2904 #[test]
2910 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2911 let seat = SeatState::new("plan", "a", 7);
2912 let atts = [
2913 PathBuf::from("/art/attachments/own.png"),
2914 PathBuf::from("/other-chat/attachments/inherited.png"),
2915 ];
2916
2917 let p = build_command(
2918 &spec(AgentKind::Antigravity, None),
2919 &seat,
2920 &Invocation {
2921 attachments: &atts,
2922 writable: &[],
2923 ..inv(Path::new("."), Path::new("/art"), true)
2924 },
2925 Path::new("/art/p.md"),
2926 )
2927 .unwrap();
2928
2929 assert!(
2930 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2931 "this conversation's own artifacts dir must still be granted: {p:?}"
2932 );
2933 assert!(
2934 p.argv
2935 .windows(2)
2936 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2937 "the inherited attachment's own directory must be granted too: {p:?}"
2938 );
2939 }
2940
2941 #[test]
2942 fn command_agents_get_placeholders_substituted() {
2943 let seat = SeatState::new("impl-A", "a", 7);
2944 let p = plan_for(AgentKind::Command, &seat, true);
2945 assert_eq!(p.argv[0], "echo");
2946 assert_eq!(p.argv[1], "impl-A");
2947 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2948 }
2949
2950 const EXPIRED_AUTH: &str = r#"{"is_error":true,"terminal_reason":"api_error","result":"Failed to authenticate: OAuth session expired and could not be refreshed"}"#;
2951 const CODEX_LIMIT: &str = r#"{"type":"error","message":"You've hit your usage limit. Visit https://chatgpt.com/settings/usage to purchase more credits or try again at Oct 27th, 2026 6:18 PM."}
2952{"type":"turn.failed","error":{"message":"You've hit your usage limit."}}"#;
2953
2954 #[test]
2955 fn observed_machine_failures_are_detected_conservatively() {
2956 let auth = extract(AgentKind::Claude, EXPIRED_AUTH).quota.unwrap();
2957 assert_eq!(auth.cause, QuotaCause::Authentication);
2958 assert_eq!(auth.reset, None);
2959 let limit = extract(AgentKind::Codex, CODEX_LIMIT).quota.unwrap();
2960 assert_eq!(limit.cause, QuotaCause::UsageLimit);
2961 assert_eq!(limit.reset.as_deref(), Some("Oct 27th, 2026 6:18 PM"));
2962 assert!(
2964 extract(AgentKind::Codex, &format!("{CODEX_LIMIT}\n{CODEX_LIMIT}"))
2965 .quota
2966 .is_some()
2967 );
2968 let without_reset = CODEX_LIMIT.replace(" or try again at Oct 27th, 2026 6:18 PM", "");
2969 assert!(
2970 extract(AgentKind::Codex, &without_reset)
2971 .quota
2972 .unwrap()
2973 .reset
2974 .is_none()
2975 );
2976 for text in [
2977 EXPIRED_AUTH.replace("true", "false"),
2978 EXPIRED_AUTH.replace("api_error", "unknown"),
2979 EXPIRED_AUTH.replace(
2980 "OAuth session expired and could not be refreshed",
2981 "unknown error",
2982 ),
2983 ] {
2984 assert!(extract(AgentKind::Claude, &text).quota.is_none());
2985 }
2986 assert!(
2987 extract(AgentKind::Codex, CODEX_LIMIT.lines().next().unwrap())
2988 .quota
2989 .is_none()
2990 );
2991 assert!(
2992 extract(
2993 AgentKind::Codex,
2994 &CODEX_LIMIT.replace("You've hit your usage limit.", "Something failed.")
2995 )
2996 .quota
2997 .is_none()
2998 );
2999 let quoted = serde_json::json!({"type":"item.completed","item":{"type":"agent_message","text":CODEX_LIMIT}}).to_string();
3000 assert!(extract(AgentKind::Codex, "ed).quota.is_none());
3001 assert!(extract(AgentKind::Codex, EXPIRED_AUTH).quota.is_none());
3002 assert!(
3003 extract(AgentKind::Antigravity, EXPIRED_AUTH)
3004 .quota
3005 .is_none()
3006 );
3007 let legacy: Quota = serde_json::from_str(r#"{"reset":null}"#).unwrap();
3008 assert_eq!(legacy.cause, QuotaCause::UsageLimit);
3009 }
3010
3011 #[test]
3012 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
3013 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
3015 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
3016 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
3017 let out = extract(AgentKind::Claude, stdout);
3018 let quota = out.quota.as_ref().expect("rate limit must be detected");
3019 assert_eq!(
3020 quota.reset.as_deref(),
3021 Some("4:50am (Asia/Tokyo)"),
3022 "reset time read from the body"
3023 );
3024 }
3025
3026 #[test]
3027 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
3028 let out = extract(
3029 AgentKind::Claude,
3030 r#"{"is_error":true,"result":"session limit reached"}"#,
3031 );
3032 let quota = out.quota.expect("rate limit detected without a reset");
3033 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
3034 }
3035
3036 #[test]
3037 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
3038 for kind in [AgentKind::Claude, AgentKind::Command] {
3039 for reply in [
3040 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3041 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3042 ] {
3043 assert!(extract(kind, reply).quota.is_some(), "{reply}");
3044 }
3045 for reply in [
3046 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3047 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
3048 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
3049 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3050 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
3051 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
3052 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
3053 ] {
3054 assert!(extract(kind, reply).quota.is_none(), "{reply}");
3055 }
3056 }
3057 }
3058
3059 #[test]
3060 fn ordinary_failures_are_never_quota() {
3061 let claude_fail = extract(
3063 AgentKind::Claude,
3064 r#"{"is_error":true,"result":"account does not exist"}"#,
3065 );
3066 assert!(claude_fail.quota.is_none());
3067
3068 let cmd_fail = extract(AgentKind::Command, "boom");
3070 assert!(cmd_fail.quota.is_none());
3071
3072 let success = extract(
3074 AgentKind::Command,
3075 r#"{"is_error":false,"result":"session limit is fine"}"#,
3076 );
3077 assert!(success.quota.is_none());
3078 }
3079
3080 #[test]
3088 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
3089 let stream = concat!(
3090 r#"{"type":"thread.started","thread_id":"t1"}"#,
3091 "\n",
3092 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"}}"#,
3093 "\n",
3094 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"}}"#,
3095 "\n",
3096 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
3097 "\n",
3098 r#"{"type":"turn.completed"}"#,
3099 "\n",
3100 );
3101 let out = extract(AgentKind::Codex, stream);
3102 assert_eq!(out.text, "Both tests in the target pass.");
3103 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
3104
3105 let paired = &out.commands[0];
3106 assert_eq!(paired.id, "item49");
3107 assert_eq!(paired.exit_code, Some(1));
3108 assert!(paired.description.contains("graph_cached_gate"));
3109 assert!(paired.result_summary.contains("1 failed"));
3110
3111 let solo = &out.commands[1];
3112 assert_eq!(solo.exit_code, Some(0));
3113
3114 assert!(
3118 out.commands
3119 .iter()
3120 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
3121 "a failed run of the actual target must still be visible: {:?}",
3122 out.commands
3123 );
3124 }
3125
3126 #[test]
3127 fn command_agent_can_carry_the_claude_quota_shape() {
3128 let out = extract(
3129 AgentKind::Command,
3130 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
3131 );
3132 assert!(
3133 out.quota.is_some(),
3134 "a wrapper emitting the claude shape counts as quota"
3135 );
3136 }
3137
3138 #[test]
3139 fn claude_json_result_is_extracted() {
3140 let out = extract(
3141 AgentKind::Claude,
3142 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3143 );
3144 assert_eq!(out.text, "all done");
3145 assert_eq!(out.session.as_deref(), Some("abc"));
3146 assert_eq!(out.status.as_deref(), Some("success"));
3147 }
3148
3149 #[test]
3164 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3165 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"}"#;
3166 let out = extract(AgentKind::Claude, stdout);
3167 assert_eq!(out.status.as_deref(), Some("success"));
3168 assert!(out.quota.is_none());
3169 assert!(!out.text.trim().is_empty());
3170
3171 let agent_out = AgentOutput {
3172 text: out.text.clone(),
3173 exit_code: Some(0),
3174 timed_out: false,
3175 duration_ms: 500,
3176 artifacts: Vec::new(),
3177 quota: out.quota,
3178 dropped: out.dropped,
3179 commands: out.commands,
3180 context_tokens: None,
3181 };
3182 assert!(
3183 agent_out.usable(),
3184 "the CLI turn itself ended cleanly and must read as usable"
3185 );
3186 assert!(
3187 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3188 "a clean CLI turn is not proof the node's own report ever arrived"
3189 );
3190 }
3191
3192 #[test]
3193 fn opencode_event_stream_is_concatenated() {
3194 let stream = concat!(
3195 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3196 "\n",
3197 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3198 "\n",
3199 "garbage line\n",
3200 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3201 "\n"
3202 );
3203 let out = extract(AgentKind::Opencode, stream);
3204 assert_eq!(out.text, "first\nsecond");
3205 assert_eq!(out.session.as_deref(), Some("ses_1"));
3206 }
3207
3208 #[test]
3209 fn agy_json_survives_a_leading_warning_line() {
3210 let stdout = concat!(
3211 "warning: --mode plan has no effect while slash commands are disabled.\n",
3212 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3213 "\n"
3214 );
3215 let out = extract(AgentKind::Antigravity, stdout);
3216 assert_eq!(out.text, "persimmon");
3217 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3218 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3219 }
3220
3221 const AGY_DROPPED: &str = concat!(
3228 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3229 r#""response":"","error":"the connection to the agent was interrupted before "#,
3230 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3231 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3232 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3233 r#""total_tokens":274380}}"#
3234 );
3235
3236 const AGY_QUOTA_OUT: &str = concat!(
3238 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3239 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3240 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3241 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3242 );
3243 const AGY_QUOTA_ERR: &str = concat!(
3244 "error: Individual quota reached. Resets in 1h2m49s.\n",
3245 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3246 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3247 r#""retryable":true}"#,
3248 "\n"
3249 );
3250
3251 #[test]
3252 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3253 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3254 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3255 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3256 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3257 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3259 assert!(stderr_only.reset.is_none());
3260 }
3261
3262 #[test]
3263 fn ordinary_agy_failures_are_not_a_quota() {
3264 assert!(agy_quota(AGY_DROPPED, "").is_none());
3265 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3266 assert!(
3267 agy_quota(
3268 "",
3269 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3270 )
3271 .is_none()
3272 );
3273 assert!(
3274 agy_quota(
3275 "",
3276 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3277 )
3278 .is_none()
3279 );
3280 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3281 }
3282
3283 #[test]
3284 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3285 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3286 let dropped = out.dropped.expect("recognised as undelivered work");
3287 assert_eq!(dropped.output_tokens, 14267);
3288 assert!(
3289 dropped.why.contains("subscriber fell behind"),
3290 "the CLI's own words are kept for the record: {}",
3291 dropped.why
3292 );
3293 assert_eq!(
3296 out.session.as_deref(),
3297 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3298 );
3299 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3300 }
3301
3302 #[test]
3303 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3304 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3308 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3309
3310 let answered = concat!(
3313 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3314 r#""usage":{"output_tokens":10}}"#
3315 );
3316 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3317
3318 let ok = concat!(
3320 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3321 r#""usage":{"output_tokens":10}}"#
3322 );
3323 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3324 }
3325
3326 #[test]
3327 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3328 let out = AgentOutput {
3329 text: String::new(),
3330 exit_code: Some(1),
3331 timed_out: false,
3332 duration_ms: 431_194,
3333 artifacts: Vec::new(),
3334 quota: None,
3335 dropped: Some(Dropped {
3336 why: "subscriber fell behind updates".to_owned(),
3337 output_tokens: 14267,
3338 }),
3339 commands: Vec::new(),
3340 context_tokens: None,
3341 };
3342 assert!(!out.usable());
3343 assert!(out.work_undelivered());
3344 assert!(!out.quota_exhausted());
3347 }
3348
3349 #[test]
3350 fn non_json_stdout_falls_back_to_raw_text() {
3351 let out = extract(AgentKind::Antigravity, "plain answer\n");
3352 assert_eq!(out.text, "plain answer");
3353 assert!(out.session.is_none());
3354 }
3355
3356 #[tokio::test]
3357 async fn command_agent_round_trip_writes_artifacts() {
3358 let dir = tempfile::tempdir().unwrap();
3359 let art = dir.path().join("artifacts");
3360 let mut seat = SeatState::new("impl-A", "a", 7);
3361 let s = command_helper("reply");
3362 let out = invoke(
3363 &s,
3364 &mut seat,
3365 &Invocation {
3366 coordination: None,
3367 fetched_base: None,
3368 cwd: dir.path(),
3369 prompt: "unused",
3370 timeout: Duration::from_secs(30),
3371 allow_write: true,
3372 sessions: true,
3373 artifacts: &art,
3374 stem: "impl-A",
3375 run: "test-run",
3376 node: "test",
3377 cache_dir: None,
3378 attachments: &[],
3379 writable: &[],
3380 },
3381 )
3382 .await
3383 .unwrap();
3384 assert!(out.usable(), "{out:?}");
3385 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3386 assert_eq!(seat.turns, 1);
3387 assert!(art.join("impl-A.prompt.md").is_file());
3388 assert!(art.join("impl-A.out").is_file());
3389 }
3390
3391 #[tokio::test]
3392 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3393 crate::run::pin_test_home();
3394 let dir = tempfile::tempdir().unwrap();
3398 let cache = dir.path().join("magi-cache");
3399 let mut seat = SeatState::new("impl-A", "a", 7);
3400 let s = command_helper("cache");
3401 let out = invoke(
3402 &s,
3403 &mut seat,
3404 &Invocation {
3405 coordination: None,
3406 fetched_base: None,
3407 cwd: dir.path(),
3408 prompt: "unused",
3409 timeout: Duration::from_secs(30),
3410 allow_write: true,
3411 sessions: true,
3412 artifacts: &dir.path().join("artifacts"),
3413 stem: "cache",
3414 run: "test-run",
3415 node: "test",
3416 cache_dir: Some(&cache),
3417 attachments: &[],
3418 writable: &[],
3419 },
3420 )
3421 .await
3422 .unwrap();
3423 assert!(out.usable(), "{out:?}");
3424 assert!(
3425 out.text.contains(cache.to_string_lossy().as_ref()),
3426 "the seat must see CARGO_TARGET_DIR = the shared cache"
3427 );
3428 }
3429
3430 #[tokio::test]
3431 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3432 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3440 unsafe {
3445 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3446 }
3447 let dir = tempfile::tempdir().unwrap();
3448 let mut seat = SeatState::new("review-1", "a", 7);
3449 let s = command_helper("no-cache");
3450 let result = invoke(
3451 &s,
3452 &mut seat,
3453 &Invocation {
3454 coordination: None,
3455 fetched_base: None,
3456 cwd: dir.path(),
3457 prompt: "unused",
3458 timeout: Duration::from_secs(30),
3459 allow_write: false,
3460 sessions: true,
3461 artifacts: &dir.path().join("artifacts"),
3462 stem: "no-cache",
3463 run: "test-run",
3464 node: "test",
3465 cache_dir: None,
3466 attachments: &[],
3467 writable: &[],
3468 },
3469 )
3470 .await;
3471 unsafe {
3476 match &previous {
3477 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3478 None => std::env::remove_var("CARGO_TARGET_DIR"),
3479 }
3480 }
3481 let out = result.unwrap();
3482 assert!(out.usable(), "{out:?}");
3483 assert!(
3484 out.text.contains("ABSENT"),
3485 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3486 out.text
3487 );
3488 }
3489
3490 #[tokio::test]
3491 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3492 let dir = tempfile::tempdir().unwrap();
3493 let mut seat = SeatState::new("impl-A", "a", 7);
3494 let s = command_helper("ignore-stdin");
3497 let big = "x".repeat(1_000_000);
3498 let out = invoke(
3499 &s,
3500 &mut seat,
3501 &Invocation {
3502 coordination: None,
3503 fetched_base: None,
3504 cwd: dir.path(),
3505 prompt: &big,
3506 timeout: Duration::from_secs(60),
3507 allow_write: true,
3508 sessions: true,
3509 artifacts: &dir.path().join("artifacts"),
3510 stem: "big",
3511 run: "test-run",
3512 node: "test",
3513 cache_dir: None,
3514 attachments: &[],
3515 writable: &[],
3516 },
3517 )
3518 .await
3519 .unwrap();
3520 assert!(out.usable(), "{out:?}");
3521 assert!(out.text.contains("done"), "{}", out.text);
3522 }
3523
3524 #[tokio::test]
3525 async fn agent_turn_cap_limits_a_direct_invocation() {
3526 let dir = tempfile::tempdir().unwrap();
3527 let artifacts = dir.path().join("artifacts");
3528 let mut seat = SeatState::new("impl-A", "helper", 7);
3529 let mut s = command_helper("sleep");
3530 s.max_turn_secs = Some(2);
3531 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3532 .await
3533 .unwrap();
3534 assert!(out.timed_out, "{out:?}");
3535 assert!(out.duration_ms < 20_000, "{out:?}");
3538 }
3539
3540 #[tokio::test]
3541 async fn timeout_is_reported_not_hung() {
3542 let dir = tempfile::tempdir().unwrap();
3543 let mut seat = SeatState::new("impl-A", "a", 7);
3544 let s = command_helper("sleep");
3545 let out = invoke(
3546 &s,
3547 &mut seat,
3548 &Invocation {
3549 coordination: None,
3550 fetched_base: None,
3551 cwd: dir.path(),
3552 prompt: "unused",
3553 timeout: Duration::from_millis(300),
3554 allow_write: true,
3555 sessions: true,
3556 artifacts: &dir.path().join("artifacts"),
3557 stem: "slow",
3558 run: "test-run",
3559 node: "test",
3560 cache_dir: None,
3561 attachments: &[],
3562 writable: &[],
3563 },
3564 )
3565 .await
3566 .unwrap();
3567 assert!(out.timed_out);
3568 assert!(!out.usable());
3569 }
3570
3571 #[tokio::test]
3572 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3573 let dir = tempfile::tempdir().unwrap();
3579 let artifacts = dir.path().join("artifacts");
3580 let mut seat = SeatState::new("impl-A", "a", 7);
3581 let s = command_helper("chatty-sleep");
3582 let out = invoke(
3583 &s,
3584 &mut seat,
3585 &Invocation {
3586 coordination: None,
3587 fetched_base: None,
3588 cwd: dir.path(),
3589 prompt: "unused",
3590 timeout: Duration::from_secs(10),
3595 allow_write: true,
3596 sessions: true,
3597 artifacts: &artifacts,
3598 stem: "chatty",
3599 run: "test-run",
3600 node: "test",
3601 cache_dir: None,
3602 attachments: &[],
3603 writable: &[],
3604 },
3605 )
3606 .await
3607 .unwrap();
3608
3609 assert!(out.timed_out, "{out:?}");
3610 assert!(!out.usable(), "a cut-off answer is still not an answer");
3611 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3612 assert!(
3613 recorded.contains("i-said-something"),
3614 "the artifact must keep what arrived before the kill, got {recorded:?}"
3615 );
3616 assert!(
3617 out.text.contains("i-said-something"),
3618 "and the graph must be able to see it too, got {:?}",
3619 out.text
3620 );
3621 }
3622
3623 #[test]
3624 fn missing_programs_reports_command_binaries() {
3625 let mut s = spec(AgentKind::Command, None);
3626 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3627 assert_eq!(
3628 missing_programs(&[s]),
3629 ["definitely-not-a-real-binary-xyz".to_owned()]
3630 );
3631 }
3632
3633 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3634 AgentSpec {
3635 id: id.to_owned(),
3636 kind,
3637 model: None,
3638 command: Vec::new(),
3639 extra_args: Vec::new(),
3640 env: BTreeMap::new(),
3641 prompt_delivery: None,
3642 max_turn_secs: None,
3643 }
3644 }
3645
3646 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3649 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3650 }
3651
3652 #[test]
3653 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3654 let agents = [
3655 pick_spec("oc", AgentKind::Opencode),
3656 pick_spec("opus", AgentKind::Claude),
3657 pick_spec("agy", AgentKind::Antigravity),
3658 ];
3659 let got = pick(&agents, None, &without(&[])).expect("a pick");
3660 assert_eq!(got.id, "opus");
3661 }
3662
3663 #[test]
3664 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3665 let agents = [
3666 pick_spec("opus", AgentKind::Claude),
3667 pick_spec("oc", AgentKind::Opencode),
3668 pick_spec("agy", AgentKind::Antigravity),
3669 ];
3670 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3671 assert_eq!(got.id, "agy");
3672 }
3673
3674 #[test]
3675 fn pick_on_an_empty_roster_says_what_to_install() {
3676 let msg = pick(&[], None, &without(&[]))
3677 .expect_err("nobody to ask")
3678 .to_string();
3679 assert!(msg.contains("roster is empty"), "{msg}");
3680 assert!(msg.contains("claude"), "{msg}");
3681 assert!(msg.contains("magi.toml"), "{msg}");
3682 }
3683
3684 #[test]
3685 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3686 let agents = [
3687 pick_spec("opus", AgentKind::Claude),
3688 pick_spec("oc", AgentKind::Opencode),
3689 ];
3690 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3691 let msg = format!("{err:#}");
3692 assert!(msg.contains("claude"), "{msg}");
3693 assert!(msg.contains("opencode"), "{msg}");
3694 }
3695
3696 #[test]
3697 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3698 let agents = [
3699 pick_spec("opus", AgentKind::Claude),
3700 pick_spec("oc", AgentKind::Opencode),
3701 ];
3702 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3703 assert_eq!(got.id, "oc");
3704 }
3705
3706 #[test]
3707 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3708 let agents = [
3709 pick_spec("opus", AgentKind::Claude),
3710 pick_spec("oc", AgentKind::Opencode),
3711 ];
3712 let msg = pick(&agents, Some("gemini"), &without(&[]))
3713 .expect_err("no such agent")
3714 .to_string();
3715 assert!(msg.contains("gemini"), "{msg}");
3716 assert!(msg.contains("opus, oc"), "{msg}");
3717 }
3718
3719 #[test]
3720 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3721 let agents = [
3722 pick_spec("opus", AgentKind::Claude),
3723 pick_spec("oc", AgentKind::Opencode),
3724 ];
3725 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3726 .expect_err("must not silently substitute another model")
3727 .to_string();
3728 assert!(msg.contains("opencode"), "{msg}");
3729 assert!(msg.contains("--agent"), "{msg}");
3730 }
3731
3732 fn named(id: &str) -> AgentSpec {
3733 AgentSpec {
3734 id: id.to_owned(),
3735 ..spec(AgentKind::Command, None)
3736 }
3737 }
3738
3739 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3740 AgentOutput {
3741 text: text.to_owned(),
3742 exit_code: Some(exit),
3743 timed_out: false,
3744 duration_ms: 0,
3745 artifacts: Vec::new(),
3746 quota: quota.then_some(Quota {
3747 reset: None,
3748 cause: QuotaCause::UsageLimit,
3749 agent: String::new(),
3750 }),
3751 dropped: None,
3752 commands: Vec::new(),
3753 context_tokens: None,
3754 }
3755 }
3756
3757 #[test]
3758 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3759 let agents = [named("a"), named("b"), named("c")];
3760 let all = |_: &AgentSpec| true;
3761 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3762 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3763 assert_eq!(
3764 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3765 ["c", "a"]
3766 );
3767
3768 let one = AgentChoice::from("b");
3769 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3770 assert_eq!(got.len(), 1);
3771 assert_eq!(got[0].id, "b");
3772
3773 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3774 assert_eq!(got.len(), 1, "unset keeps pick's default");
3775 assert_eq!(got[0].id, "a");
3776 let empty = AgentChoice::Chain(Vec::new());
3777 assert_eq!(
3778 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3779 "a"
3780 );
3781 }
3782
3783 #[test]
3784 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3785 let agents = [named("a"), named("b")];
3786 let not_a = |s: &AgentSpec| s.id != "a";
3787 let chain = AgentChoice::Chain(
3788 ["a", "ghost", "b", "b"]
3789 .iter()
3790 .map(|s| (*s).to_owned())
3791 .collect(),
3792 );
3793 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3794 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3795
3796 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3797 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3798 assert_eq!(
3799 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3800 ["b", "a"]
3801 );
3802 }
3803
3804 #[test]
3805 fn a_chain_with_nothing_runnable_names_the_role() {
3806 let agents = [named("a")];
3807 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3808 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3809 .unwrap_err()
3810 .to_string();
3811 assert!(err.contains("conductor"), "{err}");
3812 }
3813
3814 #[test]
3815 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3816 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3817 assert!(chain_advances(&Ok(output("limit", 0, true))));
3818 assert!(chain_advances(&Ok(output("", 0, false))));
3819 assert!(chain_advances(&Ok(output("x", 1, false))));
3820 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3821 }
3822
3823 #[test]
3824 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3825 for out in [
3826 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3827 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3828 r#"{"result":"ok"}"#,
3829 "not json",
3830 ] {
3831 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3832 }
3833 }
3834
3835 #[test]
3836 fn opencode_context_tokens_take_the_last_step_finish() {
3837 let out = concat!(
3838 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3839 "\n",
3840 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3841 "\n",
3842 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3843 "\n"
3844 );
3845 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3846 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3847 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3848 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3849 }
3850
3851 #[test]
3852 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3853 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3854 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3855 }
3856
3857 #[test]
3858 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3859 let out = concat!(
3860 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3861 "\n"
3862 );
3863 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3864 }
3865
3866 #[test]
3867 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3868 let out = concat!(
3869 r#"{"type":"session","id":"s"}"#,
3870 "\n",
3871 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3872 "\n",
3873 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3874 "\n"
3875 );
3876 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3877 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3878 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3879 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3880 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3881 }
3882
3883 #[test]
3884 fn command_agents_report_no_context_tokens() {
3885 let out = r#"{"usage":{"input_tokens":5}}"#;
3886 assert_eq!(context_tokens(AgentKind::Command, out), None);
3887 }
3888}