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::collections::HashSet;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23/// Which back-end/runtime a case was (or should be) run against. Value domain matches the
24/// `dispatcher/<name>` directory names exactly, so `clap`'s own unknown-value error already lists
25/// every back-end this crate knows the *name* of — but knowing the name is not the same as having an
26/// adapter for it; see `resolve_adapter` (crate-private) below.
27///
28/// Defaults to [`Backend::ClaudeCodeCli`] so an eval invocation that never mentions `--backend` keeps
29/// measuring exactly what it always measured (binding decision: never silently change what an
30/// existing invocation reports).
31#[derive(
32    Debug,
33    Clone,
34    Copy,
35    PartialEq,
36    Eq,
37    Default,
38    serde::Serialize,
39    serde::Deserialize,
40    clap::ValueEnum,
41)]
42#[serde(rename_all = "kebab-case")]
43#[clap(rename_all = "kebab-case")]
44pub enum Backend {
45    #[default]
46    ClaudeCodeCli,
47    ClaudeAgentSdk,
48    CodexLocal,
49    Vercel,
50}
51
52impl Backend {
53    /// The spelling shared by CLI/serde and the cache-key element — identical to the back-end's
54    /// `dispatcher/<name>` directory name.
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            Backend::ClaudeCodeCli => "claude-code-cli",
58            Backend::ClaudeAgentSdk => "claude-agent-sdk",
59            Backend::CodexLocal => "codex-local",
60            Backend::Vercel => "vercel",
61        }
62    }
63}
64
65impl std::fmt::Display for Backend {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71/// What a [`BackendAdapter`] invocation actually measured. Every field a back-end genuinely cannot
72/// supply must come back `None`, never a defaulted `0.0`/`0` — a metric a back-end can't report is
73/// missing, not free (see [`crate::CaseResult::cost`] and friends for where this flows to).
74pub struct AdapterResult {
75    pub ok: bool,
76    pub raw: String,
77    pub latency_ms: Option<u64>,
78    pub cost: Option<f64>,
79    pub turns: Option<u64>,
80}
81
82/// The model binding a [`BackendAdapter::invoke`] call was asked to run with. `Flat` is the
83/// pre-existing whole-run override (one model pinned to every tier — what `--models` has always
84/// produced); `Tiered` is a differentiated per-tier binding (`--strong`/`--cheap`/`--orchestrator`
85/// or `--models-config`, each a genuinely distinct model). `run_eval` validates up front (see
86/// `validate_tier_binding_backend`) that only [`Backend::ClaudeAgentSdk`] ever receives `Tiered` —
87/// the other back-ends have no differentiated-tier knob at all, so their `invoke` only ever sees
88/// `None` or `Flat`, and treat a `Tiered` they should never receive as an internal-error fallback
89/// rather than silently flattening it.
90#[derive(Debug, Clone, Copy)]
91pub enum ModelOverride<'a> {
92    Flat(&'a str),
93    Tiered {
94        strong: &'a str,
95        cheap: &'a str,
96        orchestrator: &'a str,
97    },
98}
99
100/// One back-end's launch mechanism: how the question is passed, how the run is invoked, and how the
101/// trace/metadata come back. This is the seam that replaces the old hard-coded `Command::new("claude")`
102/// call in `run_case` — the target decides how it runs, not the eval loop.
103pub trait BackendAdapter: Sync {
104    /// Run one sample of one case's `question` against the already-installed agent under `project`,
105    /// returning the raw final output plus whatever cost/latency/turns metadata this back-end can
106    /// supply. `model_override` is `Some(Flat(_))` on the whole-run `--models` sweep path, or
107    /// `Some(Tiered { .. })` on a differentiated `--strong`/`--cheap`/`--orchestrator` (or
108    /// `--models-config`) binding, or `None` on the ablation/frontmatter path (the tier→model
109    /// binding is baked into the emitted agent). `max_turns` is the `--max-turns` cap this run was
110    /// invoked with (`None` = the back-end's own default). `run_eval` validates up front that only
111    /// [`Backend::ClaudeAgentSdk`] ever receives a `Some` for `max_turns`, or a `Tiered` binding for
112    /// `model_override` (see `validate_max_turns_backend` / `validate_tier_binding_backend`), so the
113    /// other implementors only ever need to handle `None`/`Flat`.
114    fn invoke(
115        &self,
116        project: &Path,
117        agent: &str,
118        path_env: &str,
119        question: &str,
120        model_override: Option<ModelOverride<'_>>,
121        max_turns: Option<u32>,
122    ) -> AdapterResult;
123}
124
125/// The reference back-end: shells out to the `claude` CLI exactly as `run_case` always has. The
126/// capability envelope (which tools the agent may use) is never a literal in this crate —
127/// `claude-code-cli`'s own dispatch already computed it as a per-component `.claude/settings.json`,
128/// and `install_agents` copies that file into the project before this is ever invoked. But `claude
129/// -p` only *auto-discovers* `.claude/settings.json` when the project directory has prior workspace
130/// trust recorded in `~/.claude.json` — a directory it has never seen before (every fresh CI runner
131/// checkout, and every non-interactive fixture directory) silently drops the file's
132/// `permissions.allow` entirely and runs with no grant, which is what turned into a near-total
133/// accuracy collapse in CI (confirmed by reproducing the same collapse locally against a
134/// newly-created, never-trusted directory, while the identical run against this trusted worktree
135/// passes). Passing the already-installed file's own content inline via `--settings <json>` bypasses
136/// file *discovery* — and therefore the trust gate — while still sourcing the envelope from the
137/// same computed file, not a hard-coded literal here.
138pub(crate) struct ClaudeCodeCliAdapter;
139
140impl BackendAdapter for ClaudeCodeCliAdapter {
141    fn invoke(
142        &self,
143        project: &Path,
144        agent: &str,
145        path_env: &str,
146        question: &str,
147        model_override: Option<ModelOverride<'_>>,
148        // `claude -p` has no turn-budget flag at all; `run_eval`'s upfront validation guarantees
149        // this is always `None` for this back-end (see `validate_max_turns_backend`), so it is
150        // accepted only for trait uniformity and otherwise unused here.
151        _max_turns: Option<u32>,
152    ) -> AdapterResult {
153        // Carry a reason on every failure path — the same convention `ClaudeAgentSdkAdapter` and
154        // `CodexLocalAdapter` already follow. This adapter was the one that did not, and it is the
155        // one the jaffle gate runs: an auth rejection or a missing binary reached the report as a
156        // bare "backend invocation failed" for all N cases, which reads as an accuracy collapse
157        // rather than as the environment failure it is.
158        let fail = |reason: String| AdapterResult {
159            ok: false,
160            raw: reason,
161            latency_ms: None,
162            cost: None,
163            turns: None,
164        };
165
166        // `claude -p` takes a single `--model` flag — no differentiated-tier knob exists.
167        // `run_eval`'s upfront `validate_tier_binding_backend` guarantees a `Tiered` binding never
168        // reaches this back-end at all, so this arm is unreachable in practice; it stays a loud,
169        // non-panicking failure rather than silently flattening to one of the three models.
170        let model: Option<&str> = match model_override {
171            None => None,
172            Some(ModelOverride::Flat(model)) => Some(model),
173            Some(ModelOverride::Tiered { .. }) => {
174                return fail(
175                    "internal error: backend 'claude-code-cli' received a differentiated tier \
176binding, which validate_tier_binding_backend should have rejected before any process was spawned"
177                        .to_string(),
178                );
179            }
180        };
181        let mut args: Vec<String> = vec![
182            "-p".to_string(),
183            question.to_string(),
184            "--agent".to_string(),
185            agent.to_string(),
186        ];
187        if let Some(model) = model {
188            args.push("--model".to_string());
189            args.push(model.to_string());
190        }
191        // Same file `install_agents` just copied to `project/.claude/settings.json` — read back and
192        // handed to `claude` inline so it applies regardless of this directory's trust history.
193        //
194        // Absent and unreadable are deliberately NOT the same case. `install_agents` treats
195        // settings.json as optional (an agent dir may legitimately ship none), so a missing file
196        // keeps the pre-existing behaviour: run without an envelope override. A file that exists
197        // but cannot be read is the dangerous case — proceeding would silently drop both the
198        // computed `permissions.allow` grant and the `deny` list (destructive-bash denials), which
199        // is exactly the silent envelope loss this whole code path exists to prevent. Fail loudly
200        // rather than run unprotected.
201        let settings_path = project.join(".claude/settings.json");
202        match std::fs::read_to_string(&settings_path) {
203            Ok(settings_json) => {
204                args.push("--settings".to_string());
205                args.push(settings_json);
206            }
207            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
208            Err(e) => {
209                return fail(format!(
210                    ".claude/settings.json exists but could not be read ({e}); refusing to run \
211without its capability envelope"
212                ));
213            }
214        }
215        args.push("--output-format".to_string());
216        args.push("json".to_string());
217
218        let output = Command::new("claude")
219            .args(&args)
220            .current_dir(project)
221            .env("PATH", path_env)
222            .output();
223
224        let raw = match output {
225            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
226            // Non-zero exit. `claude` writes its own diagnostic to stderr (an auth rejection, an
227            // unknown flag, a refused workspace) and then exits; that line is the only evidence of
228            // *why* the run produced nothing, so it must reach the report.
229            Ok(o) => {
230                let stderr = String::from_utf8_lossy(&o.stderr);
231                let detail = stderr
232                    .lines()
233                    .map(str::trim)
234                    .find(|line| !line.is_empty())
235                    .unwrap_or("no stderr output");
236                let status = match o.status.code() {
237                    Some(code) => format!("status {code}"),
238                    None => "a signal".to_string(),
239                };
240                return fail(format!("`claude` exited with {status}: {detail}"));
241            }
242            // The process never started: `claude` is absent from `path_env`, or is not executable.
243            // Indistinguishable from the above in the report until it says so.
244            Err(e) => {
245                return fail(format!(
246                    "could not spawn `claude` (not found on the run PATH, or not executable): {e}"
247                ));
248            }
249        };
250
251        let meta: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
252        let result_text = meta
253            .get("result")
254            .and_then(|v| v.as_str())
255            .unwrap_or(&raw)
256            .to_string();
257        let latency_ms = meta.get("duration_ms").and_then(|v| v.as_u64());
258        let cost = meta.get("total_cost_usd").and_then(|v| v.as_f64());
259        let turns = meta.get("num_turns").and_then(|v| v.as_u64());
260
261        AdapterResult {
262            ok: true,
263            raw: result_text,
264            latency_ms,
265            cost,
266            turns,
267        }
268    }
269}
270
271/// This checkout's own `claude-agent-sdk` back-end package — a fixed sibling of the `eval/runner`
272/// crate's manifest dir, known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking
273/// the filesystem at runtime. Mirrors the precedent set by `cli::in_repo_hub_dir` for locating an
274/// in-repo sibling package the same way.
275fn claude_agent_sdk_dir() -> PathBuf {
276    Path::new(env!("CARGO_MANIFEST_DIR"))
277        .join("..")
278        .join("..")
279        .join("dispatcher")
280        .join("claude-agent-sdk")
281}
282
283/// The built CLI entry point (`npm run build`, i.e. `just build-ts`, emits this via `tsup`). Kept as
284/// its own function so `resolve_adapter` can check it exists (and fail loudly with a build
285/// instruction) before ever reaching a `Report`/`CaseKey`/`Trace`, rather than discovering it missing
286/// deep inside a per-case `invoke`.
287fn claude_agent_sdk_cli_js() -> PathBuf {
288    claude_agent_sdk_dir().join("dist").join("cli.js")
289}
290
291/// The `claude-agent-sdk` back-end: drives `dispatcher/claude-agent-sdk`'s own `dispatch <ir.json>
292/// "<question>" --out <dir>` CLI subcommand — the SDK's `query()` loop run headlessly, one question
293/// at a time, exactly like `ClaudeCodeCliAdapter` drives `claude -p`. Unlike `claude-code-cli` (which
294/// installs static per-component agent files ahead of time), this back-end has no pre-installed
295/// "dispatched agent dir" to point at — it dispatches directly from the compiled IR at invocation
296/// time. So for this adapter the `agent` parameter (otherwise a frontmatter agent name) is
297/// repurposed to carry the path to the compiled `ir.json` instead; see `run_eval`'s backend branch
298/// for where that path comes from. The `BackendAdapter` trait signature itself is unchanged — only
299/// the *meaning* of one already-generic string parameter is generalized per back-end.
300pub(crate) struct ClaudeAgentSdkAdapter;
301
302impl ClaudeAgentSdkAdapter {
303    /// `dispatch.ts`'s `prepareDispatch` maps the same question over every component in the fed
304    /// IR (no `--component` filter exists on `dispatch`, unlike `chat`) — so the IR path handed to
305    /// this adapter must itself be single-component, same as `eval/answer-agent`'s compiled
306    /// `answer-ir.json` used for the claude-code-cli golden run.
307    fn absolute(path: &Path) -> PathBuf {
308        if path.is_absolute() {
309            path.to_path_buf()
310        } else {
311            std::env::current_dir().unwrap_or_default().join(path)
312        }
313    }
314}
315
316/// Build the `claude-agent-sdk dispatch` argv for one invocation. Pure (no I/O, no process spawn)
317/// so the tier-binding flag wiring — the exact mechanism a differentiated `--strong`/`--cheap`/
318/// `--orchestrator` binding depends on — is unit-testable without a built `dist/cli.js` or a live
319/// SDK call. Mirrors `build_dispatch_args` (the `codex-local` analogue below) in shape and
320/// rationale. Callers resolve `ir_path`/`out_dir`/`project_abs` to absolute paths first (see
321/// `invoke`); this function only assembles the argument list from already-resolved pieces.
322fn build_claude_agent_sdk_dispatch_args(
323    ir_path: &Path,
324    question: &str,
325    out_dir: &Path,
326    project_abs: &Path,
327    model_override: Option<ModelOverride<'_>>,
328    max_turns: Option<u32>,
329) -> Vec<String> {
330    let mut args: Vec<String> = vec![
331        "dispatch".to_string(),
332        ir_path.display().to_string(),
333        question.to_string(),
334        "--out".to_string(),
335        out_dir.display().to_string(),
336        "--project".to_string(),
337        project_abs.display().to_string(),
338    ];
339    // `Flat` mirrors `ClaudeCodeCliAdapter`'s `--model` override: pin every tier to the same
340    // model, regardless of the frontmatter/IR's own per-step tier binding. `Tiered` passes three
341    // genuinely distinct values through to `dispatch`'s own `--strong`/`--cheap`/`--orchestrator`
342    // flags, so this back-end's behaviour no longer depends on `cli.ts`'s hardcoded defaults for a
343    // differentiated binding. `None` (the ablation/frontmatter path) leaves the CLI's own tier
344    // defaults in place.
345    match model_override {
346        None => {}
347        Some(ModelOverride::Flat(model)) => {
348            args.extend([
349                "--strong".to_string(),
350                model.to_string(),
351                "--cheap".to_string(),
352                model.to_string(),
353                "--orchestrator".to_string(),
354                model.to_string(),
355            ]);
356        }
357        Some(ModelOverride::Tiered {
358            strong,
359            cheap,
360            orchestrator,
361        }) => {
362            args.extend([
363                "--strong".to_string(),
364                strong.to_string(),
365                "--cheap".to_string(),
366                cheap.to_string(),
367                "--orchestrator".to_string(),
368                orchestrator.to_string(),
369            ]);
370        }
371    }
372    // The dispatch CLI's own `--max-turns N` flag (see `dispatcher/claude-agent-sdk/src/cli.ts`).
373    // `None` leaves the SDK's own default turn budget in place, same convention as
374    // `model_override`'s `None` above.
375    if let Some(n) = max_turns {
376        args.push("--max-turns".to_string());
377        args.push(n.to_string());
378    }
379    args
380}
381
382impl BackendAdapter for ClaudeAgentSdkAdapter {
383    fn invoke(
384        &self,
385        project: &Path,
386        agent: &str,
387        path_env: &str,
388        question: &str,
389        model_override: Option<ModelOverride<'_>>,
390        max_turns: Option<u32>,
391    ) -> AdapterResult {
392        // Carry a reason on every failure path. `dispatch`'s own CLI writes `error: <message>` to
393        // stderr before exiting non-zero, and the runner surfaces an adapter's first output line in
394        // the case's failure reason — discarding it here would render every failure of this
395        // back-end as the bare generic string, with the diagnostic thrown away.
396        let fail = |reason: String| AdapterResult {
397            ok: false,
398            raw: reason,
399            latency_ms: None,
400            cost: None,
401            turns: None,
402        };
403
404        let out_dir = match tempfile::tempdir() {
405            Ok(dir) => dir,
406            Err(e) => {
407                return fail(format!(
408                    "could not create a temporary output directory: {e}"
409                ))
410            }
411        };
412        let ir_path = Self::absolute(Path::new(agent));
413        let project_abs = Self::absolute(project);
414
415        let args = build_claude_agent_sdk_dispatch_args(
416            &ir_path,
417            question,
418            out_dir.path(),
419            &project_abs,
420            model_override,
421            max_turns,
422        );
423
424        let output = Command::new("node")
425            .arg(claude_agent_sdk_cli_js())
426            .args(&args)
427            .current_dir(claude_agent_sdk_dir())
428            .env("PATH", path_env)
429            .output();
430
431        let o = match output {
432            Ok(o) => o,
433            Err(e) => return fail(format!("could not run the dispatch CLI via node: {e}")),
434        };
435        if !o.status.success() {
436            let stderr = String::from_utf8_lossy(&o.stderr);
437            let detail = stderr
438                .lines()
439                .map(str::trim)
440                .find(|line| !line.is_empty())
441                .unwrap_or("no stderr output");
442            return fail(format!("dispatch CLI failed ({}): {detail}", o.status));
443        }
444
445        let raw = std::fs::read_to_string(out_dir.path().join("result.txt")).unwrap_or_default();
446        let trace: serde_json::Value = std::fs::read_to_string(out_dir.path().join("trace.json"))
447            .ok()
448            .and_then(|text| serde_json::from_str(&text).ok())
449            .unwrap_or(serde_json::Value::Null);
450        // `trace.json`'s `run` field is `{...} | null` — `null` exactly when the SDK's result
451        // message never arrived, which naturally yields `None` for all three below rather than a
452        // defaulted `0`/`0.0`.
453        let run = trace.get("run");
454        let latency_ms = run
455            .and_then(|r| r.get("duration_ms"))
456            .and_then(|v| v.as_u64());
457        let cost = run
458            .and_then(|r| r.get("total_cost_usd"))
459            .and_then(|v| v.as_f64());
460        let turns = run
461            .and_then(|r| r.get("num_turns"))
462            .and_then(|v| v.as_u64());
463
464        AdapterResult {
465            ok: true,
466            raw,
467            latency_ms,
468            cost,
469            turns,
470        }
471    }
472}
473
474/// This checkout's own `codex-local` back-end package — a fixed sibling of the `eval/runner` crate's
475/// manifest dir, same convention as [`claude_agent_sdk_dir`].
476fn codex_local_dir() -> PathBuf {
477    Path::new(env!("CARGO_MANIFEST_DIR"))
478        .join("..")
479        .join("..")
480        .join("dispatcher")
481        .join("codex-local")
482}
483
484/// The built CLI entry point (`just build-codex-ts`, i.e. `npm run build` in `dispatcher/codex-local`,
485/// emits this via `tsup`). Same role as [`claude_agent_sdk_cli_js`]: checked by `resolve_adapter`
486/// before ever reaching a `Report`/`CaseKey`/`Trace`.
487fn codex_local_cli_js() -> PathBuf {
488    codex_local_dir().join("dist").join("cli.js")
489}
490
491fn absolute_path(path: &Path) -> PathBuf {
492    if path.is_absolute() {
493        path.to_path_buf()
494    } else {
495        std::env::current_dir().unwrap_or_default().join(path)
496    }
497}
498
499/// Worked examples of [`CodexLocalDispatchSpec`]'s two JSON shapes, inlined into every
500/// parse-failure message below so the fix is visible without leaving the terminal.
501const CODEX_LOCAL_SPEC_SHAPES: &str = concat!(
502    r#"setup-shaped {"ir_path":"<compiled IR>","component":"build_context","mcp":{"command":"<MCP executable>","args":[],"tools_by_step":{"build":["<tool>"]},"require_tool":["build"]}}; "#,
503    r#"ask-shaped {"shape":"ask","ir_path":"<compiled IR>","component":"answer_query","codex_home":"<Codex home>","mcp":{"command":"<MCP executable>","args":[],"tools_by_step":{"resolve_intent":["get_context"],"generate_sql":["run_sql"],"repair_sql":["run_sql"]}}}"#
504);
505
506/// Where the full write-up (every field, a runnable example, the `warble eval run` invocation)
507/// lives — named in every parse-failure message rather than left to the doc comments on
508/// [`CodexLocalDispatchSpec`] alone, which a CLI user never sees.
509const CODEX_LOCAL_SPEC_DOC_POINTER: &str =
510    "see the \"codex-local\" section of docs/site/docs/guides/evaluating.md for the full shape and a worked example";
511
512/// Whether `spec_text` looks like a compiled IR (a `warble_ir_version` key present) rather than a
513/// codex-local dispatch spec. This is the exact confusion a user hits by pointing `--ir` at their
514/// compiled IR the same way they would for [`ClaudeAgentSdkAdapter`] — without this check, the
515/// resulting `serde_json` error reads as "your IR is missing a field named `ir_path`", which
516/// describes the file's own shape as broken rather than naming the real problem (this back-end
517/// wants a different artifact under `--ir`).
518fn looks_like_compiled_ir(spec_text: &str) -> bool {
519    serde_json::from_str::<serde_json::Value>(spec_text)
520        .ok()
521        .is_some_and(|v| v.get("warble_ir_version").is_some())
522}
523
524/// Turn a [`CodexLocalDispatchSpec`] parse failure into a message a user who just followed
525/// `--ir`'s own `--help` text can act on, instead of a bare `serde_json::Error` quoted against a
526/// file it was never meant to describe. See [`looks_like_compiled_ir`] for why the compiled-IR
527/// case gets its own wording.
528fn describe_spec_parse_failure(
529    spec_path: &Path,
530    spec_text: &str,
531    cause: &serde_json::Error,
532) -> String {
533    if looks_like_compiled_ir(spec_text) {
534        format!(
535            "{} looks like a compiled IR (it has a `warble_ir_version` field), not a codex-local \
536dispatch spec. Unlike `claude-agent-sdk`, backend 'codex-local' does not take the compiled IR \
537directly via --ir — point --ir at an unambiguous setup-shaped or ask-shaped JSON dispatch spec \
538that names this IR (plus the component and MCP server to dispatch it with) instead: \
539{CODEX_LOCAL_SPEC_SHAPES}. \
540{CODEX_LOCAL_SPEC_DOC_POINTER}.",
541            spec_path.display()
542        )
543    } else {
544        format!(
545            "could not parse {} as a codex-local dispatch spec ({cause}). Backend 'codex-local' \
546needs either the setup-shaped spec or an explicitly ask-shaped spec; do not mix their fields: \
547{CODEX_LOCAL_SPEC_SHAPES}. {CODEX_LOCAL_SPEC_DOC_POINTER}.",
548            spec_path.display()
549        )
550    }
551}
552
553/// Caller-owned per-step MCP grants for an exec dispatch; no capability-to-tool inference.
554/// `command` and any relative `ir_path` alongside it in
555/// [`CodexLocalDispatchSpec`] are resolved relative to the spec file's own directory, not the
556/// process cwd — the spec is meant to travel with (and point at) its sibling artifacts.
557#[derive(Debug, serde::Deserialize)]
558#[serde(deny_unknown_fields)]
559struct CodexLocalSetupMcp {
560    #[serde(default = "CodexLocalSetupMcp::default_name")]
561    name: String,
562    command: String,
563    #[serde(default)]
564    args: Vec<String>,
565    #[serde(default)]
566    tools_by_step: std::collections::BTreeMap<String, Vec<String>>,
567    #[serde(default)]
568    require_tool: Vec<String>,
569}
570
571impl CodexLocalSetupMcp {
572    fn default_name() -> String {
573        "setup".to_string()
574    }
575}
576
577/// Exec sidecar without a discriminator. Legacy capability-grant fields are rejected;
578/// callers must supply exact step bindings and successful-tool requirements explicitly.
579///
580/// `claude-agent-sdk`'s `dispatch` subcommand takes only an IR path (see [`ClaudeAgentSdkAdapter`]'s
581/// doc comment) because it maps the question over every component in the fed IR. `codex-local`'s
582/// `dispatch` is shaped differently: it REQUIRES `--component` and an external MCP server binding.
583/// `BackendAdapter::invoke`'s fixed 5-argument signature has no channel for either, so `agent` for
584/// this back-end is repurposed once more: not an IR path, but the path to this small JSON spec.
585#[derive(Debug, serde::Deserialize)]
586#[serde(deny_unknown_fields)]
587struct CodexLocalSetupDispatchSpec {
588    ir_path: String,
589    component: String,
590    mcp: CodexLocalSetupMcp,
591}
592
593#[derive(Debug, serde::Deserialize)]
594#[serde(rename_all = "snake_case")]
595enum CodexLocalAskShape {
596    Ask,
597}
598
599/// Independent answer-query step grants, emitted as repeatable `--step-tool` bindings.
600#[derive(Debug, serde::Deserialize)]
601#[serde(deny_unknown_fields)]
602struct CodexLocalAskToolsByStep {
603    resolve_intent: Vec<String>,
604    generate_sql: Vec<String>,
605    repair_sql: Vec<String>,
606}
607
608#[derive(Debug, serde::Deserialize)]
609#[serde(deny_unknown_fields)]
610struct CodexLocalAskMcp {
611    #[serde(default = "CodexLocalAskMcp::default_name")]
612    name: String,
613    command: String,
614    #[serde(default)]
615    args: Vec<String>,
616    tools_by_step: CodexLocalAskToolsByStep,
617}
618
619impl CodexLocalAskMcp {
620    fn default_name() -> String {
621        "wren".to_string()
622    }
623}
624
625/// Ask is explicit because its runtime needs more authority-bearing inputs than setup: the
626/// dedicated Codex home and a per-step Wren tool allowlist. Relative paths use the same
627/// spec-relative resolution as the original setup shape.
628#[derive(Debug, serde::Deserialize)]
629#[serde(deny_unknown_fields)]
630struct CodexLocalAskDispatchSpec {
631    #[serde(rename = "shape")]
632    _shape: CodexLocalAskShape,
633    ir_path: String,
634    component: String,
635    codex_home: String,
636    mcp: CodexLocalAskMcp,
637}
638
639/// Backward-compatible implicit setup plus explicitly tagged ask. `deny_unknown_fields` on both
640/// variants makes a mixed/ambiguous object fail rather than winning whichever untagged arm happens
641/// to be tried first.
642#[derive(Debug, serde::Deserialize)]
643#[serde(untagged)]
644enum CodexLocalDispatchSpec {
645    Ask(CodexLocalAskDispatchSpec),
646    Setup(CodexLocalSetupDispatchSpec),
647}
648
649/// Build the original setup-shaped `codex-local dispatch` argv. Kept separate so its exact existing
650/// vector remains pinned while ask grows its own required flags.
651fn build_setup_dispatch_args(
652    spec: &CodexLocalSetupDispatchSpec,
653    ir_path: &Path,
654    server_command: &Path,
655    project_abs: &Path,
656    question: &str,
657    model_override: Option<&str>,
658) -> Vec<String> {
659    let mut args: Vec<String> = vec![
660        "dispatch".to_string(),
661        ir_path.display().to_string(),
662        question.to_string(),
663        "--component".to_string(),
664        spec.component.clone(),
665        "--transport".to_string(),
666        "exec".to_string(),
667        "--server".to_string(),
668        spec.mcp.name.clone(),
669        "--server-command".to_string(),
670        server_command.display().to_string(),
671    ];
672    for server_arg in &spec.mcp.args {
673        args.push(format!("--server-arg={server_arg}"));
674    }
675    for (step, tools) in &spec.mcp.tools_by_step {
676        for tool in tools {
677            args.push("--step-tool".to_string());
678            args.push(format!("{step}={tool}"));
679        }
680    }
681    for step in &spec.mcp.require_tool {
682        args.extend(["--require-tool".to_string(), step.clone()]);
683    }
684    args.push("--project".to_string());
685    args.push(project_abs.display().to_string());
686    if let Some(model) = model_override {
687        args.push("--model".to_string());
688        args.push(model.to_string());
689    }
690    args
691}
692
693/// Build the ask-shaped argv. `codex-local` cannot accept a differentiated eval binding, so the
694/// ordinary flat `--models` sweep is repeated across the three CLI-required model slots. This is
695/// one flat binding, not a per-tier experiment.
696fn build_ask_dispatch_args(
697    spec: &CodexLocalAskDispatchSpec,
698    ir_path: &Path,
699    server_command: &Path,
700    codex_home: &Path,
701    project_abs: &Path,
702    question: &str,
703    flat_model: &str,
704) -> Result<Vec<String>, String> {
705    let mut args = vec![
706        "dispatch".to_string(),
707        ir_path.display().to_string(),
708        question.to_string(),
709        "--component".to_string(),
710        spec.component.clone(),
711        "--server".to_string(),
712        spec.mcp.name.clone(),
713        "--server-command".to_string(),
714        server_command.display().to_string(),
715    ];
716    for server_arg in &spec.mcp.args {
717        args.push(format!("--server-arg={server_arg}"));
718    }
719    args.extend(["--transport".to_string(), "orchestrate".to_string()]);
720    for (step, tools) in [
721        ("resolve_intent", &spec.mcp.tools_by_step.resolve_intent),
722        ("generate_sql", &spec.mcp.tools_by_step.generate_sql),
723        ("repair_sql", &spec.mcp.tools_by_step.repair_sql),
724    ] {
725        for tool in tools {
726            args.push("--step-tool".to_string());
727            args.push(format!("{step}={tool}"));
728        }
729    }
730    args.extend([
731        "--require-tool".to_string(),
732        "generate_sql".to_string(),
733        "--require-tool".to_string(),
734        "repair_sql".to_string(),
735    ]);
736    args.extend([
737        "--project".to_string(),
738        project_abs.display().to_string(),
739        "--codex-home".to_string(),
740        codex_home.display().to_string(),
741        "--orchestrator-model".to_string(),
742        flat_model.to_string(),
743        "--cheap-model".to_string(),
744        flat_model.to_string(),
745        "--strong-model".to_string(),
746        flat_model.to_string(),
747    ]);
748    Ok(args)
749}
750
751/// Build the `codex-local dispatch` argv for one invocation. Pure (no I/O, no process spawn) so
752/// the flag wiring — which is the part most likely to drift as `dispatcher/codex-local`'s own CLI
753/// grows flags — is unit-testable without a built `dist/cli.js` or a live `codex` call. Callers
754/// resolve `ir_path`/`server_command`/`project_abs` to absolute paths first (see `invoke`); this
755/// function only assembles the argument list from already-resolved pieces.
756fn build_dispatch_args(
757    spec: &CodexLocalDispatchSpec,
758    ir_path: &Path,
759    server_command: &Path,
760    project_abs: &Path,
761    question: &str,
762    codex_home: Option<&Path>,
763    model_override: Option<&str>,
764) -> Result<Vec<String>, String> {
765    match spec {
766        CodexLocalDispatchSpec::Setup(spec) => Ok(build_setup_dispatch_args(
767            spec,
768            ir_path,
769            server_command,
770            project_abs,
771            question,
772            model_override,
773        )),
774        CodexLocalDispatchSpec::Ask(spec) => {
775            let flat_model = model_override.ok_or_else(|| {
776                "ask-shaped codex-local eval requires a flat --models binding; the \
777frontmatter/ablation path cannot supply the three CLI-required model slots"
778                    .to_string()
779            })?;
780            let codex_home = codex_home.ok_or_else(|| {
781                "internal error: ask-shaped codex-local spec lost its codex_home path".to_string()
782            })?;
783            build_ask_dispatch_args(
784                spec,
785                ir_path,
786                server_command,
787                codex_home,
788                project_abs,
789                question,
790                flat_model,
791            )
792        }
793    }
794}
795
796/// Project the rich table envelope emitted by the Ask runtime onto eval's existing positional
797/// `{columns,rows}` contract. The product runtime deliberately returns object rows plus definition,
798/// summary, and verification metadata; eval needs only the table, and its comparator deserializes
799/// rows as `Vec<Vec<Value>>`. This adapter-boundary projection keeps both contracts intact.
800///
801/// Conversion is intentionally fail-closed: every column must be a unique string and every object
802/// row must contain exactly that key set. Anything already positional, malformed, mixed, missing,
803/// or extra is returned unchanged so the ordinary extractor rejects it rather than this adapter
804/// dropping or inventing data.
805fn normalize_codex_local_ask_stdout(raw: &str) -> Option<String> {
806    let value: serde_json::Value = serde_json::from_str(raw).ok()?;
807    let object = value.as_object()?;
808    let columns = object.get("columns")?.as_array()?;
809    let column_names = columns
810        .iter()
811        .map(serde_json::Value::as_str)
812        .collect::<Option<Vec<_>>>()?;
813    let mut seen = HashSet::with_capacity(column_names.len());
814    if column_names.iter().any(|name| !seen.insert(*name)) {
815        return None;
816    }
817
818    let rows = object.get("rows")?.as_array()?;
819    if rows.is_empty() || rows.iter().all(serde_json::Value::is_array) {
820        return None;
821    }
822    if !rows.iter().all(serde_json::Value::is_object) {
823        return None;
824    }
825
826    let normalized_rows = rows
827        .iter()
828        .map(|row| {
829            let row = row.as_object()?;
830            if row.len() != column_names.len() {
831                return None;
832            }
833            column_names
834                .iter()
835                .map(|column| row.get(*column).cloned())
836                .collect::<Option<Vec<_>>>()
837                .map(serde_json::Value::Array)
838        })
839        .collect::<Option<Vec<_>>>()?;
840
841    serde_json::to_string(&serde_json::json!({
842        "columns": columns,
843        "rows": normalized_rows,
844    }))
845    .ok()
846}
847
848/// Apply Ask's eval-only table projection without changing setup output or already-valid tables.
849fn normalize_codex_local_stdout(spec: &CodexLocalDispatchSpec, raw: String) -> String {
850    match spec {
851        CodexLocalDispatchSpec::Ask(_) => normalize_codex_local_ask_stdout(&raw).unwrap_or(raw),
852        CodexLocalDispatchSpec::Setup(_) => raw,
853    }
854}
855
856/// First non-blank line of a process's stderr, or a fallback when stderr was empty (or all
857/// whitespace) — the diagnostic every failure path here surfaces alongside the exit status, so a
858/// case's failure reason is never just the bare generic string.
859fn first_stderr_line(stderr: &str) -> &str {
860    stderr
861        .lines()
862        .map(str::trim)
863        .find(|line| !line.is_empty())
864        .unwrap_or("no stderr output")
865}
866
867/// The `codex-local` back-end: drives `dispatcher/codex-local`'s own `dispatch <ir.json> "<question>"
868/// --component <id> --server-command <path> …` CLI subcommand. Setup uses its sandboxed single-turn
869/// execution; ask uses the dispatcher's read-only named-step runtime and per-step MCP allowlists.
870/// See [`CodexLocalDispatchSpec`] for why `agent` means something different here than it does for
871/// [`ClaudeAgentSdkAdapter`].
872pub(crate) struct CodexLocalAdapter;
873
874impl BackendAdapter for CodexLocalAdapter {
875    fn invoke(
876        &self,
877        project: &Path,
878        agent: &str,
879        path_env: &str,
880        question: &str,
881        model_override: Option<ModelOverride<'_>>,
882        // Already sandboxed to a single turn (see the struct doc above) with no turn-budget knob
883        // of its own; `run_eval`'s upfront validation guarantees this is always `None` for this
884        // back-end (see `validate_max_turns_backend`), so it is accepted only for trait uniformity.
885        _max_turns: Option<u32>,
886    ) -> AdapterResult {
887        // Carry a reason on every failure path — same rationale as `ClaudeAgentSdkAdapter::invoke`:
888        // discarding it would render every failure of this back-end as the bare generic string.
889        let fail = |reason: String| AdapterResult {
890            ok: false,
891            raw: reason,
892            latency_ms: None,
893            cost: None,
894            turns: None,
895        };
896
897        // `codex-local` has no eval-time differentiated-tier knob. Ask's CLI requires three model
898        // slots, but this adapter fills all three from one flat override; it never accepts three
899        // different eval bindings. `run_eval`'s upfront `validate_tier_binding_backend` guarantees
900        // a `Tiered` binding never reaches this back-end, so this arm is unreachable in practice;
901        // it stays a loud, non-panicking failure rather than silently flattening one of them.
902        let model: Option<&str> = match model_override {
903            None => None,
904            Some(ModelOverride::Flat(model)) => Some(model),
905            Some(ModelOverride::Tiered { .. }) => {
906                return fail(
907                    "internal error: backend 'codex-local' received a differentiated tier \
908binding, which validate_tier_binding_backend should have rejected before any process was spawned"
909                        .to_string(),
910                );
911            }
912        };
913
914        let spec_path = absolute_path(Path::new(agent));
915        let spec_text = match std::fs::read_to_string(&spec_path) {
916            Ok(text) => text,
917            Err(e) => {
918                return fail(format!(
919                    "could not read codex-local dispatch spec {}: {e}",
920                    spec_path.display()
921                ))
922            }
923        };
924        let spec: CodexLocalDispatchSpec = match serde_json::from_str(&spec_text) {
925            Ok(spec) => spec,
926            Err(e) => return fail(describe_spec_parse_failure(&spec_path, &spec_text, &e)),
927        };
928        let spec_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
929        let (ir_path_raw, server_command_raw, codex_home_raw) = match &spec {
930            CodexLocalDispatchSpec::Setup(spec) => (&spec.ir_path, &spec.mcp.command, None),
931            CodexLocalDispatchSpec::Ask(spec) => (
932                &spec.ir_path,
933                &spec.mcp.command,
934                Some(spec.codex_home.as_str()),
935            ),
936        };
937        let ir_path = absolute_path(&spec_dir.join(ir_path_raw));
938        let server_command = absolute_path(&spec_dir.join(server_command_raw));
939        let codex_home = codex_home_raw.map(|path| absolute_path(&spec_dir.join(path)));
940        let project_abs = absolute_path(project);
941
942        let args = match build_dispatch_args(
943            &spec,
944            &ir_path,
945            &server_command,
946            &project_abs,
947            question,
948            codex_home.as_deref(),
949            model,
950        ) {
951            Ok(args) => args,
952            Err(e) => return fail(e),
953        };
954
955        let output = Command::new("node")
956            .arg(codex_local_cli_js())
957            .args(&args)
958            .current_dir(codex_local_dir())
959            .env("PATH", path_env)
960            .output();
961
962        let o = match output {
963            Ok(o) => o,
964            Err(e) => {
965                return fail(format!(
966                    "could not run the codex-local dispatch CLI via node: {e}"
967                ))
968            }
969        };
970        if !o.status.success() {
971            let stderr = String::from_utf8_lossy(&o.stderr);
972            let detail = first_stderr_line(&stderr);
973            return fail(format!(
974                "codex-local dispatch CLI failed ({}): {detail}",
975                o.status
976            ));
977        }
978
979        // `dispatch` (non-`--stream-json`) writes only the final answer text to stdout — no
980        // cost/latency/turn metadata anywhere (unlike `claude-agent-sdk`'s `trace.json`), so all
981        // three are genuinely unavailable here, never a defaulted `0`/`0.0`.
982        let raw = String::from_utf8_lossy(&o.stdout).trim_end().to_string();
983        let raw = normalize_codex_local_stdout(&spec, raw);
984        AdapterResult {
985            ok: true,
986            raw,
987            latency_ms: None,
988            cost: None,
989            turns: None,
990        }
991    }
992}
993
994/// Resolve `backend` to a runner adapter, or fail loudly naming the supported subset. Recognizing a
995/// back-end's *name* (every `dispatcher/<name>` directory, via `clap::ValueEnum`) is not the same as
996/// having a real adapter wired up for it — this is the boundary that keeps the eval runner from ever
997/// silently pretending to support a back-end it doesn't.
998pub(crate) fn resolve_adapter(backend: Backend) -> Result<Box<dyn BackendAdapter>, String> {
999    let supported = format!(
1000        "{}, {}, {}",
1001        Backend::ClaudeCodeCli.as_str(),
1002        Backend::ClaudeAgentSdk.as_str(),
1003        Backend::CodexLocal.as_str()
1004    );
1005    match backend {
1006        Backend::ClaudeCodeCli => Ok(Box::new(ClaudeCodeCliAdapter)),
1007        Backend::ClaudeAgentSdk => {
1008            let cli_js = claude_agent_sdk_cli_js();
1009            if !cli_js.exists() {
1010                return Err(format!(
1011                    "backend 'claude-agent-sdk' has no built CLI at {} — run `just build-ts` first",
1012                    cli_js.display()
1013                ));
1014            }
1015            Ok(Box::new(ClaudeAgentSdkAdapter))
1016        }
1017        Backend::CodexLocal => {
1018            let cli_js = codex_local_cli_js();
1019            if !cli_js.exists() {
1020                return Err(format!(
1021                    "backend 'codex-local' has no built CLI at {} — run `just build-codex-ts` first",
1022                    cli_js.display()
1023                ));
1024            }
1025            Ok(Box::new(CodexLocalAdapter))
1026        }
1027        other => Err(format!(
1028            "backend '{other}' has no eval runner adapter yet — supported: {supported}"
1029        )),
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    /// The gate this test exists to hold: a failure that produced no output must still say why.
1038    ///
1039    /// `ClaudeCodeCliAdapter` used to collapse "the process never started" and "the process exited
1040    /// non-zero" into `raw: String::new()`. The runner renders an empty reason as the bare string
1041    /// `backend invocation failed`, so an expired credential or a missing binary reached the
1042    /// committed report as every case scoring zero — indistinguishable from the model getting
1043    /// every answer wrong, with the actual cause written down nowhere.
1044    ///
1045    /// Pointing the adapter's run PATH at a directory holding no `claude` reproduces exactly that
1046    /// class of failure, with no credential, no network call and no real CLI.
1047    #[test]
1048    fn a_failure_to_spawn_names_its_cause_rather_than_reporting_nothing() {
1049        let project = tempfile::tempdir().expect("tempdir");
1050        let claude_free_path = tempfile::tempdir().expect("tempdir");
1051
1052        let result = ClaudeCodeCliAdapter.invoke(
1053            project.path(),
1054            "answer_query",
1055            claude_free_path
1056                .path()
1057                .to_str()
1058                .expect("utf-8 tempdir path"),
1059            "how many customers are there?",
1060            None,
1061            None,
1062        );
1063
1064        assert!(!result.ok, "no `claude` on PATH must be a failure");
1065        assert!(
1066            !result.raw.trim().is_empty(),
1067            "an empty reason is the whole defect: the runner turns it into a bare \
1068             'backend invocation failed' with the cause discarded"
1069        );
1070        assert!(
1071            result.raw.contains("could not spawn `claude`"),
1072            "the reason must name the spawn failure; got: {}",
1073            result.raw
1074        );
1075    }
1076
1077    #[test]
1078    fn default_backend_is_claude_code_cli() {
1079        assert_eq!(Backend::default(), Backend::ClaudeCodeCli);
1080    }
1081
1082    #[test]
1083    fn as_str_matches_dispatcher_directory_names() {
1084        assert_eq!(Backend::ClaudeCodeCli.as_str(), "claude-code-cli");
1085        assert_eq!(Backend::ClaudeAgentSdk.as_str(), "claude-agent-sdk");
1086        assert_eq!(Backend::CodexLocal.as_str(), "codex-local");
1087        assert_eq!(Backend::Vercel.as_str(), "vercel");
1088    }
1089
1090    #[test]
1091    fn resolve_adapter_supports_claude_code_cli_claude_agent_sdk_and_codex_local() {
1092        assert!(resolve_adapter(Backend::ClaudeCodeCli).is_ok());
1093        // `claude-agent-sdk` and `codex-local` resolve only once their own TS build step has
1094        // produced `dist/cli.js` — true in CI/dev after `just build-ts` / `just build-codex-ts`,
1095        // but this unit test must not assume either artifact exists, so it only asserts the
1096        // *shape* of whichever outcome occurs, and that a missing build names its own fix.
1097        for (backend, build_fix) in [
1098            (Backend::ClaudeAgentSdk, "just build-ts"),
1099            (Backend::CodexLocal, "just build-codex-ts"),
1100        ] {
1101            match resolve_adapter(backend) {
1102                Ok(_) => {}
1103                Err(e) => assert!(
1104                    e.contains(build_fix),
1105                    "missing-build error for {backend:?} names the fix: {e}"
1106                ),
1107            }
1108        }
1109        // `.expect_err` would require `Box<dyn BackendAdapter>: Debug`, which it isn't (and
1110        // shouldn't be) — match manually instead.
1111        let err = match resolve_adapter(Backend::Vercel) {
1112            Err(e) => e,
1113            Ok(_) => panic!("no adapter yet for this backend"),
1114        };
1115        assert!(
1116            err.contains("claude-code-cli")
1117                && err.contains("claude-agent-sdk")
1118                && err.contains("codex-local"),
1119            "error names the supported subset: {err}"
1120        );
1121    }
1122
1123    #[test]
1124    fn serde_roundtrip_is_kebab_case() {
1125        let json = serde_json::to_string(&Backend::ClaudeAgentSdk).unwrap();
1126        assert_eq!(json, "\"claude-agent-sdk\"");
1127        let back: Backend = serde_json::from_str(&json).unwrap();
1128        assert_eq!(back, Backend::ClaudeAgentSdk);
1129    }
1130
1131    // --- codex-local dispatch-spec parse diagnostics -----------------------------------------
1132    //
1133    // A user who points `--ir` at a compiled IR for `codex-local` the same way they would for
1134    // `claude-agent-sdk` must not see "missing field `ir_path`" against a file that was never
1135    // meant to have one — these guard the actionable-error fix for that regression.
1136
1137    #[test]
1138    fn spec_parse_failure_on_a_compiled_ir_names_the_real_problem() {
1139        let spec_path = Path::new("compiled-ir.json");
1140        let ir_text = r#"{"warble_ir_version": "0.9", "components": []}"#;
1141        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(ir_text).unwrap_err();
1142        let msg = describe_spec_parse_failure(spec_path, ir_text, &cause);
1143        assert!(
1144            msg.contains("looks like a compiled IR"),
1145            "names the real shape mismatch: {msg}"
1146        );
1147        assert!(
1148            msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"),
1149            "shows the expected sidecar shape inline: {msg}"
1150        );
1151        assert!(
1152            msg.contains("evaluating.md"),
1153            "points at the documented shape: {msg}"
1154        );
1155        assert!(
1156            !msg.contains("missing field"),
1157            "must not just echo the raw serde error against the IR's own fields: {msg}"
1158        );
1159    }
1160
1161    #[test]
1162    fn spec_parse_failure_on_garbage_names_the_expected_shape() {
1163        let spec_path = Path::new("not-a-spec.json");
1164        let garbage = "not json at all";
1165        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(garbage).unwrap_err();
1166        let msg = describe_spec_parse_failure(spec_path, garbage, &cause);
1167        assert!(!msg.contains("looks like a compiled IR"));
1168        assert!(msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"));
1169        assert!(msg.contains("evaluating.md"));
1170    }
1171
1172    #[test]
1173    fn spec_parse_failure_on_valid_json_missing_a_field_names_the_expected_shape() {
1174        // Valid JSON, not IR-shaped, just missing a required sidecar field — the generic branch,
1175        // not the compiled-IR-specific one.
1176        let spec_path = Path::new("almost-a-spec.json");
1177        let almost = r#"{"component": "build_context", "mcp": {"command": "./x"}}"#;
1178        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(almost).unwrap_err();
1179        let msg = describe_spec_parse_failure(spec_path, almost, &cause);
1180        assert!(!msg.contains("looks like a compiled IR"));
1181        assert!(msg.contains("ir_path") && msg.contains("mcp"));
1182    }
1183
1184    // --- claude-agent-sdk dispatch argv building --------------------------------------------
1185    //
1186    // Pins the literal arg vector `ClaudeAgentSdkAdapter::invoke` hands to `dispatch` for each
1187    // `ModelOverride` shape. The `Tiered` case is the one the independent mutation review found
1188    // untested: nothing here caught `strong`/`cheap` being swapped, which is exactly the silent
1189    // tier-swap this feature exists to prevent. `claude_agent_sdk_tiered_args_catch_a_strong_cheap_swap`
1190    // documents that this test is order-sensitive, not just present-sensitive.
1191
1192    #[test]
1193    fn claude_agent_sdk_dispatch_args_omit_tier_flags_when_no_override() {
1194        let args = build_claude_agent_sdk_dispatch_args(
1195            Path::new("/abs/ir.json"),
1196            "what tables exist?",
1197            Path::new("/abs/out"),
1198            Path::new("/abs/project"),
1199            None,
1200            None,
1201        );
1202        assert_eq!(
1203            args,
1204            vec![
1205                "dispatch",
1206                "/abs/ir.json",
1207                "what tables exist?",
1208                "--out",
1209                "/abs/out",
1210                "--project",
1211                "/abs/project",
1212            ]
1213        );
1214    }
1215
1216    #[test]
1217    fn claude_agent_sdk_dispatch_args_pin_all_three_tiers_to_the_same_model_for_flat_override() {
1218        let args = build_claude_agent_sdk_dispatch_args(
1219            Path::new("/abs/ir.json"),
1220            "q",
1221            Path::new("/abs/out"),
1222            Path::new("/abs/project"),
1223            Some(ModelOverride::Flat("sonnet")),
1224            None,
1225        );
1226        assert_eq!(
1227            args,
1228            vec![
1229                "dispatch",
1230                "/abs/ir.json",
1231                "q",
1232                "--out",
1233                "/abs/out",
1234                "--project",
1235                "/abs/project",
1236                "--strong",
1237                "sonnet",
1238                "--cheap",
1239                "sonnet",
1240                "--orchestrator",
1241                "sonnet",
1242            ]
1243        );
1244    }
1245
1246    /// Order-sensitive by construction: `strong`/`cheap`/`orchestrator` are three distinct
1247    /// values, so any transposition among the `--strong`/`--cheap`/`--orchestrator` flag values
1248    /// (not just a missing/extra flag) fails this assertion. Verified by mutation: swapping
1249    /// `strong`/`cheap` in `build_claude_agent_sdk_dispatch_args`'s `Tiered` arm and re-running
1250    /// this test fails it (see the worker report for the before/after `cargo test` output);
1251    /// the swap was reverted afterward.
1252    #[test]
1253    fn claude_agent_sdk_tiered_args_catch_a_strong_cheap_swap() {
1254        let args = build_claude_agent_sdk_dispatch_args(
1255            Path::new("/abs/ir.json"),
1256            "what tables exist?",
1257            Path::new("/abs/out"),
1258            Path::new("/abs/project"),
1259            Some(ModelOverride::Tiered {
1260                strong: "opus",
1261                cheap: "haiku",
1262                orchestrator: "sonnet",
1263            }),
1264            None,
1265        );
1266        assert_eq!(
1267            args,
1268            vec![
1269                "dispatch",
1270                "/abs/ir.json",
1271                "what tables exist?",
1272                "--out",
1273                "/abs/out",
1274                "--project",
1275                "/abs/project",
1276                "--strong",
1277                "opus",
1278                "--cheap",
1279                "haiku",
1280                "--orchestrator",
1281                "sonnet",
1282            ]
1283        );
1284    }
1285
1286    #[test]
1287    fn claude_agent_sdk_dispatch_args_include_max_turns_alongside_a_tiered_binding() {
1288        let args = build_claude_agent_sdk_dispatch_args(
1289            Path::new("/abs/ir.json"),
1290            "q",
1291            Path::new("/abs/out"),
1292            Path::new("/abs/project"),
1293            Some(ModelOverride::Tiered {
1294                strong: "opus",
1295                cheap: "haiku",
1296                orchestrator: "sonnet",
1297            }),
1298            Some(6),
1299        );
1300        assert_eq!(
1301            args,
1302            vec![
1303                "dispatch",
1304                "/abs/ir.json",
1305                "q",
1306                "--out",
1307                "/abs/out",
1308                "--project",
1309                "/abs/project",
1310                "--strong",
1311                "opus",
1312                "--cheap",
1313                "haiku",
1314                "--orchestrator",
1315                "sonnet",
1316                "--max-turns",
1317                "6",
1318            ]
1319        );
1320    }
1321
1322    // --- codex-local dispatch argv building ---------------------------------------------------
1323
1324    #[test]
1325    fn setup_step_grants_are_explicit_and_legacy_capability_grants_are_rejected() {
1326        let spec: CodexLocalDispatchSpec = serde_json::from_value(serde_json::json!({
1327            "ir_path": "ir.json", "component": "custom",
1328            "mcp": {"command": "mcp", "tools_by_step": {"first": ["connect"], "second": ["build"], "unbound": []}, "require_tool": ["second"]}
1329        })).unwrap();
1330        let args = build_dispatch_args(
1331            &spec,
1332            Path::new("/ir"),
1333            Path::new("/mcp"),
1334            Path::new("/project"),
1335            "q",
1336            None,
1337            None,
1338        )
1339        .unwrap();
1340        let bindings: Vec<_> = args
1341            .windows(2)
1342            .filter(|pair| pair[0] == "--step-tool")
1343            .map(|pair| pair[1].as_str())
1344            .collect();
1345        assert_eq!(bindings, ["first=connect", "second=build"]);
1346        let required: Vec<_> = args
1347            .windows(2)
1348            .filter(|pair| pair[0] == "--require-tool")
1349            .map(|pair| pair[1].as_str())
1350            .collect();
1351        assert_eq!(required, ["second"]);
1352        assert!(serde_json::from_value::<CodexLocalDispatchSpec>(serde_json::json!({
1353            "ir_path": "ir.json", "component": "custom", "mcp": {"command": "mcp", "source_tools": ["connect"]}
1354        })).is_err());
1355    }
1356
1357    #[test]
1358    #[ignore = "requires codex-local npm dependencies; deterministic, no model calls"]
1359    fn generated_arguments_are_accepted_by_real_codex_cli() {
1360        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1361            .join("../..")
1362            .canonicalize()
1363            .unwrap();
1364        let cli_dir = root.join("dispatcher/codex-local");
1365        let cases = [
1366            (
1367                "examples/provision-agent/ir.golden.json",
1368                serde_json::json!({
1369                    "ir_path": "unused", "component": "attach_source",
1370                    "mcp": {"command": "/usr/bin/true", "tools_by_step": {"attach": ["probe_setup"]}, "require_tool": ["attach"]}
1371                }),
1372            ),
1373            (
1374                "examples/analysis-agent/ir.golden.json",
1375                serde_json::json!({
1376                    "shape": "ask", "ir_path": "unused", "component": "answer_query", "codex_home": "/unused",
1377                    "mcp": {"command": "/usr/bin/true", "tools_by_step": {"resolve_intent": ["get_context"], "generate_sql": ["run_sql"], "repair_sql": ["repair_sql"]}}
1378                }),
1379            ),
1380        ];
1381        for (ir, value) in cases {
1382            let spec: CodexLocalDispatchSpec = serde_json::from_value(value).unwrap();
1383            let mut args = build_dispatch_args(
1384                &spec,
1385                &root.join(ir),
1386                Path::new("/usr/bin/true"),
1387                &root,
1388                "question",
1389                Some(Path::new("/unused")),
1390                Some("fixture"),
1391            )
1392            .unwrap();
1393            // Manifest runs the shared real option parser and preparation, without starting Codex.
1394            args[0] = "manifest".into();
1395            args.remove(2);
1396            let output = std::process::Command::new("node")
1397                .current_dir(&cli_dir)
1398                .args(["--import", "tsx", "src/cli.ts"])
1399                .args(&args)
1400                .output()
1401                .unwrap();
1402            assert!(
1403                output.status.success(),
1404                "{}",
1405                String::from_utf8_lossy(&output.stderr)
1406            );
1407            let manifest: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
1408            assert_eq!(manifest["target"], "codex:local");
1409        }
1410    }
1411
1412    #[test]
1413    fn dispatch_args_include_component_server_and_tool_flags_in_order() {
1414        let legacy_setup = r#"{
1415            "ir_path": "ir.json",
1416            "component": "build_context",
1417            "mcp": {
1418                "name": "setup",
1419                "command": "fake-mcp.mjs",
1420                "args": ["--flag"],
1421                "tools_by_step": {"build": ["probe_setup"]},
1422                "require_tool": ["build"]
1423            }
1424        }"#;
1425        let spec: CodexLocalDispatchSpec =
1426            serde_json::from_str(legacy_setup).expect("the original setup shape still parses");
1427        let args = build_dispatch_args(
1428            &spec,
1429            Path::new("/abs/ir.json"),
1430            Path::new("/abs/fake-mcp.mjs"),
1431            Path::new("/abs/project"),
1432            "what tables exist?",
1433            None,
1434            Some("gpt-5.4-mini"),
1435        )
1436        .expect("setup argv");
1437        assert_eq!(
1438            args,
1439            vec![
1440                "dispatch",
1441                "/abs/ir.json",
1442                "what tables exist?",
1443                "--component",
1444                "build_context",
1445                "--transport",
1446                "exec",
1447                "--server",
1448                "setup",
1449                "--server-command",
1450                "/abs/fake-mcp.mjs",
1451                "--server-arg=--flag",
1452                "--step-tool",
1453                "build=probe_setup",
1454                "--require-tool",
1455                "build",
1456                "--project",
1457                "/abs/project",
1458                "--model",
1459                "gpt-5.4-mini",
1460            ]
1461        );
1462    }
1463
1464    #[test]
1465    fn dispatch_args_omit_model_flag_when_no_override() {
1466        let spec = CodexLocalDispatchSpec::Setup(CodexLocalSetupDispatchSpec {
1467            ir_path: "ir.json".to_string(),
1468            component: "connect_source".to_string(),
1469            mcp: CodexLocalSetupMcp {
1470                name: CodexLocalSetupMcp::default_name(),
1471                command: "fake-mcp.mjs".to_string(),
1472                args: vec![],
1473                tools_by_step: Default::default(),
1474                require_tool: vec![],
1475            },
1476        });
1477        let args = build_dispatch_args(
1478            &spec,
1479            Path::new("/abs/ir.json"),
1480            Path::new("/abs/fake-mcp.mjs"),
1481            Path::new("/abs/project"),
1482            "q",
1483            None,
1484            None,
1485        )
1486        .expect("setup argv");
1487        assert!(!args.contains(&"--model".to_string()));
1488        assert!(!args.contains(&"--server-arg".to_string()));
1489    }
1490
1491    #[test]
1492    fn ask_spec_parses_and_builds_the_exact_flat_binding_argv() {
1493        let text = r#"{
1494            "shape": "ask",
1495            "ir_path": "ir.json",
1496            "component": "answer_query",
1497            "codex_home": "codex-home",
1498            "mcp": {
1499                "command": "wren",
1500                "args": ["serve", "mcp", "--project", "/data/project", "--quiet"],
1501                "tools_by_step": {
1502                    "resolve_intent": ["get_context"],
1503                    "generate_sql": ["run_sql"],
1504                    "repair_sql": ["run_sql"]
1505                }
1506            }
1507        }"#;
1508        let spec: CodexLocalDispatchSpec = serde_json::from_str(text).expect("ask spec parses");
1509        let args = build_dispatch_args(
1510            &spec,
1511            Path::new("/abs/ir.json"),
1512            Path::new("/abs/wren"),
1513            Path::new("/abs/project"),
1514            "How many orders?",
1515            Some(Path::new("/abs/codex-home")),
1516            Some("gpt-5.4"),
1517        )
1518        .expect("ask argv");
1519        assert_eq!(
1520            args,
1521            vec![
1522                "dispatch",
1523                "/abs/ir.json",
1524                "How many orders?",
1525                "--component",
1526                "answer_query",
1527                "--server",
1528                "wren",
1529                "--server-command",
1530                "/abs/wren",
1531                "--server-arg=serve",
1532                "--server-arg=mcp",
1533                "--server-arg=--project",
1534                "--server-arg=/data/project",
1535                "--server-arg=--quiet",
1536                "--transport",
1537                "orchestrate",
1538                "--step-tool",
1539                "resolve_intent=get_context",
1540                "--step-tool",
1541                "generate_sql=run_sql",
1542                "--step-tool",
1543                "repair_sql=run_sql",
1544                "--require-tool",
1545                "generate_sql",
1546                "--require-tool",
1547                "repair_sql",
1548                "--project",
1549                "/abs/project",
1550                "--codex-home",
1551                "/abs/codex-home",
1552                "--orchestrator-model",
1553                "gpt-5.4",
1554                "--cheap-model",
1555                "gpt-5.4",
1556                "--strong-model",
1557                "gpt-5.4",
1558            ]
1559        );
1560    }
1561
1562    #[test]
1563    fn ask_spec_preserves_distinct_step_grants() {
1564        let spec = CodexLocalDispatchSpec::Ask(CodexLocalAskDispatchSpec {
1565            _shape: CodexLocalAskShape::Ask,
1566            ir_path: "ir.json".to_string(),
1567            component: "answer_query".to_string(),
1568            codex_home: "codex-home".to_string(),
1569            mcp: CodexLocalAskMcp {
1570                name: "wren".to_string(),
1571                command: "wren".to_string(),
1572                args: vec![],
1573                tools_by_step: CodexLocalAskToolsByStep {
1574                    resolve_intent: vec!["get_context".to_string()],
1575                    generate_sql: vec!["run_sql".to_string()],
1576                    repair_sql: vec!["repair_sql".to_string()],
1577                },
1578            },
1579        });
1580        let args = build_dispatch_args(
1581            &spec,
1582            Path::new("/abs/ir.json"),
1583            Path::new("/abs/wren"),
1584            Path::new("/abs/project"),
1585            "question",
1586            Some(Path::new("/abs/codex-home")),
1587            Some("gpt-5.4"),
1588        )
1589        .expect("independent grants must remain independent");
1590        assert!(args
1591            .windows(2)
1592            .any(|pair| pair == ["--step-tool", "generate_sql=run_sql"]));
1593        assert!(args
1594            .windows(2)
1595            .any(|pair| pair == ["--step-tool", "repair_sql=repair_sql"]));
1596        assert!(!args.contains(&"repair_sql=run_sql".to_string()));
1597    }
1598
1599    #[test]
1600    fn mixed_setup_and_ask_fields_fail_loudly_naming_both_shapes() {
1601        let mixed = r#"{
1602            "shape": "ask",
1603            "ir_path": "ir.json",
1604            "component": "answer_query",
1605            "codex_home": "codex-home",
1606            "mcp": {
1607                "command": "wren",
1608                "source_tools": [],
1609                "context_tools": [],
1610                "tools_by_step": {
1611                    "resolve_intent": ["get_context"],
1612                    "generate_sql": ["run_sql"],
1613                    "repair_sql": ["run_sql"]
1614                }
1615            }
1616        }"#;
1617        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(mixed)
1618            .expect_err("mixed shape must not parse");
1619        let message = describe_spec_parse_failure(Path::new("mixed.json"), mixed, &cause);
1620        assert!(message.contains("setup-shaped"), "names setup: {message}");
1621        assert!(message.contains("ask-shaped"), "names ask: {message}");
1622        assert!(message.contains("do not mix"), "names ambiguity: {message}");
1623    }
1624
1625    fn ask_spec_for_stdout_test() -> CodexLocalDispatchSpec {
1626        CodexLocalDispatchSpec::Ask(CodexLocalAskDispatchSpec {
1627            _shape: CodexLocalAskShape::Ask,
1628            ir_path: "ir.json".to_string(),
1629            component: "answer_query".to_string(),
1630            codex_home: "codex-home".to_string(),
1631            mcp: CodexLocalAskMcp {
1632                name: "wren".to_string(),
1633                command: "wren".to_string(),
1634                args: vec![],
1635                tools_by_step: CodexLocalAskToolsByStep {
1636                    resolve_intent: vec!["get_context".to_string()],
1637                    generate_sql: vec!["run_sql".to_string()],
1638                    repair_sql: vec!["run_sql".to_string()],
1639                },
1640            },
1641        })
1642    }
1643
1644    #[test]
1645    fn codex_local_ask_projects_rich_object_rows_in_declared_column_order() {
1646        let raw = r#"{"columns":["second","first"],"definition":{"sql":"SELECT 1"},"rows":[{"first":1,"second":2}],"summary":"rich","verified":true}"#;
1647        let normalized = normalize_codex_local_stdout(&ask_spec_for_stdout_test(), raw.to_string());
1648        let expected = serde_json::json!({
1649            "columns": ["second", "first"],
1650            "rows": [[2, 1]],
1651        });
1652
1653        assert_eq!(
1654            serde_json::from_str::<serde_json::Value>(&normalized).expect("normalized JSON"),
1655            expected
1656        );
1657        assert_eq!(
1658            crate::extract_result_json(&normalized).expect("ordinary extractor accepts projection"),
1659            expected,
1660            "recorded answers receive only the canonical table, not rich metadata"
1661        );
1662        let table = crate::extract_result(&normalized).expect("comparator-compatible Table");
1663        assert_eq!(table.columns, ["second", "first"]);
1664        assert_eq!(
1665            table.rows,
1666            vec![vec![serde_json::json!(2), serde_json::json!(1)]]
1667        );
1668    }
1669
1670    #[test]
1671    fn codex_local_stdout_normalization_is_ask_only_and_preserves_positional_tables() {
1672        let positional = "  {\"columns\":[\"value\"],\"rows\":[[42]]}";
1673        assert_eq!(
1674            normalize_codex_local_stdout(&ask_spec_for_stdout_test(), positional.to_string()),
1675            positional,
1676            "an already-valid Ask table stays byte-for-byte unchanged"
1677        );
1678
1679        let setup = CodexLocalDispatchSpec::Setup(CodexLocalSetupDispatchSpec {
1680            ir_path: "ir.json".to_string(),
1681            component: "build_context".to_string(),
1682            mcp: CodexLocalSetupMcp {
1683                name: "setup".to_string(),
1684                command: "server".to_string(),
1685                args: vec![],
1686                tools_by_step: Default::default(),
1687                require_tool: vec![],
1688            },
1689        });
1690        let rich = r#"{"columns":["value"],"rows":[{"value":42}],"verified":true}"#;
1691        assert_eq!(
1692            normalize_codex_local_stdout(&setup, rich.to_string()),
1693            rich,
1694            "setup stdout never passes through the Ask projection"
1695        );
1696    }
1697
1698    #[test]
1699    fn codex_local_ask_leaves_inconsistent_object_rows_unparseable() {
1700        let malformed = [
1701            r#"{"columns":["a","b"],"rows":[{"a":1}]}"#,
1702            r#"{"columns":["a"],"rows":[{"a":1,"b":2}]}"#,
1703            r#"{"columns":["a"],"rows":[{"a":1},[2]]}"#,
1704            r#"{"columns":["a","a"],"rows":[{"a":1}]}"#,
1705        ];
1706
1707        for raw in malformed {
1708            let unchanged =
1709                normalize_codex_local_stdout(&ask_spec_for_stdout_test(), raw.to_string());
1710            assert_eq!(unchanged, raw, "malformed row shape must fail closed");
1711            assert!(
1712                crate::extract_result(&unchanged).is_none(),
1713                "ordinary Table extraction must still reject malformed object rows: {raw}"
1714            );
1715        }
1716    }
1717
1718    // --- diagnostic extraction -----------------------------------------------------------------
1719
1720    #[test]
1721    fn first_stderr_line_skips_blank_lines() {
1722        assert_eq!(
1723            first_stderr_line("\n\n  real error  \nmore\n"),
1724            "real error"
1725        );
1726    }
1727
1728    #[test]
1729    fn first_stderr_line_falls_back_when_all_blank() {
1730        assert_eq!(first_stderr_line("   \n\n"), "no stderr output");
1731        assert_eq!(first_stderr_line(""), "no stderr output");
1732    }
1733
1734    // --- CodexLocalAdapter::invoke failure paths (no process spawn, no live call) -------------
1735
1736    #[test]
1737    fn invoke_reports_a_missing_spec_file_with_its_own_path() {
1738        let result = CodexLocalAdapter.invoke(
1739            Path::new("/nonexistent-project"),
1740            "/nonexistent-codex-local-spec.json",
1741            "",
1742            "question",
1743            None,
1744            None,
1745        );
1746        assert!(!result.ok);
1747        assert!(result.raw.contains("/nonexistent-codex-local-spec.json"));
1748        assert_eq!(result.cost, None);
1749        assert_eq!(result.latency_ms, None);
1750        assert_eq!(result.turns, None);
1751    }
1752
1753    // --- differentiated tier binding is rejected defensively, not flattened -------------------
1754    //
1755    // `run_eval`'s `validate_tier_binding_backend` is the real guard (checked before any process
1756    // spawns, so these adapters never see a `Tiered` binding in practice) — these tests only pin
1757    // down that if one ever did slip through, the back-end fails loudly instead of silently
1758    // collapsing three distinct models into one, which would misreport what actually ran.
1759
1760    #[test]
1761    fn codex_local_invoke_rejects_a_tiered_binding_before_touching_the_spec_file() {
1762        let result = CodexLocalAdapter.invoke(
1763            Path::new("/nonexistent-project"),
1764            "/nonexistent-codex-local-spec.json",
1765            "",
1766            "question",
1767            Some(ModelOverride::Tiered {
1768                strong: "sonnet",
1769                cheap: "haiku",
1770                orchestrator: "sonnet",
1771            }),
1772            None,
1773        );
1774        assert!(!result.ok);
1775        assert!(
1776            result.raw.contains("differentiated tier binding"),
1777            "names the real problem: {}",
1778            result.raw
1779        );
1780        // Rejected before ever reading the (nonexistent) spec file — the error is the internal
1781        // guard message, not a file-not-found error.
1782        assert!(!result.raw.contains("/nonexistent-codex-local-spec.json"));
1783    }
1784
1785    #[test]
1786    fn claude_code_cli_invoke_rejects_a_tiered_binding() {
1787        let result = ClaudeCodeCliAdapter.invoke(
1788            Path::new("/nonexistent-project"),
1789            "some-agent",
1790            "",
1791            "question",
1792            Some(ModelOverride::Tiered {
1793                strong: "sonnet",
1794                cheap: "haiku",
1795                orchestrator: "sonnet",
1796            }),
1797            None,
1798        );
1799        assert!(!result.ok);
1800        assert!(
1801            result.raw.contains("differentiated tier binding"),
1802            "names the real problem: {}",
1803            result.raw
1804        );
1805    }
1806}