Skip to main content

warble_eval_runner/
backend.rs

1//! Dispatch-target (back-end/runtime) identity for the eval runner.
2//!
3//! Warble ships several back-ends (`dispatcher/<name>`), each with a different launch mechanism,
4//! question-passing convention, and capability envelope — the eval runner never hard-codes a
5//! runtime's tool grant itself (see [`ClaudeCodeCliAdapter`]'s doc comment for the one case this
6//! bit us). [`Backend`]
7//! is the eval dimension that says *which one this run measured* — a concept distinct from the
8//! per-back-end `TargetId` capability target (`claude-code:headless` / `claude-code:interactive`)
9//! already defined in `dispatcher/claude-code-cli` and `dispatcher/vercel`: that enum picks a
10//! capability posture *within* one back-end, this one picks *which back-end ran at all*. Naming it
11//! `Backend` (not `Target`) keeps the two concepts from colliding at the type or CLI-flag level.
12//!
13//! The value domain is aligned with the `dispatcher/<name>` directories, so every real back-end has a
14//! recognized spelling — but recognizing the spelling and being able to actually run it are two
15//! different things: `resolve_adapter` (crate-private) is the loud-fail boundary that only lets
16//! through back-ends with a real [`BackendAdapter`] wired up, naming the supported subset on
17//! anything else.
18
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22/// Which back-end/runtime a case was (or should be) run against. Value domain matches the
23/// `dispatcher/<name>` directory names exactly, so `clap`'s own unknown-value error already lists
24/// every back-end this crate knows the *name* of — but knowing the name is not the same as having an
25/// adapter for it; see `resolve_adapter` (crate-private) below.
26///
27/// Defaults to [`Backend::ClaudeCodeCli`] so an eval invocation that never mentions `--backend` keeps
28/// measuring exactly what it always measured (binding decision: never silently change what an
29/// existing invocation reports).
30#[derive(
31    Debug,
32    Clone,
33    Copy,
34    PartialEq,
35    Eq,
36    Default,
37    serde::Serialize,
38    serde::Deserialize,
39    clap::ValueEnum,
40)]
41#[serde(rename_all = "kebab-case")]
42#[clap(rename_all = "kebab-case")]
43pub enum Backend {
44    #[default]
45    ClaudeCodeCli,
46    ClaudeAgentSdk,
47    CodexLocal,
48    Vercel,
49}
50
51impl Backend {
52    /// The spelling shared by CLI/serde and the cache-key element — identical to the back-end's
53    /// `dispatcher/<name>` directory name.
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Backend::ClaudeCodeCli => "claude-code-cli",
57            Backend::ClaudeAgentSdk => "claude-agent-sdk",
58            Backend::CodexLocal => "codex-local",
59            Backend::Vercel => "vercel",
60        }
61    }
62}
63
64impl std::fmt::Display for Backend {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70/// What a [`BackendAdapter`] invocation actually measured. Every field a back-end genuinely cannot
71/// supply must come back `None`, never a defaulted `0.0`/`0` — a metric a back-end can't report is
72/// missing, not free (see [`crate::CaseResult::cost`] and friends for where this flows to).
73pub struct AdapterResult {
74    pub ok: bool,
75    pub raw: String,
76    pub latency_ms: Option<u64>,
77    pub cost: Option<f64>,
78    pub turns: Option<u64>,
79}
80
81/// One back-end's launch mechanism: how the question is passed, how the run is invoked, and how the
82/// trace/metadata come back. This is the seam that replaces the old hard-coded `Command::new("claude")`
83/// call in `run_case` — the target decides how it runs, not the eval loop.
84pub trait BackendAdapter: Sync {
85    /// Run one sample of one case's `question` against the already-installed agent under `project`,
86    /// returning the raw final output plus whatever cost/latency/turns metadata this back-end can
87    /// supply. `model_override` is `Some` on the whole-run path (a `--model` binding), `None` on the
88    /// ablation/frontmatter path (the tier→model binding is baked into the emitted agent).
89    fn invoke(
90        &self,
91        project: &Path,
92        agent: &str,
93        path_env: &str,
94        question: &str,
95        model_override: Option<&str>,
96    ) -> AdapterResult;
97}
98
99/// The reference back-end: shells out to the `claude` CLI exactly as `run_case` always has. The
100/// capability envelope (which tools the agent may use) is never a literal in this crate —
101/// `claude-code-cli`'s own dispatch already computed it as a per-component `.claude/settings.json`,
102/// and `install_agents` copies that file into the project before this is ever invoked. But `claude
103/// -p` only *auto-discovers* `.claude/settings.json` when the project directory has prior workspace
104/// trust recorded in `~/.claude.json` — a directory it has never seen before (every fresh CI runner
105/// checkout, and every non-interactive fixture directory) silently drops the file's
106/// `permissions.allow` entirely and runs with no grant, which is what turned into a near-total
107/// accuracy collapse in CI (confirmed by reproducing the same collapse locally against a
108/// newly-created, never-trusted directory, while the identical run against this trusted worktree
109/// passes). Passing the already-installed file's own content inline via `--settings <json>` bypasses
110/// file *discovery* — and therefore the trust gate — while still sourcing the envelope from the
111/// same computed file, not a hard-coded literal here.
112pub(crate) struct ClaudeCodeCliAdapter;
113
114impl BackendAdapter for ClaudeCodeCliAdapter {
115    fn invoke(
116        &self,
117        project: &Path,
118        agent: &str,
119        path_env: &str,
120        question: &str,
121        model_override: Option<&str>,
122    ) -> AdapterResult {
123        let mut args: Vec<String> = vec![
124            "-p".to_string(),
125            question.to_string(),
126            "--agent".to_string(),
127            agent.to_string(),
128        ];
129        if let Some(model) = model_override {
130            args.push("--model".to_string());
131            args.push(model.to_string());
132        }
133        // Same file `install_agents` just copied to `project/.claude/settings.json` — read back and
134        // handed to `claude` inline so it applies regardless of this directory's trust history.
135        //
136        // Absent and unreadable are deliberately NOT the same case. `install_agents` treats
137        // settings.json as optional (an agent dir may legitimately ship none), so a missing file
138        // keeps the pre-existing behaviour: run without an envelope override. A file that exists
139        // but cannot be read is the dangerous case — proceeding would silently drop both the
140        // computed `permissions.allow` grant and the `deny` list (destructive-bash denials), which
141        // is exactly the silent envelope loss this whole code path exists to prevent. Fail loudly
142        // rather than run unprotected.
143        let settings_path = project.join(".claude/settings.json");
144        match std::fs::read_to_string(&settings_path) {
145            Ok(settings_json) => {
146                args.push("--settings".to_string());
147                args.push(settings_json);
148            }
149            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
150            Err(e) => {
151                return AdapterResult {
152                    ok: false,
153                    raw: format!(
154                        ".claude/settings.json exists but could not be read ({e}); refusing to run \
155without its capability envelope"
156                    ),
157                    latency_ms: None,
158                    cost: None,
159                    turns: None,
160                };
161            }
162        }
163        args.push("--output-format".to_string());
164        args.push("json".to_string());
165
166        let output = Command::new("claude")
167            .args(&args)
168            .current_dir(project)
169            .env("PATH", path_env)
170            .output();
171
172        let raw = match output {
173            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
174            _ => {
175                return AdapterResult {
176                    ok: false,
177                    raw: String::new(),
178                    latency_ms: None,
179                    cost: None,
180                    turns: None,
181                }
182            }
183        };
184
185        let meta: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
186        let result_text = meta
187            .get("result")
188            .and_then(|v| v.as_str())
189            .unwrap_or(&raw)
190            .to_string();
191        let latency_ms = meta.get("duration_ms").and_then(|v| v.as_u64());
192        let cost = meta.get("total_cost_usd").and_then(|v| v.as_f64());
193        let turns = meta.get("num_turns").and_then(|v| v.as_u64());
194
195        AdapterResult {
196            ok: true,
197            raw: result_text,
198            latency_ms,
199            cost,
200            turns,
201        }
202    }
203}
204
205/// This checkout's own `claude-agent-sdk` back-end package — a fixed sibling of the `eval/runner`
206/// crate's manifest dir, known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking
207/// the filesystem at runtime. Mirrors the precedent set by `cli::in_repo_hub_dir` for locating an
208/// in-repo sibling package the same way.
209fn claude_agent_sdk_dir() -> PathBuf {
210    Path::new(env!("CARGO_MANIFEST_DIR"))
211        .join("..")
212        .join("..")
213        .join("dispatcher")
214        .join("claude-agent-sdk")
215}
216
217/// The built CLI entry point (`npm run build`, i.e. `just build-ts`, emits this via `tsup`). Kept as
218/// its own function so `resolve_adapter` can check it exists (and fail loudly with a build
219/// instruction) before ever reaching a `Report`/`CaseKey`/`Trace`, rather than discovering it missing
220/// deep inside a per-case `invoke`.
221fn claude_agent_sdk_cli_js() -> PathBuf {
222    claude_agent_sdk_dir().join("dist").join("cli.js")
223}
224
225/// The `claude-agent-sdk` back-end: drives `dispatcher/claude-agent-sdk`'s own `dispatch <ir.json>
226/// "<question>" --out <dir>` CLI subcommand — the SDK's `query()` loop run headlessly, one question
227/// at a time, exactly like `ClaudeCodeCliAdapter` drives `claude -p`. Unlike `claude-code-cli` (which
228/// installs static per-component agent files ahead of time), this back-end has no pre-installed
229/// "dispatched agent dir" to point at — it dispatches directly from the compiled IR at invocation
230/// time. So for this adapter the `agent` parameter (otherwise a frontmatter agent name) is
231/// repurposed to carry the path to the compiled `ir.json` instead; see `run_eval`'s backend branch
232/// for where that path comes from. The `BackendAdapter` trait signature itself is unchanged — only
233/// the *meaning* of one already-generic string parameter is generalized per back-end.
234pub(crate) struct ClaudeAgentSdkAdapter;
235
236impl ClaudeAgentSdkAdapter {
237    /// `dispatch.ts`'s `prepareDispatch` maps the same question over every component in the fed
238    /// IR (no `--component` filter exists on `dispatch`, unlike `chat`) — so the IR path handed to
239    /// this adapter must itself be single-component, same as `eval/answer-agent`'s compiled
240    /// `answer-ir.json` used for the claude-code-cli golden run.
241    fn absolute(path: &Path) -> PathBuf {
242        if path.is_absolute() {
243            path.to_path_buf()
244        } else {
245            std::env::current_dir().unwrap_or_default().join(path)
246        }
247    }
248}
249
250impl BackendAdapter for ClaudeAgentSdkAdapter {
251    fn invoke(
252        &self,
253        project: &Path,
254        agent: &str,
255        path_env: &str,
256        question: &str,
257        model_override: Option<&str>,
258    ) -> AdapterResult {
259        // Carry a reason on every failure path. `dispatch`'s own CLI writes `error: <message>` to
260        // stderr before exiting non-zero, and the runner surfaces an adapter's first output line in
261        // the case's failure reason — discarding it here would render every failure of this
262        // back-end as the bare generic string, with the diagnostic thrown away.
263        let fail = |reason: String| AdapterResult {
264            ok: false,
265            raw: reason,
266            latency_ms: None,
267            cost: None,
268            turns: None,
269        };
270
271        let out_dir = match tempfile::tempdir() {
272            Ok(dir) => dir,
273            Err(e) => {
274                return fail(format!(
275                    "could not create a temporary output directory: {e}"
276                ))
277            }
278        };
279        let ir_path = Self::absolute(Path::new(agent));
280        let project_abs = Self::absolute(project);
281
282        let mut args: Vec<String> = vec![
283            "dispatch".to_string(),
284            ir_path.display().to_string(),
285            question.to_string(),
286            "--out".to_string(),
287            out_dir.path().display().to_string(),
288            "--project".to_string(),
289            project_abs.display().to_string(),
290        ];
291        // Mirrors `ClaudeCodeCliAdapter`'s `--model` override: pin every tier to the same model,
292        // regardless of the frontmatter/IR's own per-step tier binding. `None` (the
293        // ablation/frontmatter path) leaves the CLI's own tier defaults in place.
294        if let Some(model) = model_override {
295            args.extend([
296                "--strong".to_string(),
297                model.to_string(),
298                "--cheap".to_string(),
299                model.to_string(),
300                "--orchestrator".to_string(),
301                model.to_string(),
302            ]);
303        }
304
305        let output = Command::new("node")
306            .arg(claude_agent_sdk_cli_js())
307            .args(&args)
308            .current_dir(claude_agent_sdk_dir())
309            .env("PATH", path_env)
310            .output();
311
312        let o = match output {
313            Ok(o) => o,
314            Err(e) => return fail(format!("could not run the dispatch CLI via node: {e}")),
315        };
316        if !o.status.success() {
317            let stderr = String::from_utf8_lossy(&o.stderr);
318            let detail = stderr
319                .lines()
320                .map(str::trim)
321                .find(|line| !line.is_empty())
322                .unwrap_or("no stderr output");
323            return fail(format!("dispatch CLI failed ({}): {detail}", o.status));
324        }
325
326        let raw = std::fs::read_to_string(out_dir.path().join("result.txt")).unwrap_or_default();
327        let trace: serde_json::Value = std::fs::read_to_string(out_dir.path().join("trace.json"))
328            .ok()
329            .and_then(|text| serde_json::from_str(&text).ok())
330            .unwrap_or(serde_json::Value::Null);
331        // `trace.json`'s `run` field is `{...} | null` — `null` exactly when the SDK's result
332        // message never arrived, which naturally yields `None` for all three below rather than a
333        // defaulted `0`/`0.0`.
334        let run = trace.get("run");
335        let latency_ms = run
336            .and_then(|r| r.get("duration_ms"))
337            .and_then(|v| v.as_u64());
338        let cost = run
339            .and_then(|r| r.get("total_cost_usd"))
340            .and_then(|v| v.as_f64());
341        let turns = run
342            .and_then(|r| r.get("num_turns"))
343            .and_then(|v| v.as_u64());
344
345        AdapterResult {
346            ok: true,
347            raw,
348            latency_ms,
349            cost,
350            turns,
351        }
352    }
353}
354
355/// This checkout's own `codex-local` back-end package — a fixed sibling of the `eval/runner` crate's
356/// manifest dir, same convention as [`claude_agent_sdk_dir`].
357fn codex_local_dir() -> PathBuf {
358    Path::new(env!("CARGO_MANIFEST_DIR"))
359        .join("..")
360        .join("..")
361        .join("dispatcher")
362        .join("codex-local")
363}
364
365/// The built CLI entry point (`just build-codex-ts`, i.e. `npm run build` in `dispatcher/codex-local`,
366/// emits this via `tsup`). Same role as [`claude_agent_sdk_cli_js`]: checked by `resolve_adapter`
367/// before ever reaching a `Report`/`CaseKey`/`Trace`.
368fn codex_local_cli_js() -> PathBuf {
369    codex_local_dir().join("dist").join("cli.js")
370}
371
372fn absolute_path(path: &Path) -> PathBuf {
373    if path.is_absolute() {
374        path.to_path_buf()
375    } else {
376        std::env::current_dir().unwrap_or_default().join(path)
377    }
378}
379
380/// A worked example of [`CodexLocalDispatchSpec`]'s JSON shape, inlined into every parse-failure
381/// message below so the fix is visible without leaving the terminal.
382const CODEX_LOCAL_SPEC_SHAPE: &str = concat!(
383    r#"{"ir_path": "<path to a compiled IR>", "component": "<component id in that IR, e.g. "#,
384    r#""build_context">", "mcp": {"command": "<path to an MCP server executable>", "args": [], "#,
385    r#""source_tools": [], "context_tools": ["<tool name>"]}}"#
386);
387
388/// Where the full write-up (every field, a runnable example, the `warble eval run` invocation)
389/// lives — named in every parse-failure message rather than left to the doc comments on
390/// [`CodexLocalDispatchSpec`] alone, which a CLI user never sees.
391const CODEX_LOCAL_SPEC_DOC_POINTER: &str =
392    "see the \"codex-local\" section of docs/site/docs/guides/evaluating.md for the full shape and a worked example";
393
394/// Whether `spec_text` looks like a compiled IR (a `warble_ir_version` key present) rather than a
395/// codex-local dispatch spec. This is the exact confusion a user hits by pointing `--ir` at their
396/// compiled IR the same way they would for [`ClaudeAgentSdkAdapter`] — without this check, the
397/// resulting `serde_json` error reads as "your IR is missing a field named `ir_path`", which
398/// describes the file's own shape as broken rather than naming the real problem (this back-end
399/// wants a different artifact under `--ir`).
400fn looks_like_compiled_ir(spec_text: &str) -> bool {
401    serde_json::from_str::<serde_json::Value>(spec_text)
402        .ok()
403        .is_some_and(|v| v.get("warble_ir_version").is_some())
404}
405
406/// Turn a [`CodexLocalDispatchSpec`] parse failure into a message a user who just followed
407/// `--ir`'s own `--help` text can act on, instead of a bare `serde_json::Error` quoted against a
408/// file it was never meant to describe. See [`looks_like_compiled_ir`] for why the compiled-IR
409/// case gets its own wording.
410fn describe_spec_parse_failure(
411    spec_path: &Path,
412    spec_text: &str,
413    cause: &serde_json::Error,
414) -> String {
415    if looks_like_compiled_ir(spec_text) {
416        format!(
417            "{} looks like a compiled IR (it has a `warble_ir_version` field), not a codex-local \
418dispatch spec. Unlike `claude-agent-sdk`, backend 'codex-local' does not take the compiled IR \
419directly via --ir — point --ir at a small JSON dispatch spec that names this IR (plus the \
420component and MCP server to dispatch it with) instead: {CODEX_LOCAL_SPEC_SHAPE}. \
421{CODEX_LOCAL_SPEC_DOC_POINTER}.",
422            spec_path.display()
423        )
424    } else {
425        format!(
426            "could not parse {} as a codex-local dispatch spec ({cause}). Backend 'codex-local' \
427needs a small JSON dispatch spec: {CODEX_LOCAL_SPEC_SHAPE}. {CODEX_LOCAL_SPEC_DOC_POINTER}.",
428            spec_path.display()
429        )
430    }
431}
432
433/// One MCP server binding for a `codex-local dispatch`: mirrors that CLI's own `--server-command` /
434/// `--server-arg` / `--source-tool` / `--context-tool` flags (`dispatcher/codex-local/src/prepare.ts`'s
435/// `McpServerConfig`). `command` and any relative `ir_path` alongside it in
436/// [`CodexLocalDispatchSpec`] are resolved relative to the spec file's own directory, not the
437/// process cwd — the spec is meant to travel with (and point at) its sibling artifacts.
438#[derive(Debug, serde::Deserialize)]
439struct CodexLocalMcp {
440    #[serde(default = "CodexLocalMcp::default_name")]
441    name: String,
442    command: String,
443    #[serde(default)]
444    args: Vec<String>,
445    #[serde(default)]
446    source_tools: Vec<String>,
447    #[serde(default)]
448    context_tools: Vec<String>,
449}
450
451impl CodexLocalMcp {
452    fn default_name() -> String {
453        "setup".to_string()
454    }
455}
456
457/// Everything one `codex-local dispatch` needs beyond the question/model the [`BackendAdapter`] trait
458/// already carries.
459///
460/// `claude-agent-sdk`'s `dispatch` subcommand takes only an IR path (see [`ClaudeAgentSdkAdapter`]'s
461/// doc comment) because it maps the question over every component in the fed IR. `codex-local`'s
462/// `dispatch` is shaped differently: it REQUIRES `--component` (`dispatcher/codex-local`'s
463/// `prepareSetup` looks up exactly one named component, and only accepts the setup-shaped family —
464/// `connect_source`/`build_context`, per `validateSetupShape`) and an external MCP server binding
465/// that realizes that component's `source_connect`/`context_build` capability. Warble ships no real
466/// source/context MCP server itself — that tool is supplied by whatever consumes this back-end — so
467/// neither piece is a constant this adapter could hard-code. `BackendAdapter::invoke`'s fixed
468/// 5-argument signature has no channel for either, so `agent` for this back-end is repurposed once
469/// more: not an IR path, but the path to this small JSON spec that names one, alongside the
470/// component to dispatch and the MCP server that backs it.
471#[derive(Debug, serde::Deserialize)]
472struct CodexLocalDispatchSpec {
473    ir_path: String,
474    component: String,
475    mcp: CodexLocalMcp,
476}
477
478/// Build the `codex-local dispatch` argv for one invocation. Pure (no I/O, no process spawn) so
479/// the flag wiring — which is the part most likely to drift as `dispatcher/codex-local`'s own CLI
480/// grows flags — is unit-testable without a built `dist/cli.js` or a live `codex` call. Callers
481/// resolve `ir_path`/`server_command`/`project_abs` to absolute paths first (see `invoke`); this
482/// function only assembles the argument list from already-resolved pieces.
483fn build_dispatch_args(
484    spec: &CodexLocalDispatchSpec,
485    ir_path: &Path,
486    server_command: &Path,
487    project_abs: &Path,
488    question: &str,
489    model_override: Option<&str>,
490) -> Vec<String> {
491    let mut args: Vec<String> = vec![
492        "dispatch".to_string(),
493        ir_path.display().to_string(),
494        question.to_string(),
495        "--component".to_string(),
496        spec.component.clone(),
497        "--server".to_string(),
498        spec.mcp.name.clone(),
499        "--server-command".to_string(),
500        server_command.display().to_string(),
501    ];
502    for server_arg in &spec.mcp.args {
503        args.push("--server-arg".to_string());
504        args.push(server_arg.clone());
505    }
506    for tool in &spec.mcp.source_tools {
507        args.push("--source-tool".to_string());
508        args.push(tool.clone());
509    }
510    for tool in &spec.mcp.context_tools {
511        args.push("--context-tool".to_string());
512        args.push(tool.clone());
513    }
514    args.push("--project".to_string());
515    args.push(project_abs.display().to_string());
516    // Mirrors the other adapters' `--model` override: `None` (the ablation/frontmatter path)
517    // leaves the CLI's own default (`gpt-5.4`) in place.
518    if let Some(model) = model_override {
519        args.push("--model".to_string());
520        args.push(model.to_string());
521    }
522    args
523}
524
525/// First non-blank line of a process's stderr, or a fallback when stderr was empty (or all
526/// whitespace) — the diagnostic every failure path here surfaces alongside the exit status, so a
527/// case's failure reason is never just the bare generic string.
528fn first_stderr_line(stderr: &str) -> &str {
529    stderr
530        .lines()
531        .map(str::trim)
532        .find(|line| !line.is_empty())
533        .unwrap_or("no stderr output")
534}
535
536/// The `codex-local` back-end: drives `dispatcher/codex-local`'s own `dispatch <ir.json> "<question>"
537/// --component <id> --server-command <path> …` CLI subcommand — a sandboxed, single-turn `codex exec`
538/// call (see `dispatcher/codex-local/src/config.ts`'s `buildCodexArgs`: `--sandbox read-only`,
539/// `--ignore-user-config`, shell/web/browser/apps disabled) restricted to one allowlisted MCP tool.
540/// See [`CodexLocalDispatchSpec`] for why `agent` means something different here than it does for
541/// [`ClaudeAgentSdkAdapter`].
542pub(crate) struct CodexLocalAdapter;
543
544impl BackendAdapter for CodexLocalAdapter {
545    fn invoke(
546        &self,
547        project: &Path,
548        agent: &str,
549        path_env: &str,
550        question: &str,
551        model_override: Option<&str>,
552    ) -> AdapterResult {
553        // Carry a reason on every failure path — same rationale as `ClaudeAgentSdkAdapter::invoke`:
554        // discarding it would render every failure of this back-end as the bare generic string.
555        let fail = |reason: String| AdapterResult {
556            ok: false,
557            raw: reason,
558            latency_ms: None,
559            cost: None,
560            turns: None,
561        };
562
563        let spec_path = absolute_path(Path::new(agent));
564        let spec_text = match std::fs::read_to_string(&spec_path) {
565            Ok(text) => text,
566            Err(e) => {
567                return fail(format!(
568                    "could not read codex-local dispatch spec {}: {e}",
569                    spec_path.display()
570                ))
571            }
572        };
573        let spec: CodexLocalDispatchSpec = match serde_json::from_str(&spec_text) {
574            Ok(spec) => spec,
575            Err(e) => return fail(describe_spec_parse_failure(&spec_path, &spec_text, &e)),
576        };
577        let spec_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
578        let ir_path = absolute_path(&spec_dir.join(&spec.ir_path));
579        let server_command = absolute_path(&spec_dir.join(&spec.mcp.command));
580        let project_abs = absolute_path(project);
581
582        let args = build_dispatch_args(
583            &spec,
584            &ir_path,
585            &server_command,
586            &project_abs,
587            question,
588            model_override,
589        );
590
591        let output = Command::new("node")
592            .arg(codex_local_cli_js())
593            .args(&args)
594            .current_dir(codex_local_dir())
595            .env("PATH", path_env)
596            .output();
597
598        let o = match output {
599            Ok(o) => o,
600            Err(e) => {
601                return fail(format!(
602                    "could not run the codex-local dispatch CLI via node: {e}"
603                ))
604            }
605        };
606        if !o.status.success() {
607            let stderr = String::from_utf8_lossy(&o.stderr);
608            let detail = first_stderr_line(&stderr);
609            return fail(format!(
610                "codex-local dispatch CLI failed ({}): {detail}",
611                o.status
612            ));
613        }
614
615        // `dispatch` (non-`--stream-json`) writes only the final answer text to stdout — no
616        // cost/latency/turn metadata anywhere (unlike `claude-agent-sdk`'s `trace.json`), so all
617        // three are genuinely unavailable here, never a defaulted `0`/`0.0`.
618        let raw = String::from_utf8_lossy(&o.stdout).trim_end().to_string();
619        AdapterResult {
620            ok: true,
621            raw,
622            latency_ms: None,
623            cost: None,
624            turns: None,
625        }
626    }
627}
628
629/// Resolve `backend` to a runner adapter, or fail loudly naming the supported subset. Recognizing a
630/// back-end's *name* (every `dispatcher/<name>` directory, via `clap::ValueEnum`) is not the same as
631/// having a real adapter wired up for it — this is the boundary that keeps the eval runner from ever
632/// silently pretending to support a back-end it doesn't.
633pub(crate) fn resolve_adapter(backend: Backend) -> Result<Box<dyn BackendAdapter>, String> {
634    let supported = format!(
635        "{}, {}, {}",
636        Backend::ClaudeCodeCli.as_str(),
637        Backend::ClaudeAgentSdk.as_str(),
638        Backend::CodexLocal.as_str()
639    );
640    match backend {
641        Backend::ClaudeCodeCli => Ok(Box::new(ClaudeCodeCliAdapter)),
642        Backend::ClaudeAgentSdk => {
643            let cli_js = claude_agent_sdk_cli_js();
644            if !cli_js.exists() {
645                return Err(format!(
646                    "backend 'claude-agent-sdk' has no built CLI at {} — run `just build-ts` first",
647                    cli_js.display()
648                ));
649            }
650            Ok(Box::new(ClaudeAgentSdkAdapter))
651        }
652        Backend::CodexLocal => {
653            let cli_js = codex_local_cli_js();
654            if !cli_js.exists() {
655                return Err(format!(
656                    "backend 'codex-local' has no built CLI at {} — run `just build-codex-ts` first",
657                    cli_js.display()
658                ));
659            }
660            Ok(Box::new(CodexLocalAdapter))
661        }
662        other => Err(format!(
663            "backend '{other}' has no eval runner adapter yet — supported: {supported}"
664        )),
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn default_backend_is_claude_code_cli() {
674        assert_eq!(Backend::default(), Backend::ClaudeCodeCli);
675    }
676
677    #[test]
678    fn as_str_matches_dispatcher_directory_names() {
679        assert_eq!(Backend::ClaudeCodeCli.as_str(), "claude-code-cli");
680        assert_eq!(Backend::ClaudeAgentSdk.as_str(), "claude-agent-sdk");
681        assert_eq!(Backend::CodexLocal.as_str(), "codex-local");
682        assert_eq!(Backend::Vercel.as_str(), "vercel");
683    }
684
685    #[test]
686    fn resolve_adapter_supports_claude_code_cli_claude_agent_sdk_and_codex_local() {
687        assert!(resolve_adapter(Backend::ClaudeCodeCli).is_ok());
688        // `claude-agent-sdk` and `codex-local` resolve only once their own TS build step has
689        // produced `dist/cli.js` — true in CI/dev after `just build-ts` / `just build-codex-ts`,
690        // but this unit test must not assume either artifact exists, so it only asserts the
691        // *shape* of whichever outcome occurs, and that a missing build names its own fix.
692        for (backend, build_fix) in [
693            (Backend::ClaudeAgentSdk, "just build-ts"),
694            (Backend::CodexLocal, "just build-codex-ts"),
695        ] {
696            match resolve_adapter(backend) {
697                Ok(_) => {}
698                Err(e) => assert!(
699                    e.contains(build_fix),
700                    "missing-build error for {backend:?} names the fix: {e}"
701                ),
702            }
703        }
704        // `.expect_err` would require `Box<dyn BackendAdapter>: Debug`, which it isn't (and
705        // shouldn't be) — match manually instead.
706        let err = match resolve_adapter(Backend::Vercel) {
707            Err(e) => e,
708            Ok(_) => panic!("no adapter yet for this backend"),
709        };
710        assert!(
711            err.contains("claude-code-cli")
712                && err.contains("claude-agent-sdk")
713                && err.contains("codex-local"),
714            "error names the supported subset: {err}"
715        );
716    }
717
718    #[test]
719    fn serde_roundtrip_is_kebab_case() {
720        let json = serde_json::to_string(&Backend::ClaudeAgentSdk).unwrap();
721        assert_eq!(json, "\"claude-agent-sdk\"");
722        let back: Backend = serde_json::from_str(&json).unwrap();
723        assert_eq!(back, Backend::ClaudeAgentSdk);
724    }
725
726    // --- codex-local dispatch-spec parse diagnostics -----------------------------------------
727    //
728    // A user who points `--ir` at a compiled IR for `codex-local` the same way they would for
729    // `claude-agent-sdk` must not see "missing field `ir_path`" against a file that was never
730    // meant to have one — these guard the actionable-error fix for that regression.
731
732    #[test]
733    fn spec_parse_failure_on_a_compiled_ir_names_the_real_problem() {
734        let spec_path = Path::new("compiled-ir.json");
735        let ir_text = r#"{"warble_ir_version": "0.5", "components": []}"#;
736        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(ir_text).unwrap_err();
737        let msg = describe_spec_parse_failure(spec_path, ir_text, &cause);
738        assert!(
739            msg.contains("looks like a compiled IR"),
740            "names the real shape mismatch: {msg}"
741        );
742        assert!(
743            msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"),
744            "shows the expected sidecar shape inline: {msg}"
745        );
746        assert!(
747            msg.contains("evaluating.md"),
748            "points at the documented shape: {msg}"
749        );
750        assert!(
751            !msg.contains("missing field"),
752            "must not just echo the raw serde error against the IR's own fields: {msg}"
753        );
754    }
755
756    #[test]
757    fn spec_parse_failure_on_garbage_names_the_expected_shape() {
758        let spec_path = Path::new("not-a-spec.json");
759        let garbage = "not json at all";
760        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(garbage).unwrap_err();
761        let msg = describe_spec_parse_failure(spec_path, garbage, &cause);
762        assert!(!msg.contains("looks like a compiled IR"));
763        assert!(msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"));
764        assert!(msg.contains("evaluating.md"));
765    }
766
767    #[test]
768    fn spec_parse_failure_on_valid_json_missing_a_field_names_the_expected_shape() {
769        // Valid JSON, not IR-shaped, just missing a required sidecar field — the generic branch,
770        // not the compiled-IR-specific one.
771        let spec_path = Path::new("almost-a-spec.json");
772        let almost = r#"{"component": "build_context", "mcp": {"command": "./x"}}"#;
773        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(almost).unwrap_err();
774        let msg = describe_spec_parse_failure(spec_path, almost, &cause);
775        assert!(!msg.contains("looks like a compiled IR"));
776        assert!(msg.contains("ir_path") && msg.contains("mcp"));
777    }
778
779    // --- codex-local dispatch argv building ---------------------------------------------------
780
781    fn sample_mcp() -> CodexLocalMcp {
782        CodexLocalMcp {
783            name: "setup".to_string(),
784            command: "fake-mcp.mjs".to_string(),
785            args: vec!["--flag".to_string()],
786            source_tools: vec!["probe_source".to_string()],
787            context_tools: vec!["probe_setup".to_string()],
788        }
789    }
790
791    #[test]
792    fn dispatch_args_include_component_server_and_tool_flags_in_order() {
793        let spec = CodexLocalDispatchSpec {
794            ir_path: "ir.json".to_string(),
795            component: "build_context".to_string(),
796            mcp: sample_mcp(),
797        };
798        let args = build_dispatch_args(
799            &spec,
800            Path::new("/abs/ir.json"),
801            Path::new("/abs/fake-mcp.mjs"),
802            Path::new("/abs/project"),
803            "what tables exist?",
804            Some("gpt-5.4-mini"),
805        );
806        assert_eq!(
807            args,
808            vec![
809                "dispatch",
810                "/abs/ir.json",
811                "what tables exist?",
812                "--component",
813                "build_context",
814                "--server",
815                "setup",
816                "--server-command",
817                "/abs/fake-mcp.mjs",
818                "--server-arg",
819                "--flag",
820                "--source-tool",
821                "probe_source",
822                "--context-tool",
823                "probe_setup",
824                "--project",
825                "/abs/project",
826                "--model",
827                "gpt-5.4-mini",
828            ]
829        );
830    }
831
832    #[test]
833    fn dispatch_args_omit_model_flag_when_no_override() {
834        let spec = CodexLocalDispatchSpec {
835            ir_path: "ir.json".to_string(),
836            component: "connect_source".to_string(),
837            mcp: CodexLocalMcp {
838                name: CodexLocalMcp::default_name(),
839                command: "fake-mcp.mjs".to_string(),
840                args: vec![],
841                source_tools: vec![],
842                context_tools: vec![],
843            },
844        };
845        let args = build_dispatch_args(
846            &spec,
847            Path::new("/abs/ir.json"),
848            Path::new("/abs/fake-mcp.mjs"),
849            Path::new("/abs/project"),
850            "q",
851            None,
852        );
853        assert!(!args.contains(&"--model".to_string()));
854        assert!(!args.contains(&"--server-arg".to_string()));
855    }
856
857    // --- diagnostic extraction -----------------------------------------------------------------
858
859    #[test]
860    fn first_stderr_line_skips_blank_lines() {
861        assert_eq!(
862            first_stderr_line("\n\n  real error  \nmore\n"),
863            "real error"
864        );
865    }
866
867    #[test]
868    fn first_stderr_line_falls_back_when_all_blank() {
869        assert_eq!(first_stderr_line("   \n\n"), "no stderr output");
870        assert_eq!(first_stderr_line(""), "no stderr output");
871    }
872
873    // --- CodexLocalAdapter::invoke failure paths (no process spawn, no live call) -------------
874
875    #[test]
876    fn invoke_reports_a_missing_spec_file_with_its_own_path() {
877        let result = CodexLocalAdapter.invoke(
878            Path::new("/nonexistent-project"),
879            "/nonexistent-codex-local-spec.json",
880            "",
881            "question",
882            None,
883        );
884        assert!(!result.ok);
885        assert!(result.raw.contains("/nonexistent-codex-local-spec.json"));
886        assert_eq!(result.cost, None);
887        assert_eq!(result.latency_ms, None);
888        assert_eq!(result.turns, None);
889    }
890}