use super::advance::{AGENT, RecLauncher, eventually_free, worker_config};
use super::fixtures::*;
use crate::config::Workflow;
use crate::prompt::dispatch::advance::{AdvanceOutcome, run};
use crate::prompt::inbox::{self, inbox_dir, try_acquire};
use crate::prompt::resolve::WorkerConfig;
use crate::prompt::run as prompt_run;
use brazen::FinishReason;
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub(super) fn approval_control(dir: &Path) -> PathBuf {
let path = dir.join("fixture-control.sh");
std::fs::write(
&path,
"#!/bin/sh\n\
if [ -f \"$LERNIE_CONV_REPO/approval\" ]; then \
echo '{\"verdict\":\"pass\"}'; \
else \
echo '{\"verdict\":\"hold\",\"reason\":\"awaiting approval\"}'; \
fi\n",
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
pub(super) fn gated_workflow(control: &Path) -> String {
format!(
"events: {{}}\ntool_control:\n command: {}\n",
control.display()
)
}
pub(super) fn real_deps<'a>(
adapter: &'a StubAdapter,
sleeper: &'a StubSleeper,
git: &'a dyn crate::template::GitRunner,
clock: &'a FixedClock,
id: &'a FixedIdGen,
tools: &'a StubToolExecutor,
config_root: &'a Path,
) -> crate::prompt::Deps<'a> {
crate::prompt::Deps {
adapter,
sleeper,
git,
clock,
id_gen: id,
tool_executor: tools,
config_root,
adapter_target: None,
stop: never_stopped(),
launcher: no_launch(),
}
}
#[test]
fn a_hold_parks_run_exchange_without_a_terminal() {
let scripts = TempDir::new().unwrap();
let control = approval_control(scripts.path());
let repo = scaffold_repo_with_workflow(
VALID_PER_REPO_PROVIDERS_YAML,
&gated_workflow(&control),
Some("body"),
);
let harness = scaffold_harness_root();
let tool_stream = stream_of(
FinishReason::ToolUse,
&[Block::ToolUse {
id: "toolu_01",
name: "bash",
input: json!({"command": "true"}),
}],
);
let adapter = StubAdapter::happy(&tool_stream);
let git = StubGit::ok();
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (sleeper, tool_executor) = (StubSleeper::default(), StubToolExecutor::ok());
let branch = prompt_run(
repo.path(),
"do the thing",
None,
None,
None,
crate::prompt::PinnedDocs::none(),
&valid_deps(
&adapter,
&sleeper,
&git,
&clock,
&id,
&tool_executor,
harness.path(),
),
)
.unwrap();
assert_eq!(branch, "ct-1-deadbeef");
assert!(tool_executor.invocations.borrow().is_empty());
let worktree = worktree_path(repo.path());
assert!(worktree.join("messages/002-claude-sonnet-5.json").exists());
assert!(!worktree.join("messages/003-tool.json").exists());
let runs = git.runs.borrow();
assert!(
runs.iter().any(
|(_, args)| args.first().map(String::as_str) == Some("update-ref")
&& args.iter().any(|a| a == "refs/lernie/held/ct-1-deadbeef")
),
"no hold-mark write recorded"
);
assert_eq!(adapter.observed.borrow().len(), 2);
assert!(
try_acquire(&inbox_dir(repo.path(), "ct-1-deadbeef"))
.unwrap()
.is_some()
);
}
#[test]
fn a_fresh_hold_in_a_hop_exits_held_with_the_lease_released() {
let scripts = TempDir::new().unwrap();
let control = approval_control(scripts.path());
let (ws, wt) = super::advance::workspace_with_tail(&super::advance::terminal_tail());
std::fs::create_dir(ws.path().join(crate::workspace::REPO_DIR)).unwrap();
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: json!({"command": "true"}),
}],
);
let adapter = StubAdapter::scripted([StubAdapter::reply_ok(&tool_stream)]);
let (sleeper, git) = (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 = || -> Result<WorkerConfig, crate::prompt::Error> {
Ok(WorkerConfig {
workflow: Workflow::parse(&gated_workflow(&control), Path::new("workflow.yaml"))
.unwrap(),
..worker_config()
})
};
let out = run(ws.path(), AGENT, None, &deps, &mut cfg).unwrap();
assert!(matches!(out, AdvanceOutcome::Held), "got {out:?}");
assert!(tools.invocations.borrow().is_empty());
assert!(!wt.join("messages/005-tool.json").exists());
assert!(
rec.invocations.borrow().is_empty(),
"a park launches nothing"
);
assert!(eventually_free(ws.path(), AGENT));
}