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