| 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!("{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 | } |
| 47 | |
| 48 | #[test] |
| 49 | fn persisted_summary_carrier_round_trips_without_losing_the_base_prompt() { |
| 50 | let carrier = format!( |
| 51 | "stable base prompt\n\n{COMPACTION_SUMMARY_BEGIN}\n{COMPACTION_SUMMARY_MARKER}\nnew summary\n{COMPACTION_SUMMARY_END}" |
| 52 | ); |
| 53 | |
| 54 | assert_eq!( |
| 55 | extract_compaction_summary(Some(&SystemPrompt::Text(carrier.clone()))), |
| 56 | Some(SystemPrompt::Text(format!( |
| 57 | "{COMPACTION_SUMMARY_MARKER}\nnew summary" |
| 58 | ))) |
| 59 | ); |
| 60 | assert_eq!( |
| 61 | strip_compaction_summaries(Some(&SystemPrompt::Text(carrier))), |
| 62 | Some(SystemPrompt::Text("stable base prompt".to_string())) |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | #[test] |
| 67 | fn combined_block_carrier_preserves_block_metadata_and_base_text() { |
| 68 | let carrier = SystemBlock { |
| 69 | block_type: "text".to_string(), |
| 70 | text: format!( |
| 71 | "stable block\n\n{COMPACTION_SUMMARY_BEGIN}\n{COMPACTION_SUMMARY_MARKER}\nblock summary\n{COMPACTION_SUMMARY_END}" |
| 72 | ), |
| 73 | cache_control: Some(CacheControl { |
| 74 | cache_type: "ephemeral".to_string(), |
| 75 | }), |
| 76 | }; |
| 77 | |
| 78 | let extracted = extract_compaction_summary(Some(&SystemPrompt::Blocks(vec![carrier.clone()]))) |
| 79 | .expect("checkpoint"); |
| 80 | let SystemPrompt::Blocks(extracted) = extracted else { |
| 81 | panic!("blocks stay blocks"); |
| 82 | }; |
| 83 | assert_eq!(extracted.len(), 1); |
| 84 | assert_eq!( |
| 85 | extracted[0].text, |
| 86 | format!("{COMPACTION_SUMMARY_MARKER}\nblock summary") |
| 87 | ); |
| 88 | assert_eq!(extracted[0].cache_control, carrier.cache_control); |
| 89 | |
| 90 | let stripped = |
| 91 | strip_compaction_summaries(Some(&SystemPrompt::Blocks(vec![carrier]))).expect("base block"); |
| 92 | let SystemPrompt::Blocks(stripped) = stripped else { |
| 93 | panic!("blocks stay blocks"); |
| 94 | }; |
| 95 | assert_eq!(stripped.len(), 1); |
| 96 | assert_eq!(stripped[0].text, "stable block"); |
| 97 | assert_eq!( |
| 98 | stripped[0] |
| 99 | .cache_control |
| 100 | .as_ref() |
| 101 | .map(|c| c.cache_type.as_str()), |
| 102 | Some("ephemeral") |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn untyped_usage_limit_text_never_becomes_quota_exhaustion() { |
| 108 | let error = anyhow::anyhow!( |
| 109 | "[auth] Authorization failed: You've reached your usage limit for this billing cycle" |
| 110 | ); |
| 111 | let message = report(&error); |
| 112 | assert!(message.contains("provider rate limit blocked compaction")); |
| 113 | assert!(!message.contains("quota exhausted")); |
| 114 | } |
| 115 | |
| 116 | #[test] |
| 117 | fn typed_quota_renders_quota_and_is_not_transient() { |
| 118 | let error = anyhow::Error::new(crate::llm_client::LlmError::from_http_response( |
| 119 | 429, |
| 120 | r#"{"error":{"code":"insufficient_quota"}}"#, |
| 121 | )) |
| 122 | .context("summary request failed"); |
| 123 | assert_eq!( |
| 124 | report(&error), |
| 125 | "Auto-compaction failed: provider plan quota exhausted — switch provider/model or renew the provider plan" |
| 126 | ); |
| 127 | assert!(!is_transient_error(&error)); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn typed_rate_limit_stays_transient_and_does_not_become_quota() { |
| 132 | let error = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { |
| 133 | message: "Too Many Requests".into(), |
| 134 | retry_after: None, |
| 135 | }); |
| 136 | assert!(report(&error).contains("provider rate limit blocked compaction")); |
| 137 | assert!(is_transient_error(&error)); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn unknown_diagnostic_is_preserved_safely() { |
| 142 | let error = anyhow::anyhow!("summary response was structurally empty"); |
| 143 | assert_eq!( |
| 144 | report(&error), |
| 145 | "Auto-compaction failed: summary response was structurally empty" |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn untyped_transient_and_deterministic_classification_remains_compatible() { |
| 151 | for message in [ |
| 152 | "Connection timeout", |
| 153 | "429 Too Many Requests", |
| 154 | "503 Service Unavailable", |
| 155 | "network error: connection refused", |
| 156 | ] { |
| 157 | assert!(is_transient_error(&anyhow::anyhow!(message)), "{message}"); |
| 158 | } |
| 159 | for message in [ |
| 160 | "401 Unauthorized: Invalid API key", |
| 161 | "Failed to parse JSON response", |
| 162 | "Invalid request: missing required field", |
| 163 | ] { |
| 164 | assert!(!is_transient_error(&anyhow::anyhow!(message)), "{message}"); |
| 165 | } |
| 166 | assert_eq!( |
| 167 | classify_compaction_failure(&anyhow::anyhow!( |
| 168 | "prompt is too long for this model's context window" |
| 169 | )), |
| 170 | CompactionFailureKind::ContextOverflow |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | fn pressure_fixture() -> Vec<Message> { |
| 175 | (0..30) |
| 176 | .map(|index| Message { |
| 177 | role: if index % 2 == 0 { |
| 178 | Role::User |
| 179 | } else { |
| 180 | Role::Assistant |
| 181 | }, |
| 182 | content: vec![ContentBlock::Text { |
| 183 | text: "x".repeat(8_000), |
| 184 | cache_control: None, |
| 185 | }], |
| 186 | }) |
| 187 | .collect() |
| 188 | } |
| 189 | |
| 190 | fn oversized_tool_pair(id: &str, content: String) -> Vec<Message> { |
| 191 | vec![ |
| 192 | Message { |
| 193 | role: Role::Assistant, |
| 194 | content: vec![ContentBlock::ToolUse { |
| 195 | id: id.to_string(), |
| 196 | name: "read_file".to_string(), |
| 197 | input: serde_json::json!({"path": "src/compaction.rs"}), |
| 198 | caller: None, |
| 199 | thought_signature: None, |
| 200 | }], |
| 201 | }, |
| 202 | Message { |
| 203 | role: Role::User, |
| 204 | content: vec![ContentBlock::ToolResult { |
| 205 | tool_use_id: id.to_string(), |
| 206 | content, |
| 207 | is_error: None, |
| 208 | content_blocks: None, |
| 209 | }], |
| 210 | }, |
| 211 | ] |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn pinned_tool_result_local_pruning_is_reclaimable() { |
| 216 | let mut messages = |
| 217 | oversized_tool_pair("old-read", "error: ".to_string() + &"x".repeat(300_000)); |
| 218 | messages.extend(pressure_fixture()); |
| 219 | let full_pressure = estimate_input_tokens_for_pressure(&messages, None); |
| 220 | let mut projected = messages.clone(); |
| 221 | let pruned_bytes = prune_tool_results_until(&mut projected, KEEP_RECENT_MESSAGES, |_, _| false); |
| 222 | let projected_pressure = estimate_input_tokens_for_pressure(&projected, None); |
| 223 | assert!( |
| 224 | pruned_bytes > 250_000, |
| 225 | "fixture must prune the pinned result" |
| 226 | ); |
| 227 | assert!(projected_pressure < full_pressure); |
| 228 | |
| 229 | let config = CompactionConfig { |
| 230 | token_threshold: projected_pressure + (full_pressure - projected_pressure) / 2, |
| 231 | ..Default::default() |
| 232 | }; |
| 233 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 234 | assert!(!compaction_pressure_reached(&projected, None, &config)); |
| 235 | assert!(should_compact( |
| 236 | &messages, |
| 237 | None, |
| 238 | &PreparedCompactionEnvelope::new(config), |
| 239 | )); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn local_pruning_removes_nested_tool_result_images() { |
| 244 | let mut messages = oversized_tool_pair("image-read", "screenshot captured".to_string()); |
| 245 | let ContentBlock::ToolResult { content_blocks, .. } = &mut messages[1].content[0] else { |
| 246 | panic!("tool result fixture"); |
| 247 | }; |
| 248 | *content_blocks = Some(vec![serde_json::json!({ |
| 249 | "type": "image", |
| 250 | "mime_type": "image/png", |
| 251 | "data": "A".repeat(300_000), |
| 252 | })]); |
| 253 | messages.extend(pressure_fixture()); |
| 254 | |
| 255 | let before = estimate_input_tokens_for_pressure(&messages, None); |
| 256 | let pruned = prune_tool_results_until(&mut messages, KEEP_RECENT_MESSAGES, |_, _| false); |
| 257 | let after = estimate_input_tokens_for_pressure(&messages, None); |
| 258 | let ContentBlock::ToolResult { content_blocks, .. } = &messages[1].content[0] else { |
| 259 | panic!("tool result fixture"); |
| 260 | }; |
| 261 | |
| 262 | assert!(pruned > 250_000, "nested image bytes must be reclaimable"); |
| 263 | assert!(after < before); |
| 264 | assert!(content_blocks.is_none(), "base64 must not survive pruning"); |
| 265 | } |
| 266 | |
| 267 | #[test] |
| 268 | fn successor_floor_counts_retained_user_messages_not_tool_results() { |
| 269 | let mut messages = pressure_fixture(); |
| 270 | messages.extend(oversized_tool_pair( |
| 271 | "recent-read", |
| 272 | "z".repeat(RETAINED_TOOL_RESULT_MAX_CHARS * 4), |
| 273 | )); |
| 274 | let base_config = CompactionConfig::default(); |
| 275 | let prepared = PreparedCompactionEnvelope::new(base_config.clone()); |
| 276 | let retained_floor = estimate_retained_floor_conservative(&messages, None, &prepared); |
| 277 | let full_pressure = estimate_input_tokens_conservative(&messages, None); |
| 278 | assert!( |
| 279 | retained_floor < full_pressure, |
| 280 | "user-only retention must reclaim the giant tool result" |
| 281 | ); |
| 282 | |
| 283 | let config = CompactionConfig { |
| 284 | token_threshold: retained_floor + 1, |
| 285 | ..base_config |
| 286 | }; |
| 287 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 288 | assert!(should_compact( |
| 289 | &messages, |
| 290 | None, |
| 291 | &PreparedCompactionEnvelope::new(config), |
| 292 | )); |
| 293 | } |
| 294 | |
| 295 | /// #5956: with no `[compaction] summary_instructions` configured, the |
| 296 | /// summarizer prompt must stay byte-identical to the pre-#5956 constant. |
| 297 | #[test] |
| 298 | fn compact_prompt_without_operator_instructions_is_unchanged() { |
| 299 | assert_eq!( |
| 300 | compact_prompt(None, None), |
| 301 | format!("{COMPACT_PROMPT} {COMPACTION_LANGUAGE_CONTRACT}") |
| 302 | ); |
| 303 | // Whitespace-only is unset, not an empty section. |
| 304 | assert_eq!( |
| 305 | compact_prompt(None, Some(" \n ")), |
| 306 | compact_prompt(None, None) |
| 307 | ); |
| 308 | // The one-off `/compact <focus>` line keeps its exact shape. |
| 309 | assert_eq!( |
| 310 | compact_prompt(Some("the flaky test"), None), |
| 311 | format!( |
| 312 | "{COMPACT_PROMPT} {COMPACTION_LANGUAGE_CONTRACT}\n\nThe user asked this compaction to focus on: the flaky test" |
| 313 | ) |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | /// #5956: the operator suffix is a clearly delimited section, and a manual |
| 318 | /// `/compact <focus>` still composes *after* it. |
| 319 | #[test] |
| 320 | fn compact_prompt_appends_operator_instructions_before_focus() { |
| 321 | let prompt = compact_prompt( |
| 322 | Some("the flaky test"), |
| 323 | Some("Always restate open decisions."), |
| 324 | ); |
| 325 | |
| 326 | assert!(prompt.starts_with(COMPACT_PROMPT)); |
| 327 | assert!(prompt.contains(OPERATOR_INSTRUCTIONS_HEADER)); |
| 328 | assert!(prompt.contains("Always restate open decisions.")); |
| 329 | assert!(prompt.contains(OPERATOR_INSTRUCTIONS_FOOTER)); |
| 330 | |
| 331 | let instructions_at = prompt.find(OPERATOR_INSTRUCTIONS_HEADER).expect("section"); |
| 332 | let focus_at = prompt.find("focus on: the flaky test").expect("focus"); |
| 333 | assert!( |
| 334 | instructions_at < focus_at, |
| 335 | "the standing instructions come first; the one-off focus composes after them" |
| 336 | ); |
| 337 | |
| 338 | // The quality-retry prompt is the same summarizer call, so it carries the |
| 339 | // same standing instructions. |
| 340 | let retry = compact_quality_retry_prompt(None, Some("Always restate open decisions.")); |
| 341 | assert!(retry.contains("Always restate open decisions.")); |
| 342 | assert!(!compact_quality_retry_prompt(None, None).contains(OPERATOR_INSTRUCTIONS_HEADER)); |
| 343 | } |
| 344 | |
| 345 | /// #5956: an oversized standing instruction is truncated at the cap rather |
| 346 | /// than failing the compaction pass that keeps the session alive. |
| 347 | #[test] |
| 348 | fn operator_instructions_are_truncated_at_the_cap() { |
| 349 | let max = crate::config::COMPACTION_SUMMARY_INSTRUCTIONS_MAX_CHARS; |
| 350 | let long = "é".repeat(max + 500); |
| 351 | let section = operator_instructions_section(Some(&long)).expect("section is present"); |
| 352 | |
| 353 | let body = section |
| 354 | .trim_start_matches('\n') |
| 355 | .trim_start_matches(OPERATOR_INSTRUCTIONS_HEADER) |
| 356 | .trim_start_matches('\n') |
| 357 | .trim_end_matches(OPERATOR_INSTRUCTIONS_FOOTER) |
| 358 | .trim_end_matches('\n'); |
| 359 | assert_eq!(body.chars().count(), max); |
| 360 | assert!(operator_instructions_section(None).is_none()); |
| 361 | assert!(operator_instructions_section(Some(" ")).is_none()); |
| 362 | } |
| 363 | |
| 364 | /// #5956: the replacement history spends the configured verbatim budget, so a |
| 365 | /// larger budget keeps more of the user's own earlier messages. |
| 366 | #[test] |
| 367 | fn replacement_history_honours_the_configured_retention_budget() { |
| 368 | let user = |text: &str| Message { |
| 369 | role: Role::User, |
| 370 | content: vec![ContentBlock::Text { |
| 371 | text: text.to_string(), |
| 372 | cache_control: None, |
| 373 | }], |
| 374 | }; |
| 375 | let assistant = |text: &str| Message { |
| 376 | role: Role::Assistant, |
| 377 | content: vec![ContentBlock::Text { |
| 378 | text: text.to_string(), |
| 379 | cache_control: None, |
| 380 | }], |
| 381 | }; |
| 382 | |
| 383 | // Each older user message is ~1 000 conservative tokens (3 chars/token). |
| 384 | let mut messages = Vec::new(); |
| 385 | for idx in 0..10 { |
| 386 | messages.push(user(&format!("{idx}{}", "a".repeat(3_000)))); |
| 387 | messages.push(assistant("ack")); |
| 388 | } |
| 389 | messages.push(user("the live request")); |
| 390 | messages.push(assistant("working on it")); |
| 391 | |
| 392 | let checkpoint = format!("{COMPACTION_SUMMARY_MARKER} and produced this handoff."); |
| 393 | let count_verbatim = |budget: usize| { |
| 394 | last_round::build_replacement_history(&messages, &checkpoint, None, budget) |
| 395 | .expect("replacement history") |
| 396 | .len() |
| 397 | }; |
| 398 | |
| 399 | let small = count_verbatim(2_000); |
| 400 | let large = count_verbatim(20_000); |
| 401 | assert!( |
| 402 | large > small, |
| 403 | "a larger budget must keep more user messages verbatim ({small} vs {large})" |
| 404 | ); |
| 405 | // The floor still keeps the last round plus the checkpoint. |
| 406 | assert!( |
| 407 | small >= 3, |
| 408 | "the bounded last round and checkpoint always survive" |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | /// #5956: the receipt clause names the effective budget and whether standing |
| 413 | /// operator instructions were applied, so the knob is verifiable without logs. |
| 414 | #[test] |
| 415 | fn receipt_clause_reports_the_effective_compaction_tuning() { |
| 416 | let mut coverage = CompactionCoverage { |
| 417 | path: CompactionPath::Summary, |
| 418 | last_round_messages: 2, |
| 419 | last_round_tool_results: 0, |
| 420 | last_round_assistant: true, |
| 421 | dropped_messages: 8, |
| 422 | anchors_chars: 0, |
| 423 | retained_user_message_tokens: 60_000, |
| 424 | operator_instructions_applied: true, |
| 425 | }; |
| 426 | let clause = coverage.receipt_clause(); |
| 427 | assert!( |
| 428 | clause.contains("verbatim user budget 60000 tokens"), |
| 429 | "{clause}" |
| 430 | ); |
| 431 | assert!(clause.contains("operator instructions applied"), "{clause}"); |
| 432 | |
| 433 | coverage.operator_instructions_applied = false; |
| 434 | assert!(!coverage.receipt_clause().contains("operator instructions")); |
| 435 | |
| 436 | // The prune-only path builds no replacement history, so it reports no budget. |
| 437 | let prune_only = CompactionCoverage { |
| 438 | path: CompactionPath::PruneOnly, |
| 439 | ..CompactionCoverage::default() |
| 440 | }; |
| 441 | assert!(!prune_only.receipt_clause().contains("verbatim user budget")); |
| 442 | } |
| 443 |