diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index b5d41b77a5..0bf4de106f 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -160,17 +160,18 @@ by that language model. Use this to build on the work that has already been done duplicating work. Here is the summary produced by the other language model, use the information \ in this summary to assist with your own analysis:"; -/// Detection marker for committed compaction-summary text: the stable first -/// sentence of `SUMMARY_HEADER`. `engine/context.rs` restores summaries by -/// the same marker on session load. -pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to solve this problem"; -/// Marker written by pre-v0.9.6 compaction; sessions saved under the old -/// format must still be recognized so their summary is replaced, not stacked. -pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; -const COMPACTION_CHECKPOINT_PROVENANCE: &str = ""; const COMPACTION_SUMMARY_BEGIN: &str = ""; const COMPACTION_SUMMARY_END: &str = ""; +// The checkpoint-recognition family lives in the `history_recognition` leaf so +// `compaction` and `runtime_handoff` can share it without a module cycle; the +// names below stay reachable on the `compaction` path. +pub(crate) use crate::history_recognition::{ + COMPACTION_CHECKPOINT_PROVENANCE, is_compaction_checkpoint_message, + is_generated_compaction_checkpoint, +}; +pub use crate::history_recognition::{COMPACTION_SUMMARY_MARKER, LEGACY_COMPACTION_SUMMARY_MARKER}; + /// Whether a system-prompt text block is a committed compaction summary. #[must_use] pub fn is_compaction_summary_text(text: &str) -> bool { @@ -296,77 +297,6 @@ pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message { } } -/// Whether the first text block carries the summary header, current or legacy. -fn has_compaction_summary_header(text: &str) -> bool { - // `COMPACTION_SUMMARY_MARKER` is the current header's first sentence, and - // releases before this one also committed carriers with their own body - // after that sentence, so the prefix — not the whole header — is the - // stable shape. - text.starts_with(COMPACTION_SUMMARY_MARKER) - || text.starts_with(LEGACY_COMPACTION_SUMMARY_MARKER) -} - -/// Structural recognition of the one generated checkpoint in saved history. -/// -/// The marker substring scan above stays scoped to system-prompt carriers: -/// on history it matches an ordinary user turn that merely *quotes* the -/// header, and every consumer here either deletes or replaces what it matches. -/// Structure instead — a `role="user"` message whose first text block begins -/// with the header and whose remaining block, if any, is exactly the -/// engine-written provenance marker. -/// -/// Boundary: the single-block form has no provenance to check, because -/// releases before this one saved the bare summary, and a reload that failed -/// to recognize it would stack a second summary beside it. A user turn that -/// *begins* with the header is therefore still read as a carrier here. Request -/// rewriting does not share that reading — see -/// [`is_generated_compaction_checkpoint`] — so such a turn is never moved or -/// merged on the wire. -#[must_use] -pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { - let [ - ContentBlock::Text { - text, - cache_control: None, - }, - rest @ .., - ] = message.content.as_slice() - else { - return false; - }; - message.role == Role::User - && has_compaction_summary_header(text) - && (rest.is_empty() - || matches!( - rest, - [ContentBlock::Text { - text: provenance, - cache_control: None, - }] if provenance == COMPACTION_CHECKPOINT_PROVENANCE - )) -} - -/// The provenance-stamped form, and the only form request rewriting may -/// relocate or merge. A user cannot type this shape, so an ordinary turn that -/// pastes the whole summary header after a tool result keeps its position. -#[must_use] -pub(crate) fn is_generated_compaction_checkpoint(message: &Message) -> bool { - let [ - ContentBlock::Text { - cache_control: None, - .. - }, - ContentBlock::Text { - text: provenance, - cache_control: None, - }, - ] = message.content.as_slice() - else { - return false; - }; - provenance == COMPACTION_CHECKPOINT_PROVENANCE && is_compaction_checkpoint_message(message) -} - /// Replace the saved history checkpoint with the authoritative carrier, keep /// its position relative to later turns, and repair a pre-placement-fix /// Agent-topology sidecar. Both steps exist so the first request after a diff --git a/crates/tui/src/history_recognition.rs b/crates/tui/src/history_recognition.rs new file mode 100644 index 0000000000..f9216ae1b8 --- /dev/null +++ b/crates/tui/src/history_recognition.rs @@ -0,0 +1,94 @@ +//! Structural recognition of the compaction checkpoint in saved history. +//! +//! Leaf module shared by `compaction` and `runtime_handoff`. Hosting the +//! checkpoint-recognition family here keeps `runtime_handoff`'s production +//! code from importing `compaction` (its tests still build carriers through +//! `compaction`'s constructors): `compaction` already imports +//! `runtime_handoff` for restore-time topology relocation, so a reverse edge +//! would close a module dependency cycle. + +use crate::models::{ContentBlock, Message, Role}; + +/// Detection marker for committed compaction-summary text: the stable first +/// sentence of the summary header `compaction` commits (`SUMMARY_HEADER`). +/// `engine/context.rs` restores summaries by the same marker on session load. +pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to solve this problem"; +/// Marker written by pre-v0.9.6 compaction; sessions saved under the old +/// format must still be recognized so their summary is replaced, not stacked. +pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; +pub(crate) const COMPACTION_CHECKPOINT_PROVENANCE: &str = + ""; + +/// Whether the first text block carries the summary header, current or legacy. +fn has_compaction_summary_header(text: &str) -> bool { + // `COMPACTION_SUMMARY_MARKER` is the current header's first sentence, and + // releases before this one also committed carriers with their own body + // after that sentence, so the prefix — not the whole header — is the + // stable shape. + text.starts_with(COMPACTION_SUMMARY_MARKER) + || text.starts_with(LEGACY_COMPACTION_SUMMARY_MARKER) +} + +/// Structural recognition of the one generated checkpoint in saved history. +/// +/// The marker substring scan in `compaction` (`is_compaction_summary_text`) +/// stays scoped to system-prompt carriers: +/// on history it matches an ordinary user turn that merely *quotes* the +/// header, and the consumers that match on history — `compaction` restore, +/// `runtime_handoff` placement and edit-guarding — replace what they match +/// or must protect it from deletion. +/// Structure instead — a `role="user"` message whose first text block begins +/// with the header and whose remaining block, if any, is exactly the +/// engine-written provenance marker. +/// +/// Boundary: the single-block form has no provenance to check, because +/// releases before this one saved the bare summary, and a reload that failed +/// to recognize it would stack a second summary beside it. A user turn that +/// *begins* with the header is therefore still read as a carrier here. Request +/// rewriting does not share that reading — see +/// [`is_generated_compaction_checkpoint`] — so such a turn is never moved or +/// merged on the wire. +#[must_use] +pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { + let [ + ContentBlock::Text { + text, + cache_control: None, + }, + rest @ .., + ] = message.content.as_slice() + else { + return false; + }; + message.role == Role::User + && has_compaction_summary_header(text) + && (rest.is_empty() + || matches!( + rest, + [ContentBlock::Text { + text: provenance, + cache_control: None, + }] if provenance == COMPACTION_CHECKPOINT_PROVENANCE + )) +} + +/// The provenance-stamped form, and the only form request rewriting may +/// relocate or merge. A user cannot type this shape, so an ordinary turn that +/// pastes the whole summary header after a tool result keeps its position. +#[must_use] +pub(crate) fn is_generated_compaction_checkpoint(message: &Message) -> bool { + let [ + ContentBlock::Text { + cache_control: None, + .. + }, + ContentBlock::Text { + text: provenance, + cache_control: None, + }, + ] = message.content.as_slice() + else { + return false; + }; + provenance == COMPACTION_CHECKPOINT_PROVENANCE && is_compaction_checkpoint_message(message) +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 99e43bf9cb..7ac675f7cc 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -70,6 +70,7 @@ pub use fleet::profile::WORKSPACE_AGENT_PROFILE_DIR; pub use fleet::roster::FleetRoster; mod goal_loop; mod hashing; +mod history_recognition; #[doc(hidden)] pub mod hooks; mod image_attach; diff --git a/crates/tui/src/runtime_handoff.rs b/crates/tui/src/runtime_handoff.rs index 2a759a07b8..f419e768bd 100644 --- a/crates/tui/src/runtime_handoff.rs +++ b/crates/tui/src/runtime_handoff.rs @@ -805,7 +805,7 @@ pub(crate) fn replace_agent_topology_checkpoint( let ends_with_tool_result = messages.last().is_some_and(carries_tool_result); let ends_with_compaction_summary = messages .last() - .is_some_and(crate::compaction::is_generated_compaction_checkpoint); + .is_some_and(crate::history_recognition::is_generated_compaction_checkpoint); let position = if ends_with_tool_result || ends_with_compaction_summary { compaction_anchor(messages, messages.len()).map_or(0, CompactionAnchor::placement_index) } else { @@ -1328,7 +1328,7 @@ fn is_compaction_topology_carrier(message: &Message) -> bool { fn restored_topology_anchor(messages: &[Message], index: usize) -> Option { let summary_before = messages[..index] .iter() - .rposition(crate::compaction::is_compaction_checkpoint_message); + .rposition(crate::history_recognition::is_compaction_checkpoint_message); let follows_tool_result = index > 0 && carries_tool_result(&messages[index - 1]); if summary_before.is_none() && !follows_tool_result { return None; @@ -1337,7 +1337,7 @@ fn restored_topology_anchor(messages: &[Message], index: usize) -> Option .or_else(|| { messages[index + 1..] .iter() - .position(crate::compaction::is_compaction_checkpoint_message) + .position(crate::history_recognition::is_compaction_checkpoint_message) .map(|offset| index + 1 + offset) }) .unwrap_or(messages.len()); @@ -1480,7 +1480,7 @@ fn is_runtime_owned_user_message(message: &Message) -> bool { // The compaction checkpoint is engine-written history, so `/edit` must // not treat it as the turn to truncate at: doing so deletes the summary // the session is built on. Structure decides, not the marker substring. - || crate::compaction::is_compaction_checkpoint_message(message) + || crate::history_recognition::is_compaction_checkpoint_message(message) } /// Return engine-owned metadata in either the current trailing shape or the