1use std::path::PathBuf;
30use std::time::Duration;
31
32use crate::envelope::{Envelope, JsonEnvelope};
33use crate::readiness::{CodexReadinessDetector, GeminiReadinessDetector, ReadinessDetector};
34use crate::supervisor::RestartPolicy;
35use crate::ParsedEvent;
36
37#[derive(Debug, Clone)]
39pub struct CreateContext {
40 pub name: String,
41 pub message: String,
42 pub cwd: PathBuf,
43 pub from_name: Option<String>,
45 pub session_id: Option<String>,
48 pub yolo: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct ResumeContext {
56 pub session_id: String,
57 pub message: String,
58 pub cwd: PathBuf,
59 pub from_name: Option<String>,
60 pub yolo: bool,
61}
62
63#[derive(Debug, Clone)]
67pub struct AgentEntry {
68 pub name: String,
69 pub provider: String,
70 pub session_id: Option<String>,
73 pub cwd: PathBuf,
74}
75
76#[derive(Debug, thiserror::Error, PartialEq, Eq)]
82#[error("reachability probe inconclusive for provider '{provider}': {reason}")]
83pub struct ReachabilityProbeError {
84 pub provider: String,
85 pub reason: String,
86}
87
88impl ReachabilityProbeError {
89 pub fn new(provider: &str, reason: impl Into<String>) -> Self {
90 ReachabilityProbeError {
91 provider: provider.to_string(),
92 reason: reason.into(),
93 }
94 }
95}
96
97pub trait Provider: Send + Sync {
100 fn name(&self) -> &'static str;
102
103 fn create_argv(&self, ctx: &CreateContext) -> Vec<String>;
105
106 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String>;
108
109 fn create_interactive_argv(&self, _ctx: &CreateContext) -> Option<Vec<String>> {
117 None
118 }
119
120 fn resume_interactive_argv(&self, _ctx: &ResumeContext) -> Option<Vec<String>> {
124 None
125 }
126
127 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent;
134
135 fn reachability(
139 &self,
140 entry: &AgentEntry,
141 timeout: Duration,
142 ) -> Result<bool, ReachabilityProbeError>;
143
144 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
148 None
149 }
150}
151
152pub trait ProviderWithPty: Provider {
155 fn readiness_detector(&self) -> Box<dyn ReadinessDetector>;
157
158 fn envelope(&self) -> Box<dyn Envelope>;
160
161 fn default_restart_policy(&self) -> RestartPolicy;
165}
166
167pub struct ClaudeProvider;
175
176pub fn claude_stream_json_resume_argv(session_uuid: &str) -> Vec<String> {
190 vec![
191 "claude".into(),
192 "-p".into(),
193 "--resume".into(),
194 session_uuid.into(),
195 "--input-format".into(),
196 "stream-json".into(),
197 "--output-format".into(),
198 "stream-json".into(),
199 "--include-partial-messages".into(),
200 "--replay-user-messages".into(),
201 ]
202}
203
204impl Provider for ClaudeProvider {
205 fn name(&self) -> &'static str {
206 "claude"
207 }
208
209 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
210 vec![
214 "claude".into(),
215 "--bg".into(),
216 "--name".into(),
217 ctx.name.clone(),
218 ctx.message.clone(),
219 ]
220 }
221
222 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
223 vec![
229 "claude".into(),
230 "--resume".into(),
231 ctx.session_id.clone(),
232 "--print".into(),
233 ctx.message.clone(),
234 ]
235 }
236
237 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
238 match parse_claude_short_id(chunk) {
242 Some(id) => ParsedEvent::SessionCreated { session_id: id },
243 None => ParsedEvent::Unknown {
244 raw: chunk.to_string(),
245 },
246 }
247 }
248
249 fn reachability(
250 &self,
251 entry: &AgentEntry,
252 _timeout: Duration,
253 ) -> Result<bool, ReachabilityProbeError> {
254 let short_id = entry
256 .session_id
257 .as_deref()
258 .filter(|s| !s.is_empty())
259 .ok_or_else(|| ReachabilityProbeError::new("claude", "no session id in entry"))?;
260 let jobs = home_dir()
261 .ok_or_else(|| ReachabilityProbeError::new("claude", "HOME unset"))?
262 .join(".claude")
263 .join("jobs");
264 if !jobs.exists() {
265 return Err(ReachabilityProbeError::new(
267 "claude",
268 "~/.claude/jobs absent",
269 ));
270 }
271 Ok(jobs.join(short_id).exists())
272 }
273
274 }
276
277fn parse_claude_short_id(line: &str) -> Option<String> {
280 line.split(|c: char| !c.is_ascii_hexdigit())
283 .find(|tok| tok.len() == 8 && tok.chars().all(|c| !c.is_ascii_uppercase()))
284 .map(|s| s.to_string())
285}
286
287pub struct CodexProvider;
296
297impl CodexProvider {
298 fn sandbox_create(yolo: bool) -> Vec<String> {
299 if yolo {
301 vec!["--dangerously-bypass-approvals-and-sandbox".into()]
302 } else {
303 vec!["--sandbox".into(), "workspace-write".into()]
304 }
305 }
306
307 fn sandbox_resume(yolo: bool) -> Vec<String> {
308 if yolo {
311 vec!["--dangerously-bypass-approvals-and-sandbox".into()]
312 } else {
313 vec![]
314 }
315 }
316}
317
318impl Provider for CodexProvider {
319 fn name(&self) -> &'static str {
320 "codex"
321 }
322
323 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
324 let mut argv = vec![
325 "codex".into(),
326 "exec".into(),
327 "--json".into(),
328 "-C".into(),
329 ctx.cwd.to_string_lossy().into_owned(),
330 "--skip-git-repo-check".into(),
333 ];
334 argv.extend(Self::sandbox_create(ctx.yolo));
335 argv.push(ctx.message.clone());
336 argv
337 }
338
339 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
340 let mut argv = vec![
341 "codex".into(),
342 "exec".into(),
343 "resume".into(),
344 ctx.session_id.clone(),
345 "--json".into(),
346 "--skip-git-repo-check".into(),
347 ];
348 argv.extend(Self::sandbox_resume(ctx.yolo));
349 argv.push(ctx.message.clone());
350 argv
351 }
352
353 fn create_interactive_argv(&self, ctx: &CreateContext) -> Option<Vec<String>> {
354 let mut argv = vec![
364 "codex".into(),
365 "-C".into(),
366 ctx.cwd.to_string_lossy().into_owned(),
367 ];
368 argv.extend(Self::sandbox_create(ctx.yolo));
369 if !ctx.message.is_empty() {
371 argv.push(ctx.message.clone());
372 }
373 Some(argv)
374 }
375
376 fn resume_interactive_argv(&self, ctx: &ResumeContext) -> Option<Vec<String>> {
377 let mut argv = vec![
391 "codex".into(),
392 "resume".into(),
393 ctx.session_id.clone(),
394 "--include-non-interactive".into(),
395 ];
396 argv.extend(Self::sandbox_resume(ctx.yolo));
397 if !ctx.message.is_empty() {
398 argv.push(ctx.message.clone());
399 }
400 Some(argv)
401 }
402
403 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
404 parse_codex_line(chunk)
405 }
406
407 fn reachability(
408 &self,
409 entry: &AgentEntry,
410 _timeout: Duration,
411 ) -> Result<bool, ReachabilityProbeError> {
412 codex_reachable(entry)
413 }
414
415 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
416 Some(self)
417 }
418}
419
420fn codex_reachable(entry: &AgentEntry) -> Result<bool, ReachabilityProbeError> {
431 let sid = entry
432 .session_id
433 .as_deref()
434 .filter(|s| !s.is_empty())
435 .ok_or_else(|| ReachabilityProbeError::new("codex", "no session id in entry"))?;
436 let home = home_dir().ok_or_else(|| ReachabilityProbeError::new("codex", "HOME unset"))?;
437 let index = home.join(".codex").join("session_index.jsonl");
438 match std::fs::read_to_string(&index) {
439 Ok(text) => Ok(text.contains(sid)),
440 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ReachabilityProbeError::new(
441 "codex",
442 format!("session index absent (fresh install?): {}", index.display()),
443 )),
444 Err(e) => Err(ReachabilityProbeError::new(
445 "codex",
446 format!("cannot read session index {}: {e}", index.display()),
447 )),
448 }
449}
450
451impl ProviderWithPty for CodexProvider {
452 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
453 Box::new(CodexReadinessDetector)
454 }
455
456 fn envelope(&self) -> Box<dyn Envelope> {
457 Box::new(JsonEnvelope)
458 }
459
460 fn default_restart_policy(&self) -> RestartPolicy {
461 RestartPolicy::default()
462 }
463}
464
465fn parse_codex_line(line: &str) -> ParsedEvent {
469 let trimmed = line.trim();
470 if trimmed.is_empty() {
471 return ParsedEvent::Unknown {
472 raw: line.to_string(),
473 };
474 }
475 let v: serde_json::Value = match serde_json::from_str(trimmed) {
476 Ok(v) => v,
477 Err(_) => {
478 return ParsedEvent::Unknown {
479 raw: line.to_string(),
480 }
481 }
482 };
483 let typ = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
484 match typ {
485 "thread.started" => match v.get("thread_id").and_then(|t| t.as_str()) {
486 Some(id) => ParsedEvent::SessionCreated {
487 session_id: id.to_string(),
488 },
489 None => ParsedEvent::Unknown {
490 raw: line.to_string(),
491 },
492 },
493 "item.started" | "item.completed" => {
494 let item = v.get("item");
495 let item_type = item
496 .and_then(|i| i.get("type"))
497 .and_then(|t| t.as_str())
498 .unwrap_or("");
499 match item_type {
500 "agent_message" => ParsedEvent::OutputChunk {
504 text: item
505 .and_then(|i| i.get("text"))
506 .and_then(|t| t.as_str())
507 .unwrap_or("")
508 .to_string(),
509 },
510 "error" => ParsedEvent::ProviderError {
511 message: item
512 .and_then(|i| i.get("message"))
513 .and_then(|t| t.as_str())
514 .unwrap_or("")
515 .to_string(),
516 },
517 "command_execution" => ParsedEvent::ToolUse {
518 name: "command_execution".to_string(),
519 args: item.cloned(),
520 },
521 _ => ParsedEvent::Unknown {
522 raw: line.to_string(),
523 },
524 }
525 }
526 "turn.completed" => ParsedEvent::ReplyComplete {
530 text: String::new(),
531 duration_ms: 0,
532 },
533 _ => ParsedEvent::Unknown {
536 raw: line.to_string(),
537 },
538 }
539}
540
541pub struct GeminiProvider;
550
551impl GeminiProvider {
552 fn sandbox(yolo: bool) -> Vec<String> {
553 if yolo {
555 vec!["--yolo".into()]
556 } else {
557 vec!["--approval-mode".into(), "default".into()]
558 }
559 }
560}
561
562impl Provider for GeminiProvider {
563 fn name(&self) -> &'static str {
564 "gemini"
565 }
566
567 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
568 let mut argv = vec![
569 "gemini".into(),
570 "--skip-trust".into(),
571 "-p".into(),
572 ctx.message.clone(),
573 "--output-format".into(),
574 "json".into(),
575 ];
576 argv.extend(Self::sandbox(ctx.yolo));
577 if let Some(sid) = ctx.session_id.as_deref() {
579 argv.push("--session-id".into());
580 argv.push(sid.to_string());
581 }
582 argv
583 }
584
585 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
586 let mut argv = vec![
587 "gemini".into(),
588 "--skip-trust".into(),
589 "-p".into(),
590 ctx.message.clone(),
591 "--output-format".into(),
592 "json".into(),
593 ];
594 argv.extend(Self::sandbox(ctx.yolo));
595 argv.push("--resume".into());
596 argv.push(ctx.session_id.clone());
597 argv
598 }
599
600 fn create_interactive_argv(&self, ctx: &CreateContext) -> Option<Vec<String>> {
601 let mut argv = vec!["gemini".into(), "--skip-trust".into()];
608 if !ctx.message.is_empty() {
611 argv.push("-i".into());
612 argv.push(ctx.message.clone());
613 }
614 argv.extend(Self::sandbox(ctx.yolo));
615 Some(argv)
616 }
617
618 fn resume_interactive_argv(&self, ctx: &ResumeContext) -> Option<Vec<String>> {
619 let mut argv = vec![
628 "gemini".into(),
629 "--skip-trust".into(),
630 "-r".into(),
631 ctx.session_id.clone(),
632 ];
633 if !ctx.message.is_empty() {
634 argv.push("-i".into());
635 argv.push(ctx.message.clone());
636 }
637 argv.extend(Self::sandbox(ctx.yolo));
638 Some(argv)
639 }
640
641 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
642 parse_gemini_blob(chunk)
643 }
644
645 fn reachability(
646 &self,
647 entry: &AgentEntry,
648 timeout: Duration,
649 ) -> Result<bool, ReachabilityProbeError> {
650 gemini_reachable(entry, timeout)
651 }
652
653 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
654 Some(self)
655 }
656}
657
658fn gemini_reachable(entry: &AgentEntry, budget: Duration) -> Result<bool, ReachabilityProbeError> {
666 let sid = entry
667 .session_id
668 .as_deref()
669 .filter(|s| !s.is_empty())
670 .ok_or_else(|| ReachabilityProbeError::new("gemini", "no session id in entry"))?;
671 let short = sid.get(..8).ok_or_else(|| {
674 ReachabilityProbeError::new(
675 "gemini",
676 format!("session_id too short or non-char-boundary at 8: {sid:?}"),
677 )
678 })?;
679 let home = home_dir().ok_or_else(|| ReachabilityProbeError::new("gemini", "HOME unset"))?;
680 let basename = entry.cwd.file_name().ok_or_else(|| {
681 ReachabilityProbeError::new(
682 "gemini",
683 format!("cwd has no basename: {}", entry.cwd.display()),
684 )
685 })?;
686 let chats_dir = home
687 .join(".gemini")
688 .join("tmp")
689 .join(basename)
690 .join("chats");
691 if !chats_dir.exists() {
692 return Err(ReachabilityProbeError::new(
693 "gemini",
694 format!("chats dir absent: {}", chats_dir.display()),
695 ));
696 }
697 let deadline = std::time::Instant::now() + budget;
698 let dir = std::fs::read_dir(&chats_dir).map_err(|e| {
699 ReachabilityProbeError::new("gemini", format!("read_dir {}: {e}", chats_dir.display()))
700 })?;
701 for ent in dir {
705 if std::time::Instant::now() >= deadline {
706 return Err(ReachabilityProbeError::new(
707 "gemini",
708 "probe budget exceeded before definitive result",
709 ));
710 }
711 let ent = ent.map_err(|e| {
712 ReachabilityProbeError::new(
713 "gemini",
714 format!("dir entry in {}: {e}", chats_dir.display()),
715 )
716 })?;
717 let name = ent.file_name();
718 if !name.to_string_lossy().contains(short) {
719 continue;
720 }
721 let file = std::fs::File::open(ent.path()).map_err(|e| {
726 ReachabilityProbeError::new("gemini", format!("open {}: {e}", ent.path().display()))
727 })?;
728 let mut first_line = String::new();
729 std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first_line).map_err(
730 |e| {
731 ReachabilityProbeError::new("gemini", format!("read {}: {e}", ent.path().display()))
732 },
733 )?;
734 if first_line.contains(sid) {
735 return Ok(true);
736 }
737 }
738 Ok(false)
739}
740
741impl ProviderWithPty for GeminiProvider {
742 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
743 Box::new(GeminiReadinessDetector)
744 }
745
746 fn envelope(&self) -> Box<dyn Envelope> {
747 Box::new(JsonEnvelope)
748 }
749
750 fn default_restart_policy(&self) -> RestartPolicy {
751 RestartPolicy::default()
752 }
753}
754
755fn parse_gemini_blob(blob: &str) -> ParsedEvent {
768 let v: serde_json::Value = match serde_json::from_str(blob.trim()) {
769 Ok(v) => v,
770 Err(_) => {
771 return ParsedEvent::Unknown {
772 raw: blob.to_string(),
773 }
774 }
775 };
776 if let Some(resp) = v.get("response").and_then(|r| r.as_str()) {
780 return ParsedEvent::ReplyComplete {
781 text: resp.to_string(),
782 duration_ms: gemini_total_latency_ms(&v),
783 };
784 }
785 if let Some(sid) = v.get("session_id").and_then(|s| s.as_str()) {
786 return ParsedEvent::SessionCreated {
787 session_id: sid.to_string(),
788 };
789 }
790 ParsedEvent::Unknown {
791 raw: blob.to_string(),
792 }
793}
794
795fn gemini_total_latency_ms(v: &serde_json::Value) -> u64 {
798 let Some(models) = v
799 .get("stats")
800 .and_then(|s| s.get("models"))
801 .and_then(|m| m.as_object())
802 else {
803 return 0;
804 };
805 models
806 .values()
807 .filter_map(|m| m.get("api"))
808 .filter_map(|api| api.get("totalLatencyMs"))
809 .filter_map(|l| l.as_u64())
810 .sum()
811}
812
813fn home_dir() -> Option<PathBuf> {
818 std::env::var_os("HOME").map(PathBuf::from)
819}
820
821pub fn gemini_session_id_from_blob(blob: &str) -> Option<String> {
828 serde_json::from_str::<serde_json::Value>(blob.trim())
829 .ok()?
830 .get("session_id")?
831 .as_str()
832 .map(|s| s.to_string())
833}
834
835pub fn for_name(name: &str) -> Option<Box<dyn Provider>> {
841 match name {
842 "claude" => Some(Box::new(ClaudeProvider)),
843 "codex" => Some(Box::new(CodexProvider)),
844 "gemini" => Some(Box::new(GeminiProvider)),
845 _ => None,
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 fn create_ctx() -> CreateContext {
854 CreateContext {
855 name: "worker-A".into(),
856 message: "build feature X".into(),
857 cwd: PathBuf::from("/tmp/example-repo"),
858 from_name: None,
859 session_id: None,
860 yolo: false,
861 }
862 }
863
864 #[test]
867 fn claude_create_argv_uses_bg_not_print() {
868 let argv = ClaudeProvider.create_argv(&create_ctx());
869 assert_eq!(
870 argv,
871 vec!["claude", "--bg", "--name", "worker-A", "build feature X"]
872 );
873 assert!(!argv.iter().any(|a| a == "-p"), "LD38: never claude -p");
874 }
875
876 #[test]
877 fn claude_resume_argv_is_resume_print() {
878 let ctx = ResumeContext {
879 session_id: "7c5dcf5d".into(),
880 message: "follow up".into(),
881 cwd: PathBuf::from("/x"),
882 from_name: None,
883 yolo: false,
884 };
885 assert_eq!(
886 ClaudeProvider.resume_argv(&ctx),
887 vec!["claude", "--resume", "7c5dcf5d", "--print", "follow up"]
888 );
889 }
890
891 #[test]
892 fn claude_stream_json_resume_argv_uses_p_and_full_uuid() {
893 let argv = claude_stream_json_resume_argv("019e7157-4236-7bb1-b274-ebbac6040ace");
898 assert_eq!(
899 argv,
900 vec![
901 "claude",
902 "-p",
903 "--resume",
904 "019e7157-4236-7bb1-b274-ebbac6040ace",
905 "--input-format",
906 "stream-json",
907 "--output-format",
908 "stream-json",
909 "--include-partial-messages",
910 "--replay-user-messages",
911 ]
912 );
913 }
914
915 #[test]
916 fn codex_create_argv_defaults_to_workspace_write_sandbox() {
917 let argv = CodexProvider.create_argv(&create_ctx());
918 assert_eq!(
919 argv,
920 vec![
921 "codex",
922 "exec",
923 "--json",
924 "-C",
925 "/tmp/example-repo",
926 "--skip-git-repo-check",
927 "--sandbox",
928 "workspace-write",
929 "build feature X"
930 ]
931 );
932 }
933
934 #[test]
935 fn codex_create_argv_yolo_is_mutually_exclusive_with_sandbox() {
936 let mut ctx = create_ctx();
937 ctx.yolo = true;
938 let argv = CodexProvider.create_argv(&ctx);
939 assert!(argv.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
940 assert!(!argv.iter().any(|a| a == "--sandbox"));
941 }
942
943 #[test]
944 fn codex_resume_argv_omits_sandbox_unless_yolo() {
945 let ctx = ResumeContext {
946 session_id: "uuid-1".into(),
947 message: "m".into(),
948 cwd: PathBuf::from("/x"),
949 from_name: None,
950 yolo: false,
951 };
952 assert_eq!(
953 CodexProvider.resume_argv(&ctx),
954 vec![
955 "codex",
956 "exec",
957 "resume",
958 "uuid-1",
959 "--json",
960 "--skip-git-repo-check",
961 "m"
962 ]
963 );
964 }
965
966 #[test]
967 fn gemini_create_argv_passes_session_id_and_default_approval() {
968 let mut ctx = create_ctx();
969 ctx.session_id = Some("uuid-g".into());
970 let argv = GeminiProvider.create_argv(&ctx);
971 assert_eq!(
972 argv,
973 vec![
974 "gemini",
975 "--skip-trust",
976 "-p",
977 "build feature X",
978 "--output-format",
979 "json",
980 "--approval-mode",
981 "default",
982 "--session-id",
983 "uuid-g"
984 ]
985 );
986 }
987
988 #[test]
989 fn gemini_resume_argv_uses_resume_flag() {
990 let ctx = ResumeContext {
991 session_id: "uuid-g".into(),
992 message: "m".into(),
993 cwd: PathBuf::from("/x"),
994 from_name: None,
995 yolo: true,
996 };
997 let argv = GeminiProvider.resume_argv(&ctx);
998 assert_eq!(
999 argv,
1000 vec![
1001 "gemini",
1002 "--skip-trust",
1003 "-p",
1004 "m",
1005 "--output-format",
1006 "json",
1007 "--yolo",
1008 "--resume",
1009 "uuid-g"
1010 ]
1011 );
1012 }
1013
1014 #[test]
1017 fn claude_has_no_interactive_argv() {
1018 assert!(ClaudeProvider
1022 .create_interactive_argv(&create_ctx())
1023 .is_none());
1024 let rctx = ResumeContext {
1025 session_id: "7c5dcf5d".into(),
1026 message: "m".into(),
1027 cwd: PathBuf::from("/x"),
1028 from_name: None,
1029 yolo: false,
1030 };
1031 assert!(ClaudeProvider.resume_interactive_argv(&rctx).is_none());
1032 }
1033
1034 #[test]
1035 fn codex_create_interactive_is_bare_tui_no_exec_no_json() {
1036 let argv = CodexProvider
1037 .create_interactive_argv(&create_ctx())
1038 .unwrap();
1039 assert_eq!(
1040 argv,
1041 vec![
1042 "codex",
1043 "-C",
1044 "/tmp/example-repo",
1045 "--sandbox",
1046 "workspace-write",
1047 "build feature X"
1048 ]
1049 );
1050 assert!(!argv.iter().any(|a| a == "exec"));
1052 assert!(!argv.iter().any(|a| a == "--json"));
1053 assert!(!argv.iter().any(|a| a == "--skip-git-repo-check"));
1054 }
1055
1056 #[test]
1057 fn codex_create_interactive_empty_task_is_bare_session() {
1058 let mut ctx = create_ctx();
1059 ctx.message = String::new();
1060 let argv = CodexProvider.create_interactive_argv(&ctx).unwrap();
1061 assert_eq!(
1062 argv,
1063 vec![
1064 "codex",
1065 "-C",
1066 "/tmp/example-repo",
1067 "--sandbox",
1068 "workspace-write"
1069 ]
1070 );
1071 }
1072
1073 #[test]
1074 fn codex_create_interactive_yolo_bypasses_sandbox() {
1075 let mut ctx = create_ctx();
1076 ctx.yolo = true;
1077 let argv = CodexProvider.create_interactive_argv(&ctx).unwrap();
1078 assert!(argv.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1079 assert!(!argv.iter().any(|a| a == "--sandbox"));
1080 }
1081
1082 #[test]
1083 fn codex_resume_interactive_includes_non_interactive_flag() {
1084 let ctx = ResumeContext {
1085 session_id: "019e7157-4236-7bb1-b274-ebbac6040ace".into(),
1086 message: "continue".into(),
1087 cwd: PathBuf::from("/x"),
1088 from_name: None,
1089 yolo: false,
1090 };
1091 let argv = CodexProvider.resume_interactive_argv(&ctx).unwrap();
1092 assert_eq!(
1093 argv,
1094 vec![
1095 "codex",
1096 "resume",
1097 "019e7157-4236-7bb1-b274-ebbac6040ace",
1098 "--include-non-interactive",
1099 "continue"
1100 ]
1101 );
1102 assert!(!argv.iter().any(|a| a == "exec"));
1104 assert!(!argv.iter().any(|a| a == "--json"));
1105 }
1106
1107 #[test]
1108 fn codex_resume_interactive_empty_task_and_yolo() {
1109 let ctx = ResumeContext {
1110 session_id: "uuid-x".into(),
1111 message: String::new(),
1112 cwd: PathBuf::from("/x"),
1113 from_name: None,
1114 yolo: true,
1115 };
1116 let argv = CodexProvider.resume_interactive_argv(&ctx).unwrap();
1117 assert_eq!(
1118 argv,
1119 vec![
1120 "codex",
1121 "resume",
1122 "uuid-x",
1123 "--include-non-interactive",
1124 "--dangerously-bypass-approvals-and-sandbox"
1125 ]
1126 );
1127 }
1128
1129 #[test]
1130 fn gemini_create_interactive_uses_prompt_interactive() {
1131 let argv = GeminiProvider
1132 .create_interactive_argv(&create_ctx())
1133 .unwrap();
1134 assert_eq!(
1135 argv,
1136 vec![
1137 "gemini",
1138 "--skip-trust",
1139 "-i",
1140 "build feature X",
1141 "--approval-mode",
1142 "default"
1143 ]
1144 );
1145 assert!(!argv.iter().any(|a| a == "--output-format"));
1147 assert!(!argv.iter().any(|a| a == "-p"));
1148 }
1149
1150 #[test]
1151 fn gemini_create_interactive_empty_task_omits_dash_i() {
1152 let mut ctx = create_ctx();
1153 ctx.message = String::new();
1154 let argv = GeminiProvider.create_interactive_argv(&ctx).unwrap();
1155 assert_eq!(
1156 argv,
1157 vec!["gemini", "--skip-trust", "--approval-mode", "default"]
1158 );
1159 assert!(!argv.iter().any(|a| a == "-i"));
1160 }
1161
1162 #[test]
1163 fn gemini_resume_interactive_uses_resume_uuid() {
1164 let ctx = ResumeContext {
1165 session_id: "98e129f1-ba82-4aac-a6ea-ecc626ee76e3".into(),
1166 message: "keep going".into(),
1167 cwd: PathBuf::from("/x"),
1168 from_name: None,
1169 yolo: true,
1170 };
1171 let argv = GeminiProvider.resume_interactive_argv(&ctx).unwrap();
1172 assert_eq!(
1173 argv,
1174 vec![
1175 "gemini",
1176 "--skip-trust",
1177 "-r",
1178 "98e129f1-ba82-4aac-a6ea-ecc626ee76e3",
1179 "-i",
1180 "keep going",
1181 "--yolo"
1182 ]
1183 );
1184 }
1185
1186 #[test]
1187 fn gemini_resume_interactive_empty_task_omits_dash_i() {
1188 let ctx = ResumeContext {
1189 session_id: "uuid-g".into(),
1190 message: String::new(),
1191 cwd: PathBuf::from("/x"),
1192 from_name: None,
1193 yolo: false,
1194 };
1195 let argv = GeminiProvider.resume_interactive_argv(&ctx).unwrap();
1196 assert_eq!(
1197 argv,
1198 vec![
1199 "gemini",
1200 "--skip-trust",
1201 "-r",
1202 "uuid-g",
1203 "--approval-mode",
1204 "default"
1205 ]
1206 );
1207 }
1208
1209 #[test]
1212 fn claude_is_not_pty_managed_others_are() {
1213 assert!(ClaudeProvider.as_pty().is_none());
1214 assert!(CodexProvider.as_pty().is_some());
1215 assert!(GeminiProvider.as_pty().is_some());
1216 }
1217
1218 #[test]
1219 fn for_name_round_trips_every_known_provider() {
1220 for name in ["claude", "codex", "gemini"] {
1224 let p = for_name(name).unwrap_or_else(|| panic!("for_name({name}) returned None"));
1225 assert_eq!(
1226 p.name(),
1227 name,
1228 "for_name({name}) resolved to wrong provider"
1229 );
1230 }
1231 assert!(for_name("nope").is_none(), "unknown provider must be None");
1232 }
1233
1234 #[test]
1237 fn claude_parses_short_id_from_bg_line() {
1238 let ev = ClaudeProvider.parse_stream_event("backgrounded · 7c5dcf5d · worker-A");
1239 assert_eq!(
1240 ev,
1241 ParsedEvent::SessionCreated {
1242 session_id: "7c5dcf5d".into()
1243 }
1244 );
1245 }
1246
1247 #[test]
1248 fn claude_non_id_line_is_unknown() {
1249 assert!(matches!(
1250 ClaudeProvider.parse_stream_event("starting up"),
1251 ParsedEvent::Unknown { .. }
1252 ));
1253 assert!(matches!(
1255 ClaudeProvider.parse_stream_event("ABCDEF12"),
1256 ParsedEvent::Unknown { .. }
1257 ));
1258 }
1259
1260 #[test]
1263 fn codex_thread_started_is_session_created() {
1264 let ev = parse_codex_line(
1265 r#"{"type":"thread.started","thread_id":"019e4958-80d1-7492-8054-2854dfda502c"}"#,
1266 );
1267 assert_eq!(
1268 ev,
1269 ParsedEvent::SessionCreated {
1270 session_id: "019e4958-80d1-7492-8054-2854dfda502c".into()
1271 }
1272 );
1273 }
1274
1275 #[test]
1276 fn codex_agent_message_is_output_chunk() {
1277 let ev = parse_codex_line(
1278 r#"{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"hello"}}"#,
1279 );
1280 assert_eq!(
1281 ev,
1282 ParsedEvent::OutputChunk {
1283 text: "hello".into()
1284 }
1285 );
1286 }
1287
1288 #[test]
1289 fn codex_error_item_is_provider_error() {
1290 let ev = parse_codex_line(
1291 r#"{"type":"item.completed","item":{"id":"item_0","type":"error","message":"boom"}}"#,
1292 );
1293 assert_eq!(
1294 ev,
1295 ParsedEvent::ProviderError {
1296 message: "boom".into()
1297 }
1298 );
1299 }
1300
1301 #[test]
1302 fn codex_command_execution_is_tool_use() {
1303 let ev = parse_codex_line(
1304 r#"{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"echo hi"}}"#,
1305 );
1306 match ev {
1307 ParsedEvent::ToolUse { name, args } => {
1308 assert_eq!(name, "command_execution");
1309 assert_eq!(args.unwrap()["command"], "echo hi");
1310 }
1311 other => panic!("expected ToolUse, got {other:?}"),
1312 }
1313 }
1314
1315 #[test]
1316 fn codex_turn_completed_is_reply_complete_marker() {
1317 let ev = parse_codex_line(r#"{"type":"turn.completed","usage":{"output_tokens":91}}"#);
1318 assert_eq!(
1319 ev,
1320 ParsedEvent::ReplyComplete {
1321 text: String::new(),
1322 duration_ms: 0
1323 }
1324 );
1325 }
1326
1327 #[test]
1328 fn codex_preamble_and_control_frames_are_unknown() {
1329 assert!(matches!(
1330 parse_codex_line("Reading additional input from stdin..."),
1331 ParsedEvent::Unknown { .. }
1332 ));
1333 assert!(matches!(
1334 parse_codex_line(r#"{"type":"turn.started"}"#),
1335 ParsedEvent::Unknown { .. }
1336 ));
1337 }
1338
1339 #[test]
1342 fn gemini_blob_response_is_reply_complete_with_latency() {
1343 let blob = r#"{
1344 "session_id": "abc",
1345 "response": "PONG",
1346 "stats": {"models": {"gemini-3.1-flash-lite": {"api": {"totalLatencyMs": 3359}}}}
1347 }"#;
1348 assert_eq!(
1349 parse_gemini_blob(blob),
1350 ParsedEvent::ReplyComplete {
1351 text: "PONG".into(),
1352 duration_ms: 3359
1353 }
1354 );
1355 }
1356
1357 #[test]
1358 fn gemini_latency_sums_across_models() {
1359 let blob = r#"{
1360 "response": "ok",
1361 "stats": {"models": {
1362 "m1": {"api": {"totalLatencyMs": 100}},
1363 "m2": {"api": {"totalLatencyMs": 250}}
1364 }}
1365 }"#;
1366 assert_eq!(
1367 parse_gemini_blob(blob),
1368 ParsedEvent::ReplyComplete {
1369 text: "ok".into(),
1370 duration_ms: 350
1371 }
1372 );
1373 }
1374
1375 #[test]
1376 fn gemini_session_only_blob_is_session_created() {
1377 let ev = parse_gemini_blob(r#"{"session_id":"xyz"}"#);
1378 assert_eq!(
1379 ev,
1380 ParsedEvent::SessionCreated {
1381 session_id: "xyz".into()
1382 }
1383 );
1384 }
1385
1386 #[test]
1387 fn gemini_partial_or_garbage_is_unknown() {
1388 assert!(matches!(
1389 parse_gemini_blob(r#"{"session_id": "incomplete"#),
1390 ParsedEvent::Unknown { .. }
1391 ));
1392 }
1393
1394 #[test]
1395 fn gemini_session_id_recoverable_from_create_blob_even_with_reply() {
1396 let blob = r#"{"session_id":"abc-123","response":"hi","stats":{}}"#;
1399 assert_eq!(
1400 parse_gemini_blob(blob),
1401 ParsedEvent::ReplyComplete {
1402 text: "hi".into(),
1403 duration_ms: 0
1404 }
1405 );
1406 assert_eq!(gemini_session_id_from_blob(blob), Some("abc-123".into()));
1407 assert_eq!(gemini_session_id_from_blob("not json"), None);
1408 assert_eq!(gemini_session_id_from_blob(r#"{"response":"x"}"#), None);
1409 }
1410
1411 #[test]
1414 fn reachability_no_session_id_is_inconclusive() {
1415 let entry = AgentEntry {
1416 name: "a".into(),
1417 provider: "codex".into(),
1418 session_id: None,
1419 cwd: PathBuf::from("/x"),
1420 };
1421 let err = CodexProvider
1422 .reachability(&entry, Duration::from_millis(250))
1423 .unwrap_err();
1424 assert_eq!(err.provider, "codex");
1425 }
1426
1427 fn codex_entry(session_id: &str) -> AgentEntry {
1428 AgentEntry {
1429 name: "a".into(),
1430 provider: "codex".into(),
1431 session_id: Some(session_id.into()),
1432 cwd: PathBuf::from("/x"),
1433 }
1434 }
1435
1436 #[test]
1437 fn codex_reachable_when_id_in_session_index() {
1438 let tmp = tempdir();
1439 let idx = tmp.join(".codex").join("session_index.jsonl");
1440 std::fs::create_dir_all(idx.parent().unwrap()).unwrap();
1441 std::fs::write(
1442 &idx,
1443 "{\"id\":\"019e4958-80d1-7492-8054-2854dfda502c\",\"status\":\"live\"}\n",
1444 )
1445 .unwrap();
1446 with_home(&tmp, || {
1447 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1448 assert_eq!(
1449 CodexProvider.reachability(&entry, Duration::from_secs(2)),
1450 Ok(true)
1451 );
1452 });
1453 }
1454
1455 #[test]
1456 fn codex_index_present_id_absent_is_false() {
1457 let tmp = tempdir();
1460 let idx = tmp.join(".codex").join("session_index.jsonl");
1461 std::fs::create_dir_all(idx.parent().unwrap()).unwrap();
1462 std::fs::write(&idx, "{\"id\":\"some-other-uuid\"}\n").unwrap();
1463 with_home(&tmp, || {
1464 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1465 assert_eq!(
1466 CodexProvider.reachability(&entry, Duration::from_secs(2)),
1467 Ok(false)
1468 );
1469 });
1470 }
1471
1472 #[test]
1473 fn codex_index_absent_is_inconclusive() {
1474 let tmp = tempdir();
1475 with_home(&tmp, || {
1476 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1477 assert!(CodexProvider
1479 .reachability(&entry, Duration::from_secs(2))
1480 .is_err());
1481 });
1482 }
1483
1484 fn gemini_entry(session_id: &str, cwd: &str) -> AgentEntry {
1485 AgentEntry {
1486 name: "g".into(),
1487 provider: "gemini".into(),
1488 session_id: Some(session_id.into()),
1489 cwd: PathBuf::from(cwd),
1490 }
1491 }
1492
1493 const G_UUID: &str = "35624650-b11e-4300-ad85-0fc87baeb3af";
1494
1495 #[test]
1496 fn gemini_reachability_is_cwd_pinned_and_verifies_full_uuid() {
1497 let tmp = tempdir();
1498 let chats = tmp
1501 .join(".gemini")
1502 .join("tmp")
1503 .join("myproject")
1504 .join("chats");
1505 std::fs::create_dir_all(&chats).unwrap();
1506 std::fs::write(
1507 chats.join("session-35624650.json"),
1508 format!("{{\"sessionId\":\"{G_UUID}\",\"messages\":[]}}\n").as_bytes(),
1509 )
1510 .unwrap();
1511 with_home(&tmp, || {
1512 let entry = gemini_entry(G_UUID, "/work/myproject");
1513 assert_eq!(
1514 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
1515 Ok(true)
1516 );
1517 let other = gemini_entry(G_UUID, "/work/elsewhere");
1519 assert!(GeminiProvider
1520 .reachability(&other, Duration::from_secs(2))
1521 .is_err()); });
1523 }
1524
1525 #[test]
1526 fn gemini_short_prefix_collision_without_full_uuid_is_false() {
1527 let tmp = tempdir();
1530 let chats = tmp.join(".gemini").join("tmp").join("proj").join("chats");
1531 std::fs::create_dir_all(&chats).unwrap();
1532 std::fs::write(
1533 chats.join("session-35624650.json"),
1534 b"{\"sessionId\":\"35624650-ffff-ffff-ffff-ffffffffffff\"}\n",
1535 )
1536 .unwrap();
1537 with_home(&tmp, || {
1538 let entry = gemini_entry(G_UUID, "/x/proj");
1539 assert_eq!(
1540 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
1541 Ok(false)
1542 );
1543 });
1544 }
1545
1546 #[test]
1547 fn gemini_reachability_chats_present_no_match_is_false() {
1548 let tmp = tempdir();
1549 let chats = tmp.join(".gemini").join("tmp").join("proj").join("chats");
1550 std::fs::create_dir_all(&chats).unwrap();
1551 std::fs::write(chats.join("session-deadbeef.json"), b"{}").unwrap();
1552 with_home(&tmp, || {
1553 let entry = gemini_entry("00000000-1111-2222-3333-444444444444", "/x/proj");
1554 assert_eq!(
1555 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
1556 Ok(false)
1557 );
1558 });
1559 }
1560
1561 #[test]
1562 fn gemini_reachability_short_session_id_is_inconclusive() {
1563 let entry = gemini_entry("uuid", "/x/proj");
1564 let err = GeminiProvider
1565 .reachability(&entry, Duration::from_millis(250))
1566 .unwrap_err();
1567 assert_eq!(err.provider, "gemini");
1568 assert!(err.reason.contains("too short"));
1569 }
1570
1571 fn tempdir() -> PathBuf {
1574 let mut p = std::env::temp_dir();
1575 let unique = format!(
1576 "fno-agents-test-{}-{}",
1577 std::process::id(),
1578 std::time::SystemTime::now()
1579 .duration_since(std::time::UNIX_EPOCH)
1580 .unwrap()
1581 .as_nanos()
1582 );
1583 p.push(unique);
1584 std::fs::create_dir_all(&p).unwrap();
1585 p
1586 }
1587
1588 static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1592
1593 fn with_home(home: &std::path::Path, f: impl FnOnce()) {
1597 let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1599 let prev = std::env::var_os("HOME");
1600 std::env::set_var("HOME", home);
1601 f();
1602 match prev {
1603 Some(v) => std::env::set_var("HOME", v),
1604 None => std::env::remove_var("HOME"),
1605 }
1606 }
1607}