返回 CodeWhale
tests.rs
根目录 / crates / tui / src / compaction / tests.rs
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
443 lines RUST