| 1 | //! Three-zone prompt contract types for prefix-cache stability (#2264). |
| 2 | //! |
| 3 | //! Divides every request into three rigid zones: |
| 4 | //! |
| 5 | //! ```text |
| 6 | //! ┌─────────────────────────────────────────┐ |
| 7 | //! │ PinnedPrefix (frozen after construction) │ ← system prompt + tool catalog |
| 8 | //! │ combined_sha256 computed at freeze() │ cache hit candidate |
| 9 | //! ├─────────────────────────────────────────┤ |
| 10 | //! │ AppendLog (append-only) │ ← conversation history |
| 11 | //! │ push() only, no insert / remove / edit │ preserves prefix of prior turns |
| 12 | //! ├─────────────────────────────────────────┤ |
| 13 | //! │ TurnScratch (ephemeral) │ ← per-turn metadata |
| 14 | //! │ cleared at every turn boundary │ the only new content per request |
| 15 | //! └─────────────────────────────────────────┘ |
| 16 | //! ``` |
| 17 | //! |
| 18 | //! ## Status (Phase 1 foundation) |
| 19 | //! |
| 20 | //! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` are ready for use. |
| 21 | //! `AppendLog` / `TurnScratch` / `ThreeZoneRequest` are type scaffolding |
| 22 | //! for future phases — not yet wired into the request path. |
| 23 | |
| 24 | use codewhale_models::Role; |
| 25 | use codewhale_models::{Message, SystemPrompt, Tool}; |
| 26 | use std::sync::Arc; |
| 27 | // ── helpers ──────────────────────────────────────────────────────────── |
| 28 | |
| 29 | fn sha256_hex(bytes: &[u8]) -> String { |
| 30 | crate::hashing::sha256_hex(bytes) |
| 31 | } |
| 32 | |
| 33 | fn system_text(system: Option<&SystemPrompt>) -> String { |
| 34 | match system { |
| 35 | Some(SystemPrompt::Text(text)) => text.clone(), |
| 36 | Some(SystemPrompt::Blocks(blocks)) => { |
| 37 | let mut text = String::new(); |
| 38 | for block in blocks { |
| 39 | text.push_str(&block.text); |
| 40 | text.push('\n'); |
| 41 | } |
| 42 | text |
| 43 | } |
| 44 | None => String::new(), |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// Serialize tools to a deterministic, sorted JSON string for hashing. |
| 49 | fn tool_catalog_digest(tools: &[Tool]) -> String { |
| 50 | let mut serialized: Vec<String> = tools |
| 51 | .iter() |
| 52 | .filter_map(|t| serde_json::to_string(t).ok()) |
| 53 | .collect(); |
| 54 | serialized.sort(); |
| 55 | serialized.join("\n") |
| 56 | } |
| 57 | |
| 58 | fn combined_hash(system_text: &str, tools: &[Tool]) -> String { |
| 59 | let system_sha = sha256_hex(system_text.as_bytes()); |
| 60 | let tools_digest = tool_catalog_digest(tools); |
| 61 | let tools_sha = sha256_hex(tools_digest.as_bytes()); |
| 62 | let combined = format!("{system_sha}:{tools_sha}"); |
| 63 | sha256_hex(combined.as_bytes()) |
| 64 | } |
| 65 | |
| 66 | // ── FrozenPrefix ─────────────────────────────────────────────────────── |
| 67 | |
| 68 | /// An immutable frozen prefix — system prompt text + tool catalog, |
| 69 | /// hashed at freeze time. The hash is stable as long as the system prompt |
| 70 | /// text and full tool definitions (name, description, schema) are unchanged. |
| 71 | /// |
| 72 | /// Use [`PinnedPrefix::freeze`] to produce one. |
| 73 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 74 | pub struct FrozenPrefix { |
| 75 | pub(crate) system_text: String, |
| 76 | pub(crate) tool_catalog: String, |
| 77 | pub(crate) combined_sha256: String, |
| 78 | } |
| 79 | |
| 80 | impl FrozenPrefix { |
| 81 | /// Verify that `current_system_text` and `current_tools` match the frozen |
| 82 | /// prefix. Returns `Ok(())` when stable, `Err(PrefixDrift)` on mismatch. |
| 83 | /// |
| 84 | /// Fast path: compares raw text before falling back to SHA-256. |
| 85 | pub fn verify( |
| 86 | &self, |
| 87 | current_system_text: &str, |
| 88 | current_tools: &[Tool], |
| 89 | ) -> Result<(), PrefixDrift> { |
| 90 | let system_changed = current_system_text != self.system_text; |
| 91 | let current_tool_catalog = tool_catalog_digest(current_tools); |
| 92 | let tools_changed = current_tool_catalog != self.tool_catalog; |
| 93 | |
| 94 | if !system_changed && !tools_changed { |
| 95 | return Ok(()); |
| 96 | } |
| 97 | |
| 98 | let current_hash = combined_hash(current_system_text, current_tools); |
| 99 | Err(PrefixDrift { |
| 100 | system_changed, |
| 101 | tools_changed, |
| 102 | frozen_hash: self.combined_sha256.clone(), |
| 103 | current_hash, |
| 104 | }) |
| 105 | } |
| 106 | |
| 107 | /// Returns a short (12-char) human-readable id for display. |
| 108 | #[must_use] |
| 109 | pub fn short_id(&self) -> &str { |
| 110 | if self.combined_sha256.len() >= 12 { |
| 111 | &self.combined_sha256[..12] |
| 112 | } else { |
| 113 | &self.combined_sha256 |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// Returns the full combined SHA-256. |
| 118 | #[must_use] |
| 119 | pub fn hash(&self) -> &str { |
| 120 | &self.combined_sha256 |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // ── PinnedPrefix ─────────────────────────────────────────────────────── |
| 125 | |
| 126 | /// A mutable prefix builder. Construct from the system prompt and tool |
| 127 | /// catalog, then call [`freeze`](Self::freeze) to produce a [`FrozenPrefix`]. |
| 128 | #[derive(Debug, Clone)] |
| 129 | pub struct PinnedPrefix { |
| 130 | system_text: String, |
| 131 | tools: Vec<Tool>, |
| 132 | } |
| 133 | |
| 134 | impl PinnedPrefix { |
| 135 | #[must_use] |
| 136 | pub fn new(system: Option<&SystemPrompt>, tools: Vec<Tool>) -> Self { |
| 137 | Self { |
| 138 | system_text: system_text(system), |
| 139 | tools, |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /// Freeze this prefix into an immutable [`FrozenPrefix`]. |
| 144 | #[must_use] |
| 145 | pub fn freeze(&self) -> FrozenPrefix { |
| 146 | let tool_catalog = tool_catalog_digest(&self.tools); |
| 147 | let combined_sha256 = combined_hash(&self.system_text, &self.tools); |
| 148 | |
| 149 | FrozenPrefix { |
| 150 | system_text: self.system_text.clone(), |
| 151 | tool_catalog, |
| 152 | combined_sha256, |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // ── PrefixDrift ──────────────────────────────────────────────────────── |
| 158 | |
| 159 | /// Describes how the current prefix differs from the frozen baseline. |
| 160 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 161 | pub struct PrefixDrift { |
| 162 | pub system_changed: bool, |
| 163 | pub tools_changed: bool, |
| 164 | pub frozen_hash: String, |
| 165 | pub current_hash: String, |
| 166 | } |
| 167 | |
| 168 | impl std::fmt::Display for PrefixDrift { |
| 169 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 170 | let cause = match (self.system_changed, self.tools_changed) { |
| 171 | (true, true) => "system prompt and tool set", |
| 172 | (true, false) => "system prompt", |
| 173 | (false, true) => "tool set", |
| 174 | (false, false) => "unknown component", |
| 175 | }; |
| 176 | write!( |
| 177 | f, |
| 178 | "prefix drift: {cause} changed (frozen={}, current={})", |
| 179 | &self.frozen_hash[..12.min(self.frozen_hash.len())], |
| 180 | &self.current_hash[..12.min(self.current_hash.len())] |
| 181 | ) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // ── AppendLog ────────────────────────────────────────────────────────── |
| 186 | |
| 187 | /// Append-only conversation history. Derefs to `&[Message]` via |
| 188 | /// [`Deref`](std::ops::Deref) for transparent read access; mutations go |
| 189 | /// through explicit methods (`push`, `truncate_to`, `trim_front`, `clear`) |
| 190 | /// whose names make cache impact obvious. |
| 191 | /// |
| 192 | /// Phase 4: backing store for `Session.messages` (#2264). |
| 193 | /// |
| 194 | /// The history is reference-counted (#6214 T2): snapshots hand out `Arc` |
| 195 | /// clones instead of deep-copying the transcript per event, and mutations |
| 196 | /// copy-on-write only while a snapshot is outstanding. |
| 197 | #[derive(Debug, Clone)] |
| 198 | pub struct AppendLog { |
| 199 | messages: Arc<Vec<Message>>, |
| 200 | } |
| 201 | |
| 202 | impl AppendLog { |
| 203 | pub fn new() -> Self { |
| 204 | Self { |
| 205 | messages: Arc::new(Vec::new()), |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | pub fn from_messages(messages: Vec<Message>) -> Self { |
| 210 | Self { |
| 211 | messages: Arc::new(messages), |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /// Share the current history without copying. The engine hands this to |
| 216 | /// `Event::SessionUpdated`; the `Arc` is immutable, so an outstanding |
| 217 | /// snapshot can never observe a later mutation. |
| 218 | #[must_use] |
| 219 | pub fn snapshot(&self) -> Arc<Vec<Message>> { |
| 220 | Arc::clone(&self.messages) |
| 221 | } |
| 222 | |
| 223 | /// Append a message to the log. A single-message push is the cheapest |
| 224 | /// mutation for prefix-cache stability — it extends the byte sequence |
| 225 | /// without disturbing earlier turns. |
| 226 | pub fn push(&mut self, message: Message) { |
| 227 | Arc::make_mut(&mut self.messages).push(message); |
| 228 | } |
| 229 | |
| 230 | /// Append multiple messages in one operation (fewer cache-line |
| 231 | /// invalidations than repeated `push`). |
| 232 | pub fn push_batch(&mut self, batch: Vec<Message>) { |
| 233 | Arc::make_mut(&mut self.messages).extend(batch); |
| 234 | } |
| 235 | |
| 236 | /// Truncate to keep only the first `new_len` messages. |
| 237 | /// Discards newer messages (and their prefix-cache contribution) |
| 238 | /// from the tail. |
| 239 | pub fn truncate_to(&mut self, new_len: usize) { |
| 240 | Arc::make_mut(&mut self.messages).truncate(new_len); |
| 241 | } |
| 242 | |
| 243 | /// Remove `count` messages from the front (oldest first). |
| 244 | /// Cache-destroying: drops the prefix that earlier turns share. |
| 245 | pub fn trim_front(&mut self, count: usize) { |
| 246 | let messages = Arc::make_mut(&mut self.messages); |
| 247 | if count >= messages.len() { |
| 248 | messages.clear(); |
| 249 | } else { |
| 250 | messages.drain(0..count); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /// Remove all messages. Resets cache state completely. |
| 255 | pub fn clear(&mut self) { |
| 256 | Arc::make_mut(&mut self.messages).clear(); |
| 257 | } |
| 258 | |
| 259 | /// Return a mutable reference to the last message, if any. |
| 260 | /// Prefer this over `last_mut()` on the inner vec — the name signals |
| 261 | /// that only the most recent turn's content is being modified. |
| 262 | #[must_use] |
| 263 | pub fn last_mut(&mut self) -> Option<&mut Message> { |
| 264 | Arc::make_mut(&mut self.messages).last_mut() |
| 265 | } |
| 266 | |
| 267 | /// Consume and return the inner `Vec<Message>`, copying only if a |
| 268 | /// snapshot still shares it. |
| 269 | #[must_use] |
| 270 | pub fn into_inner(self) -> Vec<Message> { |
| 271 | Arc::try_unwrap(self.messages).unwrap_or_else(|shared| (*shared).clone()) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | impl Default for AppendLog { |
| 276 | fn default() -> Self { |
| 277 | Self::new() |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | impl From<Vec<Message>> for AppendLog { |
| 282 | fn from(messages: Vec<Message>) -> Self { |
| 283 | Self { |
| 284 | messages: Arc::new(messages), |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | impl From<AppendLog> for Vec<Message> { |
| 290 | fn from(log: AppendLog) -> Self { |
| 291 | log.into_inner() |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | impl std::ops::Deref for AppendLog { |
| 296 | type Target = Vec<Message>; |
| 297 | |
| 298 | fn deref(&self) -> &Self::Target { |
| 299 | &self.messages |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // ── TurnScratch ──────────────────────────────────────────────────────── |
| 304 | |
| 305 | /// Per-turn ephemeral data. Cleared at every turn boundary. |
| 306 | /// |
| 307 | /// **Phase 1 scaffolding** — not yet wired into the engine request path. |
| 308 | #[cfg_attr(not(test), expect(dead_code))] |
| 309 | #[derive(Debug, Clone, Default)] |
| 310 | pub struct TurnScratch { |
| 311 | pub working_set: Vec<String>, |
| 312 | pub user_message: Option<Message>, |
| 313 | } |
| 314 | |
| 315 | #[cfg_attr(not(test), expect(dead_code))] |
| 316 | impl TurnScratch { |
| 317 | pub fn new() -> Self { |
| 318 | Self::default() |
| 319 | } |
| 320 | |
| 321 | pub fn clear(&mut self) { |
| 322 | self.working_set.clear(); |
| 323 | self.user_message = None; |
| 324 | } |
| 325 | |
| 326 | #[must_use] |
| 327 | pub fn is_empty(&self) -> bool { |
| 328 | self.working_set.is_empty() && self.user_message.is_none() |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // ── ThreeZoneRequest ─────────────────────────────────────────────────── |
| 333 | |
| 334 | /// A composed three-zone request ready for DeepSeek API serialization. |
| 335 | /// |
| 336 | /// **Phase 1 scaffolding** — not yet wired into the engine request path. |
| 337 | /// Currently the engine continues to use `MessageRequest` directly. |
| 338 | #[expect(dead_code)] |
| 339 | #[derive(Debug, Clone)] |
| 340 | pub struct ThreeZoneRequest<'a> { |
| 341 | pub prefix: &'a FrozenPrefix, |
| 342 | pub log: &'a AppendLog, |
| 343 | pub scratch: TurnScratch, |
| 344 | pub model: String, |
| 345 | pub max_tokens: u32, |
| 346 | pub system: Option<SystemPrompt>, |
| 347 | pub tools: Option<Vec<Tool>>, |
| 348 | pub tool_choice: Option<serde_json::Value>, |
| 349 | pub reasoning_effort: Option<String>, |
| 350 | pub thinking: Option<serde_json::Value>, |
| 351 | pub stream: Option<bool>, |
| 352 | pub temperature: Option<f32>, |
| 353 | pub top_p: Option<f32>, |
| 354 | pub metadata: Option<serde_json::Value>, |
| 355 | } |
| 356 | |
| 357 | #[cfg_attr(not(test), expect(dead_code))] |
| 358 | impl<'a> ThreeZoneRequest<'a> { |
| 359 | /// Build the full message list from system prompt, append-log messages, |
| 360 | /// and scratch user message. The returned vector is serialized as the |
| 361 | /// `messages` field in the DeepSeek chat-completion request. |
| 362 | #[must_use] |
| 363 | pub fn build_messages(&self) -> Vec<Message> { |
| 364 | let mut messages = Vec::with_capacity(self.message_count()); |
| 365 | |
| 366 | match self.system.as_ref() { |
| 367 | Some(SystemPrompt::Text(text)) => { |
| 368 | messages.push(Message { |
| 369 | role: Role::System, |
| 370 | content: vec![codewhale_models::ContentBlock::Text { |
| 371 | text: text.clone(), |
| 372 | cache_control: None, |
| 373 | }], |
| 374 | }); |
| 375 | } |
| 376 | Some(SystemPrompt::Blocks(blocks)) => { |
| 377 | let content: Vec<codewhale_models::ContentBlock> = blocks |
| 378 | .iter() |
| 379 | .map(|block| codewhale_models::ContentBlock::Text { |
| 380 | text: block.text.clone(), |
| 381 | cache_control: block.cache_control.clone(), |
| 382 | }) |
| 383 | .collect(); |
| 384 | messages.push(Message { |
| 385 | role: Role::System, |
| 386 | content, |
| 387 | }); |
| 388 | } |
| 389 | None => {} |
| 390 | } |
| 391 | |
| 392 | for msg in self.log.iter() { |
| 393 | messages.push(msg.clone()); |
| 394 | } |
| 395 | |
| 396 | if let Some(ref user_msg) = self.scratch.user_message { |
| 397 | messages.push(user_msg.clone()); |
| 398 | } |
| 399 | |
| 400 | messages |
| 401 | } |
| 402 | |
| 403 | #[must_use] |
| 404 | pub fn message_count(&self) -> usize { |
| 405 | let system_count = if self.system.is_some() { 1 } else { 0 }; |
| 406 | let scratch_count = if self.scratch.user_message.is_some() { |
| 407 | 1 |
| 408 | } else { |
| 409 | 0 |
| 410 | }; |
| 411 | system_count + self.log.len() + scratch_count |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | // ── tests ────────────────────────────────────────────────────────────── |
| 416 | |
| 417 | #[cfg(test)] |
| 418 | mod tests { |
| 419 | use super::*; |
| 420 | use codewhale_models::ContentBlock; |
| 421 | |
| 422 | fn make_tool(name: &str) -> Tool { |
| 423 | Tool { |
| 424 | name: name.to_string(), |
| 425 | description: String::new(), |
| 426 | input_schema: serde_json::Value::Null, |
| 427 | tool_type: None, |
| 428 | allowed_callers: None, |
| 429 | defer_loading: None, |
| 430 | input_examples: None, |
| 431 | strict: None, |
| 432 | cache_control: None, |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | fn make_message(role: &str, text: &str) -> Message { |
| 437 | Message { |
| 438 | role: Role::from(role), |
| 439 | content: vec![ContentBlock::Text { |
| 440 | text: text.to_string(), |
| 441 | cache_control: None, |
| 442 | }], |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | // ── AppendLog ──────────────────────────────────────────────── |
| 447 | |
| 448 | #[test] |
| 449 | fn append_log_snapshot_shares_and_mutation_detaches() { |
| 450 | let mut log = AppendLog::new(); |
| 451 | log.push(make_message("user", "hello")); |
| 452 | let shared = log.snapshot(); |
| 453 | // No copy: the snapshot aliases the live log. |
| 454 | assert!(Arc::ptr_eq(&shared, &log.snapshot())); |
| 455 | log.push(make_message("assistant", "hi")); |
| 456 | // Copy-on-write: the outstanding snapshot still sees one message. |
| 457 | assert_eq!(shared.len(), 1); |
| 458 | assert_eq!(log.len(), 2); |
| 459 | } |
| 460 | |
| 461 | // ── FrozenPrefix / PinnedPrefix ──────────────────────────────── |
| 462 | |
| 463 | #[test] |
| 464 | fn freeze_produces_stable_hash() { |
| 465 | let tools = vec![make_tool("read"), make_tool("write")]; |
| 466 | let sys = SystemPrompt::Text("hello world".to_string()); |
| 467 | |
| 468 | let a = PinnedPrefix::new(Some(&sys), tools.clone()).freeze(); |
| 469 | let b = PinnedPrefix::new(Some(&sys), tools).freeze(); |
| 470 | |
| 471 | assert_eq!(a.combined_sha256, b.combined_sha256); |
| 472 | assert_eq!(a.hash(), b.hash()); |
| 473 | assert_eq!(a.short_id(), b.short_id()); |
| 474 | } |
| 475 | |
| 476 | #[test] |
| 477 | fn freeze_tool_order_is_stable() { |
| 478 | let sys = SystemPrompt::Text("system".to_string()); |
| 479 | let tools_a = vec![make_tool("b"), make_tool("a")]; |
| 480 | let tools_b = vec![make_tool("a"), make_tool("b")]; |
| 481 | |
| 482 | let a = PinnedPrefix::new(Some(&sys), tools_a).freeze(); |
| 483 | let b = PinnedPrefix::new(Some(&sys), tools_b).freeze(); |
| 484 | |
| 485 | assert_eq!(a.combined_sha256, b.combined_sha256); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn freeze_empty_tools() { |
| 490 | let sys = SystemPrompt::Text("system".to_string()); |
| 491 | let frozen = PinnedPrefix::new(Some(&sys), vec![]).freeze(); |
| 492 | assert!(frozen.tool_catalog.is_empty()); |
| 493 | assert!(!frozen.combined_sha256.is_empty()); |
| 494 | assert_eq!(frozen.short_id().len(), 12); |
| 495 | } |
| 496 | |
| 497 | #[test] |
| 498 | fn freeze_no_system() { |
| 499 | let tools = vec![make_tool("t1")]; |
| 500 | let frozen = PinnedPrefix::new(None, tools).freeze(); |
| 501 | assert!(frozen.system_text.is_empty()); |
| 502 | assert!(frozen.tool_catalog.contains("t1")); |
| 503 | } |
| 504 | |
| 505 | #[test] |
| 506 | fn verify_passes_when_stable() { |
| 507 | let sys = SystemPrompt::Text("system".to_string()); |
| 508 | let tools = vec![make_tool("a")]; |
| 509 | let frozen = PinnedPrefix::new(Some(&sys), tools.clone()).freeze(); |
| 510 | |
| 511 | assert!(frozen.verify("system", &tools).is_ok()); |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn verify_detects_system_change() { |
| 516 | let sys = SystemPrompt::Text("old".to_string()); |
| 517 | let tools = vec![make_tool("a")]; |
| 518 | let frozen = PinnedPrefix::new(Some(&sys), tools.clone()).freeze(); |
| 519 | |
| 520 | let drift = frozen.verify("new", &tools).unwrap_err(); |
| 521 | assert!(drift.system_changed); |
| 522 | assert!(!drift.tools_changed); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn verify_detects_tool_change() { |
| 527 | let sys = SystemPrompt::Text("system".to_string()); |
| 528 | let tools_a = vec![make_tool("a")]; |
| 529 | let frozen = PinnedPrefix::new(Some(&sys), tools_a).freeze(); |
| 530 | |
| 531 | let tools_b = vec![make_tool("b")]; |
| 532 | let drift = frozen.verify("system", &tools_b).unwrap_err(); |
| 533 | assert!(!drift.system_changed); |
| 534 | assert!(drift.tools_changed); |
| 535 | } |
| 536 | |
| 537 | #[test] |
| 538 | fn verify_detects_both_changes() { |
| 539 | let sys = SystemPrompt::Text("old".to_string()); |
| 540 | let tools = vec![make_tool("a")]; |
| 541 | let frozen = PinnedPrefix::new(Some(&sys), tools).freeze(); |
| 542 | |
| 543 | let drift = frozen.verify("new", &[make_tool("b")]).unwrap_err(); |
| 544 | assert!(drift.system_changed); |
| 545 | assert!(drift.tools_changed); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn verify_detects_schema_change() { |
| 550 | let sys = SystemPrompt::Text("system".to_string()); |
| 551 | let tool_a = make_tool("a"); |
| 552 | let mut tool_a_v2 = make_tool("a"); |
| 553 | tool_a_v2.description = "updated desc".to_string(); |
| 554 | |
| 555 | let frozen = PinnedPrefix::new(Some(&sys), vec![tool_a]).freeze(); |
| 556 | let drift = frozen.verify("system", &[tool_a_v2]).unwrap_err(); |
| 557 | // Same name, different schema — should detect the change. |
| 558 | assert!(drift.tools_changed); |
| 559 | } |
| 560 | |
| 561 | #[test] |
| 562 | fn prefix_drift_display_is_readable() { |
| 563 | let drift = PrefixDrift { |
| 564 | system_changed: true, |
| 565 | tools_changed: false, |
| 566 | frozen_hash: "a".repeat(64), |
| 567 | current_hash: "b".repeat(64), |
| 568 | }; |
| 569 | let display = drift.to_string(); |
| 570 | assert!(display.contains("system prompt")); |
| 571 | assert!(display.contains("aaaaaaaaaaaa")); |
| 572 | assert!(display.contains("bbbbbbbbbbbb")); |
| 573 | } |
| 574 | |
| 575 | // ── AppendLog ───────────────────────────────────────────────── |
| 576 | |
| 577 | #[test] |
| 578 | fn append_log_push_and_iter() { |
| 579 | let mut log = AppendLog::new(); |
| 580 | assert!(log.is_empty()); |
| 581 | |
| 582 | log.push(make_message("user", "hello")); |
| 583 | log.push(make_message("assistant", "hi")); |
| 584 | |
| 585 | assert_eq!(log.len(), 2); |
| 586 | assert!(!log.is_empty()); |
| 587 | |
| 588 | let messages: Vec<_> = log.iter().collect(); |
| 589 | assert_eq!(messages.len(), 2); |
| 590 | } |
| 591 | |
| 592 | #[test] |
| 593 | fn append_log_from_messages() { |
| 594 | let msgs = vec![make_message("user", "a"), make_message("assistant", "b")]; |
| 595 | let log = AppendLog::from_messages(msgs); |
| 596 | assert_eq!(log.len(), 2); |
| 597 | assert_eq!(log.as_slice().len(), 2); |
| 598 | } |
| 599 | |
| 600 | // ── TurnScratch ─────────────────────────────────────────────── |
| 601 | |
| 602 | #[test] |
| 603 | fn scratch_clear_empties_all_fields() { |
| 604 | let mut scratch = TurnScratch::new(); |
| 605 | scratch.working_set.push("file.rs".to_string()); |
| 606 | scratch.user_message = Some(make_message("user", "task")); |
| 607 | |
| 608 | assert!(!scratch.is_empty()); |
| 609 | scratch.clear(); |
| 610 | assert!(scratch.is_empty()); |
| 611 | assert!(scratch.working_set.is_empty()); |
| 612 | assert!(scratch.user_message.is_none()); |
| 613 | } |
| 614 | |
| 615 | // ── ThreeZoneRequest ────────────────────────────────────────── |
| 616 | |
| 617 | #[test] |
| 618 | fn build_messages_concatenates_zones() { |
| 619 | let sys = SystemPrompt::Text("you are helpful".to_string()); |
| 620 | let tools = vec![make_tool("read")]; |
| 621 | let prefix = PinnedPrefix::new(Some(&sys), tools).freeze(); |
| 622 | |
| 623 | let mut log = AppendLog::new(); |
| 624 | log.push(make_message("user", "prev question")); |
| 625 | log.push(make_message("assistant", "prev answer")); |
| 626 | |
| 627 | let scratch = TurnScratch { |
| 628 | working_set: vec!["main.rs".to_string()], |
| 629 | user_message: Some(make_message("user", "current task")), |
| 630 | }; |
| 631 | |
| 632 | let request = ThreeZoneRequest { |
| 633 | prefix: &prefix, |
| 634 | log: &log, |
| 635 | scratch, |
| 636 | model: "deepseek-v4-pro".to_string(), |
| 637 | max_tokens: 4096, |
| 638 | system: Some(sys), |
| 639 | tools: None, |
| 640 | tool_choice: None, |
| 641 | reasoning_effort: None, |
| 642 | thinking: None, |
| 643 | stream: None, |
| 644 | temperature: None, |
| 645 | top_p: None, |
| 646 | metadata: None, |
| 647 | }; |
| 648 | |
| 649 | let messages = request.build_messages(); |
| 650 | assert_eq!(messages.len(), 4); |
| 651 | assert_eq!(messages[0].role, "system"); |
| 652 | assert_eq!(messages[1].role, "user"); |
| 653 | assert_eq!(messages[2].role, "assistant"); |
| 654 | assert_eq!(messages[3].role, "user"); |
| 655 | assert_eq!(request.message_count(), 4); |
| 656 | } |
| 657 | |
| 658 | #[test] |
| 659 | fn build_messages_no_system_no_scratch() { |
| 660 | let prefix = PinnedPrefix::new(None, vec![]).freeze(); |
| 661 | |
| 662 | let mut log = AppendLog::new(); |
| 663 | log.push(make_message("user", "hi")); |
| 664 | |
| 665 | let request = ThreeZoneRequest { |
| 666 | prefix: &prefix, |
| 667 | log: &log, |
| 668 | scratch: TurnScratch::new(), |
| 669 | model: "x".to_string(), |
| 670 | max_tokens: 1, |
| 671 | system: None, |
| 672 | tools: None, |
| 673 | tool_choice: None, |
| 674 | reasoning_effort: None, |
| 675 | thinking: None, |
| 676 | stream: None, |
| 677 | temperature: None, |
| 678 | top_p: None, |
| 679 | metadata: None, |
| 680 | }; |
| 681 | |
| 682 | let messages = request.build_messages(); |
| 683 | assert_eq!(messages.len(), 1); |
| 684 | assert_eq!(request.message_count(), 1); |
| 685 | } |
| 686 | |
| 687 | #[test] |
| 688 | fn blocks_system_prompt_preserves_cache_control() { |
| 689 | use codewhale_models::{CacheControl, SystemBlock}; |
| 690 | let cc = Some(CacheControl { |
| 691 | cache_type: "ephemeral".to_string(), |
| 692 | }); |
| 693 | let blocks = SystemPrompt::Blocks(vec![SystemBlock { |
| 694 | block_type: "text".to_string(), |
| 695 | text: "hello".to_string(), |
| 696 | cache_control: cc.clone(), |
| 697 | }]); |
| 698 | |
| 699 | let prefix = PinnedPrefix::new(Some(&blocks), vec![]).freeze(); |
| 700 | let log = AppendLog::new(); |
| 701 | let scratch = TurnScratch::new(); |
| 702 | let request = ThreeZoneRequest { |
| 703 | prefix: &prefix, |
| 704 | log: &log, |
| 705 | scratch, |
| 706 | model: "x".to_string(), |
| 707 | max_tokens: 1, |
| 708 | system: Some(blocks), |
| 709 | tools: None, |
| 710 | tool_choice: None, |
| 711 | reasoning_effort: None, |
| 712 | thinking: None, |
| 713 | stream: None, |
| 714 | temperature: None, |
| 715 | top_p: None, |
| 716 | metadata: None, |
| 717 | }; |
| 718 | |
| 719 | let messages = request.build_messages(); |
| 720 | assert_eq!(messages.len(), 1); |
| 721 | assert_eq!(messages[0].role, "system"); |
| 722 | // cache_control should be preserved on the block. |
| 723 | if let ContentBlock::Text { |
| 724 | cache_control: actual_cc, |
| 725 | .. |
| 726 | } = &messages[0].content[0] |
| 727 | { |
| 728 | assert_eq!( |
| 729 | actual_cc.as_ref().map(|c| c.cache_type.as_str()), |
| 730 | Some("ephemeral") |
| 731 | ); |
| 732 | } else { |
| 733 | panic!("expected Text content block"); |
| 734 | } |
| 735 | } |
| 736 | } |
| 737 |