#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use serde_json::{Value, json};
use entelix_agents::Subagent;
use entelix_core::AgentContext;
use entelix_core::ExecutionContext;
use entelix_core::Result;
use entelix_core::ir::Message;
use entelix_core::tools::{Tool, ToolMetadata};
use entelix_core::{SkillRegistry, ToolRegistry};
use entelix_runnable::Runnable;
struct EchoTool {
metadata: ToolMetadata,
}
impl EchoTool {
fn new(name: &str) -> Self {
Self {
metadata: ToolMetadata::function(name, "echo input", json!({"type": "object"})),
}
}
}
#[async_trait]
impl Tool for EchoTool {
fn metadata(&self) -> &ToolMetadata {
&self.metadata
}
async fn execute(&self, input: Value, _ctx: &AgentContext<()>) -> Result<Value> {
Ok(input)
}
}
#[derive(Debug)]
struct StubModel;
#[async_trait]
impl Runnable<Vec<Message>, Message> for StubModel {
async fn invoke(&self, _input: Vec<Message>, _ctx: &ExecutionContext) -> Result<Message> {
Ok(Message::assistant("ok"))
}
}
fn parent_with(names: &[&str]) -> ToolRegistry {
let mut reg = ToolRegistry::new();
for name in names {
reg = reg
.register(Arc::new(EchoTool::new(name)) as Arc<dyn Tool>)
.unwrap();
}
reg
}
#[test]
fn restrict_to_rejects_typo_at_build_time() {
let parent = parent_with(&["alpha", "beta"]);
let err = Subagent::builder(StubModel, &parent, "test_subagent", "test description")
.restrict_to(&["alpha", "ghost"])
.build()
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("ghost") && rendered.contains("not in registry"),
"expected diagnostic naming the missing tool; got: {rendered}"
);
}
#[test]
fn restrict_to_dedup_handles_duplicate_names_correctly() {
let parent = parent_with(&["alpha", "beta"]);
let sub = Subagent::builder(StubModel, &parent, "test_subagent", "test description")
.restrict_to(&["alpha", "alpha"])
.build()
.unwrap();
assert_eq!(sub.tool_count(), 1);
}
#[test]
fn filter_predicate_evaluated_once_per_parent_tool() {
let parent = parent_with(&["alpha", "beta", "gamma"]);
let calls = Arc::new(AtomicUsize::new(0));
let calls_in = Arc::clone(&calls);
let sub = Subagent::builder(StubModel, &parent, "test_subagent", "test description")
.filter(move |t| {
calls_in.fetch_add(1, Ordering::SeqCst);
t.metadata().name == "beta"
})
.build()
.unwrap();
let post_construction = calls.load(Ordering::SeqCst);
assert_eq!(
post_construction, 3,
"predicate should be evaluated once per parent tool at construction"
);
assert_eq!(sub.tool_count(), 1);
let _ = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
sub.tool_registry()
.dispatch("call", "beta", json!({}), &ExecutionContext::new())
.await
})
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
post_construction,
"predicate must NOT re-fire on dispatch — view is frozen at construction"
);
}
#[test]
fn filter_empty_result_is_valid_pure_orchestration_subagent() {
let parent = parent_with(&["alpha", "beta"]);
let sub = Subagent::builder(StubModel, &parent, "test_subagent", "test description")
.filter(|_| false)
.build()
.unwrap();
assert_eq!(sub.tool_count(), 0);
}
#[test]
fn metadata_inspect_without_consume() {
let parent = parent_with(&["alpha", "beta"]);
let sub = Subagent::builder(
StubModel,
&parent,
"research_assistant",
"Search the web for citations.",
)
.restrict_to(&["alpha"])
.build()
.unwrap();
assert_eq!(sub.name(), "research_assistant");
assert_eq!(sub.description(), "Search the web for citations.");
let md = sub.metadata();
assert_eq!(md.name, "research_assistant");
assert_eq!(md.description, "Search the web for citations.");
assert_eq!(md.tool_count, 1);
assert_eq!(md.tool_names, vec!["alpha".to_owned()]);
assert_eq!(sub.tool_count(), 1);
}
#[test]
fn build_rejects_empty_name() {
let parent = parent_with(&["alpha"]);
let err = Subagent::builder(StubModel, &parent, "", "test description")
.build()
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("name cannot be empty"),
"expected diagnostic naming the empty field; got: {rendered}"
);
}
#[test]
fn build_rejects_empty_description() {
let parent = parent_with(&["alpha"]);
let err = Subagent::builder(StubModel, &parent, "test_subagent", "")
.build()
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("description cannot be empty"),
"expected diagnostic naming the empty field; got: {rendered}"
);
}
#[test]
fn with_skills_rejects_typo_at_construction_time() {
let parent = parent_with(&["alpha"]);
let parent_skills = SkillRegistry::new();
let err = Subagent::builder(StubModel, &parent, "test_subagent", "test description")
.restrict_to(&["alpha"])
.with_skills(&parent_skills, &["nonexistent"])
.build()
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.contains("nonexistent") && rendered.contains("not in parent registry"),
"expected diagnostic naming the missing skill; got: {rendered}"
);
}