Skip to content

feat(compaction): publish survival contract and keep last round (#4394) - #5782

Open
Hmbown wants to merge 2 commits into
mainfrom
feat/compaction-survival-4394-20260831
Open

feat(compaction): publish survival contract and keep last round (#4394)#5782
Hmbown wants to merge 2 commits into
mainfrom
feat/compaction-survival-4394-20260831

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #4394.

297bd808a (coverage floor) was not on origin/main (git merge-base --is-ancestor 297bd808a origin/main failed; no #4394 commits on main). This ports the contract onto current main rather than cherry-picking the stale integration commit.

  • Documented schema next to the compaction module: crates/tui/src/compaction/SURVIVAL_CONTRACT.md plus a typed field table in survival_contract.rs.
  • Replacement history keeps a bounded last user round (assistant + tool results). Coverage floor refuses the rewrite if that round, /anchor text, or the checkpoint receipt would vanish.
  • Failed compact still does not replace live history.
  • /context names the compaction path (summary / prune-only) and /anchor survival.
  • Billed parent prompt tokens survive the turn boundary so last-turn pressure can still auto-compact.

Testing

  • cargo test -p codewhale-tui --lib --offline --locked -- compaction context_inspector last_round125 passed; 0 failed
  • Locale parity: message_id_list_english_pack_stay_in_exact_sync, shipped_complete_packs_have_raw_key_parity_with_english
  • cargo fmt --all -- --check (touched Rust files formatted with rustfmt --edition 2024)
  • cargo clippy --workspace --all-targets --all-features --locked
  • cargo test --workspace --all-features --locked

Checklist

  • Updated docs next to the compaction module (SURVIVAL_CONTRACT.md)
  • Added or updated tests for last-round / tool results / anchors / receipts
  • Signed-off-by: CodeWhale Bot bot@codewhale.net
  • Verified TUI behavior manually if UI changes
  • Harvested/co-authored credit uses a GitHub numeric noreply address

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Hmbown
Hmbown enabled auto-merge (squash) August 31, 2026 20:29
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 42s —— View job


Review complete

  • Gather context (diff, CLAUDE.md/AGENTS.md rules)
  • Review compaction/survival_contract.rs and last_round.rs
  • Review compaction.rs, core/engine.rs/turn.rs/turn_loop.rs/session.rs changes
  • Review context_inspector.rs, compaction_flow.rs, event_loop.rs UI changes
  • Check provider/model-prefix safety concerns — N/A, no provider/model routing touched in this diff
  • Post review findings

Summary

This PR ports the compaction survival-contract work onto current main (confirmed: 297bd808a is not an ancestor of origin/main, and this diff matches the stated file list). The overall design is solid — a documented, tested schema next to the module (SURVIVAL_CONTRACT.md cross-checked against survival_contract.rs by test), and a fail-closed coverage floor (validate_last_round_coverage / validate_survival_contract) that refuses to commit a rewrite if the last round, /anchor text, or the checkpoint receipt would vanish. compact_messages_safe still never mutates the caller's live history before Ok. Good use of TurnContext::billed_input_tokens_for_compaction with focused unit tests for the turn-boundary billing carry-over (#5577).

Correctness bug (posted inline on crates/tui/src/compaction/last_round.rs:104-139): last_round_start's fallback — used when the trailing round has no tool calls — only steps back one user round instead of continuing until it finds a round that actually contains a tool result. With two or more consecutive toolless user rounds after the last tool-bearing round, the real tool round falls outside the computed "last round" slice, and retained_user_messages drops non-text content (tool_use/tool_result/assistant) from everything before that slice. Because the new coverage floor (validate_last_round_coverage/validate_survival_contract) computes "last round" with the same function, it's blind to this gap and will report success while the tool result silently degrades to whatever the LLM summary captured — undermining the exact guarantee this PR is meant to add. Traced by hand with a concrete 8-message example in the inline comment; no existing test exercises this fallback branch (tail_has_tools == false) at all. Suggested fix: loop back over user-round boundaries until a round with a ToolResult block is found (or messages are exhausted), and add a regression test with ≥2 toolless rounds after the last tool round.

Minor / non-blocking notes:

  • crates/tui/src/compaction/survival_contract.rs's ENTRY_SURVIVAL/LIVE_STATE_FIELDS/SURVIVAL_CONTRACT_VERSION are pub but the whole module is #[cfg(test)]-gated and only ever referenced from its own test module — intentional as compile-time-checked documentation, but worth a doc comment noting it's test-only self-verification, not part of the runtime contract enforcement path (that's last_round.rs).
  • crates/tui/src/tui/context_inspector.rs's new last_round_messages helper (for the inspector's token-budget display) reuses the same last_round_start, so it inherits the same under-counting in the same edge case — cosmetic only (display), not data loss, but will self-correct once the core function is fixed.
  • apply_compaction_completed in compaction_flow.rs re-derives CompactionPath from whether summary_prompt is non-empty rather than threading the already-computed CompactionCoverage.path through EngineEvent::CompactionCompleted. It's currently equivalent (prune-only always sets summary_prompt: None), so not a bug, just a small duplication if a future path introduces a non-text prune-only summary.

Testing note: I could not run cargo test/cargo fmt/cargo clippy in this sandbox (Bash tool calls to cargo require interactive approval that isn't available here), so the bug above is verified by manual trace through last_round_start, last_round_range, retained_user_messages, and validate_last_round_coverage, not by executing the test suite. If --allowedTools can be extended to permit non-interactive cargo test -p codewhale-tui --lib in this environment, I can add and run the regression test directly.

Comment thread crates/tui/src/compaction/last_round.rs Outdated
Comment on lines +126 to +138
// A trailing user/assistant pair with no tools still needs the previous
// tool-bearing round; otherwise the last results vanish behind the summary.
messages[..last_user]
.iter()
.enumerate()
.rev()
.find_map(|(idx, message)| {
if is_compaction_checkpoint_message(message) {
return None;
}
user_text_of(message).map(|_| idx)
})
.unwrap_or(last_user)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness bug: the toolless-tail fallback only steps back one user round, not to the nearest tool-bearing round.

The comment says this fallback exists so that "a trailing user/assistant pair with no tools still needs the previous tool-bearing round." But the code just finds the previous plain-user-text index — it never checks that round actually contains tool results. If there are two or more consecutive toolless user rounds after the last tool round, only one hop back is taken, and the real tool round is left outside the computed "last round" slice.

Trace, for

0 user "Run the failing test."
1 assistant "Running."
2 assistant tool_use("live", Bash …)
3 user tool_result("live", "...FAILED")
4 user "ok thanks"
5 assistant "you're welcome"
6 user "one more thing"
7 assistant "sure"

last_user = 6 ("one more thing"), tail (6..8) has no tools → fallback searches messages[..6] and finds idx 4 ("ok thanks") — not idx 0. last_round_start returns 4, so the slice [4..8] never includes the tool_use/tool_result at 2/3.

Because retained_user_messages (crates/tui/src/compaction.rs) only keeps plain user text from messages[..start] and drops tool_use/tool_result/assistant content entirely, the live tool result silently falls out of verbatim retention and is only as good as whatever the LLM summary captured.

Worse: validate_last_round_coverage / validate_survival_contract — the coverage floor this PR introduces specifically to prevent this class of loss — computes "last round" with this same last_round_start/last_round_range, so it's blind to its own gap and will report success even though the earlier tool round silently vanished behind the summary.

Suggest walking back until a round is found that actually contains a ToolResult block (or exhausting the message list), rather than a single hop:

// after the first `messages[..last_user]` search fails to find tools in tail:
let mut candidate = last_user;
loop {
    let prev = messages[..candidate]
        .iter().enumerate().rev()
        .find_map(|(idx, m)| (!is_compaction_checkpoint_message(m))
            .then(|| user_text_of(m).map(|_| idx)).flatten());
    let Some(prev) = prev else { break candidate };
    if messages[prev..].iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))) {
        break prev;
    }
    candidate = prev;
}

(or equivalent). No existing test exercises the tail_has_tools == false fallback branch at all — worth adding a regression test with ≥2 toolless rounds after the last tool round, asserting the tool result survives build_replacement_history.

Fix this →

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 2m 0s —— View job


Review complete

  • Gather context (diff vs origin/main, CLAUDE.md/AGENTS.md rules)
  • Re-check crates/tui/src/compaction/last_round.rs against the previously-reported last_round_start fallback bug
  • Review compaction.rs, core/engine.rs/turn.rs/turn_loop.rs/session.rs changes
  • Review context_inspector.rs, compaction_flow.rs, event_loop.rs UI changes
  • Check provider/model-prefix safety concerns — N/A, no provider/model routing touched in this diff
  • Post review findings

Summary

This trigger fired on a synchronize event, but the tree is unchanged from the earlier review: HEAD is still 06017ae on top of the single feature commit 8167348 (feat(compaction): publish survival contract and keep last round (#4394)), with no new commit addressing prior feedback. The correctness bug flagged in the earlier automated review and the inline comment on last_round.rs:138 is still present.

Correctness bug (unresolved) — crates/tui/src/compaction/last_round.rs:104-139, last_round_start:

When the trailing round has no tool results, the fallback steps back exactly one user-round boundary (messages[..last_user] search, .unwrap_or(last_user)) instead of continuing until it finds a round that actually contains a ToolResult block. With ≥2 consecutive toolless user rounds after the last tool-bearing round, the real tool round is left outside the "last round" slice.

Concrete trace:

0 user "Run the failing test."
1 assistant "Running."
2 assistant tool_use("live", Bash …)
3 user tool_result("live", "...FAILED")
4 user "ok thanks"
5 assistant "you're welcome"
6 user "one more thing"
7 assistant "sure"

last_user = 6, tail (6..8) has no tools → single-hop fallback lands on idx 4 ("ok thanks"), not idx 0. last_round_start returns 4, so [4..8] never includes the tool_use/tool_result at 2/3.

retained_user_messages (crates/tui/src/compaction.rs) only keeps plain user text from messages[..start], dropping tool_use/tool_result/assistant content entirely from before that slice — so the live tool result silently degrades to whatever the LLM summary captured. Because validate_last_round_coverage/validate_survival_contract (the coverage floor this PR adds specifically to catch this) computes "last round" with the same last_round_start, it is blind to this gap and will report success on a rewrite that already lost the tool result. This undermines the PR's core guarantee. No existing test exercises the tail_has_tools == false fallback branch with more than one toolless round.

Suggested fix: loop back over user-round boundaries until a round containing a ToolResult block is found (or messages are exhausted), rather than a single hop, e.g.:

let mut candidate = last_user;
loop {
    let Some(prev) = messages[..candidate]
        .iter().enumerate().rev()
        .find_map(|(idx, m)| (!is_compaction_checkpoint_message(m))
            .then(|| user_text_of(m).map(|_| idx)).flatten())
    else { break candidate };
    if messages[prev..].iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))) {
        break prev;
    }
    candidate = prev;
}

