@@ -26,6 +26,18 @@ pub struct ConversationHistory {
2626 /// lookback window; advances only when it would otherwise fall out
2727 /// of range. Without this, a single iteration adding more than ~20
2828 /// blocks (wide multi-tool turn) would cold-miss every cache entry.
29+ ///
30+ /// Invariant — the anchor's index points at content that is
31+ /// byte-stable in the prefix `messages[0..=anchor_idx]`. Any
32+ /// `&mut self` operation that
33+ /// (a) inserts a message at or before the anchor,
34+ /// (b) drops a leading message,
35+ /// (c) mutates content inside `messages[0..=anchor_idx]`,
36+ /// must call [`Self::invalidate_cache_anchor`] *before* returning.
37+ /// Tail-only mutations (append a new message, edit
38+ /// `messages.last_mut()`, pop the rolling) are safe because the
39+ /// anchor sits strictly before the rolling by construction; the
40+ /// next [`Self::maintain_cache_anchor`] re-validates the index.
2941 cache_anchor_message_idx : Option < usize > ,
3042}
3143
@@ -303,7 +315,7 @@ Show imperial units only when the user explicitly asks for them."#,
303315 // position, since the prefix up to the anchor includes
304316 // `messages[0]`). Pure appends leave the anchor untouched.
305317 if self . messages . len ( ) != len_before || stripped_orphan {
306- self . cache_anchor_message_idx = None ;
318+ self . invalidate_cache_anchor ( ) ;
307319 }
308320
309321 // The warning describes our internal trim heuristic, not the
@@ -409,6 +421,16 @@ Show imperial units only when the user explicitly asks for them."#,
409421 self . cache_anchor_message_idx
410422 }
411423
424+ /// Drop the secondary cache-control breakpoint. Call this from any
425+ /// mutator that changes content at or before the current anchor or
426+ /// shifts indices into the anchored prefix; the next
427+ /// [`Self::maintain_cache_anchor`] re-establishes the anchor from
428+ /// the new state. Tail-only mutations (append, `last_mut`, pop the
429+ /// rolling) leave the anchor valid and must NOT call this.
430+ fn invalidate_cache_anchor ( & mut self ) {
431+ self . cache_anchor_message_idx = None ;
432+ }
433+
412434 /// Drop leading messages whose content still references tool calls
413435 /// that have been trimmed away. Called after any operation that
414436 /// removes messages from the front of the history. Returns `true`
@@ -583,7 +605,7 @@ Show imperial units only when the user explicitly asks for them."#,
583605 // In-place mutation of older message content changes the prefix
584606 // hash up to the anchor; invalidate so the next request doesn't
585607 // stamp a marker on a now-mismatched position.
586- self . cache_anchor_message_idx = None ;
608+ self . invalidate_cache_anchor ( ) ;
587609 let threshold = self . config . tool_result_truncate_threshold ;
588610 let keep_chars = COMPACTION_TOOL_RESULT_KEEP_CHARS ;
589611
@@ -683,7 +705,7 @@ Show imperial units only when the user explicitly asks for them."#,
683705 }
684706 // Front-drain + insert shifts every remaining index; the anchor
685707 // can't carry across this transformation.
686- self . cache_anchor_message_idx = None ;
708+ self . invalidate_cache_anchor ( ) ;
687709 self . messages . drain ( 0 ..split_point) ;
688710 let summary_msg = Message :: user ( format ! (
689711 "[Conversation Summary]\n \n The following is a summary of our earlier conversation:\n \n {}" ,
@@ -791,13 +813,13 @@ Show imperial units only when the user explicitly asks for them."#,
791813
792814 pub fn clear ( & mut self ) {
793815 self . messages . clear ( ) ;
794- self . cache_anchor_message_idx = None ;
816+ self . invalidate_cache_anchor ( ) ;
795817 }
796818
797819 pub fn restore_messages ( & mut self , messages : Vec < Message > ) {
798820 // The new history has no relationship to the prior conversation;
799821 // any inherited anchor index is meaningless content-wise.
800- self . cache_anchor_message_idx = None ;
822+ self . invalidate_cache_anchor ( ) ;
801823 self . messages = messages;
802824 self . trim_if_needed ( ) ;
803825 }
@@ -1811,4 +1833,58 @@ mod tests {
18111833 "small restored history must leave anchor None"
18121834 ) ;
18131835 }
1836+
1837+ #[ test]
1838+ fn cache_anchor_preserved_when_appending_to_last_user_blocks ( ) {
1839+ // Pins the tail-only safety half of the field's invariant:
1840+ // append_text_to_last_user_blocks edits messages.last_mut() —
1841+ // the rolling — which by construction sits strictly after
1842+ // the anchor, so the anchored prefix bytes don't change.
1843+ let mut history = ConversationHistory :: new ( ) ;
1844+ for _ in 0 ..12 {
1845+ history. messages . push ( blocks_msg_with ( "user" , 1 ) ) ;
1846+ }
1847+ history. maintain_cache_anchor ( ) ;
1848+ let anchor_before = history
1849+ . cache_anchor_message_idx ( )
1850+ . expect ( "12 single-block messages cross the threshold" ) ;
1851+
1852+ let appended = history. append_text_to_last_user_blocks ( "steer" . to_string ( ) ) ;
1853+ assert ! ( appended, "tail is user-blocks, append should succeed" ) ;
1854+
1855+ assert_eq ! (
1856+ history. cache_anchor_message_idx( ) ,
1857+ Some ( anchor_before) ,
1858+ "rolling-tail mutation must leave the anchor in place"
1859+ ) ;
1860+ }
1861+
1862+ #[ test]
1863+ fn cache_anchor_preserved_when_remove_last_message_pops_rolling ( ) {
1864+ // Pop the rolling and confirm the anchor stays put. We keep
1865+ // the history wide enough that the new rolling is still
1866+ // strictly after the anchor, so maintain doesn't clear it
1867+ // on the idx < rolling_idx check.
1868+ let mut history = ConversationHistory :: new ( ) ;
1869+ for _ in 0 ..14 {
1870+ history. messages . push ( blocks_msg_with ( "user" , 1 ) ) ;
1871+ }
1872+ history. maintain_cache_anchor ( ) ;
1873+ let anchor_before = history
1874+ . cache_anchor_message_idx ( )
1875+ . expect ( "14 single-block messages cross the threshold" ) ;
1876+ assert ! (
1877+ anchor_before < history. messages. len( ) - 2 ,
1878+ "test relies on anchor being < rolling - 1 so popping the \
1879+ rolling doesn't push the anchor through the maintain bound"
1880+ ) ;
1881+
1882+ history. remove_last_message ( ) ;
1883+
1884+ assert_eq ! (
1885+ history. cache_anchor_message_idx( ) ,
1886+ Some ( anchor_before) ,
1887+ "popping the rolling alone leaves the anchored prefix unchanged"
1888+ ) ;
1889+ }
18141890}
0 commit comments