Skip to main content

mermaid_cli/domain/
compaction.rs

1//! Conversation context compaction.
2//!
3//! The reducer/effect boundary treats compaction as a first-class
4//! operation: effects generate a checkpoint summary, the reducer swaps
5//! the model-visible history, and persistence archives the removed raw
6//! messages. This keeps compaction observable instead of hiding it inside
7//! a provider adapter.
8
9use chrono::{DateTime, Local};
10use serde::{Deserialize, Serialize};
11
12use crate::constants::{
13    COMPACTION_AUTO_THRESHOLD_PERCENT, COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
14    COMPACTION_MIN_RESPONSE_RESERVE_TOKENS, COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
15    COMPACTION_SUMMARY_MAX_TOKENS, COMPACTION_TAIL_TOKEN_BUDGET, COMPACTION_TAIL_TURNS,
16    COMPACTION_TOOL_OUTPUT_MAX_CHARS,
17};
18use crate::models::{ChatMessage, ChatMessageKind, MessageRole, ReasoningLevel, TokenUsage};
19
20use super::cmd::ChatRequest;
21use super::state::ContextUsageSnapshot;
22
23const CHECKPOINT_MARKER: &str = "MERMAID CONTEXT CHECKPOINT";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CompactionTrigger {
28    Manual,
29    AutoThreshold,
30    ContextLimitRetry,
31    /// A response was truncated because the context window filled mid-turn;
32    /// compact and resume the run (see the reducer's truncation-recovery path).
33    TruncationRecovery,
34}
35
36impl CompactionTrigger {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Manual => "manual",
40            Self::AutoThreshold => "auto_threshold",
41            Self::ContextLimitRetry => "context_limit_retry",
42            Self::TruncationRecovery => "truncation_recovery",
43        }
44    }
45
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::Manual => "manual",
49            Self::AutoThreshold => "automatic",
50            Self::ContextLimitRetry => "context-limit retry",
51            Self::TruncationRecovery => "truncation recovery",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CompactionPolicy {
58    pub auto_enabled: bool,
59    pub auto_threshold_percent: u8,
60    pub tail_turns: usize,
61    pub tail_token_budget: usize,
62    pub tool_output_max_chars: usize,
63    pub summary_max_tokens: usize,
64    pub summarizer_input_token_budget: usize,
65    pub min_response_reserve_tokens: usize,
66    pub max_response_reserve_tokens: usize,
67}
68
69impl Default for CompactionPolicy {
70    fn default() -> Self {
71        Self {
72            auto_enabled: true,
73            auto_threshold_percent: COMPACTION_AUTO_THRESHOLD_PERCENT,
74            tail_turns: COMPACTION_TAIL_TURNS,
75            tail_token_budget: COMPACTION_TAIL_TOKEN_BUDGET,
76            tool_output_max_chars: COMPACTION_TOOL_OUTPUT_MAX_CHARS,
77            summary_max_tokens: COMPACTION_SUMMARY_MAX_TOKENS,
78            summarizer_input_token_budget: COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
79            min_response_reserve_tokens: COMPACTION_MIN_RESPONSE_RESERVE_TOKENS,
80            max_response_reserve_tokens: COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
81        }
82    }
83}
84
85impl CompactionPolicy {
86    pub fn response_reserve(self, request_max_tokens: usize) -> usize {
87        request_max_tokens
88            .max(self.min_response_reserve_tokens)
89            .min(self.max_response_reserve_tokens)
90    }
91}
92
93#[derive(Debug, Clone)]
94pub struct CompactionRequest {
95    pub chat: ChatRequest,
96    pub trigger: CompactionTrigger,
97    pub instructions: Option<String>,
98    pub force: bool,
99    pub policy: CompactionPolicy,
100}
101
102impl CompactionRequest {
103    pub fn manual(chat: ChatRequest, instructions: Option<String>) -> Self {
104        Self {
105            chat,
106            trigger: CompactionTrigger::Manual,
107            instructions,
108            force: true,
109            policy: CompactionPolicy::default(),
110        }
111    }
112
113    pub fn auto(chat: ChatRequest, trigger: CompactionTrigger) -> Self {
114        Self {
115            chat,
116            trigger,
117            instructions: None,
118            force: false,
119            policy: CompactionPolicy::default(),
120        }
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CompactionRecord {
126    pub id: String,
127    pub trigger: CompactionTrigger,
128    pub created_at: DateTime<Local>,
129    pub before_tokens: usize,
130    pub after_tokens: usize,
131    pub archived_message_count: usize,
132    pub preserved_message_count: usize,
133    pub summary_tokens: usize,
134    pub duration_secs: f64,
135    #[serde(default = "default_verified")]
136    pub verified: bool,
137    #[serde(default)]
138    pub verification_error: Option<String>,
139    #[serde(default)]
140    pub focus: Option<String>,
141    #[serde(default)]
142    pub archive_path: Option<String>,
143}
144
145fn default_verified() -> bool {
146    true
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct CompactionArchive {
151    pub id: String,
152    pub conversation_id: String,
153    pub created_at: DateTime<Local>,
154    pub messages: Vec<ChatMessage>,
155}
156
157#[derive(Debug, Clone)]
158pub struct CompactionResult {
159    pub record: CompactionRecord,
160    pub replacement_messages: Vec<ChatMessage>,
161    pub archived_messages: Vec<ChatMessage>,
162    pub before_snapshot: ContextUsageSnapshot,
163    pub after_snapshot: ContextUsageSnapshot,
164    pub usage: Option<TokenUsage>,
165}
166
167#[derive(Debug, Clone)]
168pub struct PreparedCompaction {
169    pub archived_messages: Vec<ChatMessage>,
170    pub preserved_messages: Vec<ChatMessage>,
171    pub previous_summary: Option<String>,
172    pub history_excerpt: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum CompactionSkip {
177    NoKnownContextLimit,
178    AutoDisabled,
179    BelowThreshold,
180    NothingToCompact,
181}
182
183impl std::fmt::Display for CompactionSkip {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            Self::NoKnownContextLimit => write!(f, "model context limit is unknown"),
187            Self::AutoDisabled => write!(f, "automatic compaction is disabled"),
188            Self::BelowThreshold => write!(f, "context is below compaction threshold"),
189            Self::NothingToCompact => write!(f, "not enough history to compact"),
190        }
191    }
192}
193
194pub fn should_auto_compact(
195    snapshot: &ContextUsageSnapshot,
196    request: &ChatRequest,
197    policy: CompactionPolicy,
198) -> Result<(), CompactionSkip> {
199    if !policy.auto_enabled {
200        return Err(CompactionSkip::AutoDisabled);
201    }
202    let Some(max_tokens) = snapshot.max_tokens else {
203        return Err(CompactionSkip::NoKnownContextLimit);
204    };
205    if max_tokens == 0 {
206        return Err(CompactionSkip::NoKnownContextLimit);
207    }
208
209    let reserve = policy.response_reserve(request.max_tokens);
210    let over_percent = snapshot
211        .used_percent
212        .is_some_and(|p| p >= policy.auto_threshold_percent);
213    let low_remaining = snapshot
214        .remaining_tokens
215        .is_some_and(|remaining| remaining <= reserve);
216    if over_percent || low_remaining {
217        Ok(())
218    } else {
219        Err(CompactionSkip::BelowThreshold)
220    }
221}
222
223pub fn context_exceeds_hard_limit(
224    snapshot: &ContextUsageSnapshot,
225    request: &ChatRequest,
226    policy: CompactionPolicy,
227) -> bool {
228    let Some(max_tokens) = snapshot.max_tokens else {
229        return false;
230    };
231    let reserve = policy.response_reserve(request.max_tokens);
232    snapshot.used_tokens.saturating_add(reserve) >= max_tokens
233}
234
235pub fn prepare_compaction(
236    request: &CompactionRequest,
237    max_context_tokens: Option<usize>,
238) -> Result<PreparedCompaction, CompactionSkip> {
239    let messages = &request.chat.messages;
240    if messages.len() < 3 {
241        return Err(CompactionSkip::NothingToCompact);
242    }
243
244    let split =
245        tail_start_index(messages, request.policy).ok_or(CompactionSkip::NothingToCompact)?;
246    if split == 0 {
247        return Err(CompactionSkip::NothingToCompact);
248    }
249
250    let archived_messages = messages[..split].to_vec();
251    let mut preserved_messages = messages[split..].to_vec();
252    if archived_messages.is_empty() || preserved_messages.is_empty() {
253        return Err(CompactionSkip::NothingToCompact);
254    }
255    // The tail is forwarded verbatim into the next request; scrub any
256    // pre-existing orphan `tool_use` (e.g. a turn cancelled after the model
257    // emitted tool calls but before any result committed) so the unpaired call
258    // can't 400 the provider (#71).
259    drop_orphan_tool_calls(&mut preserved_messages);
260
261    let previous_summary = archived_messages
262        .iter()
263        .rev()
264        .find(|m| {
265            m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
266        })
267        .map(|m| m.content.clone());
268
269    let max_input_tokens = max_context_tokens
270        .map(|max| max.saturating_sub(request.policy.response_reserve(request.chat.max_tokens)))
271        .filter(|max| *max > 0)
272        .unwrap_or(request.policy.summarizer_input_token_budget)
273        .min(request.policy.summarizer_input_token_budget);
274    let max_chars = max_input_tokens.saturating_mul(4).max(4_000);
275    let history_excerpt = truncate_middle(
276        &format_history_excerpt(&archived_messages, request.policy),
277        max_chars,
278    );
279
280    Ok(PreparedCompaction {
281        archived_messages,
282        preserved_messages,
283        previous_summary,
284        history_excerpt,
285    })
286}
287
288pub fn build_summary_request(
289    base: &ChatRequest,
290    prepared: &PreparedCompaction,
291    focus: Option<&str>,
292    policy: CompactionPolicy,
293) -> ChatRequest {
294    ChatRequest {
295        model_id: base.model_id.clone(),
296        messages: vec![ChatMessage::user(summary_prompt(prepared, focus))],
297        system_prompt: compaction_system_prompt().to_string(),
298        instructions: None,
299        reasoning: compaction_reasoning(base.reasoning),
300        temperature: 0.0,
301        max_tokens: policy.summary_max_tokens,
302        tools: Vec::new(),
303        ollama_num_ctx: base.ollama_num_ctx,
304        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
305    }
306}
307
308pub fn build_verification_request(
309    base: &ChatRequest,
310    prepared: &PreparedCompaction,
311    draft_summary: &str,
312    focus: Option<&str>,
313    policy: CompactionPolicy,
314) -> ChatRequest {
315    let prompt = format!(
316        "{}\n\n# Draft Summary\n{}\n\n# Verification Task\nCritically check the draft against the conversation excerpt. If it omitted specific file paths, commands, test results, tool results, user constraints, current state, or next steps, return an improved complete checkpoint. Otherwise return the draft unchanged. Return only the final checkpoint markdown.",
317        summary_prompt(prepared, focus),
318        draft_summary.trim()
319    );
320    ChatRequest {
321        model_id: base.model_id.clone(),
322        messages: vec![ChatMessage::user(prompt)],
323        system_prompt: compaction_system_prompt().to_string(),
324        instructions: None,
325        reasoning: compaction_reasoning(base.reasoning),
326        temperature: 0.0,
327        max_tokens: policy.summary_max_tokens,
328        tools: Vec::new(),
329        ollama_num_ctx: base.ollama_num_ctx,
330        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
331    }
332}
333
334pub fn build_replacement_messages(
335    summary: &str,
336    prepared: &PreparedCompaction,
337    record: &CompactionRecord,
338) -> Vec<ChatMessage> {
339    // The summary is model-generated from the full conversation and is persisted
340    // (replacement message + conversation file). Scrub any credential it echoed
341    // back from the archived turns before it's written (#70).
342    let summary = crate::utils::redact_secrets(summary);
343    let summary = summary.as_str();
344    let checkpoint = format!(
345        "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
346        CHECKPOINT_MARKER,
347        record.id,
348        record.trigger.as_str(),
349        record.created_at.to_rfc3339(),
350        record.archived_message_count,
351        record.preserved_message_count,
352        summary.trim()
353    );
354    let mut user = ChatMessage::user(checkpoint);
355    user.kind = ChatMessageKind::ContextCheckpoint;
356    user.metadata = Some(serde_json::json!({
357        "compaction_id": record.id,
358        "trigger": record.trigger.as_str(),
359        "before_tokens": record.before_tokens,
360        "after_tokens": record.after_tokens,
361        "archived_message_count": record.archived_message_count,
362        "preserved_message_count": record.preserved_message_count,
363        "duration_secs": record.duration_secs,
364        "verified": record.verified,
365        "verification_error": record.verification_error,
366    }));
367
368    let mut assistant = ChatMessage::assistant(compaction_receipt(record));
369    assistant.kind = ChatMessageKind::ContextCheckpoint;
370    assistant.metadata = user.metadata.clone();
371
372    let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
373    messages.push(user);
374    messages.push(assistant);
375    messages.extend(prepared.preserved_messages.clone());
376    messages
377}
378
379pub fn compaction_receipt(record: &CompactionRecord) -> String {
380    let verification = if record.verified {
381        "Verified.".to_string()
382    } else if let Some(error) = &record.verification_error {
383        format!("Used draft summary because verification failed: {error}.")
384    } else {
385        "Used draft summary without verification.".to_string()
386    };
387    format!(
388        "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
389        format_compact_count(record.before_tokens),
390        format_compact_count(record.after_tokens),
391        record.archived_message_count,
392        record.preserved_message_count,
393        record.duration_secs,
394        verification
395    )
396}
397
398pub fn normalize_summary(text: &str) -> String {
399    let trimmed = text.trim();
400    if let Some(summary) = extract_tagged_summary(trimmed) {
401        return summary.trim().to_string();
402    }
403    trimmed.to_string()
404}
405
406pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
407    match (a, b) {
408        (None, None) => None,
409        (Some(u), None) | (None, Some(u)) => Some(u),
410        (Some(mut left), Some(right)) => {
411            left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
412            left.completion_tokens = left
413                .completion_tokens
414                .saturating_add(right.completion_tokens);
415            left.total_tokens = left.total_tokens.saturating_add(right.total_tokens);
416            left.cached_input_tokens = left
417                .cached_input_tokens
418                .saturating_add(right.cached_input_tokens);
419            left.cache_creation_input_tokens = left
420                .cache_creation_input_tokens
421                .saturating_add(right.cache_creation_input_tokens);
422            left.reasoning_output_tokens = left
423                .reasoning_output_tokens
424                .saturating_add(right.reasoning_output_tokens);
425            Some(left)
426        },
427    }
428}
429
430pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
431    messages.iter().map(estimate_message_tokens).sum()
432}
433
434pub fn format_compact_count(value: usize) -> String {
435    if value >= 1_000_000 {
436        format!("{:.1}M", value as f64 / 1_000_000.0)
437    } else if value >= 1_000 {
438        format!("{:.1}k", value as f64 / 1_000.0)
439    } else {
440        value.to_string()
441    }
442}
443
444fn compaction_system_prompt() -> &'static str {
445    "You are performing context checkpoint compaction for Mermaid, a model-agnostic agentic coding CLI. Produce a faithful handoff summary for the next model call. Preserve exact file paths, commands, errors, tool results, user preferences, decisions, current state, and next steps. Do not invent facts. Be concise but complete."
446}
447
448fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
449    match current {
450        ReasoningLevel::None | ReasoningLevel::Minimal => current,
451        _ => ReasoningLevel::Low,
452    }
453}
454
455fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
456    let anchor = prepared
457        .previous_summary
458        .as_deref()
459        .map(|summary| {
460            format!(
461                "A previous checkpoint exists. Update it with the newer history, preserve still-true details, and remove stale details.\n\n<previous_checkpoint>\n{}\n</previous_checkpoint>",
462                summary.trim()
463            )
464        })
465        .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
466
467    let focus = focus
468        .filter(|s| !s.trim().is_empty())
469        .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
470        .unwrap_or_default();
471
472    format!(
473        "{anchor}{focus}\n# Required Output\nReturn exactly this Markdown structure and keep section order:\n\n## Goal\n- [single-sentence task summary]\n\n## User Preferences And Constraints\n- [preferences, constraints, mode, or \"(none)\"]\n\n## Project State\n- [repo/product state and important architecture facts]\n\n## Completed Work\n- [what has already been done]\n\n## Current Work\n- [what is actively in progress]\n\n## Key Decisions\n- [decision and rationale]\n\n## Critical Files And Symbols\n- [file path or symbol: why it matters]\n\n## Commands Tests And Results\n- [command/test/result/error]\n\n## Open Questions Or Risks\n- [risk/question/blocker]\n\n## Next Steps\n- [ordered next action]\n\nRules:\n- Preserve exact paths, commands, error strings, identifiers, and numeric facts when known.\n- Mention important omitted or truncated data explicitly.\n- Do not mention that you are an AI or explain the compaction process.\n\n# Conversation History To Compact\n{}",
474        prepared.history_excerpt
475    )
476}
477
478/// Strip assistant `tool_calls` that have no matching `tool_result` later in
479/// `messages` (#71). Compaction preserves a recent tail verbatim; if that tail
480/// inherits a pre-existing orphan — e.g. a turn cancelled after the model
481/// emitted tool calls but before any result committed — forwarding the unpaired
482/// `tool_use` makes providers (Anthropic) reject the next request with a 400.
483/// Drop the orphaned calls (keeping the assistant's text) rather than fabricate
484/// results. A call with no `id` can't be paired, so it's treated as orphaned.
485fn drop_orphan_tool_calls(messages: &mut [ChatMessage]) {
486    let answered: std::collections::HashSet<String> = messages
487        .iter()
488        .filter(|m| m.role == MessageRole::Tool)
489        .filter_map(|m| m.tool_call_id.clone())
490        .collect();
491    for m in messages.iter_mut() {
492        let Some(calls) = m.tool_calls.as_mut() else {
493            continue;
494        };
495        calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
496        if calls.is_empty() {
497            m.tool_calls = None;
498        }
499    }
500}
501
502fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
503    let mut user_turns = 0usize;
504    let mut start = None;
505    for (idx, msg) in messages.iter().enumerate().rev() {
506        if msg.role == MessageRole::User {
507            user_turns += 1;
508            start = Some(idx);
509            if user_turns >= policy.tail_turns {
510                break;
511            }
512        }
513    }
514    let mut start = start?;
515    while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
516        let next_user = messages
517            .iter()
518            .enumerate()
519            .skip(start + 1)
520            .find(|(_, msg)| msg.role == MessageRole::User)
521            .map(|(idx, _)| idx);
522        match next_user {
523            Some(idx) => start = idx,
524            None => break,
525        }
526    }
527    Some(start)
528}
529
530fn format_history_excerpt(messages: &[ChatMessage], policy: CompactionPolicy) -> String {
531    let mut out = String::new();
532    for (idx, msg) in messages.iter().enumerate() {
533        let role = match msg.role {
534            MessageRole::User => "USER",
535            MessageRole::Assistant => "ASSISTANT",
536            MessageRole::System => "SYSTEM",
537            MessageRole::Tool => "TOOL",
538        };
539        out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
540        if msg.kind != ChatMessageKind::Normal {
541            out.push_str(&format!("kind: {:?}\n", msg.kind));
542        }
543        if let Some(name) = &msg.tool_name {
544            out.push_str(&format!("tool_name: {}\n", name));
545        }
546        if let Some(id) = &msg.tool_call_id {
547            out.push_str(&format!("tool_call_id: {}\n", id));
548        }
549        if let Some(calls) = &msg.tool_calls {
550            let names: Vec<&str> = calls
551                .iter()
552                .map(|call| call.function.name.as_str())
553                .collect();
554            out.push_str(&format!("tool_calls: {}\n", names.join(", ")));
555        }
556        if let Some(images) = &msg.images
557            && !images.is_empty()
558        {
559            out.push_str(&format!("[{} image attachment(s) omitted]\n", images.len()));
560        }
561        for action in &msg.actions {
562            out.push_str(&format!(
563                "action: {}({}) duration={:?}\n",
564                action.action_type, action.target, action.duration_seconds
565            ));
566            if let Some(metadata) = &action.metadata {
567                out.push_str(&format!("action_metadata: {:?}\n", metadata));
568            }
569        }
570        let cap = if msg.role == MessageRole::Tool {
571            policy.tool_output_max_chars
572        } else {
573            policy.tool_output_max_chars.saturating_mul(4)
574        };
575        out.push_str(&truncate_middle(&msg.content, cap));
576    }
577    out
578}
579
580fn estimate_message_tokens(msg: &ChatMessage) -> usize {
581    let mut chars = msg.content.len();
582    chars = chars.saturating_add(format!("{:?}", msg.role).len());
583    chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
584    chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
585    if let Some(images) = &msg.images {
586        chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
587    }
588    // Assistant tool calls carry the function name + a JSON arguments payload
589    // (often kilobytes for a file write or shell script). Omitting these made
590    // the estimate run systematically low for this tool-heavy agent, causing
591    // under-compaction and provider-side context overflows.
592    if let Some(tool_calls) = &msg.tool_calls {
593        for tc in tool_calls {
594            chars = chars.saturating_add(tc.function.name.len());
595            chars = chars.saturating_add(tc.function.arguments.to_string().len());
596            chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
597        }
598    }
599    chars.div_ceil(4)
600}
601
602fn truncate_middle(text: &str, max_chars: usize) -> String {
603    if text.chars().count() <= max_chars {
604        return text.to_string();
605    }
606    if max_chars < 128 {
607        return text.chars().take(max_chars).collect();
608    }
609    let marker = "\n\n[... truncated during context compaction ...]\n\n";
610    let keep = max_chars.saturating_sub(marker.len());
611    let head = keep / 2;
612    let tail = keep.saturating_sub(head);
613    let start: String = text.chars().take(head).collect();
614    let end: String = text
615        .chars()
616        .rev()
617        .take(tail)
618        .collect::<Vec<_>>()
619        .into_iter()
620        .rev()
621        .collect();
622    format!("{start}{marker}{end}")
623}
624
625fn extract_tagged_summary(text: &str) -> Option<&str> {
626    let start_tag = "<summary>";
627    let end_tag = "</summary>";
628    let start = text.find(start_tag)? + start_tag.len();
629    let end = text[start..].find(end_tag)? + start;
630    Some(&text[start..end])
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
638        ChatRequest {
639            model_id: "ollama/test".to_string(),
640            messages,
641            system_prompt: "system".to_string(),
642            instructions: None,
643            reasoning: ReasoningLevel::Medium,
644            temperature: 0.7,
645            max_tokens: 4096,
646            tools: Vec::new(),
647            ollama_num_ctx: None,
648            ollama_allow_ram_offload: None,
649        }
650    }
651
652    #[test]
653    fn auto_compaction_triggers_by_percent() {
654        let snapshot = ContextUsageSnapshot::from_estimate(
655            super::super::state::PromptTokenBreakdown {
656                system_tokens: 0,
657                instructions_tokens: 0,
658                message_tokens: 86,
659                tool_schema_tokens: 0,
660                image_count: 0,
661                message_count: 2,
662                tool_count: 0,
663            },
664            Some(100),
665        );
666        let req = request_with(vec![ChatMessage::user("hello")]);
667        assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
668    }
669
670    #[test]
671    fn prepare_preserves_recent_two_user_turns() {
672        let messages = vec![
673            ChatMessage::user("one"),
674            ChatMessage::assistant("one answer"),
675            ChatMessage::user("two"),
676            ChatMessage::assistant("two answer"),
677            ChatMessage::user("three"),
678        ];
679        let request = CompactionRequest::manual(request_with(messages), None);
680        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
681        assert_eq!(prepared.archived_messages.len(), 2);
682        assert_eq!(prepared.preserved_messages.len(), 3);
683        assert_eq!(prepared.preserved_messages[0].content, "two");
684    }
685
686    fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
687        crate::models::tool_call::ToolCall {
688            id: Some(id.to_string()),
689            function: crate::models::tool_call::FunctionCall {
690                name: name.to_string(),
691                arguments: serde_json::json!({}),
692            },
693        }
694    }
695
696    #[test]
697    fn prepare_strips_orphan_tool_call_from_preserved_tail() {
698        // A tail that inherits an assistant(tool_calls) with no matching result
699        // (e.g. a cancelled tool turn) must not forward the unpaired tool_use (#71).
700        let mut orphan = ChatMessage::assistant("calling a tool");
701        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
702        let messages = vec![
703            ChatMessage::user("one"),
704            ChatMessage::assistant("one answer"),
705            ChatMessage::user("two"),
706            orphan,
707            ChatMessage::user("three"),
708        ];
709        let request = CompactionRequest::manual(request_with(messages), None);
710        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
711        let has_orphan = prepared
712            .preserved_messages
713            .iter()
714            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
715        assert!(
716            !has_orphan,
717            "orphan tool_use must be stripped from the tail"
718        );
719        // The message itself (its text) is preserved — only the calls are dropped.
720        assert!(
721            prepared
722                .preserved_messages
723                .iter()
724                .any(|m| m.content == "calling a tool")
725        );
726    }
727
728    #[test]
729    fn prepare_keeps_paired_tool_call_in_tail() {
730        // The mirror case: a tool_call whose result is also in the tail survives.
731        let mut asst = ChatMessage::assistant("calling");
732        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
733        let messages = vec![
734            ChatMessage::user("one"),
735            ChatMessage::assistant("one answer"),
736            ChatMessage::user("two"),
737            asst,
738            ChatMessage::tool("call_1", "do_thing", "ok"),
739            ChatMessage::user("three"),
740        ];
741        let request = CompactionRequest::manual(request_with(messages), None);
742        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
743        let kept = prepared
744            .preserved_messages
745            .iter()
746            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
747        assert!(kept, "a tool_call paired with its result must be preserved");
748    }
749
750    #[test]
751    fn replacement_starts_with_checkpoint_and_ack() {
752        let prepared = PreparedCompaction {
753            archived_messages: vec![ChatMessage::user("old")],
754            preserved_messages: vec![ChatMessage::user("new")],
755            previous_summary: None,
756            history_excerpt: "old".to_string(),
757        };
758        let record = CompactionRecord {
759            id: "c1".to_string(),
760            trigger: CompactionTrigger::Manual,
761            created_at: Local::now(),
762            before_tokens: 100,
763            after_tokens: 25,
764            archived_message_count: 1,
765            preserved_message_count: 1,
766            summary_tokens: 10,
767            duration_secs: 1.0,
768            verified: true,
769            verification_error: None,
770            focus: None,
771            archive_path: None,
772        };
773        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
774        assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
775        assert!(messages[0].content.contains(CHECKPOINT_MARKER));
776        assert_eq!(messages[2].content, "new");
777    }
778
779    #[test]
780    fn replacement_metadata_records_verification_status() {
781        let prepared = PreparedCompaction {
782            archived_messages: vec![ChatMessage::user("old")],
783            preserved_messages: vec![ChatMessage::user("new")],
784            previous_summary: None,
785            history_excerpt: "old".to_string(),
786        };
787        let record = CompactionRecord {
788            id: "c1".to_string(),
789            trigger: CompactionTrigger::Manual,
790            created_at: Local::now(),
791            before_tokens: 100,
792            after_tokens: 25,
793            archived_message_count: 1,
794            preserved_message_count: 1,
795            summary_tokens: 10,
796            duration_secs: 1.0,
797            verified: false,
798            verification_error: Some("provider overloaded".to_string()),
799            focus: None,
800            archive_path: None,
801        };
802        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
803        let metadata = messages[0].metadata.as_ref().expect("metadata");
804        assert_eq!(
805            metadata.get("verified").and_then(|v| v.as_bool()),
806            Some(false)
807        );
808        assert_eq!(
809            metadata.get("verification_error").and_then(|v| v.as_str()),
810            Some("provider overloaded")
811        );
812        assert!(messages[1].content.contains("Used draft summary"));
813    }
814}