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, Copy, PartialEq, Eq)]
95pub enum InvocationScope {
96 Repository,
98 ConsultationControl,
100 ConsultationInvestigation,
102}
103
104#[derive(Debug, Clone)]
106pub struct Invocation<'a> {
107 pub scope: InvocationScope,
109 pub coordination: Option<&'a crate::config::Coordination>,
111 pub fetched_base: Option<&'a crate::run::FetchedBase>,
113 pub cwd: &'a Path,
116 pub prompt: &'a str,
118 pub timeout: Duration,
120 pub allow_write: bool,
122 pub unsandboxed: bool,
128 pub sessions: bool,
130 pub artifacts: &'a Path,
132 pub stem: &'a str,
134 pub run: &'a str,
138 pub node: &'a str,
142 pub cache_dir: Option<&'a Path>,
148 pub attachments: &'a [PathBuf],
155 pub writable: &'a [PathBuf],
160}
161
162fn repository_writer(inv: &Invocation<'_>) -> bool {
163 inv.scope == InvocationScope::Repository
164 && inv.allow_write
165 && (matches!(
166 inv.node,
167 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
168 ) || inv.node == crate::queue::CHAT_NODE)
169}
170
171fn repository_seat(inv: &Invocation<'_>) -> bool {
172 if inv.scope == InvocationScope::ConsultationInvestigation {
178 return true;
179 }
180 !matches!(
181 inv.node,
182 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
183 ) && inv.scope != InvocationScope::ConsultationControl
184}
185
186pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
193 let mut roots = Vec::new();
194 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
195 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
196 if !common.starts_with(&cwd) {
197 roots.push(common);
198 }
199 }
200 for name in ["questions", "notifications"] {
204 let dir = home.join(name);
205 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
206 roots.push(std::fs::canonicalize(dir)?);
207 }
208 Ok(roots)
209}
210
211fn editing_access_note(
213 kind: AgentKind,
214 unsandboxed: bool,
215 base: Option<&crate::run::FetchedBase>,
216) -> String {
217 let mut note = String::from(
218 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
219 );
220 if let Some(base) = base {
221 note.push_str(&format!(
222 "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",
223 base.remote, base.branch, base.fetched_at, base.commit,
224 ));
225 } else {
226 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");
227 }
228 if kind != AgentKind::Codex || unsandboxed {
230 return note;
231 }
232 note.push_str("Network access is restricted by the seat sandbox. A shared Git directory grant does not guarantee access to protected Git administration paths. Use local status, diff and log to inspect the checkout. If add/commit is denied, leave the implementation files in this assigned worktree: the launcher saves eligible edits after the seat exits. Do not repeatedly retry denied fetches or ask the owner to approve unavailable sandbox permissions. If an essential operation (including rebase continuation or dependency download) is still impossible, stop with an actionable infrastructure report naming the operation, target and error; the operator must repair/deploy the launcher environment before a single new attempt. Do not use bypass flags, change permissions, edit another checkout, or claim a network failure proves host DNS is broken. Specification questions remain valid.\n");
233 note
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243pub struct Quota {
244 #[serde(default)]
246 pub reset: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256pub struct Dropped {
257 pub why: String,
259 pub output_tokens: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct CommandEvidence {
279 pub id: String,
281 pub description: String,
283 pub exit_code: Option<i32>,
285 pub result_summary: String,
287 pub source: String,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct AgentOutput {
294 pub text: String,
296 pub exit_code: Option<i32>,
298 pub timed_out: bool,
300 pub duration_ms: u64,
302 pub artifacts: Vec<String>,
304 #[serde(default)]
308 pub quota: Option<Quota>,
309 #[serde(default)]
313 pub dropped: Option<Dropped>,
314 #[serde(default)]
318 pub commands: Vec<CommandEvidence>,
319 #[serde(default)]
324 pub context_tokens: Option<u64>,
325}
326
327impl AgentOutput {
328 pub fn usable(&self) -> bool {
330 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
331 }
332
333 pub fn quota_exhausted(&self) -> bool {
335 self.quota.is_some()
336 }
337
338 pub fn work_undelivered(&self) -> bool {
343 self.dropped.is_some()
344 }
345}
346
347#[derive(Debug)]
350pub(crate) struct ExecutionInterrupted(pub String);
351
352impl std::fmt::Display for ExecutionInterrupted {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 write!(f, "execution interrupted: {}", self.0)
355 }
356}
357
358impl std::error::Error for ExecutionInterrupted {}
359
360const PIPE_GRACE: Duration = Duration::from_secs(3);
365
366pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
368
369pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
379where
380 R: tokio::io::AsyncRead + Unpin + Send + 'static,
381{
382 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
383 let Some(mut pipe) = pipe else {
384 return (buf, None);
385 };
386 let sink = Arc::clone(&buf);
387 let handle = tokio::spawn(async move {
388 let mut chunk = [0u8; 8192];
389 loop {
390 match pipe.read(&mut chunk).await {
391 Ok(0) | Err(_) => break,
392 Ok(n) => {
393 if let Ok(mut guard) = sink.lock() {
394 guard.extend_from_slice(&chunk[..n]);
395 }
396 }
397 }
398 }
399 });
400 (buf, Some(handle))
401}
402
403pub(crate) async fn collect(
408 buf: &Captured,
409 handle: Option<tokio::task::JoinHandle<()>>,
410 grace: Duration,
411) -> String {
412 if let Some(handle) = handle {
413 if tokio::time::timeout(grace, handle).await.is_err() {
414 tracing::debug!("a pipe is still held open after the child exited");
415 }
416 }
417 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
418 String::from_utf8_lossy(&bytes).into_owned()
419}
420
421pub async fn invoke(
423 spec: &AgentSpec,
424 seat: &mut SeatState,
425 inv: &Invocation<'_>,
426) -> Result<AgentOutput> {
427 let lease = if inv.scope == InvocationScope::ConsultationInvestigation {
431 crate::repo_lock::SeatLease::try_reader(inv.cwd, inv.coordination).await?
432 } else 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 invoke_leased(spec, seat, inv, lease, || Ok(())).await
439}
440
441pub(crate) async fn invoke_leased(
444 spec: &AgentSpec,
445 seat: &mut SeatState,
446 inv: &Invocation<'_>,
447 lease: Option<crate::repo_lock::SeatLease>,
448 start_check: impl FnOnce() -> Result<()> + Send,
449) -> Result<AgentOutput> {
450 let readonly = inv.scope == InvocationScope::ConsultationInvestigation;
451 let inv = &Invocation {
452 allow_write: inv.allow_write && !readonly,
453 unsandboxed: inv.unsandboxed && inv.scope == InvocationScope::Repository,
454 writable: if readonly { &[] } else { inv.writable },
455 ..inv.clone()
456 };
457 let mut outputs = Vec::new();
458 if repository_seat(inv) {
459 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
460 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
461 })? {
462 outputs.push(guard);
463 }
464 if let Some(cache) = inv.cache_dir {
465 outputs.push(crate::retention::OutputLease::cache(
466 &crate::run::home(),
467 cache,
468 false,
469 )?);
470 }
471 }
472
473 let scope = match inv.scope {
474 InvocationScope::ConsultationControl => Some(
475 "Current turn scope: consultation control. This replaces all earlier per-turn scope restrictions, including investigation's prohibition on answering. Repository access is suspended. Use supplied context and supported read-only magi control-plane diagnostics; do not inspect repository files/history, fetch, test or edit. If evidence is needed (including an operator request to investigate), return ONLY a JSON object {\"investigate_question\":\"<full open question id>\"}. The launcher will attempt a coordinated read-only continuation in this same conversation. Do not answer or resolve the question to obtain access. Only magi answer settles a question. Additional writes are limited to individually evidenced task add, release, hold, done and priority; use --quote of the entire latest persisted owner send after delivery. No direct merge, discard, withdrawal, posting or other write commands; existing answer-specific effect checks remain in force. These restrictions last this whole invocation even after consultation ends. Consultation task instructions use the literal task-operation JSON protocol explained below. --reply-other records the owner's literal decision and ends consultation only, leaving the question open and waiter held. Owner decisions still require magi answer with exact required evidence.",
476 ),
477 InvocationScope::ConsultationInvestigation => Some(
478 "Current turn scope: consultation investigation. This supersedes earlier control-only restrictions for this invocation. Investigate repository evidence within the standing conversation's read-only permissions. No repository edits, fetch or other Git/network mutations; use existing history and files. Do not run tests that write into the repository. Do not answer, close, withdraw, discard or resume anything, or invoke magi answer. Return evidence and remaining decision points; the next operator turn returns to consultation control while the question remains open.",
479 ),
480 InvocationScope::Repository if inv.node == "chat" => Some(
481 "Current turn scope: ordinary repository conversation. This replaces earlier per-turn consultation restrictions. Use the standing conversation permissions and current filesystem sandbox; owner decisions still require magi answer and exact authorization evidence.",
482 ),
483 InvocationScope::Repository => None,
484 };
485 let mut prompt = inv.prompt.to_owned();
486 if let Some(scope) = scope {
487 prompt.push_str("\n\n");
488 prompt.push_str(scope);
489 if inv.scope == InvocationScope::ConsultationControl {
490 prompt.push_str("\n\n");
491 prompt.push_str(crate::prompt::CONSULT_WRITES);
492 }
493 }
494 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
495 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()));
496 }
497 let home = repository_writer(inv).then(crate::run::home);
500 let mut writable = inv.writable.to_vec();
501 if let Some(home) = &home {
502 for root in worktree_writable_roots(inv.cwd, home).await? {
503 if !writable.contains(&root) {
504 writable.push(root);
505 }
506 }
507 }
508 if repository_writer(inv) {
509 prompt.push_str("\n\n");
510 prompt.push_str(&editing_access_note(
511 spec.kind,
512 inv.unsandboxed,
513 inv.fetched_base,
514 ));
515 }
516 let inv = &Invocation {
517 writable: &writable,
518 prompt: &prompt,
519 ..inv.clone()
520 };
521 tokio::fs::create_dir_all(inv.artifacts)
522 .await
523 .with_context(|| format!("create {}", inv.artifacts.display()))?;
524 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
525 tokio::fs::write(&prompt_path, inv.prompt)
526 .await
527 .with_context(|| format!("write {}", prompt_path.display()))?;
528
529 let plan = build_command(spec, seat, inv, &prompt_path)?;
530 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
531 tokio::fs::write(
533 inv.artifacts.join(format!("{}.access.json", inv.stem)),
534 serde_json::to_vec_pretty(&serde_json::json!({
535 "launcher_version": env!("CARGO_PKG_VERSION"),
536 "kind": spec.kind,
537 "allow_write": inv.allow_write,
538 "unsandboxed": inv.unsandboxed,
539 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
540 Some(false)
541 } else { None },
542 "writable_roots": inv.writable,
543 "fetched_base": inv.fetched_base,
544 }))?,
545 )
546 .await
547 .context("write seat access evidence")?;
548
549 let started = Instant::now();
550 let child_path = spec
553 .env
554 .iter()
555 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
556 .map(|(_, v)| std::ffi::OsString::from(v))
557 .or_else(|| std::env::var_os("PATH"))
558 .unwrap_or_default();
559 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
560 || plan.argv[0].clone().into(),
561 std::path::PathBuf::into_os_string,
562 );
563 let mut cmd = Command::new(program);
564 cmd.args(&plan.argv[1..])
565 .current_dir(inv.cwd)
566 .envs(&spec.env)
567 .env("MAGI_SEAT", &seat.key)
568 .env("MAGI_TURN", seat.turns.to_string())
569 .env("MAGI_RUN", inv.run)
570 .env("MAGI_NODE", inv.node)
571 .env(
572 "MAGI_TURN_SCOPE",
573 match inv.scope {
574 InvocationScope::ConsultationControl => "consultation-control",
575 InvocationScope::ConsultationInvestigation => "consultation-investigation",
576 InvocationScope::Repository => "repository",
577 },
578 )
579 .env("MAGI_PROMPT_FILE", &prompt_path)
580 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
581 .env("GIT_TERMINAL_PROMPT", "0")
582 .stdin(if plan.stdin.is_some() {
583 Stdio::piped()
584 } else {
585 Stdio::null()
586 })
587 .stdout(Stdio::piped())
588 .stderr(Stdio::piped())
589 .kill_on_drop(true)
590 .quiet();
593 if let Some(home) = home {
594 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
597 }
598 if let Some(cache) = inv.cache_dir {
599 cmd.env("CARGO_TARGET_DIR", cache);
602 } else {
603 cmd.env_remove("CARGO_TARGET_DIR");
611 }
612
613 start_check()?;
616 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
617 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
618 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
622 tokio::spawn(async move {
623 sink.write_all(body.as_bytes()).await.ok();
624 sink.shutdown().await.ok();
625 });
626 }
627
628 let (out_buf, out_reader) = drain(running.child().stdout.take());
645 let (err_buf, err_reader) = drain(running.child().stderr.take());
646
647 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
648 .ok()
649 .map_or(0, |state| state.config.disk.min_free_bytes);
650 let mut paths = vec![inv.cwd.to_owned()];
651 if let Some(cache) = inv.cache_dir {
652 paths.push(cache.to_owned());
653 }
654 let mut pressure = None;
655 let waited = tokio::select! {
656 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
657 reason = crate::retention::emergency(paths, min_free) => {
658 pressure = Some(reason);
659 None
660 }
661 };
662 let (code, timed_out) = match waited {
663 Some(Ok(res)) => {
664 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
665 (status.code(), false)
666 }
667 Some(Err(_)) | None => {
668 if let Some(reason) = &pressure {
669 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
670 } else {
671 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
672 }
673 if !running.coordinated() {
675 running
676 .child()
677 .kill()
678 .await
679 .context("stop timed-out seat")?;
680 }
681 (None, true)
682 }
683 };
684 running.finish().await;
685
686 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
691 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
692
693 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
694 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
695 tokio::fs::write(&out_path, &stdout).await.ok();
696 tokio::fs::write(&err_path, &stderr).await.ok();
697 if let Some(reason) = pressure {
698 return Err(
699 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
700 .context(ExecutionInterrupted(reason)),
701 );
702 }
703
704 let mut extracted = extract(spec.kind, &stdout);
705 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
706 extracted.quota = agy_quota(&stdout, &stderr);
707 }
708 if let Some(quota) = &extracted.quota {
709 extracted.dropped = None;
712 tracing::warn!(
713 seat = %seat.key,
714 agent = %spec.id,
715 reset = ?quota.reset,
716 "agent is out of quota"
717 );
718 }
719 if let Some(session) = extracted.session {
720 match spec.kind {
721 AgentKind::Claude => seat.claude_session = Some(session),
722 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
723 seat.captured_session = Some(session);
724 }
725 AgentKind::Command => {}
726 }
727 }
728 if let Some(status) = &extracted.status
729 && !status.eq_ignore_ascii_case("success")
730 {
731 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
732 }
733 let text = if extracted.text.trim().is_empty() {
734 if stdout.trim().is_empty() {
736 stderr.trim().to_owned()
737 } else {
738 stdout.trim().to_owned()
739 }
740 } else {
741 extracted.text
742 };
743 seat.turns += 1;
744
745 Ok(AgentOutput {
746 text,
747 exit_code: code,
748 timed_out,
749 duration_ms: started.elapsed().as_millis() as u64,
750 artifacts: vec![
751 file_name(&prompt_path),
752 file_name(&out_path),
753 file_name(&err_path),
754 ],
755 quota: extracted.quota,
756 dropped: extracted.dropped,
757 commands: extracted.commands,
758 context_tokens: extracted.context_tokens,
759 })
760}
761
762fn file_name(p: &Path) -> String {
763 p.file_name()
764 .unwrap_or_default()
765 .to_string_lossy()
766 .into_owned()
767}
768
769#[derive(Debug)]
771struct Plan {
772 argv: Vec<String>,
773 stdin: Option<String>,
774}
775
776fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
787 if matches!(kind, AgentKind::Antigravity) {
788 return format!("@{}", prompt_path.display());
789 }
790 format!(
791 "Read the file at {} and follow every instruction in it exactly. That \
792 file is your complete task description; this message contains nothing \
793 else.",
794 prompt_path.display()
795 )
796}
797
798fn build_command(
799 spec: &AgentSpec,
800 seat: &SeatState,
801 inv: &Invocation<'_>,
802 prompt_path: &Path,
803) -> Result<Plan> {
804 let mut argv: Vec<String> = Vec::new();
805 let mut stdin: Option<String> = None;
806 let delivery = spec.delivery();
807 let resuming = has_session(spec.kind, seat, inv.sessions);
808
809 match spec.kind {
810 AgentKind::Claude => {
811 argv.push("claude".to_owned());
816 argv.push("-p".to_owned());
817 argv.push("--output-format".to_owned());
818 argv.push("json".to_owned());
819 if let Some(m) = &spec.model {
820 argv.push("--model".to_owned());
821 argv.push(m.clone());
822 }
823 if inv.sessions {
824 let uuid = seat
825 .claude_session
826 .as_deref()
827 .context("claude seat is missing its session uuid")?;
828 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
829 argv.push(uuid.to_owned());
830 }
831 argv.push("--permission-mode".to_owned());
832 argv.push("bypassPermissions".to_owned());
833 if !inv.allow_write {
834 argv.push("--disallowed-tools".to_owned());
835 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
836 }
837 }
838 AgentKind::Opencode => {
839 argv.push("opencode".to_owned());
843 argv.push("run".to_owned());
844 argv.push("--format".to_owned());
845 argv.push("json".to_owned());
846 argv.push("--dir".to_owned());
847 argv.push(inv.cwd.to_string_lossy().into_owned());
848 argv.push("--auto".to_owned());
857 if let Some(m) = &spec.model {
858 argv.push("-m".to_owned());
859 argv.push(m.clone());
860 }
861 if resuming {
862 argv.push("-s".to_owned());
863 argv.push(
864 seat.captured_session
865 .clone()
866 .expect("has_session checked the id is present"),
867 );
868 }
869 }
870 AgentKind::Antigravity => {
871 argv.push("agy".to_owned());
872 argv.push("--output-format".to_owned());
873 argv.push("json".to_owned());
874 argv.push("--print-timeout".to_owned());
877 argv.push(format!("{}s", inv.timeout.as_secs()));
878 argv.push("--mode".to_owned());
879 argv.push(
880 if inv.allow_write {
881 "accept-edits"
882 } else {
883 "plan"
884 }
885 .to_owned(),
886 );
887 if inv.allow_write {
888 argv.push("--dangerously-skip-permissions".to_owned());
889 }
890 if let Some(m) = &spec.model {
891 argv.push("--model".to_owned());
892 argv.push(m.clone());
893 }
894 if resuming {
895 argv.push("--conversation".to_owned());
896 argv.push(
897 seat.captured_session
898 .clone()
899 .expect("has_session checked the id is present"),
900 );
901 }
902 let mut add_dirs: Vec<String> = Vec::new();
912 if delivery == Delivery::File || !inv.attachments.is_empty() {
913 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
914 }
915 for path in inv.attachments {
916 let Some(parent) = path.parent() else {
917 continue;
918 };
919 if parent.starts_with(inv.artifacts) {
920 continue;
921 }
922 let dir = parent.to_string_lossy().into_owned();
923 if !add_dirs.contains(&dir) {
924 add_dirs.push(dir);
925 }
926 }
927 if inv.allow_write {
928 for path in inv.writable {
929 let dir = path.to_string_lossy().into_owned();
930 if !add_dirs.contains(&dir) {
931 add_dirs.push(dir);
932 }
933 }
934 }
935 for dir in add_dirs {
936 argv.push("--add-dir".to_owned());
937 argv.push(dir);
938 }
939 }
940 AgentKind::Codex => {
941 argv.push("codex".to_owned());
947 argv.push("exec".to_owned());
948 argv.push("--json".to_owned());
949 argv.push("--skip-git-repo-check".to_owned());
952 argv.push("-C".to_owned());
953 argv.push(inv.cwd.to_string_lossy().into_owned());
954 if inv.allow_write && inv.unsandboxed {
963 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
964 } else {
965 argv.push("--sandbox".to_owned());
966 argv.push(
967 if inv.allow_write {
968 "workspace-write"
969 } else {
970 "read-only"
971 }
972 .to_owned(),
973 );
974 argv.push("-c".to_owned());
977 argv.push("approval_policy=\"never\"".to_owned());
978 if repository_writer(inv) {
979 argv.push("-c".to_owned());
980 argv.push("sandbox_workspace_write.network_access=false".to_owned());
981 }
982 if inv.allow_write && !inv.writable.is_empty() {
983 let roots: Vec<String> = inv
984 .writable
985 .iter()
986 .map(|p| p.to_string_lossy().into_owned())
987 .collect();
988 argv.push("-c".to_owned());
989 argv.push(format!(
990 "sandbox_workspace_write.writable_roots={}",
991 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
992 ));
993 }
994 }
995 if let Some(m) = &spec.model {
996 argv.push("-m".to_owned());
997 argv.push(m.clone());
998 }
999 if resuming {
1005 argv.push("resume".to_owned());
1006 argv.push(
1007 seat.captured_session
1008 .clone()
1009 .expect("has_session checked the id is present"),
1010 );
1011 }
1012 }
1013 AgentKind::Omp => {
1014 argv.push("omp".to_owned());
1018 argv.push("-p".to_owned());
1019 argv.push("--mode=json".to_owned());
1020 argv.push("--auto-approve".to_owned());
1030 if let Some(m) = &spec.model {
1031 argv.push("--model".to_owned());
1032 argv.push(m.clone());
1033 }
1034 if resuming {
1040 argv.push("--resume".to_owned());
1041 argv.push(
1042 seat.captured_session
1043 .clone()
1044 .expect("has_session checked the id is present"),
1045 );
1046 }
1047 }
1048 AgentKind::Command => {
1049 if spec.command.is_empty() {
1054 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1055 }
1056 let vars: BTreeMap<&str, String> = BTreeMap::from([
1057 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1058 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1059 ("{label}", seat.key.clone()),
1060 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1061 ]);
1062 for raw in &spec.command {
1063 let mut arg = raw.clone();
1064 for (k, v) in &vars {
1065 if arg.contains(k) {
1066 arg = arg.replace(k, v);
1067 }
1068 }
1069 argv.push(arg);
1070 }
1071 }
1072 }
1073
1074 argv.extend(spec.extra_args.iter().cloned());
1075
1076 if spec.kind == AgentKind::Antigravity {
1079 argv.push("-p".to_owned());
1080 }
1081 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1084 argv.push("-".to_owned());
1085 }
1086 match delivery {
1087 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1088 argv.push(pointer(spec.kind, prompt_path));
1090 }
1091 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1092 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1093 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1094 }
1095
1096 Ok(Plan { argv, stdin })
1097}
1098
1099#[derive(Debug, Default)]
1101struct Extracted {
1102 text: String,
1103 session: Option<String>,
1104 status: Option<String>,
1105 quota: Option<Quota>,
1106 dropped: Option<Dropped>,
1107 commands: Vec<CommandEvidence>,
1108 context_tokens: Option<u64>,
1109}
1110
1111fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1113 let mut extracted = extract_answer(kind, stdout);
1114 extracted.context_tokens = context_tokens(kind, stdout);
1115 extracted
1116}
1117
1118fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1120 v.get(key).and_then(serde_json::Value::as_u64)
1121}
1122
1123fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1128 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1129 let base = first(input)?;
1130 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1131 Some(base.saturating_add(cached))
1132}
1133
1134fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1155 use serde_json::Value;
1156 let lines = || {
1157 stdout
1158 .lines()
1159 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1160 };
1161 match kind {
1162 AgentKind::Claude => None,
1163 AgentKind::Opencode => lines()
1164 .filter(|v| {
1165 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1167 || v.get("part")
1168 .and_then(|p| p.get("type"))
1169 .and_then(|t| t.as_str())
1170 == Some("step-finish")
1171 })
1172 .filter_map(|v| {
1173 let tokens = v.get("part")?.get("tokens")?;
1174 let base = uint(tokens, "input")?;
1175 let cache = tokens.get("cache");
1176 let cached = ["read", "write"]
1177 .iter()
1178 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1179 .sum::<u64>();
1180 Some(base.saturating_add(cached))
1181 })
1182 .next_back(),
1183 AgentKind::Antigravity => None,
1184 AgentKind::Codex => None,
1185 AgentKind::Omp => lines()
1186 .flat_map(|v| {
1187 match v.get("type").and_then(|t| t.as_str()) {
1190 Some("agent_end") => v
1191 .get("messages")
1192 .and_then(|m| m.as_array())
1193 .cloned()
1194 .unwrap_or_default(),
1195 Some("turn_end") | Some("message_end") => {
1196 v.get("message").cloned().into_iter().collect()
1197 }
1198 _ => Vec::new(),
1199 }
1200 })
1201 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1202 .filter_map(|m| {
1203 input_side(
1204 m.get("usage")?,
1205 &["input", "input_tokens"],
1206 &[
1207 "cacheRead",
1208 "cacheWrite",
1209 "cache_read_tokens",
1210 "cache_write_tokens",
1211 ],
1212 )
1213 })
1214 .next_back(),
1215 AgentKind::Command => None,
1216 }
1217}
1218
1219pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1222 extract_answer(kind, stdout).text
1223}
1224
1225fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1226 match kind {
1227 AgentKind::Claude => {
1228 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1229 return Extracted {
1230 text: stdout.trim().to_owned(),
1231 ..Extracted::default()
1232 };
1233 };
1234 Extracted {
1235 text: v
1236 .get("result")
1237 .and_then(|r| r.as_str())
1238 .unwrap_or_default()
1239 .to_owned(),
1240 session: v
1241 .get("session_id")
1242 .and_then(|s| s.as_str())
1243 .map(str::to_owned),
1244 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1245 if e {
1246 "error".to_owned()
1247 } else {
1248 "success".to_owned()
1249 }
1250 }),
1251 quota: claude_quota(&v),
1252 dropped: None,
1255 commands: Vec::new(),
1256 context_tokens: None,
1257 }
1258 }
1259 AgentKind::Opencode => {
1260 let mut text = String::new();
1262 let mut session = None;
1263 for line in stdout.lines() {
1264 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1265 continue;
1266 };
1267 if session.is_none() {
1268 session = v
1269 .get("sessionID")
1270 .and_then(|s| s.as_str())
1271 .map(str::to_owned);
1272 }
1273 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1274 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1275 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1276 {
1277 if !text.is_empty() {
1278 text.push('\n');
1279 }
1280 text.push_str(t);
1281 }
1282 }
1283 Extracted {
1284 text,
1285 session,
1286 status: None,
1287 quota: None,
1288 dropped: None,
1289 commands: Vec::new(),
1290 context_tokens: None,
1291 }
1292 }
1293 AgentKind::Antigravity => {
1294 let obj = stdout
1297 .lines()
1298 .rev()
1299 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1300 let Some(v) = obj else {
1301 return Extracted {
1302 text: stdout.trim().to_owned(),
1303 ..Extracted::default()
1304 };
1305 };
1306 Extracted {
1307 text: v
1308 .get("response")
1309 .and_then(|r| r.as_str())
1310 .unwrap_or_default()
1311 .trim()
1312 .to_owned(),
1313 session: v
1314 .get("conversation_id")
1315 .and_then(|s| s.as_str())
1316 .map(str::to_owned),
1317 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1318 quota: None,
1319 dropped: dropped_stream(&v),
1320 commands: Vec::new(),
1321 context_tokens: None,
1322 }
1323 }
1324 AgentKind::Codex => {
1325 let mut text = String::new();
1347 let mut session = None;
1348 let mut status = None;
1349 let mut commands = Vec::new();
1350 for line in stdout.lines() {
1351 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1352 continue;
1353 };
1354 match v.get("type").and_then(|t| t.as_str()) {
1355 Some("thread.started") => {
1356 session = v
1357 .get("thread_id")
1358 .and_then(|s| s.as_str())
1359 .map(str::to_owned);
1360 }
1361 Some("item.completed") => {
1362 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1363 match item.get("type").and_then(|t| t.as_str()) {
1364 Some("agent_message") => {
1365 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1366 text = t.trim().to_owned();
1367 }
1368 }
1369 Some("command_execution") => {
1370 commands.push(command_evidence(item));
1371 }
1372 _ => {}
1373 }
1374 }
1375 Some("turn.completed") => status = Some("success".to_owned()),
1376 Some("turn.failed") => status = Some("error".to_owned()),
1377 _ => {}
1378 }
1379 }
1380 Extracted {
1381 text,
1382 session,
1383 status,
1384 quota: None,
1385 dropped: None,
1386 commands,
1387 context_tokens: None,
1388 }
1389 }
1390 AgentKind::Omp => {
1391 let mut text = String::new();
1412 let mut session = None;
1413 for line in stdout.lines() {
1414 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1415 continue;
1416 };
1417 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1418 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1419 continue;
1420 }
1421 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1425 {
1426 Some("agent_end") => v
1427 .get("messages")
1428 .and_then(|m| m.as_array())
1429 .map(|m| m.iter().collect())
1430 .unwrap_or_default(),
1431 Some("turn_end") | Some("message_end") => {
1432 v.get("message").into_iter().collect()
1433 }
1434 _ => continue,
1435 };
1436 for message in messages {
1437 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1438 continue;
1439 }
1440 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1441 continue;
1442 };
1443 for part in parts {
1444 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1445 continue;
1446 }
1447 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1448 && !t.trim().is_empty()
1449 {
1450 text = t.trim().to_owned();
1451 }
1452 }
1453 }
1454 }
1455 Extracted {
1456 text,
1457 session,
1458 status: None,
1459 quota: None,
1460 dropped: None,
1461 commands: Vec::new(),
1462 context_tokens: None,
1463 }
1464 }
1465 AgentKind::Command => {
1466 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1471 let quota = parsed.as_ref().and_then(claude_quota);
1472 let dropped = parsed.as_ref().and_then(dropped_stream);
1475 Extracted {
1476 text: stdout.trim().to_owned(),
1477 session: None,
1478 status: None,
1479 quota,
1480 dropped,
1481 commands: Vec::new(),
1482 context_tokens: None,
1483 }
1484 }
1485 }
1486}
1487
1488fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1495 let description = match item.get("command") {
1496 Some(serde_json::Value::String(s)) => s.clone(),
1497 Some(serde_json::Value::Array(parts)) => parts
1498 .iter()
1499 .filter_map(|p| p.as_str())
1500 .collect::<Vec<_>>()
1501 .join(" "),
1502 _ => String::new(),
1503 };
1504 let result_summary = item
1505 .get("aggregated_output")
1506 .and_then(|o| o.as_str())
1507 .map(|s| tail_chars(s.trim(), 400))
1508 .unwrap_or_default();
1509 CommandEvidence {
1510 id: item
1511 .get("id")
1512 .and_then(|s| s.as_str())
1513 .unwrap_or_default()
1514 .to_owned(),
1515 description,
1516 exit_code: item
1517 .get("exit_code")
1518 .and_then(serde_json::Value::as_i64)
1519 .map(|e| e as i32),
1520 result_summary,
1521 source: "codex".to_owned(),
1522 }
1523}
1524
1525fn tail_chars(s: &str, max: usize) -> String {
1527 let count = s.chars().count();
1528 if count <= max {
1529 return s.to_owned();
1530 }
1531 s.chars().skip(count - max).collect()
1532}
1533
1534fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1541 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1542 if !is_err {
1543 return None;
1544 }
1545 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1546 let status = v.get("api_error_status");
1547 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1548 || status.and_then(serde_json::Value::as_str) == Some("429"))
1549 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1550 if !usage_limit && !result.to_lowercase().contains("session limit") {
1551 return None;
1552 }
1553 let reset = result
1556 .split("resets ")
1557 .nth(1)
1558 .map(str::trim)
1559 .filter(|s| !s.is_empty())
1560 .map(str::to_owned);
1561 Some(Quota { reset })
1562}
1563
1564fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1579 let from_stdout = stdout
1580 .lines()
1581 .rev()
1582 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1583 .and_then(|v| {
1584 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1585 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1586 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1587 .then(|| error.to_owned())
1588 });
1589 let from_stderr = || {
1590 stderr.lines().find_map(|l| {
1591 let v: serde_json::Value =
1592 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1593 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1594 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1595 (exhausted && code).then(|| {
1596 v.get("short_error")
1597 .and_then(|e| e.as_str())
1598 .unwrap_or_default()
1599 .to_owned()
1600 })
1601 })
1602 };
1603 let text = from_stdout.or_else(from_stderr)?;
1604 let reset = text
1605 .split_once("Resets ")
1606 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1607 .filter(|s| !s.is_empty())
1608 .map(str::to_owned);
1609 Some(Quota { reset })
1610}
1611
1612fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1643 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1644 if !status.eq_ignore_ascii_case("error") {
1645 return None;
1646 }
1647 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1648 if !response.trim().is_empty() {
1649 return None;
1651 }
1652 let produced = v
1653 .get("usage")
1654 .and_then(|u| u.get("output_tokens"))
1655 .and_then(serde_json::Value::as_u64)
1656 .unwrap_or(0);
1657 if produced == 0 {
1658 return None;
1660 }
1661 Some(Dropped {
1662 why: v
1663 .get("error")
1664 .and_then(|e| e.as_str())
1665 .unwrap_or("the CLI ended the stream without delivering its answer")
1666 .trim()
1667 .to_owned(),
1668 output_tokens: produced,
1669 })
1670}
1671
1672pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1674 let mut missing = Vec::new();
1675 for s in specs {
1676 let program = match s.kind {
1677 AgentKind::Command => s.command.first().map(String::as_str),
1678 other => other.program(),
1679 };
1680 if let Some(p) = program
1681 && !crate::config::which(p)
1682 && !Path::new(p).is_file()
1683 && !missing.iter().any(|m: &String| m == p)
1684 {
1685 missing.push(p.to_owned());
1686 }
1687 }
1688 missing
1689}
1690
1691pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1693 run_dir.join("artifacts")
1694}
1695
1696pub fn installed(spec: &AgentSpec) -> bool {
1698 spec.kind.program().is_none_or(crate::config::which)
1701}
1702
1703pub fn pick(
1725 agents: &[AgentSpec],
1726 want: Option<&str>,
1727 available: &dyn Fn(&AgentSpec) -> bool,
1728) -> Result<AgentSpec> {
1729 if let Some(id) = want {
1730 let spec = agents
1731 .iter()
1732 .find(|a| a.id == id)
1733 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1734 if !available(spec) {
1735 bail!(
1736 "agent `{}` needs `{}` on PATH; install it or pass a different \
1737 --agent",
1738 spec.id,
1739 spec.kind.program().unwrap_or("its command")
1740 );
1741 }
1742 return Ok(spec.clone());
1743 }
1744
1745 if agents.is_empty() {
1746 bail!(
1747 "the agent roster is empty, so there is nobody to ask: install one \
1748 of claude, opencode or agy - magi derives a roster from what is on \
1749 PATH - or add an [[agents]] entry to magi.toml."
1750 );
1751 }
1752
1753 if let Some(spec) = agents
1754 .iter()
1755 .find(|a| a.kind == AgentKind::Claude && available(a))
1756 {
1757 return Ok(spec.clone());
1758 }
1759
1760 agents
1761 .iter()
1762 .find(|a| available(a))
1763 .cloned()
1764 .with_context(|| {
1765 let missing = agents
1766 .iter()
1767 .filter_map(|a| a.kind.program())
1768 .collect::<Vec<_>>()
1769 .join(", ");
1770 format!(
1771 "no agent in the roster can be run here: install one of \
1772 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1773 you do have"
1774 )
1775 })
1776}
1777
1778pub fn pick_chain(
1786 agents: &[AgentSpec],
1787 choice: Option<&AgentChoice>,
1788 available: &dyn Fn(&AgentSpec) -> bool,
1789 role: &str,
1790) -> Result<Vec<AgentSpec>> {
1791 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1792 if wanted.is_empty() {
1793 return pick(agents, None, available)
1794 .map(|s| vec![s])
1795 .with_context(|| format!("choose an agent for the {role} role"));
1796 }
1797 let mut chain: Vec<AgentSpec> = Vec::new();
1798 for id in wanted {
1799 if chain.iter().any(|s| s.id == id) {
1800 continue;
1801 }
1802 match pick(agents, Some(id), available) {
1803 Ok(spec) => chain.push(spec),
1804 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1805 }
1806 }
1807 if chain.is_empty() {
1808 bail!(
1809 "[roles] {role} names no agent that can run here; the roster has {}",
1810 ids(agents)
1811 );
1812 }
1813 Ok(chain)
1814}
1815
1816pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1822 match outcome {
1823 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1824 Err(_) => true,
1825 Ok(out) => output_advances(out),
1826 }
1827}
1828
1829pub fn output_advances(out: &AgentOutput) -> bool {
1833 out.quota_exhausted() || !out.usable()
1834}
1835
1836fn ids(agents: &[AgentSpec]) -> String {
1837 if agents.is_empty() {
1838 return "no agents at all".to_owned();
1839 }
1840 agents
1841 .iter()
1842 .map(|a| a.id.clone())
1843 .collect::<Vec<_>>()
1844 .join(", ")
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849 use super::*;
1850
1851 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1852
1853 fn command_helper(mode: &str) -> AgentSpec {
1856 crate::run::pin_test_home();
1858 AgentSpec {
1859 id: "helper".to_owned(),
1860 kind: AgentKind::Command,
1861 model: None,
1862 command: vec![
1863 std::env::current_exe()
1864 .expect("locate test helper")
1865 .to_string_lossy()
1866 .into_owned(),
1867 "--exact".to_owned(),
1868 "agent::tests::command_agent_test_helper".to_owned(),
1869 "--nocapture".to_owned(),
1870 ],
1871 extra_args: Vec::new(),
1872 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1873 prompt_delivery: None,
1874 }
1875 }
1876
1877 #[test]
1878 fn command_agent_test_helper() {
1879 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1880 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1881 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1882 Ok("no-cache") => println!(
1883 "{}",
1884 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1885 ),
1886 Ok("ignore-stdin") => println!("done"),
1887 Ok("chatty-sleep") => {
1888 println!("i-said-something");
1889 std::thread::sleep(Duration::from_secs(30));
1890 }
1891 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1892 Ok(other) => panic!("unknown command helper mode {other}"),
1893 Err(_) => {}
1894 }
1895 }
1896
1897 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1898 AgentSpec {
1899 id: "a".to_owned(),
1900 kind,
1901 model: model.map(str::to_owned),
1902 command: vec!["echo".to_owned(), "{label}".to_owned()],
1903 extra_args: Vec::new(),
1904 env: BTreeMap::new(),
1905 prompt_delivery: None,
1906 }
1907 }
1908
1909 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1910 Invocation {
1911 scope: crate::agent::InvocationScope::Repository,
1912 coordination: None,
1913 fetched_base: None,
1914 cwd,
1915 prompt: "do the thing",
1916 timeout: Duration::from_secs(900),
1917 allow_write,
1918 unsandboxed: false,
1919 sessions: true,
1920 artifacts: art,
1921 stem: "t",
1922 run: "test-run",
1923 node: "test",
1924 cache_dir: None,
1925 attachments: &[],
1926 writable: &[],
1927 }
1928 }
1929
1930 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1931 build_command(
1932 &spec(kind, None),
1933 seat,
1934 &inv(Path::new("."), Path::new("/art"), allow_write),
1935 Path::new("/art/p.md"),
1936 )
1937 .unwrap()
1938 }
1939
1940 #[test]
1941 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1942 let missing = editing_access_note(AgentKind::Codex, false, None);
1943 assert!(missing.contains("No successful launcher fetch evidence"));
1944 let base = crate::run::FetchedBase {
1945 remote: "origin".into(),
1946 branch: "main".into(),
1947 commit: "0123456789abcdef".into(),
1948 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1949 };
1950 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
1951 for expected in [
1952 "origin/main",
1953 "0123456789abcdef",
1954 "2026-10-18T00:00:00Z",
1955 "Do not fetch again",
1956 "launcher saves eligible edits",
1957 "Specification questions remain valid",
1958 ] {
1959 assert!(note.contains(expected), "missing {expected}");
1960 }
1961 for kind in [
1962 AgentKind::Codex,
1963 AgentKind::Claude,
1964 AgentKind::Opencode,
1965 AgentKind::Antigravity,
1966 AgentKind::Omp,
1967 AgentKind::Command,
1968 ] {
1969 for unsandboxed in [false, true] {
1970 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
1971 assert!(scoped.contains(&base.commit));
1972 assert!(scoped.contains("Do not fetch again"));
1973 let restricted = kind == AgentKind::Codex && !unsandboxed;
1974 for claim in [
1975 "Network access is restricted",
1976 "protected Git administration paths",
1977 "launcher saves eligible edits",
1978 "unavailable sandbox permissions",
1979 ] {
1980 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
1981 assert_eq!(
1982 editing_access_note(kind, unsandboxed, None).contains(claim),
1983 restricted,
1984 "{kind:?}/{unsandboxed} without fetch evidence",
1985 );
1986 }
1987 }
1988 }
1989 let roundtrip: crate::run::FetchedBase =
1990 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
1991 assert_eq!(
1992 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
1993 note
1994 );
1995 }
1996
1997 #[test]
1998 fn editing_network_policy_is_explicit_on_initial_and_resume() {
1999 for resumed in [false, true] {
2000 let mut seat = SeatState::new("impl-A", "a", 7);
2001 if resumed {
2002 seat.turns = 1;
2003 seat.captured_session = Some("fixture-thread".into());
2004 }
2005 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2006 invocation.node = "implement";
2007 let plan = build_command(
2008 &spec(AgentKind::Codex, None),
2009 &seat,
2010 &invocation,
2011 Path::new("/art/p.md"),
2012 )
2013 .unwrap();
2014 let policy = plan
2015 .argv
2016 .iter()
2017 .position(|s| s == "sandbox_workspace_write.network_access=false")
2018 .unwrap();
2019 if resumed {
2020 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
2021 }
2022 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2023 invocation.node = "deputy";
2024 let deputy = build_command(
2025 &spec(AgentKind::Codex, None),
2026 &seat,
2027 &invocation,
2028 Path::new("/art/p.md"),
2029 )
2030 .unwrap();
2031 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
2032 assert!(
2033 !plan_for(AgentKind::Codex, &seat, false)
2034 .argv
2035 .iter()
2036 .any(|s| s.contains("writable_roots"))
2037 );
2038 }
2039 }
2040
2041 #[test]
2042 fn claude_mints_then_resumes_the_same_uuid() {
2043 let mut seat = SeatState::new("judge-1", "a", 7);
2044 let uuid = seat.claude_session.clone().unwrap();
2045 let first = plan_for(AgentKind::Claude, &seat, true);
2046 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2047 assert!(!first.argv.iter().any(|a| a == "--resume"));
2048
2049 seat.turns = 1;
2050 let second = plan_for(AgentKind::Claude, &seat, true);
2051 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2052 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2053 }
2054
2055 #[test]
2056 fn read_only_seats_cannot_edit() {
2057 let seat = SeatState::new("judge-1", "a", 7);
2058 let claude = plan_for(AgentKind::Claude, &seat, false);
2059 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2060 assert!(
2061 !plan_for(AgentKind::Claude, &seat, true)
2062 .argv
2063 .iter()
2064 .any(|a| a == "--disallowed-tools")
2065 );
2066
2067 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2068 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2069 assert!(
2070 !agy.argv
2071 .iter()
2072 .any(|a| a == "--dangerously-skip-permissions")
2073 );
2074 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2075 assert!(
2076 agy_rw
2077 .argv
2078 .windows(2)
2079 .any(|w| w == ["--mode", "accept-edits"])
2080 );
2081 assert!(
2082 agy_rw
2083 .argv
2084 .iter()
2085 .any(|a| a == "--dangerously-skip-permissions")
2086 );
2087 let agy_prompt = agy_rw
2092 .argv
2093 .iter()
2094 .position(|a| a == "-p")
2095 .map(|i| agy_rw.argv[i + 1].clone())
2096 .expect("agy takes its prompt with -p");
2097 assert!(
2098 agy_prompt.starts_with('@'),
2099 "agy must get a file reference, got {agy_prompt:?}"
2100 );
2101 assert!(
2102 !agy_prompt.contains("Read the file at"),
2103 "the prose pointer is for CLIs with no file syntax"
2104 );
2105
2106 for allow_write in [false, true] {
2111 assert!(
2112 plan_for(AgentKind::Opencode, &seat, allow_write)
2113 .argv
2114 .iter()
2115 .any(|a| a == "--auto"),
2116 "opencode needs --auto even to read (allow_write = {allow_write})"
2117 );
2118 }
2119 }
2120
2121 #[test]
2124 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2125 let seat = SeatState::new("deputy-x", "a", 7);
2126 let roots = [PathBuf::from("/data/questions")];
2127 let mk = |allow_write: bool| {
2128 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2129 i.writable = &roots;
2130 build_command(
2131 &spec(AgentKind::Codex, None),
2132 &seat,
2133 &i,
2134 Path::new("/art/p.md"),
2135 )
2136 .unwrap()
2137 };
2138 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2139 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2140 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2141 }
2142
2143 #[tokio::test]
2144 async fn investigation_checks_cancellation_after_artifacts_before_spawning() {
2145 let tmp = tempfile::tempdir().unwrap();
2146 let artifacts = tmp.path().join("artifacts");
2147 let mut invocation = inv(tmp.path(), &artifacts, true);
2148 invocation.scope = InvocationScope::ConsultationInvestigation;
2149 invocation.unsandboxed = true;
2150 let mut seat = SeatState::new("chat", "fixture", 1);
2151 let error = invoke_leased(
2152 &spec(AgentKind::Command, None),
2153 &mut seat,
2154 &invocation,
2155 None,
2156 || {
2157 assert!(artifacts.join("t.prompt.md").exists());
2158 bail!("question closed during preparation")
2159 },
2160 )
2161 .await
2162 .unwrap_err();
2163 assert!(error.to_string().contains("question closed"));
2164 assert_eq!(seat.turns, 0);
2165 assert!(!artifacts.join("t.out").exists());
2166 }
2167
2168 #[test]
2169 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2170 let mut i = inv(Path::new("."), Path::new("/art"), true);
2171 for node in [
2172 "implement",
2173 "fix",
2174 "graft",
2175 "gate-fix",
2176 "land",
2177 "rebase",
2178 "chat",
2179 ] {
2180 i.node = node;
2181 assert!(repository_writer(&i), "{node}");
2182 i.allow_write = false;
2183 assert!(!repository_writer(&i), "{node}");
2184 i.allow_write = true;
2185 }
2186 for node in ["deputy", "judge", "review", "bump", "conduct"] {
2187 i.node = node;
2188 assert!(!repository_writer(&i), "{node}");
2189 }
2190 i.node = "chat";
2191 let roots = [PathBuf::from("/questions")];
2192 i.writable = &roots;
2193 assert!(
2194 repository_writer(&i),
2195 "roots alone do not select consultation"
2196 );
2197 i.scope = InvocationScope::ConsultationControl;
2198 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2199 assert!(!repository_seat(&i));
2200 i.allow_write = false;
2201 i.writable = &[];
2202 assert!(!repository_seat(&i), "control scope needs no write roots");
2203 i.scope = InvocationScope::ConsultationInvestigation;
2204 assert!(repository_seat(&i));
2205 i.allow_write = true;
2206 assert!(
2207 !repository_writer(&i),
2208 "investigation never gains editing grants"
2209 );
2210 }
2211
2212 #[test]
2213 fn coordination_classification_is_independent_of_write_grants() {
2214 let roots = [PathBuf::from("/questions")];
2215 for node in [
2216 crate::queue::CHAT_NODE,
2217 "implement",
2218 "fix",
2219 "graft",
2220 "gate-fix",
2221 "land",
2222 "rebase",
2223 "review",
2224 "judge",
2225 "advise",
2226 "deputy",
2227 "waiter",
2228 "conduct",
2229 "dupes",
2230 ] {
2231 for allow_write in [false, true] {
2232 for writable in [&[][..], &roots[..]] {
2233 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2234 i.node = node;
2235 i.writable = writable;
2236 assert_eq!(
2237 repository_seat(&i),
2238 !matches!(
2239 node,
2240 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
2241 ),
2242 "{node}, allow_write={allow_write}, writable={writable:?}"
2243 );
2244 if node == crate::queue::CHAT_NODE {
2245 assert_eq!(repository_writer(&i), allow_write);
2246 }
2247 }
2248 }
2249 }
2250 }
2251
2252 #[test]
2253 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2254 let roots = [
2255 PathBuf::from("/shared/git"),
2256 PathBuf::from("/state/questions"),
2257 ];
2258 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2259 for resumed in [false, true] {
2260 let mut seat = SeatState::new("impl-A", "a", 7);
2261 if resumed {
2262 seat.turns = 1;
2263 seat.captured_session = Some("session-id".into());
2264 }
2265 for allow_write in [false, true] {
2266 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2267 i.writable = &roots;
2268 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2269 .unwrap();
2270 for root in &roots {
2271 if kind == AgentKind::Antigravity {
2272 assert_eq!(
2273 plan.argv.windows(2).any(|w| {
2274 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2275 }),
2276 allow_write,
2277 "resumed={resumed}: {:?}",
2278 plan.argv
2279 );
2280 }
2281 assert_eq!(
2282 plan.argv
2283 .iter()
2284 .any(|arg| arg.contains(root.to_str().unwrap())),
2285 allow_write,
2286 "{kind:?}, resumed={resumed}: {:?}",
2287 plan.argv
2288 );
2289 }
2290 if kind == AgentKind::Codex && resumed {
2291 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2292 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2293 }
2294 }
2295 }
2296 }
2297 }
2298
2299 #[test]
2300 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2301 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2302 let mut seat = SeatState::new("talk", "a", 7);
2303 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2304 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2305 i.unsandboxed = unsandboxed;
2306 build_command(
2307 &spec(AgentKind::Codex, None),
2308 seat,
2309 &i,
2310 Path::new("/art/p.md"),
2311 )
2312 .unwrap()
2313 };
2314
2315 let first = build(&seat, true, true);
2317 assert!(first.argv.iter().any(|a| a == BYPASS));
2318 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2319 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2320
2321 seat.captured_session = Some("thread-1".to_owned());
2323 seat.turns = 1;
2324 let resumed = build(&seat, true, true);
2325 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2326 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2327
2328 let ro = build(&seat, false, true);
2330 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2331 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2332 }
2333
2334 #[test]
2335 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2336 let mut seat = SeatState::new("judge-1", "a", 7);
2337
2338 let ro = plan_for(AgentKind::Codex, &seat, false);
2342 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2343 let rw = plan_for(AgentKind::Codex, &seat, true);
2344 assert!(
2345 rw.argv
2346 .windows(2)
2347 .any(|w| w == ["--sandbox", "workspace-write"])
2348 );
2349 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2352 ro_flagged.unsandboxed = true;
2353 let ro_flagged = build_command(
2354 &spec(AgentKind::Codex, None),
2355 &seat,
2356 &ro_flagged,
2357 Path::new("/art/p.md"),
2358 )
2359 .unwrap();
2360 assert!(
2361 ro_flagged
2362 .argv
2363 .windows(2)
2364 .any(|w| w == ["--sandbox", "read-only"])
2365 );
2366 for p in [&ro, &rw, &ro_flagged] {
2367 assert!(
2368 !p.argv
2369 .iter()
2370 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2371 "the bypass defeats the only enforced read-only mode we have"
2372 );
2373 assert!(
2375 p.argv
2376 .windows(2)
2377 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2378 "an unattended seat that asks for approval blocks until timeout"
2379 );
2380 }
2381
2382 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2384 assert_eq!(
2385 ro.argv.last().map(String::as_str),
2386 Some("-"),
2387 "without the `-` argument codex waits for a prompt it never gets"
2388 );
2389
2390 seat.turns = 1;
2394 assert!(!has_session(AgentKind::Codex, &seat, true));
2395 assert!(
2396 !plan_for(AgentKind::Codex, &seat, true)
2397 .argv
2398 .iter()
2399 .any(|a| a == "resume")
2400 );
2401 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2402 let resumed = plan_for(AgentKind::Codex, &seat, true);
2403 let at = resumed
2404 .argv
2405 .iter()
2406 .position(|a| a == "resume")
2407 .expect("resumes by subcommand");
2408 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2409 assert!(
2410 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2411 "every option precedes the subcommand"
2412 );
2413 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2414 }
2415
2416 #[test]
2419 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2420 let mut seat = SeatState::new("review-1", "a", 7);
2421
2422 let first = plan_for(AgentKind::Omp, &seat, false);
2426 assert!(first.argv.iter().any(|a| a == "-p"));
2427 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2428 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2429 assert!(
2430 !first.argv.iter().any(|a| a == "do the thing"),
2431 "the prompt reached argv, where Windows caps it"
2432 );
2433
2434 for allow_write in [false, true] {
2440 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2441 assert!(
2442 p.argv.iter().any(|a| a == "--auto-approve"),
2443 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2444 );
2445 assert!(
2446 !p.argv
2447 .iter()
2448 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2449 "nothing ever asks for the bypass"
2450 );
2451 }
2452
2453 seat.turns = 1;
2456 assert!(!has_session(AgentKind::Omp, &seat, true));
2457 assert!(
2458 !plan_for(AgentKind::Omp, &seat, true)
2459 .argv
2460 .iter()
2461 .any(|a| a == "--resume")
2462 );
2463 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2464 let resumed = plan_for(AgentKind::Omp, &seat, true);
2465 assert!(
2466 resumed
2467 .argv
2468 .windows(2)
2469 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2470 "a captured id is what makes the next turn a resume"
2471 );
2472 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2475 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2477 }
2478
2479 #[test]
2484 fn omp_takes_the_answer_without_an_agent_end_line() {
2485 let stream = concat!(
2486 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2487 "\n",
2488 r#"{"type":"agent_start"}"#,
2489 "\n",
2490 r#"{"type":"turn_start"}"#,
2491 "\n",
2492 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2493 "\n",
2494 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2495 "\n",
2496 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2497 "\n",
2498 );
2499 let out = extract(AgentKind::Omp, stream);
2500 assert_eq!(
2501 out.text, "{\"vote\":\"approve\"}",
2502 "the last assistant text block is the answer even with no agent_end"
2503 );
2504 assert_eq!(
2505 out.session.as_deref(),
2506 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2507 );
2508 }
2509
2510 #[test]
2514 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2515 let stream = concat!(
2516 r#"{"type":"session","version":3,"id":"s1"}"#,
2517 "\n",
2518 "{\"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問題ありません。\"}]}]}",
2519 "\n",
2520 );
2521 let out = extract(AgentKind::Omp, stream);
2522 assert_eq!(
2523 out.text, "## 判定\n\n問題ありません。",
2524 "the narration is not the answer, and non-ASCII survives intact"
2525 );
2526 assert_eq!(out.session.as_deref(), Some("s1"));
2527 }
2528
2529 #[test]
2532 fn omp_skips_non_json_lines() {
2533 let stream = concat!(
2534 "Warning: some omp notice\n",
2535 r#"{"type":"session","version":3,"id":"s2"}"#,
2536 "\n",
2537 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2538 "\n",
2539 "trailing junk",
2540 "\n",
2541 );
2542 let out = extract(AgentKind::Omp, stream);
2543 assert_eq!(out.text, "the answer");
2544 assert_eq!(out.session.as_deref(), Some("s2"));
2545 }
2546
2547 #[test]
2549 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2550 let stream = concat!(
2551 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2552 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2553 "\n",
2554 r#"{"type":"turn.started"}"#,
2555 "\n",
2556 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2557 "\n",
2558 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2559 "\n",
2560 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2561 "\n",
2562 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2563 "\n",
2564 );
2565 let out = extract(AgentKind::Codex, stream);
2566 assert_eq!(
2567 out.text, "{\"verdict\": \"ok\"}",
2568 "the last agent message is the answer; earlier ones narrate"
2569 );
2570 assert_eq!(
2571 out.session.as_deref(),
2572 Some("01a07440-4545-7492-85c1-024e3259a90a")
2573 );
2574 assert_eq!(out.status.as_deref(), Some("success"));
2575
2576 let failed = concat!(
2577 r#"{"type":"thread.started","thread_id":"t1"}"#,
2578 "\n",
2579 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2580 "\n",
2581 );
2582 assert_eq!(
2583 extract(AgentKind::Codex, failed).status.as_deref(),
2584 Some("error")
2585 );
2586 }
2587
2588 #[test]
2589 fn captured_sessions_resume_only_once_reported() {
2590 let mut seat = SeatState::new("impl-A", "a", 7);
2591 seat.turns = 1;
2592 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2593 assert!(!has_session(kind, &seat, true));
2594 let p = plan_for(kind, &seat, true);
2595 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2596 }
2597
2598 seat.captured_session = Some("sid".to_owned());
2599 assert!(has_session(AgentKind::Opencode, &seat, true));
2600 assert!(
2601 plan_for(AgentKind::Opencode, &seat, true)
2602 .argv
2603 .windows(2)
2604 .any(|w| w == ["-s", "sid"])
2605 );
2606 assert!(
2607 plan_for(AgentKind::Antigravity, &seat, true)
2608 .argv
2609 .windows(2)
2610 .any(|w| w == ["--conversation", "sid"])
2611 );
2612 }
2613
2614 #[test]
2615 fn sessions_disabled_never_resumes() {
2616 let mut seat = SeatState::new("impl-A", "a", 7);
2617 seat.turns = 3;
2618 seat.captured_session = Some("sid".to_owned());
2619 for kind in [
2620 AgentKind::Claude,
2621 AgentKind::Opencode,
2622 AgentKind::Antigravity,
2623 ] {
2624 assert!(!has_session(kind, &seat, false));
2625 }
2626 }
2627
2628 #[test]
2629 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2630 let seat = SeatState::new("judge-1", "a", 7);
2631 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2632 let p = plan_for(kind, &seat, false);
2633 assert!(
2634 p.argv.iter().all(|a| a != "do the thing"),
2635 "{kind:?} put the prompt on the command line"
2636 );
2637 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2638 }
2639 let p = plan_for(AgentKind::Antigravity, &seat, false);
2641 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2642 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2643 assert!(p.stdin.is_none());
2644 }
2645
2646 #[test]
2647 fn agy_print_timeout_tracks_the_node_budget() {
2648 let seat = SeatState::new("impl-A", "a", 7);
2649 let p = build_command(
2650 &spec(AgentKind::Antigravity, None),
2651 &seat,
2652 &Invocation {
2653 scope: crate::agent::InvocationScope::Repository,
2654 coordination: None,
2655 fetched_base: None,
2656 cwd: Path::new("."),
2657 prompt: "p",
2658 timeout: Duration::from_secs(3600),
2659 allow_write: true,
2660 unsandboxed: false,
2661 sessions: true,
2662 artifacts: Path::new("/art"),
2663 stem: "t",
2664 run: "test-run",
2665 node: "test",
2666 cache_dir: None,
2667 attachments: &[],
2668 writable: &[],
2669 },
2670 Path::new("/art/p.md"),
2671 )
2672 .unwrap();
2673 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2674 }
2675
2676 #[test]
2683 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2684 let mut s = spec(AgentKind::Antigravity, None);
2685 s.prompt_delivery = Some(Delivery::Argv);
2686 let seat = SeatState::new("talk", "a", 7);
2687 let atts = [PathBuf::from("/art/attachments/abc.png")];
2688
2689 let without = build_command(
2690 &s,
2691 &seat,
2692 &Invocation {
2693 attachments: &[],
2694 writable: &[],
2695 ..inv(Path::new("."), Path::new("/art"), true)
2696 },
2697 Path::new("/art/p.md"),
2698 )
2699 .unwrap();
2700 assert!(
2701 !without.argv.iter().any(|a| a == "--add-dir"),
2702 "no attachment, no reason to widen the sandbox: {without:?}"
2703 );
2704
2705 let with = build_command(
2706 &s,
2707 &seat,
2708 &Invocation {
2709 attachments: &atts,
2710 writable: &[],
2711 ..inv(Path::new("."), Path::new("/art"), true)
2712 },
2713 Path::new("/art/p.md"),
2714 )
2715 .unwrap();
2716 assert!(
2717 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2718 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2719 );
2720 }
2721
2722 #[test]
2728 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2729 let seat = SeatState::new("plan", "a", 7);
2730 let atts = [
2731 PathBuf::from("/art/attachments/own.png"),
2732 PathBuf::from("/other-chat/attachments/inherited.png"),
2733 ];
2734
2735 let p = build_command(
2736 &spec(AgentKind::Antigravity, None),
2737 &seat,
2738 &Invocation {
2739 attachments: &atts,
2740 writable: &[],
2741 ..inv(Path::new("."), Path::new("/art"), true)
2742 },
2743 Path::new("/art/p.md"),
2744 )
2745 .unwrap();
2746
2747 assert!(
2748 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2749 "this conversation's own artifacts dir must still be granted: {p:?}"
2750 );
2751 assert!(
2752 p.argv
2753 .windows(2)
2754 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2755 "the inherited attachment's own directory must be granted too: {p:?}"
2756 );
2757 }
2758
2759 #[test]
2760 fn command_agents_get_placeholders_substituted() {
2761 let seat = SeatState::new("impl-A", "a", 7);
2762 let p = plan_for(AgentKind::Command, &seat, true);
2763 assert_eq!(p.argv[0], "echo");
2764 assert_eq!(p.argv[1], "impl-A");
2765 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2766 }
2767
2768 #[test]
2769 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2770 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2772 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2773 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2774 let out = extract(AgentKind::Claude, stdout);
2775 let quota = out.quota.as_ref().expect("rate limit must be detected");
2776 assert_eq!(
2777 quota.reset.as_deref(),
2778 Some("4:50am (Asia/Tokyo)"),
2779 "reset time read from the body"
2780 );
2781 }
2782
2783 #[test]
2784 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2785 let out = extract(
2786 AgentKind::Claude,
2787 r#"{"is_error":true,"result":"session limit reached"}"#,
2788 );
2789 let quota = out.quota.expect("rate limit detected without a reset");
2790 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2791 }
2792
2793 #[test]
2794 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2795 for kind in [AgentKind::Claude, AgentKind::Command] {
2796 for reply in [
2797 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2798 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2799 ] {
2800 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2801 }
2802 for reply in [
2803 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2804 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2805 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2806 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2807 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2808 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2809 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2810 ] {
2811 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2812 }
2813 }
2814 }
2815
2816 #[test]
2817 fn ordinary_failures_are_never_quota() {
2818 let claude_fail = extract(
2820 AgentKind::Claude,
2821 r#"{"is_error":true,"result":"account does not exist"}"#,
2822 );
2823 assert!(claude_fail.quota.is_none());
2824
2825 let cmd_fail = extract(AgentKind::Command, "boom");
2827 assert!(cmd_fail.quota.is_none());
2828
2829 let success = extract(
2831 AgentKind::Command,
2832 r#"{"is_error":false,"result":"session limit is fine"}"#,
2833 );
2834 assert!(success.quota.is_none());
2835 }
2836
2837 #[test]
2845 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2846 let stream = concat!(
2847 r#"{"type":"thread.started","thread_id":"t1"}"#,
2848 "\n",
2849 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"}}"#,
2850 "\n",
2851 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"}}"#,
2852 "\n",
2853 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2854 "\n",
2855 r#"{"type":"turn.completed"}"#,
2856 "\n",
2857 );
2858 let out = extract(AgentKind::Codex, stream);
2859 assert_eq!(out.text, "Both tests in the target pass.");
2860 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2861
2862 let paired = &out.commands[0];
2863 assert_eq!(paired.id, "item49");
2864 assert_eq!(paired.exit_code, Some(1));
2865 assert!(paired.description.contains("graph_cached_gate"));
2866 assert!(paired.result_summary.contains("1 failed"));
2867
2868 let solo = &out.commands[1];
2869 assert_eq!(solo.exit_code, Some(0));
2870
2871 assert!(
2875 out.commands
2876 .iter()
2877 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2878 "a failed run of the actual target must still be visible: {:?}",
2879 out.commands
2880 );
2881 }
2882
2883 #[test]
2884 fn command_agent_can_carry_the_claude_quota_shape() {
2885 let out = extract(
2886 AgentKind::Command,
2887 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2888 );
2889 assert!(
2890 out.quota.is_some(),
2891 "a wrapper emitting the claude shape counts as quota"
2892 );
2893 }
2894
2895 #[test]
2896 fn claude_json_result_is_extracted() {
2897 let out = extract(
2898 AgentKind::Claude,
2899 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2900 );
2901 assert_eq!(out.text, "all done");
2902 assert_eq!(out.session.as_deref(), Some("abc"));
2903 assert_eq!(out.status.as_deref(), Some("success"));
2904 }
2905
2906 #[test]
2921 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2922 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"}"#;
2923 let out = extract(AgentKind::Claude, stdout);
2924 assert_eq!(out.status.as_deref(), Some("success"));
2925 assert!(out.quota.is_none());
2926 assert!(!out.text.trim().is_empty());
2927
2928 let agent_out = AgentOutput {
2929 text: out.text.clone(),
2930 exit_code: Some(0),
2931 timed_out: false,
2932 duration_ms: 500,
2933 artifacts: Vec::new(),
2934 quota: out.quota,
2935 dropped: out.dropped,
2936 commands: out.commands,
2937 context_tokens: None,
2938 };
2939 assert!(
2940 agent_out.usable(),
2941 "the CLI turn itself ended cleanly and must read as usable"
2942 );
2943 assert!(
2944 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2945 "a clean CLI turn is not proof the node's own report ever arrived"
2946 );
2947 }
2948
2949 #[test]
2950 fn opencode_event_stream_is_concatenated() {
2951 let stream = concat!(
2952 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2953 "\n",
2954 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2955 "\n",
2956 "garbage line\n",
2957 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2958 "\n"
2959 );
2960 let out = extract(AgentKind::Opencode, stream);
2961 assert_eq!(out.text, "first\nsecond");
2962 assert_eq!(out.session.as_deref(), Some("ses_1"));
2963 }
2964
2965 #[test]
2966 fn agy_json_survives_a_leading_warning_line() {
2967 let stdout = concat!(
2968 "warning: --mode plan has no effect while slash commands are disabled.\n",
2969 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2970 "\n"
2971 );
2972 let out = extract(AgentKind::Antigravity, stdout);
2973 assert_eq!(out.text, "persimmon");
2974 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2975 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2976 }
2977
2978 const AGY_DROPPED: &str = concat!(
2985 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2986 r#""response":"","error":"the connection to the agent was interrupted before "#,
2987 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2988 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2989 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2990 r#""total_tokens":274380}}"#
2991 );
2992
2993 const AGY_QUOTA_OUT: &str = concat!(
2995 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2996 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2997 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2998 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2999 );
3000 const AGY_QUOTA_ERR: &str = concat!(
3001 "error: Individual quota reached. Resets in 1h2m49s.\n",
3002 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3003 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3004 r#""retryable":true}"#,
3005 "\n"
3006 );
3007
3008 #[test]
3009 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3010 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3011 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3012 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3013 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3014 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3016 assert!(stderr_only.reset.is_none());
3017 }
3018
3019 #[test]
3020 fn ordinary_agy_failures_are_not_a_quota() {
3021 assert!(agy_quota(AGY_DROPPED, "").is_none());
3022 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3023 assert!(
3024 agy_quota(
3025 "",
3026 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3027 )
3028 .is_none()
3029 );
3030 assert!(
3031 agy_quota(
3032 "",
3033 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3034 )
3035 .is_none()
3036 );
3037 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3038 }
3039
3040 #[test]
3041 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3042 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3043 let dropped = out.dropped.expect("recognised as undelivered work");
3044 assert_eq!(dropped.output_tokens, 14267);
3045 assert!(
3046 dropped.why.contains("subscriber fell behind"),
3047 "the CLI's own words are kept for the record: {}",
3048 dropped.why
3049 );
3050 assert_eq!(
3053 out.session.as_deref(),
3054 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3055 );
3056 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3057 }
3058
3059 #[test]
3060 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3061 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3065 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3066
3067 let answered = concat!(
3070 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3071 r#""usage":{"output_tokens":10}}"#
3072 );
3073 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3074
3075 let ok = concat!(
3077 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3078 r#""usage":{"output_tokens":10}}"#
3079 );
3080 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3081 }
3082
3083 #[test]
3084 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3085 let out = AgentOutput {
3086 text: String::new(),
3087 exit_code: Some(1),
3088 timed_out: false,
3089 duration_ms: 431_194,
3090 artifacts: Vec::new(),
3091 quota: None,
3092 dropped: Some(Dropped {
3093 why: "subscriber fell behind updates".to_owned(),
3094 output_tokens: 14267,
3095 }),
3096 commands: Vec::new(),
3097 context_tokens: None,
3098 };
3099 assert!(!out.usable());
3100 assert!(out.work_undelivered());
3101 assert!(!out.quota_exhausted());
3104 }
3105
3106 #[test]
3107 fn non_json_stdout_falls_back_to_raw_text() {
3108 let out = extract(AgentKind::Antigravity, "plain answer\n");
3109 assert_eq!(out.text, "plain answer");
3110 assert!(out.session.is_none());
3111 }
3112
3113 #[tokio::test]
3114 async fn command_agent_round_trip_writes_artifacts() {
3115 let dir = tempfile::tempdir().unwrap();
3116 let art = dir.path().join("artifacts");
3117 let mut seat = SeatState::new("impl-A", "a", 7);
3118 let s = command_helper("reply");
3119 let out = invoke(
3120 &s,
3121 &mut seat,
3122 &Invocation {
3123 scope: crate::agent::InvocationScope::Repository,
3124 coordination: None,
3125 fetched_base: None,
3126 cwd: dir.path(),
3127 prompt: "unused",
3128 timeout: Duration::from_secs(30),
3129 allow_write: true,
3130 unsandboxed: false,
3131 sessions: true,
3132 artifacts: &art,
3133 stem: "impl-A",
3134 run: "test-run",
3135 node: "test",
3136 cache_dir: None,
3137 attachments: &[],
3138 writable: &[],
3139 },
3140 )
3141 .await
3142 .unwrap();
3143 assert!(out.usable(), "{out:?}");
3144 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3145 assert_eq!(seat.turns, 1);
3146 assert!(art.join("impl-A.prompt.md").is_file());
3147 assert!(art.join("impl-A.out").is_file());
3148 }
3149
3150 #[tokio::test]
3151 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3152 crate::run::pin_test_home();
3153 let dir = tempfile::tempdir().unwrap();
3157 let cache = dir.path().join("magi-cache");
3158 let mut seat = SeatState::new("impl-A", "a", 7);
3159 let s = command_helper("cache");
3160 let out = invoke(
3161 &s,
3162 &mut seat,
3163 &Invocation {
3164 scope: crate::agent::InvocationScope::Repository,
3165 coordination: None,
3166 fetched_base: None,
3167 cwd: dir.path(),
3168 prompt: "unused",
3169 timeout: Duration::from_secs(30),
3170 allow_write: true,
3171 unsandboxed: false,
3172 sessions: true,
3173 artifacts: &dir.path().join("artifacts"),
3174 stem: "cache",
3175 run: "test-run",
3176 node: "test",
3177 cache_dir: Some(&cache),
3178 attachments: &[],
3179 writable: &[],
3180 },
3181 )
3182 .await
3183 .unwrap();
3184 assert!(out.usable(), "{out:?}");
3185 assert!(
3186 out.text.contains(cache.to_string_lossy().as_ref()),
3187 "the seat must see CARGO_TARGET_DIR = the shared cache"
3188 );
3189 }
3190
3191 #[tokio::test]
3192 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3193 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3201 unsafe {
3206 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3207 }
3208 let dir = tempfile::tempdir().unwrap();
3209 let mut seat = SeatState::new("review-1", "a", 7);
3210 let s = command_helper("no-cache");
3211 let result = invoke(
3212 &s,
3213 &mut seat,
3214 &Invocation {
3215 scope: crate::agent::InvocationScope::Repository,
3216 coordination: None,
3217 fetched_base: None,
3218 cwd: dir.path(),
3219 prompt: "unused",
3220 timeout: Duration::from_secs(30),
3221 allow_write: false,
3222 unsandboxed: false,
3223 sessions: true,
3224 artifacts: &dir.path().join("artifacts"),
3225 stem: "no-cache",
3226 run: "test-run",
3227 node: "test",
3228 cache_dir: None,
3229 attachments: &[],
3230 writable: &[],
3231 },
3232 )
3233 .await;
3234 unsafe {
3239 match &previous {
3240 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3241 None => std::env::remove_var("CARGO_TARGET_DIR"),
3242 }
3243 }
3244 let out = result.unwrap();
3245 assert!(out.usable(), "{out:?}");
3246 assert!(
3247 out.text.contains("ABSENT"),
3248 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3249 out.text
3250 );
3251 }
3252
3253 #[tokio::test]
3254 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3255 let dir = tempfile::tempdir().unwrap();
3256 let mut seat = SeatState::new("impl-A", "a", 7);
3257 let s = command_helper("ignore-stdin");
3260 let big = "x".repeat(1_000_000);
3261 let out = invoke(
3262 &s,
3263 &mut seat,
3264 &Invocation {
3265 scope: crate::agent::InvocationScope::Repository,
3266 coordination: None,
3267 fetched_base: None,
3268 cwd: dir.path(),
3269 prompt: &big,
3270 timeout: Duration::from_secs(60),
3271 allow_write: true,
3272 unsandboxed: false,
3273 sessions: true,
3274 artifacts: &dir.path().join("artifacts"),
3275 stem: "big",
3276 run: "test-run",
3277 node: "test",
3278 cache_dir: None,
3279 attachments: &[],
3280 writable: &[],
3281 },
3282 )
3283 .await
3284 .unwrap();
3285 assert!(out.usable(), "{out:?}");
3286 assert!(out.text.contains("done"), "{}", out.text);
3287 }
3288
3289 #[tokio::test]
3290 async fn timeout_is_reported_not_hung() {
3291 let dir = tempfile::tempdir().unwrap();
3292 let mut seat = SeatState::new("impl-A", "a", 7);
3293 let s = command_helper("sleep");
3294 let out = invoke(
3295 &s,
3296 &mut seat,
3297 &Invocation {
3298 scope: crate::agent::InvocationScope::Repository,
3299 coordination: None,
3300 fetched_base: None,
3301 cwd: dir.path(),
3302 prompt: "unused",
3303 timeout: Duration::from_millis(300),
3304 allow_write: true,
3305 unsandboxed: false,
3306 sessions: true,
3307 artifacts: &dir.path().join("artifacts"),
3308 stem: "slow",
3309 run: "test-run",
3310 node: "test",
3311 cache_dir: None,
3312 attachments: &[],
3313 writable: &[],
3314 },
3315 )
3316 .await
3317 .unwrap();
3318 assert!(out.timed_out);
3319 assert!(!out.usable());
3320 }
3321
3322 #[tokio::test]
3323 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3324 let dir = tempfile::tempdir().unwrap();
3330 let artifacts = dir.path().join("artifacts");
3331 let mut seat = SeatState::new("impl-A", "a", 7);
3332 let s = command_helper("chatty-sleep");
3333 let out = invoke(
3334 &s,
3335 &mut seat,
3336 &Invocation {
3337 scope: crate::agent::InvocationScope::Repository,
3338 coordination: None,
3339 fetched_base: None,
3340 cwd: dir.path(),
3341 prompt: "unused",
3342 timeout: Duration::from_secs(10),
3347 allow_write: true,
3348 unsandboxed: false,
3349 sessions: true,
3350 artifacts: &artifacts,
3351 stem: "chatty",
3352 run: "test-run",
3353 node: "test",
3354 cache_dir: None,
3355 attachments: &[],
3356 writable: &[],
3357 },
3358 )
3359 .await
3360 .unwrap();
3361
3362 assert!(out.timed_out, "{out:?}");
3363 assert!(!out.usable(), "a cut-off answer is still not an answer");
3364 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3365 assert!(
3366 recorded.contains("i-said-something"),
3367 "the artifact must keep what arrived before the kill, got {recorded:?}"
3368 );
3369 assert!(
3370 out.text.contains("i-said-something"),
3371 "and the graph must be able to see it too, got {:?}",
3372 out.text
3373 );
3374 }
3375
3376 #[test]
3377 fn missing_programs_reports_command_binaries() {
3378 let mut s = spec(AgentKind::Command, None);
3379 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3380 assert_eq!(
3381 missing_programs(&[s]),
3382 ["definitely-not-a-real-binary-xyz".to_owned()]
3383 );
3384 }
3385
3386 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3387 AgentSpec {
3388 id: id.to_owned(),
3389 kind,
3390 model: None,
3391 command: Vec::new(),
3392 extra_args: Vec::new(),
3393 env: BTreeMap::new(),
3394 prompt_delivery: None,
3395 }
3396 }
3397
3398 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3401 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3402 }
3403
3404 #[test]
3405 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3406 let agents = [
3407 pick_spec("oc", AgentKind::Opencode),
3408 pick_spec("opus", AgentKind::Claude),
3409 pick_spec("agy", AgentKind::Antigravity),
3410 ];
3411 let got = pick(&agents, None, &without(&[])).expect("a pick");
3412 assert_eq!(got.id, "opus");
3413 }
3414
3415 #[test]
3416 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3417 let agents = [
3418 pick_spec("opus", AgentKind::Claude),
3419 pick_spec("oc", AgentKind::Opencode),
3420 pick_spec("agy", AgentKind::Antigravity),
3421 ];
3422 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3423 assert_eq!(got.id, "agy");
3424 }
3425
3426 #[test]
3427 fn pick_on_an_empty_roster_says_what_to_install() {
3428 let msg = pick(&[], None, &without(&[]))
3429 .expect_err("nobody to ask")
3430 .to_string();
3431 assert!(msg.contains("roster is empty"), "{msg}");
3432 assert!(msg.contains("claude"), "{msg}");
3433 assert!(msg.contains("magi.toml"), "{msg}");
3434 }
3435
3436 #[test]
3437 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3438 let agents = [
3439 pick_spec("opus", AgentKind::Claude),
3440 pick_spec("oc", AgentKind::Opencode),
3441 ];
3442 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3443 let msg = format!("{err:#}");
3444 assert!(msg.contains("claude"), "{msg}");
3445 assert!(msg.contains("opencode"), "{msg}");
3446 }
3447
3448 #[test]
3449 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3450 let agents = [
3451 pick_spec("opus", AgentKind::Claude),
3452 pick_spec("oc", AgentKind::Opencode),
3453 ];
3454 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3455 assert_eq!(got.id, "oc");
3456 }
3457
3458 #[test]
3459 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3460 let agents = [
3461 pick_spec("opus", AgentKind::Claude),
3462 pick_spec("oc", AgentKind::Opencode),
3463 ];
3464 let msg = pick(&agents, Some("gemini"), &without(&[]))
3465 .expect_err("no such agent")
3466 .to_string();
3467 assert!(msg.contains("gemini"), "{msg}");
3468 assert!(msg.contains("opus, oc"), "{msg}");
3469 }
3470
3471 #[test]
3472 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3473 let agents = [
3474 pick_spec("opus", AgentKind::Claude),
3475 pick_spec("oc", AgentKind::Opencode),
3476 ];
3477 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3478 .expect_err("must not silently substitute another model")
3479 .to_string();
3480 assert!(msg.contains("opencode"), "{msg}");
3481 assert!(msg.contains("--agent"), "{msg}");
3482 }
3483
3484 fn named(id: &str) -> AgentSpec {
3485 AgentSpec {
3486 id: id.to_owned(),
3487 ..spec(AgentKind::Command, None)
3488 }
3489 }
3490
3491 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3492 AgentOutput {
3493 text: text.to_owned(),
3494 exit_code: Some(exit),
3495 timed_out: false,
3496 duration_ms: 0,
3497 artifacts: Vec::new(),
3498 quota: quota.then_some(Quota { reset: None }),
3499 dropped: None,
3500 commands: Vec::new(),
3501 context_tokens: None,
3502 }
3503 }
3504
3505 #[test]
3506 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3507 let agents = [named("a"), named("b"), named("c")];
3508 let all = |_: &AgentSpec| true;
3509 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3510 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3511 assert_eq!(
3512 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3513 ["c", "a"]
3514 );
3515
3516 let one = AgentChoice::from("b");
3517 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3518 assert_eq!(got.len(), 1);
3519 assert_eq!(got[0].id, "b");
3520
3521 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3522 assert_eq!(got.len(), 1, "unset keeps pick's default");
3523 assert_eq!(got[0].id, "a");
3524 let empty = AgentChoice::Chain(Vec::new());
3525 assert_eq!(
3526 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3527 "a"
3528 );
3529 }
3530
3531 #[test]
3532 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3533 let agents = [named("a"), named("b")];
3534 let not_a = |s: &AgentSpec| s.id != "a";
3535 let chain = AgentChoice::Chain(
3536 ["a", "ghost", "b", "b"]
3537 .iter()
3538 .map(|s| (*s).to_owned())
3539 .collect(),
3540 );
3541 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3542 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3543
3544 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3545 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3546 assert_eq!(
3547 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3548 ["b", "a"]
3549 );
3550 }
3551
3552 #[test]
3553 fn a_chain_with_nothing_runnable_names_the_role() {
3554 let agents = [named("a")];
3555 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3556 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3557 .unwrap_err()
3558 .to_string();
3559 assert!(err.contains("conductor"), "{err}");
3560 }
3561
3562 #[test]
3563 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3564 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3565 assert!(chain_advances(&Ok(output("limit", 0, true))));
3566 assert!(chain_advances(&Ok(output("", 0, false))));
3567 assert!(chain_advances(&Ok(output("x", 1, false))));
3568 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3569 }
3570
3571 #[test]
3572 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3573 for out in [
3574 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3575 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3576 r#"{"result":"ok"}"#,
3577 "not json",
3578 ] {
3579 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3580 }
3581 }
3582
3583 #[test]
3584 fn opencode_context_tokens_take_the_last_step_finish() {
3585 let out = concat!(
3586 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3587 "\n",
3588 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3589 "\n",
3590 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3591 "\n"
3592 );
3593 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3594 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3595 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3596 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3597 }
3598
3599 #[test]
3600 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3601 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3602 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3603 }
3604
3605 #[test]
3606 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3607 let out = concat!(
3608 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3609 "\n"
3610 );
3611 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3612 }
3613
3614 #[test]
3615 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3616 let out = concat!(
3617 r#"{"type":"session","id":"s"}"#,
3618 "\n",
3619 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3620 "\n",
3621 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3622 "\n"
3623 );
3624 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3625 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3626 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3627 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3628 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3629 }
3630
3631 #[test]
3632 fn command_agents_report_no_context_tokens() {
3633 let out = r#"{"usage":{"input_tokens":5}}"#;
3634 assert_eq!(context_tokens(AgentKind::Command, out), None);
3635 }
3636}