Add a regression test with ≥2 toolless rounds after the last tool round, asserting the tool result survives build_replacement_history.

Fix this →

What's solid

  • compact_messages_safe still never mutates the caller's live history before returning Ok — failed compaction leaves live messages untouched, on both the prune-only and summary paths.
  • TurnContext::billed_input_tokens_for_compaction (crates/tui/src/core/turn.rs:150-163) correctly falls back to session.latest_parent_input_tokens only when the turn-local value is absent, and add_parent_usage is only invoked at the actual parent-route response site (turn_loop.rs:1452) — child-model usage can't masquerade as parent context size. clear_parent_input_tokens/Session::replace_messages both reset this on history rewrite, so a stale billed count can't leak past a compaction. Good coverage for this via fresh_turn_inherits_session_billed_prompt_for_compaction / live_turn_billed_outranks_stale_session_billed.
  • SURVIVAL_CONTRACT.md + survival_contract.rs's compile-time-checked field table is a nice way to keep the doc and the type honest.
  • No provider/model-prefix inference issues — this diff doesn't touch routing.

Minor / non-blocking (repeated from prior review, still applicable)

  • crates/tui/src/compaction/survival_contract.rs is #[cfg(test)]-gated but exports pub items only ever used by its own test module — intentional as compile-time doc verification, but worth a doc comment saying so explicitly (it is not part of the runtime enforcement path; that's last_round.rs).
  • context_inspector.rs's new last_round_messages helper (crates/tui/src/tui/context_inspector.rs:305-308) reuses last_round_start, so it inherits the same under-counting in the same edge case. Cosmetic only (display), will self-correct once the core function is fixed.
  • apply_compaction_completed (crates/tui/src/tui/ui/compaction_flow.rs:283-325) re-derives CompactionPath from whether summary_prompt is non-empty rather than threading the already-computed CompactionCoverage.path through EngineEvent::CompactionCompleted. Currently equivalent since prune-only always sets summary_prompt: None, so not a bug — just duplicated logic that would drift if a future path introduces a non-text prune-only summary.

