Files
Kigi-CLI/crates/codegen/kigi-agent/src/prompt/context.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

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"
);
}
}