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