返回 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 /// Workspace anchors are passive model input; only user-configured trust admits
108 /// them. Missing, linked, unreadable, or empty anchors contribute no text.
109 #[must_use]
110 pub fn pinned_anchors_text(workspace: Option<&std::path::Path>) -> Option<String> {
111 let workspace = workspace?;
112 if !crate::config::is_workspace_trusted(workspace) {
113 return None;
114 }
115 let primary = workspace.join(".codewhale").join("anchors.md");
116 let path = if primary.symlink_metadata().is_ok() || workspace.join(".codewhale").is_symlink() {
117 primary
118 } else {
119 workspace.join(".deepseek").join("anchors.md")
120 };
121 crate::fs_confined::read_to_string(workspace, &path)
122 .ok()
123 .map(|contents| contents.trim().to_string())
124 .filter(|contents| !contents.is_empty())
125 }
126
127 fn is_plain_user_text(message: &Message) -> bool {
128 !is_compaction_checkpoint_message(message)
129 && !crate::runtime_handoff::is_runtime_owned_user_message(message)
130 && user_text_of(message).is_some()
131 }
132
133 fn user_prompt_text_of(message: &Message) -> Option<String> {
134 is_plain_user_text(message)
135 .then(|| user_text_of(message))
136 .flatten()
137 }
138
139 fn last_plain_user_index(messages: &[Message], end: usize) -> Option<usize> {
140 messages[..end]
141 .iter()
142 .enumerate()
143 .rev()
144 .find_map(|(idx, message)| is_plain_user_text(message).then_some(idx))
145 }
146
147 fn slice_has_tool_result(messages: &[Message], start: usize) -> bool {
148 messages[start..].iter().any(|message| {
149 message
150 .content
151 .iter()
152 .any(|block| matches!(block, ContentBlock::ToolResult { .. }))
153 })
154 }
155
156 #[must_use]
157 pub(crate) fn last_round_start(messages: &[Message]) -> usize {
158 let Some(last_user) = last_plain_user_index(messages, messages.len()) else {
159 return 0;
160 };
161 if slice_has_tool_result(messages, last_user) {
162 return last_user;
163 }
164 // Trailing toolless user/assistant turns still need the previous
165 // tool-bearing round; otherwise those results vanish behind the summary.
166 // If no tool round exists, keep only the latest user turn so chat-only
167 // sessions can still summarize older text.
168 let mut candidate = last_user;
169 loop {
170 let Some(prev) = last_plain_user_index(messages, candidate) else {
171 return last_user;
172 };
173 if slice_has_tool_result(messages, prev) {
174 return prev;
175 }
176 candidate = prev;
177 }
178 }
179
180 #[must_use]
181 pub(crate) fn last_round_range(messages: &[Message]) -> (usize, usize) {
182 let start = last_round_start(messages).min(messages.len());
183 // A previous checkpoint can sit in the middle of an uninterrupted task.
184 // Stopping at that marker hid every tool step after the first compact
185 // from the next pass's survival checks.
186 let end = messages.len().saturating_sub(usize::from(
187 messages
188 .last()
189 .is_some_and(is_compaction_checkpoint_message),
190 ));
191 (start, end)
192 }
193
194 /// How many messages of the open round sit in `messages` before a checkpoint.
195 #[must_use]
196 pub fn last_round_kept_count(messages: &[Message]) -> Option<usize> {
197 let checkpoint = messages
198 .iter()
199 .rposition(is_compaction_checkpoint_message)?;
200 if checkpoint == 0 {
201 return None;
202 }
203 let start = last_round_start(&messages[..checkpoint]);
204 Some(checkpoint.saturating_sub(start))
205 }
206
207 fn last_round_slice(messages: &[Message]) -> &[Message] {
208 let (start, end) = last_round_range(messages);
209 &messages[start..end]
210 }
211
212 /// Retain the current user instructions and the two most recent tool
213 /// exchanges. A user round can contain thousands of steps: retaining that
214 /// entire round forever makes a continuous task impossible to compact.
215 /// Older completed exchanges are covered by the summary and durable history.
216 /// A split is legal only when all preceding tool calls have their results.
217 fn protected_last_round(messages: &[Message]) -> Vec<&Message> {
218 let round = last_round_slice(messages);
219 let mut pending = HashSet::new();
220 let mut boundaries = Vec::new();
221 for (idx, message) in round.iter().enumerate() {
222 let calls = tool_use_ids(message);
223 if !calls.is_empty() && pending.is_empty() {
224 boundaries.push(idx);
225 }
226 pending.extend(calls);
227 for id in tool_result_ids(message) {
228 pending.remove(&id);
229 }
230 }
231 let start = if boundaries.len() > 2 {
232 boundaries[boundaries.len() - 2]
233 } else {
234 0
235 };
236 round
237 .iter()
238 .enumerate()
239 .filter_map(|(idx, message)| {
240 (!is_compaction_checkpoint_message(message)
241 && (idx >= start || is_plain_user_text(message)))
242 .then_some(message)
243 })
244 .collect()
245 }
246
247 pub(super) fn replacement_messages(
248 messages: &[Message],
249 retained_user_message_tokens: usize,
250 ) -> Vec<Message> {
251 let (start, _) = last_round_range(messages);
252 let mut retained = retained_user_messages(&messages[..start], retained_user_message_tokens);
253 let round = protected_last_round(messages)
254 .into_iter()
255 .cloned()
256 .collect::<Vec<_>>();
257 retained.extend(bound_last_round(&round));
258 // The Operate contract applies to the current tool loop as well as later
259 // turns. It must survive compaction even when old-user retention is full.
260 let current_contract = messages
261 .iter()
262 .rev()
263 .find(|message| crate::runtime_handoff::is_current_operate_contract_message(message));
264 let contract = current_contract.or_else(|| {
265 messages
266 .iter()
267 .rev()
268 .find(|message| crate::runtime_handoff::is_operate_contract_message(message))
269 });
270 if let Some(contract) = contract {
271 retained.retain(|message| !crate::runtime_handoff::is_operate_contract_message(message));
272 retained.insert(0, contract.clone());
273 }
274 // Contributions are captured once per turn. Preserve the complete latest
275 // snapshot, including a withdrawal, rather than summarizing or truncating
276 // instructions that still apply to this tool loop.
277 if let Some(snapshot) = messages.iter().rev().find(|message| {
278 crate::runtime_handoff::extension_prompt_contributions_display(message).is_some()
279 }) {
280 retained.retain(|message| {
281 crate::runtime_handoff::extension_prompt_contributions_display(message).is_none()
282 });
283 retained.insert(0, snapshot.clone());
284 }
285 retained
286 }
287
288 pub(super) fn bound_last_round(messages: &[Message]) -> Vec<Message> {
289 let mut round = messages.to_vec();
290 for message in &mut round {
291 for block in &mut message.content {
292 if let ContentBlock::ToolResult {
293 content,
294 content_blocks,
295 ..
296 } = block
297 && truncate_retained_block("tool result", content, LAST_ROUND_TOOL_RESULT_MAX_CHARS)
298 {
299 *content_blocks = None;
300 }
301 }
302 }
303 round
304 }
305
306 fn tool_result_ids(message: &Message) -> Vec<(codewhale_models::ToolCallKey<'_>, &str)> {
307 message
308 .content
309 .iter()
310 .filter_map(|block| match block {
311 ContentBlock::ToolResult { tool_use_id, .. } => {
312 block.tool_call_key().map(|key| (key, tool_use_id.as_str()))
313 }
314 _ => None,
315 })
316 .collect()
317 }
318
319 fn has_tool_result_id(message: &Message, id: &(codewhale_models::ToolCallKey<'_>, &str)) -> bool {
320 if id.0.as_str().trim().is_empty() {
321 return false;
322 }
323 message.content.iter().any(|block| {
324 matches!(
325 block,
326 ContentBlock::ToolResult { tool_use_id, .. } if block.tool_call_key() == Some(id.0) && tool_use_id == id.1
327 )
328 })
329 }
330
331 fn tool_use_ids(message: &Message) -> Vec<(codewhale_models::ToolCallKey<'_>, &str)> {
332 message
333 .content
334 .iter()
335 .filter_map(|block| match block {
336 ContentBlock::ToolUse { id, .. } => block.tool_call_key().map(|key| (key, id.as_str())),
337 _ => None,
338 })
339 .collect()
340 }
341
342 fn has_tool_use_id(message: &Message, id: &(codewhale_models::ToolCallKey<'_>, &str)) -> bool {
343 if id.0.as_str().trim().is_empty() {
344 return false;
345 }
346 message.content.iter().any(|block| {
347 matches!(
348 block,
349 ContentBlock::ToolUse { id: provider, .. } if block.tool_call_key() == Some(id.0) && provider == id.1
350 )
351 })
352 }
353
354 fn tool_call_identity_label(id: &(codewhale_models::ToolCallKey<'_>, &str)) -> String {
355 match id.0 {
356 codewhale_models::ToolCallKey::Execution(execution_id) => {
357 format!("execution {execution_id} (provider call {})", id.1)
358 }
359 codewhale_models::ToolCallKey::LegacyProvider(provider_id) => provider_id.to_string(),
360 }
361 }
362
363 fn assistant_text_of(message: &Message) -> Option<String> {
364 if !message.role.is_assistant_like() {
365 return None;
366 }
367 let text = message
368 .content
369 .iter()
370 .filter_map(|block| match block {
371 ContentBlock::Text { text, .. } => Some(text.as_str()),
372 _ => None,
373 })
374 .collect::<Vec<_>>()
375 .join("\n");
376 let text = text.trim();
377 (!text.is_empty()).then(|| text.to_string())
378 }
379
380 /// A retained copy may be truncated (`bound_last_round` caps oversized blocks),
381 /// so a prefix either way counts as survival -- but nothing weaker does.
382 fn survives(text: &str, replacement: &[Message], of: fn(&Message) -> Option<String>) -> bool {
383 replacement.iter().any(|message| {
384 of(message)
385 .is_some_and(|kept| kept == text || text.starts_with(&kept) || kept.starts_with(text))
386 })
387 }
388
389 pub(crate) fn validate_last_round_coverage(
390 original: &[Message],
391 replacement: &[Message],
392 ) -> Result<()> {
393 let last_round = protected_last_round(original);
394 if last_round.is_empty() {
395 return Ok(());
396 }
397 // Every user turn in the round, not the first one `find_map` happens to
398 // reach. `last_round_start` walks back past a toolless tail to the previous
399 // tool-bearing turn, so the round routinely spans two user messages -- and
400 // checking only the earliest let a rewrite drop the *latest* one, which is
401 // the turn this whole contract exists to keep.
402 for text in last_round.iter().copied().filter_map(user_prompt_text_of) {
403 if !survives(&text, replacement, user_prompt_text_of) {
404 anyhow::bail!(
405 "Making room stopped: a last-round user message was dropped; history was not replaced."
406 );
407 }
408 }
409 for id in last_round.iter().copied().flat_map(tool_result_ids) {
410 if !replacement
411 .iter()
412 .any(|message| has_tool_result_id(message, &id))
413 {
414 let label = tool_call_identity_label(&id);
415 anyhow::bail!(
416 "Making room stopped: last-round tool result {label} was dropped; history was not replaced."
417 );
418 }
419 }
420 // The call, not just its result. Keeping a tool_result whose tool_use was
421 // summarized away leaves an orphaned result that providers reject outright.
422 for id in last_round.iter().copied().flat_map(tool_use_ids) {
423 if !replacement
424 .iter()
425 .any(|message| has_tool_use_id(message, &id))
426 {
427 let label = tool_call_identity_label(&id);
428 anyhow::bail!(
429 "Making room stopped: last-round tool call {label} was dropped; history was not replaced."
430 );
431 }
432 }
433 // Match the assistant's actual output. An existential "some assistant
434 // message survived" check passed on a replacement whose only assistant
435 // message was the summary the rewrite had just written.
436 for text in last_round.iter().copied().filter_map(assistant_text_of) {
437 if !survives(&text, replacement, assistant_text_of) {
438 anyhow::bail!(
439 "Making room stopped: last-round assistant output was dropped; history was not replaced."
440 );
441 }
442 }
443 if last_round
444 .iter()
445 .any(|message| message.role.is_assistant_like())
446 && !replacement
447 .iter()
448 .any(|message| message.role.is_assistant_like())
449 {
450 anyhow::bail!(
451 "Making room stopped: last-round assistant output was dropped; history was not replaced."
452 );
453 }
454 Ok(())
455 }
456
457 pub(crate) fn require_text_survives(
458 replacement: &[Message],
459 needle: &str,
460 label: &str,
461 ) -> Result<()> {
462 let needle = needle.trim();
463 if needle.is_empty() {
464 return Ok(());
465 }
466 let kept = replacement.iter().any(|message| {
467 message.content.iter().any(|block| match block {
468 ContentBlock::Text { text, .. } => text.contains(needle),
469 ContentBlock::ToolResult { content, .. } => content.contains(needle),
470 _ => false,
471 })
472 });
473 if !kept {
474 anyhow::bail!("Making room stopped: {label} was dropped; history was not replaced.");
475 }
476 Ok(())
477 }
478
479 pub(crate) fn validate_survival_contract(
480 original: &[Message],
481 replacement: &[Message],
482 anchors: Option<&str>,
483 ) -> Result<()> {
484 validate_last_round_coverage(original, replacement)?;
485 let checkpoints = replacement
486 .iter()
487 .filter(|message| is_compaction_checkpoint_message(message))
488 .count();
489 if checkpoints == 0 {
490 anyhow::bail!(
491 "Making room stopped: checkpoint receipt was dropped; history was not replaced."
492 );
493 }
494 if checkpoints > 1 {
495 anyhow::bail!(
496 "Making room stopped: prior summaries were duplicated; history was not replaced."
497 );
498 }
499 if let Some(anchors) = anchors {
500 require_text_survives(replacement, anchors, "pinned /anchor text")?;
501 }
502 Ok(())
503 }
504
505 pub(super) fn measure_coverage(
506 original: &[Message],
507 replacement: &[Message],
508 path: CompactionPath,
509 anchors_chars: usize,
510 ) -> CompactionCoverage {
511 let last_round = last_round_slice(replacement);
512 CompactionCoverage {
513 path,
514 last_round_messages: last_round.len(),
515 last_round_tool_results: last_round.iter().flat_map(tool_result_ids).count(),
516 last_round_assistant: last_round
517 .iter()
518 .any(|message| message.role.is_assistant_like()),
519 dropped_messages: original.len().saturating_sub(replacement.len()),
520 anchors_chars,
521 // Tuning provenance is owned by the caller that holds the
522 // `CompactionConfig`; measurement over two message lists cannot know it.
523 retained_user_message_tokens: 0,
524 operator_instructions_applied: false,
525 }
526 }
527
528 /// Build the post-compaction history: recent plain user messages kept
529 /// verbatim within `retained_user_message_tokens`, the bounded last round, and
530 /// the checkpoint. The budget is `[compaction] retained_user_message_tokens`
531 /// (#5956); it was a hard-coded 20 000 before that key existed.
532 pub(super) fn build_replacement_history(
533 messages: &[Message],
534 checkpoint_text: &str,
535 anchors: Option<&str>,
536 retained_user_message_tokens: usize,
537 ) -> Result<Vec<Message>> {
538 let mut retained = replacement_messages(messages, retained_user_message_tokens);
539 retained.push(compaction_checkpoint_message(&SystemPrompt::Text(
540 checkpoint_text.to_string(),
541 )));
542 validate_survival_contract(messages, &retained, anchors)?;
543 Ok(retained)
544 }
545
546 #[cfg(test)]
547 mod tests {
548 use super::*;
549 use crate::compaction::{COMPACTION_SUMMARY_MARKER, compaction_checkpoint_message};
550 use codewhale_models::{ContentBlock, Role};
551 use serde_json::json;
552
553 #[test]
554 fn coverage_requires_the_original_execution_and_provider_pair() {
555 let original: Vec<Message> = serde_json::from_value(json!([
556 {"role":"user","content":[{"type":"text","text":"keep this exchange"}]},
557 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"local","name":"read","input":{}}]},
558 {"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"local","content":"kept"}]}
559 ])).unwrap();
560 assert!(validate_last_round_coverage(&original, &original).is_ok());
561 for identity in [None, Some("different")] {
562 let mut replacement = original.clone();
563 for block in replacement
564 .iter_mut()
565 .flat_map(|message| &mut message.content)
566 {
567 match block {
568 ContentBlock::ToolUse { execution_id, .. }
569 | ContentBlock::ToolResult { execution_id, .. } => {
570 *execution_id = identity.map(str::to_string)
571 }
572 _ => {}
573 }
574 }
575 assert!(validate_last_round_coverage(&original, &replacement).is_err());
576 }
577 let mut replacement = original.clone();
578 if let ContentBlock::ToolResult { tool_use_id, .. } = &mut replacement[2].content[0] {
579 *tool_use_id = "wrong-wire".to_string();
580 }
581 let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
582 assert_eq!(
583 error.to_string(),
584 "Making room stopped: last-round tool result execution local (provider call wire) was dropped; history was not replaced."
585 );
586 let mut replacement = original.clone();
587 if let ContentBlock::ToolUse { id, .. } = &mut replacement[1].content[0] {
588 *id = "wrong-wire".to_string();
589 }
590 let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
591 assert_eq!(
592 error.to_string(),
593 "Making room stopped: last-round tool call execution local (provider call wire) was dropped; history was not replaced."
594 );
595 }
596
597 #[test]
598 fn confined_pinned_anchors_require_workspace_trust() {
599 use crate::test_support::{EnvVarGuard, lock_test_env};
600 let _lock = lock_test_env();
601 let workspace = tempfile::tempdir().unwrap();
602 let config = tempfile::tempdir().unwrap();
603 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config.path().join("config.toml"));
604 let legacy = workspace.path().join(".deepseek");
605 std::fs::create_dir(&legacy).unwrap();
606 std::fs::write(legacy.join("anchors.md"), " legacy anchor \n").unwrap();
607 std::fs::write(legacy.join("trusted"), "true").unwrap();
608 assert_eq!(pinned_anchors_text(None), None);
609 assert_eq!(pinned_anchors_text(Some(workspace.path())), None);
610
611 crate::config::save_workspace_trust(workspace.path()).unwrap();
612 assert!(crate::config::is_workspace_trusted(workspace.path()));
613 assert_eq!(
614 pinned_anchors_text(Some(workspace.path())).as_deref(),
615 Some("legacy anchor")
616 );
617 let primary = workspace.path().join(".codewhale");
618 std::fs::create_dir(&primary).unwrap();
619 std::fs::write(primary.join("anchors.md"), " primary anchor \n").unwrap();
620 assert_eq!(
621 pinned_anchors_text(Some(workspace.path())).as_deref(),
622 Some("primary anchor")
623 );
624 std::fs::write(primary.join("anchors.md"), " \n").unwrap();
625 assert_eq!(pinned_anchors_text(Some(workspace.path())), None);
626 }
627
628 #[cfg(unix)]
629 #[test]
630 fn confined_pinned_anchors_refuse_linked_files_and_directories() {
631 use crate::test_support::{EnvVarGuard, lock_test_env};
632 use std::os::unix::fs::symlink;
633 let _lock = lock_test_env();
634 let config = tempfile::tempdir().unwrap();
635 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config.path().join("config.toml"));
636 for directory in [".codewhale", ".deepseek"] {
637 for linked_directory in [false, true] {
638 let workspace = tempfile::tempdir().unwrap();
639 let outside = tempfile::tempdir().unwrap();
640 crate::config::save_workspace_trust(workspace.path()).unwrap();
641 assert!(crate::config::is_workspace_trusted(workspace.path()));
642 let target = outside.path().join("anchors.md");
643 std::fs::write(&target, "separate anchor").unwrap();
644 let parent = workspace.path().join(directory);
645 if linked_directory {
646 symlink(outside.path(), &parent).unwrap();
647 } else {
648 std::fs::create_dir(&parent).unwrap();
649 symlink(&target, parent.join("anchors.md")).unwrap();
650 }
651 assert_eq!(pinned_anchors_text(Some(workspace.path())), None);
652 // A dangling preferred path must not select legacy content.
653 if directory == ".codewhale" {
654 let legacy = workspace.path().join(".deepseek");
655 std::fs::create_dir(&legacy).unwrap();
656 std::fs::write(legacy.join("anchors.md"), "legacy anchor").unwrap();
657 }
658 std::fs::remove_file(&target).unwrap();
659 assert_eq!(pinned_anchors_text(Some(workspace.path())), None);
660 }
661 }
662 }
663
664 fn msg(role: &str, text: &str) -> Message {
665 Message {
666 role: Role::from(role),
667 content: vec![ContentBlock::Text {
668 text: text.to_string(),
669 cache_control: None,
670 }],
671 }
672 }
673
674 fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
675 Message {
676 role: Role::Assistant,
677 content: vec![ContentBlock::ToolUse {
678 execution_id: None,
679 id: id.to_string(),
680 name: name.to_string(),
681 input,
682 caller: None,
683 thought_signature: None,
684 }],
685 }
686 }
687
688 fn tool_result(id: &str, content: &str) -> Message {
689 Message {
690 role: Role::User,
691 content: vec![ContentBlock::ToolResult {
692 execution_id: None,
693 tool_use_id: id.to_string(),
694 content: content.to_string(),
695 is_error: None,
696 content_blocks: None,
697 }],
698 }
699 }
700
701 fn checkpoint(summary: &str) -> Message {
702 compaction_checkpoint_message(&SystemPrompt::Text(format!(
703 "{COMPACTION_SUMMARY_MARKER}: {summary}"
704 )))
705 }
706
707 #[test]
708 fn replacement_keeps_only_latest_complete_prompt_snapshot_and_withdrawal() {
709 use crate::runtime_handoff::{
710 extension_prompt_contributions_display, extension_prompt_contributions_runtime_message,
711 };
712 let old = extension_prompt_contributions_runtime_message(Some("old instructions"));
713 let current_text = "current instructions ".repeat(400);
714 for current in [
715 extension_prompt_contributions_runtime_message(Some(&current_text)),
716 extension_prompt_contributions_runtime_message(None),
717 ] {
718 let quoted = msg(
719 "user",
720 &user_text_of(&current).expect("runtime snapshot has text"),
721 );
722 let original = vec![
723 old.clone(),
724 quoted.clone(),
725 current.clone(),
726 msg("user", "Continue this task."),
727 tool_use("first", "Read", json!({"path": "first"})),
728 tool_result("first", "first output"),
729 tool_use("second", "Read", json!({"path": "second"})),
730 tool_result("second", "second output"),
731 tool_use("third", "Read", json!({"path": "third"})),
732 tool_result("third", "third output"),
733 ];
734 let kept = replacement_messages(&original, 20_000);
735 let snapshots: Vec<_> = kept
736 .iter()
737 .filter(|message| extension_prompt_contributions_display(message).is_some())
738 .collect();
739 assert_eq!(snapshots, [&current]);
740 assert!(
741 kept.contains(&quoted),
742 "a person's quote is ordinary user text"
743 );
744 assert_eq!(
745 replacement_messages(&kept, 20_000)
746 .iter()
747 .filter(|message| extension_prompt_contributions_display(message).is_some())
748 .count(),
749 1,
750 "repeated compaction must not accumulate snapshots"
751 );
752 }
753 }
754
755 /// The handoff header tells the next turn what survived. It must match
756 /// what the replacement history keeps: only the last steps of a long
757 /// round, with long tool output shortened and marked.
758 #[test]
759 fn summary_header_matches_what_replacement_history_keeps() {
760 let long_output = "x".repeat(LAST_ROUND_TOOL_RESULT_MAX_CHARS * 2);
761 let original = vec![
762 msg("user", "Fix the build."),
763 tool_use("first", "Bash", json!({"command": "cargo check"})),
764 tool_result("first", "first step output"),
765 tool_use("second", "Bash", json!({"command": "cargo build"})),
766 tool_result("second", "second step output"),
767 tool_use("third", "Bash", json!({"command": "cargo test"})),
768 tool_result("third", &long_output),
769 ];
770 let kept = replacement_messages(&original, 20_000);
771 let kept_ids: Vec<&str> = kept
772 .iter()
773 .flat_map(tool_result_ids)
774 .map(|(_, wire)| wire)
775 .collect();
776 assert_eq!(kept_ids, ["second", "third"], "earlier steps are dropped");
777 assert!(
778 kept.iter()
779 .any(|m| user_text_of(m).as_deref() == Some("Fix the build."))
780 );
781 let shortened = kept
782 .iter()
783 .flat_map(|m| &m.content)
784 .find_map(|block| match block {
785 ContentBlock::ToolResult {
786 tool_use_id,
787 content,
788 ..
789 } if tool_use_id == "third" => Some(content.clone()),
790 _ => None,
791 })
792 .expect("the last tool result is kept");
793 assert!(shortened.len() < long_output.len());
794 assert!(shortened.starts_with("[tool result retained-history truncated from"));
795
796 let header = crate::compaction::SUMMARY_HEADER;
797 assert!(
798 header.contains("last steps of the current round"),
799 "{header}"
800 );
801 assert!(
802 header.contains("including earlier steps of this round"),
803 "{header}"
804 );
805 assert!(
806 header.contains("Long tool output there is shortened"),
807 "{header}"
808 );
809 assert!(
810 header.contains("with a marker where it was cut"),
811 "{header}"
812 );
813 assert!(!header.contains("as they were"), "{header}");
814 }
815
816 #[test]
817 fn coverage_floor_rejects_a_replacement_that_drops_last_round_tools() {
818 let original = vec![
819 msg("user", "Run the failing test."),
820 msg("assistant", "Running."),
821 tool_use("live", "Bash", json!({"command": "cargo test"})),
822 tool_result("live", "test session_store::roundtrip ... FAILED"),
823 ];
824 let gutting = vec![
825 msg("user", "Run the failing test."),
826 checkpoint("and kept going"),
827 ];
828 let error = validate_last_round_coverage(&original, &gutting)
829 .expect_err("dropping the last tool result must fail the coverage floor");
830 assert!(error.to_string().contains("tool result live"), "{error}");
831 assert!(validate_last_round_coverage(&original, &original).is_ok());
832 }
833
834 #[test]
835 fn coverage_floor_rejects_a_replacement_that_drops_last_round_assistant() {
836 let original = vec![
837 msg("user", "What failed?"),
838 msg("assistant", "session_store::roundtrip panics on reload."),
839 ];
840 let error = validate_last_round_coverage(&original, &[msg("user", "What failed?")])
841 .expect_err("dropping last-round assistant text must fail closed");
842 assert!(error.to_string().contains("assistant"), "{error}");
843 }
844
845 /// The round spans the tool-bearing turn *and* the toolless tail after it,
846 /// because `last_round_start` walks back for the tools. Checking only the
847 /// first user text it found meant a rewrite could keep the older question
848 /// and drop the one the person actually just asked.
849 #[test]
850 fn coverage_floor_rejects_a_replacement_that_drops_the_latest_user_turn() {
851 let original = vec![
852 msg("user", "Run the suite."),
853 msg("assistant", "Running."),
854 tool_use("live", "Bash", json!({"command": "cargo test"})),
855 tool_result("live", "ok"),
856 msg("user", "Now ship it."),
857 msg("assistant", "Shipping."),
858 ];
859 assert_eq!(last_round_start(&original), 0, "round must span both turns");
860
861 let drops_latest = vec![
862 msg("user", "Run the suite."),
863 msg("assistant", "Running."),
864 tool_use("live", "Bash", json!({"command": "cargo test"})),
865 tool_result("live", "ok"),
866 checkpoint("then shipped"),
867 ];
868 let error = validate_last_round_coverage(&original, &drops_latest)
869 .expect_err("dropping the latest user turn must fail the coverage floor");
870 assert!(error.to_string().contains("user message"), "{error}");
871 assert!(validate_last_round_coverage(&original, &original).is_ok());
872 }
873
874 /// A surviving `tool_result` whose `tool_use` was summarized away is an
875 /// orphan the provider rejects, so the floor must cover the call too.
876 #[test]
877 fn coverage_floor_rejects_a_replacement_that_drops_the_tool_call() {
878 let original = vec![
879 msg("user", "Run the failing test."),
880 msg("assistant", "Running."),
881 tool_use("live", "Bash", json!({"command": "cargo test"})),
882 tool_result("live", "FAILED"),
883 ];
884 let orphaned = vec![
885 msg("user", "Run the failing test."),
886 msg("assistant", "Running."),
887 tool_result("live", "FAILED"),
888 checkpoint("and it failed"),
889 ];
890 let error = validate_last_round_coverage(&original, &orphaned)
891 .expect_err("dropping the tool call must fail the coverage floor");
892 assert!(error.to_string().contains("tool call live"), "{error}");
893 }
894
895 /// "Some assistant message survived" was satisfied by the summary the
896 /// rewrite had just written, so the round's real output could vanish.
897 #[test]
898 fn coverage_floor_rejects_assistant_output_replaced_by_a_summary() {
899 let original = vec![
900 msg("user", "What failed?"),
901 msg("assistant", "session_store::roundtrip panics on reload."),
902 ];
903 let summarized = vec![
904 msg("user", "What failed?"),
905 msg("assistant", "Earlier we discussed several test failures."),
906 ];
907 let error = validate_last_round_coverage(&original, &summarized)
908 .expect_err("substituting a summary for the round's output must fail closed");
909 assert!(error.to_string().contains("assistant"), "{error}");
910 }
911
912 #[test]
913 fn survival_contract_rejects_dropped_anchors_and_receipts() {
914 let original = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
915 let without_receipt = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
916 let error = validate_survival_contract(&original, &without_receipt, Some("ship 0.9.12"))
917 .expect_err("missing checkpoint receipt must fail closed");
918 assert!(error.to_string().contains("receipt"), "{error}");
919
920 let without_anchor = vec![
921 msg("user", "Keep the pin."),
922 msg("assistant", "Anchored."),
923 checkpoint("progress without the pin"),
924 ];
925 let error = validate_survival_contract(&original, &without_anchor, Some("ship 0.9.12"))
926 .expect_err("dropped /anchor text must fail closed");
927 assert!(error.to_string().contains("anchor"), "{error}");
928 }
929
930 #[test]
931 fn last_round_starts_at_the_latest_plain_user_message() {
932 let messages = vec![
933 msg("user", "older"),
934 msg("assistant", "working"),
935 tool_result("old", "stale"),
936 msg("user", "Run the suite now."),
937 msg("assistant", "Rerunning."),
938 tool_use("live", "Bash", json!({"command": "cargo test"})),
939 tool_result("live", "ok"),
940 ];
941 assert_eq!(last_round_start(&messages), 3); // last user with tools
942 let (start, end) = last_round_range(&messages);
943 let kept = bound_last_round(&messages[start..end]);
944 assert!(kept.iter().any(|message| {
945 message.content.iter().any(|block| {
946 matches!(
947 block,
948 ContentBlock::ToolResult { tool_use_id, content, .. }
949 if tool_use_id == "live" && content == "ok"
950 )
951 })
952 }));
953 }
954
955 #[test]
956 fn second_compaction_keeps_long_user_question_and_tool_pair_past_retention_budget() {
957 const PRODUCTION_MIN_RETAINED_TOKENS: usize = 2_000;
958 let long_question = format!(
959 "{}?",
960 "Analyze every step of this case carefully. ".repeat(400)
961 );
962 assert!(long_question.len() > PRODUCTION_MIN_RETAINED_TOKENS * 3);
963 let original = vec![
964 msg("user", &long_question),
965 tool_use("call_1", "Bash", json!({"command": "echo ready"})),
966 tool_result("call_1", "ready"),
967 ];
968 let first_summary =
969 crate::compaction::build_compaction_summary_block_text("First pass complete", "");
970 let mut first = build_replacement_history(
971 &original,
972 &first_summary,
973 None,
974 PRODUCTION_MIN_RETAINED_TOKENS,
975 )
976 .expect("first compaction");
977 crate::runtime_handoff::replace_agent_topology_checkpoint(&mut first, &[]);
978 assert_eq!(last_round_start(&first), 0);
979 let topology = first
980 .iter()
981 .find(|message| crate::runtime_handoff::is_agent_topology_checkpoint(message))
982 .expect("first compaction topology checkpoint")
983 .clone();
984 assert!(
985 crate::compaction::retained_user_messages(
986 std::slice::from_ref(&topology),
987 PRODUCTION_MIN_RETAINED_TOKENS,
988 )
989 .is_empty(),
990 "runtime topology must not consume the older-user retention budget"
991 );
992
993 let second_summary =
994 crate::compaction::build_compaction_summary_block_text("Second pass complete", "");
995 let second = build_replacement_history(
996 &first,
997 &second_summary,
998 None,
999 PRODUCTION_MIN_RETAINED_TOKENS,
1000 )
1001 .expect("second compaction");
1002 assert!(
1003 second.iter().any(|message| {
1004 user_text_of(message).as_deref() == Some(long_question.as_str())
1005 })
1006 );
1007 assert!(second.iter().any(|message| has_tool_use_id(
1008 message,
1009 &(
1010 codewhale_models::ToolCallKey::LegacyProvider("call_1"),
1011 "call_1"
1012 )
1013 )));
1014 assert!(second.iter().any(|message| has_tool_result_id(
1015 message,
1016 &(
1017 codewhale_models::ToolCallKey::LegacyProvider("call_1"),
1018 "call_1"
1019 )
1020 )));
1021 let without_question = second
1022 .iter()
1023 .filter(|message| user_text_of(message).as_deref() != Some(long_question.as_str()))
1024 .cloned()
1025 .collect::<Vec<_>>();
1026 assert!(validate_last_round_coverage(&first, &without_question).is_err());
1027 }
1028
1029 #[test]
1030 fn runtime_text_cannot_satisfy_real_user_coverage() {
1031 let runtime = crate::runtime_handoff::operate_contract_runtime_message();
1032 let copied_text = user_text_of(&runtime).expect("runtime text");
1033 let original = vec![msg("user", &copied_text), msg("assistant", "Acknowledged")];
1034 let replacement = vec![runtime, msg("assistant", "Acknowledged")];
1035 assert!(
1036 validate_last_round_coverage(&original, &replacement).is_err(),
1037 "runtime-owned text must not stand in for the user's actual prompt"
1038 );
1039 }
1040
1041 #[test]
1042 fn operate_contract_survives_compaction_without_spending_user_budget() {
1043 let contract = crate::runtime_handoff::operate_contract_runtime_message();
1044 let original = vec![
1045 contract.clone(),
1046 msg("user", "First task"),
1047 msg("assistant", "Working"),
1048 msg("user", "Continue the same task"),
1049 msg("assistant", "Continuing"),
1050 ];
1051 let replaced = build_replacement_history(
1052 &original,
1053 &format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
1054 None,
1055 1,
1056 )
1057 .expect("compaction must retain the active Operate contract");
1058 assert_eq!(replaced.first(), Some(&contract));
1059 assert_eq!(
1060 replaced
1061 .iter()
1062 .filter(|message| **message == contract)
1063 .count(),
1064 1
1065 );
1066 }
1067
1068 #[test]
1069 fn compaction_prefers_current_operate_contract_over_legacy() {
1070 let legacy = crate::runtime_handoff::legacy_operate_contract_runtime_message();
1071 let current = crate::runtime_handoff::operate_contract_runtime_message();
1072 let original = vec![
1073 legacy.clone(),
1074 current.clone(),
1075 msg("user", "Continue"),
1076 msg("assistant", "Working"),
1077 ];
1078 let replaced = build_replacement_history(
1079 &original,
1080 &format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
1081 None,
1082 1,
1083 )
1084 .expect("current contract must survive compaction");
1085 assert_eq!(replaced.first(), Some(&current));
1086 assert!(!replaced.contains(&legacy));
1087 assert_eq!(
1088 replaced
1089 .iter()
1090 .filter(|message| crate::runtime_handoff::is_operate_contract_message(message))
1091 .count(),
1092 1
1093 );
1094 }
1095
1096 #[test]
1097 fn last_round_walks_back_through_toolless_tails_to_the_tool_round() {
1098 let original = vec![
1099 msg("user", "Run the failing test."),
1100 msg("assistant", "Running."),
1101 tool_use("live", "Bash", json!({"command": "cargo test"})),
1102 tool_result("live", "test session_store::roundtrip ... FAILED"),
1103 msg("user", "ok thanks"),
1104 msg("assistant", "you're welcome"),
1105 msg("user", "one more thing"),
1106 msg("assistant", "sure"),
1107 ];
1108 assert_eq!(last_round_start(&original), 0);
1109 let next = format!("{COMPACTION_SUMMARY_MARKER}: keep the failing test result");
1110 let replaced = build_replacement_history(
1111 &original,
1112 &next,
1113 None,
1114 crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
1115 )
1116 .expect("toolless tails must not drop the last tool result");
1117 assert!(replaced.iter().any(|message| {
1118 message.content.iter().any(|block| {
1119 matches!(
1120 block,
1121 ContentBlock::ToolResult { tool_use_id, content, .. }
1122 if tool_use_id == "live" && content.contains("FAILED")
1123 )
1124 })
1125 }));
1126 }
1127
1128 #[test]
1129 fn chat_only_history_keeps_the_latest_user_round() {
1130 let messages = vec![
1131 msg("user", "hello"),
1132 msg("assistant", "hi"),
1133 msg("user", "how are you"),
1134 msg("assistant", "fine"),
1135 ];
1136 assert_eq!(last_round_start(&messages), 2);
1137 }
1138
1139 #[derive(serde::Deserialize)]
1140 struct FixtureMatrix {
1141 schema_version: u32,
1142 cases: Vec<FixtureCase>,
1143 }
1144
1145 #[derive(serde::Deserialize)]
1146 struct FixtureCase {
1147 id: String,
1148 expect: String,
1149 #[serde(default)]
1150 anchors: Option<String>,
1151 original: Vec<Message>,
1152 replacement: Vec<Message>,
1153 #[serde(default)]
1154 last_round_start: Option<usize>,
1155 }
1156
1157 #[test]
1158 fn fixture_matrix_enforces_survival_contract() {
1159 let matrix: FixtureMatrix =
1160 serde_json::from_str(include_str!("fixtures/matrix.json")).expect("matrix.json");
1161 assert_eq!(matrix.schema_version, 2);
1162 assert!(
1163 matrix.cases.len() >= 8,
1164 "fixture matrix must cover last-round, toolless-tail, chat-only, anchor, and receipt cases"
1165 );
1166 for case in &matrix.cases {
1167 if let Some(start) = case.last_round_start {
1168 assert_eq!(
1169 last_round_start(&case.original),
1170 start,
1171 "{} last_round_start",
1172 case.id
1173 );
1174 }
1175 let result = validate_survival_contract(
1176 &case.original,
1177 &case.replacement,
1178 case.anchors.as_deref(),
1179 );
1180 match case.expect.as_str() {
1181 "pass" => {
1182 result.unwrap_or_else(|error| panic!("{} should pass: {error}", case.id));
1183 }
1184 "fail" => {
1185 result.expect_err(&format!("{} should fail closed", case.id));
1186 }
1187 other => panic!("{}: unknown expect {other}", case.id),
1188 }
1189 }
1190 }
1191
1192 #[test]
1193 fn second_compact_does_not_duplicate_prior_summaries() {
1194 let first = vec![
1195 msg("user", "older"),
1196 msg("user", "Run the suite now."),
1197 msg("assistant", "Rerunning."),
1198 tool_use("live", "Bash", json!({"command": "cargo test"})),
1199 tool_result("live", "ok"),
1200 checkpoint("first handoff: suite still running"),
1201 ];
1202 let next = format!(
1203 "{COMPACTION_SUMMARY_MARKER}: second handoff with User-pinned anchors (verbatim):\nship 0.9.12"
1204 );
1205 let replaced = build_replacement_history(
1206 &first,
1207 &next,
1208 Some("ship 0.9.12"),
1209 crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
1210 )
1211 .expect("second compact must keep last round and one receipt");
1212 let checkpoints = replaced
1213 .iter()
1214 .filter(|message| is_compaction_checkpoint_message(message))
1215 .count();
1216 assert_eq!(checkpoints, 1, "{replaced:?}");
1217 assert!(replaced.iter().any(|message| {
1218 message.content.iter().any(|block| {
1219 matches!(
1220 block,
1221 ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "live"
1222 )
1223 })
1224 }));
1225 require_text_survives(&replaced, "ship 0.9.12", "pinned /anchor text").unwrap();
1226 }
1227 }
1228
1228 lines RUST