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":[],"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
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)]
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#[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#[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#[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#[derive(Debug, serde::Deserialize)]
647#[serde(untagged)]
648enum CodexLocalDispatchSpec {
649 Ask(CodexLocalAskDispatchSpec),
650 Setup(CodexLocalSetupDispatchSpec),
651}
652
653fn 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
695fn 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
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.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 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 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 #[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 #[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 #[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 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}