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 conversation history to summarize"),
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 let preserve_pending_tail = matches!(
264 request.trigger,
265 CompactionTrigger::ContextLimitRetry | CompactionTrigger::TruncationRecovery
266 );
267 drop_orphan_tool_calls(&mut preserved_messages, preserve_pending_tail);
268
269 let previous_summary = archived_messages
270 .iter()
271 .rev()
272 .find(|m| {
273 m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
274 })
275 .map(|m| m.content.clone());
276
277 let max_input_tokens = max_context_tokens
278 .map(|max| max.saturating_sub(request.policy.response_reserve(request.chat.max_tokens)))
279 .filter(|max| *max > 0)
280 .unwrap_or(request.policy.summarizer_input_token_budget)
281 .min(request.policy.summarizer_input_token_budget);
282 let max_chars = max_input_tokens.saturating_mul(4).max(4_000);
283 let history_excerpt = truncate_middle(
284 &format_history_excerpt(&archived_messages, request.policy),
285 max_chars,
286 );
287
288 Ok(PreparedCompaction {
289 archived_messages,
290 preserved_messages,
291 previous_summary,
292 history_excerpt,
293 })
294}
295
296pub fn build_summary_request(
297 base: &ChatRequest,
298 prepared: &PreparedCompaction,
299 focus: Option<&str>,
300 policy: CompactionPolicy,
301) -> ChatRequest {
302 ChatRequest {
303 model_id: base.model_id.clone(),
304 messages: vec![ChatMessage::user(summary_prompt(prepared, focus))],
305 system_prompt: compaction_system_prompt().to_string(),
306 instructions: None,
307 reasoning: compaction_reasoning(base.reasoning),
308 temperature: 0.0,
309 max_tokens: policy.summary_max_tokens,
310 tools: Vec::new(),
311 ollama_num_ctx: base.ollama_num_ctx,
312 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
313 }
314}
315
316pub fn build_verification_request(
317 base: &ChatRequest,
318 prepared: &PreparedCompaction,
319 draft_summary: &str,
320 focus: Option<&str>,
321 policy: CompactionPolicy,
322) -> ChatRequest {
323 let prompt = format!(
324 "{}\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.",
325 summary_prompt(prepared, focus),
326 draft_summary.trim()
327 );
328 ChatRequest {
329 model_id: base.model_id.clone(),
330 messages: vec![ChatMessage::user(prompt)],
331 system_prompt: compaction_system_prompt().to_string(),
332 instructions: None,
333 reasoning: compaction_reasoning(base.reasoning),
334 temperature: 0.0,
335 max_tokens: policy.summary_max_tokens,
336 tools: Vec::new(),
337 ollama_num_ctx: base.ollama_num_ctx,
338 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
339 }
340}
341
342pub fn build_replacement_messages(
343 summary: &str,
344 prepared: &PreparedCompaction,
345 record: &CompactionRecord,
346) -> Vec<ChatMessage> {
347 let summary = crate::utils::redact_secrets(summary);
351 let summary = summary.as_str();
352 let checkpoint = format!(
353 "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
354 CHECKPOINT_MARKER,
355 record.id,
356 record.trigger.as_str(),
357 record.created_at.to_rfc3339(),
358 record.archived_message_count,
359 record.preserved_message_count,
360 summary.trim()
361 );
362 let mut user = ChatMessage::user(checkpoint);
363 user.kind = ChatMessageKind::ContextCheckpoint;
364 user.metadata = Some(serde_json::json!({
365 "compaction_id": record.id,
366 "trigger": record.trigger.as_str(),
367 "before_tokens": record.before_tokens,
368 "after_tokens": record.after_tokens,
369 "archived_message_count": record.archived_message_count,
370 "preserved_message_count": record.preserved_message_count,
371 "duration_secs": record.duration_secs,
372 "verified": record.verified,
373 "verification_error": record.verification_error,
374 }));
375
376 let mut assistant = ChatMessage::assistant(compaction_receipt(record));
377 assistant.kind = ChatMessageKind::ContextCheckpoint;
378 assistant.metadata = user.metadata.clone();
379
380 let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
381 messages.push(user);
382 messages.push(assistant);
383 messages.extend(prepared.preserved_messages.clone());
384 messages
385}
386
387pub fn compaction_receipt(record: &CompactionRecord) -> String {
388 let verification = if record.verified {
389 "Verified.".to_string()
390 } else if let Some(error) = &record.verification_error {
391 format!("Used draft summary because verification failed: {error}.")
392 } else {
393 "Used draft summary without verification.".to_string()
394 };
395 format!(
396 "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
397 format_compact_count(record.before_tokens),
398 format_compact_count(record.after_tokens),
399 record.archived_message_count,
400 record.preserved_message_count,
401 record.duration_secs,
402 verification
403 )
404}
405
406pub fn normalize_summary(text: &str) -> String {
407 let trimmed = text.trim();
408 if let Some(summary) = extract_tagged_summary(trimmed) {
409 return summary.trim().to_string();
410 }
411 trimmed.to_string()
412}
413
414pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
415 match (a, b) {
416 (None, None) => None,
417 (Some(u), None) | (None, Some(u)) => Some(u),
418 (Some(mut left), Some(right)) => {
419 left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
420 left.completion_tokens = left
421 .completion_tokens
422 .saturating_add(right.completion_tokens);
423 left.total_tokens = left.total_tokens.saturating_add(right.total_tokens);
424 left.cached_input_tokens = left
425 .cached_input_tokens
426 .saturating_add(right.cached_input_tokens);
427 left.cache_creation_input_tokens = left
428 .cache_creation_input_tokens
429 .saturating_add(right.cache_creation_input_tokens);
430 left.reasoning_output_tokens = left
431 .reasoning_output_tokens
432 .saturating_add(right.reasoning_output_tokens);
433 Some(left)
434 },
435 }
436}
437
438pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
439 messages.iter().map(estimate_message_tokens).sum()
440}
441
442pub fn format_compact_count(value: usize) -> String {
443 if value >= 1_000_000 {
444 format!("{:.1}M", value as f64 / 1_000_000.0)
445 } else if value >= 1_000 {
446 format!("{:.1}k", value as f64 / 1_000.0)
447 } else {
448 value.to_string()
449 }
450}
451
452fn compaction_system_prompt() -> &'static str {
453 "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."
454}
455
456fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
457 match current {
458 ReasoningLevel::None | ReasoningLevel::Minimal => current,
459 _ => ReasoningLevel::Low,
460 }
461}
462
463fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
464 let anchor = prepared
465 .previous_summary
466 .as_deref()
467 .map(|summary| {
468 format!(
469 "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>",
470 summary.trim()
471 )
472 })
473 .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
474
475 let focus = focus
476 .filter(|s| !s.trim().is_empty())
477 .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
478 .unwrap_or_default();
479
480 format!(
481 "{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{}",
482 prepared.history_excerpt
483 )
484}
485
486fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
509 let pending_tail = if preserve_pending_tail
510 && messages.last().is_some_and(|m| {
511 m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
512 }) {
513 Some(messages.len() - 1)
514 } else {
515 None
516 };
517
518 let answered: std::collections::HashSet<String> = messages
519 .iter()
520 .filter(|m| m.role == MessageRole::Tool)
521 .filter_map(|m| m.tool_call_id.clone())
522 .collect();
523
524 for (idx, m) in messages.iter_mut().enumerate() {
526 if Some(idx) == pending_tail {
527 continue;
528 }
529 let Some(calls) = m.tool_calls.as_mut() else {
530 continue;
531 };
532 calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
533 if calls.is_empty() {
534 m.tool_calls = None;
535 }
536 }
537
538 let emitted: std::collections::HashSet<String> = messages
541 .iter()
542 .filter_map(|m| m.tool_calls.as_ref())
543 .flat_map(|calls| calls.iter())
544 .filter_map(|c| c.id.clone())
545 .collect();
546 messages.retain(|m| {
547 m.role != MessageRole::Tool
548 || m.tool_call_id
549 .as_deref()
550 .is_some_and(|id| emitted.contains(id))
551 });
552}
553
554fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
555 let mut user_turns = 0usize;
556 let mut start = None;
557 for (idx, msg) in messages.iter().enumerate().rev() {
558 if msg.role == MessageRole::User {
559 user_turns += 1;
560 start = Some(idx);
561 if user_turns >= policy.tail_turns {
562 break;
563 }
564 }
565 }
566 let mut start = start?;
567 while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
568 let next_user = messages
569 .iter()
570 .enumerate()
571 .skip(start + 1)
572 .find(|(_, msg)| msg.role == MessageRole::User)
573 .map(|(idx, _)| idx);
574 match next_user {
575 Some(idx) => start = idx,
576 None => break,
577 }
578 }
579 Some(start)
580}
581
582fn format_history_excerpt(messages: &[ChatMessage], policy: CompactionPolicy) -> String {
583 let mut out = String::new();
584 for (idx, msg) in messages.iter().enumerate() {
585 let role = match msg.role {
586 MessageRole::User => "USER",
587 MessageRole::Assistant => "ASSISTANT",
588 MessageRole::System => "SYSTEM",
589 MessageRole::Tool => "TOOL",
590 };
591 out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
592 if msg.kind != ChatMessageKind::Normal {
593 out.push_str(&format!("kind: {:?}\n", msg.kind));
594 }
595 if let Some(name) = &msg.tool_name {
596 out.push_str(&format!("tool_name: {}\n", name));
597 }
598 if let Some(id) = &msg.tool_call_id {
599 out.push_str(&format!("tool_call_id: {}\n", id));
600 }
601 if let Some(calls) = &msg.tool_calls {
602 let names: Vec<&str> = calls
603 .iter()
604 .map(|call| call.function.name.as_str())
605 .collect();
606 out.push_str(&format!("tool_calls: {}\n", names.join(", ")));
607 }
608 if let Some(images) = &msg.images
609 && !images.is_empty()
610 {
611 out.push_str(&format!("[{} image attachment(s) omitted]\n", images.len()));
612 }
613 for action in &msg.actions {
614 out.push_str(&format!(
615 "action: {}({}) duration={:?}\n",
616 action.action_type, action.target, action.duration_seconds
617 ));
618 if let Some(metadata) = &action.metadata {
619 out.push_str(&format!("action_metadata: {:?}\n", metadata));
620 }
621 }
622 let cap = if msg.role == MessageRole::Tool {
623 policy.tool_output_max_chars
624 } else {
625 policy.tool_output_max_chars.saturating_mul(4)
626 };
627 out.push_str(&truncate_middle(&msg.content, cap));
628 }
629 out
630}
631
632fn estimate_message_tokens(msg: &ChatMessage) -> usize {
633 let mut chars = msg.content.len();
634 chars = chars.saturating_add(format!("{:?}", msg.role).len());
635 chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
636 chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
637 if let Some(images) = &msg.images {
638 chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
639 }
640 if let Some(tool_calls) = &msg.tool_calls {
645 for tc in tool_calls {
646 chars = chars.saturating_add(tc.function.name.len());
647 chars = chars.saturating_add(tc.function.arguments.to_string().len());
648 chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
649 }
650 }
651 chars.div_ceil(4)
652}
653
654fn truncate_middle(text: &str, max_chars: usize) -> String {
655 if text.chars().count() <= max_chars {
656 return text.to_string();
657 }
658 if max_chars < 128 {
659 return text.chars().take(max_chars).collect();
660 }
661 let marker = "\n\n[... truncated during context compaction ...]\n\n";
662 let keep = max_chars.saturating_sub(marker.len());
663 let head = keep / 2;
664 let tail = keep.saturating_sub(head);
665 let start: String = text.chars().take(head).collect();
666 let end: String = text
667 .chars()
668 .rev()
669 .take(tail)
670 .collect::<Vec<_>>()
671 .into_iter()
672 .rev()
673 .collect();
674 format!("{start}{marker}{end}")
675}
676
677fn extract_tagged_summary(text: &str) -> Option<&str> {
678 let start_tag = "<summary>";
679 let end_tag = "</summary>";
680 let start = text.find(start_tag)? + start_tag.len();
681 let end = text[start..].find(end_tag)? + start;
682 Some(&text[start..end])
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
690 ChatRequest {
691 model_id: "ollama/test".to_string(),
692 messages,
693 system_prompt: "system".to_string(),
694 instructions: None,
695 reasoning: ReasoningLevel::Medium,
696 temperature: 0.7,
697 max_tokens: 4096,
698 tools: Vec::new(),
699 ollama_num_ctx: None,
700 ollama_allow_ram_offload: None,
701 }
702 }
703
704 #[test]
705 fn auto_compaction_triggers_by_percent() {
706 let snapshot = ContextUsageSnapshot::from_estimate(
707 super::super::state::PromptTokenBreakdown {
708 system_tokens: 0,
709 instructions_tokens: 0,
710 message_tokens: 86,
711 tool_schema_tokens: 0,
712 image_count: 0,
713 message_count: 2,
714 tool_count: 0,
715 },
716 Some(100),
717 );
718 let req = request_with(vec![ChatMessage::user("hello")]);
719 assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
720 }
721
722 #[test]
723 fn prepare_preserves_recent_two_user_turns() {
724 let messages = vec![
725 ChatMessage::user("one"),
726 ChatMessage::assistant("one answer"),
727 ChatMessage::user("two"),
728 ChatMessage::assistant("two answer"),
729 ChatMessage::user("three"),
730 ];
731 let request = CompactionRequest::manual(request_with(messages), None);
732 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
733 assert_eq!(prepared.archived_messages.len(), 2);
734 assert_eq!(prepared.preserved_messages.len(), 3);
735 assert_eq!(prepared.preserved_messages[0].content, "two");
736 }
737
738 fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
739 crate::models::tool_call::ToolCall {
740 id: Some(id.to_string()),
741 function: crate::models::tool_call::FunctionCall {
742 name: name.to_string(),
743 arguments: serde_json::json!({}),
744 },
745 }
746 }
747
748 #[test]
749 fn prepare_strips_orphan_tool_call_from_preserved_tail() {
750 let mut orphan = ChatMessage::assistant("calling a tool");
753 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
754 let messages = vec![
755 ChatMessage::user("one"),
756 ChatMessage::assistant("one answer"),
757 ChatMessage::user("two"),
758 orphan,
759 ChatMessage::user("three"),
760 ];
761 let request = CompactionRequest::manual(request_with(messages), None);
762 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
763 let has_orphan = prepared
764 .preserved_messages
765 .iter()
766 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
767 assert!(
768 !has_orphan,
769 "orphan tool_use must be stripped from the tail"
770 );
771 assert!(
773 prepared
774 .preserved_messages
775 .iter()
776 .any(|m| m.content == "calling a tool")
777 );
778 }
779
780 #[test]
781 fn prepare_keeps_paired_tool_call_in_tail() {
782 let mut asst = ChatMessage::assistant("calling");
784 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
785 let messages = vec![
786 ChatMessage::user("one"),
787 ChatMessage::assistant("one answer"),
788 ChatMessage::user("two"),
789 asst,
790 ChatMessage::tool("call_1", "do_thing", "ok"),
791 ChatMessage::user("three"),
792 ];
793 let request = CompactionRequest::manual(request_with(messages), None);
794 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
795 let kept = prepared
796 .preserved_messages
797 .iter()
798 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
799 assert!(kept, "a tool_call paired with its result must be preserved");
800 }
801
802 #[test]
803 fn prepare_drops_reverse_orphan_tool_result_from_tail() {
804 let mut asst = ChatMessage::assistant("calling");
809 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
810 let messages = vec![
811 ChatMessage::user("one"),
812 asst,
813 ChatMessage::user("two"),
814 ChatMessage::tool("call_1", "do_thing", "result"),
815 ChatMessage::user("three"),
816 ];
817 let request = CompactionRequest::manual(request_with(messages), None);
820 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
821 assert!(
822 prepared
823 .preserved_messages
824 .iter()
825 .all(|m| m.role != MessageRole::Tool),
826 "an orphan tool_result whose tool_use was archived must be dropped"
827 );
828 }
829
830 #[test]
831 fn prepare_keeps_pending_trailing_tool_use_on_retry() {
832 let mut pending = ChatMessage::assistant("calling a tool");
836 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
837 let messages = vec![
838 ChatMessage::user("one"),
839 ChatMessage::assistant("a1"),
840 ChatMessage::user("two"),
841 ChatMessage::assistant("a2"),
842 ChatMessage::user("three"),
843 pending,
844 ];
845 let request =
846 CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
847 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
848 let last = prepared
849 .preserved_messages
850 .last()
851 .expect("non-empty preserved tail");
852 assert!(
853 last.tool_calls
854 .as_ref()
855 .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
856 "a pending trailing tool_use must be preserved across a retry compaction"
857 );
858 }
859
860 #[test]
861 fn prepare_drops_trailing_tool_use_on_manual_compaction() {
862 let mut pending = ChatMessage::assistant("calling a tool");
866 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
867 let messages = vec![
868 ChatMessage::user("one"),
869 ChatMessage::assistant("a1"),
870 ChatMessage::user("two"),
871 ChatMessage::assistant("a2"),
872 ChatMessage::user("three"),
873 pending,
874 ];
875 let request = CompactionRequest::manual(request_with(messages), None);
876 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
877 assert!(
878 !prepared
879 .preserved_messages
880 .iter()
881 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
882 "manual compaction must scrub the trailing orphan tool_use"
883 );
884 assert!(
885 prepared
886 .preserved_messages
887 .iter()
888 .any(|m| m.content == "calling a tool"),
889 "the assistant text is kept even though the orphan call is dropped"
890 );
891 }
892
893 #[test]
894 fn replacement_starts_with_checkpoint_and_ack() {
895 let prepared = PreparedCompaction {
896 archived_messages: vec![ChatMessage::user("old")],
897 preserved_messages: vec![ChatMessage::user("new")],
898 previous_summary: None,
899 history_excerpt: "old".to_string(),
900 };
901 let record = CompactionRecord {
902 id: "c1".to_string(),
903 trigger: CompactionTrigger::Manual,
904 created_at: Local::now(),
905 before_tokens: 100,
906 after_tokens: 25,
907 archived_message_count: 1,
908 preserved_message_count: 1,
909 summary_tokens: 10,
910 duration_secs: 1.0,
911 verified: true,
912 verification_error: None,
913 focus: None,
914 archive_path: None,
915 };
916 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
917 assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
918 assert!(messages[0].content.contains(CHECKPOINT_MARKER));
919 assert_eq!(messages[2].content, "new");
920 }
921
922 #[test]
923 fn replacement_metadata_records_verification_status() {
924 let prepared = PreparedCompaction {
925 archived_messages: vec![ChatMessage::user("old")],
926 preserved_messages: vec![ChatMessage::user("new")],
927 previous_summary: None,
928 history_excerpt: "old".to_string(),
929 };
930 let record = CompactionRecord {
931 id: "c1".to_string(),
932 trigger: CompactionTrigger::Manual,
933 created_at: Local::now(),
934 before_tokens: 100,
935 after_tokens: 25,
936 archived_message_count: 1,
937 preserved_message_count: 1,
938 summary_tokens: 10,
939 duration_secs: 1.0,
940 verified: false,
941 verification_error: Some("provider overloaded".to_string()),
942 focus: None,
943 archive_path: None,
944 };
945 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
946 let metadata = messages[0].metadata.as_ref().expect("metadata");
947 assert_eq!(
948 metadata.get("verified").and_then(|v| v.as_bool()),
949 Some(false)
950 );
951 assert_eq!(
952 metadata.get("verification_error").and_then(|v| v.as_str()),
953 Some("provider overloaded")
954 );
955 assert!(messages[1].content.contains("Used draft summary"));
956 }
957}