use serde_json::{Value, json};
use std::{
fs,
io::{BufRead, BufReader, Read, Write},
net::TcpListener,
path::Path,
process::{Command, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
const BIN: &str = env!("CARGO_BIN_EXE_chuggin");
struct Server {
url: String,
requests: Arc<Mutex<Vec<Value>>>,
done: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Server {
fn new(wizard: bool, delay: bool) -> Self {
Self::serve(wizard, delay, None, false)
}
fn serve(wizard: bool, delay: bool, tools_mode: Option<u8>, timeout_followup: bool) -> Self {
Self::serve_with_edit(wizard, delay, tools_mode, timeout_followup, None)
}
fn serve_with_edit(
wizard: bool,
delay: bool,
tools_mode: Option<u8>,
timeout_review: bool,
edit: Option<String>,
) -> Self {
Self::serve_with_repetition(wizard, delay, tools_mode, timeout_review, edit, None)
}
fn serve_with_repetition(
wizard: bool,
delay: bool,
tools_mode: Option<u8>,
timeout_review: bool,
edit: Option<String>,
repetition_stage: Option<usize>,
) -> Self {
let mut cycle = 0;
let mut awaiting_completion = false;
Self::custom(delay, timeout_review, repetition_stage, move |body, n| {
if wizard {
return (
json!({"goal":format!("Build a useful editor. Draft {}.", n+1)}).to_string(),
json!([]),
);
}
if let Some(mode) = tools_mode {
return dev_tool_reply(body, mode);
}
if awaiting_completion {
awaiting_completion = false;
return ("PRIVATE_IMPLEMENTATION_TRANSCRIPT".into(), json!([]));
}
awaiting_completion = true;
cycle += 1;
(
String::new(),
json!([
task_tool(&format!("Increment {cycle}")),
{"function":{"name":"save_progress_note","arguments":{"note":format!("Task-local observation for cycle {cycle}")}}},
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":edit.clone().unwrap_or_else(|| cycle.to_string())}}}
]),
)
})
}
fn custom(
delay: bool,
timeout_review: bool,
repetition_request: Option<usize>,
mut reply: impl FnMut(&Value, usize) -> (String, Value) + Send + 'static,
) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let calls = requests.clone();
let done = Arc::new(AtomicBool::new(false));
let stop = done.clone();
let handle = thread::spawn(move || {
let mut review_timed_out = false;
while !stop.load(Ordering::SeqCst) {
let Ok((mut stream, _)) = listener.accept() else {
thread::sleep(Duration::from_millis(10));
continue;
};
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut first = String::new();
reader.read_line(&mut first).unwrap();
let mut length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line.trim().is_empty() {
break;
}
if let Some((key, v)) = line.split_once(':')
&& key.eq_ignore_ascii_case("content-length")
{
length = v.trim().parse().unwrap();
}
}
let data = if first.starts_with("GET") {
json!({"models":[{"name":"fake"}]}).to_string()
} else {
let mut data = vec![0; length];
reader.read_exact(&mut data).unwrap();
let body: Value = serde_json::from_slice(&data).unwrap();
let mut requests = calls.lock().unwrap();
requests.push(body.clone());
let n = requests.len() - 1;
drop(requests);
if delay {
thread::sleep(Duration::from_millis(800));
}
if timeout_review && n == 1 && !review_timed_out {
review_timed_out = true;
thread::sleep(Duration::from_millis(1200));
continue;
}
if repetition_request == Some(n) || repetition_request == Some(usize::MAX) {
let prefix = json!({"message":{"content":"The relevant file is value.txt.\n"},"done":false}).to_string()+"\n";
prefix + &(0..20).map(|_| json!({"message":{"content":"I'll try that now. Wait, let me think about it. ","tool_calls":if n == 1 {json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"MUST_NOT_EXECUTE"}}}])} else {json!([])}},"done":false}).to_string()+"\n").collect::<String>()
} else {
let (content, tools) = reply(&body, n);
if matches!(
content.as_str(),
"__FAIL_REQUEST__"
| "__HTTP_LIMIT__"
| "__HTTP_LONG_LIMIT__"
| "__CREDIT_LIMIT__"
| "__CREDIT_RETRY__"
) {
content
} else if content == "__STREAM_LIMIT__" {
json!({"error":"weekly usage limit reached"}).to_string() + "\n"
} else {
json!({"message":{"role":"assistant","content":content,"tool_calls":tools},"done":true,"done_reason":"stop"}).to_string()+"\n"
}
}
};
let status = match data.as_str() {
"__FAIL_REQUEST__" => "400 Bad Request",
"__HTTP_LIMIT__" => "429 Too Many Requests\nRetry-After: 1",
"__HTTP_LONG_LIMIT__" => "429 Too Many Requests\nRetry-After: 300",
"__CREDIT_LIMIT__" => "402 Payment Required",
"__CREDIT_RETRY__" => "402 Payment Required\nRetry-After: 1",
_ => "200 OK",
};
let response = format!(
"HTTP/1.1 {status}
Content-Length: {}
Connection: close
{}",
data.len(),
data
);
let _ = stream.write_all(response.as_bytes());
}
});
Self {
url,
requests,
done,
handle: Some(handle),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
self.done.store(true, Ordering::SeqCst);
let _ = self.handle.take().unwrap().join();
}
}
fn is_work(body: &Value) -> bool {
body["tools"].as_array().is_some_and(|tools| {
tools
.iter()
.any(|tool| tool["function"]["name"] == "write_file")
})
}
fn task_tool(title: &str) -> Value {
json!({"function":{"name":"set_task","arguments":{"title":title,"objective":"Improve value","acceptance":["Value updated"],"files":["value.txt"]}}})
}
fn state(root: &Path) -> Value {
serde_json::from_slice(&fs::read(root.join("state/state.json")).unwrap()).unwrap()
}
fn migrate_fixture(root: &Path) {
let out = command(root).arg("migrate").output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let plan: Value =
serde_json::from_slice(&fs::read(root.join("state/migration.json")).unwrap()).unwrap();
let prepared = Path::new(plan["prepared"].as_str().unwrap());
let conflicts = git(prepared, &["diff", "--name-only", "--diff-filter=U"]);
for file in conflicts.lines() {
if !Command::new("git")
.arg("-C")
.arg(prepared)
.args([
"cat-file",
"-e",
&format!("{}:{}", plan["source_snapshot"].as_str().unwrap(), file),
])
.output()
.unwrap()
.status
.success()
{
git(prepared, &["rm", "-f", "--", file]);
continue;
}
git(
prepared,
&[
"restore",
"--source",
plan["source_snapshot"].as_str().unwrap(),
"--staged",
"--worktree",
"--",
file,
],
);
}
let out = command(root).args(["migrate", "--apply"]).output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
fn run_cycles(root: &Path, cycles: u64) {
let out = command(root)
.args(["run", "--cycles", &cycles.to_string()])
.output()
.unwrap();
assert!(
out.status.success(),
"{}\n{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
}
fn git(repo: &Path, args: &[&str]) -> String {
let o = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
String::from_utf8(o.stdout).unwrap().trim().into()
}
fn fixture(root: &Path, url: &str, pass: bool) -> std::path::PathBuf {
let repo = root.join("repo");
fs::create_dir(&repo).unwrap();
git(&repo, &["init"]);
fs::write(repo.join("value.txt"), "0").unwrap();
git(&repo, &["add", "."]);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Initial",
],
);
fs::write(repo.join("value.txt"), "USER_EDIT").unwrap();
let argv = if pass {
vec!["git", "rev-parse", "HEAD"]
} else {
vec!["git", "rev-parse", "--verify", "nonexistent-ref"]
};
let config = root.join("chuggin.json");
fs::write(&config,json!({"repo":repo,"goal":"Improve value incrementally","ollama_url":url,"model":"fake",
"context_tokens":32768,"output_tokens":4096,"implementation_calls":16,
"checks":[{"argv":argv,"timeout_seconds":5}],"state_dir":root.join("state"),"retry_seconds":1}).to_string()).unwrap();
config
}
fn command(root: &Path) -> Command {
fs::create_dir_all(root.join(".chuggin")).unwrap();
let identity = root.join(".chuggin/test-gitconfig");
fs::write(
&identity,
"[user]\nname = Test Operator\nemail = operator@example.com\n",
)
.unwrap();
let mut c = Command::new(BIN);
c.current_dir(root)
.env("GIT_CONFIG_GLOBAL", identity)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("XDG_CONFIG_HOME", root.join(".chuggin/global"));
c
}
#[test]
fn missing_git_identity_blocks_before_model_requests() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
for key in ["user.name", "user.email"] {
let mut cmd = command(root.path());
let identity = root.path().join(".chuggin/test-gitconfig");
let remaining = if key == "user.name" {
"[user]\nemail = operator@example.com\n"
} else {
"[user]\nname = Test Operator\n"
};
fs::write(identity, remaining).unwrap();
let out = cmd.args(["run", "--cycles", "1"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains(&format!("missing {key}")));
assert!(server.requests.lock().unwrap().is_empty());
assert!(!root.path().join("state/state.json").exists());
}
}
#[test]
fn recovery_saves_advance_with_visible_edits_without_unfinished_commits() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
let initial = git(&repo, &["rev-parse", "HEAD"]);
run_cycles(root.path(), 1);
let first = state(root.path());
run_cycles(root.path(), 1);
let second = state(root.path());
assert_eq!(second["schema_version"], 4);
assert_eq!(first["working_workspace"], second["working_workspace"]);
assert_ne!(first["working_ref"], second["working_ref"]);
assert_eq!(second["last_checks_passed_ref"], second["working_ref"]);
assert_eq!(
fs::read_to_string(
Path::new(second["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"2"
);
assert_eq!(git(&repo, &["rev-parse", "HEAD"]), initial);
assert_eq!(fs::read_to_string(repo.join("value.txt")).unwrap(), "2");
assert_eq!(
git(
&repo,
&[
"log",
"-1",
"--format=%an <%ae>|%cn <%ce>",
second["working_ref"].as_str().unwrap()
]
),
"Test Operator <operator@example.com>|Test Operator <operator@example.com>"
);
let requests = server.requests.lock().unwrap();
let original_history = requests[1]["messages"].as_array().unwrap();
assert_eq!(
&requests[2]["messages"].as_array().unwrap()[..original_history.len()],
original_history
);
assert!(requests.iter().all(
|request| request["messages"][0] == requests[0]["messages"][0]
&& request["tools"] == requests[0]["tools"]
));
assert!(
requests[2]
.to_string()
.contains("PRIVATE_IMPLEMENTATION_TRANSCRIPT")
);
drop(requests);
let mut changed: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
changed["goal"] = json!("Changed goal");
fs::write(&config, changed.to_string()).unwrap();
assert!(
!command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap()
.status
.success()
);
}
#[test]
fn failing_work_is_checkpointed_then_repaired_across_cycles_and_process_restarts() {
for restart in [false, true] {
let mut attempt = 0;
let mut edited = false;
let server = Server::custom(false, false, None, move |_, _| {
if edited {
attempt += 1;
edited = false;
return ("The current attempt is ready for checks.".into(), json!([]));
}
edited = true;
let script = if attempt == 0 {
"printf BROKEN > value.txt"
} else {
"test \"$(cat value.txt)\" = BROKEN && printf FIXED > value.txt"
};
(
String::new(),
json!([task_tool("Repair the value"),{"function":{"name":"run_command","arguments":{"argv":["sh","-c",script]}}}]),
)
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(2);
settings["checks"] =
json!([{"argv":["sh","-c","test \"$(cat value.txt)\" = FIXED"],"timeout_seconds":5}]);
fs::write(config, settings.to_string()).unwrap();
if restart {
run_cycles(root.path(), 1);
let failed = state(root.path());
assert_eq!(
failed["recent"][0]["disposition"],
"checkpoint/checks-failing"
);
assert!(failed["last_checks_passed_ref"].is_null());
assert_eq!(
fs::read_to_string(
Path::new(failed["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"BROKEN"
);
assert_eq!(
git(
&root.path().join("repo"),
&[
"show",
&format!("{}:value.txt", failed["working_ref"].as_str().unwrap())
]
),
"BROKEN"
);
run_cycles(root.path(), 1);
} else {
run_cycles(root.path(), 2);
}
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"FIXED"
);
assert_eq!(saved["last_checks_passed_ref"], saved["working_ref"]);
let requests = server.requests.lock().unwrap();
assert!(
requests.iter().all(is_work),
"Planning, coding and refinement share the same tools"
);
assert!(
requests
.iter()
.filter(|body| is_work(body))
.any(|body| body.to_string().contains("BROKEN"))
);
let first_history = requests[1]["messages"].as_array().unwrap();
assert_eq!(
&requests[2]["messages"].as_array().unwrap()[..first_history.len()],
first_history,
"The repair retains the working conversation across a cycle or process restart"
);
}
}
#[test]
fn unfinished_and_failed_attempts_preserve_deletions_renames_and_new_files() {
for failing_checks in [false, true] {
let mut edited = false;
let server = Server::custom(false, false, None, move |_, _| {
if edited {
return (
"This change is unfinished and needs further work.".into(),
json!([]),
);
}
edited = true;
(
String::new(),
json!([task_tool("Reorganize document files"),{"function":{"name":"run_command","arguments":{"argv":["sh","-c","mv value.txt renamed.txt && printf 'new notes' > notes.txt"]}}}]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, !failing_checks);
run_cycles(root.path(), 1);
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert!(!workspace.join("value.txt").exists());
assert_eq!(
fs::read_to_string(workspace.join("renamed.txt")).unwrap(),
"USER_EDIT"
);
assert_eq!(
fs::read_to_string(workspace.join("notes.txt")).unwrap(),
"new notes"
);
assert!(!git(workspace, &["status", "--porcelain"]).is_empty());
assert_eq!(
git(
workspace,
&[
"ls-tree",
"-r",
"--name-only",
saved["working_ref"].as_str().unwrap()
]
),
"notes.txt\nrenamed.txt"
);
assert!(
saved["current_task"].is_object(),
"An unfinished task remains available for follow-up"
);
}
}
#[test]
fn model_error_after_editing_still_checkpoints_completed_tool_changes() {
let mut edited = false;
let server = Server::custom(false, false, None, move |_, _| {
if edited {
return ("__FAIL_REQUEST__".into(), json!([]));
}
edited = true;
(
String::new(),
json!([task_tool("Keep unfinished work"), {"function":{"name":"write_file","arguments":{"path":"value.txt","content":"KEEP_AFTER_NETWORK_ERROR"}}}]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"KEEP_AFTER_NETWORK_ERROR"
);
assert_eq!(
git(
workspace,
&[
"show",
&format!("{}:value.txt", saved["working_ref"].as_str().unwrap())
]
),
"KEEP_AFTER_NETWORK_ERROR"
);
assert!(saved["current_task"].is_object());
assert!(
fs::read_dir(root.path().join("state/cycle-000001"))
.unwrap()
.filter_map(Result::ok)
.any(|entry| entry
.file_name()
.to_string_lossy()
.starts_with("request-failure-"))
);
}
#[test]
fn finish_task_records_completion_only_when_checks_pass_and_always_saves_edits() {
for pass in [false, true] {
let server = Server::custom(false, false, None, |_, _| {
(
String::new(),
json!([task_tool("Finish the current task"),{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"COMPLETED_EDIT"}}},{"function":{"name":"finish_task","arguments":{"summary":"The value was updated and is ready for validation."}}}]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, pass);
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(saved["current_task"].is_null(), pass);
assert_eq!(
fs::read_to_string(
Path::new(saved["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"COMPLETED_EDIT"
);
assert_eq!(saved["last_checks_passed_ref"].is_string(), pass);
assert_eq!(server.requests.lock().unwrap().len(), 1);
}
}
#[test]
fn legacy_migration_recovers_the_entire_interrupted_workspace_including_staged_changes() {
let mut edited = false;
let server = Server::custom(false, false, None, move |_, _| {
if edited {
return (
"Recovered unfinished files and continued.".into(),
json!([]),
);
}
edited = true;
(
String::new(),
json!([task_tool("Continue interrupted reorganization"), {"function":{"name":"run_command","arguments":{"argv":["sh","-c","test ! -e value.txt && test \"$(cat renamed.txt)\" = DIRTY_CANDIDATE && test \"$(cat staged.txt)\" = STAGED_CANDIDATE && printf resumed > resumed.txt"]}}}]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
let initial = git(&repo, &["rev-parse", "HEAD"]);
let state_dir = root.path().join("state");
let art = state_dir.join("cycle-000001");
fs::create_dir_all(&art).unwrap();
let baseline = state_dir.join("baseline");
git(
&repo,
&[
"worktree",
"add",
"--detach",
baseline.to_str().unwrap(),
&initial,
],
);
let candidate = art.join("workspace");
git(
&repo,
&[
"worktree",
"add",
"-b",
"codex/legacy-interrupted",
candidate.to_str().unwrap(),
&initial,
],
);
fs::rename(candidate.join("value.txt"), candidate.join("renamed.txt")).unwrap();
fs::write(candidate.join("renamed.txt"), "DIRTY_CANDIDATE").unwrap();
fs::write(candidate.join("staged.txt"), "STAGED_CANDIDATE").unwrap();
git(&candidate, &["add", "staged.txt"]);
let original_state = json!({"run_id":"legacy-run","goal":"Improve value incrementally","repo":repo,"cycle":1,"accepted_ref":initial,"accepted_branch":"","accepted_workspace":baseline,"recent":[]});
fs::write(state_dir.join("state.json"), original_state.to_string()).unwrap();
fs::write(
art.join("attempt.json"),
json!({"base":initial,"branch":"codex/legacy-interrupted","workspace":candidate})
.to_string(),
)
.unwrap();
fs::write(art.join("task.json"), json!({"title":"Continue interrupted reorganization","objective":"Retain unfinished work","acceptance":["Files reorganized"],"files":["value.txt","renamed.txt","staged.txt"],"out_of_scope":[]}).to_string()).unwrap();
migrate_fixture(root.path());
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(saved["schema_version"], 4);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(state_dir.join("state-v1-backup.json")).unwrap())
.unwrap(),
original_state
);
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert!(!workspace.join("value.txt").exists());
assert_eq!(
fs::read_to_string(workspace.join("renamed.txt")).unwrap(),
"DIRTY_CANDIDATE"
);
assert_eq!(
fs::read_to_string(workspace.join("staged.txt")).unwrap(),
"STAGED_CANDIDATE"
);
assert_eq!(
fs::read_to_string(workspace.join("resumed.txt")).unwrap(),
"resumed"
);
assert!(!git(workspace, &["status", "--porcelain"]).is_empty());
assert!(!repo.join("value.txt").exists());
}
#[test]
fn legacy_migration_prefers_edited_work_over_a_newer_empty_attempt() {
for only_untracked in [false, true] {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
let initial = git(&repo, &["rev-parse", "HEAD"]);
let state_dir = root.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let baseline = state_dir.join("baseline");
git(
&repo,
&[
"worktree",
"add",
"--detach",
baseline.to_str().unwrap(),
&initial,
],
);
for cycle in [1, 2] {
let artifact = state_dir.join(format!("cycle-{cycle:06}"));
fs::create_dir_all(&artifact).unwrap();
let workspace = artifact.join("workspace");
let branch = format!("codex/legacy-attempt-{cycle}");
git(
&repo,
&[
"worktree",
"add",
"-b",
&branch,
workspace.to_str().unwrap(),
&initial,
],
);
fs::write(
artifact.join("attempt.json"),
json!({"base":initial,"branch":branch,"workspace":workspace}).to_string(),
)
.unwrap();
fs::write(artifact.join("task.json"), json!({"title":format!("Attempt {cycle}"),"objective":"Improve project content","acceptance":["Content updated"],"files":["value.txt"],"out_of_scope":[]}).to_string()).unwrap();
}
let edited = state_dir.join("cycle-000001/workspace");
let empty = state_dir.join("cycle-000002/workspace");
fs::write(
edited.join("unfinished-notes.txt"),
"Retain these untracked findings",
)
.unwrap();
if !only_untracked {
fs::write(edited.join("value.txt"), "STAGED_PROGRESS").unwrap();
fs::write(edited.join("new-module.txt"), "Staged new project content").unwrap();
git(&edited, &["add", "value.txt", "new-module.txt"]);
}
let edited_status = git(&edited, &["status", "--porcelain"]);
let edited_index = git(&edited, &["write-tree"]);
let legacy = json!({"run_id":"legacy-empty-last","goal":"Improve value incrementally","repo":repo,"cycle":2,"accepted_ref":initial,"accepted_branch":"","accepted_workspace":baseline,"recent":[]});
fs::write(state_dir.join("state.json"), legacy.to_string()).unwrap();
migrate_fixture(root.path());
run_cycles(root.path(), 0);
let saved = state(root.path());
assert_eq!(
saved["working_workspace"],
repo.to_str().unwrap(),
"A newer empty workspace must not hide actual unfinished work"
);
assert_eq!(saved["current_task"]["title"], "Attempt 1");
assert_eq!(
fs::read_to_string(edited.join("unfinished-notes.txt")).unwrap(),
"Retain these untracked findings"
);
if !only_untracked {
assert_eq!(
fs::read_to_string(edited.join("value.txt")).unwrap(),
"STAGED_PROGRESS"
);
assert_eq!(
fs::read_to_string(edited.join("new-module.txt")).unwrap(),
"Staged new project content"
);
}
assert_eq!(git(&edited, &["status", "--porcelain"]), edited_status);
assert_eq!(git(&edited, &["write-tree"]), edited_index);
assert_eq!(fs::read_to_string(empty.join("value.txt")).unwrap(), "0");
assert_eq!(git(&empty, &["status", "--porcelain"]), "");
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
if only_untracked {
"USER_EDIT"
} else {
"STAGED_PROGRESS"
}
);
assert!(server.requests.lock().unwrap().is_empty());
}
}
fn dirty_checkout_fixture(root: &Path, url: &str) -> (String, String, String) {
fixture(root, url, true);
let repo = root.join("repo");
fs::write(repo.join("deleted.txt"), "Delete this tracked file").unwrap();
fs::write(repo.join("old-name.txt"), "Preserve the renamed file").unwrap();
fs::write(repo.join("tracked.bin"), [0, 1, 2, 255]).unwrap();
fs::write(repo.join(".gitignore"), "ignored.txt\n").unwrap();
git(
&repo,
&[
"add",
"deleted.txt",
"old-name.txt",
"tracked.bin",
".gitignore",
],
);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Tracked project artifacts",
],
);
fs::remove_file(repo.join("deleted.txt")).unwrap();
fs::rename(repo.join("old-name.txt"), repo.join("new-name.txt")).unwrap();
fs::write(repo.join("tracked.bin"), [255, 0, 13, 10, 128]).unwrap();
fs::write(repo.join("new.bin"), [0, 254, 252, 10]).unwrap();
fs::write(repo.join("new-document.txt"), "Untracked project work").unwrap();
fs::write(repo.join("staged.txt"), "Staged project work").unwrap();
git(&repo, &["add", "staged.txt"]);
fs::write(
repo.join("staged.txt"),
"Further unstaged work after staging",
)
.unwrap();
fs::write(repo.join("ignored.txt"), "Do not import ignored files").unwrap();
fs::create_dir_all(repo.join(".chuggin")).unwrap();
fs::write(repo.join(".chuggin/runtime.txt"), "Harness runtime state").unwrap();
fs::write(repo.join("chuggin.json"), "Harness configuration").unwrap();
(
git(&repo, &["rev-parse", "HEAD"]),
git(&repo, &["status", "--porcelain"]),
git(&repo, &["write-tree"]),
)
}
fn assert_imported_dirty_checkout(root: &Path, original: &(String, String, String)) {
let repo = root.join("repo");
let saved = state(root);
assert_eq!(
saved["cycle"], 0,
"Preparing a workspace must not start a model cycle"
);
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert_eq!(workspace, repo);
assert!(!workspace.starts_with(root.join("state")));
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"USER_EDIT"
);
assert!(!workspace.join("deleted.txt").exists());
assert!(!workspace.join("old-name.txt").exists());
assert_eq!(
fs::read_to_string(workspace.join("new-name.txt")).unwrap(),
"Preserve the renamed file"
);
assert_eq!(
fs::read_to_string(workspace.join("new-document.txt")).unwrap(),
"Untracked project work"
);
assert_eq!(
fs::read_to_string(workspace.join("staged.txt")).unwrap(),
"Further unstaged work after staging"
);
assert_eq!(
fs::read(workspace.join("tracked.bin")).unwrap(),
[255, 0, 13, 10, 128]
);
assert_eq!(
fs::read(workspace.join("new.bin")).unwrap(),
[0, 254, 252, 10]
);
assert!(workspace.join("ignored.txt").exists());
assert!(workspace.join(".chuggin/runtime.txt").exists());
assert!(workspace.join("chuggin.json").exists());
assert_eq!(git(&repo, &["rev-parse", "HEAD"]), original.0);
let expected = original
.1
.lines()
.filter(|line| !line.ends_with(".chuggin/") && !line.ends_with("chuggin.json"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(git(&repo, &["status", "--porcelain"]), expected);
assert_eq!(
git(&repo, &["write-tree"]),
original.2,
"Import must leave the user's staging area intact"
);
assert_eq!(
fs::read(repo.join("tracked.bin")).unwrap(),
[255, 0, 13, 10, 128]
);
assert_eq!(
fs::read_to_string(repo.join("staged.txt")).unwrap(),
"Further unstaged work after staging"
);
}
#[test]
fn first_run_imports_current_dirty_project_files_including_binaries_and_deletions() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let original = dirty_checkout_fixture(root.path(), &server.url);
run_cycles(root.path(), 0);
assert_imported_dirty_checkout(root.path(), &original);
assert!(server.requests.lock().unwrap().is_empty());
}
#[test]
fn legacy_root_workspace_migrates_all_dirty_files_without_starting_a_cycle() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let original = dirty_checkout_fixture(root.path(), &server.url);
let state_dir = root.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let legacy = json!({"run_id":"legacy-root","goal":"Improve value incrementally","repo":root.path().join("repo"),"cycle":0,"accepted_ref":original.0,"accepted_branch":"","accepted_workspace":root.path().join("repo"),"recent":[]});
fs::write(state_dir.join("state.json"), legacy.to_string()).unwrap();
migrate_fixture(root.path());
run_cycles(root.path(), 0);
assert_imported_dirty_checkout(root.path(), &original);
assert_eq!(state(root.path())["schema_version"], 4);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(state_dir.join("state-v1-backup.json")).unwrap())
.unwrap(),
legacy
);
assert!(server.requests.lock().unwrap().is_empty());
}
#[test]
fn orphan_working_checkout_retains_its_actual_head_and_unfinished_files() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let original = dirty_checkout_fixture(root.path(), &server.url);
let repo = root.path().join("repo");
let state_dir = root.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let workspace = state_dir.join("working");
git(
&repo,
&[
"worktree",
"add",
"-b",
"codex/orphan-work",
workspace.to_str().unwrap(),
&original.0,
],
);
fs::write(workspace.join("value.txt"), "ORPHAN_COMMITTED").unwrap();
git(&workspace, &["add", "value.txt"]);
git(
&workspace,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Checkpoint whose state save was interrupted",
],
);
let orphan_head = git(&workspace, &["rev-parse", "HEAD"]);
fs::write(workspace.join("value.txt"), "ORPHAN_UNFINISHED").unwrap();
fs::write(workspace.join("orphan-staged.bin"), [0, 255, 4]).unwrap();
git(&workspace, &["add", "orphan-staged.bin"]);
fs::write(
workspace.join("orphan-notes.txt"),
"Keep these untracked findings",
)
.unwrap();
let orphan_status = git(&workspace, &["status", "--porcelain"]);
let orphan_index = git(&workspace, &["write-tree"]);
migrate_fixture(root.path());
run_cycles(root.path(), 0);
let saved = state(root.path());
assert_eq!(saved["branch_head"], orphan_head);
assert_eq!(saved["working_workspace"], repo.to_str().unwrap());
assert_eq!(
saved["working_branch"],
git(&repo, &["branch", "--show-current"])
);
assert_eq!(saved["seed_from_repo"], false);
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"ORPHAN_UNFINISHED"
);
assert_eq!(
fs::read(workspace.join("orphan-staged.bin")).unwrap(),
[0, 255, 4]
);
assert_eq!(
fs::read_to_string(workspace.join("orphan-notes.txt")).unwrap(),
"Keep these untracked findings"
);
assert!(
!workspace.join("new-document.txt").exists(),
"Do not overwrite an orphan workspace with the original checkout"
);
assert_eq!(git(&workspace, &["status", "--porcelain"]), orphan_status);
assert_eq!(git(&workspace, &["write-tree"]), orphan_index);
assert_eq!(git(&repo, &["rev-parse", "HEAD"]), orphan_head);
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"ORPHAN_UNFINISHED"
);
assert!(server.requests.lock().unwrap().is_empty());
}
#[test]
fn interrupted_initial_import_finishes_before_any_model_request() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let original = dirty_checkout_fixture(root.path(), &server.url);
let repo = root.path().join("repo");
let state_dir = root.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let workspace = state_dir.join("working");
git(
&repo,
&[
"worktree",
"add",
"-b",
"codex/interrupted-import",
workspace.to_str().unwrap(),
&original.0,
],
);
fs::write(workspace.join("value.txt"), "PARTIAL_IMPORT").unwrap();
fs::write(state_dir.join("state.json"), json!({"schema_version":2,"run_id":"interrupted-import","goal":"Improve value incrementally","repo":repo,"cycle":0,"working_ref":original.0,"working_branch":"codex/interrupted-import","working_workspace":workspace,"seed_from_repo":true,"recent":[]}).to_string()).unwrap();
migrate_fixture(root.path());
run_cycles(root.path(), 0);
assert_imported_dirty_checkout(root.path(), &original);
assert_eq!(state(root.path())["seed_from_repo"], false);
assert!(server.requests.lock().unwrap().is_empty());
fs::write(workspace.join("value.txt"), "LATER_WORKSPACE_CHANGE").unwrap();
run_cycles(root.path(), 0);
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"LATER_WORKSPACE_CHANGE",
"A completed import is never replayed on resume"
);
}
#[test]
fn initial_import_preserves_file_to_directory_and_directory_to_file_replacements() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
fs::write(repo.join("becomes-directory"), "Previously a tracked file").unwrap();
fs::create_dir_all(repo.join("becomes-file/nested")).unwrap();
fs::write(
repo.join("becomes-file/nested/old.txt"),
"Previously a tracked directory",
)
.unwrap();
git(&repo, &["add", "becomes-directory", "becomes-file"]);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Artifacts before type replacements",
],
);
fs::remove_file(repo.join("becomes-directory")).unwrap();
fs::create_dir_all(repo.join("becomes-directory/subdir")).unwrap();
fs::write(repo.join("becomes-directory/subdir/new.bin"), [0, 128, 255]).unwrap();
fs::remove_dir_all(repo.join("becomes-file")).unwrap();
fs::write(repo.join("becomes-file"), "Now a project document").unwrap();
let original_status = git(&repo, &["status", "--porcelain"]);
let original_index = git(&repo, &["write-tree"]);
run_cycles(root.path(), 0);
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert!(workspace.join("becomes-directory").is_dir());
assert_eq!(
fs::read(workspace.join("becomes-directory/subdir/new.bin")).unwrap(),
[0, 128, 255]
);
assert!(workspace.join("becomes-file").is_file());
assert_eq!(
fs::read_to_string(workspace.join("becomes-file")).unwrap(),
"Now a project document"
);
assert_eq!(git(&repo, &["status", "--porcelain"]), original_status);
assert_eq!(git(&repo, &["write-tree"]), original_index);
assert!(server.requests.lock().unwrap().is_empty());
}
#[test]
fn dirty_persistent_workspace_survives_a_restart_without_resetting_to_checkpoint() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let before = state(root.path());
let workspace = Path::new(before["working_workspace"].as_str().unwrap());
fs::write(
workspace.join("uncommitted.txt"),
"unfinished work from interrupted process",
)
.unwrap();
fs::remove_file(workspace.join("value.txt")).unwrap();
git(workspace, &["add", "-A"]);
let server2 = Server::custom(false, false, None, |_, _| {
(
"Continue checking the interrupted work; do not change files.".into(),
json!([]),
)
});
let config = root.path().join("chuggin.json");
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["ollama_url"] = json!(server2.url);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
let after = state(root.path());
assert_eq!(before["working_workspace"], after["working_workspace"]);
assert!(!workspace.join("value.txt").exists());
assert_eq!(
fs::read_to_string(workspace.join("uncommitted.txt")).unwrap(),
"unfinished work from interrupted process"
);
assert!(!git(workspace, &["status", "--porcelain"]).is_empty());
assert_eq!(
git(
workspace,
&[
"ls-tree",
"-r",
"--name-only",
after["working_ref"].as_str().unwrap()
]
),
"uncommitted.txt"
);
}
#[test]
fn legacy_model_restore_is_rejected_and_work_remains_recoverable() {
let restore_ref = Arc::new(Mutex::new(String::new()));
let target = restore_ref.clone();
let mut edited = false;
let server = Server::custom(false, false, None, move |_, _| {
if edited {
edited = false;
return ("Changes saved for verification.".into(), json!([]));
}
edited = true;
let reference = target.lock().unwrap().clone();
let tools = if reference.is_empty() {
json!([task_tool("Improve the value"), {"function":{"name":"write_file","arguments":{"path":"value.txt","content":"USEFUL_WORK"}}}])
} else {
json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"ABANDONED_APPROACH"}}},{"function":{"name":"restore_checkpoint","arguments":{"commit":reference,"reason":"The new approach is incorrect; retain the earlier implementation."}}}])
};
(String::new(), tools)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
*restore_ref.lock().unwrap() = state(root.path())["working_ref"].as_str().unwrap().into();
run_cycles(root.path(), 1);
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"ABANDONED_APPROACH"
);
let record: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000002/tool-0-1.json")).unwrap(),
)
.unwrap();
assert_eq!(record["ok"], false);
assert!(record["error"].as_str().unwrap().contains("user action"));
assert_eq!(
git(
workspace,
&[
"show",
&format!("{}:value.txt", restore_ref.lock().unwrap())
]
),
"USEFUL_WORK"
);
}
#[test]
fn plain_text_project_uses_its_own_validation_without_automatic_language_checks() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let config_path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&config_path).unwrap()).unwrap();
config["goal"] = json!("Update the numeric value in a plain text artifact.");
config["checks"] =
json!([{"argv":["sh","-c","test \"$(cat value.txt)\" = 1"],"timeout_seconds":5}]);
fs::write(&config_path, config.to_string()).unwrap();
let out = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
assert_eq!(
fs::read_to_string(
Path::new(state["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"1"
);
assert!(
!root
.path()
.join("state/cycle-000001/probe-outcome.json")
.exists()
);
let artifact = root.path().join("state/cycle-000001");
assert!(
!fs::read_dir(artifact)
.unwrap()
.filter_map(Result::ok)
.any(|entry| entry
.file_name()
.to_string_lossy()
.starts_with("diagnostics-"))
);
assert!(
!Path::new(state["working_workspace"].as_str().unwrap())
.join("Cargo.toml")
.exists()
);
}
#[test]
fn duration_limit_finishes_cycle_and_resets_on_resume() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["run_duration_seconds"] = json!(1);
fs::write(&path, config.to_string()).unwrap();
for cycle in [1, 2] {
let output = command(root.path())
.args(["run", "--forever"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("Run duration reached"));
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap())
.unwrap();
assert_eq!(state["cycle"], cycle);
assert_eq!(
state["recent"].as_array().unwrap().last().unwrap()["disposition"],
"checkpoint"
);
}
}
#[test]
fn timed_out_followup_retries_without_repeating_edits() {
let server = Server::serve(false, false, None, true);
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["request_timeout_seconds"] = json!(1);
fs::write(&path, config.to_string()).unwrap();
let output = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(
requests[1], requests[2],
"Retry exactly the failed follow-up, preserving completed tool results"
);
}
#[test]
fn conversation_preserves_long_tool_arguments_beyond_former_byte_limit() {
let content = format!(
"{}\nDIFF_END_MARKER\n",
"Useful project content.\n".repeat(3000)
);
let server = Server::serve_with_edit(false, false, None, false, Some(content));
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["context_tokens"] = json!(65536);
fs::write(&path, config.to_string()).unwrap();
let output = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
let requests = server.requests.lock().unwrap();
let continuation = &requests[1];
assert!(continuation["messages"].to_string().len() > 64000);
assert!(
continuation["messages"]
.to_string()
.contains("DIFF_END_MARKER")
);
assert_eq!(continuation["options"]["num_ctx"], 65536);
let actual = git(
Path::new(state["working_workspace"].as_str().unwrap()),
&[
"diff",
&format!("{}^", state["working_ref"].as_str().unwrap()),
state["working_ref"].as_str().unwrap(),
"--no-ext-diff",
"--no-textconv",
],
);
assert!(actual.len() > 64000);
assert!(actual.ends_with("+DIFF_END_MARKER"));
}
#[test]
fn streaming_repetition_recovers_without_replaying_completed_tools() {
for stage in [0, 1] {
let server = Server::serve_with_repetition(false, false, None, false, None, Some(stage));
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let output = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap())
.unwrap();
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
assert_eq!(
fs::read_to_string(
Path::new(state["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"1"
);
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 3, "Completed edits are not replayed");
let original = &requests[stage];
let retry = &requests[stage + 1];
let messages = original["messages"].as_array().unwrap();
assert_eq!(
&retry["messages"].as_array().unwrap()[..messages.len()],
messages
);
assert_eq!(retry["format"], original["format"]);
assert_eq!(retry["tools"], original["tools"]);
assert!(
retry["messages"].as_array().unwrap().last().unwrap()["content"]
.as_str()
.unwrap()
.contains("Recovery attempt 1")
);
assert!(!retry["messages"].to_string().contains("MUST_NOT_EXECUTE"));
if stage == 1 {
assert!(messages.iter().any(|m| m["role"] == "tool"));
}
if stage == 0 {
assert_eq!(
retry["messages"][messages.len()]["content"],
"The relevant file is value.txt."
);
} else {
assert_eq!(
retry["messages"].as_array().unwrap().len(),
messages.len() + 1,
"Partial structured responses and interrupted tool calls are discarded whole"
);
}
let failure: Value = serde_json::from_slice(
&fs::read(root.path().join(format!(
"state/cycle-000001/request-failure-{stage:03}.json"
)))
.unwrap(),
)
.unwrap();
assert_eq!(failure["retry_same_stage"], true);
assert!(failure["error"].as_str().unwrap().contains("Repetitive"));
}
}
#[test]
fn persistent_repetition_has_bounded_retries_and_records_failure() {
let server = Server::serve_with_repetition(false, false, None, false, None, Some(usize::MAX));
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let output = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(server.requests.lock().unwrap().len(), 9);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert_eq!(state["recent"][0]["disposition"], "unchanged");
assert_eq!(
fs::read_to_string(
Path::new(state["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"USER_EDIT",
"Imported user work remains saved even when every model response fails"
);
assert!(state["feedback"].as_str().unwrap().contains("Repetitive"));
let failure: Value = serde_json::from_slice(
&fs::read(
root.path()
.join("state/cycle-000001/request-failure-002.json"),
)
.unwrap(),
)
.unwrap();
assert_eq!(failure["retry_same_stage"], false);
}
#[test]
fn setup_revises_goal_and_reuses_global_settings() {
let server = Server::new(true, false);
let root = tempfile::tempdir().unwrap();
let input = format!(
"{}
fake
32768
4096
16
Build an editor
Include tables
accept
git rev-parse HEAD
120
",
server.url
);
let mut child = command(root.path())
.arg("setup")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"{}
{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
let config: Value =
serde_json::from_slice(&fs::read(root.path().join("chuggin.json")).unwrap()).unwrap();
assert!(config["goal"].as_str().unwrap().contains("Draft 2"));
assert!(config.get("ollama_url").is_none());
let settings = root.path().join(".chuggin/global/chuggin/settings.json");
assert!(settings.exists());
let calls = server.requests.lock().unwrap();
assert_eq!(calls.len(), 2);
assert!(calls[1].to_string().contains("Include tables"));
drop(calls);
let second_dir = tempfile::tempdir().unwrap();
let second = second_dir.path().to_path_buf();
let mut c = command(&second);
c.env("XDG_CONFIG_HOME", root.path().join(".chuggin/global"));
let mut child = c
.arg("setup")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(
b"Another editor
accept
git rev-parse HEAD
120
",
)
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!String::from_utf8_lossy(&out.stdout).contains("Ollama server ["));
}
#[cfg(unix)]
#[test]
fn soft_stop_finishes_cycle_and_force_stop_releases_lock() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut child = command(root.path())
.args(["run", "--forever"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
while server.requests.lock().unwrap().is_empty() {
thread::sleep(Duration::from_millis(10));
}
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
assert!(child.wait().unwrap().success());
assert_eq!(server.requests.lock().unwrap().len(), 2);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert_eq!(state["cycle"], 1);
let mut child = command(root.path())
.args(["run", "--forever"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
while server.requests.lock().unwrap().len() < 3 {
thread::sleep(Duration::from_millis(10));
}
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
thread::sleep(Duration::from_millis(40));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
assert_eq!(child.wait().unwrap().code(), Some(130));
let out = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn setup_offers_first_commit_and_respects_ignored_files() {
for accept in [false, true] {
let root = tempfile::tempdir().unwrap();
fs::create_dir_all(root.path().join(".chuggin/global/chuggin")).unwrap();
fs::write(
root.path().join(".chuggin/global/chuggin/settings.json"),
json!({"model":"fake","ollama_url":"http://127.0.0.1:1"}).to_string(),
)
.unwrap();
fs::write(
root.path().join(".chuggin/goal-draft.json"),
json!({"pitch":"Editor","goal":"Build an editor","feedback":""}).to_string(),
)
.unwrap();
fs::write(root.path().join("main.rs"), "fn main() {}\n").unwrap();
fs::write(root.path().join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(root.path().join("ignored.txt"), "ignored data").unwrap();
git(root.path(), &["init"]);
git(root.path(), &["add", ".chuggin/goal-draft.json"]);
let mut child = command(root.path())
.arg("setup")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let input = format!(
"accept\ngit rev-parse HEAD\n120\n{}\n",
if accept { "yes" } else { "no" }
);
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.success(),
accept,
"{}",
String::from_utf8_lossy(&out.stderr)
);
if accept {
let files = git(root.path(), &["ls-tree", "-r", "--name-only", "HEAD"]);
assert_eq!(files, ".gitignore\nmain.rs");
assert!(root.path().join("chuggin.json").exists());
} else {
assert!(
!Command::new("git")
.arg("-C")
.arg(root.path())
.args(["rev-parse", "--verify", "HEAD"])
.output()
.unwrap()
.status
.success()
);
assert!(!root.path().join("chuggin.json").exists());
assert!(root.path().join(".chuggin/goal-draft.json").exists());
}
assert_eq!(
fs::read_to_string(root.path().join("main.rs")).unwrap(),
"fn main() {}\n"
);
}
}
#[test]
fn cargo_generated_lockfile_does_not_reject_a_valid_task() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
fs::create_dir(repo.join("src")).unwrap();
fs::write(
repo.join("Cargo.toml"),
"[package]\nname = \"scope_fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(repo.join(".gitignore"), "/target\n").unwrap();
git(&repo, &["add", "Cargo.toml", "src/main.rs", ".gitignore"]);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Rust fixture",
],
);
let mut cfg: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
cfg["checks"] = json!([{"argv":[env!("CARGO"),"test"],"timeout_seconds":30}]);
fs::write(&config, cfg.to_string()).unwrap();
let out = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let outcome: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/outcome.json")).unwrap(),
)
.unwrap();
assert_eq!(outcome["disposition"], "checkpoint/unverified", "{outcome}");
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap()).unwrap();
assert!(
Path::new(state["working_workspace"].as_str().unwrap())
.join("Cargo.lock")
.exists()
);
}
#[cfg(unix)]
mod terminal_ui {
use super::*;
use std::os::{
fd::{AsRawFd, FromRawFd},
unix::process::CommandExt,
};
struct TerminalProcess {
master: fs::File,
slave: fs::File,
child: std::process::Child,
parser: vt100::Parser,
original: libc::tcflag_t,
}
impl TerminalProcess {
fn start(root: &Path) -> Self {
let (mut master, mut slave) = (0, 0);
let size = libc::winsize {
ws_row: 36,
ws_col: 110,
ws_xpixel: 0,
ws_ypixel: 0,
};
assert_eq!(
unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null(),
&size,
)
},
0
);
let master = unsafe { fs::File::from_raw_fd(master) };
let slave = unsafe { fs::File::from_raw_fd(slave) };
let mut term = std::mem::MaybeUninit::<libc::termios>::uninit();
assert_eq!(
unsafe { libc::tcgetattr(slave.as_raw_fd(), term.as_mut_ptr()) },
0
);
let original = unsafe { term.assume_init() }.c_lflag;
let mut cmd = command(root);
cmd.env("TERM", "xterm-256color")
.stdin(slave.try_clone().unwrap())
.stdout(slave.try_clone().unwrap())
.stderr(slave.try_clone().unwrap());
unsafe {
cmd.pre_exec(|| {
if libc::setsid() < 0 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn().unwrap();
unsafe {
libc::fcntl(master.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK);
}
Self {
master,
slave,
child,
parser: vt100::Parser::new(36, 110, 0),
original,
}
}
fn read(&mut self) {
let mut bytes = [0; 65536];
while let Ok(n) = self.master.read(&mut bytes) {
if n == 0 {
break;
}
self.parser.process(&bytes[..n]);
}
}
fn wait(&mut self, text: &str) {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
self.read();
if self.parser.screen().contents().contains(text) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"Missing {text:?}:\n{}",
self.parser.screen().contents()
);
thread::sleep(Duration::from_millis(30));
}
}
fn wait_until(&mut self, description: &str, ready: impl Fn() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
self.read();
if ready() {
return;
}
assert!(
std::time::Instant::now() < deadline,
"Waiting for {description}:\n{}",
self.parser.screen().contents()
);
thread::sleep(Duration::from_millis(30));
}
}
fn send(&mut self, keys: &[u8]) {
self.master.write_all(keys).unwrap();
}
fn restored(&mut self) {
self.exited(0);
}
fn resize(&mut self, rows: u16, cols: u16) {
let size = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
assert_eq!(
unsafe { libc::ioctl(self.slave.as_raw_fd(), libc::TIOCSWINSZ, &size) },
0
);
self.parser.screen_mut().set_size(rows, cols);
unsafe {
libc::kill(self.child.id() as i32, libc::SIGWINCH);
}
thread::sleep(Duration::from_millis(250));
self.read();
}
fn exited(&mut self, code: i32) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
self.read();
if let Some(status) = self.child.try_wait().unwrap() {
assert_eq!(status.code(), Some(code));
break;
}
assert!(std::time::Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
let mut term = std::mem::MaybeUninit::<libc::termios>::uninit();
unsafe {
libc::tcgetattr(self.slave.as_raw_fd(), term.as_mut_ptr());
}
assert_eq!(
unsafe { term.assume_init() }.c_lflag & (libc::ECHO | libc::ICANON),
self.original & (libc::ECHO | libc::ICANON)
);
}
}
impl Drop for TerminalProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn resume_prompts_for_missing_git_identity_before_starting_the_worker() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
fs::write(root.path().join(".chuggin/test-gitconfig"), "").unwrap();
ui.send(b"\r");
ui.wait("Git author name");
assert!(server.requests.lock().unwrap().is_empty());
assert!(!root.path().join("state").exists());
ui.send(b"Benchmark Operator\r");
ui.wait("Git author email");
assert!(server.requests.lock().unwrap().is_empty());
ui.send(b"benchmark@example.com\r");
ui.wait("Overall cycle: #1");
ui.send(b"\x03");
ui.wait("Run saved");
ui.send(b"\r");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
let repo = root.path().join("repo");
assert_eq!(
git(&repo, &["config", "--local", "user.name"]),
"Benchmark Operator"
);
assert_eq!(
git(&repo, &["config", "--local", "user.email"]),
"benchmark@example.com"
);
let saved = state(root.path());
assert_eq!(
git(
&repo,
&[
"show",
"-s",
"--format=%an <%ae>",
saved["working_ref"].as_str().unwrap()
]
),
"Benchmark Operator <benchmark@example.com>"
);
assert!(!server.requests.lock().unwrap().is_empty());
}
#[test]
fn full_screen_run_streams_soft_stops_and_restores_the_terminal() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait("LIVE");
ui.wait("Overall cycle: #1");
ui.send(b"?");
ui.wait("Keyboard guide");
ui.send(b"?");
ui.wait("following live");
ui.send(b"\x1b[5~");
ui.wait("scrollback");
ui.send(b"f");
ui.wait("following live");
ui.send(b"\x03");
ui.wait("Finishing this cycle");
ui.wait("Run saved");
ui.resize(26, 80);
assert!(!ui.parser.screen().contents().contains("Run health"));
ui.wait("Run saved");
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap())
.unwrap();
assert_eq!(state["cycle"], 1);
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
ui.send(b"r");
ui.wait("Overall cycle: #2");
ui.send(b"\x03");
ui.wait("Finishing this cycle");
ui.send(b"r");
ui.wait("Stop cancelled");
ui.wait("Overall cycle: #3");
ui.send(b"\x03");
ui.wait("Run saved");
let resumed: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap())
.unwrap();
assert_eq!(resumed["cycle"], 3);
ui.send(b"\r");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
}
#[test]
fn pause_finishes_the_response_then_resumes_its_tools_in_the_same_cycle() {
let release_response = Arc::new(AtomicBool::new(false));
let release = release_response.clone();
let server = Server::custom(false, false, None, move |_, n| {
if n == 0 {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !release.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
(
"The edit is ready.".into(),
json!([
task_tool("Pause in the middle of a cycle"),
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"APPLIED_ONCE"}}}
]),
)
} else {
thread::sleep(Duration::from_millis(800));
("Ready to checkpoint.".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait_until("first model request", || {
server.requests.lock().unwrap().len() == 1
});
ui.send(b"p");
ui.wait("Pause requested");
release_response.store(true, Ordering::SeqCst);
ui.wait("CYCLE PAUSED");
let paused = state(root.path());
assert_eq!(paused["cycle"], 1);
let workspace = Path::new(paused["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"USER_EDIT"
);
assert!(!root.path().join("state/cycle-000001/outcome.json").exists());
let conversation: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert!(
conversation["messages"]
.as_array()
.unwrap()
.iter()
.any(|message| {
message["role"] == "assistant" && message["content"] == "The edit is ready."
}),
"The complete response must be retained before pausing"
);
thread::sleep(Duration::from_millis(500));
ui.read();
assert_eq!(server.requests.lock().unwrap().len(), 1);
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"USER_EDIT"
);
ui.send(b"p");
ui.wait_until("continuation request in the same cycle", || {
server.requests.lock().unwrap().len() == 2
});
ui.send(b"\x03");
ui.wait("Run saved");
let saved = state(root.path());
assert_eq!(saved["cycle"], 1);
assert_eq!(server.requests.lock().unwrap().len(), 2);
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"APPLIED_ONCE"
);
let conversation: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(
conversation["messages"]
.as_array()
.unwrap()
.iter()
.filter(|message| message["role"] == "tool" && message["tool_name"] == "write_file")
.count(),
1,
"Resume must not replay completed edits"
);
ui.send(b"q");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
}
#[test]
fn retry_now_bypasses_saved_cooldown_once_and_waits_for_resume_when_paused() {
let server = Server::custom(true, false, None, |_, n| {
if n == 0 {
("__HTTP_LONG_LIMIT__".into(), json!([]))
} else {
("Provider recovered.".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let wait_path = root.path().join("state/provider-wait.json");
fs::create_dir_all(wait_path.parent().unwrap()).unwrap();
let until = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 300;
fs::write(
&wait_path,
json!({
"reason":"Saved provider cooldown", "model":"fake",
"url":format!("{}/api/chat", server.url),
"attempt":7, "until":until, "delay_seconds":300
})
.to_string(),
)
.unwrap();
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait("Waiting for provider");
assert!(server.requests.lock().unwrap().is_empty());
ui.send(b"t");
ui.wait_until("one immediate retry and its new cooldown", || {
fs::read(&wait_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.is_some_and(|record| record["attempt"] == 8)
});
ui.wait("Waiting for provider");
assert_eq!(server.requests.lock().unwrap().len(), 1);
thread::sleep(Duration::from_millis(600));
ui.read();
assert_eq!(
server.requests.lock().unwrap().len(),
1,
"A failed manual retry must return to backoff, not hammer the provider"
);
ui.send(b"p");
ui.wait("CYCLE PAUSED");
ui.send(b"t");
ui.wait("Retry queued");
thread::sleep(Duration::from_millis(500));
ui.read();
assert_eq!(
server.requests.lock().unwrap().len(),
1,
"Retry now must not send requests while paused"
);
ui.send(b"p");
ui.wait_until("queued retry after resuming", || {
server.requests.lock().unwrap().len() == 2
});
ui.send(b"\x03");
ui.wait("Run saved");
let calls = server.requests.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(
calls[0], calls[1],
"Retry must preserve the exact conversation"
);
drop(calls);
assert_eq!(state(root.path())["cycle"], 1);
assert!(!wait_path.exists());
ui.send(b"q");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
}
#[test]
fn migration_menu_previews_before_applying_and_resumes_in_the_visible_folder() {
for mode in 0..3 {
let conflict = mode != 0;
let server = Server::custom(true, false, None, move |body, _| {
if body["messages"][0]["content"]
.as_str()
.unwrap_or("")
.starts_with("Help a user upgrade")
{
if mode == 2 {
return ("__HTTP_LIMIT__".into(), json!([]));
}
let read = body["messages"]
.as_array()
.unwrap()
.iter()
.any(|m| m["tool_name"] == "read_version");
return if read {
(
String::new(),
json!([{"function":{"name":"recommend_version","arguments":{"choice":"chuggin","reason":"Keep the developing version; the original is retained in the backup."}}}]),
)
} else {
(
String::new(),
json!([{"function":{"name":"read_version","arguments":{"version":"original"}}},{"function":{"name":"read_version","arguments":{"version":"chuggin"}}}]),
)
};
}
("Inspect the migrated project.".into(), json!([]))
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
git(&repo, &["restore", "value.txt"]);
let dir = root.path().join("state");
fs::create_dir_all(&dir).unwrap();
let legacy = dir.join("working");
git(
&repo,
&[
"worktree",
"add",
"-b",
"legacy-menu",
legacy.to_str().unwrap(),
"HEAD",
],
);
fs::write(legacy.join("value.txt"), "VISIBLE DEVELOPMENT").unwrap();
fs::write(dir.join("state.json"),json!({"schema_version":3,"run_id":"menu-migration","goal":"Improve value incrementally","repo":repo,"cycle":7,"working_ref":git(&repo,&["rev-parse","HEAD"]),"working_branch":"legacy-menu","working_workspace":legacy}).to_string()).unwrap();
if conflict {
fs::write(repo.join("value.txt"), "MY ORIGINAL EDIT").unwrap();
git(&repo, &["add", "value.txt"]);
}
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait("Update project folder");
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
if conflict { "MY ORIGINAL EDIT" } else { "0" }
);
assert!(server.requests.lock().unwrap().is_empty());
if conflict {
ui.wait("1 file needs review before continuing");
assert!(
!ui.parser
.screen()
.contents()
.contains("Move files and resume")
);
ui.send(b"\r");
ui.wait("Choose a file version");
ui.wait("Keep Chuggin's version (recommended)");
ui.send(b"\x1b[B\x1b[B\r");
ui.wait("Compare file versions");
ui.wait("MY ORIGINAL EDIT");
ui.wait("VISIBLE DEVELOPMENT");
ui.send(b"\r");
ui.wait("Choose a file version");
ui.send(b"\x1b[B\r");
if mode == 2 {
ui.wait("AI review unavailable");
assert_eq!(
server.requests.lock().unwrap().len(),
1,
"Review must return a provider failure without indefinite retries"
);
ui.send(b"\r");
ui.wait("Choose a file version");
} else {
ui.wait("AI recommendation");
ui.wait("Keep Chuggin's version (AI recommended)");
}
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"MY ORIGINAL EDIT"
);
assert_eq!(state(root.path())["schema_version"], 3);
ui.send(b"\r");
ui.wait("Move files and resume");
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"MY ORIGINAL EDIT"
);
ui.send(b"\x1b");
ui.wait("Resume project");
assert_eq!(state(root.path())["schema_version"], 3);
ui.send(b"\r");
ui.wait("Move files and resume");
}
ui.send(b"\r");
if conflict {
ui.wait("Keep an old commit selection?");
ui.wait("does NOT delete");
ui.send(b"\r");
}
ui.wait_until("model sees migrated files", || {
server.requests.lock().unwrap().len()
> match mode {
0 => 0,
1 => 2,
_ => 1,
}
});
ui.send(b"\x03");
ui.wait("Run saved");
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"VISIBLE DEVELOPMENT"
);
assert_eq!(
state(root.path())["working_workspace"],
repo.to_str().unwrap()
);
ui.send(b"4");
ui.wait("WORKING FOLDER");
ui.wait("Normal commit:");
ui.wait("Recovery save:");
ui.send(b"q");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
}
}
#[test]
fn home_model_selection_updates_project_override_only() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["model"] = json!("old-project-model");
fs::write(&path, config.to_string()).unwrap();
let shared = root.path().join(".chuggin/global/chuggin/settings.json");
fs::create_dir_all(shared.parent().unwrap()).unwrap();
let defaults = r#"{"model":"shared-model","ollama_url":"http://127.0.0.1:1"}"#;
fs::write(&shared, defaults).unwrap();
let mut ui = TerminalProcess::start(root.path());
ui.wait("old-project-model");
ui.send(b"jjj\r");
ui.wait("Choose model for this project");
ui.send(b"\r");
ui.wait("Resume project");
ui.wait("fake");
ui.send(b"q");
ui.restored();
config["model"] = json!("fake");
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(saved, config);
assert_eq!(fs::read_to_string(shared).unwrap(), defaults);
}
#[test]
fn full_screen_settings_and_unicode_input_do_not_modify_project() {
let root = tempfile::tempdir().unwrap();
let mut ui = TerminalProcess::start(root.path());
ui.wait("Set up this project");
ui.send(b"jjjj\r");
ui.wait("Shared settings");
ui.send(b"\r");
ui.wait("Ctrl+U clear");
ui.send("\x15http://localhost:11434".as_bytes());
ui.send(b"\r");
ui.wait("Shared settings");
ui.send(b"\x1b");
ui.wait("Set up this project");
ui.send(b"q");
ui.restored();
let saved: Value = serde_json::from_slice(
&fs::read(root.path().join(".chuggin/global/chuggin/settings.json")).unwrap(),
)
.unwrap();
assert_eq!(saved["ollama_url"], "http://localhost:11434");
assert!(!root.path().join("chuggin.json").exists());
}
#[test]
fn observation_settings_apply_without_interrupting_active_requests() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait("Receiving response");
ui.send(b"5");
ui.wait("PROJECT SETTINGS");
ui.send(b"\r\x15fake-next\r");
ui.send(b"\x1b[B\r\x150\r");
ui.send(b"\x1b[B\r\x150.0003\r");
ui.wait("Time limit reached");
ui.wait("Run saved");
let config: Value =
serde_json::from_slice(&fs::read(root.path().join("chuggin.json")).unwrap()).unwrap();
assert_eq!(config["model"], "fake-next");
assert_eq!(config["request_timeout_seconds"], 0);
assert_eq!(config["run_duration_seconds"], 1);
let state: Value =
serde_json::from_slice(&fs::read(root.path().join("state/state.json")).unwrap())
.unwrap();
assert_eq!(state["cycle"], 1);
assert_eq!(state["recent"][0]["disposition"], "checkpoint");
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0]["model"], "fake");
assert!(requests[1..].iter().all(|r| r["model"] == "fake-next"));
drop(requests);
ui.send(b"q");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
}
#[test]
fn brave_key_entry_is_masked_and_stored_separately() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let mut ui = TerminalProcess::start(root.path());
ui.wait("Set up this project");
ui.send(b"jjjj\r");
ui.wait("Shared settings");
ui.send(b"jjjjjjj\r");
ui.wait("Enter or replace key");
ui.send(b"\r");
ui.wait("input is masked");
ui.send(b"fake-brave-secret");
ui.wait("••••");
assert!(!ui.parser.screen().contents().contains("fake-brave-secret"));
ui.send(b"\r");
ui.wait("Configured");
ui.send(b"\x1b");
ui.wait("Set up this project");
ui.send(b"q");
ui.restored();
let global = root.path().join(".chuggin/global/chuggin");
assert_eq!(
fs::read_to_string(global.join("brave.key")).unwrap(),
"fake-brave-secret"
);
assert_eq!(
fs::metadata(global.join("brave.key"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
let text = fs::read_to_string(global.join("settings.json")).unwrap();
assert!(!text.contains("fake-brave-secret"));
assert_eq!(
serde_json::from_str::<Value>(&text).unwrap()["web_enabled"],
true
);
}
#[test]
fn force_stop_restores_terminal_and_releases_the_run_lock() {
let server = Server::new(false, true);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let mut ui = TerminalProcess::start(root.path());
ui.wait("Resume project");
ui.send(b"\r");
ui.wait("LIVE");
ui.send(b"\x03");
ui.wait("Finishing this cycle");
ui.send(b"\x03");
ui.exited(130);
let file = fs::OpenOptions::new()
.write(true)
.open(root.path().join("state/run.lock"))
.unwrap();
assert_eq!(
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
0
);
}
#[test]
fn goal_setup_uses_full_screen_drafting_and_persists_the_result() {
let server = Server::new(true, true);
let root = tempfile::tempdir().unwrap();
let settings = root.path().join(".chuggin/global/chuggin/settings.json");
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
settings,
json!({"ollama_url":server.url,"model":"fake"}).to_string(),
)
.unwrap();
let mut ui = TerminalProcess::start(root.path());
ui.wait("Set up this project");
ui.send(b"\r");
ui.wait("Describe what you want to build");
ui.send("An éditeur in Rust".as_bytes());
ui.send(b"\r");
ui.wait("Review your goal");
ui.wait("Build a useful editor. Draft 1.");
ui.send(b"\r");
ui.wait("Validation command");
ui.send(b"git rev-parse HEAD\r");
ui.wait("First command review (seconds)");
ui.send(b"\r");
ui.wait("Resume project");
ui.send(b"q");
ui.restored();
let config: Value =
serde_json::from_slice(&fs::read(root.path().join("chuggin.json")).unwrap()).unwrap();
assert_eq!(config["goal"], "Build a useful editor. Draft 1.");
}
}
fn dev_tool_reply(body: &Value, mode: u8) -> (String, Value) {
let messages = body["messages"].as_array().unwrap();
if messages
.iter()
.any(|m| m["tool_name"] == "read_command_log")
{
return ("Done".into(), json!([]));
}
if messages.iter().any(|m| m["tool_name"] == "run_command") {
return (
String::new(),
json!([{"function":{"name":"read_command_log","arguments":{"log_id":"command-0-2.log"}}}]),
);
}
let script = "cat > src/lib.rs <<'RS'\npub fn value() -> u8 { 2 }\n#[test] fn baseline() { assert!(value() > 0); }\n#[test] fn returns_two() { assert_eq!(value(), 2); }\nRS\necho COMMAND_FINISHED";
let script = if mode == 4 {
script.replace("assert_eq!(value(), 2)", "assert_eq!(value(), 3)")
} else {
script.into()
};
(
String::new(),
json!([
{"function":{"name":"lookup_symbol","arguments":{"symbol":"value"}}},
{"function":{"name":"compiler_diagnostics","arguments":{}}},
{"function":{"name":"run_command","arguments":{"argv":["sh","-c",script]}}}
]),
)
}
#[test]
fn execution_diagnostics_and_lookup_work_in_the_real_pipeline() {
exercise_project_tools(false);
}
#[test]
fn successful_command_does_not_hide_failing_checks_or_discard_work() {
exercise_project_tools(true);
}
fn exercise_project_tools(failing: bool) {
let server = Server::serve(false, false, Some(if failing { 4 } else { 3 }), false);
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
fs::create_dir(repo.join("src")).unwrap();
fs::write(
repo.join("Cargo.toml"),
"[package]\nname = \"tools_demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(
repo.join("src/lib.rs"),
"pub fn value() -> u8 { 1 }\n#[test] fn baseline() { assert!(value() > 0); }\n",
)
.unwrap();
fs::write(repo.join(".gitignore"), "/target\n").unwrap();
git(&repo, &["add", "Cargo.toml", "src", ".gitignore"]);
git(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Rust fixture",
],
);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["checks"] = json!([{"argv":[env!("CARGO"),"test","--offline"],"timeout_seconds":30}]);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(
saved["recent"][0]["disposition"],
if failing {
"checkpoint/checks-failing"
} else {
"checkpoint"
}
);
assert!(
fs::read_to_string(
Path::new(saved["working_workspace"].as_str().unwrap()).join("src/lib.rs")
)
.unwrap()
.contains("{ 2 }")
);
assert!(
fs::read_to_string(repo.join("src/lib.rs"))
.unwrap()
.contains("{ 2 }")
);
let artifact = root.path().join("state/cycle-000001");
for name in [
"command-0-2.log",
"diagnostics-0-1.log",
"tool-1-0.json",
"verification.json",
] {
assert!(artifact.join(name).exists(), "Missing {name}");
}
let log: Value =
serde_json::from_slice(&fs::read(artifact.join("tool-1-0.json")).unwrap()).unwrap();
assert_eq!(log["ok"], true);
assert!(
log["result"]["text"]
.as_str()
.unwrap()
.contains("COMMAND_FINISHED")
);
assert!(
!artifact.join("probe-outcome.json").exists(),
"Rust probes should not run automatically"
);
}
#[test]
fn provider_rate_limit_retries_identical_conversation_without_replaying_edits() {
let server = Server::custom(false, false, None, |_, n| match n {
0 => (
String::new(),
json!([task_tool("Provider recovery"),{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"KEPT"}}}]),
),
1 => ("__HTTP_LIMIT__".into(), json!([])),
_ => ("Recovered".into(), json!([])),
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let calls = server.requests.lock().unwrap();
assert_eq!(calls.len(), 3);
assert_eq!(calls[1], calls[2]);
let saved = state(root.path());
assert_eq!(
git(
Path::new(saved["working_workspace"].as_str().unwrap()),
&[
"show",
&format!("{}:value.txt", saved["working_ref"].as_str().unwrap())
]
),
"KEPT"
);
assert!(!root.path().join("state/provider-wait.json").exists());
assert!(
root.path()
.join("state/cycle-000001/provider-error-001.json")
.exists()
);
let session: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(session["response_errors"], 0);
}
#[test]
fn provider_credits_keep_waiting_until_operator_timer_and_checkpoint_edits() {
let server = Server::custom(false, false, None, |_, n| {
if n == 0 {
(
String::new(),
json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"SAVED_BEFORE_QUOTA"}}}]),
)
} else {
("__CREDIT_LIMIT__".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
let config_path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&config_path).unwrap()).unwrap();
config["run_duration_seconds"] = json!(1);
fs::write(&config_path, config.to_string()).unwrap();
run_cycles(root.path(), 10);
assert_eq!(server.requests.lock().unwrap().len(), 2);
let saved = state(root.path());
assert_eq!(saved["cycle"], 1);
assert_eq!(
git(
Path::new(saved["working_workspace"].as_str().unwrap()),
&[
"show",
&format!("{}:value.txt", saved["working_ref"].as_str().unwrap())
]
),
"SAVED_BEFORE_QUOTA"
);
assert!(
saved["feedback"]
.as_str()
.unwrap()
.contains("Run timer reached")
);
assert!(root.path().join("state/provider-wait.json").exists());
}
#[test]
fn provider_stream_limit_timer_and_restart_preserve_cooldown() {
let server = Server::custom(false, false, None, |_, _| {
("__STREAM_LIMIT__".into(), json!([]))
});
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["run_duration_seconds"] = json!(1);
fs::write(&path, config.to_string()).unwrap();
let start = std::time::Instant::now();
run_cycles(root.path(), 10);
assert!(start.elapsed() < Duration::from_secs(5));
let wait = fs::read(root.path().join("state/provider-wait.json")).unwrap();
run_cycles(root.path(), 10);
assert_eq!(server.requests.lock().unwrap().len(), 1);
assert_eq!(
wait,
fs::read(root.path().join("state/provider-wait.json")).unwrap()
);
let session: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(session["response_errors"], 0);
assert!(
!fs::read_dir(root.path().join("state/cycle-000001"))
.unwrap()
.flatten()
.any(|e| e
.file_name()
.to_string_lossy()
.starts_with("conversation-before-refresh"))
);
}
#[test]
fn provider_wait_allows_soft_stop_and_model_change() {
let server = Server::custom(false, false, None, |body, _| {
if body["model"] == "fake" {
("__STREAM_LIMIT__".into(), json!([]))
} else {
("Recovered on selected model".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut child = command(root.path())
.args(["run", "--cycles", "1"])
.stdout(Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !root.path().join("state/provider-wait.json").exists() {
assert!(std::time::Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
while child.try_wait().unwrap().is_none() {
assert!(std::time::Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
assert_eq!(server.requests.lock().unwrap().len(), 1);
let mut child = command(root.path())
.args(["run", "--cycles", "1"])
.stdout(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(300));
assert_eq!(server.requests.lock().unwrap().len(), 1);
let mut config: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
config["model"] = json!("other");
let temp = path.with_extension("tmp");
fs::write(&temp, config.to_string()).unwrap();
fs::rename(temp, &path).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success());
break;
}
assert!(std::time::Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
assert_eq!(server.requests.lock().unwrap().len(), 2);
assert!(!root.path().join("state/provider-wait.json").exists());
}
#[test]
fn provider_credits_automatically_resume_after_repeated_exhaustion() {
let server = Server::custom(false, false, None, |_, n| {
if n < 2 {
("__CREDIT_RETRY__".into(), json!([]))
} else {
("Credits available; resumed".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let calls = server.requests.lock().unwrap();
assert_eq!(calls.len(), 3);
assert_eq!(calls[0], calls[1]);
assert_eq!(calls[1], calls[2]);
assert!(!root.path().join("state/provider-wait.json").exists());
let session: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(session["response_errors"], 0);
}
#[test]
fn duplicate_completion_after_restart_selects_new_work() {
let server = Server::custom(false, false, None, |_, n| {
let finish =
json!({"function":{"name":"finish_task","arguments":{"summary":"Research complete"}}});
(
String::new(),
match n {
0 => json!([task_tool("Inspect existing work"), finish]),
1..=4 => json!([finish]),
_ => {
json!([task_tool("New work"),{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"new"}}},finish])
}
},
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
fs::write(root.path().join("repo/value.txt"), "0").unwrap();
run_cycles(root.path(), 1);
let first = state(root.path());
assert!(first["current_task"].is_null());
assert_eq!(first["completed_tasks"].as_array().unwrap().len(), 1);
assert_eq!(first["recent"][0]["disposition"], "unchanged");
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(saved["completed_tasks"].as_array().unwrap().len(), 2);
assert_eq!(saved["completed_tasks"][1]["title"], "New work");
assert_eq!(saved["task_serial"], 2);
assert_eq!(server.requests.lock().unwrap().len(), 6);
assert!(
root.path()
.join("state/cycle-000002/action-recovery-1.json")
.exists()
);
assert_eq!(
fs::read_to_string(
Path::new(saved["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"new"
);
}
#[test]
fn repeated_tool_recovery_archives_loop_and_preserves_work() {
let server = Server::custom(false, false, None, |body, n| {
if [9, 17, 25].contains(&n) {
let text = body["messages"].to_string();
assert!(text.contains("repetitive history was archived"));
assert!(body["messages"].as_array().unwrap().len() < 10);
assert!(!text.contains("PRIVATE_REPEATED_READ"));
}
(
String::new(),
match n {
0 => {
json!([task_tool("Investigate"),{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"PRIVATE_REPEATED_READ"}}}])
}
1..=24 => {
json!([{"function":{"name":"read_file","arguments":{"path":"value.txt"}}}])
}
_ => {
json!([{"function":{"name":"finish_task","arguments":{"summary":"Investigation completed; existing work retained","task_id":1}}}])
}
},
)
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(32);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(server.requests.lock().unwrap().len(), 26);
assert_eq!(
fs::read_to_string(
Path::new(saved["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"PRIVATE_REPEATED_READ"
);
assert!(
root.path()
.join("state/cycle-000001/action-recovery-6.json")
.exists()
);
assert_eq!(
fs::read_dir(root.path().join("state/cycle-000001"))
.unwrap()
.flatten()
.filter(|e| e
.file_name()
.to_string_lossy()
.starts_with("conversation-before-refresh-"))
.count(),
3
);
}
#[test]
fn task_completion_is_bound_to_current_task_in_multi_tool_replies() {
let server = Server::custom(false, false, None, |_, n| {
(
String::new(),
match n {
0 => {
json!([task_tool("Old task"),{"function":{"name":"finish_task","arguments":{"summary":"old done","task_id":1}}},task_tool("Replacement task")])
}
1 => {
json!([{"function":{"name":"finish_task","arguments":{"summary":"stale completion","task_id":1}}}])
}
_ => {
json!([{"function":{"name":"finish_task","arguments":{"summary":"replacement done","task_id":2}}}])
}
},
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(server.requests.lock().unwrap().len(), 1);
assert_eq!(saved["completed_tasks"][0]["title"], "Old task");
let deferred: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/tool-0-2.json")).unwrap(),
)
.unwrap();
assert_eq!(deferred["ok"], false);
assert!(
deferred["error"]
.as_str()
.unwrap()
.contains("awaiting validation")
);
}
#[test]
fn complete_progress_notes_survive_restart_and_are_retrievable() {
let note = "Quote: \"界\"\n".repeat(2000);
let expected = note.clone();
let server = Server::custom(false, false, None, move |_, n| {
(
String::new(),
match n {
0 => json!([{"function":{"name":"save_progress_note","arguments":{"note":note}}}]),
1 => json!([]),
2 => json!([{"function":{"name":"read_progress_note","arguments":{"offset":0}}}]),
_ => json!([]),
},
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
run_cycles(root.path(), 1);
let conversation: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(conversation["note"]["model_note"], expected.trim());
let result: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000002/tool-0-0.json")).unwrap(),
)
.unwrap();
let page = &result["result"];
assert!(page["next_offset"].as_u64().unwrap() > 0);
assert!(expected.starts_with(page["text"].as_str().unwrap()));
}
#[test]
fn schema_two_upgrade_preserves_active_task_and_original_state() {
let server = Server::custom(false, false, None, |_, n| {
(
String::new(),
match n {
0 => json!([task_tool("Existing research")]),
1 => json!([]),
_ => {
json!([{"function":{"name":"finish_task","arguments":{"summary":"Research supported by checks","task_id":1}}}])
}
},
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let mut old = state(root.path());
let before_ref = old["working_ref"].clone();
let before_workspace = old["working_workspace"].clone();
let legacy_workspace = root.path().join("state/working");
git(
&root.path().join("repo"),
&[
"worktree",
"add",
"-b",
"legacy-test",
legacy_workspace.to_str().unwrap(),
old["working_ref"].as_str().unwrap(),
],
);
old["working_workspace"] = json!(legacy_workspace);
old["working_branch"] = json!("legacy-test");
old["schema_version"] = json!(2);
old.as_object_mut().unwrap().remove("task_serial");
old.as_object_mut().unwrap().remove("completed_tasks");
let bytes = serde_json::to_vec(&old).unwrap();
fs::write(root.path().join("state/state.json"), &bytes).unwrap();
migrate_fixture(root.path());
run_cycles(root.path(), 1);
let upgraded = state(root.path());
assert_eq!(upgraded["schema_version"], 4);
assert_eq!(
git(
&root.path().join("repo"),
&[
"rev-parse",
&format!("{}^{{tree}}", upgraded["working_ref"].as_str().unwrap())
]
),
git(
&root.path().join("repo"),
&[
"rev-parse",
&format!("{}^{{tree}}", before_ref.as_str().unwrap())
]
)
);
assert_eq!(upgraded["working_workspace"], before_workspace);
assert_eq!(upgraded["completed_tasks"][0]["title"], "Existing research");
assert_eq!(upgraded["completed_tasks"][0]["id"], 1);
assert_eq!(
fs::read(root.path().join("state/state-before-v3.json")).unwrap(),
bytes
);
}
fn diagnostic_reply(verdict: &str) -> Value {
json!([{"function":{"name":"report_diagnosis","arguments":{
"verdict":verdict,"reason":"Inspected the current task and recorded command evidence.",
"next_action":"Inspect the remaining task criteria and update the project only if needed.",
"expected_new_evidence":"Whether the current artifact satisfies the outstanding requirement."
}}}])
}
fn is_diagnostic(body: &Value) -> bool {
body["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t["function"]["name"] == "report_diagnosis")
}
#[test]
fn command_stall_survives_restart_and_recovers_with_read_only_diagnosis() {
let mut work = 0;
let mut diagnostics = 0;
let output = "stable evidence\n".repeat(180);
let server = Server::custom(false, false, None, move |body, _| {
if is_diagnostic(body) {
assert_eq!(body["options"]["num_predict"], 2048);
assert!(
!body["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t["function"]["name"] == "run_command")
);
diagnostics += 1;
if diagnostics == 1 {
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
assert!(
!body["messages"]
.to_string()
.contains("OUTDATED_OTHER_TASK_NOTE")
);
return (
String::new(),
json!([
{"function":{"name":"read_file","arguments":{"path":"value.txt"}}},
{"function":{"name":"run_command","arguments":{"argv":["touch","diagnostic-must-not-execute"]}}}
]),
);
}
assert!(
body["messages"]
.to_string()
.contains("no action was executed")
);
if diagnostics < 6 {
return (
String::new(),
json!([{"function":{"name":"read_file","arguments":{"path":"value.txt"}}}]),
);
}
assert_eq!(body["tools"].as_array().unwrap().len(), 1);
return (String::new(), diagnostic_reply("stalled"));
}
assert_eq!(body["options"]["num_predict"], 4096);
let n = work;
work += 1;
if n == 16 {
assert!(body["messages"].to_string().contains("command_diagnostic"));
assert!(body["messages"].as_array().unwrap().len() < 10);
return (
String::new(),
json!([
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"refined"}}},
{"function":{"name":"finish_task","arguments":{"summary":"Refined after assessment","task_id":1}}}
]),
);
}
let mut calls = vec![];
if n == 0 {
calls.push(json!({"function":{"name":"save_progress_note","arguments":{"note":"OUTDATED_OTHER_TASK_NOTE"}}}));
calls.push(task_tool("Inspect and refine"));
}
calls.push(
json!({"function":{"name":"run_command","arguments":{"argv":["printf","%s",output]}}}),
);
(String::new(), json!(calls))
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(8);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
run_cycles(root.path(), 1);
let session: Value =
serde_json::from_slice(&fs::read(root.path().join("state/conversation.json")).unwrap())
.unwrap();
assert_eq!(session["command_watch"]["count"], 16);
assert_eq!(session["command_watch"]["pending_diagnosis"], true);
run_cycles(root.path(), 1);
let s = state(root.path());
let workspace = Path::new(s["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"refined"
);
assert!(!workspace.join("diagnostic-must-not-execute").exists());
assert_eq!(s["completed_tasks"][0]["id"], 1);
let art = root.path().join("state/cycle-000003");
assert!(art.join("command-diagnostic-1-report.json").exists());
assert!(fs::read_dir(art).unwrap().flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("conversation-before-refresh")
}));
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 23);
assert!(
requests[3]["messages"]
.to_string()
.contains("output_compacted")
);
let full: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/tool-1-0.json")).unwrap(),
)
.unwrap();
assert!(full["result"]["output_tail"].as_str().unwrap().len() > 1200);
}
#[test]
fn legitimate_repeated_commands_keep_the_conversation_and_continue() {
let mut work = 0;
let server = Server::custom(false, false, None, move |body, _| {
if is_diagnostic(body) {
return (String::new(), diagnostic_reply("productive"));
}
let n = work;
work += 1;
if n == 24 {
assert!(
body["messages"]
.to_string()
.contains("PURPOSEFUL_REPETITION")
);
return (
String::new(),
json!([{"function":{"name":"finish_task","arguments":{"summary":"Collected all observations"}}}]),
);
}
let mut calls = vec![];
if n == 0 {
calls.push(task_tool("Repeat observation 24 times"));
}
calls.push(json!({"function":{"name":"run_command","arguments":{"argv":["printf","observation"],"reason":"Collect the requested repeated observations"}}}));
(
if n == 0 {
"PURPOSEFUL_REPETITION".into()
} else {
String::new()
},
json!(calls),
)
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(32);
settings["goal"] = json!("Repeat the observation 24 times and record the outcome.");
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
assert!(state(root.path())["current_task"].is_null());
assert_eq!(server.requests.lock().unwrap().len(), 26);
let art = root.path().join("state/cycle-000001");
assert!(!fs::read_dir(&art).unwrap().flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("conversation-before-refresh")
}));
assert!(art.join("command-23-0.log").exists());
}
#[test]
fn commands_that_change_project_files_do_not_trigger_stall_diagnosis() {
let mut work = 0;
let server = Server::custom(false, false, None, move |body, _| {
assert!(!is_diagnostic(body));
let n = work;
work += 1;
if n == 20 {
return (
String::new(),
json!([{"function":{"name":"finish_task","arguments":{"summary":"Recorded measurements"}}}]),
);
}
let mut calls = vec![];
if n == 0 {
calls.push(task_tool("Collect measurements"));
}
calls.push(json!({"function":{"name":"run_command","arguments":{"argv":["sh","-c","printf x >> value.txt"]}}}));
(String::new(), json!(calls))
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(24);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
let s = state(root.path());
assert!(s["current_task"].is_null());
assert!(
fs::read_to_string(Path::new(s["working_workspace"].as_str().unwrap()).join("value.txt"))
.unwrap()
.ends_with(&"x".repeat(20))
);
assert_eq!(server.requests.lock().unwrap().len(), 21);
}
#[test]
fn unavailable_diagnostic_is_bounded_and_does_not_stop_project_work() {
let mut work = 0;
let server = Server::custom(false, false, None, move |body, _| {
if is_diagnostic(body) {
return ("I need to think more.".into(), json!([]));
}
let n = work;
work += 1;
if n == 16 {
assert!(
body["messages"]
.to_string()
.contains("command_diagnostic_error")
);
return (
String::new(),
json!([{"function":{"name":"finish_task","arguments":{"summary":"Inspected the evidence and finished"}}}]),
);
}
let mut calls = vec![];
if n == 0 {
calls.push(task_tool("Inspect evidence"));
}
calls.push(json!({"function":{"name":"run_command","arguments":{"argv":["true"]}}}));
(String::new(), json!(calls))
});
let root = tempfile::tempdir().unwrap();
let config = fixture(root.path(), &server.url, true);
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
settings["implementation_calls"] = json!(24);
fs::write(config, settings.to_string()).unwrap();
run_cycles(root.path(), 1);
assert!(state(root.path())["current_task"].is_null());
assert_eq!(server.requests.lock().unwrap().len(), 23);
assert!(
root.path()
.join("state/cycle-000001/command-diagnostic-1-error.json")
.exists()
);
}
#[test]
fn watchdog_stops_confirmed_hang_and_main_agent_repairs_without_losing_work() {
let mut work = 0;
let mut observations = 0;
let server = Server::custom(false, false, None, move |body, _| {
if body["messages"][0]["content"]
.as_str()
.unwrap_or("")
.contains("command watchdog")
{
observations += 1;
if observations == 1 {
return (
String::new(),
json!([{"function":{"name":"write_file","arguments":{"path":"forbidden.txt","content":"no"}}},{"function":{"name":"read_file","arguments":{"path":"value.txt"}}}]),
);
}
assert!(
body["messages"]
.to_string()
.contains("no action was executed")
);
return (
String::new(),
json!([{"function":{"name":"report_diagnosis","arguments":{"verdict":"stalled","reason":"Fixture command is an intentional endless wait with no useful work; source still says OLD.","next_action":"Stop it and write FIXED to value.txt.","expected_new_evidence":"Validation exits successfully after repair"}}}]),
);
}
work += 1;
if work == 1 {
return (
String::new(),
json!([task_tool("Recover long command"),{"function":{"name":"run_command","arguments":{"argv":["sh","-c","printf WAITING; while :; do sleep 1; done"],"timeout_seconds":1}}}]),
);
}
assert!(body["messages"].to_string().contains("Watchdog:"));
(
String::new(),
json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"FIXED"}}},{"function":{"name":"finish_task","arguments":{"summary":"Repaired after watchdog stopped hung command"}}}]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let s = state(root.path());
let workspace = Path::new(s["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"FIXED"
);
assert!(!workspace.join("forbidden.txt").exists());
assert_eq!(s["completed_tasks"].as_array().unwrap().len(), 1);
let art = root.path().join("state/cycle-000001");
assert!(
fs::read_dir(art)
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with("watchdog-"))
);
}
#[test]
fn watchdog_allows_long_checkpoint_check_and_cannot_mark_unfinished_check_passed() {
let mut work = 0;
let server = Server::custom(false, false, None, move |body, _| {
if body["messages"][0]["content"]
.as_str()
.unwrap_or("")
.contains("command watchdog")
{
assert!(body["messages"].to_string().contains("running"));
return (
String::new(),
json!([{"function":{"name":"report_diagnosis","arguments":{"verdict":"productive","reason":"Command deliberately waits briefly to simulate a long computation.","next_action":"Allow the existing process to finish.","expected_new_evidence":"A zero exit status and final output"}}}]),
);
}
work += 1;
assert_eq!(work, 1);
(
String::new(),
json!([task_tool("Long verification"),{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"FIXED"}}},{"function":{"name":"finish_task","arguments":{"summary":"Ready for actual verification"}}}]),
)
});
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut c: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
c["checks"] = json!([{"argv":["sh","-c","sleep 2; echo FINISHED; test \"$(cat value.txt)\" = FIXED"],"timeout_seconds":1}]);
fs::write(path, c.to_string()).unwrap();
run_cycles(root.path(), 1);
let s = state(root.path());
assert_eq!(s["completed_tasks"].as_array().unwrap().len(), 1);
let verification: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/verification.json")).unwrap(),
)
.unwrap();
assert_eq!(verification.as_array().unwrap().len(), 1);
assert_eq!(verification[0]["passed"], true);
assert!(
verification[0]["output"]
.as_str()
.unwrap()
.contains("FINISHED")
);
assert!(server.requests.lock().unwrap().len() > 1);
}
#[test]
fn command_session_yields_to_model_and_can_be_polled_without_relaunching() {
let mut started = false;
let server = Server::custom(false, false, None, move |body, _| {
if !started {
started = true;
return (
String::new(),
json!([task_tool("Inspect long-running command"),{"function":{"name":"run_command","arguments":{"argv":["sh","-c","echo once >> launches.txt; head -c 6000 /dev/zero | tr '\\000' '\\134'; sleep 2; echo DONE"],"timeout_seconds":30}}}]),
);
}
let messages = body["messages"].as_array().unwrap();
let result = messages
.iter()
.rev()
.find_map(|m| {
if matches!(
m["tool_name"].as_str(),
Some("run_command" | "command_status")
) {
serde_json::from_str::<Value>(m["content"].as_str().unwrap()).ok()
} else {
None
}
})
.unwrap();
if result["result"]["running"] == true {
return (
String::new(),
json!([{"function":{"name":"read_file","arguments":{"path":"launches.txt"}}},{"function":{"name":"command_status","arguments":{"command_id":result["result"]["command_id"],"wait_ms":1000}}}]),
);
}
assert_eq!(result["result"]["passed"], true);
("Observed completion".into(), json!([]))
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let s = state(root.path());
assert_eq!(
fs::read_to_string(
Path::new(s["working_workspace"].as_str().unwrap()).join("launches.txt")
)
.unwrap(),
"once\n"
);
assert!(server.requests.lock().unwrap().len() >= 3);
}
#[test]
fn unavailable_watchdog_keeps_command_alive_and_final_checks_collect_all_results() {
let mut work = 0;
let server = Server::custom(false, false, None, move |body, _| {
if body["messages"][0]["content"]
.as_str()
.unwrap_or("")
.contains("command watchdog")
{
return ("__HTTP_LIMIT__".into(), json!([]));
}
work += 1;
assert_eq!(work, 1);
("Checkpoint unfinished work".into(), json!([]))
});
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut c: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
c["checks"] = json!([{"argv":["sh","-c","sleep 2; echo LONG_CHECK_FINISHED"],"timeout_seconds":1},{"argv":["sh","-c","echo SECOND_CHECK_FAILED; exit 1"],"timeout_seconds":1}]);
fs::write(path, c.to_string()).unwrap();
run_cycles(root.path(), 1);
let v: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/verification.json")).unwrap(),
)
.unwrap();
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["passed"], true);
assert_eq!(v[1]["passed"], false);
assert!(
v[0]["output"]
.as_str()
.unwrap()
.contains("LONG_CHECK_FINISHED")
);
assert_eq!(state(root.path())["last_checks_passed_ref"], Value::Null);
assert!(!root.path().join("state/provider-wait.json").exists());
assert_eq!(server.requests.lock().unwrap().len(), 2);
}
#[test]
fn nudge_completion_keeps_working_and_survives_resume() {
let server = Server::custom(false, false, None, |_, n| match n {
0 => (
String::new(),
json!([task_tool("Temporary priority"), {"function":{"name":"finish_nudge","arguments":{"nudge_id":1,"summary":"Compared options","evidence":"Recorded alternatives"}}}]),
),
1 => (
String::new(),
json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"Continued after nudge"}}}]),
),
_ => ("Checkpoint".into(), json!([])),
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
fs::create_dir_all(root.path().join("state")).unwrap();
fs::write(root.path().join("state/nudges.json"),json!({"revision":1,"serial":1,"active":{"id":1,"request":"Compare alternatives","created_unix":1,"status":"active"},"history":[]}).to_string()).unwrap();
run_cycles(root.path(), 1);
run_cycles(root.path(), 1);
let store: Value =
serde_json::from_slice(&fs::read(root.path().join("state/nudges.json")).unwrap()).unwrap();
assert!(store["active"].is_null());
assert_eq!(store["history"][0]["status"], "completed");
let saved = state(root.path());
assert_eq!(
fs::read_to_string(
Path::new(saved["working_workspace"].as_str().unwrap()).join("value.txt")
)
.unwrap(),
"Continued after nudge"
);
let calls = server.requests.lock().unwrap();
assert!(
calls[0]["messages"]
.to_string()
.contains("Compare alternatives")
);
assert!(calls[1]["messages"].to_string().contains("nudge_completed"));
let updates = calls.last().unwrap()["messages"]
.as_array()
.unwrap()
.iter()
.filter(|m| {
m["role"] == "user"
&& m["content"]
.as_str()
.unwrap_or("")
.contains("priority_update")
})
.count();
assert_eq!(
updates, 2,
"Send priority once, and completion once; don't repeat on resume"
);
}
#[test]
fn project_completion_is_opt_in_saved_and_sticky_across_restart() {
for enabled in [false, true] {
let server = Server::custom(false, false, None, |_, n| match n {
0 => (
String::new(),
json!([
task_tool("Goal work"),
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"Finished goal"}}},
{"function":{"name":"finish_project","arguments":{"summary":"Goal achieved","evidence":"Inspected results"}}},
{"function":{"name":"write_file","arguments":{"path":"after.txt","content":"Should not run after completion"}}}
]),
),
_ => ("Continue".into(), json!([])),
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let path = root.path().join("chuggin.json");
let mut c: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
c["allow_goal_completion"] = json!(enabled);
fs::write(&path, c.to_string()).unwrap();
run_cycles(root.path(), 3);
let saved = state(root.path());
let workspace = Path::new(saved["working_workspace"].as_str().unwrap());
assert_eq!(
fs::read_to_string(workspace.join("value.txt")).unwrap(),
"Finished goal"
);
assert_eq!(workspace.join("after.txt").exists(), !enabled);
assert_eq!(saved["cycle"], if enabled { 1 } else { 3 });
let report = root.path().join("state/goal-completion.json");
assert_eq!(report.exists(), enabled);
let before = server.requests.lock().unwrap().len();
run_cycles(root.path(), 1);
if enabled {
assert_eq!(server.requests.lock().unwrap().len(), before);
let r: Value = serde_json::from_slice(&fs::read(report).unwrap()).unwrap();
assert_eq!(r["checkpoint"], saved["working_ref"]);
c["allow_goal_completion"] = json!(false);
fs::write(&path, c.to_string()).unwrap();
run_cycles(root.path(), 1);
assert!(server.requests.lock().unwrap().len() > before);
}
let calls = server.requests.lock().unwrap();
let exposed = calls[0]["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t["function"]["name"] == "finish_project");
assert_eq!(exposed, enabled);
}
}
#[test]
fn project_completion_setting_updates_between_calls_and_revokes_stale_calls() {
for initially_enabled in [false, true] {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("chuggin.json");
let live_path = path.clone();
let server = Server::custom(false, false, None, move |_, n| {
if n == 0 {
let mut c: Value = serde_json::from_slice(&fs::read(&live_path).unwrap()).unwrap();
c["allow_goal_completion"] = json!(!initially_enabled);
fs::write(&live_path, c.to_string()).unwrap();
}
if n < 2 {
(
String::new(),
json!([{"function":{"name":"finish_project","arguments":{"summary":"Complete","evidence":"Verified"}}}]),
)
} else {
("Continue".into(), json!([]))
}
});
fixture(root.path(), &server.url, true);
let mut c: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
c["allow_goal_completion"] = json!(initially_enabled);
fs::write(&path, c.to_string()).unwrap();
run_cycles(root.path(), 1);
assert_eq!(
root.path().join("state/goal-completion.json").exists(),
!initially_enabled
);
let calls = server.requests.lock().unwrap();
assert!(
calls[1]["messages"]
.to_string()
.contains("Goal completion is not enabled")
);
assert_eq!(
calls[1]["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t["function"]["name"] == "finish_project"),
!initially_enabled
);
}
}
#[test]
fn project_completion_waits_for_running_commands() {
let server = Server::custom(false, false, None, |_, n| {
if n == 0 {
(
String::new(),
json!([
task_tool("Long command"),
{"function":{"name":"run_command","arguments":{"argv":["sh","-c","sleep 3; echo saved > result.txt"],"timeout_seconds":30}}},
{"function":{"name":"finish_project","arguments":{"summary":"Complete","evidence":"Verified"}}}
]),
)
} else {
("Checkpoint".into(), json!([]))
}
});
let root = tempfile::tempdir().unwrap();
let path = fixture(root.path(), &server.url, true);
let mut c: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
c["allow_goal_completion"] = json!(true);
fs::write(&path, c.to_string()).unwrap();
run_cycles(root.path(), 1);
assert!(!root.path().join("state/goal-completion.json").exists());
let s = state(root.path());
assert_eq!(
fs::read_to_string(Path::new(s["working_workspace"].as_str().unwrap()).join("result.txt"))
.unwrap(),
"saved\n"
);
}
#[test]
fn command_finished_during_inference_does_not_block_the_next_edit() {
let server = Server::custom(false, false, None, |_, n| match n {
0 => (
String::new(),
json!([
task_tool("Refresh command status"),
{"function":{"name":"run_command","arguments":{"argv":["sh","-c","sleep 2; echo done"],"timeout_seconds":30}}}
]),
),
1 => {
thread::sleep(Duration::from_secs(2));
(
String::new(),
json!([{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"Edited without a wasted retry"}}}]),
)
}
_ => ("Checkpoint".into(), json!([])),
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
let result: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/tool-1-0.json")).unwrap(),
)
.unwrap();
assert_eq!(result["ok"], true);
let calls = server.requests.lock().unwrap();
let messages = calls[2]["messages"].as_array().unwrap();
let edit_reply = messages.iter().rposition(|m| m["role"] == "tool").unwrap();
let completion = messages
.iter()
.rposition(|m| {
m["content"]
.as_str()
.unwrap_or("")
.contains("command_update")
})
.unwrap();
assert!(
completion > edit_reply,
"Command updates must follow the entire tool reply batch"
);
assert!(
messages[completion]["content"]
.as_str()
.unwrap()
.contains("\"running\":false")
);
}
#[test]
fn completed_tasks_commit_visible_files_and_human_staging_defers_only_the_commit() {
for staged in [false, true] {
let server = Server::custom(false, false, None, |_, _| {
(
String::new(),
json!([
task_tool("Publish the research note"),
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"USEFUL RESULT"}}},
{"function":{"name":"finish_task","arguments":{"summary":"Recorded and checked the research result."}}}
]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
if staged {
git(&repo, &["add", "value.txt"]);
}
let original = git(&repo, &["rev-parse", "HEAD"]);
let index = git(&repo, &["write-tree"]);
run_cycles(root.path(), 1);
let saved = state(root.path());
assert_eq!(saved["working_workspace"], repo.to_str().unwrap());
assert_eq!(saved["completed_tasks"].as_array().unwrap().len(), 1);
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"USEFUL RESULT"
);
if staged {
assert_eq!(git(&repo, &["rev-parse", "HEAD"]), original);
assert_eq!(git(&repo, &["write-tree"]), index);
assert!(saved["commit_pending"].as_str().unwrap().contains("staged"));
} else {
assert_ne!(git(&repo, &["rev-parse", "HEAD"]), original);
assert_eq!(
git(&repo, &["log", "-1", "--format=%s"]),
"Publish the research note"
);
assert_eq!(git(&repo, &["status", "--porcelain"]), "");
}
assert_eq!(
git(
&repo,
&[
"show",
&format!("{}:value.txt", saved["working_ref"].as_str().unwrap())
]
),
"USEFUL RESULT"
);
}
}
#[test]
fn a_human_edit_during_inference_is_preserved_and_reported_for_rereading() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let edited = repo.clone();
let server = Server::custom(false, false, None, move |_, n| {
if n == 0 {
fs::write(edited.join("value.txt"), "HUMAN EDIT").unwrap();
(
String::new(),
json!([{ "function":{"name":"write_file","arguments":{"path":"value.txt","content":"STALE REPLACEMENT"}}}]),
)
} else {
(
"I will inspect the human edit before proposing changes.".into(),
json!([]),
)
}
});
fixture(root.path(), &server.url, true);
run_cycles(root.path(), 1);
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"HUMAN EDIT"
);
let result: Value = serde_json::from_slice(
&fs::read(root.path().join("state/cycle-000001/tool-0-0.json")).unwrap(),
)
.unwrap();
assert_eq!(result["ok"], false);
assert!(result["error"].as_str().unwrap().contains("changed"));
}
#[test]
fn migration_preview_preserves_inputs_and_can_resume_after_files_were_applied() {
let server = Server::new(false, false);
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
git(&repo, &["restore", "value.txt"]);
let dir = root.path().join("state");
fs::create_dir_all(&dir).unwrap();
let legacy = dir.join("working");
let initial = git(&repo, &["rev-parse", "HEAD"]);
git(
&repo,
&[
"worktree",
"add",
"-b",
"legacy",
legacy.to_str().unwrap(),
"HEAD",
],
);
fs::write(legacy.join("value.txt"), "DEVELOPING").unwrap();
fs::write(legacy.join("new.bin"), [0, 255, 128]).unwrap();
fs::write(repo.join("human-notes.txt"), "Keep my notes").unwrap();
git(&repo, &["add", "human-notes.txt"]);
let index = git(&repo, &["write-tree"]);
fs::write(dir.join("state.json"),json!({"schema_version":3,"run_id":"migration-resume","goal":"Improve value incrementally","repo":repo,"cycle":12,"working_ref":initial,"working_branch":"legacy","working_workspace":legacy}).to_string()).unwrap();
let before = git(&repo, &["status", "--porcelain"]);
let preview = command(root.path()).arg("migrate").output().unwrap();
assert!(
preview.status.success(),
"{}",
String::from_utf8_lossy(&preview.stderr)
);
assert_eq!(git(&repo, &["status", "--porcelain"]), before);
assert_eq!(git(&repo, &["write-tree"]), index);
assert_eq!(fs::read_to_string(repo.join("value.txt")).unwrap(), "0");
let run = command(root.path())
.args(["run", "--cycles", "1"])
.output()
.unwrap();
assert!(!run.status.success());
assert!(server.requests.lock().unwrap().is_empty());
let result = command(root.path())
.args(["migrate", "--apply"])
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"DEVELOPING"
);
assert_eq!(fs::read(repo.join("new.bin")).unwrap(), [0, 255, 128]);
assert_eq!(git(&repo, &["show", ":human-notes.txt"]), "Keep my notes");
assert_eq!(
fs::read_to_string(legacy.join("value.txt")).unwrap(),
"DEVELOPING"
);
let record = dir.join("migration.json");
let mut journal: Value = serde_json::from_slice(&fs::read(&record).unwrap()).unwrap();
journal["phase"] = json!("files-applied");
fs::write(&record, journal.to_string()).unwrap();
let result = command(root.path())
.args(["migrate", "--apply"])
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(state(root.path())["schema_version"], 4);
assert_eq!(state(root.path())["cycle"], 12);
assert_eq!(git(&repo, &["write-tree"]), index);
}
#[cfg(unix)]
#[test]
fn interrupted_normal_commit_repairs_its_index_without_replaying_the_commit() {
use std::os::unix::fs::PermissionsExt;
let server = Server::custom(false, false, None, |_, _| {
(
String::new(),
json!([
task_tool("Save the final note"),
{"function":{"name":"write_file","arguments":{"path":"value.txt","content":"FINAL NOTE"}}},
{"function":{"name":"finish_task","arguments":{"summary":"Recorded the final note and verified it."}}}
]),
)
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
let hook = repo.join(".git/hooks/post-commit");
fs::write(&hook, "#!/bin/sh\ntouch .git/hook-started\nsleep 30\n").unwrap();
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
let mut child = command(root.path())
.args(["run", "--cycles", "1"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !repo.join(".git/hook-started").exists()
&& std::time::Instant::now() < deadline
&& child.try_wait().unwrap().is_none()
{
thread::sleep(Duration::from_millis(20));
}
let reached = repo.join(".git/hook-started").exists();
Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.unwrap();
thread::sleep(Duration::from_millis(100));
Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.unwrap();
child.wait().unwrap();
assert!(reached, "Commit did not reach its post-commit hook");
let committed = git(&repo, &["rev-parse", "HEAD"]);
fs::remove_file(hook).unwrap();
run_cycles(root.path(), 0);
assert_eq!(git(&repo, &["rev-parse", "HEAD"]), committed);
assert_eq!(git(&repo, &["status", "--porcelain"]), "");
assert_eq!(
fs::read_to_string(repo.join("value.txt")).unwrap(),
"FINAL NOTE"
);
assert!(!repo.join(".git/index.lock").exists());
assert!(!repo.join(".git/chuggin-promotion").exists());
}
#[test]
fn migration_handoff_only_for_changed_agent_files_and_only_one_completed_cycle() {
for merged in [false, true] {
let server = Server::custom(false, false, None, |_, _| {
("Continue work.".into(), json!([]))
});
let root = tempfile::tempdir().unwrap();
fixture(root.path(), &server.url, true);
let repo = root.path().join("repo");
git(&repo, &["restore", "value.txt"]);
let dir = root.path().join("state");
fs::create_dir_all(&dir).unwrap();
let legacy = dir.join("working");
git(
&repo,
&[
"worktree",
"add",
"-b",
"handoff-test",
legacy.to_str().unwrap(),
"HEAD",
],
);
fs::write(legacy.join("value.txt"), "1").unwrap();
if merged {
fs::write(repo.join("research-note.md"), "Human research to retain").unwrap();
}
fs::write(dir.join("state.json"), json!({"schema_version":3,"run_id":"handoff-test","goal":"Improve value incrementally","repo":repo,"cycle":7,"working_ref":git(&repo,&["rev-parse","HEAD"]),"working_branch":"handoff-test","working_workspace":legacy}).to_string()).unwrap();
migrate_fixture(root.path());
let report: Value =
serde_json::from_slice(&fs::read(dir.join("migration-handoff.json")).unwrap()).unwrap();
assert_eq!(
report["changes"].as_array().unwrap().len(),
usize::from(merged)
);
if merged {
assert_eq!(report["changes"][0]["path"], "research-note.md");
assert_eq!(report["changes"][0]["change"], "Added during migration");
}
run_cycles(root.path(), 0);
assert!(server.requests.lock().unwrap().is_empty());
run_cycles(root.path(), 1);
let notes = |request: &Value| -> Vec<Value> {
request["messages"]
.as_array()
.unwrap()
.iter()
.filter_map(|m| {
m["content"]
.as_str()
.and_then(|s| serde_json::from_str::<Value>(s).ok())
})
.filter(|v| v["kind"] == "migration_handoff")
.collect()
};
let calls = server.requests.lock().unwrap();
let first = notes(&calls[0]);
assert_eq!(first.len(), usize::from(merged));
if merged {
assert_eq!(first[0]["changes"][0]["path"], "research-note.md");
assert!(
first[0]["instruction"]
.as_str()
.unwrap()
.contains("first post-upgrade cycle only")
);
}
let previous = calls.len();
drop(calls);
let conversation: Value =
serde_json::from_slice(&fs::read(dir.join("conversation.json")).unwrap()).unwrap();
assert!(conversation["migration_handoff"].is_null());
assert_eq!(conversation["migration_handoff_seen"], report["id"]);
run_cycles(root.path(), 2);
for call in &server.requests.lock().unwrap()[previous..] {
assert!(
notes(call).is_empty(),
"Later cycles and cold starts must not repeat upgrade review instructions"
);
}
}
}