| 1 | #![allow(dead_code)] |
| 2 | //! System prompts for different modes. |
| 3 | //! |
| 4 | //! Prompts are assembled from composable layers loaded at compile time: |
| 5 | //! base.md → personality overlay → mode delta → approval policy |
| 6 | //! |
| 7 | //! This keeps each concern in its own file and makes prompt tuning |
| 8 | //! a single-file operation. |
| 9 | |
| 10 | use crate::models::SystemPrompt; |
| 11 | use crate::project_context::{ProjectContext, load_project_context_with_parents}; |
| 12 | use crate::tui::app::AppMode; |
| 13 | use crate::tui::approval::ApprovalMode; |
| 14 | use std::path::{Path, PathBuf}; |
| 15 | |
| 16 | #[derive(Debug, Clone, Copy, Default)] |
| 17 | pub struct PromptSessionContext<'a> { |
| 18 | pub user_memory_block: Option<&'a str>, |
| 19 | pub goal_objective: Option<&'a str>, |
| 20 | /// Resolved BCP-47 locale tag for the `## Environment` block in |
| 21 | /// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The |
| 22 | /// caller is responsible for resolving this from `Settings`; no |
| 23 | /// disk I/O happens inside the prompt builder, so the workspace- |
| 24 | /// static portion of the system prompt stays cache-friendly. |
| 25 | pub locale_tag: &'a str, |
| 26 | } |
| 27 | |
| 28 | /// Conventional location for the structured session-handoff artifact (#32). |
| 29 | /// A previous session writes it on exit / `/compact`; the next session reads |
| 30 | /// it back on startup and prepends it to the system prompt so a fresh agent |
| 31 | /// doesn't have to re-discover open blockers from scratch. |
| 32 | pub const HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; |
| 33 | |
| 34 | /// Per-file size cap for `instructions = [...]` entries (#454). Mirrors |
| 35 | /// the existing project-context cap in `project_context::load_context_file` |
| 36 | /// so a malicious / oversized include can't blow the prompt budget on |
| 37 | /// its own. Files larger than this are truncated with an `[…elided]` |
| 38 | /// marker rather than skipped entirely so the model still sees the head. |
| 39 | const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024; |
| 40 | |
| 41 | /// Render a `## Environment` block listing the resolved locale tag, |
| 42 | /// host platform, login shell, and current working directory. |
| 43 | /// |
| 44 | /// The block is appended to the workspace-static portion of the |
| 45 | /// system prompt (after mode prompt + project context, before |
| 46 | /// configured instructions / skills) so the `## Language` directive |
| 47 | /// in `prompts/base.md` can reference it without the model having to |
| 48 | /// guess from the user's first message. `locale_tag` is resolved by |
| 49 | /// the caller from `Settings` so this function stays I/O-free. |
| 50 | fn render_environment_block(workspace: &Path, locale_tag: &str) -> String { |
| 51 | let platform = std::env::consts::OS; |
| 52 | let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()); |
| 53 | let pwd = workspace.display(); |
| 54 | |
| 55 | format!( |
| 56 | "## Environment\n\ |
| 57 | \n\ |
| 58 | - lang: {locale_tag}\n\ |
| 59 | - platform: {platform}\n\ |
| 60 | - shell: {shell}\n\ |
| 61 | - pwd: {pwd}" |
| 62 | ) |
| 63 | } |
| 64 | |
| 65 | /// Render the `instructions = [...]` config array as a single |
| 66 | /// system-prompt block (#454). Each path is loaded in declared order; |
| 67 | /// missing files are skipped with a tracing warning so a stale entry |
| 68 | /// in `~/.deepseek/config.toml` doesn't fail the launch. Empty input |
| 69 | /// (or all paths missing) returns `None` so callers append nothing. |
| 70 | fn render_instructions_block(paths: &[PathBuf]) -> Option<String> { |
| 71 | let mut sections: Vec<String> = Vec::new(); |
| 72 | for path in paths { |
| 73 | match std::fs::read_to_string(path) { |
| 74 | Ok(raw) => { |
| 75 | let trimmed = raw.trim(); |
| 76 | if trimmed.is_empty() { |
| 77 | continue; |
| 78 | } |
| 79 | let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES { |
| 80 | let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES) |
| 81 | .rev() |
| 82 | .find(|&i| trimmed.is_char_boundary(i)) |
| 83 | .unwrap_or(0); |
| 84 | format!("{}\n[…elided]", &trimmed[..head_end]) |
| 85 | } else { |
| 86 | trimmed.to_string() |
| 87 | }; |
| 88 | sections.push(format!( |
| 89 | "<instructions source=\"{}\">\n{}\n</instructions>", |
| 90 | path.display(), |
| 91 | body |
| 92 | )); |
| 93 | } |
| 94 | Err(err) => { |
| 95 | tracing::warn!( |
| 96 | target: "instructions", |
| 97 | ?err, |
| 98 | ?path, |
| 99 | "skipping unreadable instructions file" |
| 100 | ); |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | if sections.is_empty() { |
| 105 | None |
| 106 | } else { |
| 107 | Some(sections.join("\n\n")) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Read the workspace-local handoff artifact, if present, and format it as a |
| 112 | /// system-prompt block. Returns `None` when the file is absent or empty so |
| 113 | /// callers can keep the default-uncluttered prompt for fresh workspaces. |
| 114 | fn load_handoff_block(workspace: &Path) -> Option<String> { |
| 115 | let path = workspace.join(HANDOFF_RELATIVE_PATH); |
| 116 | let raw = std::fs::read_to_string(&path).ok()?; |
| 117 | let trimmed = raw.trim(); |
| 118 | if trimmed.is_empty() { |
| 119 | return None; |
| 120 | } |
| 121 | Some(format!( |
| 122 | "## Previous Session Handoff\n\nThe previous session in this workspace left a handoff at `{}`. Consider it the first artifact to read on this turn — open blockers, in-flight changes, and recent decisions live there. Update or rewrite it before exiting if state changes materially.\n\n{}", |
| 123 | HANDOFF_RELATIVE_PATH, trimmed |
| 124 | )) |
| 125 | } |
| 126 | |
| 127 | // ── Prompt layers loaded at compile time ────────────────────────────── |
| 128 | |
| 129 | /// Core: task execution, tool-use rules, output format, toolbox reference, |
| 130 | /// "When NOT to use" guidance, sub-agent sentinel protocol. |
| 131 | pub const BASE_PROMPT: &str = include_str!("prompts/base.md"); |
| 132 | |
| 133 | /// Personality overlays — voice and tone. |
| 134 | pub const CALM_PERSONALITY: &str = include_str!("prompts/personalities/calm.md"); |
| 135 | pub const PLAYFUL_PERSONALITY: &str = include_str!("prompts/personalities/playful.md"); |
| 136 | |
| 137 | /// Mode deltas — permissions, workflow expectations, mode-specific rules. |
| 138 | pub const AGENT_MODE: &str = include_str!("prompts/modes/agent.md"); |
| 139 | pub const PLAN_MODE: &str = include_str!("prompts/modes/plan.md"); |
| 140 | pub const YOLO_MODE: &str = include_str!("prompts/modes/yolo.md"); |
| 141 | |
| 142 | /// Approval-policy overlays — whether tool calls are auto-approved, |
| 143 | /// require confirmation, or are blocked. |
| 144 | pub const AUTO_APPROVAL: &str = include_str!("prompts/approvals/auto.md"); |
| 145 | pub const SUGGEST_APPROVAL: &str = include_str!("prompts/approvals/suggest.md"); |
| 146 | pub const NEVER_APPROVAL: &str = include_str!("prompts/approvals/never.md"); |
| 147 | |
| 148 | /// Compaction handoff template — written into the system prompt so the |
| 149 | /// model knows the format to use when writing `.deepseek/handoff.md`. |
| 150 | pub const COMPACT_TEMPLATE: &str = include_str!("prompts/compact.md"); |
| 151 | |
| 152 | // ── Legacy prompt constants (kept for backwards compatibility) ──────── |
| 153 | |
| 154 | /// Legacy base prompt (agent.txt — now decomposed into base.md + overlays). |
| 155 | /// Still available for callers that haven't migrated to the layered API. |
| 156 | pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt"); |
| 157 | pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt"); |
| 158 | pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt"); |
| 159 | |
| 160 | // ── Personality selection ───────────────────────────────────────────── |
| 161 | |
| 162 | /// Which personality overlay to apply. |
| 163 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 164 | pub enum Personality { |
| 165 | /// Cool, spatial, reserved — the default. |
| 166 | Calm, |
| 167 | /// Warm, energetic, playful — alternative for fun mode. |
| 168 | Playful, |
| 169 | } |
| 170 | |
| 171 | impl Personality { |
| 172 | /// Resolve from the `calm_mode` settings flag. |
| 173 | /// When `calm_mode` is true → Calm; when false → Playful (future). |
| 174 | /// For now, always returns Calm — Playful is wired but opt-in. |
| 175 | #[must_use] |
| 176 | pub fn from_settings(calm_mode: bool) -> Self { |
| 177 | if calm_mode { |
| 178 | Self::Calm |
| 179 | } else { |
| 180 | // Future: when playful mode is exposed in settings, return Playful here. |
| 181 | // For now, calm is the only default. |
| 182 | Self::Calm |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | fn prompt(self) -> &'static str { |
| 187 | match self { |
| 188 | Self::Calm => CALM_PERSONALITY, |
| 189 | Self::Playful => PLAYFUL_PERSONALITY, |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // ── Composition ─────────────────────────────────────────────────────── |
| 195 | |
| 196 | fn mode_prompt(mode: AppMode) -> &'static str { |
| 197 | match mode { |
| 198 | AppMode::Agent => AGENT_MODE, |
| 199 | AppMode::Yolo => YOLO_MODE, |
| 200 | AppMode::Plan => PLAN_MODE, |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | fn default_approval_mode_for_mode(mode: AppMode) -> ApprovalMode { |
| 205 | match mode { |
| 206 | AppMode::Agent => ApprovalMode::Suggest, |
| 207 | AppMode::Yolo => ApprovalMode::Auto, |
| 208 | AppMode::Plan => ApprovalMode::Never, |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | fn approval_prompt_for_mode(mode: AppMode, approval_mode: ApprovalMode) -> &'static str { |
| 213 | match mode { |
| 214 | AppMode::Yolo => AUTO_APPROVAL, |
| 215 | AppMode::Plan => NEVER_APPROVAL, |
| 216 | AppMode::Agent => match approval_mode { |
| 217 | ApprovalMode::Auto => AUTO_APPROVAL, |
| 218 | ApprovalMode::Suggest => SUGGEST_APPROVAL, |
| 219 | ApprovalMode::Never => NEVER_APPROVAL, |
| 220 | }, |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Compose the full system prompt in deterministic order: |
| 225 | /// 1. base.md — core identity, toolbox, execution contract |
| 226 | /// 2. personality — voice and tone overlay |
| 227 | /// 3. mode delta — mode-specific permissions and workflow |
| 228 | /// 4. approval policy — tool-approval behavior |
| 229 | /// |
| 230 | /// Each layer is separated by a blank line for readability in the |
| 231 | /// rendered prompt (the model sees them as contiguous sections). |
| 232 | pub fn compose_prompt(mode: AppMode, personality: Personality) -> String { |
| 233 | compose_prompt_with_approval(mode, personality, default_approval_mode_for_mode(mode)) |
| 234 | } |
| 235 | |
| 236 | pub fn compose_prompt_with_approval( |
| 237 | mode: AppMode, |
| 238 | personality: Personality, |
| 239 | approval_mode: ApprovalMode, |
| 240 | ) -> String { |
| 241 | let parts: [&str; 4] = [ |
| 242 | BASE_PROMPT.trim(), |
| 243 | personality.prompt().trim(), |
| 244 | mode_prompt(mode).trim(), |
| 245 | approval_prompt_for_mode(mode, approval_mode).trim(), |
| 246 | ]; |
| 247 | |
| 248 | let mut out = |
| 249 | String::with_capacity(parts.iter().map(|p| p.len()).sum::<usize>() + (parts.len() - 1) * 2); |
| 250 | for (i, part) in parts.iter().enumerate() { |
| 251 | if i > 0 { |
| 252 | out.push('\n'); |
| 253 | out.push('\n'); |
| 254 | } |
| 255 | out.push_str(part); |
| 256 | } |
| 257 | out |
| 258 | } |
| 259 | |
| 260 | /// Compose for the default personality (Calm). |
| 261 | fn compose_mode_prompt(mode: AppMode) -> String { |
| 262 | compose_prompt(mode, Personality::Calm) |
| 263 | } |
| 264 | |
| 265 | fn compose_mode_prompt_with_approval(mode: AppMode, approval_mode: ApprovalMode) -> String { |
| 266 | compose_prompt_with_approval(mode, Personality::Calm, approval_mode) |
| 267 | } |
| 268 | |
| 269 | // ── Public API ──────────────────────────────────────────────────────── |
| 270 | |
| 271 | /// Get the system prompt for a specific mode (default Calm personality). |
| 272 | pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt { |
| 273 | SystemPrompt::Text(compose_mode_prompt(mode)) |
| 274 | } |
| 275 | |
| 276 | /// Get the system prompt for a specific mode with explicit personality. |
| 277 | pub fn system_prompt_for_mode_with_personality( |
| 278 | mode: AppMode, |
| 279 | personality: Personality, |
| 280 | ) -> SystemPrompt { |
| 281 | SystemPrompt::Text(compose_prompt(mode, personality)) |
| 282 | } |
| 283 | |
| 284 | /// Get the system prompt for a specific mode with project context. |
| 285 | pub fn system_prompt_for_mode_with_context( |
| 286 | mode: AppMode, |
| 287 | workspace: &Path, |
| 288 | working_set_summary: Option<&str>, |
| 289 | ) -> SystemPrompt { |
| 290 | system_prompt_for_mode_with_context_and_skills( |
| 291 | mode, |
| 292 | workspace, |
| 293 | working_set_summary, |
| 294 | None, |
| 295 | None, |
| 296 | None, |
| 297 | ) |
| 298 | } |
| 299 | |
| 300 | /// Get the system prompt for a specific mode with project and skills context. |
| 301 | /// |
| 302 | /// **Volatile-content-last invariant.** Blocks are appended in order from |
| 303 | /// most-static to most-volatile so DeepSeek's KV prefix cache hits the |
| 304 | /// longest possible byte prefix turn-over-turn: |
| 305 | /// |
| 306 | /// 1. mode prompt (compile-time constant) |
| 307 | /// 2. project context / fallback (workspace-static) |
| 308 | /// 3. skills block (skills-dir-static) |
| 309 | /// 4. `## Context Management` (compile-time constant, Agent/Yolo only) |
| 310 | /// 5. compaction handoff template (compile-time constant) |
| 311 | /// 6. handoff block — file-backed; rewritten by `/compact` and on exit |
| 312 | /// |
| 313 | /// Anything appended after a volatile block forfeits the cache for the rest |
| 314 | /// of the request. New blocks belong above the handoff boundary unless they |
| 315 | /// themselves are turn-volatile. Working-set metadata is now injected into the |
| 316 | /// latest user message as per-turn metadata instead of this system prompt. |
| 317 | pub fn system_prompt_for_mode_with_context_and_skills( |
| 318 | mode: AppMode, |
| 319 | workspace: &Path, |
| 320 | working_set_summary: Option<&str>, |
| 321 | skills_dir: Option<&Path>, |
| 322 | instructions: Option<&[PathBuf]>, |
| 323 | user_memory_block: Option<&str>, |
| 324 | ) -> SystemPrompt { |
| 325 | system_prompt_for_mode_with_context_skills_and_session( |
| 326 | mode, |
| 327 | workspace, |
| 328 | working_set_summary, |
| 329 | skills_dir, |
| 330 | instructions, |
| 331 | PromptSessionContext { |
| 332 | user_memory_block, |
| 333 | goal_objective: None, |
| 334 | locale_tag: "en", |
| 335 | }, |
| 336 | ) |
| 337 | } |
| 338 | |
| 339 | pub fn system_prompt_for_mode_with_context_skills_and_session( |
| 340 | mode: AppMode, |
| 341 | workspace: &Path, |
| 342 | _working_set_summary: Option<&str>, |
| 343 | skills_dir: Option<&Path>, |
| 344 | instructions: Option<&[PathBuf]>, |
| 345 | session_context: PromptSessionContext<'_>, |
| 346 | ) -> SystemPrompt { |
| 347 | system_prompt_for_mode_with_context_skills_session_and_approval( |
| 348 | mode, |
| 349 | workspace, |
| 350 | _working_set_summary, |
| 351 | skills_dir, |
| 352 | instructions, |
| 353 | session_context, |
| 354 | default_approval_mode_for_mode(mode), |
| 355 | ) |
| 356 | } |
| 357 | |
| 358 | pub fn system_prompt_for_mode_with_context_skills_session_and_approval( |
| 359 | mode: AppMode, |
| 360 | workspace: &Path, |
| 361 | _working_set_summary: Option<&str>, |
| 362 | skills_dir: Option<&Path>, |
| 363 | instructions: Option<&[PathBuf]>, |
| 364 | session_context: PromptSessionContext<'_>, |
| 365 | approval_mode: ApprovalMode, |
| 366 | ) -> SystemPrompt { |
| 367 | let mode_prompt = compose_mode_prompt_with_approval(mode, approval_mode); |
| 368 | |
| 369 | // Load project context from workspace |
| 370 | let project_context = load_project_context_with_parents(workspace); |
| 371 | |
| 372 | // 1–2. Mode prompt + project context (or fallback automap). |
| 373 | let mut full_prompt = if let Some(project_block) = project_context.as_system_block() { |
| 374 | format!("{}\n\n{}", mode_prompt, project_block) |
| 375 | } else { |
| 376 | // Fallback: Generate an automatic project map summary |
| 377 | let summary = crate::utils::summarize_project(workspace); |
| 378 | let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt |
| 379 | format!( |
| 380 | "{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```", |
| 381 | mode_prompt, summary, tree |
| 382 | ) |
| 383 | }; |
| 384 | |
| 385 | // 2.25. Environment block — locale, platform, shell, pwd. All |
| 386 | // four inputs are session-stable (workspace path is fixed for |
| 387 | // the run; locale is loaded once by the caller; platform/shell |
| 388 | // come from process env). Inserted above instructions/skills so |
| 389 | // it remains in the workspace-static cache layer alongside the |
| 390 | // mode prompt and project context. |
| 391 | full_prompt = format!( |
| 392 | "{full_prompt}\n\n{}", |
| 393 | render_environment_block(workspace, session_context.locale_tag), |
| 394 | ); |
| 395 | |
| 396 | // 2.5a. Configured `instructions = [...]` files (#454). Loaded |
| 397 | // and concatenated in declared order. Lives above the skills |
| 398 | // block so it's part of the workspace-static layer that the KV |
| 399 | // prefix cache can hit, and so per-project overrides apply |
| 400 | // consistently turn-over-turn. |
| 401 | if let Some(paths) = instructions |
| 402 | && let Some(block) = render_instructions_block(paths) |
| 403 | { |
| 404 | full_prompt = format!("{full_prompt}\n\n{block}"); |
| 405 | } |
| 406 | |
| 407 | // 2.5b. User memory block (#489). Goes above skills/context-management |
| 408 | // because it's session-stable: the memory file changes when the user |
| 409 | // edits it via `/memory` or `# foo` quick-add, but not turn-over-turn. |
| 410 | if let Some(memory_block) = session_context.user_memory_block |
| 411 | && !memory_block.trim().is_empty() |
| 412 | { |
| 413 | full_prompt = format!("{full_prompt}\n\n{memory_block}"); |
| 414 | } |
| 415 | |
| 416 | if let Some(goal_objective) = session_context.goal_objective |
| 417 | && !goal_objective.trim().is_empty() |
| 418 | { |
| 419 | full_prompt = format!( |
| 420 | "{full_prompt}\n\n## Current Session Goal\n\n<session_goal>\n{}\n</session_goal>", |
| 421 | goal_objective.trim() |
| 422 | ); |
| 423 | } |
| 424 | |
| 425 | // 3. Skills block. #432: walks every candidate workspace |
| 426 | // skills directory (`.agents/skills`, `skills`, |
| 427 | // `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus global |
| 428 | // `~/.agents/skills` / `~/.deepseek/skills` so skills installed for any |
| 429 | // AI-tool convention show up in the catalogue. The legacy |
| 430 | // single-`skills_dir` path is |
| 431 | // honoured as a fallback for callers that don't supply a |
| 432 | // workspace-aware view; it falls through to the same merged |
| 433 | // registry when available. |
| 434 | let skills_block = crate::skills::render_available_skills_context_for_workspace(workspace) |
| 435 | .or_else(|| skills_dir.and_then(crate::skills::render_available_skills_context)); |
| 436 | if let Some(block) = skills_block { |
| 437 | full_prompt = format!("{full_prompt}\n\n{block}"); |
| 438 | } |
| 439 | |
| 440 | // 4. Context Management (Agent / Yolo only). |
| 441 | if matches!(mode, AppMode::Agent | AppMode::Yolo) { |
| 442 | full_prompt.push_str( |
| 443 | "\n\n## Context Management\n\n\ |
| 444 | When the conversation gets long (you'll see a context usage indicator), you can:\n\ |
| 445 | 1. Use `/compact` to summarize earlier context and free up space\n\ |
| 446 | 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ |
| 447 | 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ |
| 448 | If you notice context is getting long (>80%), proactively suggest using `/compact` to the user.\n\n\ |
| 449 | ### Prompt-cache awareness\n\n\ |
| 450 | DeepSeek caches the longest *byte-stable prefix* of every request and charges roughly 100× less for cache-hit tokens than miss tokens. The system prompt above is layered most-static-first specifically so the prefix stays stable turn-over-turn. To keep cache hits high:\n\ |
| 451 | - **Working set location:** the current repo working set is injected into the latest user message inside a `<turn_meta>` block. Treat it as high-priority turn metadata, not as a stable system-prompt section.\n\ |
| 452 | - **Append, don't reorder.** New context goes at the end (latest user / tool messages). Reshuffling earlier messages or rewriting their content invalidates the cache for everything after the change.\n\ |
| 453 | - **Don't paraphrase quoted content.** If you've already read a file, refer to it by path or line range instead of re-quoting it with different formatting.\n\ |
| 454 | - **Use `/compact` as a hard reset, not a tweak.** Compaction is meant for when the cache is already losing — it intentionally rewrites the prefix to a shorter summary. Don't trigger it for small wins.\n\ |
| 455 | - **Read once, refer back.** Re-reading the same file produces a different tool-result envelope than the prior read; it's cheaper to scroll back than to re-fetch.\n\ |
| 456 | - **Footer chip:** the `cache hit %` chip turns red below 40% and yellow below 80%. If it's been red for several turns, that's a signal to consolidate." |
| 457 | ); |
| 458 | } |
| 459 | |
| 460 | // 5. Compaction handoff template — so the model knows the format to use |
| 461 | // when writing `.deepseek/handoff.md` on exit / `/compact`. |
| 462 | full_prompt.push_str("\n\n"); |
| 463 | full_prompt.push_str(COMPACT_TEMPLATE); |
| 464 | |
| 465 | // ── Volatile-content boundary ───────────────────────────────────────── |
| 466 | // Everything below drifts mid-session and busts the prefix cache for |
| 467 | // bytes that follow. Keep new static blocks above this comment. |
| 468 | |
| 469 | // 6. Previous-session handoff (file-backed, rewritten by `/compact`). |
| 470 | if let Some(handoff_block) = load_handoff_block(workspace) { |
| 471 | full_prompt = format!("{full_prompt}\n\n{handoff_block}"); |
| 472 | } |
| 473 | |
| 474 | SystemPrompt::Text(full_prompt) |
| 475 | } |
| 476 | |
| 477 | /// Build a system prompt with explicit project context |
| 478 | pub fn build_system_prompt(base: &str, project_context: Option<&ProjectContext>) -> SystemPrompt { |
| 479 | let full_prompt = |
| 480 | match project_context.and_then(super::project_context::ProjectContext::as_system_block) { |
| 481 | Some(project_block) => format!("{}\n\n{}", base.trim(), project_block), |
| 482 | None => base.trim().to_string(), |
| 483 | }; |
| 484 | SystemPrompt::Text(full_prompt) |
| 485 | } |
| 486 | |
| 487 | // ── Legacy functions for backwards compatibility ────────────────────── |
| 488 | |
| 489 | pub fn base_system_prompt() -> SystemPrompt { |
| 490 | SystemPrompt::Text(BASE_PROMPT.trim().to_string()) |
| 491 | } |
| 492 | |
| 493 | pub fn normal_system_prompt() -> SystemPrompt { |
| 494 | system_prompt_for_mode(AppMode::Agent) |
| 495 | } |
| 496 | |
| 497 | pub fn agent_system_prompt() -> SystemPrompt { |
| 498 | system_prompt_for_mode(AppMode::Agent) |
| 499 | } |
| 500 | |
| 501 | pub fn yolo_system_prompt() -> SystemPrompt { |
| 502 | system_prompt_for_mode(AppMode::Yolo) |
| 503 | } |
| 504 | |
| 505 | pub fn plan_system_prompt() -> SystemPrompt { |
| 506 | system_prompt_for_mode(AppMode::Plan) |
| 507 | } |
| 508 | |
| 509 | #[cfg(test)] |
| 510 | mod tests { |
| 511 | // Don't assert on prose. If you wouldn't fail a code review for |
| 512 | // changing the wording, don't fail a test for it. |
| 513 | use super::*; |
| 514 | use tempfile::tempdir; |
| 515 | |
| 516 | /// Discriminator unique to the injected handoff block (not present in the |
| 517 | /// agent prompt's own discussion of the convention). |
| 518 | const HANDOFF_BLOCK_MARKER: &str = "left a handoff at `.deepseek/handoff.md`"; |
| 519 | |
| 520 | #[test] |
| 521 | fn render_environment_block_lists_supplied_locale_and_workspace() { |
| 522 | let tmp = tempdir().expect("tempdir"); |
| 523 | let block = render_environment_block(tmp.path(), "zh-Hans"); |
| 524 | assert!(block.starts_with("## Environment")); |
| 525 | assert!(block.contains("- lang: zh-Hans")); |
| 526 | assert!(block.contains(&format!("- pwd: {}", tmp.path().display()))); |
| 527 | assert!(block.contains("- platform:")); |
| 528 | assert!(block.contains("- shell:")); |
| 529 | } |
| 530 | |
| 531 | #[test] |
| 532 | fn environment_block_is_inserted_into_system_prompt() { |
| 533 | let tmp = tempdir().expect("tempdir"); |
| 534 | let prompt = match system_prompt_for_mode_with_context_skills_and_session( |
| 535 | AppMode::Agent, |
| 536 | tmp.path(), |
| 537 | None, |
| 538 | None, |
| 539 | None, |
| 540 | PromptSessionContext { |
| 541 | user_memory_block: None, |
| 542 | goal_objective: None, |
| 543 | locale_tag: "ja", |
| 544 | }, |
| 545 | ) { |
| 546 | SystemPrompt::Text(text) => text, |
| 547 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 548 | }; |
| 549 | assert!(prompt.contains("## Environment")); |
| 550 | assert!(prompt.contains("- lang: ja")); |
| 551 | } |
| 552 | |
| 553 | #[test] |
| 554 | fn handoff_artifact_is_prepended_to_system_prompt_when_present() { |
| 555 | let tmp = tempdir().expect("tempdir"); |
| 556 | let workspace = tmp.path(); |
| 557 | let handoff_dir = workspace.join(".deepseek"); |
| 558 | std::fs::create_dir_all(&handoff_dir).unwrap(); |
| 559 | std::fs::write( |
| 560 | handoff_dir.join("handoff.md"), |
| 561 | "# Session handoff — prior\n\n## Active task\nFinish #32.\n\n## Open blockers\n- [ ] write the basic version\n", |
| 562 | ) |
| 563 | .unwrap(); |
| 564 | |
| 565 | let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) { |
| 566 | SystemPrompt::Text(text) => text, |
| 567 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 568 | }; |
| 569 | |
| 570 | assert!(prompt.contains(HANDOFF_BLOCK_MARKER)); |
| 571 | assert!(prompt.contains("Finish #32.")); |
| 572 | assert!(prompt.contains("write the basic version")); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn missing_handoff_does_not_inject_block() { |
| 577 | let tmp = tempdir().expect("tempdir"); |
| 578 | let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, tmp.path(), None) { |
| 579 | SystemPrompt::Text(text) => text, |
| 580 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 581 | }; |
| 582 | assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); |
| 583 | } |
| 584 | |
| 585 | #[test] |
| 586 | fn empty_handoff_file_does_not_inject_block() { |
| 587 | let tmp = tempdir().expect("tempdir"); |
| 588 | let dir = tmp.path().join(".deepseek"); |
| 589 | std::fs::create_dir_all(&dir).unwrap(); |
| 590 | std::fs::write(dir.join("handoff.md"), " \n\n ").unwrap(); |
| 591 | let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, tmp.path(), None) { |
| 592 | SystemPrompt::Text(text) => text, |
| 593 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 594 | }; |
| 595 | assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); |
| 596 | } |
| 597 | |
| 598 | #[test] |
| 599 | fn compose_prompt_includes_all_layers() { |
| 600 | let prompt = compose_prompt(AppMode::Agent, Personality::Calm); |
| 601 | // Base layer |
| 602 | assert!(prompt.contains("You are DeepSeek TUI")); |
| 603 | // Personality layer |
| 604 | assert!(prompt.contains("Personality: Calm")); |
| 605 | // Mode layer |
| 606 | assert!(prompt.contains("Mode: Agent")); |
| 607 | // Approval layer |
| 608 | assert!(prompt.contains("Approval Policy: Suggest")); |
| 609 | } |
| 610 | |
| 611 | #[test] |
| 612 | fn compose_prompt_deterministic_order() { |
| 613 | let prompt = compose_prompt(AppMode::Yolo, Personality::Calm); |
| 614 | let base_pos = prompt.find("You are DeepSeek TUI").unwrap(); |
| 615 | let personality_pos = prompt.find("Personality: Calm").unwrap(); |
| 616 | let mode_pos = prompt.find("Mode: YOLO").unwrap(); |
| 617 | let approval_pos = prompt.find("Approval Policy: Auto").unwrap(); |
| 618 | |
| 619 | assert!(base_pos < personality_pos); |
| 620 | assert!(personality_pos < mode_pos); |
| 621 | assert!(mode_pos < approval_pos); |
| 622 | } |
| 623 | |
| 624 | #[test] |
| 625 | fn each_mode_gets_correct_approval() { |
| 626 | assert!( |
| 627 | compose_prompt(AppMode::Agent, Personality::Calm).contains("Approval Policy: Suggest") |
| 628 | ); |
| 629 | assert!(compose_prompt(AppMode::Yolo, Personality::Calm).contains("Approval Policy: Auto")); |
| 630 | assert!( |
| 631 | compose_prompt(AppMode::Plan, Personality::Calm).contains("Approval Policy: Never") |
| 632 | ); |
| 633 | } |
| 634 | |
| 635 | #[test] |
| 636 | fn agent_prompt_can_reflect_never_approval_policy() { |
| 637 | let prompt = |
| 638 | compose_prompt_with_approval(AppMode::Agent, Personality::Calm, ApprovalMode::Never); |
| 639 | assert!(prompt.contains("Mode: Agent")); |
| 640 | assert!(prompt.contains("Approval Policy: Never")); |
| 641 | assert!(prompt.contains("/config approval_mode suggest")); |
| 642 | } |
| 643 | |
| 644 | #[test] |
| 645 | fn personality_switches_correctly() { |
| 646 | let calm = compose_prompt(AppMode::Agent, Personality::Calm); |
| 647 | let playful = compose_prompt(AppMode::Agent, Personality::Playful); |
| 648 | assert!(calm.contains("Personality: Calm")); |
| 649 | assert!(playful.contains("Personality: Playful")); |
| 650 | assert!(!calm.contains("Personality: Playful")); |
| 651 | } |
| 652 | |
| 653 | #[test] |
| 654 | fn compact_template_is_included_in_full_prompt() { |
| 655 | let tmp = tempdir().expect("tempdir"); |
| 656 | let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, tmp.path(), None) { |
| 657 | SystemPrompt::Text(text) => text, |
| 658 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 659 | }; |
| 660 | assert!(prompt.contains("## Compaction Handoff")); |
| 661 | // #429: structured Markdown template. Goal/Constraints/Progress |
| 662 | // (Done/InProgress/Blocked)/Key Decisions/Next step. |
| 663 | assert!(prompt.contains("### Goal")); |
| 664 | assert!(prompt.contains("### Constraints")); |
| 665 | assert!(prompt.contains("### Progress")); |
| 666 | assert!(prompt.contains("#### Done")); |
| 667 | assert!(prompt.contains("#### In Progress")); |
| 668 | assert!(prompt.contains("#### Blocked")); |
| 669 | assert!(prompt.contains("### Key Decisions")); |
| 670 | assert!(prompt.contains("### Next step")); |
| 671 | } |
| 672 | |
| 673 | #[test] |
| 674 | fn session_goal_is_injected_above_handoff_tail() { |
| 675 | let tmp = tempdir().expect("tempdir"); |
| 676 | let prompt = match system_prompt_for_mode_with_context_skills_and_session( |
| 677 | AppMode::Agent, |
| 678 | tmp.path(), |
| 679 | Some("## Repo Working Set\nsrc/lib.rs"), |
| 680 | None, |
| 681 | None, |
| 682 | PromptSessionContext { |
| 683 | user_memory_block: None, |
| 684 | goal_objective: Some("Fix transcript corruption"), |
| 685 | locale_tag: "en", |
| 686 | }, |
| 687 | ) { |
| 688 | SystemPrompt::Text(text) => text, |
| 689 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 690 | }; |
| 691 | |
| 692 | let goal_pos = prompt.find("<session_goal>").expect("goal block"); |
| 693 | let compact_pos = prompt.find("## Compaction Handoff").expect("compact block"); |
| 694 | |
| 695 | assert!(prompt.contains("Fix transcript corruption")); |
| 696 | assert!(goal_pos < compact_pos); |
| 697 | assert!(!prompt.contains("src/lib.rs")); |
| 698 | } |
| 699 | |
| 700 | #[test] |
| 701 | fn empty_session_goal_is_not_injected() { |
| 702 | let tmp = tempdir().expect("tempdir"); |
| 703 | let prompt = match system_prompt_for_mode_with_context_skills_and_session( |
| 704 | AppMode::Agent, |
| 705 | tmp.path(), |
| 706 | None, |
| 707 | None, |
| 708 | None, |
| 709 | PromptSessionContext { |
| 710 | user_memory_block: None, |
| 711 | goal_objective: Some(" "), |
| 712 | locale_tag: "en", |
| 713 | }, |
| 714 | ) { |
| 715 | SystemPrompt::Text(text) => text, |
| 716 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 717 | }; |
| 718 | |
| 719 | assert!(!prompt.contains("<session_goal>")); |
| 720 | assert!(!prompt.contains("## Current Session Goal")); |
| 721 | } |
| 722 | |
| 723 | #[test] |
| 724 | fn when_not_to_use_sections_present() { |
| 725 | let prompt = compose_prompt(AppMode::Agent, Personality::Calm); |
| 726 | assert!(prompt.contains("When NOT to use certain tools")); |
| 727 | assert!(prompt.contains("### `apply_patch`")); |
| 728 | assert!(prompt.contains("### `edit_file`")); |
| 729 | assert!(prompt.contains("### `exec_shell`")); |
| 730 | assert!(prompt.contains("### `agent_spawn`")); |
| 731 | assert!(prompt.contains("### `rlm`")); |
| 732 | } |
| 733 | |
| 734 | /// #588: language-mirroring directive must ship in every mode so |
| 735 | /// DeepSeek's `reasoning_content` and final reply follow the user's |
| 736 | /// language. Structural test — wording is not a test concern, but |
| 737 | /// the cross-cutting commitment of #588 is specifically that the |
| 738 | /// `reasoning_content` field tracks the user's language (not just |
| 739 | /// the visible reply); pin that anchor token so a future edit |
| 740 | /// can't silently weaken the section to a generic "respond in the |
| 741 | /// user's language" directive while keeping the heading. |
| 742 | #[test] |
| 743 | fn language_mirroring_section_present_in_all_modes() { |
| 744 | for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { |
| 745 | let prompt = compose_prompt(mode, Personality::Calm); |
| 746 | assert!( |
| 747 | prompt.contains("## Language"), |
| 748 | "## Language section missing from mode {mode:?}" |
| 749 | ); |
| 750 | assert!( |
| 751 | prompt.contains("reasoning_content"), |
| 752 | "## Language section in {mode:?} must mention `reasoning_content` — \ |
| 753 | that field name is the structural anchor for the #588 commitment that \ |
| 754 | internal reasoning, not just the visible reply, follows the user's language" |
| 755 | ); |
| 756 | } |
| 757 | } |
| 758 | |
| 759 | /// #358: rlm guidance was reframed from "first-class" to "specialty |
| 760 | /// tool" — verify the structural markers are present so a future |
| 761 | /// change doesn't silently remove the RLM section entirely. |
| 762 | /// |
| 763 | /// Don't assert on prose. If you wouldn't fail a code review for |
| 764 | /// changing the wording, don't fail a test for it. |
| 765 | #[test] |
| 766 | fn rlm_specialty_tool_guidance_present() { |
| 767 | let prompt = compose_prompt(AppMode::Agent, Personality::Calm); |
| 768 | // Structural: the RLM heading must exist as a section anchor. |
| 769 | assert!(prompt.contains("RLM — When to Use It")); |
| 770 | // Structural: the word "rlm" must appear multiple times (tool |
| 771 | // name, section heading, toolbox reference). Just verify the |
| 772 | // lowercase form — exact wording is NOT a test concern. |
| 773 | let rlm_count = prompt.to_lowercase().matches("rlm").count(); |
| 774 | assert!( |
| 775 | rlm_count >= 5, |
| 776 | "RLM guidance present: expected >= 5 mentions of 'rlm', got {rlm_count}" |
| 777 | ); |
| 778 | } |
| 779 | |
| 780 | #[test] |
| 781 | fn subagent_done_sentinel_section_present() { |
| 782 | let prompt = compose_prompt(AppMode::Agent, Personality::Calm); |
| 783 | assert!(prompt.contains("Sub-agent completion sentinel")); |
| 784 | assert!(prompt.contains("<deepseek:subagent.done>")); |
| 785 | assert!(prompt.contains("Integration protocol")); |
| 786 | } |
| 787 | |
| 788 | #[test] |
| 789 | fn preamble_rhythm_section_present() { |
| 790 | let prompt = compose_prompt(AppMode::Agent, Personality::Calm); |
| 791 | assert!(prompt.contains("Preamble Rhythm")); |
| 792 | assert!(prompt.contains("I'll start by reading the module structure")); |
| 793 | } |
| 794 | |
| 795 | #[test] |
| 796 | fn legacy_constants_still_available() { |
| 797 | // Verify the old .txt constants still compile and contain expected content |
| 798 | assert!(!AGENT_PROMPT.is_empty()); |
| 799 | assert!(!YOLO_PROMPT.is_empty()); |
| 800 | assert!(!PLAN_PROMPT.is_empty()); |
| 801 | } |
| 802 | |
| 803 | // ── Cache-prefix stability harness (#263 step 2) ─────────────────────── |
| 804 | // |
| 805 | // These tests pin the byte-stability invariant required for DeepSeek's |
| 806 | // KV prefix cache to hit: any prompt-construction surface that ends up |
| 807 | // in the cached prefix must produce identical bytes given identical |
| 808 | // inputs across calls. |
| 809 | |
| 810 | use crate::test_support::assert_byte_identical; |
| 811 | |
| 812 | #[test] |
| 813 | fn compose_prompt_is_byte_stable_across_calls() { |
| 814 | // Suspect #4 from #263: mode prompt churn within a single mode. |
| 815 | // Two calls with identical (mode, personality) inputs must produce |
| 816 | // identical bytes — anything else is a cache buster. |
| 817 | for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { |
| 818 | for personality in [Personality::Calm, Personality::Playful] { |
| 819 | let a = compose_prompt(mode, personality); |
| 820 | let b = compose_prompt(mode, personality); |
| 821 | assert_byte_identical( |
| 822 | &format!("compose_prompt(mode={mode:?}, personality={personality:?})"), |
| 823 | &a, |
| 824 | &b, |
| 825 | ); |
| 826 | } |
| 827 | } |
| 828 | } |
| 829 | |
| 830 | #[test] |
| 831 | fn system_prompt_for_mode_with_context_is_byte_stable_for_unchanged_workspace() { |
| 832 | // Same workspace, no working_set / skills churn between calls → |
| 833 | // identical bytes. This pins the most representative production |
| 834 | // surface (engine.rs builds the system prompt via this fn or |
| 835 | // its sibling _and_skills variant on every turn). |
| 836 | let tmp = tempdir().expect("tempdir"); |
| 837 | let workspace = tmp.path(); |
| 838 | |
| 839 | for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { |
| 840 | let a = match system_prompt_for_mode_with_context(mode, workspace, None) { |
| 841 | SystemPrompt::Text(text) => text, |
| 842 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 843 | }; |
| 844 | let b = match system_prompt_for_mode_with_context(mode, workspace, None) { |
| 845 | SystemPrompt::Text(text) => text, |
| 846 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 847 | }; |
| 848 | assert_byte_identical( |
| 849 | &format!("system_prompt_for_mode_with_context(mode={mode:?}) on empty workspace"), |
| 850 | &a, |
| 851 | &b, |
| 852 | ); |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | #[test] |
| 857 | fn system_prompt_ignores_working_set_summary_argument() { |
| 858 | // Working-set metadata is now injected into the latest user message |
| 859 | // per turn. The legacy argument remains for call-site compatibility |
| 860 | // but must not reintroduce volatile bytes into the system prompt. |
| 861 | let tmp = tempdir().expect("tempdir"); |
| 862 | let workspace = tmp.path(); |
| 863 | let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; |
| 864 | |
| 865 | let a = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) |
| 866 | { |
| 867 | SystemPrompt::Text(text) => text, |
| 868 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 869 | }; |
| 870 | let b = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) |
| 871 | { |
| 872 | SystemPrompt::Text(text) => text, |
| 873 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 874 | }; |
| 875 | assert_byte_identical( |
| 876 | "system_prompt_for_mode_with_context with constant working_set summary", |
| 877 | &a, |
| 878 | &b, |
| 879 | ); |
| 880 | assert!( |
| 881 | !a.contains(summary), |
| 882 | "summary must not be embedded in system prompt" |
| 883 | ); |
| 884 | } |
| 885 | |
| 886 | #[test] |
| 887 | fn system_prompt_with_handoff_file_is_byte_stable_when_file_is_unchanged() { |
| 888 | // If `.deepseek/handoff.md` hasn't moved between two builds, the |
| 889 | // rendered prompt must produce identical bytes. The handoff block |
| 890 | // lands below the static boundary in |
| 891 | // `system_prompt_for_mode_with_context_and_skills`. |
| 892 | let tmp = tempdir().expect("tempdir"); |
| 893 | let workspace = tmp.path(); |
| 894 | let handoff_dir = workspace.join(".deepseek"); |
| 895 | std::fs::create_dir_all(&handoff_dir).unwrap(); |
| 896 | std::fs::write( |
| 897 | handoff_dir.join("handoff.md"), |
| 898 | "# Session handoff\n\n## Active task\nFinish #280.\n\n## Open blockers\n- [ ] none\n", |
| 899 | ) |
| 900 | .unwrap(); |
| 901 | |
| 902 | let a = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) { |
| 903 | SystemPrompt::Text(text) => text, |
| 904 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 905 | }; |
| 906 | let b = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) { |
| 907 | SystemPrompt::Text(text) => text, |
| 908 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 909 | }; |
| 910 | assert_byte_identical( |
| 911 | "system_prompt_for_mode_with_context with constant handoff file", |
| 912 | &a, |
| 913 | &b, |
| 914 | ); |
| 915 | assert!(a.contains(HANDOFF_BLOCK_MARKER), "handoff must be embedded"); |
| 916 | assert!(a.contains("Finish #280."), "handoff body must be present"); |
| 917 | } |
| 918 | |
| 919 | #[test] |
| 920 | fn handoff_appears_after_static_blocks_without_working_set() { |
| 921 | // Cache-prefix invariant: the handoff block must come after static |
| 922 | // `## Context Management` and the compaction handoff template |
| 923 | // (`## Compaction Handoff`). Working-set metadata is per-turn user |
| 924 | // metadata now, not a system-prompt tail block. |
| 925 | let tmp = tempdir().expect("tempdir"); |
| 926 | let workspace = tmp.path(); |
| 927 | let handoff_dir = workspace.join(".deepseek"); |
| 928 | std::fs::create_dir_all(&handoff_dir).unwrap(); |
| 929 | std::fs::write(handoff_dir.join("handoff.md"), "# handoff body\n").unwrap(); |
| 930 | |
| 931 | let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; |
| 932 | let prompt = |
| 933 | match system_prompt_for_mode_with_context(AppMode::Agent, workspace, Some(summary)) { |
| 934 | SystemPrompt::Text(text) => text, |
| 935 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 936 | }; |
| 937 | |
| 938 | let context_pos = prompt |
| 939 | .find("## Context Management") |
| 940 | .expect("Context Management section present in Agent mode"); |
| 941 | let compact_pos = prompt |
| 942 | .find("## Compaction Handoff") |
| 943 | .expect("compaction handoff template present"); |
| 944 | let handoff_pos = prompt |
| 945 | .find(HANDOFF_BLOCK_MARKER) |
| 946 | .expect("handoff block present when fixture file exists"); |
| 947 | assert!( |
| 948 | !prompt.contains("## Repo Working Set"), |
| 949 | "working-set summary must stay out of the system prompt" |
| 950 | ); |
| 951 | |
| 952 | assert!( |
| 953 | context_pos < handoff_pos, |
| 954 | "## Context Management must precede the handoff block" |
| 955 | ); |
| 956 | assert!( |
| 957 | compact_pos < handoff_pos, |
| 958 | "## Compaction Handoff must precede the handoff block" |
| 959 | ); |
| 960 | } |
| 961 | |
| 962 | #[test] |
| 963 | fn render_instructions_block_returns_none_for_empty_input() { |
| 964 | assert!(super::render_instructions_block(&[]).is_none()); |
| 965 | } |
| 966 | |
| 967 | #[test] |
| 968 | fn render_instructions_block_skips_missing_files_with_warning() { |
| 969 | let tmp = tempdir().expect("tempdir"); |
| 970 | let real = tmp.path().join("real.md"); |
| 971 | std::fs::write(&real, "real content here").unwrap(); |
| 972 | let bogus = tmp.path().join("does-not-exist.md"); |
| 973 | |
| 974 | let block = super::render_instructions_block(&[bogus.clone(), real.clone()]) |
| 975 | .expect("present file should produce a block"); |
| 976 | assert!(block.contains("real content here")); |
| 977 | assert!(block.contains(&real.display().to_string())); |
| 978 | // Bogus path is skipped, not rendered. |
| 979 | assert!(!block.contains(&bogus.display().to_string())); |
| 980 | } |
| 981 | |
| 982 | #[test] |
| 983 | fn render_instructions_block_concatenates_in_declared_order() { |
| 984 | let tmp = tempdir().expect("tempdir"); |
| 985 | let a = tmp.path().join("a.md"); |
| 986 | let b = tmp.path().join("b.md"); |
| 987 | std::fs::write(&a, "ALPHA_MARKER").unwrap(); |
| 988 | std::fs::write(&b, "BRAVO_MARKER").unwrap(); |
| 989 | |
| 990 | let block = super::render_instructions_block(&[a, b]).expect("non-empty"); |
| 991 | let alpha_pos = block.find("ALPHA_MARKER").expect("alpha rendered"); |
| 992 | let bravo_pos = block.find("BRAVO_MARKER").expect("bravo rendered"); |
| 993 | assert!( |
| 994 | alpha_pos < bravo_pos, |
| 995 | "instructions must concatenate in declared order" |
| 996 | ); |
| 997 | } |
| 998 | |
| 999 | #[test] |
| 1000 | fn render_instructions_block_skips_empty_files() { |
| 1001 | let tmp = tempdir().expect("tempdir"); |
| 1002 | let empty = tmp.path().join("empty.md"); |
| 1003 | let real = tmp.path().join("real.md"); |
| 1004 | std::fs::write(&empty, " \n \n").unwrap(); |
| 1005 | std::fs::write(&real, "real content").unwrap(); |
| 1006 | |
| 1007 | let block = super::render_instructions_block(&[empty, real]).expect("non-empty"); |
| 1008 | // Empty file produces no `<instructions>` section, only the real one. |
| 1009 | let count = block.matches("<instructions").count(); |
| 1010 | assert_eq!(count, 1, "only the non-empty file should produce a section"); |
| 1011 | } |
| 1012 | |
| 1013 | #[test] |
| 1014 | fn render_instructions_block_truncates_oversize_files() { |
| 1015 | let tmp = tempdir().expect("tempdir"); |
| 1016 | let big = tmp.path().join("big.md"); |
| 1017 | // 200 KiB of content — well above the 100 KiB cap. |
| 1018 | std::fs::write(&big, "X".repeat(200 * 1024)).unwrap(); |
| 1019 | |
| 1020 | let block = super::render_instructions_block(&[big]).expect("non-empty"); |
| 1021 | assert!(block.contains("[…elided]"), "truncation marker missing"); |
| 1022 | // Block should be much smaller than the original file. |
| 1023 | assert!( |
| 1024 | block.len() < 110 * 1024, |
| 1025 | "block should be capped near 100 KiB" |
| 1026 | ); |
| 1027 | } |
| 1028 | |
| 1029 | #[test] |
| 1030 | fn instructions_block_appears_in_system_prompt_when_configured() { |
| 1031 | let tmp = tempdir().expect("tempdir"); |
| 1032 | let workspace = tmp.path(); |
| 1033 | let extra = workspace.join("extra-instructions.md"); |
| 1034 | std::fs::write(&extra, "EXTRA_INSTRUCTIONS_MARKER_BODY").unwrap(); |
| 1035 | |
| 1036 | let prompt = match super::system_prompt_for_mode_with_context_and_skills( |
| 1037 | AppMode::Agent, |
| 1038 | workspace, |
| 1039 | None, |
| 1040 | None, |
| 1041 | Some(std::slice::from_ref(&extra)), |
| 1042 | None, |
| 1043 | ) { |
| 1044 | SystemPrompt::Text(text) => text, |
| 1045 | SystemPrompt::Blocks(_) => panic!("expected text system prompt"), |
| 1046 | }; |
| 1047 | |
| 1048 | assert!( |
| 1049 | prompt.contains("EXTRA_INSTRUCTIONS_MARKER_BODY"), |
| 1050 | "configured instructions file body must appear in the prompt" |
| 1051 | ); |
| 1052 | assert!( |
| 1053 | prompt.contains(&extra.display().to_string()), |
| 1054 | "instructions block must annotate its source path" |
| 1055 | ); |
| 1056 | } |
| 1057 | } |
| 1058 |