use crate::harness_root::Roots;
use crate::prompt::dispatch::door;
use crate::prompt::step::STEPS_DIR;
use crate::prompt::tool::{ENV_CONV_BRANCH, ENV_CONV_REPO, STEP_TOOLS_SUBDIR};
use crate::test_support::{litany_binary, with_contract_env, with_litany_home};
use crate::workspace::fixture;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use tempfile::TempDir;
pub(super) const AGENT: &str = "20260101-a1";
pub(super) struct FakeEnv(pub(super) HashMap<&'static str, OsString>);
impl FakeEnv {
pub(super) fn at(ws: &Path) -> Self {
Self(HashMap::from([
(ENV_CONV_REPO, ws.as_os_str().to_owned()),
(ENV_CONV_BRANCH, OsString::from(AGENT)),
]))
}
}
impl crate::prompt::tool::builtin::dispatch::EnvLookup for FakeEnv {
fn get(&self, key: &str) -> Option<OsString> {
self.0.get(key).cloned()
}
}
pub(super) struct Scene {
_holder: TempDir,
pub(super) home: PathBuf,
pub(super) ws: PathBuf,
pub(super) step: PathBuf,
}
impl Scene {
pub(super) fn new() -> Self {
let holder = TempDir::new().unwrap();
let home = holder.path().join("home");
let roots = Roots {
config: home.clone(),
data: home.clone(),
};
let ws = fixture::workspace_under(&roots);
fixture::spawn_root(&ws, AGENT);
let step = ws.join(STEPS_DIR).join(AGENT).join("001");
std::fs::create_dir_all(&step).unwrap();
Self {
_holder: holder,
home,
ws,
step,
}
}
pub(super) fn invoke(&self, block: &str) -> (Result<i32, door::cli::Error>, String) {
self.invoke_with(block, &AtomicBool::new(false))
}
pub(super) fn invoke_with(
&self,
block: &str,
stop: &AtomicBool,
) -> (Result<i32, door::cli::Error>, String) {
let mut stdout: Vec<u8> = Vec::new();
let mut stdin = block.as_bytes();
let code = self.drive(&mut stdin, &mut stdout, stop);
(code, String::from_utf8_lossy(&stdout).into_owned())
}
pub(super) fn drive(
&self,
stdin: &mut dyn std::io::Read,
stdout: &mut dyn std::io::Write,
stop: &AtomicBool,
) -> Result<i32, door::cli::Error> {
let adapter = self.ws.join("no-adapter");
with_litany_home(&self.home, || {
door::cli::run(
&FakeEnv::at(&self.ws),
stdin,
stdout,
&litany_binary(),
Some(&adapter),
stop,
None,
)
})
}
pub(super) fn recorded(&self, id: &str) -> bool {
self.step.join(STEP_TOOLS_SUBDIR).join(id).exists()
}
}
#[test]
fn a_permitted_invocation_runs_and_answers_with_the_raw_envelope() {
let scene = Scene::new();
let (code, out) = scene.invoke(
r#"{"id":"tu_1","name":"bash","input":{"command":"echo hi; echo warn >&2; exit 3"}}"#,
);
assert_eq!(code.map_err(|e| e.to_string()), Ok(3), "{out}");
assert!(out.starts_with("Exit code: 3\nhi\n"), "{out}");
assert!(out.contains("--- stderr ---\nwarn\n"), "{out}");
assert!(scene.recorded("tu_1"));
let head = crate::template::GitRunner::run_capture(
&crate::template::RealGit::new(),
&crate::workspace::agent_worktree(&scene.ws, AGENT),
&["log", "--format=%s"],
)
.unwrap();
assert_eq!(head.lines().next(), Some("dispatch"), "{head}");
}
#[test]
fn an_omitted_input_is_the_empty_object() {
let scene = Scene::new();
let (code, out) = scene.invoke(r#"{"id":"tu_2","name":"bash"}"#);
assert!(matches!(code, Ok(c) if c != 0), "{out}");
assert!(scene.recorded("tu_2"));
}
#[test]
fn the_verb_reads_the_contract_from_the_process_environment() {
let scene = Scene::new();
let block = r#"{"id":"tu_v","name":"bash","input":{"command":"echo through"}}"#;
let (outcome, stdout, _) = with_contract_env(&scene.home, &scene.ws, AGENT, || {
super::with_fx(
&litany_binary().to_string_lossy(),
block.as_bytes(),
&super::noop_editor,
|fx| {
fx.adapter_target = Some(scene.ws.join("no-adapter"));
super::Command::Invoke(super::invoke::Args {}).run(fx)
},
)
});
assert!(
matches!(outcome, Ok(super::Outcome::Code(0))),
"{outcome:?}"
);
assert_eq!(String::from_utf8_lossy(&stdout), "Exit code: 0\nthrough\n");
}
#[test]
fn the_verb_carries_its_own_failure_prefix() {
let (outcome, ..) = super::with_fx("litany", b"{}", &super::noop_editor, |fx| {
super::invoke::run(super::invoke::Args {}, fx)
});
super::assert_prefixed(outcome.expect_err("no block, no invocation"), "invoke");
}