Skip to main content

imp_core/
system_prompt.rs

1use std::fmt;
2
3use crate::config::AgentMode;
4use crate::context::estimate_tokens;
5use crate::guardrails::{self, GuardrailProfile};
6use crate::personality::{soul_identity_text, PersonalityBand, PersonalityProfile};
7use crate::resources::{AgentsMd, Skill, SoulDoc};
8use crate::roles::Role;
9use crate::tools::ToolRegistry;
10
11/// A project fact from mana-core.
12#[derive(Debug, Clone)]
13pub struct Fact {
14    pub text: String,
15    pub verified_ago: String,
16}
17
18/// Previous attempt info for task context.
19#[derive(Debug, Clone)]
20pub struct Attempt {
21    pub number: u32,
22    pub outcome: String,
23    pub summary: String,
24}
25
26/// Dependency info for task context.
27#[derive(Debug, Clone)]
28pub struct Dependency {
29    pub name: String,
30    pub status: String,
31    pub detail: String,
32}
33
34/// Task context for headless/task mode (Layer 5).
35#[derive(Debug, Clone)]
36pub struct TaskContext {
37    pub title: String,
38    pub description: String,
39    pub acceptance: Option<String>,
40    pub verify: Option<String>,
41    pub notes: Option<String>,
42    pub attempts: Vec<Attempt>,
43    pub dependencies: Vec<Dependency>,
44    pub decisions: Vec<String>,
45    pub context_paths: Vec<String>,
46    pub constraints: Vec<String>,
47}
48
49/// Result of system prompt assembly, including size tracking.
50#[derive(Debug)]
51pub struct AssembledPrompt {
52    pub text: String,
53    pub estimated_tokens: u32,
54}
55
56impl fmt::Display for AssembledPrompt {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.text)
59    }
60}
61
62/// All inputs needed to assemble a system prompt.
63pub struct AssembleParams<'a> {
64    pub tools: &'a ToolRegistry,
65    pub agents_md: &'a [AgentsMd],
66    pub skills: &'a [Skill],
67    pub facts: &'a [Fact],
68    pub project_memory_status: Option<&'a str>,
69    pub personality: Option<&'a PersonalityProfile>,
70    pub soul: Option<&'a SoulDoc>,
71    pub task: Option<&'a TaskContext>,
72    pub role: Option<&'a Role>,
73    pub mode: &'a AgentMode,
74    pub memory: Option<&'a str>,
75    pub user_profile: Option<&'a str>,
76    pub cwd: Option<&'a std::path::Path>,
77    /// Whether to include learning instructions in the system prompt.
78    pub learning_enabled: bool,
79    /// Resolved guardrail profile (None = guardrails disabled).
80    pub guardrail_profile: Option<GuardrailProfile>,
81}
82
83/// Assemble the system prompt from seven layers.
84///
85/// - Layer 1: Identity + tool descriptions (+ role instructions if any)
86/// - Layer 1.25: Execution policy
87/// - Layer 1.5: Environment context
88/// - Layer 2: Project context from AGENTS.md files
89/// - Layer 3: Skills index
90/// - Layer 4: Mana facts (skipped if empty)
91/// - Layer 4.25: Compact project memory status (skipped if empty)
92/// - Layer 5: Task context (only in headless/task mode)
93/// - Layer 6: Agent memory (if present)
94pub fn assemble(params: &AssembleParams<'_>) -> AssembledPrompt {
95    assemble_inner(params)
96}
97
98fn assemble_inner(p: &AssembleParams<'_>) -> AssembledPrompt {
99    let mut parts = Vec::new();
100
101    // Layer 1: Identity + tool descriptions
102    parts.push(identity_layer(
103        p.tools,
104        p.role,
105        p.mode,
106        p.learning_enabled,
107        p.personality,
108        p.soul,
109    ));
110
111    // Layer 1.25: Execution policy
112    parts.push(execution_policy_layer());
113
114    // Layer 1.5: Environment context
115    parts.push(environment_layer(p.cwd));
116
117    // Layer 2: Project context from AGENTS.md
118    if !p.agents_md.is_empty() {
119        parts.push(agents_md_layer(p.agents_md));
120    }
121
122    // Layer 3: Skills index
123    if !p.skills.is_empty() {
124        parts.push(skills_layer(p.skills, p.mode));
125    }
126
127    // Layer 4: Mana facts
128    if !p.facts.is_empty() {
129        parts.push(facts_layer(p.facts));
130    }
131
132    // Layer 4.25: Compact project memory status
133    if let Some(status) = p.project_memory_status {
134        if !status.is_empty() {
135            parts.push(project_memory_status_layer(status));
136        }
137    }
138
139    // Layer 4.5: Engineering guardrails (when enabled)
140    if let Some(profile) = p.guardrail_profile {
141        parts.push(guardrails::guardrails_layer(profile));
142    }
143
144    // Layer 5: Task context (headless mode only)
145    if let Some(task) = p.task {
146        parts.push(task_layer(task));
147        parts.push(headless_execution_layer(task));
148    }
149
150    // Layer 6: Agent memory
151    if let Some(mem) = p.memory {
152        if !mem.is_empty() {
153            parts.push(mem.to_string());
154        }
155    }
156    if let Some(user) = p.user_profile {
157        if !user.is_empty() {
158            parts.push(user.to_string());
159        }
160    }
161
162    let text = parts.join("\n\n");
163    let estimated_tokens = estimate_tokens(&text);
164
165    AssembledPrompt {
166        text,
167        estimated_tokens,
168    }
169}
170
171fn identity_layer(
172    tools: &ToolRegistry,
173    role: Option<&Role>,
174    mode: &AgentMode,
175    learning_enabled: bool,
176    personality: Option<&PersonalityProfile>,
177    soul: Option<&SoulDoc>,
178) -> String {
179    let mut s = String::new();
180    if let Some(soul) = soul {
181        s.push_str(&soul_identity_text(&soul.content));
182    } else if let Some(personality) = personality {
183        s.push_str(&personality.identity.render_sentence());
184    } else {
185        s.push_str("You are imp, a coding agent.");
186    }
187    s.push_str("\n\nAvailable tools:\n");
188
189    let defs = match role {
190        Some(r) if r.readonly => tools.readonly_definitions(),
191        _ => tools.definitions_for_mode(mode),
192    };
193
194    for def in &defs {
195        s.push_str(&format!("- {}: {}\n", def.name, def.description));
196    }
197
198    if let Some(soul) = soul {
199        s.push_str("\n\nSoul:\n");
200        s.push_str(&soul.content);
201        s.push('\n');
202    } else if let Some(personality) = personality {
203        let working_style = working_style_lines(&personality.sliders);
204        if !working_style.is_empty() {
205            s.push_str("\nWorking style:\n");
206            for line in working_style {
207                s.push_str("- ");
208                s.push_str(line);
209                s.push('\n');
210            }
211        }
212    }
213
214    s.push_str("\nTool usage guide:\n");
215    s.push_str("- Use `shell` for search, file discovery, directory listing, builds, tests, scripts, package managers, and other shell-native tasks. Prefer `scan` for structural code understanding before broad text search when symbol relationships or code blocks matter.\n");
216    if defs.iter().any(|def| def.name == "git") {
217        s.push_str("- Use `git` for local repo/worktree operations; use `shell` for uncovered git commands.\n");
218    }
219    if defs.iter().any(|def| def.name == "mana") {
220        s.push_str("- Prefer the native `mana` tool over `shell` for mana operations when an equivalent action exists; use `shell` only for mana-adjacent shell work with no native mana action.\n");
221    }
222    s.push_str("- Use `read` to inspect a specific file with stable line-oriented output.\n");
223    s.push_str("- Use `scan` for structural code understanding and for extracting code at file:line, file:start-end, or file#symbol. Prefer it over `shell`+search when you need symbols, call sites, or coherent code blocks.\n");
224    s.push_str("- Use `edit` and `write` for file changes.\n");
225    s.push_str("- Use specialized tools like `mana`, `ask`, `web`, `extend`, and `recall` when the task calls for them. Use `recall` when you need to search past conversations.\n");
226
227    s.push_str("\nMana doctrine:\n");
228    s.push_str("- Mana is imp's substrate for explicit work. Represent work in mana whenever structure, verification, retries, dependencies, or handoff would help. Any mana unit must be detailed enough for another agent to execute cold without guesswork, even if you end up doing the work yourself.\n");
229    s.push_str("- Treat each unit description as an execution prompt.\n");
230    s.push_str("- Include current state, concrete steps, file paths with intent, edge cases, and a targeted verify command.\n");
231    s.push_str("- Update units with new context after failures; do not retry unchanged.\n");
232    s.push_str("- Mana is the durable project context — session memory is ephemeral, personal memory is global preferences; project plans, architecture decisions, verified facts, and implementation structure belong in mana, not in session history or memory.md.\n");
233    s.push_str("- During planning and design conversations, proactively externalize durable structure as it appears. When the conversation settles on a goal, decomposition, architecture direction, dependency, blocker, or follow-up that should survive the turn, create or update mana during the conversation.\n");
234    s.push_str("- Map durable planning artifacts deliberately: use an epic, child job, note, or decision based on the shape of the information, and keep facts reserved for verifiable claims.\n");
235    s.push_str("- Do not ask permission merely to capture plan artifacts in mana. Do ask before making consequential scope, architecture, destructive, or execution commitments on the user's behalf.\n");
236    s.push_str("- If durable planning state changed this turn, make the between-turn mana update before the substantive reply. Summarize the delta only when it adds value beyond what the mana tool or UI already made visible.\n");
237    s.push_str("- A future wiki layer at `.mana/wiki/` will hold synthesized project knowledge; for now, use mana facts for verifiable claims and mana units/notes for everything else that should survive the session.\n");
238
239    // Append role instructions after identity layer
240    if let Some(role) = role {
241        if let Some(ref instructions) = role.instructions {
242            s.push('\n');
243            s.push_str(instructions);
244            s.push('\n');
245        }
246    }
247
248    // Append mode instructions if present
249    if let Some(instructions) = mode.instructions() {
250        s.push('\n');
251        s.push_str(instructions);
252        s.push('\n');
253    }
254
255    // Append learning instructions when enabled
256    if learning_enabled {
257        s.push('\n');
258        s.push_str(crate::learning::LEARNING_INSTRUCTIONS);
259        s.push('\n');
260    }
261
262    s
263}
264
265fn execution_policy_layer() -> String {
266    let mut s = String::from("Execution discipline:\n");
267    s.push_str("- Re-evaluate the user's intent on every new message before acting. Distinguish analysis, implementation, planning, review, and orchestration.\n");
268    s.push_str("- Never claim repository facts that you have not inspected in this session. Ground important claims in opened files, tool output, or explicit user-provided evidence.\n");
269    s.push_str("- If the user references a specific file, symbol, command, or error, inspect it before explaining or proposing changes.\n");
270    s.push_str("- For analysis-only requests, stay read-only unless the user asks for changes.\n");
271    s.push_str("- For implementation, prefer small, reversible changes and verify them with the smallest relevant checks before declaring success.\n");
272    s.push_str("- Do not treat failed commands, failed checks, or missing evidence as success. If blocked, report the exact blocker and the next useful action.\n");
273    s.push_str("- Before changing code, make sure you understand the relevant local context: inspect the files you will modify, nearby patterns, and any task-specific constraints already present in the repo.\n");
274    s.push_str("- Match the completion mode to the request. For diagnosis, provide findings, root cause, and concrete next-step options without making changes. For implementation, make the change, verify it, and summarize the evidence.\n");
275    s.push_str("- Treat task verify commands, failing tests, compiler errors, and tool errors as first-class evidence. Either resolve them or explain precisely why they remain blocked.\n");
276    s.push_str("- Prefer the smallest check that proves the work: targeted tests, focused builds, existence checks, or narrow validation commands before broad project-wide verification.\n");
277    s.push_str("- When uncertainty is consequential, ask one concise question instead of guessing. When uncertainty is local and low-risk, proceed and state the assumption briefly.\n");
278    s.push_str("- If prior attempts failed, do not retry the same plan unchanged. Use the new evidence to adapt the approach first.\n");
279    s.push_str("- Keep user-facing updates concise and evidence-oriented: what you inspected, what you changed, what you verified, and what remains.\n");
280    s.push_str("- When working from a mana unit, treat the unit as an execution contract: use its scope, verify gate, dependencies, and embedded context before broadening the search.\n");
281    s.push_str("- Prefer mana-native inspection and progress recording over ad hoc shell workflows when mana already models the work state.\n");
282    s.push_str("- For worker-style execution, keep planning lightweight and execution direct; use `mana update` to record discoveries and blockers instead of carrying them only in transient chat.\n");
283    s.push_str("- If a mana unit is underspecified, identify the exact missing context and either inspect the relevant artifacts or report the gap precisely rather than inventing missing requirements.\n");
284    s.push_str("- Treat chat as discussion and mana as the persistent external work and idea graph. When a conversation produces durable structure — plans, architecture, decomposition, migrations, blockers, dependencies, or follow-up work — represent that structure in mana during the conversation, not after it.\n");
285    s.push_str("- When durable planning state changes, update mana before the substantive reply when that durable state should become the source of truth for the next turn. Avoid restating mana-visible deltas verbosely when the tool output or UI already shows them.\n");
286    s.push_str("- Do not use mana only when work is large; use it whenever externalizing the plan is likely to improve execution quality, correctness, scope clarity, reviewability, or future continuation.\n");
287    s.push_str("- Prefer native mana actions over shell or direct file mutation for durable planning/work structure. Use append-style mana updates to keep the graph current during conversation.\n");
288    s.push_str("- If work spans more than one project or clearly belongs at the ecosystem layer, target root mana explicitly rather than relying on the nearest project scope.\n");
289    s
290}
291
292fn working_style_lines(sliders: &crate::personality::PersonalitySliders) -> Vec<&'static str> {
293    vec![
294        autonomy_line(sliders.autonomy),
295        verbosity_line(sliders.verbosity),
296        caution_line(sliders.caution),
297        warmth_line(sliders.warmth),
298        planning_depth_line(sliders.planning_depth),
299        "If you find yourself repeating the same action without progress, step back and try a different approach or ask the user for guidance.",
300    ]
301}
302
303pub(crate) fn autonomy_line(band: PersonalityBand) -> &'static str {
304    match band {
305        PersonalityBand::VeryLow => {
306            "Ask for confirmation before making consequential decisions or larger changes."
307        }
308        PersonalityBand::Low => {
309            "Prefer confirmation before acting when requirements or consequences are unclear."
310        }
311        PersonalityBand::Medium => {
312            "Act on clear next steps, but ask when requirements are ambiguous."
313        }
314        PersonalityBand::High => {
315            "Act independently by default and ask when blocked, uncertain, or facing a consequential decision. Keep working until the task is fully resolved before yielding."
316        }
317        PersonalityBand::VeryHigh => {
318            "Take initiative aggressively on clear work and only ask when blocked or genuinely uncertain. Keep working until the task is fully resolved before yielding."
319        }
320    }
321}
322
323pub(crate) fn verbosity_line(band: PersonalityBand) -> &'static str {
324    match band {
325        PersonalityBand::VeryLow => "Keep responses terse and strongly action-oriented.",
326        PersonalityBand::Low => "Keep responses brief and focused on progress.",
327        PersonalityBand::Medium => {
328            "Be concise by default, but explain important tradeoffs when useful."
329        }
330        PersonalityBand::High => {
331            "Explain reasoning and tradeoffs when they help the user follow the work."
332        }
333        PersonalityBand::VeryHigh => {
334            "Give fuller explanations of reasoning, tradeoffs, and next steps."
335        }
336    }
337}
338
339pub(crate) fn caution_line(band: PersonalityBand) -> &'static str {
340    match band {
341        PersonalityBand::VeryLow => {
342            "Move forward with reasonable assumptions when the path is clear."
343        }
344        PersonalityBand::Low => "Favor progress over caution when risks are limited and local.",
345        PersonalityBand::Medium => "Balance steady progress with avoiding avoidable risk.",
346        PersonalityBand::High => {
347            "Prefer small, reversible changes and verify assumptions before riskier actions."
348        }
349        PersonalityBand::VeryHigh => {
350            "Be highly conservative with risky changes: verify assumptions and avoid acting on weak evidence."
351        }
352    }
353}
354
355pub(crate) fn warmth_line(band: PersonalityBand) -> &'static str {
356    match band {
357        PersonalityBand::VeryLow => "Use a direct, neutral tone.",
358        PersonalityBand::Low => "Use a clear, matter-of-fact tone.",
359        PersonalityBand::Medium => "Use a clear and calm tone.",
360        PersonalityBand::High => "Use a warm, supportive tone without becoming verbose.",
361        PersonalityBand::VeryHigh => {
362            "Use a notably warm, encouraging tone while staying useful and grounded."
363        }
364    }
365}
366
367pub(crate) fn planning_depth_line(band: PersonalityBand) -> &'static str {
368    match band {
369        PersonalityBand::VeryLow => "Favor immediate execution on the most obvious next step.",
370        PersonalityBand::Low => "Plan lightly, then move quickly into execution.",
371        PersonalityBand::Medium => "Plan briefly, then execute.",
372        PersonalityBand::High => "Think through structure and likely consequences before acting.",
373        PersonalityBand::VeryHigh => {
374            "Be methodical: think through structure, dependencies, and consequences before acting."
375        }
376    }
377}
378
379fn environment_layer(cwd: Option<&std::path::Path>) -> String {
380    let home = std::env::var("HOME").unwrap_or_default();
381    let cwd_str = cwd.map(|p| p.display().to_string()).unwrap_or_else(|| {
382        std::env::current_dir()
383            .map(|p| p.display().to_string())
384            .unwrap_or_default()
385    });
386    let os = std::env::consts::OS;
387    let today = {
388        use std::time::{SystemTime, UNIX_EPOCH};
389        let secs = SystemTime::now()
390            .duration_since(UNIX_EPOCH)
391            .unwrap_or_default()
392            .as_secs();
393        let days = secs / 86400;
394        // Simple date calculation
395        let (y, m, d) = days_to_ymd(days);
396        format!("{y}-{m:02}-{d:02}")
397    };
398    format!("Environment: cwd={cwd_str}, os={os}, home={home}, date={today}")
399}
400
401/// Convert days since Unix epoch to (year, month, day).
402fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
403    // Civil days algorithm (Howard Hinnant)
404    days += 719_468;
405    let era = days / 146_097;
406    let doe = days - era * 146_097;
407    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
408    let y = yoe + era * 400;
409    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
410    let mp = (5 * doy + 2) / 153;
411    let d = doy - (153 * mp + 2) / 5 + 1;
412    let m = if mp < 10 { mp + 3 } else { mp - 9 };
413    let y = if m <= 2 { y + 1 } else { y };
414    (y, m, d)
415}
416
417fn agents_md_layer(agents: &[AgentsMd]) -> String {
418    let mut s = String::from("# Project Context\n\n");
419    for agent in agents {
420        s.push_str(&agent.content);
421        s.push('\n');
422    }
423    s
424}
425
426fn skills_layer(skills: &[Skill], mode: &AgentMode) -> String {
427    let mut s = String::from("Available skills (use read to load when relevant):\n");
428    if let Some(trigger) = mana_skill_trigger(skills, mode) {
429        s.push_str(&format!("- Trigger: {trigger}\n"));
430    }
431    for skill in skills {
432        s.push_str(&format!(
433            "- {}: {} [{}]\n",
434            skill.name,
435            skill.description,
436            skill.path.display()
437        ));
438    }
439    s
440}
441
442fn mana_skill_trigger(skills: &[Skill], mode: &AgentMode) -> Option<&'static str> {
443    let has_mana = skills.iter().any(|skill| skill.name == "mana");
444    let has_mana_basics = skills.iter().any(|skill| skill.name == "mana-basics");
445
446    match mode {
447        AgentMode::Full | AgentMode::Orchestrator | AgentMode::Planner => {
448            if has_mana {
449                Some("Load `mana` before writing or restructuring mana units for non-trivial work.")
450            } else {
451                None
452            }
453        }
454        AgentMode::Worker => {
455            if has_mana_basics {
456                Some(
457                    "Load `mana-basics` before using worker-safe mana actions beyond a quick status check.",
458                )
459            } else if has_mana {
460                Some(
461                    "Load `mana` before using worker-safe mana actions beyond a quick status check.",
462                )
463            } else {
464                None
465            }
466        }
467        AgentMode::Auditor => {
468            if has_mana_basics {
469                Some(
470                    "Load `mana-basics` before inspecting mana state across multiple units or runs.",
471                )
472            } else if has_mana {
473                Some("Load `mana` before inspecting mana state across multiple units or runs.")
474            } else {
475                None
476            }
477        }
478        AgentMode::Reviewer => None,
479    }
480}
481
482fn facts_layer(facts: &[Fact]) -> String {
483    let mut s = String::from("Project facts:\n");
484    for fact in facts {
485        s.push_str(&format!(
486            "- \"{}\" [verified {}]\n",
487            fact.text, fact.verified_ago
488        ));
489    }
490    s
491}
492
493fn project_memory_status_layer(status: &str) -> String {
494    status.to_string()
495}
496
497fn task_layer(task: &TaskContext) -> String {
498    let mut s = String::from("## Task\n");
499    s.push_str(&format!("Title: {}\n", task.title));
500    s.push_str(&format!("Description: {}\n", task.description));
501    if let Some(ref notes) = task.notes {
502        if !notes.trim().is_empty() {
503            s.push_str("Notes:\n");
504            s.push_str(notes);
505            s.push('\n');
506        }
507    }
508    if let Some(ref acceptance) = task.acceptance {
509        s.push_str("Acceptance:\n");
510        s.push_str(acceptance);
511        s.push('\n');
512    }
513    if let Some(ref verify) = task.verify {
514        s.push_str(&format!("Verify: {}\n", verify));
515        s.push_str("Treat the verify command as the primary completion check for this task.\n");
516    }
517
518    if !task.context_paths.is_empty() {
519        s.push_str("\n## Referenced files\n");
520        s.push_str("Use these declared file/path hints before broadening the search.\n");
521        for path in &task.context_paths {
522            s.push_str(&format!("- {}\n", path));
523        }
524    }
525
526    if !task.constraints.is_empty() {
527        s.push_str("\n## Constraints\n");
528        for constraint in &task.constraints {
529            s.push_str(&format!("- {}\n", constraint));
530        }
531    }
532
533    if !task.attempts.is_empty() {
534        s.push_str("\n## Previous attempts\n");
535        s.push_str("Do not repeat a failed approach unchanged; use the attempt history to adjust your plan.\n");
536        for attempt in &task.attempts {
537            s.push_str(&format!(
538                "Attempt {} ({}): {}\n",
539                attempt.number, attempt.outcome, attempt.summary
540            ));
541        }
542    }
543
544    if !task.dependencies.is_empty() {
545        s.push_str("\n## Dependencies\n");
546        s.push_str("Respect dependency state when sequencing work; unresolved dependencies are potential blockers.\n");
547        for dep in &task.dependencies {
548            s.push_str(&format!(
549                "- {} ({}): {}\n",
550                dep.name, dep.status, dep.detail
551            ));
552        }
553    }
554
555    if !task.decisions.is_empty() {
556        s.push_str("\n## Unresolved decisions\n");
557        s.push_str("These decisions block fully autonomous execution; resolve them or surface them clearly instead of guessing.\n");
558        for decision in &task.decisions {
559            s.push_str(&format!("- {}\n", decision));
560        }
561    }
562
563    s
564}
565
566fn headless_execution_layer(task: &TaskContext) -> String {
567    let mut s = String::from("## Headless execution contract\n");
568    s.push_str("- You are executing an explicit mana unit, not exploring broadly.\n");
569    s.push_str("- Treat the unit title, description, notes, acceptance criteria, and verify gate as the source of truth for scope and success.\n");
570    s.push_str("- Execute the assigned outcome before expanding into adjacent cleanup, refactors, or unrelated improvements.\n");
571    s.push_str("- Use explicit file references and prefilled context first before searching more broadly.\n");
572    s.push_str(
573        "- If the unit includes prior failed attempts, do not retry the same plan unchanged.\n",
574    );
575    s.push_str("- If dependency state or prerequisite decisions are unresolved, treat that as a blocker rather than improvising around it.\n");
576    s.push_str("- Keep progress updates concise and useful. Record meaningful discoveries, blockers, and revised plans with `mana update`.\n");
577    if task.verify.is_some() {
578        s.push_str("- If the verify command fails, either fix the issue or report the exact blocker. Do not claim completion anyway.\n");
579    }
580    s.push_str("- In batch-verify flows, treat your goal as leaving the unit ready for verify rather than assuming verify already passed.\n");
581    s.push_str(
582        "- Respect parent/child structure: finish this unit's outcome, not the whole feature.\n",
583    );
584    s
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use std::path::PathBuf;
591    use std::sync::Arc;
592
593    use crate::personality::{
594        PersonaFocus, PersonaRole, PersonalityBand, PersonalityIdentity, PersonalityProfile,
595        PersonalitySliders, VoiceWord, WorkStyleWord,
596    };
597    use crate::resources::SoulDoc;
598    use crate::tools::{Tool, ToolContext, ToolOutput};
599    use async_trait::async_trait;
600
601    // -- Test tool helpers --
602
603    struct FakeTool {
604        name: &'static str,
605        description: &'static str,
606        readonly: bool,
607    }
608
609    #[async_trait]
610    impl Tool for FakeTool {
611        fn name(&self) -> &str {
612            self.name
613        }
614        fn label(&self) -> &str {
615            self.name
616        }
617        fn description(&self) -> &str {
618            self.description
619        }
620        fn parameters(&self) -> serde_json::Value {
621            serde_json::json!({"type": "object"})
622        }
623        fn is_readonly(&self) -> bool {
624            self.readonly
625        }
626        async fn execute(
627            &self,
628            _: &str,
629            _: serde_json::Value,
630            _: ToolContext,
631        ) -> crate::Result<ToolOutput> {
632            Ok(ToolOutput::text("ok"))
633        }
634    }
635
636    fn make_registry() -> ToolRegistry {
637        let mut reg = ToolRegistry::new();
638        reg.register(Arc::new(FakeTool {
639            name: "read",
640            description: "Read file contents",
641            readonly: true,
642        }));
643        reg.register(Arc::new(FakeTool {
644            name: "write",
645            description: "Write content to a file",
646            readonly: false,
647        }));
648        reg.register(Arc::new(FakeTool {
649            name: "edit",
650            description: "Edit a file by replacing exact text",
651            readonly: false,
652        }));
653        reg.register(Arc::new(FakeTool {
654            name: "bash",
655            description: "Run shell commands",
656            readonly: false,
657        }));
658        reg
659    }
660
661    fn make_skill(name: &str, desc: &str, path: &str) -> Skill {
662        Skill {
663            name: name.into(),
664            description: desc.into(),
665            path: PathBuf::from(path),
666        }
667    }
668
669    fn make_agents_md(content: &str) -> AgentsMd {
670        AgentsMd {
671            path: PathBuf::from("/project/AGENTS.md"),
672            content: content.into(),
673        }
674    }
675
676    fn make_readonly_role() -> Role {
677        use crate::roles::ToolSet;
678        Role {
679            name: "reviewer".into(),
680            model: None,
681            thinking_level: None,
682            tool_set: ToolSet::All,
683            readonly: true,
684            instructions: Some("Review code carefully. Do not modify files.".into()),
685            max_turns: Some(10),
686        }
687    }
688
689    fn make_worker_role() -> Role {
690        use crate::roles::ToolSet;
691        Role {
692            name: "worker".into(),
693            model: None,
694            thinking_level: None,
695            tool_set: ToolSet::All,
696            readonly: false,
697            instructions: None,
698            max_turns: None,
699        }
700    }
701
702    fn make_personality() -> PersonalityProfile {
703        PersonalityProfile {
704            identity: PersonalityIdentity {
705                name: "Nova".into(),
706                work_style: WorkStyleWord::Careful,
707                voice: VoiceWord::Direct,
708                focus: PersonaFocus::Research,
709                role: PersonaRole::Assistant,
710            },
711            sliders: PersonalitySliders {
712                autonomy: PersonalityBand::Low,
713                verbosity: PersonalityBand::Medium,
714                caution: PersonalityBand::VeryHigh,
715                warmth: PersonalityBand::High,
716                planning_depth: PersonalityBand::VeryLow,
717            },
718        }
719    }
720
721    /// Test helper: shorthand for assemble() with no memory/user_profile.
722    fn test_assemble(
723        tools: &ToolRegistry,
724        agents_md: &[AgentsMd],
725        skills: &[Skill],
726        facts: &[Fact],
727        personality: Option<&PersonalityProfile>,
728        task: Option<&TaskContext>,
729        role: Option<&Role>,
730    ) -> AssembledPrompt {
731        assemble(&AssembleParams {
732            tools,
733            agents_md,
734            skills,
735            facts,
736            project_memory_status: None,
737            personality,
738            soul: None,
739            task,
740            role,
741            mode: &AgentMode::Full,
742            memory: None,
743            user_profile: None,
744            cwd: None,
745            learning_enabled: false,
746            guardrail_profile: None,
747        })
748    }
749
750    // -- Layer 1: Identity --
751
752    #[test]
753    fn system_prompt_includes_execution_policy_layer() {
754        let reg = make_registry();
755        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
756        assert!(result.text.contains("Execution discipline:"));
757        assert!(result
758            .text
759            .contains("Never claim repository facts that you have not inspected in this session."));
760        assert!(result.text.contains(
761            "For analysis-only requests, stay read-only unless the user asks for changes."
762        ));
763    }
764
765    #[test]
766    fn system_prompt_includes_conversation_time_mana_planning_doctrine() {
767        let reg = make_registry();
768        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
769        assert!(result.text.contains(
770            "During planning and design conversations, proactively externalize durable structure as it appears."
771        ));
772        assert!(result.text.contains("epic, child job, note, or decision"));
773        assert!(result
774            .text
775            .contains("Do not ask permission merely to capture plan artifacts in mana."));
776        assert!(result.text.contains(
777            "If durable planning state changed this turn, make the between-turn mana update before the substantive reply"
778        ));
779        assert!(result.text.contains(
780            "Summarize the delta only when it adds value beyond what the mana tool or UI already made visible."
781        ));
782        assert_eq!(
783            result
784                .text
785                .matches("between-turn mana update before the substantive reply")
786                .count(),
787            1,
788            "between-turn mana update guidance should appear once"
789        );
790        assert!(!result
791            .text
792            .contains("include a concise mana delta summary in the response"));
793    }
794
795    #[test]
796    fn system_prompt_identity_includes_all_tools() {
797        let reg = make_registry();
798        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
799        assert!(result.text.contains("You are imp, a coding agent."));
800        assert!(result.text.contains("- read: Read file contents"));
801        assert!(result.text.contains("- write: Write content to a file"));
802        assert!(result
803            .text
804            .contains("- edit: Edit a file by replacing exact text"));
805        assert!(result.text.contains("- bash: Run shell commands"));
806    }
807
808    #[test]
809    fn system_prompt_mana_guidance_prefers_native_tool_when_available() {
810        let mut reg = make_registry();
811        reg.register(Arc::new(FakeTool {
812            name: "mana",
813            description: "Manage mana work natively",
814            readonly: false,
815        }));
816
817        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
818        assert!(result.text.contains(
819            "Prefer the native `mana` tool over `bash` for mana operations when an equivalent action exists"
820        ));
821    }
822
823    #[test]
824    fn system_prompt_mana_guidance_omitted_without_mana_tool() {
825        let reg = make_registry();
826        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
827        assert!(!result.text.contains(
828            "Prefer the native `mana` tool over `bash` for mana operations when an equivalent action exists"
829        ));
830    }
831
832    #[test]
833    fn system_prompt_no_mana_guidance_or_delegation_in_prompt() {
834        // Mana guidance and delegation blocks have been moved to the mana skill.
835        // Verify they no longer appear regardless of tool availability.
836        let mut reg = make_registry();
837        reg.register(Arc::new(FakeTool {
838            name: "bash",
839            description: "Run shell commands",
840            readonly: false,
841        }));
842        reg.register(Arc::new(FakeTool {
843            name: "mana",
844            description: "Manage mana work",
845            readonly: false,
846        }));
847
848        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
849        assert!(
850            !result.text.contains("Mana guidance:"),
851            "mana guidance block should not appear in system prompt"
852        );
853        assert!(
854            !result.text.contains("## Mana delegation"),
855            "delegation guidance should not appear in system prompt"
856        );
857    }
858
859    #[test]
860    fn system_prompt_identity_only_when_all_layers_empty() {
861        let reg = make_registry();
862        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
863        // Should have identity but no section headers for missing layers
864        assert!(result.text.contains("You are imp"));
865        assert!(!result.text.contains("# Project Context"));
866        assert!(!result.text.contains("Available skills"));
867        assert!(!result.text.contains("Project facts"));
868        assert!(!result.text.contains("## Task"));
869    }
870
871    #[test]
872    fn system_prompt_uses_personality_identity_sentence() {
873        let reg = make_registry();
874        let personality = make_personality();
875        let result = test_assemble(&reg, &[], &[], &[], Some(&personality), None, None);
876        assert!(result
877            .text
878            .contains("You are Nova, a careful, direct, research assistant."));
879    }
880
881    #[test]
882    fn system_prompt_renders_personality_working_style_block() {
883        let reg = make_registry();
884        let personality = make_personality();
885        let result = test_assemble(&reg, &[], &[], &[], Some(&personality), None, None);
886        assert!(result.text.contains("Working style:"));
887        assert!(result.text.contains(
888            "Prefer confirmation before acting when requirements or consequences are unclear."
889        ));
890        assert!(result
891            .text
892            .contains("Be concise by default, but explain important tradeoffs when useful."));
893        assert!(result.text.contains(
894            "Be highly conservative with risky changes: verify assumptions and avoid acting on weak evidence."
895        ));
896        assert!(result
897            .text
898            .contains("Use a warm, supportive tone without becoming verbose."));
899        assert!(result
900            .text
901            .contains("Favor immediate execution on the most obvious next step."));
902    }
903
904    #[test]
905    fn system_prompt_prefers_soul_over_personality_profile() {
906        let reg = make_registry();
907        let personality = make_personality();
908        let soul = SoulDoc {
909            path: PathBuf::from("/tmp/soul.md"),
910            content: "# Soul\n\nYou are Sol, a tuned and reflective collaborator.\n\n## Tunables\n\n- Autonomy: Act independently by default.\n".into(),
911        };
912        let result = assemble(&AssembleParams {
913            tools: &reg,
914            agents_md: &[],
915            skills: &[],
916            facts: &[],
917            project_memory_status: None,
918            personality: Some(&personality),
919            soul: Some(&soul),
920            task: None,
921            role: None,
922            mode: &AgentMode::Full,
923            memory: None,
924            user_profile: None,
925            cwd: None,
926            learning_enabled: false,
927            guardrail_profile: None,
928        });
929        assert!(result
930            .text
931            .contains("You are Sol, a tuned and reflective collaborator."));
932        assert!(result.text.contains("Soul:"));
933        assert!(result.text.contains("## Tunables"));
934        assert!(!result.text.contains("Working style:"));
935    }
936
937    #[test]
938    fn system_prompt_without_soul_keeps_personality_working_style_block() {
939        let reg = make_registry();
940        let personality = make_personality();
941        let result = test_assemble(&reg, &[], &[], &[], Some(&personality), None, None);
942        assert!(result.text.contains("Working style:"));
943    }
944
945    // -- Layer 2: AGENTS.md --
946
947    #[test]
948    fn system_prompt_agents_md_included_verbatim() {
949        let reg = make_registry();
950        let agents = vec![make_agents_md("# Rules\n\nUse snake_case everywhere.")];
951        let result = test_assemble(&reg, &agents, &[], &[], None, None, None);
952        assert!(result.text.contains("# Project Context"));
953        assert!(result
954            .text
955            .contains("# Rules\n\nUse snake_case everywhere."));
956    }
957
958    #[test]
959    fn system_prompt_multiple_agents_md_concatenated() {
960        let reg = make_registry();
961        let agents = vec![
962            make_agents_md("Global rules here."),
963            make_agents_md("Project rules here."),
964        ];
965        let result = test_assemble(&reg, &agents, &[], &[], None, None, None);
966        assert!(result.text.contains("Global rules here."));
967        assert!(result.text.contains("Project rules here."));
968    }
969
970    #[test]
971    fn system_prompt_empty_agents_md_skipped() {
972        let reg = make_registry();
973        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
974        assert!(!result.text.contains("# Project Context"));
975    }
976
977    // -- Layer 3: Skills --
978
979    #[test]
980    fn system_prompt_skills_listed_with_paths() {
981        let reg = make_registry();
982        let skills = vec![
983            make_skill(
984                "rust",
985                "Conventions for Rust code",
986                "/home/.imp/skills/rust/SKILL.md",
987            ),
988            make_skill(
989                "testing",
990                "Write and review tests",
991                "/home/.imp/skills/testing/SKILL.md",
992            ),
993        ];
994        let result = test_assemble(&reg, &[], &skills, &[], None, None, None);
995        assert!(result
996            .text
997            .contains("Available skills (use read to load when relevant):"));
998        assert!(result
999            .text
1000            .contains("- rust: Conventions for Rust code [/home/.imp/skills/rust/SKILL.md]"));
1001        assert!(result
1002            .text
1003            .contains("- testing: Write and review tests [/home/.imp/skills/testing/SKILL.md]"));
1004    }
1005
1006    #[test]
1007    fn system_prompt_includes_mode_aware_mana_skill_trigger() {
1008        let reg = make_registry();
1009        let skills = vec![make_skill(
1010            "mana",
1011            "Coordinate explicit work through mana",
1012            "/home/.imp/skills/mana/SKILL.md",
1013        )];
1014        let result = assemble(&AssembleParams {
1015            tools: &reg,
1016            agents_md: &[],
1017            skills: &skills,
1018            facts: &[],
1019            project_memory_status: None,
1020            personality: None,
1021            soul: None,
1022            task: None,
1023            role: None,
1024            mode: &AgentMode::Planner,
1025            memory: None,
1026            user_profile: None,
1027            cwd: None,
1028            learning_enabled: false,
1029            guardrail_profile: None,
1030        });
1031
1032        assert!(result.text.contains("- Trigger:"));
1033        assert!(result.text.contains(
1034            "Load `mana` before writing or restructuring mana units for non-trivial work."
1035        ));
1036    }
1037
1038    #[test]
1039    fn system_prompt_orchestrator_uses_same_mana_trigger() {
1040        let reg = make_registry();
1041        let skills = vec![make_skill(
1042            "mana",
1043            "Coordinate explicit work through mana",
1044            "/home/.imp/skills/mana/SKILL.md",
1045        )];
1046        let result = assemble(&AssembleParams {
1047            tools: &reg,
1048            agents_md: &[],
1049            skills: &skills,
1050            facts: &[],
1051            project_memory_status: None,
1052            personality: None,
1053            soul: None,
1054            task: None,
1055            role: None,
1056            mode: &AgentMode::Orchestrator,
1057            memory: None,
1058            user_profile: None,
1059            cwd: None,
1060            learning_enabled: false,
1061            guardrail_profile: None,
1062        });
1063
1064        assert!(result.text.contains(
1065            "Load `mana` before writing or restructuring mana units for non-trivial work."
1066        ));
1067    }
1068
1069    #[test]
1070    fn system_prompt_worker_prefers_mana_basics_trigger() {
1071        let reg = make_registry();
1072        let skills = vec![
1073            make_skill(
1074                "mana",
1075                "Coordinate multi-step work through mana",
1076                "/home/.imp/skills/mana/SKILL.md",
1077            ),
1078            make_skill(
1079                "mana-basics",
1080                "Use native mana actions safely and efficiently",
1081                "/home/.imp/skills/mana-basics/SKILL.md",
1082            ),
1083        ];
1084        let result = assemble(&AssembleParams {
1085            tools: &reg,
1086            agents_md: &[],
1087            skills: &skills,
1088            facts: &[],
1089            project_memory_status: None,
1090            personality: None,
1091            soul: None,
1092            task: None,
1093            role: None,
1094            mode: &AgentMode::Worker,
1095            memory: None,
1096            user_profile: None,
1097            cwd: None,
1098            learning_enabled: false,
1099            guardrail_profile: None,
1100        });
1101
1102        assert!(result.text.contains(
1103            "Load `mana-basics` before using worker-safe mana actions beyond a quick status check."
1104        ));
1105    }
1106
1107    #[test]
1108    fn system_prompt_omits_mana_trigger_without_mana_skill() {
1109        let reg = make_registry();
1110        let skills = vec![make_skill(
1111            "rust",
1112            "Conventions for Rust code",
1113            "/home/.imp/skills/rust/SKILL.md",
1114        )];
1115        let result = assemble(&AssembleParams {
1116            tools: &reg,
1117            agents_md: &[],
1118            skills: &skills,
1119            facts: &[],
1120            project_memory_status: None,
1121            personality: None,
1122            soul: None,
1123            task: None,
1124            role: None,
1125            mode: &AgentMode::Planner,
1126            memory: None,
1127            user_profile: None,
1128            cwd: None,
1129            learning_enabled: false,
1130            guardrail_profile: None,
1131        });
1132
1133        assert!(!result.text.contains("- Trigger:"));
1134    }
1135
1136    #[test]
1137    fn system_prompt_reviewer_mode_omits_mana_trigger() {
1138        let reg = make_registry();
1139        let skills = vec![make_skill(
1140            "mana",
1141            "Coordinate multi-step work through mana",
1142            "/home/.imp/skills/mana/SKILL.md",
1143        )];
1144        let result = assemble(&AssembleParams {
1145            tools: &reg,
1146            agents_md: &[],
1147            skills: &skills,
1148            facts: &[],
1149            project_memory_status: None,
1150            personality: None,
1151            soul: None,
1152            task: None,
1153            role: None,
1154            mode: &AgentMode::Reviewer,
1155            memory: None,
1156            user_profile: None,
1157            cwd: None,
1158            learning_enabled: false,
1159            guardrail_profile: None,
1160        });
1161
1162        assert!(!result.text.contains("- Trigger:"));
1163    }
1164
1165    #[test]
1166    fn system_prompt_empty_skills_skipped() {
1167        let reg = make_registry();
1168        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
1169        assert!(!result.text.contains("Available skills"));
1170    }
1171
1172    // -- Layer 4: Mana facts --
1173
1174    #[test]
1175    fn system_prompt_facts_included() {
1176        let reg = make_registry();
1177        let facts = vec![
1178            Fact {
1179                text: "Uses JWT for auth".into(),
1180                verified_ago: "2h ago".into(),
1181            },
1182            Fact {
1183                text: "Test suite requires Docker".into(),
1184                verified_ago: "1d ago".into(),
1185            },
1186        ];
1187        let result = test_assemble(&reg, &[], &[], &facts, None, None, None);
1188        assert!(result.text.contains("Project facts:"));
1189        assert!(result
1190            .text
1191            .contains("\"Uses JWT for auth\" [verified 2h ago]"));
1192        assert!(result
1193            .text
1194            .contains("\"Test suite requires Docker\" [verified 1d ago]"));
1195    }
1196
1197    #[test]
1198    fn system_prompt_empty_facts_skipped() {
1199        let reg = make_registry();
1200        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
1201        assert!(!result.text.contains("Project facts"));
1202    }
1203
1204    #[test]
1205    fn system_prompt_project_memory_status_included() {
1206        let reg = make_registry();
1207        let result = assemble(&AssembleParams {
1208            tools: &reg,
1209            agents_md: &[],
1210            skills: &[],
1211            facts: &[],
1212            project_memory_status: Some(
1213                "Project memory status:\nWarnings:\n- STALE: \"Lockfile drift\"\n\nWorking on:\n- [12] Refresh auth flow",
1214            ),
1215            personality: None,
1216            soul: None,
1217            task: None,
1218            role: None,
1219            mode: &AgentMode::Full,
1220            memory: None,
1221            user_profile: None,
1222            cwd: None,
1223            learning_enabled: false,
1224            guardrail_profile: None,
1225        });
1226        assert!(result.text.contains("Project memory status:"));
1227        assert!(result.text.contains("Warnings:"));
1228        assert!(result.text.contains("Working on:"));
1229    }
1230
1231    #[test]
1232    fn system_prompt_project_memory_status_empty_string_is_skipped() {
1233        let reg = make_registry();
1234        let result = assemble(&AssembleParams {
1235            tools: &reg,
1236            agents_md: &[],
1237            skills: &[],
1238            facts: &[],
1239            project_memory_status: Some(""),
1240            personality: None,
1241            soul: None,
1242            task: None,
1243            role: None,
1244            mode: &AgentMode::Full,
1245            memory: None,
1246            user_profile: None,
1247            cwd: None,
1248            learning_enabled: false,
1249            guardrail_profile: None,
1250        });
1251        assert!(!result.text.contains("Project memory status:"));
1252    }
1253
1254    #[test]
1255    fn system_prompt_project_memory_status_included_separately_from_facts() {
1256        let reg = make_registry();
1257        let facts = vec![Fact {
1258            text: "Uses JWT for auth".into(),
1259            verified_ago: "2h ago".into(),
1260        }];
1261        let status =
1262            "Project memory status:\nWarnings:\n- stale fact\n\nWorking on:\n- [7] Fix auth flow";
1263        let result = assemble(&AssembleParams {
1264            tools: &reg,
1265            agents_md: &[],
1266            skills: &[],
1267            facts: &facts,
1268            project_memory_status: Some(status),
1269            personality: None,
1270            soul: None,
1271            task: None,
1272            role: None,
1273            mode: &AgentMode::Full,
1274            memory: None,
1275            user_profile: None,
1276            cwd: None,
1277            learning_enabled: false,
1278            guardrail_profile: None,
1279        });
1280
1281        let facts_pos = result.text.find("Project facts:").unwrap();
1282        let status_pos = result.text.find("Project memory status:").unwrap();
1283        assert!(result
1284            .text
1285            .contains("\"Uses JWT for auth\" [verified 2h ago]"));
1286        assert!(result.text.contains("Warnings:"));
1287        assert!(result.text.contains("Working on:"));
1288        assert!(facts_pos < status_pos);
1289    }
1290
1291    // -- Layer 5: Task context --
1292
1293    #[test]
1294    fn system_prompt_task_context_included() {
1295        let reg = make_registry();
1296        let task = TaskContext {
1297            title: "Fix the failing auth test".into(),
1298            description: "The JWT validation test panics on expired tokens".into(),
1299            acceptance: None,
1300            verify: Some("cargo test auth::jwt_test".into()),
1301            notes: None,
1302            attempts: vec![],
1303            dependencies: vec![],
1304            decisions: vec![],
1305            context_paths: vec![],
1306            constraints: vec![],
1307        };
1308        let result = test_assemble(&reg, &[], &[], &[], None, Some(&task), None);
1309        assert!(result.text.contains("## Task"));
1310        assert!(result.text.contains("Title: Fix the failing auth test"));
1311        assert!(result
1312            .text
1313            .contains("Description: The JWT validation test panics"));
1314        assert!(result.text.contains("Verify: cargo test auth::jwt_test"));
1315        assert!(result
1316            .text
1317            .contains("Treat the verify command as the primary completion check for this task."));
1318    }
1319
1320    #[test]
1321    fn system_prompt_task_with_attempts() {
1322        let reg = make_registry();
1323        let task = TaskContext {
1324            title: "Fix bug".into(),
1325            description: "Something is broken".into(),
1326            acceptance: None,
1327            verify: None,
1328            notes: None,
1329            attempts: vec![
1330                Attempt {
1331                    number: 1,
1332                    outcome: "failed".into(),
1333                    summary: "Tried X, got error Y".into(),
1334                },
1335                Attempt {
1336                    number: 2,
1337                    outcome: "failed".into(),
1338                    summary: "Tried Z, still broken".into(),
1339                },
1340            ],
1341            dependencies: vec![],
1342            decisions: vec![],
1343            context_paths: vec![],
1344            constraints: vec![],
1345        };
1346        let result = test_assemble(&reg, &[], &[], &[], None, Some(&task), None);
1347        assert!(result.text.contains("## Previous attempts"));
1348        assert!(result.text.contains(
1349            "Do not repeat a failed approach unchanged; use the attempt history to adjust your plan."
1350        ));
1351        assert!(result
1352            .text
1353            .contains("Attempt 1 (failed): Tried X, got error Y"));
1354        assert!(result
1355            .text
1356            .contains("Attempt 2 (failed): Tried Z, still broken"));
1357    }
1358
1359    #[test]
1360    fn system_prompt_task_with_dependencies() {
1361        let reg = make_registry();
1362        let task = TaskContext {
1363            title: "Implement feature".into(),
1364            description: "New feature".into(),
1365            acceptance: None,
1366            verify: None,
1367            notes: None,
1368            attempts: vec![],
1369            dependencies: vec![Dependency {
1370                name: "Schema types".into(),
1371                status: "completed".into(),
1372                detail: "defined in src/schema.rs".into(),
1373            }],
1374            decisions: vec![],
1375            context_paths: vec![],
1376            constraints: vec![],
1377        };
1378        let result = test_assemble(&reg, &[], &[], &[], None, Some(&task), None);
1379        assert!(result.text.contains("## Dependencies"));
1380        assert!(result.text.contains(
1381            "Respect dependency state when sequencing work; unresolved dependencies are potential blockers."
1382        ));
1383        assert!(result
1384            .text
1385            .contains("- Schema types (completed): defined in src/schema.rs"));
1386    }
1387
1388    #[test]
1389    fn system_prompt_task_with_notes_and_context_paths() {
1390        let reg = make_registry();
1391        let task = TaskContext {
1392            title: "Fix auth".into(),
1393            description: "Tighten token validation".into(),
1394            acceptance: None,
1395            verify: Some("cargo test auth".into()),
1396            notes: Some("Prefer touching only auth paths unless necessary".into()),
1397            attempts: vec![],
1398            dependencies: vec![],
1399            decisions: vec![],
1400            context_paths: vec!["src/auth.rs".into(), "tests/auth.rs".into()],
1401            constraints: vec![
1402                "Scope changes to auth-related files unless broader edits are necessary".into(),
1403            ],
1404        };
1405        let result = test_assemble(&reg, &[], &[], &[], None, Some(&task), None);
1406        assert!(result.text.contains("Notes:"));
1407        assert!(result
1408            .text
1409            .contains("Prefer touching only auth paths unless necessary"));
1410        assert!(result.text.contains("## Referenced files"));
1411        assert!(result.text.contains("- src/auth.rs"));
1412        assert!(result.text.contains("- tests/auth.rs"));
1413        assert!(result.text.contains("## Constraints"));
1414        assert!(result
1415            .text
1416            .contains("Scope changes to auth-related files unless broader edits are necessary"));
1417    }
1418
1419    #[test]
1420    fn system_prompt_no_task_skips_layer5() {
1421        let reg = make_registry();
1422        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
1423        assert!(!result.text.contains("## Task"));
1424    }
1425
1426    #[test]
1427    fn system_prompt_task_without_verify_omits_verify_line() {
1428        let reg = make_registry();
1429        let task = TaskContext {
1430            title: "Do something".into(),
1431            description: "Details here".into(),
1432            acceptance: None,
1433            verify: None,
1434            notes: None,
1435            attempts: vec![],
1436            dependencies: vec![],
1437            decisions: vec![],
1438            context_paths: vec![],
1439            constraints: vec![],
1440        };
1441        let result = test_assemble(&reg, &[], &[], &[], None, Some(&task), None);
1442        assert!(result.text.contains("Title: Do something"));
1443        assert!(!result.text.contains("Verify:"));
1444    }
1445
1446    // -- Role-aware assembly --
1447
1448    #[test]
1449    fn system_prompt_readonly_role_filters_tools() {
1450        let reg = make_registry();
1451        let role = make_readonly_role();
1452        let result = test_assemble(&reg, &[], &[], &[], None, None, Some(&role));
1453        // Should include readonly tools
1454        assert!(result.text.contains("- read:"));
1455        // Should NOT include write tools
1456        assert!(!result.text.contains("- write:"));
1457        assert!(!result.text.contains("- edit:"));
1458    }
1459
1460    #[test]
1461    fn system_prompt_role_instructions_appended() {
1462        let reg = make_registry();
1463        let role = make_readonly_role();
1464        let result = test_assemble(&reg, &[], &[], &[], None, None, Some(&role));
1465        assert!(result
1466            .text
1467            .contains("Review code carefully. Do not modify files."));
1468    }
1469
1470    #[test]
1471    fn system_prompt_worker_role_includes_all_tools() {
1472        let reg = make_registry();
1473        let role = make_worker_role();
1474        let result = test_assemble(&reg, &[], &[], &[], None, None, Some(&role));
1475        assert!(result.text.contains("- read:"));
1476        assert!(result.text.contains("- write:"));
1477        assert!(result.text.contains("- edit:"));
1478        assert!(result.text.contains("- bash:"));
1479    }
1480
1481    #[test]
1482    fn system_prompt_no_role_instructions_when_none() {
1483        let reg = make_registry();
1484        let role = make_worker_role();
1485        let result = test_assemble(&reg, &[], &[], &[], None, None, Some(&role));
1486        // Worker has no instructions, so the prompt shouldn't have extra instruction text
1487        let lines: Vec<&str> = result.text.lines().collect();
1488        let after_tools = lines.iter().position(|l| l.starts_with("- bash:")).unwrap();
1489        // Next non-empty line after the last tool should be end of identity layer
1490        // (no instructions appended)
1491        let remaining = &lines[after_tools + 1..];
1492        let next_content = remaining.iter().find(|l| !l.is_empty());
1493        assert!(next_content.is_none() || !next_content.unwrap().contains("Review"));
1494    }
1495
1496    // -- Size tracking --
1497
1498    #[test]
1499    fn system_prompt_tracks_estimated_tokens() {
1500        let reg = make_registry();
1501        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
1502        assert!(result.estimated_tokens > 0);
1503        // Rough check: the text is at least ~100 chars, so >= 25 tokens
1504        assert!(result.estimated_tokens >= 10);
1505    }
1506
1507    #[test]
1508    fn system_prompt_more_layers_means_more_tokens() {
1509        let reg = make_registry();
1510
1511        let minimal = test_assemble(&reg, &[], &[], &[], None, None, None);
1512
1513        let agents = vec![make_agents_md(
1514            "Lots of project context here with many words.",
1515        )];
1516        let skills = vec![make_skill(
1517            "rust",
1518            "Rust conventions",
1519            "/skills/rust/SKILL.md",
1520        )];
1521        let facts = vec![Fact {
1522            text: "Uses Postgres".into(),
1523            verified_ago: "1h ago".into(),
1524        }];
1525
1526        let full = test_assemble(&reg, &agents, &skills, &facts, None, None, None);
1527
1528        assert!(
1529            full.estimated_tokens > minimal.estimated_tokens,
1530            "full ({}) should have more tokens than minimal ({})",
1531            full.estimated_tokens,
1532            minimal.estimated_tokens
1533        );
1534    }
1535
1536    // -- Full assembly --
1537
1538    #[test]
1539    fn system_prompt_all_layers_present() {
1540        let reg = make_registry();
1541        let agents = vec![make_agents_md("Be concise.")];
1542        let skills = vec![make_skill(
1543            "rust",
1544            "Rust code conventions",
1545            "/skills/rust/SKILL.md",
1546        )];
1547        let facts = vec![Fact {
1548            text: "Uses SQLite".into(),
1549            verified_ago: "30m ago".into(),
1550        }];
1551        let task = TaskContext {
1552            title: "Add caching".into(),
1553            description: "Add Redis caching layer".into(),
1554            acceptance: None,
1555            verify: Some("cargo test cache".into()),
1556            notes: None,
1557            attempts: vec![Attempt {
1558                number: 1,
1559                outcome: "failed".into(),
1560                summary: "Wrong key format".into(),
1561            }],
1562            dependencies: vec![Dependency {
1563                name: "Config".into(),
1564                status: "done".into(),
1565                detail: "src/config.rs".into(),
1566            }],
1567            decisions: vec![],
1568            context_paths: vec![],
1569            constraints: vec![],
1570        };
1571
1572        let result = test_assemble(&reg, &agents, &skills, &facts, None, Some(&task), None);
1573
1574        // All layers present in order
1575        let identity_pos = result.text.find("You are imp").unwrap();
1576        let policy_pos = result.text.find("Execution discipline").unwrap();
1577        let context_pos = result.text.find("# Project Context").unwrap();
1578        let skills_pos = result.text.find("Available skills").unwrap();
1579        let facts_pos = result.text.find("Project facts").unwrap();
1580        let task_pos = result.text.find("## Task").unwrap();
1581
1582        assert!(identity_pos < policy_pos, "identity before policy");
1583        assert!(policy_pos < context_pos, "policy before context");
1584        assert!(context_pos < skills_pos, "context before skills");
1585        assert!(skills_pos < facts_pos, "skills before facts");
1586        assert!(facts_pos < task_pos, "facts before task");
1587    }
1588
1589    #[test]
1590    fn system_prompt_display_impl() {
1591        let reg = make_registry();
1592        let result = test_assemble(&reg, &[], &[], &[], None, None, None);
1593        let displayed = format!("{result}");
1594        assert_eq!(displayed, result.text);
1595    }
1596
1597    // -- Layer 6: Agent Memory --
1598
1599    #[test]
1600    fn system_prompt_memory_included() {
1601        let reg = make_registry();
1602        let mem = "══════════════════\nMEMORY [50% — 100/200]\n══════════════════\nUser runs macOS";
1603        let result = assemble(&AssembleParams {
1604            tools: &reg,
1605            agents_md: &[],
1606            skills: &[],
1607            facts: &[],
1608            project_memory_status: None,
1609            personality: None,
1610            soul: None,
1611            task: None,
1612            role: None,
1613            mode: &AgentMode::Full,
1614            memory: Some(mem),
1615            user_profile: None,
1616            cwd: None,
1617            learning_enabled: false,
1618            guardrail_profile: None,
1619        });
1620        assert!(result.text.contains("MEMORY"));
1621        assert!(result.text.contains("User runs macOS"));
1622    }
1623
1624    #[test]
1625    fn system_prompt_user_profile_included() {
1626        let reg = make_registry();
1627        let user =
1628            "══════════════════\nUSER PROFILE [30% — 42/140]\n══════════════════\nPrefers concise";
1629        let result = assemble(&AssembleParams {
1630            tools: &reg,
1631            agents_md: &[],
1632            skills: &[],
1633            facts: &[],
1634            project_memory_status: None,
1635            personality: None,
1636            soul: None,
1637            task: None,
1638            role: None,
1639            mode: &AgentMode::Full,
1640            memory: None,
1641            user_profile: Some(user),
1642            cwd: None,
1643            learning_enabled: false,
1644            guardrail_profile: None,
1645        });
1646        assert!(result.text.contains("USER PROFILE"));
1647        assert!(result.text.contains("Prefers concise"));
1648    }
1649
1650    #[test]
1651    fn system_prompt_empty_memory_skipped() {
1652        let reg = make_registry();
1653        let result = assemble(&AssembleParams {
1654            tools: &reg,
1655            agents_md: &[],
1656            skills: &[],
1657            facts: &[],
1658            project_memory_status: None,
1659            personality: None,
1660            soul: None,
1661            task: None,
1662            role: None,
1663            mode: &AgentMode::Full,
1664            memory: Some(""),
1665            user_profile: Some(""),
1666            cwd: None,
1667            learning_enabled: false,
1668            guardrail_profile: None,
1669        });
1670        assert!(!result.text.contains("MEMORY"));
1671        assert!(!result.text.contains("USER PROFILE"));
1672    }
1673
1674    #[test]
1675    fn system_prompt_memory_after_all_other_layers() {
1676        let reg = make_registry();
1677        let agents = vec![make_agents_md("Project context.")];
1678        let skills = vec![make_skill("rust", "Rust", "/skills/rust/SKILL.md")];
1679        let facts = vec![Fact {
1680            text: "Uses SQLite".into(),
1681            verified_ago: "1h".into(),
1682        }];
1683        let task = TaskContext {
1684            title: "Fix bug".into(),
1685            description: "Broken".into(),
1686            acceptance: None,
1687            verify: None,
1688            notes: None,
1689            attempts: vec![],
1690            dependencies: vec![],
1691            decisions: vec![],
1692            context_paths: vec![],
1693            constraints: vec![],
1694        };
1695        let mem = "══════\nMEMORY [50%]\n══════\nSome fact";
1696        let result = assemble(&AssembleParams {
1697            tools: &reg,
1698            agents_md: &agents,
1699            skills: &skills,
1700            facts: &facts,
1701            project_memory_status: None,
1702            personality: None,
1703            soul: None,
1704            task: Some(&task),
1705            role: None,
1706            mode: &AgentMode::Full,
1707            memory: Some(mem),
1708            user_profile: None,
1709            cwd: None,
1710            learning_enabled: false,
1711            guardrail_profile: None,
1712        });
1713
1714        let identity_pos = result.text.find("You are imp").unwrap();
1715        let context_pos = result.text.find("# Project Context").unwrap();
1716        let facts_pos = result.text.find("Project facts").unwrap();
1717        let task_pos = result.text.find("## Task").unwrap();
1718        let memory_pos = result.text.find("MEMORY").unwrap();
1719
1720        assert!(identity_pos < context_pos);
1721        assert!(context_pos < facts_pos);
1722        assert!(facts_pos < task_pos);
1723        assert!(task_pos < memory_pos, "memory should come after task");
1724    }
1725}