Skip to main content

bamboo_compression/
compression_tooling.rs

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
11/// Checks if a message is part of a skill tool chain (load_skill / read_skill_resource).
12fn 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
20/// The `tool_call_id`s a message participates in: the ids of the tool calls an
21/// assistant message initiates, plus the id of the call a `tool` result answers.
22/// Two messages belong to the same tool chain iff these overlap.
23fn 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
32/// Close the compressed (`messages_to_summarize`) set over tool chains: repeatedly
33/// move any message still in `messages_to_keep` that shares a `tool_call_id` with
34/// an already-compressed message into the summarize set. This keeps an assistant
35/// `tool_calls` message and its matching `tool` result(s) on the same side — a
36/// split leaves an orphan `tool_result` (or a `tool_use` with no result) in the
37/// active set, which providers reject with a 400 that then poisons every
38/// subsequent request in the session (#340). Protected messages are left in place
39/// (skill chains are already fully protected upstream, so are never partially
40/// compressed; protected user messages carry no `tool_call_id`).
41fn 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/// Structured reason why a compression plan could not be built.
67#[derive(Debug, Clone)]
68pub enum CompressionPlanError {
69    /// The exposure gate (threshold not reached) prevented building.
70    ExposureGateNotMet {
71        usage_percent: f64,
72        trigger_percent: u8,
73    },
74    /// No active messages in the session.
75    NoActiveMessages,
76    /// Not enough non-system messages to compress (need >=3).
77    NotEnoughMessages { non_system_count: usize },
78    /// Nothing to compress after anchor/keep splitting.
79    NothingToCompress {
80        anchor_index: usize,
81        non_system_count: usize,
82    },
83    /// Eligible history was exhausted while protected/recent content still kept
84    /// the active prompt above the configured post-compression target.
85    ProtectedContentExceedsTarget {
86        projected_tokens: u32,
87        target_tokens: u32,
88    },
89    /// The session changed after candidate selection and before finalization.
90    CandidateSetChanged,
91    /// Message-only compression must never archive a partial or protected tool chain.
92    MessageSummaryToolProtocol { reason: String },
93    /// A message-only summary may be empty only when no prose is represented.
94    EmptyMessageSummary,
95    /// The real generated summary was larger than the source-derived reserve,
96    /// so applying the candidate set would miss the post-compression target.
97    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/// Metadata about current context pressure, used to decide when compression
161/// should be requested by host-side control flow.
162#[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/// A concrete archive selection and replacement summary. Message-summary
172/// archives may include tools that are deliberately excluded from its source.
173#[derive(Debug, Clone)]
174pub struct CompressionPlan {
175    pub kind: CompressionEventKind,
176    /// Stable identifier for all requests and the persisted event belonging to
177    /// one logical compression pass.
178    pub logical_pass_id: Option<String>,
179    /// Tokens from active prompt blocks rendered outside `Session.messages`.
180    pub fixed_prompt_tokens: u32,
181    pub compressed_message_ids: Vec<String>,
182    /// Exact summary input; MessageSummary contains only sanitized prose.
183    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    /// Tokens represented by summary input; MessageSummary excludes discarded tools.
196    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/// Immutable archive selection and summary input prepared before model requests.
209/// Tools may be archived without entering a MessageSummary source.
210#[derive(Debug, Clone)]
211pub struct CompressionCandidatePlan {
212    pub kind: CompressionEventKind,
213    /// False when a message-only pass must rebuild a mixed legacy summary from prose.
214    pub merge_existing_summary: bool,
215    pub compressed_message_ids: Vec<String>,
216    /// Exact summary input, which may differ from archive IDs in MessageSummary.
217    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
331/// Select the exact archive candidates before invoking the summarization model.
332///
333/// Candidate segments are considered oldest-first. Generic tool chains remain
334/// atomic because selection operates on [`crate::MessageSegmenter`] output;
335/// newest user turns, `never_compress`, and skill chains remain active.
336pub 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
353/// Variant of [`build_forced_compression_candidate_plan`] that accounts for
354/// fixed prompt blocks rendered outside `Session.messages` (for example task,
355/// plan, workflow, project-resource, and external-memory context blocks).
356pub 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    // Preserve only the source identity used by ordered map/reduce diagnostics.
509    // Fresh construction deliberately excludes reasoning, parts, OCR, metadata,
510    // tool arguments/results, and compression state from the summary input.
511    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
576/// Archive all eligible active history, retaining the current user request and
577/// explicit prose anchors, while summarizing only user/assistant `content`.
578///
579/// Completed tool chains, including Skill-loading chains, are archived without
580/// entering the summary source. Incomplete or protected tool protocol rejects
581/// the whole pass, so no successful plan leaves completed tool context active.
582/// A mixed existing summary is rebuilt from durable prose on the first pass.
583pub 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    // Retrieval may have archived prose since the last message-only summary.
639    // Include that newly hidden prose once, alongside active archive candidates.
640    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
719/// Bind a completed summary to a previously selected candidate set and verify
720/// the real post-compression prompt before any session mutation occurs.
721pub 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
832/// Estimate whether context pressure has crossed the configured threshold for
833/// compression eligibility.
834pub 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
847/// Fixed-token-aware variant used when provider-visible context lives outside
848/// `Session::messages` (for example, the durable model-context ledger).
849pub 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    // When a budget was already resolved upstream (the production path — see
856    // `resolve_token_budget`, which publishes the current-round snapshot in
857    // `session.resolved_token_budget` (#180),
858    // issue #20 bug 1), use it directly. Only when none is available do we fall
859    // back to a model-derived budget. No `model_limits.json` registry is in
860    // scope synchronously here, so this fallback resolves to the global default
861    // rather than silently fabricating an empty override registry (#20 bug 2).
862    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    // Use context window as the denominator for a single, provider-aligned
881    // pressure scale across backend and frontend.
882    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                // Legacy payload compatibility.
892                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    // Check non-system message count to stay consistent with the plan
914    // building requirement of >=3 non-system messages.  Using
915    // active_messages.len() would include system messages and expose the
916    // tool even when plan building would immediately fail.
917    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
933/// Build a compression plan that archives older active messages and replaces
934/// them with a caller-provided summary.
935pub 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
951/// Build a compression plan while bypassing "tool exposure" gating.
952///
953/// This is intended for host-enforced fallback paths when context pressure is
954/// critically high and compression must be attempted regardless of the normal
955/// trigger gate.
956pub 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    // Keep the newest 3 user turns (or fewer if there are not enough user
1059    // turns) as active context and summarize older history before that
1060    // boundary. If budget is still too high, continue moving the oldest
1061    // non-protected messages into the summarize set.
1062    let mut messages_to_summarize = non_system[..anchor_index].to_vec();
1063
1064    // Protected messages must never be summarized — move them to the keep set.
1065    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    // Also protect tool result messages that correspond to skill tool calls.
1072    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    // Add never_compress / skill messages to the keep set.
1094    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            // Remaining messages are all protected; stop shrinking.
1113            break;
1114        };
1115        let moved = messages_to_keep.remove(remove_index);
1116        messages_to_summarize.push(moved);
1117    }
1118
1119    // Tool-chain atomicity. The one-at-a-time eviction above can split a generic
1120    // (non-skill) tool chain — compressing an assistant `tool_calls` message
1121    // while keeping its `tool` result active, or vice versa. That leaves an
1122    // orphan `tool_result` (or a `tool_use` with no result) in the active set,
1123    // which providers reject with a 400 that then poisons EVERY subsequent
1124    // request in the session. Close the compressed set over tool chains: keep
1125    // moving any kept message that shares a `tool_call_id` with an
1126    // already-compressed message into the summarize set until none remain.
1127    // (Skill chains are fully protected above, so are never partially
1128    // compressed; protected user messages carry no `tool_call_id`.)
1129    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    // Use context_window as denominator, consistent with
1154    // estimate_context_compression_exposure().
1155    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    // Count actual segments being compressed using the same segmenter that
1163    // prepare_hybrid_context uses, so the segment count is accurate.
1164    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
1204/// Apply a previously computed compression plan to the session.
1205/// Extract recently modified files from tool calls in the given messages.
1206pub(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                        // Extract file paths from shell commands heuristically
1221                        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
1242/// Extract key decision snippets from assistant messages.
1243pub(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
1279/// Build a post-compaction recovery message that preserves critical context
1280/// from the compressed messages so the LLM can continue work without losing
1281/// track of active files, tasks, and decisions.
1282fn 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    // 1. Recently modified files
1293    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    // 2. Active tasks from task list
1303    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    // 3. Key decisions
1319    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(&sections.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    // Inject a post-compaction recovery message to preserve critical context
1455    // from the compressed messages (files, tasks, decisions).
1456    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        // Insert just before the last user message, or at the end
1467        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    // Instead of clearing token_usage entirely (which forces the next round
1492    // to rely on heuristic estimates that don't account for tool schema
1493    // tokens), recompute an approximate post-compression snapshot.  We
1494    // preserve both the total context-window denominator and the request input
1495    // limit from the previous usage snapshot so their meanings stay distinct
1496    // across rounds.
1497    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                // Legacy snapshots used the total context window as the only
1530                // denominator.
1531                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        // Keep this compatibility renderer lossless. Production callers bound
1666        // the fully rendered request through the hierarchical summarizer;
1667        // clipping each message would discard tails without bounding the total.
1668        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        // Old user message that should be summarized
1976        session.add_message(Message::user("Old question about X"));
1977        session.add_message(Message::assistant("Old answer about X", None));
1978
1979        // Protected user message (never_compress = true)
1980        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        // Recent user messages that anchor the keep window
1986        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        // Find the never_compress message
2013        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        // Skill tool chain (load_skill + read_skill_resource)
2133        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        // Regular messages to fill budget
2149        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        // Skill tool chain messages should not be compressed
2176        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        // A generic (non-skill) tool_use and its tool_result must never be split
2199        // across the compression boundary — a split orphans one of them in the
2200        // active set and the provider 400s, poisoning the session. #340.
2201        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        // A generic tool chain (search + its result) placed early so it is an
2225        // eviction candidate; the large result makes it a prime compression target.
2226        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        // Filler to push usage over budget and force eviction.
2241        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        // The tool_use and its tool_result must land on the SAME side.
2285        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        // Pre-split state: the assistant tool_use is compressed while its
2308        // tool_result was left in the keep (active) set — an orphan. #340.
2309        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        // The orphan tool_result must be pulled into the compressed set.
2325        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        // A protected (never-compress) chain member must NOT be force-compressed.
2340        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        // Completed tasks should NOT appear in active tasks
2459        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        // Old messages with tool calls containing file paths
2493        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        // Fill with enough messages to force compression
2507        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        // Verify recovery message was injected
2562        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}