use super::*;
struct NoopDecomposeTool;
#[async_trait::async_trait]
impl Tool for NoopDecomposeTool {
fn name(&self) -> &'static str {
"decompose"
}
fn description(&self) -> &'static str {
"split a task"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn call(
&self,
_args: &serde_json::Value,
_ctx: &agent_base::ToolContext,
) -> agent_base::AgentResult<Vec<agent_base::Content>> {
Ok(vec![agent_base::Content::text("serial")])
}
}
fn make_runtime_with_tools(
business_tools: Vec<Arc<dyn Tool>>,
excluded: Vec<String>,
) -> Arc<MultiAgentRuntime> {
let config = MultiAgentConfig {
child_excluded_tools: excluded,
..MultiAgentConfig::enabled()
};
Arc::new(MultiAgentRuntime::new(
config,
Arc::new(StreamingStub),
business_tools,
tokio_util::sync::CancellationToken::new(),
None,
agent_base::Language::En,
None,
None,
))
}
#[tokio::test(flavor = "multi_thread")]
async fn whitelist_selects_registered_subset() {
let ma = make_runtime_with_tools(
vec![Arc::new(NoopReadFileTool), Arc::new(NoopDecomposeTool)],
vec![],
);
let spawned = ma
.spawn_with_config(
"w".to_string(),
ChildConfig {
system_prompt: Some("prompt".to_string()),
tool_names: Some(BTreeSet::from(["read_file".to_string()])),
..Default::default()
},
)
.await
.expect("whitelist spawn");
assert_eq!(spawned.spawned_tools().len(), 1);
assert!(spawned.spawned_tools().contains("read_file"));
assert_eq!(spawned.agent_path(), &AgentPath::root().join("w"));
}
#[tokio::test(flavor = "multi_thread")]
async fn whitelist_unknown_name_is_tool_not_found() {
let ma = make_runtime_with_tools(vec![Arc::new(NoopReadFileTool)], vec![]);
let err = ma
.spawn_with_config(
"w".to_string(),
ChildConfig {
system_prompt: Some("prompt".to_string()),
tool_names: Some(BTreeSet::from(["grep".to_string()])),
..Default::default()
},
)
.await
.map(|_| ())
.expect_err("unknown tool must fail");
assert!(
matches!(err, AgentError::ToolNotFound { ref name } if name == "grep"),
"expected typed ToolNotFound, got {err:?}"
);
assert_eq!(ma.registry.lock().unwrap().count(), 0);
assert_eq!(ma.limiter.current(), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn whitelist_intersection_with_excluded_warns_not_errors() {
let ma = make_runtime_with_tools(
vec![Arc::new(NoopReadFileTool), Arc::new(NoopDecomposeTool)],
vec!["decompose".to_string()],
);
let spawned = ma
.spawn_with_config(
"w".to_string(),
ChildConfig {
system_prompt: Some("prompt".to_string()),
tool_names: Some(BTreeSet::from([
"read_file".to_string(),
"decompose".to_string(),
])),
..Default::default()
},
)
.await
.expect("excluded-in-whitelist spawns with reduced set");
assert_eq!(spawned.spawned_tools().len(), 1);
assert!(spawned.spawned_tools().contains("read_file"));
assert!(!spawned.spawned_tools().contains("decompose"));
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_with_config_requires_system_prompt() {
let ma = make_runtime_with_tools(vec![Arc::new(NoopReadFileTool)], vec![]);
let err = ma
.spawn_with_config(
"w".to_string(),
ChildConfig {
system_prompt: None,
..Default::default()
},
)
.await
.map(|_| ())
.expect_err("empty prompt must fail");
assert!(
err.to_string().contains("system_prompt is required"),
"unexpected error: {err}"
);
assert_eq!(ma.limiter.current(), 0, "no gate reached before validation");
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_with_config_wires_max_turns_and_runs() {
let ma = make_runtime_with_tools(vec![Arc::new(NoopReadFileTool)], vec![]);
let spawned = ma
.spawn_with_config(
"w".to_string(),
ChildConfig {
system_prompt: Some("prompt".to_string()),
max_turns: Some(8),
context_window: Some(4096),
..Default::default()
},
)
.await
.expect("spawn with overrides");
let path = spawned.agent_path().to_string();
ma.send_task(&path, "work".to_string(), false).unwrap();
let r = ma.wait_for_result(Some(&path), 2000).await;
assert_eq!(r.status, "ok");
}