1use std::collections::HashSet;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23#[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 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
71pub 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#[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
100pub trait BackendAdapter: Sync {
104 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
125pub(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 _max_turns: Option<u32>,
152 ) -> AdapterResult {
153 let fail = |reason: String| AdapterResult {
159 ok: false,
160 raw: reason,
161 latency_ms: None,
162 cost: None,
163 turns: None,
164 };
165
166 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 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 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 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
271fn 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
283fn claude_agent_sdk_cli_js() -> PathBuf {
288 claude_agent_sdk_dir().join("dist").join("cli.js")
289}
290
291pub(crate) struct ClaudeAgentSdkAdapter;
301
302impl ClaudeAgentSdkAdapter {
303 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
316fn 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 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 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 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 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
474fn codex_local_dir() -> PathBuf {
477 Path::new(env!("CARGO_MANIFEST_DIR"))
478 .join("..")
479 .join("..")
480 .join("dispatcher")
481 .join("codex-local")
482}
483
484fn 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
499const 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
506const 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
512fn 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
524fn 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#[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#[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#[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#[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#[derive(Debug, serde::Deserialize)]
643#[serde(untagged)]
644enum CodexLocalDispatchSpec {
645 Ask(CodexLocalAskDispatchSpec),
646 Setup(CodexLocalSetupDispatchSpec),
647}
648
649fn 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
693fn 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
751fn 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
796fn 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
848fn 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
856fn 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
867pub(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 _max_turns: Option<u32>,
886 ) -> AdapterResult {
887 let fail = |reason: String| AdapterResult {
890 ok: false,
891 raw: reason,
892 latency_ms: None,
893 cost: None,
894 turns: None,
895 };
896
897 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 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
994pub(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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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}