use crate::{
instructions::InstructionFile,
skills::SkillDiscovery,
tools::{MVP_TOOL_CAPABILITIES, process::terminate_child_tree_and_wait},
};
use anyhow::Context;
use std::{
collections::HashSet,
env,
path::Path,
process::{Command, Stdio},
sync::OnceLock,
thread,
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
const SYSTEM_FRAGMENT: &str = "system.md";
const TOOLS_FRAGMENT: &str = "tools.md";
const SKILLS_FRAGMENT: &str = "skills.md";
const COMPACT_FRAGMENT: &str = "compact.md";
const DEFAULT_SYSTEM_TEMPLATE: &str = include_str!("../../prompts/system.md");
const DEFAULT_TOOLS_TEMPLATE: &str = include_str!("../../prompts/tools.md");
const DEFAULT_SKILLS_TEMPLATE: &str = include_str!("../../prompts/skills.md");
const DEFAULT_COMPACT_TEMPLATE: &str = include_str!("../../prompts/compact.md");
pub(super) fn build_system_prompt(
instructions: &[InstructionFile],
skills: &SkillDiscovery,
) -> anyhow::Result<String> {
build_system_prompt_with_prompt_dir(None, instructions, skills)
}
pub(super) fn build_system_prompt_with_prompt_dir(
prompt_dir: Option<&Path>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
) -> anyhow::Result<String> {
build_system_prompt_with_prompt_dir_and_subagents(prompt_dir, instructions, skills, None)
}
pub(super) fn build_system_prompt_with_prompt_dir_and_subagents(
prompt_dir: Option<&Path>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
subagents_section: Option<&str>,
) -> anyhow::Result<String> {
build_system_prompt_with_prompt_dir_and_subagents_and_disabled(
prompt_dir,
instructions,
skills,
subagents_section,
&HashSet::new(),
)
}
pub(super) fn build_system_prompt_with_prompt_dir_and_subagents_and_disabled(
prompt_dir: Option<&Path>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
subagents_section: Option<&str>,
disabled_tools: &HashSet<String>,
) -> anyhow::Result<String> {
let defaults = PromptTemplateDefaults::bundled();
build_system_prompt_from_templates(
prompt_dir,
&defaults,
instructions,
skills,
subagents_section,
disabled_tools,
)
}
pub(crate) fn load_compact_prompt(prompt_dir: Option<&Path>) -> anyhow::Result<String> {
let values = TemplateValues {
tools_list: String::new(),
skills_list: String::new(),
runtime_facts: RuntimePromptFacts::detect(),
};
render_fragment(
&load_fragment(prompt_dir, COMPACT_FRAGMENT, DEFAULT_COMPACT_TEMPLATE)?,
&values,
)
}
fn build_system_prompt_from_templates(
prompt_dir: Option<&Path>,
defaults: &PromptTemplateDefaults<'_>,
instructions: &[InstructionFile],
skills: &SkillDiscovery,
subagents_section: Option<&str>,
disabled_tools: &HashSet<String>,
) -> anyhow::Result<String> {
let values = TemplateValues::from_skills(skills, disabled_tools);
build_system_prompt_from_templates_with_values(
prompt_dir,
defaults,
instructions,
subagents_section,
&values,
)
}
fn build_system_prompt_from_templates_with_values(
prompt_dir: Option<&Path>,
defaults: &PromptTemplateDefaults<'_>,
instructions: &[InstructionFile],
subagents_section: Option<&str>,
values: &TemplateValues,
) -> anyhow::Result<String> {
let system = render_fragment(
&load_fragment(prompt_dir, SYSTEM_FRAGMENT, defaults.system)?,
values,
)?;
let tools = render_fragment(
&load_fragment(prompt_dir, TOOLS_FRAGMENT, defaults.tools)?,
values,
)?;
let skills = render_fragment(
&load_fragment(prompt_dir, SKILLS_FRAGMENT, defaults.skills)?,
values,
)?;
Ok(join_prompt_sections([
system,
tools,
subagents_section.unwrap_or_default().to_string(),
skills,
render_dynamic_instructions(instructions),
]))
}
fn load_fragment(
prompt_dir: Option<&Path>,
fragment_name: &'static str,
default_template: &str,
) -> anyhow::Result<PromptFragment> {
if let Some(prompt_dir) = prompt_dir {
let path = prompt_dir.join(fragment_name);
if path.exists() {
let content = crate::prompt_file::read_prompt_file(&path, 256 * 1024, false)
.with_context(|| {
format!(
"failed to read prompt template fragment '{fragment_name}' at {}",
path.display()
)
})?
.text;
return Ok(PromptFragment {
name: fragment_name,
content,
});
}
}
Ok(PromptFragment {
name: fragment_name,
content: default_template.to_string(),
})
}
fn render_fragment(fragment: &PromptFragment, values: &TemplateValues) -> anyhow::Result<String> {
let mut rendered = String::with_capacity(fragment.content.len());
let mut rest = fragment.content.as_str();
loop {
let Some(open) = rest.find("{{") else {
if let Some(close) = rest.find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{}': unexpected '}}}}' at byte {}",
fragment.name,
close
);
}
rendered.push_str(rest);
return Ok(rendered);
};
if let Some(close) = rest[..open].find("}}") {
anyhow::bail!(
"malformed prompt template variable in '{}': unexpected '}}}}' at byte {}",
fragment.name,
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 '{}': missing closing '}}}}' for token starting at byte {}",
fragment.name,
open
);
};
let token = &after_open[..close];
let replacement = match token.trim() {
"TOOLS_LIST" if token == "TOOLS_LIST" => values.tools_list.as_str(),
"SKILLS_LIST" if token == "SKILLS_LIST" => values.skills_list.as_str(),
"has_ripgrep" if token == "has_ripgrep" => values.runtime_facts.has_ripgrep.as_str(),
"operating_system" if token == "operating_system" => {
values.runtime_facts.operating_system.as_str()
}
"terminal_environment" if token == "terminal_environment" => {
values.runtime_facts.terminal_environment.as_str()
}
supported @ ("TOOLS_LIST"
| "SKILLS_LIST"
| "has_ripgrep"
| "operating_system"
| "terminal_environment") => {
anyhow::bail!(
"malformed prompt template variable in '{}': use '{{{{{supported}}}}}' without extra whitespace",
fragment.name
);
}
other => anyhow::bail!(
"unsupported prompt template variable in '{}': '{{{{{other}}}}}'; supported variables are {}",
fragment.name,
supported_system_variables()
),
};
rendered.push_str(replacement);
rest = &after_open[close + 2..];
}
}
fn render_dynamic_instructions(instructions: &[InstructionFile]) -> String {
let mut prompt = String::new();
if instructions.is_empty() {
prompt.push_str("No AGENTS.md instruction files were discovered.\n");
} else {
prompt.push_str("<Additional-Context-Files>\n");
prompt.push_str(
" These files are automatically injected and MUST ALWAYS be remembered and considered in every action, do NOT read these files again:\n",
);
for instruction in instructions {
prompt.push_str(&format!(
" <Context File={}>\n{}\n </Context>\n",
instruction.path.display(),
instruction.content
));
}
prompt.push_str("</Additional-Context-Files>\n");
}
prompt
}
fn join_prompt_sections(sections: impl IntoIterator<Item = String>) -> String {
let mut prompt = String::new();
for section in sections {
let section = section.trim_end();
if section.is_empty() {
continue;
}
if !prompt.is_empty() {
prompt.push_str("\n\n");
}
prompt.push_str(section);
}
prompt.push('\n');
prompt
}
fn render_tools_list(disabled_tools: &HashSet<String>) -> String {
MVP_TOOL_CAPABILITIES
.iter()
.filter(|tool| !disabled_tools.contains(tool.canonical_name()))
.map(|tool| format!("\n{}", tool.description()))
.collect::<Vec<_>>()
.join("\n")
}
fn render_skills_list(skills: &SkillDiscovery) -> String {
if skills.skills.is_empty() {
return "- none".to_string();
}
skills
.skills
.iter()
.map(|(name, skill)| {
skill
.frontmatter
.get("description")
.map(String::as_str)
.map(str::trim)
.filter(|description| !description.is_empty())
.map_or_else(
|| format!("- {name}"),
|description| format!("- {name}: {description}"),
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn supported_system_variables() -> &'static str {
"'{{TOOLS_LIST}}', '{{SKILLS_LIST}}', '{{has_ripgrep}}', '{{operating_system}}', and '{{terminal_environment}}'"
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RuntimePromptFacts {
has_ripgrep: String,
operating_system: String,
terminal_environment: String,
}
impl RuntimePromptFacts {
fn detect() -> Self {
Self {
has_ripgrep: has_ripgrep_value(detect_ripgrep_available()),
operating_system: operating_system_value(env::consts::OS),
terminal_environment: terminal_environment_from_process(),
}
}
}
fn has_ripgrep_value(available: bool) -> String {
if available {
"ripgrep is available".to_string()
} else {
"ripgrep is not available".to_string()
}
}
static RIPGREP_AVAILABLE: OnceLock<bool> = OnceLock::new();
fn detect_ripgrep_available() -> bool {
*RIPGREP_AVAILABLE
.get_or_init(|| command_available_with_timeout("rg", Duration::from_millis(500)))
}
fn command_available_with_timeout(bin: &str, timeout: Duration) -> bool {
let mut command = Command::new(bin);
command
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
command.process_group(0);
}
let Ok(mut child) = command.spawn() else {
return false;
};
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {}
Err(_) => {
let _ = terminate_child_tree_and_wait(&mut child);
return false;
}
}
if start.elapsed() >= timeout {
let _ = terminate_child_tree_and_wait(&mut child);
return false;
}
thread::sleep(Duration::from_millis(10));
}
}
fn operating_system_value(target_os: &str) -> String {
match target_os {
"macos" => "macOS".to_string(),
"linux" => "Linux".to_string(),
"windows" => "Windows".to_string(),
other => format!("the operating system is {other}"),
}
}
fn terminal_environment_from_process() -> String {
let indicators = [
("TERM_PROGRAM", env::var("TERM_PROGRAM").ok()),
("TERM", env::var("TERM").ok()),
("COLORTERM", env::var("COLORTERM").ok()),
];
terminal_environment_value(indicators.iter().filter_map(|(name, value)| {
value
.as_deref()
.and_then(|value| sanitize_terminal_indicator_value(value).map(|value| (*name, value)))
}))
}
fn terminal_environment_value(
indicators: impl IntoIterator<Item = (&'static str, String)>,
) -> String {
let parts = indicators
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>();
if parts.is_empty() {
"terminal environment is unspecified".to_string()
} else {
format!("terminal environment: {}", parts.join(", "))
}
}
fn sanitize_terminal_indicator_value(value: &str) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|ch| !ch.is_control())
.take(64)
.collect::<String>();
(!sanitized.is_empty()).then_some(sanitized)
}
struct PromptFragment {
name: &'static str,
content: String,
}
struct PromptTemplateDefaults<'a> {
system: &'a str,
tools: &'a str,
skills: &'a str,
}
impl PromptTemplateDefaults<'static> {
fn bundled() -> Self {
Self {
system: DEFAULT_SYSTEM_TEMPLATE,
tools: DEFAULT_TOOLS_TEMPLATE,
skills: DEFAULT_SKILLS_TEMPLATE,
}
}
}
struct TemplateValues {
tools_list: String,
skills_list: String,
runtime_facts: RuntimePromptFacts,
}
impl TemplateValues {
fn from_skills(skills: &SkillDiscovery, disabled_tools: &HashSet<String>) -> Self {
Self {
tools_list: render_tools_list(disabled_tools),
skills_list: render_skills_list(skills),
runtime_facts: RuntimePromptFacts::detect(),
}
}
#[cfg(test)]
fn for_tests(runtime_facts: RuntimePromptFacts) -> Self {
Self {
tools_list: "TOOLS".to_string(),
skills_list: "SKILLS".to_string(),
runtime_facts,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
instructions::InstructionSourceKind,
skills::{Skill, filter_enabled_skills},
};
use std::fs;
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
};
use tempfile::TempDir;
fn skill(name: &str) -> Skill {
Skill {
name: name.to_string(),
path: PathBuf::from(format!("/repo/.agents/skills/{name}/SKILL.md")),
frontmatter: BTreeMap::<String, String>::new(),
body: format!("{name} skill"),
}
}
fn skill_with_description(name: &str, description: &str) -> Skill {
let mut skill = skill(name);
skill
.frontmatter
.insert("description".to_string(), description.to_string());
skill
}
fn defaults() -> PromptTemplateDefaults<'static> {
PromptTemplateDefaults {
system: "SYSTEM",
tools: "TOOLS\n{{TOOLS_LIST}}",
skills: "SKILLS\n{{SKILLS_LIST}}",
}
}
fn runtime_facts(
has_ripgrep: &str,
operating_system: &str,
terminal_environment: &str,
) -> RuntimePromptFacts {
RuntimePromptFacts {
has_ripgrep: has_ripgrep.to_string(),
operating_system: operating_system.to_string(),
terminal_environment: terminal_environment.to_string(),
}
}
#[test]
fn compact_prompt_uses_user_override_or_bundled_default() {
let bundled = load_compact_prompt(None).unwrap();
assert!(bundled.contains("continuation-ready summary"));
assert!(bundled.contains("Exclude credentials"));
let temp = TempDir::new().unwrap();
let prompt_dir = temp.path().join("prompts");
fs::create_dir_all(&prompt_dir).unwrap();
fs::write(prompt_dir.join("compact.md"), "USER COMPACT PROMPT").unwrap();
let overridden = load_compact_prompt(Some(&prompt_dir)).unwrap();
assert_eq!(overridden, "USER COMPACT PROMPT");
}
#[test]
fn compact_prompt_unreadable_override_is_actionable() {
let temp = TempDir::new().unwrap();
let prompt_dir = temp.path().join("prompts");
fs::create_dir_all(prompt_dir.join("compact.md")).unwrap();
let error = load_compact_prompt(Some(&prompt_dir))
.unwrap_err()
.to_string();
assert!(error.contains("compact.md"), "{error}");
assert!(
error.contains(&prompt_dir.join("compact.md").display().to_string()),
"{error}"
);
}
#[test]
fn system_prompt_includes_agents_tools_subagents_and_skill_names_in_order() {
let instructions = vec![
InstructionFile {
kind: InstructionSourceKind::User,
path: PathBuf::from("/home/user/.magi-code/AGENTS.md"),
content: "user rules".to_string(),
},
InstructionFile {
kind: InstructionSourceKind::Repository,
path: PathBuf::from("/repo/AGENTS.md"),
content: "repo rules".to_string(),
},
InstructionFile {
kind: InstructionSourceKind::Configured,
path: PathBuf::from("/shared/one.md"),
content: "configured one".to_string(),
},
InstructionFile {
kind: InstructionSourceKind::Configured,
path: PathBuf::from("/shared/two.md"),
content: "configured two".to_string(),
},
];
let mut skills = SkillDiscovery::default();
skills.skills.insert("review".to_string(), skill("review"));
let prompt = build_system_prompt_from_templates(
None,
&defaults(),
&instructions,
&skills,
Some("SUBAGENTS"),
&HashSet::new(),
)
.unwrap();
let system_index = prompt.find("SYSTEM").unwrap();
let tools_index = prompt.find("TOOLS").unwrap();
let subagents_index = prompt.find("SUBAGENTS").unwrap();
let skills_index = prompt.find("SKILLS").unwrap();
let agents_index = prompt.find("<Additional-Context-Files>").unwrap();
let user_index = prompt.find("user rules").unwrap();
let repo_index = prompt.find("repo rules").unwrap();
let configured_one_index = prompt.find("configured one").unwrap();
let configured_two_index = prompt.find("configured two").unwrap();
assert!(system_index < tools_index);
assert!(tools_index < subagents_index);
assert!(subagents_index < skills_index);
assert!(skills_index < agents_index);
assert!(agents_index < user_index);
assert!(user_index < repo_index);
assert!(repo_index < configured_one_index);
assert!(configured_one_index < configured_two_index);
assert!(prompt.contains("/repo/AGENTS.md"));
assert!(prompt.contains("/shared/one.md"));
assert!(prompt.contains("- review"));
assert!(prompt.contains("### Bash Tool"));
assert!(prompt.contains("**Tool Name**: `bash`"));
assert!(prompt.contains("### Read Tool"));
assert!(prompt.contains("**Tool Name**: `read`"));
assert!(!prompt.contains("### Skill Tool"));
assert!(!prompt.contains("**Tool Name**: `skill`"));
}
#[test]
fn dynamic_instructions_use_additional_context_file_wrapper() {
let instructions = vec![
InstructionFile {
kind: InstructionSourceKind::User,
path: PathBuf::from("/home/user/.magi-code/AGENTS.md"),
content: "user rules".to_string(),
},
InstructionFile {
kind: InstructionSourceKind::Repository,
path: PathBuf::from("./AGENTS.md"),
content: "repo rules".to_string(),
},
];
let prompt = render_dynamic_instructions(&instructions);
assert!(
prompt.starts_with("<Additional-Context-Files>\n"),
"{prompt}"
);
assert!(
prompt.contains("These files are automatically injected and MUST ALWAYS be remembered and considered in every action, do NOT read these files again:"),
"{prompt}"
);
assert!(
prompt.contains(
" <Context File=/home/user/.magi-code/AGENTS.md>\nuser rules\n </Context>"
),
"{prompt}"
);
assert!(
prompt.contains(" <Context File=./AGENTS.md>\nrepo rules\n </Context>"),
"{prompt}"
);
assert!(
prompt.ends_with("</Additional-Context-Files>\n"),
"{prompt}"
);
assert!(!prompt.contains("Discovered instruction files"), "{prompt}");
assert!(!prompt.contains("--- user:"), "{prompt}");
assert!(!prompt.contains("--- repository:"), "{prompt}");
}
#[test]
fn empty_subagents_section_is_omitted() {
let prompt = build_system_prompt_from_templates(
None,
&defaults(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap();
assert!(!prompt.contains("SUBAGENTS"));
assert!(prompt.find("TOOLS").unwrap() < prompt.find("SKILLS").unwrap());
}
#[test]
fn user_overrides_take_precedence_independently() {
let temp = TempDir::new().unwrap();
let prompt_dir = temp.path().join("prompts");
fs::create_dir_all(&prompt_dir).unwrap();
fs::write(prompt_dir.join("system.md"), "USER SYSTEM").unwrap();
fs::write(prompt_dir.join("skills.md"), "USER SKILLS\n{{SKILLS_LIST}}").unwrap();
let prompt = build_system_prompt_from_templates(
Some(&prompt_dir),
&defaults(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap();
assert!(prompt.contains("USER SYSTEM"));
assert!(prompt.contains("TOOLS\n\n### Read Tool"));
assert!(prompt.contains("**Tool Name**: `read`"));
assert!(prompt.contains("USER SKILLS\n- none"));
assert!(!prompt.contains("\nSYSTEM\n"));
}
#[test]
fn missing_user_overrides_fall_back_to_bundled_defaults() {
let temp = TempDir::new().unwrap();
let prompt = build_system_prompt_from_templates(
Some(temp.path()),
&defaults(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap();
assert!(prompt.contains("SYSTEM"));
assert!(prompt.contains("TOOLS\n\n### Read Tool"));
assert!(prompt.contains("**Tool Name**: `read`"));
assert!(prompt.contains("SKILLS\n- none"));
}
#[test]
fn bundled_tools_prompt_lists_canonical_bash_tool_with_description() {
let prompt = build_system_prompt_from_templates(
None,
&PromptTemplateDefaults::bundled(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap();
assert!(prompt.contains("**Tool Name**: `bash`"), "{prompt}");
assert!(!prompt.contains("bash/shell"), "{prompt}");
}
#[test]
fn skills_list_is_deterministic_and_empty_is_none() {
let mut skills = SkillDiscovery::default();
skills.skills.insert("zeta".to_string(), skill("zeta"));
skills.skills.insert(
"alpha".to_string(),
skill_with_description("alpha", "Alpha skill"),
);
assert_eq!(render_skills_list(&SkillDiscovery::default()), "- none");
assert_eq!(render_skills_list(&skills), "- alpha: Alpha skill\n- zeta");
}
#[test]
fn skills_list_uses_name_fallback_for_empty_or_whitespace_descriptions() {
let mut skills = SkillDiscovery::default();
skills
.skills
.insert("empty".to_string(), skill_with_description("empty", ""));
skills.skills.insert(
"whitespace".to_string(),
skill_with_description("whitespace", " \n\t "),
);
assert_eq!(render_skills_list(&skills), "- empty\n- whitespace");
}
#[test]
fn skills_list_omits_disabled_skills_after_filtering() {
let mut discovered = SkillDiscovery::default();
discovered
.skills
.insert("enabled".to_string(), skill("enabled"));
discovered
.skills
.insert("disabled".to_string(), skill("disabled"));
let enabled = filter_enabled_skills(
&discovered,
&BTreeSet::from(["disabled".to_string(), "unknown".to_string()]),
);
let prompt = build_system_prompt_from_templates(
None,
&defaults(),
&[],
&enabled,
None,
&HashSet::new(),
)
.unwrap();
assert!(prompt.contains("- enabled"), "{prompt}");
assert!(!prompt.contains("- disabled"), "{prompt}");
}
#[test]
fn tools_list_contains_canonical_tool_names_and_descriptions() {
let tools = render_tools_list(&HashSet::new());
for capability in crate::tools::MVP_TOOL_CAPABILITIES {
assert!(
tools.contains(&format!("**Tool Name**: `{}`", capability.canonical_name())),
"{tools}"
);
}
assert!(tools.contains("**Tool Name**: `read`"), "{tools}");
assert!(tools.contains("paths"), "{tools}");
assert!(tools.contains("grep"), "{tools}");
assert!(!tools.contains("ffgrep"), "{tools}");
assert!(tools.contains("**Tool Name**: `bash`"), "{tools}");
assert!(tools.contains("cwd-scope preflight"), "{tools}");
assert!(!tools.contains("bash/shell"), "{tools}");
}
#[test]
fn tools_list_omits_disabled_builtin_tools() {
let disabled = HashSet::from(["bash".to_string(), "subagents".to_string()]);
let tools = render_tools_list(&disabled);
assert!(!tools.contains("**Tool Name**: `bash`"), "{tools}");
assert!(!tools.contains("**Tool Name**: `subagents`"), "{tools}");
assert!(tools.contains("**Tool Name**: `read`"), "{tools}");
}
#[test]
fn runtime_fact_variables_render_in_system_prompt_fragments() {
let values = TemplateValues::for_tests(runtime_facts(
"ripgrep fact",
"operating system fact",
"terminal environment fact",
));
for name in [SYSTEM_FRAGMENT, TOOLS_FRAGMENT, SKILLS_FRAGMENT] {
let fragment = PromptFragment {
name,
content: "{{has_ripgrep}}\n{{operating_system}}\n{{terminal_environment}}"
.to_string(),
};
let rendered = render_fragment(&fragment, &values).unwrap();
assert_eq!(
rendered,
"ripgrep fact\noperating system fact\nterminal environment fact"
);
}
}
#[test]
fn runtime_fact_values_report_ripgrep_availability() {
assert_eq!(has_ripgrep_value(true), "ripgrep is available");
assert_eq!(has_ripgrep_value(false), "ripgrep is not available");
}
#[test]
fn operating_system_value_uses_friendly_names_and_fallback() {
assert_eq!(operating_system_value("macos"), "macOS");
assert_eq!(operating_system_value("linux"), "Linux");
assert_eq!(operating_system_value("windows"), "Windows");
assert_eq!(
operating_system_value("freebsd"),
"the operating system is freebsd"
);
}
#[test]
fn terminal_environment_uses_allowlist_and_unspecified_fallback() {
assert_eq!(
terminal_environment_value([
("TERM_PROGRAM", "Apple_Terminal".to_string()),
("TERM", "xterm-256color".to_string()),
("COLORTERM", "truecolor".to_string()),
]),
"terminal environment: TERM_PROGRAM=Apple_Terminal, TERM=xterm-256color, COLORTERM=truecolor"
);
assert_eq!(
terminal_environment_value([]),
"terminal environment is unspecified"
);
assert_eq!(
sanitize_terminal_indicator_value("\n xterm\u{7f}-256color \t").unwrap(),
"xterm-256color"
);
}
#[test]
fn rendered_system_prompt_changes_when_runtime_facts_change() {
let defaults = PromptTemplateDefaults {
system: "SYSTEM {{has_ripgrep}}",
tools: "TOOLS {{operating_system}}",
skills: "SKILLS {{terminal_environment}}",
};
let first = TemplateValues::for_tests(runtime_facts("rg yes", "Linux", "term one"));
let second = TemplateValues::for_tests(runtime_facts("rg no", "Windows", "term two"));
let first_prompt =
build_system_prompt_from_templates_with_values(None, &defaults, &[], None, &first)
.unwrap();
let second_prompt =
build_system_prompt_from_templates_with_values(None, &defaults, &[], None, &second)
.unwrap();
assert_ne!(first_prompt, second_prompt);
assert!(first_prompt.contains("SYSTEM rg yes"), "{first_prompt}");
assert!(first_prompt.contains("TOOLS Linux"), "{first_prompt}");
assert!(first_prompt.contains("SKILLS term one"), "{first_prompt}");
assert!(second_prompt.contains("SYSTEM rg no"), "{second_prompt}");
assert!(second_prompt.contains("TOOLS Windows"), "{second_prompt}");
assert!(second_prompt.contains("SKILLS term two"), "{second_prompt}");
}
#[test]
fn prompt_template_variable_spacing_is_rejected() {
let values = TemplateValues::for_tests(runtime_facts("rg", "os", "term"));
for content in [
"{{HAS_RIPGREP}}",
"{{has_ripgrep }}",
"{{ terminal_environment }}",
] {
let fragment = PromptFragment {
name: "system.md",
content: content.to_string(),
};
let error = render_fragment(&fragment, &values).unwrap_err().to_string();
assert!(error.contains("system.md"), "{error}");
if content == "{{HAS_RIPGREP}}" {
assert!(error.contains("unsupported"), "{error}");
assert!(error.contains("HAS_RIPGREP"), "{error}");
} else {
assert!(error.contains("without extra whitespace"), "{error}");
}
}
}
#[test]
fn unknown_variable_is_actionable_error() {
let fragment = PromptFragment {
name: "system.md",
content: "hello {{UNKNOWN}}".to_string(),
};
let error = render_fragment(
&fragment,
&TemplateValues::from_skills(&SkillDiscovery::default(), &HashSet::new()),
)
.unwrap_err()
.to_string();
assert!(error.contains("system.md"), "{error}");
assert!(error.contains("UNKNOWN"), "{error}");
assert!(error.contains("TOOLS_LIST"), "{error}");
assert!(error.contains("SKILLS_LIST"), "{error}");
assert!(error.contains("has_ripgrep"), "{error}");
assert!(error.contains("operating_system"), "{error}");
assert!(error.contains("terminal_environment"), "{error}");
}
#[test]
fn malformed_variable_is_actionable_error() {
let fragment = PromptFragment {
name: "tools.md",
content: "hello {{TOOLS_LIST".to_string(),
};
let error = render_fragment(
&fragment,
&TemplateValues::from_skills(&SkillDiscovery::default(), &HashSet::new()),
)
.unwrap_err()
.to_string();
assert!(error.contains("tools.md"), "{error}");
assert!(error.contains("missing closing"), "{error}");
let fragment = PromptFragment {
name: "skills.md",
content: "hello }}".to_string(),
};
let error = render_fragment(
&fragment,
&TemplateValues::from_skills(&SkillDiscovery::default(), &HashSet::new()),
)
.unwrap_err()
.to_string();
assert!(error.contains("skills.md"), "{error}");
assert!(error.contains("unexpected"), "{error}");
}
#[test]
fn unreadable_selected_file_is_error() {
let temp = TempDir::new().unwrap();
let prompt_dir = temp.path().join("prompts");
fs::create_dir_all(prompt_dir.join("system.md")).unwrap();
let error = build_system_prompt_from_templates(
Some(&prompt_dir),
&defaults(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap_err()
.to_string();
assert!(error.contains("system.md"), "{error}");
assert!(
error.contains(&prompt_dir.join("system.md").display().to_string()),
"{error}"
);
}
#[test]
fn missing_instructions_are_non_fatal_and_reported() {
let prompt = build_system_prompt_from_templates(
None,
&defaults(),
&[],
&SkillDiscovery::default(),
None,
&HashSet::new(),
)
.unwrap();
assert!(prompt.contains("No AGENTS.md instruction files were discovered."));
}
}