| 1 | use super::*; |
| 2 | |
| 3 | fn report(error: &anyhow::Error) -> String { |
| 4 | report_compaction_failure("Auto-compaction failed", "compact_fixture", true, error) |
| 5 | } |
| 6 | |
| 7 | #[test] |
| 8 | fn strip_compaction_summaries_removes_only_summary_blocks() { |
| 9 | let base = SystemBlock { |
| 10 | block_type: "text".to_string(), |
| 11 | text: "stable base prompt".to_string(), |
| 12 | cache_control: None, |
| 13 | }; |
| 14 | let summary = SystemBlock { |
| 15 | block_type: "text".to_string(), |
| 16 | text: format!("{LEGACY_V2_COMPACTION_SUMMARY_MARKER} and its body"), |
| 17 | cache_control: None, |
| 18 | }; |
| 19 | let legacy = SystemBlock { |
| 20 | block_type: "text".to_string(), |
| 21 | text: format!("{LEGACY_COMPACTION_SUMMARY_MARKER}\nold-format body"), |
| 22 | cache_control: None, |
| 23 | }; |
| 24 | |
| 25 | let stripped = strip_compaction_summaries(Some(&SystemPrompt::Blocks(vec![ |
| 26 | base.clone(), |
| 27 | summary.clone(), |
| 28 | legacy, |
| 29 | ]))) |
| 30 | .expect("base block survives"); |
| 31 | match stripped { |
| 32 | SystemPrompt::Blocks(blocks) => { |
| 33 | assert_eq!(blocks.len(), 1); |
| 34 | assert_eq!(blocks[0].text, "stable base prompt"); |
| 35 | } |
| 36 | SystemPrompt::Text(_) => panic!("blocks stay blocks"), |
| 37 | } |
| 38 | |
| 39 | // A prompt that is nothing but a summary strips to None. |
| 40 | assert!(strip_compaction_summaries(Some(&SystemPrompt::Text(summary.text))).is_none()); |
| 41 | // A prompt without summaries is unchanged. |
| 42 | assert_eq!( |
| 43 | strip_compaction_summaries(Some(&SystemPrompt::Text("plain".to_string()))), |
| 44 | Some(SystemPrompt::Text("plain".to_string())) |
| 45 | ); |
| 46 | // Host text that quotes the new marker outside the carrier delimiters is |
| 47 | // project instruction, not a checkpoint: nothing is truncated or extracted. |
| 48 | let quoted = SystemPrompt::Text(format!( |
| 49 | "stable base prompt\nRead the {COMPACTION_SUMMARY_MARKER} before resuming.\nmore rules" |
| 50 | )); |
| 51 | assert_eq!( |
| 52 | strip_compaction_summaries(Some("ed)), |
| 53 | Some(quoted.clone()) |
| 54 | ); |
| 55 | assert!(extract_compaction_summary(Some("ed)).is_none()); |
| 56 | } |
| 57 | |
| 58 | #[test] |
| 59 | fn persisted_summary_carrier_round_trips_without_losing_the_base_prompt() { |
| 60 | let carrier = format!( |
| 61 | "stable base prompt\n\n{COMPACTION_SUMMARY_BEGIN}\n{COMPACTION_SUMMARY_MARKER}\nnew summary\n{COMPACTION_SUMMARY_END}" |
| 62 | ); |
| 63 | |
| 64 | assert_eq!( |
| 65 | extract_compaction_summary(Some(&SystemPrompt::Text(carrier.clone()))), |
| 66 | Some(SystemPrompt::Text(format!( |
| 67 | "{COMPACTION_SUMMARY_MARKER}\nnew summary" |
| 68 | ))) |
| 69 | ); |
| 70 | assert_eq!( |
| 71 | strip_compaction_summaries(Some(&SystemPrompt::Text(carrier))), |
| 72 | Some(SystemPrompt::Text("stable base prompt".to_string())) |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | #[test] |
| 77 | fn combined_block_carrier_preserves_block_metadata_and_base_text() { |
| 78 | let carrier = SystemBlock { |
| 79 | block_type: "text".to_string(), |
| 80 | text: format!( |
| 81 | "stable block\n\n{COMPACTION_SUMMARY_BEGIN}\n{COMPACTION_SUMMARY_MARKER}\nblock summary\n{COMPACTION_SUMMARY_END}" |
| 82 | ), |
| 83 | cache_control: Some(CacheControl { |
| 84 | cache_type: "ephemeral".to_string(), |
| 85 | }), |
| 86 | }; |
| 87 | |
| 88 | let extracted = extract_compaction_summary(Some(&SystemPrompt::Blocks(vec![carrier.clone()]))) |
| 89 | .expect("checkpoint"); |
| 90 | let SystemPrompt::Blocks(extracted) = extracted else { |
| 91 | panic!("blocks stay blocks"); |
| 92 | }; |
| 93 | assert_eq!(extracted.len(), 1); |
| 94 | assert_eq!( |
| 95 | extracted[0].text, |
| 96 | format!("{COMPACTION_SUMMARY_MARKER}\nblock summary") |
| 97 | ); |
| 98 | assert_eq!(extracted[0].cache_control, carrier.cache_control); |
| 99 | |
| 100 | let stripped = |
| 101 | strip_compaction_summaries(Some(&SystemPrompt::Blocks(vec![carrier]))).expect("base block"); |
| 102 | let SystemPrompt::Blocks(stripped) = stripped else { |
| 103 | panic!("blocks stay blocks"); |
| 104 | }; |
| 105 | assert_eq!(stripped.len(), 1); |
| 106 | assert_eq!(stripped[0].text, "stable block"); |
| 107 | assert_eq!( |
| 108 | stripped[0] |
| 109 | .cache_control |
| 110 | .as_ref() |
| 111 | .map(|c| c.cache_type.as_str()), |
| 112 | Some("ephemeral") |
| 113 | ); |
| 114 | } |
| 115 | |
| 116 | #[test] |
| 117 | fn untyped_usage_limit_text_never_becomes_quota_exhaustion() { |
| 118 | let error = anyhow::anyhow!( |
| 119 | "[auth] Authorization failed: You've reached your usage limit for this billing cycle" |
| 120 | ); |
| 121 | let message = report(&error); |
| 122 | assert!(message.contains("provider rate limit blocked making room")); |
| 123 | assert!(!message.contains("quota exhausted")); |
| 124 | } |
| 125 | |
| 126 | #[test] |
| 127 | fn typed_quota_renders_quota_and_is_not_transient() { |
| 128 | let error = anyhow::Error::new(crate::llm_client::LlmError::from_http_response( |
| 129 | 429, |
| 130 | r#"{"error":{"code":"insufficient_quota"}}"#, |
| 131 | )) |
| 132 | .context("summary request failed"); |
| 133 | assert_eq!( |
| 134 | report(&error), |
| 135 | "Auto-compaction failed: provider plan quota exhausted — switch provider/model or renew the provider plan" |
| 136 | ); |
| 137 | assert!(!is_transient_error(&error)); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn typed_rate_limit_stays_transient_and_does_not_become_quota() { |
| 142 | let error = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { |
| 143 | message: "Too Many Requests".into(), |
| 144 | retry_after: None, |
| 145 | }); |
| 146 | assert!(report(&error).contains("provider rate limit blocked making room")); |
| 147 | assert!(is_transient_error(&error)); |
| 148 | } |
| 149 | |
| 150 | #[test] |
| 151 | fn unknown_diagnostic_is_preserved_safely() { |
| 152 | let error = anyhow::anyhow!("summary response was structurally empty"); |
| 153 | assert_eq!( |
| 154 | report(&error), |
| 155 | "Auto-compaction failed: summary response was structurally empty" |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn untyped_transient_and_deterministic_classification_remains_compatible() { |
| 161 | for message in [ |
| 162 | "Connection timeout", |
| 163 | "429 Too Many Requests", |
| 164 | "503 Service Unavailable", |
| 165 | "network error: connection refused", |
| 166 | ] { |
| 167 | assert!(is_transient_error(&anyhow::anyhow!(message)), "{message}"); |
| 168 | } |
| 169 | for message in [ |
| 170 | "401 Unauthorized: Invalid API key", |
| 171 | "Failed to parse JSON response", |
| 172 | "Invalid request: missing required field", |
| 173 | ] { |
| 174 | assert!(!is_transient_error(&anyhow::anyhow!(message)), "{message}"); |
| 175 | } |
| 176 | assert_eq!( |
| 177 | classify_compaction_failure(&anyhow::anyhow!( |
| 178 | "prompt is too long for this model's context window" |
| 179 | )), |
| 180 | CompactionFailureKind::ContextOverflow |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | fn pressure_fixture() -> Vec<Message> { |
| 185 | (0..30) |
| 186 | .map(|index| Message { |
| 187 | role: if index % 2 == 0 { |
| 188 | Role::User |
| 189 | } else { |
| 190 | Role::Assistant |
| 191 | }, |
| 192 | content: vec![ContentBlock::Text { |
| 193 | text: "x".repeat(8_000), |
| 194 | cache_control: None, |
| 195 | }], |
| 196 | }) |
| 197 | .collect() |
| 198 | } |
| 199 | |
| 200 | fn oversized_tool_pair(id: &str, content: String) -> Vec<Message> { |
| 201 | vec![ |
| 202 | Message { |
| 203 | role: Role::Assistant, |
| 204 | content: vec![ContentBlock::ToolUse { |
| 205 | execution_id: None, |
| 206 | id: id.to_string(), |
| 207 | name: "read_file".to_string(), |
| 208 | input: serde_json::json!({"path": "src/compaction.rs"}), |
| 209 | caller: None, |
| 210 | thought_signature: None, |
| 211 | }], |
| 212 | }, |
| 213 | Message { |
| 214 | role: Role::User, |
| 215 | content: vec![ContentBlock::ToolResult { |
| 216 | execution_id: None, |
| 217 | tool_use_id: id.to_string(), |
| 218 | content, |
| 219 | is_error: None, |
| 220 | content_blocks: None, |
| 221 | }], |
| 222 | }, |
| 223 | ] |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn pinned_tool_result_local_pruning_is_reclaimable() { |
| 228 | let mut messages = |
| 229 | oversized_tool_pair("old-read", "error: ".to_string() + &"x".repeat(300_000)); |
| 230 | messages.extend(pressure_fixture()); |
| 231 | let full_pressure = estimate_input_tokens_for_pressure(&messages, None); |
| 232 | let mut projected = messages.clone(); |
| 233 | let pruned_bytes = prune_tool_results_until(&mut projected, KEEP_RECENT_MESSAGES, |_, _| false); |
| 234 | let projected_pressure = estimate_input_tokens_for_pressure(&projected, None); |
| 235 | assert!( |
| 236 | pruned_bytes > 250_000, |
| 237 | "fixture must prune the pinned result" |
| 238 | ); |
| 239 | assert!(projected_pressure < full_pressure); |
| 240 | |
| 241 | let config = CompactionConfig { |
| 242 | token_threshold: projected_pressure + (full_pressure - projected_pressure) / 2, |
| 243 | ..Default::default() |
| 244 | }; |
| 245 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 246 | assert!(!compaction_pressure_reached(&projected, None, &config)); |
| 247 | assert!(should_compact( |
| 248 | &messages, |
| 249 | None, |
| 250 | &PreparedCompactionEnvelope::new(config), |
| 251 | )); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn local_pruning_removes_nested_tool_result_images() { |
| 256 | let mut messages = oversized_tool_pair("image-read", "screenshot captured".to_string()); |
| 257 | let ContentBlock::ToolResult { content_blocks, .. } = &mut messages[1].content[0] else { |
| 258 | panic!("tool result fixture"); |
| 259 | }; |
| 260 | *content_blocks = Some(vec![serde_json::json!({ |
| 261 | "type": "image", |
| 262 | "mime_type": "image/png", |
| 263 | "data": "A".repeat(300_000), |
| 264 | })]); |
| 265 | messages.extend(pressure_fixture()); |
| 266 | |
| 267 | let before = estimate_input_tokens_for_pressure(&messages, None); |
| 268 | let pruned = prune_tool_results_until(&mut messages, KEEP_RECENT_MESSAGES, |_, _| false); |
| 269 | let after = estimate_input_tokens_for_pressure(&messages, None); |
| 270 | let ContentBlock::ToolResult { content_blocks, .. } = &messages[1].content[0] else { |
| 271 | panic!("tool result fixture"); |
| 272 | }; |
| 273 | |
| 274 | assert!(pruned > 250_000, "nested image bytes must be reclaimable"); |
| 275 | assert!(after < before); |
| 276 | assert!(content_blocks.is_none(), "base64 must not survive pruning"); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn successor_floor_counts_retained_user_messages_not_tool_results() { |
| 281 | let mut messages = pressure_fixture(); |
| 282 | messages.extend(oversized_tool_pair( |
| 283 | "recent-read", |
| 284 | "z".repeat(RETAINED_TOOL_RESULT_MAX_CHARS * 4), |
| 285 | )); |
| 286 | let base_config = CompactionConfig::default(); |
| 287 | let prepared = PreparedCompactionEnvelope::new(base_config.clone()); |
| 288 | let retained_floor = estimate_retained_floor_conservative(&messages, None, &prepared); |
| 289 | let full_pressure = estimate_input_tokens_conservative(&messages, None); |
| 290 | assert!( |
| 291 | retained_floor < full_pressure, |
| 292 | "user-only retention must reclaim the giant tool result" |
| 293 | ); |
| 294 | |
| 295 | let config = CompactionConfig { |
| 296 | token_threshold: retained_floor + 1, |
| 297 | ..base_config |
| 298 | }; |
| 299 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 300 | assert!(should_compact( |
| 301 | &messages, |
| 302 | None, |
| 303 | &PreparedCompactionEnvelope::new(config), |
| 304 | )); |
| 305 | } |
| 306 | |
| 307 | /// #5956: with no `[compaction] summary_instructions` configured, the |
| 308 | /// summarizer prompt is exactly the handoff body plus the language contract. |
| 309 | #[test] |
| 310 | fn compact_prompt_without_operator_instructions_is_unchanged() { |
| 311 | assert_eq!( |
| 312 | compact_prompt(None, None), |
| 313 | format!("{} {COMPACTION_LANGUAGE_CONTRACT}", compact_prompt_body()) |
| 314 | ); |
| 315 | // Whitespace-only is unset, not an empty section. |
| 316 | assert_eq!( |
| 317 | compact_prompt(None, Some(" \n ")), |
| 318 | compact_prompt(None, None) |
| 319 | ); |
| 320 | // The one-off `/compact <focus>` line keeps its exact shape. |
| 321 | assert_eq!( |
| 322 | compact_prompt(Some("the flaky test"), None), |
| 323 | format!( |
| 324 | "{} {COMPACTION_LANGUAGE_CONTRACT}\n\nThe user asked this handoff to focus on: the flaky test", |
| 325 | compact_prompt_body() |
| 326 | ) |
| 327 | ); |
| 328 | } |
| 329 | |
| 330 | /// #5956: the operator suffix is a clearly delimited section, and a manual |
| 331 | /// `/compact <focus>` still composes *after* it. |
| 332 | #[test] |
| 333 | fn compact_prompt_appends_operator_instructions_before_focus() { |
| 334 | let prompt = compact_prompt( |
| 335 | Some("the flaky test"), |
| 336 | Some("Always restate open decisions."), |
| 337 | ); |
| 338 | |
| 339 | assert!(prompt.starts_with(&compact_prompt_body())); |
| 340 | assert!(prompt.contains(OPERATOR_INSTRUCTIONS_HEADER)); |
| 341 | assert!(prompt.contains("Always restate open decisions.")); |
| 342 | assert!(prompt.contains(OPERATOR_INSTRUCTIONS_FOOTER)); |
| 343 | |
| 344 | let instructions_at = prompt.find(OPERATOR_INSTRUCTIONS_HEADER).expect("section"); |
| 345 | let focus_at = prompt.find("focus on: the flaky test").expect("focus"); |
| 346 | assert!( |
| 347 | instructions_at < focus_at, |
| 348 | "the standing instructions come first; the one-off focus composes after them" |
| 349 | ); |
| 350 | |
| 351 | // The quality-retry prompt is the same summarizer call, so it carries the |
| 352 | // same standing instructions. |
| 353 | let retry = compact_quality_retry_prompt(None, Some("Always restate open decisions.")); |
| 354 | assert!(retry.contains("Always restate open decisions.")); |
| 355 | assert!(!compact_quality_retry_prompt(None, None).contains(OPERATOR_INSTRUCTIONS_HEADER)); |
| 356 | } |
| 357 | |
| 358 | /// #5956: an oversized standing instruction is truncated at the cap rather |
| 359 | /// than failing the compaction pass that keeps the session alive. |
| 360 | #[test] |
| 361 | fn operator_instructions_are_truncated_at_the_cap() { |
| 362 | let max = crate::config::COMPACTION_SUMMARY_INSTRUCTIONS_MAX_CHARS; |
| 363 | let long = "é".repeat(max + 500); |
| 364 | let section = operator_instructions_section(Some(&long)).expect("section is present"); |
| 365 | |
| 366 | let body = section |
| 367 | .trim_start_matches('\n') |
| 368 | .trim_start_matches(OPERATOR_INSTRUCTIONS_HEADER) |
| 369 | .trim_start_matches('\n') |
| 370 | .trim_end_matches(OPERATOR_INSTRUCTIONS_FOOTER) |
| 371 | .trim_end_matches('\n'); |
| 372 | assert_eq!(body.chars().count(), max); |
| 373 | assert!(operator_instructions_section(None).is_none()); |
| 374 | assert!(operator_instructions_section(Some(" ")).is_none()); |
| 375 | } |
| 376 | |
| 377 | /// #5956: the replacement history spends the configured verbatim budget, so a |
| 378 | /// larger budget keeps more of the user's own earlier messages. |
| 379 | #[test] |
| 380 | fn replacement_history_honours_the_configured_retention_budget() { |
| 381 | let user = |text: &str| Message { |
| 382 | role: Role::User, |
| 383 | content: vec![ContentBlock::Text { |
| 384 | text: text.to_string(), |
| 385 | cache_control: None, |
| 386 | }], |
| 387 | }; |
| 388 | let assistant = |text: &str| Message { |
| 389 | role: Role::Assistant, |
| 390 | content: vec![ContentBlock::Text { |
| 391 | text: text.to_string(), |
| 392 | cache_control: None, |
| 393 | }], |
| 394 | }; |
| 395 | |
| 396 | // Each older user message is ~1 000 conservative tokens (3 chars/token). |
| 397 | let mut messages = Vec::new(); |
| 398 | for idx in 0..10 { |
| 399 | messages.push(user(&format!("{idx}{}", "a".repeat(3_000)))); |
| 400 | messages.push(assistant("ack")); |
| 401 | } |
| 402 | messages.push(user("the live request")); |
| 403 | messages.push(assistant("working on it")); |
| 404 | |
| 405 | let checkpoint = format!("{COMPACTION_SUMMARY_MARKER} and produced this handoff."); |
| 406 | let count_verbatim = |budget: usize| { |
| 407 | last_round::build_replacement_history(&messages, &checkpoint, None, budget) |
| 408 | .expect("replacement history") |
| 409 | .len() |
| 410 | }; |
| 411 | |
| 412 | let small = count_verbatim(2_000); |
| 413 | let large = count_verbatim(20_000); |
| 414 | assert!( |
| 415 | large > small, |
| 416 | "a larger budget must keep more user messages verbatim ({small} vs {large})" |
| 417 | ); |
| 418 | // The floor still keeps the last round plus the checkpoint. |
| 419 | assert!( |
| 420 | small >= 3, |
| 421 | "the bounded last round and checkpoint always survive" |
| 422 | ); |
| 423 | } |
| 424 | |
| 425 | /// #5956: the receipt clause names the effective budget and whether standing |
| 426 | /// operator instructions were applied, so the knob is verifiable without logs. |
| 427 | #[test] |
| 428 | fn receipt_clause_reports_the_effective_compaction_tuning() { |
| 429 | let mut coverage = CompactionCoverage { |
| 430 | path: CompactionPath::Summary, |
| 431 | last_round_messages: 2, |
| 432 | last_round_tool_results: 0, |
| 433 | last_round_assistant: true, |
| 434 | dropped_messages: 8, |
| 435 | anchors_chars: 0, |
| 436 | retained_user_message_tokens: 60_000, |
| 437 | operator_instructions_applied: true, |
| 438 | }; |
| 439 | let clause = coverage.receipt_clause(); |
| 440 | assert!( |
| 441 | clause.contains("verbatim user budget 60000 tokens"), |
| 442 | "{clause}" |
| 443 | ); |
| 444 | assert!(clause.contains("operator instructions applied"), "{clause}"); |
| 445 | |
| 446 | coverage.operator_instructions_applied = false; |
| 447 | assert!(!coverage.receipt_clause().contains("operator instructions")); |
| 448 | |
| 449 | // The prune-only path builds no replacement history, so it reports no budget. |
| 450 | let prune_only = CompactionCoverage { |
| 451 | path: CompactionPath::PruneOnly, |
| 452 | ..CompactionCoverage::default() |
| 453 | }; |
| 454 | assert!(!prune_only.receipt_clause().contains("verbatim user budget")); |
| 455 | } |
| 456 | |
| 457 | /// Both summarizer requests ask for the same layout, the fold-in rule and the |
| 458 | /// focus line, and neither is the text the note itself opens with. |
| 459 | #[test] |
| 460 | fn handoff_prompts_share_sections_fold_in_and_focus() { |
| 461 | let first = compact_prompt(Some("the flaky test"), None); |
| 462 | let retry = compact_quality_retry_prompt(Some("the flaky test"), None); |
| 463 | for prompt in [&first, &retry] { |
| 464 | for (heading, _) in HANDOFF_SECTIONS { |
| 465 | assert!( |
| 466 | prompt.contains(&format!("\n## {heading} - ")), |
| 467 | "{heading}: {prompt}" |
| 468 | ); |
| 469 | } |
| 470 | assert!(prompt.contains(HANDOFF_FOLD_IN_RULE), "{prompt}"); |
| 471 | assert!( |
| 472 | prompt.ends_with("The user asked this handoff to focus on: the flaky test"), |
| 473 | "{prompt}" |
| 474 | ); |
| 475 | assert!(prompt.contains(COMPACTION_LANGUAGE_CONTRACT)); |
| 476 | assert!(!prompt.contains(COMPACTION_SUMMARY_MARKER)); |
| 477 | assert!(!prompt.contains(LEGACY_V2_COMPACTION_SUMMARY_MARKER)); |
| 478 | } |
| 479 | assert!(first.starts_with(COMPACT_PROMPT_OPENING)); |
| 480 | // Headings come in the documented order. |
| 481 | let positions = HANDOFF_SECTIONS |
| 482 | .iter() |
| 483 | .map(|(heading, _)| first.find(&format!("\n## {heading} - ")).expect(heading)) |
| 484 | .collect::<Vec<_>>(); |
| 485 | assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); |
| 486 | } |
| 487 | |
| 488 | /// The note opens with the marker, restates no authority and ends with the |
| 489 | /// continuation line, so detection and the resume contract stay intact. |
| 490 | #[test] |
| 491 | fn handoff_note_opens_with_marker_and_closes_with_continuation() { |
| 492 | let note = build_compaction_summary_block_text( |
| 493 | "## Objective\nShip it", |
| 494 | "\n\nUser-pinned anchors (verbatim):\nno force-push", |
| 495 | ); |
| 496 | assert!(note.starts_with(COMPACTION_SUMMARY_MARKER)); |
| 497 | assert!(note.starts_with(SUMMARY_HEADER)); |
| 498 | assert!(note.contains("## Objective\nShip it")); |
| 499 | assert!(note.contains("no force-push")); |
| 500 | assert!(note.ends_with(SUMMARY_CLOSING)); |
| 501 | assert!(!note.contains(LEGACY_V2_COMPACTION_SUMMARY_MARKER)); |
| 502 | } |
| 503 | |
| 504 | fn text_message(role: Role, text: &str) -> Message { |
| 505 | Message { |
| 506 | role, |
| 507 | content: vec![ContentBlock::Text { |
| 508 | text: text.to_string(), |
| 509 | cache_control: None, |
| 510 | }], |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | /// A user message that quotes the new marker is ordinary user text: it is |
| 515 | /// retained, kept by restore, and never mistaken for a checkpoint. |
| 516 | #[test] |
| 517 | fn user_quote_of_new_marker_is_not_a_checkpoint() { |
| 518 | let quote = text_message( |
| 519 | Role::User, |
| 520 | &format!("What is in the {COMPACTION_SUMMARY_MARKER}? {COMPACTION_SUMMARY_MARKER}."), |
| 521 | ); |
| 522 | let header_quote = text_message( |
| 523 | Role::User, |
| 524 | &build_compaction_summary_block_text("pasted by the user", ""), |
| 525 | ); |
| 526 | for message in ["e, &header_quote] { |
| 527 | assert!(!is_compaction_checkpoint_message(message)); |
| 528 | assert!(!is_wire_compaction_checkpoint_message(message)); |
| 529 | } |
| 530 | |
| 531 | let retained = retained_user_messages(&[quote.clone(), header_quote.clone()], 10_000); |
| 532 | assert_eq!(retained, vec![quote.clone(), header_quote.clone()]); |
| 533 | |
| 534 | let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", "")); |
| 535 | let history = vec![ |
| 536 | quote.clone(), |
| 537 | compaction_checkpoint_message(&summary), |
| 538 | header_quote.clone(), |
| 539 | ]; |
| 540 | let restored = restore_compaction_checkpoint(history.clone(), Some(&summary)); |
| 541 | assert_eq!(restored, history); |
| 542 | |
| 543 | // Without a typed checkpoint the legacy cleanup runs, and it still keeps |
| 544 | // both quotes because the new marker is never matched by substring. |
| 545 | let restored = restore_compaction_checkpoint(vec![quote.clone(), header_quote.clone()], None); |
| 546 | assert_eq!(restored, vec![quote, header_quote]); |
| 547 | } |
| 548 | |
| 549 | /// A checkpoint saved with the previous header and the provenance block is |
| 550 | /// still a wire checkpoint: it keeps its boundary on restore and is replaced, |
| 551 | /// not stacked, by the next pass. |
| 552 | #[test] |
| 553 | fn legacy_v2_checkpoint_with_provenance_is_recognised_and_replaced() { |
| 554 | let old = compaction_checkpoint_message(&SystemPrompt::Text(format!( |
| 555 | "{LEGACY_V2_COMPACTION_SUMMARY_MARKER} and produced a summary.\n\nold work" |
| 556 | ))); |
| 557 | assert!(is_wire_compaction_checkpoint_message(&old)); |
| 558 | assert!(is_compaction_checkpoint_message(&old)); |
| 559 | |
| 560 | let later = text_message(Role::User, "a later turn"); |
| 561 | let fresh = SystemPrompt::Text(build_compaction_summary_block_text("new work", "")); |
| 562 | let restored = restore_compaction_checkpoint( |
| 563 | vec![text_message(Role::User, "before"), old, later.clone()], |
| 564 | Some(&fresh), |
| 565 | ); |
| 566 | assert_eq!(restored.len(), 3); |
| 567 | assert!(is_wire_compaction_checkpoint_message(&restored[1])); |
| 568 | assert!( |
| 569 | user_text_of(&restored[1]).is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_MARKER)) |
| 570 | ); |
| 571 | assert_eq!(restored[2], later); |
| 572 | |
| 573 | let first = vec![ |
| 574 | text_message(Role::User, "Run the suite now."), |
| 575 | text_message(Role::Assistant, "Rerunning."), |
| 576 | compaction_checkpoint_message(&SystemPrompt::Text(format!( |
| 577 | "{LEGACY_V2_COMPACTION_SUMMARY_MARKER}: first handoff" |
| 578 | ))), |
| 579 | ]; |
| 580 | let replaced = last_round::build_replacement_history( |
| 581 | &first, |
| 582 | &build_compaction_summary_block_text("second handoff", ""), |
| 583 | None, |
| 584 | COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS, |
| 585 | ) |
| 586 | .expect("replacement"); |
| 587 | let checkpoints = replaced |
| 588 | .iter() |
| 589 | .filter(|message| is_compaction_checkpoint_message(message)) |
| 590 | .collect::<Vec<_>>(); |
| 591 | assert_eq!(checkpoints.len(), 1, "{replaced:?}"); |
| 592 | assert!(user_text_of(checkpoints[0]).is_some_and(|text| text.contains("second handoff"))); |
| 593 | } |
| 594 | |
| 595 | /// Single-block messages carrying either legacy marker predate provenance and |
| 596 | /// still count as checkpoints. |
| 597 | #[test] |
| 598 | fn legacy_single_block_markers_still_count_as_checkpoints() { |
| 599 | for marker in [ |
| 600 | LEGACY_COMPACTION_SUMMARY_MARKER, |
| 601 | LEGACY_V2_COMPACTION_SUMMARY_MARKER, |
| 602 | ] { |
| 603 | let legacy = text_message(Role::User, &format!("{marker}\nold summary")); |
| 604 | assert!(is_compaction_checkpoint_message(&legacy), "{marker}"); |
| 605 | assert!(!is_wire_compaction_checkpoint_message(&legacy), "{marker}"); |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | fn tool_call(id: &str) -> Message { |
| 610 | Message { |
| 611 | role: Role::Assistant, |
| 612 | content: vec![ContentBlock::ToolUse { |
| 613 | execution_id: None, |
| 614 | id: id.to_string(), |
| 615 | name: "Bash".to_string(), |
| 616 | input: serde_json::json!({"command": "ls"}), |
| 617 | caller: None, |
| 618 | thought_signature: None, |
| 619 | }], |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | fn tool_output(id: &str) -> Message { |
| 624 | Message { |
| 625 | role: Role::User, |
| 626 | content: vec![ContentBlock::ToolResult { |
| 627 | execution_id: None, |
| 628 | tool_use_id: id.to_string(), |
| 629 | content: "ok".to_string(), |
| 630 | is_error: None, |
| 631 | content_blocks: None, |
| 632 | }], |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | /// An over-window retry drops history on both sides of the previous note but |
| 637 | /// never the note or the handoff instruction, and stops once only those two |
| 638 | /// (plus nothing droppable) remain. |
| 639 | #[test] |
| 640 | fn overflow_retry_keeps_the_previous_note_and_the_instruction() { |
| 641 | let note = compaction_checkpoint_message(&SystemPrompt::Text( |
| 642 | build_compaction_summary_block_text("previous note", ""), |
| 643 | )); |
| 644 | let instruction = text_message(Role::User, &compact_prompt(None, None)); |
| 645 | let mut messages = vec![ |
| 646 | text_message(Role::User, "older request"), |
| 647 | note.clone(), |
| 648 | tool_call("after"), |
| 649 | tool_output("after"), |
| 650 | text_message(Role::User, "latest request"), |
| 651 | instruction.clone(), |
| 652 | ]; |
| 653 | |
| 654 | assert!(drop_oldest_history_messages(&mut messages)); |
| 655 | assert_eq!(messages[0], note); |
| 656 | assert_eq!(messages.len(), 5); |
| 657 | |
| 658 | // The call right after the kept note goes, and its orphaned result with it. |
| 659 | assert!(drop_oldest_history_messages(&mut messages)); |
| 660 | assert_eq!( |
| 661 | messages, |
| 662 | vec![ |
| 663 | note.clone(), |
| 664 | text_message(Role::User, "latest request"), |
| 665 | instruction.clone() |
| 666 | ] |
| 667 | ); |
| 668 | |
| 669 | assert!(drop_oldest_history_messages(&mut messages)); |
| 670 | assert_eq!(messages, vec![note.clone(), instruction.clone()]); |
| 671 | |
| 672 | // Only the note and the instruction remain: nothing changes and the |
| 673 | // caller fails the pass instead of summarizing without the note. |
| 674 | assert!(!drop_oldest_history_messages(&mut messages)); |
| 675 | assert_eq!(messages, vec![note, instruction.clone()]); |
| 676 | |
| 677 | // Without a checkpoint the old floor holds: one history message stays. |
| 678 | let mut plain = vec![text_message(Role::User, "only"), instruction.clone()]; |
| 679 | assert!(!drop_oldest_history_messages(&mut plain)); |
| 680 | assert_eq!(plain.len(), 2); |
| 681 | |
| 682 | // A user quote of the marker is ordinary history and can be dropped. |
| 683 | let quote = text_message(Role::User, COMPACTION_SUMMARY_MARKER); |
| 684 | let mut quoted = vec![quote, text_message(Role::User, "later"), instruction]; |
| 685 | assert!(drop_oldest_history_messages(&mut quoted)); |
| 686 | assert_eq!(quoted[0], text_message(Role::User, "later")); |
| 687 | } |
| 688 | |
| 689 | #[test] |
| 690 | fn pruning_metadata_belongs_to_the_exact_execution() { |
| 691 | let messages: Vec<Message> = serde_json::from_value(serde_json::json!([ |
| 692 | {"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":"first","name":"read","input":{"path":"first.txt"}}]}, |
| 693 | {"role":"user","content":[{"type":"tool_result","tool_use_id":"reused","execution_id":"first","content":"A".repeat(20000)}]}, |
| 694 | {"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":"second","name":"search","input":{"pattern":"second"}}]}, |
| 695 | {"role":"user","content":[{"type":"tool_result","tool_use_id":"reused","execution_id":"second","content":"B".repeat(20000)}]} |
| 696 | ])).unwrap(); |
| 697 | let plan = plan_tool_result_prunes(&messages, 0); |
| 698 | assert_eq!(plan.len(), 2); |
| 699 | let first = plan.iter().find(|item| item.message_idx == 1).unwrap(); |
| 700 | assert!(first.summary.contains("first.txt"), "{}", first.summary); |
| 701 | assert!(!first.summary.contains("second"), "{}", first.summary); |
| 702 | let mut ambiguous = messages.clone(); |
| 703 | ambiguous.push(messages[0].clone()); |
| 704 | assert!( |
| 705 | !plan_tool_result_prunes(&ambiguous, 0) |
| 706 | .iter() |
| 707 | .any(|item| item.message_idx == 1) |
| 708 | ); |
| 709 | } |
| 710 |