use std::collections::BTreeMap;
use std::fs;
use std::sync::Arc;
use defect_acp::EchoProvider;
use defect_agent::hooks::builtin::BuiltinRegistry;
use defect_agent::llm::{LlmProvider, ModelInfo, ProviderRegistry};
use defect_agent::policy::{AskWritesPolicy, SandboxPolicy};
use defect_agent::tool::SkillEntry;
use defect_config::{
LoadConfigOptions, LoadedConfig, ProfileSpec, discover_profiles, discover_skills, load_config,
};
use tempfile::TempDir;
use crate::hooks::HookEngineCtx;
use crate::tools::{build_process_tools_with_subagents, project_skills};
use defect_agent::session::filter_registry_by_allowlist;
fn no_skills() -> BTreeMap<String, SkillEntry> {
BTreeMap::new()
}
fn echo_registry() -> Arc<ProviderRegistry> {
let provider: Arc<dyn LlmProvider> = Arc::new(EchoProvider::new());
ProviderRegistry::single(
provider,
ModelInfo {
id: "echo-1".to_string(),
display_name: None,
context_window: None,
max_output_tokens: None,
deprecated: false,
capabilities_overrides: Default::default(),
},
)
}
fn setup() -> (TempDir, LoadedConfig, LoadConfigOptions) {
let tmp = TempDir::new().expect("tmp");
let repo = tmp.path().join("repo");
fs::create_dir_all(repo.join(".git")).expect("git");
let opts = LoadConfigOptions {
cwd: repo,
xdg_config_home: Some(tmp.path().join("xdg")),
..LoadConfigOptions::default()
};
let config = load_config(opts.clone()).expect("load config");
(tmp, config, opts)
}
fn write_profile(opts: &LoadConfigOptions, name: &str, config_toml: &str, system_md: &str) {
let dir = opts.cwd.join(".defect/agents").join(name);
fs::create_dir_all(&dir).expect("mkdir profile");
fs::write(dir.join("config.toml"), config_toml).expect("config.toml");
fs::write(dir.join("system.md"), system_md).expect("system.md");
}
fn discover(opts: &LoadConfigOptions) -> BTreeMap<String, ProfileSpec> {
discover_profiles(opts).expect("discover")
}
fn write_skill(opts: &LoadConfigOptions, name: &str, skill_md: &str) {
let dir = opts.cwd.join(".defect/skills").join(name);
fs::create_dir_all(&dir).expect("mkdir skill");
fs::write(dir.join("SKILL.md"), skill_md).expect("SKILL.md");
}
fn discover_skill_index(opts: &LoadConfigOptions) -> BTreeMap<String, SkillEntry> {
project_skills(&discover_skills(opts).expect("discover skills"))
}
fn policy() -> Arc<dyn SandboxPolicy> {
Arc::new(AskWritesPolicy::new())
}
fn assemble(
config: &LoadedConfig,
profiles: &BTreeMap<String, ProfileSpec>,
skills: &BTreeMap<String, SkillEntry>,
registry: &Arc<ProviderRegistry>,
policy: &Arc<dyn SandboxPolicy>,
base_prompt: Option<String>,
) -> Arc<dyn defect_agent::session::ToolRegistry> {
let builtins = BuiltinRegistry::defaults();
let hook_rt = HookEngineCtx {
registry,
default_model: "echo-1",
};
build_process_tools_with_subagents(
config,
profiles,
skills,
registry,
policy,
base_prompt,
&builtins,
&hook_rt,
)
.expect("assemble tools")
}
#[test]
fn spawn_agent_registered_when_profiles_exist() {
let (_tmp, config, opts) = setup();
write_profile(
&opts,
"reviewer",
"description = \"review diffs\"\n",
"you are reviewer",
);
let profiles = discover(&opts);
let tools = assemble(
&config,
&profiles,
&no_skills(),
&echo_registry(),
&policy(),
None,
);
let names: Vec<String> = tools.schemas().into_iter().map(|s| s.name).collect();
assert!(names.contains(&"spawn_agent".to_string()), "got: {names:?}");
assert!(names.contains(&"read_file".to_string()));
}
#[test]
fn spawn_agent_absent_when_no_profiles() {
let (_tmp, config, opts) = setup();
let profiles = discover(&opts);
assert!(profiles.is_empty());
let tools = assemble(
&config,
&profiles,
&no_skills(),
&echo_registry(),
&policy(),
None,
);
let names: Vec<String> = tools.schemas().into_iter().map(|s| s.name).collect();
assert!(
!names.contains(&"spawn_agent".to_string()),
"got: {names:?}"
);
}
#[test]
fn spawn_agent_schema_lists_profile_in_enum() {
let (_tmp, config, opts) = setup();
write_profile(
&opts,
"reviewer",
"description = \"review diffs for races\"\n",
"sys",
);
let profiles = discover(&opts);
let tools = assemble(
&config,
&profiles,
&no_skills(),
&echo_registry(),
&policy(),
None,
);
let schema = tools
.schemas()
.into_iter()
.find(|s| s.name == "spawn_agent")
.expect("spawn_agent schema");
assert!(schema.description.contains("review diffs for races"));
let enum_vals = schema.input_schema["properties"]["profile"]["enum"]
.as_array()
.expect("enum");
assert!(enum_vals.iter().any(|v| v == "reviewer"));
}
#[test]
fn top_level_profile_filters_tools_by_allowlist() {
let (_tmp, config, opts) = setup();
write_profile(
&opts,
"reader",
"description = \"reads\"\n[tools]\nallow = [\"read_file\", \"search\"]\n",
"sys",
);
let profiles = discover(&opts);
let spec = &profiles["reader"];
let base = crate::tools::build_process_tools(&config);
let filtered = filter_registry_by_allowlist(&base, &spec.tool_allow).expect("filter");
let names: Vec<String> = filtered.schemas().into_iter().map(|s| s.name).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"read_file".to_string()));
assert!(names.contains(&"search".to_string()));
assert!(!names.contains(&"bash".to_string()));
assert!(!names.contains(&"write_file".to_string()));
}
#[test]
fn top_level_profile_unknown_tool_fails_loud() {
let base = crate::tools::build_process_tools(&setup().1);
match filter_registry_by_allowlist(&base, &["nonexistent_tool".to_string()]) {
Err(pattern) => assert_eq!(pattern, "nonexistent_tool"),
Ok(_) => panic!("expected unknown-tool error"),
}
}
#[test]
fn skill_tool_registered_when_skills_exist() {
let (_tmp, config, opts) = setup();
write_skill(
&opts,
"code-review",
"+++\nname = \"code-review\"\ndescription = \"review Rust diffs\"\n+++\nbody\n",
);
let skills = discover_skill_index(&opts);
let profiles = discover(&opts);
let tools = assemble(
&config,
&profiles,
&skills,
&echo_registry(),
&policy(),
None,
);
let schema = tools
.schemas()
.into_iter()
.find(|s| s.name == "skill")
.expect("skill schema");
assert!(schema.description.contains("review Rust diffs"));
let enum_vals = schema.input_schema["properties"]["name"]["enum"]
.as_array()
.expect("enum");
assert!(enum_vals.iter().any(|v| v == "code-review"));
let names: Vec<String> = tools.schemas().into_iter().map(|s| s.name).collect();
assert!(names.contains(&"read_file".to_string()));
assert!(
!names.contains(&"spawn_agent".to_string()),
"got: {names:?}"
);
}
#[test]
fn skill_tool_absent_when_no_skills() {
let (_tmp, config, opts) = setup();
let skills = discover_skill_index(&opts);
assert!(skills.is_empty());
let tools = assemble(
&config,
&discover(&opts),
&skills,
&echo_registry(),
&policy(),
None,
);
let names: Vec<String> = tools.schemas().into_iter().map(|s| s.name).collect();
assert!(!names.contains(&"skill".to_string()), "got: {names:?}");
}
#[test]
fn main_session_auto_mounts_skill_hooks() {
use defect_agent::hooks::HookCtx;
use defect_agent::hooks::step::{AfterSessionEnter, BeforeIngest, IngestSource, SessionSource};
let (_tmp, config, opts) = setup();
write_skill(
&opts,
"style",
"+++\nname = \"style\"\ndescription = \"coding style\"\nalways = true\n+++\nALWAYS USE TABS\n",
);
write_skill(
&opts,
"sql",
"+++\nname = \"sql\"\ndescription = \"sql help\"\n[triggers]\nglobs = [\"**/*.sql\"]\n+++\nSQL body\n",
);
let skills = Arc::new(discover_skill_index(&opts));
assert_eq!(skills.len(), 2);
let builtins = BuiltinRegistry::defaults();
let registry = echo_registry();
let hook_rt = HookEngineCtx {
registry: ®istry,
default_model: "echo-1",
};
let engine = crate::hooks::build_main_session_engine(
&config.effective.hooks,
&builtins,
&hook_rt,
&skills,
None,
)
.expect("engine");
let session_id = agent_client_protocol_schema::SessionId::new("s1");
let cwd = std::path::Path::new("/");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("rt");
let mut enter = AfterSessionEnter {
cwd: "/".to_string(),
source: SessionSource::New,
additional_context: Vec::new(),
};
rt.block_on(engine.dispatch(
&mut enter,
HookCtx::new(&session_id, cwd, tokio_util::sync::CancellationToken::new()),
));
let injected = enter
.additional_context
.iter()
.filter_map(|b| match b {
agent_client_protocol_schema::ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
assert!(injected.contains("**style**"), "L1 manifest: {injected}");
assert!(
injected.contains("ALWAYS USE TABS"),
"always-on body missing"
);
let mut ingest = BeforeIngest {
source: IngestSource::User,
input: vec![agent_client_protocol_schema::ContentBlock::from(
"please edit migrations/0001.sql",
)],
};
rt.block_on(engine.dispatch(
&mut ingest,
HookCtx::new(&session_id, cwd, tokio_util::sync::CancellationToken::new()),
));
let texts: Vec<&str> = ingest
.input
.iter()
.filter_map(|b| match b {
agent_client_protocol_schema::ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(texts.iter().any(|t| t.contains("`sql`")), "got: {texts:?}");
assert!(
texts.iter().any(|t| t.contains("migrations/0001.sql")),
"original prompt dropped: {texts:?}"
);
}