| 1 | //! Exact effective base-prompt preview (#3928). |
| 2 | //! |
| 3 | //! `/constitution preview` shows the *structured user constitution*. This module |
| 4 | //! shows something different and stricter: **the exact bytes the next turn's |
| 5 | //! system prompt is assembled from**, block by block, with provenance, digests, |
| 6 | //! and byte/token measures. |
| 7 | //! |
| 8 | //! Three properties make it trustworthy: |
| 9 | //! |
| 10 | //! - **Exactness by construction.** [`preview`] takes the very |
| 11 | //! [`SystemPrompt`] the caller is about to send. It never re-derives, re-orders, |
| 12 | //! or re-renders anything, so "what you previewed" and "what was sent" cannot |
| 13 | //! drift. [`BasePromptPreview::exact_digest`] is computed over the unredacted |
| 14 | //! assembled text and equals the digest of |
| 15 | //! [`system_prompt_flat_text`](super::system_prompt_flat_text). |
| 16 | //! - **Truthful provenance.** Each segment says where its bytes came from: |
| 17 | //! the bundled constant, a config-directory override (with the opt-in gate |
| 18 | //! that let it in), an embedder composer hook, the user-global constitution |
| 19 | //! file, workspace-generated context, or a marked WorldState fragment. No |
| 20 | //! segment is described by a source-tree path it does not actually load from. |
| 21 | //! - **Human-only.** This is a pure function of a `&SystemPrompt`. It makes no |
| 22 | //! provider call, expands no tool catalog, spawns nothing, and takes no |
| 23 | //! registry — so previewing costs nothing and cannot change the next turn. |
| 24 | //! |
| 25 | //! Display text is redacted (home paths, key-shaped tokens, URL userinfo) while |
| 26 | //! the measures and digests stay over the real bytes, so a redacted preview |
| 27 | //! still tells the truth about size and identity. |
| 28 | |
| 29 | use std::path::Path; |
| 30 | |
| 31 | use crate::models::{SystemBlock, SystemPrompt}; |
| 32 | |
| 33 | /// Pager title for the preview surface. |
| 34 | pub const PREVIEW_TITLE: &str = "Effective Base Prompt"; |
| 35 | |
| 36 | /// Bytes-per-token divisor for the coarse, deterministic token estimate. |
| 37 | /// Shared with the constitution's own projection so the two agree. |
| 38 | pub(crate) const APPROX_BYTES_PER_TOKEN: usize = codewhale_config::APPROX_BYTES_PER_TOKEN; |
| 39 | |
| 40 | /// Deterministic size measures for a run of prompt bytes. |
| 41 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 42 | pub struct Measures { |
| 43 | pub byte_len: usize, |
| 44 | pub char_len: usize, |
| 45 | /// Coarse estimate, not a tokenizer result. Labeled as approximate wherever |
| 46 | /// it is displayed. |
| 47 | pub approx_tokens: usize, |
| 48 | } |
| 49 | |
| 50 | impl Measures { |
| 51 | #[must_use] |
| 52 | pub fn of(text: &str) -> Self { |
| 53 | let byte_len = text.len(); |
| 54 | Self { |
| 55 | byte_len, |
| 56 | char_len: text.chars().count(), |
| 57 | approx_tokens: byte_len.div_ceil(APPROX_BYTES_PER_TOKEN), |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | fn add(self, other: Self) -> Self { |
| 62 | Self { |
| 63 | byte_len: self.byte_len + other.byte_len, |
| 64 | char_len: self.char_len + other.char_len, |
| 65 | approx_tokens: self.approx_tokens + other.approx_tokens, |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// Where a segment's bytes actually came from. |
| 71 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 72 | pub enum SegmentProvenance { |
| 73 | /// A compile-time constant shipped in the binary. |
| 74 | Bundled { symbol: &'static str }, |
| 75 | /// A file under `$CODEWHALE_HOME` that replaced a bundled constant, plus the |
| 76 | /// opt-in environment gate that allowed it. |
| 77 | ConfigOverride { |
| 78 | path: String, |
| 79 | opt_in_env: &'static str, |
| 80 | }, |
| 81 | /// An embedder replaced the static composition through the public hook. |
| 82 | EmbedderComposer, |
| 83 | /// The structured user-global constitution file. |
| 84 | UserGlobalConstitution { path: String }, |
| 85 | /// Repo-local law or project instructions read from the workspace. |
| 86 | Workspace { path: String }, |
| 87 | /// Generated in memory from the workspace (no file backs it). |
| 88 | WorkspaceGenerated, |
| 89 | /// A marked, volatile WorldState fragment below the cache boundary. |
| 90 | WorldStateFragment { marker: String }, |
| 91 | /// Composed from several of the above; the label names which. |
| 92 | Composed { detail: String }, |
| 93 | } |
| 94 | |
| 95 | impl SegmentProvenance { |
| 96 | /// Short label for the preview surface. |
| 97 | #[must_use] |
| 98 | pub fn label(&self) -> String { |
| 99 | match self { |
| 100 | Self::Bundled { symbol } => format!("bundled ({symbol})"), |
| 101 | Self::ConfigOverride { path, opt_in_env } => { |
| 102 | format!("config override {path} (opted in via {opt_in_env})") |
| 103 | } |
| 104 | Self::EmbedderComposer => "embedder composer hook".to_string(), |
| 105 | Self::UserGlobalConstitution { path } => format!("user-global constitution {path}"), |
| 106 | Self::Workspace { path } => format!("workspace {path}"), |
| 107 | Self::WorkspaceGenerated => "workspace-generated (no file)".to_string(), |
| 108 | Self::WorldStateFragment { marker } => format!("world-state fragment {marker}"), |
| 109 | Self::Composed { detail } => format!("composed: {detail}"), |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// True when these bytes belong to the cache-stable prefix. WorldState |
| 114 | /// fragments drift turn-over-turn and are not cacheable. |
| 115 | #[must_use] |
| 116 | pub fn is_cache_stable(&self) -> bool { |
| 117 | !matches!(self, Self::WorldStateFragment { .. }) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// One system block, measured and attributed. |
| 122 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 123 | pub struct PreviewSegment { |
| 124 | /// 0-based position in the assembled prompt. |
| 125 | pub index: usize, |
| 126 | pub label: String, |
| 127 | pub provenance: SegmentProvenance, |
| 128 | /// Measures over the **real** bytes, before redaction. |
| 129 | pub measures: Measures, |
| 130 | /// Digest over the real bytes. |
| 131 | pub digest: String, |
| 132 | /// Display text, redacted. May differ from the real bytes; `redacted` says so. |
| 133 | pub text: String, |
| 134 | pub redacted: bool, |
| 135 | } |
| 136 | |
| 137 | /// The exact effective base prompt for the next turn. |
| 138 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 139 | pub struct BasePromptPreview { |
| 140 | pub segments: Vec<PreviewSegment>, |
| 141 | /// Totals over every segment's real bytes. |
| 142 | pub total: Measures, |
| 143 | /// Totals over the cache-stable prefix only. |
| 144 | pub cache_stable: Measures, |
| 145 | /// Digest of the full assembled, unredacted prompt text. This is the |
| 146 | /// identity check: it must equal the digest of what is actually sent. |
| 147 | pub exact_digest: String, |
| 148 | /// How many segments were redacted for display. |
| 149 | pub redacted_segments: usize, |
| 150 | } |
| 151 | |
| 152 | impl BasePromptPreview { |
| 153 | /// True when nothing needed masking, so the displayed text is byte-exact. |
| 154 | #[must_use] |
| 155 | pub fn display_is_exact(&self) -> bool { |
| 156 | self.redacted_segments == 0 |
| 157 | } |
| 158 | |
| 159 | /// Rejoin the displayed segments the same way the prompt is assembled. |
| 160 | #[must_use] |
| 161 | pub fn display_text(&self) -> String { |
| 162 | self.segments |
| 163 | .iter() |
| 164 | .map(|segment| segment.text.as_str()) |
| 165 | .collect::<Vec<_>>() |
| 166 | .join("\n\n") |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// Which source the effective base prompt currently comes from. |
| 171 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 172 | pub enum BasePromptSource { |
| 173 | Bundled, |
| 174 | ConfigOverride { path: String }, |
| 175 | EmbedderComposer, |
| 176 | } |
| 177 | |
| 178 | impl BasePromptSource { |
| 179 | fn provenance(&self) -> SegmentProvenance { |
| 180 | match self { |
| 181 | Self::Bundled => SegmentProvenance::Bundled { |
| 182 | symbol: "BASE_PROMPT", |
| 183 | }, |
| 184 | Self::ConfigOverride { path } => SegmentProvenance::ConfigOverride { |
| 185 | path: path.clone(), |
| 186 | opt_in_env: super::BASE_PROMPT_OVERRIDE_OPT_IN_ENV, |
| 187 | }, |
| 188 | Self::EmbedderComposer => SegmentProvenance::EmbedderComposer, |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Sources the caller already knows, so the preview never guesses. |
| 194 | /// |
| 195 | /// Everything here is provenance metadata. Supplying it wrong makes the labels |
| 196 | /// wrong; it can never change the previewed bytes, which come from the |
| 197 | /// `SystemPrompt` alone. |
| 198 | #[derive(Debug, Clone, Default)] |
| 199 | pub struct PreviewSources<'a> { |
| 200 | /// Where the effective base prompt came from. `None` means bundled. |
| 201 | pub base_prompt: Option<BasePromptSource>, |
| 202 | /// Path of the loaded user-global constitution, when one is active. |
| 203 | pub user_constitution_path: Option<&'a Path>, |
| 204 | /// Workspace root, used to shorten workspace-relative paths in display text. |
| 205 | pub workspace: Option<&'a Path>, |
| 206 | /// Home directory, masked to `~` in display text. |
| 207 | pub home: Option<&'a Path>, |
| 208 | } |
| 209 | |
| 210 | /// Build the exact effective base-prompt preview. |
| 211 | /// |
| 212 | /// Pure over `prompt`: no I/O beyond the caller-supplied path metadata, no |
| 213 | /// provider call, no tool expansion. |
| 214 | #[must_use] |
| 215 | pub fn preview(prompt: &SystemPrompt, sources: &PreviewSources<'_>) -> BasePromptPreview { |
| 216 | let blocks: Vec<SystemBlock> = match prompt { |
| 217 | SystemPrompt::Text(text) => vec![SystemBlock { |
| 218 | block_type: "text".to_string(), |
| 219 | text: text.clone(), |
| 220 | cache_control: None, |
| 221 | }], |
| 222 | SystemPrompt::Blocks(blocks) => blocks.clone(), |
| 223 | }; |
| 224 | |
| 225 | let mut segments = Vec::with_capacity(blocks.len()); |
| 226 | let mut total = Measures::default(); |
| 227 | let mut cache_stable = Measures::default(); |
| 228 | let mut redacted_segments = 0; |
| 229 | |
| 230 | for (index, block) in blocks.iter().enumerate() { |
| 231 | let provenance = classify(index, &block.text, sources); |
| 232 | let measures = Measures::of(&block.text); |
| 233 | let display = redact(&block.text, sources); |
| 234 | let redacted = display != block.text; |
| 235 | if redacted { |
| 236 | redacted_segments += 1; |
| 237 | } |
| 238 | total = total.add(measures); |
| 239 | if provenance.is_cache_stable() { |
| 240 | cache_stable = cache_stable.add(measures); |
| 241 | } |
| 242 | segments.push(PreviewSegment { |
| 243 | index, |
| 244 | label: segment_label(index, &block.text), |
| 245 | provenance, |
| 246 | measures, |
| 247 | digest: digest(&block.text), |
| 248 | text: display, |
| 249 | redacted, |
| 250 | }); |
| 251 | } |
| 252 | |
| 253 | let exact = super::system_prompt_flat_text(prompt); |
| 254 | BasePromptPreview { |
| 255 | segments, |
| 256 | total, |
| 257 | cache_stable, |
| 258 | exact_digest: digest(&exact), |
| 259 | redacted_segments, |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | /// Render the preview as the pager body: a provenance/measure table, then the |
| 264 | /// segments themselves. |
| 265 | #[must_use] |
| 266 | pub fn render_report(preview: &BasePromptPreview) -> String { |
| 267 | use std::fmt::Write as _; |
| 268 | |
| 269 | let mut out = String::new(); |
| 270 | out.push_str("Exact effective base prompt for the next turn\n\n"); |
| 271 | let _ = writeln!( |
| 272 | out, |
| 273 | "Total: {} bytes, {} chars, ~{} tokens (estimate) across {} block(s)", |
| 274 | preview.total.byte_len, |
| 275 | preview.total.char_len, |
| 276 | preview.total.approx_tokens, |
| 277 | preview.segments.len() |
| 278 | ); |
| 279 | let _ = writeln!( |
| 280 | out, |
| 281 | "Cache-stable prefix: {} bytes, ~{} tokens (estimate)", |
| 282 | preview.cache_stable.byte_len, preview.cache_stable.approx_tokens |
| 283 | ); |
| 284 | let _ = writeln!( |
| 285 | out, |
| 286 | "Digest of the exact sent bytes: {}", |
| 287 | preview.exact_digest |
| 288 | ); |
| 289 | if preview.display_is_exact() { |
| 290 | out.push_str("Displayed text is byte-exact; nothing needed redaction.\n"); |
| 291 | } else { |
| 292 | let _ = writeln!( |
| 293 | out, |
| 294 | "{} block(s) are redacted below for display. Measures and digests are over the real bytes.", |
| 295 | preview.redacted_segments |
| 296 | ); |
| 297 | } |
| 298 | out.push_str( |
| 299 | "\nNo provider request was made and no tool catalog was expanded to build this preview.\n", |
| 300 | ); |
| 301 | |
| 302 | out.push_str("\nBlocks\n"); |
| 303 | for segment in &preview.segments { |
| 304 | let _ = writeln!( |
| 305 | out, |
| 306 | "- [{}] {} — {} — {} bytes, ~{} tokens, digest {}{}", |
| 307 | segment.index, |
| 308 | segment.label, |
| 309 | segment.provenance.label(), |
| 310 | segment.measures.byte_len, |
| 311 | segment.measures.approx_tokens, |
| 312 | segment.digest, |
| 313 | if segment.redacted { " (redacted)" } else { "" } |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | for segment in &preview.segments { |
| 318 | let _ = write!( |
| 319 | out, |
| 320 | "\n--- [{}] {} ---\n{}\n", |
| 321 | segment.index, segment.label, segment.text |
| 322 | ); |
| 323 | } |
| 324 | out |
| 325 | } |
| 326 | |
| 327 | fn segment_label(index: usize, text: &str) -> String { |
| 328 | if let Some(marker) = world_state_marker(text) { |
| 329 | return format!("world state: {marker}"); |
| 330 | } |
| 331 | if index == 0 { |
| 332 | return "constitution prefix (cache-stable)".to_string(); |
| 333 | } |
| 334 | if text.starts_with("## Authority Recap") { |
| 335 | return "authority recap trailer".to_string(); |
| 336 | } |
| 337 | format!("trailer block {index}") |
| 338 | } |
| 339 | |
| 340 | fn classify(index: usize, text: &str, sources: &PreviewSources<'_>) -> SegmentProvenance { |
| 341 | if let Some(marker) = world_state_marker(text) { |
| 342 | return SegmentProvenance::WorldStateFragment { marker }; |
| 343 | } |
| 344 | if index > 0 { |
| 345 | return SegmentProvenance::Bundled { |
| 346 | symbol: "AUTHORITY_RECAP / locale trailer", |
| 347 | }; |
| 348 | } |
| 349 | |
| 350 | // Block 0 is the composed cache-stable prefix. Name every source that |
| 351 | // actually contributed rather than pretending it is one file. |
| 352 | let base = sources |
| 353 | .base_prompt |
| 354 | .clone() |
| 355 | .unwrap_or(BasePromptSource::Bundled); |
| 356 | let mut parts = vec![base.provenance().label()]; |
| 357 | if text.contains("<codewhale_user_constitution") { |
| 358 | parts.push(match sources.user_constitution_path { |
| 359 | Some(path) => SegmentProvenance::UserGlobalConstitution { |
| 360 | path: display_path(path, sources), |
| 361 | } |
| 362 | .label(), |
| 363 | // The block is present but the caller did not tell us which file it |
| 364 | // came from. Say that, rather than naming a path we did not read. |
| 365 | None => "user-global constitution (path not reported)".to_string(), |
| 366 | }); |
| 367 | } |
| 368 | if text.contains("<project_context") || text.contains("## Project Context") { |
| 369 | parts.push(SegmentProvenance::WorkspaceGenerated.label()); |
| 370 | } |
| 371 | if parts.len() == 1 { |
| 372 | return base.provenance(); |
| 373 | } |
| 374 | SegmentProvenance::Composed { |
| 375 | detail: parts.join(" + "), |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | fn world_state_marker(text: &str) -> Option<String> { |
| 380 | let first = text.lines().next()?.trim(); |
| 381 | let inner = first.strip_prefix("<!-- cw:ctx:")?.strip_suffix("-->")?; |
| 382 | Some(inner.trim().to_string()) |
| 383 | } |
| 384 | |
| 385 | fn display_path(path: &Path, sources: &PreviewSources<'_>) -> String { |
| 386 | let text = path.display().to_string(); |
| 387 | redact(&text, sources) |
| 388 | } |
| 389 | |
| 390 | /// Mask home paths, key-shaped tokens, and URL userinfo for display. |
| 391 | /// |
| 392 | /// Deliberately conservative and local: the preview shows prompt bytes, so it |
| 393 | /// must not become a way to read a secret that leaked into one. |
| 394 | fn redact(text: &str, sources: &PreviewSources<'_>) -> String { |
| 395 | let mut out = text.to_string(); |
| 396 | if let Some(home) = sources.home { |
| 397 | let home = home.display().to_string(); |
| 398 | if !home.is_empty() && home != "/" { |
| 399 | out = out.replace(&home, "~"); |
| 400 | } |
| 401 | } |
| 402 | out = redact_userinfo(&out); |
| 403 | redact_key_shaped(&out) |
| 404 | } |
| 405 | |
| 406 | /// Replace `scheme://user:secret@host` with `scheme://***@host`. |
| 407 | fn redact_userinfo(text: &str) -> String { |
| 408 | let mut out = String::with_capacity(text.len()); |
| 409 | let mut rest = text; |
| 410 | while let Some(at) = rest.find("://") { |
| 411 | let (head, tail) = rest.split_at(at + 3); |
| 412 | out.push_str(head); |
| 413 | let end = tail |
| 414 | .find(|c: char| c.is_whitespace() || c == '/' || c == '"') |
| 415 | .unwrap_or(tail.len()); |
| 416 | let (authority, remainder) = tail.split_at(end); |
| 417 | match authority.rsplit_once('@') { |
| 418 | Some((_, host)) => { |
| 419 | out.push_str("***@"); |
| 420 | out.push_str(host); |
| 421 | } |
| 422 | None => out.push_str(authority), |
| 423 | } |
| 424 | rest = remainder; |
| 425 | } |
| 426 | out.push_str(rest); |
| 427 | out |
| 428 | } |
| 429 | |
| 430 | /// Mask long opaque tokens that look like credentials (`sk-…`, `ghp_…`, bearer |
| 431 | /// values). Ordinary prose and paths are left alone. |
| 432 | fn redact_key_shaped(text: &str) -> String { |
| 433 | const PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "Bearer "]; |
| 434 | let mut out = String::with_capacity(text.len()); |
| 435 | for token in text.split_inclusive(char::is_whitespace) { |
| 436 | let trimmed = token.trim_end(); |
| 437 | let looks_secret = PREFIXES |
| 438 | .iter() |
| 439 | .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 8); |
| 440 | if looks_secret { |
| 441 | out.push_str("[redacted]"); |
| 442 | out.push_str(&token[trimmed.len()..]); |
| 443 | } else { |
| 444 | out.push_str(token); |
| 445 | } |
| 446 | } |
| 447 | out |
| 448 | } |
| 449 | |
| 450 | /// FNV-1a 64-bit, hex. Same construction the constitution projection uses, so |
| 451 | /// digests from the two surfaces are comparable. |
| 452 | fn digest(text: &str) -> String { |
| 453 | const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; |
| 454 | const PRIME: u64 = 0x0000_0100_0000_01b3; |
| 455 | let mut hash = OFFSET; |
| 456 | for &b in text.as_bytes() { |
| 457 | hash ^= u64::from(b); |
| 458 | hash = hash.wrapping_mul(PRIME); |
| 459 | } |
| 460 | format!("{hash:016x}") |
| 461 | } |
| 462 | |
| 463 | #[cfg(test)] |
| 464 | mod tests { |
| 465 | use super::*; |
| 466 | |
| 467 | fn blocks(texts: &[&str]) -> SystemPrompt { |
| 468 | SystemPrompt::Blocks( |
| 469 | texts |
| 470 | .iter() |
| 471 | .map(|text| SystemBlock { |
| 472 | block_type: "text".to_string(), |
| 473 | text: (*text).to_string(), |
| 474 | cache_control: None, |
| 475 | }) |
| 476 | .collect(), |
| 477 | ) |
| 478 | } |
| 479 | |
| 480 | #[test] |
| 481 | fn preview_digest_equals_the_exact_sent_prompt() { |
| 482 | let prompt = blocks(&[ |
| 483 | "# Constitution\nBe truthful.", |
| 484 | "<!-- cw:ctx:workspace -->\ncwd: /tmp", |
| 485 | "## Authority Recap\nConsult Whose word wins.", |
| 486 | ]); |
| 487 | let preview = preview(&prompt, &PreviewSources::default()); |
| 488 | |
| 489 | let exact = crate::prompts::system_prompt_flat_text(&prompt); |
| 490 | assert_eq!(preview.exact_digest, digest(&exact)); |
| 491 | // Nothing needed masking, so the display is byte-exact too. |
| 492 | assert!(preview.display_is_exact()); |
| 493 | assert_eq!(preview.display_text(), exact); |
| 494 | assert_eq!(preview.total.byte_len, exact.len() - 2 * "\n\n".len()); |
| 495 | } |
| 496 | |
| 497 | #[test] |
| 498 | fn measures_are_per_block_and_sum_to_the_total() { |
| 499 | let prompt = blocks(&["aaaa", "bbbbbbbb"]); |
| 500 | let preview = preview(&prompt, &PreviewSources::default()); |
| 501 | assert_eq!(preview.segments[0].measures.byte_len, 4); |
| 502 | assert_eq!(preview.segments[0].measures.approx_tokens, 1); |
| 503 | assert_eq!(preview.segments[1].measures.approx_tokens, 2); |
| 504 | assert_eq!(preview.total.byte_len, 12); |
| 505 | assert_eq!(preview.total.approx_tokens, 3); |
| 506 | } |
| 507 | |
| 508 | #[test] |
| 509 | fn world_state_fragments_are_named_and_excluded_from_the_cache_stable_total() { |
| 510 | let prompt = blocks(&[ |
| 511 | "# Constitution", |
| 512 | "<!-- cw:ctx:route -->\nmodel: glm-5.2", |
| 513 | "<!-- cw:ctx:token_budget -->\nrelay", |
| 514 | ]); |
| 515 | let preview = preview(&prompt, &PreviewSources::default()); |
| 516 | |
| 517 | assert_eq!( |
| 518 | preview.segments[1].provenance, |
| 519 | SegmentProvenance::WorldStateFragment { |
| 520 | marker: "route".to_string() |
| 521 | } |
| 522 | ); |
| 523 | assert!(preview.segments[1].label.contains("route")); |
| 524 | assert!(!preview.segments[1].provenance.is_cache_stable()); |
| 525 | assert_eq!( |
| 526 | preview.cache_stable.byte_len, |
| 527 | preview.segments[0].measures.byte_len |
| 528 | ); |
| 529 | assert!(preview.cache_stable.byte_len < preview.total.byte_len); |
| 530 | } |
| 531 | |
| 532 | #[test] |
| 533 | fn bundled_and_overridden_base_prompts_are_labeled_differently() { |
| 534 | let prompt = blocks(&["# Constitution"]); |
| 535 | |
| 536 | let bundled = preview(&prompt, &PreviewSources::default()); |
| 537 | assert_eq!( |
| 538 | bundled.segments[0].provenance, |
| 539 | SegmentProvenance::Bundled { |
| 540 | symbol: "BASE_PROMPT" |
| 541 | } |
| 542 | ); |
| 543 | assert!(bundled.segments[0].provenance.label().contains("bundled")); |
| 544 | |
| 545 | let overridden = preview( |
| 546 | &prompt, |
| 547 | &PreviewSources { |
| 548 | base_prompt: Some(BasePromptSource::ConfigOverride { |
| 549 | path: "/home/u/.codewhale/prompts/constitution.md".to_string(), |
| 550 | }), |
| 551 | ..PreviewSources::default() |
| 552 | }, |
| 553 | ); |
| 554 | let label = overridden.segments[0].provenance.label(); |
| 555 | assert!(label.contains("config override"), "{label}"); |
| 556 | assert!( |
| 557 | label.contains(super::super::BASE_PROMPT_OVERRIDE_OPT_IN_ENV), |
| 558 | "the opt-in gate that allowed the override must be named: {label}" |
| 559 | ); |
| 560 | |
| 561 | let embedder = preview( |
| 562 | &prompt, |
| 563 | &PreviewSources { |
| 564 | base_prompt: Some(BasePromptSource::EmbedderComposer), |
| 565 | ..PreviewSources::default() |
| 566 | }, |
| 567 | ); |
| 568 | assert_eq!( |
| 569 | embedder.segments[0].provenance, |
| 570 | SegmentProvenance::EmbedderComposer |
| 571 | ); |
| 572 | } |
| 573 | |
| 574 | #[test] |
| 575 | fn constitution_block_provenance_never_invents_a_path() { |
| 576 | let prompt = blocks(&[ |
| 577 | "# Constitution\n\n<codewhale_user_constitution source=\"user-global\">\nx\n</codewhale_user_constitution>", |
| 578 | ]); |
| 579 | let unknown = preview(&prompt, &PreviewSources::default()); |
| 580 | let label = unknown.segments[0].provenance.label(); |
| 581 | assert!(label.contains("path not reported"), "{label}"); |
| 582 | assert!(!label.contains("crates/"), "no source-tree path: {label}"); |
| 583 | |
| 584 | let known = preview( |
| 585 | &prompt, |
| 586 | &PreviewSources { |
| 587 | user_constitution_path: Some(Path::new("/home/u/.codewhale/constitution.json")), |
| 588 | ..PreviewSources::default() |
| 589 | }, |
| 590 | ); |
| 591 | assert!( |
| 592 | known.segments[0] |
| 593 | .provenance |
| 594 | .label() |
| 595 | .contains("constitution.json") |
| 596 | ); |
| 597 | } |
| 598 | |
| 599 | #[test] |
| 600 | fn redaction_masks_secrets_without_lying_about_size() { |
| 601 | let raw = "key sk-abcdefghijklmnop and https://user:pw@example.com/x"; |
| 602 | let prompt = blocks(&[raw]); |
| 603 | let preview = preview(&prompt, &PreviewSources::default()); |
| 604 | let segment = &preview.segments[0]; |
| 605 | |
| 606 | assert!(segment.redacted); |
| 607 | assert!(!segment.text.contains("sk-abcdefghijklmnop")); |
| 608 | assert!(!segment.text.contains("pw@")); |
| 609 | assert!(segment.text.contains("***@example.com")); |
| 610 | // Measures and digest still describe the real bytes. |
| 611 | assert_eq!(segment.measures.byte_len, raw.len()); |
| 612 | assert_eq!(segment.digest, digest(raw)); |
| 613 | assert_eq!(preview.exact_digest, digest(raw)); |
| 614 | assert!(!preview.display_is_exact()); |
| 615 | } |
| 616 | |
| 617 | #[test] |
| 618 | fn home_paths_are_masked_in_display_only() { |
| 619 | let raw = "constitution at /Users/someone/.codewhale/constitution.json"; |
| 620 | let prompt = blocks(&[raw]); |
| 621 | let preview = preview( |
| 622 | &prompt, |
| 623 | &PreviewSources { |
| 624 | home: Some(Path::new("/Users/someone")), |
| 625 | ..PreviewSources::default() |
| 626 | }, |
| 627 | ); |
| 628 | assert!(preview.segments[0].text.contains("~/.codewhale")); |
| 629 | assert!(!preview.segments[0].text.contains("/Users/someone")); |
| 630 | assert_eq!(preview.segments[0].measures.byte_len, raw.len()); |
| 631 | } |
| 632 | |
| 633 | #[test] |
| 634 | fn report_states_the_no_request_no_tool_expansion_boundary() { |
| 635 | let prompt = blocks(&["# Constitution"]); |
| 636 | let report = render_report(&preview(&prompt, &PreviewSources::default())); |
| 637 | assert!(report.contains("No provider request was made")); |
| 638 | assert!(report.contains("no tool catalog was expanded")); |
| 639 | assert!(report.contains("Digest of the exact sent bytes")); |
| 640 | assert!( |
| 641 | report.contains("~"), |
| 642 | "token estimate must be marked approximate" |
| 643 | ); |
| 644 | } |
| 645 | |
| 646 | #[test] |
| 647 | fn text_prompts_preview_as_one_segment() { |
| 648 | let prompt = SystemPrompt::Text("flat prompt".to_string()); |
| 649 | let preview = preview(&prompt, &PreviewSources::default()); |
| 650 | assert_eq!(preview.segments.len(), 1); |
| 651 | assert_eq!(preview.exact_digest, digest("flat prompt")); |
| 652 | } |
| 653 | } |
| 654 |