Testing note: cargo (fmt/clippy/test) requires interactive approval not available in this sandbox, so the finding above is verified by manual trace through last_round_startlast_round_rangeretained_user_messagesvalidate_last_round_coverage, consistent with the PR's own checklist (which leaves cargo fmt/clippy/cargo test --workspace unchecked).
(branch)

CodeWhale Bot added 2 commits August 31, 2026 14:32
Port the missing coverage floor onto origin/main: replacement history
keeps a bounded last user round (assistant + tool results) and refuses
the rewrite if that round, /anchor text, or the checkpoint receipt
would vanish. Failed compact still does not replace live history.

Publish the language-invariant schema next to the compaction module
and surface path + /anchor survival in /context. Billed parent prompt
tokens survive the turn boundary so last-turn pressure can still
auto-compact.

Evidence:
  cargo test -p codewhale-tui --lib --offline --locked -- compaction context_inspector last_round
    -> 125 passed; 0 failed

Refs: #4394
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Drop the unused validate_last_round_coverage re-export that failed Lint.
Walk last_round_start back to the last tool-bearing user round instead of
one hop, so two trailing toolless turns cannot drop a tool result behind
the summary. Sync the TUI changelog slice with root.

Add a language-invariant fixture matrix and a Node coverage validator
next to the survival contract. There is no Go runtime in this repository
yet.

