1use 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 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}