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