返回 DeepSeek-TUI-2026
prompts.rs
根目录 / crates / tui / src / prompts.rs
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
1058 lines RUST