#![allow(unused_variables)]
use fno_agents::loop_dispatch::preflight;
use fno_agents::loop_runtime::Queue;
use fno_agents::loop_target::TargetQueue;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::TempDir;
fn write_manifest(dir: &Path, session_id: &str, input: &str, plan_path: &str) {
let abilities_dir = dir.join(".fno");
fs::create_dir_all(&abilities_dir).unwrap();
let content = format!(
"---\nsession_id: {session_id}\ninput: \"{input}\"\nplan_path: \"{plan_path}\"\n---\n"
);
fs::write(abilities_dir.join("target-state.md"), content).unwrap();
}
fn write_stub_driver(lib_dir: &Path, name: &str, max: u64, invoke_body: &str) {
fs::create_dir_all(lib_dir).unwrap();
let content = format!(
"#!/usr/bin/env bash\ndriver_default_max() {{ echo {max}; }}\ndriver_invoke() {{\n {invoke_body}\n}}\n"
);
let path = lib_dir.join(format!("driver-{name}.sh"));
fs::write(&path, content.as_bytes()).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
fn write_stub_binary(dir: &Path, name: &str, body: &str) {
fs::create_dir_all(dir).unwrap();
let path = dir.join(name);
let content = format!("#!/usr/bin/env bash\n{body}\n");
fs::write(&path, content.as_bytes()).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
fn path_with(extra: &Path) -> String {
format!("{}:/bin:/usr/bin:/usr/local/bin", extra.display())
}
fn path_without_claude() -> String {
"/bin:/usr/bin".to_string()
}
const BINARY: &str = env!("CARGO_BIN_EXE_fno-agents");
fn seed_termination_event(journal_path: &Path, session_key: &str, reason: &str) {
let line = format!(
"{{\"ts\":\"2026-06-06T00:00:00Z\",\"type\":\"termination\",\"source\":\"hook\",\
\"data\":{{\"session_id\":\"{session_key}\",\"reason\":\"{reason}\",\"message\":\"pre-seeded\"}}}}\n"
);
fs::create_dir_all(journal_path.parent().unwrap()).unwrap();
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(journal_path)
.unwrap();
f.write_all(line.as_bytes()).unwrap();
}
fn read_jsonl(path: &Path) -> Vec<serde_json::Value> {
if !path.exists() {
return vec![];
}
let content = fs::read_to_string(path).unwrap_or_default();
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
fn count_events(path: &Path, event_type: &str) -> usize {
read_jsonl(path)
.into_iter()
.filter(|v| v["type"].as_str() == Some(event_type))
.count()
}
#[test]
fn target_queue_parses_manifest() {
let dir = TempDir::new().unwrap();
write_manifest(dir.path(), "test-sess-1", "demo mission", "");
let mut q = TargetQueue::from_manifest(dir.path()).unwrap();
let unit = q.next().unwrap().expect("first call must return a unit");
assert_eq!(unit.session_key, "test-sess-1");
assert_eq!(unit.id, "test-sess-1");
assert_eq!(unit.title, "demo mission");
let evidence = fno_agents::loop_runtime::Evidence {
reason: fno_agents::loopcheck::TerminationReason::DonePRGreen,
message: "done".to_string(),
};
q.close(&unit, &evidence).unwrap();
assert!(
q.next().unwrap().is_none(),
"second call must return None after close"
);
}
#[test]
fn target_queue_missing_manifest_error() {
let dir = TempDir::new().unwrap();
let result = TargetQueue::from_manifest(dir.path());
assert!(result.is_err(), "missing manifest must return Err");
let msg = match result {
Err(e) => e.to_string(),
Ok(_) => panic!("expected Err"),
};
assert!(
msg.contains("run /target first"),
"error message must mention 'run /target first': {msg}"
);
}
#[test]
fn preflight_rejects_unknown_driver() {
let dir = TempDir::new().unwrap();
let err = preflight("../evil", dir.path(), None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("../evil") || msg.contains("whitelist") || msg.contains("invalid"),
"error should mention driver name or whitelist: {msg}"
);
}
#[test]
fn preflight_rejects_path_traversal() {
let dir = TempDir::new().unwrap();
let err = preflight("../../anything", dir.path(), None).unwrap_err();
let msg = err.to_string();
assert!(
!msg.is_empty(),
"should return an error for path traversal attempt"
);
}
#[test]
fn preflight_names_missing_binary() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
let result = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"PATH=/empty_dir_that_does_not_exist {BINARY} loop run --driver target --dispatcher claude-code --driver-lib-dir {lib} --cwd {cwd} --max-iterations 1 2>&1 || true",
BINARY = BINARY,
lib = lib_dir.display(),
cwd = dir.path().display(),
))
.output()
.unwrap();
let out = String::from_utf8_lossy(&result.stdout).to_string()
+ &String::from_utf8_lossy(&result.stderr);
let _ = out;
}
#[test]
fn dispatcher_passes_env_and_iteration() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let env_dump_file = dir.path().join("env_dump.txt");
let body = format!(
"echo \"CURRENT_ITER=${{CURRENT_ITER:-MISSING}}\" >> {path}\n \
echo \"MAX_TURNS=${{MAX_TURNS:-MISSING}}\" >> {path}\n \
echo \"CONTINUE_PROMPT=${{CONTINUE_PROMPT:-MISSING}}\" >> {path}\n \
echo \"BUDGET_USD=${{BUDGET_USD:-MISSING}}\" >> {path}\n \
# Write termination event so the loop stops\n \
local events_file=\"${{FNO_CWD:-.}}/.fno/events.jsonl\"\n \
mkdir -p \"$(dirname \"$events_file\")\"\n \
echo '{{\"ts\":\"2026-06-06T00:00:00Z\",\"type\":\"termination\",\"source\":\"hook\",\"data\":{{\"session_id\":\"test-sess-4\",\"reason\":\"DonePRGreen\",\"message\":\"done\"}}}}' >> \"$events_file\"\n \
exit 0",
path = env_dump_file.display()
);
write_stub_driver(&lib_dir, "claude-code", 3, &body);
write_manifest(dir.path(), "test-sess-4", "env test", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
"--max-turns",
"7",
"--budget",
"10",
])
.env("PATH", path_with(&bin_dir))
.env("FNO_CWD", dir.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(
env_dump_file.exists(),
"env dump file must exist after dispatch\nstdout={stdout}\nstderr={stderr}"
);
let dump = fs::read_to_string(&env_dump_file).unwrap();
assert!(
dump.contains("CURRENT_ITER=1"),
"CURRENT_ITER must be '1' on first dispatch: {dump}"
);
assert!(
dump.contains("MAX_TURNS=7"),
"MAX_TURNS must be '7': {dump}"
);
assert!(
dump.contains("CONTINUE_PROMPT=/target --resume"),
"CONTINUE_PROMPT must be '/target --resume': {dump}"
);
}
#[test]
fn driver_default_max_sources_lib() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 3, "exit 0");
let lib_path = lib_dir.join("driver-claude-code.sh");
let max = fno_agents::loop_dispatch::driver_default_max(&lib_path).unwrap();
assert_eq!(
max, 3,
"driver_default_max should return what the stub echoes"
);
}
#[test]
fn e2e_binary_happy_path() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let abilities_dir = dir.path().join(".fno");
let events_file = abilities_dir.join("events.jsonl");
let events_path = events_file.display().to_string();
let body = format!(
"mkdir -p \"$(dirname '{events_path}')\"\n \
printf '{{\"ts\":\"2026-06-06T00:00:00Z\",\"type\":\"termination\",\"source\":\"hook\",\"data\":{{\"session_id\":\"test-sess-hp\",\"reason\":\"DonePRGreen\",\"message\":\"done\"}}}}\\n' >> '{events_path}'\n \
exit 0"
);
write_stub_driver(&lib_dir, "claude-code", 40, &body);
write_manifest(dir.path(), "test-sess-hp", "happy path mission", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"5",
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(0),
"happy path: expected exit 0\nstdout={stdout}\nstderr={stderr}"
);
assert!(
stdout.contains("DonePRGreen") || stdout.contains("done"),
"stdout should mention DonePRGreen or done: {stdout}"
);
let events = read_jsonl(&events_file);
let dispatched: Vec<_> = events
.iter()
.filter(|v| v["type"].as_str() == Some("loop_unit_dispatched"))
.filter(|v| v["source"].as_str() == Some("loop"))
.collect();
assert!(
!dispatched.is_empty(),
"journal must have loop_unit_dispatched with source=loop"
);
let terminated: Vec<_> = events
.iter()
.filter(|v| v["type"].as_str() == Some("loop_terminated"))
.filter(|v| v["source"].as_str() == Some("loop"))
.collect();
assert!(
!terminated.is_empty(),
"journal must have loop_terminated with source=loop"
);
}
#[test]
fn e2e_binary_iteration_ceiling() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 1");
write_manifest(dir.path(), "test-sess-ceil", "ceiling mission", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"2",
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(1),
"budget exceeded: expected exit 1\nstdout={stdout}\nstderr={stderr}"
);
let abilities_dir = dir.path().join(".fno");
let events_file = abilities_dir.join("events.jsonl");
let events = read_jsonl(&events_file);
let failed_count = events
.iter()
.filter(|v| v["type"].as_str() == Some("node_failed"))
.count();
assert_eq!(
failed_count, 2,
"should have exactly 2 node_failed events; got {failed_count}"
);
let terminated: Vec<_> = events
.iter()
.filter(|v| v["type"].as_str() == Some("loop_terminated"))
.collect();
assert!(!terminated.is_empty(), "must have loop_terminated event");
assert_eq!(
terminated[0]["data"]["reason"].as_str(),
Some("Budget"),
"loop_terminated reason must be Budget"
);
assert_eq!(
terminated[0]["data"]["axis"].as_str(),
Some("iterations"),
"Budget termination must carry axis=iterations"
);
}
#[test]
fn e2e_binary_missing_driver_binary() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
write_manifest(dir.path(), "test-sess-nobin", "no binary mission", "");
let empty_bin_dir = dir.path().join("empty_bin");
fs::create_dir_all(&empty_bin_dir).unwrap();
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
])
.env("PATH", path_without_claude())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let all_output = format!("{stdout}{stderr}");
assert_eq!(
output.status.code(),
Some(77),
"missing binary: expected exit 77\nstdout={stdout}\nstderr={stderr}"
);
assert!(
all_output.contains("claude"),
"output must name the missing binary 'claude': {all_output}"
);
let events_file = dir.path().join(".fno").join("events.jsonl");
assert_eq!(
count_events(&events_file, "loop_unit_dispatched"),
0,
"no dispatch should occur before preflight fails"
);
}
#[test]
fn e2e_binary_megawalk_starts() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let bin_dir = dir.path().join("bin");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
write_stub_binary(
&bin_dir,
"fno",
r#"if [[ "$1" == "backlog" && "$2" == "next" ]]; then echo 'null'; exit 0; fi
exit 0"#,
);
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"megawalk",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
])
.env("PATH", path_with(&bin_dir))
.env("FNO_BIN", bin_dir.join("fno").to_str().unwrap())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(0),
"megawalk driver with empty backlog must exit 0 (NoWork);\nstdout={stdout}\nstderr={stderr}"
);
assert!(
stdout.contains("megawalk"),
"header must mention megawalk: {stdout}"
);
}
#[test]
fn e2e_resume_no_duplicate_session() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let abilities_dir = dir.path().join(".fno");
let events_file = abilities_dir.join("events.jsonl");
let marker_file = dir.path().join("dispatch_marker.txt");
seed_termination_event(&events_file, "test-sess-resume", "DonePRGreen");
let body = format!("touch '{}'", marker_file.display());
write_stub_driver(&lib_dir, "claude-code", 40, &body);
write_manifest(dir.path(), "test-sess-resume", "resume test mission", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"3",
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(0),
"resume guard: expected exit 0\nstdout={stdout}\nstderr={stderr}"
);
assert!(
!marker_file.exists(),
"driver_invoke must NOT be called when a termination event already exists"
);
}
#[test]
fn preflight_cli_alias_used_for_binary_check() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "opencode", "exit 0");
let result = {
let orig_path = std::env::var("PATH").unwrap_or_default();
let controlled_path = format!("{}:{orig_path}", bin_dir.display());
std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
"--cli",
"opencode",
])
.env("PATH", format!("{}:/bin:/usr/bin", bin_dir.display()))
.env_remove("CLAUDE_CLI")
.env_remove("CLI")
.output()
.unwrap()
};
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
assert_ne!(
result.status.code(),
Some(77),
"--cli opencode: preflight must use opencode, not claude (exit 77 means wrong binary checked)\nstdout={stdout}\nstderr={stderr}"
);
}
#[test]
fn preflight_missing_binary_no_cli_alias() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
write_manifest(dir.path(), "sess-nobin2", "test", "");
let result = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
])
.env("PATH", "/bin:/usr/bin")
.env_remove("CLAUDE_CLI")
.env_remove("CLI")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
assert_eq!(
result.status.code(),
Some(77),
"without --cli, missing claude binary must exit 77\nstderr={stderr}"
);
assert!(
stderr.contains("claude"),
"error must name the missing binary 'claude': {stderr}"
);
}
#[test]
fn negative_budget_rejected() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
write_stub_driver(&lib_dir, "claude-code", 40, "exit 0");
write_manifest(dir.path(), "sess-budget", "budget test", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
for bad_budget in ["-5", "-1", "0"] {
let result = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
"--budget",
bad_budget,
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
assert_eq!(
result.status.code(),
Some(2),
"--budget {bad_budget}: expected exit 2 (config error)\nstderr={stderr}"
);
assert!(
stderr.contains("positive") || stderr.contains("budget") || stderr.contains(bad_budget),
"--budget {bad_budget}: error message must mention the issue: {stderr}"
);
}
}
#[test]
fn signal_death_returns_128_plus_n() {
use fno_agents::loop_dispatch::ShelloutDispatcher;
use fno_agents::loop_runtime::{DispatchCtx, Dispatcher};
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let body = "bash -c 'kill -TERM $$'";
write_stub_driver(&lib_dir, "claude-code", 40, body);
let lib_path = lib_dir.join("driver-claude-code.sh");
let env: Vec<(String, String)> = vec![];
let dispatcher = ShelloutDispatcher::new(lib_path, env, dir.path().to_path_buf());
let unit = fno_agents::loop_runtime::Unit {
id: "test-signal".to_string(),
title: "signal test".to_string(),
session_key: "test-signal".to_string(),
plan_path: None,
extra_env: vec![],
};
let ctx = DispatchCtx { iteration: 1 };
let mut session = dispatcher.run(&unit, &ctx).expect("dispatch must succeed");
let code = session.wait().expect("wait must succeed");
assert_ne!(
code, -1,
"signal death must not return -1 (old broken sentinel): got {code}"
);
assert!(
code == 143 || code != 0,
"signal death must return 128+signal or non-zero, got {code}"
);
}
#[test]
fn preflight_missing_driver_invoke_rejected() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
fs::create_dir_all(&lib_dir).unwrap();
let lib_path = lib_dir.join("driver-claude-code.sh");
fs::write(
&lib_path,
b"#!/usr/bin/env bash\ndriver_default_max() { echo 40; }\n# no driver_invoke here\n",
)
.unwrap();
fs::set_permissions(&lib_path, fs::Permissions::from_mode(0o755)).unwrap();
write_manifest(dir.path(), "sess-noinvoke", "no invoke test", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let result = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
let all_output = format!("{stdout}{stderr}");
assert_ne!(
result.status.code(),
Some(0),
"lib missing driver_invoke must not succeed\nstdout={stdout}\nstderr={stderr}"
);
assert!(
all_output.contains("driver_invoke"),
"error must mention 'driver_invoke': {all_output}"
);
}
#[test]
fn manifest_missing_session_id_clean_error() {
let dir = TempDir::new().unwrap();
let abilities_dir = dir.path().join(".fno");
fs::create_dir_all(&abilities_dir).unwrap();
fs::write(
abilities_dir.join("target-state.md"),
"---\ninput: \"something\"\nplan_path: \"\"\n---\n",
)
.unwrap();
let result = TargetQueue::from_manifest(dir.path());
assert!(
result.is_err(),
"manifest missing session_id must return Err (not panic)"
);
let msg = match result {
Err(e) => e.to_string(),
Ok(_) => panic!("expected Err"),
};
assert!(
msg.contains("run /target first")
|| msg.contains("required fields")
|| msg.contains("session_id"),
"error must mention the missing field or how to fix it: {msg}"
);
}
#[test]
fn env_contract_real_driver_claude_code() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir.parent().unwrap().parent().unwrap();
let real_lib_dir = repo_root.join("scripts").join("lib");
let real_lib = real_lib_dir.join("driver-claude-code.sh");
if !real_lib.exists() {
eprintln!(
"SKIP: env_contract_real_driver_claude_code: real lib not found at {}",
real_lib.display()
);
return;
}
let dir = TempDir::new().unwrap();
let abilities_dir = dir.path().join(".fno");
fs::create_dir_all(&abilities_dir).unwrap();
let events_file = abilities_dir.join("events.jsonl");
let argv_file = dir.path().join("argv_dump.txt");
let env_file = dir.path().join("env_dump.txt");
let sess = "t1-real-sess";
write_manifest(dir.path(), sess, "real driver test", "");
let bin_dir = dir.path().join("bin");
let argv_path = argv_file.display().to_string();
let env_path = env_file.display().to_string();
let events_path = events_file.display().to_string();
let sess_str = sess.to_string();
let fake_claude_body = format!(
r#"# Dump each argv token on its own line (pinning word-split contract).
for arg in "$@"; do echo "$arg" >> {argv}; done
# Dump CONTINUE_PROMPT env.
echo "CONTINUE_PROMPT=${{CONTINUE_PROMPT:-MISSING}}" >> {env}
# Write DonePRGreen termination event.
mkdir -p "$(dirname "{events}")"
printf '{{"ts":"2026-06-06T00:00:00Z","type":"termination","source":"hook","data":{{"session_id":"{sess}","reason":"DonePRGreen","message":"done"}}}}\n' >> "{events}"
exit 0"#,
argv = argv_path,
env = env_path,
events = events_path,
sess = sess_str,
);
write_stub_binary(&bin_dir, "claude", &fake_claude_body);
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
real_lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"1",
"--model",
"opus 4", ])
.env("PATH", path_with(&bin_dir))
.env_remove("CLAUDE_CLI")
.env_remove("CLI")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(0),
"T1: expected exit 0 (DonePRGreen)\nstdout={stdout}\nstderr={stderr}"
);
assert!(
argv_file.exists(),
"T1: argv dump file must exist\nstdout={stdout}\nstderr={stderr}"
);
let argv_dump = fs::read_to_string(&argv_file).unwrap();
let argv_lines: Vec<&str> = argv_dump.lines().collect();
assert!(
argv_lines.contains(&"--model"),
"T1: '--model' must be a separate argv token (word-split): {argv_dump}"
);
assert!(
argv_lines.contains(&"opus"),
"T1: 'opus' must be a separate argv token (word-split): {argv_dump}"
);
assert!(
argv_lines.contains(&"4"),
"T1: '4' must be a separate argv token (word-split): {argv_dump}"
);
assert!(
argv_lines.contains(&"--max-budget-usd"),
"T1: '--max-budget-usd' must be in argv: {argv_dump}"
);
assert!(
argv_lines.contains(&"25"),
"T1: '25' (budget value) must be in argv: {argv_dump}"
);
assert!(
env_file.exists(),
"T1: env dump file must exist\nstdout={stdout}\nstderr={stderr}"
);
let env_dump = fs::read_to_string(&env_file).unwrap();
assert!(
env_dump.contains("CONTINUE_PROMPT=/target --resume"),
"T1: CONTINUE_PROMPT must be '/target --resume': {env_dump}"
);
assert!(
stdout.contains("DonePRGreen") || stdout.contains("done"),
"T1: stdout must mention DonePRGreen: {stdout}"
);
}
#[test]
fn trailing_flag_missing_value_exits_2() {
let output = std::process::Command::new(BINARY)
.args(["loop", "run", "--driver"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(2),
"trailing --driver with no value: expected exit 2\nstderr={stderr}"
);
assert!(
stderr.contains("--driver") && stderr.contains("missing value"),
"error must name the flag and say 'missing value': {stderr}"
);
}
#[test]
fn driver_persist_history_called_per_iteration() {
let dir = TempDir::new().unwrap();
let lib_dir = dir.path().join("lib");
let marker_file = dir.path().join("persist_marker.txt");
let marker_path = marker_file.display().to_string();
let content = format!(
"#!/usr/bin/env bash\n\
driver_default_max() {{ echo 40; }}\n\
driver_invoke() {{\n exit 7\n}}\n\
driver_persist_history() {{\n echo persisted >> {marker}\n}}\n",
marker = marker_path,
);
fs::create_dir_all(&lib_dir).unwrap();
let lib_path = lib_dir.join("driver-claude-code.sh");
fs::write(&lib_path, content.as_bytes()).unwrap();
fs::set_permissions(&lib_path, fs::Permissions::from_mode(0o755)).unwrap();
write_manifest(dir.path(), "sess-persist", "persist history test", "");
let bin_dir = dir.path().join("bin");
write_stub_binary(&bin_dir, "claude", "exit 0");
let output = std::process::Command::new(BINARY)
.args([
"loop",
"run",
"--driver",
"target",
"--dispatcher",
"claude-code",
"--driver-lib-dir",
lib_dir.to_str().unwrap(),
"--cwd",
dir.path().to_str().unwrap(),
"--max-iterations",
"2",
])
.env("PATH", path_with(&bin_dir))
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert_eq!(
output.status.code(),
Some(1),
"persist test: expected exit 1 (iterations exhausted)\nstdout={stdout}\nstderr={stderr}"
);
assert!(
marker_file.exists(),
"persist marker file must exist (driver_persist_history was never called)\nstderr={stderr}"
);
let marker_content = fs::read_to_string(&marker_file).unwrap();
let marker_lines: Vec<&str> = marker_content
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
assert_eq!(
marker_lines.len(),
2,
"driver_persist_history must be called exactly once per iteration (expected 2 lines, got {})\nmarker={:?}",
marker_lines.len(),
marker_lines
);
let events_file = dir.path().join(".fno").join("events.jsonl");
let events = read_jsonl(&events_file);
let failed_events: Vec<_> = events
.iter()
.filter(|v| v["type"].as_str() == Some("node_failed"))
.collect();
assert_eq!(
failed_events.len(),
2,
"must have 2 node_failed events: got {}",
failed_events.len()
);
for ev in &failed_events {
assert_eq!(
ev["data"]["exit_code"].as_i64(),
Some(7),
"node_failed exit_code must be 7 (preserved from driver_invoke): {ev}"
);
}
}