use super::tests::{RecordingLauncher, req, test_rng};
use super::*;
use crate::workspace::fixture;
#[test]
fn missing_providers_is_surfaced_as_control_read_before_any_spawn() {
let (_h, ws) = fixture::workspace();
let g = crate::template::RealGit::new();
let author = ws.join(".strip");
let author_str = author.to_string_lossy().to_string();
g.run(
&workspace::repo_git(&ws),
&["worktree", "add", author_str.as_str(), "config/default"],
)
.unwrap();
g.run(&author, &["rm", "-q", "providers.yaml"]).unwrap();
g.run(&author, &["commit", "-m", "config: no providers"])
.unwrap();
g.run(
&workspace::repo_git(&ws),
&["worktree", "remove", "--force", author_str.as_str()],
)
.unwrap();
let parent_wt = fixture::spawn_root(&ws, "20260101-p7");
let launcher = RecordingLauncher::ok();
let err = run(
&req(&ws, "20260101-p7", &parent_wt, "g"),
&g,
&crate::prompt::SystemClock,
&crate::prompt::NanoIdGen,
&launcher,
&test_rng(),
)
.unwrap_err();
assert!(matches!(err, Error::ControlRead { .. }), "got {err:?}");
assert_eq!(workspace::agent_ids(&ws, &g).unwrap().len(), 1);
assert!(launcher.invocations.borrow().is_empty());
}
#[test]
fn the_childs_tree_carries_only_the_descriptors_its_role_grants() {
let (_h, ws) = fixture::workspace();
fixture::amend_config(
&ws,
&[
("souls/worker.md", "worker soul body\n"),
(
"providers.yaml",
"roles:\n worker:\n provider: anthropic\n \
model: claude-sonnet-5\n tools: [bash]\n",
),
("descriptions/tools/bash.json", "{}\n"),
(
"descriptions/skills/bash.md",
"name: bash\ndescription: d\n",
),
("descriptions/tools/message.json", "{}\n"),
(
"descriptions/skills/message.md",
"name: message\ndescription: d\n",
),
(
"descriptions/skills/notes.md",
"name: notes\ndescription: d\n",
),
],
);
let g = crate::template::RealGit::new();
let parent_wt = fixture::spawn_root(&ws, "20260101-p8");
let launcher = RecordingLauncher::ok();
let child = run(
&req(&ws, "20260101-p8", &parent_wt, "g"),
&g,
&crate::prompt::SystemClock,
&crate::prompt::NanoIdGen,
&launcher,
&test_rng(),
)
.unwrap();
let listing = g
.run_capture(
&workspace::repo_git(&ws),
&[
"ls-tree",
"-r",
"--name-only",
&workspace::agent_ref(&child),
],
)
.unwrap();
let has = |p: &str| listing.lines().any(|l| l == p);
assert!(has("descriptions/tools/bash.json"), "{listing}");
assert!(has("descriptions/skills/bash.md"), "{listing}");
assert!(!has("descriptions/tools/message.json"), "{listing}");
assert!(!has("descriptions/skills/message.md"), "{listing}");
assert!(has("descriptions/skills/notes.md"), "{listing}");
assert!(!has("providers.yaml"), "{listing}");
}
fn disjoint_roles(ws: &Path) {
fixture::amend_config(
ws,
&[
("souls/worker.md", "worker soul body\n"),
("souls/sensor.md", "sensor soul body\n"),
(
"providers.yaml",
"roles:\n worker:\n provider: anthropic\n \
model: claude-sonnet-5\n tools: [bash]\n sensor:\n \
provider: anthropic\n model: claude-sonnet-5\n \
tools: [message]\n",
),
("descriptions/tools/bash.json", "{}\n"),
(
"descriptions/skills/bash.md",
"name: bash\ndescription: d\n",
),
("descriptions/tools/message.json", "{}\n"),
(
"descriptions/skills/message.md",
"name: message\ndescription: d\n",
),
(
"descriptions/skills/notes.md",
"name: notes\ndescription: d\n",
),
],
);
}
pub(super) fn dispatch_role(ws: &Path, parent: &str, role: &str) -> String {
let parent_wt = workspace::agent_worktree(ws, parent);
run(
&ChildDispatchRequest {
repo: ws,
parent_branch: parent,
parent_worktree: &parent_wt,
role,
goal: "g",
name: None,
fork_point: None,
cwd: None,
pins: crate::prompt::PinnedDocs::none(),
},
&crate::template::RealGit::new(),
&crate::prompt::SystemClock,
&crate::prompt::NanoIdGen,
&RecordingLauncher::ok(),
&test_rng(),
)
.unwrap()
}
pub(super) fn tree(ws: &Path, agent: &str) -> String {
crate::template::RealGit::new()
.run_capture(
&workspace::repo_git(ws),
&["ls-tree", "-r", "--name-only", &workspace::agent_ref(agent)],
)
.unwrap()
}
#[test]
fn a_child_grant_its_dispatcher_lacks_is_not_capped_by_the_dispatch_chain() {
let (_h, ws) = fixture::workspace();
disjoint_roles(&ws);
fixture::spawn_root(&ws, "20260101-p9");
let worker = dispatch_role(&ws, "20260101-p9", "worker");
let dispatcher = tree(&ws, &worker);
assert!(
dispatcher
.lines()
.any(|l| l == "descriptions/tools/bash.json")
);
assert!(
!dispatcher
.lines()
.any(|l| l == "descriptions/tools/message.json")
);
let sensor = dispatch_role(&ws, &worker, "sensor");
let listing = tree(&ws, &sensor);
let has = |p: &str| listing.lines().any(|l| l == p);
assert!(has("descriptions/tools/message.json"), "{listing}");
assert!(has("descriptions/skills/message.md"), "{listing}");
assert!(!has("descriptions/tools/bash.json"), "{listing}");
assert!(!has("descriptions/skills/bash.md"), "{listing}");
assert!(has("descriptions/skills/notes.md"), "{listing}");
}
#[test]
fn a_granted_tool_the_config_describes_nowhere_declines_before_the_fork() {
let (_h, ws) = fixture::workspace();
fixture::amend_config(
&ws,
&[
("souls/sensor.md", "sensor soul body\n"),
(
"providers.yaml",
"roles:\n sensor:\n provider: anthropic\n \
model: claude-sonnet-5\n tools: [message, slack_read]\n",
),
("descriptions/tools/message.json", "{}\n"),
],
);
let parent_wt = fixture::spawn_root(&ws, "20260101-pa");
let g = crate::template::RealGit::new();
let launcher = RecordingLauncher::ok();
let err = run(
&ChildDispatchRequest {
repo: &ws,
parent_branch: "20260101-pa",
parent_worktree: &parent_wt,
role: "sensor",
goal: "g",
name: None,
fork_point: None,
cwd: None,
pins: crate::prompt::PinnedDocs::none(),
},
&g,
&crate::prompt::SystemClock,
&crate::prompt::NanoIdGen,
&launcher,
&test_rng(),
)
.unwrap_err();
match &err {
Error::GrantUndescribed(u) => {
assert_eq!((u.role.as_str(), u.tool.as_str()), ("sensor", "slack_read"));
assert!(u.described.contains("message"), "{}", u.described);
}
other => panic!("got {other:?}"),
}
assert_eq!(workspace::agent_ids(&ws, &g).unwrap(), ["20260101-pa"]);
assert!(launcher.invocations.borrow().is_empty());
}