use super::advance::{
AGENT, RecLauncher, model_entry, no_resolve, terminal_tail, worker_config, workspace_with_tail,
};
use super::fixtures::*;
use crate::config::Budgets;
use crate::prompt::dispatch::advance::{AdvanceOutcome, run};
use crate::prompt::inbox::{self, inbox_dir};
use crate::prompt::tool::ToolOutcome;
use crate::prompt::{AdapterRunner, Deps, Error};
use brazen::{Content, FinishReason};
use std::ffi::OsString;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use tempfile::TempDir;
struct StopMidCallAdapter<'a> {
flag: &'a AtomicBool,
}
impl AdapterRunner for StopMidCallAdapter<'_> {
fn run(
&self,
_binary: &OsString,
_args: &[&str],
_stdin: &[u8],
_on_line: &mut dyn FnMut(&[u8]) -> io::Result<()>,
) -> io::Result<Vec<u8>> {
self.flag.store(true, Ordering::SeqCst);
Ok(Vec::new()) }
}
struct StopMidToolExecutor<'a> {
flag: &'a AtomicBool,
}
impl crate::prompt::tool::ToolExecutor for StopMidToolExecutor<'_> {
fn execute(
&self,
_call: crate::prompt::tool::ToolCall<'_>,
_step_dir: &std::path::Path,
_stop: &AtomicBool,
) -> Result<ToolOutcome, crate::prompt::ExecError> {
self.flag.store(true, Ordering::SeqCst);
Ok(ToolOutcome {
content: b"interrupted output".to_vec(),
is_error: false,
})
}
}
#[test]
fn a_stop_flag_at_entry_terminates_stopped_without_launching() {
let (ws, _wt) = workspace_with_tail(&terminal_tail());
let (clock, id) = (FixedClock::default(), FixedIdGen);
inbox::deposit(ws.path(), AGENT, "user", "again", &clock).unwrap();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let tools = StubToolExecutor::ok();
let rec = RecLauncher::default();
let stopped = AtomicBool::new(true);
let mut deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
deps.launcher = &rec;
deps.stop = &stopped;
let out = run(ws.path(), AGENT, None, &deps, &mut || Ok(worker_config())).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(!ws.path().join("steps").exists());
assert!(rec.invocations.borrow().is_empty());
}
#[test]
fn a_stop_during_the_model_call_is_a_stop_not_a_failure() {
let (ws, wt) = workspace_with_tail(&terminal_tail());
let (clock, id) = (FixedClock::default(), FixedIdGen);
inbox::deposit(ws.path(), AGENT, "user", "again", &clock).unwrap();
let stopped = AtomicBool::new(false);
let adapter = StopMidCallAdapter { flag: &stopped };
let (sleeper, git) = (StubSleeper::default(), StubGit::ok());
let tools = StubToolExecutor::ok();
let rec = RecLauncher::default();
let deps = Deps {
adapter: &adapter,
sleeper: &sleeper,
git: &git,
clock: &clock,
id_gen: &id,
tool_executor: &tools,
config_root: ws.path(),
adapter_target: None,
stop: &stopped,
launcher: &rec,
};
let out = run(ws.path(), AGENT, None, &deps, &mut || Ok(worker_config())).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(!wt.join("messages/004-claude-sonnet-5.json").exists());
assert!(rec.invocations.borrow().is_empty());
}
#[test]
fn a_stop_during_the_tool_window_never_rides_the_baton() {
let (ws, wt) = workspace_with_tail(&terminal_tail());
let (clock, id) = (FixedClock::default(), FixedIdGen);
inbox::deposit(ws.path(), AGENT, "user", "run it", &clock).unwrap();
let tool_stream = stream_of(
FinishReason::ToolUse,
&[Block::ToolUse {
id: "t1",
name: "bash",
input: serde_json::json!({"command": "sleep"}),
}],
);
let adapter = StubAdapter::scripted([StubAdapter::reply_ok(&tool_stream)]);
let (sleeper, git) = (StubSleeper::default(), StubGit::ok());
let stopped = AtomicBool::new(false);
let tools = StopMidToolExecutor { flag: &stopped };
let rec = RecLauncher::default();
let deps = Deps {
adapter: &adapter,
sleeper: &sleeper,
git: &git,
clock: &clock,
id_gen: &id,
tool_executor: &tools,
config_root: ws.path(),
adapter_target: None,
stop: &stopped,
launcher: &rec,
};
let out = run(ws.path(), AGENT, None, &deps, &mut || Ok(worker_config())).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(wt.join("messages/005-tool.json").exists());
assert!(rec.invocations.borrow().is_empty());
}
#[test]
fn budget_exhaustion_at_the_boundary_terminates_without_a_model_call() {
let (ws, _wt) = workspace_with_tail(&terminal_tail());
let (clock, id) = (FixedClock::default(), FixedIdGen);
inbox::deposit(ws.path(), AGENT, "user", "again", &clock).unwrap();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let tools = StubToolExecutor::ok();
let rec = RecLauncher::default();
let mut deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
deps.launcher = &rec;
let mut cfg = worker_config();
cfg.workflow.budgets = Budgets {
max_total_tokens: Some(0),
..Budgets::default()
};
let out = run(ws.path(), AGENT, None, &deps, &mut || Ok(cfg.clone())).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(git.runs.borrow().iter().any(|(_, args)| {
args.first().map(String::as_str) == Some("update-ref")
&& args
.get(1)
.is_some_and(|r| r.starts_with("refs/lernie/budget-exhausted/"))
}));
assert!(rec.invocations.borrow().is_empty());
}
#[test]
fn a_resolve_failure_propagates() {
let (ws, _wt) = workspace_with_tail(&terminal_tail());
let clock = FixedClock::default();
inbox::deposit(ws.path(), AGENT, "user", "again", &clock).unwrap();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let id = FixedIdGen;
let tools = StubToolExecutor::ok();
let deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
let err = run(ws.path(), AGENT, None, &deps, &mut || {
Err(Error::RoleMissing("worker".into()))
})
.unwrap_err();
assert!(matches!(err, Error::RoleMissing(_)), "{err}");
}
#[test]
fn a_missing_pinned_goal_surfaces_as_io() {
let (ws, wt) = workspace_with_tail(&terminal_tail());
std::fs::remove_file(wt.join("goal.md")).unwrap();
let (clock, id) = (FixedClock::default(), FixedIdGen);
inbox::deposit(ws.path(), AGENT, "user", "again", &clock).unwrap();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let tools = StubToolExecutor::ok();
let deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
let err = run(ws.path(), AGENT, None, &deps, &mut || Ok(worker_config())).unwrap_err();
assert!(matches!(err, Error::Io(_)), "{err}");
}
#[test]
fn a_broken_inbox_surfaces_as_an_executor_lock_error() {
let ws = TempDir::new().unwrap();
std::fs::create_dir_all(ws.path().join("inbox")).unwrap();
std::fs::write(inbox_dir(ws.path(), AGENT), b"not a dir").unwrap();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let (clock, id) = (FixedClock::default(), FixedIdGen);
let tools = StubToolExecutor::ok();
let deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
let err = run(ws.path(), AGENT, None, &deps, &mut no_resolve).unwrap_err();
assert!(matches!(err, Error::ExecutorLock { .. }), "{err}");
}
#[test]
fn unpaired_tool_use_is_declined_loudly() {
let tail = vec![
("001-user.md", "hi".to_string()),
(
"002-claude-sonnet-5.json",
model_entry(&[Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: serde_json::json!({"command": "true"}),
signature: None,
}]),
),
];
let (ws, _wt) = workspace_with_tail(&tail);
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let (clock, id) = (FixedClock::default(), FixedIdGen);
let tools = StubToolExecutor::ok();
let deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
let err = run(ws.path(), AGENT, None, &deps, &mut no_resolve).unwrap_err();
assert!(matches!(err, Error::UnpairedToolUse { .. }), "{err}");
}