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 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 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 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
478fn 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 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 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 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 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}