Skip to main content

fno_agents/
provider.rs

1//! The `Provider` abstraction (design module `provider.rs`, LD8).
2//!
3//! Provider knowledge lives ONLY here. AgentRelay's mistake was spreading
4//! per-CLI logic across `command_parse.rs` + `readiness.rs` + `injection_format.rs`;
5//! this trait absorbs all of it into one source-of-truth file. Nothing crosses
6//! module boundaries except via the well-defined types in [`crate`].
7//!
8//! Two traits, per the design:
9//!
10//! - [`Provider`]: every provider implements it (argv construction, stream-event
11//!   parsing, reachability probe).
12//! - [`ProviderWithPty`]: the PTY-managed extension (readiness detector,
13//!   anti-injection envelope, restart policy). [`Provider::as_pty`] returns
14//!   `Option<&dyn ProviderWithPty>` so a non-PTY provider's status is visible in
15//!   the type system rather than expressed as no-op impls (LD8 / Rejected
16//!   Alternative 7).
17//!
18//! Phase 6 ships three impls:
19//! - [`ClaudeProvider`] — shellout to `claude --bg`; NOT PTY-managed
20//!   (`as_pty()` -> `None`). Billing posture LD38: `--bg`, never `claude -p`.
21//! - [`CodexProvider`] — full PTY-managed; JSONL stream parser.
22//! - [`GeminiProvider`] — full PTY-managed; single JSON blob at EOF (the
23//!   structural cleavage from codex established in US4-gemini).
24//!
25//! Argv shapes mirror the validated US4 Python adapters
26//! (`cli/src/fno/agents/providers/{claude,codex,gemini}.py`) so the Rust
27//! daemon invokes the CLIs identically to the proven implementations.
28
29use 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/// Inputs for a fresh spawn (`fno agents spawn`).
38#[derive(Debug, Clone)]
39pub struct CreateContext {
40    pub name: String,
41    pub message: String,
42    pub cwd: PathBuf,
43    /// Operator/peer attribution, threaded into the envelope on PTY paths.
44    pub from_name: Option<String>,
45    /// Caller-assigned session id. codex/gemini accept a pre-assigned UUID;
46    /// claude assigns its own short-id (so this is `None` for claude create).
47    pub session_id: Option<String>,
48    /// Yolo / sandbox-bypass opt-in (codex/gemini); maps to provider-specific
49    /// flags. Claude ignores it.
50    pub yolo: bool,
51}
52
53/// Inputs for continuing an existing session (`fno agents ask`).
54#[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/// Lean registry projection a reachability probe needs. Wave 3's full registry
64/// `AgentEntry` is a superset; the fields here are the load-bearing subset for
65/// [`Provider::reachability`] and are additive-compatible with the Wave 3 shape.
66#[derive(Debug, Clone)]
67pub struct AgentEntry {
68    pub name: String,
69    pub provider: String,
70    /// `None` when no session id was ever recorded (e.g. a create that failed
71    /// before the id was captured); reachability treats it as inconclusive.
72    pub session_id: Option<String>,
73    pub cwd: PathBuf,
74}
75
76/// Tri-state reachability probe failure (mirrors the Python
77/// `ReachabilityProbeError` contract from US4-gemini). An `Err` means the probe
78/// was **inconclusive** (store inaccessible, no session id), NOT that the agent
79/// is unreachable. Callers MUST NOT flip an agent to `orphaned` on `Err`; they
80/// preserve the prior status (Failure Modes / Errors invariant).
81#[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
97/// The central per-CLI abstraction. Send + Sync so the daemon can hold trait
98/// objects across tasks.
99pub trait Provider: Send + Sync {
100    /// Stable provider identifier (`"claude"` / `"codex"` / `"gemini"`).
101    fn name(&self) -> &'static str;
102
103    /// Argv for a fresh session.
104    fn create_argv(&self, ctx: &CreateContext) -> Vec<String>;
105
106    /// Argv for continuing a session.
107    fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String>;
108
109    /// Argv for a fresh *interactive* session (`host_mode = interactive`, the
110    /// `fno agents host` verb). Default `None`: claude has no abi-hosted
111    /// interactive form (it drives via its own `--bg` surface), so `host`
112    /// rejects `provider=claude` before reaching here. codex/gemini override.
113    /// Returning `None` keeps the "not interactive-hostable" fact in the type
114    /// system rather than as a panicking no-op (the same discipline as
115    /// [`Provider::as_pty`]).
116    fn create_interactive_argv(&self, _ctx: &CreateContext) -> Option<Vec<String>> {
117        None
118    }
119
120    /// Argv for resuming an existing session *interactively* (the
121    /// `fno agents promote --from <uuid>` verb). `ctx.session_id` is the resume
122    /// target UUID. Default `None` (claude); codex/gemini override.
123    fn resume_interactive_argv(&self, _ctx: &ResumeContext) -> Option<Vec<String>> {
124        None
125    }
126
127    /// Parse one unit of provider stream output into the sealed [`ParsedEvent`]
128    /// vocabulary. The unit is provider-shaped: codex is fed one JSONL line at a
129    /// time; gemini is fed its complete JSON blob (it emits a single document at
130    /// EOF, so per-line feeding yields [`ParsedEvent::Unknown`] until the daemon
131    /// has the whole blob). Unrecognized input becomes [`ParsedEvent::Unknown`]
132    /// rather than an error, so a provider version bump degrades gracefully.
133    fn parse_stream_event(&self, chunk: &str) -> ParsedEvent;
134
135    /// Probe whether `entry`'s session is still reachable. Returns `Ok(true)` /
136    /// `Ok(false)` for a definitive answer, `Err(ReachabilityProbeError)` when
137    /// the probe is inconclusive. `timeout` bounds any I/O the probe performs.
138    fn reachability(
139        &self,
140        entry: &AgentEntry,
141        timeout: Duration,
142    ) -> Result<bool, ReachabilityProbeError>;
143
144    /// Downcast to the PTY-managed extension, or `None` for shellout providers
145    /// (claude). The daemon's spawn handler matches on this to route between the
146    /// portable-pty path and the shellout path.
147    fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
148        None
149    }
150}
151
152/// PTY-managed extension of [`Provider`]. Implemented by codex / gemini (and
153/// Phase 7's OpenCode), NOT by claude.
154pub trait ProviderWithPty: Provider {
155    /// Per-CLI readiness signal over the terminal grid.
156    fn readiness_detector(&self) -> Box<dyn ReadinessDetector>;
157
158    /// Structural anti-injection envelope for input on the PTY stdin path.
159    fn envelope(&self) -> Box<dyn Envelope>;
160
161    /// Provider-recommended restart policy. The daemon's enforcer still imposes
162    /// the hard ceiling ([`crate::supervisor::HARD_FAILURE_CEILING`], LD36)
163    /// regardless of what a provider returns.
164    fn default_restart_policy(&self) -> RestartPolicy;
165}
166
167// ---------------------------------------------------------------------------
168// Claude — shellout, not PTY-managed (LD38 billing: `--bg`, never `-p`).
169// ---------------------------------------------------------------------------
170
171/// Claude provider. The daemon shells out to `claude --bg` (the per-user
172/// supervisor owns the session) and follows up over the Phase 5 messaging
173/// socket. [`as_pty`](Provider::as_pty) returns `None`.
174pub struct ClaudeProvider;
175
176/// The stream-json host-lane resume argv for claude adoption (Group 1,
177/// ab-5896938c). The daemon builds this to launch the per-session stream worker.
178///
179/// Unlike [`ClaudeProvider::create_argv`] (which uses `--bg`, the subscription
180/// lane), the stream-json host lane REQUIRES `claude -p`: `--input-format
181/// stream-json` only works with `--print`/`-p` (Domain Pitfall). Per Locked
182/// Decision 1 this is a DELIBERATE, resolved choice - `-p` draws a dedicated
183/// Agent SDK credit isolated from interactive limits - so it is NOT an LD38
184/// violation but the explicit, opt-in adoption lane. Resume keys on the FULL
185/// session UUID (`claude_session_uuid`), never the 8-hex jobId (a 32-bit prefix,
186/// not collision-proof). `--include-partial-messages` surfaces streamed tokens;
187/// `--replay-user-messages` echoes injected turns back as delivery receipts
188/// (the frame parser discriminates the echo from the reply).
189pub 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        // Mirrors claude.py `_build_argv`: `claude --bg --name <name> <message>`.
211        // LD38: `--bg` is the subscription-billed mode; `claude -p` is
212        // Agent-SDK-credit-billed and MUST NOT be used.
213        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        // Subprocess fallback form (`claude --resume <id> --print <msg>`). The
224        // production daemon prefers the Phase 5 messaging-socket poke when a
225        // `messaging_socket_path` is registered; this argv exists so the trait
226        // is satisfiable without the socket (e.g. tests, socket-unavailable
227        // degradation). `--print` is non-streaming (Domain Pitfall).
228        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        // `claude --bg` prints a single line like
239        // "backgrounded · 7c5dcf5d · <name>"; the only structured datum is the
240        // 8-hex short-id, which is the session id. Anything else is Unknown.
241        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        // Claude liveness is the supervisor's `~/.claude/jobs/<short_id>` dir.
255        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            // Supervisor never ran / store absent: inconclusive, not "dead".
266            return Err(ReachabilityProbeError::new(
267                "claude",
268                "~/.claude/jobs absent",
269            ));
270        }
271        Ok(jobs.join(short_id).exists())
272    }
273
274    // as_pty() uses the default None: claude is not PTY-managed.
275}
276
277/// Extract a claude `--bg` short-id (`^[0-9a-f]{8}$`) from a line like
278/// "backgrounded · 7c5dcf5d · name". Returns the first 8-hex token found.
279fn parse_claude_short_id(line: &str) -> Option<String> {
280    // Split on non-hexdigits, so every token is already all-hexdigit; we only
281    // need to reject the uppercase-hex case (claude short-ids are lowercase).
282    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
287// ---------------------------------------------------------------------------
288// Codex — full PTY-managed; JSONL stream.
289// ---------------------------------------------------------------------------
290
291/// Codex provider. Argv mirrors codex.py (`codex exec --json ...` /
292/// `codex exec resume <id> --json ...`); the stream parser is pinned to the
293/// codex 0.130 JSONL vocabulary captured in
294/// `cli/tests/agents/fixtures/codex-jsonl-sample.jsonl`.
295pub struct CodexProvider;
296
297impl CodexProvider {
298    fn sandbox_create(yolo: bool) -> Vec<String> {
299        // codex.py::sandbox_flag (LD5/LD6): mutually exclusive; never both.
300        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        // codex.py::sandbox_flag_resume: resume has no `--sandbox`; only the
309        // bypass flag is honored, else inherit the session's original mode.
310        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            // codex exec refuses to run in a non-git dir without this; the
331            // validated codex.py create path always passes it.
332            "--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        // Fresh interactive TUI. `codex [OPTIONS] [PROMPT]` with no subcommand
355        // is the interactive CLI (codex 0.133.0 top-level help: "If no
356        // subcommand is specified, options will be forwarded to the interactive
357        // CLI"). NOT `exec` (that's the one-shot --json path) and NO
358        // `--skip-git-repo-check` (exec-only). `-C` pins the working root the
359        // same way create_argv does; sandbox/yolo reuses sandbox_create so the
360        // human-driven default keeps codex's own approval UI for out-of-sandbox
361        // actions and `--yolo` maps to the bypass flag (Claude's Discretion 1,
362        // verified against codex-cli 0.133.0 global flags).
363        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        // Empty task -> bare interactive session (codex accepts no prompt).
370        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        // Promote an exited exec session to a live interactive TUI.
378        // `codex resume <uuid>` (the interactive resume subcommand, NOT
379        // `codex exec resume`). EMPIRICALLY VERIFIED (AC3-FR, 2026-05-29,
380        // codex-cli 0.133.0): a session born from `codex exec --json` resumes
381        // with full conversation history via `codex resume <uuid>` (the model
382        // recalled the prior turn). `--include-non-interactive` only governs
383        // the resume PICKER and `--last` selection per `codex resume --help`; an
384        // explicit positional UUID bypasses the picker ("UUIDs take precedence
385        // if it parses"), so the flag is REDUNDANT here. Per AC3-FR's fallback
386        // we include it anyway (harmless-when-redundant, belt-and-suspenders
387        // against a future codex that consults the picker for explicit ids).
388        // No `--sandbox` on resume (inherit the session's original mode); only
389        // the yolo bypass is honored, mirroring sandbox_resume.
390        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
420/// Codex reachability via the authoritative session index (mirrors codex.py
421/// `load_known_session_ids`). NOT a scan of `~/.codex/sessions/` — those are
422/// historical rollout artifacts that persist after a session is removed, so a
423/// file-existence scan would report a dead session `Ok(true)` and reconcile
424/// would never orphan it (Codex review P1). The index drops the id when the
425/// session ends, making membership the real liveness signal.
426///
427/// Tri-state: index missing -> `Err` (fresh install / can't determine, preserve
428/// status, never orphan); index unreadable -> `Err` (inconclusive); index
429/// present + id found -> `Ok(true)`; present + absent -> `Ok(false)`.
430fn 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
465/// Parse one codex JSONL line into a [`ParsedEvent`]. Non-JSON preamble (e.g.
466/// "Reading additional input from stdin...") and unrecognized event types map
467/// to [`ParsedEvent::Unknown`].
468fn 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                // codex delivers assistant text as discrete agent_message
501                // items; the daemon concatenates OutputChunks and treats
502                // turn.completed as the reply boundary.
503                "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        // Terminal marker for a turn. Reply text arrives via agent_message
527        // OutputChunks; codex's usage payload carries no wall-clock duration, so
528        // duration_ms is 0 and the daemon fills it from its own timing.
529        "turn.completed" => ParsedEvent::ReplyComplete {
530            text: String::new(),
531            duration_ms: 0,
532        },
533        // turn.started and any future control frame: not an error, just not a
534        // model-facing event.
535        _ => ParsedEvent::Unknown {
536            raw: line.to_string(),
537        },
538    }
539}
540
541// ---------------------------------------------------------------------------
542// Gemini — full PTY-managed; single JSON blob at EOF.
543// ---------------------------------------------------------------------------
544
545/// Gemini provider. Argv mirrors gemini.py
546/// (`gemini --skip-trust -p <msg> --output-format json --session-id <uuid>` /
547/// `... --resume <uuid>`). The parser consumes gemini's single JSON document
548/// (pinned to `cli/tests/agents/fixtures/gemini-json-sample.json`).
549pub struct GeminiProvider;
550
551impl GeminiProvider {
552    fn sandbox(yolo: bool) -> Vec<String> {
553        // gemini.py::sandbox_flag (Wave 2.0 OQ5).
554        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        // gemini accepts a caller-assigned session UUID on create.
578        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        // Fresh interactive gemini: `-i/--prompt-interactive` executes the
602        // prompt then stays interactive (gemini 0.42.0). NO `--output-format
603        // json` (that is the exec/parse path; interactive renders a raw TUI).
604        // `--skip-trust` avoids the workspace-trust prompt blocking the TUI, as
605        // on the exec path. sandbox/yolo reuses Self::sandbox (default keeps
606        // gemini's approval UI; --yolo bypasses it).
607        let mut argv = vec!["gemini".into(), "--skip-trust".into()];
608        // Empty task -> bare interactive session (omit `-i`, which requires a
609        // value); gemini opens interactive with no initial prompt.
610        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        // Promote: `gemini -r <uuid>` resumes a prior session into the
620        // interactive TUI. EMPIRICALLY VERIFIED (AC3-FR / Open Question 2,
621        // 2026-05-29, gemini 0.42.0): `gemini -r <full-uuid>` resumes a
622        // `-p`-created (exec) session and reports back the same session_id, so
623        // the UUID fno stores in `gemini_session_id` IS the `-r` target. (The
624        // `-r/--resume` help text only documents "latest"/index, but a full
625        // UUID resolves.) An optional `-i "<task>"` injects an initial prompt on
626        // resume; an empty task resumes with no new prompt.
627        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
658/// Gemini reachability, cwd-pinned to `~/.gemini/tmp/<cwd-basename>/chats` and
659/// matched on the session-id 8-char short prefix (mirrors gemini.py
660/// `_gemini_chats_dir` + `gemini_session_reachable`). Gemini's on-disk
661/// filenames carry the short prefix, NOT the full UUID, and the store is
662/// per-cwd; a full-UUID recursive scan would `Ok(false)` a live session and a
663/// caller would false-orphan it. `Err` is inconclusive (chats dir absent can't
664/// be distinguished from a fresh install without scanning every cwd).
665fn 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    // `.get(..8)` is None for a too-short id OR a non-UTF-8-boundary index 8,
672    // so a multibyte session_id can never panic on a hardcoded slice.
673    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    // Iterate WITHOUT `.flatten()`: a dropped per-entry read error would turn an
702    // inconclusive probe into a definitive Ok(false) and false-orphan a live
703    // session (Codex P2). Propagate as inconclusive instead.
704    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        // Short-prefix match is a candidate only. Verify the FULL UUID appears
722        // in the file's first line to defeat short-prefix collisions (Codex P2;
723        // mirrors gemini.py's first-line full-UUID check). Read only the first
724        // line - chat logs can be multi-MB. I/O errors stay inconclusive.
725        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
755/// Parse gemini's single JSON document. Gemini emits one blob at EOF (the
756/// structural cleavage from codex), so this expects the COMPLETE document;
757/// partial input parses as [`ParsedEvent::Unknown`].
758///
759/// A create-path blob can carry BOTH `session_id` and `response`. `parse_stream_event`
760/// returns exactly one [`ParsedEvent`], so this surfaces the reply
761/// ([`ParsedEvent::ReplyComplete`]) as the primary signal. On the create path
762/// the daemon (Wave 3) captures the session id from [`CreateContext::session_id`]
763/// (the daemon pre-assigns the UUID via `--session-id`, the design's default
764/// flow) or, in the rare no-pre-assignment case, from this same raw blob via
765/// [`gemini_session_id_from_blob`] before persisting the entry — so the id is
766/// never lost despite the single-event return (Codex review P2).
767fn 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    // A completed reply carries `response`; map to ReplyComplete with duration
777    // summed from per-model API latency. snake_case `session_id` per US4-gemini
778    // (NOT camelCase `sessionId`, which is gemini's internal storage shape).
779    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
795/// Sum `stats.models.<model>.api.totalLatencyMs` across all models. Returns 0
796/// when the stats block is absent or shaped unexpectedly (degrade, don't fail).
797fn 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
813// ---------------------------------------------------------------------------
814// Reachability helpers (HOME-relative; testable via $HOME override).
815// ---------------------------------------------------------------------------
816
817fn home_dir() -> Option<PathBuf> {
818    std::env::var_os("HOME").map(PathBuf::from)
819}
820
821/// Extract gemini's assigned `session_id` from a create-path JSON blob. The
822/// daemon (Wave 3) calls this on the create path when it did NOT pre-assign a
823/// UUID, so the session handle gemini chose is persisted even though
824/// [`parse_stream_event`](Provider::parse_stream_event) surfaces the reply
825/// rather than the id (single-event return; Codex review P2). Returns `None`
826/// when the blob is malformed or carries no `session_id`.
827pub 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
835/// Resolve a provider impl by its stable name (`"claude"` / `"codex"` /
836/// `"gemini"`). Returns `None` for an unknown provider so callers (e.g.
837/// reconcile) can treat the probe as inconclusive rather than guessing. The
838/// only place provider names map to impls — keeping provider knowledge in this
839/// one file (the LD8 discipline).
840pub 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    // ---- argv shapes ----
865
866    #[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        // The stream-json host lane resumes by the FULL UUID with -p +
894        // stream-json IO (the only flags that yield a drivable bidirectional
895        // pipe). -p here is the deliberate adoption lane (LD1), distinct from
896        // the --bg create path (LD38).
897        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    // ---- interactive argv (host_mode=interactive): host + promote ----
1015
1016    #[test]
1017    fn claude_has_no_interactive_argv() {
1018        // Type-system guard: claude is not abi-hostable interactively, so both
1019        // interactive argv builders default to None (host/promote reject claude
1020        // before reaching the provider).
1021        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        // Interactive must NOT carry the exec-only markers.
1051        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        // Interactive resume is `codex resume`, NOT `codex exec resume`.
1103        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        // Interactive renders a raw TUI: no exec JSON path.
1146        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    // ---- as_pty type-level routing ----
1210
1211    #[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 is the LD8 single registration point; a copy-paste slip
1221        // (e.g. "codex" => GeminiProvider) would pass every other test, so
1222        // assert each name resolves to a provider reporting that same name.
1223        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    // ---- claude short-id parse ----
1235
1236    #[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        // Uppercase hex is not a claude short-id (lowercase contract).
1254        assert!(matches!(
1255            ClaudeProvider.parse_stream_event("ABCDEF12"),
1256            ParsedEvent::Unknown { .. }
1257        ));
1258    }
1259
1260    // ---- codex JSONL parse (pinned to fixture vocabulary) ----
1261
1262    #[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    // ---- gemini blob parse ----
1340
1341    #[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        // parse_stream_event surfaces the reply (single-event return), but the
1397        // session id is still recoverable from the same blob for the create path.
1398        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    // ---- reachability tri-state (HOME-overridden) ----
1412
1413    #[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        // The id is NOT in the index -> the session ended (index drops it) ->
1458        // definitively orphaned, even if a historical session file still exists.
1459        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            // No session_index.jsonl (fresh install) -> inconclusive, never orphan.
1478            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        // Filename carries the 8-char short prefix; the FULL uuid must appear in
1499        // the file's first line for the probe to confirm (defeats collisions).
1500        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            // Same id but a DIFFERENT cwd must not find it (cwd-pinned).
1518            let other = gemini_entry(G_UUID, "/work/elsewhere");
1519            assert!(GeminiProvider
1520                .reachability(&other, Duration::from_secs(2))
1521                .is_err()); // chats dir for "elsewhere" absent -> inconclusive
1522        });
1523    }
1524
1525    #[test]
1526    fn gemini_short_prefix_collision_without_full_uuid_is_false() {
1527        // A different session shares the 8-char prefix but the file's full uuid
1528        // differs -> the content-verification step rejects it (Codex P2).
1529        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    // ---- test helpers (no external tempfile dep) ----
1572
1573    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    /// Process-global lock serializing $HOME mutation. cargo runs tests in
1589    /// parallel threads within one process; HOME is process-global, so two
1590    /// `with_home` calls would race without this guard.
1591    static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1592
1593    /// Run `f` with $HOME set to `home`, restoring the prior value after. The
1594    /// reachability helpers read HOME on each call; the lock makes the
1595    /// set -> run -> restore window atomic across parallel test threads.
1596    fn with_home(home: &std::path::Path, f: impl FnOnce()) {
1597        // Poisoning is irrelevant here (the guarded data is unit); recover it.
1598        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}