Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
881 lines
32 KiB
Rust
881 lines
32 KiB
Rust
//! System prompt template source and constants.
|
|
//!
|
|
//! Templates are XOR-obfuscated by `scripts/encrypt_templates.py` (obfuscation,
|
|
//! not security — seeds live in-repo) so they don't appear as obvious plaintext
|
|
//! in `strings` output. They are decrypted on demand and the returned
|
|
//! `Zeroizing<String>` wipes the plaintext from memory on drop.
|
|
|
|
use zeroize::Zeroizing;
|
|
|
|
// Encrypted template bytes (pre-generated by scripts/encrypt_templates.py).
|
|
#[path = "prompt_encrypted.rs"]
|
|
mod prompt_encrypted;
|
|
use prompt_encrypted::*;
|
|
|
|
/// Decrypt XOR-obfuscated template data (mirrors `scripts/encrypt_templates.py::xor_encrypt`).
|
|
/// Obfuscation only — not a security boundary.
|
|
fn decrypt(data: &[u8], seed: u8) -> Zeroizing<String> {
|
|
let bytes: Vec<u8> = data
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
|
|
.collect();
|
|
Zeroizing::new(String::from_utf8(bytes).expect(
|
|
"prompt template decryption produced invalid UTF-8 — \
|
|
prompt_encrypted.rs is likely stale; run: \
|
|
python3 scripts/encrypt_templates.py",
|
|
))
|
|
}
|
|
|
|
/// The base prompt template (decrypted fresh; zeroed on drop).
|
|
pub(crate) fn base_template() -> Zeroizing<String> {
|
|
decrypt(BASE_PROMPT_ENC, PROMPT_SEEDS[0])
|
|
}
|
|
|
|
/// The base prompt template source, exposed for `kigi prompt --section template`.
|
|
pub fn base_template_source() -> Zeroizing<String> {
|
|
base_template()
|
|
}
|
|
|
|
pub(crate) fn apply_patch_template() -> Zeroizing<String> {
|
|
decrypt(CODEX_PROMPT_ENC, PROMPT_SEEDS[1])
|
|
}
|
|
|
|
/// Apply-patch prompt template source, exposed for `kigi prompt --section apply-patch-template`.
|
|
pub fn apply_patch_template_source() -> Zeroizing<String> {
|
|
apply_patch_template()
|
|
}
|
|
|
|
/// The subagent-specific base template (decrypted fresh; zeroed on drop).
|
|
pub(crate) fn subagent_template() -> Zeroizing<String> {
|
|
decrypt(SUBAGENT_PROMPT_ENC, PROMPT_SEEDS[2])
|
|
}
|
|
|
|
/// The compact system prompt used after conversation compaction.
|
|
pub const COMPACT_SYSTEM_PROMPT: &str = "You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
|
|
Your main goal is to complete the user's request, denoted within the <user_query> tag.";
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kigi_tools::types::template_renderer::TemplateRenderer;
|
|
use kigi_tools::types::tool::ToolKind;
|
|
use std::collections::HashMap;
|
|
|
|
/// Verify the pre-generated encrypted file matches the current template sources.
|
|
/// If this fails, run: `python3 scripts/encrypt_templates.py`
|
|
#[test]
|
|
fn test_encrypted_templates_not_stale() {
|
|
fn xor_encrypt(data: &[u8], seed: u8) -> Vec<u8> {
|
|
data.iter()
|
|
.enumerate()
|
|
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
|
|
.collect()
|
|
}
|
|
let base_raw = include_bytes!("../../templates/prompt.md");
|
|
let apply_patch_raw = include_bytes!("../../templates/apply_patch_prompt.md");
|
|
let subagent_raw = include_bytes!("../../templates/subagent_prompt.md");
|
|
|
|
assert_eq!(
|
|
BASE_PROMPT_ENC,
|
|
&xor_encrypt(base_raw, PROMPT_SEEDS[0]),
|
|
"prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
|
);
|
|
assert_eq!(
|
|
CODEX_PROMPT_ENC,
|
|
&xor_encrypt(apply_patch_raw, PROMPT_SEEDS[1]),
|
|
"apply_patch_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
|
);
|
|
assert_eq!(
|
|
SUBAGENT_PROMPT_ENC,
|
|
&xor_encrypt(subagent_raw, PROMPT_SEEDS[2]),
|
|
"subagent_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
|
);
|
|
}
|
|
|
|
/// Build a TemplateRenderer with the standard kigi tool kinds.
|
|
fn default_renderer() -> TemplateRenderer {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "read_file"),
|
|
(ToolKind::Edit, "search_replace"),
|
|
(ToolKind::Execute, "run_terminal_command"),
|
|
(ToolKind::Search, "grep"),
|
|
(ToolKind::List, "list_dir"),
|
|
(ToolKind::Plan, "todo_write"),
|
|
(ToolKind::Skill, "skill"),
|
|
(
|
|
ToolKind::BackgroundTaskAction,
|
|
"get_command_or_subagent_output",
|
|
),
|
|
(ToolKind::KillTaskAction, "kill_command_or_subagent"),
|
|
(ToolKind::WebSearch, "web_search"),
|
|
]
|
|
.into_iter()
|
|
.map(|(k, v)| (k, v.to_string()))
|
|
.collect();
|
|
TemplateRenderer::new(tools, HashMap::new())
|
|
}
|
|
|
|
fn default_placeholders() -> serde_json::Value {
|
|
serde_json::json!({
|
|
"os_name": "macos",
|
|
"shell_path": "/bin/zsh",
|
|
"working_directory": "/tmp/test",
|
|
"current_date": "2025-01-15",
|
|
"memory_enabled": false,
|
|
"is_non_interactive": false,
|
|
"system_prompt_label": crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL,
|
|
})
|
|
}
|
|
|
|
fn render_base(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
|
let tmpl = base_template();
|
|
renderer
|
|
.render_with_extra(&tmpl, placeholders)
|
|
.expect("base template render failed")
|
|
}
|
|
|
|
fn render_subagent(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
|
let tmpl = subagent_template();
|
|
renderer
|
|
.render_with_extra(&tmpl, placeholders)
|
|
.expect("subagent template render failed")
|
|
}
|
|
|
|
fn render_apply_patch(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
|
let tmpl = apply_patch_template();
|
|
renderer
|
|
.render_with_extra(&tmpl, placeholders)
|
|
.expect("codex template render failed")
|
|
}
|
|
|
|
// Variable substitution
|
|
|
|
#[test]
|
|
fn test_variable_substitution_tool_kind() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra("Use ${{ tools.by_kind.read }} to read files.", &p)
|
|
.unwrap();
|
|
assert_eq!(result, "Use read_file to read files.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_variable_substitution_agent_fields() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra("OS: ${{ os_name }}, Shell: ${{ shell_path }}", &p)
|
|
.unwrap();
|
|
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
|
|
}
|
|
|
|
// Conditionals
|
|
|
|
#[test]
|
|
fn test_conditional_tool_present() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
|
|
.unwrap();
|
|
assert_eq!(result, "show");
|
|
}
|
|
|
|
#[test]
|
|
fn test_conditional_tool_absent() {
|
|
// Renderer without plan tool
|
|
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
|
|
.unwrap();
|
|
assert_eq!(result, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_literal_braces_pass_through() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra("Use {{ literal_braces }} in prose.", &p)
|
|
.unwrap();
|
|
assert_eq!(result, "Use {{ literal_braces }} in prose.");
|
|
}
|
|
|
|
// Tool name overrides
|
|
|
|
#[test]
|
|
fn test_tool_name_override() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "view_file".to_string()),
|
|
(ToolKind::Edit, "Edit".to_string()),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let p = default_placeholders();
|
|
let result = r
|
|
.render_with_extra(
|
|
"Use ${{ tools.by_kind.read }} and ${{ tools.by_kind.edit }}.",
|
|
&p,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result, "Use view_file and Edit.");
|
|
}
|
|
|
|
// Base template rendering
|
|
|
|
#[test]
|
|
fn test_base_template_renders() {
|
|
let prompt = render_base(&default_renderer(), &default_placeholders());
|
|
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
|
|
assert!(prompt.contains("user_query"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_base_template_contains_resolved_tool_names() {
|
|
let prompt = render_base(&default_renderer(), &default_placeholders());
|
|
// The minimal prompt only resolves the read/edit tool names, inside
|
|
// <tool_calling>. (todo_write / run_terminal_command lived in sections
|
|
// that the trimmed prompt no longer renders.)
|
|
assert!(prompt.contains("read_file"), "Should contain 'read_file'");
|
|
assert!(
|
|
prompt.contains("search_replace"),
|
|
"Should contain 'search_replace'"
|
|
);
|
|
assert!(!prompt.contains("${{"), "No unresolved template variables");
|
|
assert!(!prompt.contains("${%"), "No unresolved template blocks");
|
|
}
|
|
|
|
#[test]
|
|
fn test_base_template_with_overridden_tool_names() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "view_file".to_string()),
|
|
(ToolKind::Edit, "edit".to_string()),
|
|
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
|
(ToolKind::Search, "grep".to_string()),
|
|
(ToolKind::Plan, "todo_write".to_string()),
|
|
(
|
|
ToolKind::BackgroundTaskAction,
|
|
"get_task_output".to_string(),
|
|
),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
prompt.contains("`view_file`"),
|
|
"Should use overridden 'view_file'"
|
|
);
|
|
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
|
|
assert!(
|
|
!prompt.contains("`read_file`"),
|
|
"Should NOT contain canonical 'read_file'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_base_template_plan_absent_omits_task_management() {
|
|
// Renderer without Plan tool
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "read_file".to_string()),
|
|
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
|
(
|
|
ToolKind::BackgroundTaskAction,
|
|
"get_task_output".to_string(),
|
|
),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("Task Management"),
|
|
"Task Management section should be omitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_base_template_execute_absent_omits_background_tasks() {
|
|
// Renderer without Execute tool
|
|
let tools: HashMap<ToolKind, String> = [(ToolKind::Plan, "todo_write".to_string())].into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("background_tasks"),
|
|
"background_tasks section should be omitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_tool_renders_watch_section() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Execute, "run_command".to_string()),
|
|
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
|
|
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
|
(ToolKind::Monitor, "monitor".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
prompt.contains("For watch processes"),
|
|
"monitor section should render when Monitor tool is present"
|
|
);
|
|
assert!(
|
|
prompt.contains("streams each stdout line back as a chat notification"),
|
|
"monitor section should describe streaming stdout as notifications"
|
|
);
|
|
assert!(
|
|
prompt.contains("Use the `monitor` tool"),
|
|
"monitor section should resolve the Monitor tool name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_monitor_tool_omits_watch_section() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Execute, "run_command".to_string()),
|
|
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
|
|
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("For watch processes"),
|
|
"monitor section should NOT render without Monitor tool"
|
|
);
|
|
assert!(
|
|
!prompt.contains("<background_tasks>"),
|
|
"background_tasks section is gated on the Monitor tool and is omitted without it"
|
|
);
|
|
}
|
|
|
|
// Required sections regression
|
|
|
|
#[test]
|
|
fn test_base_template_contains_required_sections() {
|
|
let p = default_placeholders();
|
|
let prompt = render_base(&default_renderer(), &p);
|
|
assert!(
|
|
prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL),
|
|
"Must contain agent identity"
|
|
);
|
|
assert!(
|
|
prompt.contains("user_query"),
|
|
"Must reference user_query tag"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compact_prompt_matches_expected() {
|
|
assert_eq!(
|
|
COMPACT_SYSTEM_PROMPT,
|
|
"You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
|
|
Your main goal is to complete the user's request, denoted within the <user_query> tag.",
|
|
);
|
|
}
|
|
|
|
// Mid-session mode switching
|
|
|
|
#[test]
|
|
fn test_mid_session_switch_concise_to_full() {
|
|
let compact = COMPACT_SYSTEM_PROMPT;
|
|
assert!(!compact.contains("read_file"), "Compact has no tool names");
|
|
assert!(
|
|
!compact.contains("<tool_calling>"),
|
|
"Compact has no tool section"
|
|
);
|
|
|
|
let full = render_base(&default_renderer(), &default_placeholders());
|
|
assert!(
|
|
full.contains("<tool_calling>"),
|
|
"Full prompt has tool section"
|
|
);
|
|
assert!(full.contains("read_file"), "Full prompt has read_file");
|
|
assert!(
|
|
full.contains("search_replace"),
|
|
"Full prompt has search_replace"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mid_session_switch_preserves_tool_overrides() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "view".to_string()),
|
|
(ToolKind::Edit, "edit".to_string()),
|
|
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
|
(ToolKind::Plan, "todo_write".to_string()),
|
|
(
|
|
ToolKind::BackgroundTaskAction,
|
|
"get_task_output".to_string(),
|
|
),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
|
|
assert!(prompt.contains("`view`"), "Should use overridden 'view'");
|
|
assert!(
|
|
!prompt.contains("`read_file`"),
|
|
"Should not contain original 'read_file'"
|
|
);
|
|
assert!(
|
|
!prompt.contains("`search_replace`"),
|
|
"Should not contain original 'search_replace'"
|
|
);
|
|
}
|
|
|
|
// Determinism
|
|
|
|
#[test]
|
|
fn test_prompt_deterministic_across_renders() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let a = render_base(&r, &p);
|
|
let b = render_base(&r, &p);
|
|
assert_eq!(a, b, "Prompt rendering must be deterministic");
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_mode_deterministic() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let body = "Agent: ${{ tools.by_kind.read }}, OS: ${{ os_name }}";
|
|
let a = r.render_with_extra(body, &p).unwrap();
|
|
let b = r.render_with_extra(body, &p).unwrap();
|
|
assert_eq!(a, b, "Full mode rendering must be deterministic");
|
|
}
|
|
|
|
// Disabled tools
|
|
|
|
#[test]
|
|
fn test_disabled_tools_omit_sections() {
|
|
// No plan, no execute
|
|
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_base(&r, &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("Task Management"),
|
|
"Task Management must be omitted"
|
|
);
|
|
assert!(
|
|
!prompt.contains("background_tasks"),
|
|
"background_tasks must be omitted"
|
|
);
|
|
}
|
|
|
|
// Memory section
|
|
|
|
#[test]
|
|
fn test_memory_enabled_does_not_render_memory_section() {
|
|
// The <memory> section is absent from the minimal base prompt.
|
|
// Even when the memory tools are registered AND memory_enabled=true,
|
|
// the trimmed template must not render a memory section. (Complements
|
|
// test_memory_disabled_omits_memory_section, which covers the default.)
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "read_file".to_string()),
|
|
(ToolKind::MemorySearch, "memory_search".to_string()),
|
|
(ToolKind::MemoryGet, "memory_get".to_string()),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let mut p = default_placeholders();
|
|
p["memory_enabled"] = serde_json::json!(true);
|
|
let prompt = render_base(&r, &p);
|
|
assert!(
|
|
!prompt.contains("<memory>"),
|
|
"Memory section was removed from the minimal prompt"
|
|
);
|
|
assert!(
|
|
!prompt.contains("### Memory Management"),
|
|
"Memory Management section was removed from the minimal prompt"
|
|
);
|
|
assert!(
|
|
!prompt.contains("memory_search"),
|
|
"memory tool names must not appear once the memory section is gone"
|
|
);
|
|
assert!(
|
|
!prompt.contains("memory_get"),
|
|
"memory tool names must not appear once the memory section is gone"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_memory_disabled_omits_memory_section() {
|
|
let prompt = render_base(&default_renderer(), &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("<memory>"),
|
|
"Memory section must be omitted"
|
|
);
|
|
}
|
|
|
|
// Web search disabled
|
|
|
|
#[test]
|
|
fn test_web_search_disabled_renders_without_crash() {
|
|
// No Fetch tool
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "read_file".to_string()),
|
|
(ToolKind::Plan, "todo_write".to_string()),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let tmpl = base_template();
|
|
let result = r.render_with_extra(&tmpl, &default_placeholders());
|
|
assert!(
|
|
result.is_ok(),
|
|
"Must render without crash: {:?}",
|
|
result.err()
|
|
);
|
|
}
|
|
|
|
// Apply-patch template rendering
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_renders() {
|
|
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
|
assert!(prompt.contains("coding agent"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_contains_resolved_tool_names() {
|
|
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
|
assert!(prompt.contains("todo_write"), "Should contain 'todo_write'");
|
|
// apply_patch is hardcoded, not resolved via ${{ tools.by_kind.edit }}
|
|
assert!(
|
|
prompt.contains("apply_patch"),
|
|
"Should contain hardcoded 'apply_patch'"
|
|
);
|
|
assert!(!prompt.contains("${{"), "No unresolved template variables");
|
|
assert!(!prompt.contains("${%"), "No unresolved template blocks");
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_plan_absent_omits_planning() {
|
|
// Renderer without Plan tool
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "read_file".to_string()),
|
|
(ToolKind::Edit, "search_replace".to_string()),
|
|
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_apply_patch(&r, &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("## Planning"),
|
|
"Planning section should be omitted when plan tool absent"
|
|
);
|
|
assert!(
|
|
!prompt.contains("update_plan"),
|
|
"update_plan references should be omitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_plan_present_includes_planning() {
|
|
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
|
assert!(
|
|
prompt.contains("## Planning"),
|
|
"Planning section should be present when plan tool exists"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_with_overridden_tool_names() {
|
|
let tools: HashMap<ToolKind, String> = [
|
|
(ToolKind::Read, "view_file".to_string()),
|
|
(ToolKind::Edit, "some_other_edit".to_string()),
|
|
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
|
(ToolKind::Plan, "update_plan".to_string()),
|
|
(
|
|
ToolKind::BackgroundTaskAction,
|
|
"get_task_output".to_string(),
|
|
),
|
|
]
|
|
.into();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let prompt = render_apply_patch(&r, &default_placeholders());
|
|
// apply_patch is hardcoded — NOT affected by Edit tool override
|
|
assert!(
|
|
prompt.contains("`apply_patch`"),
|
|
"apply_patch must remain hardcoded regardless of edit override"
|
|
);
|
|
assert!(
|
|
!prompt.contains("some_other_edit"),
|
|
"Edit override must NOT leak into apply-patch prompt"
|
|
);
|
|
// Plan tool IS resolved via template
|
|
assert!(
|
|
prompt.contains("`update_plan`"),
|
|
"Should use overridden 'update_plan'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_patch_template_deterministic_across_renders() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let a = render_apply_patch(&r, &p);
|
|
let b = render_apply_patch(&r, &p);
|
|
assert_eq!(a, b, "Apply-patch template rendering must be deterministic");
|
|
}
|
|
|
|
#[test]
|
|
fn test_subagent_template_deterministic_across_renders() {
|
|
let r = default_renderer();
|
|
let p = default_placeholders();
|
|
let a = render_subagent(&r, &p);
|
|
let b = render_subagent(&r, &p);
|
|
assert_eq!(a, b, "Subagent template rendering must be deterministic");
|
|
}
|
|
|
|
// Task completion discipline
|
|
//
|
|
// The `<task_completion_discipline>` block is absent from both
|
|
// base and subagent templates. These tests pin the deletion so the
|
|
// block doesn't accidentally come back, and so the runtime TodoGate
|
|
// doesn't start firing reminders that reference a non-existent
|
|
// block.
|
|
|
|
#[test]
|
|
fn task_completion_discipline_block_is_not_rendered() {
|
|
let prompt = render_base(&default_renderer(), &default_placeholders());
|
|
assert!(
|
|
!prompt.contains("<task_completion_discipline>"),
|
|
"discipline block was removed from the base template"
|
|
);
|
|
let subagent = render_subagent(&default_renderer(), &default_placeholders());
|
|
assert!(
|
|
!subagent.contains("<task_completion_discipline>"),
|
|
"discipline block was removed from the subagent template"
|
|
);
|
|
}
|
|
|
|
/// Soft byte ceiling shared by both prompt-size budget tests.
|
|
/// Forward-budget guard against runaway growth, not a tight target.
|
|
const PROMPT_SIZE_SOFT_CEILING_BYTES: usize = 16384;
|
|
|
|
fn assert_template_size_under(prompt: &str, label: &str) {
|
|
assert!(
|
|
prompt.len() < PROMPT_SIZE_SOFT_CEILING_BYTES,
|
|
"{label} prompt is {} bytes, exceeding soft ceiling of {} bytes",
|
|
prompt.len(),
|
|
PROMPT_SIZE_SOFT_CEILING_BYTES,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_base_template_size_budget() {
|
|
let prompt = render_base(&default_renderer(), &default_placeholders());
|
|
assert_template_size_under(&prompt, "base");
|
|
}
|
|
|
|
#[test]
|
|
fn test_subagent_template_size_budget() {
|
|
let prompt = render_subagent(&default_renderer(), &default_placeholders());
|
|
assert_template_size_under(&prompt, "subagent");
|
|
}
|
|
|
|
// Guard invariant
|
|
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
|
|
// whose condition requires X (contains `tools.by_kind.X` at a word
|
|
// boundary, with no top-level ` or `). If violated, X could render
|
|
// as empty string at runtime.
|
|
|
|
fn word_bounded(hay: &str, needle: &str) -> bool {
|
|
let mut s = 0;
|
|
while let Some(i) = hay[s..].find(needle) {
|
|
let end = s + i + needle.len();
|
|
match hay[end..].chars().next() {
|
|
None => return true,
|
|
Some(c) if !(c.is_alphanumeric() || c == '_') => return true,
|
|
_ => s += i + 1,
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn guarantees(cond: &str, kind: &str) -> bool {
|
|
if word_bounded(cond, &format!("tools.by_kind.{kind}")) && !cond.contains(" or ") {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn assert_guards(template: &str, label: &str) {
|
|
let bytes = template.as_bytes();
|
|
let mut stack: Vec<String> = Vec::new();
|
|
let mut errors: Vec<String> = Vec::new();
|
|
let mut i = 0;
|
|
while i + 2 < bytes.len() {
|
|
let three = &bytes[i..i + 3];
|
|
if three == b"${%" {
|
|
let end = bytes[i + 3..]
|
|
.windows(2)
|
|
.position(|w| w == b"%}")
|
|
.map(|e| i + 3 + e + 2)
|
|
.unwrap_or(bytes.len());
|
|
let body = std::str::from_utf8(&bytes[i + 3..end - 2])
|
|
.unwrap()
|
|
.trim_matches(['-', ' ']);
|
|
if let Some(c) = body.strip_prefix("if ") {
|
|
stack.push(c.trim().into());
|
|
} else if let Some(c) = body.strip_prefix("elif ") {
|
|
stack.pop();
|
|
stack.push(c.trim().into());
|
|
} else if body == "else" {
|
|
stack.pop();
|
|
stack.push("<else>".into());
|
|
} else if body == "endif" {
|
|
stack.pop();
|
|
}
|
|
i = end;
|
|
} else if three == b"${{" {
|
|
let end = bytes[i + 3..]
|
|
.windows(2)
|
|
.position(|w| w == b"}}")
|
|
.map(|e| i + 3 + e + 2)
|
|
.unwrap_or(bytes.len());
|
|
let body = std::str::from_utf8(&bytes[i + 3..end - 2]).unwrap().trim();
|
|
// search_tool and use_tool are always built-in, so they
|
|
// never need a guard.
|
|
const ALWAYS_BUILTIN: &[&str] = &["search_tool", "use_tool"];
|
|
if let Some(kind) = body.strip_prefix("tools.by_kind.")
|
|
&& kind.chars().all(|c| c.is_alphanumeric() || c == '_')
|
|
&& !ALWAYS_BUILTIN.contains(&kind)
|
|
&& !stack.iter().any(|c| guarantees(c, kind))
|
|
{
|
|
let line = template[..i].lines().count() + 1;
|
|
errors.push(format!(
|
|
"{label}:{line}: unguarded `${{{{ tools.by_kind.{kind} }}}}` (stack: {stack:?})"
|
|
));
|
|
}
|
|
i = end;
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
assert!(errors.is_empty(), "\n {}", errors.join("\n "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_vars_are_always_guarded() {
|
|
assert_guards(&base_template(), "prompt.md");
|
|
assert_guards(&subagent_template(), "subagent_prompt.md");
|
|
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
|
|
}
|
|
|
|
// Combination sweep
|
|
// Belt-and-braces: renders the base template across tool-kind subsets
|
|
// and asserts no raw template tokens leak. The static guard test above
|
|
// is the authoritative check; this one just catches syntax drift.
|
|
|
|
// is_non_interactive gating
|
|
// Headless / SDK / stdio / generic-ACP sessions have no human typing
|
|
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
|
|
// `<user_guide>` TUI pointer are noise. Those sections must drop out
|
|
// when `is_non_interactive=true` and remain when it's false.
|
|
|
|
#[test]
|
|
fn interactive_renders_shell_prefix_tip_and_user_guide() {
|
|
// The `! <command>` shell-prefix tip is absent from the minimal
|
|
// prompt. The <user_guide> block still renders for interactive
|
|
// sessions only, so that's what we assert here.
|
|
let mut p = default_placeholders();
|
|
p["is_non_interactive"] = serde_json::json!(false);
|
|
let prompt = render_base(&default_renderer(), &p);
|
|
assert!(
|
|
prompt.contains("<user_guide>"),
|
|
"interactive prompt must keep the <user_guide> block"
|
|
);
|
|
assert!(
|
|
prompt.contains("interactive CLI tool"),
|
|
"interactive prompt must declare interactive mode in the header"
|
|
);
|
|
assert!(
|
|
!prompt.contains("autonomous agent"),
|
|
"interactive prompt must NOT advertise non-interactive (autonomous) mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_suppresses_shell_prefix_tip_and_user_guide() {
|
|
let mut p = default_placeholders();
|
|
p["is_non_interactive"] = serde_json::json!(true);
|
|
let prompt = render_base(&default_renderer(), &p);
|
|
assert!(
|
|
!prompt.contains("`! <command>`"),
|
|
"non-interactive prompt must suppress the shell-prefix tip"
|
|
);
|
|
assert!(
|
|
!prompt.contains("<user_guide>"),
|
|
"non-interactive prompt must suppress the <user_guide> block"
|
|
);
|
|
assert!(
|
|
prompt.contains("autonomous agent"),
|
|
"non-interactive prompt must declare autonomous mode in the header"
|
|
);
|
|
assert!(
|
|
!prompt.contains("interactive CLI tool"),
|
|
"non-interactive prompt must NOT claim to be the interactive CLI"
|
|
);
|
|
// Sanity: rest of the template still renders.
|
|
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
|
|
assert!(prompt.contains("user_query"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_combination_sweep_no_unresolved_variables() {
|
|
let optional = [
|
|
ToolKind::Read,
|
|
ToolKind::Edit,
|
|
ToolKind::Execute,
|
|
ToolKind::Search,
|
|
ToolKind::List,
|
|
ToolKind::Plan,
|
|
ToolKind::Skill,
|
|
ToolKind::Task,
|
|
ToolKind::AskUser,
|
|
ToolKind::EnterPlan,
|
|
ToolKind::ExitPlan,
|
|
ToolKind::BackgroundTaskAction,
|
|
ToolKind::Monitor,
|
|
ToolKind::MemorySearch,
|
|
ToolKind::MemoryGet,
|
|
];
|
|
let mut subsets: Vec<Vec<ToolKind>> = vec![vec![], optional.to_vec()];
|
|
for i in 0..optional.len() {
|
|
subsets.push(vec![optional[i]]);
|
|
for j in (i + 1)..optional.len() {
|
|
subsets.push(vec![optional[i], optional[j]]);
|
|
}
|
|
}
|
|
|
|
for memory_enabled in [false, true] {
|
|
for subset in &subsets {
|
|
let tools: HashMap<ToolKind, String> = subset
|
|
.iter()
|
|
.map(|k| (*k, format!("{k:?}").to_lowercase()))
|
|
.collect();
|
|
let r = TemplateRenderer::new(tools, HashMap::new());
|
|
let mut p = default_placeholders();
|
|
p["memory_enabled"] = serde_json::json!(memory_enabled);
|
|
let rendered = r
|
|
.render_with_extra(&base_template(), &p)
|
|
.unwrap_or_else(|e| {
|
|
panic!("render failed: {subset:?} mem={memory_enabled}: {e:?}")
|
|
});
|
|
assert!(
|
|
!rendered.contains("${{") && !rendered.contains("${%"),
|
|
"unresolved token in render: {subset:?} mem={memory_enabled}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|