Evidence:
  cargo check -p codewhale-tui --locked
  cargo clippy -p codewhale-tui --lib --locked -- -A clippy::all -D unused-imports
  cargo test -p codewhale-tui --lib --offline --locked -- compaction context_inspector last_round
    -> 128 passed; 0 failed
  node crates/tui/src/compaction/validate_survival_contract.mjs
    -> ok 9 survival-contract fixtures

Refs: #4394
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@Hmbown
Hmbown force-pushed the feat/compaction-survival-4394-20260831 branch from 06017ae to 74aa1c9 Compare August 31, 2026 21:45
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 1m 42s —— View job


Review complete

  • Gather context (diff vs origin/main since last review)
  • Check whether the previously-reported last_round_start fallback bug (crates/tui/src/compaction/last_round.rs) is addressed
  • Re-review remaining changed files for new issues
  • Post final findings

Summary

New commit 74aa1c9 ("fix(compaction): keep last tool round and unstick CI") is the only thing that changed since the last review, and it fixes the correctness bug flagged twice before. It touches only crates/tui/CHANGELOG.md, compaction.rs, SURVIVAL_CONTRACT.md, last_round.rs, survival_contract.rs, and adds fixtures/matrix.json + validate_survival_contract.mjs — nothing in core/engine.rs, turn_loop.rs, session.rs, turn.rs, context_inspector.rs, compaction_flow.rs, or event_loop.rs changed, so the rest of the earlier review still stands (billing carry-over, fail-closed compact_messages_safe, no provider/routing touch).

Previously-reported bug — fixed. last_round_start (crates/tui/src/compaction/last_round.rs:104-145) now loops via last_plain_user_index / slice_has_tool_result instead of taking a single hop back:

let mut candidate = last_user;
loop {
    let Some(prev) = last_plain_user_index(messages, candidate) else {
        return last_user;
    };
    if slice_has_tool_result(messages, prev) {
        return prev;
    }
    candidate = prev;
}

