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 mut preserved_messages = messages[split..].to_vec();
247 if archived_messages.is_empty() || preserved_messages.is_empty() {
248 return Err(CompactionSkip::NothingToCompact);
249 }
250 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 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
469fn 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 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 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 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 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}