Skip to main content

arcan_core/
prompt.rs

1//! Liquid prompt system — assembles a structured system prompt from multiple sources.
2//!
3//! Architecture (cacheable vs. dynamic split for prompt cache savings):
4//!
5//! **Cacheable** (stable across turns — Anthropic auto-caches matching prefixes):
6//! 1. Role definition
7//! 2. Environment info (OS, shell, date, model)
8//! 3. Project instructions (CLAUDE.md, AGENTS.md, docs/, .control/policy.yaml)
9//! 4. Guidelines
10//!
11//! **Dynamic** (changes per turn — appended after cacheable prefix):
12//! 5. Git context (branch, status, recent commits)
13//! 6. Memory context (MEMORY.md index from `.arcan/memory/*.md`)
14//! 7. Workspace context (shared cross-session journal summaries)
15//! 8. Skill catalog
16//!
17//! This module lives in `arcan-core` so both the shell REPL (`arcan` binary)
18//! and the daemon HTTP server (`arcand`) can share the same prompt builder.
19
20use std::collections::BTreeMap;
21use std::path::Path;
22
23/// Structured system prompt split into cacheable and dynamic sections.
24///
25/// Anthropic automatically caches the longest matching prefix of the system
26/// prompt across turns. By placing stable content first (cacheable) and
27/// per-turn content after (dynamic), we get ~75% token savings on cache hits.
28#[derive(Debug, Clone)]
29pub struct SystemPrompt {
30    /// Stable across turns — gets Anthropic prompt cache hits.
31    pub cacheable: String,
32    /// Changes per turn — always re-sent fresh.
33    pub dynamic: String,
34}
35
36impl SystemPrompt {
37    /// Combine both sections into a single prompt string (backward compatible).
38    pub fn combined(&self) -> String {
39        if self.dynamic.is_empty() {
40            self.cacheable.clone()
41        } else {
42            format!("{}\n\n---\n\n{}", self.cacheable, self.dynamic)
43        }
44    }
45}
46
47/// Identity information for prompt injection.
48///
49/// When provided to [`build_system_prompt()`], an identity block is included
50/// in the cacheable (stable) section of the system prompt.
51#[derive(Debug, Clone)]
52pub struct PromptIdentity {
53    /// Tier label (e.g. "pro", "free", "anonymous").
54    pub tier: String,
55    /// Subject identifier (e.g. "user@example.com"). `None` for anonymous.
56    pub subject: Option<String>,
57}
58
59/// Build an identity/persona block for the system prompt.
60///
61/// Matches the daemon's `AgentIdentityProvider::persona_block()` pattern
62/// from `arcand/src/canonical.rs`.
63pub fn build_identity_section(identity: Option<&PromptIdentity>) -> String {
64    match identity {
65        Some(id) => {
66            let subject_line = id
67                .subject
68                .as_deref()
69                .map(|s| format!("\n**Subject**: {s}"))
70                .unwrap_or_default();
71            format!(
72                "## Identity\n\
73                 **Agent**: arcan shell\n\
74                 **Tier**: {}{}",
75                id.tier, subject_line
76            )
77        }
78        None => "## Identity\n\
79             **Agent**: arcan shell\n\
80             **Tier**: anonymous local agent"
81            .to_string(),
82    }
83}
84
85/// Build the complete system prompt from all available context sources.
86///
87/// Returns a [`SystemPrompt`] with cacheable (stable) and dynamic (per-turn)
88/// sections. Use [`SystemPrompt::combined()`] for backward-compatible single string.
89pub fn build_system_prompt(
90    workspace: &Path,
91    provider_name: &str,
92    model_name: &str,
93    memory_dir: &Path,
94    workspace_context: Option<&str>,
95    skill_catalog: Option<&str>,
96    claude_md_content: Option<&str>,
97) -> SystemPrompt {
98    build_system_prompt_with_identity(
99        workspace,
100        provider_name,
101        model_name,
102        memory_dir,
103        workspace_context,
104        skill_catalog,
105        claude_md_content,
106        None,
107    )
108}
109
110/// Build the complete system prompt with optional identity injection.
111///
112/// Same as [`build_system_prompt()`] but accepts a [`PromptIdentity`] for
113/// persona injection into the cacheable section.
114#[allow(clippy::too_many_arguments)]
115pub fn build_system_prompt_with_identity(
116    workspace: &Path,
117    provider_name: &str,
118    model_name: &str,
119    memory_dir: &Path,
120    workspace_context: Option<&str>,
121    skill_catalog: Option<&str>,
122    claude_md_content: Option<&str>,
123    identity: Option<&PromptIdentity>,
124) -> SystemPrompt {
125    // --- CACHEABLE (stable across turns) ---
126    let mut cacheable_sections = Vec::new();
127
128    // 1. Role definition
129    cacheable_sections.push(build_role_section());
130
131    // 1b. Identity/persona block (BRO-367)
132    cacheable_sections.push(build_identity_section(identity));
133
134    // 2. Environment info
135    cacheable_sections.push(build_environment_section(
136        workspace,
137        provider_name,
138        model_name,
139    ));
140
141    // 3. CLAUDE.md / project instructions
142    if let Some(instructions) = claude_md_content
143        && !instructions.is_empty()
144    {
145        cacheable_sections.push(format!("# Project Instructions\n\n{instructions}"));
146    }
147
148    // 4. Guidelines
149    cacheable_sections.push(build_guidelines_section());
150
151    let cacheable = cacheable_sections.join("\n\n---\n\n");
152
153    // --- DYNAMIC (changes per turn) ---
154    let mut dynamic_sections = Vec::new();
155
156    // 5. Git context
157    if let Some(git) = build_git_section(workspace) {
158        dynamic_sections.push(git);
159    }
160
161    // 6. Memory context (MEMORY.md index)
162    if let Some(memory) = build_memory_section(memory_dir) {
163        dynamic_sections.push(memory);
164    }
165
166    // 7. Workspace context
167    if let Some(context) = workspace_context
168        && !context.is_empty()
169    {
170        dynamic_sections.push(format!("# Workspace Context\n\n{context}"));
171    }
172
173    // 8. Skills catalog
174    if let Some(catalog) = skill_catalog
175        && !catalog.is_empty()
176    {
177        dynamic_sections.push(format!("# Available Skills\n\n{catalog}"));
178    }
179
180    let dynamic = if dynamic_sections.is_empty() {
181        String::new()
182    } else {
183        dynamic_sections.join("\n\n---\n\n")
184    };
185
186    SystemPrompt { cacheable, dynamic }
187}
188
189/// The role identity block — defines what the agent is and how it should behave.
190pub fn build_role_section() -> String {
191    "# System\n\n\
192     You are an AI coding assistant powered by Arcan, the Life Agent OS runtime. \
193     You help users with software engineering tasks by reading files, editing code, \
194     running commands, and searching codebases. Be concise and direct. \
195     Read files before editing them. Use tools to explore rather than guessing. \
196     Follow existing code style and conventions."
197        .to_string()
198}
199
200/// Platform, runtime, and temporal context.
201pub fn build_environment_section(workspace: &Path, provider: &str, model: &str) -> String {
202    let cwd = workspace.display();
203    let platform = std::env::consts::OS;
204    let arch = std::env::consts::ARCH;
205    let date = chrono::Local::now().format("%Y-%m-%d");
206    let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into());
207
208    format!(
209        "# Environment\n\n\
210         - Working directory: {cwd}\n\
211         - Platform: {platform} ({arch})\n\
212         - Shell: {shell}\n\
213         - Date: {date}\n\
214         - Provider: {provider}\n\
215         - Model: {model}"
216    )
217}
218
219/// Git branch, working-tree status, and recent commits.
220///
221/// Returns `None` if the workspace is not inside a git repository.
222pub fn build_git_section(workspace: &Path) -> Option<String> {
223    let branch = std::process::Command::new("git")
224        .args(["rev-parse", "--abbrev-ref", "HEAD"])
225        .current_dir(workspace)
226        .output()
227        .ok()?;
228    if !branch.status.success() {
229        return None;
230    }
231    let branch_name = String::from_utf8_lossy(&branch.stdout).trim().to_string();
232
233    let status = std::process::Command::new("git")
234        .args(["status", "--short"])
235        .current_dir(workspace)
236        .output()
237        .ok()?;
238    let status_text = String::from_utf8_lossy(&status.stdout).trim().to_string();
239    let status_display = if status_text.is_empty() {
240        "Clean".to_string()
241    } else if status_text.len() > 500 {
242        format!("{}...(truncated)", &status_text[..500])
243    } else {
244        status_text
245    };
246
247    let log = std::process::Command::new("git")
248        .args(["log", "--oneline", "-5"])
249        .current_dir(workspace)
250        .output()
251        .ok()?;
252    let log_text = String::from_utf8_lossy(&log.stdout).trim().to_string();
253
254    Some(format!(
255        "# Git Context\n\n\
256         - Branch: {branch_name}\n\
257         - Status:\n```\n{status_display}\n```\n\
258         - Recent commits:\n```\n{log_text}\n```"
259    ))
260}
261
262/// Load project instructions from the workspace hierarchy.
263///
264/// Searches for instructions in multiple locations (all optional, concatenated):
265///
266/// **Base rules** (project-level, not tied to any specific agent framework):
267/// 1. `<workspace>/CLAUDE.md` — Claude Code conventions
268/// 2. `<workspace>/AGENTS.md` — Agent operational rules and boundaries
269/// 3. `<workspace>/.claude/CLAUDE.md` — Additional Claude-specific instructions
270/// 4. `<workspace>/.claude/rules/*.md` — Granular rule files (sorted)
271///
272/// **Life framework context** (if running inside a Life Agent OS workspace):
273/// 5. `<workspace>/../CLAUDE.md` — Parent workspace instructions (e.g., `core/life/CLAUDE.md`)
274/// 6. `<workspace>/docs/STATUS.md` — Current implementation status
275/// 7. `<workspace>/docs/ARCHITECTURE.md` — System architecture
276/// 8. `<workspace>/docs/ROADMAP.md` — Development roadmap
277///
278/// **Control metalayer** (if present):
279/// 9. `<workspace>/.control/policy.yaml` — Enforceable policy constraints
280///
281/// Returns the concatenated content, or `None` if nothing was found.
282pub fn load_project_instructions(workspace: &Path) -> Option<String> {
283    let mut contents = Vec::new();
284
285    // --- Base rules ---
286
287    // CLAUDE.md (Claude Code conventions — widely adopted standard)
288    load_file_if_exists(workspace, "CLAUDE.md", &mut contents);
289
290    // AGENTS.md (agent operational rules — framework-agnostic)
291    load_file_if_exists(workspace, "AGENTS.md", &mut contents);
292
293    // .claude/CLAUDE.md (additional instructions)
294    load_file_if_exists(workspace, ".claude/CLAUDE.md", &mut contents);
295
296    // .claude/rules/*.md (granular rules, sorted for deterministic ordering)
297    load_rules_dir(workspace, ".claude/rules", &mut contents);
298
299    // --- Life framework context (if present) ---
300
301    // Parent CLAUDE.md (e.g., core/life/CLAUDE.md when running in core/life/arcan/)
302    if let Some(parent) = workspace.parent() {
303        let parent_claude = parent.join("CLAUDE.md");
304        if parent_claude.exists()
305            && parent_claude != workspace.join("CLAUDE.md")
306            && let Ok(content) = std::fs::read_to_string(&parent_claude)
307            && !content.trim().is_empty()
308        {
309            contents.push(format!(
310                "<!-- from {} -->\n{}",
311                parent_claude.display(),
312                content
313            ));
314        }
315    }
316
317    // docs/ context files — lightweight summaries that inform the agent
318    // about project status without requiring tool calls
319    for doc_file in &["docs/STATUS.md", "docs/ARCHITECTURE.md", "docs/ROADMAP.md"] {
320        let path = workspace.join(doc_file);
321        if path.exists()
322            && let Ok(content) = std::fs::read_to_string(&path)
323        {
324            let trimmed = content.trim();
325            if !trimmed.is_empty() {
326                // Truncate large docs to first 2000 chars to save tokens
327                let truncated = if trimmed.len() > 2000 {
328                    format!(
329                        "{}\n\n... (truncated, {} total chars — use read_file for full content)",
330                        &trimmed[..2000],
331                        trimmed.len()
332                    )
333                } else {
334                    trimmed.to_string()
335                };
336                contents.push(format!("<!-- from {doc_file} -->\n{truncated}"));
337            }
338        }
339    }
340
341    // --- Control metalayer ---
342
343    // .control/policy.yaml — machine-readable policy constraints
344    let policy_path = workspace.join(".control/policy.yaml");
345    if policy_path.exists()
346        && let Ok(content) = std::fs::read_to_string(&policy_path)
347        && !content.trim().is_empty()
348    {
349        contents.push(format!(
350            "<!-- Control policy (.control/policy.yaml) -->\n```yaml\n{}\n```",
351            content.trim()
352        ));
353    }
354
355    if contents.is_empty() {
356        None
357    } else {
358        Some(contents.join("\n\n"))
359    }
360}
361
362/// Backward-compatible alias for `load_project_instructions`.
363pub fn load_claude_md(workspace: &Path) -> Option<String> {
364    load_project_instructions(workspace)
365}
366
367/// Load a single file relative to workspace if it exists and is non-empty.
368fn load_file_if_exists(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
369    let path = workspace.join(relative);
370    if path.exists()
371        && let Ok(content) = std::fs::read_to_string(&path)
372        && !content.trim().is_empty()
373    {
374        contents.push(content);
375    }
376}
377
378/// Load all .md files from a rules directory, sorted alphabetically.
379fn load_rules_dir(workspace: &Path, relative: &str, contents: &mut Vec<String>) {
380    let rules_dir = workspace.join(relative);
381    if rules_dir.is_dir()
382        && let Ok(entries) = std::fs::read_dir(&rules_dir)
383    {
384        let mut rule_files: Vec<_> = entries
385            .flatten()
386            .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
387            .collect();
388        rule_files.sort_by_key(std::fs::DirEntry::path);
389
390        for entry in rule_files {
391            if let Ok(content) = std::fs::read_to_string(entry.path())
392                && !content.trim().is_empty()
393            {
394                contents.push(content);
395            }
396        }
397    }
398}
399
400/// Cross-session memory loaded from the MEMORY.md index.
401///
402/// Reads the generated `MEMORY.md` index from the memory directory and returns
403/// a formatted string for inclusion in the system prompt. Falls back to reading
404/// individual `.md` files if the index doesn't exist.
405///
406/// Returns `None` if the directory doesn't exist or contains no memory files.
407pub fn build_memory_section(memory_dir: &Path) -> Option<String> {
408    if !memory_dir.exists() {
409        return None;
410    }
411
412    // Prefer the generated MEMORY.md index
413    let index_path = memory_dir.join("MEMORY.md");
414    if index_path.exists()
415        && let Ok(content) = std::fs::read_to_string(&index_path)
416        && !content.trim().is_empty()
417    {
418        return Some(format!("# Agent Memory\n\n{content}"));
419    }
420
421    // Fallback: read individual files (backward compat)
422    let entries = std::fs::read_dir(memory_dir).ok()?;
423    let mut sections = Vec::new();
424
425    for entry in entries.flatten() {
426        let path = entry.path();
427        if path.extension().and_then(|e| e.to_str()) != Some("md") {
428            continue;
429        }
430        if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
431            continue;
432        }
433        let key = path
434            .file_stem()
435            .and_then(|s| s.to_str())
436            .unwrap_or("unknown")
437            .to_string();
438        if let Ok(content) = std::fs::read_to_string(&path)
439            && !content.trim().is_empty()
440        {
441            sections.push(format!("## {key}\n{content}"));
442        }
443    }
444
445    if sections.is_empty() {
446        return None;
447    }
448
449    sections.sort();
450    Some(format!(
451        "# Agent Memory (cross-session)\n\n{}",
452        sections.join("\n\n")
453    ))
454}
455
456// ---------------------------------------------------------------------------
457// MEMORY.md index generation (BRO-419)
458// ---------------------------------------------------------------------------
459
460/// Maximum number of lines allowed in the MEMORY.md index.
461const MEMORY_INDEX_MAX_LINES: usize = 200;
462
463/// Maximum number of bytes allowed in the MEMORY.md index.
464const MEMORY_INDEX_MAX_BYTES: usize = 25_000;
465
466/// Generate a `MEMORY.md` index from all `.md` files in the memory directory.
467///
468/// Groups entries by the `type` field in YAML frontmatter (defaults to "general").
469/// Each entry is a markdown link with a description extracted from the first
470/// non-frontmatter, non-heading content line.
471///
472/// The output is capped at 200 lines / 25KB.
473pub fn generate_memory_index(memory_dir: &Path) -> String {
474    let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
475
476    let Ok(entries) = std::fs::read_dir(memory_dir) else {
477        return String::from("# Memory Index\n");
478    };
479
480    for entry in entries.flatten() {
481        let path = entry.path();
482        if path.extension().and_then(|e| e.to_str()) != Some("md") {
483            continue;
484        }
485        if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
486            continue;
487        }
488
489        let key = path
490            .file_stem()
491            .and_then(|s| s.to_str())
492            .unwrap_or("unknown")
493            .to_string();
494        let content = std::fs::read_to_string(&path).unwrap_or_default();
495
496        let mem_type = extract_frontmatter_type(&content).unwrap_or_else(|| "general".to_string());
497
498        let description = extract_first_content_line(&content);
499
500        sections
501            .entry(mem_type)
502            .or_default()
503            .push(format!("- [{}]({}.md) — {}", key, key, description));
504    }
505
506    let mut index = String::from("# Memory Index\n\n");
507    for (section, entries) in &sections {
508        index.push_str(&format!("## {}\n", capitalize(section)));
509        for entry in entries {
510            index.push_str(entry);
511            index.push('\n');
512        }
513        index.push('\n');
514    }
515
516    // Cap at 200 lines
517    let lines: Vec<&str> = index.lines().collect();
518    if lines.len() > MEMORY_INDEX_MAX_LINES {
519        index = lines[..MEMORY_INDEX_MAX_LINES].join("\n");
520        index.push_str("\n\n... (truncated, showing first 200 entries)\n");
521    }
522
523    // Cap at 25KB
524    if index.len() > MEMORY_INDEX_MAX_BYTES {
525        index.truncate(MEMORY_INDEX_MAX_BYTES);
526        index.push_str("\n\n... (truncated at 25KB)\n");
527    }
528
529    index
530}
531
532/// Write the generated MEMORY.md index to disk.
533///
534/// Creates the memory directory if it doesn't exist.
535pub fn write_memory_index(memory_dir: &Path) {
536    let _ = std::fs::create_dir_all(memory_dir);
537    let index = generate_memory_index(memory_dir);
538    let index_path = memory_dir.join("MEMORY.md");
539    let _ = std::fs::write(&index_path, &index);
540}
541
542/// Extract the `type` value from YAML frontmatter (between `---` markers).
543///
544/// Returns `None` if no frontmatter or no `type:` field is found.
545fn extract_frontmatter_type(content: &str) -> Option<String> {
546    if !content.starts_with("---") {
547        return None;
548    }
549    let end = content[3..].find("---")?;
550    let frontmatter = &content[3..3 + end];
551    for line in frontmatter.lines() {
552        let trimmed = line.trim();
553        if let Some(value) = trimmed.strip_prefix("type:") {
554            return Some(value.trim().to_string());
555        }
556    }
557    None
558}
559
560/// Extract the first non-empty, non-heading content line after any frontmatter.
561///
562/// Skips YAML frontmatter (between `---` markers) and markdown headings.
563/// Truncates to 120 characters.
564fn extract_first_content_line(content: &str) -> String {
565    let body = if let Some(after_prefix) = content.strip_prefix("---") {
566        after_prefix
567            .find("---")
568            .map(|i| &after_prefix[i + 3..])
569            .unwrap_or(content)
570    } else {
571        content
572    };
573    body.lines()
574        .map(str::trim)
575        .find(|l| !l.is_empty() && !l.starts_with('#'))
576        .unwrap_or("(no description)")
577        .chars()
578        .take(120)
579        .collect()
580}
581
582/// Capitalize the first character of a string.
583fn capitalize(s: &str) -> String {
584    let mut c = s.chars();
585    match c.next() {
586        Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
587        None => String::new(),
588    }
589}
590
591/// Build a minimal system prompt for small-context-window models.
592///
593/// Omits project instructions, memory, git context, and skills to stay
594/// under ~300 tokens. Designed for models with ≤4K context windows
595/// (e.g. Apple's on-device model via apfel).
596///
597/// Tool definitions are described as plain text (not OpenAI function schemas)
598/// so the model knows what capabilities exist without attempting function calls.
599pub fn build_bare_prompt(workspace: &Path, provider: &str, model: &str) -> String {
600    let cwd = workspace.display();
601    let platform = std::env::consts::OS;
602    let date = chrono::Local::now().format("%Y-%m-%d");
603
604    format!(
605        "You are an AI coding assistant running on {platform}. \
606         Help with software engineering tasks and answer questions. \
607         Be concise and direct.\n\n\
608         Workspace: {cwd} | Date: {date} | Provider: {provider} | Model: {model}\n\n\
609         You have these capabilities (available as tools when needed):\n\
610         - read_file: Read file contents from the workspace\n\
611         - write_file: Create or overwrite a file\n\
612         - edit_file: Make targeted edits to existing files\n\
613         - bash: Run shell commands\n\
614         - glob: Find files by pattern\n\
615         - grep: Search file contents with regex\n\n\
616         When answering questions directly, respond with plain text. \
617         Only suggest using tools when the user needs to interact with files or run commands."
618    )
619}
620
621/// Behavioral guidelines that bound how the agent operates.
622pub fn build_guidelines_section() -> String {
623    "# Guidelines\n\n\
624     - Read files before editing them\n\
625     - Use tools to explore the codebase rather than guessing\n\
626     - Be concise and direct in responses\n\
627     - Follow existing code style and conventions\n\
628     - Prefer editing existing files over creating new ones\n\
629     - Do not add features beyond what was asked"
630        .to_string()
631}
632
633/// Build a "Peer Activity" section from Spaces agent-logs messages (BRO-369).
634///
635/// Call this separately and append to the `SystemPrompt.dynamic` section
636/// when Spaces is connected and has recent peer messages.
637pub fn build_peer_context_section(messages: &[String]) -> Option<String> {
638    if messages.is_empty() {
639        return None;
640    }
641    let mut section = String::from("# Peer Activity\n\nRecent messages from other agents:\n\n");
642    for msg in messages.iter().take(10) {
643        section.push_str(&format!("- {msg}\n"));
644    }
645    Some(section)
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::fs;
652    use tempfile::TempDir;
653
654    #[test]
655    fn test_build_system_prompt_includes_all_sections() {
656        let tmp = TempDir::new().unwrap();
657        let workspace = tmp.path();
658        let memory_dir = workspace.join(".arcan/memory");
659        fs::create_dir_all(&memory_dir).unwrap();
660        fs::write(memory_dir.join("notes.md"), "Some notes here").unwrap();
661
662        let sp = build_system_prompt(
663            workspace,
664            "anthropic",
665            "claude-sonnet-4-5-20250929",
666            &memory_dir,
667            Some("- Peer session: explored workspace journal"),
668            Some("- skill_a: Does A\n- skill_b: Does B"),
669            Some("# My Project\n\nBuild fast."),
670        );
671        let prompt = sp.combined();
672
673        // All sections should be present
674        assert!(prompt.contains("# System"), "missing role section");
675        assert!(
676            prompt.contains("# Environment"),
677            "missing environment section"
678        );
679        assert!(
680            prompt.contains("# Project Instructions"),
681            "missing claude.md section"
682        );
683        assert!(prompt.contains("# Agent Memory"), "missing memory section");
684        assert!(
685            prompt.contains("# Workspace Context"),
686            "missing workspace context section"
687        );
688        assert!(
689            prompt.contains("# Available Skills"),
690            "missing skills section"
691        );
692        assert!(
693            prompt.contains("# Guidelines"),
694            "missing guidelines section"
695        );
696        // Section separators
697        assert!(prompt.contains("---"), "missing section separators");
698    }
699
700    #[test]
701    fn test_build_system_prompt_omits_empty_sections() {
702        let tmp = TempDir::new().unwrap();
703        let workspace = tmp.path();
704        let memory_dir = workspace.join(".arcan/memory");
705        // Don't create memory dir — should be omitted
706
707        let sp = build_system_prompt(
708            workspace,
709            "mock",
710            "mock-model",
711            &memory_dir,
712            None,
713            None,
714            None,
715        );
716        let prompt = sp.combined();
717
718        assert!(prompt.contains("# System"));
719        assert!(prompt.contains("# Environment"));
720        assert!(prompt.contains("# Guidelines"));
721        assert!(
722            !prompt.contains("# Project Instructions"),
723            "should omit empty claude.md"
724        );
725        assert!(
726            !prompt.contains("# Agent Memory"),
727            "should omit missing memory"
728        );
729        assert!(
730            !prompt.contains("# Available Skills"),
731            "should omit empty skills"
732        );
733    }
734
735    #[test]
736    fn test_load_claude_md_from_workspace() {
737        let tmp = TempDir::new().unwrap();
738        let workspace = tmp.path();
739        fs::write(workspace.join("CLAUDE.md"), "# Instructions\nDo X.").unwrap();
740
741        let result = load_project_instructions(workspace);
742        assert!(result.is_some());
743        assert!(result.unwrap().contains("Do X."));
744    }
745
746    #[test]
747    fn test_load_agents_md() {
748        let tmp = TempDir::new().unwrap();
749        let workspace = tmp.path();
750        fs::write(workspace.join("AGENTS.md"), "# Agent Rules\nBe safe.").unwrap();
751
752        let result = load_project_instructions(workspace);
753        assert!(result.is_some());
754        assert!(result.unwrap().contains("Be safe."));
755    }
756
757    #[test]
758    fn test_load_both_claude_and_agents_md() {
759        let tmp = TempDir::new().unwrap();
760        let workspace = tmp.path();
761        fs::write(workspace.join("CLAUDE.md"), "Claude rules.").unwrap();
762        fs::write(workspace.join("AGENTS.md"), "Agent rules.").unwrap();
763
764        let result = load_project_instructions(workspace).unwrap();
765        assert!(result.contains("Claude rules."));
766        assert!(result.contains("Agent rules."));
767    }
768
769    #[test]
770    fn test_load_rules_dir() {
771        let tmp = TempDir::new().unwrap();
772        let workspace = tmp.path();
773        let rules_dir = workspace.join(".claude/rules");
774        fs::create_dir_all(&rules_dir).unwrap();
775        fs::write(rules_dir.join("code-style.md"), "Use snake_case.").unwrap();
776        fs::write(rules_dir.join("testing.md"), "All code needs tests.").unwrap();
777
778        let result = load_project_instructions(workspace);
779        assert!(result.is_some());
780        let content = result.unwrap();
781        assert!(content.contains("Use snake_case."));
782        assert!(content.contains("All code needs tests."));
783    }
784
785    #[test]
786    fn test_load_docs_context() {
787        let tmp = TempDir::new().unwrap();
788        let workspace = tmp.path();
789        let docs_dir = workspace.join("docs");
790        fs::create_dir_all(&docs_dir).unwrap();
791        fs::write(docs_dir.join("STATUS.md"), "# Status\n100% tests passing").unwrap();
792        fs::write(docs_dir.join("ARCHITECTURE.md"), "# Arch\nEvent-sourced.").unwrap();
793
794        let result = load_project_instructions(workspace).unwrap();
795        assert!(result.contains("100% tests passing"));
796        assert!(result.contains("Event-sourced."));
797    }
798
799    #[test]
800    fn test_load_control_policy() {
801        let tmp = TempDir::new().unwrap();
802        let workspace = tmp.path();
803        let control_dir = workspace.join(".control");
804        fs::create_dir_all(&control_dir).unwrap();
805        fs::write(
806            control_dir.join("policy.yaml"),
807            "gates:\n  - name: G1\n    blocking: true",
808        )
809        .unwrap();
810
811        let result = load_project_instructions(workspace).unwrap();
812        assert!(result.contains("gates:"));
813        assert!(result.contains("blocking: true"));
814    }
815
816    #[test]
817    fn test_load_empty_workspace_returns_none() {
818        let tmp = TempDir::new().unwrap();
819        let result = load_project_instructions(tmp.path());
820        assert!(result.is_none());
821    }
822
823    #[test]
824    fn test_git_section_in_repo() {
825        // Run in the actual workspace which is a git repo
826        let workspace = std::env::current_dir().unwrap();
827        let result = build_git_section(&workspace);
828        // This test is running inside a git repo (the arcan worktree),
829        // so we should get a result.
830        if let Some(git_section) = result {
831            assert!(git_section.contains("# Git Context"));
832            assert!(git_section.contains("Branch:"));
833        }
834        // If git is not available, the test passes trivially.
835    }
836
837    #[test]
838    fn test_git_section_non_repo() {
839        let tmp = TempDir::new().unwrap();
840        let result = build_git_section(tmp.path());
841        assert!(result.is_none(), "non-repo dir should return None");
842    }
843
844    #[test]
845    fn test_environment_section() {
846        let tmp = TempDir::new().unwrap();
847        let section = build_environment_section(tmp.path(), "anthropic", "claude-sonnet");
848
849        assert!(section.contains("# Environment"));
850        assert!(section.contains("Platform:"));
851        assert!(section.contains("Provider: anthropic"));
852        assert!(section.contains("Model: claude-sonnet"));
853        assert!(section.contains("Date:"));
854    }
855
856    #[test]
857    fn test_memory_section() {
858        let tmp = TempDir::new().unwrap();
859        let memory_dir = tmp.path().join("memory");
860        fs::create_dir_all(&memory_dir).unwrap();
861        fs::write(memory_dir.join("notes.md"), "Remember this.").unwrap();
862
863        let result = build_memory_section(&memory_dir);
864        assert!(result.is_some());
865        let content = result.unwrap();
866        assert!(content.contains("# Agent Memory"));
867        assert!(content.contains("Remember this."));
868    }
869
870    #[test]
871    fn test_memory_section_empty_dir() {
872        let tmp = TempDir::new().unwrap();
873        let memory_dir = tmp.path().join("memory");
874        fs::create_dir_all(&memory_dir).unwrap();
875
876        let result = build_memory_section(&memory_dir);
877        assert!(result.is_none(), "empty memory dir should return None");
878    }
879
880    #[test]
881    fn test_memory_section_missing_dir() {
882        let tmp = TempDir::new().unwrap();
883        let memory_dir = tmp.path().join("nonexistent");
884
885        let result = build_memory_section(&memory_dir);
886        assert!(result.is_none(), "missing memory dir should return None");
887    }
888
889    #[test]
890    fn test_role_section_content() {
891        let role = build_role_section();
892        assert!(role.contains("Arcan"));
893        assert!(role.contains("Life Agent OS"));
894    }
895
896    #[test]
897    fn test_guidelines_section_content() {
898        let guidelines = build_guidelines_section();
899        assert!(guidelines.contains("Read files before editing"));
900        assert!(guidelines.contains("Do not add features beyond what was asked"));
901    }
902
903    #[test]
904    fn test_load_combines_all_sources() {
905        let tmp = TempDir::new().unwrap();
906        let workspace = tmp.path();
907
908        // Create all sources
909        fs::write(workspace.join("CLAUDE.md"), "Root instructions.").unwrap();
910        fs::write(workspace.join("AGENTS.md"), "Agent boundaries.").unwrap();
911        let dot_claude = workspace.join(".claude");
912        fs::create_dir_all(&dot_claude).unwrap();
913        fs::write(dot_claude.join("CLAUDE.md"), "Dot-claude instructions.").unwrap();
914        let rules_dir = dot_claude.join("rules");
915        fs::create_dir_all(&rules_dir).unwrap();
916        fs::write(rules_dir.join("style.md"), "Style rules.").unwrap();
917        let docs = workspace.join("docs");
918        fs::create_dir_all(&docs).unwrap();
919        fs::write(docs.join("STATUS.md"), "All green.").unwrap();
920        let control = workspace.join(".control");
921        fs::create_dir_all(&control).unwrap();
922        fs::write(control.join("policy.yaml"), "version: 1").unwrap();
923
924        let result = load_project_instructions(workspace).unwrap();
925        assert!(result.contains("Root instructions."));
926        assert!(result.contains("Agent boundaries."));
927        assert!(result.contains("Dot-claude instructions."));
928        assert!(result.contains("Style rules."));
929        assert!(result.contains("All green."));
930        assert!(result.contains("version: 1"));
931    }
932
933    #[test]
934    fn test_backward_compat_load_claude_md() {
935        let tmp = TempDir::new().unwrap();
936        let workspace = tmp.path();
937        fs::write(workspace.join("CLAUDE.md"), "Legacy call.").unwrap();
938        let result = load_claude_md(workspace);
939        assert!(result.is_some());
940        assert!(result.unwrap().contains("Legacy call."));
941    }
942
943    /// Verify the prompt module is accessible from arcan-core's public API.
944    #[test]
945    fn test_prompt_available_from_core() {
946        // Key public functions that both shell and daemon need.
947        let _ = build_system_prompt
948            as fn(
949                &Path,
950                &str,
951                &str,
952                &Path,
953                Option<&str>,
954                Option<&str>,
955                Option<&str>,
956            ) -> SystemPrompt;
957        let _ = build_system_prompt_with_identity
958            as fn(
959                &Path,
960                &str,
961                &str,
962                &Path,
963                Option<&str>,
964                Option<&str>,
965                Option<&str>,
966                Option<&PromptIdentity>,
967            ) -> SystemPrompt;
968        let _ = build_identity_section as fn(Option<&PromptIdentity>) -> String;
969        let _ = build_git_section as fn(&Path) -> Option<String>;
970        let _ = load_project_instructions as fn(&Path) -> Option<String>;
971        let _ = build_environment_section as fn(&Path, &str, &str) -> String;
972        let _ = build_memory_section as fn(&Path) -> Option<String>;
973        let _ = build_role_section as fn() -> String;
974        let _ = build_guidelines_section as fn() -> String;
975        let _ = build_bare_prompt as fn(&Path, &str, &str) -> String;
976        let _ = generate_memory_index as fn(&Path) -> String;
977        let _ = write_memory_index as fn(&Path);
978    }
979
980    // ── BRO-367: Identity section tests ──
981
982    #[test]
983    fn test_identity_section_with_full_identity() {
984        let id = PromptIdentity {
985            tier: "pro".to_string(),
986            subject: Some("user@example.com".to_string()),
987        };
988        let section = build_identity_section(Some(&id));
989        assert!(section.contains("## Identity"));
990        assert!(section.contains("**Agent**: arcan shell"));
991        assert!(section.contains("**Tier**: pro"));
992        assert!(section.contains("**Subject**: user@example.com"));
993    }
994
995    #[test]
996    fn test_identity_section_without_subject() {
997        let id = PromptIdentity {
998            tier: "free".to_string(),
999            subject: None,
1000        };
1001        let section = build_identity_section(Some(&id));
1002        assert!(section.contains("**Tier**: free"));
1003        assert!(!section.contains("**Subject**"));
1004    }
1005
1006    #[test]
1007    fn test_identity_section_anonymous() {
1008        let section = build_identity_section(None);
1009        assert!(section.contains("## Identity"));
1010        assert!(section.contains("anonymous local agent"));
1011    }
1012
1013    #[test]
1014    fn test_system_prompt_with_identity_includes_block() {
1015        let tmp = TempDir::new().unwrap();
1016        let workspace = tmp.path();
1017        let memory_dir = workspace.join(".arcan/memory");
1018
1019        let id = PromptIdentity {
1020            tier: "enterprise".to_string(),
1021            subject: Some("admin@corp.com".to_string()),
1022        };
1023        let sp = build_system_prompt_with_identity(
1024            workspace,
1025            "anthropic",
1026            "claude-sonnet",
1027            &memory_dir,
1028            None,
1029            None,
1030            None,
1031            Some(&id),
1032        );
1033        let combined = sp.combined();
1034        assert!(combined.contains("## Identity"), "missing identity section");
1035        assert!(
1036            combined.contains("**Tier**: enterprise"),
1037            "missing tier in identity"
1038        );
1039        assert!(
1040            combined.contains("**Subject**: admin@corp.com"),
1041            "missing subject in identity"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_system_prompt_without_identity_shows_anonymous() {
1047        let tmp = TempDir::new().unwrap();
1048        let workspace = tmp.path();
1049        let memory_dir = workspace.join(".arcan/memory");
1050
1051        let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
1052        let combined = sp.combined();
1053        assert!(
1054            combined.contains("anonymous local agent"),
1055            "should show anonymous when no identity"
1056        );
1057    }
1058
1059    // ── BRO-419: MEMORY.md index tests ──
1060
1061    #[test]
1062    fn test_generate_memory_index() {
1063        let tmp = TempDir::new().unwrap();
1064        let memory_dir = tmp.path().join("memory");
1065        fs::create_dir_all(&memory_dir).unwrap();
1066
1067        fs::write(
1068            memory_dir.join("project_notes.md"),
1069            "Key architecture decisions for the project.",
1070        )
1071        .unwrap();
1072        fs::write(
1073            memory_dir.join("user_prefs.md"),
1074            "---\ntype: user\n---\n# Preferences\nPrefers dark mode.",
1075        )
1076        .unwrap();
1077
1078        let index = generate_memory_index(&memory_dir);
1079
1080        assert!(index.contains("# Memory Index"), "missing header");
1081        assert!(
1082            index.contains("[project_notes]"),
1083            "missing project_notes entry"
1084        );
1085        assert!(index.contains("[user_prefs]"), "missing user_prefs entry");
1086        // user_prefs should be grouped under "User" section
1087        assert!(index.contains("## User"), "missing User section header");
1088        // project_notes has no frontmatter, defaults to "General"
1089        assert!(
1090            index.contains("## General"),
1091            "missing General section header"
1092        );
1093        // Description extraction
1094        assert!(
1095            index.contains("Key architecture decisions"),
1096            "missing description from project_notes"
1097        );
1098        assert!(
1099            index.contains("Prefers dark mode"),
1100            "missing description from user_prefs"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_memory_index_skips_memory_md() {
1106        let tmp = TempDir::new().unwrap();
1107        let memory_dir = tmp.path().join("memory");
1108        fs::create_dir_all(&memory_dir).unwrap();
1109
1110        fs::write(memory_dir.join("MEMORY.md"), "# Old index").unwrap();
1111        fs::write(memory_dir.join("real_note.md"), "A real note.").unwrap();
1112
1113        let index = generate_memory_index(&memory_dir);
1114        assert!(index.contains("[real_note]"));
1115        // MEMORY.md should not appear as an entry
1116        assert!(!index.contains("[MEMORY]"));
1117    }
1118
1119    #[test]
1120    fn test_memory_index_caps_at_200_lines() {
1121        let tmp = TempDir::new().unwrap();
1122        let memory_dir = tmp.path().join("memory");
1123        fs::create_dir_all(&memory_dir).unwrap();
1124
1125        // Create enough files to exceed 200 lines.
1126        // Each file adds 1 line. With header (2 lines), section header (1 line),
1127        // and trailing blank (1 line), we need >196 files to exceed 200 lines.
1128        for i in 0..250 {
1129            fs::write(
1130                memory_dir.join(format!("note_{i:03}.md")),
1131                format!("Content for note {i}."),
1132            )
1133            .unwrap();
1134        }
1135
1136        let index = generate_memory_index(&memory_dir);
1137        let line_count = index.lines().count();
1138        // Should be capped (200 lines + truncation message ~2 more lines)
1139        assert!(line_count <= 205, "expected <= 205 lines, got {line_count}");
1140        assert!(
1141            index.contains("truncated"),
1142            "should contain truncation notice"
1143        );
1144    }
1145
1146    #[test]
1147    fn test_memory_index_extracts_frontmatter_type() {
1148        let tmp = TempDir::new().unwrap();
1149        let memory_dir = tmp.path().join("memory");
1150        fs::create_dir_all(&memory_dir).unwrap();
1151
1152        fs::write(
1153            memory_dir.join("arch_notes.md"),
1154            "---\ntype: project\ntags: [arch]\n---\n# Architecture\nEvent-sourced design.",
1155        )
1156        .unwrap();
1157        fs::write(
1158            memory_dir.join("tax_info.md"),
1159            "---\ntype: user\n---\nColombian tax rules.",
1160        )
1161        .unwrap();
1162        fs::write(
1163            memory_dir.join("general_stuff.md"),
1164            "Just some general notes without frontmatter.",
1165        )
1166        .unwrap();
1167
1168        let index = generate_memory_index(&memory_dir);
1169
1170        assert!(index.contains("## Project"), "missing Project section");
1171        assert!(index.contains("## User"), "missing User section");
1172        assert!(index.contains("## General"), "missing General section");
1173    }
1174
1175    #[test]
1176    fn test_write_memory_index_creates_file() {
1177        let tmp = TempDir::new().unwrap();
1178        let memory_dir = tmp.path().join("memory");
1179        fs::create_dir_all(&memory_dir).unwrap();
1180        fs::write(memory_dir.join("test.md"), "Test content.").unwrap();
1181
1182        write_memory_index(&memory_dir);
1183
1184        let index_path = memory_dir.join("MEMORY.md");
1185        assert!(index_path.exists(), "MEMORY.md should be created");
1186        let content = fs::read_to_string(&index_path).unwrap();
1187        assert!(content.contains("# Memory Index"));
1188        assert!(content.contains("[test]"));
1189    }
1190
1191    #[test]
1192    fn test_memory_section_prefers_index() {
1193        let tmp = TempDir::new().unwrap();
1194        let memory_dir = tmp.path().join("memory");
1195        fs::create_dir_all(&memory_dir).unwrap();
1196
1197        fs::write(memory_dir.join("notes.md"), "Individual note.").unwrap();
1198        // Write a MEMORY.md index
1199        write_memory_index(&memory_dir);
1200
1201        let section = build_memory_section(&memory_dir).unwrap();
1202        // Should use the MEMORY.md index (contains "Memory Index" heading)
1203        assert!(
1204            section.contains("Memory Index"),
1205            "should prefer MEMORY.md index"
1206        );
1207    }
1208
1209    // ── BRO-420: Prompt cache boundary tests ──
1210
1211    #[test]
1212    fn test_system_prompt_struct_has_both_sections() {
1213        let tmp = TempDir::new().unwrap();
1214        let workspace = tmp.path();
1215        let memory_dir = workspace.join(".arcan/memory");
1216        fs::create_dir_all(&memory_dir).unwrap();
1217        fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
1218
1219        let sp = build_system_prompt(
1220            workspace,
1221            "anthropic",
1222            "claude-sonnet",
1223            &memory_dir,
1224            None,
1225            Some("- skill_a: Does A"),
1226            Some("Build fast."),
1227        );
1228
1229        assert!(!sp.cacheable.is_empty(), "cacheable should not be empty");
1230        assert!(!sp.dynamic.is_empty(), "dynamic should not be empty");
1231    }
1232
1233    #[test]
1234    fn test_cacheable_section_stable() {
1235        let tmp = TempDir::new().unwrap();
1236        let workspace = tmp.path();
1237        fs::write(workspace.join("CLAUDE.md"), "Project rules.").unwrap();
1238        let memory_dir = workspace.join(".arcan/memory");
1239
1240        let sp1 = build_system_prompt(
1241            workspace,
1242            "anthropic",
1243            "claude-sonnet",
1244            &memory_dir,
1245            None,
1246            None,
1247            Some("Project rules."),
1248        );
1249        let sp2 = build_system_prompt(
1250            workspace,
1251            "anthropic",
1252            "claude-sonnet",
1253            &memory_dir,
1254            None,
1255            None,
1256            Some("Project rules."),
1257        );
1258
1259        assert_eq!(
1260            sp1.cacheable, sp2.cacheable,
1261            "cacheable section should be identical for same inputs"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_dynamic_section_changes_with_memory() {
1267        let tmp = TempDir::new().unwrap();
1268        let workspace = tmp.path();
1269        let memory_dir = workspace.join(".arcan/memory");
1270        fs::create_dir_all(&memory_dir).unwrap();
1271
1272        // No memory files
1273        let sp1 = build_system_prompt(
1274            workspace,
1275            "anthropic",
1276            "claude-sonnet",
1277            &memory_dir,
1278            None,
1279            None,
1280            None,
1281        );
1282
1283        // Add a memory file
1284        fs::write(memory_dir.join("new_note.md"), "New insight.").unwrap();
1285
1286        let sp2 = build_system_prompt(
1287            workspace,
1288            "anthropic",
1289            "claude-sonnet",
1290            &memory_dir,
1291            None,
1292            None,
1293            None,
1294        );
1295
1296        assert_ne!(
1297            sp1.dynamic, sp2.dynamic,
1298            "dynamic section should change when memory files are added"
1299        );
1300        // Cacheable should remain the same
1301        assert_eq!(
1302            sp1.cacheable, sp2.cacheable,
1303            "cacheable section should not change with memory"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_cacheable_contains_role_env_guidelines() {
1309        let tmp = TempDir::new().unwrap();
1310        let workspace = tmp.path();
1311        let memory_dir = workspace.join("memory");
1312
1313        let sp = build_system_prompt(
1314            workspace,
1315            "anthropic",
1316            "claude-sonnet",
1317            &memory_dir,
1318            None,
1319            None,
1320            None,
1321        );
1322
1323        assert!(
1324            sp.cacheable.contains("# System"),
1325            "cacheable should contain role"
1326        );
1327        assert!(
1328            sp.cacheable.contains("# Environment"),
1329            "cacheable should contain environment"
1330        );
1331        assert!(
1332            sp.cacheable.contains("# Guidelines"),
1333            "cacheable should contain guidelines"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_dynamic_contains_git_memory_skills() {
1339        let tmp = TempDir::new().unwrap();
1340        let workspace = tmp.path();
1341        let memory_dir = workspace.join(".arcan/memory");
1342        fs::create_dir_all(&memory_dir).unwrap();
1343        fs::write(memory_dir.join("notes.md"), "Remember.").unwrap();
1344
1345        let sp = build_system_prompt(
1346            workspace,
1347            "anthropic",
1348            "claude-sonnet",
1349            &memory_dir,
1350            Some("- Session abc turn 3: Added memory_similar"),
1351            Some("- skill_a"),
1352            None,
1353        );
1354
1355        assert!(
1356            sp.dynamic.contains("# Agent Memory"),
1357            "dynamic should contain memory"
1358        );
1359        assert!(
1360            sp.dynamic.contains("# Workspace Context"),
1361            "dynamic should contain workspace context"
1362        );
1363        assert!(
1364            sp.dynamic.contains("# Available Skills"),
1365            "dynamic should contain skills"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_backward_compat_combined() {
1371        let tmp = TempDir::new().unwrap();
1372        let workspace = tmp.path();
1373        let memory_dir = workspace.join(".arcan/memory");
1374        fs::create_dir_all(&memory_dir).unwrap();
1375        fs::write(memory_dir.join("notes.md"), "Some notes.").unwrap();
1376
1377        let sp = build_system_prompt(
1378            workspace,
1379            "anthropic",
1380            "claude-sonnet",
1381            &memory_dir,
1382            None,
1383            Some("- skill_a"),
1384            Some("Project instructions."),
1385        );
1386        let combined = sp.combined();
1387
1388        // Combined should contain content from both sections
1389        assert!(combined.contains("# System"));
1390        assert!(combined.contains("# Guidelines"));
1391        assert!(combined.contains("# Agent Memory"));
1392        assert!(combined.contains("# Available Skills"));
1393    }
1394
1395    #[test]
1396    fn test_combined_empty_dynamic() {
1397        let tmp = TempDir::new().unwrap();
1398        let workspace = tmp.path();
1399        let memory_dir = workspace.join("nonexistent");
1400
1401        let sp = build_system_prompt(workspace, "mock", "mock", &memory_dir, None, None, None);
1402
1403        // With no git, no memory, no skills — dynamic should be empty
1404        let combined = sp.combined();
1405        // Combined should just be the cacheable section (no trailing ---)
1406        assert_eq!(combined, sp.cacheable);
1407    }
1408
1409    // ── Helper function tests ──
1410
1411    #[test]
1412    fn test_extract_frontmatter_type_valid() {
1413        let content = "---\ntype: project\ntags: [a, b]\n---\n# Title\nBody.";
1414        assert_eq!(
1415            extract_frontmatter_type(content),
1416            Some("project".to_string())
1417        );
1418    }
1419
1420    #[test]
1421    fn test_extract_frontmatter_type_missing() {
1422        let content = "---\ntags: [a]\n---\nNo type field.";
1423        assert_eq!(extract_frontmatter_type(content), None);
1424    }
1425
1426    #[test]
1427    fn test_extract_frontmatter_type_no_frontmatter() {
1428        let content = "Just plain text.";
1429        assert_eq!(extract_frontmatter_type(content), None);
1430    }
1431
1432    #[test]
1433    fn test_extract_first_content_line_with_frontmatter() {
1434        let content = "---\ntype: user\n---\n# Heading\nFirst real line.";
1435        assert_eq!(extract_first_content_line(content), "First real line.");
1436    }
1437
1438    #[test]
1439    fn test_extract_first_content_line_no_frontmatter() {
1440        let content = "# Heading\nContent line.";
1441        assert_eq!(extract_first_content_line(content), "Content line.");
1442    }
1443
1444    #[test]
1445    fn test_extract_first_content_line_empty() {
1446        let content = "";
1447        assert_eq!(extract_first_content_line(content), "(no description)");
1448    }
1449
1450    #[test]
1451    fn test_capitalize() {
1452        assert_eq!(capitalize("general"), "General");
1453        assert_eq!(capitalize("user"), "User");
1454        assert_eq!(capitalize(""), "");
1455        assert_eq!(capitalize("ALREADY"), "ALREADY");
1456    }
1457
1458    #[test]
1459    fn test_bare_prompt_is_compact() {
1460        let tmp = TempDir::new().unwrap();
1461        let prompt = build_bare_prompt(tmp.path(), "apfel", "apple-foundationmodel");
1462
1463        // Must contain key info
1464        assert!(prompt.contains("AI coding assistant"), "missing role");
1465        assert!(prompt.contains("Date:"), "missing date");
1466        assert!(prompt.contains("Provider: apfel"), "missing provider");
1467        assert!(
1468            prompt.contains("Model: apple-foundationmodel"),
1469            "missing model"
1470        );
1471
1472        // Must contain tool descriptions as text
1473        assert!(prompt.contains("read_file"), "missing read_file tool");
1474        assert!(prompt.contains("bash"), "missing bash tool");
1475        assert!(prompt.contains("grep"), "missing grep tool");
1476
1477        // Must NOT contain heavy sections
1478        assert!(
1479            !prompt.contains("# Project Instructions"),
1480            "bare prompt should not have project instructions"
1481        );
1482        assert!(
1483            !prompt.contains("# Agent Memory"),
1484            "bare prompt should not have memory"
1485        );
1486        assert!(
1487            !prompt.contains("# Git Context"),
1488            "bare prompt should not have git context"
1489        );
1490
1491        // Should be compact — under ~300 tokens (~4 chars/token heuristic)
1492        assert!(
1493            prompt.len() < 1200,
1494            "bare prompt too long: {} chars (target <1200)",
1495            prompt.len()
1496        );
1497    }
1498}