use super::output_schema::{SubagentOutputPhase, SubagentOutputSchemaRef};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashSet},
fs,
path::{Path, PathBuf},
str::FromStr,
};
pub const MAX_SUBAGENT_PROFILE_BYTES: u64 = 64 * 1024;
const ID_RULES: &str = "identity id must use only ASCII letters, digits, '_' or '-'";
const SUBAGENTS_FRAGMENT: &str = "subagents.md";
const DEFAULT_SUBAGENTS_TEMPLATE: &str = include_str!("../../prompts/subagents.md");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentModelOverride {
pub provider: String,
pub model: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfile {
pub id: String,
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<SubagentModelOverride>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<crate::thinking::ThinkingLevel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<SubagentOutputSchemaRef>,
pub path: PathBuf,
pub prompt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfileDiagnostic {
pub id: Option<String>,
pub path: Option<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfileDiscovery {
pub profiles: BTreeMap<String, SubagentProfile>,
pub diagnostics: Vec<SubagentProfileDiagnostic>,
}
pub fn validate_subagent_identity_id(id: &str) -> anyhow::Result<String> {
if id.is_empty() {
anyhow::bail!("subagent identity id must not be empty; {ID_RULES}");
}
if id != id.trim() {
anyhow::bail!(
"subagent identity id must not have leading or trailing whitespace; {ID_RULES}"
);
}
if id == "." || id == ".." {
anyhow::bail!("subagent identity id must not be a path segment; {ID_RULES}");
}
if !id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
anyhow::bail!("{ID_RULES}");
}
Ok(id.to_string())
}
pub fn discover_subagent_profiles(root: &Path) -> SubagentProfileDiscovery {
let mut discovery = SubagentProfileDiscovery::default();
let metadata = match fs::symlink_metadata(root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return discovery,
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not inspect subagent profiles directory: {error}"),
});
return discovery;
}
};
if metadata.file_type().is_symlink() {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: "subagent profiles root directory must not be a symlink".to_string(),
});
return discovery;
}
if !metadata.is_dir() {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: "subagent profiles root must be a directory".to_string(),
});
return discovery;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not read subagent profiles directory: {error}"),
});
return discovery;
}
};
let mut paths = entries
.filter_map(|entry| match entry {
Ok(entry) => Some(entry.path()),
Err(error) => {
discovery.diagnostics.push(SubagentProfileDiagnostic {
id: None,
path: Some(root.to_path_buf()),
message: format!("could not inspect subagent profile directory entry: {error}"),
});
None
}
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
.collect::<Vec<_>>();
paths.sort();
for path in paths {
match load_subagent_profile(&path) {
Ok(profile) => {
discovery.profiles.insert(profile.id.clone(), profile);
}
Err(error) => discovery.diagnostics.push(SubagentProfileDiagnostic {
id: path
.file_stem()
.and_then(|stem| stem.to_str())
.map(ToString::to_string),
path: Some(path.clone()),
message: format!(
"skipping subagent profile {}: {error}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("<non-utf8>")
),
}),
}
}
discovery
}
pub fn load_subagent_profile(path: &Path) -> anyhow::Result<SubagentProfile> {
let id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| anyhow::anyhow!("profile filename stem must be valid UTF-8"))?;
let id = validate_subagent_identity_id(id)?;
let text = crate::prompt_file::read_prompt_file(path, MAX_SUBAGENT_PROFILE_BYTES, true)?.text;
let (frontmatter, body) = parse_profile_markdown(&text)?;
let _ = required_string(&frontmatter, "name")?;
let description = required_string(&frontmatter, "description")?;
let model = optional_model_override(&frontmatter)?;
let reasoning = optional_reasoning(&frontmatter)?;
let output_schema = optional_output_schema(&frontmatter)?;
let prompt = body.trim().to_string();
if prompt.is_empty() {
anyhow::bail!("profile body must not be empty");
}
Ok(SubagentProfile {
name: id.clone(),
id,
description,
model,
reasoning,
output_schema,
path: path.to_path_buf(),
prompt,
})
}
pub fn render_subagent_profiles_prompt(
prompt_dir: Option<&Path>,
discovery: &SubagentProfileDiscovery,
) -> anyhow::Result<Option<String>> {
if discovery.profiles.is_empty() && discovery.diagnostics.is_empty() {
return Ok(None);
}
let template = load_subagents_prompt_template(prompt_dir)?;
render_subagent_profiles_prompt_from_template(SUBAGENTS_FRAGMENT, &template, discovery)
.map(Some)
}
pub fn filter_enabled_profiles(
discovery: &SubagentProfileDiscovery,
disabled: &HashSet<String>,
) -> SubagentProfileDiscovery {
SubagentProfileDiscovery {
profiles: discovery
.profiles
.iter()
.filter(|(id, _)| !disabled.contains(*id))
.map(|(id, profile)| (id.clone(), profile.clone()))
.collect(),
diagnostics: discovery.diagnostics.clone(),
}
}
fn load_subagents_prompt_template(prompt_dir: Option<&Path>) -> anyhow::Result<String> {
if let Some(prompt_dir) = prompt_dir {
let path = prompt_dir.join(SUBAGENTS_FRAGMENT);
if path.exists() {
return crate::prompt_file::read_prompt_file(&path, MAX_SUBAGENT_PROFILE_BYTES, false)
.map(|file| file.text)
.map_err(|error| {
anyhow::anyhow!(
"failed to read prompt template fragment '{}' at {}: {error}",
SUBAGENTS_FRAGMENT,
path.display()
)
});
}
}
Ok(DEFAULT_SUBAGENTS_TEMPLATE.to_string())
}
fn render_subagent_profiles_prompt_from_template(
template_name: &'static str,
template: &str,
discovery: &SubagentProfileDiscovery,
) -> anyhow::Result<String> {
render_subagents_template(
template_name,
template,
&render_subagent_profiles_list(discovery),
)
}
fn render_subagent_profiles_list(discovery: &SubagentProfileDiscovery) -> String {
let mut out = String::new();
if discovery.profiles.is_empty() {
out.push_str("- none\n");
} else {
for profile in discovery.profiles.values() {
out.push_str(&format!("- `{}` — {}\n", profile.id, profile.description));
}
}
if !discovery.diagnostics.is_empty() {
out.push_str("\nProfile diagnostics (not selectable):\n");
for diagnostic in &discovery.diagnostics {
let label = diagnostic
.id
.as_deref()
.or_else(|| {
diagnostic
.path
.as_ref()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
})
.unwrap_or("unknown");
out.push_str(&format!("- `{label}`: {}\n", diagnostic.message));
}
}
out.trim_end().to_string()
}
fn render_subagents_template(
template_name: &'static str,
template: &str,
list_subagents: &str,
) -> anyhow::Result<String> {
let mut rendered = String::with_capacity(template.len() + list_subagents.len());
let mut rest = template;
loop {
let Some(open) = rest.find("{{") else {
if let Some(close) = rest.find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': unexpected '}}}}' at byte {close}",
);
}
rendered.push_str(rest);
return Ok(rendered.trim_end().to_string());
};
if let Some(close) = rest[..open].find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': unexpected '}}}}' at byte {close}",
);
}
rendered.push_str(&rest[..open]);
let after_open = &rest[open + 2..];
let Some(close) = after_open.find("}}") else {
anyhow::bail!(
"malformed prompt template variable in '{template_name}': missing closing '}}}}' for token starting at byte {open}",
);
};
let token = &after_open[..close];
let replacement = match token.trim() {
"LIST_SUBAGENTS" if token == "LIST_SUBAGENTS" => list_subagents,
"LIST_SUBAGENTS" => anyhow::bail!(
"malformed prompt template variable in '{template_name}': use '{{{{LIST_SUBAGENTS}}}}' without extra whitespace",
),
other => anyhow::bail!(
"unsupported prompt template variable in '{template_name}': '{{{{{other}}}}}'; supported variable is '{{{{LIST_SUBAGENTS}}}}'",
),
};
rendered.push_str(replacement);
rest = &after_open[close + 2..];
}
}
fn parse_profile_markdown(text: &str) -> anyhow::Result<(BTreeMap<String, String>, String)> {
if !text.starts_with("---\n") {
anyhow::bail!("missing YAML frontmatter with required 'name' and 'description'");
}
let rest = &text[4..];
let Some(end) = rest.find("\n---\n") else {
anyhow::bail!("frontmatter start marker without closing marker");
};
let frontmatter_text = &rest[..end];
let body = rest[end + 5..].to_string();
let mut frontmatter = BTreeMap::new();
for line in frontmatter_text
.lines()
.filter(|line| !line.trim().is_empty())
{
if line.starts_with(char::is_whitespace) {
continue;
}
let Some((key, value)) = line.split_once(':') else {
anyhow::bail!("malformed frontmatter line: {line}");
};
let key = key.trim();
if matches!(
key,
"name" | "description" | "model" | "reasoning" | "output_schema"
) {
frontmatter.insert(key.to_string(), value.trim().trim_matches('"').to_string());
}
}
Ok((frontmatter, body))
}
fn optional_model_override(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<SubagentModelOverride>> {
let Some(value) = optional_string(frontmatter, "model") else {
return Ok(None);
};
let model_id = crate::model_catalog::ModelId::parse(&value)?;
Ok(Some(SubagentModelOverride {
provider: model_id.provider().to_string(),
model: model_id.model().to_string(),
}))
}
fn optional_reasoning(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<crate::thinking::ThinkingLevel>> {
let Some(value) = optional_string(frontmatter, "reasoning") else {
return Ok(None);
};
crate::thinking::ThinkingLevel::from_str(&value)
.map(Some)
.map_err(|error| anyhow::anyhow!(error.replace("thinking_level", "reasoning")))
}
fn optional_output_schema(
frontmatter: &BTreeMap<String, String>,
) -> anyhow::Result<Option<SubagentOutputSchemaRef>> {
let Some(value) = optional_string(frontmatter, "output_schema") else {
return Ok(None);
};
if value == "none" {
return Ok(Some(SubagentOutputSchemaRef::None));
}
if let Some(phase) = SubagentOutputPhase::from_schema_ref(&value) {
return Ok(Some(SubagentOutputSchemaRef::Builtin(phase)));
}
if let Some(raw_schema) = value.strip_prefix("inline:") {
let schema: serde_json::Value = serde_json::from_str(raw_schema).map_err(|error| {
anyhow::anyhow!("invalid output_schema inline JSON object: {error}")
})?;
if !schema.is_object() {
anyhow::bail!("output_schema inline value must be a JSON object");
}
return Ok(Some(SubagentOutputSchemaRef::Inline(schema)));
}
anyhow::bail!(
"invalid output_schema '{value}'; expected none, plan, research, implement, review, document, or inline:<json object>"
)
}
fn optional_string(frontmatter: &BTreeMap<String, String>, key: &str) -> Option<String> {
frontmatter
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn required_string(frontmatter: &BTreeMap<String, String>, key: &str) -> anyhow::Result<String> {
let value = frontmatter
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required frontmatter field '{key}'"))?;
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_profile(root: &Path, name: &str, text: &str) {
fs::write(root.join(name), text).unwrap();
}
#[test]
fn discovers_valid_profiles_from_direct_markdown_files() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend-dev.md",
"---\nname: Frontend Developer\ndescription: UI work\n---\nPrompt body\n",
);
fs::create_dir(temp.path().join("nested.md")).unwrap();
write_profile(temp.path(), "ignore.txt", "ignored");
let discovery = discover_subagent_profiles(temp.path());
assert!(
discovery
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("regular file"))
);
let profile = &discovery.profiles["frontend-dev"];
assert_eq!(profile.id, "frontend-dev");
assert_eq!(profile.name, "frontend-dev");
assert_eq!(profile.description, "UI work");
assert_eq!(profile.prompt, "Prompt body");
}
#[test]
fn extra_frontmatter_fields_are_ignored_for_profile_loading() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend-dev.md",
"---\nname: Frontend Developer\ndescription: UI work\ntools:\n - read\n - write\nskills:\n - rust-dev\ncolor: blue\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(
discovery.diagnostics.is_empty(),
"{:?}",
discovery.diagnostics
);
let profile = &discovery.profiles["frontend-dev"];
assert_eq!(profile.description, "UI work");
assert_eq!(profile.prompt, "Prompt body");
}
#[test]
fn subagent_profile_output_schema_loads_builtin_and_inline() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"planner.md",
"---\nname: Planner\ndescription: Plan work\noutput_schema: plan\n---\nPrompt body\n",
);
write_profile(
temp.path(),
"custom.md",
"---\nname: Custom\ndescription: Custom schema\noutput_schema: inline:{\"type\":\"object\"}\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(
discovery.diagnostics.is_empty(),
"{:?}",
discovery.diagnostics
);
assert_eq!(
discovery.profiles["planner"].output_schema,
Some(SubagentOutputSchemaRef::Builtin(SubagentOutputPhase::Plan))
);
assert!(matches!(
discovery.profiles["custom"].output_schema,
Some(SubagentOutputSchemaRef::Inline(_))
));
}
#[test]
fn subagent_profile_output_schema_invalid_value_diagnostic() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"bad-schema.md",
"---\nname: Bad\ndescription: Bad schema\noutput_schema: banana\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
assert_eq!(discovery.diagnostics.len(), 1);
assert!(
discovery.diagnostics[0]
.message
.contains("invalid output_schema"),
"{}",
discovery.diagnostics[0].message
);
}
#[test]
fn optional_model_and_reasoning_frontmatter_are_loaded() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend-dev.md",
"---\nname: Frontend Developer\ndescription: UI work\nmodel: openai-codex/gpt-5.5\nreasoning: high\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(
discovery.diagnostics.is_empty(),
"{:?}",
discovery.diagnostics
);
let profile = &discovery.profiles["frontend-dev"];
let model = profile.model.as_ref().expect("model override");
assert_eq!(model.provider, "openai-codex");
assert_eq!(model.model, "gpt-5.5");
assert_eq!(
profile.reasoning,
Some(crate::thinking::ThinkingLevel::High)
);
}
#[test]
fn invalid_model_and_reasoning_frontmatter_are_diagnostics() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"bad-model.md",
"---\nname: Bad Model\ndescription: Bad\nmodel: missing-provider-model\n---\nPrompt body\n",
);
write_profile(
temp.path(),
"bad-reasoning.md",
"---\nname: Bad Reasoning\ndescription: Bad\nreasoning: maximum\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
let messages = discovery
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
messages.contains("model id must use provider/model-name format"),
"{messages}"
);
assert!(messages.contains("invalid reasoning"), "{messages}");
}
#[test]
fn malformed_missing_fields_empty_body_and_invalid_ids_are_diagnostics() {
let temp = TempDir::new().unwrap();
write_profile(temp.path(), "malformed.md", "---\nname nope\n---\nBody\n");
write_profile(temp.path(), "missing.md", "---\nname: Missing\n---\nBody\n");
write_profile(
temp.path(),
"empty.md",
"---\nname: Empty\ndescription: Empty body\n---\n \n",
);
write_profile(
temp.path(),
"bad.id.md",
"---\nname: Bad\ndescription: Bad id\n---\nBody\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
let messages = discovery
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(messages.contains("malformed frontmatter"), "{messages}");
assert!(
messages.contains("missing required frontmatter field 'description'"),
"{messages}"
);
assert!(messages.contains("body must not be empty"), "{messages}");
assert!(messages.contains("ASCII letters"), "{messages}");
}
#[test]
fn oversized_profile_is_rejected_without_prompt_body_in_diagnostic() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("big.md");
let mut file = fs::File::create(&path).unwrap();
writeln!(file, "---\nname: Big\ndescription: Large\n---").unwrap();
file.write_all(&vec![b's'; MAX_SUBAGENT_PROFILE_BYTES as usize + 1])
.unwrap();
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
let message = &discovery.diagnostics[0].message;
assert!(message.contains("exceeding"), "{message}");
assert!(
message.contains(&MAX_SUBAGENT_PROFILE_BYTES.to_string()),
"{message}"
);
assert!(!message.contains("ssssssss"), "{message}");
}
#[cfg(unix)]
#[test]
fn symlinked_profile_file_is_rejected_before_prompt_read() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("target.md");
fs::write(
&target,
"---\nname: Target\ndescription: Linked profile\n---\nSECRET PROMPT BODY\n",
)
.unwrap();
let linked = temp.path().join("linked.md");
std::os::unix::fs::symlink(&target, &linked).unwrap();
let error = load_subagent_profile(&linked).unwrap_err().to_string();
assert!(error.contains("symlink"), "{error}");
assert!(!error.contains("SECRET PROMPT BODY"), "{error}");
}
#[cfg(unix)]
#[test]
fn discovery_rejects_symlinked_profile_with_redacted_diagnostic() {
let temp = TempDir::new().unwrap();
let profiles = temp.path().join("profiles");
fs::create_dir(&profiles).unwrap();
let target = temp.path().join("target.md");
fs::write(
&target,
"---\nname: Target\ndescription: Linked profile\n---\nSECRET PROMPT BODY\n",
)
.unwrap();
std::os::unix::fs::symlink(&target, profiles.join("linked.md")).unwrap();
let discovery = discover_subagent_profiles(&profiles);
assert!(discovery.profiles.is_empty(), "{:?}", discovery.profiles);
assert_eq!(discovery.diagnostics.len(), 1);
assert_eq!(discovery.diagnostics[0].id.as_deref(), Some("linked"));
let message = &discovery.diagnostics[0].message;
assert!(message.contains("symlink"), "{message}");
assert!(!message.contains("SECRET PROMPT BODY"), "{message}");
}
#[test]
fn duplicate_frontmatter_names_are_normalized_to_distinct_identity_ids() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"a.md",
"---\nname: Same\ndescription: One\n---\nPrompt A\n",
);
write_profile(
temp.path(),
"b.md",
"---\nname: Same\ndescription: Two\n---\nPrompt B\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.diagnostics.is_empty());
assert_eq!(discovery.profiles["a"].name, "a");
assert_eq!(discovery.profiles["b"].name, "b");
}
#[test]
fn validates_identity_ids() {
assert_eq!(
validate_subagent_identity_id("frontend-dev_1").unwrap(),
"frontend-dev_1"
);
for bad in [
"",
" ",
"frontend ",
" frontend",
"\tfrontend",
"frontend\n",
".",
"..",
"../x",
"x/y",
"x y",
"x.y",
"x\\y",
] {
assert!(validate_subagent_identity_id(bad).is_err(), "{bad:?}");
}
}
#[test]
fn whitespace_prefixed_profile_filename_is_rejected() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
" frontend.md",
"---\nname: Frontend\ndescription: UI work\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
assert!(discovery.profiles.is_empty());
assert_eq!(discovery.diagnostics.len(), 1);
assert_eq!(discovery.diagnostics[0].id.as_deref(), Some(" frontend"));
assert!(
discovery.diagnostics[0].message.contains("ASCII letters"),
"{}",
discovery.diagnostics[0].message
);
}
#[test]
fn disabled_profiles_are_filtered_but_diagnostics_remain() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend.md",
"---\nname: Frontend\ndescription: UI work\n---\nPrompt body\n",
);
write_profile(
temp.path(),
"review.md",
"---\nname: Review\ndescription: Code review\n---\nPrompt body\n",
);
write_profile(temp.path(), "bad.md", "---\nname: Bad\n---\nPrompt body\n");
let discovery = discover_subagent_profiles(temp.path());
let disabled = HashSet::from(["frontend".to_string()]);
let filtered = filter_enabled_profiles(&discovery, &disabled);
let rendered = render_subagent_profiles_list(&filtered);
assert!(!filtered.profiles.contains_key("frontend"));
assert!(filtered.profiles.contains_key("review"));
assert_eq!(filtered.diagnostics, discovery.diagnostics);
assert!(!rendered.contains("frontend"), "{rendered}");
assert!(rendered.contains("review"), "{rendered}");
assert!(rendered.contains("Profile diagnostics"), "{rendered}");
}
#[test]
fn rendered_parent_metadata_excludes_prompt_bodies() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend.md",
"---\nname: Frontend\ndescription: UI work\n---\nSECRET BODY\n",
);
let discovery = discover_subagent_profiles(temp.path());
let rendered = render_subagent_profiles_prompt(None, &discovery)
.unwrap()
.unwrap();
assert!(rendered.contains("frontend"));
assert!(rendered.contains("UI work"));
assert!(!rendered.contains("SECRET BODY"));
}
#[test]
fn rendered_parent_metadata_omits_name_when_it_matches_identity_id() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend-dev.md",
"---\nname: frontend-dev\ndescription: UI work\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
let rendered = render_subagent_profiles_list(&discovery);
assert_eq!(rendered, "- `frontend-dev` — UI work");
}
#[test]
fn rendered_parent_metadata_normalizes_distinct_frontmatter_name_to_identity_id() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"frontend-dev.md",
"---\nname: Frontend Dev\ndescription: UI work\n---\nPrompt body\n",
);
let discovery = discover_subagent_profiles(temp.path());
let rendered = render_subagent_profiles_list(&discovery);
assert_eq!(discovery.profiles["frontend-dev"].name, "frontend-dev");
assert_eq!(rendered, "- `frontend-dev` — UI work");
assert!(!rendered.contains("Frontend Dev"), "{rendered}");
}
#[test]
fn rendered_parent_metadata_uses_prompt_template_and_placeholder() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"backend.md",
"---\nname: Backend\ndescription: API work\n---\nSECRET BODY\n",
);
let prompt_dir = temp.path().join("prompts");
fs::create_dir_all(&prompt_dir).unwrap();
fs::write(
prompt_dir.join("subagents.md"),
"# Custom Subagents\n\nVisible choices:\n{{LIST_SUBAGENTS}}\n",
)
.unwrap();
let discovery = discover_subagent_profiles(temp.path());
let rendered = render_subagent_profiles_prompt(Some(&prompt_dir), &discovery)
.unwrap()
.unwrap();
assert!(rendered.starts_with("# Custom Subagents"), "{rendered}");
assert!(
rendered.contains("Visible choices:\n- `backend` — API work"),
"{rendered}"
);
assert!(!rendered.contains("SECRET BODY"), "{rendered}");
assert!(!rendered.contains("## Subagent identities"), "{rendered}");
}
#[test]
fn rendered_parent_metadata_reports_none_when_only_diagnostics_exist() {
let temp = TempDir::new().unwrap();
write_profile(
temp.path(),
"bad id.md",
"---\nname: Bad\ndescription: Bad\n---\nBody\n",
);
let discovery = discover_subagent_profiles(temp.path());
let rendered = render_subagent_profiles_prompt(None, &discovery)
.unwrap()
.unwrap();
assert!(rendered.contains("- none"), "{rendered}");
assert!(
rendered.contains("Profile diagnostics (not selectable):"),
"{rendered}"
);
assert!(rendered.contains("ASCII letters"), "{rendered}");
}
#[test]
fn empty_discovery_has_no_parent_metadata_section() {
let discovery = SubagentProfileDiscovery::default();
assert_eq!(
render_subagent_profiles_prompt(None, &discovery).unwrap(),
None
);
}
}