1use std::path::PathBuf;
30use std::time::{Duration, Instant};
31
32use crate::envelope::{Envelope, JsonEnvelope, NoEnvelope};
33use crate::readiness::{
34 AgyReadinessDetector, CodexReadinessDetector, GeminiReadinessDetector,
35 OpencodeReadinessDetector, ReadinessDetector,
36};
37use crate::supervisor::RestartPolicy;
38use crate::ParsedEvent;
39
40#[derive(Debug, Clone)]
42pub struct CreateContext {
43 pub name: String,
44 pub message: String,
45 pub cwd: PathBuf,
46 pub from_name: Option<String>,
48 pub session_id: Option<String>,
51 pub yolo: bool,
54 pub reasoning_effort: Option<String>,
56 pub append_system_prompt: Option<String>,
63}
64
65#[derive(Debug, Clone)]
67pub struct ResumeContext {
68 pub session_id: String,
69 pub message: String,
70 pub cwd: PathBuf,
71 pub from_name: Option<String>,
72 pub yolo: bool,
73}
74
75#[derive(Debug, Clone)]
79pub struct AgentEntry {
80 pub name: String,
81 pub provider: String,
82 pub session_id: Option<String>,
85 pub cwd: PathBuf,
86}
87
88#[derive(Debug, thiserror::Error, PartialEq, Eq)]
94#[error("reachability probe inconclusive for provider '{provider}': {reason}")]
95pub struct ReachabilityProbeError {
96 pub provider: String,
97 pub reason: String,
98}
99
100impl ReachabilityProbeError {
101 pub fn new(provider: &str, reason: impl Into<String>) -> Self {
102 ReachabilityProbeError {
103 provider: provider.to_string(),
104 reason: reason.into(),
105 }
106 }
107}
108
109pub trait Provider: Send + Sync {
112 fn name(&self) -> &'static str;
114
115 fn create_argv(&self, ctx: &CreateContext) -> Vec<String>;
117
118 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String>;
120
121 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent;
128
129 fn reachability(
133 &self,
134 entry: &AgentEntry,
135 timeout: Duration,
136 ) -> Result<bool, ReachabilityProbeError>;
137
138 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
142 None
143 }
144}
145
146pub trait ProviderWithPty: Provider {
149 fn readiness_detector(&self) -> Box<dyn ReadinessDetector>;
151
152 fn envelope(&self) -> Box<dyn Envelope>;
154
155 fn default_restart_policy(&self) -> RestartPolicy;
159}
160
161pub struct ClaudeProvider;
169
170pub fn claude_stream_json_resume_argv(session_uuid: &str) -> Vec<String> {
184 vec![
185 "claude".into(),
186 "-p".into(),
187 "--resume".into(),
188 session_uuid.into(),
189 "--input-format".into(),
190 "stream-json".into(),
191 "--output-format".into(),
192 "stream-json".into(),
193 "--include-partial-messages".into(),
194 "--replay-user-messages".into(),
195 ]
196}
197
198impl Provider for ClaudeProvider {
199 fn name(&self) -> &'static str {
200 "claude"
201 }
202
203 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
204 vec![
208 "claude".into(),
209 "--bg".into(),
210 "--name".into(),
211 ctx.name.clone(),
212 ctx.message.clone(),
213 ]
214 }
215
216 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
217 vec![
223 "claude".into(),
224 "--resume".into(),
225 ctx.session_id.clone(),
226 "--print".into(),
227 ctx.message.clone(),
228 ]
229 }
230
231 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
232 match parse_claude_short_id(chunk) {
236 Some(id) => ParsedEvent::SessionCreated { session_id: id },
237 None => ParsedEvent::Unknown {
238 raw: chunk.to_string(),
239 },
240 }
241 }
242
243 fn reachability(
244 &self,
245 entry: &AgentEntry,
246 _timeout: Duration,
247 ) -> Result<bool, ReachabilityProbeError> {
248 let short_id = entry
250 .session_id
251 .as_deref()
252 .filter(|s| !s.is_empty())
253 .ok_or_else(|| ReachabilityProbeError::new("claude", "no session id in entry"))?;
254 let jobs = home_dir()
255 .ok_or_else(|| ReachabilityProbeError::new("claude", "HOME unset"))?
256 .join(".claude")
257 .join("jobs");
258 if !jobs.exists() {
259 return Err(ReachabilityProbeError::new(
261 "claude",
262 "~/.claude/jobs absent",
263 ));
264 }
265 Ok(jobs.join(short_id).exists())
266 }
267
268 }
270
271fn parse_claude_short_id(line: &str) -> Option<String> {
274 line.split(|c: char| !c.is_ascii_hexdigit())
277 .find(|tok| tok.len() == 8 && tok.chars().all(|c| !c.is_ascii_uppercase()))
278 .map(|s| s.to_string())
279}
280
281pub struct ClaudeInteractiveProvider;
298
299impl ClaudeInteractiveProvider {
300 fn interactive_argv(ctx: &CreateContext) -> Vec<String> {
307 let mut argv = vec!["claude".into()];
308 if let Some(sid) = ctx.session_id.as_deref().filter(|s| !s.is_empty()) {
309 argv.push("--session-id".into());
310 argv.push(sid.to_string());
311 }
312 if let Some(prompt) = ctx
316 .append_system_prompt
317 .as_deref()
318 .filter(|s| !s.is_empty())
319 {
320 argv.push("--append-system-prompt".into());
321 argv.push(prompt.to_string());
322 }
323 if !ctx.message.is_empty() {
324 argv.push(ctx.message.clone());
325 }
326 argv
327 }
328}
329
330impl Provider for ClaudeInteractiveProvider {
331 fn name(&self) -> &'static str {
332 "claude"
333 }
334
335 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
339 Self::interactive_argv(ctx)
340 }
341
342 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
343 let mut argv = vec!["claude".into(), "--resume".into(), ctx.session_id.clone()];
346 if !ctx.message.is_empty() {
347 argv.push(ctx.message.clone());
348 }
349 argv
350 }
351
352 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
353 ParsedEvent::Unknown {
356 raw: chunk.to_string(),
357 }
358 }
359
360 fn reachability(
361 &self,
362 _entry: &AgentEntry,
363 _timeout: Duration,
364 ) -> Result<bool, ReachabilityProbeError> {
365 Err(ReachabilityProbeError::new(
370 "claude",
371 "interactive claude liveness is PTY-governed (pid/ConnState), not store-probed",
372 ))
373 }
374
375 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
376 Some(self)
377 }
378}
379
380impl ProviderWithPty for ClaudeInteractiveProvider {
381 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
382 Box::new(crate::readiness::ClaudeReadinessDetector)
383 }
384
385 fn envelope(&self) -> Box<dyn Envelope> {
386 Box::new(JsonEnvelope)
390 }
391
392 fn default_restart_policy(&self) -> RestartPolicy {
393 RestartPolicy::default()
394 }
395}
396
397pub struct CodexProvider;
406
407pub fn normalize_codex_command(message: &str) -> String {
408 let command = message.trim();
409 if let Some(verb) = command.strip_prefix("/fno:") {
410 format!("$fno:{verb}")
411 } else if let Some(verb) = command.strip_prefix('/') {
412 format!("$fno:{verb}")
413 } else if command.starts_with("$fno:") {
414 command.to_string()
415 } else {
416 message.to_string()
417 }
418}
419
420impl CodexProvider {
421 fn sandbox_create(yolo: bool) -> Vec<String> {
422 if yolo {
424 vec!["--dangerously-bypass-approvals-and-sandbox".into()]
425 } else {
426 vec!["--sandbox".into(), "workspace-write".into()]
427 }
428 }
429
430 fn sandbox_resume(yolo: bool) -> Vec<String> {
431 if yolo {
434 vec!["--dangerously-bypass-approvals-and-sandbox".into()]
435 } else {
436 vec![]
437 }
438 }
439}
440
441impl Provider for CodexProvider {
442 fn name(&self) -> &'static str {
443 "codex"
444 }
445
446 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
447 let mut argv = vec![
448 "codex".into(),
449 "exec".into(),
450 "--json".into(),
451 "-C".into(),
452 ctx.cwd.to_string_lossy().into_owned(),
453 "--skip-git-repo-check".into(),
456 ];
457 argv.extend(Self::sandbox_create(ctx.yolo));
458 if let Some(effort) = ctx.reasoning_effort.as_deref().filter(|e| !e.is_empty()) {
459 argv.push("-c".into());
460 argv.push(format!("model_reasoning_effort={effort}"));
461 }
462 argv.push(normalize_codex_command(&ctx.message));
463 argv
464 }
465
466 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
467 let mut argv = vec![
468 "codex".into(),
469 "exec".into(),
470 "resume".into(),
471 ctx.session_id.clone(),
472 "--json".into(),
473 "--skip-git-repo-check".into(),
474 ];
475 argv.extend(Self::sandbox_resume(ctx.yolo));
476 argv.push(normalize_codex_command(&ctx.message));
477 argv
478 }
479
480 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
481 parse_codex_line(chunk)
482 }
483
484 fn reachability(
485 &self,
486 entry: &AgentEntry,
487 _timeout: Duration,
488 ) -> Result<bool, ReachabilityProbeError> {
489 codex_reachable(entry)
490 }
491
492 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
493 Some(self)
494 }
495}
496
497fn codex_reachable(entry: &AgentEntry) -> Result<bool, ReachabilityProbeError> {
508 let sid = entry
509 .session_id
510 .as_deref()
511 .filter(|s| !s.is_empty())
512 .ok_or_else(|| ReachabilityProbeError::new("codex", "no session id in entry"))?;
513 let home = home_dir().ok_or_else(|| ReachabilityProbeError::new("codex", "HOME unset"))?;
514 let index = home.join(".codex").join("session_index.jsonl");
515 match std::fs::read_to_string(&index) {
516 Ok(text) => Ok(text.contains(sid)),
517 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ReachabilityProbeError::new(
518 "codex",
519 format!("session index absent (fresh install?): {}", index.display()),
520 )),
521 Err(e) => Err(ReachabilityProbeError::new(
522 "codex",
523 format!("cannot read session index {}: {e}", index.display()),
524 )),
525 }
526}
527
528impl ProviderWithPty for CodexProvider {
529 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
530 Box::new(CodexReadinessDetector)
531 }
532
533 fn envelope(&self) -> Box<dyn Envelope> {
534 Box::new(JsonEnvelope)
535 }
536
537 fn default_restart_policy(&self) -> RestartPolicy {
538 RestartPolicy::default()
539 }
540}
541
542fn parse_codex_line(line: &str) -> ParsedEvent {
546 let trimmed = line.trim();
547 if trimmed.is_empty() {
548 return ParsedEvent::Unknown {
549 raw: line.to_string(),
550 };
551 }
552 let v: serde_json::Value = match serde_json::from_str(trimmed) {
553 Ok(v) => v,
554 Err(_) => {
555 return ParsedEvent::Unknown {
556 raw: line.to_string(),
557 }
558 }
559 };
560 let typ = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
561 match typ {
562 "thread.started" => match v.get("thread_id").and_then(|t| t.as_str()) {
563 Some(id) => ParsedEvent::SessionCreated {
564 session_id: id.to_string(),
565 },
566 None => ParsedEvent::Unknown {
567 raw: line.to_string(),
568 },
569 },
570 "item.started" | "item.completed" => {
571 let item = v.get("item");
572 let item_type = item
573 .and_then(|i| i.get("type"))
574 .and_then(|t| t.as_str())
575 .unwrap_or("");
576 match item_type {
577 "agent_message" => ParsedEvent::OutputChunk {
581 text: item
582 .and_then(|i| i.get("text"))
583 .and_then(|t| t.as_str())
584 .unwrap_or("")
585 .to_string(),
586 },
587 "error" => ParsedEvent::ProviderError {
588 message: item
589 .and_then(|i| i.get("message"))
590 .and_then(|t| t.as_str())
591 .unwrap_or("")
592 .to_string(),
593 },
594 "command_execution" => ParsedEvent::ToolUse {
595 name: "command_execution".to_string(),
596 args: item.cloned(),
597 },
598 _ => ParsedEvent::Unknown {
599 raw: line.to_string(),
600 },
601 }
602 }
603 "turn.completed" => ParsedEvent::ReplyComplete {
607 text: String::new(),
608 duration_ms: 0,
609 },
610 _ => ParsedEvent::Unknown {
613 raw: line.to_string(),
614 },
615 }
616}
617
618pub struct GeminiProvider;
627
628impl GeminiProvider {
629 fn sandbox(yolo: bool) -> Vec<String> {
630 if yolo {
632 vec!["--yolo".into()]
633 } else {
634 vec!["--approval-mode".into(), "default".into()]
635 }
636 }
637}
638
639impl Provider for GeminiProvider {
640 fn name(&self) -> &'static str {
641 "gemini"
642 }
643
644 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
645 let mut argv = vec![
646 "gemini".into(),
647 "--skip-trust".into(),
648 "-p".into(),
649 ctx.message.clone(),
650 "--output-format".into(),
651 "json".into(),
652 ];
653 argv.extend(Self::sandbox(ctx.yolo));
654 if let Some(sid) = ctx.session_id.as_deref() {
656 argv.push("--session-id".into());
657 argv.push(sid.to_string());
658 }
659 argv
660 }
661
662 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
663 let mut argv = vec![
664 "gemini".into(),
665 "--skip-trust".into(),
666 "-p".into(),
667 ctx.message.clone(),
668 "--output-format".into(),
669 "json".into(),
670 ];
671 argv.extend(Self::sandbox(ctx.yolo));
672 argv.push("--resume".into());
673 argv.push(ctx.session_id.clone());
674 argv
675 }
676
677 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
678 parse_gemini_blob(chunk)
679 }
680
681 fn reachability(
682 &self,
683 entry: &AgentEntry,
684 timeout: Duration,
685 ) -> Result<bool, ReachabilityProbeError> {
686 gemini_reachable(entry, timeout)
687 }
688
689 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
690 Some(self)
691 }
692}
693
694fn gemini_reachable(entry: &AgentEntry, budget: Duration) -> Result<bool, ReachabilityProbeError> {
702 let sid = entry
703 .session_id
704 .as_deref()
705 .filter(|s| !s.is_empty())
706 .ok_or_else(|| ReachabilityProbeError::new("gemini", "no session id in entry"))?;
707 let short = sid.get(..8).ok_or_else(|| {
710 ReachabilityProbeError::new(
711 "gemini",
712 format!("session_id too short or non-char-boundary at 8: {sid:?}"),
713 )
714 })?;
715 let home = home_dir().ok_or_else(|| ReachabilityProbeError::new("gemini", "HOME unset"))?;
716 let basename = entry.cwd.file_name().ok_or_else(|| {
717 ReachabilityProbeError::new(
718 "gemini",
719 format!("cwd has no basename: {}", entry.cwd.display()),
720 )
721 })?;
722 let chats_dir = home
723 .join(".gemini")
724 .join("tmp")
725 .join(basename)
726 .join("chats");
727 if !chats_dir.exists() {
728 return Err(ReachabilityProbeError::new(
729 "gemini",
730 format!("chats dir absent: {}", chats_dir.display()),
731 ));
732 }
733 let deadline = std::time::Instant::now() + budget;
734 let dir = std::fs::read_dir(&chats_dir).map_err(|e| {
735 ReachabilityProbeError::new("gemini", format!("read_dir {}: {e}", chats_dir.display()))
736 })?;
737 for ent in dir {
741 if std::time::Instant::now() >= deadline {
742 return Err(ReachabilityProbeError::new(
743 "gemini",
744 "probe budget exceeded before definitive result",
745 ));
746 }
747 let ent = ent.map_err(|e| {
748 ReachabilityProbeError::new(
749 "gemini",
750 format!("dir entry in {}: {e}", chats_dir.display()),
751 )
752 })?;
753 let name = ent.file_name();
754 if !name.to_string_lossy().contains(short) {
755 continue;
756 }
757 let file = std::fs::File::open(ent.path()).map_err(|e| {
762 ReachabilityProbeError::new("gemini", format!("open {}: {e}", ent.path().display()))
763 })?;
764 let mut first_line = String::new();
765 std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first_line).map_err(
766 |e| {
767 ReachabilityProbeError::new("gemini", format!("read {}: {e}", ent.path().display()))
768 },
769 )?;
770 if first_line.contains(sid) {
771 return Ok(true);
772 }
773 }
774 Ok(false)
775}
776
777impl ProviderWithPty for GeminiProvider {
778 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
779 Box::new(GeminiReadinessDetector)
780 }
781
782 fn envelope(&self) -> Box<dyn Envelope> {
783 Box::new(JsonEnvelope)
784 }
785
786 fn default_restart_policy(&self) -> RestartPolicy {
787 RestartPolicy::default()
788 }
789}
790
791pub struct AgyProvider;
809
810impl AgyProvider {}
811
812impl Provider for AgyProvider {
813 fn name(&self) -> &'static str {
814 "agy"
815 }
816
817 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
818 let mut argv = vec!["agy".into(), "--dangerously-skip-permissions".into()];
821 argv.push("-p".into());
822 argv.push(ctx.message.clone());
823 argv
824 }
825
826 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
827 let mut argv = vec![
830 "agy".into(),
831 "--dangerously-skip-permissions".into(),
832 "--conversation".into(),
833 ctx.session_id.clone(),
834 ];
835 argv.push("-p".into());
836 argv.push(ctx.message.clone());
837 argv
838 }
839
840 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
841 if chunk.trim().is_empty() {
844 ParsedEvent::Unknown {
845 raw: chunk.to_string(),
846 }
847 } else {
848 ParsedEvent::ReplyComplete {
849 text: chunk.to_string(),
850 duration_ms: 0,
851 }
852 }
853 }
854
855 fn reachability(
856 &self,
857 _entry: &AgentEntry,
858 _timeout: Duration,
859 ) -> Result<bool, ReachabilityProbeError> {
860 Err(ReachabilityProbeError::new(
863 "agy",
864 "agy sessions are not probeable (plain-text, no session store)",
865 ))
866 }
867
868 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
869 Some(self)
870 }
871}
872
873impl ProviderWithPty for AgyProvider {
874 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
875 Box::new(AgyReadinessDetector)
876 }
877
878 fn envelope(&self) -> Box<dyn Envelope> {
879 Box::new(NoEnvelope)
881 }
882
883 fn default_restart_policy(&self) -> RestartPolicy {
884 RestartPolicy::default()
885 }
886}
887
888pub(crate) fn opencode_run_tail(message: &str) -> Vec<String> {
913 if let Some(rest) = message.strip_prefix('/') {
914 let mut parts = rest.splitn(2, ' ');
915 if let Some(cmd) = parts.next().filter(|c| !c.is_empty()) {
917 let mut tail = vec!["--command".to_string(), cmd.to_string()];
918 if let Some(args) = parts.next().filter(|a| !a.is_empty()) {
919 tail.push(args.to_string());
920 }
921 return tail;
922 }
923 }
924 vec![message.to_string()]
925}
926
927pub struct OpencodeProvider;
928
929impl Provider for OpencodeProvider {
930 fn name(&self) -> &'static str {
931 "opencode"
932 }
933
934 fn create_argv(&self, ctx: &CreateContext) -> Vec<String> {
935 let mut argv = vec![
941 "opencode".into(),
942 "run".into(),
943 "--dangerously-skip-permissions".into(),
944 ];
945 argv.extend(opencode_run_tail(&ctx.message));
946 argv
947 }
948
949 fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String> {
950 let mut argv = vec![
952 "opencode".into(),
953 "run".into(),
954 "--dangerously-skip-permissions".into(),
955 "--session".into(),
956 ctx.session_id.clone(),
957 ];
958 argv.extend(opencode_run_tail(&ctx.message));
959 argv
960 }
961
962 fn parse_stream_event(&self, chunk: &str) -> ParsedEvent {
963 if chunk.trim().is_empty() {
966 ParsedEvent::Unknown {
967 raw: chunk.to_string(),
968 }
969 } else {
970 ParsedEvent::ReplyComplete {
971 text: chunk.to_string(),
972 duration_ms: 0,
973 }
974 }
975 }
976
977 fn reachability(
978 &self,
979 entry: &AgentEntry,
980 timeout: Duration,
981 ) -> Result<bool, ReachabilityProbeError> {
982 opencode_reachable_with(
983 entry,
984 timeout.max(OPENCODE_PROBE_MIN_BUDGET),
985 &(run_opencode_db as OpencodeDbRunner),
986 )
987 }
988
989 fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
990 Some(self)
991 }
992}
993
994type OpencodeDbRunner = fn(&str, Duration) -> Result<(bool, String), String>;
997
998const OPENCODE_PROBE_MIN_BUDGET: Duration = Duration::from_secs(2);
1004
1005fn is_opencode_session_id(s: &str) -> bool {
1009 match s.strip_prefix("ses_") {
1010 Some(tail) => !tail.is_empty() && tail.chars().all(|c| c.is_ascii_alphanumeric()),
1011 None => false,
1012 }
1013}
1014
1015fn opencode_reachable_with(
1031 entry: &AgentEntry,
1032 timeout: Duration,
1033 run: &OpencodeDbRunner,
1034) -> Result<bool, ReachabilityProbeError> {
1035 let sid = entry
1036 .session_id
1037 .as_deref()
1038 .filter(|s| !s.is_empty())
1039 .ok_or_else(|| ReachabilityProbeError::new("opencode", "no session id in entry"))?;
1040 if !is_opencode_session_id(sid) {
1041 return Err(ReachabilityProbeError::new(
1042 "opencode",
1043 format!("malformed opencode session id {sid:?} (expected ses_<alnum>)"),
1044 ));
1045 }
1046 let sql = format!("select id from session where id='{sid}'");
1047 match run(&sql, timeout) {
1048 Ok((true, stdout)) => Ok(stdout.contains(sid)),
1049 Ok((false, _)) => Err(ReachabilityProbeError::new(
1050 "opencode",
1051 "`opencode db` exited nonzero (store unreadable or query rejected)",
1052 )),
1053 Err(e) => Err(ReachabilityProbeError::new(
1054 "opencode",
1055 format!("cannot run `opencode db`: {e}"),
1056 )),
1057 }
1058}
1059
1060fn run_opencode_db(sql: &str, timeout: Duration) -> Result<(bool, String), String> {
1071 use std::process::{Command, Stdio};
1072 let mut child = Command::new("opencode")
1073 .arg("db")
1074 .arg(sql)
1075 .stdin(Stdio::null())
1076 .stdout(Stdio::piped())
1077 .stderr(Stdio::null())
1078 .spawn()
1079 .map_err(|e| e.to_string())?;
1080 let deadline = Instant::now() + timeout;
1081 loop {
1082 match child.try_wait() {
1083 Ok(Some(_)) => break,
1084 Ok(None) => {
1085 if Instant::now() >= deadline {
1086 let _ = child.kill();
1087 let _ = child.wait();
1088 return Err(format!("probe timed out after {timeout:?}"));
1089 }
1090 std::thread::sleep(Duration::from_millis(25));
1091 }
1092 Err(e) => {
1093 let _ = child.kill();
1096 let _ = child.wait();
1097 return Err(e.to_string());
1098 }
1099 }
1100 }
1101 let out = child.wait_with_output().map_err(|e| e.to_string())?;
1102 Ok((
1103 out.status.success(),
1104 String::from_utf8_lossy(&out.stdout).into_owned(),
1105 ))
1106}
1107
1108impl ProviderWithPty for OpencodeProvider {
1109 fn readiness_detector(&self) -> Box<dyn ReadinessDetector> {
1110 Box::new(OpencodeReadinessDetector)
1111 }
1112
1113 fn envelope(&self) -> Box<dyn Envelope> {
1114 Box::new(NoEnvelope)
1116 }
1117
1118 fn default_restart_policy(&self) -> RestartPolicy {
1119 RestartPolicy::default()
1120 }
1121}
1122
1123fn parse_gemini_blob(blob: &str) -> ParsedEvent {
1136 let v: serde_json::Value = match serde_json::from_str(blob.trim()) {
1137 Ok(v) => v,
1138 Err(_) => {
1139 return ParsedEvent::Unknown {
1140 raw: blob.to_string(),
1141 }
1142 }
1143 };
1144 if let Some(resp) = v.get("response").and_then(|r| r.as_str()) {
1148 return ParsedEvent::ReplyComplete {
1149 text: resp.to_string(),
1150 duration_ms: gemini_total_latency_ms(&v),
1151 };
1152 }
1153 if let Some(sid) = v.get("session_id").and_then(|s| s.as_str()) {
1154 return ParsedEvent::SessionCreated {
1155 session_id: sid.to_string(),
1156 };
1157 }
1158 ParsedEvent::Unknown {
1159 raw: blob.to_string(),
1160 }
1161}
1162
1163fn gemini_total_latency_ms(v: &serde_json::Value) -> u64 {
1166 let Some(models) = v
1167 .get("stats")
1168 .and_then(|s| s.get("models"))
1169 .and_then(|m| m.as_object())
1170 else {
1171 return 0;
1172 };
1173 models
1174 .values()
1175 .filter_map(|m| m.get("api"))
1176 .filter_map(|api| api.get("totalLatencyMs"))
1177 .filter_map(|l| l.as_u64())
1178 .sum()
1179}
1180
1181fn home_dir() -> Option<PathBuf> {
1186 std::env::var_os("HOME").map(PathBuf::from)
1187}
1188
1189pub fn gemini_session_id_from_blob(blob: &str) -> Option<String> {
1196 serde_json::from_str::<serde_json::Value>(blob.trim())
1197 .ok()?
1198 .get("session_id")?
1199 .as_str()
1200 .map(|s| s.to_string())
1201}
1202
1203pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "agy", "opencode"];
1216
1217pub fn known_providers_csv() -> String {
1219 KNOWN_PROVIDERS.join(", ")
1220}
1221
1222pub fn for_name(name: &str) -> Option<Box<dyn Provider>> {
1228 match name {
1229 "claude" => Some(Box::new(ClaudeProvider)),
1230 "codex" => Some(Box::new(CodexProvider)),
1231 "gemini" => Some(Box::new(GeminiProvider)),
1232 "agy" => Some(Box::new(AgyProvider)),
1233 "opencode" => Some(Box::new(OpencodeProvider)),
1234 _ => None,
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241
1242 fn create_ctx() -> CreateContext {
1243 CreateContext {
1244 name: "worker-A".into(),
1245 message: "build feature X".into(),
1246 cwd: PathBuf::from("/tmp/example-repo"),
1247 from_name: None,
1248 session_id: None,
1249 yolo: false,
1250 reasoning_effort: None,
1251 append_system_prompt: None,
1252 }
1253 }
1254
1255 #[test]
1258 fn claude_create_argv_uses_bg_not_print() {
1259 let argv = ClaudeProvider.create_argv(&create_ctx());
1260 assert_eq!(
1261 argv,
1262 vec!["claude", "--bg", "--name", "worker-A", "build feature X"]
1263 );
1264 assert!(!argv.iter().any(|a| a == "-p"), "LD38: never claude -p");
1265 }
1266
1267 #[test]
1268 fn claude_resume_argv_is_resume_print() {
1269 let ctx = ResumeContext {
1270 session_id: "7c5dcf5d".into(),
1271 message: "follow up".into(),
1272 cwd: PathBuf::from("/x"),
1273 from_name: None,
1274 yolo: false,
1275 };
1276 assert_eq!(
1277 ClaudeProvider.resume_argv(&ctx),
1278 vec!["claude", "--resume", "7c5dcf5d", "--print", "follow up"]
1279 );
1280 }
1281
1282 #[test]
1283 fn claude_stream_json_resume_argv_uses_p_and_full_uuid() {
1284 let argv = claude_stream_json_resume_argv("019e7157-4236-7bb1-b274-ebbac6040ace");
1289 assert_eq!(
1290 argv,
1291 vec![
1292 "claude",
1293 "-p",
1294 "--resume",
1295 "019e7157-4236-7bb1-b274-ebbac6040ace",
1296 "--input-format",
1297 "stream-json",
1298 "--output-format",
1299 "stream-json",
1300 "--include-partial-messages",
1301 "--replay-user-messages",
1302 ]
1303 );
1304 }
1305
1306 #[test]
1307 fn codex_create_argv_defaults_to_workspace_write_sandbox() {
1308 let argv = CodexProvider.create_argv(&create_ctx());
1309 assert_eq!(
1310 argv,
1311 vec![
1312 "codex",
1313 "exec",
1314 "--json",
1315 "-C",
1316 "/tmp/example-repo",
1317 "--skip-git-repo-check",
1318 "--sandbox",
1319 "workspace-write",
1320 "build feature X"
1321 ]
1322 );
1323 }
1324
1325 #[test]
1326 fn codex_create_argv_yolo_is_mutually_exclusive_with_sandbox() {
1327 let mut ctx = create_ctx();
1328 ctx.yolo = true;
1329 let argv = CodexProvider.create_argv(&ctx);
1330 assert!(argv.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1331 assert!(!argv.iter().any(|a| a == "--sandbox"));
1332 }
1333
1334 #[test]
1335 fn codex_create_argv_appends_reasoning_effort() {
1336 let mut ctx = create_ctx();
1337 ctx.reasoning_effort = Some("high".into());
1338 let argv = CodexProvider.create_argv(&ctx);
1339 assert!(argv
1340 .windows(2)
1341 .any(|w| w == ["-c", "model_reasoning_effort=high"]));
1342 }
1343
1344 #[test]
1345 fn codex_create_and_resume_normalize_direct_slash_commands() {
1346 let mut create = create_ctx();
1347 create.message = " /fno:target x-81ad ".into();
1348 assert_eq!(
1349 CodexProvider
1350 .create_argv(&create)
1351 .last()
1352 .map(String::as_str),
1353 Some("$fno:target x-81ad")
1354 );
1355
1356 let resume = ResumeContext {
1357 session_id: "uuid-1".into(),
1358 message: " /fno:target x-81ad ".into(),
1359 cwd: PathBuf::from("/x"),
1360 from_name: None,
1361 yolo: false,
1362 };
1363 assert_eq!(
1364 CodexProvider
1365 .resume_argv(&resume)
1366 .last()
1367 .map(String::as_str),
1368 Some("$fno:target x-81ad")
1369 );
1370
1371 assert_eq!(
1372 normalize_codex_command(" review this\n code "),
1373 " review this\n code "
1374 );
1375 }
1376
1377 #[test]
1378 fn codex_resume_argv_omits_sandbox_unless_yolo() {
1379 let ctx = ResumeContext {
1380 session_id: "uuid-1".into(),
1381 message: "m".into(),
1382 cwd: PathBuf::from("/x"),
1383 from_name: None,
1384 yolo: false,
1385 };
1386 assert_eq!(
1387 CodexProvider.resume_argv(&ctx),
1388 vec![
1389 "codex",
1390 "exec",
1391 "resume",
1392 "uuid-1",
1393 "--json",
1394 "--skip-git-repo-check",
1395 "m"
1396 ]
1397 );
1398 }
1399
1400 #[test]
1401 fn gemini_create_argv_passes_session_id_and_default_approval() {
1402 let mut ctx = create_ctx();
1403 ctx.session_id = Some("uuid-g".into());
1404 let argv = GeminiProvider.create_argv(&ctx);
1405 assert_eq!(
1406 argv,
1407 vec![
1408 "gemini",
1409 "--skip-trust",
1410 "-p",
1411 "build feature X",
1412 "--output-format",
1413 "json",
1414 "--approval-mode",
1415 "default",
1416 "--session-id",
1417 "uuid-g"
1418 ]
1419 );
1420 }
1421
1422 #[test]
1423 fn gemini_resume_argv_uses_resume_flag() {
1424 let ctx = ResumeContext {
1425 session_id: "uuid-g".into(),
1426 message: "m".into(),
1427 cwd: PathBuf::from("/x"),
1428 from_name: None,
1429 yolo: true,
1430 };
1431 let argv = GeminiProvider.resume_argv(&ctx);
1432 assert_eq!(
1433 argv,
1434 vec![
1435 "gemini",
1436 "--skip-trust",
1437 "-p",
1438 "m",
1439 "--output-format",
1440 "json",
1441 "--yolo",
1442 "--resume",
1443 "uuid-g"
1444 ]
1445 );
1446 }
1447
1448 #[test]
1453 fn claude_is_not_pty_managed_others_are() {
1454 assert!(ClaudeProvider.as_pty().is_none());
1457 assert!(ClaudeInteractiveProvider.as_pty().is_some());
1458 assert!(CodexProvider.as_pty().is_some());
1459 assert!(GeminiProvider.as_pty().is_some());
1460 }
1461
1462 #[test]
1467 fn opencode_create_argv_is_headless_run_never_bare_tui() {
1468 let argv = OpencodeProvider.create_argv(&create_ctx());
1472 assert_eq!(
1473 argv,
1474 vec![
1475 "opencode",
1476 "run",
1477 "--dangerously-skip-permissions",
1478 "build feature X"
1479 ]
1480 );
1481 }
1482
1483 #[test]
1484 fn opencode_create_argv_routes_slash_command_via_command_flag() {
1485 let mut ctx = create_ctx();
1489 ctx.message = "/fno:target no-merge x-abcd".into();
1490 assert_eq!(
1491 OpencodeProvider.create_argv(&ctx),
1492 vec![
1493 "opencode",
1494 "run",
1495 "--dangerously-skip-permissions",
1496 "--command",
1497 "fno:target",
1498 "no-merge x-abcd"
1499 ]
1500 );
1501 }
1502
1503 #[test]
1504 fn opencode_run_tail_prose_through_and_bare_verb() {
1505 assert_eq!(
1507 opencode_run_tail("build feature X"),
1508 vec!["build feature X"]
1509 );
1510 assert_eq!(opencode_run_tail("/fno:pr"), vec!["--command", "fno:pr"]);
1511 }
1512
1513 #[test]
1514 fn opencode_resume_argv_uses_session_flag() {
1515 let ctx = ResumeContext {
1516 session_id: "ses_abc".into(),
1517 message: "m".into(),
1518 cwd: PathBuf::from("/x"),
1519 from_name: None,
1520 yolo: false,
1521 };
1522 assert_eq!(
1523 OpencodeProvider.resume_argv(&ctx),
1524 vec![
1525 "opencode",
1526 "run",
1527 "--dangerously-skip-permissions",
1528 "--session",
1529 "ses_abc",
1530 "m"
1531 ]
1532 );
1533 }
1534
1535 #[test]
1536 fn opencode_is_pty_managed_and_id_less_probe_is_inconclusive() {
1537 assert!(OpencodeProvider.as_pty().is_some());
1538 let entry = AgentEntry {
1542 name: "oc".into(),
1543 provider: "opencode".into(),
1544 session_id: None,
1545 cwd: PathBuf::from("/x"),
1546 };
1547 assert!(OpencodeProvider
1548 .reachability(&entry, Duration::from_secs(1))
1549 .is_err());
1550 }
1551
1552 #[test]
1553 fn for_name_round_trips_every_known_provider() {
1554 for name in KNOWN_PROVIDERS.iter().copied() {
1563 let p = for_name(name).unwrap_or_else(|| panic!("for_name({name}) returned None"));
1564 assert_eq!(
1565 p.name(),
1566 name,
1567 "for_name({name}) resolved to wrong provider"
1568 );
1569 }
1570 assert!(for_name("nope").is_none(), "unknown provider must be None");
1571 }
1572
1573 #[test]
1576 fn claude_parses_short_id_from_bg_line() {
1577 let ev = ClaudeProvider.parse_stream_event("backgrounded · 7c5dcf5d · worker-A");
1578 assert_eq!(
1579 ev,
1580 ParsedEvent::SessionCreated {
1581 session_id: "7c5dcf5d".into()
1582 }
1583 );
1584 }
1585
1586 #[test]
1587 fn claude_non_id_line_is_unknown() {
1588 assert!(matches!(
1589 ClaudeProvider.parse_stream_event("starting up"),
1590 ParsedEvent::Unknown { .. }
1591 ));
1592 assert!(matches!(
1594 ClaudeProvider.parse_stream_event("ABCDEF12"),
1595 ParsedEvent::Unknown { .. }
1596 ));
1597 }
1598
1599 #[test]
1602 fn codex_thread_started_is_session_created() {
1603 let ev = parse_codex_line(
1604 r#"{"type":"thread.started","thread_id":"019e4958-80d1-7492-8054-2854dfda502c"}"#,
1605 );
1606 assert_eq!(
1607 ev,
1608 ParsedEvent::SessionCreated {
1609 session_id: "019e4958-80d1-7492-8054-2854dfda502c".into()
1610 }
1611 );
1612 }
1613
1614 #[test]
1615 fn codex_agent_message_is_output_chunk() {
1616 let ev = parse_codex_line(
1617 r#"{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"hello"}}"#,
1618 );
1619 assert_eq!(
1620 ev,
1621 ParsedEvent::OutputChunk {
1622 text: "hello".into()
1623 }
1624 );
1625 }
1626
1627 #[test]
1628 fn codex_error_item_is_provider_error() {
1629 let ev = parse_codex_line(
1630 r#"{"type":"item.completed","item":{"id":"item_0","type":"error","message":"boom"}}"#,
1631 );
1632 assert_eq!(
1633 ev,
1634 ParsedEvent::ProviderError {
1635 message: "boom".into()
1636 }
1637 );
1638 }
1639
1640 #[test]
1641 fn codex_command_execution_is_tool_use() {
1642 let ev = parse_codex_line(
1643 r#"{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"echo hi"}}"#,
1644 );
1645 match ev {
1646 ParsedEvent::ToolUse { name, args } => {
1647 assert_eq!(name, "command_execution");
1648 assert_eq!(args.unwrap()["command"], "echo hi");
1649 }
1650 other => panic!("expected ToolUse, got {other:?}"),
1651 }
1652 }
1653
1654 #[test]
1655 fn codex_turn_completed_is_reply_complete_marker() {
1656 let ev = parse_codex_line(r#"{"type":"turn.completed","usage":{"output_tokens":91}}"#);
1657 assert_eq!(
1658 ev,
1659 ParsedEvent::ReplyComplete {
1660 text: String::new(),
1661 duration_ms: 0
1662 }
1663 );
1664 }
1665
1666 #[test]
1667 fn codex_preamble_and_control_frames_are_unknown() {
1668 assert!(matches!(
1669 parse_codex_line("Reading additional input from stdin..."),
1670 ParsedEvent::Unknown { .. }
1671 ));
1672 assert!(matches!(
1673 parse_codex_line(r#"{"type":"turn.started"}"#),
1674 ParsedEvent::Unknown { .. }
1675 ));
1676 }
1677
1678 #[test]
1681 fn gemini_blob_response_is_reply_complete_with_latency() {
1682 let blob = r#"{
1683 "session_id": "abc",
1684 "response": "PONG",
1685 "stats": {"models": {"gemini-3.1-flash-lite": {"api": {"totalLatencyMs": 3359}}}}
1686 }"#;
1687 assert_eq!(
1688 parse_gemini_blob(blob),
1689 ParsedEvent::ReplyComplete {
1690 text: "PONG".into(),
1691 duration_ms: 3359
1692 }
1693 );
1694 }
1695
1696 #[test]
1697 fn gemini_latency_sums_across_models() {
1698 let blob = r#"{
1699 "response": "ok",
1700 "stats": {"models": {
1701 "m1": {"api": {"totalLatencyMs": 100}},
1702 "m2": {"api": {"totalLatencyMs": 250}}
1703 }}
1704 }"#;
1705 assert_eq!(
1706 parse_gemini_blob(blob),
1707 ParsedEvent::ReplyComplete {
1708 text: "ok".into(),
1709 duration_ms: 350
1710 }
1711 );
1712 }
1713
1714 #[test]
1715 fn gemini_session_only_blob_is_session_created() {
1716 let ev = parse_gemini_blob(r#"{"session_id":"xyz"}"#);
1717 assert_eq!(
1718 ev,
1719 ParsedEvent::SessionCreated {
1720 session_id: "xyz".into()
1721 }
1722 );
1723 }
1724
1725 #[test]
1726 fn gemini_partial_or_garbage_is_unknown() {
1727 assert!(matches!(
1728 parse_gemini_blob(r#"{"session_id": "incomplete"#),
1729 ParsedEvent::Unknown { .. }
1730 ));
1731 }
1732
1733 #[test]
1734 fn gemini_session_id_recoverable_from_create_blob_even_with_reply() {
1735 let blob = r#"{"session_id":"abc-123","response":"hi","stats":{}}"#;
1738 assert_eq!(
1739 parse_gemini_blob(blob),
1740 ParsedEvent::ReplyComplete {
1741 text: "hi".into(),
1742 duration_ms: 0
1743 }
1744 );
1745 assert_eq!(gemini_session_id_from_blob(blob), Some("abc-123".into()));
1746 assert_eq!(gemini_session_id_from_blob("not json"), None);
1747 assert_eq!(gemini_session_id_from_blob(r#"{"response":"x"}"#), None);
1748 }
1749
1750 #[test]
1753 fn reachability_no_session_id_is_inconclusive() {
1754 let entry = AgentEntry {
1755 name: "a".into(),
1756 provider: "codex".into(),
1757 session_id: None,
1758 cwd: PathBuf::from("/x"),
1759 };
1760 let err = CodexProvider
1761 .reachability(&entry, Duration::from_millis(250))
1762 .unwrap_err();
1763 assert_eq!(err.provider, "codex");
1764 }
1765
1766 fn codex_entry(session_id: &str) -> AgentEntry {
1767 AgentEntry {
1768 name: "a".into(),
1769 provider: "codex".into(),
1770 session_id: Some(session_id.into()),
1771 cwd: PathBuf::from("/x"),
1772 }
1773 }
1774
1775 const OC_SES: &str = "ses_09679f284ffeJv7NdBAoLQLnLZ";
1780
1781 fn opencode_entry(session_id: Option<&str>) -> AgentEntry {
1782 AgentEntry {
1783 name: "o".into(),
1784 provider: "opencode".into(),
1785 session_id: session_id.map(Into::into),
1786 cwd: PathBuf::from("/x"),
1787 }
1788 }
1789
1790 #[test]
1791 fn opencode_reachable_when_store_returns_the_id() {
1792 fn run(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1795 Ok((
1796 true,
1797 format!("[claude-mem] OpenCode plugin loading\nid\n{OC_SES}\n"),
1798 ))
1799 }
1800 assert_eq!(
1801 opencode_reachable_with(
1802 &opencode_entry(Some(OC_SES)),
1803 Duration::from_secs(2),
1804 &(run as OpencodeDbRunner)
1805 ),
1806 Ok(true)
1807 );
1808 }
1809
1810 #[test]
1811 fn opencode_probe_embeds_only_the_validated_id_in_the_query() {
1812 use std::sync::{Mutex, OnceLock};
1813 static SEEN: OnceLock<Mutex<String>> = OnceLock::new();
1814 fn run(sql: &str, _t: Duration) -> Result<(bool, String), String> {
1815 *SEEN.get_or_init(Default::default).lock().unwrap() = sql.to_string();
1816 Ok((true, OC_SES.to_string()))
1817 }
1818 let _ = opencode_reachable_with(
1819 &opencode_entry(Some(OC_SES)),
1820 Duration::from_secs(2),
1821 &(run as OpencodeDbRunner),
1822 );
1823 assert_eq!(
1824 *SEEN.get_or_init(Default::default).lock().unwrap(),
1825 format!("select id from session where id='{OC_SES}'")
1826 );
1827 }
1828
1829 #[test]
1830 fn opencode_clean_query_without_the_id_is_gone() {
1831 fn run(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1833 Ok((true, String::new()))
1834 }
1835 assert_eq!(
1836 opencode_reachable_with(
1837 &opencode_entry(Some(OC_SES)),
1838 Duration::from_secs(2),
1839 &(run as OpencodeDbRunner)
1840 ),
1841 Ok(false)
1842 );
1843 }
1844
1845 #[test]
1846 fn opencode_infrastructure_failure_is_inconclusive_never_gone() {
1847 fn spawn_failed(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1850 Err("No such file or directory (os error 2)".into())
1851 }
1852 fn nonzero(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1853 Ok((false, String::new()))
1854 }
1855 for run in [
1856 spawn_failed as OpencodeDbRunner,
1857 nonzero as OpencodeDbRunner,
1858 ] {
1859 let err = opencode_reachable_with(
1860 &opencode_entry(Some(OC_SES)),
1861 Duration::from_secs(2),
1862 &run,
1863 )
1864 .unwrap_err();
1865 assert_eq!(err.provider, "opencode");
1866 }
1867 }
1868
1869 #[test]
1870 fn opencode_malformed_id_never_reaches_the_subprocess() {
1871 fn run(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1872 panic!("probe must reject a malformed id before spawning");
1873 }
1874 for bad in ["ses_'; drop table session--", "ses_ x", "ses_", "not-a-ses"] {
1875 let err = opencode_reachable_with(
1876 &opencode_entry(Some(bad)),
1877 Duration::from_secs(2),
1878 &(run as OpencodeDbRunner),
1879 )
1880 .unwrap_err();
1881 assert!(err.reason.contains(bad), "reason should quote {bad:?}");
1882 }
1883 }
1884
1885 #[test]
1886 fn opencode_missing_session_id_is_inconclusive() {
1887 fn run(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1888 panic!("no id means nothing to probe");
1889 }
1890 assert!(opencode_reachable_with(
1891 &opencode_entry(None),
1892 Duration::from_secs(2),
1893 &(run as OpencodeDbRunner)
1894 )
1895 .is_err());
1896 }
1897
1898 #[test]
1899 fn opencode_probe_is_stateless_across_calls() {
1900 fn failing(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1903 Err("binary briefly unavailable".into())
1904 }
1905 fn healthy(_sql: &str, _t: Duration) -> Result<(bool, String), String> {
1906 Ok((true, OC_SES.to_string()))
1907 }
1908 let entry = opencode_entry(Some(OC_SES));
1909 assert!(opencode_reachable_with(
1910 &entry,
1911 Duration::from_secs(2),
1912 &(failing as OpencodeDbRunner)
1913 )
1914 .is_err());
1915 assert_eq!(
1916 opencode_reachable_with(
1917 &entry,
1918 Duration::from_secs(2),
1919 &(healthy as OpencodeDbRunner)
1920 ),
1921 Ok(true)
1922 );
1923 }
1924
1925 #[test]
1926 fn codex_reachable_when_id_in_session_index() {
1927 let tmp = tempdir();
1928 let idx = tmp.join(".codex").join("session_index.jsonl");
1929 std::fs::create_dir_all(idx.parent().unwrap()).unwrap();
1930 std::fs::write(
1931 &idx,
1932 "{\"id\":\"019e4958-80d1-7492-8054-2854dfda502c\",\"status\":\"live\"}\n",
1933 )
1934 .unwrap();
1935 with_home(&tmp, || {
1936 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1937 assert_eq!(
1938 CodexProvider.reachability(&entry, Duration::from_secs(2)),
1939 Ok(true)
1940 );
1941 });
1942 }
1943
1944 #[test]
1945 fn codex_index_present_id_absent_is_false() {
1946 let tmp = tempdir();
1949 let idx = tmp.join(".codex").join("session_index.jsonl");
1950 std::fs::create_dir_all(idx.parent().unwrap()).unwrap();
1951 std::fs::write(&idx, "{\"id\":\"some-other-uuid\"}\n").unwrap();
1952 with_home(&tmp, || {
1953 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1954 assert_eq!(
1955 CodexProvider.reachability(&entry, Duration::from_secs(2)),
1956 Ok(false)
1957 );
1958 });
1959 }
1960
1961 #[test]
1962 fn codex_index_absent_is_inconclusive() {
1963 let tmp = tempdir();
1964 with_home(&tmp, || {
1965 let entry = codex_entry("019e4958-80d1-7492-8054-2854dfda502c");
1966 assert!(CodexProvider
1968 .reachability(&entry, Duration::from_secs(2))
1969 .is_err());
1970 });
1971 }
1972
1973 fn gemini_entry(session_id: &str, cwd: &str) -> AgentEntry {
1974 AgentEntry {
1975 name: "g".into(),
1976 provider: "gemini".into(),
1977 session_id: Some(session_id.into()),
1978 cwd: PathBuf::from(cwd),
1979 }
1980 }
1981
1982 const G_UUID: &str = "35624650-b11e-4300-ad85-0fc87baeb3af";
1983
1984 #[test]
1985 fn gemini_reachability_is_cwd_pinned_and_verifies_full_uuid() {
1986 let tmp = tempdir();
1987 let chats = tmp
1990 .join(".gemini")
1991 .join("tmp")
1992 .join("myproject")
1993 .join("chats");
1994 std::fs::create_dir_all(&chats).unwrap();
1995 std::fs::write(
1996 chats.join("session-35624650.json"),
1997 format!("{{\"sessionId\":\"{G_UUID}\",\"messages\":[]}}\n").as_bytes(),
1998 )
1999 .unwrap();
2000 with_home(&tmp, || {
2001 let entry = gemini_entry(G_UUID, "/work/myproject");
2002 assert_eq!(
2003 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
2004 Ok(true)
2005 );
2006 let other = gemini_entry(G_UUID, "/work/elsewhere");
2008 assert!(GeminiProvider
2009 .reachability(&other, Duration::from_secs(2))
2010 .is_err()); });
2012 }
2013
2014 #[test]
2015 fn gemini_short_prefix_collision_without_full_uuid_is_false() {
2016 let tmp = tempdir();
2019 let chats = tmp.join(".gemini").join("tmp").join("proj").join("chats");
2020 std::fs::create_dir_all(&chats).unwrap();
2021 std::fs::write(
2022 chats.join("session-35624650.json"),
2023 b"{\"sessionId\":\"35624650-ffff-ffff-ffff-ffffffffffff\"}\n",
2024 )
2025 .unwrap();
2026 with_home(&tmp, || {
2027 let entry = gemini_entry(G_UUID, "/x/proj");
2028 assert_eq!(
2029 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
2030 Ok(false)
2031 );
2032 });
2033 }
2034
2035 #[test]
2036 fn gemini_reachability_chats_present_no_match_is_false() {
2037 let tmp = tempdir();
2038 let chats = tmp.join(".gemini").join("tmp").join("proj").join("chats");
2039 std::fs::create_dir_all(&chats).unwrap();
2040 std::fs::write(chats.join("session-deadbeef.json"), b"{}").unwrap();
2041 with_home(&tmp, || {
2042 let entry = gemini_entry("00000000-1111-2222-3333-444444444444", "/x/proj");
2043 assert_eq!(
2044 GeminiProvider.reachability(&entry, Duration::from_secs(2)),
2045 Ok(false)
2046 );
2047 });
2048 }
2049
2050 #[test]
2051 fn gemini_reachability_short_session_id_is_inconclusive() {
2052 let entry = gemini_entry("uuid", "/x/proj");
2053 let err = GeminiProvider
2054 .reachability(&entry, Duration::from_millis(250))
2055 .unwrap_err();
2056 assert_eq!(err.provider, "gemini");
2057 assert!(err.reason.contains("too short"));
2058 }
2059
2060 fn tempdir() -> PathBuf {
2063 let mut p = std::env::temp_dir();
2064 let unique = format!(
2065 "fno-agents-test-{}-{}",
2066 std::process::id(),
2067 std::time::SystemTime::now()
2068 .duration_since(std::time::UNIX_EPOCH)
2069 .unwrap()
2070 .as_nanos()
2071 );
2072 p.push(unique);
2073 std::fs::create_dir_all(&p).unwrap();
2074 p
2075 }
2076
2077 static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2081
2082 fn with_home(home: &std::path::Path, f: impl FnOnce()) {
2086 let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2088 let prev = std::env::var_os("HOME");
2089 std::env::set_var("HOME", home);
2090 f();
2091 match prev {
2092 Some(v) => std::env::set_var("HOME", v),
2093 None => std::env::remove_var("HOME"),
2094 }
2095 }
2096}