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 preserved_messages = messages[split..].to_vec();
247    if archived_messages.is_empty() || preserved_messages.is_empty() {
248        return Err(CompactionSkip::NothingToCompact);
249    }
250
251    let previous_summary = archived_messages
252        .iter()
253        .rev()
254        .find(|m| {
255            m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
256        })
257        .map(|m| m.content.clone());
258
259    let max_input_tokens = max_context_tokens
260        .map(|max| max.saturating_sub(request.policy.response_reserve(request.chat.max_tokens)))
261        .filter(|max| *max > 0)
262        .unwrap_or(request.policy.summarizer_input_token_budget)
263        .min(request.policy.summarizer_input_token_budget);
264    let max_chars = max_input_tokens.saturating_mul(4).max(4_000);
265    let history_excerpt = truncate_middle(
266        &format_history_excerpt(&archived_messages, request.policy),
267        max_chars,
268    );
269
270    Ok(PreparedCompaction {
271        archived_messages,
272        preserved_messages,
273        previous_summary,
274        history_excerpt,
275    })
276}
277
278pub fn build_summary_request(
279    base: &ChatRequest,
280    prepared: &PreparedCompaction,
281    focus: Option<&str>,
282    policy: CompactionPolicy,
283) -> ChatRequest {
284    ChatRequest {
285        model_id: base.model_id.clone(),
286        messages: vec![ChatMessage::user(summary_prompt(prepared, focus))],
287        system_prompt: compaction_system_prompt().to_string(),
288        instructions: None,
289        reasoning: compaction_reasoning(base.reasoning),
290        temperature: 0.0,
291        max_tokens: policy.summary_max_tokens,
292        tools: Vec::new(),
293    }
294}
295
296pub fn build_verification_request(
297    base: &ChatRequest,
298    prepared: &PreparedCompaction,
299    draft_summary: &str,
300    focus: Option<&str>,
301    policy: CompactionPolicy,
302) -> ChatRequest {
303    let prompt = format!(
304        "{}\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.",
305        summary_prompt(prepared, focus),
306        draft_summary.trim()
307    );
308    ChatRequest {
309        model_id: base.model_id.clone(),
310        messages: vec![ChatMessage::user(prompt)],
311        system_prompt: compaction_system_prompt().to_string(),
312        instructions: None,
313        reasoning: compaction_reasoning(base.reasoning),
314        temperature: 0.0,
315        max_tokens: policy.summary_max_tokens,
316        tools: Vec::new(),
317    }
318}
319
320pub fn build_replacement_messages(
321    summary: &str,
322    prepared: &PreparedCompaction,
323    record: &CompactionRecord,
324) -> Vec<ChatMessage> {
325    let checkpoint = format!(
326        "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
327        CHECKPOINT_MARKER,
328        record.id,
329        record.trigger.as_str(),
330        record.created_at.to_rfc3339(),
331        record.archived_message_count,
332        record.preserved_message_count,
333        summary.trim()
334    );
335    let mut user = ChatMessage::user(checkpoint);
336    user.kind = ChatMessageKind::ContextCheckpoint;
337    user.metadata = Some(serde_json::json!({
338        "compaction_id": record.id,
339        "trigger": record.trigger.as_str(),
340        "before_tokens": record.before_tokens,
341        "after_tokens": record.after_tokens,
342        "archived_message_count": record.archived_message_count,
343        "preserved_message_count": record.preserved_message_count,
344        "duration_secs": record.duration_secs,
345        "verified": record.verified,
346        "verification_error": record.verification_error,
347    }));
348
349    let mut assistant = ChatMessage::assistant(compaction_receipt(record));
350    assistant.kind = ChatMessageKind::ContextCheckpoint;
351    assistant.metadata = user.metadata.clone();
352
353    let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
354    messages.push(user);
355    messages.push(assistant);
356    messages.extend(prepared.preserved_messages.clone());
357    messages
358}
359
360pub fn compaction_receipt(record: &CompactionRecord) -> String {
361    let verification = if record.verified {
362        "Verified.".to_string()
363    } else if let Some(error) = &record.verification_error {
364        format!("Used draft summary because verification failed: {error}.")
365    } else {
366        "Used draft summary without verification.".to_string()
367    };
368    format!(
369        "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
370        format_compact_count(record.before_tokens),
371        format_compact_count(record.after_tokens),
372        record.archived_message_count,
373        record.preserved_message_count,
374        record.duration_secs,
375        verification
376    )
377}
378
379pub fn normalize_summary(text: &str) -> String {
380    let trimmed = text.trim();
381    if let Some(summary) = extract_tagged_summary(trimmed) {
382        return summary.trim().to_string();
383    }
384    trimmed.to_string()
385}
386
387pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
388    match (a, b) {
389        (None, None) => None,
390        (Some(u), None) | (None, Some(u)) => Some(u),
391        (Some(mut left), Some(right)) => {
392            left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
393            left.completion_tokens = left
394                .completion_tokens
395                .saturating_add(right.completion_tokens);
396            left.total_tokens = left.total_tokens.saturating_add(right.total_tokens);
397            left.cached_input_tokens = left
398                .cached_input_tokens
399                .saturating_add(right.cached_input_tokens);
400            left.cache_creation_input_tokens = left
401                .cache_creation_input_tokens
402                .saturating_add(right.cache_creation_input_tokens);
403            left.reasoning_output_tokens = left
404                .reasoning_output_tokens
405                .saturating_add(right.reasoning_output_tokens);
406            Some(left)
407        },
408    }
409}
410
411pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
412    messages.iter().map(estimate_message_tokens).sum()
413}
414
415pub fn format_compact_count(value: usize) -> String {
416    if value >= 1_000_000 {
417        format!("{:.1}M", value as f64 / 1_000_000.0)
418    } else if value >= 1_000 {
419        format!("{:.1}k", value as f64 / 1_000.0)
420    } else {
421        value.to_string()
422    }
423}
424
425fn compaction_system_prompt() -> &'static str {
426    "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."
427}
428
429fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
430    match current {
431        ReasoningLevel::None | ReasoningLevel::Minimal => current,
432        _ => ReasoningLevel::Low,
433    }
434}
435
436fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
437    let anchor = prepared
438        .previous_summary
439        .as_deref()
440        .map(|summary| {
441            format!(
442                "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>",
443                summary.trim()
444            )
445        })
446        .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
447
448    let focus = focus
449        .filter(|s| !s.trim().is_empty())
450        .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
451        .unwrap_or_default();
452
453    format!(
454        "{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{}",
455        prepared.history_excerpt
456    )
457}
458
459fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
460    let mut user_turns = 0usize;
461    let mut start = None;
462    for (idx, msg) in messages.iter().enumerate().rev() {
463        if msg.role == MessageRole::User {
464            user_turns += 1;
465            start = Some(idx);
466            if user_turns >= policy.tail_turns {
467                break;
468            }
469        }
470    }
471    let mut start = start?;
472    while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
473        let next_user = messages
474            .iter()
475            .enumerate()
476            .skip(start + 1)
477            .find(|(_, msg)| msg.role == MessageRole::User)
478            .map(|(idx, _)| idx);
479        match next_user {
480            Some(idx) => start = idx,
481            None => break,
482        }
483    }
484    Some(start)
485}
486
487fn format_history_excerpt(messages: &[ChatMessage], policy: CompactionPolicy) -> String {
488    let mut out = String::new();
489    for (idx, msg) in messages.iter().enumerate() {
490        let role = match msg.role {
491            MessageRole::User => "USER",
492            MessageRole::Assistant => "ASSISTANT",
493            MessageRole::System => "SYSTEM",
494            MessageRole::Tool => "TOOL",
495        };
496        out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
497        if msg.kind != ChatMessageKind::Normal {
498            out.push_str(&format!("kind: {:?}\n", msg.kind));
499        }
500        if let Some(name) = &msg.tool_name {
501            out.push_str(&format!("tool_name: {}\n", name));
502        }
503        if let Some(id) = &msg.tool_call_id {
504            out.push_str(&format!("tool_call_id: {}\n", id));
505        }
506        if let Some(calls) = &msg.tool_calls {
507            let names: Vec<&str> = calls
508                .iter()
509                .map(|call| call.function.name.as_str())
510                .collect();
511            out.push_str(&format!("tool_calls: {}\n", names.join(", ")));
512        }
513        if let Some(images) = &msg.images
514            && !images.is_empty()
515        {
516            out.push_str(&format!("[{} image attachment(s) omitted]\n", images.len()));
517        }
518        for action in &msg.actions {
519            out.push_str(&format!(
520                "action: {}({}) duration={:?}\n",
521                action.action_type, action.target, action.duration_seconds
522            ));
523            if let Some(metadata) = &action.metadata {
524                out.push_str(&format!("action_metadata: {:?}\n", metadata));
525            }
526        }
527        let cap = if msg.role == MessageRole::Tool {
528            policy.tool_output_max_chars
529        } else {
530            policy.tool_output_max_chars.saturating_mul(4)
531        };
532        out.push_str(&truncate_middle(&msg.content, cap));
533    }
534    out
535}
536
537fn estimate_message_tokens(msg: &ChatMessage) -> usize {
538    let mut chars = msg.content.len();
539    chars = chars.saturating_add(format!("{:?}", msg.role).len());
540    chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
541    chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
542    if let Some(images) = &msg.images {
543        chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
544    }
545    // Assistant tool calls carry the function name + a JSON arguments payload
546    // (often kilobytes for a file write or shell script). Omitting these made
547    // the estimate run systematically low for this tool-heavy agent, causing
548    // under-compaction and provider-side context overflows.
549    if let Some(tool_calls) = &msg.tool_calls {
550        for tc in tool_calls {
551            chars = chars.saturating_add(tc.function.name.len());
552            chars = chars.saturating_add(tc.function.arguments.to_string().len());
553            chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
554        }
555    }
556    chars.div_ceil(4)
557}
558
559fn truncate_middle(text: &str, max_chars: usize) -> String {
560    if text.chars().count() <= max_chars {
561        return text.to_string();
562    }
563    if max_chars < 128 {
564        return text.chars().take(max_chars).collect();
565    }
566    let marker = "\n\n[... truncated during context compaction ...]\n\n";
567    let keep = max_chars.saturating_sub(marker.len());
568    let head = keep / 2;
569    let tail = keep.saturating_sub(head);
570    let start: String = text.chars().take(head).collect();
571    let end: String = text
572        .chars()
573        .rev()
574        .take(tail)
575        .collect::<Vec<_>>()
576        .into_iter()
577        .rev()
578        .collect();
579    format!("{start}{marker}{end}")
580}
581
582fn extract_tagged_summary(text: &str) -> Option<&str> {
583    let start_tag = "<summary>";
584    let end_tag = "</summary>";
585    let start = text.find(start_tag)? + start_tag.len();
586    let end = text[start..].find(end_tag)? + start;
587    Some(&text[start..end])
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
595        ChatRequest {
596            model_id: "ollama/test".to_string(),
597            messages,
598            system_prompt: "system".to_string(),
599            instructions: None,
600            reasoning: ReasoningLevel::Medium,
601            temperature: 0.7,
602            max_tokens: 4096,
603            tools: Vec::new(),
604        }
605    }
606
607    #[test]
608    fn auto_compaction_triggers_by_percent() {
609        let snapshot = ContextUsageSnapshot::from_estimate(
610            super::super::state::PromptTokenBreakdown {
611                system_tokens: 0,
612                instructions_tokens: 0,
613                message_tokens: 86,
614                tool_schema_tokens: 0,
615                image_count: 0,
616                message_count: 2,
617                tool_count: 0,
618            },
619            Some(100),
620        );
621        let req = request_with(vec![ChatMessage::user("hello")]);
622        assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
623    }
624
625    #[test]
626    fn prepare_preserves_recent_two_user_turns() {
627        let messages = vec![
628            ChatMessage::user("one"),
629            ChatMessage::assistant("one answer"),
630            ChatMessage::user("two"),
631            ChatMessage::assistant("two answer"),
632            ChatMessage::user("three"),
633        ];
634        let request = CompactionRequest::manual(request_with(messages), None);
635        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
636        assert_eq!(prepared.archived_messages.len(), 2);
637        assert_eq!(prepared.preserved_messages.len(), 3);
638        assert_eq!(prepared.preserved_messages[0].content, "two");
639    }
640
641    #[test]
642    fn replacement_starts_with_checkpoint_and_ack() {
643        let prepared = PreparedCompaction {
644            archived_messages: vec![ChatMessage::user("old")],
645            preserved_messages: vec![ChatMessage::user("new")],
646            previous_summary: None,
647            history_excerpt: "old".to_string(),
648        };
649        let record = CompactionRecord {
650            id: "c1".to_string(),
651            trigger: CompactionTrigger::Manual,
652            created_at: Local::now(),
653            before_tokens: 100,
654            after_tokens: 25,
655            archived_message_count: 1,
656            preserved_message_count: 1,
657            summary_tokens: 10,
658            duration_secs: 1.0,
659            verified: true,
660            verification_error: None,
661            focus: None,
662            archive_path: None,
663        };
664        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
665        assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
666        assert!(messages[0].content.contains(CHECKPOINT_MARKER));
667        assert_eq!(messages[2].content, "new");
668    }
669
670    #[test]
671    fn replacement_metadata_records_verification_status() {
672        let prepared = PreparedCompaction {
673            archived_messages: vec![ChatMessage::user("old")],
674            preserved_messages: vec![ChatMessage::user("new")],
675            previous_summary: None,
676            history_excerpt: "old".to_string(),
677        };
678        let record = CompactionRecord {
679            id: "c1".to_string(),
680            trigger: CompactionTrigger::Manual,
681            created_at: Local::now(),
682            before_tokens: 100,
683            after_tokens: 25,
684            archived_message_count: 1,
685            preserved_message_count: 1,
686            summary_tokens: 10,
687            duration_secs: 1.0,
688            verified: false,
689            verification_error: Some("provider overloaded".to_string()),
690            focus: None,
691            archive_path: None,
692        };
693        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
694        let metadata = messages[0].metadata.as_ref().expect("metadata");
695        assert_eq!(
696            metadata.get("verified").and_then(|v| v.as_bool()),
697            Some(false)
698        );
699        assert_eq!(
700            metadata.get("verification_error").and_then(|v| v.as_str()),
701            Some("provider overloaded")
702        );
703        assert!(messages[1].content.contains("Used draft summary"));
704    }
705}