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