1use crate::counter::{TiktokenTokenCounter, TokenCounter};
2use crate::limits::{create_budget_for_model, ModelLimitsRegistry};
3use crate::skill_history::{is_skill_tool_name, SkillNameMatch};
4use crate::{BudgetStrategy, TokenBudget};
5use bamboo_domain::MessagePhase;
6use bamboo_domain::{
7 CompressionEvent, CompressionEventKind, CompressionTriggerType, ConversationSummary, Message,
8 ModelContextResetReason, Session,
9};
10
11fn is_skill_tool_chain_message(message: &Message) -> bool {
13 message.tool_calls.as_ref().is_some_and(|calls| {
14 calls
15 .iter()
16 .any(|call| is_skill_tool_name(&call.function.name, SkillNameMatch::Exact))
17 })
18}
19
20fn tool_chain_call_ids(message: &Message) -> impl Iterator<Item = String> + '_ {
24 message
25 .tool_calls
26 .iter()
27 .flatten()
28 .map(|call| call.id.clone())
29 .chain(message.tool_call_id.clone())
30}
31
32fn close_compressed_set_over_tool_chains(
42 messages_to_keep: &mut Vec<Message>,
43 messages_to_summarize: &mut Vec<Message>,
44 protected_user_ids: &HashSet<String>,
45 never_compress_ids: &[String],
46) {
47 loop {
48 let compressed_call_ids: HashSet<String> = messages_to_summarize
49 .iter()
50 .flat_map(tool_chain_call_ids)
51 .collect();
52 let split_index = messages_to_keep.iter().position(|message| {
53 !protected_user_ids.contains(message.id.as_str())
54 && !never_compress_ids.contains(&message.id)
55 && tool_chain_call_ids(message).any(|id| compressed_call_ids.contains(&id))
56 });
57 match split_index {
58 Some(index) => messages_to_summarize.push(messages_to_keep.remove(index)),
59 None => break,
60 }
61 }
62}
63use chrono::Utc;
64use std::collections::HashSet;
65
66#[derive(Debug, Clone)]
68pub enum CompressionPlanError {
69 ExposureGateNotMet {
71 usage_percent: f64,
72 trigger_percent: u8,
73 },
74 NoActiveMessages,
76 NotEnoughMessages { non_system_count: usize },
78 NothingToCompress {
80 anchor_index: usize,
81 non_system_count: usize,
82 },
83 ProtectedContentExceedsTarget {
86 projected_tokens: u32,
87 target_tokens: u32,
88 },
89 CandidateSetChanged,
91 MessageSummaryToolProtocol { reason: String },
93 EmptyMessageSummary,
95 SummaryExceedsTarget {
98 projected_tokens: u32,
99 target_tokens: u32,
100 },
101}
102
103impl std::fmt::Display for CompressionPlanError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 Self::ExposureGateNotMet {
107 usage_percent,
108 trigger_percent,
109 } => write!(
110 f,
111 "compression threshold not reached (usage={:.1}%, trigger={}%)",
112 usage_percent, trigger_percent
113 ),
114 Self::NoActiveMessages => write!(f, "no active messages to compress"),
115 Self::NotEnoughMessages { non_system_count } => write!(
116 f,
117 "not enough non-system messages to compress ({}, need >=3)",
118 non_system_count
119 ),
120 Self::NothingToCompress {
121 anchor_index,
122 non_system_count,
123 } => write!(
124 f,
125 "nothing to compress after anchor/keep splitting (anchor_index={}, non_system={})",
126 anchor_index, non_system_count
127 ),
128 Self::ProtectedContentExceedsTarget {
129 projected_tokens,
130 target_tokens,
131 } => write!(
132 f,
133 "protected active content prevents compression target (projected={}, target={})",
134 projected_tokens, target_tokens
135 ),
136 Self::CandidateSetChanged => {
137 write!(f, "compression candidate set changed before finalization")
138 }
139 Self::MessageSummaryToolProtocol { reason } => {
140 write!(f, "message-summary compression deferred: {reason}")
141 }
142 Self::EmptyMessageSummary => {
143 write!(
144 f,
145 "message-summary compression returned no summary for represented prose"
146 )
147 }
148 Self::SummaryExceedsTarget {
149 projected_tokens,
150 target_tokens,
151 } => write!(
152 f,
153 "actual summary misses compression target (projected={}, target={})",
154 projected_tokens, target_tokens
155 ),
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
163pub struct ContextCompressionExposure {
164 pub budget: TokenBudget,
165 pub active_tokens: u32,
166 pub active_usage_percent: f64,
167 pub active_usage_percent_rounded: u8,
168 pub should_expose_tool: bool,
169}
170
171#[derive(Debug, Clone)]
174pub struct CompressionPlan {
175 pub kind: CompressionEventKind,
176 pub logical_pass_id: Option<String>,
179 pub fixed_prompt_tokens: u32,
181 pub compressed_message_ids: Vec<String>,
182 pub messages_to_summarize: Vec<Message>,
184 pub summary_tokens: u32,
185 pub summary_content: String,
186 pub active_usage_before_percent: f64,
187 pub active_usage_after_percent: f64,
188 pub trigger_percent: u8,
189 pub target_percent: u8,
190 pub segments_removed: usize,
191 pub trigger_type: CompressionTriggerType,
192 pub compression_ratio: f64,
193 pub model_used: Option<String>,
194 pub latency_ms: u64,
195 pub source_tokens: u32,
197 pub represented_source_tokens: u32,
198 pub target_summary_tokens: u32,
199 pub actual_summary_content_tokens: u32,
200 pub summary_target_ratio: f64,
201 pub summary_budget_clamped: bool,
202 pub summary_budget_clamp_reason: Option<String>,
203 pub summarization_map_calls: u32,
204 pub summarization_reduce_calls: u32,
205 pub summarization_fallback_used: bool,
206}
207
208#[derive(Debug, Clone)]
211pub struct CompressionCandidatePlan {
212 pub kind: CompressionEventKind,
213 pub merge_existing_summary: bool,
215 pub compressed_message_ids: Vec<String>,
216 pub messages_to_summarize: Vec<Message>,
218 pub source_tokens: u32,
219 pub previous_represented_source_tokens: u32,
220 pub represented_source_tokens: u32,
221 pub target_summary_tokens: u32,
222 pub summary_target_ratio: f64,
223 pub active_usage_before_percent: f64,
224 pub projected_usage_after_percent: f64,
225 pub trigger_percent: u8,
226 pub target_percent: u8,
227 pub segments_removed: usize,
228 pub trigger_type: CompressionTriggerType,
229 additional_fixed_tokens: u32,
230 context_window: u32,
231 target_limit: u32,
232 message_summary_snapshot: Option<Vec<u8>>,
233}
234
235pub const DEFAULT_SUMMARY_TARGET_RATIO: f64 = 0.20;
236
237pub fn normalized_summary_target_ratio(value: f64) -> f64 {
238 if value.is_finite() && value > 0.0 {
239 value.clamp(0.01, 0.50)
240 } else {
241 DEFAULT_SUMMARY_TARGET_RATIO
242 }
243}
244
245fn target_summary_content_tokens(
246 session: &Session,
247 counter: &impl TokenCounter,
248 newly_represented_tokens: u32,
249 target_ratio: f64,
250) -> (u32, u32) {
251 let target_ratio = normalized_summary_target_ratio(target_ratio);
252 match session.conversation_summary.as_ref() {
253 Some(summary) if summary.represented_source_tokens > 0 => {
254 let represented = summary
255 .represented_source_tokens
256 .saturating_add(newly_represented_tokens);
257 (
258 ((represented as f64) * target_ratio).ceil() as u32,
259 summary.represented_source_tokens,
260 )
261 }
262 Some(summary) => {
263 let existing_tokens = counter.count_text(&summary.content);
264 let inferred_previous_source = ((existing_tokens as f64) / target_ratio).ceil() as u32;
265 (
266 existing_tokens.saturating_add(
267 ((newly_represented_tokens as f64) * target_ratio).ceil() as u32,
268 ),
269 inferred_previous_source,
270 )
271 }
272 None => (
273 ((newly_represented_tokens as f64) * target_ratio).ceil() as u32,
274 0,
275 ),
276 }
277}
278
279fn protected_compression_message_ids(non_system: &[Message]) -> HashSet<String> {
280 let user_indexes = non_system
281 .iter()
282 .enumerate()
283 .filter_map(|(index, message)| {
284 matches!(message.role, bamboo_domain::Role::User).then_some(index)
285 })
286 .collect::<Vec<_>>();
287 let keep_user_count = user_indexes.len().min(3);
288 let mut protected = user_indexes[user_indexes.len().saturating_sub(keep_user_count)..]
289 .iter()
290 .filter_map(|index| non_system.get(*index))
291 .map(|message| message.id.clone())
292 .collect::<HashSet<_>>();
293
294 let skill_call_ids = non_system
295 .iter()
296 .filter(|message| is_skill_tool_chain_message(message))
297 .flat_map(|message| {
298 message
299 .tool_calls
300 .iter()
301 .flatten()
302 .map(|call| call.id.clone())
303 })
304 .collect::<HashSet<_>>();
305
306 for message in non_system {
307 if message.never_compress
308 || is_skill_tool_chain_message(message)
309 || message
310 .tool_call_id
311 .as_ref()
312 .is_some_and(|id| skill_call_ids.contains(id))
313 {
314 protected.insert(message.id.clone());
315 }
316 }
317 protected
318}
319
320fn post_compaction_recovery_tokens(
321 compressed_messages: &[Message],
322 session: &Session,
323 counter: &impl TokenCounter,
324) -> u32 {
325 build_post_compaction_recovery_message(compressed_messages, session)
326 .as_ref()
327 .map(|message| counter.count_message(message))
328 .unwrap_or(0)
329}
330
331pub fn build_forced_compression_candidate_plan(
337 session: &Session,
338 model_name: &str,
339 configured_budget: Option<&TokenBudget>,
340 summary_target_ratio: f64,
341 trigger_type: CompressionTriggerType,
342) -> Result<CompressionCandidatePlan, CompressionPlanError> {
343 build_forced_compression_candidate_plan_with_fixed_tokens(
344 session,
345 model_name,
346 configured_budget,
347 summary_target_ratio,
348 trigger_type,
349 0,
350 )
351}
352
353pub fn build_forced_compression_candidate_plan_with_fixed_tokens(
357 session: &Session,
358 model_name: &str,
359 configured_budget: Option<&TokenBudget>,
360 summary_target_ratio: f64,
361 trigger_type: CompressionTriggerType,
362 additional_fixed_tokens: u32,
363) -> Result<CompressionCandidatePlan, CompressionPlanError> {
364 let exposure = estimate_context_compression_exposure(session, model_name, configured_budget);
365 let budget = &exposure.budget;
366 let counter = TiktokenTokenCounter::default();
367 let active_messages = active_messages_for_budget(session);
368 if active_messages.is_empty() {
369 return Err(CompressionPlanError::NoActiveMessages);
370 }
371
372 let system_messages = active_messages
373 .iter()
374 .filter(|message| matches!(message.role, bamboo_domain::Role::System))
375 .cloned()
376 .collect::<Vec<_>>();
377 let non_system = active_messages
378 .into_iter()
379 .filter(|message| !matches!(message.role, bamboo_domain::Role::System))
380 .collect::<Vec<_>>();
381 if non_system.len() < 3 {
382 return Err(CompressionPlanError::NotEnoughMessages {
383 non_system_count: non_system.len(),
384 });
385 }
386
387 let context_window = budget.max_context_tokens;
388 let target_limit = budget.compression_target_context_tokens();
389 let system_tokens = counter.count_messages(&system_messages);
390 let protected_ids = protected_compression_message_ids(&non_system);
391 let segments = crate::segmenter::MessageSegmenter::new().segment(non_system.clone());
392 let mut remaining_tokens = counter.count_messages(&non_system);
393 let mut source_tokens = 0u32;
394 let mut selected_messages = Vec::new();
395 let mut selected_segment_count = 0usize;
396 let ratio = normalized_summary_target_ratio(summary_target_ratio);
397 let summary_envelope_tokens = counter.count_messages(&[compression_summary_message("")]);
398 let mut projected_tokens = system_tokens
399 .saturating_add(remaining_tokens)
400 .saturating_add(additional_fixed_tokens);
401 let mut target_summary_tokens = 0u32;
402 let mut previous_represented_source_tokens = session
403 .conversation_summary
404 .as_ref()
405 .map(|summary| summary.represented_source_tokens)
406 .unwrap_or(0);
407
408 for segment in segments {
409 if segment
410 .messages
411 .iter()
412 .any(|message| protected_ids.contains(&message.id))
413 {
414 continue;
415 }
416
417 let segment_tokens = counter.count_messages(&segment.messages);
418 source_tokens = source_tokens.saturating_add(segment_tokens);
419 remaining_tokens = remaining_tokens.saturating_sub(segment_tokens);
420 selected_messages.extend(segment.messages);
421 selected_segment_count += 1;
422
423 let (desired, previous_represented) =
424 target_summary_content_tokens(session, &counter, source_tokens, ratio);
425 target_summary_tokens = desired;
426 previous_represented_source_tokens = previous_represented;
427 let recovery_tokens =
428 post_compaction_recovery_tokens(&selected_messages, session, &counter);
429 projected_tokens = system_tokens
430 .saturating_add(remaining_tokens)
431 .saturating_add(additional_fixed_tokens)
432 .saturating_add(summary_envelope_tokens)
433 .saturating_add(target_summary_tokens)
434 .saturating_add(recovery_tokens);
435 if projected_tokens <= target_limit {
436 break;
437 }
438 }
439
440 if selected_messages.is_empty() {
441 return Err(CompressionPlanError::NothingToCompress {
442 anchor_index: 0,
443 non_system_count: non_system.len(),
444 });
445 }
446 if projected_tokens > target_limit {
447 return Err(CompressionPlanError::ProtectedContentExceedsTarget {
448 projected_tokens,
449 target_tokens: target_limit,
450 });
451 }
452
453 let represented_source_tokens =
454 previous_represented_source_tokens.saturating_add(source_tokens);
455 let projected_usage_after_percent =
456 context_window_usage_percent(projected_tokens, context_window);
457 let compressed_message_ids = selected_messages
458 .iter()
459 .map(|message| message.id.clone())
460 .collect();
461
462 Ok(CompressionCandidatePlan {
463 kind: CompressionEventKind::Summary,
464 merge_existing_summary: true,
465 compressed_message_ids,
466 messages_to_summarize: selected_messages,
467 source_tokens,
468 previous_represented_source_tokens,
469 represented_source_tokens,
470 target_summary_tokens,
471 summary_target_ratio: ratio,
472 active_usage_before_percent: exposure.active_usage_percent,
473 projected_usage_after_percent,
474 trigger_percent: budget.compression_trigger_percent,
475 target_percent: budget.compression_target_percent,
476 segments_removed: selected_segment_count,
477 trigger_type,
478 additional_fixed_tokens,
479 context_window,
480 target_limit,
481 message_summary_snapshot: None,
482 })
483}
484
485fn message_summary_snapshot(session: &Session) -> Result<Vec<u8>, CompressionPlanError> {
486 serde_json::to_vec(&(
487 &session.messages,
488 &session.conversation_summary,
489 &session.compression_events,
490 ))
491 .map_err(|_| CompressionPlanError::CandidateSetChanged)
492}
493
494fn is_post_compaction_recovery(message: &Message) -> bool {
495 message.role == bamboo_domain::Role::Assistant
496 && message.content.starts_with("[post-compaction-recovery]\n")
497}
498
499fn message_content_source(message: &Message) -> Option<Message> {
500 if message.content.trim().is_empty() || is_post_compaction_recovery(message) {
501 return None;
502 }
503 let mut source = match message.role {
504 bamboo_domain::Role::User => Message::user(message.content.clone()),
505 bamboo_domain::Role::Assistant => Message::assistant(message.content.clone(), None),
506 _ => return None,
507 };
508 source.id.clone_from(&message.id);
512 source.phase = None;
513 Some(source)
514}
515
516fn validate_message_summary_tool_protocol(
517 messages: &[Message],
518) -> Result<(), CompressionPlanError> {
519 let invalid = |reason: &str| CompressionPlanError::MessageSummaryToolProtocol {
520 reason: reason.to_string(),
521 };
522 let mut calls = HashSet::new();
523 let mut results = HashSet::new();
524 for message in messages {
525 let has_calls = message
526 .tool_calls
527 .as_ref()
528 .is_some_and(|calls| !calls.is_empty());
529 let has_tool_protocol = has_calls
530 || message.tool_call_id.is_some()
531 || message.role == bamboo_domain::Role::Tool;
532 if message.never_compress && has_tool_protocol {
533 return Err(invalid("a tool-chain record is marked never_compress"));
534 }
535 if has_calls {
536 if message.role != bamboo_domain::Role::Assistant {
537 return Err(invalid("tool calls have a non-assistant role"));
538 }
539 for call in message.tool_calls.iter().flatten() {
540 if call.id.is_empty() || !calls.insert(call.id.as_str()) {
541 return Err(invalid("tool calls have an empty or duplicate identifier"));
542 }
543 }
544 }
545 if message.role == bamboo_domain::Role::Tool {
546 let id = message
547 .tool_call_id
548 .as_deref()
549 .filter(|id| !id.is_empty())
550 .ok_or_else(|| invalid("a tool result has no call identifier"))?;
551 if !results.insert(id) {
552 return Err(invalid("a tool call has duplicate results"));
553 }
554 } else if message.tool_call_id.is_some() {
555 return Err(invalid("a tool result identifier has a non-tool role"));
556 }
557 }
558 if calls != results {
559 return Err(invalid("tool calls and results are incomplete or orphaned"));
560 }
561 Ok(())
562}
563
564fn summary_prompt_tokens(
565 kind: CompressionEventKind,
566 content: &str,
567 counter: &impl TokenCounter,
568) -> u32 {
569 if kind == CompressionEventKind::MessageSummary && content.trim().is_empty() {
570 0
571 } else {
572 counter.count_messages(&[compression_summary_message(content)])
573 }
574}
575
576pub fn build_message_summary_candidate_plan_with_fixed_tokens(
584 session: &Session,
585 model_name: &str,
586 configured_budget: Option<&TokenBudget>,
587 summary_target_ratio: f64,
588 trigger_type: CompressionTriggerType,
589 additional_fixed_tokens: u32,
590) -> Result<CompressionCandidatePlan, CompressionPlanError> {
591 let exposure = estimate_context_compression_exposure_with_fixed_tokens(
592 session,
593 model_name,
594 configured_budget,
595 additional_fixed_tokens,
596 );
597 let active_messages = active_messages_for_budget(session);
598 if active_messages.is_empty() {
599 return Err(CompressionPlanError::NoActiveMessages);
600 }
601 validate_message_summary_tool_protocol(&active_messages)?;
602 let latest_user_id = active_messages
603 .iter()
604 .rfind(|message| message.role == bamboo_domain::Role::User)
605 .map(|message| message.id.as_str());
606 let archived = active_messages
607 .iter()
608 .filter(|message| message.role != bamboo_domain::Role::System)
609 .filter(|message| Some(message.id.as_str()) != latest_user_id)
610 .filter(|message| !message.never_compress || is_post_compaction_recovery(message))
611 .cloned()
612 .collect::<Vec<_>>();
613 if archived.is_empty() {
614 return Err(CompressionPlanError::NothingToCompress {
615 anchor_index: 0,
616 non_system_count: active_messages
617 .iter()
618 .filter(|message| message.role != bamboo_domain::Role::System)
619 .count(),
620 });
621 }
622 let compressed_message_ids = archived
623 .iter()
624 .map(|message| message.id.clone())
625 .collect::<Vec<_>>();
626 let archived_ids = compressed_message_ids
627 .iter()
628 .map(String::as_str)
629 .collect::<HashSet<_>>();
630 let latest_summary_index = session
631 .compression_events
632 .iter()
633 .rposition(|event| event.kind != CompressionEventKind::RetrievalWindow);
634 let merge_existing_summary = session.conversation_summary.is_some()
635 && latest_summary_index.is_some_and(|index| {
636 session.compression_events[index].kind == CompressionEventKind::MessageSummary
637 });
638 let intervening_retrieval_ids = session
641 .compression_events
642 .iter()
643 .skip(latest_summary_index.map_or(0, |index| index + 1))
644 .filter(|event| event.kind == CompressionEventKind::RetrievalWindow)
645 .map(|event| event.id.as_str())
646 .collect::<HashSet<_>>();
647 let messages_to_summarize = session
648 .messages
649 .iter()
650 .filter(|message| {
651 archived_ids.contains(message.id.as_str())
652 || (message.compressed
653 && !message.never_compress
654 && (!merge_existing_summary
655 || message
656 .compressed_by_event_id
657 .as_deref()
658 .is_some_and(|id| intervening_retrieval_ids.contains(id))))
659 })
660 .filter_map(message_content_source)
661 .collect::<Vec<_>>();
662 let counter = TiktokenTokenCounter::default();
663 let source_tokens = counter.count_messages(&messages_to_summarize);
664 let ratio = normalized_summary_target_ratio(summary_target_ratio);
665 let (target_summary_tokens, previous_represented_source_tokens) = if merge_existing_summary {
666 target_summary_content_tokens(session, &counter, source_tokens, ratio)
667 } else {
668 (((source_tokens as f64) * ratio).ceil() as u32, 0)
669 };
670 let remaining = active_messages
671 .iter()
672 .filter(|message| !archived_ids.contains(message.id.as_str()))
673 .cloned()
674 .collect::<Vec<_>>();
675 let summary_envelope_tokens = if target_summary_tokens > 0 {
676 counter.count_messages(&[compression_summary_message("")])
677 } else {
678 0
679 };
680 let projected_tokens = counter
681 .count_messages(&remaining)
682 .saturating_add(additional_fixed_tokens)
683 .saturating_add(summary_envelope_tokens)
684 .saturating_add(target_summary_tokens);
685 let budget = &exposure.budget;
686 let target_limit = budget.compression_target_context_tokens();
687 if projected_tokens > target_limit {
688 return Err(CompressionPlanError::ProtectedContentExceedsTarget {
689 projected_tokens,
690 target_tokens: target_limit,
691 });
692 }
693 Ok(CompressionCandidatePlan {
694 kind: CompressionEventKind::MessageSummary,
695 merge_existing_summary,
696 compressed_message_ids,
697 messages_to_summarize,
698 source_tokens,
699 previous_represented_source_tokens,
700 represented_source_tokens: previous_represented_source_tokens.saturating_add(source_tokens),
701 target_summary_tokens,
702 summary_target_ratio: ratio,
703 active_usage_before_percent: exposure.active_usage_percent,
704 projected_usage_after_percent: context_window_usage_percent(
705 projected_tokens,
706 budget.max_context_tokens,
707 ),
708 trigger_percent: budget.compression_trigger_percent,
709 target_percent: budget.compression_target_percent,
710 segments_removed: crate::MessageSegmenter::new().segment(archived).len(),
711 trigger_type,
712 additional_fixed_tokens,
713 context_window: budget.max_context_tokens,
714 target_limit,
715 message_summary_snapshot: Some(message_summary_snapshot(session)?),
716 })
717}
718
719pub fn finalize_compression_candidate_plan(
722 session: &Session,
723 candidate: CompressionCandidatePlan,
724 summary_content: String,
725) -> Result<CompressionPlan, CompressionPlanError> {
726 if candidate.kind == CompressionEventKind::MessageSummary
727 && candidate.target_summary_tokens > 0
728 && summary_content.trim().is_empty()
729 {
730 return Err(CompressionPlanError::EmptyMessageSummary);
731 }
732 if let Some(expected) = candidate.message_summary_snapshot.as_ref() {
733 let actual = message_summary_snapshot(session)?;
734 if expected != &actual {
735 return Err(CompressionPlanError::CandidateSetChanged);
736 }
737 }
738 let active_ids = session
739 .messages
740 .iter()
741 .filter(|message| !message.compressed)
742 .map(|message| message.id.as_str())
743 .collect::<HashSet<_>>();
744 if candidate
745 .compressed_message_ids
746 .iter()
747 .any(|id| !active_ids.contains(id.as_str()))
748 {
749 return Err(CompressionPlanError::CandidateSetChanged);
750 }
751
752 let candidate_ids = candidate
753 .compressed_message_ids
754 .iter()
755 .map(String::as_str)
756 .collect::<HashSet<_>>();
757 let counter = TiktokenTokenCounter::default();
758 let summary_tokens = summary_prompt_tokens(candidate.kind, &summary_content, &counter);
759 let actual_summary_content_tokens = counter.count_text(&summary_content);
760 let recovery_tokens = if candidate.kind == CompressionEventKind::MessageSummary {
761 0
762 } else {
763 post_compaction_recovery_tokens(&candidate.messages_to_summarize, session, &counter)
764 };
765 let remaining_tokens = session
766 .messages
767 .iter()
768 .filter(|message| !message.compressed)
769 .filter(|message| !candidate_ids.contains(message.id.as_str()))
770 .cloned()
771 .collect::<Vec<_>>();
772 let projected_tokens = counter
773 .count_messages(&remaining_tokens)
774 .saturating_add(candidate.additional_fixed_tokens)
775 .saturating_add(summary_tokens)
776 .saturating_add(recovery_tokens);
777 if projected_tokens > candidate.target_limit {
778 return Err(CompressionPlanError::SummaryExceedsTarget {
779 projected_tokens,
780 target_tokens: candidate.target_limit,
781 });
782 }
783
784 Ok(CompressionPlan {
785 kind: candidate.kind,
786 logical_pass_id: None,
787 fixed_prompt_tokens: candidate.additional_fixed_tokens,
788 compressed_message_ids: candidate.compressed_message_ids,
789 messages_to_summarize: candidate.messages_to_summarize,
790 summary_tokens,
791 summary_content,
792 active_usage_before_percent: candidate.active_usage_before_percent,
793 active_usage_after_percent: context_window_usage_percent(
794 projected_tokens,
795 candidate.context_window,
796 ),
797 trigger_percent: candidate.trigger_percent,
798 target_percent: candidate.target_percent,
799 segments_removed: candidate.segments_removed,
800 trigger_type: candidate.trigger_type,
801 compression_ratio: 0.0,
802 model_used: None,
803 latency_ms: 0,
804 source_tokens: candidate.source_tokens,
805 represented_source_tokens: candidate.represented_source_tokens,
806 target_summary_tokens: candidate.target_summary_tokens,
807 actual_summary_content_tokens,
808 summary_target_ratio: candidate.summary_target_ratio,
809 summary_budget_clamped: false,
810 summary_budget_clamp_reason: None,
811 summarization_map_calls: 0,
812 summarization_reduce_calls: 0,
813 summarization_fallback_used: false,
814 })
815}
816
817pub fn context_window_usage_percent(total_tokens: u32, context_window_tokens: u32) -> f64 {
818 if context_window_tokens == 0 {
819 return 0.0;
820 }
821 (total_tokens as f64 / context_window_tokens as f64) * 100.0
822}
823
824pub fn normalized_trigger_percent(trigger_percent: u8) -> f64 {
825 match trigger_percent {
826 0 => 100.0,
827 1..=100 => trigger_percent as f64,
828 _ => 100.0,
829 }
830}
831
832pub fn estimate_context_compression_exposure(
835 session: &Session,
836 model_name: &str,
837 configured_budget: Option<&TokenBudget>,
838) -> ContextCompressionExposure {
839 estimate_context_compression_exposure_with_fixed_tokens(
840 session,
841 model_name,
842 configured_budget,
843 0,
844 )
845}
846
847pub fn estimate_context_compression_exposure_with_fixed_tokens(
850 session: &Session,
851 model_name: &str,
852 configured_budget: Option<&TokenBudget>,
853 additional_fixed_tokens: u32,
854) -> ContextCompressionExposure {
855 let budget = configured_budget.cloned().unwrap_or_else(|| {
863 create_budget_for_model(
864 model_name,
865 BudgetStrategy::default(),
866 &ModelLimitsRegistry::new(),
867 )
868 });
869 let counter = TiktokenTokenCounter::default();
870 let active_messages = active_messages_for_budget(session);
871 let active_message_tokens = counter.count_messages(&active_messages);
872 let summary_tokens = session
873 .conversation_summary
874 .as_ref()
875 .map(|summary| counter.count_messages(&[compression_summary_message(&summary.content)]))
876 .unwrap_or(0);
877 let active_tokens = active_message_tokens
878 .saturating_add(summary_tokens)
879 .saturating_add(additional_fixed_tokens);
880 let context_window = budget.max_context_tokens;
883 let estimated_usage = context_window_usage_percent(active_tokens, context_window);
884 let usage = session
885 .token_usage
886 .as_ref()
887 .and_then(|token_usage| {
888 let denominator = if token_usage.max_context_tokens > 0 {
889 token_usage.max_context_tokens
890 } else if token_usage.budget_limit > 0 {
891 token_usage.budget_limit
893 } else {
894 context_window
895 };
896 (denominator > 0).then_some(context_window_usage_percent(
897 token_usage.total_tokens,
898 denominator,
899 ))
900 })
901 .map(|persisted_usage| persisted_usage.max(estimated_usage))
902 .unwrap_or(estimated_usage);
903
904 let rounded = usage.clamp(0.0, 100.0).round() as u8;
905 let trigger_tokens = budget.compression_trigger_context_tokens();
906 let trigger_percent = if budget.max_context_tokens > 0 {
907 (trigger_tokens as f64 / budget.max_context_tokens as f64) * 100.0
908 } else {
909 0.0
910 };
911 let threshold_reached = usage >= trigger_percent;
912
913 let non_system_count = active_messages
918 .iter()
919 .filter(|m| !matches!(m.role, bamboo_domain::Role::System))
920 .count();
921
922 let should_expose_tool = threshold_reached && non_system_count >= 3;
923
924 ContextCompressionExposure {
925 budget,
926 active_tokens,
927 active_usage_percent: usage,
928 active_usage_percent_rounded: rounded,
929 should_expose_tool,
930 }
931}
932
933pub fn build_compression_plan_with_summary(
936 session: &Session,
937 model_name: &str,
938 configured_budget: Option<&TokenBudget>,
939 summary_content: String,
940) -> Result<CompressionPlan, CompressionPlanError> {
941 build_compression_plan_with_summary_internal(
942 session,
943 model_name,
944 configured_budget,
945 summary_content,
946 true,
947 CompressionTriggerType::Auto,
948 )
949}
950
951pub fn build_forced_compression_plan_with_summary(
957 session: &Session,
958 model_name: &str,
959 configured_budget: Option<&TokenBudget>,
960 summary_content: String,
961 trigger_type: CompressionTriggerType,
962) -> Result<CompressionPlan, CompressionPlanError> {
963 build_compression_plan_with_summary_internal(
964 session,
965 model_name,
966 configured_budget,
967 summary_content,
968 false,
969 trigger_type,
970 )
971}
972
973fn build_compression_plan_with_summary_internal(
974 session: &Session,
975 model_name: &str,
976 configured_budget: Option<&TokenBudget>,
977 summary_content: String,
978 require_exposure_gate: bool,
979 trigger_type: CompressionTriggerType,
980) -> Result<CompressionPlan, CompressionPlanError> {
981 let exposure = estimate_context_compression_exposure(session, model_name, configured_budget);
982 if require_exposure_gate && !exposure.should_expose_tool {
983 return Err(CompressionPlanError::ExposureGateNotMet {
984 usage_percent: exposure.active_usage_percent,
985 trigger_percent: exposure.budget.compression_trigger_percent,
986 });
987 }
988
989 let budget = &exposure.budget;
990 let counter = TiktokenTokenCounter::default();
991 let summary_message = compression_summary_message(&summary_content);
992 let summary_tokens = counter.count_messages(&[summary_message]);
993
994 let context_window = budget.max_context_tokens;
995 let target_limit = budget.compression_target_context_tokens();
996
997 let mut active_messages = active_messages_for_budget(session);
998 if active_messages.is_empty() {
999 tracing::debug!("compression plan: no active messages, cannot build plan");
1000 return Err(CompressionPlanError::NoActiveMessages);
1001 }
1002
1003 let system_messages: Vec<Message> = active_messages
1004 .iter()
1005 .filter(|m| matches!(m.role, bamboo_domain::Role::System))
1006 .cloned()
1007 .collect();
1008 let system_tokens = counter.count_messages(&system_messages);
1009 let reserved_non_window_tokens = system_tokens.saturating_add(summary_tokens);
1010 let window_limit = target_limit.saturating_sub(reserved_non_window_tokens);
1011
1012 let non_system: Vec<Message> = active_messages
1013 .drain(..)
1014 .filter(|m| !matches!(m.role, bamboo_domain::Role::System))
1015 .collect();
1016
1017 if non_system.len() < 3 {
1018 tracing::debug!(
1019 "compression plan: not enough non-system messages ({}), need at least 3",
1020 non_system.len()
1021 );
1022 return Err(CompressionPlanError::NotEnoughMessages {
1023 non_system_count: non_system.len(),
1024 });
1025 }
1026
1027 let user_indexes = non_system
1028 .iter()
1029 .enumerate()
1030 .filter_map(|(index, message)| {
1031 matches!(message.role, bamboo_domain::Role::User).then_some(index)
1032 })
1033 .collect::<Vec<_>>();
1034 let keep_user_count = user_indexes.len().min(3);
1035 let anchor_index = if keep_user_count > 0 {
1036 user_indexes[user_indexes.len() - keep_user_count]
1037 } else {
1038 non_system
1039 .iter()
1040 .rposition(|m| matches!(m.role, bamboo_domain::Role::User))
1041 .unwrap_or(non_system.len().saturating_sub(1))
1042 };
1043 let protected_user_ids: HashSet<String> = if keep_user_count > 0 {
1044 user_indexes[user_indexes.len() - keep_user_count..]
1045 .iter()
1046 .filter_map(|idx| non_system.get(*idx))
1047 .map(|message| message.id.clone())
1048 .collect()
1049 } else {
1050 HashSet::new()
1051 };
1052
1053 tracing::debug!(
1054 "compression plan: context_window={}, target_limit={}, system_tokens={}, summary_tokens={}, window_limit={}, non_system_messages={}, keep_user_count={}, keep_from_index={}",
1055 context_window, target_limit, system_tokens, summary_tokens, window_limit, non_system.len(), keep_user_count, anchor_index
1056 );
1057
1058 let mut messages_to_summarize = non_system[..anchor_index].to_vec();
1063
1064 let mut never_compress_ids: Vec<String> = messages_to_summarize
1066 .iter()
1067 .filter(|m| m.never_compress || is_skill_tool_chain_message(m))
1068 .map(|m| m.id.clone())
1069 .collect();
1070
1071 let skill_call_ids: Vec<String> = messages_to_summarize
1073 .iter()
1074 .filter(|m| is_skill_tool_chain_message(m))
1075 .flat_map(|m| m.tool_calls.iter().flatten().map(|c| c.id.clone()))
1076 .collect();
1077 if !skill_call_ids.is_empty() {
1078 for m in &*messages_to_summarize {
1079 if let Some(ref call_id) = m.tool_call_id {
1080 if skill_call_ids.contains(call_id) && !never_compress_ids.contains(&m.id) {
1081 never_compress_ids.push(m.id.clone());
1082 }
1083 }
1084 }
1085 }
1086
1087 if !never_compress_ids.is_empty() {
1088 messages_to_summarize.retain(|m| !never_compress_ids.contains(&m.id));
1089 }
1090
1091 let non_system_count = non_system.len();
1092 let mut messages_to_keep = non_system[anchor_index..].to_vec();
1093 for id in &never_compress_ids {
1095 if let Some(msg) = non_system.iter().find(|m| &m.id == id) {
1096 if !messages_to_keep.iter().any(|m| m.id == *id) {
1097 messages_to_keep.push(msg.clone());
1098 }
1099 }
1100 }
1101
1102 while !messages_to_keep.is_empty() {
1103 let keep_tokens = counter.count_messages(&messages_to_keep);
1104 if keep_tokens <= window_limit {
1105 break;
1106 }
1107
1108 let Some(remove_index) = messages_to_keep.iter().position(|message| {
1109 !protected_user_ids.contains(message.id.as_str())
1110 && !never_compress_ids.contains(&message.id)
1111 }) else {
1112 break;
1114 };
1115 let moved = messages_to_keep.remove(remove_index);
1116 messages_to_summarize.push(moved);
1117 }
1118
1119 close_compressed_set_over_tool_chains(
1130 &mut messages_to_keep,
1131 &mut messages_to_summarize,
1132 &protected_user_ids,
1133 &never_compress_ids,
1134 );
1135
1136 if messages_to_summarize.is_empty() {
1137 tracing::debug!(
1138 "compression plan: messages_to_summarize is empty after anchor/keep splitting"
1139 );
1140 return Err(CompressionPlanError::NothingToCompress {
1141 anchor_index,
1142 non_system_count,
1143 });
1144 }
1145
1146 let compressed_message_ids = messages_to_summarize
1147 .iter()
1148 .map(|message| message.id.clone())
1149 .collect::<Vec<_>>();
1150
1151 let keep_tokens = counter.count_messages(&messages_to_keep);
1152 let active_before = exposure.active_usage_percent;
1153 let active_after = if context_window == 0 {
1156 0.0
1157 } else {
1158 let after_total = reserved_non_window_tokens.saturating_add(keep_tokens);
1159 (after_total as f64 / context_window as f64) * 100.0
1160 };
1161
1162 let segmenter = crate::segmenter::MessageSegmenter::new();
1165 let segments_removed = segmenter.segment(messages_to_summarize.clone()).len();
1166 let source_tokens = counter.count_messages(&messages_to_summarize);
1167 let actual_summary_content_tokens = counter.count_text(&summary_content);
1168 let previous_represented_source_tokens = session
1169 .conversation_summary
1170 .as_ref()
1171 .map(|summary| summary.represented_source_tokens)
1172 .unwrap_or(0);
1173
1174 Ok(CompressionPlan {
1175 kind: CompressionEventKind::Summary,
1176 logical_pass_id: None,
1177 fixed_prompt_tokens: 0,
1178 compressed_message_ids,
1179 messages_to_summarize,
1180 summary_tokens,
1181 summary_content,
1182 active_usage_before_percent: active_before,
1183 active_usage_after_percent: active_after,
1184 trigger_percent: budget.compression_trigger_percent,
1185 target_percent: budget.compression_target_percent,
1186 segments_removed,
1187 trigger_type,
1188 compression_ratio: 0.0,
1189 model_used: None,
1190 latency_ms: 0,
1191 source_tokens,
1192 represented_source_tokens: previous_represented_source_tokens.saturating_add(source_tokens),
1193 target_summary_tokens: actual_summary_content_tokens,
1194 actual_summary_content_tokens,
1195 summary_target_ratio: 0.0,
1196 summary_budget_clamped: false,
1197 summary_budget_clamp_reason: None,
1198 summarization_map_calls: 0,
1199 summarization_reduce_calls: 0,
1200 summarization_fallback_used: false,
1201 })
1202}
1203
1204pub(super) fn extract_recently_modified_files(messages: &[Message]) -> Vec<(String, String)> {
1207 let mut files = Vec::new();
1208 for message in messages {
1209 if let Some(ref tool_calls) = message.tool_calls {
1210 for call in tool_calls {
1211 let tool_name = call.function.name.as_str();
1212 if !matches!(tool_name, "Write" | "Edit" | "Bash") {
1213 continue;
1214 }
1215 let args = &call.function.arguments;
1216 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(args) {
1217 if let Some(path) = parsed.get("file_path").and_then(|v| v.as_str()) {
1218 files.push((path.to_string(), tool_name.to_string()));
1219 } else if let Some(cmd) = parsed.get("command").and_then(|v| v.as_str()) {
1220 for part in cmd.split_whitespace() {
1222 if part.contains('/')
1223 && (part.ends_with(".rs")
1224 || part.ends_with(".ts")
1225 || part.ends_with(".js")
1226 || part.ends_with(".toml")
1227 || part.ends_with(".json")
1228 || part.ends_with(".md"))
1229 {
1230 files.push((part.to_string(), "Bash".to_string()));
1231 }
1232 }
1233 }
1234 }
1235 }
1236 }
1237 }
1238 files.truncate(10);
1239 files
1240}
1241
1242pub(super) fn extract_key_decisions(messages: &[Message], limit: usize) -> Vec<String> {
1244 let decision_keywords = [
1245 "decided to",
1246 "approach is",
1247 "use ",
1248 "using ",
1249 "we'll go with",
1250 "the plan is",
1251 "strategy:",
1252 "solution:",
1253 "chose to",
1254 "switched to",
1255 "refactored to",
1256 "migrated to",
1257 "replaced with",
1258 ];
1259 let mut decisions = Vec::new();
1260 for message in messages {
1261 if !matches!(message.role, bamboo_domain::Role::Assistant) {
1262 continue;
1263 }
1264 let content = &message.content;
1265 for line in content.lines() {
1266 let line_lower = line.to_lowercase();
1267 if decision_keywords.iter().any(|kw| line_lower.contains(kw)) {
1268 let truncated: String = line.chars().take(200).collect();
1269 decisions.push(truncated);
1270 if decisions.len() >= limit {
1271 return decisions;
1272 }
1273 }
1274 }
1275 }
1276 decisions
1277}
1278
1279fn build_post_compaction_recovery_message(
1283 compressed_messages: &[Message],
1284 session: &Session,
1285) -> Option<Message> {
1286 if compressed_messages.is_empty() {
1287 return None;
1288 }
1289
1290 let mut sections = Vec::new();
1291
1292 let files = extract_recently_modified_files(compressed_messages);
1294 if !files.is_empty() {
1295 let mut section = String::from("## Recently Modified Files\n");
1296 for (path, tool) in &files {
1297 section.push_str(&format!("- {} ({})\n", path, tool));
1298 }
1299 sections.push(section);
1300 }
1301
1302 if let Some(ref task_list) = session.task_list {
1304 let active_items: Vec<_> = task_list
1305 .items
1306 .iter()
1307 .filter(|item| !matches!(item.status, bamboo_domain::TaskItemStatus::Completed))
1308 .collect();
1309 if !active_items.is_empty() {
1310 let mut section = String::from("## Active Tasks\n");
1311 for item in active_items.iter().take(10) {
1312 section.push_str(&format!("- [{:?}] {}\n", item.status, item.description));
1313 }
1314 sections.push(section);
1315 }
1316 }
1317
1318 let decisions = extract_key_decisions(compressed_messages, 5);
1320 if !decisions.is_empty() {
1321 let mut section = String::from("## Key Decisions\n");
1322 for decision in &decisions {
1323 section.push_str(&format!("- {}\n", decision));
1324 }
1325 sections.push(section);
1326 }
1327
1328 if sections.is_empty() {
1329 return None;
1330 }
1331
1332 let mut content = String::from("[post-compaction-recovery]\nContext extracted from compressed messages for continued work.\n\n");
1333 content.push_str(§ions.join("\n"));
1334
1335 let mut message = Message::assistant(content, None);
1336 message.never_compress = true;
1337 Some(message)
1338}
1339
1340struct SummaryQualityMetrics {
1341 file_coverage: f64,
1342 decision_coverage: f64,
1343}
1344
1345fn validate_summary_quality(summary: &str, messages: &[Message]) -> SummaryQualityMetrics {
1346 let files = extract_recently_modified_files(messages);
1347 let decisions = extract_key_decisions(messages, 10);
1348
1349 let files_mentioned = files
1350 .iter()
1351 .filter(|(path, _)| summary.contains(path.as_str()))
1352 .count();
1353 let file_coverage = if files.is_empty() {
1354 1.0
1355 } else {
1356 files_mentioned as f64 / files.len() as f64
1357 };
1358
1359 let decisions_mentioned = decisions
1360 .iter()
1361 .filter(|d| {
1362 let check_str: String = d.chars().take(50).collect();
1363 summary.contains(&check_str)
1364 })
1365 .count();
1366 let decision_coverage = if decisions.is_empty() {
1367 1.0
1368 } else {
1369 decisions_mentioned as f64 / decisions.len() as f64
1370 };
1371
1372 SummaryQualityMetrics {
1373 file_coverage,
1374 decision_coverage,
1375 }
1376}
1377
1378pub fn apply_compression_plan(session: &mut Session, plan: CompressionPlan) -> usize {
1379 let compressed_ids: HashSet<&str> = plan
1380 .compressed_message_ids
1381 .iter()
1382 .map(String::as_str)
1383 .collect();
1384
1385 let mut changed_indexes = Vec::new();
1386 for (index, message) in session.messages.iter_mut().enumerate() {
1387 if message.compressed || !compressed_ids.contains(message.id.as_str()) {
1388 continue;
1389 }
1390 message.compressed = true;
1391 changed_indexes.push(index);
1392 }
1393
1394 if changed_indexes.is_empty() {
1395 return 0;
1396 }
1397
1398 let mut event = CompressionEvent::new(
1399 changed_indexes.len(),
1400 plan.segments_removed,
1401 plan.active_usage_before_percent,
1402 plan.active_usage_after_percent,
1403 plan.summary_tokens,
1404 plan.trigger_type,
1405 plan.compression_ratio,
1406 plan.model_used.clone(),
1407 plan.latency_ms,
1408 );
1409 event.kind = plan.kind;
1410 if let Some(logical_pass_id) = plan.logical_pass_id.as_ref() {
1411 event.id.clone_from(logical_pass_id);
1412 }
1413 event.source_tokens = plan.source_tokens;
1414 event.fixed_prompt_tokens = plan.fixed_prompt_tokens;
1415 event.actual_summary_tokens = plan.actual_summary_content_tokens;
1416 event.target_summary_tokens = plan.target_summary_tokens;
1417 event.summary_target_ratio = plan.summary_target_ratio;
1418 event.actual_summary_ratio = if plan.represented_source_tokens == 0 {
1419 0.0
1420 } else {
1421 plan.actual_summary_content_tokens as f64 / plan.represented_source_tokens as f64
1422 };
1423 event.summary_budget_clamped = plan.summary_budget_clamped;
1424 event.summary_budget_clamp_reason = plan.summary_budget_clamp_reason.clone();
1425 event.summarization_map_calls = plan.summarization_map_calls;
1426 event.summarization_reduce_calls = plan.summarization_reduce_calls;
1427 event.summarization_fallback_used = plan.summarization_fallback_used;
1428 let event_id = event.id.clone();
1429 for index in changed_indexes {
1430 session.messages[index].compressed_by_event_id = Some(event_id.clone());
1431 }
1432 session.compression_events.push(event);
1433 session.conversation_summary = if plan.kind == CompressionEventKind::MessageSummary
1434 && plan.summary_content.trim().is_empty()
1435 {
1436 None
1437 } else {
1438 Some(
1439 ConversationSummary::new(
1440 &plan.summary_content,
1441 plan.compressed_message_ids.len(),
1442 plan.summary_tokens,
1443 )
1444 .with_compression_metrics(
1445 plan.represented_source_tokens,
1446 plan.target_summary_tokens,
1447 plan.summary_target_ratio,
1448 plan.summary_budget_clamped,
1449 plan.summary_budget_clamp_reason.clone(),
1450 ),
1451 )
1452 };
1453
1454 let compressed_messages: Vec<Message> = session
1457 .messages
1458 .iter()
1459 .filter(|m| compressed_ids.contains(m.id.as_str()))
1460 .cloned()
1461 .collect();
1462 if let Some(recovery) = (plan.kind != CompressionEventKind::MessageSummary)
1463 .then(|| build_post_compaction_recovery_message(&compressed_messages, session))
1464 .flatten()
1465 {
1466 let insert_pos = session
1468 .messages
1469 .iter()
1470 .rposition(|m| matches!(m.role, bamboo_domain::Role::User) && !m.compressed)
1471 .map(|pos| pos + 1)
1472 .unwrap_or(session.messages.len());
1473 session.messages.insert(insert_pos, recovery);
1474 }
1475
1476 let quality_source = if plan.kind == CompressionEventKind::MessageSummary {
1477 &plan.messages_to_summarize
1478 } else {
1479 &compressed_messages
1480 };
1481 let quality = validate_summary_quality(&plan.summary_content, quality_source);
1482 if quality.file_coverage < 0.5 || quality.decision_coverage < 0.3 {
1483 tracing::warn!(
1484 "[{}] Summary quality: file_coverage={:.0}%, decision_coverage={:.0}%",
1485 session.id,
1486 quality.file_coverage * 100.0,
1487 quality.decision_coverage * 100.0
1488 );
1489 }
1490
1491 let counter = TiktokenTokenCounter::default();
1498 let remaining_active: Vec<_> = session
1499 .messages
1500 .iter()
1501 .filter(|m| !m.compressed)
1502 .cloned()
1503 .collect();
1504 let system_msgs: Vec<_> = remaining_active
1505 .iter()
1506 .filter(|m| matches!(m.role, bamboo_domain::Role::System))
1507 .cloned()
1508 .collect();
1509 let window_msgs: Vec<_> = remaining_active
1510 .iter()
1511 .filter(|m| !matches!(m.role, bamboo_domain::Role::System))
1512 .cloned()
1513 .collect();
1514 let system_tokens = counter
1515 .count_messages(&system_msgs)
1516 .saturating_add(plan.fixed_prompt_tokens);
1517 let new_summary_tokens = plan.summary_tokens;
1518 let window_tokens = counter.count_messages(&window_msgs);
1519 let total_tokens = system_tokens
1520 .saturating_add(new_summary_tokens)
1521 .saturating_add(window_tokens);
1522 let previous_usage = session.token_usage.take();
1523 let budget_limit = previous_usage
1524 .as_ref()
1525 .map(|u| {
1526 if u.budget_limit > 0 {
1527 u.budget_limit
1528 } else {
1529 u.max_context_tokens
1532 }
1533 })
1534 .unwrap_or(0);
1535 let max_context_tokens = previous_usage
1536 .as_ref()
1537 .map(|u| u.max_context_tokens)
1538 .unwrap_or(0);
1539 let previous_cache_read_input_tokens = previous_usage
1540 .as_ref()
1541 .map(|u| u.cache_read_input_tokens)
1542 .unwrap_or(0);
1543 let previous_provider_prompt_usage = previous_usage
1544 .as_ref()
1545 .and_then(|u| u.provider_prompt_usage)
1546 .map(|mut usage| {
1547 usage.retained_from_previous_call = true;
1548 usage
1549 });
1550 session.token_usage = Some(bamboo_domain::TokenBudgetUsage {
1551 system_tokens,
1552 summary_tokens: new_summary_tokens,
1553 window_tokens,
1554 total_tokens,
1555 max_context_tokens,
1556 budget_limit,
1557 truncation_occurred: false,
1558 segments_removed: 0,
1559 prompt_cached_tool_outputs: 0,
1560 prompt_cached_tool_tokens_saved: 0,
1561 thinking_tokens: 0,
1562 cache_read_input_tokens: previous_cache_read_input_tokens,
1563 provider_prompt_usage: previous_provider_prompt_usage,
1564 });
1565
1566 session.reset_model_context_epoch(ModelContextResetReason::Compression);
1567 session.updated_at = Utc::now();
1568 plan.compressed_message_ids.len()
1569}
1570
1571pub fn compression_summary_message(summary_content: &str) -> Message {
1572 Message::system(format!(
1573 "<!-- CONVERSATION_SUMMARY_START -->\n\
1574 ## Previous Conversation Summary\n\
1575 The following is compressed historical context for continuity only.\n\
1576 It is background memory, not a new user request. Follow the current task list and recent messages over this summary when they conflict.\n\n\
1577 {}\n\
1578 <!-- CONVERSATION_SUMMARY_END -->",
1579 summary_content
1580 ))
1581}
1582
1583pub fn active_messages_for_budget(session: &Session) -> Vec<Message> {
1584 session
1585 .messages
1586 .iter()
1587 .filter(|message| !message.compressed)
1588 .cloned()
1589 .collect()
1590}
1591
1592pub fn summary_source_messages(session: &Session) -> Vec<Message> {
1593 session
1594 .messages
1595 .iter()
1596 .filter(|message| !message.compressed)
1597 .filter(|message| !matches!(message.role, bamboo_domain::Role::System))
1598 .cloned()
1599 .collect()
1600}
1601
1602pub fn build_summary_prompt(
1603 session: &Session,
1604 messages: &[Message],
1605 existing_summary: Option<&str>,
1606) -> String {
1607 let mut content = String::new();
1608 content.push_str(
1609 "You are compressing conversation history for continued work. Produce a compact but reliable working-memory summary.\n\n",
1610 );
1611 content.push_str(
1612 "Critical requirements:\n- First capture the in-flight work right before compression (what was being done, where, and with which tool/file)\n- Distinguish clearly between ACTIVE work, COMPLETED work, and OBSOLETE or superseded work\n- Do not restate old tasks as active unless they are still unresolved\n- The current task list is the source of truth for what is actively being worked on\n- Preserve constraints, decisions, file paths, code changes, errors, tool findings, blockers, and the next step\n- If earlier plans conflict with the current task list or newer messages, treat the earlier plans as obsolete or completed\n- Explicitly evaluate each clear user requirement (e.g. requirement 1, requirement 2) with a status and evidence\n- Return only summary text in the same language as the conversation\n\n",
1613 );
1614
1615 if let Some(existing) = existing_summary.map(str::trim).filter(|s| !s.is_empty()) {
1616 content.push_str("## Existing Summary\n");
1617 content.push_str(existing);
1618 content.push_str("\n\n");
1619 }
1620
1621 let task_list_prompt = session.format_task_list_for_prompt();
1622 if !task_list_prompt.trim().is_empty() {
1623 content.push_str("## Current Task List\n");
1624 content.push_str(task_list_prompt.trim());
1625 content.push_str("\n\n");
1626 }
1627
1628 content.push_str(
1629 "## Required Output Sections\n1. Pre-compression in-flight work (what was being done immediately before compression)\n2. Current active objective\n3. Requirement checklist (Requirement | Status: completed/in_progress/pending/blocked/obsolete | Evidence)\n4. Active tasks\n5. Completed tasks\n6. Obsolete or superseded tasks\n7. Important context and constraints\n8. Files, code, and tool findings\n9. Open issues and next step\n\n",
1630 );
1631
1632 content.push_str("## Messages To Compress\n\n");
1633 for message in messages {
1634 let role = match message.role {
1635 bamboo_domain::Role::System => continue,
1636 bamboo_domain::Role::User => "User",
1637 bamboo_domain::Role::Assistant => match message.phase {
1638 Some(MessagePhase::Commentary) => "Assistant Commentary",
1639 Some(MessagePhase::FinalAnswer) => "Assistant Final",
1640 None => "Assistant",
1641 },
1642 bamboo_domain::Role::Tool => "Tool Result",
1643 };
1644
1645 content.push_str("### ");
1646 content.push_str(role);
1647 content.push('\n');
1648 if let Some(tool_calls) = &message.tool_calls {
1649 if !tool_calls.is_empty() {
1650 let names = tool_calls
1651 .iter()
1652 .map(|call| call.function.name.as_str())
1653 .collect::<Vec<_>>()
1654 .join(", ");
1655 content.push_str("Called tools: ");
1656 content.push_str(&names);
1657 content.push('\n');
1658 }
1659 }
1660 if let Some(tool_call_id) = &message.tool_call_id {
1661 content.push_str("Tool call id: ");
1662 content.push_str(tool_call_id);
1663 content.push('\n');
1664 }
1665 content.push_str(&message.content);
1669 content.push_str("\n\n");
1670 }
1671
1672 content.push_str(
1673 "Return only the summary text. Be explicit about what is active now versus what is already done or no longer relevant.",
1674 );
1675 content
1676}
1677
1678#[cfg(test)]
1679#[path = "message_summary_tests.rs"]
1680mod message_summary_tests;
1681
1682#[cfg(test)]
1683mod tests {
1684 use super::*;
1685 use bamboo_domain::{
1686 CompressionEventKind, FunctionCall, TaskItem, TaskItemStatus, TaskList, ToolCall,
1687 };
1688 use bamboo_domain::{
1689 ModelContextResetReason, ModelContextState, ProviderPromptUsage, TokenBudgetUsage,
1690 };
1691 use chrono::Utc;
1692
1693 fn make_budget() -> TokenBudget {
1694 TokenBudget {
1695 max_context_tokens: 1000,
1696 max_output_tokens: 100,
1697 strategy: BudgetStrategy::Hybrid {
1698 window_size: 20,
1699 enable_summarization: true,
1700 },
1701 safety_margin: 0,
1702 compression_trigger_percent: 50,
1703 compression_target_percent: 20,
1704 working_reserve_tokens: 0,
1705 fallback_trigger_percent: 75,
1706 prompt_cache_min_tool_output_chars: 1_200,
1707 prompt_cache_head_chars: 280,
1708 prompt_cache_tail_chars: 180,
1709 prompt_cache_recent_user_turns: 2,
1710 prompt_cache_recent_tool_chains: 2,
1711 max_tool_output_tokens: 0,
1712 }
1713 }
1714
1715 fn make_session_with_pressure() -> Session {
1716 let mut session = Session::new("compression-hysteresis", "gpt-4o-mini");
1717 session.token_budget = Some(make_budget());
1718 session.add_message(Message::system("system"));
1719 for i in 0..3 {
1720 session.add_message(Message::user(format!(
1721 "User message {i}: {}",
1722 "alpha beta gamma delta epsilon ".repeat(2)
1723 )));
1724 session.add_message(Message::assistant(
1725 format!(
1726 "Assistant message {i}: {}",
1727 "work log decisions next steps ".repeat(2)
1728 ),
1729 None,
1730 ));
1731 }
1732 session
1733 }
1734
1735 #[test]
1736 fn context_window_usage_percent_uses_context_window_denominator() {
1737 assert_eq!(context_window_usage_percent(0, 0), 0.0);
1738 assert_eq!(context_window_usage_percent(500, 1000), 50.0);
1739 }
1740
1741 #[test]
1742 fn estimate_context_compression_exposure_crosses_trigger_when_usage_is_high_enough() {
1743 let mut session = make_session_with_pressure();
1744 if let Some(budget) = session.token_budget.as_mut() {
1745 budget.compression_trigger_percent = 10;
1746 }
1747 let exposure = estimate_context_compression_exposure(
1748 &session,
1749 "gpt-4o-mini",
1750 session.token_budget.as_ref(),
1751 );
1752 assert!(exposure.active_usage_percent >= 10.0);
1753 assert!(exposure.should_expose_tool);
1754 }
1755
1756 #[test]
1757 fn estimate_context_compression_exposure_stays_below_trigger_when_usage_is_low() {
1758 let mut session = make_session_with_pressure();
1759 if let Some(budget) = session.token_budget.as_mut() {
1760 budget.compression_trigger_percent = 99;
1761 }
1762
1763 let exposure = estimate_context_compression_exposure(
1764 &session,
1765 "gpt-4o-mini",
1766 session.token_budget.as_ref(),
1767 );
1768
1769 assert!(exposure.active_usage_percent < 99.0);
1770 assert!(!exposure.should_expose_tool);
1771 }
1772
1773 #[test]
1774 fn build_summary_prompt_includes_task_list_and_state_sections() {
1775 let mut session = Session::new("summary-prompt", "gpt-4o-mini");
1776 session.set_task_list(TaskList {
1777 session_id: session.id.clone(),
1778 title: "Task List".to_string(),
1779 items: vec![
1780 TaskItem {
1781 id: "task_1".to_string(),
1782 description: "检查 51% 又回落到 50% 的触发逻辑".to_string(),
1783 status: TaskItemStatus::InProgress,
1784 depends_on: Vec::new(),
1785 notes: "避免刚压缩完又立刻再次压缩".to_string(),
1786 ..TaskItem::default()
1787 },
1788 TaskItem {
1789 id: "task_2".to_string(),
1790 description: "重写 summarizer prompt 并纳入 task list".to_string(),
1791 status: TaskItemStatus::Pending,
1792 depends_on: Vec::new(),
1793 notes: String::new(),
1794 ..TaskItem::default()
1795 },
1796 ],
1797 created_at: Utc::now(),
1798 updated_at: Utc::now(),
1799 });
1800 let prompt = build_summary_prompt(
1801 &session,
1802 &[
1803 Message::user("继续修复 context compression"),
1804 Message::assistant("先分析 trigger / target / summary", None),
1805 ],
1806 Some("old summary"),
1807 );
1808
1809 assert!(prompt.contains("## Current Task List"));
1810 assert!(prompt.contains("Current active objective"));
1811 assert!(prompt.contains("Requirement checklist"));
1812 assert!(prompt.contains("Active tasks"));
1813 assert!(prompt.contains("Completed tasks"));
1814 assert!(prompt.contains("Obsolete or superseded tasks"));
1815 assert!(prompt.contains("检查 51% 又回落到 50% 的触发逻辑"));
1816 assert!(prompt.contains("old summary"));
1817 }
1818
1819 #[test]
1820 fn forced_plan_keeps_last_three_user_messages_active() {
1821 let budget = TokenBudget {
1822 max_context_tokens: 1200,
1823 max_output_tokens: 100,
1824 strategy: BudgetStrategy::Hybrid {
1825 window_size: 20,
1826 enable_summarization: true,
1827 },
1828 safety_margin: 0,
1829 compression_trigger_percent: 80,
1830 compression_target_percent: 20,
1831 working_reserve_tokens: 0,
1832 fallback_trigger_percent: 75,
1833 prompt_cache_min_tool_output_chars: 1_200,
1834 prompt_cache_head_chars: 280,
1835 prompt_cache_tail_chars: 180,
1836 prompt_cache_recent_user_turns: 2,
1837 prompt_cache_recent_tool_chains: 2,
1838 max_tool_output_tokens: 0,
1839 };
1840 let mut session = Session::new("keep-last-three-user-turns", "gpt-4o-mini");
1841 session.token_budget = Some(budget.clone());
1842 session.add_message(Message::system("system"));
1843 for i in 0..6 {
1844 session.add_message(Message::user(format!(
1845 "U{i}: {}",
1846 "alpha beta gamma ".repeat(8)
1847 )));
1848 session.add_message(Message::assistant(
1849 format!("A{i}: {}", "analysis plan steps ".repeat(8)),
1850 None,
1851 ));
1852 }
1853
1854 let plan = build_forced_compression_plan_with_summary(
1855 &session,
1856 "gpt-4o-mini",
1857 Some(&budget),
1858 "summary".to_string(),
1859 CompressionTriggerType::CriticalOverflow,
1860 )
1861 .expect("forced plan should build");
1862
1863 let compressed_ids = plan
1864 .compressed_message_ids
1865 .iter()
1866 .map(String::as_str)
1867 .collect::<HashSet<_>>();
1868 let kept_user_contents = session
1869 .messages
1870 .iter()
1871 .filter(|message| !matches!(message.role, bamboo_domain::Role::System))
1872 .filter(|message| !compressed_ids.contains(message.id.as_str()))
1873 .filter(|message| matches!(message.role, bamboo_domain::Role::User))
1874 .map(|message| message.content.clone())
1875 .collect::<Vec<_>>();
1876
1877 assert!(
1878 kept_user_contents.len() >= 3,
1879 "expected to keep at least 3 user messages, got {}",
1880 kept_user_contents.len()
1881 );
1882 assert!(kept_user_contents
1883 .iter()
1884 .any(|content| content.starts_with("U3:")));
1885 assert!(kept_user_contents
1886 .iter()
1887 .any(|content| content.starts_with("U4:")));
1888 assert!(kept_user_contents
1889 .iter()
1890 .any(|content| content.starts_with("U5:")));
1891 }
1892
1893 #[test]
1894 fn estimate_exposure_prefers_persisted_budget_usage_when_higher() {
1895 let mut session = Session::new("persisted-usage", "gpt-4o-mini");
1896 session.token_budget = Some(TokenBudget {
1897 max_context_tokens: 100_000,
1898 max_output_tokens: 1_000,
1899 strategy: BudgetStrategy::Hybrid {
1900 window_size: 20,
1901 enable_summarization: true,
1902 },
1903 safety_margin: 0,
1904 compression_trigger_percent: 80,
1905 compression_target_percent: 50,
1906 working_reserve_tokens: 0,
1907 fallback_trigger_percent: 75,
1908 prompt_cache_min_tool_output_chars: 1_200,
1909 prompt_cache_head_chars: 280,
1910 prompt_cache_tail_chars: 180,
1911 prompt_cache_recent_user_turns: 2,
1912 prompt_cache_recent_tool_chains: 2,
1913 max_tool_output_tokens: 0,
1914 });
1915 session.add_message(Message::system("system"));
1916 session.add_message(Message::user("short"));
1917 session.add_message(Message::assistant("short", None));
1918 session.add_message(Message::user("follow-up"));
1919 session.add_message(Message::assistant("reply", None));
1920 session.token_usage = Some(TokenBudgetUsage {
1921 system_tokens: 100,
1922 summary_tokens: 0,
1923 window_tokens: 95_900,
1924 total_tokens: 96_000,
1925 max_context_tokens: 100_000,
1926 budget_limit: 10_000,
1927 truncation_occurred: true,
1928 segments_removed: 12,
1929 prompt_cached_tool_outputs: 0,
1930 prompt_cached_tool_tokens_saved: 0,
1931 thinking_tokens: 0,
1932 cache_read_input_tokens: 0,
1933 provider_prompt_usage: None,
1934 });
1935
1936 let exposure = estimate_context_compression_exposure(
1937 &session,
1938 "gpt-4o-mini",
1939 session.token_budget.as_ref(),
1940 );
1941
1942 assert!(
1943 exposure.active_usage_percent >= 96.0,
1944 "expected persisted context-window usage to drive exposure, got {}",
1945 exposure.active_usage_percent
1946 );
1947 assert!(exposure.should_expose_tool);
1948 }
1949
1950 #[test]
1951 fn never_compress_messages_are_excluded_from_summarize_set() {
1952 let budget = TokenBudget {
1953 max_context_tokens: 1200,
1954 max_output_tokens: 100,
1955 strategy: BudgetStrategy::Hybrid {
1956 window_size: 20,
1957 enable_summarization: true,
1958 },
1959 safety_margin: 0,
1960 compression_trigger_percent: 80,
1961 compression_target_percent: 20,
1962 working_reserve_tokens: 0,
1963 fallback_trigger_percent: 75,
1964 prompt_cache_min_tool_output_chars: 1_200,
1965 prompt_cache_head_chars: 280,
1966 prompt_cache_tail_chars: 180,
1967 prompt_cache_recent_user_turns: 2,
1968 prompt_cache_recent_tool_chains: 2,
1969 max_tool_output_tokens: 0,
1970 };
1971 let mut session = Session::new("never-compress-test", "gpt-4o-mini");
1972 session.token_budget = Some(budget.clone());
1973 session.add_message(Message::system("system"));
1974
1975 session.add_message(Message::user("Old question about X"));
1977 session.add_message(Message::assistant("Old answer about X", None));
1978
1979 let mut protected = Message::user("Critical context that must survive");
1981 protected.never_compress = true;
1982 session.add_message(protected);
1983 session.add_message(Message::assistant("Response to critical", None));
1984
1985 for i in 0..4 {
1987 session.add_message(Message::user(format!(
1988 "Recent U{i}: {}",
1989 "padding text to fill budget ".repeat(6)
1990 )));
1991 session.add_message(Message::assistant(
1992 format!("Recent A{i}: {}", "reply padding text ".repeat(6)),
1993 None,
1994 ));
1995 }
1996
1997 let plan = build_forced_compression_plan_with_summary(
1998 &session,
1999 "gpt-4o-mini",
2000 Some(&budget),
2001 "summary".to_string(),
2002 CompressionTriggerType::Auto,
2003 )
2004 .expect("plan should build");
2005
2006 let compressed_ids: HashSet<&str> = plan
2007 .compressed_message_ids
2008 .iter()
2009 .map(String::as_str)
2010 .collect();
2011
2012 let protected_msg = session
2014 .messages
2015 .iter()
2016 .find(|m| m.never_compress)
2017 .expect("should find the protected message");
2018
2019 assert!(
2020 !compressed_ids.contains(protected_msg.id.as_str()),
2021 "never_compress message should NOT be in the compressed set"
2022 );
2023 }
2024
2025 #[test]
2026 fn legacy_skill_tooling_matches_exact_names_without_narrowing_roles() {
2027 for role in [
2028 bamboo_domain::Role::Assistant,
2029 bamboo_domain::Role::User,
2030 bamboo_domain::Role::Tool,
2031 bamboo_domain::Role::System,
2032 ] {
2033 for (name, expected) in [
2034 ("load_skill", true),
2035 ("read_skill_resource", true),
2036 ("LOAD_SKILL", false),
2037 ("namespace::load_skill", false),
2038 ("mcp__server__load_skill", false),
2039 ] {
2040 let mut message = Message::assistant("", None);
2041 message.role = role.clone();
2042 message.tool_calls = Some(vec![ToolCall {
2043 id: "candidate-call".into(),
2044 tool_type: "function".into(),
2045 function: FunctionCall {
2046 name: name.into(),
2047 arguments: "{}".into(),
2048 },
2049 }]);
2050 assert_eq!(
2051 is_skill_tool_chain_message(&message),
2052 expected,
2053 "{role:?}/{name}"
2054 );
2055 }
2056 }
2057 }
2058
2059 #[test]
2060 fn legacy_skill_mixed_calls_keep_all_results_through_plan_and_apply() {
2061 for name in ["load_skill", "read_skill_resource"] {
2062 let mut session = Session::new("mixed-skill-history", "gpt-4o-mini");
2063 let calls = [("skill-call", name), ("other-call", "Read")]
2064 .into_iter()
2065 .map(|(id, name)| ToolCall {
2066 id: id.into(),
2067 tool_type: "function".into(),
2068 function: FunctionCall {
2069 name: name.into(),
2070 arguments: "{}".into(),
2071 },
2072 })
2073 .collect();
2074 session.add_message(Message::assistant("", Some(calls)));
2075 session.add_message(Message::tool_result("skill-call", "skill payload"));
2076 session.add_message(Message::tool_result("other-call", "unrelated payload"));
2077 let protected_ids: Vec<_> = session.messages.iter().map(|m| m.id.clone()).collect();
2078 let original_chain = serde_json::to_vec(&session.messages).unwrap();
2079 for index in 0..4 {
2080 session.add_message(Message::user(format!("question {index}")));
2081 session.add_message(Message::assistant(format!("answer {index}"), None));
2082 }
2083 let protection = protected_compression_message_ids(&session.messages);
2084 assert!(protected_ids.iter().all(|id| protection.contains(id)));
2085 let plan = build_forced_compression_plan_with_summary(
2086 &session,
2087 "gpt-4o-mini",
2088 Some(&make_budget()),
2089 "summary".into(),
2090 CompressionTriggerType::Auto,
2091 )
2092 .unwrap();
2093 assert!(!plan.compressed_message_ids.is_empty());
2094 assert!(protected_ids
2095 .iter()
2096 .all(|id| !plan.compressed_message_ids.contains(id)));
2097 assert!(apply_compression_plan(&mut session, plan) > 0);
2098 let retained: Vec<_> = session
2099 .messages
2100 .iter()
2101 .filter(|message| protected_ids.contains(&message.id))
2102 .collect();
2103 assert_eq!(serde_json::to_vec(&retained).unwrap(), original_chain);
2104 }
2105 }
2106
2107 #[test]
2108 fn skill_tool_chain_messages_are_protected_from_compression() {
2109 let budget = TokenBudget {
2110 max_context_tokens: 1200,
2111 max_output_tokens: 100,
2112 strategy: BudgetStrategy::Hybrid {
2113 window_size: 20,
2114 enable_summarization: true,
2115 },
2116 safety_margin: 0,
2117 compression_trigger_percent: 80,
2118 compression_target_percent: 20,
2119 working_reserve_tokens: 0,
2120 fallback_trigger_percent: 75,
2121 prompt_cache_min_tool_output_chars: 1_200,
2122 prompt_cache_head_chars: 280,
2123 prompt_cache_tail_chars: 180,
2124 prompt_cache_recent_user_turns: 2,
2125 prompt_cache_recent_tool_chains: 2,
2126 max_tool_output_tokens: 0,
2127 };
2128 let mut session = Session::new("skill-chain-test", "gpt-4o-mini");
2129 session.token_budget = Some(budget.clone());
2130 session.add_message(Message::system("system"));
2131
2132 let mut skill_call = Message::assistant(String::new(), None);
2134 skill_call.tool_calls = Some(vec![ToolCall {
2135 id: "tc-skill".to_string(),
2136 tool_type: "function".to_string(),
2137 function: FunctionCall {
2138 name: "load_skill".to_string(),
2139 arguments: r#"{"skill_id":"my-skill"}"#.to_string(),
2140 },
2141 }]);
2142 session.add_message(skill_call);
2143
2144 let mut skill_result = Message::tool_result("tc-skill", "skill loaded");
2145 skill_result.tool_success = Some(true);
2146 session.add_message(skill_result);
2147
2148 for i in 0..6 {
2150 session.add_message(Message::user(format!(
2151 "U{i}: {}",
2152 "alpha beta gamma delta ".repeat(8)
2153 )));
2154 session.add_message(Message::assistant(
2155 format!("A{i}: {}", "analysis steps plan ".repeat(8)),
2156 None,
2157 ));
2158 }
2159
2160 let plan = build_forced_compression_plan_with_summary(
2161 &session,
2162 "gpt-4o-mini",
2163 Some(&budget),
2164 "summary".to_string(),
2165 CompressionTriggerType::Auto,
2166 )
2167 .expect("plan should build");
2168
2169 let compressed_ids: HashSet<&str> = plan
2170 .compressed_message_ids
2171 .iter()
2172 .map(String::as_str)
2173 .collect();
2174
2175 let skill_messages: Vec<&Message> = session
2177 .messages
2178 .iter()
2179 .filter(|m| {
2180 m.tool_calls
2181 .as_ref()
2182 .is_some_and(|calls| calls.iter().any(|c| c.function.name == "load_skill"))
2183 || m.tool_call_id.as_deref() == Some("tc-skill")
2184 })
2185 .collect();
2186
2187 for msg in &skill_messages {
2188 assert!(
2189 !compressed_ids.contains(msg.id.as_str()),
2190 "skill tool chain message {} should NOT be compressed",
2191 msg.id
2192 );
2193 }
2194 }
2195
2196 #[test]
2197 fn generic_tool_chain_is_never_split_by_forced_compression() {
2198 let budget = TokenBudget {
2202 max_context_tokens: 1200,
2203 max_output_tokens: 100,
2204 strategy: BudgetStrategy::Hybrid {
2205 window_size: 20,
2206 enable_summarization: true,
2207 },
2208 safety_margin: 0,
2209 compression_trigger_percent: 80,
2210 compression_target_percent: 20,
2211 working_reserve_tokens: 0,
2212 fallback_trigger_percent: 75,
2213 prompt_cache_min_tool_output_chars: 1_200,
2214 prompt_cache_head_chars: 280,
2215 prompt_cache_tail_chars: 180,
2216 prompt_cache_recent_user_turns: 2,
2217 prompt_cache_recent_tool_chains: 2,
2218 max_tool_output_tokens: 0,
2219 };
2220 let mut session = Session::new("generic-chain-test", "gpt-4o-mini");
2221 session.token_budget = Some(budget.clone());
2222 session.add_message(Message::system("system"));
2223
2224 let mut call = Message::assistant(String::new(), None);
2227 call.tool_calls = Some(vec![ToolCall {
2228 id: "tc-gen".to_string(),
2229 tool_type: "function".to_string(),
2230 function: FunctionCall {
2231 name: "search".to_string(),
2232 arguments: r#"{"q":"rust"}"#.to_string(),
2233 },
2234 }]);
2235 session.add_message(call);
2236 let mut result = Message::tool_result("tc-gen", "search result payload ".repeat(20));
2237 result.tool_success = Some(true);
2238 session.add_message(result);
2239
2240 for i in 0..8 {
2242 session.add_message(Message::user(format!(
2243 "U{i}: {}",
2244 "alpha beta gamma delta ".repeat(8)
2245 )));
2246 session.add_message(Message::assistant(
2247 format!("A{i}: {}", "analysis steps plan ".repeat(8)),
2248 None,
2249 ));
2250 }
2251
2252 let plan = build_forced_compression_plan_with_summary(
2253 &session,
2254 "gpt-4o-mini",
2255 Some(&budget),
2256 "summary".to_string(),
2257 CompressionTriggerType::Auto,
2258 )
2259 .expect("plan should build");
2260
2261 let compressed: HashSet<&str> = plan
2262 .compressed_message_ids
2263 .iter()
2264 .map(String::as_str)
2265 .collect();
2266
2267 let assistant_id = session
2268 .messages
2269 .iter()
2270 .find(|m| {
2271 m.tool_calls
2272 .as_ref()
2273 .is_some_and(|c| c.iter().any(|tc| tc.id == "tc-gen"))
2274 })
2275 .map(|m| m.id.as_str())
2276 .expect("assistant tool_use message present");
2277 let result_id = session
2278 .messages
2279 .iter()
2280 .find(|m| m.tool_call_id.as_deref() == Some("tc-gen"))
2281 .map(|m| m.id.as_str())
2282 .expect("tool_result message present");
2283
2284 assert_eq!(
2286 compressed.contains(assistant_id),
2287 compressed.contains(result_id),
2288 "generic tool_use ({assistant_id}) and its tool_result ({result_id}) must not be split"
2289 );
2290 }
2291
2292 fn tool_use_message(call_id: &str) -> Message {
2293 let mut assistant = Message::assistant(String::new(), None);
2294 assistant.tool_calls = Some(vec![ToolCall {
2295 id: call_id.to_string(),
2296 tool_type: "function".to_string(),
2297 function: FunctionCall {
2298 name: "search".to_string(),
2299 arguments: "{}".to_string(),
2300 },
2301 }]);
2302 assistant
2303 }
2304
2305 #[test]
2306 fn close_compressed_set_over_tool_chains_reunites_a_split_chain() {
2307 let assistant = tool_use_message("tc-1");
2310 let result = Message::tool_result("tc-1", "result payload");
2311 let assistant_id = assistant.id.clone();
2312 let result_id = result.id.clone();
2313
2314 let mut messages_to_keep = vec![result];
2315 let mut messages_to_summarize = vec![assistant];
2316
2317 close_compressed_set_over_tool_chains(
2318 &mut messages_to_keep,
2319 &mut messages_to_summarize,
2320 &HashSet::new(),
2321 &[],
2322 );
2323
2324 assert!(
2326 messages_to_keep.is_empty(),
2327 "orphaned tool_result must be moved into the compressed set"
2328 );
2329 let summarized: HashSet<&str> = messages_to_summarize
2330 .iter()
2331 .map(|m| m.id.as_str())
2332 .collect();
2333 assert!(summarized.contains(assistant_id.as_str()));
2334 assert!(summarized.contains(result_id.as_str()));
2335 }
2336
2337 #[test]
2338 fn close_compressed_set_over_tool_chains_respects_protected_messages() {
2339 let assistant = tool_use_message("tc-2");
2341 let result = Message::tool_result("tc-2", "result");
2342 let result_id = result.id.clone();
2343
2344 let mut messages_to_keep = vec![result];
2345 let mut messages_to_summarize = vec![assistant];
2346 let never_compress_ids = vec![result_id.clone()];
2347
2348 close_compressed_set_over_tool_chains(
2349 &mut messages_to_keep,
2350 &mut messages_to_summarize,
2351 &HashSet::new(),
2352 &never_compress_ids,
2353 );
2354
2355 assert_eq!(
2356 messages_to_keep.len(),
2357 1,
2358 "protected result must stay in keep"
2359 );
2360 assert_eq!(messages_to_keep[0].id, result_id);
2361 }
2362
2363 #[test]
2364 fn recovery_message_returns_none_for_empty_messages() {
2365 let session = Session::new("recovery-empty", "model");
2366 let result = build_post_compaction_recovery_message(&[], &session);
2367 assert!(result.is_none());
2368 }
2369
2370 #[test]
2371 fn recovery_message_has_never_compress_flag() {
2372 let mut session = Session::new("recovery-flag", "model");
2373 let messages = vec![Message::assistant("no decisions here", None)];
2374 session.set_task_list(TaskList {
2375 session_id: session.id.clone(),
2376 title: "Tasks".to_string(),
2377 items: vec![TaskItem {
2378 id: "t1".to_string(),
2379 description: "Active task".to_string(),
2380 status: TaskItemStatus::InProgress,
2381 ..TaskItem::default()
2382 }],
2383 created_at: Utc::now(),
2384 updated_at: Utc::now(),
2385 });
2386 let recovery = build_post_compaction_recovery_message(&messages, &session)
2387 .expect("should return recovery message");
2388 assert!(recovery.never_compress);
2389 assert!(recovery.content.contains("[post-compaction-recovery]"));
2390 }
2391
2392 #[test]
2393 fn recovery_message_extracts_file_paths_from_tool_calls() {
2394 let session = Session::new("recovery-files", "model");
2395 let mut write_call = Message::assistant("writing file", None);
2396 write_call.tool_calls = Some(vec![ToolCall {
2397 id: "tc1".to_string(),
2398 tool_type: "function".to_string(),
2399 function: FunctionCall {
2400 name: "Write".to_string(),
2401 arguments: r#"{"file_path":"/src/main.rs","content":"fn main() {}"}"#.to_string(),
2402 },
2403 }]);
2404 let mut edit_call = Message::assistant("editing file", None);
2405 edit_call.tool_calls = Some(vec![ToolCall {
2406 id: "tc2".to_string(),
2407 tool_type: "function".to_string(),
2408 function: FunctionCall {
2409 name: "Edit".to_string(),
2410 arguments: r#"{"file_path":"/lib/utils.rs","old":"x","new":"y"}"#.to_string(),
2411 },
2412 }]);
2413 let messages = vec![write_call, edit_call];
2414
2415 let recovery = build_post_compaction_recovery_message(&messages, &session)
2416 .expect("should return recovery");
2417 assert!(recovery.content.contains("/src/main.rs"));
2418 assert!(recovery.content.contains("/lib/utils.rs"));
2419 assert!(recovery.content.contains("Recently Modified Files"));
2420 }
2421
2422 #[test]
2423 fn recovery_message_includes_active_tasks() {
2424 let mut session = Session::new("recovery-tasks", "model");
2425 session.set_task_list(TaskList {
2426 session_id: session.id.clone(),
2427 title: "Tasks".to_string(),
2428 items: vec![
2429 TaskItem {
2430 id: "t1".to_string(),
2431 description: "Fix auth middleware".to_string(),
2432 status: TaskItemStatus::InProgress,
2433 ..TaskItem::default()
2434 },
2435 TaskItem {
2436 id: "t2".to_string(),
2437 description: "Add tests".to_string(),
2438 status: TaskItemStatus::Pending,
2439 ..TaskItem::default()
2440 },
2441 TaskItem {
2442 id: "t3".to_string(),
2443 description: "Done task".to_string(),
2444 status: TaskItemStatus::Completed,
2445 ..TaskItem::default()
2446 },
2447 ],
2448 created_at: Utc::now(),
2449 updated_at: Utc::now(),
2450 });
2451 let messages = vec![Message::assistant("some work", None)];
2452
2453 let recovery = build_post_compaction_recovery_message(&messages, &session)
2454 .expect("should return recovery");
2455 assert!(recovery.content.contains("Active Tasks"));
2456 assert!(recovery.content.contains("Fix auth middleware"));
2457 assert!(recovery.content.contains("Add tests"));
2458 assert!(!recovery.content.contains("Done task"));
2460 }
2461
2462 #[test]
2463 fn apply_compression_plan_injects_recovery_message() {
2464 let budget = TokenBudget {
2465 max_context_tokens: 1200,
2466 max_output_tokens: 100,
2467 strategy: BudgetStrategy::Hybrid {
2468 window_size: 20,
2469 enable_summarization: true,
2470 },
2471 safety_margin: 0,
2472 compression_trigger_percent: 80,
2473 compression_target_percent: 20,
2474 working_reserve_tokens: 0,
2475 fallback_trigger_percent: 75,
2476 prompt_cache_min_tool_output_chars: 1_200,
2477 prompt_cache_head_chars: 280,
2478 prompt_cache_tail_chars: 180,
2479 prompt_cache_recent_user_turns: 2,
2480 prompt_cache_recent_tool_chains: 2,
2481 max_tool_output_tokens: 0,
2482 };
2483 let mut session = Session::new("recovery-inject", "gpt-4o-mini");
2484 session.token_budget = Some(budget.clone());
2485 session.model_context_state = Some(ModelContextState {
2486 prefix_epoch: 7,
2487 cache_scope_sha256: Some("old-scope".to_string()),
2488 ..ModelContextState::default()
2489 });
2490 session.add_message(Message::system("system"));
2491
2492 let mut write_msg = Message::assistant("writing", None);
2494 write_msg.tool_calls = Some(vec![ToolCall {
2495 id: "tc-w".to_string(),
2496 tool_type: "function".to_string(),
2497 function: FunctionCall {
2498 name: "Write".to_string(),
2499 arguments: r#"{"file_path":"/src/lib.rs","content":"pub fn hello() {}"}"#
2500 .to_string(),
2501 },
2502 }]);
2503 session.add_message(Message::user("Write the file"));
2504 session.add_message(write_msg);
2505
2506 for i in 0..6 {
2508 session.add_message(Message::user(format!(
2509 "U{i}: {}",
2510 "alpha beta gamma delta ".repeat(8)
2511 )));
2512 session.add_message(Message::assistant(
2513 format!("A{i}: {}", "analysis plan ".repeat(8)),
2514 None,
2515 ));
2516 }
2517
2518 let plan = build_forced_compression_plan_with_summary(
2519 &session,
2520 "gpt-4o-mini",
2521 Some(&budget),
2522 "summary text".to_string(),
2523 CompressionTriggerType::Auto,
2524 )
2525 .expect("plan should build");
2526
2527 assert!(!plan.compressed_message_ids.is_empty());
2528
2529 session.token_usage = Some(TokenBudgetUsage {
2530 system_tokens: 10,
2531 summary_tokens: 0,
2532 window_tokens: 90,
2533 total_tokens: 100,
2534 max_context_tokens: 1200,
2535 budget_limit: 1100,
2536 truncation_occurred: false,
2537 segments_removed: 0,
2538 prompt_cached_tool_outputs: 0,
2539 prompt_cached_tool_tokens_saved: 0,
2540 thinking_tokens: 0,
2541 cache_read_input_tokens: 80,
2542 provider_prompt_usage: Some(ProviderPromptUsage {
2543 input_tokens: 20,
2544 cache_creation_input_tokens: 0,
2545 cache_read_input_tokens: 80,
2546 retained_from_previous_call: false,
2547 }),
2548 });
2549
2550 let compressed_count = apply_compression_plan(&mut session, plan);
2551 assert!(compressed_count > 0);
2552 assert_eq!(
2553 session
2554 .compression_events
2555 .last()
2556 .expect("summary compression event")
2557 .kind,
2558 CompressionEventKind::Summary
2559 );
2560
2561 let has_recovery = session.messages.iter().any(|m| {
2563 m.never_compress
2564 && m.content.contains("[post-compaction-recovery]")
2565 && m.content.contains("/src/lib.rs")
2566 });
2567 assert!(
2568 has_recovery,
2569 "session should contain a post-compaction recovery message with the file path"
2570 );
2571 let retained_provider_usage = session
2572 .token_usage
2573 .as_ref()
2574 .and_then(|usage| usage.provider_prompt_usage)
2575 .expect("compression should retain the last provider usage");
2576 assert_eq!(
2577 session
2578 .token_usage
2579 .as_ref()
2580 .unwrap()
2581 .cache_read_input_tokens,
2582 80
2583 );
2584 assert_eq!(retained_provider_usage.cache_read_input_tokens, 80);
2585 assert!(retained_provider_usage.retained_from_previous_call);
2586 let state = session
2587 .model_context_state
2588 .as_ref()
2589 .expect("compression retains an explicit empty epoch boundary");
2590 assert_eq!(state.prefix_epoch, 8);
2591 assert_eq!(
2592 state.last_reset_reason,
2593 Some(ModelContextResetReason::Compression)
2594 );
2595 assert!(state.events.is_empty());
2596 assert!(state.baselines.is_empty());
2597 assert!(state.cache_scope_sha256.is_none());
2598 }
2599
2600 #[test]
2601 fn summary_quality_full_coverage_when_all_files_mentioned() {
2602 let messages = vec![{
2603 let mut m = Message::assistant("writing", None);
2604 m.tool_calls = Some(vec![ToolCall {
2605 id: "tc1".to_string(),
2606 tool_type: "function".to_string(),
2607 function: FunctionCall {
2608 name: "Write".to_string(),
2609 arguments: r#"{"file_path":"/src/main.rs","content":"fn main() {}"}"#
2610 .to_string(),
2611 },
2612 }]);
2613 m
2614 }];
2615 let summary = "Modified /src/main.rs to add main function";
2616 let quality = validate_summary_quality(summary, &messages);
2617 assert!(
2618 quality.file_coverage >= 0.99,
2619 "file_coverage should be ~1.0, got {:.2}",
2620 quality.file_coverage
2621 );
2622 }
2623
2624 #[test]
2625 fn summary_quality_zero_coverage_when_no_files_mentioned() {
2626 let messages = vec![{
2627 let mut m = Message::assistant("writing", None);
2628 m.tool_calls = Some(vec![ToolCall {
2629 id: "tc1".to_string(),
2630 tool_type: "function".to_string(),
2631 function: FunctionCall {
2632 name: "Write".to_string(),
2633 arguments: r#"{"file_path":"/src/main.rs","content":"fn main() {}"}"#
2634 .to_string(),
2635 },
2636 }]);
2637 m
2638 }];
2639 let summary = "Summary that mentions nothing about files";
2640 let quality = validate_summary_quality(summary, &messages);
2641 assert!(
2642 quality.file_coverage < 0.01,
2643 "file_coverage should be ~0.0, got {:.2}",
2644 quality.file_coverage
2645 );
2646 }
2647
2648 #[test]
2649 fn summary_quality_handles_empty_messages() {
2650 let quality = validate_summary_quality("some summary", &[]);
2651 assert_eq!(quality.file_coverage, 1.0);
2652 assert_eq!(quality.decision_coverage, 1.0);
2653 }
2654
2655 fn candidate_budget(max_context_tokens: u32, target_percent: u8) -> TokenBudget {
2656 TokenBudget {
2657 max_context_tokens,
2658 max_output_tokens: max_context_tokens / 4,
2659 strategy: BudgetStrategy::Hybrid {
2660 window_size: 20,
2661 enable_summarization: true,
2662 },
2663 safety_margin: 0,
2664 compression_trigger_percent: 80,
2665 compression_target_percent: target_percent,
2666 working_reserve_tokens: 0,
2667 fallback_trigger_percent: 75,
2668 prompt_cache_min_tool_output_chars: 1_200,
2669 prompt_cache_head_chars: 280,
2670 prompt_cache_tail_chars: 180,
2671 prompt_cache_recent_user_turns: 2,
2672 prompt_cache_recent_tool_chains: 2,
2673 max_tool_output_tokens: 0,
2674 }
2675 }
2676
2677 #[test]
2678 fn candidate_plan_is_selected_before_summary_and_reserves_twenty_percent_of_source() {
2679 let budget = candidate_budget(6_000, 40);
2680 let mut session = Session::new("candidate-first", "main-model");
2681 session.add_message(Message::system("system"));
2682
2683 let mut tool_call = Message::assistant("searching", None);
2684 tool_call.tool_calls = Some(vec![ToolCall {
2685 id: "candidate-chain".to_string(),
2686 tool_type: "function".to_string(),
2687 function: FunctionCall {
2688 name: "search".to_string(),
2689 arguments: r#"{"q":"compression"}"#.to_string(),
2690 },
2691 }]);
2692 let tool_call_id = tool_call.id.clone();
2693 session.add_message(tool_call);
2694 let tool_result = Message::tool_result(
2695 "candidate-chain",
2696 "result payload with decisions and paths ".repeat(80),
2697 );
2698 let tool_result_id = tool_result.id.clone();
2699 session.add_message(tool_result);
2700
2701 let mut never = Message::assistant("protected runtime state ".repeat(80), None);
2702 never.never_compress = true;
2703 let never_id = never.id.clone();
2704 session.add_message(never);
2705
2706 for index in 0..12 {
2707 session.add_message(Message::user(format!(
2708 "U{index}: {}",
2709 "requirement context decision ".repeat(40)
2710 )));
2711 session.add_message(Message::assistant(
2712 format!("A{index}: {}", "implementation evidence result ".repeat(40)),
2713 None,
2714 ));
2715 }
2716 let newest_user_ids = session
2717 .messages
2718 .iter()
2719 .filter(|message| matches!(message.role, bamboo_domain::Role::User))
2720 .rev()
2721 .take(3)
2722 .map(|message| message.id.clone())
2723 .collect::<HashSet<_>>();
2724
2725 let candidate = build_forced_compression_candidate_plan(
2726 &session,
2727 "main-model",
2728 Some(&budget),
2729 0.20,
2730 CompressionTriggerType::Auto,
2731 )
2732 .expect("candidate plan should reach target");
2733 let selected = candidate
2734 .compressed_message_ids
2735 .iter()
2736 .cloned()
2737 .collect::<HashSet<_>>();
2738
2739 assert_eq!(
2740 selected,
2741 candidate
2742 .messages_to_summarize
2743 .iter()
2744 .map(|message| message.id.clone())
2745 .collect()
2746 );
2747 assert_eq!(
2748 candidate.target_summary_tokens,
2749 ((candidate.source_tokens as f64) * 0.20).ceil() as u32
2750 );
2751 assert!(!selected.contains(&never_id));
2752 assert!(newest_user_ids.is_disjoint(&selected));
2753 assert_eq!(
2754 selected.contains(&tool_call_id),
2755 selected.contains(&tool_result_id),
2756 "generic tool chain must be selected atomically"
2757 );
2758 assert!(
2759 candidate.projected_usage_after_percent <= budget.compression_target_percent as f64
2760 );
2761 }
2762
2763 #[test]
2764 fn cumulative_twenty_percent_uses_represented_raw_tokens_not_previous_summary_length() {
2765 let budget = candidate_budget(30_000, 50);
2766 let mut session = Session::new("cumulative-ratio", "main-model");
2767 session.add_message(Message::system("system"));
2768 session.conversation_summary = Some(
2769 ConversationSummary::new("existing detailed summary ".repeat(200), 40, 2_000)
2770 .with_compression_metrics(10_000, 2_000, 0.20, false, None),
2771 );
2772 for index in 0..80 {
2773 session.add_message(Message::user(format!(
2774 "U{index}: {}",
2775 "raw source requirement and evidence ".repeat(30)
2776 )));
2777 session.add_message(Message::assistant(
2778 format!(
2779 "A{index}: {}",
2780 "implementation result and next step ".repeat(30)
2781 ),
2782 None,
2783 ));
2784 }
2785
2786 let candidate = build_forced_compression_candidate_plan(
2787 &session,
2788 "main-model",
2789 Some(&budget),
2790 0.20,
2791 CompressionTriggerType::Auto,
2792 )
2793 .expect("cumulative candidate plan");
2794 assert_eq!(
2795 candidate.target_summary_tokens,
2796 ((10_000u32.saturating_add(candidate.source_tokens) as f64) * 0.20).ceil() as u32
2797 );
2798 assert_eq!(
2799 candidate.represented_source_tokens,
2800 10_000u32.saturating_add(candidate.source_tokens)
2801 );
2802 }
2803
2804 #[test]
2805 fn legacy_summary_uses_conservative_growth_and_migrates_represented_source_metadata() {
2806 let budget = candidate_budget(20_000, 50);
2807 let counter = TiktokenTokenCounter::default();
2808 let existing_content = "legacy summary fact and decision ".repeat(180);
2809 let existing_tokens = counter.count_text(&existing_content);
2810 let mut session = Session::new("legacy-summary-ratio", "main-model");
2811 session.add_message(Message::system("system"));
2812 session.conversation_summary = Some(ConversationSummary::new(
2813 existing_content,
2814 40,
2815 existing_tokens,
2816 ));
2817 for index in 0..40 {
2818 session.add_message(Message::user(format!(
2819 "U{index}: {}",
2820 "raw requirement evidence ".repeat(40)
2821 )));
2822 session.add_message(Message::assistant(
2823 format!("A{index}: {}", "result next step ".repeat(40)),
2824 None,
2825 ));
2826 }
2827
2828 let candidate = build_forced_compression_candidate_plan(
2829 &session,
2830 "main-model",
2831 Some(&budget),
2832 0.20,
2833 CompressionTriggerType::Auto,
2834 )
2835 .expect("legacy candidate plan");
2836 assert_eq!(
2837 candidate.target_summary_tokens,
2838 existing_tokens.saturating_add(((candidate.source_tokens as f64) * 0.20).ceil() as u32)
2839 );
2840 assert_eq!(
2841 candidate.previous_represented_source_tokens,
2842 ((existing_tokens as f64) / 0.20).ceil() as u32
2843 );
2844 let expected_represented = candidate.represented_source_tokens;
2845 let expected_target = candidate.target_summary_tokens;
2846 let plan = finalize_compression_candidate_plan(
2847 &session,
2848 candidate,
2849 "migrated legacy summary with new evidence".to_string(),
2850 )
2851 .expect("short real summary should satisfy target");
2852 assert!(apply_compression_plan(&mut session, plan) > 0);
2853 let migrated = session
2854 .conversation_summary
2855 .as_ref()
2856 .expect("migrated summary");
2857 assert_eq!(migrated.represented_source_tokens, expected_represented);
2858 assert_eq!(migrated.target_token_count, expected_target);
2859 assert_eq!(migrated.target_ratio, 0.20);
2860 }
2861
2862 #[test]
2863 fn final_postcondition_counts_the_recovery_message_inserted_during_apply() {
2864 let budget = candidate_budget(8_000, 40);
2865 let mut session = Session::new("recovery-postcondition", "main-model");
2866 session.add_message(Message::system("system"));
2867 for index in 0..20 {
2868 session.add_message(Message::user(format!(
2869 "U{index}: {}",
2870 "requirement source content ".repeat(45)
2871 )));
2872 let mut assistant = Message::assistant(
2873 format!("A{index}: {}", "implementation evidence ".repeat(45)),
2874 None,
2875 );
2876 if index == 0 {
2877 assistant.tool_calls = Some(vec![ToolCall {
2878 id: "write-recovery-763".to_string(),
2879 tool_type: "function".to_string(),
2880 function: FunctionCall {
2881 name: "Write".to_string(),
2882 arguments:
2883 r#"{"file_path":"/workspace/src/recovery_763.rs","content":"fixed"}"#
2884 .to_string(),
2885 },
2886 }]);
2887 }
2888 session.add_message(assistant);
2889 if index == 0 {
2890 session.add_message(Message::tool_result(
2891 "write-recovery-763",
2892 "write completed",
2893 ));
2894 }
2895 }
2896
2897 let candidate = build_forced_compression_candidate_plan(
2898 &session,
2899 "main-model",
2900 Some(&budget),
2901 0.20,
2902 CompressionTriggerType::CriticalOverflow,
2903 )
2904 .expect("candidate should reserve recovery-message tokens");
2905 let plan = finalize_compression_candidate_plan(
2906 &session,
2907 candidate,
2908 "summary with /workspace/src/recovery_763.rs".to_string(),
2909 )
2910 .expect("final plan");
2911 assert!(apply_compression_plan(&mut session, plan) > 0);
2912 assert!(session
2913 .messages
2914 .iter()
2915 .any(|message| message.content.contains("[post-compaction-recovery]")));
2916 assert!(
2917 session.token_usage.as_ref().is_some_and(
2918 |usage| usage.total_tokens <= budget.compression_target_context_tokens()
2919 ),
2920 "the real active context, including recovery, must remain at or below target"
2921 );
2922 }
2923
2924 #[test]
2925 fn finalization_revalidates_actual_summary_without_mutating_session() {
2926 let budget = candidate_budget(6_000, 40);
2927 let mut session = Session::new("atomic-finalize", "main-model");
2928 session.add_message(Message::system("system"));
2929 for index in 0..20 {
2930 session.add_message(Message::user(format!(
2931 "U{index}: {}",
2932 "source content ".repeat(50)
2933 )));
2934 session.add_message(Message::assistant(
2935 format!("A{index}: {}", "response content ".repeat(50)),
2936 None,
2937 ));
2938 }
2939 let candidate = build_forced_compression_candidate_plan(
2940 &session,
2941 "main-model",
2942 Some(&budget),
2943 0.20,
2944 CompressionTriggerType::CriticalOverflow,
2945 )
2946 .expect("candidate plan");
2947 let before_flags = session
2948 .messages
2949 .iter()
2950 .map(|message| (message.id.clone(), message.compressed))
2951 .collect::<Vec<_>>();
2952
2953 let result = finalize_compression_candidate_plan(
2954 &session,
2955 candidate,
2956 "oversized summary ".repeat(20_000),
2957 );
2958 assert!(matches!(
2959 result,
2960 Err(CompressionPlanError::SummaryExceedsTarget { .. })
2961 ));
2962 assert_eq!(
2963 before_flags,
2964 session
2965 .messages
2966 .iter()
2967 .map(|message| (message.id.clone(), message.compressed))
2968 .collect::<Vec<_>>()
2969 );
2970 assert!(session.conversation_summary.is_none());
2971 assert!(session.compression_events.is_empty());
2972 }
2973
2974 #[test]
2975 fn candidate_plan_reports_when_protected_content_makes_target_impossible() {
2976 let budget = candidate_budget(2_000, 20);
2977 let mut session = Session::new("protected-capacity", "main-model");
2978 session.add_message(Message::system("system"));
2979 session.add_message(Message::assistant(
2980 "one eligible old message ".repeat(200),
2981 None,
2982 ));
2983 for index in 0..3 {
2984 session.add_message(Message::user(format!(
2985 "protected recent user {index} {}",
2986 "large active content ".repeat(300)
2987 )));
2988 }
2989
2990 let result = build_forced_compression_candidate_plan(
2991 &session,
2992 "main-model",
2993 Some(&budget),
2994 0.20,
2995 CompressionTriggerType::CriticalOverflow,
2996 );
2997 assert!(matches!(
2998 result,
2999 Err(CompressionPlanError::ProtectedContentExceedsTarget { .. })
3000 ));
3001 }
3002}