返回 CodeWhale
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 from
5 //! the single [`text`] module:
6 //! constitution + personality overlay → `message[0]` (byte-stable).
7 //! mode delta + approval policy → request-time runtime metadata.
8 //! Tool availability comes only from the per-turn model catalog.
9 //!
10 //! Keeping every layer's text in one module makes prompt tuning a
11 //! single-file operation.
12
13 use crate::models::{SystemBlock, SystemPrompt};
14 use crate::project_context::load_project_context_with_parents;
15 use std::path::{Path, PathBuf};
16 use std::sync::{LazyLock, Mutex};
17
18 pub mod base_preview;
19 pub(crate) mod text;
20
21 #[derive(Debug, Clone)]
22 pub struct PromptSessionContext<'a> {
23 pub user_memory_block: Option<&'a str>,
24 pub goal_objective: Option<&'a str>,
25 pub project_context_pack_enabled: bool,
26 /// Resolved BCP-47 locale tag for the `## Environment` block in
27 /// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The
28 /// caller is responsible for resolving this from `Settings`; no
29 /// disk I/O happens inside the prompt builder, so the workspace-
30 /// static portion of the system prompt stays cache-friendly.
31 pub locale_tag: &'a str,
32 /// When true, a ## Language Output Requirement block is appended
33 /// to the system prompt instructing the model to respond in
34 /// the resolved session locale.
35 pub translation_enabled: bool,
36 /// Active model identifier. The bundled constitution is model-agnostic,
37 /// but embedders may still provide a prompt override containing
38 /// `{model_id}`. Defaults to `"codewhale"` when the caller doesn't supply one.
39 pub model_id: &'a str,
40 /// Route-effective context window, when known. Prompt composition no
41 /// longer prints context-window facts, but the field remains part of the
42 /// session context contract for embedders and future runtime metadata.
43 pub context_window_override: Option<u32>,
44 /// Optional output-verbosity mode. `concise` appends a short output
45 /// discipline block; unset keeps the normal conversational prompt.
46 pub verbosity: Option<&'a str>,
47 /// Restrict skill discovery to Codewhale-owned roots plus explicit
48 /// `skills_dir` configuration.
49 pub skills_scan_codewhale_only: bool,
50 /// Immutable plugin snapshot owned by this App/Engine workspace context.
51 /// Never sourced from process-global mutable state.
52 pub plugin_registry: Option<&'a crate::plugins::PluginRegistry>,
53 /// Active mode. Its doctrine overlay ships once here, in the stable
54 /// prefix, rather than being re-asserted in `<turn_meta>` on every user
55 /// message (#4780) — repetition-per-turn out-shouts the constitution and
56 /// makes the model perform compliance instead of exercising judgment.
57 ///
58 /// Changing mode does invalidate the prefix cache, which is the intended
59 /// trade: mode changes are rare, user messages are not.
60 pub mode: crate::tui::app::AppMode,
61 }
62
63 impl Default for PromptSessionContext<'_> {
64 fn default() -> Self {
65 Self {
66 user_memory_block: None,
67 goal_objective: None,
68 project_context_pack_enabled: false,
69 locale_tag: "en",
70 translation_enabled: false,
71 model_id: "codewhale",
72 context_window_override: None,
73 verbosity: None,
74 skills_scan_codewhale_only: false,
75 plugin_registry: None,
76 mode: crate::tui::app::AppMode::Agent,
77 }
78 }
79 }
80
81 /// Conventional location for the structured session relay artifact (#32).
82 /// A previous session writes it on exit / `/compact`; the next session reads
83 /// it back on startup and prepends it to the system prompt so a fresh agent
84 /// doesn't have to re-discover open blockers from scratch.
85 pub const HANDOFF_RELATIVE_PATH: &str = ".codewhale/handoff.md";
86 /// Legacy handoff path for reading from existing installs.
87 const LEGACY_HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md";
88
89 /// Per-file size cap for `instructions = [...]` entries (#454). Mirrors
90 /// the existing project-context cap in `project_context::load_context_file`
91 /// so a malicious / oversized include can't blow the prompt budget on
92 /// its own. Files larger than this are truncated with an explicit `[…truncated: N bytes omitted]`
93 /// marker rather than skipped entirely so the model still sees the head.
94 const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
95
96 /// System prompt block appended when `translation_enabled` is true.
97 /// Instructs the model to respond in the resolved session locale for all
98 /// natural-language output — explanations, summaries, conversation.
99 /// Code identifiers, untranslatable technical terms, and explicitly
100 /// requested English code blocks are exempt.
101 fn translation_output_instruction(locale_tag: &str) -> String {
102 let target_language = translation_target_language_for_tag(locale_tag);
103 format!(
104 "\
105 ## Language Output Requirement\n\
106 \n\
107 The user requires all responses in {target_language}. \
108 Always respond in {target_language} — use natural, professional language for all \
109 explanations, code comments, summaries, and conversational turns. \
110 Only output English for:\n\
111 - Code identifiers (variable names, function names, file paths)\n\
112 - Technical terms that lack a standard translation in {target_language}\n\
113 - Code blocks the user explicitly requests in English\n\n\
114 This is a hard display requirement: the user does not read English, \
115 so any English prose in your response will block their decision-making."
116 )
117 }
118
119 fn concise_output_discipline_instruction() -> &'static str {
120 "\
121 ## Concise Output Discipline
122
123 To minimize token usage and optimize speed:
124 - Output only direct, actionable code, technical steps, or final answers.
125 - Eliminate all conversational filler, fluff, introductions, transitions, or summarizing conclusions.
126 - Do NOT explain what you are about to do or what you have just completed.
127 - Do NOT provide conversational status updates before or after running tools.
128 - Keep explanations and comments extremely brief and technical, explaining only non-obvious reasoning."
129 }
130
131 fn is_concise_verbosity(value: Option<&str>) -> bool {
132 value.is_some_and(|v| v.trim().eq_ignore_ascii_case("concise"))
133 }
134
135 fn translation_target_language_for_tag(locale_tag: &str) -> &'static str {
136 let normalized = locale_tag.trim().to_ascii_lowercase();
137 if normalized.starts_with("ja") {
138 "Japanese (日本語)"
139 } else if normalized.starts_with("zh-hant")
140 || normalized.contains("-tw")
141 || normalized.contains("-hk")
142 || normalized.contains("-mo")
143 {
144 "Traditional Chinese (繁體中文)"
145 } else if normalized.starts_with("zh") {
146 "Simplified Chinese (简体中文)"
147 } else if normalized.starts_with("pt") {
148 "Brazilian Portuguese (Português do Brasil)"
149 } else if normalized.starts_with("es") {
150 "Latin American Spanish (Español latinoamericano)"
151 } else if normalized.starts_with("vi") {
152 "Vietnamese (Tiếng Việt)"
153 } else if normalized.starts_with("ko") {
154 "Korean (한국어)"
155 } else if normalized.starts_with("ca") {
156 "Catalan (Català)"
157 } else if normalized.starts_with("de") {
158 "German (Deutsch)"
159 } else if normalized.starts_with("fr") {
160 "French (Français)"
161 } else if normalized.starts_with("id") {
162 "Indonesian (Bahasa Indonesia)"
163 } else if normalized.starts_with("hi") {
164 "Hindi (हिन्दी)"
165 } else if normalized.starts_with("ru") {
166 "Russian (Русский)"
167 } else if normalized.starts_with("uk") {
168 "Ukrainian (Українська)"
169 } else {
170 "English"
171 }
172 }
173
174 /// Render a `## Environment` block listing the resolved locale tag and the
175 /// actionable host facts that affect command syntax.
176 ///
177 /// The block is appended to the workspace-static portion of the system
178 /// prompt (after mode prompt + project context, before configured
179 /// instructions / skills). `locale_tag` is resolved by the caller from
180 /// `Settings` so this function stays I/O-free.
181 ///
182 /// `platform` and `shell` remain because they change how commands must be
183 /// written and are stable for the life of the process. The release version was
184 /// removed by the turn-meta diet: it is telemetry the model cannot act on and
185 /// churned the otherwise-static prefix on every release. The live workspace
186 /// path is delivered per-turn via `<turn_meta>` (see `turn_metadata_block`).
187 pub(crate) fn render_environment_block(_workspace: &Path, locale_tag: &str) -> String {
188 let platform = std::env::consts::OS;
189 let shell = crate::shell_dispatcher::global_dispatcher()
190 .kind()
191 .binary()
192 .to_string();
193
194 format!(
195 "## Environment\n\
196 \n\
197 - lang: {locale_tag}\n\
198 - platform: {platform}\n\
199 - shell: {shell}"
200 )
201 }
202
203 /// Source for an `EngineConfig.instructions` entry. Either a disk file (loaded
204 /// at render time, original semantics) or an inline string (content baked into
205 /// `EngineConfig`, no disk I/O at render time).
206 ///
207 /// The inline variant is useful for embedders that compute instructions at
208 /// runtime (e.g. rendering a template with workspace-specific substitutions)
209 /// and don't want to stage the content to a disk file just to satisfy a path
210 /// API. Staging adds two problems the inline path avoids:
211 ///
212 /// 1. The disk file looks like editable config but gets overwritten on
213 /// every launch — confusing for users browsing the install dir.
214 /// 2. Multi-engine setups need per-engine paths to avoid `rehydrate`
215 /// reading another session's instructions; with inline sources the
216 /// content lives in the per-engine `EngineConfig` and the race
217 /// surface goes away.
218 ///
219 /// `From<PathBuf>` is provided so existing callers passing `Vec<PathBuf>` can
220 /// keep working with a `.into()` upgrade at the call site.
221 #[derive(Debug, Clone)]
222 pub enum InstructionSource {
223 /// Load this file from disk at prompt-render time. Original behavior:
224 /// missing files are skipped with a warning, oversized files are
225 /// truncated to `INSTRUCTIONS_FILE_MAX_BYTES` with an `[…elided]`
226 /// marker.
227 File(PathBuf),
228 /// Use the provided string directly. `name` becomes the
229 /// `<instructions source="…">` attribute (typically a synthetic
230 /// identifier like `embedded:my-template` or a logical path).
231 Inline { name: String, content: String },
232 }
233
234 impl From<PathBuf> for InstructionSource {
235 fn from(path: PathBuf) -> Self {
236 InstructionSource::File(path)
237 }
238 }
239
240 impl From<&PathBuf> for InstructionSource {
241 fn from(path: &PathBuf) -> Self {
242 InstructionSource::File(path.clone())
243 }
244 }
245
246 /// Render the `instructions = [...]` config array as a single
247 /// system-prompt block (#454). Each source is processed in declared order;
248 /// missing `File` sources are skipped with a tracing warning so a stale entry
249 /// doesn't fail the launch. Empty input (or all sources missing/empty)
250 /// returns `None` so callers append nothing.
251 fn render_instructions_block(sources: &[InstructionSource]) -> Option<String> {
252 let mut sections: Vec<String> = Vec::new();
253 for source in sources {
254 let (raw_source_name, raw_content): (String, String) = match source {
255 InstructionSource::File(path) => match std::fs::read_to_string(path) {
256 Ok(raw) => (path.display().to_string(), raw),
257 Err(err) => {
258 tracing::warn!(
259 target: "instructions",
260 ?err,
261 ?path,
262 "skipping unreadable instructions file"
263 );
264 continue;
265 }
266 },
267 InstructionSource::Inline { name, content } => (name.clone(), content.clone()),
268 };
269 let trimmed = raw_content.trim();
270 if trimmed.is_empty() {
271 continue;
272 }
273 let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
274 let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES)
275 .rev()
276 .find(|&i| trimmed.is_char_boundary(i))
277 .unwrap_or(0);
278 format!(
279 "{}\n[…truncated: {} of {} bytes omitted — consider splitting this instructions file]",
280 &trimmed[..head_end],
281 trimmed.len() - head_end,
282 trimmed.len()
283 )
284 } else {
285 trimmed.to_string()
286 };
287 sections.push(format!(
288 "<instructions source=\"{raw_source_name}\">\n{body}\n</instructions>"
289 ));
290 }
291 if sections.is_empty() {
292 None
293 } else {
294 Some(sections.join("\n\n"))
295 }
296 }
297
298 /// Read the workspace-local relay artifact, if present, and format it as a
299 /// system-prompt block. Returns `None` when the file is absent or empty so
300 /// callers can keep the default-uncluttered prompt for fresh workspaces.
301 fn load_handoff_block(workspace: &Path) -> Option<String> {
302 let primary = workspace.join(HANDOFF_RELATIVE_PATH);
303 let path = if primary.exists() {
304 primary
305 } else {
306 workspace.join(LEGACY_HANDOFF_RELATIVE_PATH)
307 };
308 let raw = std::fs::read_to_string(&path).ok()?;
309 let trimmed = raw.trim();
310 if trimmed.is_empty() {
311 return None;
312 }
313 Some(format!(
314 "## Previous Session Relay\n\nThe previous session in this workspace left a relay artifact at `{HANDOFF_RELATIVE_PATH}`. 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{trimmed}"
315 ))
316 }
317
318 /// Load the structured user-global constitution, if present, and render it as
319 /// its own model-facing block.
320 pub(crate) fn load_user_constitution_block() -> Option<String> {
321 if user_constitution_disabled_by_setup_state() {
322 return None;
323 }
324
325 let path = match codewhale_config::UserConstitution::path() {
326 Ok(path) => path,
327 Err(err) => {
328 tracing::warn!(
329 target: "prompts",
330 "could not resolve user-global constitution path: {err:#}"
331 );
332 return None;
333 }
334 };
335
336 match codewhale_config::UserConstitution::load_from(&path) {
337 codewhale_config::UserConstitutionLoad::Loaded(constitution) => {
338 constitution.render_block(None)
339 }
340 codewhale_config::UserConstitutionLoad::Missing
341 | codewhale_config::UserConstitutionLoad::Empty => None,
342 codewhale_config::UserConstitutionLoad::Invalid(err) => {
343 tracing::warn!(
344 target: "prompts",
345 "skipping invalid user-global constitution {}: {err}",
346 path.display()
347 );
348 None
349 }
350 codewhale_config::UserConstitutionLoad::Unreadable(err) => {
351 tracing::warn!(
352 target: "prompts",
353 "skipping unreadable user-global constitution {}: {err}",
354 path.display()
355 );
356 None
357 }
358 }
359 }
360
361 fn user_constitution_disabled_by_setup_state() -> bool {
362 match codewhale_config::SetupState::load() {
363 Ok(Some(state)) => matches!(
364 state.constitution_choice,
365 codewhale_config::ConstitutionChoice::Bundled
366 | codewhale_config::ConstitutionChoice::Deferred
367 | codewhale_config::ConstitutionChoice::ExpertOverride
368 ),
369 Ok(None) => false,
370 Err(err) => {
371 tracing::warn!(
372 target: "prompts",
373 "could not resolve setup-state path while loading user constitution: {err:#}"
374 );
375 false
376 }
377 }
378 }
379
380 // ── Prompt layers loaded at compile time ──────────────────────────────
381 //
382 // Every bundled prompt layer lives in `prompts/text.rs` as a compile-time
383 // constant (consolidated from the retired per-layer `prompts/*.md` files;
384 // each constant is byte-identical to the file it replaced, trailing newline
385 // included). The constants are re-exported here so the existing
386 // `crate::prompts::NAME` paths used across the crate are unchanged. Edit
387 // prompt text in `text.rs` directly; the test suite below guards content
388 // and ordering invariants (constitution structure and binding gates #4032,
389 // byte-stable prefix ordering, prefix privacy #4632).
390 #[cfg(test)]
391 use text::CALM_PERSONALITY;
392 pub use text::{
393 AGENT_MODE, BASE_PROMPT, COMPACT_TEMPLATE, CORE_EXECUTION_PROFILE_PROMPT,
394 GOAL_CONTINUATION_PROMPT, LANGUAGE_PROMPT, MEMORY_GUIDANCE, OPERATE_MODE, OUTPUT_PROMPT,
395 PLAN_MODE,
396 };
397
398 // ── Embedder prompt overrides ──
399 // Let an embedder replace these compile-time prompt constants at startup,
400 // so brand / slimming customizations live in the embedder crate instead of
401 // editing these files in-tree. Unset → the bundled constant (fully
402 // backward compatible). Intended to be set once at process start, before
403 // any engine spawns; later sets return the rejected override string.
404 static BASE_PROMPT_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
405 static LOCALE_PREAMBLE_ZH_HANS_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
406 static LOCALE_PREAMBLE_JA_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
407 static LOCALE_PREAMBLE_PT_BR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
408 static LOCALE_PREAMBLE_VI_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
409 static LOCALE_CLOSER_ZH_HANS_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
410 static LOCALE_CLOSER_JA_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
411 static LOCALE_CLOSER_PT_BR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
412 static LOCALE_CLOSER_VI_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
413 static AUTHORITY_RECAP_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
414 static STATIC_PROMPT_COMPOSER: std::sync::OnceLock<Box<StaticPromptComposer>> =
415 std::sync::OnceLock::new();
416 static PROMPT_OVERRIDE_NOTICES: LazyLock<Mutex<Vec<String>>> =
417 LazyLock::new(|| Mutex::new(Vec::new()));
418
419 /// Context passed to an embedder-provided static prompt composer.
420 ///
421 /// This hook only replaces the byte-stable base/personality prompt segment.
422 /// Mode deltas, approval policy, Core Execution, and action-specific relay
423 /// formatting stay owned by Codewhale.
424 #[non_exhaustive]
425 #[derive(Debug)]
426 pub struct StaticPromptCtx<'a> {
427 /// Active model identifier after caller-side routing.
428 pub model_id: &'a str,
429 /// Personality overlay requested for the base static prompt.
430 pub personality: Personality,
431 /// Default base/personality prompt layers that would be used without an
432 /// override.
433 pub default_layers: &'a str,
434 }
435
436 /// Embedder hook for replacing Codewhale's byte-stable base/personality prompt
437 /// segment.
438 pub type StaticPromptComposer = dyn Fn(&StaticPromptCtx<'_>) -> String + Send + Sync + 'static;
439
440 /// Replace `BASE_PROMPT` for all subsequent prompt composition. First call
441 /// wins; later calls return the rejected string. Set before spawning any
442 /// engine.
443 pub fn set_base_prompt_override(s: String) -> Result<(), String> {
444 set_prompt_override(&BASE_PROMPT_OVERRIDE, s)
445 }
446
447 // ── Config-directory prompt overrides (issue #3638) ──
448 // Bridge the embedder override hooks above to a user-facing source: an
449 // optional file in the Codewhale config directory. This lets users repurpose
450 // the TUI for non-software use cases (e.g. long-form writing) by swapping the
451 // constitutional base prompt, without editing in-tree files or shipping a
452 // custom embedder build.
453 //
454 // Scope is deliberately narrow: only the byte-stable base prompt segment is
455 // user-overridable. Mode deltas, approval policy, Core Execution, and
456 // action-specific relay formatting stay owned by the runtime assembly (see
457 // `StaticPromptCtx`), so an override cannot strip safety-relevant guidance.
458 // A missing or empty file is a no-op — the bundled constant is used — so this
459 // is fully backward compatible.
460 //
461 // Because replacing the base prompt is a trust-boundary action (per maintainer
462 // review on #3638), the override file alone is NOT sufficient: the user must
463 // also set an explicit opt-in flag (`CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE`).
464 // This keeps replacing the global Constitution a deliberate, auditable act
465 // rather than something a stray file can do.
466
467 /// Relative path, under the config directory, of the optional base-prompt
468 /// (constitution) override file.
469 pub const CONSTITUTION_OVERRIDE_FILE: &str = "prompts/constitution.md";
470
471 /// Env flag that must be set (`1`/`true`/`on`/`yes`) to enable config-dir base
472 /// prompt overrides. Required in addition to the override file so the global
473 /// base prompt can never be replaced by file presence alone.
474 pub const BASE_PROMPT_OVERRIDE_OPT_IN_ENV: &str = "CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE";
475
476 /// Whether the user has explicitly opted in to base-prompt overrides.
477 pub(crate) fn base_prompt_override_opt_in() -> bool {
478 match std::env::var(BASE_PROMPT_OVERRIDE_OPT_IN_ENV) {
479 Ok(v) => matches!(
480 v.trim().to_ascii_lowercase().as_str(),
481 "1" | "true" | "on" | "yes"
482 ),
483 Err(_) => false,
484 }
485 }
486
487 /// Read an optional prompt-override file rooted at `config_dir`.
488 ///
489 /// Returns the file contents when it exists and is non-empty after trimming;
490 /// otherwise `None` so the caller falls back to the embedded default. Pure
491 /// over `config_dir`, so it is unit-testable without touching the global
492 /// override cells.
493 fn read_prompt_override_file(config_dir: &Path, relative: &str) -> Option<String> {
494 let path = config_dir.join(relative);
495 let raw = std::fs::read_to_string(&path).ok()?;
496 if raw.trim().is_empty() {
497 tracing::warn!(
498 target: "prompts",
499 "ignoring empty prompt override file {}",
500 path.display(),
501 );
502 return None;
503 }
504 tracing::info!(
505 target: "prompts",
506 "loaded prompt override from {}",
507 path.display(),
508 );
509 Some(raw)
510 }
511
512 fn push_prompt_override_notice(message: String) {
513 if let Ok(mut notices) = PROMPT_OVERRIDE_NOTICES.lock() {
514 notices.push(message);
515 }
516 }
517
518 pub fn take_prompt_override_notices() -> Vec<String> {
519 PROMPT_OVERRIDE_NOTICES
520 .lock()
521 .map(|mut notices| std::mem::take(&mut *notices))
522 .unwrap_or_default()
523 }
524
525 /// Load user prompt overrides from `config_dir` and install them through the
526 /// existing override hooks. Returns the names of the overrides that were
527 /// applied (for logging/diagnostics).
528 ///
529 /// Call once at startup, before any engine spawns, because the underlying
530 /// override cells are first-call-wins. Missing files are a no-op, preserving
531 /// the bundled defaults.
532 pub fn load_config_dir_prompt_overrides(config_dir: &Path) -> Vec<&'static str> {
533 let mut applied = Vec::new();
534 if let Some(text) = read_prompt_override_file(config_dir, CONSTITUTION_OVERRIDE_FILE) {
535 if !base_prompt_override_opt_in() {
536 // A file exists but the user hasn't opted in. Don't silently
537 // replace the base prompt — surface the gate instead.
538 let warning = format!(
539 "Custom Constitution override found at {}/{} but {} is not set; using the bundled Constitution. Set {}=1 to opt in.",
540 config_dir.display(),
541 CONSTITUTION_OVERRIDE_FILE,
542 BASE_PROMPT_OVERRIDE_OPT_IN_ENV,
543 BASE_PROMPT_OVERRIDE_OPT_IN_ENV,
544 );
545 tracing::warn!(
546 target: "prompts",
547 "{warning}",
548 );
549 push_prompt_override_notice(warning);
550 } else if set_base_prompt_override(text).is_ok() {
551 applied.push("constitution");
552 }
553 }
554 applied
555 }
556
557 /// Resolve the Codewhale config directory and load any prompt overrides found
558 /// there. Convenience wrapper around [`load_config_dir_prompt_overrides`] for
559 /// startup wiring; silently does nothing when the config home cannot be
560 /// resolved.
561 pub fn load_prompt_overrides_from_config_home() {
562 let Ok(home) = codewhale_config::codewhale_home() else {
563 return;
564 };
565 let applied = load_config_dir_prompt_overrides(&home);
566 if !applied.is_empty() {
567 tracing::info!(
568 target: "prompts",
569 "applied {} config-directory prompt override(s): {}",
570 applied.len(),
571 applied.join(", "),
572 );
573 }
574 }
575
576 fn set_prompt_override(cell: &std::sync::OnceLock<String>, s: String) -> Result<(), String> {
577 cell.set(s)
578 }
579
580 fn effective_prompt_override<'a>(
581 cell: &'a std::sync::OnceLock<String>,
582 fallback: &'static str,
583 ) -> &'a str {
584 cell.get().map(String::as_str).unwrap_or(fallback)
585 }
586
587 fn effective_base_prompt() -> &'static str {
588 effective_prompt_override(&BASE_PROMPT_OVERRIDE, BASE_PROMPT)
589 }
590
591 /// Where the base-prompt bytes used by this process actually came from.
592 ///
593 /// #3928: diagnostics used to cite `crates/tui/src/prompts/text.rs`, which is
594 /// a source-tree path that does not exist on an installed binary and says
595 /// nothing about whether an override replaced the constant at startup. This
596 /// reports the runtime truth instead.
597 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
598 pub(crate) enum BasePromptOrigin {
599 /// The `BASE_PROMPT` constant compiled into this binary.
600 Bundled,
601 /// An opted-in `prompts/constitution.md` override installed at startup.
602 ConfigOverride,
603 }
604
605 impl BasePromptOrigin {
606 /// Short, user-facing provenance label. Contains no filesystem paths.
607 pub(crate) fn label(self) -> &'static str {
608 match self {
609 Self::Bundled => "bundled in this codewhale-tui build (BASE_PROMPT, compiled in)",
610 Self::ConfigOverride => concat!(
611 "config-directory override installed at startup ",
612 "(prompts/constitution.md, opt-in enabled)"
613 ),
614 }
615 }
616 }
617
618 /// Runtime provenance of the base prompt for this process.
619 pub(crate) fn base_prompt_origin() -> BasePromptOrigin {
620 if BASE_PROMPT_OVERRIDE.get().is_some() {
621 BasePromptOrigin::ConfigOverride
622 } else {
623 BasePromptOrigin::Bundled
624 }
625 }
626
627 /// The exact base-prompt bytes this process will compose into the system
628 /// prompt — the override when one is installed, the bundled constant
629 /// otherwise.
630 pub(crate) fn effective_base_prompt_text() -> &'static str {
631 effective_base_prompt()
632 }
633
634 /// Where the effective base prompt actually comes from right now (#3928).
635 ///
636 /// Reads the same cells composition reads, so a preview cannot claim "bundled"
637 /// while an override is live. `config_dir` only supplies the path shown in the
638 /// override label; it does not decide whether an override is in effect.
639 #[must_use]
640 pub fn effective_base_prompt_source(config_dir: Option<&Path>) -> base_preview::BasePromptSource {
641 if STATIC_PROMPT_COMPOSER.get().is_some() {
642 // An embedder composer wraps or replaces the whole static layer set, so
643 // it outranks the base-prompt cell as the honest answer.
644 return base_preview::BasePromptSource::EmbedderComposer;
645 }
646 if BASE_PROMPT_OVERRIDE.get().is_some() {
647 return base_preview::BasePromptSource::ConfigOverride {
648 path: config_dir.map_or_else(
649 || CONSTITUTION_OVERRIDE_FILE.to_string(),
650 |dir| dir.join(CONSTITUTION_OVERRIDE_FILE).display().to_string(),
651 ),
652 };
653 }
654 base_preview::BasePromptSource::Bundled
655 }
656
657 fn effective_static_prompt_composer() -> Option<&'static StaticPromptComposer> {
658 STATIC_PROMPT_COMPOSER.get().map(Box::as_ref)
659 }
660
661 fn effective_locale_preamble_zh_hans() -> &'static str {
662 effective_prompt_override(&LOCALE_PREAMBLE_ZH_HANS_OVERRIDE, LOCALE_PREAMBLE_ZH_HANS)
663 }
664
665 fn effective_locale_preamble_ja() -> &'static str {
666 effective_prompt_override(&LOCALE_PREAMBLE_JA_OVERRIDE, LOCALE_PREAMBLE_JA)
667 }
668
669 fn effective_locale_preamble_pt_br() -> &'static str {
670 effective_prompt_override(&LOCALE_PREAMBLE_PT_BR_OVERRIDE, LOCALE_PREAMBLE_PT_BR)
671 }
672
673 fn effective_locale_preamble_vi() -> &'static str {
674 effective_prompt_override(&LOCALE_PREAMBLE_VI_OVERRIDE, LOCALE_PREAMBLE_VI)
675 }
676
677 fn effective_locale_closer_zh_hans() -> &'static str {
678 effective_prompt_override(&LOCALE_CLOSER_ZH_HANS_OVERRIDE, LOCALE_CLOSER_ZH_HANS)
679 }
680
681 fn effective_locale_closer_ja() -> &'static str {
682 effective_prompt_override(&LOCALE_CLOSER_JA_OVERRIDE, LOCALE_CLOSER_JA)
683 }
684
685 fn effective_locale_closer_pt_br() -> &'static str {
686 effective_prompt_override(&LOCALE_CLOSER_PT_BR_OVERRIDE, LOCALE_CLOSER_PT_BR)
687 }
688
689 fn effective_locale_closer_vi() -> &'static str {
690 effective_prompt_override(&LOCALE_CLOSER_VI_OVERRIDE, LOCALE_CLOSER_VI)
691 }
692
693 pub(crate) fn effective_authority_recap() -> &'static str {
694 effective_prompt_override(&AUTHORITY_RECAP_OVERRIDE, AUTHORITY_RECAP)
695 }
696
697 /// Optional locale-native reinforcement preamble prepended to the system
698 /// prompt when the user's UI locale is non-English.
699 ///
700 /// `constitution.md` itself stays English (single source of truth, model is
701 /// natively multilingual, prefix-cache stable across users in the same
702 /// locale). For non-English locales we prepend a short locale-native
703 /// passage so the model's first exposure to the prompt overrides the
704 /// "match user message language" English directive with an explicit
705 /// "use {locale}" instruction in the user's own writing system. Reduces
706 /// the model's reliance on inferring intent from `## Environment.lang`
707 /// — which previously got overpowered by overwhelmingly English task
708 /// context, the symptom reported in #1118 and visible in the WeChat
709 /// screenshot that prompted this change.
710 ///
711 /// The list is intentionally short (`zh-Hans`, `ja`, `pt-BR`, `vi`) even
712 /// though the TUI ships UI packs for many more locales. Other locales fall
713 /// through to `None` and get the English-only directive, which is the same
714 /// behavior as before this change; the test
715 /// `v092_locales_add_no_prompt_bookends_so_prompt_bytes_stay_stable` locks
716 /// that set so adding a UI pack never silently changes prompt bytes.
717 ///
718 /// ## Design philosophy: why a bookend, not a full translation
719 ///
720 /// Community feedback on the WeChat thread that prompted this work
721 /// pointed out — correctly — that DeepSeek V4 is a Chinese-first
722 /// multilingual model, not an English-only model with multilingual
723 /// veneer. Its tokenizer is co-trained on Chinese; `你好` typically
724 /// encodes to ~1 token, not 2 — the "Chinese is expensive in tokens"
725 /// folk wisdom from Western-LLM commentary doesn't apply here.
726 ///
727 /// The naïve translation of that argument would be: ship a fully
728 /// translated `constitution.md` per locale. We deliberately stop short of
729 /// that for v0.8.29. The reasons, ranked:
730 ///
731 /// 1. **Drift risk.** A 200+ line technical prompt has subtle
732 /// phrasing that drives subtle behavior. Every rule change has
733 /// to land in N translated copies, kept in lockstep. The class
734 /// of bug that arises (Chinese users see slightly different
735 /// agent behavior than English users) is hard to reproduce and
736 /// hard to triage from bug reports.
737 /// 2. **Cache stability.** With one English `constitution.md` and a
738 /// per-locale preamble+closer, the largest cacheable chunk
739 /// (mode prompt + project context + environment) stays
740 /// byte-stable within a session and across users in the same
741 /// locale. A fully translated per-locale `constitution.md` keeps cache
742 /// per-locale but doesn't share with English users.
743 /// 3. **Translation QA is expensive.** Each prompt-language pair
744 /// needs a native speaker reviewing tone, register, and rule
745 /// preservation. Getting it 95% right is bad, because the
746 /// missing 5% becomes silent behavior divergence.
747 ///
748 /// What we DO instead — the bookend pattern @MuMu described from
749 /// their other project — is reinforce the locale directive in
750 /// native script at BOTH ends of the prompt. The opening anchors
751 /// behavior at session start; the closing reinforcement
752 /// (`locale_reinforcement_closer`) sits at the maximum-recency
753 /// position right before the user's next message. Empirically this
754 /// is sufficient to keep `reasoning_content` in the target locale
755 /// even as English code accumulates in context turn-over-turn.
756 ///
757 /// If at some future point the bookend proves insufficient — or if
758 /// the maintenance cost of per-locale `constitution.md` files becomes
759 /// preferable to whatever's blocking it — full translation is the
760 /// natural next step. The locale tags here, the test invariants,
761 /// and the closer position would all carry over unchanged.
762 pub(crate) fn locale_reinforcement_preamble(locale_tag: &str) -> Option<&'static str> {
763 match locale_tag {
764 "zh-Hans" | "zh-CN" | "zh" => Some(effective_locale_preamble_zh_hans()),
765 "ja" | "ja-JP" => Some(effective_locale_preamble_ja()),
766 "pt-BR" | "pt" => Some(effective_locale_preamble_pt_br()),
767 "vi" | "vi-VN" => Some(effective_locale_preamble_vi()),
768 _ => None,
769 }
770 }
771
772 /// Locale-native closing reinforcement appended to the very end of the
773 /// system prompt — the bookend MuMu described in the WeChat thread that
774 /// prompted #1118 follow-up work.
775 ///
776 /// The opening preamble alone is not enough: as the model accumulates
777 /// English context turn-over-turn (code, error logs, search results,
778 /// file listings), the recency bias of the transformer's attention
779 /// drifts thinking back toward English even when the user keeps writing
780 /// in their own language. A closing native-script reinforcement sits at
781 /// the position closest to the user's next message — where attention
782 /// weight is highest — and re-asserts the language rule right before
783 /// the model generates `reasoning_content` for the turn.
784 ///
785 /// Like the opening preamble, English (and unknown) locales return
786 /// `None` and the system prompt is byte-identical to the pre-bookend
787 /// behavior.
788 pub(crate) fn locale_reinforcement_closer(locale_tag: &str) -> Option<&'static str> {
789 match locale_tag {
790 "zh-Hans" | "zh-CN" | "zh" => Some(effective_locale_closer_zh_hans()),
791 "ja" | "ja-JP" => Some(effective_locale_closer_ja()),
792 "pt-BR" | "pt" => Some(effective_locale_closer_pt_br()),
793 "vi" | "vi-VN" => Some(effective_locale_closer_vi()),
794 _ => None,
795 }
796 }
797
798 const LOCALE_PREAMBLE_ZH_HANS: &str = "## 语言要求\n\n\
799 你正在 codewhale 中运行。无论任务上下文(代码、错误日志、文件名)\
800 是英文,无论系统提示的其余部分是英文,你都必须用简体中文进行 \
801 `reasoning_content`(内部思考)和最终回复。代码、文件路径、工具名称\
802 (例如 `File`、`Bash`)、环境变量、命令行参数和 URL \
803 保持原样 —— 只有自然语言散文要切换到简体中文。\n\n\
804 如果用户在会话中切换到另一种语言,从下一轮开始跟随切换。\
805 如果用户明确要求(例如 \"think in English\"),则覆盖此规则。";
806
807 const LOCALE_PREAMBLE_JA: &str = "## 言語要件\n\n\
808 codewhale を実行しています。タスクコンテキスト(コード、エラーログ、\
809 ファイル名)が英語であっても、システムプロンプトの他の部分が英語で\
810 あっても、`reasoning_content`(内部思考)と最終的な返信は日本語で\
811 行ってください。コード、ファイルパス、ツール名(例:`File`、\
812 `Bash`)、環境変数、コマンドライン引数、URL は元のまま —— \
813 自然言語の文章のみ日本語に切り替えます。\n\n\
814 ユーザーがセッション中に別の言語に切り替えた場合は、次のターンから\
815 それに従ってください。ユーザーが明示的に要求した場合(例:\
816 \"think in English\")はこのルールを上書きします。";
817
818 const LOCALE_PREAMBLE_PT_BR: &str = "## Requisito de Idioma\n\n\
819 Você está rodando dentro do codewhale. Escreva tanto \
820 `reasoning_content` (seu pensamento interno) quanto a resposta final \
821 em português do Brasil, mesmo quando o contexto da tarefa (código, \
822 logs de erro, nomes de arquivos) estiver em inglês e mesmo quando o \
823 resto do system prompt for em inglês. Mantenha código, caminhos de \
824 arquivos, nomes de ferramentas (por exemplo `File`, \
825 `Bash`), variáveis de ambiente, flags de linha de comando e \
826 URLs no formato original — apenas a prosa em linguagem natural muda \
827 para português do Brasil.\n\n\
828 Se o usuário mudar de idioma no meio da sessão, mude no próximo turno. \
829 Se o usuário pedir explicitamente (por exemplo, \"think in English\"), \
830 isso sobrescreve esta regra.";
831
832 // ── Closing bookends (appended to the very end of the system prompt) ──
833
834 const LOCALE_CLOSER_ZH_HANS: &str = "## 语言再次提醒\n\n\
835 **重要:你的 `reasoning_content`(内部思考)和最终回复必须保持简体中文。** \
836 无论你在这次会话中读到了多少英文代码、错误日志或文档,无论项目上下文 \
837 是英文,思考过程不能漂移到英文。这是会话级硬性要求 —— 用户的语言决定 \
838 你的语言,与上下文中累积的英文内容无关。除非用户明确要求切换(例如 \
839 \"think in English\"),否则继续用简体中文思考和回答。";
840
841 const LOCALE_CLOSER_JA: &str = "## 言語再確認\n\n\
842 **重要:`reasoning_content`(内部思考)と最終的な返信は日本語で行ってください。** \
843 このセッションで読み込んだ英語のコード、エラーログ、ドキュメントの量に \
844 関係なく、プロジェクトコンテキストが英語であっても、思考プロセスを \
845 英語に逸らさないでください。これはセッションレベルの厳格な要件であり、 \
846 ユーザーの言語があなたの言語を決定します。ユーザーが明示的に切り替えを \
847 要求しない限り(例:\"think in English\")、日本語で思考し、回答し続けて \
848 ください。";
849
850 const LOCALE_CLOSER_PT_BR: &str = "## Reforço de Idioma\n\n\
851 **Importante: seu `reasoning_content` (pensamento interno) e a resposta \
852 final devem permanecer em português do Brasil.** Independentemente de \
853 quanto código em inglês, logs de erro ou documentação você ler nesta \
854 sessão, e independentemente de o contexto do projeto ser em inglês, o \
855 processo de pensamento não pode derivar para o inglês. Este é um \
856 requisito rígido em nível de sessão — o idioma do usuário define seu \
857 idioma. A menos que o usuário peça explicitamente a troca (por exemplo, \
858 \"think in English\"), continue pensando e respondendo em português do \
859 Brasil.";
860
861 const LOCALE_PREAMBLE_VI: &str = "## Yêu cầu ngôn ngữ\n\n\
862 Bạn đang chạy trong codewhale. Cho dù ngữ cảnh tác vụ (mã nguồn, nhật ký lỗi, tên tệp) \
863 là tiếng Anh, cho dù phần còn lại của system prompt là tiếng Anh, bạn đều phải sử dụng \
864 tiếng Việt cho phần `reasoning_content` (suy nghĩ nội bộ) và câu trả lời cuối cùng. Các từ \
865 mã nguồn, đường dẫn tệp, tên công cụ (ví dụ `File`, `Bash`), biến môi trường, \
866 tham số dòng lệnh và URL giữ nguyên dạng gốc —— chỉ các văn bản giải thích bằng ngôn ngữ \
867 tự nhiên mới được chuyển sang tiếng Việt.\n\n\
868 Nếu người dùng chuyển sang ngôn ngữ khác trong phiên làm việc, hãy chuyển theo từ lượt tiếp theo. \
869 Nếu người dùng yêu cầu rõ ràng (ví dụ \"think in English\"), hãy ghi đè quy tắc này.";
870
871 const LOCALE_CLOSER_VI: &str = "## Nhắc nhở ngôn ngữ một lần nữa\n\n\
872 **Quan trọng: phần `reasoning_content` (suy nghĩ nội bộ) và phản hồi cuối cùng của bạn phải được viết bằng tiếng Việt.** \
873 Dù bạn có đọc bao nhiêu mã nguồn tiếng Anh, nhật ký lỗi hay tài liệu trong phiên làm việc này, và dù ngữ cảnh \
874 dự án có là tiếng Anh, quá trình suy nghĩ của bạn cũng không được chuyển sang tiếng Anh. Đây là yêu cầu cứng \
875 ở cấp phiên làm việc —— ngôn ngữ của người dùng quyết định ngôn ngữ của bạn, không phụ thuộc vào nội dung tiếng Anh \
876 tích lũy trong ngữ cảnh. Trừ khi người dùng yêu cầu rõ ràng việc chuyển đổi (ví dụ \"think in English\"), \
877 hãy tiếp tục suy nghĩ và trả lời bằng tiếng Việt.";
878
879 // ── Personality selection ─────────────────────────────────────────────
880
881 /// Which personality overlay to apply. Tone is folded into the constitutional
882 /// preamble, so this is a compile-time marker carried through the static-prompt
883 /// composer context rather than a separate overlay.
884 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
885 pub enum Personality {
886 /// Cool, spatial, reserved — the default and only shipped personality.
887 Calm,
888 }
889
890 // ── Composition ───────────────────────────────────────────────────────
891
892 /// Substitute the model id for embedder-supplied prompt overrides that still
893 /// template it. The bundled constitution is deliberately model-agnostic and
894 /// carries no model-fact placeholders.
895 fn apply_model_template(
896 prompt: &str,
897 model_id: &str,
898 _context_window_override: Option<u32>,
899 ) -> String {
900 prompt.replace("{model_id}", model_id)
901 }
902
903 /// Authority recap block — appended at the end of the system prompt,
904 /// just before the user's first message. Uses recency bias constructively
905 /// without restating ranks: precedence is stated only in `BASE_PROMPT`
906 /// § Whose word wins (#4777).
907 const AUTHORITY_RECAP: &str = "\
908 ## Authority Recap
909
910 Codewhale's constitution governs your behavior. Ground truth underlies the
911 whole list: the user may override a fact, but no one may invent one. When
912 guidance conflicts, consult ### Whose word wins — that is the only place
913 precedence is stated.";
914
915 pub(crate) fn compose_prompt_with_approval_model_and_shell(
916 personality: Personality,
917 model_id: &str,
918 ) -> String {
919 let default_layers = compose_default_static_layers(personality, model_id);
920 apply_static_prompt_composer(
921 effective_static_prompt_composer(),
922 personality,
923 model_id,
924 &default_layers,
925 )
926 }
927
928 pub(crate) fn compose_default_static_layers(_personality: Personality, model_id: &str) -> String {
929 compose_default_static_layers_with_context(model_id, None)
930 }
931
932 fn compose_default_static_layers_with_context(
933 model_id: &str,
934 context_window_override: Option<u32>,
935 ) -> String {
936 // Personality is folded into the constitutional preamble/articles — no
937 // separate overlay is appended. Language and output rules are split into
938 // their own static segments so the 0.9.0 constitution stays compact.
939 let layers = format!(
940 "{}\n\n{}\n\n{}",
941 effective_base_prompt().trim(),
942 LANGUAGE_PROMPT.trim(),
943 OUTPUT_PROMPT.trim()
944 );
945 apply_model_template(&layers, model_id, context_window_override)
946 }
947
948 /// Mode doctrine overlay for the stable prefix.
949 ///
950 /// Single mapping shared by prompt composition and the engine's mode-change
951 /// invalidation check, so the two can never disagree about which text a mode
952 /// ships (#4780).
953 pub(crate) fn mode_doctrine(mode: crate::tui::app::AppMode) -> &'static str {
954 use crate::tui::app::AppMode;
955
956 match mode {
957 AppMode::Agent | AppMode::Auto | AppMode::Yolo => AGENT_MODE,
958 AppMode::Plan => PLAN_MODE,
959 AppMode::Operate => OPERATE_MODE,
960 }
961 }
962
963 fn apply_static_prompt_composer(
964 composer: Option<&StaticPromptComposer>,
965 personality: Personality,
966 model_id: &str,
967 default_layers: &str,
968 ) -> String {
969 match composer {
970 Some(composer) => composer(&StaticPromptCtx {
971 model_id,
972 personality,
973 default_layers,
974 }),
975 None => default_layers.to_string(),
976 }
977 }
978
979 // The full base prompt is always used; effective tool availability is enforced
980 // by the tool catalog and execution layer rather than by mutating message[0].
981
982 // ── Public API ────────────────────────────────────────────────────────
983
984 /// Get the system prompt for a specific mode with project context.
985 pub fn system_prompt_for_mode_with_context(
986 workspace: &Path,
987 working_set_summary: Option<&str>,
988 ) -> SystemPrompt {
989 system_prompt_for_mode_with_context_and_skills(workspace, working_set_summary, None, None, None)
990 }
991
992 /// Get the system prompt for a specific mode with project and skills context.
993 ///
994 /// **Volatile-content-last invariant.** Blocks are appended in order from
995 /// most-static to most-volatile so DeepSeek's KV prefix cache hits the
996 /// longest possible byte prefix turn-over-turn:
997 ///
998 /// 1. mode prompt (compile-time constant)
999 /// 2. project context / fallback (workspace-static)
1000 /// 3. skills block (skills-dir-static)
1001 /// 4. `## Core Execution` (compile-time constant)
1002 /// 5. compaction relay template (compile-time constant)
1003 /// 6. relay block — file-backed; rewritten by `/compact` and on exit
1004 ///
1005 /// Anything appended after a volatile block forfeits the cache for the rest
1006 /// of the request. New blocks belong above the relay boundary unless they
1007 /// themselves are turn-volatile. Working-set metadata is now injected into the
1008 /// latest user message as per-turn metadata instead of this system prompt.
1009 pub fn system_prompt_for_mode_with_context_and_skills(
1010 workspace: &Path,
1011 working_set_summary: Option<&str>,
1012 skills_dir: Option<&Path>,
1013 instructions: Option<&[InstructionSource]>,
1014 user_memory_block: Option<&str>,
1015 ) -> SystemPrompt {
1016 system_prompt_for_mode_with_context_skills_and_session(
1017 workspace,
1018 working_set_summary,
1019 skills_dir,
1020 instructions,
1021 PromptSessionContext {
1022 user_memory_block,
1023 goal_objective: None,
1024 project_context_pack_enabled: false,
1025 locale_tag: "en",
1026 translation_enabled: false,
1027 model_id: "codewhale",
1028 context_window_override: None,
1029 verbosity: None,
1030 skills_scan_codewhale_only: false,
1031 plugin_registry: None,
1032 mode: crate::tui::app::AppMode::Agent,
1033 },
1034 )
1035 }
1036
1037 pub fn system_prompt_for_mode_with_context_skills_and_session(
1038 workspace: &Path,
1039 _working_set_summary: Option<&str>,
1040 skills_dir: Option<&Path>,
1041 instructions: Option<&[InstructionSource]>,
1042 session_context: PromptSessionContext<'_>,
1043 ) -> SystemPrompt {
1044 system_prompt_for_mode_with_context_skills_session_and_approval(
1045 workspace,
1046 _working_set_summary,
1047 skills_dir,
1048 instructions,
1049 session_context,
1050 )
1051 }
1052
1053 pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
1054 workspace: &Path,
1055 _working_set_summary: Option<&str>,
1056 skills_dir: Option<&Path>,
1057 instructions: Option<&[InstructionSource]>,
1058 session_context: PromptSessionContext<'_>,
1059 ) -> SystemPrompt {
1060 let default_layers = compose_default_static_layers_with_context(
1061 session_context.model_id,
1062 session_context.context_window_override,
1063 );
1064 let composed = apply_static_prompt_composer(
1065 effective_static_prompt_composer(),
1066 Personality::Calm,
1067 session_context.model_id,
1068 &default_layers,
1069 );
1070
1071 // Mode doctrine is layer 1 of the stable prefix (#4780). It sits above the
1072 // constitution's own layers so the model reads "which mode am I in" before
1073 // the general rules it modulates, and it ships exactly once per prefix
1074 // rather than per user message.
1075 let mode_prompt = format!(
1076 "{}\n\n{}",
1077 mode_doctrine(session_context.mode).trim(),
1078 composed.trim_start()
1079 );
1080
1081 // Load project context from workspace
1082 let project_context = load_project_context_with_parents(workspace);
1083
1084 // 0. Locale-native reinforcement preamble (#1118 follow-up). When the
1085 // user's UI locale is non-English we prepend a short native-script
1086 // passage so the model's first exposure to the prompt is an explicit
1087 // "think and reply in {locale}" directive in the user's own writing
1088 // system — defeats the "task context is English, so the model thinks
1089 // in English even though `lang: zh-Hans` is set" failure mode that
1090 // PR #1398 partially addressed. English (and unknown) locales get
1091 // `None` and keep the previous behavior unchanged.
1092 let preamble = locale_reinforcement_preamble(session_context.locale_tag);
1093
1094 // 1–2. Mode prompt + project context.
1095 // `load_project_context_with_parents` generates an in-memory bounded
1096 // overview when no context file exists, so the fallback should usually be
1097 // available without writing project-local files.
1098 let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
1099 format!("{mode_prompt}\n\n{project_block}")
1100 } else {
1101 // Extremely unlikely: context generation failed (e.g. filesystem error).
1102 // Use mode prompt alone rather than panic.
1103 tracing::warn!("No project context available and auto-generation failed");
1104 mode_prompt
1105 };
1106
1107 if let Some(preamble) = preamble {
1108 full_prompt = format!("{preamble}\n\n{full_prompt}");
1109 }
1110
1111 if let Some(user_constitution_block) = load_user_constitution_block() {
1112 full_prompt = format!("{full_prompt}\n\n{user_constitution_block}");
1113 }
1114
1115 if session_context.project_context_pack_enabled
1116 && let Some(pack) = crate::project_context::generate_project_context_pack(workspace)
1117 {
1118 full_prompt = format!("{full_prompt}\n\n{pack}");
1119 }
1120
1121 // 2.3a. Translation output instruction — when enabled, instruct
1122 // the model to respond in the resolved session locale. Stays
1123 // above the volatile-content boundary because it's a per-session
1124 // flag, not a per-turn one: enabling `/translate` is a session
1125 // toggle, so the prompt-prefix bytes don't drift turn-over-turn.
1126 if session_context.translation_enabled {
1127 full_prompt = format!(
1128 "{full_prompt}\n\n{}",
1129 translation_output_instruction(session_context.locale_tag)
1130 );
1131 }
1132
1133 if is_concise_verbosity(session_context.verbosity) {
1134 full_prompt = format!(
1135 "{full_prompt}\n\n{}",
1136 concise_output_discipline_instruction()
1137 );
1138 }
1139
1140 // 3. Skills block. #432: default discovery walks every compatible
1141 // workspace/global skill directory so skills installed for other AI-tool
1142 // conventions show up in the catalogue. Users can opt into a Codewhale-only
1143 // scan with `[skills] scan_codewhale_only = true`. When an explicit
1144 // `skills_dir` is configured, union it with the workspace view instead of
1145 // treating it as a fallback; the workspace view often returns Some and
1146 // would otherwise shadow the configured directory entirely.
1147 let skill_discovery_mode = crate::skills::SkillDiscoveryMode::from_codewhale_only(
1148 session_context.skills_scan_codewhale_only,
1149 );
1150 let skills_block = match skills_dir {
1151 Some(dir) => {
1152 crate::skills::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
1153 workspace,
1154 dir,
1155 skill_discovery_mode,
1156 session_context.locale_tag,
1157 session_context.plugin_registry,
1158 )
1159 }
1160 None => crate::skills::render_available_skills_context_for_workspace_with_mode_and_plugins(
1161 workspace,
1162 skill_discovery_mode,
1163 session_context.locale_tag,
1164 session_context.plugin_registry,
1165 ),
1166 };
1167 if let Some(block) = skills_block {
1168 full_prompt = format!("{full_prompt}\n\n{block}");
1169 }
1170
1171 // 4. Lean, runtime-only coding discipline. Context pressure, prompt-cache
1172 // accounting, footer presentation, and automatic compaction are host
1173 // responsibilities; teaching their UI to the model dilutes the task.
1174 full_prompt.push_str("\n\n");
1175 full_prompt.push_str(CORE_EXECUTION_PROFILE_PROMPT.trim());
1176
1177 // The compaction/relay format is action-specific context. Automatic
1178 // compaction owns its structured successor brief, while `/relay` appends
1179 // `COMPACT_TEMPLATE` to that command's user message. Keeping the template
1180 // out of every fresh session saves a stable-prefix block without removing
1181 // the capability.
1182
1183 // ── Volatile-content boundary → WorldState fragments ──────────────────
1184 // Constitution (`full_prompt`) stays the cache-stable Blocks[0] prefix.
1185 // Everything below drifts mid-session and is assembled as marked
1186 // WorldState fragments so an env/memory/goal/handoff change can
1187 // `render_diff` without rebuilding unrelated material.
1188
1189 // Workspace fragment: environment + mid-session memory/goal facts.
1190 let mut workspace_parts = vec![render_environment_block(
1191 workspace,
1192 session_context.locale_tag,
1193 )];
1194 if let Some(memory_block) = session_context.user_memory_block
1195 && !memory_block.trim().is_empty()
1196 {
1197 workspace_parts.push(format!("{memory_block}\n\n{MEMORY_GUIDANCE}"));
1198 }
1199 if let Some(harness_block) = crate::continual_harness::prompt_block(workspace) {
1200 workspace_parts.push(harness_block);
1201 }
1202 if let Some(goal_objective) = session_context.goal_objective
1203 && !goal_objective.trim().is_empty()
1204 {
1205 workspace_parts.push(format!(
1206 "## Current Goal\n\n<session_goal>\n{}\n</session_goal>",
1207 goal_objective.trim()
1208 ));
1209 }
1210 let workspace_body = workspace_parts.join("\n\n");
1211
1212 // Permissions fragment: configured `instructions = [...]` files (#454).
1213 let permissions_body = instructions.and_then(render_instructions_block);
1214
1215 // Route fragment: verbosity / translation posture (the model id was
1216 // removed by the turn-meta diet — it is telemetry the model cannot act on).
1217 let route_body = render_route_fragment(&session_context);
1218
1219 // Token-budget / continuity fragment: prior-session handoff relay.
1220 let token_budget_body = load_handoff_block(workspace);
1221
1222 let world_state = world_state_from_session_facts(
1223 Some(workspace_body.as_str()),
1224 permissions_body.as_deref(),
1225 Some(route_body.as_str()),
1226 None, // AgentTopology is updated by runtime callers when available.
1227 None, // Skills stay in the constitution prefix (skills-dir-static).
1228 token_budget_body.as_deref(),
1229 );
1230
1231 let mut blocks = crate::model_context::WorldStateSnapshot {
1232 constitution: full_prompt,
1233 world_state,
1234 }
1235 .to_system_blocks();
1236
1237 // Trailers keep recency bias after WorldState: authority, then locale.
1238 blocks.push(SystemBlock {
1239 block_type: "text".to_string(),
1240 text: effective_authority_recap().trim().to_string(),
1241 cache_control: None,
1242 });
1243 if let Some(closer) = locale_reinforcement_closer(session_context.locale_tag) {
1244 blocks.push(SystemBlock {
1245 block_type: "text".to_string(),
1246 text: closer.trim().to_string(),
1247 cache_control: None,
1248 });
1249 }
1250
1251 SystemPrompt::Blocks(blocks)
1252 }
1253
1254 /// Flatten a system prompt to joined text (tests + debug inspectors).
1255 #[must_use]
1256 pub fn system_prompt_flat_text(prompt: &SystemPrompt) -> String {
1257 match prompt {
1258 SystemPrompt::Text(text) => text.clone(),
1259 SystemPrompt::Blocks(blocks) => blocks
1260 .iter()
1261 .map(|block| block.text.as_str())
1262 .collect::<Vec<_>>()
1263 .join("\n\n"),
1264 }
1265 }
1266
1267 fn render_route_fragment(session_context: &PromptSessionContext<'_>) -> String {
1268 let verbosity = session_context
1269 .verbosity
1270 .map(str::trim)
1271 .filter(|value| !value.is_empty())
1272 .unwrap_or("default");
1273 format!(
1274 "verbosity: {verbosity}\ntranslation: {}",
1275 if session_context.translation_enabled {
1276 "on"
1277 } else {
1278 "off"
1279 },
1280 )
1281 }
1282
1283 /// Build a WorldState from the common volatile session facts.
1284 ///
1285 /// Does not load constitution — callers keep that as the stable base.
1286 pub fn world_state_from_session_facts(
1287 workspace_body: Option<&str>,
1288 permissions_body: Option<&str>,
1289 route_body: Option<&str>,
1290 agent_topology_body: Option<&str>,
1291 skills_tools_body: Option<&str>,
1292 token_budget_body: Option<&str>,
1293 ) -> crate::model_context::WorldState {
1294 let mut state = crate::model_context::WorldState::new();
1295 if let Some(body) = workspace_body.filter(|s| !s.trim().is_empty()) {
1296 state = state.with_workspace(body);
1297 }
1298 if let Some(body) = permissions_body.filter(|s| !s.trim().is_empty()) {
1299 state = state.with_permissions(body);
1300 }
1301 if let Some(body) = route_body.filter(|s| !s.trim().is_empty()) {
1302 state = state.with_route(body);
1303 }
1304 if let Some(body) = agent_topology_body.filter(|s| !s.trim().is_empty()) {
1305 state = state.with_agent_topology(body);
1306 }
1307 if let Some(body) = skills_tools_body.filter(|s| !s.trim().is_empty()) {
1308 state = state.with_skills_tools(body);
1309 }
1310 if let Some(body) = token_budget_body.filter(|s| !s.trim().is_empty()) {
1311 state = state.with_token_budget(body);
1312 }
1313 state
1314 }
1315
1316 #[cfg(test)]
1317 mod tests {
1318 // Don't assert on prose. If you wouldn't fail a code review for
1319 // changing the wording, don't fail a test for it.
1320 use super::*;
1321 use crate::tools::apply_patch::ApplyPatchTool;
1322 use crate::tools::file::{EditFileTool, WriteFileTool};
1323 use crate::tools::handle::HandleReadTool;
1324 use crate::tools::rlm::RlmTool;
1325 use crate::tools::shell::BashTool;
1326 use crate::tools::spec::ToolSpec;
1327 use tempfile::tempdir;
1328
1329 /// Discriminator unique to the injected relay block (not present in the
1330 /// agent prompt's own discussion of the convention).
1331 const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.codewhale/handoff.md`";
1332
1333 // Config-directory prompt override resolution (#3638). These exercise the
1334 // pure file resolver only; the global install path is intentionally not
1335 // unit-tested here because `set_base_prompt_override` writes a process-wide
1336 // `OnceLock` that would leak into sibling tests (same reason
1337 // `prompt_override_storage_reports_duplicate_sets` uses a local cell).
1338
1339 #[test]
1340 fn config_override_reads_present_nonempty_file() {
1341 let tmp = tempdir().expect("tempdir");
1342 let prompts_dir = tmp.path().join("prompts");
1343 std::fs::create_dir_all(&prompts_dir).expect("mkdir");
1344 std::fs::write(
1345 prompts_dir.join("constitution.md"),
1346 "You are a long-form writing companion.\n",
1347 )
1348 .expect("write override");
1349
1350 let got = read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE);
1351 assert_eq!(
1352 got.as_deref(),
1353 Some("You are a long-form writing companion.\n")
1354 );
1355 }
1356
1357 #[test]
1358 fn config_override_absent_file_falls_back() {
1359 let tmp = tempdir().expect("tempdir");
1360 // No prompts/ directory at all → None so the embedded constant is used.
1361 assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_none());
1362 }
1363
1364 #[test]
1365 fn config_override_requires_explicit_opt_in() {
1366 // A present, non-empty override file must NOT replace the base prompt
1367 // unless the explicit opt-in flag is set. This test drains the shared
1368 // process-global PROMPT_OVERRIDE_NOTICES queue, so it must serialize
1369 // against the sibling test that also touches it
1370 // (`tui::ui::tests::prompt_override_notice_surfaces_in_transcript_and_toast`);
1371 // both take `lock_test_env()` for mutual exclusion under the multi-
1372 // threaded test binary.
1373 let _env_guard = crate::test_support::lock_test_env();
1374 let tmp = tempdir().expect("tempdir");
1375 let prompts_dir = tmp.path().join("prompts");
1376 std::fs::create_dir_all(&prompts_dir).expect("mkdir");
1377 std::fs::write(
1378 prompts_dir.join("constitution.md"),
1379 "You are a long-form writing companion.\n",
1380 )
1381 .expect("write override");
1382
1383 // The resolver still finds the file...
1384 assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_some());
1385 // ...but without the opt-in flag, nothing is applied.
1386 if std::env::var(BASE_PROMPT_OVERRIDE_OPT_IN_ENV).is_err() {
1387 let _ = take_prompt_override_notices();
1388 assert!(
1389 load_config_dir_prompt_overrides(tmp.path()).is_empty(),
1390 "override must require the explicit opt-in flag, not just a file"
1391 );
1392 let notices = take_prompt_override_notices();
1393 assert!(
1394 notices
1395 .iter()
1396 .any(|notice| notice.contains(BASE_PROMPT_OVERRIDE_OPT_IN_ENV)
1397 && notice.contains("using the bundled Constitution")),
1398 "gated override should record a visible notice, got {notices:?}"
1399 );
1400 }
1401 }
1402
1403 #[test]
1404 fn config_override_empty_file_is_ignored() {
1405 let tmp = tempdir().expect("tempdir");
1406 let prompts_dir = tmp.path().join("prompts");
1407 std::fs::create_dir_all(&prompts_dir).expect("mkdir");
1408 std::fs::write(prompts_dir.join("constitution.md"), " \n\t\n").expect("write blank");
1409
1410 // Whitespace-only overrides are treated as absent so a stray empty file
1411 // can't silently blank the system prompt.
1412 assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_none());
1413 }
1414
1415 #[test]
1416 fn prompt_override_storage_reports_duplicate_sets() {
1417 let cell = std::sync::OnceLock::new();
1418
1419 assert_eq!(effective_prompt_override(&cell, "fallback"), "fallback");
1420 assert!(set_prompt_override(&cell, "first".to_string()).is_ok());
1421 assert_eq!(effective_prompt_override(&cell, "fallback"), "first");
1422 assert_eq!(
1423 set_prompt_override(&cell, "second".to_string()),
1424 Err("second".to_string())
1425 );
1426 assert_eq!(effective_prompt_override(&cell, "fallback"), "first");
1427 }
1428
1429 #[test]
1430 fn static_prompt_composer_unset_keeps_default_layers_byte_identical() {
1431 let default_layers = compose_default_static_layers(Personality::Calm, "deepseek-v4-flash");
1432 let composed = apply_static_prompt_composer(
1433 None,
1434 Personality::Calm,
1435 "deepseek-v4-flash",
1436 &default_layers,
1437 );
1438
1439 assert_byte_identical("unset static prompt composer", &default_layers, &composed);
1440 }
1441
1442 #[test]
1443 fn static_prompt_composer_receives_context_and_replaces_layers() {
1444 let default_layers = compose_default_static_layers(Personality::Calm, "deepseek-v4-pro");
1445 let composer: Box<StaticPromptComposer> = Box::new(|ctx| {
1446 assert_eq!(ctx.model_id, "deepseek-v4-pro");
1447 assert_eq!(ctx.personality, Personality::Calm);
1448 // The 0.9.0 core is model-agnostic ("You are Codewhale") and
1449 // folds tone in — no per-model id line, no separate personality
1450 // section in default_layers.
1451 assert!(ctx.default_layers.contains("You are Codewhale"));
1452 assert!(
1453 ctx.default_layers
1454 .contains("Take the work seriously. Don't take")
1455 );
1456 assert!(!ctx.default_layers.contains("## Core Tool Taxonomy"));
1457 assert!(!ctx.default_layers.contains("Approval Policy"));
1458 "embedder static prompt".to_string()
1459 });
1460
1461 let composed = apply_static_prompt_composer(
1462 Some(composer.as_ref()),
1463 Personality::Calm,
1464 "deepseek-v4-pro",
1465 &default_layers,
1466 );
1467
1468 assert_eq!(composed, "embedder static prompt");
1469 }
1470
1471 fn contains_cjk(text: &str) -> bool {
1472 text.chars().any(|ch| {
1473 matches!(
1474 ch,
1475 '\u{3040}'..='\u{30ff}'
1476 | '\u{3400}'..='\u{4dbf}'
1477 | '\u{4e00}'..='\u{9fff}'
1478 | '\u{f900}'..='\u{faff}'
1479 )
1480 })
1481 }
1482
1483 #[test]
1484 fn agent_mode_carries_execution_discipline_block() {
1485 for phrase in [
1486 "Execute the user's task autonomously",
1487 "Keep `work_update` current",
1488 "present; otherwise",
1489 "verify load-bearing child",
1490 "never manufacture completion sentinels",
1491 "For substantial work",
1492 "session-persistent `repl` blocks",
1493 "retain source/transcript\nas data",
1494 "`workflow`, `agent`, goals, `harness`",
1495 ] {
1496 assert!(
1497 AGENT_MODE.contains(phrase),
1498 "AGENT_MODE missing execution-discipline phrase {phrase:?}"
1499 );
1500 }
1501 assert!(
1502 !BASE_PROMPT.contains("<tool_persistence>")
1503 && !BASE_PROMPT.contains("Tool-use enforcement"),
1504 "0.9.0 base constitution should not carry the old execution-discipline tail"
1505 );
1506 }
1507
1508 #[test]
1509 fn base_prompt_carries_constitutional_core() {
1510 for phrase in [
1511 "## Codewhale",
1512 "You are Codewhale",
1513 "The A is already yours",
1514 "Let the work speak",
1515 "### Ground truth",
1516 "### User intent and scope",
1517 "### Truthful completion",
1518 "### Put guarantees in mechanism",
1519 "### Whose word wins",
1520 ] {
1521 assert!(
1522 BASE_PROMPT.contains(phrase),
1523 "BASE_PROMPT missing Constitutional phrase {phrase:?}"
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn constitutional_kernel_keeps_first_turn_authority_safety_and_completion() {
1530 let fresh_prefix = compose_default_static_layers(Personality::Calm, "deepseek-v4-pro");
1531 for phrase in [
1532 "Do what the user's current request asks, no more.",
1533 "require express user authorization in",
1534 "otherwise name the decision and ask.",
1535 "external publication, spending",
1536 "credentials, and material scope expansion",
1537 "prohibitions stay binding; convenience creates no exception",
1538 "never route around it or claim prose granted",
1539 "Nothing is done until checked.",
1540 "Read test output, not only exit status",
1541 "External actions are not complete until",
1542 "Work still running is not complete",
1543 "Never present a partial result as the whole.",
1544 "no one may tell you to invent one",
1545 "1. The user's request, this turn.",
1546 "2. This constitution.",
1547 ] {
1548 assert!(
1549 fresh_prefix.contains(phrase),
1550 "fresh constitution prefix missing kernel invariant {phrase:?}"
1551 );
1552 }
1553 }
1554
1555 #[test]
1556 fn procedural_playbooks_are_not_eager_constitution() {
1557 let fresh_prefix = compose_default_static_layers(Personality::Calm, "deepseek-v4-pro");
1558 for heading in [
1559 "### Keep momentum",
1560 "### Think in causes",
1561 "### Honor constraints before preferences",
1562 "### Skill and role constraints are binding",
1563 "### Restraint",
1564 "### Leave continuity",
1565 ] {
1566 assert!(
1567 !fresh_prefix.contains(heading),
1568 "procedural playbook should stay outside the full fresh prefix: {heading:?}"
1569 );
1570 }
1571 assert!(
1572 !BASE_PROMPT.contains("## STATUTES (Tier 2)")
1573 && !BASE_PROMPT.contains("## REGULATIONS (Tier 3)"),
1574 "the balanced Constitution must not restore the old procedural policy tail"
1575 );
1576 }
1577
1578 #[test]
1579 fn base_prompt_carries_verify_then_stop_completion_contract() {
1580 // The completion contract behind "Truthful completion": verify with real
1581 // evidence, keep running work visible, and hand back exactly what
1582 // changed. These phrases encode the contract's semantics, not its
1583 // prose — a rewording that keeps the contract should keep these, and
1584 // one that drops them is a real behavior change worth failing review
1585 // for. (Constitution kernel rewrite in #5077 renamed the section and
1586 // condensed the prose; the contract stands.)
1587 for phrase in [
1588 "Nothing is done until checked.",
1589 "Read test output, not only exit status",
1590 "Work still running is not complete",
1591 "Never present a partial result as the whole.",
1592 ] {
1593 assert!(
1594 BASE_PROMPT.contains(phrase),
1595 "BASE_PROMPT missing completion-contract phrase {phrase:?}"
1596 );
1597 }
1598 }
1599
1600 #[test]
1601 fn yolo_mode_composed_prompt_carries_completion_contract() {
1602 // `codewhale exec --auto` runs AppMode::Yolo; the verify-then-stop
1603 // contract must survive composition into the prompt that mode ships.
1604 let tmp = tempdir().expect("tempdir");
1605 let text = system_prompt_flat_text(
1606 &system_prompt_for_mode_with_context_skills_session_and_approval(
1607 tmp.path(),
1608 None,
1609 None,
1610 None,
1611 PromptSessionContext {
1612 user_memory_block: None,
1613 goal_objective: None,
1614 project_context_pack_enabled: false,
1615 locale_tag: "en",
1616 translation_enabled: false,
1617 model_id: "codewhale",
1618 context_window_override: None,
1619 verbosity: None,
1620 skills_scan_codewhale_only: false,
1621 plugin_registry: None,
1622 mode: crate::tui::app::AppMode::Yolo,
1623 },
1624 ),
1625 );
1626 for phrase in [
1627 "##### Mode: Agent",
1628 "### Truthful completion",
1629 "Nothing is done until checked.",
1630 "Never present a partial result as the whole.",
1631 ] {
1632 assert!(
1633 text.contains(phrase),
1634 "YOLO-mode composed prompt missing completion-contract phrase {phrase:?}"
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn constitutional_hierarchy_keeps_user_turn_above_local_law() {
1641 let heading_at = BASE_PROMPT
1642 .find("### Whose word wins")
1643 .expect("Whose word wins heading present");
1644 let user_at = BASE_PROMPT
1645 .find("1. The user's request, this turn.")
1646 .expect("user request tier present");
1647 let constitution_at = BASE_PROMPT
1648 .find("2. This constitution.")
1649 .expect("constitution tier present");
1650 let project_at = BASE_PROMPT
1651 .find("3. Project law and instructions")
1652 .expect("project tier present");
1653 let preference_at = BASE_PROMPT
1654 .find("4. Your standing user-global preferences.")
1655 .expect("user-global preference tier present");
1656 let memory_at = BASE_PROMPT
1657 .find("5. Memory and previous-session handoffs.")
1658 .expect("memory/handoff tier present");
1659
1660 assert!(
1661 heading_at < user_at
1662 && user_at < constitution_at
1663 && constitution_at < project_at
1664 && project_at < preference_at
1665 && preference_at < memory_at,
1666 "Whose word wins must rank the current user request above constitution, \
1667 project law, standing user-global preferences, then memory/handoffs"
1668 );
1669 assert!(
1670 BASE_PROMPT.contains("the user may override a fact, but no one may invent\none"),
1671 "Whose word wins must keep ground truth overridable but never inventable"
1672 );
1673 assert!(
1674 BASE_PROMPT.contains("A tie you cannot break is not yours to break"),
1675 "Whose word wins must keep tie-break escalation"
1676 );
1677 }
1678
1679 #[test]
1680 fn base_prompt_is_model_fact_free() {
1681 for placeholder in [
1682 "{model_id}",
1683 "{context_window_note}",
1684 "{subagent_economics}",
1685 "{model_thinking_note}",
1686 "{model_characteristics}",
1687 ] {
1688 assert!(
1689 !BASE_PROMPT.contains(placeholder),
1690 "0.9.0 BASE_PROMPT must not contain model-fact placeholder {placeholder}"
1691 );
1692 }
1693 for forbidden in [
1694 "Your V4 Characteristics",
1695 "Model Characteristics",
1696 "one-million-token context window",
1697 "provider-dependent and not known",
1698 ] {
1699 assert!(
1700 !BASE_PROMPT.contains(forbidden),
1701 "0.9.0 BASE_PROMPT must not contain model-specific fact {forbidden:?}"
1702 );
1703 }
1704 }
1705
1706 fn assert_no_unresolved_model_placeholders(prompt: &str) {
1707 for placeholder in [
1708 "{model_id}",
1709 "{context_window_note}",
1710 "{subagent_economics}",
1711 "{model_thinking_note}",
1712 "{model_characteristics}",
1713 ] {
1714 assert!(
1715 !prompt.contains(placeholder),
1716 "composed prompt must not contain unresolved {placeholder}"
1717 );
1718 }
1719 }
1720
1721 #[test]
1722 fn compose_prompt_for_v4_model_stays_model_fact_free() {
1723 let prompt =
1724 compose_prompt_with_approval_model_and_shell(Personality::Calm, "deepseek-v4-pro");
1725 assert!(prompt.contains("You are Codewhale"));
1726 assert!(!prompt.contains("Your V4 Characteristics"));
1727 assert!(!prompt.contains("one-million-token context window"));
1728 assert_no_unresolved_model_placeholders(&prompt);
1729 }
1730
1731 #[test]
1732 fn compose_prompt_for_kimi_stays_model_fact_free() {
1733 let prompt =
1734 compose_prompt_with_approval_model_and_shell(Personality::Calm, "moonshotai/kimi-k2.6");
1735 assert!(prompt.contains("You are Codewhale"));
1736 assert!(!prompt.contains("Your V4 Characteristics"));
1737 assert!(!prompt.contains("one-million"));
1738 assert!(!prompt.contains("$0.14"));
1739 assert!(!prompt.contains("262144-token context window"));
1740 assert!(!prompt.contains("Models may emit *thinking tokens*"));
1741 assert_no_unresolved_model_placeholders(&prompt);
1742 }
1743
1744 #[test]
1745 fn compose_prompt_for_openai_api_gpt_55_stays_model_fact_free() {
1746 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "gpt-5.5");
1747 assert!(prompt.contains("You are Codewhale"));
1748 assert!(!prompt.contains("Your V4 Characteristics"));
1749 assert!(!prompt.contains("1050000-token context window"));
1750 assert!(!prompt.contains("Models may emit *thinking tokens*"));
1751 assert!(!prompt.contains("provider-dependent and not known"));
1752 assert_no_unresolved_model_placeholders(&prompt);
1753 }
1754
1755 #[test]
1756 fn compose_prompt_for_unknown_model_stays_model_fact_free() {
1757 let prompt =
1758 compose_prompt_with_approval_model_and_shell(Personality::Calm, "llama3.3:70b");
1759 assert!(prompt.contains("You are Codewhale"));
1760 assert!(!prompt.contains("Your V4 Characteristics"));
1761 assert!(!prompt.contains("one-million"));
1762 assert!(!prompt.contains("$0.14"));
1763 assert!(!prompt.contains("provider-dependent and not known"));
1764 assert!(!prompt.contains("Models may emit *thinking tokens*"));
1765 assert_no_unresolved_model_placeholders(&prompt);
1766 }
1767
1768 #[test]
1769 fn apply_model_template_replaces_placeholder() {
1770 let result = apply_model_template("You are {model_id}", "deepseek-v4-pro", None);
1771 assert_eq!(result, "You are deepseek-v4-pro");
1772 assert!(!result.contains("{model_id}"));
1773 }
1774
1775 #[test]
1776 fn apply_model_template_does_not_resolve_removed_model_fact_templates() {
1777 let result = apply_model_template("{context_window_note}", "gpt-5.5", Some(400_000));
1778 assert_eq!(result, "{context_window_note}");
1779 assert!(!result.contains("400000-token context window"));
1780 assert!(!result.contains("1050000-token context window"));
1781 }
1782
1783 #[test]
1784 fn compose_prompt_is_model_agnostic_in_preamble() {
1785 // 0.9.0 keeps the preamble byte-for-byte the same regardless of
1786 // model id, and no {model_id} placeholder leaks.
1787 let flash =
1788 compose_prompt_with_approval_model_and_shell(Personality::Calm, "deepseek-v4-flash");
1789 let kimi =
1790 compose_prompt_with_approval_model_and_shell(Personality::Calm, "moonshotai/kimi-k2.6");
1791 assert!(
1792 flash.contains("You are Codewhale"),
1793 "0.9.0 preamble must open with the model-agnostic Codewhale stance"
1794 );
1795 assert!(
1796 !flash.contains("You are deepseek-v4-flash")
1797 && !kimi.contains("You are moonshotai/kimi-k2.6"),
1798 "0.9.0 preamble must not inject a per-model identity line"
1799 );
1800 assert!(
1801 !flash.contains("{model_id}") && !kimi.contains("{model_id}"),
1802 "composed prompt must not contain the raw {{model_id}} placeholder"
1803 );
1804 }
1805
1806 #[test]
1807 fn tool_descriptions_carry_edit_and_shell_guidance() {
1808 let write = WriteFileTool.description();
1809 assert!(
1810 write.contains("instead of heredocs")
1811 && write.contains("`Bash`")
1812 && !write.contains("exec_shell"),
1813 "write guidance must name the live Bash tool and never the retired exec_shell name"
1814 );
1815
1816 let edit = EditFileTool.description();
1817 // Every handler description must name the live `File` surface plus an
1818 // action. `read_file`/`write_file`/`apply_patch` are retired spellings
1819 // (crates/tui/src/tools/registry.rs:2066-2088).
1820 assert!(edit.contains("File `read`"));
1821 assert!(edit.contains("File `patch` or `write`"));
1822 assert!(
1823 !edit.contains("read_file")
1824 && !edit.contains("write_file")
1825 && !edit.contains("apply_patch"),
1826 "edit guidance must not teach a retired tool name: {edit:?}"
1827 );
1828
1829 let patch = ApplyPatchTool.description();
1830 assert!(patch.contains("unified-diff") && patch.contains("transactional"));
1831
1832 let shell_tool = BashTool::new("Bash");
1833 let shell = shell_tool.description();
1834 assert!(shell.contains("background=true"));
1835 assert!(shell.contains(">5 seconds"));
1836 }
1837
1838 #[test]
1839 fn composed_prompt_does_not_claim_tool_availability() {
1840 let prompt =
1841 compose_prompt_with_approval_model_and_shell(Personality::Calm, "deepseek-v4-pro");
1842 assert!(!prompt.contains("## Core Tool Taxonomy"));
1843 assert!(!prompt.contains("## Toolbox"));
1844 assert!(prompt.contains("You are Codewhale"));
1845 }
1846
1847 #[test]
1848 fn authority_recap_appears_in_full_prompt() {
1849 let tmp = tempdir().expect("tempdir");
1850 let text = system_prompt_flat_text(
1851 &system_prompt_for_mode_with_context_skills_session_and_approval(
1852 tmp.path(),
1853 None,
1854 None,
1855 None,
1856 PromptSessionContext::default(),
1857 ),
1858 );
1859 assert!(
1860 text.contains("## Authority Recap"),
1861 "full system prompt must contain the authority recap"
1862 );
1863 assert!(
1864 text.contains("Codewhale's constitution governs your behavior"),
1865 "authority recap must reference the Constitution"
1866 );
1867 assert!(
1868 text.contains("consult ### Whose word wins"),
1869 "authority recap must point at 0.9.0's precedence section"
1870 );
1871 }
1872
1873 #[test]
1874 fn system_prompt_merges_workspace_and_configured_skills_dir() {
1875 let _env_guard = crate::test_support::lock_test_env();
1876 let tmp = tempdir().expect("tempdir");
1877 let _home = ScopedHome::set(tmp.path().join("home"));
1878 let workspace = tmp.path().join("workspace");
1879 let configured_dir = tmp.path().join("configured-skills");
1880 write_test_skill(
1881 &workspace.join(".claude").join("skills"),
1882 "workspace-skill",
1883 "workspace skill",
1884 );
1885 write_test_skill(&configured_dir, "configured-skill", "configured skill");
1886
1887 let text = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills(
1888 &workspace,
1889 None,
1890 Some(&configured_dir),
1891 None,
1892 None,
1893 ));
1894
1895 assert!(text.contains("workspace-skill"));
1896 assert!(text.contains("configured-skill"));
1897 }
1898
1899 struct ScopedHome {
1900 previous: Option<std::ffi::OsString>,
1901 }
1902
1903 impl ScopedHome {
1904 fn set(path: std::path::PathBuf) -> Self {
1905 let previous = std::env::var_os("HOME");
1906 // Safety: this test serializes environment access with
1907 // lock_test_env and restores HOME in Drop.
1908 unsafe {
1909 std::env::set_var("HOME", path);
1910 }
1911 Self { previous }
1912 }
1913 }
1914
1915 impl Drop for ScopedHome {
1916 fn drop(&mut self) {
1917 // Safety: this test serializes environment access with
1918 // lock_test_env and restores HOME in Drop.
1919 unsafe {
1920 if let Some(previous) = self.previous.take() {
1921 std::env::set_var("HOME", previous);
1922 } else {
1923 std::env::remove_var("HOME");
1924 }
1925 }
1926 }
1927 }
1928
1929 fn write_test_skill(root: &std::path::Path, name: &str, description: &str) {
1930 let dir = root.join(name);
1931 std::fs::create_dir_all(&dir).expect("skill dir");
1932 std::fs::write(
1933 dir.join("SKILL.md"),
1934 format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
1935 )
1936 .expect("skill file");
1937 }
1938
1939 #[test]
1940 fn constitution_has_no_separate_personality_tier() {
1941 // 0.9.0 has no personality tier. Voice and tone live in the
1942 // compact constitution rather than a separate section, so
1943 // personality remains folded in by omission.
1944 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
1945 assert!(
1946 !prompt.contains("Personality: Calm — Tier 8"),
1947 "Personality tier should not appear as a separate section"
1948 );
1949 assert!(
1950 prompt.contains("Take the work seriously. Don't take"),
1951 "Preamble should carry tone guidance (take the work, not yourself, seriously)"
1952 );
1953 // Verify the preamble still carries the Codewhale identity.
1954 assert!(prompt.contains("You are Codewhale"));
1955 assert!(prompt.contains("Let the work speak"));
1956 }
1957
1958 #[test]
1959 fn execution_discipline_lives_in_agent_mode_after_core_constitution() {
1960 assert!(AGENT_MODE.contains("Execute the user's task autonomously"));
1961 assert!(AGENT_MODE.contains("verify load-bearing child"));
1962 assert!(
1963 !BASE_PROMPT.contains("Execution Discipline")
1964 && !BASE_PROMPT.contains("<tool_persistence>"),
1965 "base constitution should stay reduced; execution discipline belongs to Agent mode"
1966 );
1967 }
1968
1969 #[test]
1970 fn plan_mode_prompt_uses_one_progress_surface() {
1971 assert!(
1972 PLAN_MODE.contains("When `work_update` is present")
1973 && PLAN_MODE.contains("canonical list there")
1974 && PLAN_MODE.contains("otherwise keep progress in your response"),
1975 "Plan mode must condition progress guidance on the live catalog"
1976 );
1977 assert!(!PLAN_MODE.contains("call `update_plan`"));
1978 assert!(
1979 PLAN_MODE.contains("switch to Act (`/mode act`)"),
1980 "Plan mode must use a normal conversational handoff"
1981 );
1982 }
1983
1984 #[test]
1985 fn render_environment_block_keeps_actionable_host_facts_without_version() {
1986 let tmp = tempdir().expect("tempdir");
1987 let block = render_environment_block(tmp.path(), "zh-Hans");
1988 assert!(block.starts_with("## Environment"));
1989 assert!(block.contains("- lang: zh-Hans"));
1990 // The workspace remains per-turn and the release version is telemetry;
1991 // platform and shell still steer valid command syntax.
1992 assert!(!block.contains("- pwd:"));
1993 assert!(!block.contains("- codewhale_version:"));
1994 assert!(block.contains("- platform:"));
1995 assert!(block.contains("- shell:"));
1996 }
1997
1998 #[test]
1999 fn locale_reinforcement_preamble_returns_native_script_for_supported_locales() {
2000 // English (and unknown locales) get None — the existing English
2001 // directive in `constitution.md` is sufficient.
2002 assert!(locale_reinforcement_preamble("en").is_none());
2003 assert!(locale_reinforcement_preamble("en-US").is_none());
2004 assert!(locale_reinforcement_preamble("fr-FR").is_none());
2005 assert!(locale_reinforcement_preamble("").is_none());
2006
2007 // zh-Hans (and the de-facto equivalents the TUI accepts) get a
2008 // native-script preamble. The text must explicitly mention
2009 // `reasoning_content` (the V4 knob this is meant to steer) and
2010 // preserve tool-name immutability — those are the load-bearing
2011 // claims behind the #1118 fix that someone could quietly
2012 // delete in a future translation pass.
2013 for tag in ["zh-Hans", "zh-CN", "zh"] {
2014 let preamble =
2015 locale_reinforcement_preamble(tag).expect("zh-Hans preamble should exist");
2016 assert!(
2017 preamble.contains("简体中文"),
2018 "zh preamble must be in Simplified Chinese: {preamble:?}"
2019 );
2020 assert!(
2021 preamble.contains("reasoning_content"),
2022 "zh preamble must steer reasoning_content: {preamble:?}"
2023 );
2024 assert!(
2025 preamble.contains("`File`"),
2026 "zh preamble must call out tool-name immutability with a LIVE tool \
2027 name; `read_file` is retired (registry.rs:2067): {preamble:?}"
2028 );
2029 assert!(
2030 !preamble.contains("read_file") && !preamble.contains("exec_shell"),
2031 "zh preamble must never teach a retired tool name: {preamble:?}"
2032 );
2033 }
2034
2035 let ja = locale_reinforcement_preamble("ja").expect("ja preamble");
2036 assert!(ja.contains("日本語"), "ja preamble must be in Japanese");
2037 assert!(ja.contains("reasoning_content"));
2038
2039 let pt = locale_reinforcement_preamble("pt-BR").expect("pt-BR preamble");
2040 assert!(
2041 pt.contains("português do Brasil"),
2042 "pt preamble must call out pt-BR explicitly"
2043 );
2044 assert!(pt.contains("reasoning_content"));
2045 }
2046
2047 #[test]
2048 fn system_prompt_prepends_locale_preamble_for_zh_hans() {
2049 // Build the full system prompt with locale=zh-Hans and assert
2050 // the native-script preamble shows up *before* the English
2051 // base-prompt body. Cache stability and attention precedence
2052 // both depend on this ordering.
2053 let tmp = tempdir().expect("tempdir");
2054 let text = system_prompt_flat_text(
2055 &system_prompt_for_mode_with_context_skills_session_and_approval(
2056 tmp.path(),
2057 None,
2058 None,
2059 None,
2060 PromptSessionContext {
2061 user_memory_block: None,
2062 goal_objective: None,
2063 project_context_pack_enabled: false,
2064 locale_tag: "zh-Hans",
2065 translation_enabled: false,
2066 model_id: "codewhale",
2067 context_window_override: None,
2068 verbosity: None,
2069 skills_scan_codewhale_only: false,
2070 plugin_registry: None,
2071 mode: crate::tui::app::AppMode::Agent,
2072 },
2073 ),
2074 );
2075 let preamble_marker = "## 语言要求";
2076 let base_marker = "You are Codewhale";
2077 let preamble_pos = text
2078 .find(preamble_marker)
2079 .expect("zh-Hans preamble should be present");
2080 let base_pos = text
2081 .find(base_marker)
2082 .expect("base prompt should be present");
2083 assert!(
2084 preamble_pos < base_pos,
2085 "locale preamble must precede the English base prompt (preamble={preamble_pos}, base={base_pos})",
2086 );
2087 }
2088
2089 #[test]
2090 fn locale_reinforcement_closer_returns_native_script_for_supported_locales() {
2091 // English (and unknown locales) get None.
2092 assert!(locale_reinforcement_closer("en").is_none());
2093 assert!(locale_reinforcement_closer("fr-FR").is_none());
2094 assert!(locale_reinforcement_closer("").is_none());
2095
2096 // Each supported locale gets a closer in its own script that
2097 // explicitly tells the model "don't drift to English even as
2098 // English context accumulates" — that's the load-bearing claim
2099 // behind the bookend pattern.
2100 let zh = locale_reinforcement_closer("zh-Hans").expect("zh closer");
2101 assert!(
2102 zh.contains("简体中文"),
2103 "zh closer must be in Simplified Chinese"
2104 );
2105 assert!(
2106 zh.contains("reasoning_content"),
2107 "zh closer must steer reasoning_content"
2108 );
2109 let ja = locale_reinforcement_closer("ja").expect("ja closer");
2110 assert!(ja.contains("日本語"), "ja closer must be in Japanese");
2111 assert!(ja.contains("reasoning_content"));
2112 let pt = locale_reinforcement_closer("pt-BR").expect("pt-BR closer");
2113 assert!(pt.contains("português do Brasil"));
2114 assert!(pt.contains("reasoning_content"));
2115 }
2116
2117 #[test]
2118 fn v092_locales_add_no_prompt_bookends_so_prompt_bytes_stay_stable() {
2119 // Cache-stability contract: adding the v0.9.2 UI locales
2120 // (ca, de, fr, id, hi, ru, uk) — and the already-shipped UI packs
2121 // that never had bookends (ko, es-419, zh-Hant) — must not change
2122 // the model-visible system prompt for an identical route/session
2123 // when translation is not explicitly enabled. The bookend list
2124 // stays intentionally short (zh-Hans, ja, pt-BR, vi); every other
2125 // shipped locale resolves to None and therefore renders the exact
2126 // same prompt bytes as English.
2127 for tag in [
2128 "zh-Hant", "ko", "es-419", "ca", "de", "fr", "id", "hi", "ru", "uk",
2129 ] {
2130 assert!(
2131 locale_reinforcement_preamble(tag).is_none(),
2132 "{tag} must not gain a locale preamble"
2133 );
2134 assert!(
2135 locale_reinforcement_closer(tag).is_none(),
2136 "{tag} must not gain a locale closer"
2137 );
2138 }
2139 // The bookend set is exactly the original four locales — growing it
2140 // is a deliberate, reviewable prompt change, not a side effect of
2141 // adding a UI pack.
2142 for tag in ["zh-Hans", "ja", "pt-BR", "vi"] {
2143 assert!(
2144 locale_reinforcement_preamble(tag).is_some(),
2145 "{tag} lost its locale preamble"
2146 );
2147 assert!(
2148 locale_reinforcement_closer(tag).is_some(),
2149 "{tag} lost its locale closer"
2150 );
2151 }
2152 }
2153
2154 #[test]
2155 fn translation_seam_names_every_shipped_locale_canonically() {
2156 // The translation output instruction is the declared model-facing
2157 // seam: it only enters the prompt when `translation_enabled` is
2158 // true. When it does, every shipped locale must be named
2159 // canonically (English name + endonym) — never silently "English".
2160 for locale in crate::localization::Locale::shipped() {
2161 assert_eq!(
2162 translation_target_language_for_tag(locale.tag()),
2163 locale.translation_target_name(),
2164 "{} translation seam drifted from the canonical locale name",
2165 locale.tag()
2166 );
2167 }
2168 }
2169
2170 #[test]
2171 fn system_prompt_bookends_zh_hans_with_preamble_and_closer() {
2172 // The full system prompt for zh-Hans must contain BOTH the
2173 // opening preamble (`## 语言要求`) and the closing reinforcement
2174 // (`## 语言再次提醒`), with the closer appearing AFTER the
2175 // preamble — i.e. the prompt is "bookended" in native script,
2176 // matching the empirical finding from the WeChat thread that
2177 // motivated the closer.
2178 let tmp = tempdir().expect("tempdir");
2179 let text = system_prompt_flat_text(
2180 &system_prompt_for_mode_with_context_skills_session_and_approval(
2181 tmp.path(),
2182 None,
2183 None,
2184 None,
2185 PromptSessionContext {
2186 user_memory_block: None,
2187 goal_objective: None,
2188 project_context_pack_enabled: false,
2189 locale_tag: "zh-Hans",
2190 translation_enabled: false,
2191 model_id: "codewhale",
2192 context_window_override: None,
2193 verbosity: None,
2194 skills_scan_codewhale_only: false,
2195 plugin_registry: None,
2196 mode: crate::tui::app::AppMode::Agent,
2197 },
2198 ),
2199 );
2200 let preamble_pos = text
2201 .find("## 语言要求")
2202 .expect("zh-Hans preamble must be in prompt");
2203 let closer_pos = text
2204 .find("## 语言再次提醒")
2205 .expect("zh-Hans closer must be in prompt");
2206 assert!(
2207 preamble_pos < closer_pos,
2208 "closer must come after preamble (preamble={preamble_pos}, closer={closer_pos})",
2209 );
2210 // The closer must be the very last block — anything else after
2211 // it defeats the recency-bias purpose. Skip the closer's own
2212 // `## ` header before scanning.
2213 let closer_header_end = closer_pos + "## 语言再次提醒".len();
2214 let after_closer_body = &text[closer_header_end..];
2215 assert!(
2216 !after_closer_body.contains("\n## "),
2217 "no other top-level section should follow the closer; got: {after_closer_body:?}",
2218 );
2219 }
2220
2221 #[test]
2222 fn system_prompt_skips_locale_preamble_for_english() {
2223 // English locale → no preamble injected. Asserts the
2224 // "preamble is opt-in for non-English" invariant.
2225 let tmp = tempdir().expect("tempdir");
2226 let text = system_prompt_flat_text(
2227 &system_prompt_for_mode_with_context_skills_session_and_approval(
2228 tmp.path(),
2229 None,
2230 None,
2231 None,
2232 PromptSessionContext {
2233 user_memory_block: None,
2234 goal_objective: None,
2235 project_context_pack_enabled: false,
2236 locale_tag: "en",
2237 translation_enabled: false,
2238 model_id: "codewhale",
2239 context_window_override: None,
2240 verbosity: None,
2241 skills_scan_codewhale_only: false,
2242 plugin_registry: None,
2243 mode: crate::tui::app::AppMode::Agent,
2244 },
2245 ),
2246 );
2247 assert!(
2248 !text.contains("语言要求"),
2249 "English locale must not get a zh preamble: {text:?}"
2250 );
2251 assert!(
2252 !text.contains("言語要件"),
2253 "English locale must not get a ja preamble: {text:?}"
2254 );
2255 assert!(
2256 !text.contains("Requisito de Idioma"),
2257 "English locale must not get a pt-BR preamble: {text:?}"
2258 );
2259 // Closer too — same bookend rule.
2260 assert!(
2261 !text.contains("语言再次提醒"),
2262 "English locale must not get a zh closer: {text:?}"
2263 );
2264 assert!(
2265 !text.contains("言語再確認"),
2266 "English locale must not get a ja closer: {text:?}"
2267 );
2268 assert!(
2269 !text.contains("Reforço de Idioma"),
2270 "English locale must not get a pt-BR closer: {text:?}"
2271 );
2272 assert!(
2273 !contains_cjk(BASE_PROMPT),
2274 "base prompt must not contain static CJK priming tokens"
2275 );
2276 // Do not assert on arbitrary CJK in the full system prompt: project
2277 // context may legitimately contain localized file names, README text,
2278 // or user-authored instructions. The locale bookend markers above are
2279 // the priming tokens this test is meant to guard.
2280 }
2281
2282 #[test]
2283 fn locale_bookends_carry_reasoning_content_directives_for_1118() {
2284 // #1118 ("Language has been configured to Chinese, but thinking
2285 // outputs are still in English"): after the 0.9.0 constitution
2286 // reduction, locale-native bookends carry the runtime language
2287 // reinforcement instead of the base constitution.
2288 let lang = LOCALE_PREAMBLE_ZH_HANS;
2289 assert!(
2290 lang.contains("reasoning_content"),
2291 "locale preamble must explicitly call out reasoning_content"
2292 );
2293 assert!(
2294 lang.contains("最终回复"),
2295 "locale preamble must explicitly cover the final reply"
2296 );
2297 assert!(
2298 lang.contains("代码") && lang.contains("工具名称"),
2299 "code and tool names must be named as non-language signals"
2300 );
2301 assert!(
2302 LOCALE_CLOSER_ZH_HANS.contains("reasoning_content")
2303 && LOCALE_CLOSER_ZH_HANS.contains("继续用简体中文思考和回答"),
2304 "closing bookend must preserve recency-positioned language reinforcement"
2305 );
2306 // Explicit-user-override clause keeps the prompt useful for the
2307 // opposite preference (#1118 commenters who want English
2308 // thinking for token-cost reasons).
2309 let phrase = "think in English";
2310 assert!(
2311 lang.contains(phrase) && LOCALE_CLOSER_ZH_HANS.contains(phrase),
2312 "expected the user-override example `{phrase}`"
2313 );
2314 }
2315
2316 #[test]
2317 fn environment_block_is_inserted_into_system_prompt() {
2318 let tmp = tempdir().expect("tempdir");
2319 let prompt =
2320 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2321 tmp.path(),
2322 None,
2323 None,
2324 None,
2325 PromptSessionContext {
2326 user_memory_block: None,
2327 goal_objective: None,
2328 project_context_pack_enabled: false,
2329 locale_tag: "ja",
2330 translation_enabled: false,
2331 model_id: "codewhale",
2332 context_window_override: None,
2333 verbosity: None,
2334 skills_scan_codewhale_only: false,
2335 plugin_registry: None,
2336 mode: crate::tui::app::AppMode::Agent,
2337 },
2338 ));
2339 assert!(prompt.contains("## Environment"));
2340 assert!(prompt.contains("- lang: ja"));
2341 assert!(!prompt.contains("- codewhale_version:"));
2342 assert!(prompt.contains("- platform:"));
2343 assert!(prompt.contains("- shell:"));
2344 }
2345
2346 #[test]
2347 fn user_global_constitution_block_is_injected_separately() {
2348 let _env_guard = crate::test_support::lock_test_env();
2349 let tmp = tempdir().expect("tempdir");
2350 let workspace = tmp.path().join("workspace");
2351 std::fs::create_dir_all(&workspace).expect("workspace dir");
2352 let codewhale_home = tmp.path().join("codewhale-home");
2353 std::fs::create_dir_all(&codewhale_home).expect("codewhale home");
2354 let _codewhale_home =
2355 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2356
2357 let constitution = codewhale_config::UserConstitution {
2358 about: Some("Maintains Codewhale release lanes.".to_string()),
2359 working_style: vec!["Prefer live verification before claims.".to_string()],
2360 priorities: vec!["Keep release gates green.".to_string()],
2361 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
2362 ..codewhale_config::UserConstitution::default()
2363 };
2364 constitution
2365 .save_to(
2366 &codewhale_home
2367 .join(codewhale_config::user_constitution::USER_CONSTITUTION_FILE_NAME),
2368 )
2369 .expect("save user constitution");
2370
2371 let prompt =
2372 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2373 &workspace,
2374 None,
2375 None,
2376 None,
2377 PromptSessionContext {
2378 project_context_pack_enabled: false,
2379 ..PromptSessionContext::default()
2380 },
2381 ));
2382
2383 let base_at = prompt.find("### Whose word wins").expect("base prompt");
2384 let user_block_at = prompt
2385 .find("<codewhale_user_constitution")
2386 .expect("user constitution block");
2387 let env_at = prompt.find("- lang:").expect("rendered environment block");
2388 assert!(
2389 base_at < user_block_at && user_block_at < env_at,
2390 "user constitution should be its own layer after the base/project context and before volatile environment data"
2391 );
2392 assert!(prompt.contains("source=\"user-global\""));
2393 assert!(prompt.contains("Maintains Codewhale release lanes."));
2394 assert!(prompt.contains("Prefer live verification before claims."));
2395 assert!(
2396 !prompt.contains(&codewhale_home.display().to_string()),
2397 "prompt should use the stable user-global source label, not a device-specific home path"
2398 );
2399 }
2400
2401 #[test]
2402 fn bundled_choice_disables_user_global_constitution_block() {
2403 let _env_guard = crate::test_support::lock_test_env();
2404 let tmp = tempdir().expect("tempdir");
2405 let workspace = tmp.path().join("workspace");
2406 std::fs::create_dir_all(&workspace).expect("workspace dir");
2407 let codewhale_home = tmp.path().join("codewhale-home");
2408 std::fs::create_dir_all(&codewhale_home).expect("codewhale home");
2409 let _codewhale_home =
2410 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2411
2412 let constitution = codewhale_config::UserConstitution {
2413 about: Some("This file should stay inactive.".to_string()),
2414 ..codewhale_config::UserConstitution::default()
2415 };
2416 constitution
2417 .save_to(
2418 &codewhale_home
2419 .join(codewhale_config::user_constitution::USER_CONSTITUTION_FILE_NAME),
2420 )
2421 .expect("save user constitution");
2422
2423 let mut state = codewhale_config::SetupState::default();
2424 state.complete_constitution_checkpoint(
2425 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
2426 codewhale_config::ConstitutionChoice::Bundled,
2427 );
2428 state
2429 .save_to(&codewhale_home.join(codewhale_config::setup_state::SETUP_STATE_FILE_NAME))
2430 .expect("save setup state");
2431
2432 let prompt =
2433 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2434 &workspace,
2435 None,
2436 None,
2437 None,
2438 PromptSessionContext {
2439 project_context_pack_enabled: false,
2440 ..PromptSessionContext::default()
2441 },
2442 ));
2443
2444 assert!(!prompt.contains("<codewhale_user_constitution"));
2445 assert!(!prompt.contains("This file should stay inactive."));
2446 }
2447
2448 #[test]
2449 fn invalid_user_global_constitution_is_skipped() {
2450 let _env_guard = crate::test_support::lock_test_env();
2451 let tmp = tempdir().expect("tempdir");
2452 let workspace = tmp.path().join("workspace");
2453 std::fs::create_dir_all(&workspace).expect("workspace dir");
2454 let codewhale_home = tmp.path().join("codewhale-home");
2455 std::fs::create_dir_all(&codewhale_home).expect("codewhale home");
2456 std::fs::write(
2457 codewhale_home.join(codewhale_config::user_constitution::USER_CONSTITUTION_FILE_NAME),
2458 "{ not valid json",
2459 )
2460 .expect("write invalid user constitution");
2461 let _codewhale_home =
2462 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
2463
2464 let prompt =
2465 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2466 &workspace,
2467 None,
2468 None,
2469 None,
2470 PromptSessionContext {
2471 project_context_pack_enabled: false,
2472 ..PromptSessionContext::default()
2473 },
2474 ));
2475
2476 assert!(!prompt.contains("<codewhale_user_constitution"));
2477 }
2478
2479 #[test]
2480 fn memory_guidance_carries_paired_examples() {
2481 // The fragment is the contract — verify the verbatim ✓ / ✗
2482 // pair is present so V4 has both shapes to imitate.
2483 assert!(MEMORY_GUIDANCE.contains("declarative facts"));
2484 assert!(MEMORY_GUIDANCE.contains(" ✓"));
2485 assert!(MEMORY_GUIDANCE.contains(" ✗"));
2486 assert!(MEMORY_GUIDANCE.contains("Imperative"));
2487 }
2488
2489 #[test]
2490 fn memory_guidance_does_not_reference_scrapped_moraine() {
2491 // Moraine was scrapped for v0.9.4 (no in-repo server ever existed);
2492 // the native Markdown + SQLite FTS5 memory is the surviving system.
2493 assert!(!MEMORY_GUIDANCE.contains("Moraine"));
2494 assert!(!MEMORY_GUIDANCE.contains("moraine"));
2495 }
2496
2497 #[test]
2498 fn memory_guidance_absent_when_no_memory_block() {
2499 let tmp = tempdir().expect("tempdir");
2500 let prompt =
2501 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2502 tmp.path(),
2503 None,
2504 None,
2505 None,
2506 PromptSessionContext {
2507 user_memory_block: None,
2508 goal_objective: None,
2509 project_context_pack_enabled: false,
2510 locale_tag: "en",
2511 translation_enabled: false,
2512 model_id: "codewhale",
2513 context_window_override: None,
2514 verbosity: None,
2515 skills_scan_codewhale_only: false,
2516 plugin_registry: None,
2517 mode: crate::tui::app::AppMode::Agent,
2518 },
2519 ));
2520 assert!(
2521 !prompt.contains("Memory Hygiene"),
2522 "memory guidance must not leak into sessions without a memory block"
2523 );
2524 }
2525
2526 #[test]
2527 fn memory_guidance_appended_after_memory_block() {
2528 let tmp = tempdir().expect("tempdir");
2529 let block = "## User Memory\n\n- prefers Rust\n";
2530 let prompt =
2531 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2532 tmp.path(),
2533 None,
2534 None,
2535 None,
2536 PromptSessionContext {
2537 user_memory_block: Some(block),
2538 goal_objective: None,
2539 project_context_pack_enabled: false,
2540 locale_tag: "en",
2541 translation_enabled: false,
2542 model_id: "codewhale",
2543 context_window_override: None,
2544 verbosity: None,
2545 skills_scan_codewhale_only: false,
2546 plugin_registry: None,
2547 mode: crate::tui::app::AppMode::Agent,
2548 },
2549 ));
2550 let mem_at = prompt.find("User Memory").expect("user memory present");
2551 let guide_at = prompt.find("Memory Hygiene").expect("guidance present");
2552 assert!(
2553 mem_at < guide_at,
2554 "guidance must come after the user memory block"
2555 );
2556 }
2557
2558 #[test]
2559 fn continual_harness_is_injected_as_untrusted_world_state() {
2560 let tmp = tempdir().expect("tempdir");
2561 crate::continual_harness::refine(
2562 tmp.path(),
2563 crate::continual_harness::HarnessRefinement {
2564 kind: crate::continual_harness::HarnessEntryKind::PromptNote,
2565 title: "Verify release claims from direct evidence".to_string(),
2566 content: "Retain exact current command output for each release gate.".to_string(),
2567 evidence:
2568 "A prior release report mixed stale hosted CI with newer local test output."
2569 .to_string(),
2570 },
2571 )
2572 .expect("persist harness state");
2573
2574 let prompt =
2575 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2576 tmp.path(),
2577 None,
2578 None,
2579 None,
2580 PromptSessionContext {
2581 user_memory_block: None,
2582 goal_objective: None,
2583 project_context_pack_enabled: false,
2584 locale_tag: "en",
2585 translation_enabled: false,
2586 model_id: "codewhale",
2587 context_window_override: None,
2588 verbosity: None,
2589 skills_scan_codewhale_only: false,
2590 plugin_registry: None,
2591 mode: crate::tui::app::AppMode::Agent,
2592 },
2593 ));
2594 assert!(prompt.contains("<continual_harness trust=\"untrusted\">"));
2595 assert!(prompt.contains("supplemental working guidance"));
2596 assert!(prompt.contains("Verify release claims from direct evidence"));
2597 }
2598
2599 #[test]
2600 fn memory_guidance_does_not_state_precedence() {
2601 // #4777: only BASE_PROMPT § Whose word wins states ranks. Memory
2602 // hygiene keeps the imperative→preference rule and drops the
2603 // inverted Tier list that used to put Constitution above the user.
2604 let guidance = MEMORY_GUIDANCE.to_ascii_lowercase();
2605 for forbidden in [
2606 "tier 1",
2607 "tier 2",
2608 "tier 7",
2609 "statute",
2610 "regulation",
2611 "local law",
2612 "constitutional hierarchy",
2613 ] {
2614 assert!(
2615 !guidance.contains(forbidden),
2616 "MEMORY_GUIDANCE must not restate ranks (found {forbidden:?})"
2617 );
2618 }
2619 assert!(
2620 MEMORY_GUIDANCE.contains("treated as a preference")
2621 && MEMORY_GUIDANCE.contains("not a command"),
2622 "keep the imperative-as-preference rule"
2623 );
2624 }
2625
2626 #[test]
2627 fn only_the_constitution_states_precedence() {
2628 // Composed overlays must describe behavior, never their own rank.
2629 let overlays = [
2630 ("CALM_PERSONALITY", CALM_PERSONALITY),
2631 ("AGENT_MODE", AGENT_MODE),
2632 ("PLAN_MODE", PLAN_MODE),
2633 ("OPERATE_MODE", OPERATE_MODE),
2634 ("COMPACT_TEMPLATE", COMPACT_TEMPLATE),
2635 ("MEMORY_GUIDANCE", MEMORY_GUIDANCE),
2636 ("LANGUAGE_PROMPT", LANGUAGE_PROMPT),
2637 ("OUTPUT_PROMPT", OUTPUT_PROMPT),
2638 ("AUTHORITY_RECAP", AUTHORITY_RECAP),
2639 ];
2640 let rank_markers = [
2641 "Tier 1",
2642 "Tier 2",
2643 "Tier 3",
2644 "Tier 4",
2645 "Tier 5",
2646 "Tier 6",
2647 "Tier 7",
2648 "Tier 8",
2649 "Tier 9",
2650 "Statute",
2651 "Article IV",
2652 "Article V",
2653 "Article VII",
2654 "Local Law",
2655 "Regulation (Tier",
2656 ];
2657 for (name, text) in overlays {
2658 for marker in rank_markers {
2659 assert!(
2660 !text.contains(marker),
2661 "{name} must not carry rank vocabulary {marker:?}"
2662 );
2663 }
2664 }
2665 assert!(
2666 BASE_PROMPT.contains("### Whose word wins"),
2667 "canonical precedence section must remain in BASE_PROMPT"
2668 );
2669 assert!(
2670 BASE_PROMPT.contains("This ordering is stated here and nowhere else"),
2671 "BASE_PROMPT must assert single-source precedence"
2672 );
2673 }
2674
2675 #[test]
2676 fn project_context_pack_can_be_disabled() {
2677 let tmp = tempdir().expect("tempdir");
2678 std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
2679 let prompt =
2680 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2681 tmp.path(),
2682 None,
2683 None,
2684 None,
2685 PromptSessionContext {
2686 user_memory_block: None,
2687 goal_objective: None,
2688 project_context_pack_enabled: false,
2689 locale_tag: "en",
2690 translation_enabled: false,
2691 model_id: "codewhale",
2692 context_window_override: None,
2693 verbosity: None,
2694 skills_scan_codewhale_only: false,
2695 plugin_registry: None,
2696 mode: crate::tui::app::AppMode::Agent,
2697 },
2698 ));
2699 assert!(!prompt.contains("<project_context_pack>"));
2700 }
2701
2702 #[test]
2703 fn project_context_pack_is_before_dynamic_tail() {
2704 let tmp = tempdir().expect("tempdir");
2705 std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
2706 std::fs::create_dir_all(tmp.path().join(".deepseek")).expect("mkdir");
2707 std::fs::write(tmp.path().join(".deepseek").join("handoff.md"), "handoff")
2708 .expect("handoff");
2709 let prompt =
2710 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
2711 tmp.path(),
2712 None,
2713 None,
2714 None,
2715 PromptSessionContext {
2716 user_memory_block: None,
2717 goal_objective: None,
2718 // Explicit opt-in — pack is off by default (#4781).
2719 project_context_pack_enabled: true,
2720 locale_tag: "en",
2721 translation_enabled: false,
2722 model_id: "codewhale",
2723 context_window_override: None,
2724 verbosity: None,
2725 skills_scan_codewhale_only: false,
2726 plugin_registry: None,
2727 mode: crate::tui::app::AppMode::Agent,
2728 },
2729 ));
2730 assert!(prompt.contains("<project_context_pack>"));
2731 assert!(
2732 prompt.find("<project_context_pack>").expect("pack")
2733 < prompt.find("## Previous Session Relay").expect("relay")
2734 );
2735 }
2736
2737 #[test]
2738 fn handoff_artifact_is_prepended_to_system_prompt_when_present() {
2739 let tmp = tempdir().expect("tempdir");
2740 let workspace = tmp.path();
2741 let handoff_dir = workspace.join(".deepseek");
2742 std::fs::create_dir_all(&handoff_dir).unwrap();
2743 std::fs::write(
2744 handoff_dir.join("handoff.md"),
2745 "# Session relay — prior\n\n## Active task\nFinish #32.\n\n## Open blockers\n- [ ] write the basic version\n",
2746 )
2747 .unwrap();
2748
2749 let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None));
2750
2751 assert!(prompt.contains(HANDOFF_BLOCK_MARKER));
2752 assert!(prompt.contains("Finish #32."));
2753 assert!(prompt.contains("write the basic version"));
2754 }
2755
2756 #[test]
2757 fn missing_handoff_does_not_inject_block() {
2758 let tmp = tempdir().expect("tempdir");
2759 let prompt =
2760 system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None));
2761 assert!(!prompt.contains(HANDOFF_BLOCK_MARKER));
2762 }
2763
2764 #[test]
2765 fn empty_handoff_file_does_not_inject_block() {
2766 let tmp = tempdir().expect("tempdir");
2767 let dir = tmp.path().join(".deepseek");
2768 std::fs::create_dir_all(&dir).unwrap();
2769 std::fs::write(dir.join("handoff.md"), " \n\n ").unwrap();
2770 let prompt =
2771 system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None));
2772 assert!(!prompt.contains(HANDOFF_BLOCK_MARKER));
2773 }
2774
2775 #[test]
2776 fn compose_prompt_includes_all_layers() {
2777 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
2778 // Base layer — balanced Constitution; procedural recipes stay out.
2779 assert!(prompt.contains("## Codewhale"));
2780 assert!(prompt.contains("### Whose word wins"));
2781 assert!(!prompt.contains("## STATUTES (Tier 2)"));
2782 assert!(!prompt.contains("## EVIDENCE (Tier 6)"));
2783 // Mode and approval are not inlined — they travel as
2784 // request-time runtime metadata.
2785 assert!(!prompt.contains("Mode: Agent"));
2786 assert!(!prompt.contains("Approval Policy:"));
2787 }
2788
2789 /// `constitution.md` is the single hand-maintained source of the balanced
2790 /// constitutional core. This replaces the old 600-line policy tail: a
2791 /// hand-edit that drops a core section or reorders the skeleton fails the
2792 /// build instead of silently shipping a malformed prompt.
2793 #[test]
2794 fn constitution_md_carries_required_structure() {
2795 let md = BASE_PROMPT;
2796 assert!(md.contains("## Codewhale"), "missing title");
2797 let mut cursor = 0usize;
2798 for needle in [
2799 "## Codewhale",
2800 "### Ground truth",
2801 "### User intent and scope",
2802 "### Truthful completion",
2803 "### Put guarantees in mechanism",
2804 "### Whose word wins",
2805 ] {
2806 let pos = md
2807 .find(needle)
2808 .unwrap_or_else(|| panic!("ordering check: {needle:?} not found"));
2809 assert!(
2810 pos >= cursor,
2811 "cache-stable ordering broken: {needle:?} at {pos} precedes a previous section at {cursor}"
2812 );
2813 cursor = pos + needle.len();
2814 }
2815 }
2816
2817 /// Gate against shipping a release with a missing CHANGELOG entry — which
2818 /// is exactly what happened with v0.8.21 / v0.8.22 (entries had to be
2819 /// backfilled in v0.8.23). Asserts the top-of-file CHANGELOG contains a
2820 /// `## [X.Y.Z]` heading matching the current `CARGO_PKG_VERSION`. No
2821 /// hardcoded version string — the test self-updates with the workspace
2822 /// version bump and only fires when the CHANGELOG is the missing piece.
2823 ///
2824 /// Walks up from `CARGO_MANIFEST_DIR` to find `CHANGELOG.md` instead of
2825 /// assuming a fixed `../../CHANGELOG.md` layout. The workspace root is
2826 /// the common case, but the walk also tolerates deeper crate layouts and
2827 /// the packaged-crate case (where the workspace root has been stripped
2828 /// out): if no `CHANGELOG.md` is reachable, the gate quietly skips
2829 /// rather than panicking, so consumers running the suite outside the
2830 /// workspace checkout don't see a spurious failure.
2831 #[test]
2832 fn changelog_entry_exists_for_current_package_version() {
2833 let version = env!("CARGO_PKG_VERSION");
2834 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2835 let Some(changelog_path) = manifest_dir
2836 .ancestors()
2837 .map(|dir| dir.join("CHANGELOG.md"))
2838 .find(|candidate| candidate.is_file())
2839 else {
2840 eprintln!(
2841 "changelog_entry_exists_for_current_package_version: no \
2842 CHANGELOG.md found above {} — skipping (this gate only \
2843 fires inside a workspace checkout).",
2844 manifest_dir.display()
2845 );
2846 return;
2847 };
2848
2849 let contents = std::fs::read_to_string(&changelog_path).unwrap_or_else(|err| {
2850 panic!(
2851 "failed to read CHANGELOG.md at {}: {err}",
2852 changelog_path.display()
2853 )
2854 });
2855 let header = format!("## [{version}]");
2856 assert!(
2857 contents.contains(&header),
2858 "CHANGELOG.md is missing a `{header}` entry for the current package \
2859 version. Add a release section at the top before tagging — see \
2860 docs/RELEASE_CHECKLIST.md."
2861 );
2862 }
2863
2864 #[test]
2865 fn compose_prompt_deterministic_order() {
2866 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
2867 let base_pos = prompt.find("## Codewhale").unwrap();
2868 let article_pos = prompt.find("### Ground truth").unwrap();
2869
2870 assert!(base_pos < article_pos);
2871 }
2872
2873 #[test]
2874 fn base_prompt_is_mode_agnostic() {
2875 // Mode and approval text are no longer inlined into compose_prompt —
2876 // they travel as request-time runtime metadata.
2877 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
2878 assert!(!prompt.contains("Mode: Agent"));
2879 assert!(!prompt.contains("Mode: YOLO"));
2880 assert!(!prompt.contains("Mode: Plan"));
2881 assert!(!prompt.contains("Approval Policy:"));
2882 // Base prompt carries the 0.9.0 compact Constitution.
2883 assert!(prompt.contains("You are Codewhale"));
2884 assert!(prompt.contains("Take the work seriously. Don't take"));
2885 }
2886
2887 #[test]
2888 fn agent_mode_prompt_keeps_safety_invariants_after_compression() {
2889 let prompt = AGENT_MODE.replace("\r\n", "\n").replace('\r', "\n");
2890 for must in [
2891 "autonomously",
2892 "tools in the current catalog",
2893 "work_update",
2894 "current catalog includes delegation",
2895 "Do not announce the mode",
2896 ] {
2897 assert!(
2898 prompt.contains(must),
2899 "compressed agent mode missing invariant {must:?}"
2900 );
2901 }
2902 for unavailable_claim in ["`File`", "`Git`", "`Run`", "`Bash`"] {
2903 assert!(!prompt.contains(unavailable_claim));
2904 }
2905 // Procedural PowerShell manuals must not live in the mode delta.
2906 for forbidden in ["Invoke-Expression", "pwsh.exe -NoLogo", "ProcessStartInfo"] {
2907 assert!(
2908 !prompt.contains(forbidden),
2909 "agent mode must not absorb PowerShell manuals: {forbidden}"
2910 );
2911 }
2912 }
2913
2914 #[test]
2915 fn mode_prompts_remain_small_deltas_not_base_policy_copies() {
2916 assert!(
2917 PLAN_MODE.contains("All writes, patches, shell commands"),
2918 "Plan may summarize the user-facing mode delta"
2919 );
2920 for (name, prompt) in [("agent", AGENT_MODE), ("plan", PLAN_MODE)] {
2921 // Measure semantic size on LF so Windows autocrlf checkouts do not
2922 // inflate char/3 token estimates via extra `\r` bytes.
2923 let normalized = prompt.replace("\r\n", "\n").replace('\r', "\n");
2924 let word_count = normalized.split_whitespace().count();
2925 let estimated_tokens =
2926 crate::compaction::estimate_text_tokens_conservative(&normalized);
2927 // 2026-07-21: mode deltas contain permissions and durable behavior
2928 // only. Action recipes belong to the canonical tool schemas.
2929 let max_words = 120;
2930 let max_tokens = 320;
2931
2932 assert!(
2933 word_count <= max_words,
2934 "{name} mode prompt should remain a delta, got {word_count} words"
2935 );
2936 assert!(
2937 estimated_tokens <= max_tokens,
2938 "{name} mode prompt should remain compact, got {estimated_tokens} estimated tokens"
2939 );
2940 for forbidden in [
2941 "## Codewhale",
2942 "## STATUTES (Tier 2)",
2943 "## REGULATIONS (Tier 3)",
2944 "## EVIDENCE (Tier 6)",
2945 "## Context Management",
2946 "## Runtime Policy Reference",
2947 ] {
2948 assert!(
2949 !normalized.contains(forbidden),
2950 "{name} mode prompt duplicated shared base section {forbidden:?}"
2951 );
2952 }
2953 }
2954 }
2955
2956 #[test]
2957 fn approval_policy_no_longer_inlined_in_base_prompt() {
2958 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
2959 assert!(!prompt.contains("Mode: Agent"));
2960 assert!(!prompt.contains("Approval Policy:"));
2961 // The compact Constitutional preamble is still present.
2962 assert!(prompt.contains("You are Codewhale"));
2963 }
2964
2965 #[test]
2966 fn execution_contract_states_proposal_is_not_execution() {
2967 // #5146: the live execution layer must make the propose-vs-execute
2968 // contract explicit after the legacy approval overlay was removed.
2969 assert!(
2970 CORE_EXECUTION_PROFILE_PROMPT.contains("is the proposal, not the execution"),
2971 "Execution profile must state the propose-vs-execute contract"
2972 );
2973 assert!(
2974 CORE_EXECUTION_PROFILE_PROMPT.contains("present the change in your plan"),
2975 "Execution profile must name the correct behavior on rejection"
2976 );
2977 }
2978
2979 #[test]
2980 fn personality_is_folded_into_constitution() {
2981 // v4 has no separate personality tier. Voice and tone live in
2982 // the preamble, so composition appends no personality overlay.
2983 let calm = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
2984 assert!(!calm.contains("## Personality:"));
2985 assert!(calm.contains("Take the work seriously. Don't take"));
2986 assert!(calm.contains("You are Codewhale"));
2987 }
2988
2989 #[test]
2990 fn compact_template_is_lazy_in_fresh_prompt() {
2991 let tmp = tempdir().expect("tempdir");
2992 let prompt =
2993 system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None));
2994 assert!(!prompt.contains("# Session relay"));
2995 assert!(!prompt.contains("## Verification"));
2996 }
2997
2998 #[test]
2999 fn session_goal_stays_volatile_while_compact_template_is_lazy() {
3000 let tmp = tempdir().expect("tempdir");
3001 let prompt =
3002 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
3003 tmp.path(),
3004 Some("## Repo Working Set\nsrc/lib.rs"),
3005 None,
3006 None,
3007 PromptSessionContext {
3008 user_memory_block: None,
3009 goal_objective: Some("Fix transcript corruption"),
3010 project_context_pack_enabled: false,
3011 locale_tag: "en",
3012 translation_enabled: false,
3013 model_id: "codewhale",
3014 context_window_override: None,
3015 verbosity: None,
3016 skills_scan_codewhale_only: false,
3017 plugin_registry: None,
3018 mode: crate::tui::app::AppMode::Agent,
3019 },
3020 ));
3021
3022 let goal_pos = prompt.find("<session_goal>").expect("goal block");
3023 assert!(prompt.contains("Fix transcript corruption"));
3024 // Session goal remains volatile content below the stable static
3025 // layers. The relay template is injected only when relay/compaction
3026 // actually needs it.
3027 assert!(goal_pos > 0);
3028 assert!(!prompt.contains("# Session relay"));
3029 assert!(!prompt.contains("src/lib.rs"));
3030 }
3031
3032 #[test]
3033 fn empty_session_goal_is_not_injected() {
3034 let tmp = tempdir().expect("tempdir");
3035 let prompt =
3036 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
3037 tmp.path(),
3038 None,
3039 None,
3040 None,
3041 PromptSessionContext {
3042 user_memory_block: None,
3043 goal_objective: Some(" "),
3044 project_context_pack_enabled: false,
3045 locale_tag: "en",
3046 translation_enabled: false,
3047 model_id: "codewhale",
3048 context_window_override: None,
3049 verbosity: None,
3050 skills_scan_codewhale_only: false,
3051 plugin_registry: None,
3052 mode: crate::tui::app::AppMode::Agent,
3053 },
3054 ));
3055
3056 assert!(!prompt.contains("<session_goal>"));
3057 assert!(!prompt.contains("## Current Goal"));
3058 }
3059
3060 #[test]
3061 fn agent_mode_tool_guidance_avoids_defensive_tool_suppression() {
3062 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3063 assert!(!prompt.contains("Tool Selection Guide"));
3064 for tool in ["`File`", "`Git`", "`Run`", "`Bash`"] {
3065 assert!(!AGENT_MODE.contains(tool));
3066 }
3067 assert!(AGENT_MODE.contains("tools in the current catalog"));
3068 for legacy in ["read_file", "git_status", "run_tests", "exec_shell"] {
3069 assert!(!AGENT_MODE.contains(legacy));
3070 }
3071 assert!(
3072 !AGENT_MODE.contains("When NOT to use certain tools"),
3073 "agent mode should steer tool choice without training the model to avoid available tools"
3074 );
3075 assert!(
3076 !AGENT_MODE.contains("Don't reach for"),
3077 "avoid defensive anti-tool wording in mode guidance"
3078 );
3079 }
3080
3081 /// #588: after the 0.9.0 constitution reduction, language-mirroring
3082 /// reinforcement lives in its own static segment plus locale bookends.
3083 #[test]
3084 fn language_segment_present_outside_reduced_constitution() {
3085 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3086 assert!(
3087 !BASE_PROMPT.contains("## Language"),
3088 "0.9.0 constitution.md should stay reduced; language belongs in its own segment"
3089 );
3090 assert!(
3091 LANGUAGE_PROMPT.contains("## Language") && prompt.contains("## Language"),
3092 "default static prompt must still include the language segment"
3093 );
3094 assert!(
3095 LANGUAGE_PROMPT.contains("latest user message")
3096 && LANGUAGE_PROMPT.contains("fallback, not an override")
3097 && LANGUAGE_PROMPT.contains("localized READMEs")
3098 && LANGUAGE_PROMPT.contains("Use the `lang` field only when")
3099 && LANGUAGE_PROMPT.contains("constitution and other system law stay English"),
3100 "language segment must keep the mirror contract while staying short (#4784)"
3101 );
3102 assert!(
3103 LANGUAGE_PROMPT.contains("reasoning_content")
3104 && prompt.contains("reasoning_content")
3105 && LOCALE_PREAMBLE_ZH_HANS.contains("reasoning_content")
3106 && LOCALE_CLOSER_ZH_HANS.contains("reasoning_content"),
3107 "language segment and locale bookends must keep the reasoning_content anchor"
3108 );
3109 }
3110
3111 #[test]
3112 fn output_formatting_segment_present_outside_reduced_constitution() {
3113 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3114 assert!(
3115 !BASE_PROMPT.contains("## Output Formatting"),
3116 "0.9.0 constitution.md should stay reduced; output formatting belongs in its own segment"
3117 );
3118 assert!(OUTPUT_PROMPT.contains("## Output Formatting"));
3119 assert!(prompt.contains("## Output Formatting"));
3120 assert!(prompt.contains("terminal, not a browser"));
3121 assert!(prompt.contains("Markdown tables almost never render correctly"));
3122 }
3123
3124 #[test]
3125 fn runtime_prompt_assembly_preserves_split_static_layers() {
3126 let tmp = tempdir().expect("tempdir");
3127 let prompt =
3128 system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
3129 tmp.path(),
3130 None,
3131 None,
3132 None,
3133 PromptSessionContext {
3134 user_memory_block: None,
3135 goal_objective: None,
3136 project_context_pack_enabled: false,
3137 locale_tag: "en",
3138 translation_enabled: false,
3139 model_id: "glm-5.2",
3140 context_window_override: Some(1_000_000),
3141 verbosity: None,
3142 skills_scan_codewhale_only: false,
3143 plugin_registry: None,
3144 mode: crate::tui::app::AppMode::Agent,
3145 },
3146 ));
3147
3148 assert!(prompt.contains("## Codewhale"));
3149 assert!(prompt.contains("## Language"));
3150 assert!(prompt.contains("## Output Formatting"));
3151 assert!(prompt.contains("Use the `lang` field only when"));
3152 }
3153
3154 #[test]
3155 fn locale_bookends_resist_english_context_drift() {
3156 assert!(
3157 LOCALE_PREAMBLE_ZH_HANS.contains("reasoning_content")
3158 && LOCALE_CLOSER_ZH_HANS.contains("reasoning_content"),
3159 "locale bookends must keep the reasoning_content anchor"
3160 );
3161 assert!(
3162 LOCALE_CLOSER_ZH_HANS.contains("英文代码")
3163 && LOCALE_CLOSER_ZH_HANS.contains("用户的语言决定"),
3164 "closing locale bookend must explicitly resist English-context drift"
3165 );
3166 assert!(
3167 LOCALE_PREAMBLE_ZH_HANS.contains("代码、文件路径、工具名称"),
3168 "opening locale bookend must keep code/tool tokens untranslated"
3169 );
3170 }
3171
3172 #[test]
3173 fn english_base_prompt_avoids_native_script_language_priming() {
3174 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3175 assert!(
3176 !contains_cjk(&prompt),
3177 "English base prompt should keep native-script reinforcement in locale bookends only"
3178 );
3179 assert!(
3180 !prompt.contains("multilingual coding agent"),
3181 "identity should not prime language switching; language belongs in runtime bookends"
3182 );
3183 }
3184
3185 #[test]
3186 fn legacy_rlm_compatibility_descriptions_remain_available() {
3187 assert!(!AGENT_MODE.contains("Large Context Tools"));
3188
3189 let descriptions = [
3190 RlmTool::alias("rlm_open", "open", None)
3191 .description()
3192 .to_string(),
3193 RlmTool::alias("rlm_eval", "eval", None)
3194 .description()
3195 .to_string(),
3196 RlmTool::alias("rlm_configure", "configure", None)
3197 .description()
3198 .to_string(),
3199 RlmTool::alias("rlm_close", "close", None)
3200 .description()
3201 .to_string(),
3202 HandleReadTool.description().to_string(),
3203 ]
3204 .join("\n");
3205 let rlm_count = descriptions.to_lowercase().matches("rlm").count();
3206 assert!(
3207 rlm_count >= 5,
3208 "RLM tool descriptions present: expected >= 5 mentions of 'rlm', got {rlm_count}"
3209 );
3210 assert!(
3211 !AGENT_MODE.contains("`rlm`"),
3212 "the normal Agent prompt should not teach the retired RLM control surface"
3213 );
3214 }
3215
3216 /// Project instructions rank above memory, with the nearest scope winning
3217 /// over the broader. The embedder-injected-instructions case is covered
3218 /// by project law/instructions sitting above memory/handoffs.
3219 #[test]
3220 fn project_instructions_outrank_memory_in_whose_word_wins() {
3221 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3222 let project_at = prompt
3223 .find("3. Project law and instructions")
3224 .expect("Whose word wins must rank project instructions");
3225 let memory_at = prompt
3226 .find("5. Memory and previous-session handoffs.")
3227 .expect("Whose word wins must rank memory below project instructions");
3228 assert!(
3229 project_at < memory_at,
3230 "project instructions must outrank memory so embedder-injected \
3231 instructions are not treated as mere memory preferences"
3232 );
3233 }
3234
3235 #[test]
3236 fn workspace_orientation_guidance_present() {
3237 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3238 assert!(prompt.contains("Project law and instructions"));
3239 assert!(
3240 prompt.contains("the nearest in\nscope winning over the broader")
3241 || prompt.contains("the nearest in scope winning over the broader"),
3242 "Whose word wins must keep the nearest-scope-wins rule for project instructions"
3243 );
3244 }
3245
3246 #[test]
3247 fn prompt_uses_the_single_agent_kernel_surface() {
3248 for tool in ["rlm_open", "rlm_eval", "rlm_configure", "rlm_close"] {
3249 assert!(!AGENT_MODE.contains(tool));
3250 }
3251 assert!(AGENT_MODE.contains("sub-agent"));
3252 assert!(AGENT_MODE.contains("session-persistent `repl` blocks"));
3253 }
3254
3255 #[test]
3256 fn prompt_documents_fork_context_prefix_cache_contract() {
3257 let source = include_str!("tools/subagent/mod.rs");
3258 assert!(source.contains("fork_context"));
3259 assert!(!AGENT_MODE.contains("fork_context"));
3260 }
3261
3262 #[test]
3263 fn prompt_documents_explicit_subagent_model_strength() {
3264 let source = include_str!("tools/subagent/mod.rs");
3265 assert!(source.contains("model_strength"));
3266 assert!(!AGENT_MODE.contains("model_strength"));
3267 }
3268
3269 #[test]
3270 fn prompt_documents_structured_subagent_briefs() {
3271 assert!(!AGENT_MODE.contains("Subagent Brief"));
3272 for heading in [
3273 "### SUMMARY",
3274 "### EVIDENCE",
3275 "### CHANGES",
3276 "### RISKS",
3277 "### BLOCKERS",
3278 ] {
3279 assert!(text::SUBAGENT_OUTPUT_FORMAT.contains(heading));
3280 }
3281 }
3282
3283 #[test]
3284 fn prompt_bounds_explore_without_tiny_cap_for_implementers() {
3285 assert!(AGENT_MODE.contains("current catalog includes delegation"));
3286 assert!(!AGENT_MODE.contains("3-5 tool calls"));
3287 assert!(!AGENT_MODE.contains("No fan-out without a fan-in owner"));
3288 }
3289
3290 #[test]
3291 fn agent_mode_prompt_teaches_automatic_workflow_use() {
3292 for recipe in [
3293 "Workflow",
3294 "responseSchema",
3295 "request_user_input",
3296 ".workflow.js",
3297 ] {
3298 assert!(!AGENT_MODE.contains(recipe));
3299 }
3300 }
3301
3302 #[test]
3303 fn operate_mode_prompt_keeps_multitask_simple_and_async() {
3304 for phrase in [
3305 "dispatch, join,",
3306 "capabilities present in the current catalog",
3307 "When worker dispatch is available",
3308 "Fan out, block on one wait until the batch lands",
3309 "queued user messages as new tasks",
3310 "Preserve approval",
3311 "settled is not",
3312 "internal control-plane mechanics",
3313 "When goal control is available",
3314 "Dispatch is not completion",
3315 "verification capabilities",
3316 "When an ordered Workflow capability is present",
3317 ] {
3318 assert!(
3319 OPERATE_MODE.contains(phrase),
3320 "OPERATE_MODE missing multitask phrase {phrase:?}"
3321 );
3322 }
3323 for implementation_detail in [
3324 "risk` is exactly",
3325 "parallel([() =>",
3326 "terminal Workflow receipt",
3327 "/multitask",
3328 ] {
3329 assert!(
3330 !OPERATE_MODE.contains(implementation_detail),
3331 "OPERATE_MODE leaks implementation detail {implementation_detail:?}"
3332 );
3333 }
3334 }
3335
3336 /// The owner watched a model reason that it had to stay available for its
3337 /// children instead of simply joining them. Operate must not frame a
3338 /// blocking join as a lapse: the anti-pattern is the poll loop, not the
3339 /// single wait, and returning control mid-flight is the exception.
3340 #[test]
3341 fn operate_mode_endorses_one_blocking_join_over_staying_responsive() {
3342 for obligation in [
3343 "keep the parent responsive",
3344 "parent responsive",
3345 "busy-waiting",
3346 "return control instead",
3347 ] {
3348 assert!(
3349 !OPERATE_MODE.contains(obligation),
3350 "OPERATE_MODE still frames staying responsive as an obligation: {obligation:?}"
3351 );
3352 }
3353 for endorsement in [
3354 "Fan out, block on one wait until the batch lands, then synthesize",
3355 "endorsed default",
3356 "Polling in a loop is the anti-pattern; one blocking wait",
3357 "Returning control mid-flight is the exception",
3358 ] {
3359 assert!(
3360 OPERATE_MODE.contains(endorsement),
3361 "OPERATE_MODE missing fan-out/join endorsement {endorsement:?}"
3362 );
3363 }
3364 }
3365
3366 #[test]
3367 fn subagent_done_sentinel_section_present() {
3368 assert!(AGENT_MODE.contains("completion events as internal evidence"));
3369 assert!(AGENT_MODE.contains("verify load-bearing child"));
3370 assert!(AGENT_MODE.contains("never manufacture completion sentinels"));
3371 assert!(!AGENT_MODE.contains("<codewhale:subagent.done>"));
3372 }
3373
3374 #[test]
3375 fn preamble_carries_tone_and_ownership_guidance() {
3376 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3377 assert!(prompt.contains("The A is already yours"));
3378 assert!(prompt.contains("Your competence is a settled fact"));
3379 assert!(prompt.contains("Take the work seriously. Don't take"));
3380 assert!(prompt.contains("Let the work speak"));
3381 }
3382
3383 // ── Cache-prefix stability harness (#263 step 2) ───────────────────────
3384 //
3385 // These tests pin the byte-stability invariant required for DeepSeek's
3386 // KV prefix cache to hit: any prompt-construction surface that ends up
3387 // in the cached prefix must produce identical bytes given identical
3388 // inputs across calls.
3389
3390 use crate::test_support::{EnvVarGuard, assert_byte_identical};
3391
3392 #[test]
3393 fn compose_prompt_is_byte_stable_across_calls() {
3394 // Suspect #4 from #263: mode prompt churn within a single mode.
3395 // Two calls with identical (mode, personality) inputs must produce
3396 // identical bytes — anything else is a cache buster.
3397 let a = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3398 let b = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3399 assert_byte_identical("compose_prompt(Personality::Calm)", &a, &b);
3400 }
3401
3402 #[test]
3403 fn system_prompt_for_mode_with_context_is_byte_stable_for_unchanged_workspace() {
3404 // Same workspace, no working_set / skills churn between calls →
3405 // identical bytes. This pins the most representative production
3406 // surface (engine.rs builds the system prompt via this fn or
3407 // its sibling _and_skills variant on every turn).
3408 let _env_guard = crate::test_support::lock_test_env();
3409 let workspace_tmp = tempdir().expect("workspace tempdir");
3410 let home_tmp = tempdir().expect("home tempdir");
3411 let _home = EnvVarGuard::set("HOME", home_tmp.path().as_os_str());
3412 let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str());
3413 let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR");
3414 let workspace = workspace_tmp.path();
3415
3416 let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None));
3417 let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None));
3418 assert_byte_identical(
3419 "system_prompt_for_mode_with_context() on empty workspace",
3420 &a,
3421 &b,
3422 );
3423 }
3424
3425 #[test]
3426 fn system_prompt_ignores_working_set_summary_argument() {
3427 // Working-set metadata is now injected into the latest user message
3428 // per turn. The legacy argument remains for call-site compatibility
3429 // but must not reintroduce volatile bytes into the system prompt.
3430 let _env_guard = crate::test_support::lock_test_env();
3431 let tmp = tempdir().expect("tempdir");
3432 let home_tmp = tempdir().expect("home tempdir");
3433 let _home = EnvVarGuard::set("HOME", home_tmp.path().as_os_str());
3434 let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str());
3435 let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR");
3436 let workspace = tmp.path();
3437 let summary = "## Repo Working Set\nWorkspace: /tmp/x\n";
3438
3439 let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(
3440 workspace,
3441 Some(summary),
3442 ));
3443 let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(
3444 workspace,
3445 Some(summary),
3446 ));
3447 assert_byte_identical(
3448 "system_prompt_for_mode_with_context with constant working_set summary",
3449 &a,
3450 &b,
3451 );
3452 assert!(
3453 !a.contains(summary),
3454 "summary must not be embedded in system prompt"
3455 );
3456 }
3457
3458 #[test]
3459 fn system_prompt_with_handoff_file_is_byte_stable_when_file_is_unchanged() {
3460 // If `.deepseek/handoff.md` hasn't moved between two builds, the
3461 // rendered prompt must produce identical bytes. The relay block
3462 // lands below the static boundary in
3463 // `system_prompt_for_mode_with_context_and_skills`.
3464 let _env_guard = crate::test_support::lock_test_env();
3465 let tmp = tempdir().expect("tempdir");
3466 let home_tmp = tempdir().expect("home tempdir");
3467 let _home = EnvVarGuard::set("HOME", home_tmp.path().as_os_str());
3468 let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str());
3469 let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR");
3470 let workspace = tmp.path();
3471 let handoff_dir = workspace.join(".deepseek");
3472 std::fs::create_dir_all(&handoff_dir).unwrap();
3473 std::fs::write(
3474 handoff_dir.join("handoff.md"),
3475 "# Session relay\n\n## Active task\nFinish #280.\n\n## Open blockers\n- [ ] none\n",
3476 )
3477 .unwrap();
3478
3479 let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None));
3480 let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None));
3481 assert_byte_identical(
3482 "system_prompt_for_mode_with_context with constant handoff file",
3483 &a,
3484 &b,
3485 );
3486 assert!(a.contains(HANDOFF_BLOCK_MARKER), "relay must be embedded");
3487 assert!(a.contains("Finish #280."), "relay body must be present");
3488 }
3489
3490 #[test]
3491 fn handoff_appears_after_static_blocks_without_working_set() {
3492 // Cache-prefix invariant: the relay artifact must come after static
3493 // `## Core Execution`. The relay template itself is now action-local,
3494 // not part of every system prompt. Working-set metadata is per-turn
3495 // user metadata, not a system-prompt tail block.
3496 let tmp = tempdir().expect("tempdir");
3497 let workspace = tmp.path();
3498 let handoff_dir = workspace.join(".deepseek");
3499 std::fs::create_dir_all(&handoff_dir).unwrap();
3500 std::fs::write(handoff_dir.join("handoff.md"), "# handoff body\n").unwrap();
3501
3502 let summary = "## Repo Working Set\nWorkspace: /tmp/x\n";
3503 let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context(
3504 workspace,
3505 Some(summary),
3506 ));
3507
3508 let execution_pos = prompt
3509 .find("## Core Execution")
3510 .expect("Core Execution section present in Agent mode");
3511 let handoff_pos = prompt
3512 .find(HANDOFF_BLOCK_MARKER)
3513 .expect("relay block present when fixture file exists");
3514 assert!(
3515 !prompt.contains("## Repo Working Set"),
3516 "working-set summary must stay out of the system prompt"
3517 );
3518
3519 assert!(
3520 execution_pos < handoff_pos,
3521 "## Core Execution must precede the relay block"
3522 );
3523 assert!(!prompt.contains("# Session relay"));
3524 }
3525
3526 #[test]
3527 fn render_instructions_block_returns_none_for_empty_input() {
3528 let empty: &[super::InstructionSource] = &[];
3529 assert!(super::render_instructions_block(empty).is_none());
3530 }
3531
3532 /// #4632 — The system prompt prefix (the byte-stable part cached by
3533 /// inference servers) must never contain private content: absolute
3534 /// filesystem paths, API keys, or home-directory references.
3535 #[test]
3536 fn system_prompt_prefix_never_leaks_private_content() {
3537 let tmp = tempdir().expect("tempdir");
3538 let workspace = tmp.path();
3539 let prompt = match system_prompt_for_mode_with_context(workspace, None) {
3540 SystemPrompt::Text(text) => text,
3541 SystemPrompt::Blocks(blocks) => blocks
3542 .iter()
3543 .map(|block| block.text.as_str())
3544 .collect::<Vec<_>>()
3545 .join("\n"),
3546 };
3547
3548 // No absolute paths (Unix or Windows).
3549 let offending: Vec<&str> = prompt
3550 .lines()
3551 .filter(|line| {
3552 line.contains("/Users/") || line.contains("/home/") || line.contains("C:\\")
3553 })
3554 .collect();
3555 assert!(
3556 offending.is_empty(),
3557 "system prompt must not contain absolute user paths, found: {offending:?}"
3558 );
3559 // No API key patterns.
3560 assert!(
3561 !prompt.contains("sk-") && !prompt.contains("api_key") && !prompt.contains("API_KEY"),
3562 "system prompt must not contain API key material"
3563 );
3564 // The workspace path itself must not appear.
3565 assert!(
3566 !prompt.contains(workspace.to_str().unwrap_or("/nonexistent")),
3567 "system prompt must not embed the workspace path"
3568 );
3569 }
3570
3571 #[test]
3572 fn render_instructions_block_skips_missing_files_with_warning() {
3573 let tmp = tempdir().expect("tempdir");
3574 let real = tmp.path().join("real.md");
3575 std::fs::write(&real, "real content here").unwrap();
3576 let bogus = tmp.path().join("does-not-exist.md");
3577
3578 let block = super::render_instructions_block(&[bogus.clone().into(), real.clone().into()])
3579 .expect("present file should produce a block");
3580 assert!(block.contains("real content here"));
3581 assert!(block.contains(&real.display().to_string()));
3582 // Bogus path is skipped, not rendered.
3583 assert!(!block.contains(&bogus.display().to_string()));
3584 }
3585
3586 #[test]
3587 fn render_instructions_block_concatenates_in_declared_order() {
3588 let tmp = tempdir().expect("tempdir");
3589 let a = tmp.path().join("a.md");
3590 let b = tmp.path().join("b.md");
3591 std::fs::write(&a, "ALPHA_MARKER").unwrap();
3592 std::fs::write(&b, "BRAVO_MARKER").unwrap();
3593
3594 let block = super::render_instructions_block(&[a.into(), b.into()]).expect("non-empty");
3595 let alpha_pos = block.find("ALPHA_MARKER").expect("alpha rendered");
3596 let bravo_pos = block.find("BRAVO_MARKER").expect("bravo rendered");
3597 assert!(
3598 alpha_pos < bravo_pos,
3599 "instructions must concatenate in declared order"
3600 );
3601 }
3602
3603 #[test]
3604 fn render_instructions_block_skips_empty_files() {
3605 let tmp = tempdir().expect("tempdir");
3606 let empty = tmp.path().join("empty.md");
3607 let real = tmp.path().join("real.md");
3608 std::fs::write(&empty, " \n \n").unwrap();
3609 std::fs::write(&real, "real content").unwrap();
3610
3611 let block =
3612 super::render_instructions_block(&[empty.into(), real.into()]).expect("non-empty");
3613 // Empty file produces no `<instructions>` section, only the real one.
3614 let count = block.matches("<instructions").count();
3615 assert_eq!(count, 1, "only the non-empty file should produce a section");
3616 }
3617
3618 #[test]
3619 fn render_instructions_block_truncates_oversize_files() {
3620 let tmp = tempdir().expect("tempdir");
3621 let big = tmp.path().join("big.md");
3622 // 200 KiB of content — well above the 100 KiB cap.
3623 std::fs::write(&big, "X".repeat(200 * 1024)).unwrap();
3624
3625 let block = super::render_instructions_block(&[big.into()]).expect("non-empty");
3626 assert!(block.contains("[…truncated:"), "truncation marker missing");
3627 // Block should be much smaller than the original file.
3628 assert!(
3629 block.len() < 110 * 1024,
3630 "block should be capped near 100 KiB"
3631 );
3632 }
3633
3634 /// `InstructionSource::Inline` bypasses disk reads — the content is used
3635 /// directly and `name` becomes the `<instructions source="…">` attribute.
3636 /// Empty / oversize handling mirrors `File` variant.
3637 #[test]
3638 fn render_instructions_block_handles_inline_source() {
3639 let block = super::render_instructions_block(&[super::InstructionSource::Inline {
3640 name: "embedded:test/template".to_string(),
3641 content: "INLINE_MARKER_CONTENT".to_string(),
3642 }])
3643 .expect("non-empty");
3644 assert!(block.contains("INLINE_MARKER_CONTENT"));
3645 assert!(block.contains("source=\"embedded:test/template\""));
3646
3647 // Empty inline → skipped just like empty file.
3648 let empty_inline = super::InstructionSource::Inline {
3649 name: "empty".to_string(),
3650 content: " ".to_string(),
3651 };
3652 assert!(super::render_instructions_block(&[empty_inline]).is_none());
3653
3654 // Oversize inline → truncated with elided marker.
3655 let big_inline = super::InstructionSource::Inline {
3656 name: "huge".to_string(),
3657 content: "Y".repeat(200 * 1024),
3658 };
3659 let trimmed = super::render_instructions_block(&[big_inline]).expect("non-empty");
3660 assert!(trimmed.contains("[…truncated:"));
3661
3662 // File + Inline 混用,顺序保持。
3663 let tmp = tempdir().expect("tempdir");
3664 let file_path = tmp.path().join("file-first.md");
3665 std::fs::write(&file_path, "FILE_MARKER").unwrap();
3666 let mixed = super::render_instructions_block(&[
3667 file_path.into(),
3668 super::InstructionSource::Inline {
3669 name: "inline-second".to_string(),
3670 content: "INLINE_MARKER".to_string(),
3671 },
3672 ])
3673 .expect("non-empty");
3674 let file_pos = mixed.find("FILE_MARKER").expect("file rendered");
3675 let inline_pos = mixed.find("INLINE_MARKER").expect("inline rendered");
3676 assert!(file_pos < inline_pos, "声明顺序必须保留(File then Inline)");
3677 }
3678
3679 #[test]
3680 fn instructions_block_appears_in_system_prompt_when_configured() {
3681 let tmp = tempdir().expect("tempdir");
3682 let workspace = tmp.path();
3683 let extra = workspace.join("extra-instructions.md");
3684 std::fs::write(&extra, "EXTRA_INSTRUCTIONS_MARKER_BODY").unwrap();
3685
3686 let extra_source: super::InstructionSource = extra.clone().into();
3687 let prompt =
3688 system_prompt_flat_text(&super::system_prompt_for_mode_with_context_and_skills(
3689 workspace,
3690 None,
3691 None,
3692 Some(std::slice::from_ref(&extra_source)),
3693 None,
3694 ));
3695
3696 assert!(
3697 prompt.contains("EXTRA_INSTRUCTIONS_MARKER_BODY"),
3698 "configured instructions file body must appear in the prompt"
3699 );
3700 assert!(
3701 prompt.contains(&extra.display().to_string()),
3702 "instructions block must annotate its source path"
3703 );
3704 }
3705
3706 #[test]
3707 fn verbosity_concise_appends_discipline_block() {
3708 let tmp = tempdir().expect("tempdir");
3709 let workspace = tmp.path();
3710 let prompt = system_prompt_flat_text(
3711 &super::system_prompt_for_mode_with_context_skills_session_and_approval(
3712 workspace,
3713 None,
3714 None,
3715 None,
3716 PromptSessionContext {
3717 user_memory_block: None,
3718 goal_objective: None,
3719 project_context_pack_enabled: false,
3720 locale_tag: "en",
3721 translation_enabled: false,
3722 model_id: "codewhale",
3723 context_window_override: None,
3724 verbosity: Some(" Concise "),
3725 skills_scan_codewhale_only: false,
3726 plugin_registry: None,
3727 mode: crate::tui::app::AppMode::Agent,
3728 },
3729 ),
3730 );
3731
3732 assert!(
3733 prompt.contains("## Concise Output Discipline"),
3734 "Concise Output Discipline should be appended"
3735 );
3736 }
3737
3738 /// #2953 — the Calm overlay (`CALM_PERSONALITY`) stays out of the default
3739 /// model-prompt path to keep the static prefix slim. Voice and tone
3740 /// guidance travels via the constitution preamble instead.
3741 #[test]
3742 fn default_prompt_does_not_include_calm_personality_overlay() {
3743 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3744 let calm_text = CALM_PERSONALITY;
3745 let first_calm_line = calm_text.lines().find(|l| !l.is_empty()).unwrap_or("");
3746 assert!(
3747 !prompt.contains(first_calm_line),
3748 "default agent prompt must not include the calm personality overlay"
3749 );
3750 }
3751
3752 #[test]
3753 fn live_prompt_path_returns_world_state_blocks_with_markers() {
3754 let tmp = tempdir().expect("tempdir");
3755 let prompt = system_prompt_for_mode_with_context_skills_session_and_approval(
3756 tmp.path(),
3757 None,
3758 None,
3759 None,
3760 PromptSessionContext {
3761 user_memory_block: Some("## Memory\n- remember the cutover"),
3762 goal_objective: Some("ship WorldState Blocks"),
3763 project_context_pack_enabled: false,
3764 locale_tag: "en",
3765 translation_enabled: false,
3766 model_id: "deepseek-v4-pro",
3767 context_window_override: None,
3768 verbosity: Some("concise"),
3769 skills_scan_codewhale_only: false,
3770 plugin_registry: None,
3771 mode: crate::tui::app::AppMode::Agent,
3772 },
3773 );
3774
3775 let SystemPrompt::Blocks(blocks) = prompt else {
3776 panic!("live prompt assembly must return SystemPrompt::Blocks");
3777 };
3778 assert!(
3779 blocks.len() >= 3,
3780 "constitution + at least one WorldState fragment + authority trailer"
3781 );
3782 assert!(
3783 !blocks[0].text.contains("<!-- cw:ctx:"),
3784 "constitution block must stay marker-free for prefix cache stability"
3785 );
3786 assert!(
3787 blocks[0].text.contains("## Core Execution"),
3788 "constitution retains static core execution guidance"
3789 );
3790
3791 let flat = system_prompt_flat_text(&SystemPrompt::Blocks(blocks.clone()));
3792 assert!(flat.contains(crate::model_context::FragmentId::Workspace.marker()));
3793 assert!(flat.contains(crate::model_context::FragmentId::Route.marker()));
3794 assert!(flat.contains("## Environment"));
3795 assert!(
3796 flat.contains("verbosity: concise") && flat.contains("translation: off"),
3797 "route fragment keeps verbosity/translation but drops the model id"
3798 );
3799 assert!(!flat.contains("model: deepseek-v4-pro"));
3800 assert!(flat.contains("<session_goal>"));
3801 assert!(flat.contains("ship WorldState Blocks"));
3802 assert!(flat.contains("remember the cutover"));
3803 assert!(flat.contains("## Authority Recap"));
3804 assert!(
3805 !flat.contains(crate::model_context::FragmentId::SkillsTools.marker()),
3806 "skills remain in constitution, not a volatile SkillsTools fragment"
3807 );
3808 }
3809
3810 #[test]
3811 fn live_prompt_world_state_diff_retains_unchanged_fragments() {
3812 let tmp = tempdir().expect("tempdir");
3813 let session = PromptSessionContext {
3814 user_memory_block: None,
3815 goal_objective: None,
3816 project_context_pack_enabled: false,
3817 locale_tag: "en",
3818 translation_enabled: false,
3819 model_id: "codewhale",
3820 context_window_override: None,
3821 verbosity: None,
3822 skills_scan_codewhale_only: false,
3823 plugin_registry: None,
3824 mode: crate::tui::app::AppMode::Agent,
3825 };
3826 let first = system_prompt_for_mode_with_context_skills_session_and_approval(
3827 tmp.path(),
3828 None,
3829 None,
3830 None,
3831 session.clone(),
3832 );
3833 let second = system_prompt_for_mode_with_context_skills_session_and_approval(
3834 tmp.path(),
3835 None,
3836 None,
3837 None,
3838 PromptSessionContext {
3839 goal_objective: Some("only goal changed"),
3840 ..session
3841 },
3842 );
3843
3844 let extract_world = |prompt: &SystemPrompt| -> crate::model_context::WorldState {
3845 let SystemPrompt::Blocks(blocks) = prompt else {
3846 panic!("expected Blocks");
3847 };
3848 let mut state = crate::model_context::WorldState::new();
3849 for block in blocks.iter().skip(1) {
3850 for id in crate::model_context::FragmentId::all() {
3851 let marker = id.marker();
3852 if let Some(rest) = block.text.strip_prefix(marker) {
3853 let body = rest.trim_start_matches('\n');
3854 state.upsert(crate::model_context::ModelContextFragment::new(
3855 *id,
3856 id.role(),
3857 body,
3858 ));
3859 }
3860 }
3861 }
3862 state
3863 };
3864
3865 let previous = extract_world(&first);
3866 let next = extract_world(&second);
3867 let diff = next.render_diff(Some(&previous));
3868 assert!(
3869 diff.retained
3870 .iter()
3871 .any(|marker| marker == crate::model_context::FragmentId::Route.marker()),
3872 "unchanged route fragment must be retained: {diff:?}"
3873 );
3874 assert!(
3875 diff.updated
3876 .iter()
3877 .any(|fragment| fragment.id == crate::model_context::FragmentId::Workspace),
3878 "goal change must update workspace fragment: {diff:?}"
3879 );
3880 }
3881
3882 #[test]
3883 fn default_prompt_stays_under_2953_static_baseline() {
3884 const ISSUE_2953_BASELINE_CHARS: usize = 30_461;
3885 let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
3886
3887 assert!(
3888 prompt.chars().count() < ISSUE_2953_BASELINE_CHARS,
3889 "default static prompt should stay below the #2953 baseline"
3890 );
3891 }
3892 }
3893 #[test]
3894 fn core_execution_profile_is_runtime_only() {
3895 for required in [
3896 "repository instructions",
3897 "inspect the narrow owner",
3898 "verify it",
3899 "Report changed files",
3900 ] {
3901 assert!(CORE_EXECUTION_PROFILE_PROMPT.contains(required));
3902 }
3903 for forbidden in [
3904 "footer",
3905 "color",
3906 "hotbar",
3907 "panel",
3908 "Fleet",
3909 "Workflow",
3910 "OpenHands",
3911 ] {
3912 assert!(!CORE_EXECUTION_PROFILE_PROMPT.contains(forbidden));
3913 }
3914 }
3915
3915 lines RUST