use crate::test_support::prelude::*;
use crate::test_support::*;
#[test]
fn recovery_prompt_identifies_the_owned_task_even_when_todo_has_other_ready_work() {
for folders in [false, true] {
let root = temp_root("exact-recovery-prompt");
init_tasks(&root, folders).unwrap();
add_task(&root, "Style references", None).unwrap();
add_task(
&root,
"Feather mask. BLOCKED 2026-10-22: Waiting for recovery. codex:feather",
None,
)
.unwrap();
let mut project = crate::tui::tests::tui_agent_project_for_test(1, "project").project;
project.path = root.clone();
let mut command = Command::new("codex");
configure_automated_codex_subcommand(
&mut command,
&project,
AgentTaskSelection::ResumeSession,
Some("feather"),
)
.unwrap();
let prompt = command.get_args().last().unwrap().to_string_lossy();
assert!(prompt.contains("Exact task session for this run: codex:feather"));
assert!(prompt.contains("Linked task (todo):\nFeather mask."));
assert!(!prompt.contains("Style references"));
assert!(prompt.contains("stop without selecting or blocking another task"));
fs::remove_dir_all(root).unwrap();
}
}
#[test]
fn returned_todo_keeps_session_fences_and_refuses_missing_git_boundary() {
for scenario in [
"missing-journal",
"stopped",
"running",
"interactive",
"duplicate",
"blocked",
"terminal",
] {
let root = temp_root("returned-todo-fences");
let state = root.join("state");
let project_root = root.join("project");
init_tasks(&project_root, false).unwrap();
add_task(&project_root, "Existing work. codex:prior-session", None).unwrap();
initialize_test_git_repository(&project_root);
let store = agent::TursoAgentStore::open_blocking(&state).unwrap();
store
.register_project_blocking(&project_root, "project")
.unwrap();
let mut project = store.list_projects_blocking().unwrap().remove(0);
project.git_mode = AgentGitMode::Commit;
store
.record_run_outcome_blocking(agent::AgentRunOutcome {
project_id: project.id,
status: "blocked",
started_at: "100",
finished_at: Some("101"),
exit_code: Some(0),
log_dir: None,
stdout_path: None,
stderr_path: None,
summary: None,
codex_session_id: Some("prior-session"),
})
.unwrap();
let control_state = match scenario {
"stopped" => Some(AgentSessionControlState::Stopped),
"running" => Some(AgentSessionControlState::Running),
"interactive" => Some(AgentSessionControlState::Interactive),
_ => None,
};
if let Some(control_state) = control_state {
store
.set_session_control_state_blocking(project.id, "prior-session", control_state)
.unwrap();
}
if scenario == "duplicate" {
add_task(&project_root, "Other task. codex:prior-session", None).unwrap();
}
if scenario == "blocked" {
let board = TaskBoard::new(get_tasks_dir(&project_root));
let task = board.entry(TaskStatus::Todo, 1).unwrap();
board
.write_entry_content(
TaskStatus::Todo,
&task,
"Existing work. BLOCKED 2026-10-06: Waiting. codex:prior-session",
)
.unwrap();
}
if scenario == "terminal" {
let start = capture_agent_git_start_state(&project_root, AgentGitMode::Commit).unwrap();
store
.create_git_finalization_blocking(agent::NewGitFinalization {
project_id: project.id,
codex_session_id: "prior-session",
git_mode: AgentGitMode::Commit,
starting_head: Some(&start.starting_head),
branch_ref: start.branch_ref.as_deref(),
upstream_ref: start.upstream_ref.as_deref(),
worktree_baseline: &start.worktree_baseline,
task_identity: None,
owner_run_token: None,
created_at: "100",
})
.unwrap();
assert!(
store
.compare_and_set_git_finalization_blocking(
project.id,
"prior-session",
0,
GitFinalizationState::Cancelled,
None,
None,
None,
"101"
)
.unwrap()
);
}
let before = store
.git_finalization_blocking(project.id, "prior-session")
.unwrap();
let control_before = store
.session_control_blocking(project.id, "prior-session")
.unwrap();
let runner = CodexAgentRunner::with_command(
state,
Duration::from_secs(1),
root.join("must-not-launch"),
);
let error = runner
.run_project(
&project,
AgentTaskSelection::NextTodo,
Some("prior-session"),
"holder",
None,
&new_agent_shutdown_signal(),
)
.unwrap_err();
let expected = match scenario {
"missing-journal" => "no frozen Git start journal",
"duplicate" => "exactly one task",
"blocked" => "no longer belongs to the next ready Todo",
"terminal" => "Git finalization history",
_ => "busy or stopped",
};
assert!(
format!("{error:#}").contains(expected),
"{scenario}: {error:#}"
);
assert_eq!(
store
.git_finalization_blocking(project.id, "prior-session")
.unwrap(),
before
);
assert_eq!(
store
.session_control_blocking(project.id, "prior-session")
.unwrap(),
control_before
);
assert!(
store
.git_launch_state_for_project_blocking(project.id)
.unwrap()
.is_none()
);
drop(store);
fs::remove_dir_all(root).unwrap();
}
}
#[test]
fn planned_todo_selection_preserves_queue_order_and_skips_unavailable_tasks() {
for folders in [false, true] {
let root = temp_root("planned-todo-selection");
init_tasks(&root, folders).unwrap();
for content in [
"Stopped plan. codex:stopped clt:stopped",
"Blocked plan. BLOCKED 2026-09-30: Waiting. codex:blocked",
"First ready task.",
"Second ready task. codex:plan-two",
] {
add_task(&root, content, None).unwrap();
}
assert_eq!(
automated_codex_session_to_resume(&root, AgentTaskSelection::NextTodo).unwrap(),
None,
);
let board = TaskBoard::new(get_tasks_dir(&root));
let task = board.entry(TaskStatus::Todo, 3).unwrap();
board
.write_entry_content(TaskStatus::Todo, &task, "First ready task. codex:plan-one")
.unwrap();
assert_eq!(
automated_codex_session_to_resume(&root, AgentTaskSelection::NextTodo)
.unwrap()
.as_deref(),
Some("plan-one"),
);
let mut project = crate::tui::tests::tui_agent_project_for_test(1, "project").project;
project.path = root.clone();
let mut command = Command::new("codex");
configure_automated_codex_subcommand(
&mut command,
&project,
AgentTaskSelection::NextTodo,
None,
)
.unwrap();
let args: Vec<_> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
&args[..4],
["exec", "resume", "--skip-git-repo-check", "plan-one"]
);
let prompt = args.last().unwrap();
assert!(prompt.contains("supersedes the earlier planning-only instruction"));
assert!(prompt.contains("Preserve its codex:plan-one marker"));
assert!(prompt.contains("Do not select another task"));
fs::remove_dir_all(root).unwrap();
}
}
#[test]
fn fresh_launch_keeps_its_selected_todo_and_rejects_a_new_uncommitted_selection() {
for folders in [false, true] {
let root = temp_root("fresh-todo-selection");
init_tasks(&root, folders).unwrap();
add_task(&root, "Waiting. BLOCKED 2026-10-20: needs input", None).unwrap();
add_task(&root, "Original ready work.", None).unwrap();
initialize_test_git_repository(&root);
let start = capture_agent_git_start_state(&root, AgentGitMode::Commit).unwrap();
let mut project = crate::tui::tests::tui_agent_project_for_test(1, "project").project;
project.path = root.clone();
project.git_mode = AgentGitMode::Commit;
let mut command = Command::new("codex");
crate::runner::configure_automated_codex_subcommand_with_direction(
&mut command,
&project,
AgentTaskSelection::NextTodo,
None,
None,
Some(&start),
)
.unwrap();
TaskBoard::new(get_tasks_dir(&root))
.insert_content(TaskStatus::Todo, Some(0), "Newly added work.")
.unwrap();
let prompt = command.get_args().last().unwrap().to_string_lossy();
assert!(prompt.contains("Selected task for this run:\nOriginal ready work."));
assert!(!prompt.contains("Newly added work."));
assert!(prompt.contains("do not substitute a Todo added or reordered after launch"));
let index = run_test_git(&root, &["write-tree"]);
let mut second = Command::new("codex");
let error = crate::runner::configure_automated_codex_subcommand_with_direction(
&mut second,
&project,
AgentTaskSelection::NextTodo,
None,
None,
Some(&start),
)
.unwrap_err();
assert!(error.to_string().contains("retry before starting Codex"));
assert_eq!(
run_test_git(&root, &["rev-parse", "HEAD"]),
start.starting_head
);
assert_eq!(run_test_git(&root, &["write-tree"]), index);
assert_eq!(
TaskBoard::new(get_tasks_dir(&root))
.entries(TaskStatus::Todo)
.unwrap()
.len(),
3
);
fs::remove_dir_all(root).unwrap();
}
}
#[cfg(unix)]
#[test]
fn planning_runner_process_entry() {
let Ok(mode) = std::env::var("CLT_TEST_PLANNING_RUNNER_MODE") else {
return;
};
let mode = AgentGitMode::from_database(&mode).unwrap();
let resumed: bool = std::env::var("CLT_TEST_PLANNING_RUNNER_RESUMED")
.unwrap()
.parse()
.unwrap();
let state = PathBuf::from(std::env::var_os(AGENT_STATE_DIR_ENV).unwrap());
let context = AutomatedAgentChildContext {
project_id: std::env::var(AGENT_PROJECT_ID_ENV).unwrap().parse().unwrap(),
run_token: std::env::var(AGENT_RUN_TOKEN_ENV).unwrap(),
};
let store = agent::TursoAgentStore::open_blocking(&state).unwrap();
let mut project = store
.list_projects_blocking()
.unwrap()
.into_iter()
.find(|project| project.id == context.project_id)
.unwrap();
project.git_mode = mode;
let control = store
.session_control_blocking(project.id, "planning-session")
.unwrap()
.unwrap();
assert_eq!(control.state, AgentSessionControlState::Running);
assert_eq!(control.run_token.as_deref(), Some(context.run_token.as_str()));
assert_eq!(control.child_pid, Some(std::process::id()));
if resumed {
let doing = read_task_entries(&get_tasks_dir(&project.path), TaskStatus::Doing).unwrap();
assert_eq!(doing.len(), 1);
assert_eq!(
codex_session_for_task(&doing[0]).as_deref(),
Some("planning-session")
);
} else {
let wrong_task = if mode == AgentGitMode::Off {
move_task_to_doing_with_agent_session(&project.path, "2", &context, &project, &store)
} else {
move_task_to_doing_with_agent_git_journal(&project.path, "2", &context, &project, &store)
};
assert!(
wrong_task
.unwrap_err()
.to_string()
.contains("different Codex session")
);
if mode == AgentGitMode::Off {
move_task_to_doing_with_agent_session(&project.path, "1", &context, &project, &store)
.unwrap();
} else {
let journal = store
.git_finalization_blocking(project.id, "planning-session")
.unwrap()
.unwrap();
assert_eq!(journal.state, GitFinalizationState::Working);
assert_eq!(
journal.owner_run_token.as_deref(),
Some(context.run_token.as_str())
);
move_task_to_doing_with_agent_git_journal(&project.path, "1", &context, &project, &store)
.unwrap();
}
}
println!("activated");
}
#[cfg(unix)]
#[test]
fn todo_runner_starts_plans_and_resumes_prior_work_with_each_git_mode() {
use std::os::unix::fs::PermissionsExt;
for (mode, registered, folders, resumed) in [
(AgentGitMode::Off, true, false, false),
(AgentGitMode::Off, false, true, false),
(AgentGitMode::Commit, true, true, false),
(AgentGitMode::CommitAndPush, true, false, false),
(AgentGitMode::Off, false, false, true),
(AgentGitMode::Off, false, true, true),
(AgentGitMode::Commit, false, true, true),
(AgentGitMode::CommitAndPush, false, false, true),
(AgentGitMode::CommitAndPush, false, true, true),
] {
let root = temp_root("planned-todo-runner");
let state = root.join("state");
let project_root = root.join("project");
init_tasks(&project_root, folders).unwrap();
add_task(
&project_root,
"Implement the agreed plan. codex:planning-session",
None,
)
.unwrap();
add_task(&project_root, "Leave queued. codex:other-plan", None).unwrap();
let project_root = fs::canonicalize(project_root).unwrap();
if mode != AgentGitMode::Off {
initialize_test_git_repository(&project_root);
if mode == AgentGitMode::CommitAndPush {
let remote = root.join("remote.git");
fs::create_dir_all(&remote).unwrap();
run_test_git(&remote, &["init", "--bare"]);
run_test_git(
&project_root,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
run_test_git(&project_root, &["push", "-u", "origin", "HEAD"]);
}
}
let store = agent::TursoAgentStore::open_blocking(&state).unwrap();
store
.register_project_blocking(&project_root, "project")
.unwrap();
let mut project = store.list_projects_blocking().unwrap().remove(0);
project.git_mode = mode;
if registered {
assert!(
store
.register_shared_planning_session_blocking(project.id, "planning-session")
.unwrap()
);
}
let original_journal = if resumed {
store
.record_run_outcome_blocking(agent::AgentRunOutcome {
project_id: project.id,
status: "blocked",
started_at: "100",
finished_at: Some("101"),
exit_code: Some(0),
log_dir: None,
stdout_path: None,
stderr_path: None,
summary: Some("Waiting for prerequisite"),
codex_session_id: Some("planning-session"),
})
.unwrap();
if mode != AgentGitMode::Off {
let start = capture_agent_git_start_state(&project_root, mode).unwrap();
let identity = durable_task_identity("Implement the agreed plan.").unwrap();
assert!(
store
.create_git_finalization_blocking(agent::NewGitFinalization {
project_id: project.id,
codex_session_id: "planning-session",
git_mode: mode,
starting_head: Some(&start.starting_head),
branch_ref: start.branch_ref.as_deref(),
upstream_ref: start.upstream_ref.as_deref(),
worktree_baseline: &start.worktree_baseline,
task_identity: Some(&identity),
owner_run_token: None,
created_at: "100",
})
.unwrap()
);
fs::write(project_root.join("independent.txt"), "Other work\n").unwrap();
run_test_git(&project_root, &["add", "independent.txt"]);
run_test_git(&project_root, &["commit", "-m", "Independent work"]);
}
let board = TaskBoard::new(get_tasks_dir(&project_root));
let task = board.entry(TaskStatus::Todo, 1).unwrap();
board.write_entry_content(TaskStatus::Todo, &task,
"Implement the agreed plan. BLOCKED 2026-10-05: Waiting. UNBLOCKED 2026-10-06: Prerequisite restored. codex:planning-session").unwrap();
store
.git_finalization_blocking(project.id, "planning-session")
.unwrap()
} else {
None
};
let exact_resume = resumed && mode == AgentGitMode::CommitAndPush && !folders;
if exact_resume {
assert!(
store
.ensure_pending_git_finalization_resume_requested_blocking(
project.id,
"planning-session"
)
.unwrap()
);
}
assert!(
store
.try_acquire_lease_blocking(
project.id,
"holder",
&agent_timestamp(),
&agent_timestamp_after(60)
)
.unwrap()
);
let fake = root.join("codex");
let executable = std::env::current_exe().unwrap();
let quoted_executable = executable.to_str().unwrap().replace('\'', "'\\''");
fs::write(
&fake,
format!(
"#!/bin/sh\nprintf 'arg=%s\\n' \"$@\" >&2\n\
export CLT_TEST_PLANNING_RUNNER_MODE={}\n\
export CLT_TEST_PLANNING_RUNNER_RESUMED={resumed}\n\
exec '{quoted_executable}' --exact runner::tests::planning::planning_runner_process_entry --nocapture\n",
mode.database_value(),
),
)
.unwrap();
fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
let runner = CodexAgentRunner::with_command(state, Duration::from_secs(60), fake);
let result = runner
.run_project(
&project,
if exact_resume {
AgentTaskSelection::ResumeSession
} else {
AgentTaskSelection::NextTodo
},
exact_resume.then_some("planning-session"),
"holder",
None,
&new_agent_shutdown_signal(),
)
.unwrap_or_else(|error| {
panic!("{mode:?}, registered={registered}, folders={folders}, resumed={resumed}: {error:#}")
});
assert_eq!(
result.status,
"success",
"{mode:?}, registered={registered}, folders={folders}, resumed={resumed}: {}\nstdout:\n{}\nstderr:\n{}",
result.summary,
fs::read_to_string(&result.stdout_path).unwrap(),
fs::read_to_string(&result.stderr_path).unwrap(),
);
assert!(
fs::read_to_string(&result.stdout_path)
.unwrap()
.contains("activated\n")
);
assert_eq!(result.codex_session_id.as_deref(), Some("planning-session"));
assert!(
fs::read_to_string(&result.stderr_path).unwrap().contains(
"arg=exec\narg=resume\narg=--skip-git-repo-check\narg=planning-session\n"
)
);
let log = fs::read_to_string(&result.stderr_path).unwrap();
if resumed {
assert!(log.contains("Existing task recovery:"));
assert!(!log.contains("Start the planned task:"));
} else {
assert!(log.contains("Start the planned task:"));
}
if let Some(original) = original_journal {
let current = store
.git_finalization_blocking(project.id, "planning-session")
.unwrap()
.unwrap();
assert_eq!(current.starting_head, original.starting_head);
assert_eq!(current.branch_ref, original.branch_ref);
assert_eq!(current.upstream_ref, original.upstream_ref);
assert_eq!(current.worktree_baseline, original.worktree_baseline);
assert_eq!(current.task_identity, original.task_identity);
assert_eq!(current.created_at, original.created_at);
assert!(
store
.git_launch_state_for_project_blocking(project.id)
.unwrap()
.is_none()
);
}
let doing = read_task_entries(&get_tasks_dir(&project_root), TaskStatus::Doing).unwrap();
assert_eq!(doing.len(), 1);
assert_eq!(
codex_session_for_task(&doing[0]).as_deref(),
Some("planning-session")
);
let todo = read_task_entries(&get_tasks_dir(&project_root), TaskStatus::Todo).unwrap();
assert_eq!(todo.len(), 1);
assert_eq!(
codex_session_for_task(&todo[0]).as_deref(),
Some("other-plan")
);
drop(store);
fs::remove_dir_all(root).unwrap();
}
}