use std::process::Stdio;
use std::time::Duration;
use crate::config::{
CliPreset, CliSection, JsonPaths, LineMatchers, OutputMode, PromptDelivery, Provider,
};
use crate::error::RecallError;
use crate::graph::error::GraphError;
use crate::graph::llm::LlmProvider;
const STDERR_EXCERPT: usize = 300;
const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliSpec {
pub command: String,
pub command_env: Option<String>,
pub args: Vec<String>,
pub prompt_delivery: PromptDelivery,
pub prompt_flag: String,
pub model_flag: String,
pub default_model: String,
pub output_format_flag: String,
pub output_format_value: String,
pub system_prompt_flag: String,
pub output_mode: OutputMode,
pub result_json_paths: JsonPaths,
pub ndjson_match: LineMatchers,
pub extra_args: Vec<String>,
pub timeout: Option<Duration>,
pub env_remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
pub argv: Vec<String>,
pub stdin: Option<String>,
}
impl CliSpec {
#[must_use]
pub fn preset(preset: CliPreset) -> Self {
match preset {
CliPreset::ClaudeCode => Self {
command: "claude".into(),
command_env: Some("CLAUDE_BIN".into()),
args: vec!["-p".into()],
prompt_delivery: PromptDelivery::Stdin,
prompt_flag: String::new(),
model_flag: "--model".into(),
default_model: "sonnet".into(),
output_format_flag: "--output-format".into(),
output_format_value: "text".into(),
system_prompt_flag: "--system-prompt".into(),
output_mode: OutputMode::Raw,
result_json_paths: JsonPaths::default(),
ndjson_match: LineMatchers::default(),
extra_args: vec!["--no-session-persistence".into()],
timeout: default_timeout(),
env_remove: vec!["CLAUDECODE".into()],
},
CliPreset::Gemini => Self {
command: "gemini".into(),
command_env: Some("GEMINI_BIN".into()),
args: Vec::new(),
prompt_delivery: PromptDelivery::Flag,
prompt_flag: "-p".into(),
model_flag: "-m".into(),
default_model: String::new(),
output_format_flag: "-o".into(),
output_format_value: "json".into(),
system_prompt_flag: String::new(),
output_mode: OutputMode::SingleJson,
result_json_paths: JsonPaths::new(["response".into(), "result".into()]),
ndjson_match: LineMatchers::default(),
extra_args: Vec::new(),
timeout: default_timeout(),
env_remove: Vec::new(),
},
CliPreset::Grok => Self {
command: "grok".into(),
command_env: Some("GROK_BIN".into()),
args: Vec::new(),
prompt_delivery: PromptDelivery::Flag,
prompt_flag: "-p".into(),
model_flag: "-m".into(),
default_model: String::new(),
output_format_flag: "--output-format".into(),
output_format_value: "json".into(),
system_prompt_flag: String::new(),
output_mode: OutputMode::SingleJson,
result_json_paths: JsonPaths::new(["text".into()]),
ndjson_match: LineMatchers::default(),
extra_args: Vec::new(),
timeout: default_timeout(),
env_remove: Vec::new(),
},
CliPreset::Codex => Self {
command: "codex".into(),
command_env: Some("CODEX_BIN".into()),
args: vec!["exec".into()],
prompt_delivery: PromptDelivery::Stdin,
prompt_flag: String::new(),
model_flag: "-m".into(),
default_model: String::new(),
output_format_flag: "--json".into(),
output_format_value: String::new(),
system_prompt_flag: String::new(),
output_mode: OutputMode::Ndjson,
result_json_paths: JsonPaths::new(["item.text".into()]),
ndjson_match: LineMatchers::new([
"type=item.completed".into(),
"item.type=agent_message".into(),
]),
extra_args: vec!["--skip-git-repo-check".into()],
timeout: default_timeout(),
env_remove: Vec::new(),
},
CliPreset::Custom => Self {
command: String::new(),
command_env: Some("RECALL_CLI_BIN".into()),
args: Vec::new(),
prompt_delivery: PromptDelivery::Stdin,
prompt_flag: String::new(),
model_flag: String::new(),
default_model: String::new(),
output_format_flag: String::new(),
output_format_value: String::new(),
system_prompt_flag: String::new(),
output_mode: OutputMode::Raw,
result_json_paths: JsonPaths::default(),
ndjson_match: LineMatchers::default(),
extra_args: Vec::new(),
timeout: default_timeout(),
env_remove: Vec::new(),
},
}
}
pub fn resolve(provider: &Provider, section: &CliSection) -> Result<Self, RecallError> {
let preset = section
.preset
.or_else(|| provider.default_cli_preset())
.ok_or_else(|| {
RecallError::Config(format!(
"provider {provider} is not a CLI provider — use create_provider()"
))
})?;
let mut spec = Self::preset(preset);
spec.apply(section);
spec.validate(provider)?;
Ok(spec)
}
fn apply(&mut self, section: &CliSection) {
if let Some(command) = §ion.command {
self.command = command.clone();
self.command_env = None;
}
if let Some(args) = §ion.args {
self.args = args.clone();
}
if let Some(delivery) = section.prompt_delivery {
self.prompt_delivery = delivery;
}
if let Some(flag) = §ion.prompt_flag {
self.prompt_flag = flag.clone();
}
if let Some(flag) = §ion.model_flag {
self.model_flag = flag.clone();
}
if let Some(flag) = §ion.output_format_flag {
self.output_format_flag = flag.clone();
}
if let Some(value) = §ion.output_format_value {
self.output_format_value = value.clone();
}
if let Some(flag) = §ion.system_prompt_flag {
self.system_prompt_flag = flag.clone();
}
if let Some(paths) = §ion.result_json_path {
self.result_json_paths = paths.clone();
if self.output_mode == OutputMode::Raw && !paths.is_empty() {
self.output_mode = OutputMode::SingleJson;
}
}
if let Some(matchers) = §ion.ndjson_match {
self.ndjson_match = matchers.clone();
}
if let Some(mode) = section.output_mode {
self.output_mode = mode;
}
if let Some(args) = §ion.extra_args {
self.extra_args = args.clone();
}
if let Some(secs) = section.timeout_secs {
self.timeout = (secs > 0).then(|| Duration::from_secs(secs));
}
}
fn validate(&self, provider: &Provider) -> Result<(), RecallError> {
if self.resolve_command().trim().is_empty() {
return Err(RecallError::Config(format!(
"provider {provider} has no command — set `[llm.cli] command = \"<binary>\"`"
)));
}
if self.prompt_delivery == PromptDelivery::Flag && self.prompt_flag.is_empty() {
return Err(RecallError::Config(format!(
"provider {provider} delivers the prompt by flag but sets no \
`[llm.cli] prompt_flag`"
)));
}
Ok(())
}
#[must_use]
pub fn resolve_command(&self) -> String {
self.command_env
.as_ref()
.and_then(|key| std::env::var(key).ok())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| self.command.clone())
}
#[must_use]
pub fn resolve_model(&self, configured: &str) -> String {
if configured.is_empty() {
self.default_model.clone()
} else {
configured.to_string()
}
}
#[must_use]
pub fn invocation(&self, model: &str, system_prompt: &str, user_message: &str) -> Invocation {
let mut argv = vec![self.resolve_command()];
argv.extend(self.args.iter().cloned());
if !self.model_flag.is_empty() && !model.is_empty() {
argv.push(self.model_flag.clone());
argv.push(model.to_string());
}
if !self.output_format_flag.is_empty() {
argv.push(self.output_format_flag.clone());
if !self.output_format_value.is_empty() {
argv.push(self.output_format_value.clone());
}
}
let prompt = if self.system_prompt_flag.is_empty() {
fold_system_prompt(system_prompt, user_message)
} else {
argv.push(self.system_prompt_flag.clone());
argv.push(system_prompt.to_string());
user_message.to_string()
};
argv.extend(self.extra_args.iter().cloned());
let stdin = match self.prompt_delivery {
PromptDelivery::Stdin => Some(prompt),
PromptDelivery::Flag => {
argv.push(self.prompt_flag.clone());
argv.push(prompt);
None
}
PromptDelivery::Arg => {
argv.push(prompt);
None
}
};
Invocation { argv, stdin }
}
#[must_use]
pub fn argv_preview(&self, model: &str) -> String {
let invocation = self.invocation(model, "<system>", "<prompt>");
let mut parts: Vec<String> = invocation.argv.iter().map(|arg| quote(arg)).collect();
if invocation.stdin.is_some() {
parts.push("< <prompt>".into());
}
parts.join(" ")
}
}
fn quote(arg: &str) -> String {
if arg.chars().any(char::is_whitespace) {
format!("\"{}\"", arg.replace('\n', "\\n"))
} else {
arg.to_string()
}
}
fn fold_system_prompt(system_prompt: &str, user_message: &str) -> String {
if system_prompt.is_empty() {
user_message.to_string()
} else {
format!("{system_prompt}\n\n{user_message}")
}
}
fn default_timeout() -> Option<Duration> {
Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
}
pub struct CliProvider {
spec: CliSpec,
model: String,
}
impl CliProvider {
#[must_use]
pub fn new(spec: CliSpec, model: String) -> Self {
Self { spec, model }
}
#[must_use]
pub fn spec(&self) -> &CliSpec {
&self.spec
}
}
#[async_trait::async_trait]
impl LlmProvider for CliProvider {
async fn complete(
&self,
system_prompt: &str,
user_message: &str,
_max_tokens: u32,
) -> Result<String, GraphError> {
let invocation = self
.spec
.invocation(&self.model, system_prompt, user_message);
let output = self.run(&invocation).await?;
extract_answer(&output, &self.spec)
}
}
impl CliProvider {
async fn run(&self, invocation: &Invocation) -> Result<String, GraphError> {
let (binary, args) = invocation
.argv
.split_first()
.ok_or_else(|| GraphError::Llm("empty CLI invocation".into()))?;
let mut command = tokio::process::Command::new(binary);
command
.args(args)
.stdin(if invocation.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for key in &self.spec.env_remove {
command.env_remove(key);
}
let mut child = command
.spawn()
.map_err(|e| GraphError::Llm(format!("failed to spawn {binary}: {e}")))?;
if let Some(payload) = &invocation.stdin {
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(payload.as_bytes())
.await
.map_err(|e| GraphError::Llm(format!("write to {binary} stdin: {e}")))?;
drop(stdin);
}
}
let output = match self.spec.timeout {
Some(limit) => tokio::time::timeout(limit, child.wait_with_output())
.await
.map_err(|_| {
GraphError::Llm(format!("{binary} timed out after {}s", limit.as_secs()))
})?,
None => child.wait_with_output().await,
}
.map_err(|e| GraphError::Llm(format!("{binary} process failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GraphError::Llm(format!(
"{binary} exited {}: {}",
output.status,
truncate_str(stderr.trim(), STDERR_EXCERPT)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if stdout.trim().is_empty() {
return Err(GraphError::Llm(format!("{binary} returned empty output")));
}
Ok(stdout)
}
}
fn extract_answer(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
let paths = spec.result_json_paths.paths();
match spec.output_mode {
OutputMode::Raw => Ok(stdout.to_string()),
OutputMode::SingleJson => {
if paths.is_empty() {
return Ok(stdout.to_string());
}
let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
return Ok(stdout.to_string());
};
match first_match(&json, paths) {
Some(text) => Ok(text),
None => reported_error(&json).map_or_else(|| Ok(stdout.to_string()), Err),
}
}
OutputMode::Ndjson => extract_from_ndjson(stdout, spec),
}
}
fn extract_from_ndjson(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
let predicates = spec.ndjson_match.predicates();
let paths = spec.result_json_paths.paths();
let mut answer = None;
let mut error = None;
for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
let Ok(event) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
if let Some(message) = reported_error(&event) {
error = Some(message);
}
if !predicates
.iter()
.all(|(path, expected)| matches_scalar(&event, path, expected))
{
continue;
}
if let Some(text) = first_match(&event, paths) {
answer = Some(text);
}
}
match (answer, error) {
(Some(text), _) => Ok(text),
(None, Some(err)) => Err(err),
(None, None) => Ok(stdout.to_string()),
}
}
fn first_match(json: &serde_json::Value, paths: &[String]) -> Option<String> {
paths
.iter()
.find_map(|path| lookup(json, path).and_then(serde_json::Value::as_str))
.map(str::to_string)
}
fn matches_scalar(json: &serde_json::Value, path: &str, expected: &str) -> bool {
let Some(node) = lookup(json, path) else {
return false;
};
if let Some(text) = node.as_str() {
return text == expected;
}
serde_json::from_str::<serde_json::Value>(expected).is_ok_and(|wanted| *node == wanted)
}
fn reported_error(json: &serde_json::Value) -> Option<GraphError> {
lookup(json, "error.message")
.and_then(serde_json::Value::as_str)
.map(|message| {
GraphError::Llm(format!(
"CLI reported an error: {}",
truncate_str(message, STDERR_EXCERPT)
))
})
}
fn lookup<'a>(json: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut node = json;
for segment in path.split('.') {
node = match node {
serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
other => other.get(segment)?,
};
}
Some(node)
}
fn truncate_str(text: &str, max: usize) -> &str {
let end = text.len().min(max);
let mut i = end;
while i > 0 && !text.is_char_boundary(i) {
i -= 1;
}
&text[..i]
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
const SYSTEM: &str = "You extract entities.";
const USER: &str = "Dani uses NeoVim.";
fn spec_for(provider: Provider) -> CliSpec {
CliSpec::resolve(&provider, &CliSection::default()).expect("preset resolves")
}
#[test]
fn claude_code_argv_is_unchanged() {
let spec = spec_for(Provider::ClaudeCode);
let invocation = spec.invocation("sonnet", SYSTEM, USER);
assert_eq!(
invocation.argv,
vec![
"claude",
"-p",
"--model",
"sonnet",
"--output-format",
"text",
"--system-prompt",
SYSTEM,
"--no-session-persistence",
]
);
assert_eq!(invocation.stdin.as_deref(), Some(USER));
}
#[test]
fn claude_code_defaults_to_sonnet() {
let spec = spec_for(Provider::ClaudeCode);
assert_eq!(spec.resolve_model(""), "sonnet");
assert_eq!(spec.resolve_model("opus"), "opus");
}
#[test]
fn gemini_argv_passes_the_prompt_by_flag() {
let spec = spec_for(Provider::Gemini);
let invocation = spec.invocation("gemini-2.5-pro", SYSTEM, USER);
assert_eq!(
invocation.argv,
vec![
"gemini",
"-m",
"gemini-2.5-pro",
"-o",
"json",
"-p",
&format!("{SYSTEM}\n\n{USER}"),
]
);
assert!(invocation.stdin.is_none());
assert_eq!(spec.result_json_paths.paths(), ["response", "result"]);
}
#[test]
fn grok_argv_matches_the_verified_flags() {
let spec = spec_for(Provider::Grok);
let invocation = spec.invocation("grok-4", SYSTEM, USER);
assert_eq!(
invocation.argv,
vec![
"grok",
"-m",
"grok-4",
"--output-format",
"json",
"-p",
&format!("{SYSTEM}\n\n{USER}"),
]
);
assert!(invocation.stdin.is_none());
assert_eq!(spec.result_json_paths.paths(), ["text"]);
}
#[test]
fn grok_extracts_text_and_not_the_reasoning() {
let spec = spec_for(Provider::Grok);
let stdout = r#"{"thought":"thinking about it","text":"OK","stopReason":"end_turn"}"#;
assert_eq!(extract_answer(stdout, &spec).unwrap(), "OK");
}
#[test]
fn codex_argv_uses_the_subcommand_stdin_and_the_repo_check_escape() {
let spec = spec_for(Provider::Codex);
let invocation = spec.invocation("gpt-5.1-codex", SYSTEM, USER);
assert_eq!(
invocation.argv,
vec![
"codex",
"exec",
"-m",
"gpt-5.1-codex",
"--json",
"--skip-git-repo-check",
]
);
assert!(
!invocation.argv.iter().any(|arg| arg == "-p"),
"-p is --profile for codex, never the prompt"
);
assert_eq!(
invocation.stdin.as_deref(),
Some(format!("{SYSTEM}\n\n{USER}").as_str())
);
assert_eq!(spec.output_mode, OutputMode::Ndjson);
}
#[test]
fn a_value_less_output_flag_is_passed_alone() {
let spec = spec_for(Provider::Codex);
let argv = spec.invocation("", SYSTEM, USER).argv;
assert_eq!(
argv,
vec!["codex", "exec", "--json", "--skip-git-repo-check"]
);
}
#[test]
fn an_empty_model_omits_the_model_flag() {
let spec = spec_for(Provider::Gemini);
let invocation = spec.invocation("", SYSTEM, USER);
assert_eq!(invocation.argv[1], "-o");
}
#[test]
fn a_custom_provider_needs_a_command() {
let err = CliSpec::resolve(&Provider::Cli, &CliSection::default())
.expect_err("custom preset has no default binary");
assert!(err.to_string().contains("command"), "{err}");
}
#[test]
fn a_custom_provider_is_built_entirely_from_config() {
let section = CliSection {
command: Some("mycli".into()),
args: Some(vec!["chat".into()]),
prompt_delivery: Some(PromptDelivery::Arg),
model_flag: Some("--model".into()),
output_format_flag: Some("--format".into()),
output_format_value: Some("json".into()),
result_json_path: Some(JsonPaths::parse("data.text")),
extra_args: Some(vec!["--quiet".into()]),
timeout_secs: Some(0),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
assert_eq!(
spec.invocation("m1", SYSTEM, USER).argv,
vec![
"mycli",
"chat",
"--model",
"m1",
"--format",
"json",
"--quiet",
&format!("{SYSTEM}\n\n{USER}"),
]
);
assert!(spec.timeout.is_none(), "0 seconds means no limit");
assert_eq!(spec.result_json_paths.paths(), ["data.text"]);
}
#[test]
fn overrides_are_applied_on_top_of_a_preset() {
let section = CliSection {
command: Some("/opt/bin/gemini".into()),
model_flag: Some("--model".into()),
result_json_path: Some(JsonPaths::parse("output")),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::Gemini, §ion).expect("resolves");
let invocation = spec.invocation("flash", SYSTEM, USER);
assert_eq!(invocation.argv[0], "/opt/bin/gemini");
assert_eq!(invocation.argv[1], "--model");
assert_eq!(spec.result_json_paths.paths(), ["output"]);
}
#[test]
fn a_preset_can_be_chosen_independently_of_the_provider() {
let section = CliSection {
preset: Some(CliPreset::Gemini),
command: Some("gemini-next".into()),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
assert_eq!(spec.prompt_delivery, PromptDelivery::Flag);
assert_eq!(spec.invocation("", "", USER).argv.last().unwrap(), USER);
}
#[test]
fn flag_delivery_without_a_flag_is_rejected() {
let section = CliSection {
command: Some("mycli".into()),
prompt_delivery: Some(PromptDelivery::Flag),
..CliSection::default()
};
let err = CliSpec::resolve(&Provider::Cli, §ion).expect_err("no prompt flag");
assert!(err.to_string().contains("prompt_flag"), "{err}");
}
#[test]
fn http_providers_have_no_cli_spec() {
assert!(CliSpec::resolve(&Provider::Anthropic, &CliSection::default()).is_err());
assert!(CliSpec::resolve(&Provider::Openai, &CliSection::default()).is_err());
}
#[test]
fn a_missing_system_prompt_still_uses_the_flag_when_there_is_one() {
let spec = spec_for(Provider::ClaudeCode);
let invocation = spec.invocation("sonnet", "", USER);
assert!(invocation.argv.contains(&"--system-prompt".to_string()));
}
#[test]
fn argv_preview_shows_the_stdin_redirect() {
let preview = spec_for(Provider::ClaudeCode).argv_preview("sonnet");
assert!(preview.starts_with("claude -p --model sonnet"), "{preview}");
assert!(preview.ends_with("< <prompt>"), "{preview}");
}
#[test]
fn argv_preview_keeps_a_folded_prompt_on_one_line() {
let preview = spec_for(Provider::Grok).argv_preview("grok-4");
assert!(!preview.contains('\n'), "{preview}");
assert!(
preview.ends_with(r#"-p "<system>\n\n<prompt>""#),
"{preview}"
);
}
fn json_spec(paths: &[&str]) -> CliSpec {
CliSpec {
output_mode: OutputMode::SingleJson,
result_json_paths: JsonPaths::new(paths.iter().map(|p| (*p).to_string())),
..spec_for(Provider::Gemini)
}
}
#[test]
fn json_path_present_returns_the_field() {
let spec = json_spec(&["response", "result"]);
let answer = extract_answer(r#"{"response":"hello","session_id":"a"}"#, &spec).unwrap();
assert_eq!(answer, "hello");
}
#[test]
fn json_paths_are_tried_in_order() {
let spec = json_spec(&["response", "result"]);
let answer = extract_answer(r#"{"result":"second choice"}"#, &spec).unwrap();
assert_eq!(answer, "second choice");
}
#[test]
fn nested_and_indexed_paths_resolve() {
let spec = json_spec(&["messages.0.text"]);
let answer = extract_answer(r#"{"messages":[{"text":"deep"}]}"#, &spec).unwrap();
assert_eq!(answer, "deep");
}
#[test]
fn a_missing_json_path_falls_back_to_raw_stdout() {
let spec = json_spec(&["response"]);
let stdout = r#"{"text":"renamed field"}"#;
assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
}
#[test]
fn malformed_json_falls_back_to_raw_stdout() {
let spec = json_spec(&["response"]);
let stdout = "{not json at all";
assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
}
#[test]
fn a_non_string_at_the_path_falls_back_to_raw_stdout() {
let spec = json_spec(&["response"]);
let stdout = r#"{"response":{"text":"nested"}}"#;
assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
}
#[test]
fn an_error_envelope_is_an_error_not_an_answer() {
let spec = json_spec(&["response"]);
let err = extract_answer(r#"{"error":{"message":"quota exceeded"}}"#, &spec)
.expect_err("error envelope");
assert!(err.to_string().contains("quota exceeded"), "{err}");
}
#[test]
fn raw_mode_means_stdout_is_the_answer() {
let spec = spec_for(Provider::ClaudeCode);
assert_eq!(spec.output_mode, OutputMode::Raw);
assert_eq!(extract_answer("plain prose", &spec).unwrap(), "plain prose");
}
const CODEX_STREAM: &str = concat!(
r#"{"type":"thread.started","thread_id":"01999"}"#,
"\n",
r#"{"type":"turn.started"}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK"}}"#,
"\n",
r#"{"type":"turn.completed","usage":{"input_tokens":13658,"output_tokens":5}}"#,
"\n",
);
#[test]
fn ndjson_takes_the_matching_event_and_ignores_the_rest() {
let spec = spec_for(Provider::Codex);
assert_eq!(extract_answer(CODEX_STREAM, &spec).unwrap(), "OK");
}
#[test]
fn ndjson_keeps_the_last_matching_event() {
let spec = spec_for(Provider::Codex);
let stream = concat!(
r#"{"type":"item.completed","item":{"type":"agent_message","text":"first"}}"#,
"\n",
r#"{"type":"item.completed","item":{"type":"reasoning","text":"ignore me"}}"#,
"\n",
r#"{"type":"item.completed","item":{"type":"agent_message","text":"final"}}"#,
);
assert_eq!(extract_answer(stream, &spec).unwrap(), "final");
}
#[test]
fn ndjson_skips_lines_that_are_not_json() {
let spec = spec_for(Provider::Codex);
let stream = format!("a banner line\n{CODEX_STREAM}");
assert_eq!(extract_answer(&stream, &spec).unwrap(), "OK");
}
#[test]
fn ndjson_with_no_matching_event_falls_back_to_raw_stdout() {
let spec = spec_for(Provider::Codex);
let stream = r#"{"type":"turn.completed","usage":{"output_tokens":5}}"#;
assert_eq!(extract_answer(stream, &spec).unwrap(), stream);
}
#[test]
fn ndjson_surfaces_an_error_event_when_nothing_matched() {
let spec = spec_for(Provider::Codex);
let stream = r#"{"type":"error","error":{"message":"model overloaded"}}"#;
let err = extract_answer(stream, &spec).expect_err("error event");
assert!(err.to_string().contains("model overloaded"), "{err}");
}
#[test]
fn ndjson_matchers_are_configurable() {
let section = CliSection {
command: Some("mycli".into()),
output_mode: Some(OutputMode::Ndjson),
ndjson_match: Some(LineMatchers::parse("kind=message, final=true")),
result_json_path: Some(JsonPaths::parse("content")),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
let stream = concat!(
r#"{"kind":"message","final":false,"content":"partial"}"#,
"\n",
r#"{"kind":"message","final":true,"content":"done"}"#,
);
assert_eq!(extract_answer(stream, &spec).unwrap(), "done");
}
#[test]
fn a_result_path_on_a_prose_preset_implies_single_json() {
let section = CliSection {
result_json_path: Some(JsonPaths::parse("result")),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
assert_eq!(spec.output_mode, OutputMode::SingleJson);
assert_eq!(
extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
"from json"
);
}
#[test]
fn an_explicit_output_mode_wins_over_the_inference() {
let section = CliSection {
result_json_path: Some(JsonPaths::parse("result")),
output_mode: Some(OutputMode::Raw),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
assert_eq!(spec.output_mode, OutputMode::Raw);
assert_eq!(
extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
r#"{"result":"from json"}"#
);
}
struct MockCli {
_dir: tempfile::TempDir,
path: PathBuf,
argv_log: PathBuf,
stdin_log: PathBuf,
}
impl MockCli {
fn new(body: &str) -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("mock-cli");
let argv_log = dir.path().join("argv.txt");
let stdin_log = dir.path().join("stdin.txt");
let script = format!(
"#!/bin/sh\n\
: > '{argv}'\n\
for a in \"$@\"; do printf '%s\\0' \"$a\" >> '{argv}'; done\n\
cat > '{stdin}'\n\
{body}\n",
argv = argv_log.display(),
stdin = stdin_log.display(),
);
fs::write(&path, script).expect("write mock");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
Self {
_dir: dir,
path,
argv_log,
stdin_log,
}
}
fn provider(&self, section: CliSection, model: &str) -> CliProvider {
let section = CliSection {
command: Some(self.path.display().to_string()),
..section
};
let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
let model = spec.resolve_model(model);
CliProvider::new(spec, model)
}
fn recorded_argv(&self) -> Vec<String> {
fs::read_to_string(&self.argv_log)
.unwrap_or_default()
.split('\0')
.filter(|arg| !arg.is_empty())
.map(str::to_string)
.collect()
}
fn recorded_stdin(&self) -> String {
fs::read_to_string(&self.stdin_log).unwrap_or_default()
}
}
#[tokio::test]
async fn stdin_delivery_feeds_the_prompt_to_the_process() {
let mock = MockCli::new("printf 'answer from stdin'");
let provider = mock.provider(
CliSection {
preset: Some(CliPreset::ClaudeCode),
..CliSection::default()
},
"sonnet",
);
let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
assert_eq!(answer, "answer from stdin");
assert_eq!(
mock.recorded_argv(),
vec![
"-p",
"--model",
"sonnet",
"--output-format",
"text",
"--system-prompt",
SYSTEM,
"--no-session-persistence",
]
);
assert_eq!(mock.recorded_stdin(), USER);
}
#[tokio::test]
async fn flag_delivery_puts_the_prompt_in_argv_and_leaves_stdin_closed() {
let mock = MockCli::new(r#"printf '{"response":"answer from flag"}'"#);
let provider = mock.provider(
CliSection {
preset: Some(CliPreset::Gemini),
..CliSection::default()
},
"",
);
let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
assert_eq!(answer, "answer from flag");
assert_eq!(
mock.recorded_argv(),
vec!["-o", "json", "-p", &format!("{SYSTEM}\n\n{USER}")]
);
assert!(mock.recorded_stdin().is_empty());
}
#[tokio::test]
async fn a_streaming_cli_is_read_through_the_ndjson_mode() {
let mock = MockCli::new(
"printf '%s\\n' '{\"type\":\"turn.started\"}' \
'{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"OK\"}}' \
'{\"type\":\"turn.completed\"}'",
);
let provider = mock.provider(
CliSection {
preset: Some(CliPreset::Codex),
..CliSection::default()
},
"",
);
let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
assert_eq!(answer, "OK");
assert_eq!(
mock.recorded_argv(),
vec!["exec", "--json", "--skip-git-repo-check"]
);
assert_eq!(mock.recorded_stdin(), format!("{SYSTEM}\n\n{USER}"));
}
#[tokio::test]
async fn a_non_zero_exit_surfaces_the_cli_stderr() {
let mock = MockCli::new("printf 'not authenticated: run gemini auth' >&2\nexit 3");
let provider = mock.provider(
CliSection {
preset: Some(CliPreset::Gemini),
..CliSection::default()
},
"",
);
let err = provider
.complete(SYSTEM, USER, 1024)
.await
.expect_err("non-zero exit");
let message = err.to_string();
assert!(message.contains("not authenticated"), "{message}");
assert!(message.contains("exited"), "{message}");
}
#[tokio::test]
async fn empty_output_is_an_error() {
let mock = MockCli::new("printf ''");
let provider = mock.provider(CliSection::default(), "");
let err = provider
.complete(SYSTEM, USER, 1024)
.await
.expect_err("no output");
assert!(err.to_string().contains("empty output"), "{err}");
}
#[tokio::test]
async fn a_hung_cli_hits_the_timeout() {
let mock = MockCli::new("sleep 30\nprintf 'too late'");
let provider = mock.provider(
CliSection {
timeout_secs: Some(1),
..CliSection::default()
},
"",
);
let err = provider
.complete(SYSTEM, USER, 1024)
.await
.expect_err("timeout");
assert!(err.to_string().contains("timed out after 1s"), "{err}");
}
#[tokio::test]
async fn a_missing_binary_is_reported_as_a_spawn_failure() {
let section = CliSection {
command: Some("/nonexistent/agent-cli".into()),
..CliSection::default()
};
let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
let provider = CliProvider::new(spec, String::new());
let err = provider
.complete(SYSTEM, USER, 1024)
.await
.expect_err("missing binary");
assert!(err.to_string().contains("failed to spawn"), "{err}");
}
}