I traced this by hand against the exact 8-message example from the earlier review (0 user run test / 2 tool_use / 3 tool_result / 4 "ok thanks" / 6 "one more thing"): last_user = 6candidate = 6prev = 4 (no tool result in [4..8]) → candidate = 4prev = 0 ([0..8] contains the ToolResult at idx 3) → returns 0. That's exactly right, and now matches a real regression test (last_round_walks_back_through_toolless_tails_to_the_tool_round) plus the same fixture (toolless-tail-keeps-prior-tools / toolless-tail-drops-prior-tools) in the new language-invariant fixtures/matrix.json, checked from both the Rust suite and validate_survival_contract.mjs. The loop is bounded (last_plain_user_index only searches strictly before candidate, so candidate strictly decreases), so there's no infinite-loop risk, and the else { return last_user } branch correctly preserves the intended chat-only fallback (verified against chat_only_history_keeps_the_latest_user_round).

Since validate_last_round_coverage/validate_survival_contract (compaction.rs) call the same fixed last_round_start, the coverage floor is no longer blind to this class of loss — the core guarantee this PR set out to add now holds for the traced case.

Also cleaned up in this commit:

  • Dropped the unused pub(crate) use last_round::{..., validate_last_round_coverage} re-export that was failing lint; call sites in compaction.rs now use last_round::validate_last_round_coverage(...) directly — confirmed no stale unqualified references remain.
  • Added the doc comment on survival_contract.rs noting it's cfg(test)-only compile-time documentation, not the runtime enforcement path — this was one of the minor notes from the earlier review.
  • crates/tui/CHANGELOG.md now matches root CHANGELOG.md (was previously out of sync on the same #4394 entries).

Remaining minor/non-blocking (from earlier review, unchanged, not required for merge):

  • context_inspector.rs's last_round_messages helper reuses last_round_start, so it now benefits from the fix automatically — no action needed.
  • apply_compaction_completed (compaction_flow.rs) still re-derives CompactionPath from summary_prompt emptiness rather than threading CompactionCoverage.path through the event. Currently equivalent, just duplicated logic.

Testing note: cargo/node invocations require interactive approval not available in this sandbox, so verification here is a manual trace through last_round_startlast_plain_user_index/slice_has_tool_result, plus reading the new fixture matrix and its two independent validators (Rust #[test] fixture_matrix_enforces_survival_contract and validate_survival_contract.mjs), not by executing them. The commit's own evidence block reports cargo test -p codewhale-tui --lib --offline --locked -- compaction context_inspector last_round128 passed; 0 failed and node validate_survival_contract.mjsok 9 survival-contract fixtures.

No new issues found. The correctness bug is resolved and covered by regression tests.
(branch)

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

PR 5782 ports the survival contract to main and keeps the last user round, anchors, checkpoint receipt, and billed parent prompt tokens. The Rust builder produces compliant history, but the enforcement validator is weaker than the contract states, and there are a few smaller documentation/i18n concerns.

Findings

  • [WARNING] validate_last_round_coverage enforces a weaker floor than the survival contract (crates/tui/src/compaction/last_round.rs:231)
    The function only checks the first user text in last_round, not all user turns; it checks only that some assistant-like message exists, not that the assistant's last-round text or tool_use IDs survive; and it validates tool_result IDs but not tool_use IDs. A replacement that keeps the first user text and an unrelated assistant message while dropping the actual latest user turn or the assistant's tool_use call would pass validation. The JS validator has the same weakness.
  • [INFO] Placeholder-only checkpoint rejection is documented but not implemented (crates/tui/src/compaction/last_round.rs:289)
    SURVIVAL_CONTRACT.md says validate_survival_contract refuses a placeholder-only checkpoint, but the implementation only counts checkpoint messages and checks anchors; it never inspects the checkpoint text. If a placeholder checkpoint carries the standard marker, it would pass.
  • [INFO] Hardcoded English summary leaks into localized inspector text (crates/tui/src/tui/context_inspector.rs:222)
    build_context_inspector_text writes 'Last compaction: kept last round verbatim...' directly without a MessageId. This line will appear in English even for non-English locales, unlike the new CtxInspCompaction* rows.
  • [INFO] Trailing toolless tail can make replacement history unbounded (crates/tui/src/compaction/last_round.rs:126)
    last_round_start walks back to the previous tool-bearing round instead of bounding only the latest user round. In a long chat-only tail after the last tool result, build_replacement_history retains the entire tail verbatim outside the retained_user_messages token budget, which can defeat compaction pressure and grow the cacheable prefix.

Suggestions

  • crates/tui/src/compaction/last_round.rs:231 — Validate every plain user text in the last round, not just the first, so a replacement cannot drop the actual latest user turn while keeping an earlier one.

        for text in last_round.iter().filter_map(user_text_of) {
            let kept = replacement.iter().any(|message| {
                user_text_of(message).is_some_and(|kept| {
                    kept == text || text.starts_with(&kept) || kept.starts_with(&text)
                })
            });
            if !kept {
                anyhow::bail!(
                    "Compaction coverage floor: the last user message was dropped; history was not replaced."
                );
            }
        }
    

Assessment

Request changes. The main Rust path constructs compliant replacement history, but the coverage floor should be tightened to actually fail closed as the contract describes. The localized inspector also has a small i18n leak.


Advisory review by Codewhale (codewhale review --pr 5782 --post, head 74aa1c97154b0dea3d85643f91036e5f095398ce). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id
)
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] validate_last_round_coverage enforces a weaker floor than the survival contract

