use std::path::Path;
use crate::config::prepend_project_context;
use crate::skills::{skill_index, Skill};
#[derive(Debug, Clone)]
pub struct AssembledPrompt {
pub full: String,
pub segments: PromptSegments,
}
impl AssembledPrompt {
pub fn full(&self) -> &str {
&self.full
}
pub fn into_full(self) -> String {
self.full
}
}
impl std::fmt::Display for AssembledPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.full)
}
}
#[derive(Debug, Clone, Default)]
pub struct PromptSegments {
pub rules: String,
pub system_prompt: String,
pub skills: String,
pub subagents: String,
}
pub fn assemble_system_prompt(
base: &str,
workspace: &Path,
skills: &[Skill],
sub_agent_enabled: bool,
) -> AssembledPrompt {
let segments = PromptSegments {
rules: project_context_block(workspace),
system_prompt: base.to_string(),
skills: skill_index(skills),
subagents: if sub_agent_enabled {
"\n\n---\n\n## Sub-agents enabled\n\n\
The `agent` tool is registered. See its description for how to \
delegate, continue, and verify worker output."
.to_string()
} else {
String::new()
},
};
let mut full = prepend_project_context(base, workspace);
if !segments.subagents.is_empty() {
full.push_str(&segments.subagents);
}
AssembledPrompt { full, segments }
}
fn project_context_block(workspace: &Path) -> String {
match crate::config::load_project_context(workspace) {
Some(ctx) => format!("# Project context\n\n{ctx}\n\n---\n\n"),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::SkillMode;
use std::path::PathBuf;
fn make_skill(name: &str, desc: &str) -> Skill {
Skill {
name: name.to_string(),
description: desc.to_string(),
path: PathBuf::from(format!("/tmp/skills/{name}/SKILL.md")),
mode: SkillMode::Manual,
triggers: vec![],
hint: String::new(),
depends_on: vec![],
refs: vec![],
params: vec![],
scripts: vec![],
sections: vec![],
globs: None,
}
}
#[test]
fn no_files_no_skills_no_subagent_returns_base() {
let tmp = tempfile::tempdir().expect("tempdir");
let out = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
assert_eq!(out, "BASE");
}
#[test]
fn prepends_agents_and_claude_sections() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "agents-body").expect("write");
std::fs::write(tmp.path().join("CLAUDE.md"), "claude-body").expect("write");
let out = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
assert!(out.starts_with("# Project context\n\n"), "{out}");
assert!(out.contains("## AGENTS.md"), "{out}");
assert!(out.contains("agents-body"));
assert!(
!out.contains("## CLAUDE.md"),
"CLAUDE.md must not load when AGENTS.md present: {out}"
);
assert!(
!out.contains("claude-body"),
"CLAUDE.md body must not leak: {out}"
);
assert!(
out.contains("\n\n---\n\nBASE"),
"base after separator: {out}"
);
}
#[test]
fn skill_index_not_inlined_into_system_prompt() {
let tmp = tempfile::tempdir().expect("tempdir");
let skills = vec![make_skill("pdf", "Manipulate PDF documents")];
let out = assemble_system_prompt("BASE", tmp.path(), &skills, false).into_full();
assert!(!out.contains("Available skills"), "{out}");
assert!(!out.contains("pdf"), "{out}");
let reminder = crate::skills::skill_reminder(&skills);
assert!(reminder.contains("Available skills"), "{reminder}");
assert!(reminder.contains("pdf"), "{reminder}");
assert!(reminder.contains("<system-reminder>"), "{reminder}");
}
#[test]
fn subagent_suffix_appended_only_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let off = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
assert!(!off.contains("Sub-agents enabled"), "{off}");
assert!(
!off.contains("`agent` tool is registered"),
"sub-agent usage note must not appear when disabled: {off}"
);
let on = assemble_system_prompt("BASE", tmp.path(), &[], true).into_full();
assert!(on.contains("## Sub-agents enabled"), "{on}");
assert!(on.contains("`agent` tool is registered"), "{on}");
}
#[test]
fn ordering_project_context_then_base_then_subagent() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "AG").expect("write");
let skills = vec![make_skill("xlsx", "Spreadsheets")];
let out = assemble_system_prompt("BASE", tmp.path(), &skills, true).into_full();
let pc = out.find("# Project context").unwrap();
let base = out.find("BASE").unwrap();
let coord = out.find("Sub-agents enabled").unwrap();
assert!(out.find("Available skills").is_none(), "{out}");
assert!(pc < base && base < coord, "{out}");
}
#[test]
fn full_is_stable_and_excludes_skill_index() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "AG").expect("write");
std::fs::write(tmp.path().join("CLAUDE.md"), "CL").expect("write");
let skills = vec![make_skill("xlsx", "Spreadsheets")];
let mut expected = crate::config::prepend_project_context("BASE", tmp.path());
assert!(
crate::skills::skill_reminder(&skills).contains("Available skills"),
"skill_reminder must still produce the catalog"
);
expected.push_str(
"\n\n---\n\n## Sub-agents enabled\n\n\
The `agent` tool is registered. See its description for how to \
delegate, continue, and verify worker output.",
);
let assembled = assemble_system_prompt("BASE", tmp.path(), &skills, true).into_full();
assert_eq!(
assembled, expected,
"AssembledPrompt::full must be stable and exclude the skill catalog"
);
assert!(
!assembled.contains("Available skills"),
"skill catalog must not be inlined into full (ships via skill_reminder): {assembled}"
);
}
#[test]
fn segments_are_populated_and_non_overlapping() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "AGENTS body").expect("write");
let skills = vec![make_skill("alpha", "first skill")];
let assembled = assemble_system_prompt("BASE", tmp.path(), &skills, true);
assert!(
assembled.segments.rules.contains("AGENTS.md"),
"rules segment must contain the AGENTS.md heading; got {:?}",
assembled.segments.rules
);
assert!(
assembled.segments.rules.contains("AGENTS body"),
"rules segment must contain the AGENTS.md body; got {:?}",
assembled.segments.rules
);
assert_eq!(
assembled.segments.system_prompt, "BASE",
"system_prompt segment must be the unwrapped base"
);
assert!(
assembled.segments.skills.contains("Available skills"),
"skills segment must contain the skill index header; got {:?}",
assembled.segments.skills
);
assert!(
assembled.segments.skills.contains("alpha"),
"skills segment must contain the skill name; got {:?}",
assembled.segments.skills
);
assert!(
assembled.segments.subagents.contains("Sub-agents enabled"),
"subagents segment must contain the coordinator workflow heading when enabled"
);
assert!(
assembled.full.starts_with(&assembled.segments.rules),
"full must start with the rules segment; rules={:?} full={:?}",
assembled.segments.rules,
assembled.full
);
let base_idx = assembled
.full
.find("BASE")
.expect("full must contain the BASE substring unchanged");
assert_eq!(
base_idx,
assembled.segments.rules.len(),
"BASE must start exactly where the rules segment ends"
);
assert!(
assembled.full.contains("AGENTS.md"),
"full must contain the AGENTS.md heading from the rules segment"
);
assert!(
!assembled.full.contains("Available skills"),
"full must NOT inline the skill catalog: {assembled}"
);
assert!(
assembled.full.contains("Sub-agents enabled"),
"full must contain the sub-agent suffix after BASE"
);
}
#[test]
fn empty_segments_when_layers_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
assert!(
assembled.segments.rules.is_empty(),
"rules must be empty when no project context files exist"
);
assert_eq!(
assembled.segments.system_prompt, "BASE",
"system_prompt segment must be the base even when rules is empty"
);
assert!(
assembled.segments.skills.is_empty(),
"skills must be empty when no skills are loaded"
);
assert!(
assembled.segments.subagents.is_empty(),
"subagents must be empty when sub_agent_enabled is false"
);
assert_eq!(assembled.full, "BASE");
}
#[test]
fn segments_skills_contains_available_skills_only_when_skills_present() {
let tmp = tempfile::tempdir().expect("tempdir");
let no_skills = assemble_system_prompt("BASE", tmp.path(), &[], false);
assert!(
!no_skills.segments.skills.contains("Available skills"),
"skills segment must not advertise an index when none is loaded"
);
assert!(
no_skills.full == "BASE",
"with no skills the joined prompt must equal the base"
);
let skills = vec![make_skill("only", "the only skill")];
let with_skills = assemble_system_prompt("BASE", tmp.path(), &skills, false);
assert!(
with_skills.segments.skills.contains("Available skills"),
"skills segment must include the index header when any skill is loaded"
);
}
#[test]
fn segments_subagents_contains_coordinator_only_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let off = assemble_system_prompt("BASE", tmp.path(), &[], false);
assert!(
!off.segments.subagents.contains("Sub-agents enabled"),
"subagents segment must be empty when sub_agent_enabled=false"
);
let on = assemble_system_prompt("BASE", tmp.path(), &[], true);
assert!(
on.segments.subagents.contains("Sub-agents enabled"),
"subagents segment must contain the coordinator workflow when enabled"
);
}
#[test]
fn full_accessor_returns_str() {
let tmp = tempfile::tempdir().expect("tempdir");
let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
let s: &str = assembled.full();
assert_eq!(s, "BASE");
}
#[test]
fn display_impl_writes_full() {
let tmp = tempfile::tempdir().expect("tempdir");
let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
assert_eq!(format!("{assembled}"), "BASE");
}
}