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 #[cfg(unix)]
614 let _chat_controls = {
615 use crate::chat_control::bridge::{self, Bridge};
616 bridge::clear_environment(&mut cmd);
617 let bridge = if inv.node == crate::queue::CHAT_NODE
618 && inv.scope == InvocationScope::Repository
619 {
620 crate::run::try_home().and_then(|home| {
621 match Bridge::start(&home, inv.run, inv.artifacts) {
622 Ok(bridge) => Some(bridge),
623 Err(error) => {
624 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
625 None
626 }
627 }
628 })
629 } else {
630 None
631 };
632 if let Some(bridge) = &bridge {
633 bridge.attach(&mut cmd);
634 }
635 bridge
636 };
637
638 start_check()?;
641 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
642 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
643 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
647 tokio::spawn(async move {
648 sink.write_all(body.as_bytes()).await.ok();
649 sink.shutdown().await.ok();
650 });
651 }
652
653 let (out_buf, out_reader) = drain(running.child().stdout.take());
670 let (err_buf, err_reader) = drain(running.child().stderr.take());
671
672 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
673 .ok()
674 .map_or(0, |state| state.config.disk.min_free_bytes);
675 let mut paths = vec![inv.cwd.to_owned()];
676 if let Some(cache) = inv.cache_dir {
677 paths.push(cache.to_owned());
678 }
679 let mut pressure = None;
680 let waited = tokio::select! {
681 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
682 reason = crate::retention::emergency(paths, min_free) => {
683 pressure = Some(reason);
684 None
685 }
686 };
687 let (code, timed_out) = match waited {
688 Some(Ok(res)) => {
689 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
690 (status.code(), false)
691 }
692 Some(Err(_)) | None => {
693 if let Some(reason) = &pressure {
694 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
695 } else {
696 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
697 }
698 if !running.coordinated() {
700 running
701 .child()
702 .kill()
703 .await
704 .context("stop timed-out seat")?;
705 }
706 (None, true)
707 }
708 };
709 running.finish().await;
710
711 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
716 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
717
718 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
719 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
720 tokio::fs::write(&out_path, &stdout).await.ok();
721 tokio::fs::write(&err_path, &stderr).await.ok();
722 if let Some(reason) = pressure {
723 return Err(
724 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
725 .context(ExecutionInterrupted(reason)),
726 );
727 }
728
729 let mut extracted = extract(spec.kind, &stdout);
730 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
731 extracted.quota = agy_quota(&stdout, &stderr);
732 }
733 if let Some(quota) = &extracted.quota {
734 extracted.dropped = None;
737 tracing::warn!(
738 seat = %seat.key,
739 agent = %spec.id,
740 reset = ?quota.reset,
741 "agent is out of quota"
742 );
743 }
744 if let Some(session) = extracted.session {
745 match spec.kind {
746 AgentKind::Claude => seat.claude_session = Some(session),
747 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
748 seat.captured_session = Some(session);
749 }
750 AgentKind::Command => {}
751 }
752 }
753 if let Some(status) = &extracted.status
754 && !status.eq_ignore_ascii_case("success")
755 {
756 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
757 }
758 let text = if extracted.text.trim().is_empty() {
759 if stdout.trim().is_empty() {
761 stderr.trim().to_owned()
762 } else {
763 stdout.trim().to_owned()
764 }
765 } else {
766 extracted.text
767 };
768 seat.turns += 1;
769
770 Ok(AgentOutput {
771 text,
772 exit_code: code,
773 timed_out,
774 duration_ms: started.elapsed().as_millis() as u64,
775 artifacts: vec![
776 file_name(&prompt_path),
777 file_name(&out_path),
778 file_name(&err_path),
779 ],
780 quota: extracted.quota,
781 dropped: extracted.dropped,
782 commands: extracted.commands,
783 context_tokens: extracted.context_tokens,
784 })
785}
786
787fn file_name(p: &Path) -> String {
788 p.file_name()
789 .unwrap_or_default()
790 .to_string_lossy()
791 .into_owned()
792}
793
794#[derive(Debug)]
796struct Plan {
797 argv: Vec<String>,
798 stdin: Option<String>,
799}
800
801fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
812 if matches!(kind, AgentKind::Antigravity) {
813 return format!("@{}", prompt_path.display());
814 }
815 format!(
816 "Read the file at {} and follow every instruction in it exactly. That \
817 file is your complete task description; this message contains nothing \
818 else.",
819 prompt_path.display()
820 )
821}
822
823fn build_command(
824 spec: &AgentSpec,
825 seat: &SeatState,
826 inv: &Invocation<'_>,
827 prompt_path: &Path,
828) -> Result<Plan> {
829 let mut argv: Vec<String> = Vec::new();
830 let mut stdin: Option<String> = None;
831 let delivery = spec.delivery();
832 let resuming = has_session(spec.kind, seat, inv.sessions);
833
834 match spec.kind {
835 AgentKind::Claude => {
836 argv.push("claude".to_owned());
841 argv.push("-p".to_owned());
842 argv.push("--output-format".to_owned());
843 argv.push("json".to_owned());
844 if let Some(m) = &spec.model {
845 argv.push("--model".to_owned());
846 argv.push(m.clone());
847 }
848 if inv.sessions {
849 let uuid = seat
850 .claude_session
851 .as_deref()
852 .context("claude seat is missing its session uuid")?;
853 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
854 argv.push(uuid.to_owned());
855 }
856 argv.push("--permission-mode".to_owned());
857 argv.push("bypassPermissions".to_owned());
858 if !inv.allow_write {
859 argv.push("--disallowed-tools".to_owned());
860 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
861 }
862 }
863 AgentKind::Opencode => {
864 argv.push("opencode".to_owned());
868 argv.push("run".to_owned());
869 argv.push("--format".to_owned());
870 argv.push("json".to_owned());
871 argv.push("--dir".to_owned());
872 argv.push(inv.cwd.to_string_lossy().into_owned());
873 argv.push("--auto".to_owned());
882 if let Some(m) = &spec.model {
883 argv.push("-m".to_owned());
884 argv.push(m.clone());
885 }
886 if resuming {
887 argv.push("-s".to_owned());
888 argv.push(
889 seat.captured_session
890 .clone()
891 .expect("has_session checked the id is present"),
892 );
893 }
894 }
895 AgentKind::Antigravity => {
896 argv.push("agy".to_owned());
897 argv.push("--output-format".to_owned());
898 argv.push("json".to_owned());
899 argv.push("--print-timeout".to_owned());
902 argv.push(format!("{}s", inv.timeout.as_secs()));
903 argv.push("--mode".to_owned());
904 argv.push(
905 if inv.allow_write {
906 "accept-edits"
907 } else {
908 "plan"
909 }
910 .to_owned(),
911 );
912 if inv.allow_write {
913 argv.push("--dangerously-skip-permissions".to_owned());
914 }
915 if let Some(m) = &spec.model {
916 argv.push("--model".to_owned());
917 argv.push(m.clone());
918 }
919 if resuming {
920 argv.push("--conversation".to_owned());
921 argv.push(
922 seat.captured_session
923 .clone()
924 .expect("has_session checked the id is present"),
925 );
926 }
927 let mut add_dirs: Vec<String> = Vec::new();
937 if delivery == Delivery::File || !inv.attachments.is_empty() {
938 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
939 }
940 for path in inv.attachments {
941 let Some(parent) = path.parent() else {
942 continue;
943 };
944 if parent.starts_with(inv.artifacts) {
945 continue;
946 }
947 let dir = parent.to_string_lossy().into_owned();
948 if !add_dirs.contains(&dir) {
949 add_dirs.push(dir);
950 }
951 }
952 if inv.allow_write {
953 for path in inv.writable {
954 let dir = path.to_string_lossy().into_owned();
955 if !add_dirs.contains(&dir) {
956 add_dirs.push(dir);
957 }
958 }
959 }
960 for dir in add_dirs {
961 argv.push("--add-dir".to_owned());
962 argv.push(dir);
963 }
964 }
965 AgentKind::Codex => {
966 argv.push("codex".to_owned());
972 argv.push("exec".to_owned());
973 argv.push("--json".to_owned());
974 argv.push("--skip-git-repo-check".to_owned());
977 argv.push("-C".to_owned());
978 argv.push(inv.cwd.to_string_lossy().into_owned());
979 if inv.allow_write && inv.unsandboxed {
988 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
989 } else {
990 argv.push("--sandbox".to_owned());
991 argv.push(
992 if inv.allow_write {
993 "workspace-write"
994 } else {
995 "read-only"
996 }
997 .to_owned(),
998 );
999 argv.push("-c".to_owned());
1002 argv.push("approval_policy=\"never\"".to_owned());
1003 if repository_writer(inv) {
1004 argv.push("-c".to_owned());
1005 argv.push("sandbox_workspace_write.network_access=false".to_owned());
1006 }
1007 if inv.allow_write && !inv.writable.is_empty() {
1008 let roots: Vec<String> = inv
1009 .writable
1010 .iter()
1011 .map(|p| p.to_string_lossy().into_owned())
1012 .collect();
1013 argv.push("-c".to_owned());
1014 argv.push(format!(
1015 "sandbox_workspace_write.writable_roots={}",
1016 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
1017 ));
1018 }
1019 }
1020 if let Some(m) = &spec.model {
1021 argv.push("-m".to_owned());
1022 argv.push(m.clone());
1023 }
1024 if resuming {
1030 argv.push("resume".to_owned());
1031 argv.push(
1032 seat.captured_session
1033 .clone()
1034 .expect("has_session checked the id is present"),
1035 );
1036 }
1037 }
1038 AgentKind::Omp => {
1039 argv.push("omp".to_owned());
1043 argv.push("-p".to_owned());
1044 argv.push("--mode=json".to_owned());
1045 argv.push("--auto-approve".to_owned());
1055 if let Some(m) = &spec.model {
1056 argv.push("--model".to_owned());
1057 argv.push(m.clone());
1058 }
1059 if resuming {
1065 argv.push("--resume".to_owned());
1066 argv.push(
1067 seat.captured_session
1068 .clone()
1069 .expect("has_session checked the id is present"),
1070 );
1071 }
1072 }
1073 AgentKind::Command => {
1074 if spec.command.is_empty() {
1079 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1080 }
1081 let vars: BTreeMap<&str, String> = BTreeMap::from([
1082 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1083 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1084 ("{label}", seat.key.clone()),
1085 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1086 ]);
1087 for raw in &spec.command {
1088 let mut arg = raw.clone();
1089 for (k, v) in &vars {
1090 if arg.contains(k) {
1091 arg = arg.replace(k, v);
1092 }
1093 }
1094 argv.push(arg);
1095 }
1096 }
1097 }
1098
1099 argv.extend(spec.extra_args.iter().cloned());
1100
1101 if spec.kind == AgentKind::Antigravity {
1104 argv.push("-p".to_owned());
1105 }
1106 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1109 argv.push("-".to_owned());
1110 }
1111 match delivery {
1112 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1113 argv.push(pointer(spec.kind, prompt_path));
1115 }
1116 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1117 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1118 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1119 }
1120
1121 Ok(Plan { argv, stdin })
1122}
1123
1124#[derive(Debug, Default)]
1126struct Extracted {
1127 text: String,
1128 session: Option<String>,
1129 status: Option<String>,
1130 quota: Option<Quota>,
1131 dropped: Option<Dropped>,
1132 commands: Vec<CommandEvidence>,
1133 context_tokens: Option<u64>,
1134}
1135
1136fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1138 let mut extracted = extract_answer(kind, stdout);
1139 extracted.context_tokens = context_tokens(kind, stdout);
1140 extracted
1141}
1142
1143fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1145 v.get(key).and_then(serde_json::Value::as_u64)
1146}
1147
1148fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1153 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1154 let base = first(input)?;
1155 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1156 Some(base.saturating_add(cached))
1157}
1158
1159fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1180 use serde_json::Value;
1181 let lines = || {
1182 stdout
1183 .lines()
1184 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1185 };
1186 match kind {
1187 AgentKind::Claude => None,
1188 AgentKind::Opencode => lines()
1189 .filter(|v| {
1190 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1192 || v.get("part")
1193 .and_then(|p| p.get("type"))
1194 .and_then(|t| t.as_str())
1195 == Some("step-finish")
1196 })
1197 .filter_map(|v| {
1198 let tokens = v.get("part")?.get("tokens")?;
1199 let base = uint(tokens, "input")?;
1200 let cache = tokens.get("cache");
1201 let cached = ["read", "write"]
1202 .iter()
1203 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1204 .sum::<u64>();
1205 Some(base.saturating_add(cached))
1206 })
1207 .next_back(),
1208 AgentKind::Antigravity => None,
1209 AgentKind::Codex => None,
1210 AgentKind::Omp => lines()
1211 .flat_map(|v| {
1212 match v.get("type").and_then(|t| t.as_str()) {
1215 Some("agent_end") => v
1216 .get("messages")
1217 .and_then(|m| m.as_array())
1218 .cloned()
1219 .unwrap_or_default(),
1220 Some("turn_end") | Some("message_end") => {
1221 v.get("message").cloned().into_iter().collect()
1222 }
1223 _ => Vec::new(),
1224 }
1225 })
1226 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1227 .filter_map(|m| {
1228 input_side(
1229 m.get("usage")?,
1230 &["input", "input_tokens"],
1231 &[
1232 "cacheRead",
1233 "cacheWrite",
1234 "cache_read_tokens",
1235 "cache_write_tokens",
1236 ],
1237 )
1238 })
1239 .next_back(),
1240 AgentKind::Command => None,
1241 }
1242}
1243
1244pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1247 extract_answer(kind, stdout).text
1248}
1249
1250fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1251 match kind {
1252 AgentKind::Claude => {
1253 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1254 return Extracted {
1255 text: stdout.trim().to_owned(),
1256 ..Extracted::default()
1257 };
1258 };
1259 Extracted {
1260 text: v
1261 .get("result")
1262 .and_then(|r| r.as_str())
1263 .unwrap_or_default()
1264 .to_owned(),
1265 session: v
1266 .get("session_id")
1267 .and_then(|s| s.as_str())
1268 .map(str::to_owned),
1269 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1270 if e {
1271 "error".to_owned()
1272 } else {
1273 "success".to_owned()
1274 }
1275 }),
1276 quota: claude_quota(&v),
1277 dropped: None,
1280 commands: Vec::new(),
1281 context_tokens: None,
1282 }
1283 }
1284 AgentKind::Opencode => {
1285 let mut text = String::new();
1287 let mut session = None;
1288 for line in stdout.lines() {
1289 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1290 continue;
1291 };
1292 if session.is_none() {
1293 session = v
1294 .get("sessionID")
1295 .and_then(|s| s.as_str())
1296 .map(str::to_owned);
1297 }
1298 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1299 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1300 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1301 {
1302 if !text.is_empty() {
1303 text.push('\n');
1304 }
1305 text.push_str(t);
1306 }
1307 }
1308 Extracted {
1309 text,
1310 session,
1311 status: None,
1312 quota: None,
1313 dropped: None,
1314 commands: Vec::new(),
1315 context_tokens: None,
1316 }
1317 }
1318 AgentKind::Antigravity => {
1319 let obj = stdout
1322 .lines()
1323 .rev()
1324 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1325 let Some(v) = obj else {
1326 return Extracted {
1327 text: stdout.trim().to_owned(),
1328 ..Extracted::default()
1329 };
1330 };
1331 Extracted {
1332 text: v
1333 .get("response")
1334 .and_then(|r| r.as_str())
1335 .unwrap_or_default()
1336 .trim()
1337 .to_owned(),
1338 session: v
1339 .get("conversation_id")
1340 .and_then(|s| s.as_str())
1341 .map(str::to_owned),
1342 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1343 quota: None,
1344 dropped: dropped_stream(&v),
1345 commands: Vec::new(),
1346 context_tokens: None,
1347 }
1348 }
1349 AgentKind::Codex => {
1350 let mut text = String::new();
1372 let mut session = None;
1373 let mut status = None;
1374 let mut commands = Vec::new();
1375 for line in stdout.lines() {
1376 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1377 continue;
1378 };
1379 match v.get("type").and_then(|t| t.as_str()) {
1380 Some("thread.started") => {
1381 session = v
1382 .get("thread_id")
1383 .and_then(|s| s.as_str())
1384 .map(str::to_owned);
1385 }
1386 Some("item.completed") => {
1387 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1388 match item.get("type").and_then(|t| t.as_str()) {
1389 Some("agent_message") => {
1390 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1391 text = t.trim().to_owned();
1392 }
1393 }
1394 Some("command_execution") => {
1395 commands.push(command_evidence(item));
1396 }
1397 _ => {}
1398 }
1399 }
1400 Some("turn.completed") => status = Some("success".to_owned()),
1401 Some("turn.failed") => status = Some("error".to_owned()),
1402 _ => {}
1403 }
1404 }
1405 Extracted {
1406 text,
1407 session,
1408 status,
1409 quota: None,
1410 dropped: None,
1411 commands,
1412 context_tokens: None,
1413 }
1414 }
1415 AgentKind::Omp => {
1416 let mut text = String::new();
1437 let mut session = None;
1438 for line in stdout.lines() {
1439 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1440 continue;
1441 };
1442 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1443 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1444 continue;
1445 }
1446 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1450 {
1451 Some("agent_end") => v
1452 .get("messages")
1453 .and_then(|m| m.as_array())
1454 .map(|m| m.iter().collect())
1455 .unwrap_or_default(),
1456 Some("turn_end") | Some("message_end") => {
1457 v.get("message").into_iter().collect()
1458 }
1459 _ => continue,
1460 };
1461 for message in messages {
1462 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1463 continue;
1464 }
1465 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1466 continue;
1467 };
1468 for part in parts {
1469 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1470 continue;
1471 }
1472 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1473 && !t.trim().is_empty()
1474 {
1475 text = t.trim().to_owned();
1476 }
1477 }
1478 }
1479 }
1480 Extracted {
1481 text,
1482 session,
1483 status: None,
1484 quota: None,
1485 dropped: None,
1486 commands: Vec::new(),
1487 context_tokens: None,
1488 }
1489 }
1490 AgentKind::Command => {
1491 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1496 let quota = parsed.as_ref().and_then(claude_quota);
1497 let dropped = parsed.as_ref().and_then(dropped_stream);
1500 Extracted {
1501 text: stdout.trim().to_owned(),
1502 session: None,
1503 status: None,
1504 quota,
1505 dropped,
1506 commands: Vec::new(),
1507 context_tokens: None,
1508 }
1509 }
1510 }
1511}
1512
1513fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1520 let description = match item.get("command") {
1521 Some(serde_json::Value::String(s)) => s.clone(),
1522 Some(serde_json::Value::Array(parts)) => parts
1523 .iter()
1524 .filter_map(|p| p.as_str())
1525 .collect::<Vec<_>>()
1526 .join(" "),
1527 _ => String::new(),
1528 };
1529 let result_summary = item
1530 .get("aggregated_output")
1531 .and_then(|o| o.as_str())
1532 .map(|s| tail_chars(s.trim(), 400))
1533 .unwrap_or_default();
1534 CommandEvidence {
1535 id: item
1536 .get("id")
1537 .and_then(|s| s.as_str())
1538 .unwrap_or_default()
1539 .to_owned(),
1540 description,
1541 exit_code: item
1542 .get("exit_code")
1543 .and_then(serde_json::Value::as_i64)
1544 .map(|e| e as i32),
1545 result_summary,
1546 source: "codex".to_owned(),
1547 }
1548}
1549
1550fn tail_chars(s: &str, max: usize) -> String {
1552 let count = s.chars().count();
1553 if count <= max {
1554 return s.to_owned();
1555 }
1556 s.chars().skip(count - max).collect()
1557}
1558
1559fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1566 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1567 if !is_err {
1568 return None;
1569 }
1570 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1571 let status = v.get("api_error_status");
1572 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1573 || status.and_then(serde_json::Value::as_str) == Some("429"))
1574 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1575 if !usage_limit && !result.to_lowercase().contains("session limit") {
1576 return None;
1577 }
1578 let reset = result
1581 .split("resets ")
1582 .nth(1)
1583 .map(str::trim)
1584 .filter(|s| !s.is_empty())
1585 .map(str::to_owned);
1586 Some(Quota { reset })
1587}
1588
1589fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1604 let from_stdout = stdout
1605 .lines()
1606 .rev()
1607 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1608 .and_then(|v| {
1609 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1610 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1611 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1612 .then(|| error.to_owned())
1613 });
1614 let from_stderr = || {
1615 stderr.lines().find_map(|l| {
1616 let v: serde_json::Value =
1617 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1618 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1619 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1620 (exhausted && code).then(|| {
1621 v.get("short_error")
1622 .and_then(|e| e.as_str())
1623 .unwrap_or_default()
1624 .to_owned()
1625 })
1626 })
1627 };
1628 let text = from_stdout.or_else(from_stderr)?;
1629 let reset = text
1630 .split_once("Resets ")
1631 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1632 .filter(|s| !s.is_empty())
1633 .map(str::to_owned);
1634 Some(Quota { reset })
1635}
1636
1637fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1668 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1669 if !status.eq_ignore_ascii_case("error") {
1670 return None;
1671 }
1672 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1673 if !response.trim().is_empty() {
1674 return None;
1676 }
1677 let produced = v
1678 .get("usage")
1679 .and_then(|u| u.get("output_tokens"))
1680 .and_then(serde_json::Value::as_u64)
1681 .unwrap_or(0);
1682 if produced == 0 {
1683 return None;
1685 }
1686 Some(Dropped {
1687 why: v
1688 .get("error")
1689 .and_then(|e| e.as_str())
1690 .unwrap_or("the CLI ended the stream without delivering its answer")
1691 .trim()
1692 .to_owned(),
1693 output_tokens: produced,
1694 })
1695}
1696
1697pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1699 let mut missing = Vec::new();
1700 for s in specs {
1701 let program = match s.kind {
1702 AgentKind::Command => s.command.first().map(String::as_str),
1703 other => other.program(),
1704 };
1705 if let Some(p) = program
1706 && !crate::config::which(p)
1707 && !Path::new(p).is_file()
1708 && !missing.iter().any(|m: &String| m == p)
1709 {
1710 missing.push(p.to_owned());
1711 }
1712 }
1713 missing
1714}
1715
1716pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1718 run_dir.join("artifacts")
1719}
1720
1721pub fn installed(spec: &AgentSpec) -> bool {
1723 spec.kind.program().is_none_or(crate::config::which)
1726}
1727
1728pub fn pick(
1750 agents: &[AgentSpec],
1751 want: Option<&str>,
1752 available: &dyn Fn(&AgentSpec) -> bool,
1753) -> Result<AgentSpec> {
1754 if let Some(id) = want {
1755 let spec = agents
1756 .iter()
1757 .find(|a| a.id == id)
1758 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1759 if !available(spec) {
1760 bail!(
1761 "agent `{}` needs `{}` on PATH; install it or pass a different \
1762 --agent",
1763 spec.id,
1764 spec.kind.program().unwrap_or("its command")
1765 );
1766 }
1767 return Ok(spec.clone());
1768 }
1769
1770 if agents.is_empty() {
1771 bail!(
1772 "the agent roster is empty, so there is nobody to ask: install one \
1773 of claude, opencode or agy - magi derives a roster from what is on \
1774 PATH - or add an [[agents]] entry to magi.toml."
1775 );
1776 }
1777
1778 if let Some(spec) = agents
1779 .iter()
1780 .find(|a| a.kind == AgentKind::Claude && available(a))
1781 {
1782 return Ok(spec.clone());
1783 }
1784
1785 agents
1786 .iter()
1787 .find(|a| available(a))
1788 .cloned()
1789 .with_context(|| {
1790 let missing = agents
1791 .iter()
1792 .filter_map(|a| a.kind.program())
1793 .collect::<Vec<_>>()
1794 .join(", ");
1795 format!(
1796 "no agent in the roster can be run here: install one of \
1797 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1798 you do have"
1799 )
1800 })
1801}
1802
1803pub fn pick_chain(
1811 agents: &[AgentSpec],
1812 choice: Option<&AgentChoice>,
1813 available: &dyn Fn(&AgentSpec) -> bool,
1814 role: &str,
1815) -> Result<Vec<AgentSpec>> {
1816 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1817 if wanted.is_empty() {
1818 return pick(agents, None, available)
1819 .map(|s| vec![s])
1820 .with_context(|| format!("choose an agent for the {role} role"));
1821 }
1822 let mut chain: Vec<AgentSpec> = Vec::new();
1823 for id in wanted {
1824 if chain.iter().any(|s| s.id == id) {
1825 continue;
1826 }
1827 match pick(agents, Some(id), available) {
1828 Ok(spec) => chain.push(spec),
1829 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1830 }
1831 }
1832 if chain.is_empty() {
1833 bail!(
1834 "[roles] {role} names no agent that can run here; the roster has {}",
1835 ids(agents)
1836 );
1837 }
1838 Ok(chain)
1839}
1840
1841pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1847 match outcome {
1848 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1849 Err(_) => true,
1850 Ok(out) => output_advances(out),
1851 }
1852}
1853
1854pub fn output_advances(out: &AgentOutput) -> bool {
1858 out.quota_exhausted() || !out.usable()
1859}
1860
1861fn ids(agents: &[AgentSpec]) -> String {
1862 if agents.is_empty() {
1863 return "no agents at all".to_owned();
1864 }
1865 agents
1866 .iter()
1867 .map(|a| a.id.clone())
1868 .collect::<Vec<_>>()
1869 .join(", ")
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874 use super::*;
1875
1876 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1877
1878 fn command_helper(mode: &str) -> AgentSpec {
1881 crate::run::pin_test_home();
1883 AgentSpec {
1884 id: "helper".to_owned(),
1885 kind: AgentKind::Command,
1886 model: None,
1887 command: vec![
1888 std::env::current_exe()
1889 .expect("locate test helper")
1890 .to_string_lossy()
1891 .into_owned(),
1892 "--exact".to_owned(),
1893 "agent::tests::command_agent_test_helper".to_owned(),
1894 "--nocapture".to_owned(),
1895 ],
1896 extra_args: Vec::new(),
1897 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1898 prompt_delivery: None,
1899 }
1900 }
1901
1902 #[test]
1903 fn command_agent_test_helper() {
1904 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1905 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1906 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1907 Ok("no-cache") => println!(
1908 "{}",
1909 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1910 ),
1911 Ok("ignore-stdin") => println!("done"),
1912 Ok("chatty-sleep") => {
1913 println!("i-said-something");
1914 std::thread::sleep(Duration::from_secs(30));
1915 }
1916 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1917 Ok(other) => panic!("unknown command helper mode {other}"),
1918 Err(_) => {}
1919 }
1920 }
1921
1922 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1923 AgentSpec {
1924 id: "a".to_owned(),
1925 kind,
1926 model: model.map(str::to_owned),
1927 command: vec!["echo".to_owned(), "{label}".to_owned()],
1928 extra_args: Vec::new(),
1929 env: BTreeMap::new(),
1930 prompt_delivery: None,
1931 }
1932 }
1933
1934 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1935 Invocation {
1936 scope: crate::agent::InvocationScope::Repository,
1937 coordination: None,
1938 fetched_base: None,
1939 cwd,
1940 prompt: "do the thing",
1941 timeout: Duration::from_secs(900),
1942 allow_write,
1943 unsandboxed: false,
1944 sessions: true,
1945 artifacts: art,
1946 stem: "t",
1947 run: "test-run",
1948 node: "test",
1949 cache_dir: None,
1950 attachments: &[],
1951 writable: &[],
1952 }
1953 }
1954
1955 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1956 build_command(
1957 &spec(kind, None),
1958 seat,
1959 &inv(Path::new("."), Path::new("/art"), allow_write),
1960 Path::new("/art/p.md"),
1961 )
1962 .unwrap()
1963 }
1964
1965 #[test]
1966 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1967 let missing = editing_access_note(AgentKind::Codex, false, None);
1968 assert!(missing.contains("No successful launcher fetch evidence"));
1969 let base = crate::run::FetchedBase {
1970 remote: "origin".into(),
1971 branch: "main".into(),
1972 commit: "0123456789abcdef".into(),
1973 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1974 };
1975 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
1976 for expected in [
1977 "origin/main",
1978 "0123456789abcdef",
1979 "2026-10-18T00:00:00Z",
1980 "Do not fetch again",
1981 "launcher saves eligible edits",
1982 "Specification questions remain valid",
1983 ] {
1984 assert!(note.contains(expected), "missing {expected}");
1985 }
1986 for kind in [
1987 AgentKind::Codex,
1988 AgentKind::Claude,
1989 AgentKind::Opencode,
1990 AgentKind::Antigravity,
1991 AgentKind::Omp,
1992 AgentKind::Command,
1993 ] {
1994 for unsandboxed in [false, true] {
1995 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
1996 assert!(scoped.contains(&base.commit));
1997 assert!(scoped.contains("Do not fetch again"));
1998 let restricted = kind == AgentKind::Codex && !unsandboxed;
1999 for claim in [
2000 "Network access is restricted",
2001 "protected Git administration paths",
2002 "launcher saves eligible edits",
2003 "unavailable sandbox permissions",
2004 ] {
2005 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
2006 assert_eq!(
2007 editing_access_note(kind, unsandboxed, None).contains(claim),
2008 restricted,
2009 "{kind:?}/{unsandboxed} without fetch evidence",
2010 );
2011 }
2012 }
2013 }
2014 let roundtrip: crate::run::FetchedBase =
2015 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2016 assert_eq!(
2017 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
2018 note
2019 );
2020 }
2021
2022 #[test]
2023 fn editing_network_policy_is_explicit_on_initial_and_resume() {
2024 for resumed in [false, true] {
2025 let mut seat = SeatState::new("impl-A", "a", 7);
2026 if resumed {
2027 seat.turns = 1;
2028 seat.captured_session = Some("fixture-thread".into());
2029 }
2030 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2031 invocation.node = "implement";
2032 let plan = build_command(
2033 &spec(AgentKind::Codex, None),
2034 &seat,
2035 &invocation,
2036 Path::new("/art/p.md"),
2037 )
2038 .unwrap();
2039 let policy = plan
2040 .argv
2041 .iter()
2042 .position(|s| s == "sandbox_workspace_write.network_access=false")
2043 .unwrap();
2044 if resumed {
2045 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
2046 }
2047 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2048 invocation.node = "deputy";
2049 let deputy = build_command(
2050 &spec(AgentKind::Codex, None),
2051 &seat,
2052 &invocation,
2053 Path::new("/art/p.md"),
2054 )
2055 .unwrap();
2056 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
2057 assert!(
2058 !plan_for(AgentKind::Codex, &seat, false)
2059 .argv
2060 .iter()
2061 .any(|s| s.contains("writable_roots"))
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn claude_mints_then_resumes_the_same_uuid() {
2068 let mut seat = SeatState::new("judge-1", "a", 7);
2069 let uuid = seat.claude_session.clone().unwrap();
2070 let first = plan_for(AgentKind::Claude, &seat, true);
2071 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2072 assert!(!first.argv.iter().any(|a| a == "--resume"));
2073
2074 seat.turns = 1;
2075 let second = plan_for(AgentKind::Claude, &seat, true);
2076 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2077 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2078 }
2079
2080 #[test]
2081 fn read_only_seats_cannot_edit() {
2082 let seat = SeatState::new("judge-1", "a", 7);
2083 let claude = plan_for(AgentKind::Claude, &seat, false);
2084 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2085 assert!(
2086 !plan_for(AgentKind::Claude, &seat, true)
2087 .argv
2088 .iter()
2089 .any(|a| a == "--disallowed-tools")
2090 );
2091
2092 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2093 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2094 assert!(
2095 !agy.argv
2096 .iter()
2097 .any(|a| a == "--dangerously-skip-permissions")
2098 );
2099 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2100 assert!(
2101 agy_rw
2102 .argv
2103 .windows(2)
2104 .any(|w| w == ["--mode", "accept-edits"])
2105 );
2106 assert!(
2107 agy_rw
2108 .argv
2109 .iter()
2110 .any(|a| a == "--dangerously-skip-permissions")
2111 );
2112 let agy_prompt = agy_rw
2117 .argv
2118 .iter()
2119 .position(|a| a == "-p")
2120 .map(|i| agy_rw.argv[i + 1].clone())
2121 .expect("agy takes its prompt with -p");
2122 assert!(
2123 agy_prompt.starts_with('@'),
2124 "agy must get a file reference, got {agy_prompt:?}"
2125 );
2126 assert!(
2127 !agy_prompt.contains("Read the file at"),
2128 "the prose pointer is for CLIs with no file syntax"
2129 );
2130
2131 for allow_write in [false, true] {
2136 assert!(
2137 plan_for(AgentKind::Opencode, &seat, allow_write)
2138 .argv
2139 .iter()
2140 .any(|a| a == "--auto"),
2141 "opencode needs --auto even to read (allow_write = {allow_write})"
2142 );
2143 }
2144 }
2145
2146 #[test]
2149 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2150 let seat = SeatState::new("deputy-x", "a", 7);
2151 let roots = [PathBuf::from("/data/questions")];
2152 let mk = |allow_write: bool| {
2153 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2154 i.writable = &roots;
2155 build_command(
2156 &spec(AgentKind::Codex, None),
2157 &seat,
2158 &i,
2159 Path::new("/art/p.md"),
2160 )
2161 .unwrap()
2162 };
2163 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2164 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2165 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2166 }
2167
2168 #[tokio::test]
2169 async fn investigation_checks_cancellation_after_artifacts_before_spawning() {
2170 let tmp = tempfile::tempdir().unwrap();
2171 let artifacts = tmp.path().join("artifacts");
2172 let mut invocation = inv(tmp.path(), &artifacts, true);
2173 invocation.scope = InvocationScope::ConsultationInvestigation;
2174 invocation.unsandboxed = true;
2175 let mut seat = SeatState::new("chat", "fixture", 1);
2176 let error = invoke_leased(
2177 &spec(AgentKind::Command, None),
2178 &mut seat,
2179 &invocation,
2180 None,
2181 || {
2182 assert!(artifacts.join("t.prompt.md").exists());
2183 bail!("question closed during preparation")
2184 },
2185 )
2186 .await
2187 .unwrap_err();
2188 assert!(error.to_string().contains("question closed"));
2189 assert_eq!(seat.turns, 0);
2190 assert!(!artifacts.join("t.out").exists());
2191 }
2192
2193 #[test]
2194 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2195 let mut i = inv(Path::new("."), Path::new("/art"), true);
2196 for node in [
2197 "implement",
2198 "fix",
2199 "graft",
2200 "gate-fix",
2201 "land",
2202 "rebase",
2203 "chat",
2204 ] {
2205 i.node = node;
2206 assert!(repository_writer(&i), "{node}");
2207 i.allow_write = false;
2208 assert!(!repository_writer(&i), "{node}");
2209 i.allow_write = true;
2210 }
2211 for node in ["deputy", "judge", "review", "bump", "conduct"] {
2212 i.node = node;
2213 assert!(!repository_writer(&i), "{node}");
2214 }
2215 i.node = "chat";
2216 let roots = [PathBuf::from("/questions")];
2217 i.writable = &roots;
2218 assert!(
2219 repository_writer(&i),
2220 "roots alone do not select consultation"
2221 );
2222 i.scope = InvocationScope::ConsultationControl;
2223 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2224 assert!(!repository_seat(&i));
2225 i.allow_write = false;
2226 i.writable = &[];
2227 assert!(!repository_seat(&i), "control scope needs no write roots");
2228 i.scope = InvocationScope::ConsultationInvestigation;
2229 assert!(repository_seat(&i));
2230 i.allow_write = true;
2231 assert!(
2232 !repository_writer(&i),
2233 "investigation never gains editing grants"
2234 );
2235 }
2236
2237 #[test]
2238 fn coordination_classification_is_independent_of_write_grants() {
2239 let roots = [PathBuf::from("/questions")];
2240 for node in [
2241 crate::queue::CHAT_NODE,
2242 "implement",
2243 "fix",
2244 "graft",
2245 "gate-fix",
2246 "land",
2247 "rebase",
2248 "review",
2249 "judge",
2250 "advise",
2251 "deputy",
2252 "waiter",
2253 "conduct",
2254 "dupes",
2255 ] {
2256 for allow_write in [false, true] {
2257 for writable in [&[][..], &roots[..]] {
2258 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2259 i.node = node;
2260 i.writable = writable;
2261 assert_eq!(
2262 repository_seat(&i),
2263 !matches!(
2264 node,
2265 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
2266 ),
2267 "{node}, allow_write={allow_write}, writable={writable:?}"
2268 );
2269 if node == crate::queue::CHAT_NODE {
2270 assert_eq!(repository_writer(&i), allow_write);
2271 }
2272 }
2273 }
2274 }
2275 }
2276
2277 #[test]
2278 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2279 let roots = [
2280 PathBuf::from("/shared/git"),
2281 PathBuf::from("/state/questions"),
2282 ];
2283 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2284 for resumed in [false, true] {
2285 let mut seat = SeatState::new("impl-A", "a", 7);
2286 if resumed {
2287 seat.turns = 1;
2288 seat.captured_session = Some("session-id".into());
2289 }
2290 for allow_write in [false, true] {
2291 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2292 i.writable = &roots;
2293 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2294 .unwrap();
2295 for root in &roots {
2296 if kind == AgentKind::Antigravity {
2297 assert_eq!(
2298 plan.argv.windows(2).any(|w| {
2299 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2300 }),
2301 allow_write,
2302 "resumed={resumed}: {:?}",
2303 plan.argv
2304 );
2305 }
2306 assert_eq!(
2307 plan.argv
2308 .iter()
2309 .any(|arg| arg.contains(root.to_str().unwrap())),
2310 allow_write,
2311 "{kind:?}, resumed={resumed}: {:?}",
2312 plan.argv
2313 );
2314 }
2315 if kind == AgentKind::Codex && resumed {
2316 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2317 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2318 }
2319 }
2320 }
2321 }
2322 }
2323
2324 #[test]
2325 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2326 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2327 let mut seat = SeatState::new("talk", "a", 7);
2328 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2329 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2330 i.unsandboxed = unsandboxed;
2331 build_command(
2332 &spec(AgentKind::Codex, None),
2333 seat,
2334 &i,
2335 Path::new("/art/p.md"),
2336 )
2337 .unwrap()
2338 };
2339
2340 let first = build(&seat, true, true);
2342 assert!(first.argv.iter().any(|a| a == BYPASS));
2343 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2344 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2345
2346 seat.captured_session = Some("thread-1".to_owned());
2348 seat.turns = 1;
2349 let resumed = build(&seat, true, true);
2350 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2351 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2352
2353 let ro = build(&seat, false, true);
2355 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2356 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2357 }
2358
2359 #[test]
2360 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2361 let mut seat = SeatState::new("judge-1", "a", 7);
2362
2363 let ro = plan_for(AgentKind::Codex, &seat, false);
2367 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2368 let rw = plan_for(AgentKind::Codex, &seat, true);
2369 assert!(
2370 rw.argv
2371 .windows(2)
2372 .any(|w| w == ["--sandbox", "workspace-write"])
2373 );
2374 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2377 ro_flagged.unsandboxed = true;
2378 let ro_flagged = build_command(
2379 &spec(AgentKind::Codex, None),
2380 &seat,
2381 &ro_flagged,
2382 Path::new("/art/p.md"),
2383 )
2384 .unwrap();
2385 assert!(
2386 ro_flagged
2387 .argv
2388 .windows(2)
2389 .any(|w| w == ["--sandbox", "read-only"])
2390 );
2391 for p in [&ro, &rw, &ro_flagged] {
2392 assert!(
2393 !p.argv
2394 .iter()
2395 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2396 "the bypass defeats the only enforced read-only mode we have"
2397 );
2398 assert!(
2400 p.argv
2401 .windows(2)
2402 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2403 "an unattended seat that asks for approval blocks until timeout"
2404 );
2405 }
2406
2407 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2409 assert_eq!(
2410 ro.argv.last().map(String::as_str),
2411 Some("-"),
2412 "without the `-` argument codex waits for a prompt it never gets"
2413 );
2414
2415 seat.turns = 1;
2419 assert!(!has_session(AgentKind::Codex, &seat, true));
2420 assert!(
2421 !plan_for(AgentKind::Codex, &seat, true)
2422 .argv
2423 .iter()
2424 .any(|a| a == "resume")
2425 );
2426 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2427 let resumed = plan_for(AgentKind::Codex, &seat, true);
2428 let at = resumed
2429 .argv
2430 .iter()
2431 .position(|a| a == "resume")
2432 .expect("resumes by subcommand");
2433 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2434 assert!(
2435 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2436 "every option precedes the subcommand"
2437 );
2438 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2439 }
2440
2441 #[test]
2444 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2445 let mut seat = SeatState::new("review-1", "a", 7);
2446
2447 let first = plan_for(AgentKind::Omp, &seat, false);
2451 assert!(first.argv.iter().any(|a| a == "-p"));
2452 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2453 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2454 assert!(
2455 !first.argv.iter().any(|a| a == "do the thing"),
2456 "the prompt reached argv, where Windows caps it"
2457 );
2458
2459 for allow_write in [false, true] {
2465 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2466 assert!(
2467 p.argv.iter().any(|a| a == "--auto-approve"),
2468 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2469 );
2470 assert!(
2471 !p.argv
2472 .iter()
2473 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2474 "nothing ever asks for the bypass"
2475 );
2476 }
2477
2478 seat.turns = 1;
2481 assert!(!has_session(AgentKind::Omp, &seat, true));
2482 assert!(
2483 !plan_for(AgentKind::Omp, &seat, true)
2484 .argv
2485 .iter()
2486 .any(|a| a == "--resume")
2487 );
2488 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2489 let resumed = plan_for(AgentKind::Omp, &seat, true);
2490 assert!(
2491 resumed
2492 .argv
2493 .windows(2)
2494 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2495 "a captured id is what makes the next turn a resume"
2496 );
2497 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2500 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2502 }
2503
2504 #[test]
2509 fn omp_takes_the_answer_without_an_agent_end_line() {
2510 let stream = concat!(
2511 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2512 "\n",
2513 r#"{"type":"agent_start"}"#,
2514 "\n",
2515 r#"{"type":"turn_start"}"#,
2516 "\n",
2517 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2518 "\n",
2519 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2520 "\n",
2521 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2522 "\n",
2523 );
2524 let out = extract(AgentKind::Omp, stream);
2525 assert_eq!(
2526 out.text, "{\"vote\":\"approve\"}",
2527 "the last assistant text block is the answer even with no agent_end"
2528 );
2529 assert_eq!(
2530 out.session.as_deref(),
2531 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2532 );
2533 }
2534
2535 #[test]
2539 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2540 let stream = concat!(
2541 r#"{"type":"session","version":3,"id":"s1"}"#,
2542 "\n",
2543 "{\"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問題ありません。\"}]}]}",
2544 "\n",
2545 );
2546 let out = extract(AgentKind::Omp, stream);
2547 assert_eq!(
2548 out.text, "## 判定\n\n問題ありません。",
2549 "the narration is not the answer, and non-ASCII survives intact"
2550 );
2551 assert_eq!(out.session.as_deref(), Some("s1"));
2552 }
2553
2554 #[test]
2557 fn omp_skips_non_json_lines() {
2558 let stream = concat!(
2559 "Warning: some omp notice\n",
2560 r#"{"type":"session","version":3,"id":"s2"}"#,
2561 "\n",
2562 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2563 "\n",
2564 "trailing junk",
2565 "\n",
2566 );
2567 let out = extract(AgentKind::Omp, stream);
2568 assert_eq!(out.text, "the answer");
2569 assert_eq!(out.session.as_deref(), Some("s2"));
2570 }
2571
2572 #[test]
2574 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2575 let stream = concat!(
2576 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2577 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2578 "\n",
2579 r#"{"type":"turn.started"}"#,
2580 "\n",
2581 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2582 "\n",
2583 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2584 "\n",
2585 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2586 "\n",
2587 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2588 "\n",
2589 );
2590 let out = extract(AgentKind::Codex, stream);
2591 assert_eq!(
2592 out.text, "{\"verdict\": \"ok\"}",
2593 "the last agent message is the answer; earlier ones narrate"
2594 );
2595 assert_eq!(
2596 out.session.as_deref(),
2597 Some("01a07440-4545-7492-85c1-024e3259a90a")
2598 );
2599 assert_eq!(out.status.as_deref(), Some("success"));
2600
2601 let failed = concat!(
2602 r#"{"type":"thread.started","thread_id":"t1"}"#,
2603 "\n",
2604 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2605 "\n",
2606 );
2607 assert_eq!(
2608 extract(AgentKind::Codex, failed).status.as_deref(),
2609 Some("error")
2610 );
2611 }
2612
2613 #[test]
2614 fn captured_sessions_resume_only_once_reported() {
2615 let mut seat = SeatState::new("impl-A", "a", 7);
2616 seat.turns = 1;
2617 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2618 assert!(!has_session(kind, &seat, true));
2619 let p = plan_for(kind, &seat, true);
2620 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2621 }
2622
2623 seat.captured_session = Some("sid".to_owned());
2624 assert!(has_session(AgentKind::Opencode, &seat, true));
2625 assert!(
2626 plan_for(AgentKind::Opencode, &seat, true)
2627 .argv
2628 .windows(2)
2629 .any(|w| w == ["-s", "sid"])
2630 );
2631 assert!(
2632 plan_for(AgentKind::Antigravity, &seat, true)
2633 .argv
2634 .windows(2)
2635 .any(|w| w == ["--conversation", "sid"])
2636 );
2637 }
2638
2639 #[test]
2640 fn sessions_disabled_never_resumes() {
2641 let mut seat = SeatState::new("impl-A", "a", 7);
2642 seat.turns = 3;
2643 seat.captured_session = Some("sid".to_owned());
2644 for kind in [
2645 AgentKind::Claude,
2646 AgentKind::Opencode,
2647 AgentKind::Antigravity,
2648 ] {
2649 assert!(!has_session(kind, &seat, false));
2650 }
2651 }
2652
2653 #[test]
2654 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2655 let seat = SeatState::new("judge-1", "a", 7);
2656 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2657 let p = plan_for(kind, &seat, false);
2658 assert!(
2659 p.argv.iter().all(|a| a != "do the thing"),
2660 "{kind:?} put the prompt on the command line"
2661 );
2662 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2663 }
2664 let p = plan_for(AgentKind::Antigravity, &seat, false);
2666 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2667 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2668 assert!(p.stdin.is_none());
2669 }
2670
2671 #[test]
2672 fn agy_print_timeout_tracks_the_node_budget() {
2673 let seat = SeatState::new("impl-A", "a", 7);
2674 let p = build_command(
2675 &spec(AgentKind::Antigravity, None),
2676 &seat,
2677 &Invocation {
2678 scope: crate::agent::InvocationScope::Repository,
2679 coordination: None,
2680 fetched_base: None,
2681 cwd: Path::new("."),
2682 prompt: "p",
2683 timeout: Duration::from_secs(3600),
2684 allow_write: true,
2685 unsandboxed: false,
2686 sessions: true,
2687 artifacts: Path::new("/art"),
2688 stem: "t",
2689 run: "test-run",
2690 node: "test",
2691 cache_dir: None,
2692 attachments: &[],
2693 writable: &[],
2694 },
2695 Path::new("/art/p.md"),
2696 )
2697 .unwrap();
2698 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2699 }
2700
2701 #[test]
2708 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2709 let mut s = spec(AgentKind::Antigravity, None);
2710 s.prompt_delivery = Some(Delivery::Argv);
2711 let seat = SeatState::new("talk", "a", 7);
2712 let atts = [PathBuf::from("/art/attachments/abc.png")];
2713
2714 let without = build_command(
2715 &s,
2716 &seat,
2717 &Invocation {
2718 attachments: &[],
2719 writable: &[],
2720 ..inv(Path::new("."), Path::new("/art"), true)
2721 },
2722 Path::new("/art/p.md"),
2723 )
2724 .unwrap();
2725 assert!(
2726 !without.argv.iter().any(|a| a == "--add-dir"),
2727 "no attachment, no reason to widen the sandbox: {without:?}"
2728 );
2729
2730 let with = build_command(
2731 &s,
2732 &seat,
2733 &Invocation {
2734 attachments: &atts,
2735 writable: &[],
2736 ..inv(Path::new("."), Path::new("/art"), true)
2737 },
2738 Path::new("/art/p.md"),
2739 )
2740 .unwrap();
2741 assert!(
2742 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2743 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2744 );
2745 }
2746
2747 #[test]
2753 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2754 let seat = SeatState::new("plan", "a", 7);
2755 let atts = [
2756 PathBuf::from("/art/attachments/own.png"),
2757 PathBuf::from("/other-chat/attachments/inherited.png"),
2758 ];
2759
2760 let p = build_command(
2761 &spec(AgentKind::Antigravity, None),
2762 &seat,
2763 &Invocation {
2764 attachments: &atts,
2765 writable: &[],
2766 ..inv(Path::new("."), Path::new("/art"), true)
2767 },
2768 Path::new("/art/p.md"),
2769 )
2770 .unwrap();
2771
2772 assert!(
2773 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2774 "this conversation's own artifacts dir must still be granted: {p:?}"
2775 );
2776 assert!(
2777 p.argv
2778 .windows(2)
2779 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2780 "the inherited attachment's own directory must be granted too: {p:?}"
2781 );
2782 }
2783
2784 #[test]
2785 fn command_agents_get_placeholders_substituted() {
2786 let seat = SeatState::new("impl-A", "a", 7);
2787 let p = plan_for(AgentKind::Command, &seat, true);
2788 assert_eq!(p.argv[0], "echo");
2789 assert_eq!(p.argv[1], "impl-A");
2790 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2791 }
2792
2793 #[test]
2794 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2795 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2797 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2798 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2799 let out = extract(AgentKind::Claude, stdout);
2800 let quota = out.quota.as_ref().expect("rate limit must be detected");
2801 assert_eq!(
2802 quota.reset.as_deref(),
2803 Some("4:50am (Asia/Tokyo)"),
2804 "reset time read from the body"
2805 );
2806 }
2807
2808 #[test]
2809 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2810 let out = extract(
2811 AgentKind::Claude,
2812 r#"{"is_error":true,"result":"session limit reached"}"#,
2813 );
2814 let quota = out.quota.expect("rate limit detected without a reset");
2815 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2816 }
2817
2818 #[test]
2819 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2820 for kind in [AgentKind::Claude, AgentKind::Command] {
2821 for reply in [
2822 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2823 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2824 ] {
2825 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2826 }
2827 for reply in [
2828 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2829 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2830 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2831 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2832 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2833 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2834 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2835 ] {
2836 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2837 }
2838 }
2839 }
2840
2841 #[test]
2842 fn ordinary_failures_are_never_quota() {
2843 let claude_fail = extract(
2845 AgentKind::Claude,
2846 r#"{"is_error":true,"result":"account does not exist"}"#,
2847 );
2848 assert!(claude_fail.quota.is_none());
2849
2850 let cmd_fail = extract(AgentKind::Command, "boom");
2852 assert!(cmd_fail.quota.is_none());
2853
2854 let success = extract(
2856 AgentKind::Command,
2857 r#"{"is_error":false,"result":"session limit is fine"}"#,
2858 );
2859 assert!(success.quota.is_none());
2860 }
2861
2862 #[test]
2870 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2871 let stream = concat!(
2872 r#"{"type":"thread.started","thread_id":"t1"}"#,
2873 "\n",
2874 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"}}"#,
2875 "\n",
2876 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"}}"#,
2877 "\n",
2878 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2879 "\n",
2880 r#"{"type":"turn.completed"}"#,
2881 "\n",
2882 );
2883 let out = extract(AgentKind::Codex, stream);
2884 assert_eq!(out.text, "Both tests in the target pass.");
2885 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2886
2887 let paired = &out.commands[0];
2888 assert_eq!(paired.id, "item49");
2889 assert_eq!(paired.exit_code, Some(1));
2890 assert!(paired.description.contains("graph_cached_gate"));
2891 assert!(paired.result_summary.contains("1 failed"));
2892
2893 let solo = &out.commands[1];
2894 assert_eq!(solo.exit_code, Some(0));
2895
2896 assert!(
2900 out.commands
2901 .iter()
2902 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2903 "a failed run of the actual target must still be visible: {:?}",
2904 out.commands
2905 );
2906 }
2907
2908 #[test]
2909 fn command_agent_can_carry_the_claude_quota_shape() {
2910 let out = extract(
2911 AgentKind::Command,
2912 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2913 );
2914 assert!(
2915 out.quota.is_some(),
2916 "a wrapper emitting the claude shape counts as quota"
2917 );
2918 }
2919
2920 #[test]
2921 fn claude_json_result_is_extracted() {
2922 let out = extract(
2923 AgentKind::Claude,
2924 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2925 );
2926 assert_eq!(out.text, "all done");
2927 assert_eq!(out.session.as_deref(), Some("abc"));
2928 assert_eq!(out.status.as_deref(), Some("success"));
2929 }
2930
2931 #[test]
2946 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2947 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"}"#;
2948 let out = extract(AgentKind::Claude, stdout);
2949 assert_eq!(out.status.as_deref(), Some("success"));
2950 assert!(out.quota.is_none());
2951 assert!(!out.text.trim().is_empty());
2952
2953 let agent_out = AgentOutput {
2954 text: out.text.clone(),
2955 exit_code: Some(0),
2956 timed_out: false,
2957 duration_ms: 500,
2958 artifacts: Vec::new(),
2959 quota: out.quota,
2960 dropped: out.dropped,
2961 commands: out.commands,
2962 context_tokens: None,
2963 };
2964 assert!(
2965 agent_out.usable(),
2966 "the CLI turn itself ended cleanly and must read as usable"
2967 );
2968 assert!(
2969 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2970 "a clean CLI turn is not proof the node's own report ever arrived"
2971 );
2972 }
2973
2974 #[test]
2975 fn opencode_event_stream_is_concatenated() {
2976 let stream = concat!(
2977 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2978 "\n",
2979 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2980 "\n",
2981 "garbage line\n",
2982 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2983 "\n"
2984 );
2985 let out = extract(AgentKind::Opencode, stream);
2986 assert_eq!(out.text, "first\nsecond");
2987 assert_eq!(out.session.as_deref(), Some("ses_1"));
2988 }
2989
2990 #[test]
2991 fn agy_json_survives_a_leading_warning_line() {
2992 let stdout = concat!(
2993 "warning: --mode plan has no effect while slash commands are disabled.\n",
2994 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2995 "\n"
2996 );
2997 let out = extract(AgentKind::Antigravity, stdout);
2998 assert_eq!(out.text, "persimmon");
2999 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3000 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3001 }
3002
3003 const AGY_DROPPED: &str = concat!(
3010 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3011 r#""response":"","error":"the connection to the agent was interrupted before "#,
3012 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3013 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3014 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3015 r#""total_tokens":274380}}"#
3016 );
3017
3018 const AGY_QUOTA_OUT: &str = concat!(
3020 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3021 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3022 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3023 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3024 );
3025 const AGY_QUOTA_ERR: &str = concat!(
3026 "error: Individual quota reached. Resets in 1h2m49s.\n",
3027 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3028 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3029 r#""retryable":true}"#,
3030 "\n"
3031 );
3032
3033 #[test]
3034 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3035 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3036 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3037 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3038 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3039 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3041 assert!(stderr_only.reset.is_none());
3042 }
3043
3044 #[test]
3045 fn ordinary_agy_failures_are_not_a_quota() {
3046 assert!(agy_quota(AGY_DROPPED, "").is_none());
3047 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3048 assert!(
3049 agy_quota(
3050 "",
3051 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3052 )
3053 .is_none()
3054 );
3055 assert!(
3056 agy_quota(
3057 "",
3058 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3059 )
3060 .is_none()
3061 );
3062 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3063 }
3064
3065 #[test]
3066 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3067 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3068 let dropped = out.dropped.expect("recognised as undelivered work");
3069 assert_eq!(dropped.output_tokens, 14267);
3070 assert!(
3071 dropped.why.contains("subscriber fell behind"),
3072 "the CLI's own words are kept for the record: {}",
3073 dropped.why
3074 );
3075 assert_eq!(
3078 out.session.as_deref(),
3079 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3080 );
3081 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3082 }
3083
3084 #[test]
3085 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3086 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3090 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3091
3092 let answered = concat!(
3095 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3096 r#""usage":{"output_tokens":10}}"#
3097 );
3098 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3099
3100 let ok = concat!(
3102 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3103 r#""usage":{"output_tokens":10}}"#
3104 );
3105 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3106 }
3107
3108 #[test]
3109 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3110 let out = AgentOutput {
3111 text: String::new(),
3112 exit_code: Some(1),
3113 timed_out: false,
3114 duration_ms: 431_194,
3115 artifacts: Vec::new(),
3116 quota: None,
3117 dropped: Some(Dropped {
3118 why: "subscriber fell behind updates".to_owned(),
3119 output_tokens: 14267,
3120 }),
3121 commands: Vec::new(),
3122 context_tokens: None,
3123 };
3124 assert!(!out.usable());
3125 assert!(out.work_undelivered());
3126 assert!(!out.quota_exhausted());
3129 }
3130
3131 #[test]
3132 fn non_json_stdout_falls_back_to_raw_text() {
3133 let out = extract(AgentKind::Antigravity, "plain answer\n");
3134 assert_eq!(out.text, "plain answer");
3135 assert!(out.session.is_none());
3136 }
3137
3138 #[tokio::test]
3139 async fn command_agent_round_trip_writes_artifacts() {
3140 let dir = tempfile::tempdir().unwrap();
3141 let art = dir.path().join("artifacts");
3142 let mut seat = SeatState::new("impl-A", "a", 7);
3143 let s = command_helper("reply");
3144 let out = invoke(
3145 &s,
3146 &mut seat,
3147 &Invocation {
3148 scope: crate::agent::InvocationScope::Repository,
3149 coordination: None,
3150 fetched_base: None,
3151 cwd: dir.path(),
3152 prompt: "unused",
3153 timeout: Duration::from_secs(30),
3154 allow_write: true,
3155 unsandboxed: false,
3156 sessions: true,
3157 artifacts: &art,
3158 stem: "impl-A",
3159 run: "test-run",
3160 node: "test",
3161 cache_dir: None,
3162 attachments: &[],
3163 writable: &[],
3164 },
3165 )
3166 .await
3167 .unwrap();
3168 assert!(out.usable(), "{out:?}");
3169 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3170 assert_eq!(seat.turns, 1);
3171 assert!(art.join("impl-A.prompt.md").is_file());
3172 assert!(art.join("impl-A.out").is_file());
3173 }
3174
3175 #[tokio::test]
3176 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3177 crate::run::pin_test_home();
3178 let dir = tempfile::tempdir().unwrap();
3182 let cache = dir.path().join("magi-cache");
3183 let mut seat = SeatState::new("impl-A", "a", 7);
3184 let s = command_helper("cache");
3185 let out = invoke(
3186 &s,
3187 &mut seat,
3188 &Invocation {
3189 scope: crate::agent::InvocationScope::Repository,
3190 coordination: None,
3191 fetched_base: None,
3192 cwd: dir.path(),
3193 prompt: "unused",
3194 timeout: Duration::from_secs(30),
3195 allow_write: true,
3196 unsandboxed: false,
3197 sessions: true,
3198 artifacts: &dir.path().join("artifacts"),
3199 stem: "cache",
3200 run: "test-run",
3201 node: "test",
3202 cache_dir: Some(&cache),
3203 attachments: &[],
3204 writable: &[],
3205 },
3206 )
3207 .await
3208 .unwrap();
3209 assert!(out.usable(), "{out:?}");
3210 assert!(
3211 out.text.contains(cache.to_string_lossy().as_ref()),
3212 "the seat must see CARGO_TARGET_DIR = the shared cache"
3213 );
3214 }
3215
3216 #[tokio::test]
3217 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3218 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3226 unsafe {
3231 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3232 }
3233 let dir = tempfile::tempdir().unwrap();
3234 let mut seat = SeatState::new("review-1", "a", 7);
3235 let s = command_helper("no-cache");
3236 let result = invoke(
3237 &s,
3238 &mut seat,
3239 &Invocation {
3240 scope: crate::agent::InvocationScope::Repository,
3241 coordination: None,
3242 fetched_base: None,
3243 cwd: dir.path(),
3244 prompt: "unused",
3245 timeout: Duration::from_secs(30),
3246 allow_write: false,
3247 unsandboxed: false,
3248 sessions: true,
3249 artifacts: &dir.path().join("artifacts"),
3250 stem: "no-cache",
3251 run: "test-run",
3252 node: "test",
3253 cache_dir: None,
3254 attachments: &[],
3255 writable: &[],
3256 },
3257 )
3258 .await;
3259 unsafe {
3264 match &previous {
3265 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3266 None => std::env::remove_var("CARGO_TARGET_DIR"),
3267 }
3268 }
3269 let out = result.unwrap();
3270 assert!(out.usable(), "{out:?}");
3271 assert!(
3272 out.text.contains("ABSENT"),
3273 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3274 out.text
3275 );
3276 }
3277
3278 #[tokio::test]
3279 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3280 let dir = tempfile::tempdir().unwrap();
3281 let mut seat = SeatState::new("impl-A", "a", 7);
3282 let s = command_helper("ignore-stdin");
3285 let big = "x".repeat(1_000_000);
3286 let out = invoke(
3287 &s,
3288 &mut seat,
3289 &Invocation {
3290 scope: crate::agent::InvocationScope::Repository,
3291 coordination: None,
3292 fetched_base: None,
3293 cwd: dir.path(),
3294 prompt: &big,
3295 timeout: Duration::from_secs(60),
3296 allow_write: true,
3297 unsandboxed: false,
3298 sessions: true,
3299 artifacts: &dir.path().join("artifacts"),
3300 stem: "big",
3301 run: "test-run",
3302 node: "test",
3303 cache_dir: None,
3304 attachments: &[],
3305 writable: &[],
3306 },
3307 )
3308 .await
3309 .unwrap();
3310 assert!(out.usable(), "{out:?}");
3311 assert!(out.text.contains("done"), "{}", out.text);
3312 }
3313
3314 #[tokio::test]
3315 async fn timeout_is_reported_not_hung() {
3316 let dir = tempfile::tempdir().unwrap();
3317 let mut seat = SeatState::new("impl-A", "a", 7);
3318 let s = command_helper("sleep");
3319 let out = invoke(
3320 &s,
3321 &mut seat,
3322 &Invocation {
3323 scope: crate::agent::InvocationScope::Repository,
3324 coordination: None,
3325 fetched_base: None,
3326 cwd: dir.path(),
3327 prompt: "unused",
3328 timeout: Duration::from_millis(300),
3329 allow_write: true,
3330 unsandboxed: false,
3331 sessions: true,
3332 artifacts: &dir.path().join("artifacts"),
3333 stem: "slow",
3334 run: "test-run",
3335 node: "test",
3336 cache_dir: None,
3337 attachments: &[],
3338 writable: &[],
3339 },
3340 )
3341 .await
3342 .unwrap();
3343 assert!(out.timed_out);
3344 assert!(!out.usable());
3345 }
3346
3347 #[tokio::test]
3348 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3349 let dir = tempfile::tempdir().unwrap();
3355 let artifacts = dir.path().join("artifacts");
3356 let mut seat = SeatState::new("impl-A", "a", 7);
3357 let s = command_helper("chatty-sleep");
3358 let out = invoke(
3359 &s,
3360 &mut seat,
3361 &Invocation {
3362 scope: crate::agent::InvocationScope::Repository,
3363 coordination: None,
3364 fetched_base: None,
3365 cwd: dir.path(),
3366 prompt: "unused",
3367 timeout: Duration::from_secs(10),
3372 allow_write: true,
3373 unsandboxed: false,
3374 sessions: true,
3375 artifacts: &artifacts,
3376 stem: "chatty",
3377 run: "test-run",
3378 node: "test",
3379 cache_dir: None,
3380 attachments: &[],
3381 writable: &[],
3382 },
3383 )
3384 .await
3385 .unwrap();
3386
3387 assert!(out.timed_out, "{out:?}");
3388 assert!(!out.usable(), "a cut-off answer is still not an answer");
3389 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3390 assert!(
3391 recorded.contains("i-said-something"),
3392 "the artifact must keep what arrived before the kill, got {recorded:?}"
3393 );
3394 assert!(
3395 out.text.contains("i-said-something"),
3396 "and the graph must be able to see it too, got {:?}",
3397 out.text
3398 );
3399 }
3400
3401 #[test]
3402 fn missing_programs_reports_command_binaries() {
3403 let mut s = spec(AgentKind::Command, None);
3404 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3405 assert_eq!(
3406 missing_programs(&[s]),
3407 ["definitely-not-a-real-binary-xyz".to_owned()]
3408 );
3409 }
3410
3411 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3412 AgentSpec {
3413 id: id.to_owned(),
3414 kind,
3415 model: None,
3416 command: Vec::new(),
3417 extra_args: Vec::new(),
3418 env: BTreeMap::new(),
3419 prompt_delivery: None,
3420 }
3421 }
3422
3423 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3426 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3427 }
3428
3429 #[test]
3430 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3431 let agents = [
3432 pick_spec("oc", AgentKind::Opencode),
3433 pick_spec("opus", AgentKind::Claude),
3434 pick_spec("agy", AgentKind::Antigravity),
3435 ];
3436 let got = pick(&agents, None, &without(&[])).expect("a pick");
3437 assert_eq!(got.id, "opus");
3438 }
3439
3440 #[test]
3441 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3442 let agents = [
3443 pick_spec("opus", AgentKind::Claude),
3444 pick_spec("oc", AgentKind::Opencode),
3445 pick_spec("agy", AgentKind::Antigravity),
3446 ];
3447 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3448 assert_eq!(got.id, "agy");
3449 }
3450
3451 #[test]
3452 fn pick_on_an_empty_roster_says_what_to_install() {
3453 let msg = pick(&[], None, &without(&[]))
3454 .expect_err("nobody to ask")
3455 .to_string();
3456 assert!(msg.contains("roster is empty"), "{msg}");
3457 assert!(msg.contains("claude"), "{msg}");
3458 assert!(msg.contains("magi.toml"), "{msg}");
3459 }
3460
3461 #[test]
3462 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3463 let agents = [
3464 pick_spec("opus", AgentKind::Claude),
3465 pick_spec("oc", AgentKind::Opencode),
3466 ];
3467 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3468 let msg = format!("{err:#}");
3469 assert!(msg.contains("claude"), "{msg}");
3470 assert!(msg.contains("opencode"), "{msg}");
3471 }
3472
3473 #[test]
3474 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3475 let agents = [
3476 pick_spec("opus", AgentKind::Claude),
3477 pick_spec("oc", AgentKind::Opencode),
3478 ];
3479 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3480 assert_eq!(got.id, "oc");
3481 }
3482
3483 #[test]
3484 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3485 let agents = [
3486 pick_spec("opus", AgentKind::Claude),
3487 pick_spec("oc", AgentKind::Opencode),
3488 ];
3489 let msg = pick(&agents, Some("gemini"), &without(&[]))
3490 .expect_err("no such agent")
3491 .to_string();
3492 assert!(msg.contains("gemini"), "{msg}");
3493 assert!(msg.contains("opus, oc"), "{msg}");
3494 }
3495
3496 #[test]
3497 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3498 let agents = [
3499 pick_spec("opus", AgentKind::Claude),
3500 pick_spec("oc", AgentKind::Opencode),
3501 ];
3502 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3503 .expect_err("must not silently substitute another model")
3504 .to_string();
3505 assert!(msg.contains("opencode"), "{msg}");
3506 assert!(msg.contains("--agent"), "{msg}");
3507 }
3508
3509 fn named(id: &str) -> AgentSpec {
3510 AgentSpec {
3511 id: id.to_owned(),
3512 ..spec(AgentKind::Command, None)
3513 }
3514 }
3515
3516 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3517 AgentOutput {
3518 text: text.to_owned(),
3519 exit_code: Some(exit),
3520 timed_out: false,
3521 duration_ms: 0,
3522 artifacts: Vec::new(),
3523 quota: quota.then_some(Quota { reset: None }),
3524 dropped: None,
3525 commands: Vec::new(),
3526 context_tokens: None,
3527 }
3528 }
3529
3530 #[test]
3531 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3532 let agents = [named("a"), named("b"), named("c")];
3533 let all = |_: &AgentSpec| true;
3534 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3535 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3536 assert_eq!(
3537 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3538 ["c", "a"]
3539 );
3540
3541 let one = AgentChoice::from("b");
3542 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3543 assert_eq!(got.len(), 1);
3544 assert_eq!(got[0].id, "b");
3545
3546 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3547 assert_eq!(got.len(), 1, "unset keeps pick's default");
3548 assert_eq!(got[0].id, "a");
3549 let empty = AgentChoice::Chain(Vec::new());
3550 assert_eq!(
3551 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3552 "a"
3553 );
3554 }
3555
3556 #[test]
3557 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3558 let agents = [named("a"), named("b")];
3559 let not_a = |s: &AgentSpec| s.id != "a";
3560 let chain = AgentChoice::Chain(
3561 ["a", "ghost", "b", "b"]
3562 .iter()
3563 .map(|s| (*s).to_owned())
3564 .collect(),
3565 );
3566 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3567 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3568
3569 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3570 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3571 assert_eq!(
3572 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3573 ["b", "a"]
3574 );
3575 }
3576
3577 #[test]
3578 fn a_chain_with_nothing_runnable_names_the_role() {
3579 let agents = [named("a")];
3580 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3581 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3582 .unwrap_err()
3583 .to_string();
3584 assert!(err.contains("conductor"), "{err}");
3585 }
3586
3587 #[test]
3588 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3589 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3590 assert!(chain_advances(&Ok(output("limit", 0, true))));
3591 assert!(chain_advances(&Ok(output("", 0, false))));
3592 assert!(chain_advances(&Ok(output("x", 1, false))));
3593 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3594 }
3595
3596 #[test]
3597 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3598 for out in [
3599 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3600 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3601 r#"{"result":"ok"}"#,
3602 "not json",
3603 ] {
3604 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3605 }
3606 }
3607
3608 #[test]
3609 fn opencode_context_tokens_take_the_last_step_finish() {
3610 let out = concat!(
3611 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3612 "\n",
3613 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3614 "\n",
3615 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3616 "\n"
3617 );
3618 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3619 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3620 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3621 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3622 }
3623
3624 #[test]
3625 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3626 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3627 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3628 }
3629
3630 #[test]
3631 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3632 let out = concat!(
3633 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3634 "\n"
3635 );
3636 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3637 }
3638
3639 #[test]
3640 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3641 let out = concat!(
3642 r#"{"type":"session","id":"s"}"#,
3643 "\n",
3644 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3645 "\n",
3646 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3647 "\n"
3648 );
3649 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3650 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3651 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3652 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3653 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3654 }
3655
3656 #[test]
3657 fn command_agents_report_no_context_tokens() {
3658 let out = r#"{"usage":{"input_tokens":5}}"#;
3659 assert_eq!(context_tokens(AgentKind::Command, out), None);
3660 }
3661}