1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentChoice, AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Invocation<'a> {
96 pub coordination: Option<&'a crate::config::Coordination>,
98 pub fetched_base: Option<&'a crate::run::FetchedBase>,
100 pub cwd: &'a Path,
103 pub prompt: &'a str,
105 pub timeout: Duration,
107 pub allow_write: bool,
112 pub sessions: bool,
114 pub artifacts: &'a Path,
116 pub stem: &'a str,
118 pub run: &'a str,
122 pub node: &'a str,
126 pub cache_dir: Option<&'a Path>,
132 pub attachments: &'a [PathBuf],
139 pub writable: &'a [PathBuf],
146}
147
148fn repository_writer(inv: &Invocation<'_>) -> bool {
157 inv.allow_write
158 && (matches!(
159 inv.node,
160 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
161 ) || inv.node == crate::queue::CHAT_NODE)
162}
163
164fn repository_seat(inv: &Invocation<'_>) -> bool {
165 !matches!(
171 inv.node,
172 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
173 )
174}
175
176pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
183 let mut roots = Vec::new();
184 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
185 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
186 if !common.starts_with(&cwd) {
187 roots.push(common);
188 }
189 }
190 for name in ["questions", "notifications"] {
194 let dir = home.join(name);
195 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
196 roots.push(std::fs::canonicalize(dir)?);
197 }
198 Ok(roots)
199}
200
201fn editing_access_note(base: Option<&crate::run::FetchedBase>) -> String {
203 let mut note = String::from(
204 "Launcher repository access contract (applies to this turn and overrides earlier fetch instructions):\n",
205 );
206 if let Some(base) = base {
207 note.push_str(&format!(
208 "The launcher successfully fetched `{}/{}` at {} and pinned the run base to `{}`. Use this exact local commit as the verified fetched base; this is a snapshot, not a claim that the remote has not moved since. Do not fetch again as a prerequisite, move the base, or refresh it on resume. Base updates belong to the launcher's existing base-sync path.\n",
209 base.remote, base.branch, base.fetched_at, base.commit,
210 ));
211 } else {
212 note.push_str("No successful launcher fetch evidence is supplied for this invocation. Do not describe local tracking refs as freshly fetched. If fresh remote evidence is essential, report an infrastructure blocker requiring a launcher fetch and a new prepared turn.\n");
213 }
214 note
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct Quota {
225 #[serde(default)]
227 pub cause: QuotaCause,
228 #[serde(default)]
230 pub agent: String,
231 #[serde(default)]
233 pub reset: Option<String>,
234}
235
236#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum QuotaCause {
240 #[default]
242 UsageLimit,
243 Authentication,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct Dropped {
255 pub why: String,
257 pub output_tokens: u64,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct CommandEvidence {
277 pub id: String,
279 pub description: String,
281 pub exit_code: Option<i32>,
283 pub result_summary: String,
285 pub source: String,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct AgentOutput {
292 pub text: String,
294 pub exit_code: Option<i32>,
296 pub timed_out: bool,
298 pub duration_ms: u64,
300 pub artifacts: Vec<String>,
302 #[serde(default)]
306 pub quota: Option<Quota>,
307 #[serde(default)]
311 pub dropped: Option<Dropped>,
312 #[serde(default)]
316 pub commands: Vec<CommandEvidence>,
317 #[serde(default)]
322 pub context_tokens: Option<u64>,
323}
324
325impl AgentOutput {
326 pub fn usable(&self) -> bool {
328 !self.quota_exhausted()
329 && !self.timed_out
330 && self.exit_code == Some(0)
331 && !self.text.trim().is_empty()
332 }
333
334 pub fn quota_exhausted(&self) -> bool {
336 self.quota.is_some()
337 }
338
339 pub fn work_undelivered(&self) -> bool {
344 self.dropped.is_some()
345 }
346}
347
348#[derive(Debug)]
351pub(crate) struct ExecutionInterrupted(pub String);
352
353impl std::fmt::Display for ExecutionInterrupted {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 write!(f, "execution interrupted: {}", self.0)
356 }
357}
358
359impl std::error::Error for ExecutionInterrupted {}
360
361const PIPE_GRACE: Duration = Duration::from_secs(3);
366
367pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
369
370pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
380where
381 R: tokio::io::AsyncRead + Unpin + Send + 'static,
382{
383 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
384 let Some(mut pipe) = pipe else {
385 return (buf, None);
386 };
387 let sink = Arc::clone(&buf);
388 let handle = tokio::spawn(async move {
389 let mut chunk = [0u8; 8192];
390 loop {
391 match pipe.read(&mut chunk).await {
392 Ok(0) | Err(_) => break,
393 Ok(n) => {
394 if let Ok(mut guard) = sink.lock() {
395 guard.extend_from_slice(&chunk[..n]);
396 }
397 }
398 }
399 }
400 });
401 (buf, Some(handle))
402}
403
404pub(crate) async fn collect(
409 buf: &Captured,
410 handle: Option<tokio::task::JoinHandle<()>>,
411 grace: Duration,
412) -> String {
413 if let Some(handle) = handle {
414 if tokio::time::timeout(grace, handle).await.is_err() {
415 tracing::debug!("a pipe is still held open after the child exited");
416 }
417 }
418 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
419 String::from_utf8_lossy(&bytes).into_owned()
420}
421
422pub async fn invoke(
424 spec: &AgentSpec,
425 seat: &mut SeatState,
426 inv: &Invocation<'_>,
427) -> Result<AgentOutput> {
428 let lease = if repository_seat(inv) {
433 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
434 .await?
435 } else {
436 None
437 };
438 let inv = &Invocation {
439 timeout: spec.turn_timeout(inv.timeout),
440 ..inv.clone()
441 };
442 let mut outputs = Vec::new();
443 if repository_writer(inv) {
444 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
445 .map_or(0, |state| state.config.disk.min_free_bytes);
446 let mut paths = vec![inv.cwd.to_owned()];
447 if let Some(cache) = inv.cache_dir {
448 paths.push(cache.to_owned());
449 }
450 outputs.extend(
451 crate::retention::OutputLease::admit(&crate::run::home(), &paths, min_free)
452 .map_err(|e| crate::repo_lock::ResourceBlocked(format!("disk admission: {e:#}")))?,
453 );
454 }
455 if repository_seat(inv) {
456 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
457 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
458 })? {
459 outputs.push(guard);
460 }
461 if let Some(cache) = inv.cache_dir {
462 outputs.push(crate::retention::OutputLease::cache(
463 &crate::run::home(),
464 cache,
465 false,
466 )?);
467 }
468 }
469
470 let mut prompt = inv.prompt.to_owned();
471 if inv.node == crate::queue::CHAT_NODE {
472 prompt.push_str("\n\n");
473 prompt.push_str(crate::prompt::CHAT_FOLLOW_THROUGH);
474 }
475 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
476 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()));
477 }
478 let home = repository_writer(inv).then(crate::run::home);
481 let mut writable = inv.writable.to_vec();
482 if let Some(home) = &home {
483 for root in worktree_writable_roots(inv.cwd, home).await? {
484 if !writable.contains(&root) {
485 writable.push(root);
486 }
487 }
488 }
489 if repository_writer(inv) {
490 prompt.push_str("\n\n");
491 prompt.push_str(&editing_access_note(inv.fetched_base));
492 }
493 let inv = &Invocation {
494 writable: &writable,
495 prompt: &prompt,
496 ..inv.clone()
497 };
498 tokio::fs::create_dir_all(inv.artifacts)
499 .await
500 .with_context(|| format!("create {}", inv.artifacts.display()))?;
501 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
502 tokio::fs::write(&prompt_path, inv.prompt)
503 .await
504 .with_context(|| format!("write {}", prompt_path.display()))?;
505
506 let plan = build_command(spec, seat, inv, &prompt_path)?;
507 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
508 tokio::fs::write(
510 inv.artifacts.join(format!("{}.access.json", inv.stem)),
511 serde_json::to_vec_pretty(&serde_json::json!({
512 "launcher_version": env!("CARGO_PKG_VERSION"),
513 "kind": spec.kind,
514 "allow_write": inv.allow_write,
515 "unsandboxed": repository_writer(inv),
516 "writable_roots": inv.writable,
517 "fetched_base": inv.fetched_base,
518 }))?,
519 )
520 .await
521 .context("write seat access evidence")?;
522
523 let started = Instant::now();
524 let child_path = spec
527 .env
528 .iter()
529 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
530 .map(|(_, v)| std::ffi::OsString::from(v))
531 .or_else(|| std::env::var_os("PATH"))
532 .unwrap_or_default();
533 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
534 || plan.argv[0].clone().into(),
535 std::path::PathBuf::into_os_string,
536 );
537 let mut cmd = Command::new(program);
538 cmd.args(&plan.argv[1..])
539 .current_dir(inv.cwd)
540 .envs(&spec.env)
541 .env("MAGI_SEAT", &seat.key)
542 .env("MAGI_TURN", seat.turns.to_string())
543 .env("MAGI_RUN", inv.run)
544 .env("MAGI_NODE", inv.node)
545 .env("MAGI_PROMPT_FILE", &prompt_path)
546 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
547 .env("GIT_TERMINAL_PROMPT", "0")
548 .stdin(if plan.stdin.is_some() {
549 Stdio::piped()
550 } else {
551 Stdio::null()
552 })
553 .stdout(Stdio::piped())
554 .stderr(Stdio::piped())
555 .kill_on_drop(true)
556 .quiet();
559 crate::incremental::default_environment(&mut cmd);
560 if repository_writer(inv) {
561 crate::incremental::configure(&mut cmd, inv.cwd)?;
562 }
563 if let Some(home) = home {
564 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
567 }
568 if let Some(cache) = inv.cache_dir {
569 cmd.env("CARGO_TARGET_DIR", cache);
572 } else {
573 cmd.env_remove("CARGO_TARGET_DIR");
581 }
582
583 #[cfg(any(unix, windows))]
584 let _chat_controls = {
585 use crate::chat_control::bridge::{self, Bridge};
586 bridge::clear_environment(&mut cmd);
587 let bridge = if inv.node == crate::queue::CHAT_NODE {
588 crate::run::try_home().and_then(|home| {
589 match Bridge::start(&home, inv.run, inv.artifacts) {
590 Ok(bridge) => Some(bridge),
591 Err(error) => {
592 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
593 None
594 }
595 }
596 })
597 } else {
598 None
599 };
600 if let Some(bridge) = &bridge {
601 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
605 bridge.attach(&mut cmd);
606 }
607 bridge
608 };
609
610 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
611 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
612 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
616 tokio::spawn(async move {
617 sink.write_all(body.as_bytes()).await.ok();
618 sink.shutdown().await.ok();
619 });
620 }
621
622 let (out_buf, out_reader) = drain(running.child().stdout.take());
639 let (err_buf, err_reader) = drain(running.child().stderr.take());
640
641 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
642 .ok()
643 .map_or(0, |state| state.config.disk.min_free_bytes);
644 #[cfg(all(test, unix))]
645 let min_free = test_watchdog::floor(inv.run, min_free);
646 let mut paths = vec![inv.cwd.to_owned()];
647 if let Some(cache) = inv.cache_dir {
648 paths.push(cache.to_owned());
649 }
650 let mut pressure = None;
651 let waited = tokio::select! {
652 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
653 reason = crate::retention::emergency(paths, min_free) => {
654 pressure = Some(reason);
655 None
656 }
657 };
658 let (code, timed_out) = match waited {
659 Some(Ok(res)) => {
660 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
661 (status.code(), false)
662 }
663 Some(Err(_)) | None => {
664 if let Some(reason) = &pressure {
665 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
666 } else {
667 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
668 }
669 if !running.coordinated() {
671 running
672 .child()
673 .kill()
674 .await
675 .context("stop timed-out seat")?;
676 }
677 (None, true)
678 }
679 };
680 running.finish().await;
681
682 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
687 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
688
689 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
690 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
691 tokio::fs::write(&out_path, &stdout).await.ok();
692 tokio::fs::write(&err_path, &stderr).await.ok();
693 if let Some(reason) = pressure {
694 return Err(
695 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
696 .context(ExecutionInterrupted(reason)),
697 );
698 }
699
700 if code != Some(0)
701 && (stdout.contains(crate::incremental::BLOCKED)
702 || stderr.contains(crate::incremental::BLOCKED))
703 {
704 let detail = if stderr.contains(crate::incremental::BLOCKED) {
705 &stderr
706 } else {
707 &stdout
708 };
709 return Err(crate::repo_lock::ResourceBlocked(tail_chars(detail, 2000)).into());
710 }
711
712 let mut extracted = extract(spec.kind, &stdout);
713 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
714 extracted.quota = agy_quota(&stdout, &stderr);
715 }
716 if let Some(quota) = &mut extracted.quota {
717 quota.agent.clone_from(&spec.id);
718 if quota.cause == QuotaCause::Authentication {
719 let _ = crate::notices::Notices::open().raise(crate::notices::Notice::warn(
720 &format!("agent-authentication:{}", spec.id),
721 format!("Agent `{}` cannot authenticate. Re-authenticate this agent, then resume the held task.", spec.id),
722 ));
723 }
724 extracted.dropped = None;
727 tracing::warn!(
728 seat = %seat.key,
729 agent = %spec.id,
730 reset = ?quota.reset,
731 cause = ?quota.cause,
732 "agent needs machine-side recovery"
733 );
734 }
735 if let Some(session) = extracted.session {
736 match spec.kind {
737 AgentKind::Claude => seat.claude_session = Some(session),
738 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
739 seat.captured_session = Some(session);
740 }
741 AgentKind::Command => {}
742 }
743 }
744 if let Some(status) = &extracted.status
745 && !status.eq_ignore_ascii_case("success")
746 {
747 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
748 }
749 let text = if extracted.text.trim().is_empty() {
750 if stdout.trim().is_empty() {
752 stderr.trim().to_owned()
753 } else {
754 stdout.trim().to_owned()
755 }
756 } else {
757 extracted.text
758 };
759 seat.turns += 1;
760
761 Ok(AgentOutput {
762 text,
763 exit_code: code,
764 timed_out,
765 duration_ms: started.elapsed().as_millis() as u64,
766 artifacts: vec![
767 file_name(&prompt_path),
768 file_name(&out_path),
769 file_name(&err_path),
770 ],
771 quota: extracted.quota,
772 dropped: extracted.dropped,
773 commands: extracted.commands,
774 context_tokens: extracted.context_tokens,
775 })
776}
777
778fn file_name(p: &Path) -> String {
779 p.file_name()
780 .unwrap_or_default()
781 .to_string_lossy()
782 .into_owned()
783}
784
785#[derive(Debug)]
787struct Plan {
788 argv: Vec<String>,
789 stdin: Option<String>,
790}
791
792impl Plan {
793 fn logged_argv(&self) -> Vec<&str> {
796 let mut settings_value = false;
797 self.argv
798 .iter()
799 .map(|arg| {
800 if settings_value {
801 settings_value = false;
802 "[redacted]"
803 } else if arg == "--settings" {
804 settings_value = true;
805 arg.as_str()
806 } else if arg.starts_with("--settings=") {
807 "--settings=[redacted]"
808 } else {
809 arg.as_str()
810 }
811 })
812 .collect()
813 }
814}
815
816fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
827 if matches!(kind, AgentKind::Antigravity) {
828 return format!("@{}", prompt_path.display());
829 }
830 format!(
831 "Read the file at {} and follow every instruction in it exactly. That \
832 file is your complete task description; this message contains nothing \
833 else.",
834 prompt_path.display()
835 )
836}
837
838fn build_command(
839 spec: &AgentSpec,
840 seat: &SeatState,
841 inv: &Invocation<'_>,
842 prompt_path: &Path,
843) -> Result<Plan> {
844 let mut argv: Vec<String> = Vec::new();
845 let mut stdin: Option<String> = None;
846 let delivery = spec.delivery();
847 let resuming = has_session(spec.kind, seat, inv.sessions);
848
849 match spec.kind {
850 AgentKind::Claude => {
851 argv.push("claude".to_owned());
856 argv.push("-p".to_owned());
857 argv.push("--output-format".to_owned());
858 argv.push("json".to_owned());
859 if let Some(m) = &spec.model {
860 argv.push("--model".to_owned());
861 argv.push(m.clone());
862 }
863 if inv.sessions {
864 let uuid = seat
865 .claude_session
866 .as_deref()
867 .context("claude seat is missing its session uuid")?;
868 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
869 argv.push(uuid.to_owned());
870 }
871 argv.push("--permission-mode".to_owned());
872 argv.push("bypassPermissions".to_owned());
873 if !inv.allow_write {
874 argv.push("--disallowed-tools".to_owned());
875 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
876 }
877 }
878 AgentKind::Opencode => {
879 argv.push("opencode".to_owned());
883 argv.push("run".to_owned());
884 argv.push("--format".to_owned());
885 argv.push("json".to_owned());
886 argv.push("--dir".to_owned());
887 argv.push(inv.cwd.to_string_lossy().into_owned());
888 argv.push("--auto".to_owned());
897 if let Some(m) = &spec.model {
898 argv.push("-m".to_owned());
899 argv.push(m.clone());
900 }
901 if resuming {
902 argv.push("-s".to_owned());
903 argv.push(
904 seat.captured_session
905 .clone()
906 .expect("has_session checked the id is present"),
907 );
908 }
909 }
910 AgentKind::Antigravity => {
911 argv.push("agy".to_owned());
912 argv.push("--output-format".to_owned());
913 argv.push("json".to_owned());
914 argv.push("--print-timeout".to_owned());
917 argv.push(format!("{}s", inv.timeout.as_secs()));
918 argv.push("--mode".to_owned());
919 argv.push(
920 if inv.allow_write {
921 "accept-edits"
922 } else {
923 "plan"
924 }
925 .to_owned(),
926 );
927 if inv.allow_write {
928 argv.push("--dangerously-skip-permissions".to_owned());
929 }
930 if let Some(m) = &spec.model {
931 argv.push("--model".to_owned());
932 argv.push(m.clone());
933 }
934 if resuming {
935 argv.push("--conversation".to_owned());
936 argv.push(
937 seat.captured_session
938 .clone()
939 .expect("has_session checked the id is present"),
940 );
941 }
942 let mut add_dirs: Vec<String> = Vec::new();
952 if delivery == Delivery::File || !inv.attachments.is_empty() {
953 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
954 }
955 for path in inv.attachments {
956 let Some(parent) = path.parent() else {
957 continue;
958 };
959 if parent.starts_with(inv.artifacts) {
960 continue;
961 }
962 let dir = parent.to_string_lossy().into_owned();
963 if !add_dirs.contains(&dir) {
964 add_dirs.push(dir);
965 }
966 }
967 if inv.allow_write {
968 for path in inv.writable {
969 let dir = path.to_string_lossy().into_owned();
970 if !add_dirs.contains(&dir) {
971 add_dirs.push(dir);
972 }
973 }
974 }
975 for dir in add_dirs {
976 argv.push("--add-dir".to_owned());
977 argv.push(dir);
978 }
979 }
980 AgentKind::Codex => {
981 argv.push("codex".to_owned());
987 argv.push("exec".to_owned());
988 argv.push("--json".to_owned());
989 argv.push("--skip-git-repo-check".to_owned());
992 argv.push("-C".to_owned());
993 argv.push(inv.cwd.to_string_lossy().into_owned());
994 if repository_writer(inv) {
1004 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
1005 } else {
1006 argv.push("--sandbox".to_owned());
1007 argv.push(
1008 if inv.allow_write {
1009 "workspace-write"
1010 } else {
1011 "read-only"
1012 }
1013 .to_owned(),
1014 );
1015 argv.push("-c".to_owned());
1018 argv.push("approval_policy=\"never\"".to_owned());
1019 if inv.allow_write && !inv.writable.is_empty() {
1020 let roots: Vec<String> = inv
1021 .writable
1022 .iter()
1023 .map(|p| p.to_string_lossy().into_owned())
1024 .collect();
1025 argv.push("-c".to_owned());
1026 argv.push(format!(
1027 "sandbox_workspace_write.writable_roots={}",
1028 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
1029 ));
1030 }
1031 }
1032 if let Some(m) = &spec.model {
1033 argv.push("-m".to_owned());
1034 argv.push(m.clone());
1035 }
1036 if resuming {
1042 argv.push("resume".to_owned());
1043 argv.push(
1044 seat.captured_session
1045 .clone()
1046 .expect("has_session checked the id is present"),
1047 );
1048 }
1049 }
1050 AgentKind::Omp => {
1051 argv.push("omp".to_owned());
1055 argv.push("-p".to_owned());
1056 argv.push("--mode=json".to_owned());
1057 argv.push("--auto-approve".to_owned());
1067 if let Some(m) = &spec.model {
1068 argv.push("--model".to_owned());
1069 argv.push(m.clone());
1070 }
1071 if resuming {
1077 argv.push("--resume".to_owned());
1078 argv.push(
1079 seat.captured_session
1080 .clone()
1081 .expect("has_session checked the id is present"),
1082 );
1083 }
1084 }
1085 AgentKind::Command => {
1086 if spec.command.is_empty() {
1091 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1092 }
1093 let vars: BTreeMap<&str, String> = BTreeMap::from([
1094 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1095 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1096 ("{label}", seat.key.clone()),
1097 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1098 ]);
1099 for raw in &spec.command {
1100 let mut arg = raw.clone();
1101 for (k, v) in &vars {
1102 if arg.contains(k) {
1103 arg = arg.replace(k, v);
1104 }
1105 }
1106 argv.push(arg);
1107 }
1108 }
1109 }
1110
1111 argv.extend(spec.extra_args.iter().cloned());
1112
1113 if spec.kind == AgentKind::Antigravity {
1116 argv.push("-p".to_owned());
1117 }
1118 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1121 argv.push("-".to_owned());
1122 }
1123 match delivery {
1124 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1125 argv.push(pointer(spec.kind, prompt_path));
1127 }
1128 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1129 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1130 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1131 }
1132
1133 Ok(Plan { argv, stdin })
1134}
1135
1136#[derive(Debug, Default)]
1138struct Extracted {
1139 text: String,
1140 session: Option<String>,
1141 status: Option<String>,
1142 quota: Option<Quota>,
1143 dropped: Option<Dropped>,
1144 commands: Vec<CommandEvidence>,
1145 context_tokens: Option<u64>,
1146}
1147
1148fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1150 let mut extracted = extract_answer(kind, stdout);
1151 extracted.context_tokens = context_tokens(kind, stdout);
1152 extracted
1153}
1154
1155fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1157 v.get(key).and_then(serde_json::Value::as_u64)
1158}
1159
1160fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1165 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1166 let base = first(input)?;
1167 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1168 Some(base.saturating_add(cached))
1169}
1170
1171fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1192 use serde_json::Value;
1193 let lines = || {
1194 stdout
1195 .lines()
1196 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1197 };
1198 match kind {
1199 AgentKind::Claude => None,
1200 AgentKind::Opencode => lines()
1201 .filter(|v| {
1202 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1204 || v.get("part")
1205 .and_then(|p| p.get("type"))
1206 .and_then(|t| t.as_str())
1207 == Some("step-finish")
1208 })
1209 .filter_map(|v| {
1210 let tokens = v.get("part")?.get("tokens")?;
1211 let base = uint(tokens, "input")?;
1212 let cache = tokens.get("cache");
1213 let cached = ["read", "write"]
1214 .iter()
1215 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1216 .sum::<u64>();
1217 Some(base.saturating_add(cached))
1218 })
1219 .next_back(),
1220 AgentKind::Antigravity => None,
1221 AgentKind::Codex => None,
1222 AgentKind::Omp => lines()
1223 .flat_map(|v| {
1224 match v.get("type").and_then(|t| t.as_str()) {
1227 Some("agent_end") => v
1228 .get("messages")
1229 .and_then(|m| m.as_array())
1230 .cloned()
1231 .unwrap_or_default(),
1232 Some("turn_end") | Some("message_end") => {
1233 v.get("message").cloned().into_iter().collect()
1234 }
1235 _ => Vec::new(),
1236 }
1237 })
1238 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1239 .filter_map(|m| {
1240 input_side(
1241 m.get("usage")?,
1242 &["input", "input_tokens"],
1243 &[
1244 "cacheRead",
1245 "cacheWrite",
1246 "cache_read_tokens",
1247 "cache_write_tokens",
1248 ],
1249 )
1250 })
1251 .next_back(),
1252 AgentKind::Command => None,
1253 }
1254}
1255
1256pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1259 extract_answer(kind, stdout).text
1260}
1261
1262fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1263 match kind {
1264 AgentKind::Claude => {
1265 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1266 return Extracted {
1267 text: stdout.trim().to_owned(),
1268 ..Extracted::default()
1269 };
1270 };
1271 Extracted {
1272 text: v
1273 .get("result")
1274 .and_then(|r| r.as_str())
1275 .unwrap_or_default()
1276 .to_owned(),
1277 session: v
1278 .get("session_id")
1279 .and_then(|s| s.as_str())
1280 .map(str::to_owned),
1281 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1282 if e {
1283 "error".to_owned()
1284 } else {
1285 "success".to_owned()
1286 }
1287 }),
1288 quota: claude_quota(&v),
1289 dropped: None,
1292 commands: Vec::new(),
1293 context_tokens: None,
1294 }
1295 }
1296 AgentKind::Opencode => {
1297 let mut text = String::new();
1299 let mut session = None;
1300 for line in stdout.lines() {
1301 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1302 continue;
1303 };
1304 if session.is_none() {
1305 session = v
1306 .get("sessionID")
1307 .and_then(|s| s.as_str())
1308 .map(str::to_owned);
1309 }
1310 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1311 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1312 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1313 {
1314 if !text.is_empty() {
1315 text.push('\n');
1316 }
1317 text.push_str(t);
1318 }
1319 }
1320 Extracted {
1321 text,
1322 session,
1323 status: None,
1324 quota: None,
1325 dropped: None,
1326 commands: Vec::new(),
1327 context_tokens: None,
1328 }
1329 }
1330 AgentKind::Antigravity => {
1331 let obj = stdout
1334 .lines()
1335 .rev()
1336 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1337 let Some(v) = obj else {
1338 return Extracted {
1339 text: stdout.trim().to_owned(),
1340 ..Extracted::default()
1341 };
1342 };
1343 Extracted {
1344 text: v
1345 .get("response")
1346 .and_then(|r| r.as_str())
1347 .unwrap_or_default()
1348 .trim()
1349 .to_owned(),
1350 session: v
1351 .get("conversation_id")
1352 .and_then(|s| s.as_str())
1353 .map(str::to_owned),
1354 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1355 quota: None,
1356 dropped: dropped_stream(&v),
1357 commands: Vec::new(),
1358 context_tokens: None,
1359 }
1360 }
1361 AgentKind::Codex => {
1362 let mut text = String::new();
1384 let mut session = None;
1385 let mut status = None;
1386 let mut commands = Vec::new();
1387 for line in stdout.lines() {
1388 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1389 continue;
1390 };
1391 match v.get("type").and_then(|t| t.as_str()) {
1392 Some("thread.started") => {
1393 session = v
1394 .get("thread_id")
1395 .and_then(|s| s.as_str())
1396 .map(str::to_owned);
1397 }
1398 Some("item.completed") => {
1399 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1400 match item.get("type").and_then(|t| t.as_str()) {
1401 Some("agent_message") => {
1402 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1403 text = t.trim().to_owned();
1404 }
1405 }
1406 Some("command_execution") => {
1407 commands.push(command_evidence(item));
1408 }
1409 _ => {}
1410 }
1411 }
1412 Some("turn.completed") => status = Some("success".to_owned()),
1413 Some("turn.failed") => status = Some("error".to_owned()),
1414 _ => {}
1415 }
1416 }
1417 Extracted {
1418 text,
1419 session,
1420 status,
1421 quota: codex_quota(stdout),
1422 dropped: None,
1423 commands,
1424 context_tokens: None,
1425 }
1426 }
1427 AgentKind::Omp => {
1428 let mut text = String::new();
1449 let mut session = None;
1450 for line in stdout.lines() {
1451 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1452 continue;
1453 };
1454 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1455 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1456 continue;
1457 }
1458 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1462 {
1463 Some("agent_end") => v
1464 .get("messages")
1465 .and_then(|m| m.as_array())
1466 .map(|m| m.iter().collect())
1467 .unwrap_or_default(),
1468 Some("turn_end") | Some("message_end") => {
1469 v.get("message").into_iter().collect()
1470 }
1471 _ => continue,
1472 };
1473 for message in messages {
1474 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1475 continue;
1476 }
1477 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1478 continue;
1479 };
1480 for part in parts {
1481 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1482 continue;
1483 }
1484 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1485 && !t.trim().is_empty()
1486 {
1487 text = t.trim().to_owned();
1488 }
1489 }
1490 }
1491 }
1492 Extracted {
1493 text,
1494 session,
1495 status: None,
1496 quota: None,
1497 dropped: None,
1498 commands: Vec::new(),
1499 context_tokens: None,
1500 }
1501 }
1502 AgentKind::Command => {
1503 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1508 let quota = parsed.as_ref().and_then(claude_quota);
1509 let dropped = parsed.as_ref().and_then(dropped_stream);
1512 Extracted {
1513 text: stdout.trim().to_owned(),
1514 session: None,
1515 status: None,
1516 quota,
1517 dropped,
1518 commands: Vec::new(),
1519 context_tokens: None,
1520 }
1521 }
1522 }
1523}
1524
1525fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1532 let description = match item.get("command") {
1533 Some(serde_json::Value::String(s)) => s.clone(),
1534 Some(serde_json::Value::Array(parts)) => parts
1535 .iter()
1536 .filter_map(|p| p.as_str())
1537 .collect::<Vec<_>>()
1538 .join(" "),
1539 _ => String::new(),
1540 };
1541 let result_summary = item
1542 .get("aggregated_output")
1543 .and_then(|o| o.as_str())
1544 .map(|s| tail_chars(s.trim(), 400))
1545 .unwrap_or_default();
1546 CommandEvidence {
1547 id: item
1548 .get("id")
1549 .and_then(|s| s.as_str())
1550 .unwrap_or_default()
1551 .to_owned(),
1552 description,
1553 exit_code: item
1554 .get("exit_code")
1555 .and_then(serde_json::Value::as_i64)
1556 .map(|e| e as i32),
1557 result_summary,
1558 source: "codex".to_owned(),
1559 }
1560}
1561
1562fn tail_chars(s: &str, max: usize) -> String {
1564 let count = s.chars().count();
1565 if count <= max {
1566 return s.to_owned();
1567 }
1568 s.chars().skip(count - max).collect()
1569}
1570
1571fn codex_quota(stdout: &str) -> Option<Quota> {
1574 let mut message = None;
1575 let mut failed = false;
1576 for line in stdout.lines() {
1577 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1578 continue;
1579 };
1580 match v.get("type").and_then(serde_json::Value::as_str) {
1581 Some("error") => {
1582 if let Some(m) = v.get("message").and_then(serde_json::Value::as_str)
1583 && m.starts_with("You've hit your usage limit. Visit https://chatgpt.com/settings/usage to purchase more credits")
1584 {
1585 message = Some(m.to_owned());
1586 }
1587 }
1588 Some("turn.failed") => failed = true,
1589 _ => {}
1590 }
1591 }
1592 if !failed {
1593 return None;
1594 }
1595 let message = message?;
1596 Some(Quota {
1597 reset: message
1598 .split_once("try again at ")
1599 .map(|(_, hint)| hint.trim().trim_end_matches('.').trim().to_owned())
1600 .filter(|hint| !hint.is_empty()),
1601 cause: QuotaCause::UsageLimit,
1602 agent: String::new(),
1603 })
1604}
1605
1606fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1613 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1614 if !is_err {
1615 return None;
1616 }
1617 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1618 if v.get("terminal_reason").and_then(serde_json::Value::as_str) == Some("api_error")
1619 && result.starts_with("Failed to authenticate:")
1620 && result.contains("OAuth session expired and could not be refreshed")
1621 {
1622 return Some(Quota {
1623 reset: None,
1624 cause: QuotaCause::Authentication,
1625 agent: String::new(),
1626 });
1627 }
1628 let status = v.get("api_error_status");
1629 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1630 || status.and_then(serde_json::Value::as_str) == Some("429"))
1631 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1632 if !usage_limit && !result.to_lowercase().contains("session limit") {
1633 return None;
1634 }
1635 let reset = result
1638 .split("resets ")
1639 .nth(1)
1640 .map(str::trim)
1641 .filter(|s| !s.is_empty())
1642 .map(str::to_owned);
1643 Some(Quota {
1644 reset,
1645 cause: QuotaCause::UsageLimit,
1646 agent: String::new(),
1647 })
1648}
1649
1650fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1665 let from_stdout = stdout
1666 .lines()
1667 .rev()
1668 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1669 .and_then(|v| {
1670 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1671 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1672 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1673 .then(|| error.to_owned())
1674 });
1675 let from_stderr = || {
1676 stderr.lines().find_map(|l| {
1677 let v: serde_json::Value =
1678 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1679 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1680 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1681 (exhausted && code).then(|| {
1682 v.get("short_error")
1683 .and_then(|e| e.as_str())
1684 .unwrap_or_default()
1685 .to_owned()
1686 })
1687 })
1688 };
1689 let text = from_stdout.or_else(from_stderr)?;
1690 let reset = text
1691 .split_once("Resets ")
1692 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1693 .filter(|s| !s.is_empty())
1694 .map(str::to_owned);
1695 Some(Quota {
1696 reset,
1697 cause: QuotaCause::UsageLimit,
1698 agent: String::new(),
1699 })
1700}
1701
1702fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1733 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1734 if !status.eq_ignore_ascii_case("error") {
1735 return None;
1736 }
1737 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1738 if !response.trim().is_empty() {
1739 return None;
1741 }
1742 let produced = v
1743 .get("usage")
1744 .and_then(|u| u.get("output_tokens"))
1745 .and_then(serde_json::Value::as_u64)
1746 .unwrap_or(0);
1747 if produced == 0 {
1748 return None;
1750 }
1751 Some(Dropped {
1752 why: v
1753 .get("error")
1754 .and_then(|e| e.as_str())
1755 .unwrap_or("the CLI ended the stream without delivering its answer")
1756 .trim()
1757 .to_owned(),
1758 output_tokens: produced,
1759 })
1760}
1761
1762pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1764 let mut missing = Vec::new();
1765 for s in specs {
1766 let program = match s.kind {
1767 AgentKind::Command => s.command.first().map(String::as_str),
1768 other => other.program(),
1769 };
1770 if let Some(p) = program
1771 && !crate::config::which(p)
1772 && !Path::new(p).is_file()
1773 && !missing.iter().any(|m: &String| m == p)
1774 {
1775 missing.push(p.to_owned());
1776 }
1777 }
1778 missing
1779}
1780
1781pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1783 run_dir.join("artifacts")
1784}
1785
1786pub fn installed(spec: &AgentSpec) -> bool {
1788 spec.kind.program().is_none_or(crate::config::which)
1791}
1792
1793pub fn pick(
1815 agents: &[AgentSpec],
1816 want: Option<&str>,
1817 available: &dyn Fn(&AgentSpec) -> bool,
1818) -> Result<AgentSpec> {
1819 if let Some(id) = want {
1820 let spec = agents
1821 .iter()
1822 .find(|a| a.id == id)
1823 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1824 if !available(spec) {
1825 bail!(
1826 "agent `{}` needs `{}` on PATH; install it or pass a different \
1827 --agent",
1828 spec.id,
1829 spec.kind.program().unwrap_or("its command")
1830 );
1831 }
1832 return Ok(spec.clone());
1833 }
1834
1835 if agents.is_empty() {
1836 bail!(
1837 "the agent roster is empty, so there is nobody to ask: install one \
1838 of claude, opencode or agy - magi derives a roster from what is on \
1839 PATH - or add an [[agents]] entry to magi.toml."
1840 );
1841 }
1842
1843 if let Some(spec) = agents
1844 .iter()
1845 .find(|a| a.kind == AgentKind::Claude && available(a))
1846 {
1847 return Ok(spec.clone());
1848 }
1849
1850 agents
1851 .iter()
1852 .find(|a| available(a))
1853 .cloned()
1854 .with_context(|| {
1855 let missing = agents
1856 .iter()
1857 .filter_map(|a| a.kind.program())
1858 .collect::<Vec<_>>()
1859 .join(", ");
1860 format!(
1861 "no agent in the roster can be run here: install one of \
1862 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1863 you do have"
1864 )
1865 })
1866}
1867
1868pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1881 let mut spec = spec.clone();
1882 if spec.kind == AgentKind::Claude {
1883 let mut existing = None;
1886 let mut args = spec.extra_args.iter().enumerate();
1887 while let Some((index, arg)) = args.next() {
1888 if arg == "--settings" {
1889 let (value_index, value) = args.next().context("--settings requires a value")?;
1890 existing = Some((value_index, value.as_str(), false));
1891 } else if let Some(value) = arg.strip_prefix("--settings=") {
1892 existing = Some((index, value, true));
1893 }
1894 }
1895 let mut settings = if let Some((_, value, _)) = existing {
1896 let json = if value.trim_start().starts_with('{') {
1897 value.to_owned()
1898 } else {
1899 std::fs::read_to_string(cwd.join(value))
1900 .context("cannot read existing seat --settings file")?
1901 };
1902 serde_json::from_str::<serde_json::Value>(&json)
1903 .context("cannot parse existing seat --settings JSON")?
1904 } else {
1905 serde_json::json!({})
1906 };
1907 settings
1908 .as_object_mut()
1909 .context("seat --settings must be a JSON object")?
1910 .insert(
1911 "language".to_owned(),
1912 serde_json::json!(crate::lang::name(github_language)),
1913 );
1914 let settings = settings.to_string();
1915 if let Some((index, _, equals)) = existing {
1916 spec.extra_args[index] = if equals {
1917 format!("--settings={settings}")
1918 } else {
1919 settings
1920 };
1921 } else {
1922 spec.extra_args.extend(["--settings".to_owned(), settings]);
1923 }
1924 }
1925 Ok(spec)
1926}
1927
1928pub fn pick_chain(
1936 agents: &[AgentSpec],
1937 choice: Option<&AgentChoice>,
1938 available: &dyn Fn(&AgentSpec) -> bool,
1939 role: &str,
1940) -> Result<Vec<AgentSpec>> {
1941 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1942 if wanted.is_empty() {
1943 return pick(agents, None, available)
1944 .map(|s| vec![s])
1945 .with_context(|| format!("choose an agent for the {role} role"));
1946 }
1947 let mut chain: Vec<AgentSpec> = Vec::new();
1948 for id in wanted {
1949 if chain.iter().any(|s| s.id == id) {
1950 continue;
1951 }
1952 match pick(agents, Some(id), available) {
1953 Ok(spec) => chain.push(spec),
1954 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1955 }
1956 }
1957 if chain.is_empty() {
1958 bail!(
1959 "[roles] {role} names no agent that can run here; the roster has {}",
1960 ids(agents)
1961 );
1962 }
1963 Ok(chain)
1964}
1965
1966pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1972 match outcome {
1973 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1974 Err(_) => true,
1975 Ok(out) => output_advances(out),
1976 }
1977}
1978
1979pub fn output_advances(out: &AgentOutput) -> bool {
1983 out.quota_exhausted() || !out.usable()
1984}
1985
1986fn ids(agents: &[AgentSpec]) -> String {
1987 if agents.is_empty() {
1988 return "no agents at all".to_owned();
1989 }
1990 agents
1991 .iter()
1992 .map(|a| a.id.clone())
1993 .collect::<Vec<_>>()
1994 .join(", ")
1995}
1996
1997#[cfg(all(test, unix))]
2000pub(crate) mod test_watchdog {
2001 use super::*;
2002 static FLOORS: Mutex<BTreeMap<String, u64>> = Mutex::new(BTreeMap::new());
2003
2004 pub(crate) struct Guard(String);
2005
2006 pub(crate) fn set(run: &str, floor: u64) -> Guard {
2007 assert!(
2008 FLOORS
2009 .lock()
2010 .unwrap()
2011 .insert(run.to_owned(), floor)
2012 .is_none()
2013 );
2014 Guard(run.to_owned())
2015 }
2016
2017 pub(super) fn floor(run: &str, configured: u64) -> u64 {
2018 FLOORS
2019 .lock()
2020 .unwrap()
2021 .get(run)
2022 .copied()
2023 .unwrap_or(configured)
2024 }
2025
2026 impl Drop for Guard {
2027 fn drop(&mut self) {
2028 FLOORS.lock().unwrap().remove(&self.0);
2029 }
2030 }
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035 use super::*;
2036
2037 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
2038
2039 fn command_helper(mode: &str) -> AgentSpec {
2042 crate::run::pin_test_home();
2044 AgentSpec {
2045 id: "helper".to_owned(),
2046 kind: AgentKind::Command,
2047 model: None,
2048 command: vec![
2049 std::env::current_exe()
2050 .expect("locate test helper")
2051 .to_string_lossy()
2052 .into_owned(),
2053 "--exact".to_owned(),
2054 "agent::tests::command_agent_test_helper".to_owned(),
2055 "--nocapture".to_owned(),
2056 ],
2057 extra_args: Vec::new(),
2058 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
2059 prompt_delivery: None,
2060 max_turn_secs: None,
2061 }
2062 }
2063
2064 #[test]
2065 fn command_agent_test_helper() {
2066 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
2067 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
2068 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
2069 Ok("no-cache") => println!(
2070 "{}",
2071 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
2072 ),
2073 Ok("ignore-stdin") => println!("done"),
2074 Ok("chatty-sleep") => {
2075 println!("i-said-something");
2076 std::thread::sleep(Duration::from_secs(30));
2077 }
2078 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
2079 Ok(other) => panic!("unknown command helper mode {other}"),
2080 Err(_) => {}
2081 }
2082 }
2083
2084 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
2085 AgentSpec {
2086 id: "a".to_owned(),
2087 kind,
2088 model: model.map(str::to_owned),
2089 command: vec!["echo".to_owned(), "{label}".to_owned()],
2090 extra_args: Vec::new(),
2091 env: BTreeMap::new(),
2092 prompt_delivery: None,
2093 max_turn_secs: None,
2094 }
2095 }
2096
2097 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
2098 Invocation {
2099 coordination: None,
2100 fetched_base: None,
2101 cwd,
2102 prompt: "do the thing",
2103 timeout: Duration::from_secs(900),
2104 allow_write,
2105 sessions: true,
2106 artifacts: art,
2107 stem: "t",
2108 run: "test-run",
2109 node: "test",
2110 cache_dir: None,
2111 attachments: &[],
2112 writable: &[],
2113 }
2114 }
2115
2116 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
2117 build_command(
2118 &spec(kind, None),
2119 seat,
2120 &inv(Path::new("."), Path::new("/art"), allow_write),
2121 Path::new("/art/p.md"),
2122 )
2123 .unwrap()
2124 }
2125
2126 #[test]
2127 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
2128 let missing = editing_access_note(None);
2129 assert!(missing.contains("No successful launcher fetch evidence"));
2130 let base = crate::run::FetchedBase {
2131 remote: "origin".into(),
2132 branch: "main".into(),
2133 commit: "0123456789abcdef".into(),
2134 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
2135 };
2136 let note = editing_access_note(Some(&base));
2137 for expected in [
2138 "origin/main",
2139 "0123456789abcdef",
2140 "2026-10-18T00:00:00Z",
2141 "Do not fetch again",
2142 ] {
2143 assert!(note.contains(expected), "missing {expected}");
2144 }
2145 for text in [&missing, ¬e] {
2148 assert!(!text.to_lowercase().contains("sandbox"), "{text}");
2149 assert!(!text.contains("Network access is restricted"), "{text}");
2150 }
2151 let roundtrip: crate::run::FetchedBase =
2152 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2153 assert_eq!(editing_access_note(Some(&roundtrip)), note);
2154 }
2155
2156 #[test]
2157 fn codex_editing_seats_bypass_the_sandbox_on_initial_and_resume() {
2158 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2159 let roots = [PathBuf::from("/shared/git"), PathBuf::from("/questions")];
2160 for resumed in [false, true] {
2161 let mut seat = SeatState::new("impl-A", "a", 7);
2162 if resumed {
2163 seat.turns = 1;
2164 seat.captured_session = Some("fixture-thread".into());
2165 }
2166 let build = |invocation: &Invocation<'_>| {
2167 build_command(
2168 &spec(AgentKind::Codex, None),
2169 &seat,
2170 invocation,
2171 Path::new("/art/p.md"),
2172 )
2173 .unwrap()
2174 };
2175 for node in [
2176 "implement",
2177 "fix",
2178 "graft",
2179 "gate-fix",
2180 "land",
2181 "rebase",
2182 crate::queue::CHAT_NODE,
2183 ] {
2184 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2185 invocation.node = node;
2186 invocation.writable = &roots;
2187 let plan = build(&invocation);
2188 let at = plan.argv.iter().position(|s| s == BYPASS);
2189 assert!(at.is_some(), "{node}: {:?}", plan.argv);
2190 if resumed {
2191 let resume = plan.argv.iter().position(|s| s == "resume").unwrap();
2192 assert!(at.unwrap() < resume, "{node}: {:?}", plan.argv);
2193 }
2194 assert!(!plan.argv.iter().any(|s| s == "--sandbox"), "{node}");
2196 assert!(!plan.argv.iter().any(|s| s == "-c"), "{node}");
2197 }
2198 for (node, write) in [
2199 ("review", false),
2200 ("judge", false),
2201 ("advise", false),
2202 ("implement", false),
2203 ("fix", false),
2204 ("dupes", false),
2205 ("conduct", false),
2206 ("waiter", false),
2207 ("talk-title", false),
2208 (crate::queue::CHAT_NODE, false),
2209 ("deputy", true),
2210 ] {
2211 let mut invocation = inv(Path::new("."), Path::new("/art"), write);
2212 invocation.node = node;
2213 let plan = build(&invocation);
2214 assert!(!plan.argv.iter().any(|s| s == BYPASS), "{node}");
2215 let mode = if write {
2216 "workspace-write"
2217 } else {
2218 "read-only"
2219 };
2220 assert!(
2221 plan.argv.windows(2).any(|w| w == ["--sandbox", mode]),
2222 "{node}: {:?}",
2223 plan.argv
2224 );
2225 assert!(
2226 plan.argv
2227 .windows(2)
2228 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2229 "{node}"
2230 );
2231 }
2232 }
2233 }
2234
2235 #[test]
2236 fn claude_mints_then_resumes_the_same_uuid() {
2237 let mut seat = SeatState::new("judge-1", "a", 7);
2238 let uuid = seat.claude_session.clone().unwrap();
2239 let first = plan_for(AgentKind::Claude, &seat, true);
2240 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2241 assert!(!first.argv.iter().any(|a| a == "--resume"));
2242
2243 seat.turns = 1;
2244 let second = plan_for(AgentKind::Claude, &seat, true);
2245 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2246 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2247 }
2248
2249 #[test]
2250 fn github_bound_claude_seats_override_the_operators_answer_language() {
2251 let seat = SeatState::new("change-summary", "a", 7);
2252 let art = Path::new("/art");
2253 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2254 let claude =
2255 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2256 let plan = build_command(
2257 &claude,
2258 &seat,
2259 &inv(Path::new("."), art, false),
2260 Path::new("/art/p.md"),
2261 )
2262 .unwrap();
2263 let settings = format!(r#"{{"language":"{name}"}}"#);
2264 assert!(
2265 plan.argv
2266 .windows(2)
2267 .any(|w| w == ["--settings", settings.as_str()]),
2268 "{:?}",
2269 plan.argv
2270 );
2271 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2274 }
2275 for kind in [
2276 AgentKind::Codex,
2277 AgentKind::Opencode,
2278 AgentKind::Antigravity,
2279 AgentKind::Omp,
2280 ] {
2281 assert!(
2282 for_github_text(&spec(kind, None), "en", Path::new("."))
2283 .unwrap()
2284 .extra_args
2285 .is_empty()
2286 );
2287 }
2288 }
2289
2290 #[test]
2291 fn github_text_preserves_existing_settings() {
2292 let dir = tempfile::tempdir().unwrap();
2293 let original = serde_json::json!({
2294 "language": "Japanese",
2295 "env": { "SEAT_MODE": "configured" },
2296 "apiKeyHelper": "credential-helper",
2297 "hooks": { "Stop": [] }
2298 });
2299 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2300 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2301 for equals in [false, true] {
2302 let mut spec = spec(AgentKind::Claude, None);
2303 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2305 if equals {
2306 spec.extra_args.push(format!("--settings={value}"));
2307 } else {
2308 spec.extra_args
2309 .extend(["--settings".to_owned(), value.clone()]);
2310 }
2311 spec.extra_args.push("--verbose".to_owned());
2312 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2313 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2314 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2315 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2316 let value = &updated.extra_args[updated.extra_args.len() - 2];
2317 let actual: serde_json::Value =
2318 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2319 .unwrap();
2320 let mut expected = original.clone();
2321 expected["language"] = serde_json::json!("English");
2322 assert_eq!(actual, expected);
2323 }
2324 }
2325 let on_disk: serde_json::Value = serde_json::from_str(
2326 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2327 )
2328 .unwrap();
2329 assert_eq!(on_disk, original);
2330 }
2331
2332 #[test]
2333 fn github_text_settings_are_redacted_from_command_logging() {
2334 let dir = tempfile::tempdir().unwrap();
2335 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2336 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2337 for equals in [false, true] {
2338 let mut spec = spec(AgentKind::Claude, None);
2339 spec.extra_args =
2340 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2341 if equals {
2342 spec.extra_args
2343 .push("--settings=./seat-settings.json".to_owned());
2344 } else {
2345 spec.extra_args
2346 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2347 }
2348 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2349 let plan = build_command(
2350 &spec,
2351 &SeatState::new("change-summary", "a", 7),
2352 &inv(dir.path(), dir.path(), false),
2353 &dir.path().join("prompt.md"),
2354 )
2355 .unwrap();
2356 let actual = plan.argv.last().unwrap();
2358 let actual: serde_json::Value =
2359 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2360 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2361 assert_eq!(actual["language"], "English");
2362 let logged = format!("{:?}", plan.logged_argv());
2363 assert!(!logged.contains("secret"));
2364 assert!(!logged.contains("AUTH_TOKEN"));
2365 assert!(logged.contains("[redacted]"));
2366 assert_eq!(plan.logged_argv()[0], "claude");
2367 assert!(plan.logged_argv().contains(&"--output-format"));
2368 }
2369 }
2370
2371 #[test]
2372 fn github_text_rejects_unreadable_or_invalid_settings() {
2373 let dir = tempfile::tempdir().unwrap();
2374 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2375 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2376 for args in [
2377 vec!["--settings"],
2378 vec!["--settings", "missing.json"],
2379 vec!["--settings=bad.json"],
2380 vec!["--settings", "array.json"],
2381 ] {
2382 let mut spec = spec(AgentKind::Claude, None);
2383 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2384 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2385 }
2386 }
2387
2388 #[test]
2389 fn read_only_seats_cannot_edit() {
2390 let seat = SeatState::new("judge-1", "a", 7);
2391 let claude = plan_for(AgentKind::Claude, &seat, false);
2392 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2393 assert!(
2394 !plan_for(AgentKind::Claude, &seat, true)
2395 .argv
2396 .iter()
2397 .any(|a| a == "--disallowed-tools")
2398 );
2399
2400 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2401 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2402 assert!(
2403 !agy.argv
2404 .iter()
2405 .any(|a| a == "--dangerously-skip-permissions")
2406 );
2407 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2408 assert!(
2409 agy_rw
2410 .argv
2411 .windows(2)
2412 .any(|w| w == ["--mode", "accept-edits"])
2413 );
2414 assert!(
2415 agy_rw
2416 .argv
2417 .iter()
2418 .any(|a| a == "--dangerously-skip-permissions")
2419 );
2420 let agy_prompt = agy_rw
2425 .argv
2426 .iter()
2427 .position(|a| a == "-p")
2428 .map(|i| agy_rw.argv[i + 1].clone())
2429 .expect("agy takes its prompt with -p");
2430 assert!(
2431 agy_prompt.starts_with('@'),
2432 "agy must get a file reference, got {agy_prompt:?}"
2433 );
2434 assert!(
2435 !agy_prompt.contains("Read the file at"),
2436 "the prose pointer is for CLIs with no file syntax"
2437 );
2438
2439 for allow_write in [false, true] {
2444 assert!(
2445 plan_for(AgentKind::Opencode, &seat, allow_write)
2446 .argv
2447 .iter()
2448 .any(|a| a == "--auto"),
2449 "opencode needs --auto even to read (allow_write = {allow_write})"
2450 );
2451 }
2452 }
2453
2454 #[test]
2457 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2458 let seat = SeatState::new("deputy-x", "a", 7);
2459 let roots = [PathBuf::from("/data/questions")];
2460 let mk = |allow_write: bool| {
2461 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2462 i.writable = &roots;
2463 build_command(
2464 &spec(AgentKind::Codex, None),
2465 &seat,
2466 &i,
2467 Path::new("/art/p.md"),
2468 )
2469 .unwrap()
2470 };
2471 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2472 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2473 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2474 }
2475
2476 #[test]
2477 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2478 let mut i = inv(Path::new("."), Path::new("/art"), true);
2479 for node in [
2480 "implement",
2481 "fix",
2482 "graft",
2483 "gate-fix",
2484 "land",
2485 "rebase",
2486 "chat",
2487 ] {
2488 i.node = node;
2489 assert!(repository_writer(&i), "{node}");
2490 i.allow_write = false;
2491 assert!(!repository_writer(&i), "{node}");
2492 i.allow_write = true;
2493 }
2494 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2495 i.node = node;
2496 assert!(!repository_writer(&i), "{node}");
2497 }
2498 i.node = "chat";
2499 let roots = [PathBuf::from("/questions")];
2500 i.writable = &roots;
2501 assert!(
2502 repository_writer(&i),
2503 "explicit roots do not change the role"
2504 );
2505 assert!(
2506 !repository_seat(&i),
2507 "chat turns bypass repository scheduling"
2508 );
2509 }
2510
2511 #[test]
2512 fn coordination_classification_is_independent_of_write_grants() {
2513 let roots = [PathBuf::from("/questions")];
2514 for node in [
2515 crate::queue::CHAT_NODE,
2516 "implement",
2517 "fix",
2518 "graft",
2519 "gate-fix",
2520 "land",
2521 "rebase",
2522 "review",
2523 "judge",
2524 "advise",
2525 "deputy",
2526 "waiter",
2527 "conduct",
2528 "dupes",
2529 "talk-title",
2530 ] {
2531 for allow_write in [false, true] {
2532 for writable in [&[][..], &roots[..]] {
2533 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2534 i.node = node;
2535 i.writable = writable;
2536 assert_eq!(
2537 repository_seat(&i),
2538 !matches!(
2539 node,
2540 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2541 ),
2542 "{node}, allow_write={allow_write}, writable={writable:?}"
2543 );
2544 if node == crate::queue::CHAT_NODE {
2545 assert_eq!(repository_writer(&i), allow_write);
2546 }
2547 }
2548 }
2549 }
2550 }
2551
2552 #[test]
2553 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2554 let roots = [
2555 PathBuf::from("/shared/git"),
2556 PathBuf::from("/state/questions"),
2557 ];
2558 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2559 for resumed in [false, true] {
2560 let mut seat = SeatState::new("impl-A", "a", 7);
2561 if resumed {
2562 seat.turns = 1;
2563 seat.captured_session = Some("session-id".into());
2564 }
2565 for allow_write in [false, true] {
2566 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2567 i.writable = &roots;
2568 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2569 .unwrap();
2570 for root in &roots {
2571 if kind == AgentKind::Antigravity {
2572 assert_eq!(
2573 plan.argv.windows(2).any(|w| {
2574 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2575 }),
2576 allow_write,
2577 "resumed={resumed}: {:?}",
2578 plan.argv
2579 );
2580 }
2581 assert_eq!(
2582 plan.argv
2583 .iter()
2584 .any(|arg| arg.contains(root.to_str().unwrap())),
2585 allow_write,
2586 "{kind:?}, resumed={resumed}: {:?}",
2587 plan.argv
2588 );
2589 }
2590 if kind == AgentKind::Codex && resumed {
2591 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2592 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2593 }
2594 }
2595 }
2596 }
2597 }
2598
2599 #[test]
2600 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2601 let mut seat = SeatState::new("judge-1", "a", 7);
2602
2603 let ro = plan_for(AgentKind::Codex, &seat, false);
2609 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2610 let rw = plan_for(AgentKind::Codex, &seat, true);
2611 assert!(
2612 rw.argv
2613 .windows(2)
2614 .any(|w| w == ["--sandbox", "workspace-write"])
2615 );
2616 for p in [&ro, &rw] {
2617 assert!(
2618 !p.argv
2619 .iter()
2620 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2621 "the bypass defeats the only enforced read-only mode we have"
2622 );
2623 assert!(
2625 p.argv
2626 .windows(2)
2627 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2628 "an unattended seat that asks for approval blocks until timeout"
2629 );
2630 }
2631
2632 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2634 assert_eq!(
2635 ro.argv.last().map(String::as_str),
2636 Some("-"),
2637 "without the `-` argument codex waits for a prompt it never gets"
2638 );
2639
2640 seat.turns = 1;
2644 assert!(!has_session(AgentKind::Codex, &seat, true));
2645 assert!(
2646 !plan_for(AgentKind::Codex, &seat, true)
2647 .argv
2648 .iter()
2649 .any(|a| a == "resume")
2650 );
2651 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2652 let resumed = plan_for(AgentKind::Codex, &seat, true);
2653 let at = resumed
2654 .argv
2655 .iter()
2656 .position(|a| a == "resume")
2657 .expect("resumes by subcommand");
2658 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2659 assert!(
2660 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2661 "every option precedes the subcommand"
2662 );
2663 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2664 }
2665
2666 #[test]
2669 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2670 let mut seat = SeatState::new("review-1", "a", 7);
2671
2672 let first = plan_for(AgentKind::Omp, &seat, false);
2676 assert!(first.argv.iter().any(|a| a == "-p"));
2677 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2678 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2679 assert!(
2680 !first.argv.iter().any(|a| a == "do the thing"),
2681 "the prompt reached argv, where Windows caps it"
2682 );
2683
2684 for allow_write in [false, true] {
2690 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2691 assert!(
2692 p.argv.iter().any(|a| a == "--auto-approve"),
2693 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2694 );
2695 assert!(
2696 !p.argv
2697 .iter()
2698 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2699 "nothing ever asks for the bypass"
2700 );
2701 }
2702
2703 seat.turns = 1;
2706 assert!(!has_session(AgentKind::Omp, &seat, true));
2707 assert!(
2708 !plan_for(AgentKind::Omp, &seat, true)
2709 .argv
2710 .iter()
2711 .any(|a| a == "--resume")
2712 );
2713 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2714 let resumed = plan_for(AgentKind::Omp, &seat, true);
2715 assert!(
2716 resumed
2717 .argv
2718 .windows(2)
2719 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2720 "a captured id is what makes the next turn a resume"
2721 );
2722 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2725 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2727 }
2728
2729 #[test]
2734 fn omp_takes_the_answer_without_an_agent_end_line() {
2735 let stream = concat!(
2736 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2737 "\n",
2738 r#"{"type":"agent_start"}"#,
2739 "\n",
2740 r#"{"type":"turn_start"}"#,
2741 "\n",
2742 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2743 "\n",
2744 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2745 "\n",
2746 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2747 "\n",
2748 );
2749 let out = extract(AgentKind::Omp, stream);
2750 assert_eq!(
2751 out.text, "{\"vote\":\"approve\"}",
2752 "the last assistant text block is the answer even with no agent_end"
2753 );
2754 assert_eq!(
2755 out.session.as_deref(),
2756 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2757 );
2758 }
2759
2760 #[test]
2764 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2765 let stream = concat!(
2766 r#"{"type":"session","version":3,"id":"s1"}"#,
2767 "\n",
2768 "{\"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問題ありません。\"}]}]}",
2769 "\n",
2770 );
2771 let out = extract(AgentKind::Omp, stream);
2772 assert_eq!(
2773 out.text, "## 判定\n\n問題ありません。",
2774 "the narration is not the answer, and non-ASCII survives intact"
2775 );
2776 assert_eq!(out.session.as_deref(), Some("s1"));
2777 }
2778
2779 #[test]
2782 fn omp_skips_non_json_lines() {
2783 let stream = concat!(
2784 "Warning: some omp notice\n",
2785 r#"{"type":"session","version":3,"id":"s2"}"#,
2786 "\n",
2787 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2788 "\n",
2789 "trailing junk",
2790 "\n",
2791 );
2792 let out = extract(AgentKind::Omp, stream);
2793 assert_eq!(out.text, "the answer");
2794 assert_eq!(out.session.as_deref(), Some("s2"));
2795 }
2796
2797 #[test]
2799 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2800 let stream = concat!(
2801 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2802 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2803 "\n",
2804 r#"{"type":"turn.started"}"#,
2805 "\n",
2806 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2807 "\n",
2808 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2809 "\n",
2810 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2811 "\n",
2812 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2813 "\n",
2814 );
2815 let out = extract(AgentKind::Codex, stream);
2816 assert_eq!(
2817 out.text, "{\"verdict\": \"ok\"}",
2818 "the last agent message is the answer; earlier ones narrate"
2819 );
2820 assert_eq!(
2821 out.session.as_deref(),
2822 Some("01a07440-4545-7492-85c1-024e3259a90a")
2823 );
2824 assert_eq!(out.status.as_deref(), Some("success"));
2825
2826 let failed = concat!(
2827 r#"{"type":"thread.started","thread_id":"t1"}"#,
2828 "\n",
2829 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2830 "\n",
2831 );
2832 assert_eq!(
2833 extract(AgentKind::Codex, failed).status.as_deref(),
2834 Some("error")
2835 );
2836 }
2837
2838 #[test]
2839 fn captured_sessions_resume_only_once_reported() {
2840 let mut seat = SeatState::new("impl-A", "a", 7);
2841 seat.turns = 1;
2842 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2843 assert!(!has_session(kind, &seat, true));
2844 let p = plan_for(kind, &seat, true);
2845 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2846 }
2847
2848 seat.captured_session = Some("sid".to_owned());
2849 assert!(has_session(AgentKind::Opencode, &seat, true));
2850 assert!(
2851 plan_for(AgentKind::Opencode, &seat, true)
2852 .argv
2853 .windows(2)
2854 .any(|w| w == ["-s", "sid"])
2855 );
2856 assert!(
2857 plan_for(AgentKind::Antigravity, &seat, true)
2858 .argv
2859 .windows(2)
2860 .any(|w| w == ["--conversation", "sid"])
2861 );
2862 }
2863
2864 #[test]
2865 fn sessions_disabled_never_resumes() {
2866 let mut seat = SeatState::new("impl-A", "a", 7);
2867 seat.turns = 3;
2868 seat.captured_session = Some("sid".to_owned());
2869 for kind in [
2870 AgentKind::Claude,
2871 AgentKind::Opencode,
2872 AgentKind::Antigravity,
2873 ] {
2874 assert!(!has_session(kind, &seat, false));
2875 }
2876 }
2877
2878 #[test]
2879 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2880 let seat = SeatState::new("judge-1", "a", 7);
2881 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2882 let p = plan_for(kind, &seat, false);
2883 assert!(
2884 p.argv.iter().all(|a| a != "do the thing"),
2885 "{kind:?} put the prompt on the command line"
2886 );
2887 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2888 }
2889 let p = plan_for(AgentKind::Antigravity, &seat, false);
2891 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2892 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2893 assert!(p.stdin.is_none());
2894 }
2895
2896 #[test]
2897 fn agy_print_timeout_tracks_the_node_budget() {
2898 let seat = SeatState::new("impl-A", "a", 7);
2899 let p = build_command(
2900 &spec(AgentKind::Antigravity, None),
2901 &seat,
2902 &Invocation {
2903 coordination: None,
2904 fetched_base: None,
2905 cwd: Path::new("."),
2906 prompt: "p",
2907 timeout: Duration::from_secs(3600),
2908 allow_write: true,
2909 sessions: true,
2910 artifacts: Path::new("/art"),
2911 stem: "t",
2912 run: "test-run",
2913 node: "test",
2914 cache_dir: None,
2915 attachments: &[],
2916 writable: &[],
2917 },
2918 Path::new("/art/p.md"),
2919 )
2920 .unwrap();
2921 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2922 }
2923
2924 #[test]
2931 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2932 let mut s = spec(AgentKind::Antigravity, None);
2933 s.prompt_delivery = Some(Delivery::Argv);
2934 let seat = SeatState::new("talk", "a", 7);
2935 let atts = [PathBuf::from("/art/attachments/abc.png")];
2936
2937 let without = build_command(
2938 &s,
2939 &seat,
2940 &Invocation {
2941 attachments: &[],
2942 writable: &[],
2943 ..inv(Path::new("."), Path::new("/art"), true)
2944 },
2945 Path::new("/art/p.md"),
2946 )
2947 .unwrap();
2948 assert!(
2949 !without.argv.iter().any(|a| a == "--add-dir"),
2950 "no attachment, no reason to widen the sandbox: {without:?}"
2951 );
2952
2953 let with = build_command(
2954 &s,
2955 &seat,
2956 &Invocation {
2957 attachments: &atts,
2958 writable: &[],
2959 ..inv(Path::new("."), Path::new("/art"), true)
2960 },
2961 Path::new("/art/p.md"),
2962 )
2963 .unwrap();
2964 assert!(
2965 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2966 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2967 );
2968 }
2969
2970 #[test]
2976 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2977 let seat = SeatState::new("plan", "a", 7);
2978 let atts = [
2979 PathBuf::from("/art/attachments/own.png"),
2980 PathBuf::from("/other-chat/attachments/inherited.png"),
2981 ];
2982
2983 let p = build_command(
2984 &spec(AgentKind::Antigravity, None),
2985 &seat,
2986 &Invocation {
2987 attachments: &atts,
2988 writable: &[],
2989 ..inv(Path::new("."), Path::new("/art"), true)
2990 },
2991 Path::new("/art/p.md"),
2992 )
2993 .unwrap();
2994
2995 assert!(
2996 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2997 "this conversation's own artifacts dir must still be granted: {p:?}"
2998 );
2999 assert!(
3000 p.argv
3001 .windows(2)
3002 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
3003 "the inherited attachment's own directory must be granted too: {p:?}"
3004 );
3005 }
3006
3007 #[test]
3008 fn command_agents_get_placeholders_substituted() {
3009 let seat = SeatState::new("impl-A", "a", 7);
3010 let p = plan_for(AgentKind::Command, &seat, true);
3011 assert_eq!(p.argv[0], "echo");
3012 assert_eq!(p.argv[1], "impl-A");
3013 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
3014 }
3015
3016 const EXPIRED_AUTH: &str = r#"{"is_error":true,"terminal_reason":"api_error","result":"Failed to authenticate: OAuth session expired and could not be refreshed"}"#;
3017 const CODEX_LIMIT: &str = r#"{"type":"error","message":"You've hit your usage limit. Visit https://chatgpt.com/settings/usage to purchase more credits or try again at Oct 27th, 2026 6:18 PM."}
3018{"type":"turn.failed","error":{"message":"You've hit your usage limit."}}"#;
3019
3020 #[test]
3021 fn observed_machine_failures_are_detected_conservatively() {
3022 let auth = extract(AgentKind::Claude, EXPIRED_AUTH).quota.unwrap();
3023 assert_eq!(auth.cause, QuotaCause::Authentication);
3024 assert_eq!(auth.reset, None);
3025 let limit = extract(AgentKind::Codex, CODEX_LIMIT).quota.unwrap();
3026 assert_eq!(limit.cause, QuotaCause::UsageLimit);
3027 assert_eq!(limit.reset.as_deref(), Some("Oct 27th, 2026 6:18 PM"));
3028 assert!(
3030 extract(AgentKind::Codex, &format!("{CODEX_LIMIT}\n{CODEX_LIMIT}"))
3031 .quota
3032 .is_some()
3033 );
3034 let without_reset = CODEX_LIMIT.replace(" or try again at Oct 27th, 2026 6:18 PM", "");
3035 assert!(
3036 extract(AgentKind::Codex, &without_reset)
3037 .quota
3038 .unwrap()
3039 .reset
3040 .is_none()
3041 );
3042 for text in [
3043 EXPIRED_AUTH.replace("true", "false"),
3044 EXPIRED_AUTH.replace("api_error", "unknown"),
3045 EXPIRED_AUTH.replace(
3046 "OAuth session expired and could not be refreshed",
3047 "unknown error",
3048 ),
3049 ] {
3050 assert!(extract(AgentKind::Claude, &text).quota.is_none());
3051 }
3052 assert!(
3053 extract(AgentKind::Codex, CODEX_LIMIT.lines().next().unwrap())
3054 .quota
3055 .is_none()
3056 );
3057 assert!(
3058 extract(
3059 AgentKind::Codex,
3060 &CODEX_LIMIT.replace("You've hit your usage limit.", "Something failed.")
3061 )
3062 .quota
3063 .is_none()
3064 );
3065 let quoted = serde_json::json!({"type":"item.completed","item":{"type":"agent_message","text":CODEX_LIMIT}}).to_string();
3066 assert!(extract(AgentKind::Codex, "ed).quota.is_none());
3067 assert!(extract(AgentKind::Codex, EXPIRED_AUTH).quota.is_none());
3068 assert!(
3069 extract(AgentKind::Antigravity, EXPIRED_AUTH)
3070 .quota
3071 .is_none()
3072 );
3073 let legacy: Quota = serde_json::from_str(r#"{"reset":null}"#).unwrap();
3074 assert_eq!(legacy.cause, QuotaCause::UsageLimit);
3075 }
3076
3077 #[test]
3078 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
3079 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
3081 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
3082 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
3083 let out = extract(AgentKind::Claude, stdout);
3084 let quota = out.quota.as_ref().expect("rate limit must be detected");
3085 assert_eq!(
3086 quota.reset.as_deref(),
3087 Some("4:50am (Asia/Tokyo)"),
3088 "reset time read from the body"
3089 );
3090 }
3091
3092 #[test]
3093 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
3094 let out = extract(
3095 AgentKind::Claude,
3096 r#"{"is_error":true,"result":"session limit reached"}"#,
3097 );
3098 let quota = out.quota.expect("rate limit detected without a reset");
3099 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
3100 }
3101
3102 #[test]
3103 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
3104 for kind in [AgentKind::Claude, AgentKind::Command] {
3105 for reply in [
3106 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3107 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3108 ] {
3109 assert!(extract(kind, reply).quota.is_some(), "{reply}");
3110 }
3111 for reply in [
3112 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3113 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
3114 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
3115 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3116 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
3117 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
3118 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
3119 ] {
3120 assert!(extract(kind, reply).quota.is_none(), "{reply}");
3121 }
3122 }
3123 }
3124
3125 #[test]
3126 fn ordinary_failures_are_never_quota() {
3127 let claude_fail = extract(
3129 AgentKind::Claude,
3130 r#"{"is_error":true,"result":"account does not exist"}"#,
3131 );
3132 assert!(claude_fail.quota.is_none());
3133
3134 let cmd_fail = extract(AgentKind::Command, "boom");
3136 assert!(cmd_fail.quota.is_none());
3137
3138 let success = extract(
3140 AgentKind::Command,
3141 r#"{"is_error":false,"result":"session limit is fine"}"#,
3142 );
3143 assert!(success.quota.is_none());
3144 }
3145
3146 #[test]
3154 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
3155 let stream = concat!(
3156 r#"{"type":"thread.started","thread_id":"t1"}"#,
3157 "\n",
3158 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"}}"#,
3159 "\n",
3160 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"}}"#,
3161 "\n",
3162 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
3163 "\n",
3164 r#"{"type":"turn.completed"}"#,
3165 "\n",
3166 );
3167 let out = extract(AgentKind::Codex, stream);
3168 assert_eq!(out.text, "Both tests in the target pass.");
3169 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
3170
3171 let paired = &out.commands[0];
3172 assert_eq!(paired.id, "item49");
3173 assert_eq!(paired.exit_code, Some(1));
3174 assert!(paired.description.contains("graph_cached_gate"));
3175 assert!(paired.result_summary.contains("1 failed"));
3176
3177 let solo = &out.commands[1];
3178 assert_eq!(solo.exit_code, Some(0));
3179
3180 assert!(
3184 out.commands
3185 .iter()
3186 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
3187 "a failed run of the actual target must still be visible: {:?}",
3188 out.commands
3189 );
3190 }
3191
3192 #[test]
3193 fn command_agent_can_carry_the_claude_quota_shape() {
3194 let out = extract(
3195 AgentKind::Command,
3196 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
3197 );
3198 assert!(
3199 out.quota.is_some(),
3200 "a wrapper emitting the claude shape counts as quota"
3201 );
3202 }
3203
3204 #[test]
3205 fn claude_json_result_is_extracted() {
3206 let out = extract(
3207 AgentKind::Claude,
3208 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3209 );
3210 assert_eq!(out.text, "all done");
3211 assert_eq!(out.session.as_deref(), Some("abc"));
3212 assert_eq!(out.status.as_deref(), Some("success"));
3213 }
3214
3215 #[test]
3230 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3231 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"}"#;
3232 let out = extract(AgentKind::Claude, stdout);
3233 assert_eq!(out.status.as_deref(), Some("success"));
3234 assert!(out.quota.is_none());
3235 assert!(!out.text.trim().is_empty());
3236
3237 let agent_out = AgentOutput {
3238 text: out.text.clone(),
3239 exit_code: Some(0),
3240 timed_out: false,
3241 duration_ms: 500,
3242 artifacts: Vec::new(),
3243 quota: out.quota,
3244 dropped: out.dropped,
3245 commands: out.commands,
3246 context_tokens: None,
3247 };
3248 assert!(
3249 agent_out.usable(),
3250 "the CLI turn itself ended cleanly and must read as usable"
3251 );
3252 assert!(
3253 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3254 "a clean CLI turn is not proof the node's own report ever arrived"
3255 );
3256 }
3257
3258 #[test]
3259 fn opencode_event_stream_is_concatenated() {
3260 let stream = concat!(
3261 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3262 "\n",
3263 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3264 "\n",
3265 "garbage line\n",
3266 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3267 "\n"
3268 );
3269 let out = extract(AgentKind::Opencode, stream);
3270 assert_eq!(out.text, "first\nsecond");
3271 assert_eq!(out.session.as_deref(), Some("ses_1"));
3272 }
3273
3274 #[test]
3275 fn agy_json_survives_a_leading_warning_line() {
3276 let stdout = concat!(
3277 "warning: --mode plan has no effect while slash commands are disabled.\n",
3278 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3279 "\n"
3280 );
3281 let out = extract(AgentKind::Antigravity, stdout);
3282 assert_eq!(out.text, "persimmon");
3283 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3284 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3285 }
3286
3287 const AGY_DROPPED: &str = concat!(
3294 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3295 r#""response":"","error":"the connection to the agent was interrupted before "#,
3296 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3297 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3298 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3299 r#""total_tokens":274380}}"#
3300 );
3301
3302 const AGY_QUOTA_OUT: &str = concat!(
3304 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3305 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3306 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3307 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3308 );
3309 const AGY_QUOTA_ERR: &str = concat!(
3310 "error: Individual quota reached. Resets in 1h2m49s.\n",
3311 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3312 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3313 r#""retryable":true}"#,
3314 "\n"
3315 );
3316
3317 #[test]
3318 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3319 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3320 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3321 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3322 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3323 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3325 assert!(stderr_only.reset.is_none());
3326 }
3327
3328 #[test]
3329 fn ordinary_agy_failures_are_not_a_quota() {
3330 assert!(agy_quota(AGY_DROPPED, "").is_none());
3331 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3332 assert!(
3333 agy_quota(
3334 "",
3335 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3336 )
3337 .is_none()
3338 );
3339 assert!(
3340 agy_quota(
3341 "",
3342 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3343 )
3344 .is_none()
3345 );
3346 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3347 }
3348
3349 #[test]
3350 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3351 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3352 let dropped = out.dropped.expect("recognised as undelivered work");
3353 assert_eq!(dropped.output_tokens, 14267);
3354 assert!(
3355 dropped.why.contains("subscriber fell behind"),
3356 "the CLI's own words are kept for the record: {}",
3357 dropped.why
3358 );
3359 assert_eq!(
3362 out.session.as_deref(),
3363 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3364 );
3365 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3366 }
3367
3368 #[test]
3369 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3370 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3374 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3375
3376 let answered = concat!(
3379 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3380 r#""usage":{"output_tokens":10}}"#
3381 );
3382 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3383
3384 let ok = concat!(
3386 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3387 r#""usage":{"output_tokens":10}}"#
3388 );
3389 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3390 }
3391
3392 #[test]
3393 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3394 let out = AgentOutput {
3395 text: String::new(),
3396 exit_code: Some(1),
3397 timed_out: false,
3398 duration_ms: 431_194,
3399 artifacts: Vec::new(),
3400 quota: None,
3401 dropped: Some(Dropped {
3402 why: "subscriber fell behind updates".to_owned(),
3403 output_tokens: 14267,
3404 }),
3405 commands: Vec::new(),
3406 context_tokens: None,
3407 };
3408 assert!(!out.usable());
3409 assert!(out.work_undelivered());
3410 assert!(!out.quota_exhausted());
3413 }
3414
3415 #[test]
3416 fn non_json_stdout_falls_back_to_raw_text() {
3417 let out = extract(AgentKind::Antigravity, "plain answer\n");
3418 assert_eq!(out.text, "plain answer");
3419 assert!(out.session.is_none());
3420 }
3421
3422 #[tokio::test]
3423 async fn command_agent_round_trip_writes_artifacts() {
3424 let dir = tempfile::tempdir().unwrap();
3425 let art = dir.path().join("artifacts");
3426 let mut seat = SeatState::new("impl-A", "a", 7);
3427 let s = command_helper("reply");
3428 let out = invoke(
3429 &s,
3430 &mut seat,
3431 &Invocation {
3432 coordination: None,
3433 fetched_base: None,
3434 cwd: dir.path(),
3435 prompt: "unused",
3436 timeout: Duration::from_secs(30),
3437 allow_write: true,
3438 sessions: true,
3439 artifacts: &art,
3440 stem: "impl-A",
3441 run: "test-run",
3442 node: "test",
3443 cache_dir: None,
3444 attachments: &[],
3445 writable: &[],
3446 },
3447 )
3448 .await
3449 .unwrap();
3450 assert!(out.usable(), "{out:?}");
3451 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3452 assert_eq!(seat.turns, 1);
3453 assert!(art.join("impl-A.prompt.md").is_file());
3454 assert!(art.join("impl-A.out").is_file());
3455 }
3456
3457 #[tokio::test]
3458 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3459 crate::run::pin_test_home();
3460 let dir = tempfile::tempdir().unwrap();
3464 let cache = dir.path().join("magi-cache");
3465 let mut seat = SeatState::new("impl-A", "a", 7);
3466 let s = command_helper("cache");
3467 let out = invoke(
3468 &s,
3469 &mut seat,
3470 &Invocation {
3471 coordination: None,
3472 fetched_base: None,
3473 cwd: dir.path(),
3474 prompt: "unused",
3475 timeout: Duration::from_secs(30),
3476 allow_write: true,
3477 sessions: true,
3478 artifacts: &dir.path().join("artifacts"),
3479 stem: "cache",
3480 run: "test-run",
3481 node: "test",
3482 cache_dir: Some(&cache),
3483 attachments: &[],
3484 writable: &[],
3485 },
3486 )
3487 .await
3488 .unwrap();
3489 assert!(out.usable(), "{out:?}");
3490 assert!(
3491 out.text.contains(cache.to_string_lossy().as_ref()),
3492 "the seat must see CARGO_TARGET_DIR = the shared cache"
3493 );
3494 }
3495
3496 #[tokio::test]
3497 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3498 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3506 unsafe {
3511 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3512 }
3513 let dir = tempfile::tempdir().unwrap();
3514 let mut seat = SeatState::new("review-1", "a", 7);
3515 let s = command_helper("no-cache");
3516 let result = invoke(
3517 &s,
3518 &mut seat,
3519 &Invocation {
3520 coordination: None,
3521 fetched_base: None,
3522 cwd: dir.path(),
3523 prompt: "unused",
3524 timeout: Duration::from_secs(30),
3525 allow_write: false,
3526 sessions: true,
3527 artifacts: &dir.path().join("artifacts"),
3528 stem: "no-cache",
3529 run: "test-run",
3530 node: "test",
3531 cache_dir: None,
3532 attachments: &[],
3533 writable: &[],
3534 },
3535 )
3536 .await;
3537 unsafe {
3542 match &previous {
3543 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3544 None => std::env::remove_var("CARGO_TARGET_DIR"),
3545 }
3546 }
3547 let out = result.unwrap();
3548 assert!(out.usable(), "{out:?}");
3549 assert!(
3550 out.text.contains("ABSENT"),
3551 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3552 out.text
3553 );
3554 }
3555
3556 #[tokio::test]
3557 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3558 let dir = tempfile::tempdir().unwrap();
3559 let mut seat = SeatState::new("impl-A", "a", 7);
3560 let s = command_helper("ignore-stdin");
3563 let big = "x".repeat(1_000_000);
3564 let out = invoke(
3565 &s,
3566 &mut seat,
3567 &Invocation {
3568 coordination: None,
3569 fetched_base: None,
3570 cwd: dir.path(),
3571 prompt: &big,
3572 timeout: Duration::from_secs(60),
3573 allow_write: true,
3574 sessions: true,
3575 artifacts: &dir.path().join("artifacts"),
3576 stem: "big",
3577 run: "test-run",
3578 node: "test",
3579 cache_dir: None,
3580 attachments: &[],
3581 writable: &[],
3582 },
3583 )
3584 .await
3585 .unwrap();
3586 assert!(out.usable(), "{out:?}");
3587 assert!(out.text.contains("done"), "{}", out.text);
3588 }
3589
3590 #[tokio::test]
3591 async fn agent_turn_cap_limits_a_direct_invocation() {
3592 let dir = tempfile::tempdir().unwrap();
3593 let artifacts = dir.path().join("artifacts");
3594 let mut seat = SeatState::new("impl-A", "helper", 7);
3595 let mut s = command_helper("sleep");
3596 s.max_turn_secs = Some(2);
3597 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3598 .await
3599 .unwrap();
3600 assert!(out.timed_out, "{out:?}");
3601 assert!(out.duration_ms < 20_000, "{out:?}");
3604 }
3605
3606 #[tokio::test]
3607 async fn timeout_is_reported_not_hung() {
3608 let dir = tempfile::tempdir().unwrap();
3609 let mut seat = SeatState::new("impl-A", "a", 7);
3610 let s = command_helper("sleep");
3611 let out = invoke(
3612 &s,
3613 &mut seat,
3614 &Invocation {
3615 coordination: None,
3616 fetched_base: None,
3617 cwd: dir.path(),
3618 prompt: "unused",
3619 timeout: Duration::from_millis(300),
3620 allow_write: true,
3621 sessions: true,
3622 artifacts: &dir.path().join("artifacts"),
3623 stem: "slow",
3624 run: "test-run",
3625 node: "test",
3626 cache_dir: None,
3627 attachments: &[],
3628 writable: &[],
3629 },
3630 )
3631 .await
3632 .unwrap();
3633 assert!(out.timed_out);
3634 assert!(!out.usable());
3635 }
3636
3637 #[tokio::test]
3638 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3639 let dir = tempfile::tempdir().unwrap();
3645 let artifacts = dir.path().join("artifacts");
3646 let mut seat = SeatState::new("impl-A", "a", 7);
3647 let s = command_helper("chatty-sleep");
3648 let out = invoke(
3649 &s,
3650 &mut seat,
3651 &Invocation {
3652 coordination: None,
3653 fetched_base: None,
3654 cwd: dir.path(),
3655 prompt: "unused",
3656 timeout: Duration::from_secs(10),
3661 allow_write: true,
3662 sessions: true,
3663 artifacts: &artifacts,
3664 stem: "chatty",
3665 run: "test-run",
3666 node: "test",
3667 cache_dir: None,
3668 attachments: &[],
3669 writable: &[],
3670 },
3671 )
3672 .await
3673 .unwrap();
3674
3675 assert!(out.timed_out, "{out:?}");
3676 assert!(!out.usable(), "a cut-off answer is still not an answer");
3677 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3678 assert!(
3679 recorded.contains("i-said-something"),
3680 "the artifact must keep what arrived before the kill, got {recorded:?}"
3681 );
3682 assert!(
3683 out.text.contains("i-said-something"),
3684 "and the graph must be able to see it too, got {:?}",
3685 out.text
3686 );
3687 }
3688
3689 #[test]
3690 fn missing_programs_reports_command_binaries() {
3691 let mut s = spec(AgentKind::Command, None);
3692 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3693 assert_eq!(
3694 missing_programs(&[s]),
3695 ["definitely-not-a-real-binary-xyz".to_owned()]
3696 );
3697 }
3698
3699 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3700 AgentSpec {
3701 id: id.to_owned(),
3702 kind,
3703 model: None,
3704 command: Vec::new(),
3705 extra_args: Vec::new(),
3706 env: BTreeMap::new(),
3707 prompt_delivery: None,
3708 max_turn_secs: None,
3709 }
3710 }
3711
3712 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3715 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3716 }
3717
3718 #[test]
3719 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3720 let agents = [
3721 pick_spec("oc", AgentKind::Opencode),
3722 pick_spec("opus", AgentKind::Claude),
3723 pick_spec("agy", AgentKind::Antigravity),
3724 ];
3725 let got = pick(&agents, None, &without(&[])).expect("a pick");
3726 assert_eq!(got.id, "opus");
3727 }
3728
3729 #[test]
3730 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3731 let agents = [
3732 pick_spec("opus", AgentKind::Claude),
3733 pick_spec("oc", AgentKind::Opencode),
3734 pick_spec("agy", AgentKind::Antigravity),
3735 ];
3736 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3737 assert_eq!(got.id, "agy");
3738 }
3739
3740 #[test]
3741 fn pick_on_an_empty_roster_says_what_to_install() {
3742 let msg = pick(&[], None, &without(&[]))
3743 .expect_err("nobody to ask")
3744 .to_string();
3745 assert!(msg.contains("roster is empty"), "{msg}");
3746 assert!(msg.contains("claude"), "{msg}");
3747 assert!(msg.contains("magi.toml"), "{msg}");
3748 }
3749
3750 #[test]
3751 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3752 let agents = [
3753 pick_spec("opus", AgentKind::Claude),
3754 pick_spec("oc", AgentKind::Opencode),
3755 ];
3756 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3757 let msg = format!("{err:#}");
3758 assert!(msg.contains("claude"), "{msg}");
3759 assert!(msg.contains("opencode"), "{msg}");
3760 }
3761
3762 #[test]
3763 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3764 let agents = [
3765 pick_spec("opus", AgentKind::Claude),
3766 pick_spec("oc", AgentKind::Opencode),
3767 ];
3768 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3769 assert_eq!(got.id, "oc");
3770 }
3771
3772 #[test]
3773 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3774 let agents = [
3775 pick_spec("opus", AgentKind::Claude),
3776 pick_spec("oc", AgentKind::Opencode),
3777 ];
3778 let msg = pick(&agents, Some("gemini"), &without(&[]))
3779 .expect_err("no such agent")
3780 .to_string();
3781 assert!(msg.contains("gemini"), "{msg}");
3782 assert!(msg.contains("opus, oc"), "{msg}");
3783 }
3784
3785 #[test]
3786 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3787 let agents = [
3788 pick_spec("opus", AgentKind::Claude),
3789 pick_spec("oc", AgentKind::Opencode),
3790 ];
3791 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3792 .expect_err("must not silently substitute another model")
3793 .to_string();
3794 assert!(msg.contains("opencode"), "{msg}");
3795 assert!(msg.contains("--agent"), "{msg}");
3796 }
3797
3798 fn named(id: &str) -> AgentSpec {
3799 AgentSpec {
3800 id: id.to_owned(),
3801 ..spec(AgentKind::Command, None)
3802 }
3803 }
3804
3805 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3806 AgentOutput {
3807 text: text.to_owned(),
3808 exit_code: Some(exit),
3809 timed_out: false,
3810 duration_ms: 0,
3811 artifacts: Vec::new(),
3812 quota: quota.then_some(Quota {
3813 reset: None,
3814 cause: QuotaCause::UsageLimit,
3815 agent: String::new(),
3816 }),
3817 dropped: None,
3818 commands: Vec::new(),
3819 context_tokens: None,
3820 }
3821 }
3822
3823 #[test]
3824 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3825 let agents = [named("a"), named("b"), named("c")];
3826 let all = |_: &AgentSpec| true;
3827 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3828 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3829 assert_eq!(
3830 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3831 ["c", "a"]
3832 );
3833
3834 let one = AgentChoice::from("b");
3835 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3836 assert_eq!(got.len(), 1);
3837 assert_eq!(got[0].id, "b");
3838
3839 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3840 assert_eq!(got.len(), 1, "unset keeps pick's default");
3841 assert_eq!(got[0].id, "a");
3842 let empty = AgentChoice::Chain(Vec::new());
3843 assert_eq!(
3844 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3845 "a"
3846 );
3847 }
3848
3849 #[test]
3850 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3851 let agents = [named("a"), named("b")];
3852 let not_a = |s: &AgentSpec| s.id != "a";
3853 let chain = AgentChoice::Chain(
3854 ["a", "ghost", "b", "b"]
3855 .iter()
3856 .map(|s| (*s).to_owned())
3857 .collect(),
3858 );
3859 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3860 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3861
3862 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3863 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3864 assert_eq!(
3865 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3866 ["b", "a"]
3867 );
3868 }
3869
3870 #[test]
3871 fn a_chain_with_nothing_runnable_names_the_role() {
3872 let agents = [named("a")];
3873 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3874 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3875 .unwrap_err()
3876 .to_string();
3877 assert!(err.contains("conductor"), "{err}");
3878 }
3879
3880 #[test]
3881 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3882 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3883 assert!(chain_advances(&Ok(output("limit", 0, true))));
3884 assert!(chain_advances(&Ok(output("", 0, false))));
3885 assert!(chain_advances(&Ok(output("x", 1, false))));
3886 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3887 }
3888
3889 #[test]
3890 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3891 for out in [
3892 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3893 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3894 r#"{"result":"ok"}"#,
3895 "not json",
3896 ] {
3897 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3898 }
3899 }
3900
3901 #[test]
3902 fn opencode_context_tokens_take_the_last_step_finish() {
3903 let out = concat!(
3904 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3905 "\n",
3906 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3907 "\n",
3908 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3909 "\n"
3910 );
3911 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3912 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3913 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3914 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3915 }
3916
3917 #[test]
3918 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3919 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3920 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3921 }
3922
3923 #[test]
3924 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3925 let out = concat!(
3926 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3927 "\n"
3928 );
3929 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3930 }
3931
3932 #[test]
3933 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3934 let out = concat!(
3935 r#"{"type":"session","id":"s"}"#,
3936 "\n",
3937 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3938 "\n",
3939 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3940 "\n"
3941 );
3942 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3943 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3944 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3945 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3946 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3947 }
3948
3949 #[test]
3950 fn command_agents_report_no_context_tokens() {
3951 let out = r#"{"usage":{"input_tokens":5}}"#;
3952 assert_eq!(context_tokens(AgentKind::Command, out), None);
3953 }
3954}