724 lines
27 KiB
Rust
724 lines
27 KiB
Rust
//! Typed effective Prompt catalog.
|
|
//!
|
|
//! Builtins are evaluated from the embedded `resources/prompts/catalog.dcdl`
|
|
//! source tree. Markdown imports use the same `{ frontmatter, content }`
|
|
//! projection as Workspace config; the catalog DCDL selects `.content`.
|
|
//! Workspace configuration materializes a complete closed `prompts` object,
|
|
//! which is carried as an immutable [`EffectivePromptCatalog`] projection.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use config_source::{
|
|
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
|
|
VirtualPath, digest_bytes,
|
|
};
|
|
use include_dir::{Dir, include_dir};
|
|
use minijinja::value::Value;
|
|
use minijinja::{Environment, UndefinedBehavior};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::prompt::source::PromptCatalogSource;
|
|
|
|
static BUILTIN_PROMPT_SOURCES: Dir<'static> =
|
|
include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
|
const BUILTIN_CATALOG_ENTRY: &str = "catalog.dcdl";
|
|
const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4";
|
|
|
|
/// Immutable Prompt projection delivered by Workspace authority.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EffectivePromptCatalog {
|
|
pub templates: BTreeMap<String, String>,
|
|
pub config_revision: u64,
|
|
pub schema_fingerprint: String,
|
|
pub toolchain_fingerprint: String,
|
|
pub catalog_digest: String,
|
|
}
|
|
|
|
impl EffectivePromptCatalog {
|
|
pub fn new(
|
|
templates: BTreeMap<String, String>,
|
|
config_revision: u64,
|
|
schema_fingerprint: impl Into<String>,
|
|
toolchain_fingerprint: impl Into<String>,
|
|
) -> Result<Self, CatalogError> {
|
|
validate_prompt_templates(&templates)?;
|
|
let catalog_digest = catalog_digest(&templates)?;
|
|
Ok(Self {
|
|
templates,
|
|
config_revision,
|
|
schema_fingerprint: schema_fingerprint.into(),
|
|
toolchain_fingerprint: toolchain_fingerprint.into(),
|
|
catalog_digest,
|
|
})
|
|
}
|
|
|
|
pub fn from_projection(
|
|
prompts: &serde_json::Value,
|
|
config_revision: u64,
|
|
schema_fingerprint: impl Into<String>,
|
|
toolchain_fingerprint: impl Into<String>,
|
|
) -> Result<Self, CatalogError> {
|
|
let mut templates = BTreeMap::new();
|
|
flatten_templates("", prompts, &mut templates)?;
|
|
if let Some(default_prompt) = templates.remove("default_prompt") {
|
|
templates.insert("default".to_string(), default_prompt);
|
|
}
|
|
Self::new(
|
|
templates,
|
|
config_revision,
|
|
schema_fingerprint,
|
|
toolchain_fingerprint,
|
|
)
|
|
}
|
|
|
|
pub fn verify_digest(&self) -> Result<(), CatalogError> {
|
|
let actual = catalog_digest(&self.templates)?;
|
|
if actual != self.catalog_digest {
|
|
return Err(CatalogError::DigestMismatch {
|
|
expected: self.catalog_digest.clone(),
|
|
actual,
|
|
});
|
|
}
|
|
validate_prompt_templates(&self.templates)
|
|
}
|
|
}
|
|
|
|
/// Worker-level prompt injection point.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum WorkerPrompt {
|
|
CompactSystem,
|
|
MemoryExtractSystem,
|
|
MemoryConsolidationSystem,
|
|
FlowVerifierSystem,
|
|
NotifyWrapper,
|
|
InterruptToolResultSummary,
|
|
InterruptSystemNote,
|
|
WorkingBoundariesSection,
|
|
AgentsMdSection,
|
|
ResidentMemorySummarySection,
|
|
WorkerOrchestrationGuidanceSection,
|
|
TicketEventCompanionNotice,
|
|
SubWorkerSpawnToolDescription,
|
|
}
|
|
|
|
impl WorkerPrompt {
|
|
pub fn key(self) -> &'static str {
|
|
match self {
|
|
Self::CompactSystem => "internal.compact_system",
|
|
Self::MemoryExtractSystem => "internal.memory_extract_system",
|
|
Self::MemoryConsolidationSystem => "internal.memory_consolidation_system",
|
|
Self::FlowVerifierSystem => "internal.flow_verifier_system",
|
|
Self::NotifyWrapper => "internal.notify_wrapper",
|
|
Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary",
|
|
Self::InterruptSystemNote => "internal.interrupt_system_note",
|
|
Self::WorkingBoundariesSection => "internal.working_boundaries_section",
|
|
Self::AgentsMdSection => "internal.agents_md_section",
|
|
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
|
Self::WorkerOrchestrationGuidanceSection => {
|
|
"internal.worker_orchestration_guidance_section"
|
|
}
|
|
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
|
|
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
|
|
}
|
|
}
|
|
|
|
pub const ALL: &'static [WorkerPrompt] = &[
|
|
WorkerPrompt::CompactSystem,
|
|
WorkerPrompt::MemoryExtractSystem,
|
|
WorkerPrompt::MemoryConsolidationSystem,
|
|
WorkerPrompt::FlowVerifierSystem,
|
|
WorkerPrompt::NotifyWrapper,
|
|
WorkerPrompt::InterruptToolResultSummary,
|
|
WorkerPrompt::InterruptSystemNote,
|
|
WorkerPrompt::WorkingBoundariesSection,
|
|
WorkerPrompt::AgentsMdSection,
|
|
WorkerPrompt::ResidentMemorySummarySection,
|
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
|
WorkerPrompt::TicketEventCompanionNotice,
|
|
WorkerPrompt::SubWorkerSpawnToolDescription,
|
|
];
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum CatalogError {
|
|
#[error("failed to build builtin Prompt source tree: {0}")]
|
|
BuiltinTree(String),
|
|
#[error("failed to evaluate builtin Prompt source tree: {0}")]
|
|
BuiltinEvaluation(String),
|
|
#[error("effective Prompt projection at '{path}' must be an object or string")]
|
|
InvalidProjection { path: String },
|
|
#[error("invalid effective Prompt template catalog: {0}")]
|
|
InvalidTemplateCatalog(String),
|
|
#[error("failed to compile prompt template '{key}': {source}")]
|
|
TemplateCompile {
|
|
key: String,
|
|
#[source]
|
|
source: minijinja::Error,
|
|
},
|
|
#[error("failed to render prompt '{key}': {source}")]
|
|
Render {
|
|
key: String,
|
|
#[source]
|
|
source: minijinja::Error,
|
|
},
|
|
#[error("prompt key '{key}' is not registered in the catalog")]
|
|
UnknownKey { key: String },
|
|
#[error("failed to serialize effective Prompt catalog: {0}")]
|
|
Serialize(#[from] serde_json::Error),
|
|
#[error("effective Prompt catalog digest mismatch: expected {expected}, got {actual}")]
|
|
DigestMismatch { expected: String, actual: String },
|
|
}
|
|
|
|
pub struct PromptCatalog {
|
|
env: Environment<'static>,
|
|
projection: EffectivePromptCatalog,
|
|
}
|
|
|
|
impl std::fmt::Debug for PromptCatalog {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("PromptCatalog")
|
|
.field("config_revision", &self.projection.config_revision)
|
|
.field("catalog_digest", &self.projection.catalog_digest)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl PromptCatalog {
|
|
pub fn builtins_only() -> Result<Arc<Self>, CatalogError> {
|
|
let templates = builtin_prompt_templates()?;
|
|
let projection = EffectivePromptCatalog::new(
|
|
templates,
|
|
0,
|
|
BUILTIN_TOOLCHAIN_FINGERPRINT,
|
|
BUILTIN_TOOLCHAIN_FINGERPRINT,
|
|
)?;
|
|
Self::from_projection(projection).map(Arc::new)
|
|
}
|
|
|
|
pub fn load(loader: &PromptCatalogSource) -> Result<Arc<Self>, CatalogError> {
|
|
if let Some(projection) = loader.effective_catalog() {
|
|
return Self::from_projection(projection.clone()).map(Arc::new);
|
|
}
|
|
Self::builtins_only()
|
|
}
|
|
|
|
pub fn from_projection(projection: EffectivePromptCatalog) -> Result<Self, CatalogError> {
|
|
projection.verify_digest()?;
|
|
let mut env = Environment::new();
|
|
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
|
for (key, source) in &projection.templates {
|
|
env.add_template_owned(key.clone(), source.clone())
|
|
.map_err(|source| CatalogError::TemplateCompile {
|
|
key: key.clone(),
|
|
source,
|
|
})?;
|
|
}
|
|
Ok(Self { env, projection })
|
|
}
|
|
|
|
pub fn projection(&self) -> &EffectivePromptCatalog {
|
|
&self.projection
|
|
}
|
|
|
|
pub(crate) fn source(&self) -> PromptCatalogSource {
|
|
PromptCatalogSource::builtins_only().with_effective_catalog(self.projection.clone())
|
|
}
|
|
|
|
pub fn contains(&self, key: &str) -> bool {
|
|
self.projection.templates.contains_key(key)
|
|
}
|
|
|
|
pub fn render_serializable<T: Serialize + ?Sized>(
|
|
&self,
|
|
key: &str,
|
|
context: &T,
|
|
) -> Result<String, CatalogError> {
|
|
self.render_name(key, Value::from_serialize(context))
|
|
}
|
|
|
|
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
|
|
let template = self
|
|
.env
|
|
.get_template(key)
|
|
.map_err(|_| CatalogError::UnknownKey { key: key.into() })?;
|
|
template.render(ctx).map_err(|source| CatalogError::Render {
|
|
key: key.into(),
|
|
source,
|
|
})
|
|
}
|
|
|
|
pub fn render(&self, prompt: WorkerPrompt, ctx: Value) -> Result<String, CatalogError> {
|
|
self.render_name(prompt.key(), ctx)
|
|
}
|
|
|
|
pub fn compact_system(&self) -> Result<String, CatalogError> {
|
|
self.render(WorkerPrompt::CompactSystem, Value::UNDEFINED)
|
|
}
|
|
pub fn memory_extract_system(&self, language: &str) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::MemoryExtractSystem,
|
|
single("language", language),
|
|
)
|
|
}
|
|
pub fn memory_consolidation_system(&self, language: &str) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::MemoryConsolidationSystem,
|
|
single("language", language),
|
|
)
|
|
}
|
|
pub fn flow_verifier_system(&self) -> Result<String, CatalogError> {
|
|
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
|
|
}
|
|
pub fn notify_wrapper(&self, message: &str) -> Result<String, CatalogError> {
|
|
self.render(WorkerPrompt::NotifyWrapper, single("message", message))
|
|
}
|
|
pub fn interrupt_tool_result_summary(&self) -> Result<String, CatalogError> {
|
|
self.render(WorkerPrompt::InterruptToolResultSummary, Value::UNDEFINED)
|
|
}
|
|
pub fn interrupt_system_note(&self) -> Result<String, CatalogError> {
|
|
self.render(WorkerPrompt::InterruptSystemNote, Value::UNDEFINED)
|
|
}
|
|
pub fn working_boundaries_section(&self, scope_summary: &str) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::WorkingBoundariesSection,
|
|
single("scope_summary", scope_summary),
|
|
)
|
|
}
|
|
pub fn agents_md_section(&self, agents_md: &str) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::AgentsMdSection,
|
|
single("agents_md", agents_md),
|
|
)
|
|
}
|
|
pub fn resident_memory_summary_section(&self, summary: &str) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::ResidentMemorySummarySection,
|
|
single("summary", summary),
|
|
)
|
|
}
|
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
|
self.render(
|
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
|
Value::UNDEFINED,
|
|
)
|
|
}
|
|
pub fn sub_worker_spawn_tool_description(
|
|
&self,
|
|
available_profiles: &str,
|
|
default_profile: &str,
|
|
profile_diagnostic: &str,
|
|
) -> Result<String, CatalogError> {
|
|
let mut context = BTreeMap::new();
|
|
context.insert("available_profiles", Value::from(available_profiles));
|
|
context.insert("default_profile", Value::from(default_profile));
|
|
context.insert("profile_diagnostic", Value::from(profile_diagnostic));
|
|
self.render(
|
|
WorkerPrompt::SubWorkerSpawnToolDescription,
|
|
Value::from(context),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// DCDL schema contribution for the closed `WorkspaceConfig.prompts` namespace.
|
|
/// Every leaf defaults to its builtin value, so Workspace config is a right-biased
|
|
/// deep patch while evaluation materializes a complete effective catalog.
|
|
pub fn prompt_schema_source() -> Result<String, CatalogError> {
|
|
let mut templates = builtin_prompt_templates()?;
|
|
if let Some(default) = templates.remove("default") {
|
|
templates.insert("default_prompt".to_string(), default);
|
|
}
|
|
let tree = unflatten_templates(&templates);
|
|
let mut output = String::from("{ prompts = ");
|
|
write_schema_object(&mut output, &tree)?;
|
|
output.push_str(" default {}; }");
|
|
Ok(output)
|
|
}
|
|
|
|
pub fn builtin_prompt_templates() -> Result<BTreeMap<String, String>, CatalogError> {
|
|
let mut entries = Vec::new();
|
|
collect_builtin_entries(&BUILTIN_PROMPT_SOURCES, "", &mut entries)?;
|
|
let snapshot = ConfigTreeSnapshot::from_entries(0, entries)
|
|
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?;
|
|
let entry = VirtualPath::parse(BUILTIN_CATALOG_ENTRY)
|
|
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?;
|
|
let result = SnapshotEnvironment::new(snapshot)
|
|
.evaluate_contract(&ToolchainContract::new(1, vec![entry], 1))
|
|
.map_err(|diagnostics| {
|
|
CatalogError::BuiltinEvaluation(
|
|
diagnostics
|
|
.into_iter()
|
|
.map(|diagnostic| {
|
|
format!(
|
|
"{}:{}:{}..{}: {}",
|
|
diagnostic.path,
|
|
diagnostic.kind,
|
|
diagnostic.span.start_byte,
|
|
diagnostic.span.end_byte,
|
|
diagnostic.message
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("; "),
|
|
)
|
|
})?;
|
|
let projection = result
|
|
.projections
|
|
.first()
|
|
.ok_or_else(|| CatalogError::BuiltinEvaluation("catalog produced no projection".into()))?;
|
|
let mut templates = BTreeMap::new();
|
|
flatten_templates("", &projection.data_json, &mut templates)?;
|
|
if let Some(default_prompt) = templates.remove("default_prompt") {
|
|
templates.insert("default".to_string(), default_prompt);
|
|
}
|
|
validate_prompt_templates(&templates)?;
|
|
Ok(templates)
|
|
}
|
|
|
|
fn collect_builtin_entries(
|
|
dir: &Dir<'static>,
|
|
prefix: &str,
|
|
entries: &mut Vec<ConfigEntry>,
|
|
) -> Result<(), CatalogError> {
|
|
for file in dir.files() {
|
|
let Some(name) = file.path().file_name().and_then(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
let relative = if prefix.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{prefix}/{name}")
|
|
};
|
|
let content_type = if relative.ends_with(".dcdl") {
|
|
ConfigContentType::Decodal
|
|
} else if relative.ends_with(".md") {
|
|
ConfigContentType::Text
|
|
} else {
|
|
continue;
|
|
};
|
|
let content = file.contents_utf8().ok_or_else(|| {
|
|
CatalogError::BuiltinTree(format!("builtin Prompt source is not UTF-8: {relative}"))
|
|
})?;
|
|
let path = VirtualPath::parse(&relative)
|
|
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?;
|
|
entries.push(
|
|
ConfigEntry::new(path, content_type, content)
|
|
.map_err(|error| CatalogError::BuiltinTree(error.to_string()))?,
|
|
);
|
|
}
|
|
for child in dir.dirs() {
|
|
let Some(name) = child.path().file_name().and_then(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
let child_prefix = if prefix.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{prefix}/{name}")
|
|
};
|
|
collect_builtin_entries(child, &child_prefix, entries)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn single(key: &'static str, value: &str) -> Value {
|
|
Value::from(BTreeMap::from([(key, Value::from(value))]))
|
|
}
|
|
|
|
fn flatten_templates(
|
|
prefix: &str,
|
|
value: &serde_json::Value,
|
|
output: &mut BTreeMap<String, String>,
|
|
) -> Result<(), CatalogError> {
|
|
match value {
|
|
serde_json::Value::String(source) if !prefix.is_empty() => {
|
|
output.insert(prefix.to_string(), source.clone());
|
|
Ok(())
|
|
}
|
|
serde_json::Value::Object(fields) => {
|
|
for (name, value) in fields {
|
|
let key = if prefix.is_empty() {
|
|
name.clone()
|
|
} else {
|
|
format!("{prefix}.{name}")
|
|
};
|
|
flatten_templates(&key, value, output)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Err(CatalogError::InvalidProjection {
|
|
path: if prefix.is_empty() {
|
|
"prompts".into()
|
|
} else {
|
|
format!("prompts.{prefix}")
|
|
},
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct TemplateNode {
|
|
value: Option<String>,
|
|
children: BTreeMap<String, TemplateNode>,
|
|
}
|
|
|
|
fn unflatten_templates(templates: &BTreeMap<String, String>) -> TemplateNode {
|
|
let mut root = TemplateNode::default();
|
|
for (key, value) in templates {
|
|
let mut node = &mut root;
|
|
for segment in key.split('.') {
|
|
node = node.children.entry(segment.to_string()).or_default();
|
|
}
|
|
node.value = Some(value.clone());
|
|
}
|
|
root
|
|
}
|
|
|
|
fn write_schema_object(output: &mut String, node: &TemplateNode) -> Result<(), CatalogError> {
|
|
output.push_str("{");
|
|
for (name, child) in &node.children {
|
|
output.push_str(name);
|
|
output.push_str(" = ");
|
|
if let Some(value) = &child.value {
|
|
output.push_str("String default ");
|
|
output.push_str(&serde_json::to_string(value)?);
|
|
} else {
|
|
write_schema_object(output, child)?;
|
|
output.push_str(" default {}");
|
|
}
|
|
output.push_str("; ");
|
|
}
|
|
output.push('}');
|
|
Ok(())
|
|
}
|
|
|
|
fn catalog_digest(templates: &BTreeMap<String, String>) -> Result<String, CatalogError> {
|
|
Ok(digest_bytes(&serde_json::to_vec(templates)?))
|
|
}
|
|
|
|
fn validate_prompt_templates(templates: &BTreeMap<String, String>) -> Result<(), CatalogError> {
|
|
config_source::validate_static_template_catalog(templates)
|
|
.map_err(CatalogError::InvalidTemplateCatalog)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn builtin_dcdl_catalog_covers_worker_prompts() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
for prompt in WorkerPrompt::ALL {
|
|
assert!(catalog.projection.templates.contains_key(prompt.key()));
|
|
}
|
|
assert!(catalog.projection.templates.contains_key("default"));
|
|
assert!(
|
|
catalog
|
|
.projection
|
|
.templates
|
|
.contains_key("common.workspace")
|
|
);
|
|
assert!(catalog.projection.templates.contains_key("role.coder"));
|
|
assert!(
|
|
catalog
|
|
.projection
|
|
.templates
|
|
.contains_key("panel.orchestrator_idle_queue_notice")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_request_instruction_matches_the_exposed_operations() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
let coder = catalog
|
|
.render_name(
|
|
"common.merge_request",
|
|
Value::from_serialize(serde_json::json!({
|
|
"tools": ["MergeRequestShow", "MergeRequestOpen"]
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert!(coder.contains("Reread the current Merge Request"));
|
|
assert!(coder.contains("Open the Merge Request only after"));
|
|
assert!(!coder.contains("Submit the authoritative verdict"));
|
|
assert!(!coder.contains("Complete integration only after"));
|
|
|
|
let orchestrator = catalog
|
|
.render_name(
|
|
"common.merge_request",
|
|
Value::from_serialize(serde_json::json!({
|
|
"tools": [
|
|
"MergeRequestShow",
|
|
"MergeRequestReadinessCheck",
|
|
"MergeRequestComplete"
|
|
]
|
|
})),
|
|
)
|
|
.unwrap();
|
|
assert!(orchestrator.contains("Use `MergeRequestReadinessCheck`"));
|
|
assert!(orchestrator.contains("Complete integration only after"));
|
|
assert!(!orchestrator.contains("Open the Merge Request only after"));
|
|
}
|
|
|
|
#[test]
|
|
fn commit_capable_roles_classify_commits_by_change_type() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
assert!(catalog.projection.templates.contains_key("common.git"));
|
|
let context = Value::from_serialize(serde_json::json!({
|
|
"cwd": "/workspace",
|
|
"date": "2026-08-16",
|
|
"language": "match the user's language",
|
|
"tool_capabilities": {
|
|
"memory_any": false,
|
|
"memory_mutation": false,
|
|
"memory_query": false,
|
|
"memory_read_document": false
|
|
}
|
|
}));
|
|
|
|
for prompt in ["default", "role.coder", "role.orchestrator"] {
|
|
let rendered = catalog.render_name(prompt, context.clone()).unwrap();
|
|
assert!(rendered.contains("use the change type as the subject prefix"));
|
|
assert!(rendered.contains("A change made because review"));
|
|
assert!(rendered.contains("Do not keep reusing a domain prefix"));
|
|
assert!(rendered.contains("fix: scope merge request foreign key checks"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_render_resolves_catalog_root_dotted_includes() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
let source = &catalog.projection.templates["default"];
|
|
assert!(source.contains("{% include \"common.workspace\" %}"));
|
|
assert!(source.contains("{% include \"common.tool_usage\" %}"));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_is_closed_and_materializes_builtin_defaults() {
|
|
let source = prompt_schema_source().unwrap();
|
|
assert!(source.starts_with("{ prompts = {"));
|
|
assert!(source.contains("compact_system = String default"));
|
|
assert!(source.contains("role = {"));
|
|
}
|
|
|
|
#[test]
|
|
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
|
let invalid = BTreeMap::from([
|
|
("a".into(), "{%- include target -%}".into()),
|
|
("target".into(), "ok".into()),
|
|
]);
|
|
assert!(validate_prompt_templates(&invalid).is_err());
|
|
|
|
let legacy = BTreeMap::from([("a".into(), "{% include \"legacy/default\" %}".into())]);
|
|
assert!(validate_prompt_templates(&legacy).is_err());
|
|
|
|
let missing = BTreeMap::from([("a".into(), "{%- include \"missing\" -%}".into())]);
|
|
assert!(validate_prompt_templates(&missing).is_err());
|
|
|
|
let cycle = BTreeMap::from([
|
|
("a".into(), "{% include \"b\" %}".into()),
|
|
("b".into(), "{% include \"a\" %}".into()),
|
|
]);
|
|
assert!(validate_prompt_templates(&cycle).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_projection_digest_is_stable_and_verified() {
|
|
let templates = builtin_prompt_templates().unwrap();
|
|
let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap();
|
|
projection.verify_digest().unwrap();
|
|
let mut tampered = projection.clone();
|
|
tampered
|
|
.templates
|
|
.insert("default".into(), "tampered".into());
|
|
assert!(matches!(
|
|
tampered.verify_digest(),
|
|
Err(CatalogError::DigestMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_source_preserves_workspace_projection_for_subworkers() {
|
|
let mut templates = builtin_prompt_templates().unwrap();
|
|
templates.insert("common.workspace".into(), "CHILD OVERRIDE".into());
|
|
let catalog = PromptCatalog::from_projection(
|
|
EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(),
|
|
)
|
|
.unwrap();
|
|
let child = PromptCatalog::load(&catalog.source()).unwrap();
|
|
assert_eq!(child.projection.config_revision, 9);
|
|
assert_eq!(
|
|
child.projection.templates["common.workspace"],
|
|
"CHILD OVERRIDE"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn orchestrator_role_keeps_review_routing_owned_by_coder() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
let prompt = &catalog.projection.templates["role.orchestrator"];
|
|
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
|
|
assert!(prompt.contains("then use `SpawnTicketCoder`"));
|
|
assert!(prompt.contains("verify its current assignment names that Coder"));
|
|
assert!(prompt.contains("never route implementation to an unassigned Coder"));
|
|
assert!(prompt.contains(
|
|
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
|
|
));
|
|
assert!(prompt.contains("never compensate by creating an independent Reviewer Worker"));
|
|
assert!(prompt.contains("durable `ready -> queued` transition delegates"));
|
|
assert!(prompt.contains("use the Ticket repository `origin` transport"));
|
|
assert!(prompt.contains("guarded non-force push"));
|
|
assert!(prompt.contains("Never mutate a Server-side repository path"));
|
|
assert!(prompt.contains("If the repository push succeeds but completion recording fails"));
|
|
assert!(prompt.contains("later target movement does not invalidate an idempotent replay"));
|
|
assert!(prompt.contains("closes the current assignment atomically"));
|
|
assert!(!prompt.contains("sibling Coder/Reviewer Workers"));
|
|
}
|
|
|
|
#[test]
|
|
fn coder_and_reviewer_roles_preserve_immutable_subject_authority() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
let coder = &catalog.projection.templates["role.coder"];
|
|
assert!(coder.contains("publish the committed source selector without force"));
|
|
assert!(coder.contains("exact local `HEAD`"));
|
|
assert!(coder.contains("keep that source ref immutable"));
|
|
assert!(coder.contains("Do not update the target selector"));
|
|
|
|
let reviewer = &catalog.projection.templates["role.reviewer"];
|
|
assert!(reviewer.contains("never as a supplied verdict"));
|
|
assert!(reviewer.contains("ReviewRequested.subject_ref"));
|
|
assert!(reviewer.contains("do not merge"));
|
|
assert!(reviewer.contains("update a repository ref"));
|
|
}
|
|
|
|
#[test]
|
|
fn existing_internal_prompt_render_contracts_are_preserved() {
|
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
|
assert!(catalog.compact_system().unwrap().contains("write_summary"));
|
|
assert!(
|
|
catalog
|
|
.memory_extract_system("Japanese")
|
|
.unwrap()
|
|
.contains("`language`: `Japanese`")
|
|
);
|
|
assert!(
|
|
catalog
|
|
.notify_wrapper("changed")
|
|
.unwrap()
|
|
.contains("changed")
|
|
);
|
|
assert!(
|
|
catalog
|
|
.working_boundaries_section("Readable: /a")
|
|
.unwrap()
|
|
.contains("Readable: /a")
|
|
);
|
|
assert!(
|
|
catalog
|
|
.worker_orchestration_guidance_section()
|
|
.unwrap()
|
|
.contains("## SubWorker orchestration")
|
|
);
|
|
}
|
|
}
|