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: &ChatRequest) -> usize {
92 let desired = if request.max_tokens > 0 {
93 request.max_tokens
94 } else {
95 self.min_response_reserve_tokens
96 + crate::models::adapters::output_budget::reasoning_output_reserve(
97 request.reasoning,
98 )
99 };
100 desired
101 .max(self.min_response_reserve_tokens)
102 .min(self.max_response_reserve_tokens)
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum LengthCause {
109 OutputCapped,
112 ContextFull,
114 Unknown,
117}
118
119pub fn classify_length_stop(
126 usage: Option<&TokenUsage>,
127 window: Option<usize>,
128 reserve: usize,
129) -> LengthCause {
130 let Some(u) = usage else {
131 return LengthCause::Unknown;
132 };
133 match window {
134 None => LengthCause::OutputCapped,
135 Some(w) => {
136 if u.total_tokens().saturating_add(reserve) >= w {
137 LengthCause::ContextFull
138 } else {
139 LengthCause::OutputCapped
140 }
141 },
142 }
143}
144
145#[derive(Debug, Clone)]
146pub struct CompactionRequest {
147 pub chat: ChatRequest,
148 pub trigger: CompactionTrigger,
149 pub instructions: Option<String>,
150 pub policy: CompactionPolicy,
151}
152
153impl CompactionRequest {
154 pub fn manual(chat: ChatRequest, instructions: Option<String>) -> Self {
155 Self {
156 chat,
157 trigger: CompactionTrigger::Manual,
158 instructions,
159 policy: CompactionPolicy::default(),
160 }
161 }
162
163 pub fn auto(chat: ChatRequest, trigger: CompactionTrigger) -> Self {
164 Self {
165 chat,
166 trigger,
167 instructions: None,
168 policy: CompactionPolicy::default(),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum CompactionReviewStatus {
176 Reviewed,
177 DraftValidated,
178}
179
180impl CompactionReviewStatus {
181 pub fn as_str(self) -> &'static str {
182 match self {
183 Self::Reviewed => "reviewed",
184 Self::DraftValidated => "draft_validated",
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct CompactionRecord {
191 pub id: String,
192 pub trigger: CompactionTrigger,
193 pub created_at: DateTime<Local>,
194 pub before_tokens: usize,
195 pub after_tokens: usize,
196 pub archived_message_count: usize,
197 pub preserved_message_count: usize,
198 pub preserved_turn_count: usize,
199 pub summary_tokens: usize,
200 pub duration_secs: f64,
201 pub review_status: CompactionReviewStatus,
202 pub review_error: Option<String>,
203 #[serde(default)]
204 pub focus: Option<String>,
205 #[serde(default)]
206 pub archive_path: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CompactionArchive {
211 pub id: String,
212 pub conversation_id: String,
213 pub created_at: DateTime<Local>,
214 pub messages: Vec<ChatMessage>,
215}
216
217#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
218pub struct CompactionResult {
219 pub record: CompactionRecord,
220 pub replacement_messages: Vec<ChatMessage>,
221 pub archived_messages: Vec<ChatMessage>,
222 pub before_snapshot: ContextUsageSnapshot,
223 pub after_snapshot: ContextUsageSnapshot,
224 pub usage: Option<TokenUsage>,
225 pub source_boundaries: Vec<CompactionBoundary>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct CompactionBoundary {
230 pub fingerprint: String,
238}
239
240impl CompactionBoundary {
241 pub fn from_message(message: &ChatMessage) -> Self {
242 Self {
243 fingerprint: Self::fingerprint_of(message),
244 }
245 }
246
247 pub fn fingerprint_of(message: &ChatMessage) -> String {
248 use sha2::{Digest, Sha256};
249 use std::fmt::Write as _;
250 let mut hasher = Sha256::new();
251 hasher.update(format!("{:?}", message.role).as_bytes());
252 hasher.update([0u8]);
253 hasher.update(format!("{:?}", message.kind).as_bytes());
254 hasher.update([0u8]);
255 hasher.update(
256 message
257 .timestamp
258 .to_utc()
259 .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
260 .as_bytes(),
261 );
262 hasher.update([0u8]);
263 hasher.update(message.content.as_bytes());
264 let digest = hasher.finalize();
265 let mut out = String::with_capacity(digest.len() * 2);
266 for byte in digest {
267 let _ = write!(out, "{byte:02x}");
268 }
269 out
270 }
271
272 pub fn matches(&self, message: &ChatMessage) -> bool {
273 Self::fingerprint_of(message) == self.fingerprint
274 }
275}
276
277#[derive(Debug, Clone)]
278pub struct PreparedCompaction {
279 pub archived_messages: Vec<ChatMessage>,
280 pub preserved_messages: Vec<ChatMessage>,
281 pub previous_summary: Option<String>,
282 pub history_excerpt: String,
283 pub summary_images: Vec<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum CompactionSkip {
288 NoKnownContextLimit,
289 AutoDisabled,
290 Suppressed,
291 BelowThreshold,
292 NothingToCompact,
293}
294
295impl std::fmt::Display for CompactionSkip {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 match self {
298 Self::NoKnownContextLimit => write!(f, "model context limit is unknown"),
299 Self::AutoDisabled => write!(f, "automatic compaction is disabled"),
300 Self::Suppressed => write!(
301 f,
302 "automatic compaction is paused after a failed attempt; run /compact to retry"
303 ),
304 Self::BelowThreshold => write!(f, "context is below compaction threshold"),
305 Self::NothingToCompact => write!(f, "not enough conversation history to summarize"),
306 }
307 }
308}
309
310pub fn should_auto_compact(
311 snapshot: &ContextUsageSnapshot,
312 request: &ChatRequest,
313 policy: CompactionPolicy,
314) -> Result<(), CompactionSkip> {
315 if !policy.auto_enabled {
316 return Err(CompactionSkip::AutoDisabled);
317 }
318 if request.suppress_auto_compact {
319 return Err(CompactionSkip::Suppressed);
320 }
321 let Some(max_tokens) = snapshot.max_tokens else {
322 return Err(CompactionSkip::NoKnownContextLimit);
323 };
324 if max_tokens == 0 {
325 return Err(CompactionSkip::NoKnownContextLimit);
326 }
327
328 let reserve = policy.response_reserve(request);
329 let over_percent = snapshot
330 .used_percent
331 .is_some_and(|p| p >= policy.auto_threshold_percent);
332 let low_remaining = snapshot
333 .remaining_tokens
334 .is_some_and(|remaining| remaining <= reserve);
335 if over_percent || low_remaining {
336 Ok(())
337 } else {
338 Err(CompactionSkip::BelowThreshold)
339 }
340}
341
342pub fn context_exceeds_hard_limit(
343 snapshot: &ContextUsageSnapshot,
344 request: &ChatRequest,
345 policy: CompactionPolicy,
346) -> bool {
347 let Some(max_tokens) = snapshot.max_tokens else {
348 return false;
349 };
350 let reserve = policy.response_reserve(request);
351 snapshot.used_tokens.saturating_add(reserve) >= max_tokens
352}
353
354pub fn prepare_compaction(
355 request: &CompactionRequest,
356 max_context_tokens: Option<usize>,
357) -> Result<PreparedCompaction, CompactionSkip> {
358 let messages = &request.chat.messages;
359 if messages.len() < 3 {
360 return Err(CompactionSkip::NothingToCompact);
361 }
362
363 let split =
364 tail_start_index(messages, request.policy).ok_or(CompactionSkip::NothingToCompact)?;
365 if split == 0 {
366 return Err(CompactionSkip::NothingToCompact);
367 }
368
369 let archived_messages = messages[..split].to_vec();
370 let mut preserved_messages = messages[split..].to_vec();
371 if archived_messages.is_empty() || preserved_messages.is_empty() {
372 return Err(CompactionSkip::NothingToCompact);
373 }
374 let preserve_pending_tail = matches!(
383 request.trigger,
384 CompactionTrigger::ContextLimitRetry | CompactionTrigger::TruncationRecovery
385 );
386 drop_orphan_tool_calls(&mut preserved_messages, preserve_pending_tail);
387
388 let previous_summary = archived_messages
389 .iter()
390 .rev()
391 .find(|m| {
392 m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
393 })
394 .map(|m| m.content.clone());
395
396 let max_input_tokens = max_context_tokens
401 .map(|max| max.saturating_sub(request.policy.summary_max_tokens))
402 .filter(|max| *max > 0)
403 .unwrap_or(request.policy.summarizer_input_token_budget)
404 .min(request.policy.summarizer_input_token_budget);
405 let sizing_prepared = PreparedCompaction {
406 archived_messages: Vec::new(),
407 preserved_messages: Vec::new(),
408 previous_summary: previous_summary.clone(),
409 history_excerpt: String::new(),
410 summary_images: Vec::new(),
411 };
412 let probe = build_summary_request(
419 &request.chat,
420 &sizing_prepared,
421 request.instructions.as_deref(),
422 request.policy,
423 );
424 let fixed_tokens = super::state::estimate_context_usage_for_request(&probe, None).used_tokens;
425 let mut remaining_tokens = max_input_tokens.saturating_sub(fixed_tokens);
426
427 let all_images: Vec<String> = archived_messages
434 .iter()
435 .flat_map(|message| message.images.iter().flatten().cloned())
436 .collect();
437 let mut summary_images = Vec::new();
438 for image in all_images.iter().rev() {
439 let image_tokens = image.len().div_ceil(4);
440 if image_tokens <= remaining_tokens {
441 summary_images.push(image.clone());
442 remaining_tokens = remaining_tokens.saturating_sub(image_tokens);
443 }
444 }
445 summary_images.reverse();
446
447 let history = format_history_excerpt(
448 &archived_messages,
449 request.policy,
450 all_images.len(),
451 summary_images.len(),
452 );
453 let history_excerpt = truncate_middle(&history, remaining_tokens.saturating_mul(4));
454
455 Ok(PreparedCompaction {
456 archived_messages,
457 preserved_messages,
458 previous_summary,
459 history_excerpt,
460 summary_images,
461 })
462}
463
464pub fn build_summary_request(
465 base: &ChatRequest,
466 prepared: &PreparedCompaction,
467 focus: Option<&str>,
468 policy: CompactionPolicy,
469) -> ChatRequest {
470 let mut message = ChatMessage::user(summary_prompt(prepared, focus));
471 if !prepared.summary_images.is_empty() {
472 message.images = Some(prepared.summary_images.clone());
473 }
474 ChatRequest {
475 model_id: base.model_id.clone(),
476 messages: vec![message],
477 system_prompt: compaction_system_prompt().to_string(),
478 instructions: None,
479 reasoning: compaction_reasoning(base.reasoning),
480 temperature: 0.0,
481 max_tokens: policy.summary_max_tokens,
482 tools: Vec::new(),
483 ollama_num_ctx: base.ollama_num_ctx,
484 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
485 resolved_context_window: base.resolved_context_window,
486 resolved_max_output: base.resolved_max_output,
487 output_schema: None,
488 suppress_auto_compact: false,
489 }
490}
491
492pub fn build_verification_request(
493 base: &ChatRequest,
494 prepared: &PreparedCompaction,
495 draft_summary: &str,
496 focus: Option<&str>,
497 policy: CompactionPolicy,
498) -> ChatRequest {
499 let prompt = format!(
500 "{}\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.",
501 summary_prompt(prepared, focus),
502 draft_summary.trim()
503 );
504 let mut message = ChatMessage::user(prompt);
505 if !prepared.summary_images.is_empty() {
506 message.images = Some(prepared.summary_images.clone());
507 }
508 ChatRequest {
509 model_id: base.model_id.clone(),
510 messages: vec![message],
511 system_prompt: compaction_system_prompt().to_string(),
512 instructions: None,
513 reasoning: compaction_reasoning(base.reasoning),
514 temperature: 0.0,
515 max_tokens: policy.summary_max_tokens,
516 tools: Vec::new(),
517 ollama_num_ctx: base.ollama_num_ctx,
518 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
519 resolved_context_window: base.resolved_context_window,
520 resolved_max_output: base.resolved_max_output,
521 output_schema: None,
522 suppress_auto_compact: false,
523 }
524}
525
526pub fn build_replacement_messages(
527 summary: &str,
528 prepared: &PreparedCompaction,
529 record: &CompactionRecord,
530) -> Vec<ChatMessage> {
531 let summary = crate::utils::redact_secrets(summary);
535 let summary = summary.as_str();
536 let checkpoint = format!(
537 "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
538 CHECKPOINT_MARKER,
539 record.id,
540 record.trigger.as_str(),
541 record.created_at.to_rfc3339(),
542 record.archived_message_count,
543 record.preserved_message_count,
544 summary.trim()
545 );
546 let mut user = ChatMessage::user(checkpoint);
547 user.kind = ChatMessageKind::ContextCheckpoint;
548 user.metadata = Some(serde_json::json!({
549 "compaction_id": record.id,
550 "trigger": record.trigger.as_str(),
551 "before_tokens": record.before_tokens,
552 "after_tokens": record.after_tokens,
553 "archived_message_count": record.archived_message_count,
554 "preserved_message_count": record.preserved_message_count,
555 "preserved_turn_count": record.preserved_turn_count,
556 "duration_secs": record.duration_secs,
557 "review_status": record.review_status.as_str(),
558 "review_error": record.review_error,
559 }));
560
561 let mut assistant = ChatMessage::assistant(compaction_receipt(record));
562 assistant.kind = ChatMessageKind::ContextCheckpoint;
563 assistant.metadata = user.metadata.clone();
564
565 let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
566 messages.push(user);
567 messages.push(assistant);
568 messages.extend(prepared.preserved_messages.clone());
569 messages
570}
571
572pub fn compaction_receipt(record: &CompactionRecord) -> String {
573 let review = match record.review_status {
574 CompactionReviewStatus::Reviewed => "Reviewed in a second pass.".to_string(),
575 CompactionReviewStatus::DraftValidated => match &record.review_error {
576 Some(error) => format!("Used the structurally validated draft: {error}."),
577 None => "Used the structurally validated draft.".to_string(),
578 },
579 };
580 format!(
581 "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
582 format_compact_count(record.before_tokens),
583 format_compact_count(record.after_tokens),
584 record.archived_message_count,
585 record.preserved_message_count,
586 record.duration_secs,
587 review
588 )
589}
590
591pub fn normalize_summary(text: &str) -> String {
592 let trimmed = text.trim();
593 if let Some(summary) = extract_tagged_summary(trimmed) {
594 return summary.trim().to_string();
595 }
596 trimmed.to_string()
597}
598
599pub fn validate_summary_structure(summary: &str) -> Result<(), String> {
600 const HEADINGS: [&str; 10] = [
601 "## Goal",
602 "## User Preferences And Constraints",
603 "## Project State",
604 "## Completed Work",
605 "## Current Work",
606 "## Key Decisions",
607 "## Critical Files And Symbols",
608 "## Commands Tests And Results",
609 "## Open Questions Or Risks",
610 "## Next Steps",
611 ];
612
613 let lines: Vec<&str> = summary.lines().collect();
617 let headings: Vec<(usize, &str)> = lines
618 .iter()
619 .enumerate()
620 .filter_map(|(index, line)| {
621 let trimmed = line.trim();
622 HEADINGS.contains(&trimmed).then_some((index, trimmed))
623 })
624 .collect();
625 let actual: Vec<&str> = headings.iter().map(|(_, heading)| *heading).collect();
626 if actual != HEADINGS {
627 return Err(format!(
628 "checkpoint headings must exactly match the required order; got {}",
629 actual.join(", ")
630 ));
631 }
632
633 for (index, (line_index, heading)) in headings.iter().enumerate() {
634 let body_end = headings
635 .get(index + 1)
636 .map(|(next_index, _)| *next_index)
637 .unwrap_or(lines.len());
638 let body = lines[line_index + 1..body_end].join("\n");
639 let body = body.trim();
640 if body.is_empty() || body == "-" || (body.starts_with("- [") && body.ends_with(']')) {
641 return Err(format!(
642 "checkpoint heading {heading} has placeholder content"
643 ));
644 }
645 }
646 Ok(())
647}
648
649pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
650 match (a, b) {
651 (None, None) => None,
652 (Some(u), None) | (None, Some(u)) => Some(u),
653 (Some(mut left), Some(right)) => {
654 left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
655 left.completion_tokens = left
656 .completion_tokens
657 .saturating_add(right.completion_tokens);
658 left.cached_input_tokens = left
659 .cached_input_tokens
660 .saturating_add(right.cached_input_tokens);
661 left.cache_creation_input_tokens = left
662 .cache_creation_input_tokens
663 .saturating_add(right.cache_creation_input_tokens);
664 left.reasoning_output_tokens = left
665 .reasoning_output_tokens
666 .saturating_add(right.reasoning_output_tokens);
667 Some(left)
668 },
669 }
670}
671
672pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
673 messages.iter().map(estimate_message_tokens).sum()
674}
675
676pub fn format_compact_count(value: usize) -> String {
683 if value >= 1_000_000 {
684 format_scaled(value, 1_000_000, "M")
685 } else if value >= 1_000 {
686 format_scaled(value, 1_000, "k")
687 } else {
688 value.to_string()
689 }
690}
691
692fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
693 let whole = value / divisor;
694 let decimal = ((value % divisor) * 10) / divisor;
695 if decimal == 0 {
696 format!("{}{}", whole, suffix)
697 } else {
698 format!("{}.{}{}", whole, decimal, suffix)
699 }
700}
701
702fn compaction_system_prompt() -> &'static str {
703 "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."
704}
705
706fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
707 match current {
708 ReasoningLevel::None | ReasoningLevel::Minimal => current,
709 _ => ReasoningLevel::Low,
710 }
711}
712
713fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
714 let anchor = prepared
715 .previous_summary
716 .as_deref()
717 .map(|summary| {
718 format!(
719 "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>",
720 summary.trim()
721 )
722 })
723 .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
724
725 let focus = focus
726 .filter(|s| !s.trim().is_empty())
727 .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
728 .unwrap_or_default();
729
730 format!(
731 "{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{}",
732 prepared.history_excerpt
733 )
734}
735
736pub(crate) fn normalize_history(messages: &mut Vec<ChatMessage>) {
770 drop_orphan_tool_calls(messages, false);
771}
772
773pub(crate) fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
774 let pending_tail = if preserve_pending_tail
775 && messages.last().is_some_and(|m| {
776 m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
777 }) {
778 Some(messages.len() - 1)
779 } else {
780 None
781 };
782
783 let answered: std::collections::HashSet<String> = messages
784 .iter()
785 .filter(|m| m.role == MessageRole::Tool)
786 .filter_map(|m| m.tool_call_id.clone())
787 .collect();
788
789 for (idx, m) in messages.iter_mut().enumerate() {
791 if Some(idx) == pending_tail {
792 continue;
793 }
794 let Some(calls) = m.tool_calls.as_mut() else {
795 continue;
796 };
797 calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
798 if calls.is_empty() {
799 m.tool_calls = None;
800 }
801 let kept: std::collections::HashSet<&str> = m
802 .tool_calls
803 .iter()
804 .flatten()
805 .filter_map(|call| call.id.as_deref())
806 .collect();
807 if let Some(continuation) = &mut m.provider_continuation {
808 continuation.retain_meta_function_calls(|call_id| kept.contains(call_id));
809 }
810 }
811
812 let emitted: std::collections::HashSet<String> = messages
815 .iter()
816 .filter_map(|m| m.tool_calls.as_ref())
817 .flat_map(|calls| calls.iter())
818 .filter_map(|c| c.id.clone())
819 .collect();
820 messages.retain(|m| {
821 m.role != MessageRole::Tool
822 || m.tool_call_id
823 .as_deref()
824 .is_some_and(|id| emitted.contains(id))
825 });
826}
827
828fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
829 let mut user_turns = 0usize;
830 let mut start = None;
831 for (idx, msg) in messages.iter().enumerate().rev() {
832 if msg.role == MessageRole::User {
833 user_turns += 1;
834 start = Some(idx);
835 if user_turns >= policy.tail_turns {
836 break;
837 }
838 }
839 }
840 let mut start = start?;
841 while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
842 let next_user = messages
843 .iter()
844 .enumerate()
845 .skip(start + 1)
846 .find(|(_, msg)| msg.role == MessageRole::User)
847 .map(|(idx, _)| idx);
848 match next_user {
849 Some(idx) => start = idx,
850 None => break,
851 }
852 }
853 Some(start)
854}
855
856fn format_history_excerpt(
857 messages: &[ChatMessage],
858 policy: CompactionPolicy,
859 total_images: usize,
860 included_images: usize,
861) -> String {
862 let mut out = String::new();
863 if total_images > 0 {
864 out.push_str(&format!(
865 "\n[Visual context: {included_images} of {total_images} archived image attachment(s) supplied with this request; {} omitted by the input budget.]\n",
866 total_images.saturating_sub(included_images)
867 ));
868 }
869 for (idx, msg) in messages.iter().enumerate() {
870 let role = match msg.role {
871 MessageRole::User => "USER",
872 MessageRole::Assistant => "ASSISTANT",
873 MessageRole::System => "SYSTEM",
874 MessageRole::Tool => "TOOL",
875 };
876 out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
877 if msg.kind != ChatMessageKind::Normal {
878 out.push_str(&format!("kind: {:?}\n", msg.kind));
879 }
880 if let Some(name) = &msg.tool_name {
881 out.push_str(&format!("tool_name: {}\n", name));
882 }
883 if let Some(id) = &msg.tool_call_id {
884 out.push_str(&format!("tool_call_id: {}\n", id));
885 }
886 if let Some(calls) = &msg.tool_calls {
887 for call in calls {
888 let mut arguments = call.function.arguments.clone();
889 crate::utils::redact_json(&mut arguments);
890 let arguments = truncate_middle(
891 &arguments.to_string(),
892 policy.tool_output_max_chars.saturating_mul(4),
893 );
894 out.push_str(&format!(
895 "tool_call: id={} name={} arguments={}\n",
896 call.id.as_deref().unwrap_or("<missing>"),
897 call.function.name,
898 arguments
899 ));
900 }
901 }
902 if let Some(images) = &msg.images
903 && !images.is_empty()
904 {
905 out.push_str(&format!(
906 "[{} image attachment(s) referenced above]\n",
907 images.len()
908 ));
909 }
910 for action in &msg.actions {
911 out.push_str(&format!(
912 "action: {}({}) duration={:?}\n",
913 action.action_type, action.target, action.duration_seconds
914 ));
915 if let Some(metadata) = &action.metadata {
916 out.push_str(&format!("action_metadata: {:?}\n", metadata));
917 }
918 }
919 let cap = if msg.role == MessageRole::Tool {
920 policy.tool_output_max_chars
921 } else {
922 policy.tool_output_max_chars.saturating_mul(4)
923 };
924 out.push_str(&truncate_middle(&msg.content, cap));
925 }
926 out
927}
928
929fn estimate_message_tokens(msg: &ChatMessage) -> usize {
930 let mut chars = msg.content.len();
931 chars = chars.saturating_add(format!("{:?}", msg.role).len());
932 chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
933 chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
934 if let Some(images) = &msg.images {
935 chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
936 }
937 if let Some(tool_calls) = &msg.tool_calls {
942 for tc in tool_calls {
943 chars = chars.saturating_add(tc.function.name.len());
944 chars = chars.saturating_add(tc.function.arguments.to_string().len());
945 chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
946 }
947 }
948 chars.div_ceil(4)
949}
950
951fn truncate_middle(text: &str, max_chars: usize) -> String {
952 if text.chars().count() <= max_chars {
953 return text.to_string();
954 }
955 if max_chars < 128 {
956 return text.chars().take(max_chars).collect();
957 }
958 let marker = "\n\n[... truncated during context compaction ...]\n\n";
959 let keep = max_chars.saturating_sub(marker.len());
960 let head = keep / 2;
961 let tail = keep.saturating_sub(head);
962 let start: String = text.chars().take(head).collect();
963 let end: String = text
964 .chars()
965 .rev()
966 .take(tail)
967 .collect::<Vec<_>>()
968 .into_iter()
969 .rev()
970 .collect();
971 format!("{start}{marker}{end}")
972}
973
974fn extract_tagged_summary(text: &str) -> Option<&str> {
975 let start_tag = "<summary>";
976 let end_tag = "</summary>";
977 let start = text.find(start_tag)? + start_tag.len();
978 let end = text[start..].find(end_tag)? + start;
979 Some(&text[start..end])
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985
986 fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
987 ChatRequest {
988 model_id: "ollama/test".to_string(),
989 messages,
990 system_prompt: "system".to_string(),
991 instructions: None,
992 reasoning: ReasoningLevel::Medium,
993 temperature: 0.7,
994 max_tokens: 4096,
995 tools: Vec::new(),
996 ollama_num_ctx: None,
997 ollama_allow_ram_offload: None,
998 resolved_context_window: None,
999 resolved_max_output: None,
1000 output_schema: None,
1001 suppress_auto_compact: false,
1002 }
1003 }
1004
1005 #[test]
1006 fn classify_length_stop_discriminates_output_cap_from_context_full() {
1007 let usage = TokenUsage::provider(16_600, 4_000);
1008 assert_eq!(
1010 classify_length_stop(None, Some(100_000), 4_000),
1011 LengthCause::Unknown
1012 );
1013 assert_eq!(
1016 classify_length_stop(Some(&usage), None, 4_000),
1017 LengthCause::OutputCapped
1018 );
1019 assert_eq!(
1021 classify_length_stop(Some(&usage), Some(1_000_000), 4_000),
1022 LengthCause::OutputCapped
1023 );
1024 assert_eq!(
1026 classify_length_stop(Some(&usage), Some(24_000), 4_000),
1027 LengthCause::ContextFull
1028 );
1029 }
1030
1031 #[test]
1032 fn classify_length_stop_counts_cached_and_reasoning_tokens() {
1033 let usage = TokenUsage::provider(100, 100)
1034 .with_cached_input(700)
1035 .with_cache_creation(50)
1036 .with_reasoning_output(50);
1037 assert_eq!(usage.total_tokens(), 1_000);
1038 assert_eq!(
1039 classify_length_stop(Some(&usage), Some(1_100), 100),
1040 LengthCause::ContextFull
1041 );
1042 }
1043
1044 #[test]
1045 fn summary_and_verification_requests_copy_resolved_limits() {
1046 let mut base = request_with(vec![ChatMessage::user("hello")]);
1050 base.resolved_context_window = Some(1_000_000);
1051 base.resolved_max_output = Some(128_000);
1052 let prepared = PreparedCompaction {
1053 archived_messages: vec![ChatMessage::user("old")],
1054 preserved_messages: vec![],
1055 previous_summary: None,
1056 history_excerpt: "excerpt".to_string(),
1057 summary_images: Vec::new(),
1058 };
1059 let policy = CompactionPolicy::default();
1060 let summary = build_summary_request(&base, &prepared, None, policy);
1061 assert_eq!(summary.resolved_context_window, Some(1_000_000));
1062 assert_eq!(summary.resolved_max_output, Some(128_000));
1063 let verify = build_verification_request(&base, &prepared, "draft", None, policy);
1064 assert_eq!(verify.resolved_context_window, Some(1_000_000));
1065 assert_eq!(verify.resolved_max_output, Some(128_000));
1066 }
1067
1068 #[test]
1069 fn response_reserve_is_reasoning_aware_on_auto() {
1070 let policy = CompactionPolicy::default();
1071 let mut req = request_with(vec![ChatMessage::user("hello")]);
1072
1073 req.max_tokens = 0;
1076 req.reasoning = ReasoningLevel::None;
1077 let base = policy.response_reserve(&req);
1078 assert_eq!(base, policy.min_response_reserve_tokens);
1079 req.reasoning = ReasoningLevel::Max;
1080 let deep = policy.response_reserve(&req);
1081 assert!(deep > base, "a Max-reasoning turn must reserve more room");
1082 assert!(deep <= policy.max_response_reserve_tokens);
1083
1084 req.max_tokens = 12_000;
1086 assert_eq!(policy.response_reserve(&req), 12_000);
1087 req.max_tokens = 1_000_000;
1088 assert_eq!(
1089 policy.response_reserve(&req),
1090 policy.max_response_reserve_tokens
1091 );
1092 }
1093
1094 #[test]
1095 fn auto_compaction_triggers_by_percent() {
1096 let snapshot = ContextUsageSnapshot::from_estimate(
1097 super::super::state::PromptTokenBreakdown {
1098 system_tokens: 0,
1099 instructions_tokens: 0,
1100 message_tokens: 86,
1101 tool_schema_tokens: 0,
1102 image_count: 0,
1103 message_count: 2,
1104 tool_count: 0,
1105 },
1106 Some(100),
1107 );
1108 let req = request_with(vec![ChatMessage::user("hello")]);
1109 assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
1110 }
1111
1112 #[test]
1113 fn auto_compaction_pause_rides_the_request() {
1114 let snapshot = ContextUsageSnapshot::from_estimate(
1115 super::super::state::PromptTokenBreakdown {
1116 system_tokens: 0,
1117 instructions_tokens: 0,
1118 message_tokens: 86,
1119 tool_schema_tokens: 0,
1120 image_count: 0,
1121 message_count: 2,
1122 tool_count: 0,
1123 },
1124 Some(100),
1125 );
1126 let mut req = request_with(vec![ChatMessage::user("hello")]);
1127 req.suppress_auto_compact = true;
1128 assert_eq!(
1129 should_auto_compact(&snapshot, &req, CompactionPolicy::default()),
1130 Err(CompactionSkip::Suppressed)
1131 );
1132 }
1133
1134 #[test]
1135 fn boundary_fingerprint_matches_only_the_identical_message() {
1136 let message = ChatMessage::user("hello");
1137 let boundary = CompactionBoundary::from_message(&message);
1138 assert!(boundary.matches(&message));
1139
1140 let mut other_content = message.clone();
1141 other_content.content = "hello!".to_string();
1142 assert!(!boundary.matches(&other_content));
1143
1144 let mut other_kind = message.clone();
1145 other_kind.kind = ChatMessageKind::ContextCheckpoint;
1146 assert!(!boundary.matches(&other_kind));
1147
1148 let mut other_time = message.clone();
1149 other_time.timestamp += chrono::Duration::nanoseconds(1);
1150 assert!(!boundary.matches(&other_time));
1151 }
1152
1153 #[test]
1154 fn prepare_preserves_recent_two_user_turns() {
1155 let messages = vec![
1156 ChatMessage::user("one"),
1157 ChatMessage::assistant("one answer"),
1158 ChatMessage::user("two"),
1159 ChatMessage::assistant("two answer"),
1160 ChatMessage::user("three"),
1161 ];
1162 let request = CompactionRequest::manual(request_with(messages), None);
1163 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1164 assert_eq!(prepared.archived_messages.len(), 2);
1165 assert_eq!(prepared.preserved_messages.len(), 3);
1166 assert_eq!(prepared.preserved_messages[0].content, "two");
1167 }
1168
1169 #[test]
1170 fn prepare_projects_redacted_tool_arguments_and_archived_images() {
1171 let mut old = ChatMessage::user("inspect the screenshot");
1172 old.images = Some(vec!["aGVsbG8=".to_string()]);
1173 let mut call = ChatMessage::assistant("");
1174 call.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1175 id: Some("call_1".to_string()),
1176 function: crate::models::tool_call::FunctionCall {
1177 name: "execute_command".to_string(),
1178 arguments: serde_json::json!({
1179 "cmd": "cargo test --workspace",
1180 "api_key": "opaque-secret-value"
1181 }),
1182 },
1183 }]);
1184 let messages = vec![
1185 old,
1186 call,
1187 ChatMessage::tool("call_1", "execute_command", "tests passed"),
1188 ChatMessage::user("second"),
1189 ChatMessage::assistant("second answer"),
1190 ChatMessage::user("third"),
1191 ];
1192 let request = CompactionRequest::manual(request_with(messages), None);
1193 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1194 assert!(prepared.history_excerpt.contains("cargo test --workspace"));
1195 assert!(prepared.history_excerpt.contains("[REDACTED]"));
1196 assert!(!prepared.history_excerpt.contains("opaque-secret-value"));
1197 assert_eq!(prepared.summary_images, vec!["aGVsbG8=".to_string()]);
1198 let summary =
1199 build_summary_request(&request.chat, &prepared, None, CompactionPolicy::default());
1200 assert_eq!(
1201 summary.messages[0].images.as_deref(),
1202 Some(prepared.summary_images.as_slice())
1203 );
1204 }
1205
1206 #[test]
1207 fn complete_summary_request_fits_known_window() {
1208 let messages = vec![
1209 ChatMessage::user("old ".repeat(40_000)),
1210 ChatMessage::assistant("old answer ".repeat(20_000)),
1211 ChatMessage::user("second"),
1212 ChatMessage::assistant("second answer"),
1213 ChatMessage::user("third"),
1214 ];
1215 let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1216 let window = 32_000;
1217 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1218 let summary = build_summary_request(
1219 &request.chat,
1220 &prepared,
1221 request.instructions.as_deref(),
1222 request.policy,
1223 );
1224 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1225 assert!(usage.used_tokens.saturating_add(summary.max_tokens) <= window);
1226 }
1227
1228 #[test]
1229 fn complete_summary_request_with_images_fits_known_window() {
1230 let mut old = ChatMessage::user("old ".repeat(40_000));
1231 old.images = Some(vec!["i".repeat(40_000), "j".repeat(40_000)]);
1232 let messages = vec![
1233 old,
1234 ChatMessage::assistant("old answer ".repeat(20_000)),
1235 ChatMessage::user("second"),
1236 ChatMessage::assistant("second answer"),
1237 ChatMessage::user("third"),
1238 ];
1239 let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1240 let window = 32_000;
1241 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1242 let summary = build_summary_request(
1243 &request.chat,
1244 &prepared,
1245 request.instructions.as_deref(),
1246 request.policy,
1247 );
1248 assert!(
1249 !prepared.summary_images.is_empty(),
1250 "the newest image fits the budget and must be attached"
1251 );
1252 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1253 assert!(
1254 usage.used_tokens.saturating_add(summary.max_tokens) <= window,
1255 "used {} + max_tokens {} > window {}",
1256 usage.used_tokens,
1257 summary.max_tokens,
1258 window
1259 );
1260 }
1261
1262 #[test]
1263 fn image_budget_keeps_every_fitting_image_newest_first() {
1264 let mut old = ChatMessage::user("inspect");
1265 old.images = Some(vec!["a".repeat(400), "b".repeat(400_000)]);
1270 let messages = vec![
1271 old,
1272 ChatMessage::assistant("looked"),
1273 ChatMessage::user("second"),
1274 ChatMessage::assistant("second answer"),
1275 ChatMessage::user("third"),
1276 ];
1277 let request = CompactionRequest::manual(request_with(messages), None);
1278 let prepared = prepare_compaction(&request, None).expect("prepared");
1279 assert_eq!(prepared.summary_images, vec!["a".repeat(400)]);
1280 assert!(
1281 prepared
1282 .history_excerpt
1283 .contains("1 of 2 archived image attachment(s)")
1284 );
1285 }
1286
1287 #[test]
1288 fn summary_structure_requires_ordered_non_placeholder_sections() {
1289 let valid = "## Goal\n- ship the fix\n\n## User Preferences And Constraints\n- none\n\n## Project State\n- ready\n\n## Completed Work\n- audit\n\n## Current Work\n- implementation\n\n## Key Decisions\n- preserve data\n\n## Critical Files And Symbols\n- compaction.rs\n\n## Commands Tests And Results\n- tests pass\n\n## Open Questions Or Risks\n- none\n\n## Next Steps\n- finish";
1290 assert!(validate_summary_structure(valid).is_ok());
1291 assert!(validate_summary_structure("## Goal\n- [single-sentence task summary]").is_err());
1292 }
1293
1294 #[test]
1295 fn summary_structure_tolerates_quoted_markdown_in_bodies() {
1296 let with_quoted_heading = "## Goal\n- ship the fix\n\n## User Preferences And Constraints\n- none\n\n## Project State\n- ready\n\n## Completed Work\n- audit\n\n## Current Work\n- implementation\n\n## Key Decisions\n- preserve data\n\n## Critical Files And Symbols\n- compaction.rs\n\n## Commands Tests And Results\n- README now starts with:\n## Quick Start\ninstall the CLI\n\n## Open Questions Or Risks\n- none\n\n## Next Steps\n- finish";
1300 assert!(validate_summary_structure(with_quoted_heading).is_ok());
1301 }
1302
1303 fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
1304 crate::models::tool_call::ToolCall {
1305 id: Some(id.to_string()),
1306 function: crate::models::tool_call::FunctionCall {
1307 name: name.to_string(),
1308 arguments: serde_json::json!({}),
1309 },
1310 }
1311 }
1312
1313 #[test]
1314 fn prepare_strips_orphan_tool_call_from_preserved_tail() {
1315 let mut orphan = ChatMessage::assistant("calling a tool");
1318 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1319 let messages = vec![
1320 ChatMessage::user("one"),
1321 ChatMessage::assistant("one answer"),
1322 ChatMessage::user("two"),
1323 orphan,
1324 ChatMessage::user("three"),
1325 ];
1326 let request = CompactionRequest::manual(request_with(messages), None);
1327 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1328 let has_orphan = prepared
1329 .preserved_messages
1330 .iter()
1331 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1332 assert!(
1333 !has_orphan,
1334 "orphan tool_use must be stripped from the tail"
1335 );
1336 assert!(
1338 prepared
1339 .preserved_messages
1340 .iter()
1341 .any(|m| m.content == "calling a tool")
1342 );
1343 }
1344
1345 #[test]
1346 fn prepare_keeps_paired_tool_call_in_tail() {
1347 let mut asst = ChatMessage::assistant("calling");
1349 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1350 let messages = vec![
1351 ChatMessage::user("one"),
1352 ChatMessage::assistant("one answer"),
1353 ChatMessage::user("two"),
1354 asst,
1355 ChatMessage::tool("call_1", "do_thing", "ok"),
1356 ChatMessage::user("three"),
1357 ];
1358 let request = CompactionRequest::manual(request_with(messages), None);
1359 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1360 let kept = prepared
1361 .preserved_messages
1362 .iter()
1363 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1364 assert!(kept, "a tool_call paired with its result must be preserved");
1365 }
1366
1367 #[test]
1368 fn normalize_history_drops_orphan_assistant_tool_use() {
1369 let mut orphan = ChatMessage::assistant("calling a tool");
1370 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1371 let mut messages = vec![ChatMessage::user("hi"), orphan];
1372 normalize_history(&mut messages);
1373 assert!(
1374 messages
1375 .iter()
1376 .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
1377 "dangling tool_use must be dropped"
1378 );
1379 assert!(
1380 messages.iter().any(|m| m.content == "calling a tool"),
1381 "the assistant text is preserved — only the unpaired call is removed"
1382 );
1383 }
1384
1385 #[test]
1386 fn normalize_history_drops_matching_meta_replay_function_call() {
1387 let mut orphan = ChatMessage::assistant("calling a tool");
1388 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1389 orphan.provider_continuation = Some(crate::models::ProviderContinuation::MetaResponses {
1390 output: vec![crate::models::MetaResponseItem::from_wire(
1391 serde_json::json!({
1392 "type": "function_call",
1393 "call_id": "call_1",
1394 "name": "do_thing",
1395 "arguments": "{}"
1396 }),
1397 )],
1398 });
1399 let mut messages = vec![ChatMessage::user("hi"), orphan];
1400 normalize_history(&mut messages);
1401 let output = messages[1]
1402 .provider_continuation
1403 .as_ref()
1404 .and_then(crate::models::ProviderContinuation::meta_output)
1405 .unwrap();
1406 assert!(
1407 output.is_empty(),
1408 "orphan Meta function_call must also drop"
1409 );
1410 }
1411
1412 #[test]
1413 fn normalize_history_drops_orphan_tool_result() {
1414 let mut messages = vec![
1415 ChatMessage::user("hi"),
1416 ChatMessage::tool("call_ghost", "do_thing", "result with no call"),
1417 ];
1418 normalize_history(&mut messages);
1419 assert!(
1420 !messages.iter().any(|m| m.role == MessageRole::Tool),
1421 "a tool_result whose call is absent must be dropped"
1422 );
1423 }
1424
1425 #[test]
1426 fn normalize_history_keeps_well_paired_tool_calls() {
1427 let mut asst = ChatMessage::assistant("calling");
1428 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1429 let mut messages = vec![
1430 ChatMessage::user("hi"),
1431 asst,
1432 ChatMessage::tool("call_1", "do_thing", "ok"),
1433 ];
1434 let before = messages.len();
1435 normalize_history(&mut messages);
1436 assert_eq!(
1437 messages.len(),
1438 before,
1439 "a paired call+result survives intact"
1440 );
1441 assert!(
1442 messages[1]
1443 .tool_calls
1444 .as_ref()
1445 .is_some_and(|c| c.len() == 1)
1446 );
1447 }
1448
1449 #[test]
1450 fn normalize_history_drops_idless_tool_use() {
1451 let mut asst = ChatMessage::assistant("calling");
1452 asst.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1453 id: None,
1454 function: crate::models::tool_call::FunctionCall {
1455 name: "do_thing".into(),
1456 arguments: serde_json::json!({}),
1457 },
1458 }]);
1459 let mut messages = vec![asst];
1460 normalize_history(&mut messages);
1461 assert!(
1462 messages[0].tool_calls.as_ref().is_none_or(|c| c.is_empty()),
1463 "an id-less tool_use is inherently unpaired → dropped"
1464 );
1465 }
1466
1467 #[test]
1468 fn prepare_drops_reverse_orphan_tool_result_from_tail() {
1469 let mut asst = ChatMessage::assistant("calling");
1474 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1475 let messages = vec![
1476 ChatMessage::user("one"),
1477 asst,
1478 ChatMessage::user("two"),
1479 ChatMessage::tool("call_1", "do_thing", "result"),
1480 ChatMessage::user("three"),
1481 ];
1482 let request = CompactionRequest::manual(request_with(messages), None);
1485 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1486 assert!(
1487 prepared
1488 .preserved_messages
1489 .iter()
1490 .all(|m| m.role != MessageRole::Tool),
1491 "an orphan tool_result whose tool_use was archived must be dropped"
1492 );
1493 }
1494
1495 #[test]
1496 fn prepare_keeps_pending_trailing_tool_use_on_retry() {
1497 let mut pending = ChatMessage::assistant("calling a tool");
1501 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1502 let messages = vec![
1503 ChatMessage::user("one"),
1504 ChatMessage::assistant("a1"),
1505 ChatMessage::user("two"),
1506 ChatMessage::assistant("a2"),
1507 ChatMessage::user("three"),
1508 pending,
1509 ];
1510 let request =
1511 CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
1512 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1513 let last = prepared
1514 .preserved_messages
1515 .last()
1516 .expect("non-empty preserved tail");
1517 assert!(
1518 last.tool_calls
1519 .as_ref()
1520 .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
1521 "a pending trailing tool_use must be preserved across a retry compaction"
1522 );
1523 }
1524
1525 #[test]
1526 fn prepare_drops_trailing_tool_use_on_manual_compaction() {
1527 let mut pending = ChatMessage::assistant("calling a tool");
1531 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1532 let messages = vec![
1533 ChatMessage::user("one"),
1534 ChatMessage::assistant("a1"),
1535 ChatMessage::user("two"),
1536 ChatMessage::assistant("a2"),
1537 ChatMessage::user("three"),
1538 pending,
1539 ];
1540 let request = CompactionRequest::manual(request_with(messages), None);
1541 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1542 assert!(
1543 !prepared
1544 .preserved_messages
1545 .iter()
1546 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1547 "manual compaction must scrub the trailing orphan tool_use"
1548 );
1549 assert!(
1550 prepared
1551 .preserved_messages
1552 .iter()
1553 .any(|m| m.content == "calling a tool"),
1554 "the assistant text is kept even though the orphan call is dropped"
1555 );
1556 }
1557
1558 #[test]
1559 fn replacement_starts_with_checkpoint_and_ack() {
1560 let prepared = PreparedCompaction {
1561 archived_messages: vec![ChatMessage::user("old")],
1562 preserved_messages: vec![ChatMessage::user("new")],
1563 previous_summary: None,
1564 history_excerpt: "old".to_string(),
1565 summary_images: Vec::new(),
1566 };
1567 let record = CompactionRecord {
1568 id: "c1".to_string(),
1569 trigger: CompactionTrigger::Manual,
1570 created_at: Local::now(),
1571 before_tokens: 100,
1572 after_tokens: 25,
1573 archived_message_count: 1,
1574 preserved_message_count: 1,
1575 preserved_turn_count: 1,
1576 summary_tokens: 10,
1577 duration_secs: 1.0,
1578 review_status: CompactionReviewStatus::Reviewed,
1579 review_error: None,
1580 focus: None,
1581 archive_path: None,
1582 };
1583 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1584 assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
1585 assert!(messages[0].content.contains(CHECKPOINT_MARKER));
1586 assert_eq!(messages[2].content, "new");
1587 }
1588
1589 #[test]
1590 fn replacement_metadata_records_review_status() {
1591 let prepared = PreparedCompaction {
1592 archived_messages: vec![ChatMessage::user("old")],
1593 preserved_messages: vec![ChatMessage::user("new")],
1594 previous_summary: None,
1595 history_excerpt: "old".to_string(),
1596 summary_images: Vec::new(),
1597 };
1598 let record = CompactionRecord {
1599 id: "c1".to_string(),
1600 trigger: CompactionTrigger::Manual,
1601 created_at: Local::now(),
1602 before_tokens: 100,
1603 after_tokens: 25,
1604 archived_message_count: 1,
1605 preserved_message_count: 1,
1606 preserved_turn_count: 1,
1607 summary_tokens: 10,
1608 duration_secs: 1.0,
1609 review_status: CompactionReviewStatus::DraftValidated,
1610 review_error: Some("provider overloaded".to_string()),
1611 focus: None,
1612 archive_path: None,
1613 };
1614 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1615 let metadata = messages[0].metadata.as_ref().expect("metadata");
1616 assert_eq!(
1617 metadata.get("review_status").and_then(|v| v.as_str()),
1618 Some("draft_validated")
1619 );
1620 assert_eq!(
1621 metadata.get("review_error").and_then(|v| v.as_str()),
1622 Some("provider overloaded")
1623 );
1624 assert!(messages[1].content.contains("structurally validated draft"));
1625 }
1626}