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