use super::advance::{RecLauncher, worker_config};
use super::fixtures::*;
use crate::config::Workflow;
use crate::prompt::child_dispatch::{ChildDispatchRequest, run as dispatch_child};
use crate::prompt::dispatch::advance::{AdvanceOutcome, run};
use crate::prompt::resolve::WorkerConfig;
use crate::prompt::tool::{ExecError, ToolCall, ToolExecutor, ToolOutcome};
use crate::prompt::{Deps, NanoIdGen, SystemClock};
use crate::template::{GitRunner, RealGit};
use crate::workspace::agent_name::mint::test_rng;
use crate::workspace::{agent_worktree, config_ref, fixture, proposal, repo_git};
use brazen::FinishReason;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
const STAGE: &str = "events:\n reviewer_return:\n - stage_proposal\n";
fn manifest(body: &str) -> String {
format!("---\nname: notes\ndescription: d\n---\n{body}")
}
struct PatchingTools {
inner: StubToolExecutor,
worktree: PathBuf,
body: String,
}
impl ToolExecutor for PatchingTools {
fn execute(
&self,
call: ToolCall<'_>,
step_dir: &Path,
stop: &AtomicBool,
bound: Option<crate::config::ToolOutputBound>,
) -> Result<ToolOutcome, ExecError> {
let outcome = self.inner.execute(call, step_dir, stop, bound)?;
let dest = self.worktree.join("skills/notes/SKILL.md");
std::fs::create_dir_all(dest.parent().expect("skills/notes has a parent"))
.and_then(|()| std::fs::write(&dest, &self.body))
.expect("the reviewer's tree is writable");
Ok(outcome)
}
}
fn scripted_reviewer(subject: &str) -> StubAdapter {
StubAdapter::scripted([
StubAdapter::reply_ok(&stream_of(
FinishReason::ToolUse,
&[Block::ToolUse {
id: "tu-1",
name: "apply_patch",
input: json!({ "patch": "*** Update File: skills/notes/SKILL.md" }),
}],
)),
StubAdapter::reply_ok(&stream_of(FinishReason::Stop, &[Block::Text(subject)])),
])
}
fn cfg(role: &str, tools: &[&str], workflow: &str) -> WorkerConfig {
WorkerConfig {
role: role.into(),
tools: tools.iter().map(|t| (*t).to_string()).collect(),
workflow: Workflow::parse(workflow, Path::new("workflow.yaml")).unwrap(),
..worker_config()
}
}
#[test]
fn a_reviewer_proposes_an_operator_accepts_and_the_next_election_sees_it() {
let (holder, ws) = fixture::workspace();
let parent = "20260101-a1";
let parent_wt = fixture::spawn_root(&ws, parent);
fixture::amend_config(
&ws,
&[("skills/notes/SKILL.md", manifest("the lesson").as_str())],
);
let git = RealGit::new();
let clock = SystemClock;
let id = NanoIdGen;
let sleeper = StubSleeper::default();
let stop = AtomicBool::new(false);
let rec = RecLauncher::default();
let cfg_root = tempfile::TempDir::new().unwrap();
let data_root = holder.path().join("data");
let reviewer = dispatch_child(
&ChildDispatchRequest {
repo: &ws,
parent_branch: parent,
parent_worktree: &parent_wt,
role: "reviewer",
goal: "review the span",
name: None,
fork_point: None,
cwd: None,
pins: crate::prompt::PinnedDocs::none(),
},
&git,
&clock,
&id,
&rec,
test_rng(),
)
.unwrap();
let patched = manifest("the lesson, corrected by review");
let tools = PatchingTools {
inner: StubToolExecutor::ok(),
worktree: agent_worktree(&ws, &reviewer),
body: patched.clone(),
};
let adapter = scripted_reviewer("notes: record what the span taught");
let deps = Deps {
adapter: &adapter,
sleeper: &sleeper,
git: &git,
clock: &clock,
id_gen: &id,
tool_executor: &tools,
config_root: cfg_root.path(),
data_root: &data_root,
adapter_target: None,
stop: &stop,
launcher: &rec,
rng: test_rng(),
};
for _ in 0..2 {
run(&ws, &reviewer, None, &deps, &mut || {
Ok(cfg("reviewer", &["apply_patch"], "events: {}\n"))
})
.unwrap();
}
let quiet = unreachable_adapter();
let deps = Deps {
adapter: &quiet,
tool_executor: &StubToolExecutor::ok(),
..deps
};
let out = run(&ws, parent, None, &deps, &mut || {
Ok(cfg("worker", &["bash"], STAGE))
})
.unwrap();
assert!(
matches!(out, AdvanceOutcome::NothingToDo),
"a review wakes nobody into a model call: {out:?}"
);
assert!(
!parent_wt.join("messages").exists(),
"and enters no transcript"
);
let rows = proposal::list(&ws, &git).unwrap();
assert_eq!(rows.len(), 1, "one proposal: {rows:?}");
assert_eq!(rows[0].id, reviewer);
assert!(rows[0].fresh);
assert_eq!(rows[0].subject, "notes: record what the span taught");
proposal::accept(&ws, &reviewer, &git).unwrap();
let tip = git
.run_capture(&repo_git(&ws), &["rev-parse", &config_ref("default")])
.unwrap();
assert_eq!(
git.run_capture(
&repo_git(&ws),
&["show", &format!("{}:skills/notes/SKILL.md", tip.trim())]
)
.unwrap()
.trim(),
patched.trim(),
"the lineage carries the accepted body"
);
let mut out = Vec::new();
crate::prompt::tool::builtin::load_skill::run(
&mut std::io::Cursor::new(json!({"name": "notes"}).to_string().into_bytes()),
&mut out,
&election_env(&ws, parent),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(parent_wt.join("skills/notes/SKILL.md")).unwrap(),
patched,
"the next election reads the accepted lesson"
);
}
struct ElectionEnv(std::collections::HashMap<&'static str, std::ffi::OsString>);
impl crate::prompt::tool::builtin::dispatch::EnvLookup for ElectionEnv {
fn get(&self, key: &str) -> Option<std::ffi::OsString> {
self.0.get(key).cloned()
}
}
fn election_env(ws: &Path, agent: &str) -> ElectionEnv {
ElectionEnv(std::collections::HashMap::from([
("LITANY_CONV_REPO", ws.as_os_str().to_owned()),
("LITANY_CONV_BRANCH", std::ffi::OsString::from(agent)),
("LITANY_HOME", ws.as_os_str().to_owned()),
]))
}