The function only checks the first user text in last_round, not all user turns; it checks only that some assistant-like message exists, not that the assistant's last-round text or tool_use IDs survive; and it validates tool_result IDs but not tool_use IDs. A replacement that keeps the first user text and an unrelated assistant message while dropping the actual latest user turn or the assistant's tool_use call would pass validation. The JS validator has the same weakness.

let kept = replacement.iter().any(|message| {
message.content.iter().any(|block| match block {
ContentBlock::Text { text, .. } => text.contains(needle),
ContentBlock::ToolResult { content, .. } => content.contains(needle),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Placeholder-only checkpoint rejection is documented but not implemented

SURVIVAL_CONTRACT.md says validate_survival_contract refuses a placeholder-only checkpoint, but the implementation only counts checkpoint messages and checks anchors; it never inspects the checkpoint text. If a placeholder checkpoint carries the standard marker, it would pass.

if let Some(kept) = last_round_kept_count(&app.api_messages) {
let _ = writeln!(
out,
"Last compaction: kept last round verbatim ({kept} messages); earlier turns summarized."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Hardcoded English summary leaks into localized inspector text

build_context_inspector_text writes 'Last compaction: kept last round verbatim...' directly without a MessageId. This line will appear in English even for non-English locales, unlike the new CtxInspCompaction* rows.

}

#[must_use]
pub(crate) fn last_round_start(messages: &[Message]) -> usize {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Trailing toolless tail can make replacement history unbounded

last_round_start walks back to the previous tool-bearing round instead of bounding only the latest user round. In a long chat-only tail after the last tool result, build_replacement_history retains the entire tail verbatim outside the retained_user_messages token budget, which can defeat compaction pressure and grow the cacheable prefix.

Comment on lines +231 to +242
}

pub(crate) fn validate_last_round_coverage(
original: &[Message],
replacement: &[Message],
) -> Result<()> {
let last_round = last_round_slice(original);
if last_round.is_empty() {
return Ok(());
}
if let Some(text) = last_round.iter().find_map(user_text_of) {
let kept = replacement.iter().any(|message| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate every plain user text in the last round, not just the first, so a replacement cannot drop the actual latest user turn while keeping an earlier one.

Suggested change
}
pub(crate) fn validate_last_round_coverage(
original: &[Message],
replacement: &[Message],
) -> Result<()> {
let last_round = last_round_slice(original);
if last_round.is_empty() {
return Ok(());
}
if let Some(text) = last_round.iter().find_map(user_text_of) {
let kept = replacement.iter().any(|message| {
for text in last_round.iter().filter_map(user_text_of) {
let kept = replacement.iter().any(|message| {
user_text_of(message).is_some_and(|kept| {
kept == text || text.starts_with(&kept) || kept.starts_with(&text)
})
});
if !kept {
anyhow::bail!(
"Compaction coverage floor: the last user message was dropped; history was not replaced."
);
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compaction: publish and enforce a structured survival contract

1 participant