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 cwd: &'a Path,
101 pub prompt: &'a str,
103 pub timeout: Duration,
105 pub allow_write: bool,
107 pub unsandboxed: bool,
113 pub sessions: bool,
115 pub artifacts: &'a Path,
117 pub stem: &'a str,
119 pub run: &'a str,
123 pub node: &'a str,
127 pub cache_dir: Option<&'a Path>,
133 pub attachments: &'a [PathBuf],
140 pub writable: &'a [PathBuf],
147}
148
149fn repository_writer(inv: &Invocation<'_>) -> bool {
150 inv.allow_write
151 && (matches!(
152 inv.node,
153 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
154 ) || (inv.node == crate::queue::CHAT_NODE && !consulted_chat(inv)))
155}
156
157fn consulted_chat(inv: &Invocation<'_>) -> bool {
158 inv.node == crate::queue::CHAT_NODE && inv.allow_write && !inv.writable.is_empty()
161}
162
163fn repository_seat(inv: &Invocation<'_>) -> bool {
164 !matches!(
170 inv.node,
171 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
172 )
173}
174
175pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
182 let mut roots = Vec::new();
183 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
184 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
185 if !common.starts_with(&cwd) {
186 roots.push(common);
187 }
188 }
189 for name in ["questions", "notifications"] {
193 let dir = home.join(name);
194 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
195 roots.push(std::fs::canonicalize(dir)?);
196 }
197 Ok(roots)
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct Quota {
208 #[serde(default)]
210 pub reset: Option<String>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct Dropped {
221 pub why: String,
223 pub output_tokens: u64,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct CommandEvidence {
243 pub id: String,
245 pub description: String,
247 pub exit_code: Option<i32>,
249 pub result_summary: String,
251 pub source: String,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct AgentOutput {
258 pub text: String,
260 pub exit_code: Option<i32>,
262 pub timed_out: bool,
264 pub duration_ms: u64,
266 pub artifacts: Vec<String>,
268 #[serde(default)]
272 pub quota: Option<Quota>,
273 #[serde(default)]
277 pub dropped: Option<Dropped>,
278 #[serde(default)]
282 pub commands: Vec<CommandEvidence>,
283 #[serde(default)]
288 pub context_tokens: Option<u64>,
289}
290
291impl AgentOutput {
292 pub fn usable(&self) -> bool {
294 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
295 }
296
297 pub fn quota_exhausted(&self) -> bool {
299 self.quota.is_some()
300 }
301
302 pub fn work_undelivered(&self) -> bool {
307 self.dropped.is_some()
308 }
309}
310
311const PIPE_GRACE: Duration = Duration::from_secs(3);
316
317type Captured = Arc<Mutex<Vec<u8>>>;
319
320fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
330where
331 R: tokio::io::AsyncRead + Unpin + Send + 'static,
332{
333 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
334 let Some(mut pipe) = pipe else {
335 return (buf, None);
336 };
337 let sink = Arc::clone(&buf);
338 let handle = tokio::spawn(async move {
339 let mut chunk = [0u8; 8192];
340 loop {
341 match pipe.read(&mut chunk).await {
342 Ok(0) | Err(_) => break,
343 Ok(n) => {
344 if let Ok(mut guard) = sink.lock() {
345 guard.extend_from_slice(&chunk[..n]);
346 }
347 }
348 }
349 }
350 });
351 (buf, Some(handle))
352}
353
354async fn collect(
359 buf: &Captured,
360 handle: Option<tokio::task::JoinHandle<()>>,
361 grace: Duration,
362) -> String {
363 if let Some(handle) = handle {
364 if tokio::time::timeout(grace, handle).await.is_err() {
365 tracing::debug!("a pipe is still held open after the child exited");
366 }
367 }
368 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
369 String::from_utf8_lossy(&bytes).into_owned()
370}
371
372pub async fn invoke(
374 spec: &AgentSpec,
375 seat: &mut SeatState,
376 inv: &Invocation<'_>,
377) -> Result<AgentOutput> {
378 let lease = if repository_seat(inv) {
382 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
383 .await?
384 } else {
385 None
386 };
387 let prompt = if consulted_chat(inv) {
388 Some(format!(
389 "{}\n\nCurrent turn scope: answer the supplied consultation or authorized hold using the conversation and supplied question context and supported magi control-plane commands only. This overrides repository investigation permissions in the standing briefing or earlier turns. No repository lease is held: do not read repository files or history, fetch, run tests, or edit repository files. If repository investigation is needed, report that it must wait for a normal chat turn after the consultation is resolved.",
390 inv.prompt
391 ))
392 } else {
393 lease.as_ref().filter(|lease| lease.delegated).map(|lease| format!(
394 "Repository coordination: the launcher holds the exclusive repository lock at {} on behalf of this read-only seat for this entire invocation. Do not acquire a second lock or release/delete that file. Your filesystem access remains read-only.\n\n{}",
395 lease.path.display(), inv.prompt
396 ))
397 };
398 let home = repository_writer(inv).then(crate::run::home);
401 let mut writable = inv.writable.to_vec();
402 if let Some(home) = &home {
403 for root in worktree_writable_roots(inv.cwd, home).await? {
404 if !writable.contains(&root) {
405 writable.push(root);
406 }
407 }
408 }
409 let inv = &Invocation {
410 writable: &writable,
411 prompt: prompt.as_deref().unwrap_or(inv.prompt),
412 ..inv.clone()
413 };
414 tokio::fs::create_dir_all(inv.artifacts)
415 .await
416 .with_context(|| format!("create {}", inv.artifacts.display()))?;
417 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
418 tokio::fs::write(&prompt_path, inv.prompt)
419 .await
420 .with_context(|| format!("write {}", prompt_path.display()))?;
421
422 let plan = build_command(spec, seat, inv, &prompt_path)?;
423 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
424
425 let started = Instant::now();
426 let child_path = spec
429 .env
430 .iter()
431 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
432 .map(|(_, v)| std::ffi::OsString::from(v))
433 .or_else(|| std::env::var_os("PATH"))
434 .unwrap_or_default();
435 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
436 || plan.argv[0].clone().into(),
437 std::path::PathBuf::into_os_string,
438 );
439 let mut cmd = Command::new(program);
440 cmd.args(&plan.argv[1..])
441 .current_dir(inv.cwd)
442 .envs(&spec.env)
443 .env("MAGI_SEAT", &seat.key)
444 .env("MAGI_TURN", seat.turns.to_string())
445 .env("MAGI_RUN", inv.run)
446 .env("MAGI_NODE", inv.node)
447 .env("MAGI_PROMPT_FILE", &prompt_path)
448 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
449 .env("GIT_TERMINAL_PROMPT", "0")
450 .stdin(if plan.stdin.is_some() {
451 Stdio::piped()
452 } else {
453 Stdio::null()
454 })
455 .stdout(Stdio::piped())
456 .stderr(Stdio::piped())
457 .kill_on_drop(true)
458 .quiet();
461 if let Some(home) = home {
462 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
465 }
466 if let Some(cache) = inv.cache_dir {
467 cmd.env("CARGO_TARGET_DIR", cache);
470 } else {
471 cmd.env_remove("CARGO_TARGET_DIR");
479 }
480
481 #[cfg(unix)]
482 if lease.is_some() {
483 cmd.process_group(0);
484 }
485 let child = cmd
486 .spawn()
487 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
488 let mut running = crate::seat_process::Running::new(child, lease);
490 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
494 tokio::spawn(async move {
495 sink.write_all(body.as_bytes()).await.ok();
496 sink.shutdown().await.ok();
497 });
498 }
499
500 let (out_buf, out_reader) = drain(running.child().stdout.take());
517 let (err_buf, err_reader) = drain(running.child().stderr.take());
518
519 let (code, timed_out) = match tokio::time::timeout(inv.timeout, running.child().wait()).await {
520 Ok(res) => {
521 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
522 (status.code(), false)
523 }
524 Err(_) => {
525 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
526 if !running.coordinated() {
528 running
529 .child()
530 .kill()
531 .await
532 .context("stop timed-out seat")?;
533 }
534 (None, true)
535 }
536 };
537 running.finish().await;
538
539 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
544 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
545
546 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
547 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
548 tokio::fs::write(&out_path, &stdout).await.ok();
549 tokio::fs::write(&err_path, &stderr).await.ok();
550
551 let mut extracted = extract(spec.kind, &stdout);
552 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
553 extracted.quota = agy_quota(&stdout, &stderr);
554 }
555 if let Some(quota) = &extracted.quota {
556 extracted.dropped = None;
559 tracing::warn!(
560 seat = %seat.key,
561 agent = %spec.id,
562 reset = ?quota.reset,
563 "agent is out of quota"
564 );
565 }
566 if let Some(session) = extracted.session {
567 match spec.kind {
568 AgentKind::Claude => seat.claude_session = Some(session),
569 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
570 seat.captured_session = Some(session);
571 }
572 AgentKind::Command => {}
573 }
574 }
575 if let Some(status) = &extracted.status
576 && !status.eq_ignore_ascii_case("success")
577 {
578 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
579 }
580 let text = if extracted.text.trim().is_empty() {
581 if stdout.trim().is_empty() {
583 stderr.trim().to_owned()
584 } else {
585 stdout.trim().to_owned()
586 }
587 } else {
588 extracted.text
589 };
590 seat.turns += 1;
591
592 Ok(AgentOutput {
593 text,
594 exit_code: code,
595 timed_out,
596 duration_ms: started.elapsed().as_millis() as u64,
597 artifacts: vec![
598 file_name(&prompt_path),
599 file_name(&out_path),
600 file_name(&err_path),
601 ],
602 quota: extracted.quota,
603 dropped: extracted.dropped,
604 commands: extracted.commands,
605 context_tokens: extracted.context_tokens,
606 })
607}
608
609fn file_name(p: &Path) -> String {
610 p.file_name()
611 .unwrap_or_default()
612 .to_string_lossy()
613 .into_owned()
614}
615
616#[derive(Debug)]
618struct Plan {
619 argv: Vec<String>,
620 stdin: Option<String>,
621}
622
623fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
634 if matches!(kind, AgentKind::Antigravity) {
635 return format!("@{}", prompt_path.display());
636 }
637 format!(
638 "Read the file at {} and follow every instruction in it exactly. That \
639 file is your complete task description; this message contains nothing \
640 else.",
641 prompt_path.display()
642 )
643}
644
645fn build_command(
646 spec: &AgentSpec,
647 seat: &SeatState,
648 inv: &Invocation<'_>,
649 prompt_path: &Path,
650) -> Result<Plan> {
651 let mut argv: Vec<String> = Vec::new();
652 let mut stdin: Option<String> = None;
653 let delivery = spec.delivery();
654 let resuming = has_session(spec.kind, seat, inv.sessions);
655
656 match spec.kind {
657 AgentKind::Claude => {
658 argv.push("claude".to_owned());
663 argv.push("-p".to_owned());
664 argv.push("--output-format".to_owned());
665 argv.push("json".to_owned());
666 if let Some(m) = &spec.model {
667 argv.push("--model".to_owned());
668 argv.push(m.clone());
669 }
670 if inv.sessions {
671 let uuid = seat
672 .claude_session
673 .as_deref()
674 .context("claude seat is missing its session uuid")?;
675 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
676 argv.push(uuid.to_owned());
677 }
678 argv.push("--permission-mode".to_owned());
679 argv.push("bypassPermissions".to_owned());
680 if !inv.allow_write {
681 argv.push("--disallowed-tools".to_owned());
682 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
683 }
684 }
685 AgentKind::Opencode => {
686 argv.push("opencode".to_owned());
690 argv.push("run".to_owned());
691 argv.push("--format".to_owned());
692 argv.push("json".to_owned());
693 argv.push("--dir".to_owned());
694 argv.push(inv.cwd.to_string_lossy().into_owned());
695 argv.push("--auto".to_owned());
704 if let Some(m) = &spec.model {
705 argv.push("-m".to_owned());
706 argv.push(m.clone());
707 }
708 if resuming {
709 argv.push("-s".to_owned());
710 argv.push(
711 seat.captured_session
712 .clone()
713 .expect("has_session checked the id is present"),
714 );
715 }
716 }
717 AgentKind::Antigravity => {
718 argv.push("agy".to_owned());
719 argv.push("--output-format".to_owned());
720 argv.push("json".to_owned());
721 argv.push("--print-timeout".to_owned());
724 argv.push(format!("{}s", inv.timeout.as_secs()));
725 argv.push("--mode".to_owned());
726 argv.push(
727 if inv.allow_write {
728 "accept-edits"
729 } else {
730 "plan"
731 }
732 .to_owned(),
733 );
734 if inv.allow_write {
735 argv.push("--dangerously-skip-permissions".to_owned());
736 }
737 if let Some(m) = &spec.model {
738 argv.push("--model".to_owned());
739 argv.push(m.clone());
740 }
741 if resuming {
742 argv.push("--conversation".to_owned());
743 argv.push(
744 seat.captured_session
745 .clone()
746 .expect("has_session checked the id is present"),
747 );
748 }
749 let mut add_dirs: Vec<String> = Vec::new();
759 if delivery == Delivery::File || !inv.attachments.is_empty() {
760 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
761 }
762 for path in inv.attachments {
763 let Some(parent) = path.parent() else {
764 continue;
765 };
766 if parent.starts_with(inv.artifacts) {
767 continue;
768 }
769 let dir = parent.to_string_lossy().into_owned();
770 if !add_dirs.contains(&dir) {
771 add_dirs.push(dir);
772 }
773 }
774 if inv.allow_write {
775 for path in inv.writable {
776 let dir = path.to_string_lossy().into_owned();
777 if !add_dirs.contains(&dir) {
778 add_dirs.push(dir);
779 }
780 }
781 }
782 for dir in add_dirs {
783 argv.push("--add-dir".to_owned());
784 argv.push(dir);
785 }
786 }
787 AgentKind::Codex => {
788 argv.push("codex".to_owned());
794 argv.push("exec".to_owned());
795 argv.push("--json".to_owned());
796 argv.push("--skip-git-repo-check".to_owned());
799 argv.push("-C".to_owned());
800 argv.push(inv.cwd.to_string_lossy().into_owned());
801 if inv.allow_write && inv.unsandboxed {
810 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
811 } else {
812 argv.push("--sandbox".to_owned());
813 argv.push(
814 if inv.allow_write {
815 "workspace-write"
816 } else {
817 "read-only"
818 }
819 .to_owned(),
820 );
821 argv.push("-c".to_owned());
824 argv.push("approval_policy=\"never\"".to_owned());
825 if inv.allow_write && !inv.writable.is_empty() {
826 let roots: Vec<String> = inv
827 .writable
828 .iter()
829 .map(|p| p.to_string_lossy().into_owned())
830 .collect();
831 argv.push("-c".to_owned());
832 argv.push(format!(
833 "sandbox_workspace_write.writable_roots={}",
834 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
835 ));
836 }
837 }
838 if let Some(m) = &spec.model {
839 argv.push("-m".to_owned());
840 argv.push(m.clone());
841 }
842 if resuming {
848 argv.push("resume".to_owned());
849 argv.push(
850 seat.captured_session
851 .clone()
852 .expect("has_session checked the id is present"),
853 );
854 }
855 }
856 AgentKind::Omp => {
857 argv.push("omp".to_owned());
861 argv.push("-p".to_owned());
862 argv.push("--mode=json".to_owned());
863 argv.push("--auto-approve".to_owned());
873 if let Some(m) = &spec.model {
874 argv.push("--model".to_owned());
875 argv.push(m.clone());
876 }
877 if resuming {
883 argv.push("--resume".to_owned());
884 argv.push(
885 seat.captured_session
886 .clone()
887 .expect("has_session checked the id is present"),
888 );
889 }
890 }
891 AgentKind::Command => {
892 if spec.command.is_empty() {
897 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
898 }
899 let vars: BTreeMap<&str, String> = BTreeMap::from([
900 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
901 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
902 ("{label}", seat.key.clone()),
903 ("{session}", seat.claude_session.clone().unwrap_or_default()),
904 ]);
905 for raw in &spec.command {
906 let mut arg = raw.clone();
907 for (k, v) in &vars {
908 if arg.contains(k) {
909 arg = arg.replace(k, v);
910 }
911 }
912 argv.push(arg);
913 }
914 }
915 }
916
917 argv.extend(spec.extra_args.iter().cloned());
918
919 if spec.kind == AgentKind::Antigravity {
922 argv.push("-p".to_owned());
923 }
924 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
927 argv.push("-".to_owned());
928 }
929 match delivery {
930 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
931 argv.push(pointer(spec.kind, prompt_path));
933 }
934 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
935 Delivery::Argv => argv.push(inv.prompt.to_owned()),
936 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
937 }
938
939 Ok(Plan { argv, stdin })
940}
941
942#[derive(Debug, Default)]
944struct Extracted {
945 text: String,
946 session: Option<String>,
947 status: Option<String>,
948 quota: Option<Quota>,
949 dropped: Option<Dropped>,
950 commands: Vec<CommandEvidence>,
951 context_tokens: Option<u64>,
952}
953
954fn extract(kind: AgentKind, stdout: &str) -> Extracted {
956 let mut extracted = extract_answer(kind, stdout);
957 extracted.context_tokens = context_tokens(kind, stdout);
958 extracted
959}
960
961fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
963 v.get(key).and_then(serde_json::Value::as_u64)
964}
965
966fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
971 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
972 let base = first(input)?;
973 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
974 Some(base.saturating_add(cached))
975}
976
977fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
998 use serde_json::Value;
999 let lines = || {
1000 stdout
1001 .lines()
1002 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1003 };
1004 match kind {
1005 AgentKind::Claude => None,
1006 AgentKind::Opencode => lines()
1007 .filter(|v| {
1008 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1010 || v.get("part")
1011 .and_then(|p| p.get("type"))
1012 .and_then(|t| t.as_str())
1013 == Some("step-finish")
1014 })
1015 .filter_map(|v| {
1016 let tokens = v.get("part")?.get("tokens")?;
1017 let base = uint(tokens, "input")?;
1018 let cache = tokens.get("cache");
1019 let cached = ["read", "write"]
1020 .iter()
1021 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1022 .sum::<u64>();
1023 Some(base.saturating_add(cached))
1024 })
1025 .next_back(),
1026 AgentKind::Antigravity => None,
1027 AgentKind::Codex => None,
1028 AgentKind::Omp => lines()
1029 .flat_map(|v| {
1030 match v.get("type").and_then(|t| t.as_str()) {
1033 Some("agent_end") => v
1034 .get("messages")
1035 .and_then(|m| m.as_array())
1036 .cloned()
1037 .unwrap_or_default(),
1038 Some("turn_end") | Some("message_end") => {
1039 v.get("message").cloned().into_iter().collect()
1040 }
1041 _ => Vec::new(),
1042 }
1043 })
1044 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1045 .filter_map(|m| {
1046 input_side(
1047 m.get("usage")?,
1048 &["input", "input_tokens"],
1049 &[
1050 "cacheRead",
1051 "cacheWrite",
1052 "cache_read_tokens",
1053 "cache_write_tokens",
1054 ],
1055 )
1056 })
1057 .next_back(),
1058 AgentKind::Command => None,
1059 }
1060}
1061
1062pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1065 extract_answer(kind, stdout).text
1066}
1067
1068fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1069 match kind {
1070 AgentKind::Claude => {
1071 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1072 return Extracted {
1073 text: stdout.trim().to_owned(),
1074 ..Extracted::default()
1075 };
1076 };
1077 Extracted {
1078 text: v
1079 .get("result")
1080 .and_then(|r| r.as_str())
1081 .unwrap_or_default()
1082 .to_owned(),
1083 session: v
1084 .get("session_id")
1085 .and_then(|s| s.as_str())
1086 .map(str::to_owned),
1087 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1088 if e {
1089 "error".to_owned()
1090 } else {
1091 "success".to_owned()
1092 }
1093 }),
1094 quota: claude_quota(&v),
1095 dropped: None,
1098 commands: Vec::new(),
1099 context_tokens: None,
1100 }
1101 }
1102 AgentKind::Opencode => {
1103 let mut text = String::new();
1105 let mut session = None;
1106 for line in stdout.lines() {
1107 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1108 continue;
1109 };
1110 if session.is_none() {
1111 session = v
1112 .get("sessionID")
1113 .and_then(|s| s.as_str())
1114 .map(str::to_owned);
1115 }
1116 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1117 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1118 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1119 {
1120 if !text.is_empty() {
1121 text.push('\n');
1122 }
1123 text.push_str(t);
1124 }
1125 }
1126 Extracted {
1127 text,
1128 session,
1129 status: None,
1130 quota: None,
1131 dropped: None,
1132 commands: Vec::new(),
1133 context_tokens: None,
1134 }
1135 }
1136 AgentKind::Antigravity => {
1137 let obj = stdout
1140 .lines()
1141 .rev()
1142 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1143 let Some(v) = obj else {
1144 return Extracted {
1145 text: stdout.trim().to_owned(),
1146 ..Extracted::default()
1147 };
1148 };
1149 Extracted {
1150 text: v
1151 .get("response")
1152 .and_then(|r| r.as_str())
1153 .unwrap_or_default()
1154 .trim()
1155 .to_owned(),
1156 session: v
1157 .get("conversation_id")
1158 .and_then(|s| s.as_str())
1159 .map(str::to_owned),
1160 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1161 quota: None,
1162 dropped: dropped_stream(&v),
1163 commands: Vec::new(),
1164 context_tokens: None,
1165 }
1166 }
1167 AgentKind::Codex => {
1168 let mut text = String::new();
1190 let mut session = None;
1191 let mut status = None;
1192 let mut commands = Vec::new();
1193 for line in stdout.lines() {
1194 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1195 continue;
1196 };
1197 match v.get("type").and_then(|t| t.as_str()) {
1198 Some("thread.started") => {
1199 session = v
1200 .get("thread_id")
1201 .and_then(|s| s.as_str())
1202 .map(str::to_owned);
1203 }
1204 Some("item.completed") => {
1205 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1206 match item.get("type").and_then(|t| t.as_str()) {
1207 Some("agent_message") => {
1208 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1209 text = t.trim().to_owned();
1210 }
1211 }
1212 Some("command_execution") => {
1213 commands.push(command_evidence(item));
1214 }
1215 _ => {}
1216 }
1217 }
1218 Some("turn.completed") => status = Some("success".to_owned()),
1219 Some("turn.failed") => status = Some("error".to_owned()),
1220 _ => {}
1221 }
1222 }
1223 Extracted {
1224 text,
1225 session,
1226 status,
1227 quota: None,
1228 dropped: None,
1229 commands,
1230 context_tokens: None,
1231 }
1232 }
1233 AgentKind::Omp => {
1234 let mut text = String::new();
1255 let mut session = None;
1256 for line in stdout.lines() {
1257 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1258 continue;
1259 };
1260 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1261 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1262 continue;
1263 }
1264 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1268 {
1269 Some("agent_end") => v
1270 .get("messages")
1271 .and_then(|m| m.as_array())
1272 .map(|m| m.iter().collect())
1273 .unwrap_or_default(),
1274 Some("turn_end") | Some("message_end") => {
1275 v.get("message").into_iter().collect()
1276 }
1277 _ => continue,
1278 };
1279 for message in messages {
1280 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1281 continue;
1282 }
1283 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1284 continue;
1285 };
1286 for part in parts {
1287 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1288 continue;
1289 }
1290 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1291 && !t.trim().is_empty()
1292 {
1293 text = t.trim().to_owned();
1294 }
1295 }
1296 }
1297 }
1298 Extracted {
1299 text,
1300 session,
1301 status: None,
1302 quota: None,
1303 dropped: None,
1304 commands: Vec::new(),
1305 context_tokens: None,
1306 }
1307 }
1308 AgentKind::Command => {
1309 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1314 let quota = parsed.as_ref().and_then(claude_quota);
1315 let dropped = parsed.as_ref().and_then(dropped_stream);
1318 Extracted {
1319 text: stdout.trim().to_owned(),
1320 session: None,
1321 status: None,
1322 quota,
1323 dropped,
1324 commands: Vec::new(),
1325 context_tokens: None,
1326 }
1327 }
1328 }
1329}
1330
1331fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1338 let description = match item.get("command") {
1339 Some(serde_json::Value::String(s)) => s.clone(),
1340 Some(serde_json::Value::Array(parts)) => parts
1341 .iter()
1342 .filter_map(|p| p.as_str())
1343 .collect::<Vec<_>>()
1344 .join(" "),
1345 _ => String::new(),
1346 };
1347 let result_summary = item
1348 .get("aggregated_output")
1349 .and_then(|o| o.as_str())
1350 .map(|s| tail_chars(s.trim(), 400))
1351 .unwrap_or_default();
1352 CommandEvidence {
1353 id: item
1354 .get("id")
1355 .and_then(|s| s.as_str())
1356 .unwrap_or_default()
1357 .to_owned(),
1358 description,
1359 exit_code: item
1360 .get("exit_code")
1361 .and_then(serde_json::Value::as_i64)
1362 .map(|e| e as i32),
1363 result_summary,
1364 source: "codex".to_owned(),
1365 }
1366}
1367
1368fn tail_chars(s: &str, max: usize) -> String {
1370 let count = s.chars().count();
1371 if count <= max {
1372 return s.to_owned();
1373 }
1374 s.chars().skip(count - max).collect()
1375}
1376
1377fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1384 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1385 if !is_err {
1386 return None;
1387 }
1388 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1389 let status = v.get("api_error_status");
1390 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1391 || status.and_then(serde_json::Value::as_str) == Some("429"))
1392 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1393 if !usage_limit && !result.to_lowercase().contains("session limit") {
1394 return None;
1395 }
1396 let reset = result
1399 .split("resets ")
1400 .nth(1)
1401 .map(str::trim)
1402 .filter(|s| !s.is_empty())
1403 .map(str::to_owned);
1404 Some(Quota { reset })
1405}
1406
1407fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1422 let from_stdout = stdout
1423 .lines()
1424 .rev()
1425 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1426 .and_then(|v| {
1427 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1428 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1429 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1430 .then(|| error.to_owned())
1431 });
1432 let from_stderr = || {
1433 stderr.lines().find_map(|l| {
1434 let v: serde_json::Value =
1435 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1436 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1437 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1438 (exhausted && code).then(|| {
1439 v.get("short_error")
1440 .and_then(|e| e.as_str())
1441 .unwrap_or_default()
1442 .to_owned()
1443 })
1444 })
1445 };
1446 let text = from_stdout.or_else(from_stderr)?;
1447 let reset = text
1448 .split_once("Resets ")
1449 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1450 .filter(|s| !s.is_empty())
1451 .map(str::to_owned);
1452 Some(Quota { reset })
1453}
1454
1455fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1486 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1487 if !status.eq_ignore_ascii_case("error") {
1488 return None;
1489 }
1490 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1491 if !response.trim().is_empty() {
1492 return None;
1494 }
1495 let produced = v
1496 .get("usage")
1497 .and_then(|u| u.get("output_tokens"))
1498 .and_then(serde_json::Value::as_u64)
1499 .unwrap_or(0);
1500 if produced == 0 {
1501 return None;
1503 }
1504 Some(Dropped {
1505 why: v
1506 .get("error")
1507 .and_then(|e| e.as_str())
1508 .unwrap_or("the CLI ended the stream without delivering its answer")
1509 .trim()
1510 .to_owned(),
1511 output_tokens: produced,
1512 })
1513}
1514
1515pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1517 let mut missing = Vec::new();
1518 for s in specs {
1519 let program = match s.kind {
1520 AgentKind::Command => s.command.first().map(String::as_str),
1521 other => other.program(),
1522 };
1523 if let Some(p) = program
1524 && !crate::config::which(p)
1525 && !Path::new(p).is_file()
1526 && !missing.iter().any(|m: &String| m == p)
1527 {
1528 missing.push(p.to_owned());
1529 }
1530 }
1531 missing
1532}
1533
1534pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1536 run_dir.join("artifacts")
1537}
1538
1539pub fn installed(spec: &AgentSpec) -> bool {
1541 spec.kind.program().is_none_or(crate::config::which)
1544}
1545
1546pub fn pick(
1568 agents: &[AgentSpec],
1569 want: Option<&str>,
1570 available: &dyn Fn(&AgentSpec) -> bool,
1571) -> Result<AgentSpec> {
1572 if let Some(id) = want {
1573 let spec = agents
1574 .iter()
1575 .find(|a| a.id == id)
1576 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1577 if !available(spec) {
1578 bail!(
1579 "agent `{}` needs `{}` on PATH; install it or pass a different \
1580 --agent",
1581 spec.id,
1582 spec.kind.program().unwrap_or("its command")
1583 );
1584 }
1585 return Ok(spec.clone());
1586 }
1587
1588 if agents.is_empty() {
1589 bail!(
1590 "the agent roster is empty, so there is nobody to ask: install one \
1591 of claude, opencode or agy - magi derives a roster from what is on \
1592 PATH - or add an [[agents]] entry to magi.toml."
1593 );
1594 }
1595
1596 if let Some(spec) = agents
1597 .iter()
1598 .find(|a| a.kind == AgentKind::Claude && available(a))
1599 {
1600 return Ok(spec.clone());
1601 }
1602
1603 agents
1604 .iter()
1605 .find(|a| available(a))
1606 .cloned()
1607 .with_context(|| {
1608 let missing = agents
1609 .iter()
1610 .filter_map(|a| a.kind.program())
1611 .collect::<Vec<_>>()
1612 .join(", ");
1613 format!(
1614 "no agent in the roster can be run here: install one of \
1615 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1616 you do have"
1617 )
1618 })
1619}
1620
1621pub fn pick_chain(
1629 agents: &[AgentSpec],
1630 choice: Option<&AgentChoice>,
1631 available: &dyn Fn(&AgentSpec) -> bool,
1632 role: &str,
1633) -> Result<Vec<AgentSpec>> {
1634 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1635 if wanted.is_empty() {
1636 return pick(agents, None, available)
1637 .map(|s| vec![s])
1638 .with_context(|| format!("choose an agent for the {role} role"));
1639 }
1640 let mut chain: Vec<AgentSpec> = Vec::new();
1641 for id in wanted {
1642 if chain.iter().any(|s| s.id == id) {
1643 continue;
1644 }
1645 match pick(agents, Some(id), available) {
1646 Ok(spec) => chain.push(spec),
1647 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1648 }
1649 }
1650 if chain.is_empty() {
1651 bail!(
1652 "[roles] {role} names no agent that can run here; the roster has {}",
1653 ids(agents)
1654 );
1655 }
1656 Ok(chain)
1657}
1658
1659pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1665 match outcome {
1666 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1667 Err(_) => true,
1668 Ok(out) => output_advances(out),
1669 }
1670}
1671
1672pub fn output_advances(out: &AgentOutput) -> bool {
1676 out.quota_exhausted() || !out.usable()
1677}
1678
1679fn ids(agents: &[AgentSpec]) -> String {
1680 if agents.is_empty() {
1681 return "no agents at all".to_owned();
1682 }
1683 agents
1684 .iter()
1685 .map(|a| a.id.clone())
1686 .collect::<Vec<_>>()
1687 .join(", ")
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693
1694 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1695
1696 fn command_helper(mode: &str) -> AgentSpec {
1699 AgentSpec {
1700 id: "helper".to_owned(),
1701 kind: AgentKind::Command,
1702 model: None,
1703 command: vec![
1704 std::env::current_exe()
1705 .expect("locate test helper")
1706 .to_string_lossy()
1707 .into_owned(),
1708 "--exact".to_owned(),
1709 "agent::tests::command_agent_test_helper".to_owned(),
1710 "--nocapture".to_owned(),
1711 ],
1712 extra_args: Vec::new(),
1713 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1714 prompt_delivery: None,
1715 }
1716 }
1717
1718 #[test]
1719 fn command_agent_test_helper() {
1720 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1721 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1722 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1723 Ok("no-cache") => println!(
1724 "{}",
1725 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1726 ),
1727 Ok("ignore-stdin") => println!("done"),
1728 Ok("chatty-sleep") => {
1729 println!("i-said-something");
1730 std::thread::sleep(Duration::from_secs(30));
1731 }
1732 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1733 Ok(other) => panic!("unknown command helper mode {other}"),
1734 Err(_) => {}
1735 }
1736 }
1737
1738 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1739 AgentSpec {
1740 id: "a".to_owned(),
1741 kind,
1742 model: model.map(str::to_owned),
1743 command: vec!["echo".to_owned(), "{label}".to_owned()],
1744 extra_args: Vec::new(),
1745 env: BTreeMap::new(),
1746 prompt_delivery: None,
1747 }
1748 }
1749
1750 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1751 Invocation {
1752 coordination: None,
1753 cwd,
1754 prompt: "do the thing",
1755 timeout: Duration::from_secs(900),
1756 allow_write,
1757 unsandboxed: false,
1758 sessions: true,
1759 artifacts: art,
1760 stem: "t",
1761 run: "test-run",
1762 node: "test",
1763 cache_dir: None,
1764 attachments: &[],
1765 writable: &[],
1766 }
1767 }
1768
1769 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1770 build_command(
1771 &spec(kind, None),
1772 seat,
1773 &inv(Path::new("."), Path::new("/art"), allow_write),
1774 Path::new("/art/p.md"),
1775 )
1776 .unwrap()
1777 }
1778
1779 #[test]
1780 fn claude_mints_then_resumes_the_same_uuid() {
1781 let mut seat = SeatState::new("judge-1", "a", 7);
1782 let uuid = seat.claude_session.clone().unwrap();
1783 let first = plan_for(AgentKind::Claude, &seat, true);
1784 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1785 assert!(!first.argv.iter().any(|a| a == "--resume"));
1786
1787 seat.turns = 1;
1788 let second = plan_for(AgentKind::Claude, &seat, true);
1789 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1790 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1791 }
1792
1793 #[test]
1794 fn read_only_seats_cannot_edit() {
1795 let seat = SeatState::new("judge-1", "a", 7);
1796 let claude = plan_for(AgentKind::Claude, &seat, false);
1797 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1798 assert!(
1799 !plan_for(AgentKind::Claude, &seat, true)
1800 .argv
1801 .iter()
1802 .any(|a| a == "--disallowed-tools")
1803 );
1804
1805 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1806 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1807 assert!(
1808 !agy.argv
1809 .iter()
1810 .any(|a| a == "--dangerously-skip-permissions")
1811 );
1812 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1813 assert!(
1814 agy_rw
1815 .argv
1816 .windows(2)
1817 .any(|w| w == ["--mode", "accept-edits"])
1818 );
1819 assert!(
1820 agy_rw
1821 .argv
1822 .iter()
1823 .any(|a| a == "--dangerously-skip-permissions")
1824 );
1825 let agy_prompt = agy_rw
1830 .argv
1831 .iter()
1832 .position(|a| a == "-p")
1833 .map(|i| agy_rw.argv[i + 1].clone())
1834 .expect("agy takes its prompt with -p");
1835 assert!(
1836 agy_prompt.starts_with('@'),
1837 "agy must get a file reference, got {agy_prompt:?}"
1838 );
1839 assert!(
1840 !agy_prompt.contains("Read the file at"),
1841 "the prose pointer is for CLIs with no file syntax"
1842 );
1843
1844 for allow_write in [false, true] {
1849 assert!(
1850 plan_for(AgentKind::Opencode, &seat, allow_write)
1851 .argv
1852 .iter()
1853 .any(|a| a == "--auto"),
1854 "opencode needs --auto even to read (allow_write = {allow_write})"
1855 );
1856 }
1857 }
1858
1859 #[test]
1862 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
1863 let seat = SeatState::new("deputy-x", "a", 7);
1864 let roots = [PathBuf::from("/data/questions")];
1865 let mk = |allow_write: bool| {
1866 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1867 i.writable = &roots;
1868 build_command(
1869 &spec(AgentKind::Codex, None),
1870 &seat,
1871 &i,
1872 Path::new("/art/p.md"),
1873 )
1874 .unwrap()
1875 };
1876 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
1877 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
1878 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
1879 }
1880
1881 #[test]
1882 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
1883 let mut i = inv(Path::new("."), Path::new("/art"), true);
1884 for node in [
1885 "implement",
1886 "fix",
1887 "graft",
1888 "gate-fix",
1889 "land",
1890 "rebase",
1891 "chat",
1892 ] {
1893 i.node = node;
1894 assert!(repository_writer(&i), "{node}");
1895 i.allow_write = false;
1896 assert!(!repository_writer(&i), "{node}");
1897 i.allow_write = true;
1898 }
1899 for node in ["deputy", "judge", "review", "bump", "conduct"] {
1900 i.node = node;
1901 assert!(!repository_writer(&i), "{node}");
1902 }
1903 i.node = "chat";
1904 let roots = [PathBuf::from("/questions")];
1905 i.writable = &roots;
1906 assert!(!repository_writer(&i), "consultation keeps explicit grants");
1907 }
1908
1909 #[test]
1910 fn coordination_classification_is_independent_of_write_grants() {
1911 let roots = [PathBuf::from("/questions")];
1912 for node in [
1913 crate::queue::CHAT_NODE,
1914 "implement",
1915 "fix",
1916 "graft",
1917 "gate-fix",
1918 "land",
1919 "rebase",
1920 "review",
1921 "judge",
1922 "advise",
1923 "deputy",
1924 "waiter",
1925 "conduct",
1926 "dupes",
1927 ] {
1928 for allow_write in [false, true] {
1929 for writable in [&[][..], &roots[..]] {
1930 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1931 i.node = node;
1932 i.writable = writable;
1933 assert_eq!(
1934 repository_seat(&i),
1935 !matches!(
1936 node,
1937 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
1938 ),
1939 "{node}, allow_write={allow_write}, writable={writable:?}"
1940 );
1941 if node == crate::queue::CHAT_NODE {
1942 assert_eq!(repository_writer(&i), allow_write && writable.is_empty());
1943 }
1944 }
1945 }
1946 }
1947 }
1948
1949 #[test]
1950 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
1951 let roots = [
1952 PathBuf::from("/shared/git"),
1953 PathBuf::from("/state/questions"),
1954 ];
1955 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
1956 for resumed in [false, true] {
1957 let mut seat = SeatState::new("impl-A", "a", 7);
1958 if resumed {
1959 seat.turns = 1;
1960 seat.captured_session = Some("session-id".into());
1961 }
1962 for allow_write in [false, true] {
1963 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1964 i.writable = &roots;
1965 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
1966 .unwrap();
1967 for root in &roots {
1968 if kind == AgentKind::Antigravity {
1969 assert_eq!(
1970 plan.argv.windows(2).any(|w| {
1971 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
1972 }),
1973 allow_write,
1974 "resumed={resumed}: {:?}",
1975 plan.argv
1976 );
1977 }
1978 assert_eq!(
1979 plan.argv
1980 .iter()
1981 .any(|arg| arg.contains(root.to_str().unwrap())),
1982 allow_write,
1983 "{kind:?}, resumed={resumed}: {:?}",
1984 plan.argv
1985 );
1986 }
1987 if kind == AgentKind::Codex && resumed {
1988 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
1989 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
1990 }
1991 }
1992 }
1993 }
1994 }
1995
1996 #[test]
1997 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
1998 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
1999 let mut seat = SeatState::new("talk", "a", 7);
2000 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2001 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2002 i.unsandboxed = unsandboxed;
2003 build_command(
2004 &spec(AgentKind::Codex, None),
2005 seat,
2006 &i,
2007 Path::new("/art/p.md"),
2008 )
2009 .unwrap()
2010 };
2011
2012 let first = build(&seat, true, true);
2014 assert!(first.argv.iter().any(|a| a == BYPASS));
2015 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2016 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2017
2018 seat.captured_session = Some("thread-1".to_owned());
2020 seat.turns = 1;
2021 let resumed = build(&seat, true, true);
2022 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2023 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2024
2025 let ro = build(&seat, false, true);
2027 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2028 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2029 }
2030
2031 #[test]
2032 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2033 let mut seat = SeatState::new("judge-1", "a", 7);
2034
2035 let ro = plan_for(AgentKind::Codex, &seat, false);
2039 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2040 let rw = plan_for(AgentKind::Codex, &seat, true);
2041 assert!(
2042 rw.argv
2043 .windows(2)
2044 .any(|w| w == ["--sandbox", "workspace-write"])
2045 );
2046 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2049 ro_flagged.unsandboxed = true;
2050 let ro_flagged = build_command(
2051 &spec(AgentKind::Codex, None),
2052 &seat,
2053 &ro_flagged,
2054 Path::new("/art/p.md"),
2055 )
2056 .unwrap();
2057 assert!(
2058 ro_flagged
2059 .argv
2060 .windows(2)
2061 .any(|w| w == ["--sandbox", "read-only"])
2062 );
2063 for p in [&ro, &rw, &ro_flagged] {
2064 assert!(
2065 !p.argv
2066 .iter()
2067 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2068 "the bypass defeats the only enforced read-only mode we have"
2069 );
2070 assert!(
2072 p.argv
2073 .windows(2)
2074 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2075 "an unattended seat that asks for approval blocks until timeout"
2076 );
2077 }
2078
2079 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2081 assert_eq!(
2082 ro.argv.last().map(String::as_str),
2083 Some("-"),
2084 "without the `-` argument codex waits for a prompt it never gets"
2085 );
2086
2087 seat.turns = 1;
2091 assert!(!has_session(AgentKind::Codex, &seat, true));
2092 assert!(
2093 !plan_for(AgentKind::Codex, &seat, true)
2094 .argv
2095 .iter()
2096 .any(|a| a == "resume")
2097 );
2098 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2099 let resumed = plan_for(AgentKind::Codex, &seat, true);
2100 let at = resumed
2101 .argv
2102 .iter()
2103 .position(|a| a == "resume")
2104 .expect("resumes by subcommand");
2105 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2106 assert!(
2107 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2108 "every option precedes the subcommand"
2109 );
2110 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2111 }
2112
2113 #[test]
2116 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2117 let mut seat = SeatState::new("review-1", "a", 7);
2118
2119 let first = plan_for(AgentKind::Omp, &seat, false);
2123 assert!(first.argv.iter().any(|a| a == "-p"));
2124 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2125 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2126 assert!(
2127 !first.argv.iter().any(|a| a == "do the thing"),
2128 "the prompt reached argv, where Windows caps it"
2129 );
2130
2131 for allow_write in [false, true] {
2137 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2138 assert!(
2139 p.argv.iter().any(|a| a == "--auto-approve"),
2140 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2141 );
2142 assert!(
2143 !p.argv
2144 .iter()
2145 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2146 "nothing ever asks for the bypass"
2147 );
2148 }
2149
2150 seat.turns = 1;
2153 assert!(!has_session(AgentKind::Omp, &seat, true));
2154 assert!(
2155 !plan_for(AgentKind::Omp, &seat, true)
2156 .argv
2157 .iter()
2158 .any(|a| a == "--resume")
2159 );
2160 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2161 let resumed = plan_for(AgentKind::Omp, &seat, true);
2162 assert!(
2163 resumed
2164 .argv
2165 .windows(2)
2166 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2167 "a captured id is what makes the next turn a resume"
2168 );
2169 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2172 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2174 }
2175
2176 #[test]
2181 fn omp_takes_the_answer_without_an_agent_end_line() {
2182 let stream = concat!(
2183 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2184 "\n",
2185 r#"{"type":"agent_start"}"#,
2186 "\n",
2187 r#"{"type":"turn_start"}"#,
2188 "\n",
2189 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2190 "\n",
2191 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2192 "\n",
2193 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2194 "\n",
2195 );
2196 let out = extract(AgentKind::Omp, stream);
2197 assert_eq!(
2198 out.text, "{\"vote\":\"approve\"}",
2199 "the last assistant text block is the answer even with no agent_end"
2200 );
2201 assert_eq!(
2202 out.session.as_deref(),
2203 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2204 );
2205 }
2206
2207 #[test]
2211 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2212 let stream = concat!(
2213 r#"{"type":"session","version":3,"id":"s1"}"#,
2214 "\n",
2215 "{\"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問題ありません。\"}]}]}",
2216 "\n",
2217 );
2218 let out = extract(AgentKind::Omp, stream);
2219 assert_eq!(
2220 out.text, "## 判定\n\n問題ありません。",
2221 "the narration is not the answer, and non-ASCII survives intact"
2222 );
2223 assert_eq!(out.session.as_deref(), Some("s1"));
2224 }
2225
2226 #[test]
2229 fn omp_skips_non_json_lines() {
2230 let stream = concat!(
2231 "Warning: some omp notice\n",
2232 r#"{"type":"session","version":3,"id":"s2"}"#,
2233 "\n",
2234 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2235 "\n",
2236 "trailing junk",
2237 "\n",
2238 );
2239 let out = extract(AgentKind::Omp, stream);
2240 assert_eq!(out.text, "the answer");
2241 assert_eq!(out.session.as_deref(), Some("s2"));
2242 }
2243
2244 #[test]
2246 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2247 let stream = concat!(
2248 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2249 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2250 "\n",
2251 r#"{"type":"turn.started"}"#,
2252 "\n",
2253 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2254 "\n",
2255 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2256 "\n",
2257 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2258 "\n",
2259 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2260 "\n",
2261 );
2262 let out = extract(AgentKind::Codex, stream);
2263 assert_eq!(
2264 out.text, "{\"verdict\": \"ok\"}",
2265 "the last agent message is the answer; earlier ones narrate"
2266 );
2267 assert_eq!(
2268 out.session.as_deref(),
2269 Some("01a07440-4545-7492-85c1-024e3259a90a")
2270 );
2271 assert_eq!(out.status.as_deref(), Some("success"));
2272
2273 let failed = concat!(
2274 r#"{"type":"thread.started","thread_id":"t1"}"#,
2275 "\n",
2276 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2277 "\n",
2278 );
2279 assert_eq!(
2280 extract(AgentKind::Codex, failed).status.as_deref(),
2281 Some("error")
2282 );
2283 }
2284
2285 #[test]
2286 fn captured_sessions_resume_only_once_reported() {
2287 let mut seat = SeatState::new("impl-A", "a", 7);
2288 seat.turns = 1;
2289 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2290 assert!(!has_session(kind, &seat, true));
2291 let p = plan_for(kind, &seat, true);
2292 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2293 }
2294
2295 seat.captured_session = Some("sid".to_owned());
2296 assert!(has_session(AgentKind::Opencode, &seat, true));
2297 assert!(
2298 plan_for(AgentKind::Opencode, &seat, true)
2299 .argv
2300 .windows(2)
2301 .any(|w| w == ["-s", "sid"])
2302 );
2303 assert!(
2304 plan_for(AgentKind::Antigravity, &seat, true)
2305 .argv
2306 .windows(2)
2307 .any(|w| w == ["--conversation", "sid"])
2308 );
2309 }
2310
2311 #[test]
2312 fn sessions_disabled_never_resumes() {
2313 let mut seat = SeatState::new("impl-A", "a", 7);
2314 seat.turns = 3;
2315 seat.captured_session = Some("sid".to_owned());
2316 for kind in [
2317 AgentKind::Claude,
2318 AgentKind::Opencode,
2319 AgentKind::Antigravity,
2320 ] {
2321 assert!(!has_session(kind, &seat, false));
2322 }
2323 }
2324
2325 #[test]
2326 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2327 let seat = SeatState::new("judge-1", "a", 7);
2328 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2329 let p = plan_for(kind, &seat, false);
2330 assert!(
2331 p.argv.iter().all(|a| a != "do the thing"),
2332 "{kind:?} put the prompt on the command line"
2333 );
2334 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2335 }
2336 let p = plan_for(AgentKind::Antigravity, &seat, false);
2338 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2339 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2340 assert!(p.stdin.is_none());
2341 }
2342
2343 #[test]
2344 fn agy_print_timeout_tracks_the_node_budget() {
2345 let seat = SeatState::new("impl-A", "a", 7);
2346 let p = build_command(
2347 &spec(AgentKind::Antigravity, None),
2348 &seat,
2349 &Invocation {
2350 coordination: None,
2351 cwd: Path::new("."),
2352 prompt: "p",
2353 timeout: Duration::from_secs(3600),
2354 allow_write: true,
2355 unsandboxed: false,
2356 sessions: true,
2357 artifacts: Path::new("/art"),
2358 stem: "t",
2359 run: "test-run",
2360 node: "test",
2361 cache_dir: None,
2362 attachments: &[],
2363 writable: &[],
2364 },
2365 Path::new("/art/p.md"),
2366 )
2367 .unwrap();
2368 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2369 }
2370
2371 #[test]
2378 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2379 let mut s = spec(AgentKind::Antigravity, None);
2380 s.prompt_delivery = Some(Delivery::Argv);
2381 let seat = SeatState::new("talk", "a", 7);
2382 let atts = [PathBuf::from("/art/attachments/abc.png")];
2383
2384 let without = build_command(
2385 &s,
2386 &seat,
2387 &Invocation {
2388 attachments: &[],
2389 writable: &[],
2390 ..inv(Path::new("."), Path::new("/art"), true)
2391 },
2392 Path::new("/art/p.md"),
2393 )
2394 .unwrap();
2395 assert!(
2396 !without.argv.iter().any(|a| a == "--add-dir"),
2397 "no attachment, no reason to widen the sandbox: {without:?}"
2398 );
2399
2400 let with = build_command(
2401 &s,
2402 &seat,
2403 &Invocation {
2404 attachments: &atts,
2405 writable: &[],
2406 ..inv(Path::new("."), Path::new("/art"), true)
2407 },
2408 Path::new("/art/p.md"),
2409 )
2410 .unwrap();
2411 assert!(
2412 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2413 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2414 );
2415 }
2416
2417 #[test]
2423 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2424 let seat = SeatState::new("plan", "a", 7);
2425 let atts = [
2426 PathBuf::from("/art/attachments/own.png"),
2427 PathBuf::from("/other-chat/attachments/inherited.png"),
2428 ];
2429
2430 let p = build_command(
2431 &spec(AgentKind::Antigravity, None),
2432 &seat,
2433 &Invocation {
2434 attachments: &atts,
2435 writable: &[],
2436 ..inv(Path::new("."), Path::new("/art"), true)
2437 },
2438 Path::new("/art/p.md"),
2439 )
2440 .unwrap();
2441
2442 assert!(
2443 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2444 "this conversation's own artifacts dir must still be granted: {p:?}"
2445 );
2446 assert!(
2447 p.argv
2448 .windows(2)
2449 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2450 "the inherited attachment's own directory must be granted too: {p:?}"
2451 );
2452 }
2453
2454 #[test]
2455 fn command_agents_get_placeholders_substituted() {
2456 let seat = SeatState::new("impl-A", "a", 7);
2457 let p = plan_for(AgentKind::Command, &seat, true);
2458 assert_eq!(p.argv[0], "echo");
2459 assert_eq!(p.argv[1], "impl-A");
2460 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2461 }
2462
2463 #[test]
2464 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2465 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2467 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2468 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2469 let out = extract(AgentKind::Claude, stdout);
2470 let quota = out.quota.as_ref().expect("rate limit must be detected");
2471 assert_eq!(
2472 quota.reset.as_deref(),
2473 Some("4:50am (Asia/Tokyo)"),
2474 "reset time read from the body"
2475 );
2476 }
2477
2478 #[test]
2479 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2480 let out = extract(
2481 AgentKind::Claude,
2482 r#"{"is_error":true,"result":"session limit reached"}"#,
2483 );
2484 let quota = out.quota.expect("rate limit detected without a reset");
2485 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2486 }
2487
2488 #[test]
2489 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2490 for kind in [AgentKind::Claude, AgentKind::Command] {
2491 for reply in [
2492 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2493 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2494 ] {
2495 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2496 }
2497 for reply in [
2498 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2499 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2500 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2501 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2502 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2503 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2504 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2505 ] {
2506 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2507 }
2508 }
2509 }
2510
2511 #[test]
2512 fn ordinary_failures_are_never_quota() {
2513 let claude_fail = extract(
2515 AgentKind::Claude,
2516 r#"{"is_error":true,"result":"account does not exist"}"#,
2517 );
2518 assert!(claude_fail.quota.is_none());
2519
2520 let cmd_fail = extract(AgentKind::Command, "boom");
2522 assert!(cmd_fail.quota.is_none());
2523
2524 let success = extract(
2526 AgentKind::Command,
2527 r#"{"is_error":false,"result":"session limit is fine"}"#,
2528 );
2529 assert!(success.quota.is_none());
2530 }
2531
2532 #[test]
2540 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2541 let stream = concat!(
2542 r#"{"type":"thread.started","thread_id":"t1"}"#,
2543 "\n",
2544 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"}}"#,
2545 "\n",
2546 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"}}"#,
2547 "\n",
2548 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2549 "\n",
2550 r#"{"type":"turn.completed"}"#,
2551 "\n",
2552 );
2553 let out = extract(AgentKind::Codex, stream);
2554 assert_eq!(out.text, "Both tests in the target pass.");
2555 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2556
2557 let paired = &out.commands[0];
2558 assert_eq!(paired.id, "item49");
2559 assert_eq!(paired.exit_code, Some(1));
2560 assert!(paired.description.contains("graph_cached_gate"));
2561 assert!(paired.result_summary.contains("1 failed"));
2562
2563 let solo = &out.commands[1];
2564 assert_eq!(solo.exit_code, Some(0));
2565
2566 assert!(
2570 out.commands
2571 .iter()
2572 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2573 "a failed run of the actual target must still be visible: {:?}",
2574 out.commands
2575 );
2576 }
2577
2578 #[test]
2579 fn command_agent_can_carry_the_claude_quota_shape() {
2580 let out = extract(
2581 AgentKind::Command,
2582 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2583 );
2584 assert!(
2585 out.quota.is_some(),
2586 "a wrapper emitting the claude shape counts as quota"
2587 );
2588 }
2589
2590 #[test]
2591 fn claude_json_result_is_extracted() {
2592 let out = extract(
2593 AgentKind::Claude,
2594 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2595 );
2596 assert_eq!(out.text, "all done");
2597 assert_eq!(out.session.as_deref(), Some("abc"));
2598 assert_eq!(out.status.as_deref(), Some("success"));
2599 }
2600
2601 #[test]
2616 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2617 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"}"#;
2618 let out = extract(AgentKind::Claude, stdout);
2619 assert_eq!(out.status.as_deref(), Some("success"));
2620 assert!(out.quota.is_none());
2621 assert!(!out.text.trim().is_empty());
2622
2623 let agent_out = AgentOutput {
2624 text: out.text.clone(),
2625 exit_code: Some(0),
2626 timed_out: false,
2627 duration_ms: 500,
2628 artifacts: Vec::new(),
2629 quota: out.quota,
2630 dropped: out.dropped,
2631 commands: out.commands,
2632 context_tokens: None,
2633 };
2634 assert!(
2635 agent_out.usable(),
2636 "the CLI turn itself ended cleanly and must read as usable"
2637 );
2638 assert!(
2639 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2640 "a clean CLI turn is not proof the node's own report ever arrived"
2641 );
2642 }
2643
2644 #[test]
2645 fn opencode_event_stream_is_concatenated() {
2646 let stream = concat!(
2647 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2648 "\n",
2649 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2650 "\n",
2651 "garbage line\n",
2652 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2653 "\n"
2654 );
2655 let out = extract(AgentKind::Opencode, stream);
2656 assert_eq!(out.text, "first\nsecond");
2657 assert_eq!(out.session.as_deref(), Some("ses_1"));
2658 }
2659
2660 #[test]
2661 fn agy_json_survives_a_leading_warning_line() {
2662 let stdout = concat!(
2663 "warning: --mode plan has no effect while slash commands are disabled.\n",
2664 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2665 "\n"
2666 );
2667 let out = extract(AgentKind::Antigravity, stdout);
2668 assert_eq!(out.text, "persimmon");
2669 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2670 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2671 }
2672
2673 const AGY_DROPPED: &str = concat!(
2680 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2681 r#""response":"","error":"the connection to the agent was interrupted before "#,
2682 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2683 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2684 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2685 r#""total_tokens":274380}}"#
2686 );
2687
2688 const AGY_QUOTA_OUT: &str = concat!(
2690 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2691 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2692 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2693 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2694 );
2695 const AGY_QUOTA_ERR: &str = concat!(
2696 "error: Individual quota reached. Resets in 1h2m49s.\n",
2697 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2698 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2699 r#""retryable":true}"#,
2700 "\n"
2701 );
2702
2703 #[test]
2704 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2705 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2706 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2707 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2708 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2709 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2711 assert!(stderr_only.reset.is_none());
2712 }
2713
2714 #[test]
2715 fn ordinary_agy_failures_are_not_a_quota() {
2716 assert!(agy_quota(AGY_DROPPED, "").is_none());
2717 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2718 assert!(
2719 agy_quota(
2720 "",
2721 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2722 )
2723 .is_none()
2724 );
2725 assert!(
2726 agy_quota(
2727 "",
2728 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2729 )
2730 .is_none()
2731 );
2732 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2733 }
2734
2735 #[test]
2736 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2737 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2738 let dropped = out.dropped.expect("recognised as undelivered work");
2739 assert_eq!(dropped.output_tokens, 14267);
2740 assert!(
2741 dropped.why.contains("subscriber fell behind"),
2742 "the CLI's own words are kept for the record: {}",
2743 dropped.why
2744 );
2745 assert_eq!(
2748 out.session.as_deref(),
2749 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2750 );
2751 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2752 }
2753
2754 #[test]
2755 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2756 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2760 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2761
2762 let answered = concat!(
2765 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2766 r#""usage":{"output_tokens":10}}"#
2767 );
2768 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2769
2770 let ok = concat!(
2772 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2773 r#""usage":{"output_tokens":10}}"#
2774 );
2775 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2776 }
2777
2778 #[test]
2779 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2780 let out = AgentOutput {
2781 text: String::new(),
2782 exit_code: Some(1),
2783 timed_out: false,
2784 duration_ms: 431_194,
2785 artifacts: Vec::new(),
2786 quota: None,
2787 dropped: Some(Dropped {
2788 why: "subscriber fell behind updates".to_owned(),
2789 output_tokens: 14267,
2790 }),
2791 commands: Vec::new(),
2792 context_tokens: None,
2793 };
2794 assert!(!out.usable());
2795 assert!(out.work_undelivered());
2796 assert!(!out.quota_exhausted());
2799 }
2800
2801 #[test]
2802 fn non_json_stdout_falls_back_to_raw_text() {
2803 let out = extract(AgentKind::Antigravity, "plain answer\n");
2804 assert_eq!(out.text, "plain answer");
2805 assert!(out.session.is_none());
2806 }
2807
2808 #[tokio::test]
2809 async fn command_agent_round_trip_writes_artifacts() {
2810 let dir = tempfile::tempdir().unwrap();
2811 let art = dir.path().join("artifacts");
2812 let mut seat = SeatState::new("impl-A", "a", 7);
2813 let s = command_helper("reply");
2814 let out = invoke(
2815 &s,
2816 &mut seat,
2817 &Invocation {
2818 coordination: None,
2819 cwd: dir.path(),
2820 prompt: "unused",
2821 timeout: Duration::from_secs(30),
2822 allow_write: true,
2823 unsandboxed: false,
2824 sessions: true,
2825 artifacts: &art,
2826 stem: "impl-A",
2827 run: "test-run",
2828 node: "test",
2829 cache_dir: None,
2830 attachments: &[],
2831 writable: &[],
2832 },
2833 )
2834 .await
2835 .unwrap();
2836 assert!(out.usable(), "{out:?}");
2837 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2838 assert_eq!(seat.turns, 1);
2839 assert!(art.join("impl-A.prompt.md").is_file());
2840 assert!(art.join("impl-A.out").is_file());
2841 }
2842
2843 #[tokio::test]
2844 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2845 let dir = tempfile::tempdir().unwrap();
2849 let cache = dir.path().join("magi-cache");
2850 let mut seat = SeatState::new("impl-A", "a", 7);
2851 let s = command_helper("cache");
2852 let out = invoke(
2853 &s,
2854 &mut seat,
2855 &Invocation {
2856 coordination: None,
2857 cwd: dir.path(),
2858 prompt: "unused",
2859 timeout: Duration::from_secs(30),
2860 allow_write: true,
2861 unsandboxed: false,
2862 sessions: true,
2863 artifacts: &dir.path().join("artifacts"),
2864 stem: "cache",
2865 run: "test-run",
2866 node: "test",
2867 cache_dir: Some(&cache),
2868 attachments: &[],
2869 writable: &[],
2870 },
2871 )
2872 .await
2873 .unwrap();
2874 assert!(out.usable(), "{out:?}");
2875 assert!(
2876 out.text.contains(cache.to_string_lossy().as_ref()),
2877 "the seat must see CARGO_TARGET_DIR = the shared cache"
2878 );
2879 }
2880
2881 #[tokio::test]
2882 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2883 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2891 unsafe {
2896 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2897 }
2898 let dir = tempfile::tempdir().unwrap();
2899 let mut seat = SeatState::new("review-1", "a", 7);
2900 let s = command_helper("no-cache");
2901 let result = invoke(
2902 &s,
2903 &mut seat,
2904 &Invocation {
2905 coordination: None,
2906 cwd: dir.path(),
2907 prompt: "unused",
2908 timeout: Duration::from_secs(30),
2909 allow_write: false,
2910 unsandboxed: false,
2911 sessions: true,
2912 artifacts: &dir.path().join("artifacts"),
2913 stem: "no-cache",
2914 run: "test-run",
2915 node: "test",
2916 cache_dir: None,
2917 attachments: &[],
2918 writable: &[],
2919 },
2920 )
2921 .await;
2922 unsafe {
2927 match &previous {
2928 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2929 None => std::env::remove_var("CARGO_TARGET_DIR"),
2930 }
2931 }
2932 let out = result.unwrap();
2933 assert!(out.usable(), "{out:?}");
2934 assert!(
2935 out.text.contains("ABSENT"),
2936 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2937 out.text
2938 );
2939 }
2940
2941 #[tokio::test]
2942 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2943 let dir = tempfile::tempdir().unwrap();
2944 let mut seat = SeatState::new("impl-A", "a", 7);
2945 let s = command_helper("ignore-stdin");
2948 let big = "x".repeat(1_000_000);
2949 let out = invoke(
2950 &s,
2951 &mut seat,
2952 &Invocation {
2953 coordination: None,
2954 cwd: dir.path(),
2955 prompt: &big,
2956 timeout: Duration::from_secs(60),
2957 allow_write: true,
2958 unsandboxed: false,
2959 sessions: true,
2960 artifacts: &dir.path().join("artifacts"),
2961 stem: "big",
2962 run: "test-run",
2963 node: "test",
2964 cache_dir: None,
2965 attachments: &[],
2966 writable: &[],
2967 },
2968 )
2969 .await
2970 .unwrap();
2971 assert!(out.usable(), "{out:?}");
2972 assert!(out.text.contains("done"), "{}", out.text);
2973 }
2974
2975 #[tokio::test]
2976 async fn timeout_is_reported_not_hung() {
2977 let dir = tempfile::tempdir().unwrap();
2978 let mut seat = SeatState::new("impl-A", "a", 7);
2979 let s = command_helper("sleep");
2980 let out = invoke(
2981 &s,
2982 &mut seat,
2983 &Invocation {
2984 coordination: None,
2985 cwd: dir.path(),
2986 prompt: "unused",
2987 timeout: Duration::from_millis(300),
2988 allow_write: true,
2989 unsandboxed: false,
2990 sessions: true,
2991 artifacts: &dir.path().join("artifacts"),
2992 stem: "slow",
2993 run: "test-run",
2994 node: "test",
2995 cache_dir: None,
2996 attachments: &[],
2997 writable: &[],
2998 },
2999 )
3000 .await
3001 .unwrap();
3002 assert!(out.timed_out);
3003 assert!(!out.usable());
3004 }
3005
3006 #[tokio::test]
3007 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3008 let dir = tempfile::tempdir().unwrap();
3014 let artifacts = dir.path().join("artifacts");
3015 let mut seat = SeatState::new("impl-A", "a", 7);
3016 let s = command_helper("chatty-sleep");
3017 let out = invoke(
3018 &s,
3019 &mut seat,
3020 &Invocation {
3021 coordination: None,
3022 cwd: dir.path(),
3023 prompt: "unused",
3024 timeout: Duration::from_secs(10),
3029 allow_write: true,
3030 unsandboxed: false,
3031 sessions: true,
3032 artifacts: &artifacts,
3033 stem: "chatty",
3034 run: "test-run",
3035 node: "test",
3036 cache_dir: None,
3037 attachments: &[],
3038 writable: &[],
3039 },
3040 )
3041 .await
3042 .unwrap();
3043
3044 assert!(out.timed_out, "{out:?}");
3045 assert!(!out.usable(), "a cut-off answer is still not an answer");
3046 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3047 assert!(
3048 recorded.contains("i-said-something"),
3049 "the artifact must keep what arrived before the kill, got {recorded:?}"
3050 );
3051 assert!(
3052 out.text.contains("i-said-something"),
3053 "and the graph must be able to see it too, got {:?}",
3054 out.text
3055 );
3056 }
3057
3058 #[test]
3059 fn missing_programs_reports_command_binaries() {
3060 let mut s = spec(AgentKind::Command, None);
3061 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3062 assert_eq!(
3063 missing_programs(&[s]),
3064 ["definitely-not-a-real-binary-xyz".to_owned()]
3065 );
3066 }
3067
3068 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3069 AgentSpec {
3070 id: id.to_owned(),
3071 kind,
3072 model: None,
3073 command: Vec::new(),
3074 extra_args: Vec::new(),
3075 env: BTreeMap::new(),
3076 prompt_delivery: None,
3077 }
3078 }
3079
3080 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3083 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3084 }
3085
3086 #[test]
3087 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3088 let agents = [
3089 pick_spec("oc", AgentKind::Opencode),
3090 pick_spec("opus", AgentKind::Claude),
3091 pick_spec("agy", AgentKind::Antigravity),
3092 ];
3093 let got = pick(&agents, None, &without(&[])).expect("a pick");
3094 assert_eq!(got.id, "opus");
3095 }
3096
3097 #[test]
3098 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3099 let agents = [
3100 pick_spec("opus", AgentKind::Claude),
3101 pick_spec("oc", AgentKind::Opencode),
3102 pick_spec("agy", AgentKind::Antigravity),
3103 ];
3104 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3105 assert_eq!(got.id, "agy");
3106 }
3107
3108 #[test]
3109 fn pick_on_an_empty_roster_says_what_to_install() {
3110 let msg = pick(&[], None, &without(&[]))
3111 .expect_err("nobody to ask")
3112 .to_string();
3113 assert!(msg.contains("roster is empty"), "{msg}");
3114 assert!(msg.contains("claude"), "{msg}");
3115 assert!(msg.contains("magi.toml"), "{msg}");
3116 }
3117
3118 #[test]
3119 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3120 let agents = [
3121 pick_spec("opus", AgentKind::Claude),
3122 pick_spec("oc", AgentKind::Opencode),
3123 ];
3124 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3125 let msg = format!("{err:#}");
3126 assert!(msg.contains("claude"), "{msg}");
3127 assert!(msg.contains("opencode"), "{msg}");
3128 }
3129
3130 #[test]
3131 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3132 let agents = [
3133 pick_spec("opus", AgentKind::Claude),
3134 pick_spec("oc", AgentKind::Opencode),
3135 ];
3136 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3137 assert_eq!(got.id, "oc");
3138 }
3139
3140 #[test]
3141 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3142 let agents = [
3143 pick_spec("opus", AgentKind::Claude),
3144 pick_spec("oc", AgentKind::Opencode),
3145 ];
3146 let msg = pick(&agents, Some("gemini"), &without(&[]))
3147 .expect_err("no such agent")
3148 .to_string();
3149 assert!(msg.contains("gemini"), "{msg}");
3150 assert!(msg.contains("opus, oc"), "{msg}");
3151 }
3152
3153 #[test]
3154 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3155 let agents = [
3156 pick_spec("opus", AgentKind::Claude),
3157 pick_spec("oc", AgentKind::Opencode),
3158 ];
3159 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3160 .expect_err("must not silently substitute another model")
3161 .to_string();
3162 assert!(msg.contains("opencode"), "{msg}");
3163 assert!(msg.contains("--agent"), "{msg}");
3164 }
3165
3166 fn named(id: &str) -> AgentSpec {
3167 AgentSpec {
3168 id: id.to_owned(),
3169 ..spec(AgentKind::Command, None)
3170 }
3171 }
3172
3173 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3174 AgentOutput {
3175 text: text.to_owned(),
3176 exit_code: Some(exit),
3177 timed_out: false,
3178 duration_ms: 0,
3179 artifacts: Vec::new(),
3180 quota: quota.then_some(Quota { reset: None }),
3181 dropped: None,
3182 commands: Vec::new(),
3183 context_tokens: None,
3184 }
3185 }
3186
3187 #[test]
3188 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3189 let agents = [named("a"), named("b"), named("c")];
3190 let all = |_: &AgentSpec| true;
3191 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3192 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3193 assert_eq!(
3194 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3195 ["c", "a"]
3196 );
3197
3198 let one = AgentChoice::from("b");
3199 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3200 assert_eq!(got.len(), 1);
3201 assert_eq!(got[0].id, "b");
3202
3203 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3204 assert_eq!(got.len(), 1, "unset keeps pick's default");
3205 assert_eq!(got[0].id, "a");
3206 let empty = AgentChoice::Chain(Vec::new());
3207 assert_eq!(
3208 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3209 "a"
3210 );
3211 }
3212
3213 #[test]
3214 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3215 let agents = [named("a"), named("b")];
3216 let not_a = |s: &AgentSpec| s.id != "a";
3217 let chain = AgentChoice::Chain(
3218 ["a", "ghost", "b", "b"]
3219 .iter()
3220 .map(|s| (*s).to_owned())
3221 .collect(),
3222 );
3223 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3224 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3225
3226 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3227 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3228 assert_eq!(
3229 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3230 ["b", "a"]
3231 );
3232 }
3233
3234 #[test]
3235 fn a_chain_with_nothing_runnable_names_the_role() {
3236 let agents = [named("a")];
3237 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3238 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3239 .unwrap_err()
3240 .to_string();
3241 assert!(err.contains("conductor"), "{err}");
3242 }
3243
3244 #[test]
3245 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3246 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3247 assert!(chain_advances(&Ok(output("limit", 0, true))));
3248 assert!(chain_advances(&Ok(output("", 0, false))));
3249 assert!(chain_advances(&Ok(output("x", 1, false))));
3250 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3251 }
3252
3253 #[test]
3254 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3255 for out in [
3256 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3257 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3258 r#"{"result":"ok"}"#,
3259 "not json",
3260 ] {
3261 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3262 }
3263 }
3264
3265 #[test]
3266 fn opencode_context_tokens_take_the_last_step_finish() {
3267 let out = concat!(
3268 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3269 "\n",
3270 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3271 "\n",
3272 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3273 "\n"
3274 );
3275 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3276 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3277 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3278 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3279 }
3280
3281 #[test]
3282 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3283 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3284 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3285 }
3286
3287 #[test]
3288 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3289 let out = concat!(
3290 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3291 "\n"
3292 );
3293 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3294 }
3295
3296 #[test]
3297 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3298 let out = concat!(
3299 r#"{"type":"session","id":"s"}"#,
3300 "\n",
3301 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3302 "\n",
3303 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3304 "\n"
3305 );
3306 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3307 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3308 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3309 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3310 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3311 }
3312
3313 #[test]
3314 fn command_agents_report_no_context_tokens() {
3315 let out = r#"{"usage":{"input_tokens":5}}"#;
3316 assert_eq!(context_tokens(AgentKind::Command, out), None);
3317 }
3318}