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":[],"source_tools":[],"context_tools":["<tool>"]}}; "#,
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/// One setup MCP server binding for a `codex-local dispatch`: mirrors that CLI's own `--server-command` /
554/// `--server-arg` / `--source-tool` / `--context-tool` flags (`dispatcher/codex-local/src/prepare.ts`'s
555/// `McpServerConfig`). `command` and any relative `ir_path` alongside it in
556/// [`CodexLocalDispatchSpec`] are resolved relative to the spec file's own directory, not the
557/// process cwd — the spec is meant to travel with (and point at) its sibling artifacts.
558#[derive(Debug, serde::Deserialize)]
559#[serde(deny_unknown_fields)]
560struct CodexLocalSetupMcp {
561    #[serde(default = "CodexLocalSetupMcp::default_name")]
562    name: String,
563    command: String,
564    #[serde(default)]
565    args: Vec<String>,
566    #[serde(default)]
567    source_tools: Vec<String>,
568    #[serde(default)]
569    context_tools: Vec<String>,
570}
571
572impl CodexLocalSetupMcp {
573    fn default_name() -> String {
574        "setup".to_string()
575    }
576}
577
578/// The original setup-shaped sidecar. It deliberately has no `shape` field so every existing spec
579/// remains byte-for-byte valid. Unknown fields are rejected so adding ask-only fields cannot be
580/// silently misread as a setup request.
581///
582/// `claude-agent-sdk`'s `dispatch` subcommand takes only an IR path (see [`ClaudeAgentSdkAdapter`]'s
583/// doc comment) because it maps the question over every component in the fed IR. `codex-local`'s
584/// `dispatch` is shaped differently: it REQUIRES `--component` and an external MCP server binding.
585/// `BackendAdapter::invoke`'s fixed 5-argument signature has no channel for either, so `agent` for
586/// this back-end is repurposed once more: not an IR path, but the path to this small JSON spec.
587#[derive(Debug, serde::Deserialize)]
588#[serde(deny_unknown_fields)]
589struct CodexLocalSetupDispatchSpec {
590    ir_path: String,
591    component: String,
592    mcp: CodexLocalSetupMcp,
593}
594
595#[derive(Debug, serde::Deserialize)]
596#[serde(rename_all = "snake_case")]
597enum CodexLocalAskShape {
598    Ask,
599}
600
601/// The three answer-query step grants the current `codex-local` CLI exposes via
602/// `--inspect-tool` and `--query-tool`. The latter is shared by generate + repair, so the adapter
603/// rejects unequal declarations instead of silently widening either step.
604#[derive(Debug, serde::Deserialize)]
605#[serde(deny_unknown_fields)]
606struct CodexLocalAskToolsByStep {
607    resolve_intent: Vec<String>,
608    generate_sql: Vec<String>,
609    repair_sql: Vec<String>,
610}
611
612#[derive(Debug, serde::Deserialize)]
613#[serde(deny_unknown_fields)]
614struct CodexLocalAskMcp {
615    #[serde(default = "CodexLocalAskMcp::default_name")]
616    name: String,
617    command: String,
618    #[serde(default)]
619    args: Vec<String>,
620    tools_by_step: CodexLocalAskToolsByStep,
621}
622
623impl CodexLocalAskMcp {
624    fn default_name() -> String {
625        "wren".to_string()
626    }
627}
628
629/// Ask is explicit because its runtime needs more authority-bearing inputs than setup: the
630/// dedicated Codex home and a per-step Wren tool allowlist. Relative paths use the same
631/// spec-relative resolution as the original setup shape.
632#[derive(Debug, serde::Deserialize)]
633#[serde(deny_unknown_fields)]
634struct CodexLocalAskDispatchSpec {
635    #[serde(rename = "shape")]
636    _shape: CodexLocalAskShape,
637    ir_path: String,
638    component: String,
639    codex_home: String,
640    mcp: CodexLocalAskMcp,
641}
642
643/// Backward-compatible implicit setup plus explicitly tagged ask. `deny_unknown_fields` on both
644/// variants makes a mixed/ambiguous object fail rather than winning whichever untagged arm happens
645/// to be tried first.
646#[derive(Debug, serde::Deserialize)]
647#[serde(untagged)]
648enum CodexLocalDispatchSpec {
649    Ask(CodexLocalAskDispatchSpec),
650    Setup(CodexLocalSetupDispatchSpec),
651}
652
653/// Build the original setup-shaped `codex-local dispatch` argv. Kept separate so its exact existing
654/// vector remains pinned while ask grows its own required flags.
655fn build_setup_dispatch_args(
656    spec: &CodexLocalSetupDispatchSpec,
657    ir_path: &Path,
658    server_command: &Path,
659    project_abs: &Path,
660    question: &str,
661    model_override: Option<&str>,
662) -> Vec<String> {
663    let mut args: Vec<String> = vec![
664        "dispatch".to_string(),
665        ir_path.display().to_string(),
666        question.to_string(),
667        "--component".to_string(),
668        spec.component.clone(),
669        "--server".to_string(),
670        spec.mcp.name.clone(),
671        "--server-command".to_string(),
672        server_command.display().to_string(),
673    ];
674    for server_arg in &spec.mcp.args {
675        args.push("--server-arg".to_string());
676        args.push(server_arg.clone());
677    }
678    for tool in &spec.mcp.source_tools {
679        args.push("--source-tool".to_string());
680        args.push(tool.clone());
681    }
682    for tool in &spec.mcp.context_tools {
683        args.push("--context-tool".to_string());
684        args.push(tool.clone());
685    }
686    args.push("--project".to_string());
687    args.push(project_abs.display().to_string());
688    if let Some(model) = model_override {
689        args.push("--model".to_string());
690        args.push(model.to_string());
691    }
692    args
693}
694
695/// Build the ask-shaped argv. `codex-local` cannot accept a differentiated eval binding, so the
696/// ordinary flat `--models` sweep is repeated across the three CLI-required model slots. This is
697/// one flat binding, not a per-tier experiment.
698fn build_ask_dispatch_args(
699    spec: &CodexLocalAskDispatchSpec,
700    ir_path: &Path,
701    server_command: &Path,
702    codex_home: &Path,
703    project_abs: &Path,
704    question: &str,
705    flat_model: &str,
706) -> Result<Vec<String>, String> {
707    if spec.mcp.tools_by_step.generate_sql != spec.mcp.tools_by_step.repair_sql {
708        return Err(
709            "ask-shaped codex-local dispatch spec has different generate_sql and repair_sql \
710tool allowlists, but the current CLI exposes one shared --query-tool grant for both steps"
711                .to_string(),
712        );
713    }
714    let mut args = vec![
715        "dispatch".to_string(),
716        ir_path.display().to_string(),
717        question.to_string(),
718        "--component".to_string(),
719        spec.component.clone(),
720        "--server".to_string(),
721        spec.mcp.name.clone(),
722        "--server-command".to_string(),
723        server_command.display().to_string(),
724    ];
725    for server_arg in &spec.mcp.args {
726        args.push(format!("--server-arg={server_arg}"));
727    }
728    for tool in &spec.mcp.tools_by_step.resolve_intent {
729        args.push("--inspect-tool".to_string());
730        args.push(tool.clone());
731    }
732    for tool in &spec.mcp.tools_by_step.generate_sql {
733        args.push("--query-tool".to_string());
734        args.push(tool.clone());
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.6", "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 dispatch_args_include_component_server_and_tool_flags_in_order() {
1326        let legacy_setup = r#"{
1327            "ir_path": "ir.json",
1328            "component": "build_context",
1329            "mcp": {
1330                "name": "setup",
1331                "command": "fake-mcp.mjs",
1332                "args": ["--flag"],
1333                "source_tools": ["probe_source"],
1334                "context_tools": ["probe_setup"]
1335            }
1336        }"#;
1337        let spec: CodexLocalDispatchSpec =
1338            serde_json::from_str(legacy_setup).expect("the original setup shape still parses");
1339        let args = build_dispatch_args(
1340            &spec,
1341            Path::new("/abs/ir.json"),
1342            Path::new("/abs/fake-mcp.mjs"),
1343            Path::new("/abs/project"),
1344            "what tables exist?",
1345            None,
1346            Some("gpt-5.4-mini"),
1347        )
1348        .expect("setup argv");
1349        assert_eq!(
1350            args,
1351            vec![
1352                "dispatch",
1353                "/abs/ir.json",
1354                "what tables exist?",
1355                "--component",
1356                "build_context",
1357                "--server",
1358                "setup",
1359                "--server-command",
1360                "/abs/fake-mcp.mjs",
1361                "--server-arg",
1362                "--flag",
1363                "--source-tool",
1364                "probe_source",
1365                "--context-tool",
1366                "probe_setup",
1367                "--project",
1368                "/abs/project",
1369                "--model",
1370                "gpt-5.4-mini",
1371            ]
1372        );
1373    }
1374
1375    #[test]
1376    fn dispatch_args_omit_model_flag_when_no_override() {
1377        let spec = CodexLocalDispatchSpec::Setup(CodexLocalSetupDispatchSpec {
1378            ir_path: "ir.json".to_string(),
1379            component: "connect_source".to_string(),
1380            mcp: CodexLocalSetupMcp {
1381                name: CodexLocalSetupMcp::default_name(),
1382                command: "fake-mcp.mjs".to_string(),
1383                args: vec![],
1384                source_tools: vec![],
1385                context_tools: vec![],
1386            },
1387        });
1388        let args = build_dispatch_args(
1389            &spec,
1390            Path::new("/abs/ir.json"),
1391            Path::new("/abs/fake-mcp.mjs"),
1392            Path::new("/abs/project"),
1393            "q",
1394            None,
1395            None,
1396        )
1397        .expect("setup argv");
1398        assert!(!args.contains(&"--model".to_string()));
1399        assert!(!args.contains(&"--server-arg".to_string()));
1400    }
1401
1402    #[test]
1403    fn ask_spec_parses_and_builds_the_exact_flat_binding_argv() {
1404        let text = r#"{
1405            "shape": "ask",
1406            "ir_path": "ir.json",
1407            "component": "answer_query",
1408            "codex_home": "codex-home",
1409            "mcp": {
1410                "command": "wren",
1411                "args": ["serve", "mcp", "--project", "/data/project", "--quiet"],
1412                "tools_by_step": {
1413                    "resolve_intent": ["get_context"],
1414                    "generate_sql": ["run_sql"],
1415                    "repair_sql": ["run_sql"]
1416                }
1417            }
1418        }"#;
1419        let spec: CodexLocalDispatchSpec = serde_json::from_str(text).expect("ask spec parses");
1420        let args = build_dispatch_args(
1421            &spec,
1422            Path::new("/abs/ir.json"),
1423            Path::new("/abs/wren"),
1424            Path::new("/abs/project"),
1425            "How many orders?",
1426            Some(Path::new("/abs/codex-home")),
1427            Some("gpt-5.4"),
1428        )
1429        .expect("ask argv");
1430        assert_eq!(
1431            args,
1432            vec![
1433                "dispatch",
1434                "/abs/ir.json",
1435                "How many orders?",
1436                "--component",
1437                "answer_query",
1438                "--server",
1439                "wren",
1440                "--server-command",
1441                "/abs/wren",
1442                "--server-arg=serve",
1443                "--server-arg=mcp",
1444                "--server-arg=--project",
1445                "--server-arg=/data/project",
1446                "--server-arg=--quiet",
1447                "--inspect-tool",
1448                "get_context",
1449                "--query-tool",
1450                "run_sql",
1451                "--project",
1452                "/abs/project",
1453                "--codex-home",
1454                "/abs/codex-home",
1455                "--orchestrator-model",
1456                "gpt-5.4",
1457                "--cheap-model",
1458                "gpt-5.4",
1459                "--strong-model",
1460                "gpt-5.4",
1461            ]
1462        );
1463    }
1464
1465    #[test]
1466    fn ask_spec_rejects_step_grants_the_cli_cannot_represent() {
1467        let spec = CodexLocalDispatchSpec::Ask(CodexLocalAskDispatchSpec {
1468            _shape: CodexLocalAskShape::Ask,
1469            ir_path: "ir.json".to_string(),
1470            component: "answer_query".to_string(),
1471            codex_home: "codex-home".to_string(),
1472            mcp: CodexLocalAskMcp {
1473                name: "wren".to_string(),
1474                command: "wren".to_string(),
1475                args: vec![],
1476                tools_by_step: CodexLocalAskToolsByStep {
1477                    resolve_intent: vec!["get_context".to_string()],
1478                    generate_sql: vec!["run_sql".to_string()],
1479                    repair_sql: vec!["repair_sql".to_string()],
1480                },
1481            },
1482        });
1483        let error = build_dispatch_args(
1484            &spec,
1485            Path::new("/abs/ir.json"),
1486            Path::new("/abs/wren"),
1487            Path::new("/abs/project"),
1488            "question",
1489            Some(Path::new("/abs/codex-home")),
1490            Some("gpt-5.4"),
1491        )
1492        .expect_err("unequal query-step grants must fail before dispatch");
1493        assert!(error.contains("generate_sql and repair_sql"), "{error}");
1494        assert!(error.contains("shared --query-tool"), "{error}");
1495    }
1496
1497    #[test]
1498    fn mixed_setup_and_ask_fields_fail_loudly_naming_both_shapes() {
1499        let mixed = r#"{
1500            "shape": "ask",
1501            "ir_path": "ir.json",
1502            "component": "answer_query",
1503            "codex_home": "codex-home",
1504            "mcp": {
1505                "command": "wren",
1506                "source_tools": [],
1507                "context_tools": [],
1508                "tools_by_step": {
1509                    "resolve_intent": ["get_context"],
1510                    "generate_sql": ["run_sql"],
1511                    "repair_sql": ["run_sql"]
1512                }
1513            }
1514        }"#;
1515        let cause = serde_json::from_str::<CodexLocalDispatchSpec>(mixed)
1516            .expect_err("mixed shape must not parse");
1517        let message = describe_spec_parse_failure(Path::new("mixed.json"), mixed, &cause);
1518        assert!(message.contains("setup-shaped"), "names setup: {message}");
1519        assert!(message.contains("ask-shaped"), "names ask: {message}");
1520        assert!(message.contains("do not mix"), "names ambiguity: {message}");
1521    }
1522
1523    fn ask_spec_for_stdout_test() -> CodexLocalDispatchSpec {
1524        CodexLocalDispatchSpec::Ask(CodexLocalAskDispatchSpec {
1525            _shape: CodexLocalAskShape::Ask,
1526            ir_path: "ir.json".to_string(),
1527            component: "answer_query".to_string(),
1528            codex_home: "codex-home".to_string(),
1529            mcp: CodexLocalAskMcp {
1530                name: "wren".to_string(),
1531                command: "wren".to_string(),
1532                args: vec![],
1533                tools_by_step: CodexLocalAskToolsByStep {
1534                    resolve_intent: vec!["get_context".to_string()],
1535                    generate_sql: vec!["run_sql".to_string()],
1536                    repair_sql: vec!["run_sql".to_string()],
1537                },
1538            },
1539        })
1540    }
1541
1542    #[test]
1543    fn codex_local_ask_projects_rich_object_rows_in_declared_column_order() {
1544        let raw = r#"{"columns":["second","first"],"definition":{"sql":"SELECT 1"},"rows":[{"first":1,"second":2}],"summary":"rich","verified":true}"#;
1545        let normalized = normalize_codex_local_stdout(&ask_spec_for_stdout_test(), raw.to_string());
1546        let expected = serde_json::json!({
1547            "columns": ["second", "first"],
1548            "rows": [[2, 1]],
1549        });
1550
1551        assert_eq!(
1552            serde_json::from_str::<serde_json::Value>(&normalized).expect("normalized JSON"),
1553            expected
1554        );
1555        assert_eq!(
1556            crate::extract_result_json(&normalized).expect("ordinary extractor accepts projection"),
1557            expected,
1558            "recorded answers receive only the canonical table, not rich metadata"
1559        );
1560        let table = crate::extract_result(&normalized).expect("comparator-compatible Table");
1561        assert_eq!(table.columns, ["second", "first"]);
1562        assert_eq!(
1563            table.rows,
1564            vec![vec![serde_json::json!(2), serde_json::json!(1)]]
1565        );
1566    }
1567
1568    #[test]
1569    fn codex_local_stdout_normalization_is_ask_only_and_preserves_positional_tables() {
1570        let positional = "  {\"columns\":[\"value\"],\"rows\":[[42]]}";
1571        assert_eq!(
1572            normalize_codex_local_stdout(&ask_spec_for_stdout_test(), positional.to_string()),
1573            positional,
1574            "an already-valid Ask table stays byte-for-byte unchanged"
1575        );
1576
1577        let setup = CodexLocalDispatchSpec::Setup(CodexLocalSetupDispatchSpec {
1578            ir_path: "ir.json".to_string(),
1579            component: "build_context".to_string(),
1580            mcp: CodexLocalSetupMcp {
1581                name: "setup".to_string(),
1582                command: "server".to_string(),
1583                args: vec![],
1584                source_tools: vec![],
1585                context_tools: vec![],
1586            },
1587        });
1588        let rich = r#"{"columns":["value"],"rows":[{"value":42}],"verified":true}"#;
1589        assert_eq!(
1590            normalize_codex_local_stdout(&setup, rich.to_string()),
1591            rich,
1592            "setup stdout never passes through the Ask projection"
1593        );
1594    }
1595
1596    #[test]
1597    fn codex_local_ask_leaves_inconsistent_object_rows_unparseable() {
1598        let malformed = [
1599            r#"{"columns":["a","b"],"rows":[{"a":1}]}"#,
1600            r#"{"columns":["a"],"rows":[{"a":1,"b":2}]}"#,
1601            r#"{"columns":["a"],"rows":[{"a":1},[2]]}"#,
1602            r#"{"columns":["a","a"],"rows":[{"a":1}]}"#,
1603        ];
1604
1605        for raw in malformed {
1606            let unchanged =
1607                normalize_codex_local_stdout(&ask_spec_for_stdout_test(), raw.to_string());
1608            assert_eq!(unchanged, raw, "malformed row shape must fail closed");
1609            assert!(
1610                crate::extract_result(&unchanged).is_none(),
1611                "ordinary Table extraction must still reject malformed object rows: {raw}"
1612            );
1613        }
1614    }
1615
1616    // --- diagnostic extraction -----------------------------------------------------------------
1617
1618    #[test]
1619    fn first_stderr_line_skips_blank_lines() {
1620        assert_eq!(
1621            first_stderr_line("\n\n  real error  \nmore\n"),
1622            "real error"
1623        );
1624    }
1625
1626    #[test]
1627    fn first_stderr_line_falls_back_when_all_blank() {
1628        assert_eq!(first_stderr_line("   \n\n"), "no stderr output");
1629        assert_eq!(first_stderr_line(""), "no stderr output");
1630    }
1631
1632    // --- CodexLocalAdapter::invoke failure paths (no process spawn, no live call) -------------
1633
1634    #[test]
1635    fn invoke_reports_a_missing_spec_file_with_its_own_path() {
1636        let result = CodexLocalAdapter.invoke(
1637            Path::new("/nonexistent-project"),
1638            "/nonexistent-codex-local-spec.json",
1639            "",
1640            "question",
1641            None,
1642            None,
1643        );
1644        assert!(!result.ok);
1645        assert!(result.raw.contains("/nonexistent-codex-local-spec.json"));
1646        assert_eq!(result.cost, None);
1647        assert_eq!(result.latency_ms, None);
1648        assert_eq!(result.turns, None);
1649    }
1650
1651    // --- differentiated tier binding is rejected defensively, not flattened -------------------
1652    //
1653    // `run_eval`'s `validate_tier_binding_backend` is the real guard (checked before any process
1654    // spawns, so these adapters never see a `Tiered` binding in practice) — these tests only pin
1655    // down that if one ever did slip through, the back-end fails loudly instead of silently
1656    // collapsing three distinct models into one, which would misreport what actually ran.
1657
1658    #[test]
1659    fn codex_local_invoke_rejects_a_tiered_binding_before_touching_the_spec_file() {
1660        let result = CodexLocalAdapter.invoke(
1661            Path::new("/nonexistent-project"),
1662            "/nonexistent-codex-local-spec.json",
1663            "",
1664            "question",
1665            Some(ModelOverride::Tiered {
1666                strong: "sonnet",
1667                cheap: "haiku",
1668                orchestrator: "sonnet",
1669            }),
1670            None,
1671        );
1672        assert!(!result.ok);
1673        assert!(
1674            result.raw.contains("differentiated tier binding"),
1675            "names the real problem: {}",
1676            result.raw
1677        );
1678        // Rejected before ever reading the (nonexistent) spec file — the error is the internal
1679        // guard message, not a file-not-found error.
1680        assert!(!result.raw.contains("/nonexistent-codex-local-spec.json"));
1681    }
1682
1683    #[test]
1684    fn claude_code_cli_invoke_rejects_a_tiered_binding() {
1685        let result = ClaudeCodeCliAdapter.invoke(
1686            Path::new("/nonexistent-project"),
1687            "some-agent",
1688            "",
1689            "question",
1690            Some(ModelOverride::Tiered {
1691                strong: "sonnet",
1692                cheap: "haiku",
1693                orchestrator: "sonnet",
1694            }),
1695            None,
1696        );
1697        assert!(!result.ok);
1698        assert!(
1699            result.raw.contains("differentiated tier binding"),
1700            "names the real problem: {}",
1701            result.raw
1702        );
1703    }
1704}