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) =>