返回 CodeWhale
last_round.rs
根目录 / crates / tui / src / compaction / last_round.rs
1 //! Last-round coverage floor for compaction replacement history.
2 //!
3 //! Compaction may summarize older turns, but the latest user round (user
4 //! text plus following assistant/tool results) must survive verbatim,
5 //! bounded, or the pass is refused. See [`SURVIVAL_CONTRACT.md`].
6
7 use anyhow::Result;
8 use std::collections::HashSet;
9
10 use codewhale_models::{ContentBlock, Message, SystemPrompt};
11
12 use super::{
13 compaction_checkpoint_message, is_compaction_checkpoint_message, retained_user_messages,
14 truncate_retained_block, user_text_of,
15 };
16
17 const LAST_ROUND_TOOL_RESULT_MAX_CHARS: usize = 8 * 1024;
18
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20 pub enum CompactionPath {
21 #[default]
22 Summary,
23 PruneOnly,
24 }
25
26 #[derive(Debug, Clone, PartialEq, Eq, Default)]
27 pub struct CompactionCoverage {
28 pub path: CompactionPath,
29 pub last_round_messages: usize,
30 pub last_round_tool_results: usize,
31 pub last_round_assistant: bool,
32 pub dropped_messages: usize,
33 pub anchors_chars: usize,
34 /// Effective `[compaction] retained_user_message_tokens` budget this pass
35 /// spent on verbatim user messages (#5956). `0` on the prune-only path,
36 /// which never builds a replacement history.
37 pub retained_user_message_tokens: usize,
38 /// Whether `[compaction] summary_instructions` was appended to the
39 /// summarizer prompt on this pass (#5956).
40 pub operator_instructions_applied: bool,
41 }
42
43 impl CompactionCoverage {
44 #[must_use]
45 pub fn receipt_clause(&self) -> String {
46 let path = match self.path {
47 CompactionPath::Summary => "summary",
48 CompactionPath::PruneOnly => "prune-only",
49 };
50 let assistant = if self.last_round_assistant {
51 ", assistant"
52 } else {
53 ""
54 };
55 let mut clause = format!(
56 "{path}; last round kept: {} messages ({} tool results{assistant})",
57 self.last_round_messages, self.last_round_tool_results
58 );
59 if self.anchors_chars > 0 {
60 clause.push_str(&format!("; anchors {} chars", self.anchors_chars));
61 }
62 // Name the tuning knobs so an operator who set them can tell they took
63 // effect without reading the log (#5956). The prune-only path builds no
64 // replacement history, so it reports no budget.
65 if self.retained_user_message_tokens > 0 {
66 clause.push_str(&format!(
67 "; verbatim user budget {} tokens",
68 self.retained_user_message_tokens
69 ));
70 }
71 if self.operator_instructions_applied {
72 clause.push_str("; operator instructions applied");
73 }
74 clause
75 }
76 }
77
78 #[derive(Debug, Clone, PartialEq, Eq)]
79 pub struct LastCompactionSnapshot {
80 pub auto: bool,
81 pub coverage: CompactionCoverage,
82 pub messages_before: usize,
83 pub messages_after: usize,
84 }
85
86 #[derive(Debug, Clone, PartialEq, Eq, Default)]
87 pub struct CompactionKeep {
88 pub has_checkpoint: bool,
89 pub last_round_messages: usize,
90 pub last_round_tool_results: usize,
91 pub last_round_assistant: bool,
92 }
93
94 #[must_use]
95 pub fn inspect_compaction_keep(messages: &[Message]) -> CompactionKeep {
96 let last_round = last_round_slice(messages);
97 CompactionKeep {
98 has_checkpoint: messages.iter().any(is_compaction_checkpoint_message),
99 last_round_messages: last_round.len(),
100 last_round_tool_results: last_round.iter().flat_map(tool_result_ids).count(),
101 last_round_assistant: last_round
102 .iter()
103 .any(|message| message.role.is_assistant_like()),
104 }
105 }
106
107 #[must_use]
108 pub fn pinned_anchors_text(workspace: Option<&std::path::Path>) -> Option<String> {
109 let workspace = workspace?;
110 let primary = workspace.join(".codewhale").join("anchors.md");
111 let path = if primary.exists() {
112 primary
113 } else {
114 workspace.join(".deepseek").join("anchors.md")
115 };
116 std::fs::read_to_string(path)
117 .ok()
118 .map(|contents| contents.trim().to_string())
119 .filter(|contents| !contents.is_empty())
120 }
121
122 fn is_plain_user_text(message: &Message) -> bool {
123 !is_compaction_checkpoint_message(message)
124 && !crate::runtime_handoff::is_runtime_owned_user_message(message)
125 && user_text_of(message).is_some()
126 }
127
128 fn user_prompt_text_of(message: &Message) -> Option<String> {
129 is_plain_user_text(message)
130 .then(|| user_text_of(message))
131 .flatten()
132 }
133
134 fn last_plain_user_index(messages: &[Message], end: usize) -> Option<usize> {
135 messages[..end]
136 .iter()
137 .enumerate()
138 .rev()
139 .find_map(|(idx, message)| is_plain_user_text(message).then_some(idx))
140 }
141
142 fn slice_has_tool_result(messages: &[Message], start: usize) -> bool {
143 messages[start..].iter().any(|message| {
144 message
145 .content
146 .iter()
147 .any(|block| matches!(block, ContentBlock::ToolResult { .. }))
148 })
149 }
150
151 #[must_use]
152 pub(crate) fn last_round_start(messages: &[Message]) -> usize {
153 let Some(last_user) = last_plain_user_index(messages, messages.len()) else {
154 return 0;
155 };
156 if slice_has_tool_result(messages, last_user) {
157 return last_user;
158 }
159 // Trailing toolless user/assistant turns still need the previous
160 // tool-bearing round; otherwise those results vanish behind the summary.
161 // If no tool round exists, keep only the latest user turn so chat-only
162 // sessions can still summarize older text.
163 let mut candidate = last_user;
164 loop {
165 let Some(prev) = last_plain_user_index(messages, candidate) else {
166 return last_user;
167 };
168 if slice_has_tool_result(messages, prev) {
169 return prev;
170 }
171 candidate = prev;
172 }
173 }
174
175 #[must_use]
176 pub(crate) fn last_round_range(messages: &[Message]) -> (usize, usize) {
177 let start = last_round_start(messages).min(messages.len());
178 // A previous checkpoint can sit in the middle of an uninterrupted task.
179 // Stopping at that marker hid every tool step after the first compact
180 // from the next pass's survival checks.
181 let end = messages.len().saturating_sub(usize::from(
182 messages
183 .last()
184 .is_some_and(is_compaction_checkpoint_message),
185 ));
186 (start, end)
187 }
188
189 /// How many messages of the open round sit in `messages` before a checkpoint.
190 #[must_use]
191 pub fn last_round_kept_count(messages: &[Message]) -> Option<usize> {
192 let checkpoint = messages
193 .iter()
194 .rposition(is_compaction_checkpoint_message)?;
195 if checkpoint == 0 {
196 return None;
197 }
198 let start = last_round_start(&messages[..checkpoint]);
199 Some(checkpoint.saturating_sub(start))
200 }
201
202 fn last_round_slice(messages: &[Message]) -> &[Message] {
203 let (start, end) = last_round_range(messages);
204 &messages[start..end]
205 }
206
207 /// Retain the current user instructions and the two most recent tool
208 /// exchanges. A user round can contain thousands of steps: retaining that
209 /// entire round forever makes a continuous task impossible to compact.
210 /// Older completed exchanges are covered by the summary and durable history.
211 /// A split is legal only when all preceding tool calls have their results.
212 fn protected_last_round(messages: &[Message]) -> Vec<&Message> {
213 let round = last_round_slice(messages);
214 let mut pending = HashSet::new();
215 let mut boundaries = Vec::new();
216 for (idx, message) in round.iter().enumerate() {
217 let calls = tool_use_ids(message);
218 if !calls.is_empty() && pending.is_empty() {
219 boundaries.push(idx);
220 }
221 pending.extend(calls);
222 for id in tool_result_ids(message) {
223 pending.remove(&id);
224 }
225 }
226 let start = if boundaries.len() > 2 {
227 boundaries[boundaries.len() - 2]
228 } else {
229 0
230 };
231 round
232 .iter()
233 .enumerate()
234 .filter_map(|(idx, message)| {
235 (!is_compaction_checkpoint_message(message)
236 && (idx >= start || is_plain_user_text(message)))
237 .then_some(message)
238 })
239 .collect()
240 }
241
242 pub(super) fn replacement_messages(
243 messages: &[Message],
244 retained_user_message_tokens: usize,
245 ) -> Vec<Message> {
246 let (start, _) = last_round_range(messages);
247 let mut retained = retained_user_messages(&messages[..start], retained_user_message_tokens);
248 let round = protected_last_round(messages)
249 .into_iter()
250 .cloned()
251 .collect::<Vec<_>>();
252 retained.extend(bound_last_round(&round));
253 // The Operate contract applies to the current tool loop as well as later
254 // turns. It must survive compaction even when old-user retention is full.
255 let current_contract = messages
256 .iter()
257 .rev()
258 .find(|message| crate::runtime_handoff::is_current_operate_contract_message(message));
259 let contract = current_contract.or_else(|| {
260 messages
261 .iter()
262 .rev()
263 .find(|message| crate::runtime_handoff::is_operate_contract_message(message))
264 });
265 if let Some(contract) = contract {
266 retained.retain(|message| !crate::runtime_handoff::is_operate_contract_message(message));
267 retained.insert(0, contract.clone());
268 }
269 retained
270 }
271
272 pub(super) fn bound_last_round(messages: &[Message]) -> Vec<Message> {
273 let mut round = messages.to_vec();
274 for message in &mut round {
275 for block in &mut message.content {
276 if let ContentBlock::ToolResult {
277 content,
278 content_blocks,
279 ..
280 } = block
281 && truncate_retained_block("tool result", content, LAST_ROUND_TOOL_RESULT_MAX_CHARS)
282 {
283 *content_blocks = None;
284 }
285 }
286 }
287 round
288 }
289
290 fn tool_result_ids(message: &Message) -> Vec<String> {
291 message
292 .content
293 .iter()
294 .filter_map(|block| match block {
295 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
296 _ => None,
297 })
298 .collect()
299 }
300
301 fn has_tool_result_id(message: &Message, id: &str) -> bool {
302 message.content.iter().any(|block| {
303 matches!(
304 block,
305 ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id
306 )
307 })
308 }
309
310 fn tool_use_ids(message: &Message) -> Vec<String> {
311 message
312 .content
313 .iter()
314 .filter_map(|block| match block {
315 ContentBlock::ToolUse { id, .. } => Some(id.clone()),
316 _ => None,
317 })
318 .collect()
319 }
320
321 fn has_tool_use_id(message: &Message, id: &str) -> bool {
322 message.content.iter().any(|block| {
323 matches!(
324 block,
325 ContentBlock::ToolUse { id: seen, .. } if seen == id
326 )
327 })
328 }
329
330 fn assistant_text_of(message: &Message) -> Option<String> {
331 if !message.role.is_assistant_like() {
332 return None;
333 }
334 let text = message
335 .content
336 .iter()
337 .filter_map(|block| match block {
338 ContentBlock::Text { text, .. } => Some(text.as_str()),
339 _ => None,
340 })
341 .collect::<Vec<_>>()
342 .join("\n");
343 let text = text.trim();
344 (!text.is_empty()).then(|| text.to_string())
345 }
346
347 /// A retained copy may be truncated (`bound_last_round` caps oversized blocks),
348 /// so a prefix either way counts as survival -- but nothing weaker does.
349 fn survives(text: &str, replacement: &[Message], of: fn(&Message) -> Option<String>) -> bool {
350 replacement.iter().any(|message| {
351 of(message)
352 .is_some_and(|kept| kept == text || text.starts_with(&kept) || kept.starts_with(text))
353 })
354 }
355
356 pub(crate) fn validate_last_round_coverage(
357 original: &[Message],
358 replacement: &[Message],
359 ) -> Result<()> {
360 let last_round = protected_last_round(original);
361 if last_round.is_empty() {
362 return Ok(());
363 }
364 // Every user turn in the round, not the first one `find_map` happens to
365 // reach. `last_round_start` walks back past a toolless tail to the previous
366 // tool-bearing turn, so the round routinely spans two user messages -- and
367 // checking only the earliest let a rewrite drop the *latest* one, which is
368 // the turn this whole contract exists to keep.
369 for text in last_round.iter().copied().filter_map(user_prompt_text_of) {
370 if !survives(&text, replacement, user_prompt_text_of) {
371 anyhow::bail!(
372 "Compaction coverage floor: a last-round user message was dropped; history was not replaced."
373 );
374 }
375 }
376 for id in last_round.iter().copied().flat_map(tool_result_ids) {
377 if !replacement
378 .iter()
379 .any(|message| has_tool_result_id(message, &id))
380 {
381 anyhow::bail!(
382 "Compaction coverage floor: last-round tool result {id} was dropped; history was not replaced."
383 );
384 }
385 }
386 // The call, not just its result. Keeping a tool_result whose tool_use was
387 // summarized away leaves an orphaned result that providers reject outright.
388 for id in last_round.iter().copied().flat_map(tool_use_ids) {
389 if !replacement
390 .iter()
391 .any(|message| has_tool_use_id(message, &id))
392 {
393 anyhow::bail!(
394 "Compaction coverage floor: last-round tool call {id} was dropped; history was not replaced."
395 );
396 }
397 }
398 // Match the assistant's actual output. An existential "some assistant
399 // message survived" check passed on a replacement whose only assistant
400 // message was the summary the rewrite had just written.
401 for text in last_round.iter().copied().filter_map(assistant_text_of) {
402 if !survives(&text, replacement, assistant_text_of) {
403 anyhow::bail!(
404 "Compaction coverage floor: last-round assistant output was dropped; history was not replaced."
405 );
406 }
407 }
408 if last_round
409 .iter()
410 .any(|message| message.role.is_assistant_like())
411 && !replacement
412 .iter()
413 .any(|message| message.role.is_assistant_like())
414 {
415 anyhow::bail!(
416 "Compaction coverage floor: last-round assistant output was dropped; history was not replaced."
417 );
418 }
419 Ok(())
420 }
421
422 pub(crate) fn require_text_survives(
423 replacement: &[Message],
424 needle: &str,
425 label: &str,
426 ) -> Result<()> {
427 let needle = needle.trim();
428 if needle.is_empty() {
429 return Ok(());
430 }
431 let kept = replacement.iter().any(|message| {
432 message.content.iter().any(|block| match block {
433 ContentBlock::Text { text, .. } => text.contains(needle),
434 ContentBlock::ToolResult { content, .. } => content.contains(needle),
435 _ => false,
436 })
437 });
438 if !kept {
439 anyhow::bail!("Compaction coverage floor: {label} was dropped; history was not replaced.");
440 }
441 Ok(())
442 }
443
444 pub(crate) fn validate_survival_contract(
445 original: &[Message],
446 replacement: &[Message],
447 anchors: Option<&str>,
448 ) -> Result<()> {
449 validate_last_round_coverage(original, replacement)?;
450 let checkpoints = replacement
451 .iter()
452 .filter(|message| is_compaction_checkpoint_message(message))
453 .count();
454 if checkpoints == 0 {
455 anyhow::bail!(
456 "Compaction coverage floor: checkpoint receipt was dropped; history was not replaced."
457 );
458 }
459 if checkpoints > 1 {
460 anyhow::bail!(
461 "Compaction coverage floor: prior summaries were duplicated; history was not replaced."
462 );
463 }
464 if let Some(anchors) = anchors {
465 require_text_survives(replacement, anchors, "pinned /anchor text")?;
466 }
467 Ok(())
468 }
469
470 pub(super) fn measure_coverage(
471 original: &[Message],
472 replacement: &[Message],
473 path: CompactionPath,
474 anchors_chars: usize,
475 ) -> CompactionCoverage {
476 let last_round = last_round_slice(replacement);
477 CompactionCoverage {
478 path,
479 last_round_messages: last_round.len(),
480 last_round_tool_results: last_round.iter().flat_map(tool_result_ids).count(),
481 last_round_assistant: last_round
482 .iter()
483 .any(|message| message.role.is_assistant_like()),
484 dropped_messages: original.len().saturating_sub(replacement.len()),
485 anchors_chars,
486 // Tuning provenance is owned by the caller that holds the
487 // `CompactionConfig`; measurement over two message lists cannot know it.
488 retained_user_message_tokens: 0,
489 operator_instructions_applied: false,
490 }
491 }
492
493 /// Build the post-compaction history: recent plain user messages kept
494 /// verbatim within `retained_user_message_tokens`, the bounded last round, and
495 /// the checkpoint. The budget is `[compaction] retained_user_message_tokens`
496 /// (#5956); it was a hard-coded 20 000 before that key existed.
497 pub(super) fn build_replacement_history(
498 messages: &[Message],
499 checkpoint_text: &str,
500 anchors: Option<&str>,
501 retained_user_message_tokens: usize,
502 ) -> Result<Vec<Message>> {
503 let mut retained = replacement_messages(messages, retained_user_message_tokens);
504 retained.push(compaction_checkpoint_message(&SystemPrompt::Text(
505 checkpoint_text.to_string(),
506 )));
507 validate_survival_contract(messages, &retained, anchors)?;
508 Ok(retained)
509 }
510
511 #[cfg(test)]
512 mod tests {
513 use super::*;
514 use crate::compaction::{COMPACTION_SUMMARY_MARKER, compaction_checkpoint_message};
515 use codewhale_models::{ContentBlock, Role};
516 use serde_json::json;
517
518 fn msg(role: &str, text: &str) -> Message {
519 Message {
520 role: Role::from(role),
521 content: vec![ContentBlock::Text {
522 text: text.to_string(),
523 cache_control: None,
524 }],
525 }
526 }
527
528 fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
529 Message {
530 role: Role::Assistant,
531 content: vec![ContentBlock::ToolUse {
532 id: id.to_string(),
533 name: name.to_string(),
534 input,
535 caller: None,
536 thought_signature: None,
537 }],
538 }
539 }
540
541 fn tool_result(id: &str, content: &str) -> Message {
542 Message {
543 role: Role::User,
544 content: vec![ContentBlock::ToolResult {
545 tool_use_id: id.to_string(),
546 content: content.to_string(),
547 is_error: None,
548 content_blocks: None,
549 }],
550 }
551 }
552
553 fn checkpoint(summary: &str) -> Message {
554 compaction_checkpoint_message(&SystemPrompt::Text(format!(
555 "{COMPACTION_SUMMARY_MARKER}: {summary}"
556 )))
557 }
558
559 #[test]
560 fn coverage_floor_rejects_a_replacement_that_drops_last_round_tools() {
561 let original = vec![
562 msg("user", "Run the failing test."),
563 msg("assistant", "Running."),
564 tool_use("live", "Bash", json!({"command": "cargo test"})),
565 tool_result("live", "test session_store::roundtrip ... FAILED"),
566 ];
567 let gutting = vec![
568 msg("user", "Run the failing test."),
569 checkpoint("and kept going"),
570 ];
571 let error = validate_last_round_coverage(&original, &gutting)
572 .expect_err("dropping the last tool result must fail the coverage floor");
573 assert!(error.to_string().contains("tool result live"), "{error}");
574 assert!(validate_last_round_coverage(&original, &original).is_ok());
575 }
576
577 #[test]
578 fn coverage_floor_rejects_a_replacement_that_drops_last_round_assistant() {
579 let original = vec![
580 msg("user", "What failed?"),
581 msg("assistant", "session_store::roundtrip panics on reload."),
582 ];
583 let error = validate_last_round_coverage(&original, &[msg("user", "What failed?")])
584 .expect_err("dropping last-round assistant text must fail closed");
585 assert!(error.to_string().contains("assistant"), "{error}");
586 }
587
588 /// The round spans the tool-bearing turn *and* the toolless tail after it,
589 /// because `last_round_start` walks back for the tools. Checking only the
590 /// first user text it found meant a rewrite could keep the older question
591 /// and drop the one the person actually just asked.
592 #[test]
593 fn coverage_floor_rejects_a_replacement_that_drops_the_latest_user_turn() {
594 let original = vec![
595 msg("user", "Run the suite."),
596 msg("assistant", "Running."),
597 tool_use("live", "Bash", json!({"command": "cargo test"})),
598 tool_result("live", "ok"),
599 msg("user", "Now ship it."),
600 msg("assistant", "Shipping."),
601 ];
602 assert_eq!(last_round_start(&original), 0, "round must span both turns");
603
604 let drops_latest = vec![
605 msg("user", "Run the suite."),
606 msg("assistant", "Running."),
607 tool_use("live", "Bash", json!({"command": "cargo test"})),
608 tool_result("live", "ok"),
609 checkpoint("then shipped"),
610 ];
611 let error = validate_last_round_coverage(&original, &drops_latest)
612 .expect_err("dropping the latest user turn must fail the coverage floor");
613 assert!(error.to_string().contains("user message"), "{error}");
614 assert!(validate_last_round_coverage(&original, &original).is_ok());
615 }
616
617 /// A surviving `tool_result` whose `tool_use` was summarized away is an
618 /// orphan the provider rejects, so the floor must cover the call too.
619 #[test]
620 fn coverage_floor_rejects_a_replacement_that_drops_the_tool_call() {
621 let original = vec![
622 msg("user", "Run the failing test."),
623 msg("assistant", "Running."),
624 tool_use("live", "Bash", json!({"command": "cargo test"})),
625 tool_result("live", "FAILED"),
626 ];
627 let orphaned = vec![
628 msg("user", "Run the failing test."),
629 msg("assistant", "Running."),
630 tool_result("live", "FAILED"),
631 checkpoint("and it failed"),
632 ];
633 let error = validate_last_round_coverage(&original, &orphaned)
634 .expect_err("dropping the tool call must fail the coverage floor");
635 assert!(error.to_string().contains("tool call live"), "{error}");
636 }
637
638 /// "Some assistant message survived" was satisfied by the summary the
639 /// rewrite had just written, so the round's real output could vanish.
640 #[test]
641 fn coverage_floor_rejects_assistant_output_replaced_by_a_summary() {
642 let original = vec![
643 msg("user", "What failed?"),
644 msg("assistant", "session_store::roundtrip panics on reload."),
645 ];
646 let summarized = vec![
647 msg("user", "What failed?"),
648 msg("assistant", "Earlier we discussed several test failures."),
649 ];
650 let error = validate_last_round_coverage(&original, &summarized)
651 .expect_err("substituting a summary for the round's output must fail closed");
652 assert!(error.to_string().contains("assistant"), "{error}");
653 }
654
655 #[test]
656 fn survival_contract_rejects_dropped_anchors_and_receipts() {
657 let original = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
658 let without_receipt = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
659 let error = validate_survival_contract(&original, &without_receipt, Some("ship 0.9.12"))
660 .expect_err("missing checkpoint receipt must fail closed");
661 assert!(error.to_string().contains("receipt"), "{error}");
662
663 let without_anchor = vec![
664 msg("user", "Keep the pin."),
665 msg("assistant", "Anchored."),
666 checkpoint("progress without the pin"),
667 ];
668 let error = validate_survival_contract(&original, &without_anchor, Some("ship 0.9.12"))
669 .expect_err("dropped /anchor text must fail closed");
670 assert!(error.to_string().contains("anchor"), "{error}");
671 }
672
673 #[test]
674 fn last_round_starts_at_the_latest_plain_user_message() {
675 let messages = vec![
676 msg("user", "older"),
677 msg("assistant", "working"),
678 tool_result("old", "stale"),
679 msg("user", "Run the suite now."),
680 msg("assistant", "Rerunning."),
681 tool_use("live", "Bash", json!({"command": "cargo test"})),
682 tool_result("live", "ok"),
683 ];
684 assert_eq!(last_round_start(&messages), 3); // last user with tools
685 let (start, end) = last_round_range(&messages);
686 let kept = bound_last_round(&messages[start..end]);
687 assert!(kept.iter().any(|message| {
688 message.content.iter().any(|block| {
689 matches!(
690 block,
691 ContentBlock::ToolResult { tool_use_id, content, .. }
692 if tool_use_id == "live" && content == "ok"
693 )
694 })
695 }));
696 }
697
698 #[test]
699 fn second_compaction_keeps_long_user_question_and_tool_pair_past_retention_budget() {
700 const PRODUCTION_MIN_RETAINED_TOKENS: usize = 2_000;
701 let long_question = format!(
702 "{}?",
703 "Analyze every step of this case carefully. ".repeat(400)
704 );
705 assert!(long_question.len() > PRODUCTION_MIN_RETAINED_TOKENS * 3);
706 let original = vec![
707 msg("user", &long_question),
708 tool_use("call_1", "Bash", json!({"command": "echo ready"})),
709 tool_result("call_1", "ready"),
710 ];
711 let first_summary =
712 crate::compaction::build_compaction_summary_block_text("First pass complete", "");
713 let mut first = build_replacement_history(
714 &original,
715 &first_summary,
716 None,
717 PRODUCTION_MIN_RETAINED_TOKENS,
718 )
719 .expect("first compaction");
720 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut first, &[]);
721 assert_eq!(last_round_start(&first), 0);
722 let topology = first
723 .iter()
724 .find(|message| crate::runtime_handoff::is_agent_topology_checkpoint(message))
725 .expect("first compaction topology checkpoint")
726 .clone();
727 assert!(
728 crate::compaction::retained_user_messages(
729 std::slice::from_ref(&topology),
730 PRODUCTION_MIN_RETAINED_TOKENS,
731 )
732 .is_empty(),
733 "runtime topology must not consume the older-user retention budget"
734 );
735
736 let second_summary =
737 crate::compaction::build_compaction_summary_block_text("Second pass complete", "");
738 let second = build_replacement_history(
739 &first,
740 &second_summary,
741 None,
742 PRODUCTION_MIN_RETAINED_TOKENS,
743 )
744 .expect("second compaction");
745 assert!(
746 second.iter().any(|message| {
747 user_text_of(message).as_deref() == Some(long_question.as_str())
748 })
749 );
750 assert!(
751 second
752 .iter()
753 .any(|message| has_tool_use_id(message, "call_1"))
754 );
755 assert!(
756 second
757 .iter()
758 .any(|message| has_tool_result_id(message, "call_1"))
759 );
760 let without_question = second
761 .iter()
762 .filter(|message| user_text_of(message).as_deref() != Some(long_question.as_str()))
763 .cloned()
764 .collect::<Vec<_>>();
765 assert!(validate_last_round_coverage(&first, &without_question).is_err());
766 }
767
768 #[test]
769 fn runtime_text_cannot_satisfy_real_user_coverage() {
770 let runtime = crate::runtime_handoff::operate_contract_runtime_message();
771 let copied_text = user_text_of(&runtime).expect("runtime text");
772 let original = vec![msg("user", &copied_text), msg("assistant", "Acknowledged")];
773 let replacement = vec![runtime, msg("assistant", "Acknowledged")];
774 assert!(
775 validate_last_round_coverage(&original, &replacement).is_err(),
776 "runtime-owned text must not stand in for the user's actual prompt"
777 );
778 }
779
780 #[test]
781 fn operate_contract_survives_compaction_without_spending_user_budget() {
782 let contract = crate::runtime_handoff::operate_contract_runtime_message();
783 let original = vec![
784 contract.clone(),
785 msg("user", "First task"),
786 msg("assistant", "Working"),
787 msg("user", "Continue the same task"),
788 msg("assistant", "Continuing"),
789 ];
790 let replaced = build_replacement_history(
791 &original,
792 &format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
793 None,
794 1,
795 )
796 .expect("compaction must retain the active Operate contract");
797 assert_eq!(replaced.first(), Some(&contract));
798 assert_eq!(
799 replaced
800 .iter()
801 .filter(|message| **message == contract)
802 .count(),
803 1
804 );
805 }
806
807 #[test]
808 fn compaction_prefers_current_operate_contract_over_legacy() {
809 let legacy = crate::runtime_handoff::legacy_operate_contract_runtime_message();
810 let current = crate::runtime_handoff::operate_contract_runtime_message();
811 let original = vec![
812 legacy.clone(),
813 current.clone(),
814 msg("user", "Continue"),
815 msg("assistant", "Working"),
816 ];
817 let replaced = build_replacement_history(
818 &original,
819 &format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
820 None,
821 1,
822 )
823 .expect("current contract must survive compaction");
824 assert_eq!(replaced.first(), Some(&current));
825 assert!(!replaced.contains(&legacy));
826 assert_eq!(
827 replaced
828 .iter()
829 .filter(|message| crate::runtime_handoff::is_operate_contract_message(message))
830 .count(),
831 1
832 );
833 }
834
835 #[test]
836 fn last_round_walks_back_through_toolless_tails_to_the_tool_round() {
837 let original = vec![
838 msg("user", "Run the failing test."),
839 msg("assistant", "Running."),
840 tool_use("live", "Bash", json!({"command": "cargo test"})),
841 tool_result("live", "test session_store::roundtrip ... FAILED"),
842 msg("user", "ok thanks"),
843 msg("assistant", "you're welcome"),
844 msg("user", "one more thing"),
845 msg("assistant", "sure"),
846 ];
847 assert_eq!(last_round_start(&original), 0);
848 let next = format!("{COMPACTION_SUMMARY_MARKER}: keep the failing test result");
849 let replaced = build_replacement_history(
850 &original,
851 &next,
852 None,
853 crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
854 )
855 .expect("toolless tails must not drop the last tool result");
856 assert!(replaced.iter().any(|message| {
857 message.content.iter().any(|block| {
858 matches!(
859 block,
860 ContentBlock::ToolResult { tool_use_id, content, .. }
861 if tool_use_id == "live" && content.contains("FAILED")
862 )
863 })
864 }));
865 }
866
867 #[test]
868 fn chat_only_history_keeps_the_latest_user_round() {
869 let messages = vec![
870 msg("user", "hello"),
871 msg("assistant", "hi"),
872 msg("user", "how are you"),
873 msg("assistant", "fine"),
874 ];
875 assert_eq!(last_round_start(&messages), 2);
876 }
877
878 #[derive(serde::Deserialize)]
879 struct FixtureMatrix {
880 schema_version: u32,
881 cases: Vec<FixtureCase>,
882 }
883
884 #[derive(serde::Deserialize)]
885 struct FixtureCase {
886 id: String,
887 expect: String,
888 #[serde(default)]
889 anchors: Option<String>,
890 original: Vec<Message>,
891 replacement: Vec<Message>,
892 #[serde(default)]
893 last_round_start: Option<usize>,
894 }
895
896 #[test]
897 fn fixture_matrix_enforces_survival_contract() {
898 let matrix: FixtureMatrix =
899 serde_json::from_str(include_str!("fixtures/matrix.json")).expect("matrix.json");
900 assert_eq!(matrix.schema_version, 2);
901 assert!(
902 matrix.cases.len() >= 8,
903 "fixture matrix must cover last-round, toolless-tail, chat-only, anchor, and receipt cases"
904 );
905 for case in &matrix.cases {
906 if let Some(start) = case.last_round_start {
907 assert_eq!(
908 last_round_start(&case.original),
909 start,
910 "{} last_round_start",
911 case.id
912 );
913 }
914 let result = validate_survival_contract(
915 &case.original,
916 &case.replacement,
917 case.anchors.as_deref(),
918 );
919 match case.expect.as_str() {
920 "pass" => {
921 result.unwrap_or_else(|error| panic!("{} should pass: {error}", case.id));
922 }
923 "fail" => {
924 result.expect_err(&format!("{} should fail closed", case.id));
925 }
926 other => panic!("{}: unknown expect {other}", case.id),
927 }
928 }
929 }
930
931 #[test]
932 fn second_compact_does_not_duplicate_prior_summaries() {
933 let first = vec![
934 msg("user", "older"),
935 msg("user", "Run the suite now."),
936 msg("assistant", "Rerunning."),
937 tool_use("live", "Bash", json!({"command": "cargo test"})),
938 tool_result("live", "ok"),
939 checkpoint("first handoff: suite still running"),
940 ];
941 let next = format!(
942 "{COMPACTION_SUMMARY_MARKER}: second handoff with User-pinned anchors (verbatim):\nship 0.9.12"
943 );
944 let replaced = build_replacement_history(
945 &first,
946 &next,
947 Some("ship 0.9.12"),
948 crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
949 )
950 .expect("second compact must keep last round and one receipt");
951 let checkpoints = replaced
952 .iter()
953 .filter(|message| is_compaction_checkpoint_message(message))
954 .count();
955 assert_eq!(checkpoints, 1, "{replaced:?}");
956 assert!(replaced.iter().any(|message| {
957 message.content.iter().any(|block| {
958 matches!(
959 block,
960 ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "live"
961 )
962 })
963 }));
964 require_text_survives(&replaced, "ship 0.9.12", "pinned /anchor text").unwrap();
965 }
966 }
967
967 lines RUST