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).
1352 lines
54 KiB
Rust
1352 lines
54 KiB
Rust
//! First-class, inspectable system prompt context.
|
|
//!
|
|
//! `PromptContext` captures the agent-specific inputs to prompt rendering
|
|
//! as a serializable struct. Users can dump it as JSON and inspect
|
|
//! individual sections.
|
|
//!
|
|
//! Rendering is done by `ToolBridge::render_prompt()` which delegates to
|
|
//! `TemplateRenderer` in `kigi-tools`. This struct does NOT own a
|
|
//! render engine — it provides placeholders and discovered sections.
|
|
use crate::config::PromptMode;
|
|
use crate::prompt::agents_md::{self, AgentConfigFile};
|
|
use crate::prompt::template::{apply_patch_template, base_template, subagent_template};
|
|
use serde::de;
|
|
use serde::{Deserialize, Serialize};
|
|
/// Selects which base template to use for `Extend` mode rendering.
|
|
///
|
|
/// Built-in variants decrypt the template on demand and never store
|
|
/// the plaintext persistently, ensuring it is zeroed after use.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TemplateOverride {
|
|
/// Use the standard base template (or subagent template based on audience).
|
|
#[default]
|
|
None,
|
|
/// Use the apply-patch profile prompt template (decrypted on demand).
|
|
Codex,
|
|
/// A caller-provided custom template string.
|
|
Custom(String),
|
|
}
|
|
/// Backward-compatible deserialization: accepts both the new tagged format
|
|
/// (`"none"`, `"codex"`, `{"custom": "..."}`) and the legacy format where
|
|
/// `system_prompt` was `Option<String>` (a raw template string).
|
|
impl<'de> Deserialize<'de> for TemplateOverride {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
struct Visitor;
|
|
impl<'de> de::Visitor<'de> for Visitor {
|
|
type Value = TemplateOverride;
|
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
f.write_str(r#""none", "codex", "cursor", {"custom": "..."}, or a template string"#)
|
|
}
|
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<TemplateOverride, E> {
|
|
match v {
|
|
"none" => Ok(TemplateOverride::None),
|
|
"codex" => Ok(TemplateOverride::Codex),
|
|
other => Ok(TemplateOverride::Custom(other.to_owned())),
|
|
}
|
|
}
|
|
fn visit_map<M: de::MapAccess<'de>>(
|
|
self,
|
|
mut map: M,
|
|
) -> Result<TemplateOverride, M::Error> {
|
|
match map.next_key::<String>()? {
|
|
Some(ref k) if k == "custom" => {
|
|
let val: String = map.next_value()?;
|
|
Ok(TemplateOverride::Custom(val))
|
|
}
|
|
Some(other) => Err(de::Error::unknown_field(&other, &["custom"])),
|
|
Option::None => Err(de::Error::custom(r#"expected {"custom": "..."}"#)),
|
|
}
|
|
}
|
|
}
|
|
deserializer.deserialize_any(Visitor)
|
|
}
|
|
}
|
|
/// Controls which base template and catalog sections are rendered.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PromptAudience {
|
|
/// Top-level interactive session. Full base template, all catalog sections.
|
|
#[default]
|
|
Primary,
|
|
/// Child/subagent session. Compact base template, no persona/subagent catalogs.
|
|
Subagent,
|
|
}
|
|
use kigi_tools::bridge::ToolBridge;
|
|
/// Agent-specific inputs for system prompt rendering.
|
|
///
|
|
/// Serializable (JSON/YAML) so users can dump it and inspect fields.
|
|
/// Rendering goes through `ToolBridge::render_prompt()`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PromptContext {
|
|
/// Schema version for forward-compatible persistence.
|
|
pub version: u32,
|
|
/// Which prompt mode produced this context.
|
|
pub prompt_mode: PromptMode,
|
|
/// Whether this is a primary (parent) or subagent (child) session.
|
|
/// Controls base template choice and catalog section rendering.
|
|
#[serde(default)]
|
|
pub audience: PromptAudience,
|
|
/// Custom body: appended after base template (Extend) or the entire
|
|
/// prompt (Full). `None` = base template only.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub prompt_body: Option<String>,
|
|
/// Which base template to use for `Extend` mode.
|
|
/// `TemplateOverride::None` = standard base/subagent template.
|
|
/// `TemplateOverride::Codex` = apply-patch profile template (decrypted on demand).
|
|
/// `TemplateOverride::Custom` = caller-provided template string.
|
|
#[serde(default, skip_serializing_if = "is_template_override_none")]
|
|
pub system_prompt: TemplateOverride,
|
|
/// AGENTS.md files discovered during build, in precedence order
|
|
/// (repo root → CWD; deeper files override).
|
|
pub agents_md_files: Vec<AgentConfigFile>,
|
|
/// Pre-rendered persona summaries for system prompt injection.
|
|
/// Each entry is a formatted string like:
|
|
/// `- **reviewer** [user]: Writes structured review notes...`
|
|
#[serde(default)]
|
|
pub persona_summaries: Vec<String>,
|
|
/// ISO-8601 UTC timestamp captured at build time.
|
|
pub build_timestamp_utc: String,
|
|
/// Whether the memory system is enabled for this session.
|
|
/// When true, the system prompt includes a `<memory>` section telling
|
|
/// the model it can use `memory_search` and `memory_get`.
|
|
#[serde(default)]
|
|
pub memory_enabled: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub memory_global_path: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub memory_workspace_path: Option<String>,
|
|
/// Role instructions to include in the system prompt.
|
|
/// Moved from the user task prompt so they're part of durable identity.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub role_instructions: Option<String>,
|
|
/// Persona instructions to include in the system prompt.
|
|
/// Moved from the user task prompt so they're part of durable identity.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub persona_instructions: Option<String>,
|
|
/// OS name for the `<user_info>` system prompt block.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub os_name: Option<String>,
|
|
/// User's default shell for the `<user_info>` system prompt block.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub shell_path: Option<String>,
|
|
/// Model-facing working directory for the `<user_info>` system prompt block.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub working_directory: Option<String>,
|
|
/// Current date (`YYYY-MM-DD`) in the user's local timezone, for the
|
|
/// `<user_info>` system prompt block.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub current_date: Option<String>,
|
|
/// Whether the agent is running in a non-interactive (headless / SDK /
|
|
/// stdio / generic-ACP).
|
|
#[serde(default)]
|
|
pub is_non_interactive: bool,
|
|
/// Identity in the primary grok-build system prompt (`You are <label>…`).
|
|
/// Not the UI picker name. Defaults to [`DEFAULT_SYSTEM_PROMPT_LABEL`].
|
|
#[serde(default = "default_system_prompt_label")]
|
|
pub system_prompt_label: String,
|
|
}
|
|
/// Default identity on trim-tool-descriptions (`You are Grok released by xAI`).
|
|
pub const DEFAULT_SYSTEM_PROMPT_LABEL: &str = "Grok";
|
|
fn default_system_prompt_label() -> String {
|
|
DEFAULT_SYSTEM_PROMPT_LABEL.to_string()
|
|
}
|
|
fn is_template_override_none(t: &TemplateOverride) -> bool {
|
|
matches!(t, TemplateOverride::None)
|
|
}
|
|
impl PromptContext {
|
|
/// Normalize this context for persistence based on audience.
|
|
///
|
|
/// For `Subagent` audience, applies the same suppression as the render
|
|
/// path: persona summaries are cleared. AGENTS.md is delivered in full,
|
|
/// identical to the primary agent.
|
|
pub fn normalize_for_persistence(&mut self) {
|
|
if self.audience != PromptAudience::Subagent {
|
|
return;
|
|
}
|
|
self.persona_summaries.clear();
|
|
}
|
|
}
|
|
impl Default for PromptContext {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: 1,
|
|
prompt_mode: PromptMode::Extend,
|
|
audience: PromptAudience::default(),
|
|
prompt_body: None,
|
|
system_prompt: TemplateOverride::None,
|
|
agents_md_files: vec![],
|
|
persona_summaries: vec![],
|
|
build_timestamp_utc: chrono::Utc::now().to_rfc3339(),
|
|
memory_enabled: false,
|
|
memory_global_path: None,
|
|
memory_workspace_path: None,
|
|
role_instructions: None,
|
|
persona_instructions: None,
|
|
os_name: None,
|
|
shell_path: None,
|
|
working_directory: None,
|
|
current_date: None,
|
|
is_non_interactive: false,
|
|
system_prompt_label: default_system_prompt_label(),
|
|
}
|
|
}
|
|
}
|
|
impl PromptContext {
|
|
/// Format the AGENTS.md section as a `<system-reminder>` block.
|
|
///
|
|
/// Returns `None` if no AGENTS.md files were discovered.
|
|
pub fn format_agents_md_section(&self) -> Option<String> {
|
|
agents_md::format_agents_md_section(&self.agents_md_files)
|
|
}
|
|
/// AGENTS.md content for injection as a prepended user message.
|
|
///
|
|
/// - Subagents and primary sessions both get the full block, so a child
|
|
/// verifier sees the same project instructions as the main agent.
|
|
pub fn agents_md_user_reminder(&self) -> Option<String> {
|
|
self.format_agents_md_section()
|
|
}
|
|
/// Personas content for injection as a prepended user message.
|
|
///
|
|
/// Returns the `<system-reminder>` block to prepend as a user message,
|
|
/// wrapping the `<personas>` section.
|
|
///
|
|
/// - Subagents never get personas (`task` itself is a parent-only tool).
|
|
pub fn personas_user_reminder(&self) -> Option<String> {
|
|
if self.audience == PromptAudience::Subagent {
|
|
return None;
|
|
}
|
|
let section = self.format_personas_section()?;
|
|
Some(format!("<system-reminder>\n{section}</system-reminder>"))
|
|
}
|
|
/// Format the personas section content.
|
|
///
|
|
/// Always returns `None` — the `persona` parameter has been removed
|
|
/// from the task tool input, so persona summaries are no longer
|
|
/// injected into the conversation.
|
|
pub fn format_personas_section(&self) -> Option<String> {
|
|
None
|
|
}
|
|
/// Build the placeholder JSON for template rendering.
|
|
///
|
|
/// These are the agent-specific values that get merged with the
|
|
/// tool context in `TemplateRenderer::render_with_extra()`.
|
|
pub fn placeholders(&self) -> serde_json::Value {
|
|
serde_json::json!(
|
|
{ "memory_enabled" : self.memory_enabled, "memory_global_path" : self
|
|
.memory_global_path.as_deref().unwrap_or(""), "memory_workspace_path" : self
|
|
.memory_workspace_path.as_deref().unwrap_or(""), "role_instructions" : self
|
|
.role_instructions.as_deref().unwrap_or(""), "persona_instructions" : self
|
|
.persona_instructions.as_deref().unwrap_or(""), "os_name" : self.os_name
|
|
.as_deref().unwrap_or(""), "shell_path" : self.shell_path.as_deref()
|
|
.unwrap_or(""), "working_directory" : self.working_directory.as_deref()
|
|
.unwrap_or(""), "current_date" : self.current_date.as_deref().unwrap_or(""),
|
|
"is_non_interactive" : self.is_non_interactive, "system_prompt_label" : self
|
|
.system_prompt_label.as_str(), }
|
|
)
|
|
}
|
|
/// Render the full system prompt via `ToolBridge`.
|
|
///
|
|
/// Tool names (`${{ tools.by_kind.* }}`) are resolved by the
|
|
/// `TemplateRenderer` inside the bridge. Agent-specific fields
|
|
/// (`memory_enabled`, `role_instructions`, etc.) are passed as placeholders.
|
|
///
|
|
/// Both the base template AND the `prompt_body` are rendered through
|
|
/// MiniJinja so that `${{ tools.by_kind.* }}` variables resolve
|
|
/// correctly regardless of prompt mode.
|
|
pub async fn render(&self, tool_bridge: &ToolBridge) -> Option<String> {
|
|
let placeholders = self.placeholders();
|
|
let prompt = match self.prompt_mode {
|
|
PromptMode::Extend => {
|
|
let decrypted;
|
|
let base = match &self.system_prompt {
|
|
TemplateOverride::Custom(s) => s.as_str(),
|
|
TemplateOverride::Codex => {
|
|
decrypted = apply_patch_template();
|
|
&decrypted
|
|
}
|
|
TemplateOverride::None => {
|
|
decrypted = if self.audience == PromptAudience::Subagent {
|
|
subagent_template()
|
|
} else {
|
|
base_template()
|
|
};
|
|
&decrypted
|
|
}
|
|
};
|
|
let mut p = tool_bridge.render_prompt(base, &placeholders).await?;
|
|
if let Some(ref body) = self.prompt_body {
|
|
p.push_str("\n\n");
|
|
let rendered_body = tool_bridge
|
|
.render_prompt(body, &placeholders)
|
|
.await
|
|
.unwrap_or_else(|| body.clone());
|
|
p.push_str(&rendered_body);
|
|
}
|
|
p
|
|
}
|
|
PromptMode::Full => {
|
|
let body = self.prompt_body.as_deref().unwrap_or("");
|
|
tool_bridge.render_prompt(body, &placeholders).await?
|
|
}
|
|
};
|
|
Some(prompt)
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
/// Fixed timestamp for deterministic tests.
|
|
const TEST_TIMESTAMP: &str = "2025-06-15T12:00:00+00:00";
|
|
fn test_context() -> PromptContext {
|
|
PromptContext {
|
|
version: 1,
|
|
prompt_mode: PromptMode::Extend,
|
|
audience: PromptAudience::Primary,
|
|
prompt_body: None,
|
|
system_prompt: TemplateOverride::None,
|
|
agents_md_files: vec![],
|
|
persona_summaries: vec![],
|
|
build_timestamp_utc: TEST_TIMESTAMP.to_string(),
|
|
memory_enabled: false,
|
|
memory_global_path: None,
|
|
memory_workspace_path: None,
|
|
role_instructions: None,
|
|
persona_instructions: None,
|
|
os_name: None,
|
|
shell_path: None,
|
|
working_directory: None,
|
|
current_date: None,
|
|
is_non_interactive: false,
|
|
system_prompt_label: default_system_prompt_label(),
|
|
}
|
|
}
|
|
#[test]
|
|
fn test_json_round_trip() {
|
|
let ctx = test_context();
|
|
let json = serde_json::to_string_pretty(&ctx).unwrap();
|
|
let ctx2: PromptContext = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(ctx.version, ctx2.version);
|
|
assert_eq!(ctx.build_timestamp_utc, ctx2.build_timestamp_utc);
|
|
assert_eq!(ctx.agents_md_files.len(), ctx2.agents_md_files.len());
|
|
}
|
|
#[test]
|
|
fn test_json_round_trip_with_agents_md() {
|
|
let mut ctx = test_context();
|
|
ctx.agents_md_files = vec![
|
|
AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/AGENTS.md".to_string(),
|
|
content: "# Repo instructions".to_string(),
|
|
},
|
|
AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/sub/AGENTS.md".to_string(),
|
|
content: "# Sub instructions".to_string(),
|
|
},
|
|
];
|
|
let json = serde_json::to_string(&ctx).unwrap();
|
|
let ctx2: PromptContext = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(ctx2.agents_md_files.len(), 2);
|
|
assert_eq!(ctx2.agents_md_files[0].content, "# Repo instructions");
|
|
assert_eq!(ctx2.agents_md_files[1].file_path, "/repo/sub/AGENTS.md");
|
|
}
|
|
#[test]
|
|
fn test_template_override_deserialize_new_format() {
|
|
let v: TemplateOverride = serde_json::from_str(r#""none""#).unwrap();
|
|
assert_eq!(v, TemplateOverride::None);
|
|
let v: TemplateOverride = serde_json::from_str(r#""codex""#).unwrap();
|
|
assert_eq!(v, TemplateOverride::Codex);
|
|
let v: TemplateOverride = serde_json::from_str(r#"{"custom": "my template"}"#).unwrap();
|
|
assert_eq!(v, TemplateOverride::Custom("my template".to_string()));
|
|
}
|
|
#[test]
|
|
fn test_template_override_deserialize_legacy_string() {
|
|
let v: TemplateOverride = serde_json::from_str(r#""You are a coding agent...""#).unwrap();
|
|
assert_eq!(
|
|
v,
|
|
TemplateOverride::Custom("You are a coding agent...".to_string())
|
|
);
|
|
}
|
|
#[test]
|
|
fn test_template_override_round_trip() {
|
|
for original in [
|
|
TemplateOverride::None,
|
|
TemplateOverride::Codex,
|
|
TemplateOverride::Custom("my custom prompt".to_string()),
|
|
] {
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let loaded: TemplateOverride = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(original, loaded);
|
|
}
|
|
}
|
|
#[test]
|
|
fn test_prompt_context_legacy_system_prompt_field() {
|
|
let legacy_json = r#"{
|
|
"version": 1,
|
|
"prompt_mode": "extend",
|
|
"os_name": "linux",
|
|
"shell_path": "/bin/bash",
|
|
"working_directory": "/workspace",
|
|
"build_timestamp_utc": "2025-01-01T00:00:00Z",
|
|
"agents_md_files": [],
|
|
"system_prompt": "You are a coding agent running in the CLI."
|
|
}"#;
|
|
let ctx: PromptContext = serde_json::from_str(legacy_json).unwrap();
|
|
assert_eq!(
|
|
ctx.system_prompt,
|
|
TemplateOverride::Custom("You are a coding agent running in the CLI.".to_string())
|
|
);
|
|
}
|
|
#[test]
|
|
fn test_prompt_context_missing_system_prompt_field() {
|
|
let json = r#"{
|
|
"version": 1,
|
|
"prompt_mode": "extend",
|
|
"os_name": "linux",
|
|
"shell_path": "/bin/bash",
|
|
"working_directory": "/workspace",
|
|
"build_timestamp_utc": "2025-01-01T00:00:00Z",
|
|
"agents_md_files": []
|
|
}"#;
|
|
let ctx: PromptContext = serde_json::from_str(json).unwrap();
|
|
assert_eq!(ctx.system_prompt, TemplateOverride::None);
|
|
}
|
|
#[test]
|
|
fn test_placeholders_contains_agent_fields() {
|
|
let ctx = test_context();
|
|
let p = ctx.placeholders();
|
|
assert_eq!(p["memory_enabled"], false);
|
|
assert!(p.get("role_instructions").is_some());
|
|
assert!(p.get("persona_instructions").is_some());
|
|
assert_eq!(p["system_prompt_label"], DEFAULT_SYSTEM_PROMPT_LABEL);
|
|
}
|
|
#[test]
|
|
fn test_placeholders_system_prompt_label_override() {
|
|
let mut ctx = test_context();
|
|
ctx.system_prompt_label = "Grok Internal".into();
|
|
let p = ctx.placeholders();
|
|
assert_eq!(p["system_prompt_label"], "Grok Internal");
|
|
}
|
|
#[test]
|
|
fn test_missing_system_prompt_label_deserializes_to_default() {
|
|
let json = r#"{"version":1,"prompt_mode":"extend","agents_md_files":[],"build_timestamp_utc":"2025-06-15T12:00:00+00:00"}"#;
|
|
let ctx: PromptContext = serde_json::from_str(json).unwrap();
|
|
assert_eq!(ctx.system_prompt_label, DEFAULT_SYSTEM_PROMPT_LABEL);
|
|
}
|
|
#[test]
|
|
fn test_placeholders_includes_all_eight_keys() {
|
|
let mut ctx = test_context();
|
|
ctx.os_name = Some("linux".into());
|
|
ctx.shell_path = Some("/bin/bash".into());
|
|
ctx.working_directory = Some("/workspace".into());
|
|
ctx.current_date = Some("2026-03-26".into());
|
|
ctx.memory_enabled = true;
|
|
ctx.role_instructions = Some("test role".into());
|
|
ctx.persona_instructions = Some("test persona".into());
|
|
let p = ctx.placeholders();
|
|
assert_eq!(p["os_name"], "linux");
|
|
assert_eq!(p["shell_path"], "/bin/bash");
|
|
assert_eq!(p["working_directory"], "/workspace");
|
|
assert_eq!(p["current_date"], "2026-03-26");
|
|
assert_eq!(p["memory_enabled"], true);
|
|
assert_eq!(p["role_instructions"], "test role");
|
|
assert_eq!(p["persona_instructions"], "test persona");
|
|
}
|
|
#[test]
|
|
fn test_placeholders_user_info_defaults_to_empty() {
|
|
let ctx = test_context();
|
|
let p = ctx.placeholders();
|
|
assert_eq!(p["os_name"], "");
|
|
assert_eq!(p["shell_path"], "");
|
|
assert_eq!(p["working_directory"], "");
|
|
assert_eq!(p["current_date"], "");
|
|
}
|
|
#[test]
|
|
fn test_user_info_fields_serialization_round_trip() {
|
|
let mut ctx = test_context();
|
|
ctx.os_name = Some("linux".into());
|
|
ctx.shell_path = Some("/bin/bash".into());
|
|
ctx.working_directory = Some("/workspace/project".into());
|
|
ctx.current_date = Some("2026-03-26".into());
|
|
let json = serde_json::to_string_pretty(&ctx).unwrap();
|
|
assert!(json.contains(r#""os_name": "linux""#));
|
|
assert!(json.contains(r#""shell_path": "/bin/bash""#));
|
|
assert!(json.contains(r#""working_directory": "/workspace/project""#));
|
|
assert!(json.contains(r#""current_date": "2026-03-26""#));
|
|
let ctx2: PromptContext = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(ctx2.os_name.as_deref(), Some("linux"));
|
|
assert_eq!(ctx2.shell_path.as_deref(), Some("/bin/bash"));
|
|
assert_eq!(
|
|
ctx2.working_directory.as_deref(),
|
|
Some("/workspace/project")
|
|
);
|
|
assert_eq!(ctx2.current_date.as_deref(), Some("2026-03-26"));
|
|
}
|
|
#[test]
|
|
fn test_user_info_fields_none_skipped_in_json() {
|
|
let ctx = test_context();
|
|
let json = serde_json::to_string(&ctx).unwrap();
|
|
assert!(!json.contains("os_name"));
|
|
assert!(!json.contains("shell_path"));
|
|
assert!(!json.contains("\"working_directory\""));
|
|
assert!(!json.contains("current_date"));
|
|
}
|
|
#[test]
|
|
fn test_user_info_backward_compat_deserialization() {
|
|
let json = r#"{
|
|
"version": 1,
|
|
"prompt_mode": "extend",
|
|
"build_timestamp_utc": "2025-01-01T00:00:00Z",
|
|
"agents_md_files": []
|
|
}"#;
|
|
let ctx: PromptContext = serde_json::from_str(json).unwrap();
|
|
assert!(ctx.os_name.is_none());
|
|
assert!(ctx.shell_path.is_none());
|
|
assert!(ctx.working_directory.is_none());
|
|
assert!(ctx.current_date.is_none());
|
|
}
|
|
#[test]
|
|
fn test_placeholders_memory_enabled() {
|
|
let mut ctx = test_context();
|
|
ctx.memory_enabled = true;
|
|
let p = ctx.placeholders();
|
|
assert_eq!(p["memory_enabled"], true);
|
|
}
|
|
#[test]
|
|
fn test_default_context() {
|
|
let ctx = PromptContext::default();
|
|
assert_eq!(ctx.version, 1);
|
|
assert!(matches!(ctx.prompt_mode, PromptMode::Extend));
|
|
assert!(ctx.prompt_body.is_none());
|
|
assert!(ctx.agents_md_files.is_empty());
|
|
}
|
|
#[test]
|
|
fn test_format_agents_md_section_empty() {
|
|
let ctx = test_context();
|
|
assert!(ctx.format_agents_md_section().is_none());
|
|
}
|
|
#[test]
|
|
fn test_format_agents_md_section_non_empty() {
|
|
let mut ctx = test_context();
|
|
ctx.agents_md_files = vec![AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/AGENTS.md".to_string(),
|
|
content: "# Instructions".to_string(),
|
|
}];
|
|
let section = ctx.format_agents_md_section().unwrap();
|
|
assert!(section.contains("# Instructions"));
|
|
assert!(section.contains("<system-reminder>"));
|
|
}
|
|
#[test]
|
|
fn test_format_personas_section_empty() {
|
|
let ctx = test_context();
|
|
assert!(ctx.format_personas_section().is_none());
|
|
}
|
|
#[test]
|
|
fn test_format_personas_section_always_none() {
|
|
let mut ctx = test_context();
|
|
ctx.persona_summaries = vec!["- **reviewer** [user]: Meticulous code reviewer".to_string()];
|
|
assert!(
|
|
ctx.format_personas_section().is_none(),
|
|
"persona section is disabled — persona param removed from task tool"
|
|
);
|
|
}
|
|
/// AGENTS.md must reach the system prompt for the default template even
|
|
/// AGENTS.md user reminder must be present for the default template
|
|
/// when files are present.
|
|
#[test]
|
|
fn agents_md_user_reminder_included_for_default_template() {
|
|
let mut ctx = test_context();
|
|
ctx.system_prompt = TemplateOverride::None;
|
|
ctx.agents_md_files = vec![AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/AGENTS.md".to_string(),
|
|
content: "# XYZZY_AGENTS_MD_MARKER".to_string(),
|
|
}];
|
|
let section = ctx
|
|
.agents_md_user_reminder()
|
|
.expect("default template must include AGENTS.md user reminder when files exist");
|
|
assert!(section.contains("<system-reminder>"));
|
|
assert!(section.contains("XYZZY_AGENTS_MD_MARKER"));
|
|
}
|
|
#[test]
|
|
fn personas_user_reminder_always_none() {
|
|
let mut ctx = test_context();
|
|
ctx.persona_summaries = vec!["- **reviewer** [user]: Meticulous code reviewer".to_string()];
|
|
assert!(
|
|
ctx.personas_user_reminder().is_none(),
|
|
"persona reminder is disabled — persona param removed from task tool"
|
|
);
|
|
}
|
|
fn child_general_purpose_context() -> PromptContext {
|
|
use crate::prompt::subagent_prompts;
|
|
PromptContext {
|
|
version: 1,
|
|
prompt_mode: PromptMode::Extend,
|
|
audience: PromptAudience::Subagent,
|
|
prompt_body: Some(subagent_prompts::GENERAL_PURPOSE_PROMPT.to_string()),
|
|
system_prompt: TemplateOverride::None,
|
|
agents_md_files: vec![],
|
|
persona_summaries: vec![
|
|
"- **reviewer** [user]: Code reviewer".to_string(),
|
|
"- **implementer** [user]: Code implementer".to_string(),
|
|
],
|
|
build_timestamp_utc: TEST_TIMESTAMP.to_string(),
|
|
memory_enabled: true,
|
|
memory_global_path: None,
|
|
memory_workspace_path: None,
|
|
role_instructions: None,
|
|
persona_instructions: None,
|
|
os_name: None,
|
|
shell_path: None,
|
|
working_directory: None,
|
|
current_date: None,
|
|
is_non_interactive: false,
|
|
system_prompt_label: default_system_prompt_label(),
|
|
}
|
|
}
|
|
#[test]
|
|
fn child_prompt_excludes_persona_catalog() {
|
|
let ctx = child_general_purpose_context();
|
|
assert!(ctx.format_personas_section().is_none());
|
|
assert!(ctx.personas_user_reminder().is_none());
|
|
}
|
|
#[test]
|
|
fn child_prompt_uses_subagent_audience() {
|
|
let ctx = child_general_purpose_context();
|
|
assert_eq!(ctx.audience, super::PromptAudience::Subagent);
|
|
}
|
|
#[test]
|
|
fn child_prompt_includes_agents_md_when_present() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.agents_md_files = vec![AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/workspace/AGENTS.md".to_string(),
|
|
content: "Build with `cargo build`".to_string(),
|
|
}];
|
|
let section = ctx.format_agents_md_section();
|
|
assert!(
|
|
section.is_some(),
|
|
"child prompt should include AGENTS.md when files are discovered"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_no_agents_md_when_empty() {
|
|
let ctx = child_general_purpose_context();
|
|
let section = ctx.format_agents_md_section();
|
|
assert!(
|
|
section.is_none(),
|
|
"child prompt has no AGENTS.md when none discovered"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_delivers_full_agents_md() {
|
|
use crate::prompt::agents_md::AgentConfigFile;
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.agents_md_files = vec![AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/AGENTS.md".to_string(),
|
|
content: "X".repeat(5000),
|
|
}];
|
|
assert_eq!(ctx.audience, super::PromptAudience::Subagent);
|
|
let reminder = ctx.agents_md_user_reminder().unwrap();
|
|
assert!(
|
|
reminder.contains(&"X".repeat(5000)),
|
|
"child must receive full AGENTS.md content"
|
|
);
|
|
assert!(
|
|
!reminder.contains("truncated"),
|
|
"child AGENTS.md must not be truncated"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_uses_extend_mode() {
|
|
let ctx = child_general_purpose_context();
|
|
assert!(
|
|
matches!(ctx.prompt_mode, PromptMode::Extend),
|
|
"CURRENT: child uses Extend mode (inherits full base template)"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_has_prompt_body() {
|
|
let ctx = child_general_purpose_context();
|
|
assert!(
|
|
ctx.prompt_body.is_some(),
|
|
"CURRENT: child has a prompt body (GENERAL_PURPOSE_PROMPT)"
|
|
);
|
|
let body = ctx.prompt_body.as_deref().unwrap();
|
|
assert!(
|
|
body.contains("Strengths") && body.contains("Guidelines"),
|
|
"body should contain structured general-purpose guidance sections"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_placeholders_include_memory_and_workspace() {
|
|
let ctx = child_general_purpose_context();
|
|
let placeholders = ctx.placeholders();
|
|
assert_eq!(
|
|
placeholders.get("memory_enabled").and_then(|v| v.as_bool()),
|
|
Some(true)
|
|
);
|
|
assert!(placeholders.get("role_instructions").is_some());
|
|
assert!(placeholders.get("persona_instructions").is_some());
|
|
}
|
|
#[test]
|
|
fn child_prompt_placeholders_include_role_and_persona() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.role_instructions = Some("Follow Rust conventions strictly".into());
|
|
ctx.persona_instructions = Some("You are a meticulous reviewer".into());
|
|
let placeholders = ctx.placeholders();
|
|
assert_eq!(
|
|
placeholders
|
|
.get("role_instructions")
|
|
.and_then(|v| v.as_str()),
|
|
Some("Follow Rust conventions strictly")
|
|
);
|
|
assert_eq!(
|
|
placeholders
|
|
.get("persona_instructions")
|
|
.and_then(|v| v.as_str()),
|
|
Some("You are a meticulous reviewer")
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_placeholders_empty_role_persona_when_unset() {
|
|
let ctx = child_general_purpose_context();
|
|
let placeholders = ctx.placeholders();
|
|
assert_eq!(
|
|
placeholders
|
|
.get("role_instructions")
|
|
.and_then(|v| v.as_str()),
|
|
Some("")
|
|
);
|
|
assert_eq!(
|
|
placeholders
|
|
.get("persona_instructions")
|
|
.and_then(|v| v.as_str()),
|
|
Some("")
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_prompt_has_no_system_prompt_override() {
|
|
let ctx = child_general_purpose_context();
|
|
assert!(
|
|
ctx.system_prompt == TemplateOverride::None,
|
|
"CURRENT: child has no custom system_prompt (uses BASE_TEMPLATE)"
|
|
);
|
|
}
|
|
#[test]
|
|
fn parent_vs_child_section_differences() {
|
|
let parent = test_context();
|
|
let child = child_general_purpose_context();
|
|
assert_eq!(parent.audience, super::PromptAudience::Primary);
|
|
assert_eq!(child.audience, super::PromptAudience::Subagent);
|
|
assert!(!child.persona_summaries.is_empty());
|
|
assert!(child.memory_enabled);
|
|
assert!(child.prompt_body.is_some());
|
|
assert!(parent.prompt_body.is_none());
|
|
}
|
|
#[test]
|
|
fn child_prompt_context_is_complete() {
|
|
let ctx = child_general_purpose_context();
|
|
assert!(ctx.prompt_body.is_some());
|
|
assert!(matches!(ctx.prompt_mode, PromptMode::Extend));
|
|
assert_eq!(ctx.audience, super::PromptAudience::Subagent);
|
|
assert!(ctx.memory_enabled);
|
|
assert!(ctx.system_prompt == TemplateOverride::None);
|
|
let p = ctx.placeholders();
|
|
assert!(p.get("memory_enabled").is_some());
|
|
assert!(p.get("role_instructions").is_some());
|
|
assert!(p.get("persona_instructions").is_some());
|
|
}
|
|
fn render_subagent_template(ctx: minijinja::Value) -> String {
|
|
let mut env = minijinja::Environment::new();
|
|
env.set_syntax(
|
|
minijinja::syntax::SyntaxConfig::builder()
|
|
.block_delimiters("${%", "%}")
|
|
.variable_delimiters("${{", "}}")
|
|
.comment_delimiters("${#", "#}")
|
|
.build()
|
|
.unwrap(),
|
|
);
|
|
let tmpl = crate::prompt::template::subagent_template();
|
|
env.add_template("prompt", &tmpl).unwrap();
|
|
env.get_template("prompt").unwrap().render(ctx).unwrap()
|
|
}
|
|
fn base_template_ctx() -> minijinja::Value {
|
|
minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => true,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read =>
|
|
"hashline_read", edit => "hashline_edit", search => "hashline_grep", execute
|
|
=> "run_terminal_cmd", background_task_action => "get_task_output",
|
|
memory_search => "memory_search", memory_get => "memory_get", } },
|
|
}
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_includes_memory_section() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.contains("<memory>"),
|
|
"should contain <memory> section"
|
|
);
|
|
assert!(
|
|
rendered.contains("memory_search"),
|
|
"should reference memory_search"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_includes_user_info_block() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(rendered.contains("OS: linux"), "should contain OS value");
|
|
assert!(
|
|
rendered.contains("Shell: /bin/bash"),
|
|
"should contain Shell value"
|
|
);
|
|
assert!(
|
|
rendered.contains("Workspace Path: /workspace"),
|
|
"should contain Workspace Path value"
|
|
);
|
|
assert!(
|
|
rendered.contains("Current Date: 2026-03-26"),
|
|
"should contain Current Date value"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_includes_project_instructions_like_main_agent() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.contains("<project_instructions_spec>"),
|
|
"subagent must include project_instructions_spec"
|
|
);
|
|
assert!(
|
|
rendered.contains("## Project Instruction Files"),
|
|
"subagent project instructions must match the main agent spec"
|
|
);
|
|
assert!(
|
|
rendered.contains("you must check for additional project instruction files"),
|
|
"subagent must be told to proactively check nested AGENTS.md"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_excludes_parent_only_sections() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(!rendered.contains("## Task Management"));
|
|
assert!(!rendered.contains("## No time estimates"));
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_includes_role_and_persona_sections() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "Follow Rust conventions", persona_instructions =>
|
|
"You are a code reviewer", tools => minijinja::context! { by_kind =>
|
|
minijinja::context! { read => "hashline_read", edit => "hashline_edit",
|
|
search => "hashline_grep", execute => "run_terminal_cmd",
|
|
background_task_action => "get_task_output", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(rendered.contains("<role-instructions>"));
|
|
assert!(rendered.contains("Follow Rust conventions"));
|
|
assert!(rendered.contains("<persona>"));
|
|
assert!(rendered.contains("You are a code reviewer"));
|
|
assert!(
|
|
!rendered.contains("<memory>"),
|
|
"memory should be absent when disabled"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_has_hashline_guidance() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.contains("hashline workflow"),
|
|
"should include hashline guidance"
|
|
);
|
|
assert!(
|
|
rendered.contains("batch semantics"),
|
|
"should include batch semantics"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_has_background_tasks_when_execute_available() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.contains("<background_tasks>"),
|
|
"should include background_tasks section when execute tool exists"
|
|
);
|
|
assert!(
|
|
rendered.contains("background"),
|
|
"background_tasks should mention background flag"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_has_code_change_rules_when_edit_available() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.contains("<making_code_changes>"),
|
|
"should include making_code_changes when edit tools are available"
|
|
);
|
|
assert!(
|
|
rendered.contains("</making_code_changes>"),
|
|
"making_code_changes section should be properly closed"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_omits_background_tasks_without_execute() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read =>
|
|
"hashline_read", edit => "hashline_edit", search => "hashline_grep", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
!rendered.contains("<background_tasks>"),
|
|
"background_tasks should be absent without execute tool"
|
|
);
|
|
assert!(
|
|
rendered.contains("hashline workflow"),
|
|
"hashline guidance should still be present"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_template_is_compact() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.len() < 3700,
|
|
"rendered child template too large: {} chars",
|
|
rendered.len()
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_omits_code_change_rules_without_edit_tools() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read =>
|
|
"hashline_read", search => "hashline_grep", execute => "run_terminal_cmd",
|
|
background_task_action => "get_task_output", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
!rendered.contains("<making_code_changes>"),
|
|
"read-only agents should not see code change rules"
|
|
);
|
|
assert!(rendered.contains("<tool_calling>"));
|
|
assert!(rendered.contains("<background_tasks>"));
|
|
assert!(rendered.contains("<formatting>"));
|
|
}
|
|
#[test]
|
|
fn rendered_prompt_size_general_purpose() {
|
|
let rendered = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.len() < 3700,
|
|
"general-purpose rendered prompt: {} chars (ceiling 3700)",
|
|
rendered.len()
|
|
);
|
|
}
|
|
#[test]
|
|
fn rendered_prompt_size_read_only() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read =>
|
|
"hashline_read", search => "hashline_grep", execute => "run_terminal_cmd",
|
|
background_task_action => "get_task_output", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
rendered.len() < 2800,
|
|
"read-only rendered prompt: {} chars (ceiling 2800)",
|
|
rendered.len()
|
|
);
|
|
let full = render_subagent_template(base_template_ctx());
|
|
assert!(
|
|
rendered.len() < full.len(),
|
|
"read-only prompt ({}) should be smaller than general-purpose ({})",
|
|
rendered.len(),
|
|
full.len()
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_omits_edit_references_without_edit_tool() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
|
|
search => "grep", execute => "run_terminal_cmd", background_task_action =>
|
|
"get_task_output", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
!rendered.contains("for editing"),
|
|
"should not mention editing when edit tool is absent"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_omits_execute_references_without_execute_tool() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
|
|
edit => "search_replace", search => "grep", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
!rendered.contains("system commands"),
|
|
"should not mention system commands when execute tool is absent"
|
|
);
|
|
assert!(
|
|
!rendered.contains("Reserve"),
|
|
"should not mention Reserve (bash) when execute tool is absent"
|
|
);
|
|
}
|
|
#[test]
|
|
fn child_rendered_prompt_omits_both_edit_and_execute_references() {
|
|
let ctx = minijinja::context! {
|
|
os_name => "linux", shell_path => "/bin/bash", working_directory =>
|
|
"/workspace", current_date => "2026-03-26", memory_enabled => false,
|
|
role_instructions => "", persona_instructions => "", tools =>
|
|
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
|
|
search => "grep", } },
|
|
};
|
|
let rendered = render_subagent_template(ctx);
|
|
assert!(
|
|
!rendered.contains("for editing"),
|
|
"should not mention editing"
|
|
);
|
|
assert!(
|
|
!rendered.contains("system commands"),
|
|
"should not mention system commands"
|
|
);
|
|
assert!(!rendered.contains("Reserve"), "should not mention Reserve");
|
|
assert!(
|
|
rendered.contains("`read_file` for reading."),
|
|
"tool_calling line should end cleanly after read reference"
|
|
);
|
|
}
|
|
#[test]
|
|
fn test_prompt_body_none_skipped_in_json() {
|
|
let ctx = test_context();
|
|
let json = serde_json::to_string(&ctx).unwrap();
|
|
assert!(
|
|
!json.contains("prompt_body"),
|
|
"prompt_body should be skipped when None"
|
|
);
|
|
}
|
|
#[test]
|
|
fn test_prompt_body_some_included_in_json() {
|
|
let mut ctx = test_context();
|
|
ctx.prompt_body = Some("custom body".to_string());
|
|
let json = serde_json::to_string(&ctx).unwrap();
|
|
assert!(
|
|
json.contains("prompt_body"),
|
|
"prompt_body should be included when Some"
|
|
);
|
|
}
|
|
/// Verify that AGENTS.md file paths rewritten to the display cwd are
|
|
/// rendered into the system prompt correctly. When `AgentConfigFile.file_path`
|
|
/// uses the display path, the rendered `## From:` line must not contain
|
|
/// the overlay/worktree path.
|
|
#[test]
|
|
fn test_agents_md_paths_use_display_cwd_in_rendered_section() {
|
|
let display_path = "/home/user/my-project";
|
|
let overlay_path = "/root/.kigi/worktrees/my-project/ab-123-a-overlay";
|
|
let ctx = PromptContext {
|
|
agents_md_files: vec![AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: format!("{display_path}/AGENTS.md"),
|
|
content: "# Project rules".to_string(),
|
|
}],
|
|
..test_context()
|
|
};
|
|
let section = ctx.format_agents_md_section().unwrap();
|
|
assert!(
|
|
section.contains(&format!("## From: {display_path}/AGENTS.md")),
|
|
"rendered AGENTS section must show the display path"
|
|
);
|
|
assert!(
|
|
!section.contains(overlay_path),
|
|
"rendered AGENTS section must not contain the overlay path"
|
|
);
|
|
}
|
|
#[test]
|
|
fn full_mode_prelude_contains_user_info_block() {
|
|
let prelude_format = format!(
|
|
"<user_info>\nOS: {}\nShell: {}\nWorkspace Path: {}\nCurrent Date: {}\n</user_info>",
|
|
"linux", "/bin/bash", "/workspace/project", "2026-03-24"
|
|
);
|
|
assert!(prelude_format.contains("<user_info>"));
|
|
assert!(prelude_format.contains("Workspace Path: /workspace/project"));
|
|
assert!(prelude_format.contains("</user_info>"));
|
|
}
|
|
#[test]
|
|
fn built_in_prompts_do_not_contain_user_info_block() {
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
assert!(
|
|
!gp.contains("OS: linux"),
|
|
"prompt text should not contain actual OS value"
|
|
);
|
|
assert!(
|
|
!explore.contains("Workspace Path:"),
|
|
"prompt text should not contain Workspace Path field"
|
|
);
|
|
assert!(
|
|
!plan.contains("Shell: /bin/bash"),
|
|
"prompt text should not contain actual Shell value"
|
|
);
|
|
}
|
|
#[test]
|
|
fn workspace_boundary_in_general_purpose_prompt() {
|
|
let prompt = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
assert!(
|
|
prompt.contains("Workspace boundary"),
|
|
"general-purpose prompt should contain workspace boundary guidance"
|
|
);
|
|
assert!(
|
|
prompt.contains("<user_info>"),
|
|
"general-purpose prompt should reference <user_info>"
|
|
);
|
|
}
|
|
#[test]
|
|
fn workspace_boundary_in_explore_prompt() {
|
|
let prompt = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
assert!(
|
|
prompt.contains("Workspace boundary"),
|
|
"explore prompt should contain workspace boundary guidance"
|
|
);
|
|
assert!(
|
|
prompt.contains("default search scope"),
|
|
"explore should mention default search scope"
|
|
);
|
|
}
|
|
#[test]
|
|
fn workspace_boundary_in_plan_prompt() {
|
|
let prompt = super::super::subagent_prompts::PLAN_PROMPT;
|
|
assert!(
|
|
prompt.contains("Workspace boundary"),
|
|
"plan prompt should contain workspace boundary guidance"
|
|
);
|
|
assert!(
|
|
prompt.contains("default analysis scope"),
|
|
"plan should mention default analysis scope"
|
|
);
|
|
}
|
|
#[test]
|
|
fn general_purpose_prompt_specialization_keywords() {
|
|
let prompt = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let keywords = [
|
|
"broad searches",
|
|
"Multi-file analysis",
|
|
"NEVER create files",
|
|
"documentation files",
|
|
"absolute file paths",
|
|
];
|
|
for kw in &keywords {
|
|
assert!(
|
|
prompt.contains(kw),
|
|
"general-purpose prompt missing specialization keyword: {kw}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn explore_prompt_specialization_keywords() {
|
|
let prompt = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let keywords = [
|
|
"read-only",
|
|
"READ-ONLY MODE",
|
|
"glob patterns",
|
|
"regex",
|
|
"parallel tool calls",
|
|
"thoroughness level",
|
|
];
|
|
for kw in &keywords {
|
|
assert!(
|
|
prompt.contains(kw),
|
|
"explore prompt missing specialization keyword: {kw}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn plan_prompt_specialization_keywords() {
|
|
let prompt = super::super::subagent_prompts::PLAN_PROMPT;
|
|
let keywords = [
|
|
"read-only",
|
|
"READ-ONLY MODE",
|
|
"architect",
|
|
"Critical Files for Implementation",
|
|
"trade-offs",
|
|
"step-by-step",
|
|
];
|
|
for kw in &keywords {
|
|
assert!(
|
|
prompt.contains(kw),
|
|
"plan prompt missing specialization keyword: {kw}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn trimmed_prompts_are_compact() {
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
assert!(
|
|
gp.len() < 1200,
|
|
"general-purpose prompt too large: {} chars",
|
|
gp.len()
|
|
);
|
|
assert!(
|
|
explore.len() < 1050,
|
|
"explore prompt too large: {} chars",
|
|
explore.len()
|
|
);
|
|
assert!(
|
|
plan.len() < 1350,
|
|
"plan prompt too large: {} chars",
|
|
plan.len()
|
|
);
|
|
}
|
|
#[test]
|
|
fn trimmed_prompts_no_redundant_identity() {
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
for (name, prompt) in [
|
|
("general-purpose", gp),
|
|
("explore", explore),
|
|
("plan", plan),
|
|
] {
|
|
assert!(
|
|
!prompt.contains("You are a Grok Build agent"),
|
|
"{name} prompt should not duplicate base template identity"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn trimmed_prompts_no_redundant_formatting_rules() {
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
for (name, prompt) in [
|
|
("general-purpose", gp),
|
|
("explore", explore),
|
|
("plan", plan),
|
|
] {
|
|
assert!(
|
|
!prompt.contains("avoid using emojis"),
|
|
"{name} prompt should not duplicate formatting rules from base template"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn all_prompts_reference_tool_templates() {
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
for (name, prompt) in [
|
|
("general-purpose", gp),
|
|
("explore", explore),
|
|
("plan", plan),
|
|
] {
|
|
assert!(
|
|
prompt.contains("${{ tools.by_kind."),
|
|
"{name} prompt should reference tool template variables"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn read_only_prompts_share_consistent_constraint() {
|
|
let explore = super::super::subagent_prompts::EXPLORE_PROMPT;
|
|
let plan = super::super::subagent_prompts::PLAN_PROMPT;
|
|
for (name, prompt) in [("explore", explore), ("plan", plan)] {
|
|
assert!(
|
|
prompt.contains("NO file editing tools"),
|
|
"{name} prompt must declare no editing tools"
|
|
);
|
|
assert!(
|
|
prompt.contains("Do not create, modify, or delete"),
|
|
"{name} prompt must forbid create/modify/delete"
|
|
);
|
|
}
|
|
let gp = super::super::subagent_prompts::GENERAL_PURPOSE_PROMPT;
|
|
assert!(
|
|
!gp.contains("READ-ONLY MODE"),
|
|
"general-purpose should not be read-only"
|
|
);
|
|
}
|
|
#[test]
|
|
fn normalize_clears_persona_summaries_for_subagent() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.persona_summaries = vec!["- **reviewer**: Reviews code".to_string()];
|
|
assert!(!ctx.persona_summaries.is_empty());
|
|
ctx.normalize_for_persistence();
|
|
assert!(
|
|
ctx.persona_summaries.is_empty(),
|
|
"persona summaries must be cleared for subagent"
|
|
);
|
|
}
|
|
#[test]
|
|
fn normalize_preserves_full_agents_md_for_subagent() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.agents_md_files = vec![super::super::agents_md::AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/AGENTS.md".to_string(),
|
|
content: "X".repeat(5000),
|
|
}];
|
|
ctx.normalize_for_persistence();
|
|
assert_eq!(
|
|
ctx.agents_md_files[0].content.chars().count(),
|
|
5000,
|
|
"AGENTS content must be preserved in full for subagents (no cap)"
|
|
);
|
|
}
|
|
#[test]
|
|
fn normalize_preserves_short_agents_md() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.agents_md_files = vec![super::super::agents_md::AgentConfigFile {
|
|
file_name: "AGENTS.md".to_string(),
|
|
file_path: "/repo/sub/AGENTS.md".to_string(),
|
|
content: "Short rules".to_string(),
|
|
}];
|
|
ctx.normalize_for_persistence();
|
|
assert_eq!(ctx.agents_md_files[0].content, "Short rules");
|
|
}
|
|
#[test]
|
|
fn normalize_preserves_role_and_persona_instructions() {
|
|
let mut ctx = child_general_purpose_context();
|
|
ctx.role_instructions = Some("Follow Rust conventions".to_string());
|
|
ctx.persona_instructions = Some("You are a code reviewer".to_string());
|
|
ctx.normalize_for_persistence();
|
|
assert_eq!(
|
|
ctx.role_instructions.as_deref(),
|
|
Some("Follow Rust conventions")
|
|
);
|
|
assert_eq!(
|
|
ctx.persona_instructions.as_deref(),
|
|
Some("You are a code reviewer")
|
|
);
|
|
}
|
|
#[test]
|
|
fn normalize_is_noop_for_primary() {
|
|
let mut ctx = test_context();
|
|
ctx.persona_summaries = vec!["- **reviewer**: Reviews code".to_string()];
|
|
ctx.normalize_for_persistence();
|
|
assert!(
|
|
!ctx.persona_summaries.is_empty(),
|
|
"primary must keep persona summaries"
|
|
);
|
|
}
|
|
}
|