1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::rng::SplitMix64;
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SeatState {
49 pub key: String,
51 pub agent: String,
53 pub turns: usize,
55 pub claude_session: Option<String>,
58 pub captured_session: Option<String>,
60}
61
62impl SeatState {
63 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
65 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
66 Self {
67 key: key.to_owned(),
68 agent: agent.to_owned(),
69 turns: 0,
70 claude_session: Some(rng.uuid_v4()),
71 captured_session: None,
72 }
73 }
74}
75
76pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
78 if !sessions_enabled || seat.turns == 0 {
79 return false;
80 }
81 match kind {
82 AgentKind::Claude => seat.claude_session.is_some(),
83 AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session.is_some(),
84 AgentKind::Command => true,
85 }
86}
87
88#[derive(Debug)]
90pub struct Invocation<'a> {
91 pub cwd: &'a Path,
94 pub prompt: &'a str,
96 pub timeout: Duration,
98 pub allow_write: bool,
100 pub sessions: bool,
102 pub artifacts: &'a Path,
104 pub stem: &'a str,
106 pub run: &'a str,
110 pub node: &'a str,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct Quota {
124 #[serde(default)]
126 pub reset: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct AgentOutput {
132 pub text: String,
134 pub exit_code: Option<i32>,
136 pub timed_out: bool,
138 pub duration_ms: u64,
140 pub artifacts: Vec<String>,
142 #[serde(default)]
146 pub quota: Option<Quota>,
147}
148
149impl AgentOutput {
150 pub fn usable(&self) -> bool {
152 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
153 }
154
155 pub fn quota_exhausted(&self) -> bool {
157 self.quota.is_some()
158 }
159}
160
161const PIPE_GRACE: Duration = Duration::from_secs(3);
166
167type Captured = Arc<Mutex<Vec<u8>>>;
169
170fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
180where
181 R: tokio::io::AsyncRead + Unpin + Send + 'static,
182{
183 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
184 let Some(mut pipe) = pipe else {
185 return (buf, None);
186 };
187 let sink = Arc::clone(&buf);
188 let handle = tokio::spawn(async move {
189 let mut chunk = [0u8; 8192];
190 loop {
191 match pipe.read(&mut chunk).await {
192 Ok(0) | Err(_) => break,
193 Ok(n) => {
194 if let Ok(mut guard) = sink.lock() {
195 guard.extend_from_slice(&chunk[..n]);
196 }
197 }
198 }
199 }
200 });
201 (buf, Some(handle))
202}
203
204async fn collect(
209 buf: &Captured,
210 handle: Option<tokio::task::JoinHandle<()>>,
211 grace: Duration,
212) -> String {
213 if let Some(handle) = handle {
214 if tokio::time::timeout(grace, handle).await.is_err() {
215 tracing::debug!("a pipe is still held open after the child exited");
216 }
217 }
218 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
219 String::from_utf8_lossy(&bytes).into_owned()
220}
221
222pub async fn invoke(
224 spec: &AgentSpec,
225 seat: &mut SeatState,
226 inv: &Invocation<'_>,
227) -> Result<AgentOutput> {
228 tokio::fs::create_dir_all(inv.artifacts)
229 .await
230 .with_context(|| format!("create {}", inv.artifacts.display()))?;
231 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
232 tokio::fs::write(&prompt_path, inv.prompt)
233 .await
234 .with_context(|| format!("write {}", prompt_path.display()))?;
235
236 let plan = build_command(spec, seat, inv, &prompt_path)?;
237 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
238
239 let started = Instant::now();
240 let mut cmd = Command::new(&plan.argv[0]);
241 cmd.args(&plan.argv[1..])
242 .current_dir(inv.cwd)
243 .envs(&spec.env)
244 .env("MAGI_SEAT", &seat.key)
245 .env("MAGI_TURN", seat.turns.to_string())
246 .env("MAGI_RUN", inv.run)
247 .env("MAGI_NODE", inv.node)
248 .env("MAGI_PROMPT_FILE", &prompt_path)
249 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
250 .env("GIT_TERMINAL_PROMPT", "0")
251 .stdin(if plan.stdin.is_some() {
252 Stdio::piped()
253 } else {
254 Stdio::null()
255 })
256 .stdout(Stdio::piped())
257 .stderr(Stdio::piped())
258 .kill_on_drop(true);
259
260 let mut child = cmd
261 .spawn()
262 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
263 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
267 tokio::spawn(async move {
268 sink.write_all(body.as_bytes()).await.ok();
269 sink.shutdown().await.ok();
270 });
271 }
272
273 let (out_buf, out_reader) = drain(child.stdout.take());
290 let (err_buf, err_reader) = drain(child.stderr.take());
291
292 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
293 Ok(res) => {
294 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
295 (status.code(), false)
296 }
297 Err(_) => {
298 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
299 child.start_kill().ok();
301 (None, true)
302 }
303 };
304
305 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
310 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
311
312 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
313 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
314 tokio::fs::write(&out_path, &stdout).await.ok();
315 tokio::fs::write(&err_path, &stderr).await.ok();
316
317 let extracted = extract(spec.kind, &stdout);
318 if let Some(session) = extracted.session {
319 match spec.kind {
320 AgentKind::Claude => seat.claude_session = Some(session),
321 AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session = Some(session),
322 AgentKind::Command => {}
323 }
324 }
325 if let Some(status) = &extracted.status
326 && !status.eq_ignore_ascii_case("success")
327 {
328 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
329 }
330 let text = if extracted.text.trim().is_empty() {
331 if stdout.trim().is_empty() {
333 stderr.trim().to_owned()
334 } else {
335 stdout.trim().to_owned()
336 }
337 } else {
338 extracted.text
339 };
340 seat.turns += 1;
341
342 Ok(AgentOutput {
343 text,
344 exit_code: code,
345 timed_out,
346 duration_ms: started.elapsed().as_millis() as u64,
347 artifacts: vec![
348 file_name(&prompt_path),
349 file_name(&out_path),
350 file_name(&err_path),
351 ],
352 quota: extracted.quota,
353 })
354}
355
356fn file_name(p: &Path) -> String {
357 p.file_name()
358 .unwrap_or_default()
359 .to_string_lossy()
360 .into_owned()
361}
362
363#[derive(Debug)]
365struct Plan {
366 argv: Vec<String>,
367 stdin: Option<String>,
368}
369
370fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
381 if matches!(kind, AgentKind::Antigravity) {
382 return format!("@{}", prompt_path.display());
383 }
384 format!(
385 "Read the file at {} and follow every instruction in it exactly. That \
386 file is your complete task description; this message contains nothing \
387 else.",
388 prompt_path.display()
389 )
390}
391
392fn build_command(
393 spec: &AgentSpec,
394 seat: &SeatState,
395 inv: &Invocation<'_>,
396 prompt_path: &Path,
397) -> Result<Plan> {
398 let mut argv: Vec<String> = Vec::new();
399 let mut stdin: Option<String> = None;
400 let delivery = spec.delivery();
401 let resuming = has_session(spec.kind, seat, inv.sessions);
402
403 match spec.kind {
404 AgentKind::Claude => {
405 argv.push("claude".to_owned());
406 argv.push("-p".to_owned());
407 argv.push("--output-format".to_owned());
408 argv.push("json".to_owned());
409 if let Some(m) = &spec.model {
410 argv.push("--model".to_owned());
411 argv.push(m.clone());
412 }
413 if inv.sessions {
414 let uuid = seat
415 .claude_session
416 .as_deref()
417 .context("claude seat is missing its session uuid")?;
418 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
419 argv.push(uuid.to_owned());
420 }
421 argv.push("--permission-mode".to_owned());
422 argv.push("bypassPermissions".to_owned());
423 if !inv.allow_write {
424 argv.push("--disallowed-tools".to_owned());
425 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
426 }
427 }
428 AgentKind::Opencode => {
429 argv.push("opencode".to_owned());
430 argv.push("run".to_owned());
431 argv.push("--format".to_owned());
432 argv.push("json".to_owned());
433 argv.push("--dir".to_owned());
434 argv.push(inv.cwd.to_string_lossy().into_owned());
435 argv.push("--auto".to_owned());
444 if let Some(m) = &spec.model {
445 argv.push("-m".to_owned());
446 argv.push(m.clone());
447 }
448 if resuming {
449 argv.push("-s".to_owned());
450 argv.push(
451 seat.captured_session
452 .clone()
453 .expect("has_session checked the id is present"),
454 );
455 }
456 }
457 AgentKind::Antigravity => {
458 argv.push("agy".to_owned());
459 argv.push("--output-format".to_owned());
460 argv.push("json".to_owned());
461 argv.push("--print-timeout".to_owned());
464 argv.push(format!("{}s", inv.timeout.as_secs()));
465 argv.push("--mode".to_owned());
466 argv.push(
467 if inv.allow_write {
468 "accept-edits"
469 } else {
470 "plan"
471 }
472 .to_owned(),
473 );
474 if inv.allow_write {
475 argv.push("--dangerously-skip-permissions".to_owned());
476 }
477 if let Some(m) = &spec.model {
478 argv.push("--model".to_owned());
479 argv.push(m.clone());
480 }
481 if resuming {
482 argv.push("--conversation".to_owned());
483 argv.push(
484 seat.captured_session
485 .clone()
486 .expect("has_session checked the id is present"),
487 );
488 }
489 if delivery == Delivery::File {
492 argv.push("--add-dir".to_owned());
493 argv.push(inv.artifacts.to_string_lossy().into_owned());
494 }
495 }
496 AgentKind::Command => {
497 if spec.command.is_empty() {
498 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
499 }
500 let vars: BTreeMap<&str, String> = BTreeMap::from([
501 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
502 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
503 ("{label}", seat.key.clone()),
504 ("{session}", seat.claude_session.clone().unwrap_or_default()),
505 ]);
506 for raw in &spec.command {
507 let mut arg = raw.clone();
508 for (k, v) in &vars {
509 if arg.contains(k) {
510 arg = arg.replace(k, v);
511 }
512 }
513 argv.push(arg);
514 }
515 }
516 }
517
518 argv.extend(spec.extra_args.iter().cloned());
519
520 if spec.kind == AgentKind::Antigravity {
523 argv.push("-p".to_owned());
524 }
525 match delivery {
526 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
527 argv.push(pointer(spec.kind, prompt_path));
529 }
530 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
531 Delivery::Argv => argv.push(inv.prompt.to_owned()),
532 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
533 }
534
535 Ok(Plan { argv, stdin })
536}
537
538#[derive(Debug, Default)]
540struct Extracted {
541 text: String,
542 session: Option<String>,
543 status: Option<String>,
544 quota: Option<Quota>,
545}
546
547fn extract(kind: AgentKind, stdout: &str) -> Extracted {
549 match kind {
550 AgentKind::Claude => {
551 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
552 return Extracted {
553 text: stdout.trim().to_owned(),
554 ..Extracted::default()
555 };
556 };
557 Extracted {
558 text: v
559 .get("result")
560 .and_then(|r| r.as_str())
561 .unwrap_or_default()
562 .to_owned(),
563 session: v
564 .get("session_id")
565 .and_then(|s| s.as_str())
566 .map(str::to_owned),
567 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
568 if e {
569 "error".to_owned()
570 } else {
571 "success".to_owned()
572 }
573 }),
574 quota: claude_quota(&v),
575 }
576 }
577 AgentKind::Opencode => {
578 let mut text = String::new();
580 let mut session = None;
581 for line in stdout.lines() {
582 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
583 continue;
584 };
585 if session.is_none() {
586 session = v
587 .get("sessionID")
588 .and_then(|s| s.as_str())
589 .map(str::to_owned);
590 }
591 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
592 if part.get("type").and_then(|t| t.as_str()) == Some("text")
593 && let Some(t) = part.get("text").and_then(|t| t.as_str())
594 {
595 if !text.is_empty() {
596 text.push('\n');
597 }
598 text.push_str(t);
599 }
600 }
601 Extracted {
602 text,
603 session,
604 status: None,
605 quota: None,
606 }
607 }
608 AgentKind::Antigravity => {
609 let obj = stdout
612 .lines()
613 .rev()
614 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
615 let Some(v) = obj else {
616 return Extracted {
617 text: stdout.trim().to_owned(),
618 ..Extracted::default()
619 };
620 };
621 Extracted {
622 text: v
623 .get("response")
624 .and_then(|r| r.as_str())
625 .unwrap_or_default()
626 .trim()
627 .to_owned(),
628 session: v
629 .get("conversation_id")
630 .and_then(|s| s.as_str())
631 .map(str::to_owned),
632 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
633 quota: None,
634 }
635 }
636 AgentKind::Command => {
637 let quota = serde_json::from_str::<serde_json::Value>(stdout.trim())
642 .ok()
643 .and_then(|v| claude_quota(&v));
644 Extracted {
645 text: stdout.trim().to_owned(),
646 session: None,
647 status: None,
648 quota,
649 }
650 }
651 }
652}
653
654fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
661 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
662 if !is_err {
663 return None;
664 }
665 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
666 if !result.to_lowercase().contains("session limit") {
667 return None;
668 }
669 let reset = result
672 .split("resets ")
673 .nth(1)
674 .map(str::trim)
675 .filter(|s| !s.is_empty())
676 .map(str::to_owned);
677 Some(Quota { reset })
678}
679
680pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
682 let mut missing = Vec::new();
683 for s in specs {
684 let program = match s.kind {
685 AgentKind::Command => s.command.first().map(String::as_str),
686 other => other.program(),
687 };
688 if let Some(p) = program
689 && !crate::config::which(p)
690 && !Path::new(p).is_file()
691 && !missing.iter().any(|m: &String| m == p)
692 {
693 missing.push(p.to_owned());
694 }
695 }
696 missing
697}
698
699pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
701 run_dir.join("artifacts")
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
709 AgentSpec {
710 id: "a".to_owned(),
711 kind,
712 model: model.map(str::to_owned),
713 command: vec!["echo".to_owned(), "{label}".to_owned()],
714 extra_args: Vec::new(),
715 env: BTreeMap::new(),
716 prompt_delivery: None,
717 }
718 }
719
720 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
721 Invocation {
722 cwd,
723 prompt: "do the thing",
724 timeout: Duration::from_secs(900),
725 allow_write,
726 sessions: true,
727 artifacts: art,
728 stem: "t",
729 run: "test-run",
730 node: "test",
731 }
732 }
733
734 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
735 build_command(
736 &spec(kind, None),
737 seat,
738 &inv(Path::new("."), Path::new("/art"), allow_write),
739 Path::new("/art/p.md"),
740 )
741 .unwrap()
742 }
743
744 #[test]
745 fn claude_mints_then_resumes_the_same_uuid() {
746 let mut seat = SeatState::new("judge-1", "a", 7);
747 let uuid = seat.claude_session.clone().unwrap();
748 let first = plan_for(AgentKind::Claude, &seat, true);
749 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
750 assert!(!first.argv.iter().any(|a| a == "--resume"));
751
752 seat.turns = 1;
753 let second = plan_for(AgentKind::Claude, &seat, true);
754 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
755 assert!(!second.argv.iter().any(|a| a == "--session-id"));
756 }
757
758 #[test]
759 fn read_only_seats_cannot_edit() {
760 let seat = SeatState::new("judge-1", "a", 7);
761 let claude = plan_for(AgentKind::Claude, &seat, false);
762 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
763 assert!(
764 !plan_for(AgentKind::Claude, &seat, true)
765 .argv
766 .iter()
767 .any(|a| a == "--disallowed-tools")
768 );
769
770 let agy = plan_for(AgentKind::Antigravity, &seat, false);
771 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
772 assert!(
773 !agy.argv
774 .iter()
775 .any(|a| a == "--dangerously-skip-permissions")
776 );
777 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
778 assert!(
779 agy_rw
780 .argv
781 .windows(2)
782 .any(|w| w == ["--mode", "accept-edits"])
783 );
784 assert!(
785 agy_rw
786 .argv
787 .iter()
788 .any(|a| a == "--dangerously-skip-permissions")
789 );
790 let agy_prompt = agy_rw
795 .argv
796 .iter()
797 .position(|a| a == "-p")
798 .map(|i| agy_rw.argv[i + 1].clone())
799 .expect("agy takes its prompt with -p");
800 assert!(
801 agy_prompt.starts_with('@'),
802 "agy must get a file reference, got {agy_prompt:?}"
803 );
804 assert!(
805 !agy_prompt.contains("Read the file at"),
806 "the prose pointer is for CLIs with no file syntax"
807 );
808
809 for allow_write in [false, true] {
814 assert!(
815 plan_for(AgentKind::Opencode, &seat, allow_write)
816 .argv
817 .iter()
818 .any(|a| a == "--auto"),
819 "opencode needs --auto even to read (allow_write = {allow_write})"
820 );
821 }
822 }
823
824 #[test]
825 fn captured_sessions_resume_only_once_reported() {
826 let mut seat = SeatState::new("impl-A", "a", 7);
827 seat.turns = 1;
828 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
829 assert!(!has_session(kind, &seat, true));
830 let p = plan_for(kind, &seat, true);
831 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
832 }
833
834 seat.captured_session = Some("sid".to_owned());
835 assert!(has_session(AgentKind::Opencode, &seat, true));
836 assert!(
837 plan_for(AgentKind::Opencode, &seat, true)
838 .argv
839 .windows(2)
840 .any(|w| w == ["-s", "sid"])
841 );
842 assert!(
843 plan_for(AgentKind::Antigravity, &seat, true)
844 .argv
845 .windows(2)
846 .any(|w| w == ["--conversation", "sid"])
847 );
848 }
849
850 #[test]
851 fn sessions_disabled_never_resumes() {
852 let mut seat = SeatState::new("impl-A", "a", 7);
853 seat.turns = 3;
854 seat.captured_session = Some("sid".to_owned());
855 for kind in [
856 AgentKind::Claude,
857 AgentKind::Opencode,
858 AgentKind::Antigravity,
859 ] {
860 assert!(!has_session(kind, &seat, false));
861 }
862 }
863
864 #[test]
865 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
866 let seat = SeatState::new("judge-1", "a", 7);
867 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
868 let p = plan_for(kind, &seat, false);
869 assert!(
870 p.argv.iter().all(|a| a != "do the thing"),
871 "{kind:?} put the prompt on the command line"
872 );
873 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
874 }
875 let p = plan_for(AgentKind::Antigravity, &seat, false);
877 let at = p.argv.iter().position(|a| a == "-p").unwrap();
878 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
879 assert!(p.stdin.is_none());
880 }
881
882 #[test]
883 fn agy_print_timeout_tracks_the_node_budget() {
884 let seat = SeatState::new("impl-A", "a", 7);
885 let p = build_command(
886 &spec(AgentKind::Antigravity, None),
887 &seat,
888 &Invocation {
889 cwd: Path::new("."),
890 prompt: "p",
891 timeout: Duration::from_secs(3600),
892 allow_write: true,
893 sessions: true,
894 artifacts: Path::new("/art"),
895 stem: "t",
896 run: "test-run",
897 node: "test",
898 },
899 Path::new("/art/p.md"),
900 )
901 .unwrap();
902 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
903 }
904
905 #[test]
906 fn command_agents_get_placeholders_substituted() {
907 let seat = SeatState::new("impl-A", "a", 7);
908 let p = plan_for(AgentKind::Command, &seat, true);
909 assert_eq!(p.argv[0], "echo");
910 assert_eq!(p.argv[1], "impl-A");
911 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
912 }
913
914 #[test]
915 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
916 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
918 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
919 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
920 let out = extract(AgentKind::Claude, stdout);
921 let quota = out.quota.as_ref().expect("rate limit must be detected");
922 assert_eq!(
923 quota.reset.as_deref(),
924 Some("4:50am (Asia/Tokyo)"),
925 "reset time read from the body"
926 );
927 }
928
929 #[test]
930 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
931 let out = extract(
932 AgentKind::Claude,
933 r#"{"is_error":true,"result":"session limit reached"}"#,
934 );
935 let quota = out.quota.expect("rate limit detected without a reset");
936 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
937 }
938
939 #[test]
940 fn ordinary_failures_are_never_quota() {
941 let claude_fail = extract(
943 AgentKind::Claude,
944 r#"{"is_error":true,"result":"account does not exist"}"#,
945 );
946 assert!(claude_fail.quota.is_none());
947
948 let cmd_fail = extract(AgentKind::Command, "boom");
950 assert!(cmd_fail.quota.is_none());
951
952 let success = extract(
954 AgentKind::Command,
955 r#"{"is_error":false,"result":"session limit is fine"}"#,
956 );
957 assert!(success.quota.is_none());
958 }
959
960 #[test]
961 fn command_agent_can_carry_the_claude_quota_shape() {
962 let out = extract(
963 AgentKind::Command,
964 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
965 );
966 assert!(
967 out.quota.is_some(),
968 "a wrapper emitting the claude shape counts as quota"
969 );
970 }
971
972 #[test]
973 fn claude_json_result_is_extracted() {
974 let out = extract(
975 AgentKind::Claude,
976 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
977 );
978 assert_eq!(out.text, "all done");
979 assert_eq!(out.session.as_deref(), Some("abc"));
980 assert_eq!(out.status.as_deref(), Some("success"));
981 }
982
983 #[test]
984 fn opencode_event_stream_is_concatenated() {
985 let stream = concat!(
986 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
987 "\n",
988 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
989 "\n",
990 "garbage line\n",
991 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
992 "\n"
993 );
994 let out = extract(AgentKind::Opencode, stream);
995 assert_eq!(out.text, "first\nsecond");
996 assert_eq!(out.session.as_deref(), Some("ses_1"));
997 }
998
999 #[test]
1000 fn agy_json_survives_a_leading_warning_line() {
1001 let stdout = concat!(
1002 "warning: --mode plan has no effect while slash commands are disabled.\n",
1003 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1004 "\n"
1005 );
1006 let out = extract(AgentKind::Antigravity, stdout);
1007 assert_eq!(out.text, "persimmon");
1008 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1009 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1010 }
1011
1012 #[test]
1013 fn non_json_stdout_falls_back_to_raw_text() {
1014 let out = extract(AgentKind::Antigravity, "plain answer\n");
1015 assert_eq!(out.text, "plain answer");
1016 assert!(out.session.is_none());
1017 }
1018
1019 #[tokio::test]
1020 async fn command_agent_round_trip_writes_artifacts() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let art = dir.path().join("artifacts");
1023 let mut seat = SeatState::new("impl-A", "a", 7);
1024 let mut s = spec(AgentKind::Command, None);
1025 s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1026 let out = invoke(
1027 &s,
1028 &mut seat,
1029 &Invocation {
1030 cwd: dir.path(),
1031 prompt: "unused",
1032 timeout: Duration::from_secs(30),
1033 allow_write: true,
1034 sessions: true,
1035 artifacts: &art,
1036 stem: "impl-A",
1037 run: "test-run",
1038 node: "test",
1039 },
1040 )
1041 .await
1042 .unwrap();
1043 assert!(out.usable(), "{out:?}");
1044 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1045 assert_eq!(seat.turns, 1);
1046 assert!(art.join("impl-A.prompt.md").is_file());
1047 assert!(art.join("impl-A.out").is_file());
1048 }
1049
1050 #[tokio::test]
1051 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1052 let dir = tempfile::tempdir().unwrap();
1053 let mut seat = SeatState::new("impl-A", "a", 7);
1054 let mut s = spec(AgentKind::Command, None);
1055 s.command = vec!["echo".to_owned(), "done".to_owned()];
1058 let big = "x".repeat(1_000_000);
1059 let out = invoke(
1060 &s,
1061 &mut seat,
1062 &Invocation {
1063 cwd: dir.path(),
1064 prompt: &big,
1065 timeout: Duration::from_secs(60),
1066 allow_write: true,
1067 sessions: true,
1068 artifacts: &dir.path().join("artifacts"),
1069 stem: "big",
1070 run: "test-run",
1071 node: "test",
1072 },
1073 )
1074 .await
1075 .unwrap();
1076 assert!(out.usable(), "{out:?}");
1077 assert_eq!(out.text, "done");
1078 }
1079
1080 #[tokio::test]
1081 async fn timeout_is_reported_not_hung() {
1082 let dir = tempfile::tempdir().unwrap();
1083 let mut seat = SeatState::new("impl-A", "a", 7);
1084 let mut s = spec(AgentKind::Command, None);
1085 s.command = vec!["sleep".to_owned(), "30".to_owned()];
1086 let out = invoke(
1087 &s,
1088 &mut seat,
1089 &Invocation {
1090 cwd: dir.path(),
1091 prompt: "unused",
1092 timeout: Duration::from_millis(300),
1093 allow_write: true,
1094 sessions: true,
1095 artifacts: &dir.path().join("artifacts"),
1096 stem: "slow",
1097 run: "test-run",
1098 node: "test",
1099 },
1100 )
1101 .await
1102 .unwrap();
1103 assert!(out.timed_out);
1104 assert!(!out.usable());
1105 }
1106
1107 #[tokio::test]
1108 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1109 let dir = tempfile::tempdir().unwrap();
1115 let artifacts = dir.path().join("artifacts");
1116 let mut seat = SeatState::new("impl-A", "a", 7);
1117 let mut s = spec(AgentKind::Command, None);
1118 s.command = vec![
1119 "sh".to_owned(),
1120 "-c".to_owned(),
1121 "echo i-said-something; sleep 30".to_owned(),
1122 ];
1123 let out = invoke(
1124 &s,
1125 &mut seat,
1126 &Invocation {
1127 cwd: dir.path(),
1128 prompt: "unused",
1129 timeout: Duration::from_secs(10),
1134 allow_write: true,
1135 sessions: true,
1136 artifacts: &artifacts,
1137 stem: "chatty",
1138 run: "test-run",
1139 node: "test",
1140 },
1141 )
1142 .await
1143 .unwrap();
1144
1145 assert!(out.timed_out, "{out:?}");
1146 assert!(!out.usable(), "a cut-off answer is still not an answer");
1147 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1148 assert!(
1149 recorded.contains("i-said-something"),
1150 "the artifact must keep what arrived before the kill, got {recorded:?}"
1151 );
1152 assert!(
1153 out.text.contains("i-said-something"),
1154 "and the graph must be able to see it too, got {:?}",
1155 out.text
1156 );
1157 }
1158
1159 #[test]
1160 fn missing_programs_reports_command_binaries() {
1161 let mut s = spec(AgentKind::Command, None);
1162 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1163 assert_eq!(
1164 missing_programs(&[s]),
1165 ["definitely-not-a-real-binary-xyz".to_owned()]
1166 );
1167 }
1168}