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, 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/// Inputs for a fresh spawn (`fno agents spawn`).
41#[derive(Debug, Clone)]
42pub struct CreateContext {
43    pub name: String,
44    pub message: String,
45    pub cwd: PathBuf,
46    /// Operator/peer attribution, threaded into the envelope on PTY paths.
47    pub from_name: Option<String>,
48    /// Caller-assigned session id. codex/gemini accept a pre-assigned UUID;
49    /// claude assigns its own short-id (so this is `None` for claude create).
50    pub session_id: Option<String>,
51    /// Yolo / sandbox-bypass opt-in (codex/gemini); maps to provider-specific
52    /// flags. Claude ignores it.
53    pub yolo: bool,
54    /// Pre-validated provider-native reasoning effort; `None` preserves argv.
55    pub reasoning_effort: Option<String>,
56    /// Optional system prompt appended at spawn (interactive claude only, the
57    /// "sentinel-prompt seam", inside-out-multiplexer E4.1): a relay-targeted
58    /// spawn passes the relay sentinel prompt (`RELAY_SYSTEM_PROMPT`, the
59    /// bracket-free `RELAY9BEGIN`/`RELAY9END` markers) so its replies are
60    /// parseable; a grid-spawned claude passes `None` and runs unsteered. One
61    /// PTY, two consumers with different prompts (the seam the design flags).
62    pub append_system_prompt: Option<String>,
63}
64
65/// Inputs for continuing an existing session (`fno agents ask`).
66#[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/// Lean registry projection a reachability probe needs. Wave 3's full registry
76/// `AgentEntry` is a superset; the fields here are the load-bearing subset for
77/// [`Provider::reachability`] and are additive-compatible with the Wave 3 shape.
78#[derive(Debug, Clone)]
79pub struct AgentEntry {
80    pub name: String,
81    pub provider: String,
82    /// `None` when no session id was ever recorded (e.g. a create that failed
83    /// before the id was captured); reachability treats it as inconclusive.
84    pub session_id: Option<String>,
85    pub cwd: PathBuf,
86}
87
88/// Tri-state reachability probe failure (mirrors the Python
89/// `ReachabilityProbeError` contract from US4-gemini). An `Err` means the probe
90/// was **inconclusive** (store inaccessible, no session id), NOT that the agent
91/// is unreachable. Callers MUST NOT flip an agent to `orphaned` on `Err`; they
92/// preserve the prior status (Failure Modes / Errors invariant).
93#[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
109/// The central per-CLI abstraction. Send + Sync so the daemon can hold trait
110/// objects across tasks.
111pub trait Provider: Send + Sync {
112    /// Stable provider identifier (`"claude"` / `"codex"` / `"gemini"`).
113    fn name(&self) -> &'static str;
114
115    /// Argv for a fresh session.
116    fn create_argv(&self, ctx: &CreateContext) -> Vec<String>;
117
118    /// Argv for continuing a session.
119    fn resume_argv(&self, ctx: &ResumeContext) -> Vec<String>;
120
121    /// Parse one unit of provider stream output into the sealed [`ParsedEvent`]
122    /// vocabulary. The unit is provider-shaped: codex is fed one JSONL line at a
123    /// time; gemini is fed its complete JSON blob (it emits a single document at
124    /// EOF, so per-line feeding yields [`ParsedEvent::Unknown`] until the daemon
125    /// has the whole blob). Unrecognized input becomes [`ParsedEvent::Unknown`]
126    /// rather than an error, so a provider version bump degrades gracefully.
127    fn parse_stream_event(&self, chunk: &str) -> ParsedEvent;
128
129    /// Probe whether `entry`'s session is still reachable. Returns `Ok(true)` /
130    /// `Ok(false)` for a definitive answer, `Err(ReachabilityProbeError)` when
131    /// the probe is inconclusive. `timeout` bounds any I/O the probe performs.
132    fn reachability(
133        &self,
134        entry: &AgentEntry,
135        timeout: Duration,
136    ) -> Result<bool, ReachabilityProbeError>;
137
138    /// Downcast to the PTY-managed extension, or `None` for shellout providers
139    /// (claude). Marks a provider as PTY-capable; the readiness/envelope/restart
140    /// surface hangs off it.
141    fn as_pty(&self) -> Option<&dyn ProviderWithPty> {
142        None
143    }
144}
145
146/// PTY-managed extension of [`Provider`]. Implemented by codex / gemini (and
147/// Phase 7's OpenCode), NOT by claude.
148pub trait ProviderWithPty: Provider {
149    /// Per-CLI readiness signal over the terminal grid.
150    fn readiness_detector(&self) -> Box<dyn ReadinessDetector>;
151
152    /// Structural anti-injection envelope for input on the PTY stdin path.
153    fn envelope(&self) -> Box<dyn Envelope>;
154
155    /// Provider-recommended restart policy. The daemon's enforcer still imposes
156    /// the hard ceiling ([`crate::supervisor::HARD_FAILURE_CEILING`], LD36)
157    /// regardless of what a provider returns.
158    fn default_restart_policy(&self) -> RestartPolicy;
159}
160
161// ---------------------------------------------------------------------------
162// Claude — shellout, not PTY-managed (LD38 billing: `--bg`, never `-p`).
163// ---------------------------------------------------------------------------
164
165/// Claude provider. The daemon shells out to `claude --bg` (the per-user
166/// supervisor owns the session) and follows up over the Phase 5 messaging
167/// socket. [`as_pty`](Provider::as_pty) returns `None`.
168pub struct ClaudeProvider;
169
170/// The stream-json host-lane resume argv for claude adoption (Group 1,
171/// ab-5896938c). The daemon builds this to launch the per-session stream worker.
172///
173/// Unlike [`ClaudeProvider::create_argv`] (which uses `--bg`, the subscription
174/// lane), the stream-json host lane REQUIRES `claude -p`: `--input-format
175/// stream-json` only works with `--print`/`-p` (Domain Pitfall). Per Locked
176/// Decision 1 this is a DELIBERATE, resolved choice - `-p` draws a dedicated
177/// Agent SDK credit isolated from interactive limits - so it is NOT an LD38
178/// violation but the explicit, opt-in adoption lane. Resume keys on the FULL
179/// session UUID (`claude_session_uuid`), never the 8-hex jobId (a 32-bit prefix,
180/// not collision-proof). `--include-partial-messages` surfaces streamed tokens;
181/// `--replay-user-messages` echoes injected turns back as delivery receipts
182/// (the frame parser discriminates the echo from the reply).
183pub 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        // Mirrors claude.py `_build_argv`: `claude --bg --name <name> <message>`.
205        // LD38: `--bg` is the subscription-billed mode; `claude -p` is
206        // Agent-SDK-credit-billed and MUST NOT be used.
207        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        // Subprocess fallback form (`claude --resume <id> --print <msg>`). The
218        // production daemon prefers the Phase 5 messaging-socket poke when a
219        // `messaging_socket_path` is registered; this argv exists so the trait
220        // is satisfiable without the socket (e.g. tests, socket-unavailable
221        // degradation). `--print` is non-streaming (Domain Pitfall).
222        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        // `claude --bg` prints a single line like
233        // "backgrounded · 7c5dcf5d · <name>"; the only structured datum is the
234        // 8-hex short-id, which is the session id. Anything else is Unknown.
235        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        // Claude liveness is the supervisor's `~/.claude/jobs/<short_id>` dir.
249        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            // Supervisor never ran / store absent: inconclusive, not "dead".
260            return Err(ReachabilityProbeError::new(
261                "claude",
262                "~/.claude/jobs absent",
263            ));
264        }
265        Ok(jobs.join(short_id).exists())
266    }
267
268    // as_pty() uses the default None: claude is not PTY-managed.
269}
270
271/// Extract a claude `--bg` short-id (`^[0-9a-f]{8}$`) from a line like
272/// "backgrounded · 7c5dcf5d · name". Returns the first 8-hex token found.
273fn parse_claude_short_id(line: &str) -> Option<String> {
274    // Split on non-hexdigits, so every token is already all-hexdigit; we only
275    // need to reject the uppercase-hex case (claude short-ids are lowercase).
276    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
281// ---------------------------------------------------------------------------
282// Claude (interactive) — PTY-managed, subscription-billed (E1 keystone).
283// ---------------------------------------------------------------------------
284
285/// Interactive subscription-billed claude, PTY-hosted by the daemon exactly as
286/// codex/gemini are (inside-out-multiplexer E1, the keystone). This is the
287/// `ProviderWithPty` counterpart to the shellout [`ClaudeProvider`] and the
288/// stream-json lane ([`claude_stream_json_resume_argv`]): the grid tiles it, the
289/// relay injects through it, the inside leg reports against it - one PTY, three
290/// consumers.
291///
292/// Billing posture (Locked Decision 2 / D2): the argv is interactive `claude`
293/// with `--session-id <uuid>` pinned for transcript discovery + the claim
294/// interlock, NEVER `claude -p`/`--print` (that bills the Agent SDK pool). The
295/// daemon's billing guard rejects any `-p`/`--print` argv before spawning; this
296/// provider only ever emits the interactive form.
297pub struct ClaudeInteractiveProvider;
298
299impl ClaudeInteractiveProvider {
300    /// Interactive argv with the session id pinned, used by `create_argv`: a
301    /// daemon-hosted claude is always interactive, so there is no separate exec
302    /// form. `claude --session-id
303    /// <uuid> [message]` - the relay-proven vehicle (roundtrip.py pins
304    /// `--session-id` at spawn so the transcript is discoverable and the
305    /// `session:<uuid>` claim keys on it).
306    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        // Sentinel-prompt seam (E4.1): a relay-targeted spawn steers replies via
313        // `--append-system-prompt`; absent, the pane runs unsteered. Pushed before
314        // the positional message (which must stay last).
315        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    // A daemon-hosted claude has no one-shot exec form; create == interactive.
336    // `provider_for_pty` only resolves this provider on the interactive route,
337    // so this is a defensive alias rather than a reachable exec path.
338    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        // Interactive resume: `claude --resume <uuid>` reattaches the session's
344        // TUI. The resume id IS the session, so no separate `--session-id` pin.
345        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        // The interactive TUI emits no JSONL stream (the daemon snapshots the
354        // screen via the readiness detector, as for codex/gemini interactive).
355        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        // Interactive PTY rows are governed by PTY liveness (the worker pid +
366        // ConnState), the authoritative signal per D4 - NOT a store scan. Report
367        // inconclusive so a caller never false-orphans a live pane on this probe;
368        // reconcile uses worker liveness for interactive rows.
369        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        // No structural anti-injection envelope on the interactive TUI path
387        // (input is human keystrokes / send-keys, not a JSON frame). The plain
388        // envelope passes text through unchanged, matching the send-keys vehicle.
389        Box::new(JsonEnvelope)
390    }
391
392    fn default_restart_policy(&self) -> RestartPolicy {
393        RestartPolicy::default()
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Codex — full PTY-managed; JSONL stream.
399// ---------------------------------------------------------------------------
400
401/// Codex provider. Argv mirrors codex.py (`codex exec --json ...` /
402/// `codex exec resume <id> --json ...`); the stream parser is pinned to the
403/// codex 0.130 JSONL vocabulary captured in
404/// `cli/tests/agents/fixtures/codex-jsonl-sample.jsonl`.
405pub 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        // codex.py::sandbox_flag (LD5/LD6): mutually exclusive; never both.
423        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        // codex.py::sandbox_flag_resume: resume has no `--sandbox`; only the
432        // bypass flag is honored, else inherit the session's original mode.
433        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            // codex exec refuses to run in a non-git dir without this; the
454            // validated codex.py create path always passes it.
455            "--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
497/// Codex reachability via the authoritative session index (mirrors codex.py
498/// `load_known_session_ids`). NOT a scan of `~/.codex/sessions/` — those are
499/// historical rollout artifacts that persist after a session is removed, so a
500/// file-existence scan would report a dead session `Ok(true)` and reconcile
501/// would never orphan it (Codex review P1). The index drops the id when the
502/// session ends, making membership the real liveness signal.
503///
504/// Tri-state: index missing -> `Err` (fresh install / can't determine, preserve
505/// status, never orphan); index unreadable -> `Err` (inconclusive); index
506/// present + id found -> `Ok(true)`; present + absent -> `Ok(false)`.
507fn 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
542/// Parse one codex JSONL line into a [`ParsedEvent`]. Non-JSON preamble (e.g.
543/// "Reading additional input from stdin...") and unrecognized event types map
544/// to [`ParsedEvent::Unknown`].
545fn 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                // codex delivers assistant text as discrete agent_message
578                // items; the daemon concatenates OutputChunks and treats
579                // turn.completed as the reply boundary.
580                "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        // Terminal marker for a turn. Reply text arrives via agent_message
604        // OutputChunks; codex's usage payload carries no wall-clock duration, so
605        // duration_ms is 0 and the daemon fills it from its own timing.
606        "turn.completed" => ParsedEvent::ReplyComplete {
607            text: String::new(),
608            duration_ms: 0,
609        },
610        // turn.started and any future control frame: not an error, just not a
611        // model-facing event.
612        _ => ParsedEvent::Unknown {
613            raw: line.to_string(),
614        },
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Gemini — full PTY-managed; single JSON blob at EOF.
620// ---------------------------------------------------------------------------
621
622/// Gemini provider. Argv mirrors gemini.py
623/// (`gemini --skip-trust -p <msg> --output-format json --session-id <uuid>` /
624/// `... --resume <uuid>`). The parser consumes gemini's single JSON document
625/// (pinned to `cli/tests/agents/fixtures/gemini-json-sample.json`).
626pub struct GeminiProvider;
627
628impl GeminiProvider {
629    fn sandbox(yolo: bool) -> Vec<String> {
630        // gemini.py::sandbox_flag (Wave 2.0 OQ5).
631        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        // gemini accepts a caller-assigned session UUID on create.
655        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
694/// Gemini reachability, cwd-pinned to `~/.gemini/tmp/<cwd-basename>/chats` and
695/// matched on the session-id 8-char short prefix (mirrors gemini.py
696/// `_gemini_chats_dir` + `gemini_session_reachable`). Gemini's on-disk
697/// filenames carry the short prefix, NOT the full UUID, and the store is
698/// per-cwd; a full-UUID recursive scan would `Ok(false)` a live session and a
699/// caller would false-orphan it. `Err` is inconclusive (chats dir absent can't
700/// be distinguished from a fresh install without scanning every cwd).
701fn 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    // `.get(..8)` is None for a too-short id OR a non-UTF-8-boundary index 8,
708    // so a multibyte session_id can never panic on a hardcoded slice.
709    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    // Iterate WITHOUT `.flatten()`: a dropped per-entry read error would turn an
738    // inconclusive probe into a definitive Ok(false) and false-orphan a live
739    // session (Codex P2). Propagate as inconclusive instead.
740    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        // Short-prefix match is a candidate only. Verify the FULL UUID appears
758        // in the file's first line to defeat short-prefix collisions (Codex P2;
759        // mirrors gemini.py's first-line full-UUID check). Read only the first
760        // line - chat logs can be multi-MB. I/O errors stay inconclusive.
761        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
791// ---------------------------------------------------------------------------
792// Agy — PTY-managed pane, PLAIN-TEXT (no JSON envelope, no session id).
793// ---------------------------------------------------------------------------
794
795/// Agy (Antigravity CLI) provider. Runs Gemini models under the hood but, unlike
796/// gemini, has NO `--output-format json` — it emits plain text. So its envelope
797/// is [`NoEnvelope`] (no structural JSON wrapper) and it carries no parseable
798/// session id (reachability is always inconclusive; the headless one-shot path
799/// lives in `agy_ask.rs`). `-p`/`--print` takes the prompt as its VALUE, so it
800/// is appended LAST in every argv.
801///
802/// x-3ab8 caveat — STATELESS interactive: a plain `spawn --provider agy` now
803/// defaults to an owned interactive pane (like the other PTY providers), and the
804/// pane is drivable WHILE attached. But because agy mints no session id, there is
805/// NO re-attach after it settles — nothing to key a resume on (`resume_argv`
806/// below keys on a conversation id agy v1.0.x does not surface). Treat the agy
807/// pane as live-only; once it settles, dispatch a fresh `--once` instead.
808pub 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        // Headless create is the autonomous lane: ALWAYS never-prompt so an
819        // unattended agy cannot wedge on its first approval prompt.
820        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        // agy resume keys on the conversation id (`--conversation <id>`); plain
828        // -p prompt as the value, last.
829        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        // agy has no structured stream — the whole chunk is the reply text. An
842        // empty chunk is Unknown (nothing to surface).
843        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        // agy carries no easily-probed session store and no parseable session id,
861        // so a probe is ALWAYS inconclusive — never false-orphan a live agy pane.
862        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        // Plain-text stdin: no JSON wrapper (agy has no structured input).
880        Box::new(NoEnvelope)
881    }
882
883    fn default_restart_policy(&self) -> RestartPolicy {
884        RestartPolicy::default()
885    }
886}
887
888// ---------------------------------------------------------------------------
889// Opencode — PTY-managed pane, PLAIN-TEXT (v1 hosts the TUI; acp is a future lane).
890// ---------------------------------------------------------------------------
891
892/// opencode provider (x-51f6). v1 hosts the interactive PTY-TUI — bare
893/// `opencode`, with the message on `--prompt` (the positional is a PROJECT
894/// PATH, not a prompt) — mirroring the codex/gemini pane pattern; opencode's
895/// structured `acp` protocol is a documented future lane, not wired here.
896/// The pane argv itself lives in `mux_spawn.build_pane_argv` (Python owns the
897/// pane back half since 4a-G2); the argv forms below are the HEADLESS
898/// `opencode run` shapes, carried for trait completeness — no client-side
899/// one-shot lane is wired in v1 (`spawn --substrate headless` refuses with a
900/// pointer to `pane`). Session ids are captured at spawn, probed against
901/// opencode's own store, and resumable (x-830c) — unlike agy, whose rows stay
902/// live-only.
903/// Trailing argv for an `opencode run` dispatch: route a footnote slash command
904/// through `--command`, else pass a prose prompt as the message positional.
905///
906/// `opencode run <message>` treats a leading-slash string as PROSE - it does NOT
907/// expand the plugin command (verified against v1.14.50: `run "/fno:target ..."`
908/// starts a model turn on the literal text). The fno opencode plugin registers
909/// the footnote verbs, so a rendered `/fno:verb args` must ride `opencode run
910/// --command fno:verb <args>` to actually invoke the command (x-de43 / codex P1).
911/// A non-slash prompt (a plain `ask`/build message) passes through unchanged.
912pub(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        // `/fno:target no-merge x` -> --command fno:target, args "no-merge x".
916        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        // `opencode run [prompt]` is the headless one-shot;
936        // `--dangerously-skip-permissions` (auto-approve permissions) is the
937        // never-prompt lane so an unattended run cannot wedge on its first
938        // approval. Confirmed vs opencode v1.14.50 `run --help` (x-567d); the
939        // docs' `--auto` is stale.
940        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        // opencode continues a session via `--session <id>` (run cmd).
951        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        // The hosted TUI is plain text — no structured stream to parse (the
964        // acp lane would change this). Same shape as agy.
965        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
994/// Runs an opencode store query, yielding `(exited_zero, stdout)`. Injected at
995/// the [`opencode_reachable_with`] seam so unit tests need no opencode binary.
996type OpencodeDbRunner = fn(&str, Duration) -> Result<(bool, String), String>;
997
998/// Floor for the opencode probe's budget. The daemon's per-probe timeout is
999/// sized for a file read, but this probe pays a node CLI's startup: `opencode
1000/// db` measured 0.30-0.35s on v1.14.50, so the 250ms reconcile bound would time
1001/// out EVERY call and report inconclusive forever. The sweep's total budget
1002/// still caps how many rows one pass probes.
1003const OPENCODE_PROBE_MIN_BUDGET: Duration = Duration::from_secs(2);
1004
1005/// True iff `s` is a well-formed opencode session id (`ses_` + ASCII
1006/// alphanumerics). Gates SQL interpolation in the probe: no quote, space, or
1007/// shell metacharacter can reach the subprocess.
1008fn 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
1015/// opencode reachability: membership in opencode's own session store, the same
1016/// question codex's probe answers ("does the session still exist" = resumable),
1017/// NOT "is the pane live" — a default TUI leaves no on-disk liveness artifact,
1018/// so liveness stays the registry row's pid axis (x-5e58).
1019///
1020/// Tri-state mirrors codex: id present -> `Ok(true)`, clean query without it ->
1021/// `Ok(false)`, any infrastructure failure -> `Err` (inconclusive), so a missing
1022/// binary or unreadable store never false-orphans a live pane. Verified against
1023/// opencode v1.14.50: an absent id exits 0 with empty stdout, while bad SQL, a
1024/// usage error, and an unopenable database all exit nonzero — so exit status
1025/// alone separates "gone" from "could not tell".
1026///
1027/// Matching is substring containment on the shape-validated id rather than a
1028/// JSON parse: opencode plugins print banners to stdout ahead of real output
1029/// (verified live), which would break any structured read.
1030fn 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
1060/// The real [`OpencodeDbRunner`]: `opencode db <sql>`, bounded by `timeout`.
1061///
1062/// Shelling out to opencode's own binary (rather than opening the sqlite file)
1063/// inherits its channel-aware database resolution (`opencode-<channel>.db`,
1064/// `OPENCODE_DB`) for free, keeps this crate free of a sqlite dependency, and
1065/// pins to the CLI verb rather than a storage layout mid-migration to v2.
1066///
1067/// The query is a single-row lookup, so its output cannot fill the stdout pipe
1068/// while we poll. On timeout only the child pid is killed — never its process
1069/// group, which fno shares with a child not placed in its own session.
1070fn 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                // Reap before bailing: an interrupted wait would otherwise
1094                // abandon a running opencode and leave it unreaped after exit.
1095                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        // Plain-text stdin into the TUI composer: no JSON wrapper.
1115        Box::new(NoEnvelope)
1116    }
1117
1118    fn default_restart_policy(&self) -> RestartPolicy {
1119        RestartPolicy::default()
1120    }
1121}
1122
1123/// Parse gemini's single JSON document. Gemini emits one blob at EOF (the
1124/// structural cleavage from codex), so this expects the COMPLETE document;
1125/// partial input parses as [`ParsedEvent::Unknown`].
1126///
1127/// A create-path blob can carry BOTH `session_id` and `response`. `parse_stream_event`
1128/// returns exactly one [`ParsedEvent`], so this surfaces the reply
1129/// ([`ParsedEvent::ReplyComplete`]) as the primary signal. On the create path
1130/// the daemon (Wave 3) captures the session id from [`CreateContext::session_id`]
1131/// (the daemon pre-assigns the UUID via `--session-id`, the design's default
1132/// flow) or, in the rare no-pre-assignment case, from this same raw blob via
1133/// [`gemini_session_id_from_blob`] before persisting the entry — so the id is
1134/// never lost despite the single-event return (Codex review P2).
1135fn 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    // A completed reply carries `response`; map to ReplyComplete with duration
1145    // summed from per-model API latency. snake_case `session_id` per US4-gemini
1146    // (NOT camelCase `sessionId`, which is gemini's internal storage shape).
1147    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
1163/// Sum `stats.models.<model>.api.totalLatencyMs` across all models. Returns 0
1164/// when the stats block is absent or shaped unexpectedly (degrade, don't fail).
1165fn 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
1181// ---------------------------------------------------------------------------
1182// Reachability helpers (HOME-relative; testable via $HOME override).
1183// ---------------------------------------------------------------------------
1184
1185fn home_dir() -> Option<PathBuf> {
1186    std::env::var_os("HOME").map(PathBuf::from)
1187}
1188
1189/// Extract gemini's assigned `session_id` from a create-path JSON blob. The
1190/// daemon (Wave 3) calls this on the create path when it did NOT pre-assign a
1191/// UUID, so the session handle gemini chose is persisted even though
1192/// [`parse_stream_event`](Provider::parse_stream_event) surfaces the reply
1193/// rather than the id (single-event return; Codex review P2). Returns `None`
1194/// when the blob is malformed or carries no `session_id`.
1195pub 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
1203/// The provider roster: every provider name the Rust side can DISPATCH/host —
1204/// the spawn gates in `bin/client.rs` and [`for_name`] ride THIS list (x-51f6
1205/// US1: one source of truth, no per-site `matches!` copies).
1206///
1207/// NAMING SKEW (x-8dfc, Discretion 4 — commented, not lockstep-renamed, to keep
1208/// the diff small): this 5-name list mirrors Python's `READABLE_PROVIDERS` (the
1209/// spawn/pane read-tolerance roster), NOT Python's narrower 3-name
1210/// `KNOWN_PROVIDERS` (its dispatch set). A cli test pins this == READABLE.
1211/// It is NO LONGER a registry-LOAD gate: `client_verbs::load_registry_entries`
1212/// now shape-checks identity, so an alien harness reads without bricking; this
1213/// list gates only spawn/`for_name`. Every name here MUST have a [`for_name`]
1214/// arm (test-enforced).
1215pub const KNOWN_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "agy", "opencode"];
1216
1217/// The roster joined for error messages ("claude, codex, gemini, agy, opencode").
1218pub fn known_providers_csv() -> String {
1219    KNOWN_PROVIDERS.join(", ")
1220}
1221
1222/// Resolve a provider impl by its stable name (`"claude"` / `"codex"` /
1223/// `"gemini"`). Returns `None` for an unknown provider so callers (e.g.
1224/// reconcile) can treat the probe as inconclusive rather than guessing. The
1225/// only place provider names map to impls — keeping provider knowledge in this
1226/// one file (the LD8 discipline).
1227pub 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    // ---- argv shapes ----
1256
1257    #[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        // The stream-json host lane resumes by the FULL UUID with -p +
1285        // stream-json IO (the only flags that yield a drivable bidirectional
1286        // pipe). -p here is the deliberate adoption lane (LD1), distinct from
1287        // the --bg create path (LD38).
1288        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    // ---- interactive argv (host_mode=interactive): host + promote ----
1449
1450    // ---- as_pty type-level routing ----
1451
1452    #[test]
1453    fn claude_is_not_pty_managed_others_are() {
1454        // The shellout `--bg` claude stays non-PTY; the interactive claude (E1)
1455        // and codex/gemini are PTY-managed.
1456        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    // ---- ClaudeInteractiveProvider (E1 keystone) ----
1463
1464    // ---- OpencodeProvider (x-51f6) ----
1465
1466    #[test]
1467    fn opencode_create_argv_is_headless_run_never_bare_tui() {
1468        // The trait's create path is the headless `opencode run` one-shot
1469        // (never-prompt via --dangerously-skip-permissions); the bare-`opencode`
1470        // TUI is the PANE form and lives in mux_spawn.build_pane_argv, not here.
1471        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        // A rendered footnote slash command rides `--command <verb>` (opencode
1486        // expands the plugin command) with the rest as args - NOT a prose prompt
1487        // that `run` would run verbatim (x-de43 / codex P1).
1488        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        // Prose passes through unchanged; a bare verb has no args tail.
1506        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        // An id-less row has nothing to look up, so the probe stays inconclusive
1539        // and never orphans the pane. A row WITH an id is probed for real
1540        // (x-830c) - see the opencode store-probe cases above.
1541        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 is the LD8 single registration point; a copy-paste slip
1555        // (e.g. "codex" => GeminiProvider) would pass every other test, so
1556        // assert each name resolves to a provider reporting that same name.
1557        // Iterating KNOWN_PROVIDERS (x-51f6 US1 / AC1-FR) makes this the
1558        // roster-parity gate too: a name added to the const without a
1559        // for_name arm fails here, and the consolidation can never silently
1560        // narrow the roster (the old hardcoded list had already drifted —
1561        // it missed agy).
1562        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    // ---- claude short-id parse ----
1574
1575    #[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        // Uppercase hex is not a claude short-id (lowercase contract).
1593        assert!(matches!(
1594            ClaudeProvider.parse_stream_event("ABCDEF12"),
1595            ParsedEvent::Unknown { .. }
1596        ));
1597    }
1598
1599    // ---- codex JSONL parse (pinned to fixture vocabulary) ----
1600
1601    #[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    // ---- gemini blob parse ----
1679
1680    #[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        // parse_stream_event surfaces the reply (single-event return), but the
1736        // session id is still recoverable from the same blob for the create path.
1737        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    // ---- reachability tri-state (HOME-overridden) ----
1751
1752    #[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    // -- opencode store probe (x-830c) ------------------------------------
1776    // Every case drives an injected runner, so the suite never shells out to a
1777    // real opencode binary or reads ~/.local/share/opencode.
1778
1779    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        // Leading plugin banner: opencode plugins print to stdout ahead of real
1793        // output, so the probe must tolerate garbage before the row.
1794        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        // Verified on v1.14.50: an absent id exits 0 with empty stdout.
1832        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        // Spawn failure (binary missing) and a nonzero exit (unopenable store)
1848        // must both stay Err, or a dead-pane pass would orphan a live pane.
1849        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        // AC1-FR: a transient failure poisons nothing; the next call reports the
1901        // true store verdict.
1902        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        // The id is NOT in the index -> the session ended (index drops it) ->
1947        // definitively orphaned, even if a historical session file still exists.
1948        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            // No session_index.jsonl (fresh install) -> inconclusive, never orphan.
1967            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        // Filename carries the 8-char short prefix; the FULL uuid must appear in
1988        // the file's first line for the probe to confirm (defeats collisions).
1989        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            // Same id but a DIFFERENT cwd must not find it (cwd-pinned).
2007            let other = gemini_entry(G_UUID, "/work/elsewhere");
2008            assert!(GeminiProvider
2009                .reachability(&other, Duration::from_secs(2))
2010                .is_err()); // chats dir for "elsewhere" absent -> inconclusive
2011        });
2012    }
2013
2014    #[test]
2015    fn gemini_short_prefix_collision_without_full_uuid_is_false() {
2016        // A different session shares the 8-char prefix but the file's full uuid
2017        // differs -> the content-verification step rejects it (Codex P2).
2018        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    // ---- test helpers (no external tempfile dep) ----
2061
2062    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    /// Process-global lock serializing $HOME mutation. cargo runs tests in
2078    /// parallel threads within one process; HOME is process-global, so two
2079    /// `with_home` calls would race without this guard.
2080    static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2081
2082    /// Run `f` with $HOME set to `home`, restoring the prior value after. The
2083    /// reachability helpers read HOME on each call; the lock makes the
2084    /// set -> run -> restore window atomic across parallel test threads.
2085    fn with_home(home: &std::path::Path, f: impl FnOnce()) {
2086        // Poisoning is irrelevant here (the guarded data is unit); recover it.
2087        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}