M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,880 @@
|
||||
//! 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 `grok 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 `grok 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 grok-build 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 was removed 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 was removed 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 was removed 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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user