M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
//! AGENTS.md / Claude.md / rules directory discovery and loading.
|
||||
//!
|
||||
//! Searches from cwd to repo root, plus `~/.kigi/`. Also discovers
|
||||
//! `*.md` files in `.kigi/rules/` and `.claude/rules/` directories.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::prompt::ignore::{build_gitignore, is_ignored};
|
||||
|
||||
use kigi_tools::types::compat::CompatConfig;
|
||||
|
||||
/// Represents an agent config file with its path and content.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AgentConfigFile {
|
||||
/// The filename (e.g., "AGENTS.md", "Claude.md")
|
||||
pub file_name: String,
|
||||
/// The full absolute path to the config file
|
||||
pub file_path: String,
|
||||
/// The content of the config file
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Find matching agent config files in a directory.
|
||||
///
|
||||
/// `filenames` is the (compat-gated) recognized list, precomputed once by the
|
||||
/// caller so the cwd→root walk doesn't re-allocate it per directory. When all
|
||||
/// cells are on it equals the legacy `AGENT_FILENAMES` list exactly.
|
||||
fn find_agent_files(dir: &Path, filenames: &[&str]) -> Vec<PathBuf> {
|
||||
filenames
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let path = dir.join(name);
|
||||
path.exists().then_some(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find `*.md` files in `.kigi/rules/`, `.claude/rules/`, and `.cursor/rules/`,
|
||||
/// sorted alphabetically. `rules_subdirs` is the (compat-gated) list, precomputed
|
||||
/// once by the caller so the walk doesn't re-allocate it per directory.
|
||||
fn find_rules_files(dir: &Path, rules_subdirs: &[&str]) -> Vec<PathBuf> {
|
||||
let mut results = Vec::new();
|
||||
for rules_subdir in rules_subdirs {
|
||||
let rules_dir = dir.join(rules_subdir);
|
||||
if !rules_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut entries: Vec<PathBuf> = match std::fs::read_dir(&rules_dir) {
|
||||
Ok(iter) => iter
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
results.extend(entries);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Read Agents.md from ~/.kigi/, git repo root, and session cwd.
|
||||
/// Returns a list of AgentConfigFile with their file names, full paths, and contents.
|
||||
///
|
||||
/// `compat` gates which vendor (`.claude`/`.cursor`) surfaces are scanned for
|
||||
/// rules / project-instruction files; pass `CompatConfig::default()` to
|
||||
/// preserve the historical all-vendors behavior.
|
||||
pub async fn read_agents_config_with_paths(
|
||||
working_directory: &str,
|
||||
compat: CompatConfig,
|
||||
) -> Vec<AgentConfigFile> {
|
||||
let workspace_user_dir = crate::prompt::workspace_user::optional_workspace_user_dir();
|
||||
read_agents_config_with_options(working_directory, workspace_user_dir.as_deref(), compat).await
|
||||
}
|
||||
|
||||
/// Inner implementation that accepts an optional workspace user dir as a
|
||||
/// parameter, making it testable without environment variable mutation.
|
||||
async fn read_agents_config_with_options(
|
||||
working_directory: &str,
|
||||
workspace_user_dir: Option<&Path>,
|
||||
compat: CompatConfig,
|
||||
) -> Vec<AgentConfigFile> {
|
||||
let cwd = PathBuf::from(working_directory);
|
||||
let global_dir = kigi_tools::util::kigi_home::kigi_home();
|
||||
let git_root = git2::Repository::discover(&cwd)
|
||||
.ok()
|
||||
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()));
|
||||
|
||||
let gitignore = build_gitignore(git_root.as_deref());
|
||||
|
||||
// Always include kigi_home (~/.kigi/) first, then ~/.claude/ and ~/.cursor/
|
||||
// for compat — each gated by the resolved `agents` compat cell.
|
||||
let mut dirs = vec![global_dir];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for compat_dir in compat.agents_home_dirs() {
|
||||
let dir = home.join(compat_dir);
|
||||
if dir.is_dir() {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk from cwd up to git root to pick up agent files in intermediate directories
|
||||
if let Some(ref root) = git_root {
|
||||
let mut current = Some(cwd.as_path());
|
||||
let mut chain: Vec<PathBuf> = Vec::new();
|
||||
while let Some(dir) = current {
|
||||
let dir_buf = dir.to_path_buf();
|
||||
if !chain.contains(&dir_buf) {
|
||||
chain.push(dir_buf);
|
||||
}
|
||||
if dir == root.as_path() {
|
||||
break;
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
// CRITICAL: Reverse to get root → CWD order (deeper files come later)
|
||||
chain.reverse();
|
||||
|
||||
// Inject optional workspace user dir if not already in the chain.
|
||||
// Insert after repo root (index 0 after reverse) so it's higher priority
|
||||
// than repo root AGENTS.md but lower priority than intermediate dirs and cwd.
|
||||
if let Some(user_dir) = workspace_user_dir {
|
||||
let user_dir_canonical =
|
||||
dunce::canonicalize(user_dir).unwrap_or_else(|_| user_dir.to_path_buf());
|
||||
let already_in_chain = chain.iter().any(|d| {
|
||||
dunce::canonicalize(d).unwrap_or_else(|_| d.clone()) == user_dir_canonical
|
||||
});
|
||||
if !already_in_chain {
|
||||
// chain[0] is repo root after reverse; insert right after it.
|
||||
let insert_pos = 1.min(chain.len());
|
||||
chain.insert(insert_pos, user_dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
dirs.extend(chain);
|
||||
} else if !dirs.contains(&cwd) {
|
||||
dirs.push(cwd.clone());
|
||||
}
|
||||
|
||||
// Compute the gated lists once (constant across all scanned dirs) so the
|
||||
// per-directory scan below doesn't re-allocate them.
|
||||
let agent_filenames = compat.agent_filenames();
|
||||
let rules_dirs = compat.rules_dirs();
|
||||
let files: Vec<PathBuf> = dirs
|
||||
.into_iter()
|
||||
.flat_map(|dir| {
|
||||
let mut combined = find_agent_files(&dir, &agent_filenames);
|
||||
combined.extend(find_rules_files(&dir, &rules_dirs));
|
||||
combined
|
||||
})
|
||||
.filter(|path| !is_ignored(path, gitignore.as_ref(), git_root.as_deref()))
|
||||
.collect();
|
||||
|
||||
// Deduplicate by canonical path to handle case-insensitive filesystems
|
||||
// and symlink-resolved tmpdir paths.
|
||||
let mut seen_canonical = std::collections::HashSet::new();
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone());
|
||||
seen_canonical.insert(canonical)
|
||||
})
|
||||
.filter_map(|file_path| {
|
||||
let content = std::fs::read_to_string(&file_path).ok()?;
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("AGENTS.md")
|
||||
.to_string();
|
||||
let full_path = file_path.display().to_string();
|
||||
Some(AgentConfigFile {
|
||||
file_name,
|
||||
file_path: full_path,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Format AGENTS.md configs into a `<system-reminder>` block for user message injection.
|
||||
pub fn format_agents_md_section(configs: &[AgentConfigFile]) -> Option<String> {
|
||||
render_agents_md(configs)
|
||||
}
|
||||
|
||||
/// Verbatim leading bytes [`render_agents_md`] emits for every reminder block.
|
||||
/// Used by `kigi-shell` to structurally detect legacy untagged AGENTS.md
|
||||
/// copies (pre-`SyntheticReason::ProjectInstructions`) on resumed sessions.
|
||||
pub const LEGACY_AGENTS_MD_REMINDER_PREFIX: &str =
|
||||
"\n\n<system-reminder>\nAs you answer the user's questions, you can use the following context";
|
||||
|
||||
fn render_agents_md(configs: &[AgentConfigFile]) -> Option<String> {
|
||||
if configs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::new();
|
||||
section.push_str(LEGACY_AGENTS_MD_REMINDER_PREFIX);
|
||||
section.push_str(
|
||||
" (ordered from repo root to current directory - deeper files take precedence on conflicts):\n",
|
||||
);
|
||||
|
||||
for config in configs {
|
||||
section.push_str(&format!("\n## From: {}\n", config.file_path));
|
||||
|
||||
// Strip YAML frontmatter from rules files (e.g. .claude/rules/*.md,
|
||||
// .kigi/rules/*.md) so globs/paths metadata doesn't leak into the
|
||||
// system prompt as raw YAML.
|
||||
let is_rules_file = config.file_path.contains("/.kigi/rules/")
|
||||
|| config.file_path.contains("/.claude/rules/");
|
||||
let content = if is_rules_file {
|
||||
kigi_tools::implementations::skills::skill::extract_skill_body(&config.content)
|
||||
} else {
|
||||
config.content.clone()
|
||||
};
|
||||
|
||||
section.push_str(&content);
|
||||
section.push('\n');
|
||||
}
|
||||
|
||||
section.push_str("\nFollow these instructions exactly. When working in subdirectories not listed above, check for additional project instruction files (AGENTS.md, Claude.md, etc.).");
|
||||
section.push_str("\n</system-reminder>");
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Helper: initialize a git repo at `path` so git2::Repository::discover works.
|
||||
fn init_git_repo(path: &Path) {
|
||||
git2::Repository::init(path).unwrap();
|
||||
}
|
||||
|
||||
// ── find_agent_files unit tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_finds_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("AGENTS.md"), "# Instructions").unwrap();
|
||||
|
||||
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
|
||||
// On case-insensitive filesystems (macOS), both "Agents.md" and "AGENTS.md"
|
||||
// resolve to the same file, so we may get more than 1 result.
|
||||
assert!(!files.is_empty());
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|f| f.to_string_lossy().contains("AGENTS.md")
|
||||
|| f.to_string_lossy().contains("Agents.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_finds_all_variants() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let filenames = CompatConfig::default().agent_filenames();
|
||||
for name in &filenames {
|
||||
let path = tmp.path().join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, format!("# {name}")).unwrap();
|
||||
}
|
||||
|
||||
let files = find_agent_files(tmp.path(), &filenames);
|
||||
assert_eq!(files.len(), filenames.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_nonexistent_dir() {
|
||||
let files = find_agent_files(
|
||||
Path::new("/nonexistent/dir"),
|
||||
&CompatConfig::default().agent_filenames(),
|
||||
);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_files_discovers_claude_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(claude_dir.join("CLAUDE.md"), "# Project instructions").unwrap();
|
||||
|
||||
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|f| f.to_string_lossy().contains(".claude/CLAUDE.md")),
|
||||
"Should discover .claude/CLAUDE.md, got: {files:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_rules_files_discovers_claude_rules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rules_dir = tmp.path().join(".claude").join("rules");
|
||||
fs::create_dir_all(&rules_dir).unwrap();
|
||||
fs::write(rules_dir.join("style.md"), "# Style rules").unwrap();
|
||||
fs::write(rules_dir.join("safety.md"), "# Safety rules").unwrap();
|
||||
|
||||
let files = find_rules_files(tmp.path(), &CompatConfig::default().rules_dirs());
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[0].to_string_lossy().contains("safety.md"));
|
||||
assert!(files[1].to_string_lossy().contains("style.md"));
|
||||
}
|
||||
|
||||
// ── format_agents_md_section tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_agents_md_section_empty_returns_none() {
|
||||
assert!(format_agents_md_section(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_agents_md_section_includes_all_configs() {
|
||||
let configs = vec![
|
||||
AgentConfigFile {
|
||||
file_name: "AGENTS.md".to_string(),
|
||||
file_path: "/repo/AGENTS.md".to_string(),
|
||||
content: "Repo-level instructions".to_string(),
|
||||
},
|
||||
AgentConfigFile {
|
||||
file_name: "AGENTS.md".to_string(),
|
||||
file_path: "/repo/x/user/AGENTS.md".to_string(),
|
||||
content: "User-level instructions".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let section = format_agents_md_section(&configs).unwrap();
|
||||
assert!(section.contains("Repo-level instructions"));
|
||||
assert!(section.contains("User-level instructions"));
|
||||
assert!(section.contains("/repo/AGENTS.md"));
|
||||
assert!(section.contains("/repo/x/user/AGENTS.md"));
|
||||
assert!(section.contains("<system-reminder>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_agents_md_section_delivers_full_content() {
|
||||
let long_content = "A".repeat(5000);
|
||||
let configs = vec![AgentConfigFile {
|
||||
file_name: "AGENTS.md".to_string(),
|
||||
file_path: "/repo/AGENTS.md".to_string(),
|
||||
content: long_content,
|
||||
}];
|
||||
let section = format_agents_md_section(&configs).unwrap();
|
||||
// No cap: the full content is delivered verbatim, with no truncation marker.
|
||||
assert!(
|
||||
section.contains(&"A".repeat(5000)),
|
||||
"full content must be preserved"
|
||||
);
|
||||
assert!(
|
||||
!section.contains("truncated"),
|
||||
"content must not be truncated"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature 2: Workspace user AGENTS.md via read_agents_config ───
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_includes_workspace_user_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// Create user AGENTS.md
|
||||
let user_dir = repo_root.join("x").join("testuser");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
fs::write(
|
||||
user_dir.join("AGENTS.md"),
|
||||
"# User-specific instructions\nAlways use tabs.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// cwd = repo root (user dir is NOT in the walk path)
|
||||
let configs = read_agents_config_with_options(
|
||||
repo_root.to_str().unwrap(),
|
||||
Some(&user_dir),
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let contents: Vec<&str> = configs.iter().map(|c| c.content.as_str()).collect();
|
||||
assert!(
|
||||
contents.iter().any(|c| c.contains("Always use tabs")),
|
||||
"Workspace user AGENTS.md should be included, got: {contents:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_workspace_user_dedup_when_cwd_inside_user_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// User dir with AGENTS.md
|
||||
let user_dir = repo_root.join("x").join("testuser");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
fs::write(user_dir.join("AGENTS.md"), "# Dedup test instructions").unwrap();
|
||||
|
||||
// cwd IS the user dir — the walk already includes it
|
||||
let configs = read_agents_config_with_options(
|
||||
user_dir.to_str().unwrap(),
|
||||
Some(&user_dir),
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// "Dedup test instructions" should appear exactly once
|
||||
let count = configs
|
||||
.iter()
|
||||
.filter(|c| c.content.contains("Dedup test instructions"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"User AGENTS.md should appear exactly once, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_no_workspace_user_dir_no_user_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// User dir with AGENTS.md (should NOT be found)
|
||||
let user_dir = repo_root.join("x").join("ghost");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
fs::write(user_dir.join("AGENTS.md"), "# Ghost instructions").unwrap();
|
||||
|
||||
// Pass None — simulates env vars not set
|
||||
let configs = read_agents_config_with_options(
|
||||
repo_root.to_str().unwrap(),
|
||||
None,
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let has_ghost = configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("Ghost instructions"));
|
||||
assert!(
|
||||
!has_ghost,
|
||||
"Without optional workspace user dir, ghost AGENTS.md should not be found"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: running outside a git repo must not panic.
|
||||
#[tokio::test]
|
||||
async fn regression_no_panic_outside_git_repo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("not_a_repo");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("AGENTS.md"), "# outside git").unwrap();
|
||||
|
||||
let configs =
|
||||
read_agents_config_with_options(dir.to_str().unwrap(), None, CompatConfig::default())
|
||||
.await;
|
||||
assert!(configs.iter().any(|c| c.content.contains("outside git")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_workspace_user_and_repo_root_both_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// Repo root AGENTS.md
|
||||
fs::write(repo_root.join("AGENTS.md"), "# XYZZY_REPO_ROOT_MARKER").unwrap();
|
||||
|
||||
// User AGENTS.md
|
||||
let user_dir = repo_root.join("x").join("testuser");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
fs::write(user_dir.join("AGENTS.md"), "# XYZZY_USER_SPECIFIC_MARKER").unwrap();
|
||||
|
||||
let configs = read_agents_config_with_options(
|
||||
repo_root.to_str().unwrap(),
|
||||
Some(&user_dir),
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Both should be found
|
||||
let has_repo = configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("XYZZY_REPO_ROOT_MARKER"));
|
||||
let has_user = configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("XYZZY_USER_SPECIFIC_MARKER"));
|
||||
|
||||
assert!(
|
||||
has_repo,
|
||||
"Repo root AGENTS.md not found in: {:?}",
|
||||
configs
|
||||
.iter()
|
||||
.map(|c| (&c.file_path, &c.content))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
has_user,
|
||||
"User AGENTS.md not found in: {:?}",
|
||||
configs
|
||||
.iter()
|
||||
.map(|c| (&c.file_path, &c.content))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_strips_frontmatter_from_rules_files() {
|
||||
let configs = vec![AgentConfigFile {
|
||||
file_name: "style.md".to_string(),
|
||||
file_path: "/repo/.claude/rules/style.md".to_string(),
|
||||
content: "---\nglobs: [\"*.rs\"]\n---\n# Use snake_case".to_string(),
|
||||
}];
|
||||
let section = format_agents_md_section(&configs).unwrap();
|
||||
assert!(section.contains("# Use snake_case"));
|
||||
assert!(!section.contains("globs:"));
|
||||
}
|
||||
|
||||
// ── .claude/CLAUDE.md integration tests ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_discovers_claude_subdir_claude_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// .claude/CLAUDE.md at repo root
|
||||
let claude_dir = repo_root.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_CLAUDE_SUBDIR_MARKER").unwrap();
|
||||
|
||||
let configs = read_agents_config_with_options(
|
||||
repo_root.to_str().unwrap(),
|
||||
None,
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("XYZZY_CLAUDE_SUBDIR_MARKER")),
|
||||
".claude/CLAUDE.md should be discovered, got: {:?}",
|
||||
configs
|
||||
.iter()
|
||||
.map(|c| (&c.file_path, &c.content))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_config_claude_subdir_and_direct_both_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo_root).unwrap();
|
||||
init_git_repo(&repo_root);
|
||||
|
||||
// Direct CLAUDE.md
|
||||
fs::write(repo_root.join("CLAUDE.md"), "# XYZZY_DIRECT_MARKER").unwrap();
|
||||
// .claude/CLAUDE.md
|
||||
let claude_dir = repo_root.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_SUBDIR_MARKER").unwrap();
|
||||
|
||||
let configs = read_agents_config_with_options(
|
||||
repo_root.to_str().unwrap(),
|
||||
None,
|
||||
CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let has_direct = configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("XYZZY_DIRECT_MARKER"));
|
||||
let has_subdir = configs
|
||||
.iter()
|
||||
.any(|c| c.content.contains("XYZZY_SUBDIR_MARKER"));
|
||||
|
||||
assert!(has_direct, "Direct CLAUDE.md should be found");
|
||||
assert!(has_subdir, ".claude/CLAUDE.md should be found");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
//! Gitignore integration for AGENTS.md and skills discovery.
|
||||
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn build_gitignore(repo_root: Option<&Path>) -> Option<Gitignore> {
|
||||
// No repo root → no gitignore rules to apply.
|
||||
let root = repo_root?;
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
|
||||
let repo_gitignore = root.join(".gitignore");
|
||||
if repo_gitignore.exists() {
|
||||
let _ = builder.add(&repo_gitignore);
|
||||
}
|
||||
|
||||
if let Some(global_path) = get_global_gitignore_path()
|
||||
&& global_path.exists()
|
||||
{
|
||||
let _ = builder.add(&global_path);
|
||||
}
|
||||
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
pub fn is_ignored(path: &Path, gitignore: Option<&Gitignore>, repo_root: Option<&Path>) -> bool {
|
||||
let Some(gi) = gitignore else {
|
||||
return false;
|
||||
};
|
||||
kigi_tools::gitignore::is_ignored(gi, path, repo_root)
|
||||
}
|
||||
|
||||
fn get_global_gitignore_path() -> Option<PathBuf> {
|
||||
git2::Config::open_default()
|
||||
.ok()
|
||||
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! System prompt assembly — template rendering, AGENTS.md, and skills.
|
||||
pub mod agents_md;
|
||||
pub mod context;
|
||||
pub mod ignore;
|
||||
pub mod skills;
|
||||
pub mod subagent_prompts;
|
||||
pub mod template;
|
||||
pub mod user_message;
|
||||
pub mod workspace_user;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
//! System prompts for built-in subagent profiles.
|
||||
//!
|
||||
//!
|
||||
//! ## Tool name resolution
|
||||
//!
|
||||
//! All tool names in these prompts use the `${{ tools.by_kind.* }}` template
|
||||
//! syntax from the `TemplateRenderer`. When the prompt is rendered via
|
||||
//! `PromptContext::render()` → `ToolBridge::render_prompt()`, MiniJinja
|
||||
//! resolves each variable to the current session's tool names.
|
||||
//!
|
||||
//! This means:
|
||||
//! - Tool names are NEVER hardcoded — they adapt to name overrides and
|
||||
//! alternate tool namespaces
|
||||
//! - If a tool kind is absent from the renderer's context, MiniJinja
|
||||
//! resolves it to an empty string (templates can also use
|
||||
//! `${%- if tools.by_kind.X %}` conditionals to hide entire sections)
|
||||
//!
|
||||
//! Tool-kind mapping (common names → ToolKind):
|
||||
//! Read → `${{ tools.by_kind.read }}`
|
||||
//! Write/Edit → `${{ tools.by_kind.edit }}`
|
||||
//! Glob → `${{ tools.by_kind.list }}`
|
||||
//! Grep → `${{ tools.by_kind.search }}`
|
||||
//! Bash → `${{ tools.by_kind.execute }}`
|
||||
//! WebSearch → `${{ tools.by_kind.web_search }}`
|
||||
|
||||
pub use kigi_tool_types::{EXPLORE_PROMPT, GENERAL_PURPOSE_PROMPT, PLAN_PROMPT};
|
||||
@@ -0,0 +1,880 @@
|
||||
//! System prompt template source and constants.
|
||||
//!
|
||||
//! Templates are XOR-obfuscated by `scripts/encrypt_templates.py` (obfuscation,
|
||||
//! not security — seeds live in-repo) so they don't appear as obvious plaintext
|
||||
//! in `strings` output. They are decrypted on demand and the returned
|
||||
//! `Zeroizing<String>` wipes the plaintext from memory on drop.
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
// Encrypted template bytes (pre-generated by scripts/encrypt_templates.py).
|
||||
#[path = "prompt_encrypted.rs"]
|
||||
mod prompt_encrypted;
|
||||
use prompt_encrypted::*;
|
||||
|
||||
/// Decrypt XOR-obfuscated template data (mirrors `scripts/encrypt_templates.py::xor_encrypt`).
|
||||
/// Obfuscation only — not a security boundary.
|
||||
fn decrypt(data: &[u8], seed: u8) -> Zeroizing<String> {
|
||||
let bytes: Vec<u8> = data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
|
||||
.collect();
|
||||
Zeroizing::new(String::from_utf8(bytes).expect(
|
||||
"prompt template decryption produced invalid UTF-8 — \
|
||||
prompt_encrypted.rs is likely stale; run: \
|
||||
python3 scripts/encrypt_templates.py",
|
||||
))
|
||||
}
|
||||
|
||||
/// The base prompt template (decrypted fresh; zeroed on drop).
|
||||
pub(crate) fn base_template() -> Zeroizing<String> {
|
||||
decrypt(BASE_PROMPT_ENC, PROMPT_SEEDS[0])
|
||||
}
|
||||
|
||||
/// The base prompt template source, exposed for `grok prompt --section template`.
|
||||
pub fn base_template_source() -> Zeroizing<String> {
|
||||
base_template()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_patch_template() -> Zeroizing<String> {
|
||||
decrypt(CODEX_PROMPT_ENC, PROMPT_SEEDS[1])
|
||||
}
|
||||
|
||||
/// Apply-patch prompt template source, exposed for `grok prompt --section apply-patch-template`.
|
||||
pub fn apply_patch_template_source() -> Zeroizing<String> {
|
||||
apply_patch_template()
|
||||
}
|
||||
|
||||
/// The subagent-specific base template (decrypted fresh; zeroed on drop).
|
||||
pub(crate) fn subagent_template() -> Zeroizing<String> {
|
||||
decrypt(SUBAGENT_PROMPT_ENC, PROMPT_SEEDS[2])
|
||||
}
|
||||
|
||||
/// The compact system prompt used after conversation compaction.
|
||||
pub const COMPACT_SYSTEM_PROMPT: &str = "You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
|
||||
Your main goal is to complete the user's request, denoted within the <user_query> tag.";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_tools::types::template_renderer::TemplateRenderer;
|
||||
use kigi_tools::types::tool::ToolKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Verify the pre-generated encrypted file matches the current template sources.
|
||||
/// If this fails, run: `python3 scripts/encrypt_templates.py`
|
||||
#[test]
|
||||
fn test_encrypted_templates_not_stale() {
|
||||
fn xor_encrypt(data: &[u8], seed: u8) -> Vec<u8> {
|
||||
data.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
|
||||
.collect()
|
||||
}
|
||||
let base_raw = include_bytes!("../../templates/prompt.md");
|
||||
let apply_patch_raw = include_bytes!("../../templates/apply_patch_prompt.md");
|
||||
let subagent_raw = include_bytes!("../../templates/subagent_prompt.md");
|
||||
|
||||
assert_eq!(
|
||||
BASE_PROMPT_ENC,
|
||||
&xor_encrypt(base_raw, PROMPT_SEEDS[0]),
|
||||
"prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
||||
);
|
||||
assert_eq!(
|
||||
CODEX_PROMPT_ENC,
|
||||
&xor_encrypt(apply_patch_raw, PROMPT_SEEDS[1]),
|
||||
"apply_patch_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
||||
);
|
||||
assert_eq!(
|
||||
SUBAGENT_PROMPT_ENC,
|
||||
&xor_encrypt(subagent_raw, PROMPT_SEEDS[2]),
|
||||
"subagent_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a TemplateRenderer with the standard grok-build tool kinds.
|
||||
fn default_renderer() -> TemplateRenderer {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "read_file"),
|
||||
(ToolKind::Edit, "search_replace"),
|
||||
(ToolKind::Execute, "run_terminal_command"),
|
||||
(ToolKind::Search, "grep"),
|
||||
(ToolKind::List, "list_dir"),
|
||||
(ToolKind::Plan, "todo_write"),
|
||||
(ToolKind::Skill, "skill"),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_command_or_subagent_output",
|
||||
),
|
||||
(ToolKind::KillTaskAction, "kill_command_or_subagent"),
|
||||
(ToolKind::WebSearch, "web_search"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.to_string()))
|
||||
.collect();
|
||||
TemplateRenderer::new(tools, HashMap::new())
|
||||
}
|
||||
|
||||
fn default_placeholders() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"os_name": "macos",
|
||||
"shell_path": "/bin/zsh",
|
||||
"working_directory": "/tmp/test",
|
||||
"current_date": "2025-01-15",
|
||||
"memory_enabled": false,
|
||||
"is_non_interactive": false,
|
||||
"system_prompt_label": crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_base(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
||||
let tmpl = base_template();
|
||||
renderer
|
||||
.render_with_extra(&tmpl, placeholders)
|
||||
.expect("base template render failed")
|
||||
}
|
||||
|
||||
fn render_subagent(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
||||
let tmpl = subagent_template();
|
||||
renderer
|
||||
.render_with_extra(&tmpl, placeholders)
|
||||
.expect("subagent template render failed")
|
||||
}
|
||||
|
||||
fn render_apply_patch(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
|
||||
let tmpl = apply_patch_template();
|
||||
renderer
|
||||
.render_with_extra(&tmpl, placeholders)
|
||||
.expect("codex template render failed")
|
||||
}
|
||||
|
||||
// ── Variable substitution ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_variable_substitution_tool_kind() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra("Use ${{ tools.by_kind.read }} to read files.", &p)
|
||||
.unwrap();
|
||||
assert_eq!(result, "Use read_file to read files.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variable_substitution_agent_fields() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra("OS: ${{ os_name }}, Shell: ${{ shell_path }}", &p)
|
||||
.unwrap();
|
||||
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
|
||||
}
|
||||
|
||||
// ── Conditionals ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_conditional_tool_present() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
|
||||
.unwrap();
|
||||
assert_eq!(result, "show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conditional_tool_absent() {
|
||||
// Renderer without plan tool
|
||||
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_braces_pass_through() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra("Use {{ literal_braces }} in prose.", &p)
|
||||
.unwrap();
|
||||
assert_eq!(result, "Use {{ literal_braces }} in prose.");
|
||||
}
|
||||
|
||||
// ── Tool name overrides ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_tool_name_override() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "view_file".to_string()),
|
||||
(ToolKind::Edit, "Edit".to_string()),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let p = default_placeholders();
|
||||
let result = r
|
||||
.render_with_extra(
|
||||
"Use ${{ tools.by_kind.read }} and ${{ tools.by_kind.edit }}.",
|
||||
&p,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "Use view_file and Edit.");
|
||||
}
|
||||
|
||||
// ── Base template rendering ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_base_template_renders() {
|
||||
let prompt = render_base(&default_renderer(), &default_placeholders());
|
||||
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
|
||||
assert!(prompt.contains("user_query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_template_contains_resolved_tool_names() {
|
||||
let prompt = render_base(&default_renderer(), &default_placeholders());
|
||||
// The minimal prompt only resolves the read/edit tool names, inside
|
||||
// <tool_calling>. (todo_write / run_terminal_command lived in sections
|
||||
// that the trimmed prompt no longer renders.)
|
||||
assert!(prompt.contains("read_file"), "Should contain 'read_file'");
|
||||
assert!(
|
||||
prompt.contains("search_replace"),
|
||||
"Should contain 'search_replace'"
|
||||
);
|
||||
assert!(!prompt.contains("${{"), "No unresolved template variables");
|
||||
assert!(!prompt.contains("${%"), "No unresolved template blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_template_with_overridden_tool_names() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "view_file".to_string()),
|
||||
(ToolKind::Edit, "edit".to_string()),
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
(ToolKind::Search, "grep".to_string()),
|
||||
(ToolKind::Plan, "todo_write".to_string()),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_task_output".to_string(),
|
||||
),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
prompt.contains("`view_file`"),
|
||||
"Should use overridden 'view_file'"
|
||||
);
|
||||
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
|
||||
assert!(
|
||||
!prompt.contains("`read_file`"),
|
||||
"Should NOT contain canonical 'read_file'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_template_plan_absent_omits_task_management() {
|
||||
// Renderer without Plan tool
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "read_file".to_string()),
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_task_output".to_string(),
|
||||
),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("Task Management"),
|
||||
"Task Management section should be omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_template_execute_absent_omits_background_tasks() {
|
||||
// Renderer without Execute tool
|
||||
let tools: HashMap<ToolKind, String> = [(ToolKind::Plan, "todo_write".to_string())].into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("background_tasks"),
|
||||
"background_tasks section should be omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_monitor_tool_renders_watch_section() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Execute, "run_command".to_string()),
|
||||
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
|
||||
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
||||
(ToolKind::Monitor, "monitor".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
prompt.contains("For watch processes"),
|
||||
"monitor section should render when Monitor tool is present"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("streams each stdout line back as a chat notification"),
|
||||
"monitor section should describe streaming stdout as notifications"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Use the `monitor` tool"),
|
||||
"monitor section should resolve the Monitor tool name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_monitor_tool_omits_watch_section() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Execute, "run_command".to_string()),
|
||||
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
|
||||
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("For watch processes"),
|
||||
"monitor section should NOT render without Monitor tool"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("<background_tasks>"),
|
||||
"background_tasks section is gated on the Monitor tool and is omitted without it"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Required sections regression ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_base_template_contains_required_sections() {
|
||||
let p = default_placeholders();
|
||||
let prompt = render_base(&default_renderer(), &p);
|
||||
assert!(
|
||||
prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL),
|
||||
"Must contain agent identity"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("user_query"),
|
||||
"Must reference user_query tag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_prompt_matches_expected() {
|
||||
assert_eq!(
|
||||
COMPACT_SYSTEM_PROMPT,
|
||||
"You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
|
||||
Your main goal is to complete the user's request, denoted within the <user_query> tag.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mid-session mode switching ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_mid_session_switch_concise_to_full() {
|
||||
let compact = COMPACT_SYSTEM_PROMPT;
|
||||
assert!(!compact.contains("read_file"), "Compact has no tool names");
|
||||
assert!(
|
||||
!compact.contains("<tool_calling>"),
|
||||
"Compact has no tool section"
|
||||
);
|
||||
|
||||
let full = render_base(&default_renderer(), &default_placeholders());
|
||||
assert!(
|
||||
full.contains("<tool_calling>"),
|
||||
"Full prompt has tool section"
|
||||
);
|
||||
assert!(full.contains("read_file"), "Full prompt has read_file");
|
||||
assert!(
|
||||
full.contains("search_replace"),
|
||||
"Full prompt has search_replace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mid_session_switch_preserves_tool_overrides() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "view".to_string()),
|
||||
(ToolKind::Edit, "edit".to_string()),
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
(ToolKind::Plan, "todo_write".to_string()),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_task_output".to_string(),
|
||||
),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
|
||||
assert!(prompt.contains("`view`"), "Should use overridden 'view'");
|
||||
assert!(
|
||||
!prompt.contains("`read_file`"),
|
||||
"Should not contain original 'read_file'"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("`search_replace`"),
|
||||
"Should not contain original 'search_replace'"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Determinism ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_prompt_deterministic_across_renders() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let a = render_base(&r, &p);
|
||||
let b = render_base(&r, &p);
|
||||
assert_eq!(a, b, "Prompt rendering must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_mode_deterministic() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let body = "Agent: ${{ tools.by_kind.read }}, OS: ${{ os_name }}";
|
||||
let a = r.render_with_extra(body, &p).unwrap();
|
||||
let b = r.render_with_extra(body, &p).unwrap();
|
||||
assert_eq!(a, b, "Full mode rendering must be deterministic");
|
||||
}
|
||||
|
||||
// ── Disabled tools ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_disabled_tools_omit_sections() {
|
||||
// No plan, no execute
|
||||
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_base(&r, &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("Task Management"),
|
||||
"Task Management must be omitted"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("background_tasks"),
|
||||
"background_tasks must be omitted"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memory section ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_memory_enabled_does_not_render_memory_section() {
|
||||
// The <memory> section was removed from the minimal base prompt.
|
||||
// Even when the memory tools are registered AND memory_enabled=true,
|
||||
// the trimmed template must not render a memory section. (Complements
|
||||
// test_memory_disabled_omits_memory_section, which covers the default.)
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "read_file".to_string()),
|
||||
(ToolKind::MemorySearch, "memory_search".to_string()),
|
||||
(ToolKind::MemoryGet, "memory_get".to_string()),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let mut p = default_placeholders();
|
||||
p["memory_enabled"] = serde_json::json!(true);
|
||||
let prompt = render_base(&r, &p);
|
||||
assert!(
|
||||
!prompt.contains("<memory>"),
|
||||
"Memory section was removed from the minimal prompt"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("### Memory Management"),
|
||||
"Memory Management section was removed from the minimal prompt"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("memory_search"),
|
||||
"memory tool names must not appear once the memory section is gone"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("memory_get"),
|
||||
"memory tool names must not appear once the memory section is gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_disabled_omits_memory_section() {
|
||||
let prompt = render_base(&default_renderer(), &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("<memory>"),
|
||||
"Memory section must be omitted"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Web search disabled ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_web_search_disabled_renders_without_crash() {
|
||||
// No Fetch tool
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "read_file".to_string()),
|
||||
(ToolKind::Plan, "todo_write".to_string()),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let tmpl = base_template();
|
||||
let result = r.render_with_extra(&tmpl, &default_placeholders());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Must render without crash: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Apply-patch template rendering ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_renders() {
|
||||
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
||||
assert!(prompt.contains("coding agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_contains_resolved_tool_names() {
|
||||
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
||||
assert!(prompt.contains("todo_write"), "Should contain 'todo_write'");
|
||||
// apply_patch is hardcoded, not resolved via ${{ tools.by_kind.edit }}
|
||||
assert!(
|
||||
prompt.contains("apply_patch"),
|
||||
"Should contain hardcoded 'apply_patch'"
|
||||
);
|
||||
assert!(!prompt.contains("${{"), "No unresolved template variables");
|
||||
assert!(!prompt.contains("${%"), "No unresolved template blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_plan_absent_omits_planning() {
|
||||
// Renderer without Plan tool
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "read_file".to_string()),
|
||||
(ToolKind::Edit, "search_replace".to_string()),
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_apply_patch(&r, &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("## Planning"),
|
||||
"Planning section should be omitted when plan tool absent"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("update_plan"),
|
||||
"update_plan references should be omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_plan_present_includes_planning() {
|
||||
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
|
||||
assert!(
|
||||
prompt.contains("## Planning"),
|
||||
"Planning section should be present when plan tool exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_with_overridden_tool_names() {
|
||||
let tools: HashMap<ToolKind, String> = [
|
||||
(ToolKind::Read, "view_file".to_string()),
|
||||
(ToolKind::Edit, "some_other_edit".to_string()),
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
(ToolKind::Plan, "update_plan".to_string()),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_task_output".to_string(),
|
||||
),
|
||||
]
|
||||
.into();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let prompt = render_apply_patch(&r, &default_placeholders());
|
||||
// apply_patch is hardcoded — NOT affected by Edit tool override
|
||||
assert!(
|
||||
prompt.contains("`apply_patch`"),
|
||||
"apply_patch must remain hardcoded regardless of edit override"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("some_other_edit"),
|
||||
"Edit override must NOT leak into apply-patch prompt"
|
||||
);
|
||||
// Plan tool IS resolved via template
|
||||
assert!(
|
||||
prompt.contains("`update_plan`"),
|
||||
"Should use overridden 'update_plan'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_template_deterministic_across_renders() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let a = render_apply_patch(&r, &p);
|
||||
let b = render_apply_patch(&r, &p);
|
||||
assert_eq!(a, b, "Apply-patch template rendering must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_template_deterministic_across_renders() {
|
||||
let r = default_renderer();
|
||||
let p = default_placeholders();
|
||||
let a = render_subagent(&r, &p);
|
||||
let b = render_subagent(&r, &p);
|
||||
assert_eq!(a, b, "Subagent template rendering must be deterministic");
|
||||
}
|
||||
|
||||
// ── Task completion discipline ─────────────────────────────────
|
||||
//
|
||||
// The `<task_completion_discipline>` block was removed from both
|
||||
// base and subagent templates. These tests pin the deletion so the
|
||||
// block doesn't accidentally come back, and so the runtime TodoGate
|
||||
// doesn't start firing reminders that reference a non-existent
|
||||
// block.
|
||||
|
||||
#[test]
|
||||
fn task_completion_discipline_block_is_not_rendered() {
|
||||
let prompt = render_base(&default_renderer(), &default_placeholders());
|
||||
assert!(
|
||||
!prompt.contains("<task_completion_discipline>"),
|
||||
"discipline block was removed from the base template"
|
||||
);
|
||||
let subagent = render_subagent(&default_renderer(), &default_placeholders());
|
||||
assert!(
|
||||
!subagent.contains("<task_completion_discipline>"),
|
||||
"discipline block was removed from the subagent template"
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft byte ceiling shared by both prompt-size budget tests.
|
||||
/// Forward-budget guard against runaway growth, not a tight target.
|
||||
const PROMPT_SIZE_SOFT_CEILING_BYTES: usize = 16384;
|
||||
|
||||
fn assert_template_size_under(prompt: &str, label: &str) {
|
||||
assert!(
|
||||
prompt.len() < PROMPT_SIZE_SOFT_CEILING_BYTES,
|
||||
"{label} prompt is {} bytes, exceeding soft ceiling of {} bytes",
|
||||
prompt.len(),
|
||||
PROMPT_SIZE_SOFT_CEILING_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_template_size_budget() {
|
||||
let prompt = render_base(&default_renderer(), &default_placeholders());
|
||||
assert_template_size_under(&prompt, "base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_template_size_budget() {
|
||||
let prompt = render_subagent(&default_renderer(), &default_placeholders());
|
||||
assert_template_size_under(&prompt, "subagent");
|
||||
}
|
||||
|
||||
// ── Guard invariant ─────────────────────────────────────────────
|
||||
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
|
||||
// whose condition requires X (contains `tools.by_kind.X` at a word
|
||||
// boundary, with no top-level ` or `). If violated, X could render
|
||||
// as empty string at runtime.
|
||||
|
||||
fn word_bounded(hay: &str, needle: &str) -> bool {
|
||||
let mut s = 0;
|
||||
while let Some(i) = hay[s..].find(needle) {
|
||||
let end = s + i + needle.len();
|
||||
match hay[end..].chars().next() {
|
||||
None => return true,
|
||||
Some(c) if !(c.is_alphanumeric() || c == '_') => return true,
|
||||
_ => s += i + 1,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn guarantees(cond: &str, kind: &str) -> bool {
|
||||
if word_bounded(cond, &format!("tools.by_kind.{kind}")) && !cond.contains(" or ") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn assert_guards(template: &str, label: &str) {
|
||||
let bytes = template.as_bytes();
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 2 < bytes.len() {
|
||||
let three = &bytes[i..i + 3];
|
||||
if three == b"${%" {
|
||||
let end = bytes[i + 3..]
|
||||
.windows(2)
|
||||
.position(|w| w == b"%}")
|
||||
.map(|e| i + 3 + e + 2)
|
||||
.unwrap_or(bytes.len());
|
||||
let body = std::str::from_utf8(&bytes[i + 3..end - 2])
|
||||
.unwrap()
|
||||
.trim_matches(['-', ' ']);
|
||||
if let Some(c) = body.strip_prefix("if ") {
|
||||
stack.push(c.trim().into());
|
||||
} else if let Some(c) = body.strip_prefix("elif ") {
|
||||
stack.pop();
|
||||
stack.push(c.trim().into());
|
||||
} else if body == "else" {
|
||||
stack.pop();
|
||||
stack.push("<else>".into());
|
||||
} else if body == "endif" {
|
||||
stack.pop();
|
||||
}
|
||||
i = end;
|
||||
} else if three == b"${{" {
|
||||
let end = bytes[i + 3..]
|
||||
.windows(2)
|
||||
.position(|w| w == b"}}")
|
||||
.map(|e| i + 3 + e + 2)
|
||||
.unwrap_or(bytes.len());
|
||||
let body = std::str::from_utf8(&bytes[i + 3..end - 2]).unwrap().trim();
|
||||
// search_tool and use_tool are always built-in, so they
|
||||
// never need a guard.
|
||||
const ALWAYS_BUILTIN: &[&str] = &["search_tool", "use_tool"];
|
||||
if let Some(kind) = body.strip_prefix("tools.by_kind.")
|
||||
&& kind.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||||
&& !ALWAYS_BUILTIN.contains(&kind)
|
||||
&& !stack.iter().any(|c| guarantees(c, kind))
|
||||
{
|
||||
let line = template[..i].lines().count() + 1;
|
||||
errors.push(format!(
|
||||
"{label}:{line}: unguarded `${{{{ tools.by_kind.{kind} }}}}` (stack: {stack:?})"
|
||||
));
|
||||
}
|
||||
i = end;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
assert!(errors.is_empty(), "\n {}", errors.join("\n "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_vars_are_always_guarded() {
|
||||
assert_guards(&base_template(), "prompt.md");
|
||||
assert_guards(&subagent_template(), "subagent_prompt.md");
|
||||
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
|
||||
}
|
||||
|
||||
// ── Combination sweep ───────────────────────────────────────────
|
||||
// Belt-and-braces: renders the base template across tool-kind subsets
|
||||
// and asserts no raw template tokens leak. The static guard test above
|
||||
// is the authoritative check; this one just catches syntax drift.
|
||||
|
||||
// ── is_non_interactive gating ──────────────────────────────────
|
||||
// Headless / SDK / stdio / generic-ACP sessions have no human typing
|
||||
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
|
||||
// `<user_guide>` TUI pointer are noise. Those sections must drop out
|
||||
// when `is_non_interactive=true` and remain when it's false.
|
||||
|
||||
#[test]
|
||||
fn interactive_renders_shell_prefix_tip_and_user_guide() {
|
||||
// The `! <command>` shell-prefix tip was removed from the minimal
|
||||
// prompt. The <user_guide> block still renders for interactive
|
||||
// sessions only, so that's what we assert here.
|
||||
let mut p = default_placeholders();
|
||||
p["is_non_interactive"] = serde_json::json!(false);
|
||||
let prompt = render_base(&default_renderer(), &p);
|
||||
assert!(
|
||||
prompt.contains("<user_guide>"),
|
||||
"interactive prompt must keep the <user_guide> block"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("interactive CLI tool"),
|
||||
"interactive prompt must declare interactive mode in the header"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("autonomous agent"),
|
||||
"interactive prompt must NOT advertise non-interactive (autonomous) mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_suppresses_shell_prefix_tip_and_user_guide() {
|
||||
let mut p = default_placeholders();
|
||||
p["is_non_interactive"] = serde_json::json!(true);
|
||||
let prompt = render_base(&default_renderer(), &p);
|
||||
assert!(
|
||||
!prompt.contains("`! <command>`"),
|
||||
"non-interactive prompt must suppress the shell-prefix tip"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("<user_guide>"),
|
||||
"non-interactive prompt must suppress the <user_guide> block"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("autonomous agent"),
|
||||
"non-interactive prompt must declare autonomous mode in the header"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("interactive CLI tool"),
|
||||
"non-interactive prompt must NOT claim to be the interactive CLI"
|
||||
);
|
||||
// Sanity: rest of the template still renders.
|
||||
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
|
||||
assert!(prompt.contains("user_query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combination_sweep_no_unresolved_variables() {
|
||||
let optional = [
|
||||
ToolKind::Read,
|
||||
ToolKind::Edit,
|
||||
ToolKind::Execute,
|
||||
ToolKind::Search,
|
||||
ToolKind::List,
|
||||
ToolKind::Plan,
|
||||
ToolKind::Skill,
|
||||
ToolKind::Task,
|
||||
ToolKind::AskUser,
|
||||
ToolKind::EnterPlan,
|
||||
ToolKind::ExitPlan,
|
||||
ToolKind::BackgroundTaskAction,
|
||||
ToolKind::Monitor,
|
||||
ToolKind::MemorySearch,
|
||||
ToolKind::MemoryGet,
|
||||
];
|
||||
let mut subsets: Vec<Vec<ToolKind>> = vec![vec![], optional.to_vec()];
|
||||
for i in 0..optional.len() {
|
||||
subsets.push(vec![optional[i]]);
|
||||
for j in (i + 1)..optional.len() {
|
||||
subsets.push(vec![optional[i], optional[j]]);
|
||||
}
|
||||
}
|
||||
|
||||
for memory_enabled in [false, true] {
|
||||
for subset in &subsets {
|
||||
let tools: HashMap<ToolKind, String> = subset
|
||||
.iter()
|
||||
.map(|k| (*k, format!("{k:?}").to_lowercase()))
|
||||
.collect();
|
||||
let r = TemplateRenderer::new(tools, HashMap::new());
|
||||
let mut p = default_placeholders();
|
||||
p["memory_enabled"] = serde_json::json!(memory_enabled);
|
||||
let rendered = r
|
||||
.render_with_extra(&base_template(), &p)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("render failed: {subset:?} mem={memory_enabled}: {e:?}")
|
||||
});
|
||||
assert!(
|
||||
!rendered.contains("${{") && !rendered.contains("${%"),
|
||||
"unresolved token in render: {subset:?} mem={memory_enabled}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Per-agent first-user-message rendering.
|
||||
//!
|
||||
//! Mirrors `prompt::context::PromptContext` but for the first user message
|
||||
//! (the prefix that contains `<user_info>`, `<git_status>`, optional
|
||||
//! workspace overview, optional rules / skills / MCP listings).
|
||||
//!
|
||||
//! `UserMessageTemplate` selects the rendering strategy:
|
||||
//! - `Default` -- the legacy Grok Build prefix (built by the shell layer).
|
||||
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
|
||||
//! as the system prompt templates).
|
||||
//!
|
||||
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
|
||||
//! files, skill registry, MCP servers) and hands them to
|
||||
//! `UserMessageContext::render`, which dispatches on `template`.
|
||||
use crate::prompt::agents_md::AgentConfigFile;
|
||||
use chrono::NaiveDate;
|
||||
use kigi_tools::bridge::ToolBridge;
|
||||
use kigi_tools::implementations::skills::types::SkillInfo;
|
||||
use kigi_tools::types::skill_discovery_tracker::{XmlRenderMode, format_announcement_xml};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
/// Date format for the `Today's date` field of the user-message preamble
|
||||
/// (e.g. "Friday Apr 24, 2026"). Any format change is observable to the model.
|
||||
pub const USER_MESSAGE_DATE_FORMAT: &str = "%A %b %-d, %Y";
|
||||
/// Per-repo character cap applied to `vcs_status` at render time. The
|
||||
/// `<git_status>` block has no token budget -- this character cap is the only
|
||||
/// size control, and it is applied per repo at render, never at gather, so
|
||||
/// other consumers of the raw status are unaffected.
|
||||
pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
|
||||
/// Trim, drop-if-empty, and cap a VCS status string for the
|
||||
/// `<git_status>` block.
|
||||
///
|
||||
/// Returns `None` when the trimmed status is empty (so the section is dropped
|
||||
/// and no empty code fence is emitted), otherwise the status capped at
|
||||
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
|
||||
/// the `... (git status truncated)` marker appended.
|
||||
fn normalize_git_status(status: &str) -> Option<String> {
|
||||
let status = status.trim();
|
||||
if status.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
||||
return Some(status.to_string());
|
||||
}
|
||||
let mut end = GIT_STATUS_CHARACTER_LIMIT;
|
||||
while !status.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let mut truncated = &status[..end];
|
||||
if let Some(nl) = truncated.rfind('\n')
|
||||
&& nl > 0
|
||||
{
|
||||
truncated = &truncated[..nl];
|
||||
}
|
||||
Some(format!("{truncated}\n\n... (git status truncated)"))
|
||||
}
|
||||
/// Selects the first-user-message rendering strategy for an agent.
|
||||
///
|
||||
/// Built-in variants decrypt the underlying XOR-obfuscated template on demand
|
||||
/// (obfuscation, not security). Decrypted bytes are zeroed on drop via
|
||||
/// `Zeroizing`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserMessageTemplate {
|
||||
/// Legacy Grok Build prefix: `<user_info>` + optional `<git_status>`.
|
||||
/// Built directly by the shell layer; this
|
||||
/// renderer returns `None` for `Default` and the caller falls back to
|
||||
/// its own legacy path.
|
||||
#[default]
|
||||
Default,
|
||||
/// Caller-supplied MiniJinja template string.
|
||||
Custom(String),
|
||||
}
|
||||
impl UserMessageTemplate {
|
||||
pub fn is_cursor(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Backward-compatible deserialization: accepts both the new tagged format
|
||||
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
|
||||
/// as `Custom`).
|
||||
impl<'de> Deserialize<'de> for UserMessageTemplate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = UserMessageTemplate;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(r#""default", "cursor", {"custom": "..."}, or a template string"#)
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
match v {
|
||||
"default" => Ok(UserMessageTemplate::Default),
|
||||
other => Ok(UserMessageTemplate::Custom(other.to_owned())),
|
||||
}
|
||||
}
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> Result<Self::Value, M::Error> {
|
||||
match map.next_key::<String>()? {
|
||||
Some(ref k) if k == "custom" => {
|
||||
let val: String = map.next_value()?;
|
||||
Ok(UserMessageTemplate::Custom(val))
|
||||
}
|
||||
Some(other) => Err(serde::de::Error::unknown_field(&other, &["custom"])),
|
||||
None => Err(serde::de::Error::custom(r#"expected {"custom": "..."}"#)),
|
||||
}
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
/// One discovered rule file (AGENTS.md / Claude.md / .kigi/rules/*.md).
|
||||
///
|
||||
/// Wire-compatible with `AgentConfigFile` -- this type exists so the
|
||||
/// `UserMessageContext` does not depend on the AGENTS-discovery internals
|
||||
/// beyond the path/content pair.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuleEntry {
|
||||
/// Absolute path of the file (used as the rule `name` attribute).
|
||||
pub path: String,
|
||||
/// Raw file body.
|
||||
pub content: String,
|
||||
}
|
||||
impl From<AgentConfigFile> for RuleEntry {
|
||||
fn from(f: AgentConfigFile) -> Self {
|
||||
Self {
|
||||
path: f.file_path,
|
||||
content: f.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Connected MCP server metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerEntry {
|
||||
pub name: String,
|
||||
/// Free-form usage instructions a user provided when configuring the
|
||||
/// server. Surfaced in the `serverUseInstructions` attribute.
|
||||
pub server_use_instructions: Option<String>,
|
||||
/// Absolute path to the per-server descriptor folder. Surfaced in
|
||||
/// the `folderPath` attribute. Compatible models read tool
|
||||
/// schemas from `<folder_path>/tools/<tool>.json` and resource
|
||||
/// descriptors from `<folder_path>/resources/<resource>.json` before
|
||||
/// calling `CallMcpTool`/`FetchMcpResource`. The session is
|
||||
/// responsible for materializing the descriptor files at this path.
|
||||
pub folder_path: Option<String>,
|
||||
}
|
||||
/// All inputs the templated first user message needs. The shell gathers
|
||||
/// these once at session start (and again on compaction) and hands the
|
||||
/// struct to `render`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserMessageContext {
|
||||
pub template: UserMessageTemplate,
|
||||
/// Display path -- the path the model sees as the workspace.
|
||||
pub workspace_path: PathBuf,
|
||||
/// OS identifier surfaced as the `<user_info>` `OS Version:` value.
|
||||
///
|
||||
/// This is `"<kernel> <release>"` (e.g. `"darwin 24.6.0"`,
|
||||
/// `"linux 6.5.0-..."`) -- not the OS family (`std::env::consts::OS`, e.g.
|
||||
/// `"macos"`). Producers that don't have a uname-style string available may
|
||||
/// pass `std::env::consts::OS` as a fallback; callers that need the full
|
||||
/// string should use `kigi_shell::util::uname::os_kernel_and_release`
|
||||
/// (or equivalent).
|
||||
pub os_family: String,
|
||||
/// `$SHELL` env, basename only -- e.g. "zsh", "bash".
|
||||
pub shell: String,
|
||||
/// Git/jj working-tree root, if any.
|
||||
pub vcs_root: Option<PathBuf>,
|
||||
/// Pre-fetched VCS status output (caller handles timeouts).
|
||||
pub vcs_status: Option<String>,
|
||||
/// Local date captured at session start (or compaction). Formatted
|
||||
/// inside the renderer using [`USER_MESSAGE_DATE_FORMAT`] so the producer
|
||||
/// cannot accidentally drift the model-facing date shape.
|
||||
pub today_local: Option<NaiveDate>,
|
||||
/// Per-workspace terminals folder, surfaced as
|
||||
/// `Terminals folder: <path>` in the `<user_info>` block. The
|
||||
/// shell tool persists each background command's output to a file
|
||||
/// here (`<terminals_folder>/<numeric-shell-id>.txt`); the model uses
|
||||
/// this path to read terminal state via the read tool. Optional --
|
||||
/// when `None`, the line is omitted from the rendered preamble.
|
||||
pub terminals_folder: Option<PathBuf>,
|
||||
/// Workspace-scoped rule files (cwd / repo root / optional workspace user dir).
|
||||
pub workspace_rules: Vec<RuleEntry>,
|
||||
/// User-scoped rule files (~/.kigi/, ~/.claude/).
|
||||
pub user_rules: Vec<RuleEntry>,
|
||||
/// Skill registry snapshot (already deduped). Rendered through the
|
||||
/// shared budget-tier renderer.
|
||||
pub skills: Vec<SkillInfo>,
|
||||
/// Optional listing budget in characters; defaults to the standard
|
||||
/// 1%-of-context heuristic when None.
|
||||
pub skill_listing_budget_chars: Option<usize>,
|
||||
/// Connected MCP servers (alphabetical).
|
||||
pub mcp_servers: Vec<McpServerEntry>,
|
||||
/// Absolute path to the per-workspace MCP descriptor root
|
||||
/// (`~/.kigi/projects/<encoded-cwd>/mcps`). Surfaced in
|
||||
/// the `<mcp_file_system>` instructions so the model knows where
|
||||
/// to discover tool/resource schemas. Required when `mcp_servers` is
|
||||
/// non-empty; ignored otherwise.
|
||||
pub mcps_root: Option<String>,
|
||||
/// Client-facing name of the read tool (resolved from `TemplateRenderer`).
|
||||
/// Used in the skill section's instructional text. Defaults to `"Read"`.
|
||||
pub read_tool_name: String,
|
||||
}
|
||||
/// Typed placeholder bag handed to MiniJinja.
|
||||
///
|
||||
/// Field names here must match `${{ … }}` references in any caller-supplied
|
||||
/// `Custom` template. Keeping this as a typed
|
||||
/// struct -- rather than a free-form `serde_json::Value` -- means the set
|
||||
/// of supported placeholders is greppable from one place, every nested
|
||||
/// shape is enforced by `Serialize`, and rename refactors flow through
|
||||
/// the compiler instead of silently producing empty strings at render
|
||||
/// time.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct UserMessagePlaceholders<'a> {
|
||||
workspace_path: String,
|
||||
os_family: &'a str,
|
||||
shell: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vcs_root: Option<String>,
|
||||
/// Owned because the renderer caps/normalizes the raw status via
|
||||
/// [`normalize_git_status`] before handing it to MiniJinja.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vcs_status: Option<String>,
|
||||
/// Pre-formatted using [`USER_MESSAGE_DATE_FORMAT`]; `None` is rendered as
|
||||
/// `null` so the `${% if today_local %}` guard in the template drops
|
||||
/// the line entirely.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
today_local: Option<String>,
|
||||
/// Pre-rendered as a string so the template can `${% if terminals_folder %}`-guard.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
terminals_folder: Option<String>,
|
||||
has_rules: bool,
|
||||
workspace_rules: &'a [RuleEntry],
|
||||
user_rules: &'a [RuleEntry],
|
||||
/// Pre-rendered budgeted `<agent_skill>` XML rows; the template
|
||||
/// just substitutes this verbatim. See `render_skill_listing_xml` for
|
||||
/// why the skill listing is special-cased.
|
||||
skill_listing: String,
|
||||
/// Client-facing name of the read tool, used in the skill section's
|
||||
/// instructional text. Defaults to `"Read"`.
|
||||
read_tool_name: String,
|
||||
mcp_servers: &'a [McpServerEntry],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mcps_root: Option<&'a str>,
|
||||
}
|
||||
impl UserMessageContext {
|
||||
/// Build placeholders for MiniJinja rendering.
|
||||
fn placeholders(&self) -> UserMessagePlaceholders<'_> {
|
||||
UserMessagePlaceholders {
|
||||
workspace_path: self.workspace_path.to_string_lossy().into_owned(),
|
||||
os_family: &self.os_family,
|
||||
shell: &self.shell,
|
||||
vcs_root: self
|
||||
.vcs_root
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
vcs_status: self.vcs_status.as_deref().and_then(normalize_git_status),
|
||||
today_local: self
|
||||
.today_local
|
||||
.map(|d| d.format(USER_MESSAGE_DATE_FORMAT).to_string()),
|
||||
terminals_folder: self
|
||||
.terminals_folder
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
has_rules: !self.workspace_rules.is_empty() || !self.user_rules.is_empty(),
|
||||
workspace_rules: &self.workspace_rules,
|
||||
user_rules: &self.user_rules,
|
||||
skill_listing: self.render_skill_listing_xml().unwrap_or_default(),
|
||||
read_tool_name: self.read_tool_name.clone(),
|
||||
mcp_servers: &self.mcp_servers,
|
||||
mcps_root: self.mcps_root.as_deref(),
|
||||
}
|
||||
}
|
||||
/// Render the skill list as `<agent_skill>` XML rows.
|
||||
pub fn render_skill_listing_xml(&self) -> Option<String> {
|
||||
if self.skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mode = if self.template.is_cursor() {
|
||||
XmlRenderMode::Verbatim
|
||||
} else {
|
||||
XmlRenderMode::Budgeted {
|
||||
budget_chars: self.skill_listing_budget_chars,
|
||||
overflow_indicator: true,
|
||||
}
|
||||
};
|
||||
let mut announced = HashSet::new();
|
||||
format_announcement_xml(&self.skills, &mut announced, None, None, mode)
|
||||
}
|
||||
/// Render the first user message.
|
||||
///
|
||||
/// Returns `None` for `UserMessageTemplate::Default` -- the caller is
|
||||
/// responsible for the legacy prefix path. `Custom` dispatches through
|
||||
/// `ToolBridge::render_prompt` so MiniJinja
|
||||
/// `${{ tools.by_kind.* }}` references resolve correctly.
|
||||
pub async fn render(&self, bridge: &ToolBridge) -> Option<String> {
|
||||
let placeholders = serde_json::to_value(self.placeholders())
|
||||
.expect("UserMessagePlaceholders serializes infallibly");
|
||||
let rendered = match &self.template {
|
||||
UserMessageTemplate::Default => return None,
|
||||
UserMessageTemplate::Custom(s) => bridge.render_prompt(s, &placeholders).await,
|
||||
};
|
||||
rendered.map(|s| s.trim_end().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn template_override_deserialize_strings() {
|
||||
let v: UserMessageTemplate = serde_json::from_str(r#""default""#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Default);
|
||||
let v: UserMessageTemplate = serde_json::from_str(r#""my custom""#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
|
||||
}
|
||||
#[test]
|
||||
fn template_override_deserialize_custom_map() {
|
||||
let v: UserMessageTemplate =
|
||||
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();
|
||||
assert_eq!(v, UserMessageTemplate::Custom("my template body".into()));
|
||||
}
|
||||
#[test]
|
||||
fn template_override_round_trip() {
|
||||
for original in [
|
||||
UserMessageTemplate::Default,
|
||||
UserMessageTemplate::Custom("body".into()),
|
||||
] {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let loaded: UserMessageTemplate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, loaded);
|
||||
}
|
||||
}
|
||||
/// A status under the cap passes through unchanged (trim is a no-op for
|
||||
/// real `git status --short --branch` output, which starts with `##`).
|
||||
#[test]
|
||||
fn normalize_git_status_passthrough_under_limit() {
|
||||
let status = "## main...origin/main\n M src/app.rs";
|
||||
assert_eq!(normalize_git_status(status).as_deref(), Some(status));
|
||||
}
|
||||
/// Empty / whitespace-only status -> `None` so the section is dropped and
|
||||
/// no empty fence is emitted.
|
||||
#[test]
|
||||
fn normalize_git_status_drops_whitespace_only() {
|
||||
assert_eq!(normalize_git_status(""), None);
|
||||
assert_eq!(normalize_git_status(" \n\t "), None);
|
||||
}
|
||||
/// A status over the cap is truncated at the last newline before the limit
|
||||
/// and carries the spec's truncation marker.
|
||||
#[test]
|
||||
fn normalize_git_status_truncates_over_limit() {
|
||||
let mut status = String::from("## main...origin/main\n");
|
||||
while status.len() <= GIT_STATUS_CHARACTER_LIMIT {
|
||||
status.push_str(" M src/some/long/path/to/file.rs\n");
|
||||
}
|
||||
assert!(status.len() > GIT_STATUS_CHARACTER_LIMIT);
|
||||
let out = normalize_git_status(&status).expect("non-empty status");
|
||||
assert!(
|
||||
out.ends_with("\n\n... (git status truncated)"),
|
||||
"missing truncation marker: {out}"
|
||||
);
|
||||
let body = out
|
||||
.strip_suffix("\n\n... (git status truncated)")
|
||||
.expect("marker suffix");
|
||||
assert!(
|
||||
body.len() <= GIT_STATUS_CHARACTER_LIMIT,
|
||||
"body {} exceeds cap {GIT_STATUS_CHARACTER_LIMIT}",
|
||||
body.len()
|
||||
);
|
||||
assert!(status.starts_with(body), "body is not a clean prefix");
|
||||
assert!(!body.ends_with('\n'), "body should be snapped to last line");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Optional multi-user workspace helpers for loading per-user agent config.
|
||||
//!
|
||||
//! When optional workspace root and user env vars are set and the resolved
|
||||
//! directory exists, that path can contribute AGENTS.md / rules / skills
|
||||
//! discovery. Unset env vars are a no-op (typical for standalone installs).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// If optional workspace env vars are set, returns the user's config directory
|
||||
/// when the resolved path exists on disk. Unset or missing paths yield `None`.
|
||||
pub fn optional_workspace_user_dir() -> Option<PathBuf> {
|
||||
let root = std::env::var("XAI_ROOT").ok()?;
|
||||
let user = std::env::var("XAI_USER").ok()?;
|
||||
resolve_workspace_user_dir(&root, &workspace_user_relpath(&user))
|
||||
}
|
||||
|
||||
/// Map `$XAI_USER` to a path relative to the workspace root.
|
||||
///
|
||||
/// A bare username is nested one level under `x/` so it cannot collide with an
|
||||
/// unrelated same-named directory at the workspace root. Values that already
|
||||
/// contain a path separator are used as-is (explicit relative path).
|
||||
fn workspace_user_relpath(user: &str) -> String {
|
||||
if user.contains('/') || user.contains('\\') {
|
||||
user.to_string()
|
||||
} else {
|
||||
format!("x/{user}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure logic: join `root` with a relative `user` path and return it if the
|
||||
/// directory exists on disk.
|
||||
///
|
||||
/// Returns `None` if either argument is empty or the resulting path is not
|
||||
/// a directory.
|
||||
///
|
||||
/// Example: `resolve_workspace_user_dir("/workspace", "users/alice")`
|
||||
/// → `Some("/workspace/users/alice")` if that directory exists.
|
||||
pub fn resolve_workspace_user_dir(root: &str, user: &str) -> Option<PathBuf> {
|
||||
if root.is_empty() || user.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(root).join(user);
|
||||
path.is_dir().then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
// ── resolve_workspace_user_dir (pure, no env vars) ───────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_for_empty_root() {
|
||||
assert!(resolve_workspace_user_dir("", "users/someone").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_for_empty_user() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_for_both_empty() {
|
||||
assert!(resolve_workspace_user_dir("", "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_dir_does_not_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/nonexistent").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_path_when_dir_exists() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let user_dir = tmp.path().join("users").join("testuser");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
|
||||
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser");
|
||||
assert_eq!(result, Some(user_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_handles_single_component_user() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let user_dir = tmp.path().join("alice");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
|
||||
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "alice");
|
||||
assert_eq!(result, Some(user_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_handles_deeply_nested_user() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let user_dir = tmp.path().join("org").join("team").join("user");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
|
||||
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "org/team/user");
|
||||
assert_eq!(result, Some(user_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_path_is_file_not_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file_path = tmp.path().join("users").join("testuser");
|
||||
fs::create_dir_all(file_path.parent().unwrap()).unwrap();
|
||||
fs::write(&file_path, "not a directory").unwrap();
|
||||
|
||||
assert!(
|
||||
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_supports_nested_user_layout_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let user_dir = tmp.path().join("x").join("testuser");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
|
||||
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "x/testuser");
|
||||
assert_eq!(result, Some(user_dir));
|
||||
}
|
||||
|
||||
// ── workspace_user_relpath ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bare_username_is_nested_under_x() {
|
||||
assert_eq!(workspace_user_relpath("alice"), "x/alice");
|
||||
assert_eq!(workspace_user_relpath("bob"), "x/bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_segment_user_is_explicit_relative_path() {
|
||||
assert_eq!(workspace_user_relpath("users/alice"), "users/alice");
|
||||
assert_eq!(workspace_user_relpath(r"users\alice"), r"users\alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_username_does_not_resolve_to_same_named_root_dir() {
|
||||
// Prefer the nested layout even when a same-named directory exists at
|
||||
// the workspace root.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("alice")).unwrap();
|
||||
let user_dir = root.join("x").join("alice");
|
||||
fs::create_dir_all(&user_dir).unwrap();
|
||||
|
||||
let rel = workspace_user_relpath("alice");
|
||||
let resolved = resolve_workspace_user_dir(root.to_str().unwrap(), &rel);
|
||||
assert_eq!(resolved, Some(user_dir));
|
||||
assert_ne!(
|
||||
resolved.as_deref(),
|
||||
Some(root.join("alice").as_path()),
|
||||
"must not resolve to a same-named directory at the workspace root"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user