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