use std::{
io::Write,
process::{Command, Output, Stdio},
};
use serde_json::Value;
use tempfile::TempDir;
const MODE_ENV: &str = "RHO_AUTOMATION_TEST_MODE";
const RESPONSE_ENV: &str = "RHO_AUTOMATION_TEST_RESPONSE";
const PATH_ENV: &str = "RHO_AUTOMATION_TEST_PATH";
#[test]
fn composes_prompt_arguments_stdin_and_combined_input() {
let root = TempDir::new().unwrap();
let arguments = run(&root, "inspect", &["run", "review", "this"], None);
assert_success(&arguments);
assert_eq!(user_prompt(&arguments), "review this");
let stdin = run(&root, "inspect", &["run", "--stdin"], Some("diff contents"));
assert_success(&stdin);
assert_eq!(user_prompt(&stdin), "diff contents");
let combined = run(
&root,
"inspect",
&["run", "--stdin", "review"],
Some("diff contents\n"),
);
assert_success(&combined);
assert_eq!(user_prompt(&combined), "review\n\ndiff contents");
}
#[test]
fn applies_runtime_configuration_tools_and_workspace_instructions() {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("AGENTS.md"), "project automation rules").unwrap();
std::fs::write(
root.path().join("config.toml"),
r#"provider = "xai"
model = "grok-fixture"
auth = "xai-oauth"
reasoning = "high"
web_search_provider = "disabled"
"#,
)
.unwrap();
let output = run(
&root,
"inspect",
&[
"--auth",
"xai-oauth",
"--reasoning",
"low",
"run",
"inspect",
],
None,
);
assert_success(&output);
let inspection = inspection(&output);
assert_eq!(inspection["identity"]["provider"], "xai");
assert_eq!(inspection["identity"]["model"], "grok-fixture");
assert_eq!(inspection["reasoning"], "low");
let system = inspection["messages"][0]["System"].as_str().unwrap();
assert!(system.contains("project automation rules"));
assert!(system.contains(&root.path().join("AGENTS.md").display().to_string()));
let names = inspection["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect::<Vec<_>>();
for expected in [
"list_dir",
"read_file",
"write_file",
"edit_file",
"grep",
"glob",
"process",
shell_tool_name(),
"skill",
"rho",
"fetch_content",
"get_search_content",
] {
assert!(
names.contains(&expected),
"missing tool {expected}: {names:?}"
);
}
assert!(!names.contains(&"web_search"));
let config = std::fs::read_to_string(root.path().join("config.toml")).unwrap();
assert!(config.contains("provider = \"xai\""));
assert!(config.contains("model = \"grok-fixture\""));
assert!(config.contains("auth = \"xai-oauth\""));
assert!(config.contains("reasoning = \"low\""));
}
#[test]
fn applies_configured_tool_output_limit() {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("large.txt"), "abcdefgh").unwrap();
std::fs::write(
root.path().join("config.toml"),
"max_output_bytes = 5\nweb_search_provider = \"disabled\"\n",
)
.unwrap();
let output = run(&root, "read-file", &["run", "read the file"], None);
assert_success(&output);
assert_eq!(stdout(&output), "abcde\n[truncated]\n");
assert!(output.stderr.is_empty());
}
#[test]
fn reads_and_writes_paths_outside_the_working_directory() {
let root = TempDir::new().unwrap();
let outside = tempfile::tempdir_in(root.path().parent().unwrap()).unwrap();
let input_path = outside.path().join("input.txt");
std::fs::write(&input_path, "outside content").unwrap();
let relative_input_path = std::path::Path::new("..")
.join(outside.path().file_name().unwrap())
.join("input.txt");
let mut read = command(&root, "read-path");
read.env(PATH_ENV, relative_input_path)
.args(["run", "read the outside file"]);
let read_output = read.output().unwrap();
assert_success(&read_output);
assert_eq!(stdout(&read_output), "outside content\n");
let output_path = outside.path().join("output.txt");
let mut write = command(&root, "write-path");
write
.env(PATH_ENV, &output_path)
.args(["run", "write the outside file"]);
let write_output = write.output().unwrap();
assert_success(&write_output);
assert_eq!(
std::fs::read_to_string(output_path).unwrap(),
"written outside the working directory"
);
}
#[test]
fn no_system_prompt_and_no_tools_only_affect_the_current_run() {
let root = TempDir::new().unwrap();
let output = run(
&root,
"inspect",
&["--no-system-prompt", "--no-tools", "run", "hello"],
None,
);
assert_success(&output);
let inspection = inspection(&output);
assert_eq!(inspection["messages"].as_array().unwrap().len(), 1);
assert_eq!(user_prompt(&output), "hello");
assert!(inspection["tools"].as_array().unwrap().is_empty());
let config = std::fs::read_to_string(root.path().join("config.toml")).unwrap();
assert!(!config.contains("no_system_prompt"));
assert!(!config.contains("no_tools"));
}
#[test]
fn provider_and_tool_failures_stay_off_stdout() {
let root = TempDir::new().unwrap();
let provider_failure = run(&root, "fail", &["run", "hello"], None);
assert_eq!(provider_failure.status.code(), Some(1));
assert!(provider_failure.stdout.is_empty());
assert!(stderr(&provider_failure).contains("deterministic provider failure"));
let mut command = command(&root, "tool-failure");
command
.env(RESPONSE_ENV, "recovered after tool failure")
.args(["run", "use a tool"]);
let tool_failure = command.output().unwrap();
assert_success(&tool_failure);
assert_eq!(stdout(&tool_failure), "recovered after tool failure\n");
assert!(tool_failure.stderr.is_empty());
}
#[test]
fn jsonl_success_emits_versioned_events_and_authoritative_text() {
let root = TempDir::new().unwrap();
let mut command = command(&root, "fixed");
command
.env(RESPONSE_ENV, "answer for automation")
.args(["run", "--output", "jsonl", "hello"]);
let output = command.output().unwrap();
assert_success(&output);
assert!(output.stderr.is_empty());
let events = jsonl_events(&output);
assert_eq!(
events
.iter()
.map(|event| event["seq"].as_u64().unwrap())
.collect::<Vec<_>>(),
(1..=events.len() as u64).collect::<Vec<_>>()
);
assert!(events.iter().all(|event| event["schema_version"] == 1));
assert_eq!(events.first().unwrap()["type"], "run.started");
assert!(same_file::is_same_file(
events.first().unwrap()["workspace"].as_str().unwrap(),
root.path(),
)
.unwrap());
assert!(events.iter().any(|event| {
event["type"] == "assistant.text_delta"
&& event["attempt"] == 1
&& event["text"] == "answer for automation"
}));
assert_eq!(
events.last().unwrap(),
&serde_json::json!({
"schema_version": 1,
"seq": events.len(),
"type": "run.completed",
"reason": "completed",
"text": "answer for automation"
})
);
}
#[test]
fn jsonl_tracks_provider_retries_without_exposing_diagnostics() {
let root = TempDir::new().unwrap();
let mut command = command(&root, "retry");
command
.env(RESPONSE_ENV, "recovered")
.args(["run", "--output", "jsonl", "hello"]);
let output = command.output().unwrap();
assert_success(&output);
let events = jsonl_events(&output);
assert!(events
.iter()
.any(|event| { event["type"] == "assistant.text_reset" && event["attempt"] == 1 }));
assert!(events.iter().any(|event| {
event["type"] == "assistant.text_delta"
&& event["attempt"] == 2
&& event["text"] == "recovered"
}));
assert!(!stdout(&output).contains("deterministic retryable failure"));
}
#[test]
fn jsonl_reports_recoverable_tool_failure_and_run_completion() {
let root = TempDir::new().unwrap();
let mut command = command(&root, "tool-failure");
command
.env(RESPONSE_ENV, "recovered after tool failure")
.args(["run", "--output", "jsonl", "use a tool"]);
let output = command.output().unwrap();
assert_success(&output);
let events = jsonl_events(&output);
assert!(events.iter().any(|event| {
event["type"] == "tool.started"
&& event["call_id"] == "fixture-tool-failure"
&& event["name"] == "read_file"
}));
assert!(events.iter().any(|event| {
event["type"] == "tool.finished"
&& event["call_id"] == "fixture-tool-failure"
&& event["status"] == "failure"
}));
assert_eq!(events.last().unwrap()["type"], "run.completed");
assert!(!stdout(&output).contains("outside-workspace"));
}
#[test]
fn jsonl_reports_provider_failure_as_the_only_terminal_event() {
let root = TempDir::new().unwrap();
let output = run(&root, "fail", &["run", "--output", "jsonl", "hello"], None);
assert_eq!(output.status.code(), Some(1));
let events = jsonl_events(&output);
let terminals = events
.iter()
.filter(|event| {
matches!(
event["type"].as_str(),
Some("run.completed" | "run.failed" | "run.stopped")
)
})
.collect::<Vec<_>>();
assert_eq!(terminals.len(), 1);
assert_eq!(terminals[0]["type"], "run.failed");
assert_eq!(terminals[0]["reason"], "provider_error");
assert_eq!(events.last().unwrap(), terminals[0]);
}
#[test]
fn jsonl_sanitizes_authentication_failures() {
let root = TempDir::new().unwrap();
let output = run(
&root,
"auth-failure",
&["run", "--output", "jsonl", "hello"],
None,
);
assert_eq!(output.status.code(), Some(1));
let events = jsonl_events(&output);
assert_eq!(events.last().unwrap()["type"], "run.failed");
assert_eq!(events.last().unwrap()["reason"], "authentication");
assert_eq!(events.last().unwrap()["message"], "authentication failed");
assert!(!stdout(&output).contains("fixture-secret"));
assert!(!stderr(&output).contains("fixture-secret"));
}
#[test]
fn explicit_step_limit_stops_with_status_124() {
let root = TempDir::new().unwrap();
let output_file = root.path().join("result.json");
let output = run(
&root,
"tool-failure",
&[
"run",
"--output",
"jsonl",
"--output-file",
output_file.to_str().unwrap(),
"--max-steps",
"1",
"use a tool",
],
None,
);
assert_eq!(output.status.code(), Some(124));
let events = jsonl_events(&output);
assert_eq!(events.last().unwrap()["type"], "run.stopped");
assert_eq!(events.last().unwrap()["reason"], "max_steps");
let result: Value =
serde_json::from_str(&std::fs::read_to_string(output_file).unwrap()).unwrap();
assert_eq!(result["state"], "stopped");
}
#[test]
fn timeout_stops_with_status_124() {
let root = TempDir::new().unwrap();
let output = run(
&root,
"delay",
&["run", "--output", "jsonl", "--timeout", "50ms", "wait"],
None,
);
assert_eq!(output.status.code(), Some(124));
let events = jsonl_events(&output);
assert_eq!(events.last().unwrap()["type"], "run.stopped");
assert_eq!(events.last().unwrap()["reason"], "timeout");
}
#[test]
fn startup_failure_emits_a_terminal_json_object() {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("config.toml"), "not valid toml = [").unwrap();
let output = run(&root, "fixed", &["run", "--output", "jsonl", "hello"], None);
assert_eq!(output.status.code(), Some(2));
assert_eq!(
jsonl_events(&output),
vec![serde_json::json!({
"schema_version": 1,
"seq": 1,
"type": "run.failed",
"reason": "configuration_error",
"message": "configuration failed"
})]
);
}
#[test]
fn broken_jsonl_stdout_fails_the_run() {
let root = TempDir::new().unwrap();
let mut command = command(&root, "fixed");
command.args(["run", "--output", "jsonl", "hello"]);
let (reader, writer) = std::io::pipe().unwrap();
drop(reader);
command.stdout(writer);
let output = command.output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("output failed"));
}
#[test]
fn output_run_persists_agent_identity() {
let root = TempDir::new().unwrap();
std::fs::write(
root.path().join("config.toml"),
"provider = \"openai\"\nmodel = \"gpt-5.5\"\n",
)
.unwrap();
let output_file = root.path().join("result.json");
let mut command = command(&root, "fixed");
command.env(RESPONSE_ENV, "done").args([
"--no-subagents",
"--agent",
"worker",
"run",
"--output-file",
output_file.to_str().unwrap(),
"complete the task",
]);
let output = command.output().unwrap();
assert_success(&output);
let result: Value =
serde_json::from_str(&std::fs::read_to_string(&output_file).unwrap()).unwrap();
assert_eq!(result["state"], "ok");
assert_eq!(result["agent_id"], "worker");
assert_eq!(result["agent_fingerprint"].as_str().unwrap().len(), 64);
assert_eq!(result["provider"], "openai");
assert!(result["model"]
.as_str()
.is_some_and(|model| !model.is_empty()));
let events = std::fs::read_to_string(root.path().join("events.jsonl")).unwrap();
assert!(events.contains("complete the task"));
}
#[test]
fn failed_output_run_persists_resolved_provider_and_model() {
let root = TempDir::new().unwrap();
let output_file = root.path().join("result.json");
let mut command = command(&root, "fail");
command.args([
"--no-subagents",
"run",
"--output-file",
output_file.to_str().unwrap(),
"complete the task",
]);
let output = command.output().unwrap();
assert_eq!(output.status.code(), Some(1));
let result: Value =
serde_json::from_str(&std::fs::read_to_string(&output_file).unwrap()).unwrap();
assert_eq!(result["state"], "error");
assert_eq!(result["provider"], "openai");
assert_eq!(result["model"], "gpt-5.5");
assert!(result["error"]
.as_str()
.unwrap()
.contains("deterministic provider failure"));
}
#[test]
fn final_answer_is_the_only_stdout_content() {
let root = TempDir::new().unwrap();
let mut command = command(&root, "fixed");
command
.env(RESPONSE_ENV, "answer for a pipeline")
.args(["run", "hello"]);
let output = command.output().unwrap();
assert_success(&output);
assert_eq!(stdout(&output), "answer for a pipeline\n");
assert!(output.stderr.is_empty());
let ledger = rusqlite::Connection::open(root.path().join(".rho/usage.sqlite3")).unwrap();
let request: (String, String, String) = ledger
.query_row(
"SELECT provider, model, purpose FROM usage_events",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(request, ("openai".into(), "gpt-5.5".into(), "agent".into()));
}
#[cfg(unix)]
#[test]
fn jsonl_sigterm_emits_stopped_event_after_cleanup() {
use std::io::{BufRead, Read};
let root = TempDir::new().unwrap();
let mut command = command(&root, "delay");
command.args(["run", "--output", "jsonl", "wait"]);
let mut child = command.spawn().unwrap();
let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
let mut stream = String::new();
stdout.read_line(&mut stream).unwrap();
assert!(stream.contains("\"type\":\"run.started\""));
let signal_status = Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.status()
.unwrap();
assert!(signal_status.success());
stdout.read_to_string(&mut stream).unwrap();
let status = child.wait().unwrap();
assert_eq!(status.code(), Some(143));
let events = stream
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(events.last().unwrap()["type"], "run.stopped");
assert_eq!(events.last().unwrap()["reason"], "interrupted");
}
fn run(root: &TempDir, mode: &str, args: &[&str], input: Option<&str>) -> Output {
let mut command = command(root, mode);
command.args(args);
if input.is_some() {
command.stdin(Stdio::piped());
}
let mut child = command.spawn().unwrap();
if let Some(input) = input {
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
}
child.wait_with_output().unwrap()
}
fn command(root: &TempDir, mode: &str) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_rho"));
command
.current_dir(root.path())
.env("HOME", root.path())
.env("RHO_HOME", root.path().join(".rho"))
.env(MODE_ENV, mode)
.env_remove(RESPONSE_ENV)
.env_remove(PATH_ENV)
.env_remove("HERDR_ENV")
.env_remove("HERDR_SOCKET_PATH")
.env_remove("HERDR_PANE_ID")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("--config")
.arg(root.path().join("config.toml"));
command
}
fn jsonl_events(output: &Output) -> Vec<Value> {
stdout(output)
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn inspection(output: &Output) -> Value {
serde_json::from_str(stdout(output).trim()).unwrap()
}
fn user_prompt(output: &Output) -> String {
let inspection = inspection(output);
inspection["messages"].as_array().unwrap().last().unwrap()["User"][0]["Text"]
.as_str()
.unwrap()
.to_string()
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"status: {}\nstdout: {}\nstderr: {}",
output.status,
stdout(output),
stderr(output)
);
}
fn stdout(output: &Output) -> &str {
std::str::from_utf8(&output.stdout).unwrap()
}
fn stderr(output: &Output) -> &str {
std::str::from_utf8(&output.stderr).unwrap()
}
fn shell_tool_name() -> &'static str {
if cfg!(windows) {
"powershell"
} else {
"bash"
}
}