From 6c4a293ff6283eb6aafa1afd069c39a9b67013e2 Mon Sep 17 00:00:00 2001 From: asto Date: Sun, 18 Oct 2026 04:56:54 +0800 Subject: [PATCH] fix(compaction): scope legacy checkpoint recognition by era MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore lane already prefers the provenance-stamped carrier, but the recompaction family still ran the loose predicate, which matches any user turn whose text merely starts with a legacy summary header. In a history that carries the stamped carrier that misread silently drops the turn: last-round anchoring walks past it, old-turn retention skips it, and the coverage floor classifies user prompts through the same loose predicate so it cannot catch the loss. The survival contract meanwhile counts the retained turn as a duplicated summary, so a replacement that does keep the turn fails closed. Recognition in the recompaction family is now era-scoped through history_is_post_provenance: a history that contains a stamped carrier recognizes only stamped carriers as checkpoints, so a pasted bare-header turn anchors the round, is retained verbatim, and does not count as a duplicated summary. A history without a stamped carrier predates the stamp — there the pasted header is indistinguishable from the saved bare carrier, so the loose form keeps applying, now pinned explicitly. The receipt count reads the replacement in its own era, except that a post-provenance original raises the bar to a stamped carrier: the engine stamps every replacement it builds, and the stamp is the only form request rewriting relocates. Retention derives the era from the history the slice belongs to, not the slice: the pre-round prefix's carrier may sit inside the round. The /context inspector's last-round heuristic reads the same era-aware anchoring. validate_survival_contract.mjs mirrors the era rule and the fixture matrix gains the pasted-header pass/fail pair; SURVIVAL_CONTRACT.md documents the raised receipt bar and the era signal (the history's content, not the session's age — one-way and destructible). Pinned by the pasted-turn survival/retention tests, the legacy bare-header boundary test, and the two new fixture-matrix cases. Signed-off-by: asto --- crates/tui/src/compaction.rs | 48 ++++- .../tui/src/compaction/SURVIVAL_CONTRACT.md | 11 ++ .../tui/src/compaction/fixtures/matrix.json | 5 +- crates/tui/src/compaction/last_round.rs | 177 +++++++++++++++--- .../compaction/validate_survival_contract.mjs | 57 ++++-- 5 files changed, 260 insertions(+), 38 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 1b27b6c8f0..fb953bef57 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -512,6 +512,36 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { || user_text_of(message).is_some_and(|text| is_legacy_compaction_summary_text(&text)) } +/// Whether this history already carries a provenance-stamped checkpoint. +/// +/// A stamped carrier postdates stamping, so within that history only the +/// stamped form is a checkpoint: a user turn whose text merely starts with a +/// legacy summary header (a pasted transcript, for example) is content. A +/// history without one predates the stamp — there the pasted header is +/// indistinguishable from the saved bare carrier, so the loose form keeps +/// applying. The signal is the history's content, not the session's age: it +/// is one-way and destructible (`/edit` or a host rewind can truncate away +/// the only stamped carrier), and typed input cannot forge the two-block +/// form. +#[must_use] +pub(crate) fn history_is_post_provenance(messages: &[Message]) -> bool { + messages.iter().any(is_wire_compaction_checkpoint_message) +} + +/// Era-aware checkpoint recognition for the recompaction family: the loose +/// legacy fallback applies only in a history with no stamped carrier. +#[must_use] +pub(crate) fn is_compaction_checkpoint_message_in_era( + message: &Message, + post_provenance: bool, +) -> bool { + if post_provenance { + is_wire_compaction_checkpoint_message(message) + } else { + is_compaction_checkpoint_message(message) + } +} + /// Request-time recognition: exactly the header text block plus the /// provenance block. User text merely quoting a marker keeps its original /// wire position. Checkpoints saved before the handoff note still pass, so @@ -1540,7 +1570,23 @@ fn is_orphaned_result_block(block: &ContentBlock) -> bool { ) } +/// Test convenience: derive the era from the slice itself. Production +/// callers pass [`retained_user_messages_in_era`] the era of the history the +/// slice belongs to — retention runs on the pre-round prefix, whose carrier +/// may sit inside the round. +#[cfg(test)] pub(crate) fn retained_user_messages(messages: &[Message], max_tokens: usize) -> Vec { + retained_user_messages_in_era(history_is_post_provenance(messages), messages, max_tokens) +} + +/// `post_provenance` must be derived from the history the slice belongs to, +/// not from the slice itself: retention runs on the pre-round prefix, whose +/// carrier may sit inside the round. +pub(crate) fn retained_user_messages_in_era( + post_provenance: bool, + messages: &[Message], + max_tokens: usize, +) -> Vec { let mut selected: Vec = Vec::new(); let mut remaining = max_tokens; for msg in messages.iter().rev() { @@ -1557,7 +1603,7 @@ pub(crate) fn retained_user_messages(messages: &[Message], max_tokens: usize) -> { continue; } - if is_compaction_checkpoint_message(msg) { + if is_compaction_checkpoint_message_in_era(msg, post_provenance) { continue; } let tokens: usize = msg diff --git a/crates/tui/src/compaction/SURVIVAL_CONTRACT.md b/crates/tui/src/compaction/SURVIVAL_CONTRACT.md index 7c871dabbc..c2d635275a 100644 --- a/crates/tui/src/compaction/SURVIVAL_CONTRACT.md +++ b/crates/tui/src/compaction/SURVIVAL_CONTRACT.md @@ -87,6 +87,17 @@ Rust now: - `validate_survival_contract` also refuses a missing checkpoint receipt, duplicated prior summaries, dropped `/anchor` text, or a placeholder-only checkpoint. +- Checkpoint recognition in the recompaction family is era-scoped. A history + that already carries a provenance-stamped carrier (header block plus the + provenance block) recognizes only stamped carriers as checkpoints, so a + user turn that pastes a bare legacy summary header stays content: it + anchors the round, is retained verbatim, and does not count as a duplicated + summary. A history without a stamped carrier predates stamping — there the + pasted header is indistinguishable from the saved bare carrier, so the + loose legacy matching keeps applying. The receipt count reads the + replacement in its own era, except that a post-provenance original raises + the bar to a stamped carrier: the engine stamps every replacement it + builds, and the stamp is the only form request rewriting relocates. - `compact_messages_safe` never mutates the caller's live history; the host commits only after `Ok`. diff --git a/crates/tui/src/compaction/fixtures/matrix.json b/crates/tui/src/compaction/fixtures/matrix.json index 5971652068..d0f5fcc17e 100644 --- a/crates/tui/src/compaction/fixtures/matrix.json +++ b/crates/tui/src/compaction/fixtures/matrix.json @@ -163,6 +163,9 @@ {"id":"continuous-round-must-keep-post-checkpoint-results","expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]}, {"id":"continuous-round-must-keep-tool-pairs","expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]}, {"id":"legacy-v2-checkpoint-replaced-by-handoff-note","expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nfirst pass"},{"type":"text","text":""}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":""}]}]}, - {"id":"quoted-handoff-marker-is-user-text","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"What is in the Codewhale handoff note?"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":""}]}]} + {"id":"quoted-handoff-marker-is-user-text","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"What is in the Codewhale handoff note?"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":""}]}]}, + {"id":"pasted-legacy-header-survives-post-provenance","expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":""}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":""}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}]}, + {"id":"pasted-legacy-header-dropped-post-provenance","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":""}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":""}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]}, + {"id":"pasted-modern-header-dropped-post-provenance","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":""}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":""}]},{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]} ] } diff --git a/crates/tui/src/compaction/last_round.rs b/crates/tui/src/compaction/last_round.rs index ac6e5f3936..5bdce5147d 100644 --- a/crates/tui/src/compaction/last_round.rs +++ b/crates/tui/src/compaction/last_round.rs @@ -10,7 +10,8 @@ use std::collections::HashSet; use codewhale_models::{ContentBlock, Message, SystemPrompt}; use super::{ - compaction_checkpoint_message, is_compaction_checkpoint_message, retained_user_messages, + compaction_checkpoint_message, history_is_post_provenance, is_compaction_checkpoint_message, + is_compaction_checkpoint_message_in_era, retained_user_messages_in_era, truncate_retained_block, user_text_of, }; @@ -95,6 +96,8 @@ pub struct CompactionKeep { pub fn inspect_compaction_keep(messages: &[Message]) -> CompactionKeep { let last_round = last_round_slice(messages); CompactionKeep { + // Existence check: a stamped carrier also matches the loose form, so + // "any checkpoint" reads the same in both eras and needs no era here. has_checkpoint: messages.iter().any(is_compaction_checkpoint_message), last_round_messages: last_round.len(), last_round_tool_results: last_round.iter().flat_map(tool_result_ids).count(), @@ -124,24 +127,24 @@ pub fn pinned_anchors_text(workspace: Option<&std::path::Path>) -> Option bool { - !is_compaction_checkpoint_message(message) +fn is_plain_user_text(post_provenance: bool, message: &Message) -> bool { + !is_compaction_checkpoint_message_in_era(message, post_provenance) && !crate::runtime_handoff::is_runtime_owned_user_message(message) && user_text_of(message).is_some() } -fn user_prompt_text_of(message: &Message) -> Option { - is_plain_user_text(message) +fn user_prompt_text_of(post_provenance: bool, message: &Message) -> Option { + is_plain_user_text(post_provenance, message) .then(|| user_text_of(message)) .flatten() } -fn last_plain_user_index(messages: &[Message], end: usize) -> Option { +fn last_plain_user_index(messages: &[Message], end: usize, post_provenance: bool) -> Option { messages[..end] .iter() .enumerate() .rev() - .find_map(|(idx, message)| is_plain_user_text(message).then_some(idx)) + .find_map(|(idx, message)| is_plain_user_text(post_provenance, message).then_some(idx)) } fn slice_has_tool_result(messages: &[Message], start: usize) -> bool { @@ -155,7 +158,15 @@ fn slice_has_tool_result(messages: &[Message], start: usize) -> bool { #[must_use] pub(crate) fn last_round_start(messages: &[Message]) -> usize { - let Some(last_user) = last_plain_user_index(messages, messages.len()) else { + last_round_start_in_era(messages, history_is_post_provenance(messages)) +} + +/// Era-aware core of [`last_round_start`]. The era belongs to the history the +/// slice is cut from: `last_round_kept_count` reads a prefix of a longer +/// history whose carrier may sit after the cut, so it passes the full +/// history's era down instead of letting this re-derive one from the prefix. +fn last_round_start_in_era(messages: &[Message], post_provenance: bool) -> usize { + let Some(last_user) = last_plain_user_index(messages, messages.len(), post_provenance) else { return 0; }; if slice_has_tool_result(messages, last_user) { @@ -167,7 +178,7 @@ pub(crate) fn last_round_start(messages: &[Message]) -> usize { // sessions can still summarize older text. let mut candidate = last_user; loop { - let Some(prev) = last_plain_user_index(messages, candidate) else { + let Some(prev) = last_plain_user_index(messages, candidate, post_provenance) else { return last_user; }; if slice_has_tool_result(messages, prev) { @@ -183,24 +194,30 @@ pub(crate) fn last_round_range(messages: &[Message]) -> (usize, usize) { // A previous checkpoint can sit in the middle of an uninterrupted task. // Stopping at that marker hid every tool step after the first compact // from the next pass's survival checks. - let end = messages.len().saturating_sub(usize::from( + let post_provenance = history_is_post_provenance(messages); + let end = messages - .last() - .is_some_and(is_compaction_checkpoint_message), - )); + .len() + .saturating_sub(usize::from(messages.last().is_some_and(|message| { + is_compaction_checkpoint_message_in_era(message, post_provenance) + }))); (start, end) } /// How many messages of the open round sit in `messages` before a checkpoint. #[must_use] pub fn last_round_kept_count(messages: &[Message]) -> Option { + let post_provenance = history_is_post_provenance(messages); let checkpoint = messages .iter() - .rposition(is_compaction_checkpoint_message)?; + .rposition(|message| is_compaction_checkpoint_message_in_era(message, post_provenance))?; if checkpoint == 0 { return None; } - let start = last_round_start(&messages[..checkpoint]); + // The prefix belongs to the full history: recognize its turns with the + // full history's era, not one re-derived from the prefix (whose carrier + // may sit after the cut). + let start = last_round_start_in_era(&messages[..checkpoint], post_provenance); Some(checkpoint.saturating_sub(start)) } @@ -215,6 +232,7 @@ fn last_round_slice(messages: &[Message]) -> &[Message] { /// Older completed exchanges are covered by the summary and durable history. /// A split is legal only when all preceding tool calls have their results. fn protected_last_round(messages: &[Message]) -> Vec<&Message> { + let post_provenance = history_is_post_provenance(messages); let round = last_round_slice(messages); let mut pending = HashSet::new(); let mut boundaries = Vec::new(); @@ -237,8 +255,8 @@ fn protected_last_round(messages: &[Message]) -> Vec<&Message> { .iter() .enumerate() .filter_map(|(idx, message)| { - (!is_compaction_checkpoint_message(message) - && (idx >= start || is_plain_user_text(message))) + (!is_compaction_checkpoint_message_in_era(message, post_provenance) + && (idx >= start || is_plain_user_text(post_provenance, message))) .then_some(message) }) .collect() @@ -248,8 +266,13 @@ pub(super) fn replacement_messages( messages: &[Message], retained_user_message_tokens: usize, ) -> Vec { + let post_provenance = history_is_post_provenance(messages); let (start, _) = last_round_range(messages); - let mut retained = retained_user_messages(&messages[..start], retained_user_message_tokens); + let mut retained = retained_user_messages_in_era( + post_provenance, + &messages[..start], + retained_user_message_tokens, + ); let round = protected_last_round(messages) .into_iter() .cloned() @@ -387,7 +410,7 @@ fn assistant_text_of(message: &Message) -> Option { /// A retained copy may be truncated (`bound_last_round` caps oversized blocks), /// so a prefix either way counts as survival -- but nothing weaker does. -fn survives(text: &str, replacement: &[Message], of: fn(&Message) -> Option) -> bool { +fn survives(text: &str, replacement: &[Message], of: impl Fn(&Message) -> Option) -> bool { replacement.iter().any(|message| { of(message) .is_some_and(|kept| kept == text || text.starts_with(&kept) || kept.starts_with(text)) @@ -398,6 +421,7 @@ pub(crate) fn validate_last_round_coverage( original: &[Message], replacement: &[Message], ) -> Result<()> { + let post_provenance = history_is_post_provenance(original); let last_round = protected_last_round(original); if last_round.is_empty() { return Ok(()); @@ -407,8 +431,14 @@ pub(crate) fn validate_last_round_coverage( // tool-bearing turn, so the round routinely spans two user messages -- and // checking only the earliest let a rewrite drop the *latest* one, which is // the turn this whole contract exists to keep. - for text in last_round.iter().copied().filter_map(user_prompt_text_of) { - if !survives(&text, replacement, user_prompt_text_of) { + for text in last_round + .iter() + .copied() + .filter_map(|message| user_prompt_text_of(post_provenance, message)) + { + if !survives(&text, replacement, |message| { + user_prompt_text_of(post_provenance, message) + }) { anyhow::bail!( "Making room stopped: a last-round user message was dropped; history was not replaced." ); @@ -490,9 +520,23 @@ pub(crate) fn validate_survival_contract( anchors: Option<&str>, ) -> Result<()> { validate_last_round_coverage(original, replacement)?; + // The receipt count reads the replacement in its own era: a replacement + // carrying the engine-stamped receipt is modern, so retained turns that + // paste a bare header do not count as duplicated summaries, while a + // legacy-shaped replacement keeps the loose count that the fixture + // matrix pins. A post-provenance *original* raises the bar instead: the + // engine stamps every replacement it builds, and the stamped carrier is + // the only form request rewriting relocates, so a replacement of a + // modern history without a stamp is a dropped receipt even where a + // retained pasted turn would loosely count as one. + let receipt_recognition = if history_is_post_provenance(original) { + true + } else { + history_is_post_provenance(replacement) + }; let checkpoints = replacement .iter() - .filter(|message| is_compaction_checkpoint_message(message)) + .filter(|message| is_compaction_checkpoint_message_in_era(message, receipt_recognition)) .count(); if checkpoints == 0 { anyhow::bail!( @@ -554,7 +598,10 @@ pub(super) fn build_replacement_history( #[cfg(test)] mod tests { use super::*; - use crate::compaction::{COMPACTION_SUMMARY_MARKER, compaction_checkpoint_message}; + use crate::compaction::{ + COMPACTION_SUMMARY_MARKER, LEGACY_V2_COMPACTION_SUMMARY_MARKER, + compaction_checkpoint_message, + }; use codewhale_models::{ContentBlock, Role}; use serde_json::json; @@ -1255,6 +1302,90 @@ mod tests { assert_eq!(last_round_start(&messages), 2); } + /// In a history that carries the stamped carrier, a user turn pasting a + /// bare legacy summary header is content: it anchors the round, survives + /// the replacement, and the receipt count stays 1 instead of rejecting + /// the rewrite as a duplicated summary. + #[test] + fn pasted_legacy_header_turn_is_content_in_a_post_provenance_history() { + let pasted = format!( + "{LEGACY_V2_COMPACTION_SUMMARY_MARKER}\n\nEarlier turns of another session, pasted." + ); + let original = vec![ + msg("user", "Run the suite now."), + tool_use("live", "Bash", json!({"command": "cargo test"})), + tool_result("live", "ok"), + checkpoint("suite finished"), + msg("assistant", "done"), + msg("user", &pasted), + ]; + assert!( + !history_is_post_provenance(&[msg("user", &pasted)]), + "the pasted turn alone carries no provenance block" + ); + assert_eq!( + last_round_start(&original), + 0, + "the pasted turn must anchor like any other user turn" + ); + + let retained = + retained_user_messages_in_era(history_is_post_provenance(&original), &original, 10_000); + assert!( + retained + .iter() + .any(|message| user_text_of(message).as_deref() == Some(pasted.as_str())), + "the pasted turn must be retained verbatim: {retained:?}" + ); + + let replacement = vec![ + checkpoint("second summary"), + msg("user", "Run the suite now."), + tool_use("live", "Bash", json!({"command": "cargo test"})), + tool_result("live", "ok"), + msg("assistant", "done"), + msg("user", &pasted), + ]; + validate_survival_contract(&original, &replacement, None) + .expect("a retained pasted-header turn must not break or lose the round"); + + let mut dropped = replacement.clone(); + dropped.pop(); + let error = validate_survival_contract(&original, &dropped, None) + .expect_err("dropping the pasted turn must fail the coverage floor"); + assert!( + error.to_string().contains("user message was dropped"), + "{error}" + ); + } + + /// A history without a stamped carrier predates stamping: the bare + /// header there is indistinguishable from the saved carrier, so the + /// loose recognition keeps applying. + #[test] + fn legacy_history_still_recognizes_a_bare_header_checkpoint() { + let bare = msg( + "user", + &format!("{LEGACY_V2_COMPACTION_SUMMARY_MARKER}\n\nCondensed progress."), + ); + assert!(!history_is_post_provenance(std::slice::from_ref(&bare))); + assert!( + retained_user_messages_in_era( + history_is_post_provenance(std::slice::from_ref(&bare)), + std::slice::from_ref(&bare), + 1_000, + ) + .is_empty(), + "a bare-header legacy checkpoint must still not be retained as user text" + ); + let messages = vec![msg("user", "older"), bare, msg("assistant", "done")]; + assert_eq!( + last_round_kept_count(&messages), + Some(1), + "the bare header still bounds the kept round in a legacy history" + ); + } + #[derive(serde::Deserialize)] struct FixtureMatrix { schema_version: u32, diff --git a/crates/tui/src/compaction/validate_survival_contract.mjs b/crates/tui/src/compaction/validate_survival_contract.mjs index a2cd50f026..434ccf112f 100644 --- a/crates/tui/src/compaction/validate_survival_contract.mjs +++ b/crates/tui/src/compaction/validate_survival_contract.mjs @@ -9,7 +9,10 @@ import { fileURLToPath } from "node:url"; // Mirrors compaction.rs: a new checkpoint is the handoff-note header block // plus the provenance block; only the two legacy markers match by substring, -// because older checkpoints may lack provenance. +// because older checkpoints may lack provenance. Recognition is era-scoped: +// in a history that already carries a stamped carrier only the stamped form +// is a checkpoint, so a user turn that pastes a bare legacy header stays +// content; a history without one keeps the loose legacy form. const MARKER = "Codewhale handoff note"; const LEGACY_V2_MARKER = "Another language model started to solve this problem"; const LEGACY_MARKERS = [LEGACY_V2_MARKER, "Conversation Summary (Auto-Generated)"]; @@ -42,19 +45,24 @@ function isWireCheckpoint(message) { ); } -function isCheckpoint(message) { +function historyIsPostProvenance(messages) { + return messages.some(isWireCheckpoint); +} + +function isCheckpoint(message, postProvenance) { + if (postProvenance) return isWireCheckpoint(message); if (isWireCheckpoint(message)) return true; const text = userTextOf(message); return Boolean(text && LEGACY_MARKERS.some((marker) => text.includes(marker))); } -function isPlainUserText(message) { - return !isCheckpoint(message) && Boolean(userTextOf(message)); +function isPlainUserText(postProvenance, message) { + return !isCheckpoint(message, postProvenance) && Boolean(userTextOf(message)); } -function lastPlainUserIndex(messages, end) { +function lastPlainUserIndex(messages, end, postProvenance) { for (let idx = end - 1; idx >= 0; idx -= 1) { - if (isPlainUserText(messages[idx])) return idx; + if (isPlainUserText(postProvenance, messages[idx])) return idx; } return null; } @@ -66,12 +74,13 @@ function sliceHasToolResult(messages, start) { } export function lastRoundStart(messages) { - const lastUser = lastPlainUserIndex(messages, messages.length); + const postProvenance = historyIsPostProvenance(messages); + const lastUser = lastPlainUserIndex(messages, messages.length, postProvenance); if (lastUser === null) return 0; if (sliceHasToolResult(messages, lastUser)) return lastUser; let candidate = lastUser; for (;;) { - const prev = lastPlainUserIndex(messages, candidate); + const prev = lastPlainUserIndex(messages, candidate, postProvenance); if (prev === null) return lastUser; if (sliceHasToolResult(messages, prev)) return prev; candidate = prev; @@ -117,6 +126,7 @@ function survives(text, replacement, of) { } export function validateSurvivalContract(original, replacement, anchors) { + const postProvenance = historyIsPostProvenance(original); const start = lastRoundStart(original); const round = original.slice(start); const pending = new Set(); @@ -128,12 +138,24 @@ export function validateSurvivalContract(original, replacement, anchors) { for (const id of toolResultIds(message)) pending.delete(id); } const tail = boundaries.length > 2 ? boundaries.at(-2) : 0; - const lastRound = round.filter((message, idx) => !isCheckpoint(message) && (idx >= tail || isPlainUserText(message))); + const lastRound = round.filter( + (message, idx) => + !isCheckpoint(message, postProvenance) && + (idx >= tail || isPlainUserText(postProvenance, message)), + ); // Every user turn in the round, not the first one `find` reaches: the round // spans a tool-bearing turn plus the toolless tail after it, so checking one - // let a rewrite drop the latest turn. - for (const text of lastRound.map(userTextOf).filter(Boolean)) { - if (!survives(text, replacement, userTextOf)) { + // let a rewrite drop the latest turn. The witness reads the replacement + // through the same era-scoped plain-user predicate: a stamped carrier is a + // user message whose text starts with the summary header, and quoting it + // verbatim would let a rewrite hide a dropped pasted turn behind the + // carrier's own header. + const plainUserTextOf = (message) => { + if (isCheckpoint(message, postProvenance)) return null; + return userTextOf(message); + }; + for (const text of lastRound.map(plainUserTextOf).filter(Boolean)) { + if (!survives(text, replacement, plainUserTextOf)) { return "a last-round user message was dropped"; } } @@ -172,7 +194,16 @@ export function validateSurvivalContract(original, replacement, anchors) { ) { return "last-round assistant output was dropped"; } - const checkpoints = replacement.filter(isCheckpoint).length; + // The receipt count reads the replacement in its own era, except that a + // post-provenance original raises the bar to a stamped carrier: the engine + // stamps every replacement it builds, and the stamp is the only form + // request rewriting relocates. + const receiptRecognition = historyIsPostProvenance(original) + ? true + : historyIsPostProvenance(replacement); + const checkpoints = replacement.filter( + (message) => isCheckpoint(message, receiptRecognition), + ).length; if (checkpoints === 0) return "checkpoint receipt was dropped"; if (checkpoints > 1) return "prior summaries were duplicated"; if (anchors && !replacement.some((message) =>