返回 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!("{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(&quoted)),
53 Some(quoted.clone())
54 );
55 assert!(extract_compaction_summary(Some(&quoted)).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 [&quote, &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
710 lines RUST