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