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}