fix(compaction): split the transcript cap between head and tail
The middle cut gave each side the full budget, so a trimmed transcript could reach twice SUMMARY_TRANSCRIPT_TOKEN_CAP. Each side now gets half. A side with no line boundary in reach still hands the whole budget to the newest content. The local truncation fallback also refused to drop the last message, so a final message bigger than the window failed the session where the remote path recovers. It now lives in truncate_chat_messages_to_budget, which lets the tail drain to empty and only gives up when the system messages alone don't fit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.5.11 <noreply@sigit.si>
Seto Elkahfi committed
Oct 4, 2026 at 23:34 UTC
095725b2947cbdb40580c88320af29dd70afec6e
1 file changed
+109
-22
src/backend.rs
+109
-22
@@ -109,10 +109,12 @@ fn truncate_transcript_middle(transcript: &str, cap: usize) -> String {
109
}
110
111
let total = transcript.len();
112
+ // Each side gets half the budget, so the two together stay inside the cap.
113
// Walk each half to a line boundary. A side with no boundary in reach
113
- // contributes nothing; the other side may then use the whole budget.
114
+ // contributes nothing; the newest side may then use the whole budget.
115
+ let half = budget / 2;
116
let head_end = {
115
- let mut cut = budget.min(total);
117
+ let mut cut = half.min(total);
118
while cut < total && !transcript.is_char_boundary(cut) {
119
cut += 1;
120
}
@@ -124,9 +126,9 @@ fn truncate_transcript_middle(transcript: &str, cap: usize) -> String {
126
}
127
};
128
let tail_start = {
127
- let mut cut = total.saturating_sub(budget);
128
- while cut > 0 && !transcript.is_char_boundary(cut) {
129
- cut -= 1;
129
+ let mut cut = total.saturating_sub(half);
130
+ while cut < total && !transcript.is_char_boundary(cut) {
131
+ cut += 1;
132
}
133
if transcript[cut..].contains('\n') {
134
transcript[cut..]
@@ -530,24 +532,11 @@ impl InferenceBackend for LocalBackend {
532
// Same deterministic shrink as the remote path, on the plain
533
// chat shapes the engine holds (its history has no tool-call
534
// structure, so there are no orphaned tool results to guard).
533
- let system_chars: usize = system.iter().map(|message| message.content.len()).sum();
534
- let mut kept: Vec<ChatMessage> = tail;
535
- while kept.iter().map(|m| m.content.len()).sum::<usize>() + system_chars
536
- > DEFAULT_CONTEXT_TOKEN_BUDGET * 4
537
- && kept.len() > 1
538
- {
539
- kept.remove(0);
540
- }
541
- if kept.iter().map(|m| m.content.len()).sum::<usize>() + system_chars
542
- > DEFAULT_CONTEXT_TOKEN_BUDGET * 4
543
- {
544
- // Even one message overflows the window: unrecoverable.
545
- return Err(error);
535
+ match truncate_chat_messages_to_budget(system, tail) {
536
+ Some(rebuilt) => rebuilt,
537
+ // The system messages alone overflow the window: unrecoverable.
538
+ None => return Err(error),
539
}
547
- let mut rebuilt = system;
548
- rebuilt.push(ChatMessage::user(TRUNCATION_PLACEHOLDER.to_string()));
549
- rebuilt.extend(kept);
550
- rebuilt
540
}
541
};
542
@@ -559,6 +548,41 @@ impl InferenceBackend for LocalBackend {
548
}
549
}
550
551
+/// The local counterpart of `truncate_history_to_budget`: system messages, the
552
+/// truncation placeholder, and the newest of `tail` that fit the budget.
553
+///
554
+/// The tail may drain to empty. A newest message that overflows the window on
555
+/// its own (a giant tool result, the very thing that triggers compaction) is
556
+/// dropped rather than failing the session, so `None` only means the system
557
+/// messages alone don't fit.
558
+fn truncate_chat_messages_to_budget(
559
+ system: Vec<ChatMessage>,
560
+ mut tail: Vec<ChatMessage>,
561
+) -> Option<Vec<ChatMessage>> {
562
+ let budget = DEFAULT_CONTEXT_TOKEN_BUDGET * 4; // estimate_tokens is chars / 4.
563
+ let fixed_chars: usize = system
564
+ .iter()
565
+ .map(|message| message.content.len())
566
+ .sum::<usize>()
567
+ + TRUNCATION_PLACEHOLDER.len();
568
+ if fixed_chars > budget {
569
+ return None;
570
+ }
571
+
572
+ let mut tail_chars: usize = tail.iter().map(|message| message.content.len()).sum();
573
+ let mut drop = 0;
574
+ while fixed_chars + tail_chars > budget {
575
+ tail_chars -= tail[drop].content.len();
576
+ drop += 1;
577
+ }
578
+ tail.drain(..drop);
579
+
580
+ let mut rebuilt = system;
581
+ rebuilt.push(ChatMessage::user(TRUNCATION_PLACEHOLDER.to_string()));
582
+ rebuilt.extend(tail);
583
+ Some(rebuilt)
584
+}
585
+
586
/// Flatten `onde` chat messages into the transcript shape summarization
587
/// expects (the local engine's history has no tool-call structure, so this is
588
/// a plain role/content rendering).
@@ -2515,6 +2539,69 @@ mod tests {
2539
assert!(truncate_history_to_budget(&system, Vec::new()).is_none());
2540
}
2541
2542
+ /// Review follow-up: the local fallback recovers from a newest message
2543
+ /// that overflows the window on its own, the same way the remote one does.
2544
+ #[test]
2545
+ fn local_truncation_fallback_drops_an_oversized_final_message() {
2546
+ let system = vec![ChatMessage::system("sys")];
2547
+ let tail = vec![
2548
+ ChatMessage::user("hi"),
2549
+ ChatMessage::user("y".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000)),
2550
+ ];
2551
+
2552
+ let rebuilt = truncate_chat_messages_to_budget(system, tail).unwrap();
2553
+
2554
+ assert_eq!(rebuilt.len(), 2, "{rebuilt:?}");
2555
+ assert_eq!(rebuilt[0].content, "sys");
2556
+ assert_eq!(rebuilt[1].content, TRUNCATION_PLACEHOLDER);
2557
+ }
2558
+
2559
+ #[test]
2560
+ fn local_truncation_fallback_keeps_the_newest_messages_that_fit() {
2561
+ let big = DEFAULT_CONTEXT_TOKEN_BUDGET * 4 / 2;
2562
+ let tail = vec![
2563
+ ChatMessage::user("a".repeat(big)),
2564
+ ChatMessage::user("b".repeat(big)),
2565
+ ChatMessage::user("newest"),
2566
+ ];
2567
+
2568
+ let rebuilt = truncate_chat_messages_to_budget(Vec::new(), tail).unwrap();
2569
+
2570
+ assert_eq!(rebuilt.len(), 3, "oldest dropped, rest kept");
2571
+ assert_eq!(rebuilt[0].content, TRUNCATION_PLACEHOLDER);
2572
+ assert!(rebuilt[1].content.starts_with('b'));
2573
+ assert_eq!(rebuilt[2].content, "newest");
2574
+ }
2575
+
2576
+ #[test]
2577
+ fn local_truncation_fallback_reports_when_the_system_messages_overflow() {
2578
+ let system = vec![ChatMessage::system(
2579
+ "s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
2580
+ )];
2581
+
2582
+ assert!(truncate_chat_messages_to_budget(system, Vec::new()).is_none());
2583
+ }
2584
+
2585
+ /// Review follow-up: head and tail share the budget, so the trimmed
2586
+ /// transcript stays near the cap instead of reaching twice it.
2587
+ #[test]
2588
+ fn truncate_transcript_middle_stays_within_the_cap() {
2589
+ let lines: Vec<String> = (0..2_000)
2590
+ .map(|i| format!("user: message {i} with some padding text"))
2591
+ .collect();
2592
+ let transcript = lines.join("\n");
2593
+
2594
+ let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
2595
+
2596
+ assert!(trimmed.contains("message 0 "), "opening kept: {trimmed}");
2597
+ assert!(trimmed.contains("message 1999"), "closing kept: {trimmed}");
2598
+ assert!(
2599
+ trimmed.len() <= 800 + 80,
2600
+ "cap plus the omission marker, got {} chars",
2601
+ trimmed.len()
2602
+ );
2603
+ }
2604
+
2605
#[test]
2606
fn truncate_transcript_middle_keeps_short_transcripts_verbatim() {
2607
let transcript = "user: hello\n\nassistant: hi";