Skip to content

Commit 991da67

Browse files
committed
document the cache-anchor invariant and route the prefix mutators through one invalidation helper
1 parent fe7fc39 commit 991da67

1 file changed

Lines changed: 81 additions & 5 deletions

File tree

src/repl/conversation.rs

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nThe 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

Comments
 (0)