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 suppressed_builtin_tools: Vec::new(),
490 }
491}
492
493pub fn build_verification_request(
494 base: &ChatRequest,
495 prepared: &PreparedCompaction,
496 draft_summary: &str,
497 focus: Option<&str>,
498 policy: CompactionPolicy,
499) -> ChatRequest {
500 let prompt = format!(
501 "{}\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.",
502 summary_prompt(prepared, focus),
503 draft_summary.trim()
504 );
505 let mut message = ChatMessage::user(prompt);
506 if !prepared.summary_images.is_empty() {
507 message.images = Some(prepared.summary_images.clone());
508 }
509 ChatRequest {
510 model_id: base.model_id.clone(),
511 messages: vec![message],
512 system_prompt: compaction_system_prompt().to_string(),
513 instructions: None,
514 reasoning: compaction_reasoning(base.reasoning),
515 temperature: 0.0,
516 max_tokens: policy.summary_max_tokens,
517 tools: Vec::new(),
518 ollama_num_ctx: base.ollama_num_ctx,
519 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
520 resolved_context_window: base.resolved_context_window,
521 resolved_max_output: base.resolved_max_output,
522 output_schema: None,
523 suppress_auto_compact: false,
524 suppressed_builtin_tools: Vec::new(),
525 }
526}
527
528pub fn build_replacement_messages(
529 summary: &str,
530 prepared: &PreparedCompaction,
531 record: &CompactionRecord,
532) -> Vec<ChatMessage> {
533 let summary = crate::utils::redact_secrets(summary);
537 let summary = summary.as_str();
538 let checkpoint = format!(
539 "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
540 CHECKPOINT_MARKER,
541 record.id,
542 record.trigger.as_str(),
543 record.created_at.to_rfc3339(),
544 record.archived_message_count,
545 record.preserved_message_count,
546 summary.trim()
547 );
548 let mut user = ChatMessage::user(checkpoint);
549 user.kind = ChatMessageKind::ContextCheckpoint;
550 user.metadata = Some(serde_json::json!({
551 "compaction_id": record.id,
552 "trigger": record.trigger.as_str(),
553 "before_tokens": record.before_tokens,
554 "after_tokens": record.after_tokens,
555 "archived_message_count": record.archived_message_count,
556 "preserved_message_count": record.preserved_message_count,
557 "preserved_turn_count": record.preserved_turn_count,
558 "duration_secs": record.duration_secs,
559 "review_status": record.review_status.as_str(),
560 "review_error": record.review_error,
561 }));
562
563 let mut assistant = ChatMessage::assistant(compaction_receipt(record));
564 assistant.kind = ChatMessageKind::ContextCheckpoint;
565 assistant.metadata = user.metadata.clone();
566
567 let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
568 messages.push(user);
569 messages.push(assistant);
570 messages.extend(prepared.preserved_messages.clone());
571 messages
572}
573
574pub fn compaction_receipt(record: &CompactionRecord) -> String {
575 let review = match record.review_status {
576 CompactionReviewStatus::Reviewed => "Reviewed in a second pass.".to_string(),
577 CompactionReviewStatus::DraftValidated => match &record.review_error {
578 Some(error) => format!("Used the structurally validated draft: {error}."),
579 None => "Used the structurally validated draft.".to_string(),
580 },
581 };
582 format!(
583 "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
584 format_compact_count(record.before_tokens),
585 format_compact_count(record.after_tokens),
586 record.archived_message_count,
587 record.preserved_message_count,
588 record.duration_secs,
589 review
590 )
591}
592
593pub fn normalize_summary(text: &str) -> String {
594 let trimmed = text.trim();
595 if let Some(summary) = extract_tagged_summary(trimmed) {
596 return summary.trim().to_string();
597 }
598 trimmed.to_string()
599}
600
601pub fn validate_summary_structure(summary: &str) -> Result<(), String> {
602 const HEADINGS: [&str; 10] = [
603 "## Goal",
604 "## User Preferences And Constraints",
605 "## Project State",
606 "## Completed Work",
607 "## Current Work",
608 "## Key Decisions",
609 "## Critical Files And Symbols",
610 "## Commands Tests And Results",
611 "## Open Questions Or Risks",
612 "## Next Steps",
613 ];
614
615 let lines: Vec<&str> = summary.lines().collect();
619 let headings: Vec<(usize, &str)> = lines
620 .iter()
621 .enumerate()
622 .filter_map(|(index, line)| {
623 let trimmed = line.trim();
624 HEADINGS.contains(&trimmed).then_some((index, trimmed))
625 })
626 .collect();
627 let actual: Vec<&str> = headings.iter().map(|(_, heading)| *heading).collect();
628 if actual != HEADINGS {
629 return Err(format!(
630 "checkpoint headings must exactly match the required order; got {}",
631 actual.join(", ")
632 ));
633 }
634
635 for (index, (line_index, heading)) in headings.iter().enumerate() {
636 let body_end = headings
637 .get(index + 1)
638 .map(|(next_index, _)| *next_index)
639 .unwrap_or(lines.len());
640 let body = lines[line_index + 1..body_end].join("\n");
641 let body = body.trim();
642 if body.is_empty() || body == "-" || (body.starts_with("- [") && body.ends_with(']')) {
643 return Err(format!(
644 "checkpoint heading {heading} has placeholder content"
645 ));
646 }
647 }
648 Ok(())
649}
650
651pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
652 match (a, b) {
653 (None, None) => None,
654 (Some(u), None) | (None, Some(u)) => Some(u),
655 (Some(mut left), Some(right)) => {
656 left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
657 left.completion_tokens = left
658 .completion_tokens
659 .saturating_add(right.completion_tokens);
660 left.cached_input_tokens = left
661 .cached_input_tokens
662 .saturating_add(right.cached_input_tokens);
663 left.cache_creation_input_tokens = left
664 .cache_creation_input_tokens
665 .saturating_add(right.cache_creation_input_tokens);
666 left.reasoning_output_tokens = left
667 .reasoning_output_tokens
668 .saturating_add(right.reasoning_output_tokens);
669 Some(left)
670 },
671 }
672}
673
674pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
675 messages.iter().map(estimate_message_tokens).sum()
676}
677
678pub fn format_compact_count(value: usize) -> String {
685 if value >= 1_000_000 {
686 format_scaled(value, 1_000_000, "M")
687 } else if value >= 1_000 {
688 format_scaled(value, 1_000, "k")
689 } else {
690 value.to_string()
691 }
692}
693
694fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
695 let whole = value / divisor;
696 let decimal = ((value % divisor) * 10) / divisor;
697 if decimal == 0 {
698 format!("{}{}", whole, suffix)
699 } else {
700 format!("{}.{}{}", whole, decimal, suffix)
701 }
702}
703
704fn compaction_system_prompt() -> &'static str {
705 "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."
706}
707
708fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
709 match current {
710 ReasoningLevel::None | ReasoningLevel::Minimal => current,
711 _ => ReasoningLevel::Low,
712 }
713}
714
715fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
716 let anchor = prepared
717 .previous_summary
718 .as_deref()
719 .map(|summary| {
720 format!(
721 "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>",
722 summary.trim()
723 )
724 })
725 .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
726
727 let focus = focus
728 .filter(|s| !s.trim().is_empty())
729 .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
730 .unwrap_or_default();
731
732 format!(
733 "{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{}",
734 prepared.history_excerpt
735 )
736}
737
738pub(crate) fn normalize_history(messages: &mut Vec<ChatMessage>) {
772 drop_orphan_tool_calls(messages, false);
773}
774
775pub(crate) fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
776 let pending_tail = if preserve_pending_tail
777 && messages.last().is_some_and(|m| {
778 m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
779 }) {
780 Some(messages.len() - 1)
781 } else {
782 None
783 };
784
785 let answered: std::collections::HashSet<String> = messages
786 .iter()
787 .filter(|m| m.role == MessageRole::Tool)
788 .filter_map(|m| m.tool_call_id.clone())
789 .collect();
790
791 for (idx, m) in messages.iter_mut().enumerate() {
793 if Some(idx) == pending_tail {
794 continue;
795 }
796 let Some(calls) = m.tool_calls.as_mut() else {
797 continue;
798 };
799 calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
800 if calls.is_empty() {
801 m.tool_calls = None;
802 }
803 let kept: std::collections::HashSet<&str> = m
804 .tool_calls
805 .iter()
806 .flatten()
807 .filter_map(|call| call.id.as_deref())
808 .collect();
809 if let Some(continuation) = &mut m.provider_continuation {
810 continuation.retain_meta_function_calls(|call_id| kept.contains(call_id));
811 }
812 }
813
814 let emitted: std::collections::HashSet<String> = messages
817 .iter()
818 .filter_map(|m| m.tool_calls.as_ref())
819 .flat_map(|calls| calls.iter())
820 .filter_map(|c| c.id.clone())
821 .collect();
822 messages.retain(|m| {
823 m.role != MessageRole::Tool
824 || m.tool_call_id
825 .as_deref()
826 .is_some_and(|id| emitted.contains(id))
827 });
828}
829
830fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
831 let mut user_turns = 0usize;
832 let mut start = None;
833 for (idx, msg) in messages.iter().enumerate().rev() {
834 if msg.role == MessageRole::User {
835 user_turns += 1;
836 start = Some(idx);
837 if user_turns >= policy.tail_turns {
838 break;
839 }
840 }
841 }
842 let mut start = start?;
843 while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
844 let next_user = messages
845 .iter()
846 .enumerate()
847 .skip(start + 1)
848 .find(|(_, msg)| msg.role == MessageRole::User)
849 .map(|(idx, _)| idx);
850 match next_user {
851 Some(idx) => start = idx,
852 None => break,
853 }
854 }
855 Some(start)
856}
857
858fn format_history_excerpt(
859 messages: &[ChatMessage],
860 policy: CompactionPolicy,
861 total_images: usize,
862 included_images: usize,
863) -> String {
864 let mut out = String::new();
865 if total_images > 0 {
866 out.push_str(&format!(
867 "\n[Visual context: {included_images} of {total_images} archived image attachment(s) supplied with this request; {} omitted by the input budget.]\n",
868 total_images.saturating_sub(included_images)
869 ));
870 }
871 for (idx, msg) in messages.iter().enumerate() {
872 let role = match msg.role {
873 MessageRole::User => "USER",
874 MessageRole::Assistant => "ASSISTANT",
875 MessageRole::System => "SYSTEM",
876 MessageRole::Tool => "TOOL",
877 };
878 out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
879 if msg.kind != ChatMessageKind::Normal {
880 out.push_str(&format!("kind: {:?}\n", msg.kind));
881 }
882 if let Some(name) = &msg.tool_name {
883 out.push_str(&format!("tool_name: {}\n", name));
884 }
885 if let Some(id) = &msg.tool_call_id {
886 out.push_str(&format!("tool_call_id: {}\n", id));
887 }
888 if let Some(calls) = &msg.tool_calls {
889 for call in calls {
890 let mut arguments = call.function.arguments.clone();
891 crate::utils::redact_json(&mut arguments);
892 let arguments = truncate_middle(
893 &arguments.to_string(),
894 policy.tool_output_max_chars.saturating_mul(4),
895 );
896 out.push_str(&format!(
897 "tool_call: id={} name={} arguments={}\n",
898 call.id.as_deref().unwrap_or("<missing>"),
899 call.function.name,
900 arguments
901 ));
902 }
903 }
904 if let Some(images) = &msg.images
905 && !images.is_empty()
906 {
907 out.push_str(&format!(
908 "[{} image attachment(s) referenced above]\n",
909 images.len()
910 ));
911 }
912 for action in &msg.actions {
913 out.push_str(&format!(
914 "action: {}({}) duration={:?}\n",
915 action.action_type, action.target, action.duration_seconds
916 ));
917 if let Some(metadata) = &action.metadata {
918 out.push_str(&format!("action_metadata: {:?}\n", metadata));
919 }
920 }
921 let cap = if msg.role == MessageRole::Tool {
922 policy.tool_output_max_chars
923 } else {
924 policy.tool_output_max_chars.saturating_mul(4)
925 };
926 out.push_str(&truncate_middle(&msg.content, cap));
927 }
928 out
929}
930
931fn estimate_message_tokens(msg: &ChatMessage) -> usize {
932 let mut chars = msg.content.len();
933 chars = chars.saturating_add(format!("{:?}", msg.role).len());
934 chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
935 chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
936 if let Some(images) = &msg.images {
937 chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
938 }
939 if let Some(tool_calls) = &msg.tool_calls {
944 for tc in tool_calls {
945 chars = chars.saturating_add(tc.function.name.len());
946 chars = chars.saturating_add(tc.function.arguments.to_string().len());
947 chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
948 }
949 }
950 chars.div_ceil(4)
951}
952
953fn truncate_middle(text: &str, max_chars: usize) -> String {
954 if text.chars().count() <= max_chars {
955 return text.to_string();
956 }
957 if max_chars < 128 {
958 return text.chars().take(max_chars).collect();
959 }
960 let marker = "\n\n[... truncated during context compaction ...]\n\n";
961 let keep = max_chars.saturating_sub(marker.len());
962 let head = keep / 2;
963 let tail = keep.saturating_sub(head);
964 let start: String = text.chars().take(head).collect();
965 let end: String = text
966 .chars()
967 .rev()
968 .take(tail)
969 .collect::<Vec<_>>()
970 .into_iter()
971 .rev()
972 .collect();
973 format!("{start}{marker}{end}")
974}
975
976fn extract_tagged_summary(text: &str) -> Option<&str> {
977 let start_tag = "<summary>";
978 let end_tag = "</summary>";
979 let start = text.find(start_tag)? + start_tag.len();
980 let end = text[start..].find(end_tag)? + start;
981 Some(&text[start..end])
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987
988 fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
989 ChatRequest {
990 model_id: "ollama/test".to_string(),
991 messages,
992 system_prompt: "system".to_string(),
993 instructions: None,
994 reasoning: ReasoningLevel::Medium,
995 temperature: 0.7,
996 max_tokens: 4096,
997 tools: Vec::new(),
998 ollama_num_ctx: None,
999 ollama_allow_ram_offload: None,
1000 resolved_context_window: None,
1001 resolved_max_output: None,
1002 output_schema: None,
1003 suppress_auto_compact: false,
1004 suppressed_builtin_tools: Vec::new(),
1005 }
1006 }
1007
1008 #[test]
1009 fn classify_length_stop_discriminates_output_cap_from_context_full() {
1010 let usage = TokenUsage::provider(16_600, 4_000);
1011 assert_eq!(
1013 classify_length_stop(None, Some(100_000), 4_000),
1014 LengthCause::Unknown
1015 );
1016 assert_eq!(
1019 classify_length_stop(Some(&usage), None, 4_000),
1020 LengthCause::OutputCapped
1021 );
1022 assert_eq!(
1024 classify_length_stop(Some(&usage), Some(1_000_000), 4_000),
1025 LengthCause::OutputCapped
1026 );
1027 assert_eq!(
1029 classify_length_stop(Some(&usage), Some(24_000), 4_000),
1030 LengthCause::ContextFull
1031 );
1032 }
1033
1034 #[test]
1035 fn classify_length_stop_counts_cached_and_reasoning_tokens() {
1036 let usage = TokenUsage::provider(100, 100)
1037 .with_cached_input(700)
1038 .with_cache_creation(50)
1039 .with_reasoning_output(50);
1040 assert_eq!(usage.total_tokens(), 1_000);
1041 assert_eq!(
1042 classify_length_stop(Some(&usage), Some(1_100), 100),
1043 LengthCause::ContextFull
1044 );
1045 }
1046
1047 #[test]
1048 fn summary_and_verification_requests_copy_resolved_limits() {
1049 let mut base = request_with(vec![ChatMessage::user("hello")]);
1053 base.resolved_context_window = Some(1_000_000);
1054 base.resolved_max_output = Some(128_000);
1055 let prepared = PreparedCompaction {
1056 archived_messages: vec![ChatMessage::user("old")],
1057 preserved_messages: vec![],
1058 previous_summary: None,
1059 history_excerpt: "excerpt".to_string(),
1060 summary_images: Vec::new(),
1061 };
1062 let policy = CompactionPolicy::default();
1063 let summary = build_summary_request(&base, &prepared, None, policy);
1064 assert_eq!(summary.resolved_context_window, Some(1_000_000));
1065 assert_eq!(summary.resolved_max_output, Some(128_000));
1066 let verify = build_verification_request(&base, &prepared, "draft", None, policy);
1067 assert_eq!(verify.resolved_context_window, Some(1_000_000));
1068 assert_eq!(verify.resolved_max_output, Some(128_000));
1069 }
1070
1071 #[test]
1072 fn response_reserve_is_reasoning_aware_on_auto() {
1073 let policy = CompactionPolicy::default();
1074 let mut req = request_with(vec![ChatMessage::user("hello")]);
1075
1076 req.max_tokens = 0;
1079 req.reasoning = ReasoningLevel::None;
1080 let base = policy.response_reserve(&req);
1081 assert_eq!(base, policy.min_response_reserve_tokens);
1082 req.reasoning = ReasoningLevel::Max;
1083 let deep = policy.response_reserve(&req);
1084 assert!(deep > base, "a Max-reasoning turn must reserve more room");
1085 assert!(deep <= policy.max_response_reserve_tokens);
1086
1087 req.max_tokens = 12_000;
1089 assert_eq!(policy.response_reserve(&req), 12_000);
1090 req.max_tokens = 1_000_000;
1091 assert_eq!(
1092 policy.response_reserve(&req),
1093 policy.max_response_reserve_tokens
1094 );
1095 }
1096
1097 #[test]
1098 fn auto_compaction_triggers_by_percent() {
1099 let snapshot = ContextUsageSnapshot::from_estimate(
1100 super::super::state::PromptTokenBreakdown {
1101 system_tokens: 0,
1102 instructions_tokens: 0,
1103 message_tokens: 86,
1104 tool_schema_tokens: 0,
1105 image_count: 0,
1106 message_count: 2,
1107 tool_count: 0,
1108 },
1109 Some(100),
1110 );
1111 let req = request_with(vec![ChatMessage::user("hello")]);
1112 assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
1113 }
1114
1115 #[test]
1116 fn auto_compaction_pause_rides_the_request() {
1117 let snapshot = ContextUsageSnapshot::from_estimate(
1118 super::super::state::PromptTokenBreakdown {
1119 system_tokens: 0,
1120 instructions_tokens: 0,
1121 message_tokens: 86,
1122 tool_schema_tokens: 0,
1123 image_count: 0,
1124 message_count: 2,
1125 tool_count: 0,
1126 },
1127 Some(100),
1128 );
1129 let mut req = request_with(vec![ChatMessage::user("hello")]);
1130 req.suppress_auto_compact = true;
1131 assert_eq!(
1132 should_auto_compact(&snapshot, &req, CompactionPolicy::default()),
1133 Err(CompactionSkip::Suppressed)
1134 );
1135 }
1136
1137 #[test]
1138 fn boundary_fingerprint_matches_only_the_identical_message() {
1139 let message = ChatMessage::user("hello");
1140 let boundary = CompactionBoundary::from_message(&message);
1141 assert!(boundary.matches(&message));
1142
1143 let mut other_content = message.clone();
1144 other_content.content = "hello!".to_string();
1145 assert!(!boundary.matches(&other_content));
1146
1147 let mut other_kind = message.clone();
1148 other_kind.kind = ChatMessageKind::ContextCheckpoint;
1149 assert!(!boundary.matches(&other_kind));
1150
1151 let mut other_time = message.clone();
1152 other_time.timestamp += chrono::Duration::nanoseconds(1);
1153 assert!(!boundary.matches(&other_time));
1154 }
1155
1156 #[test]
1157 fn prepare_preserves_recent_two_user_turns() {
1158 let messages = vec![
1159 ChatMessage::user("one"),
1160 ChatMessage::assistant("one answer"),
1161 ChatMessage::user("two"),
1162 ChatMessage::assistant("two answer"),
1163 ChatMessage::user("three"),
1164 ];
1165 let request = CompactionRequest::manual(request_with(messages), None);
1166 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1167 assert_eq!(prepared.archived_messages.len(), 2);
1168 assert_eq!(prepared.preserved_messages.len(), 3);
1169 assert_eq!(prepared.preserved_messages[0].content, "two");
1170 }
1171
1172 #[test]
1173 fn prepare_projects_redacted_tool_arguments_and_archived_images() {
1174 let mut old = ChatMessage::user("inspect the screenshot");
1175 old.images = Some(vec!["aGVsbG8=".to_string()]);
1176 let mut call = ChatMessage::assistant("");
1177 call.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1178 id: Some("call_1".to_string()),
1179 function: crate::models::tool_call::FunctionCall {
1180 name: "execute_command".to_string(),
1181 arguments: serde_json::json!({
1182 "cmd": "cargo test --workspace",
1183 "api_key": "opaque-secret-value"
1184 }),
1185 },
1186 }]);
1187 let messages = vec![
1188 old,
1189 call,
1190 ChatMessage::tool("call_1", "execute_command", "tests passed"),
1191 ChatMessage::user("second"),
1192 ChatMessage::assistant("second answer"),
1193 ChatMessage::user("third"),
1194 ];
1195 let request = CompactionRequest::manual(request_with(messages), None);
1196 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1197 assert!(prepared.history_excerpt.contains("cargo test --workspace"));
1198 assert!(prepared.history_excerpt.contains("[REDACTED]"));
1199 assert!(!prepared.history_excerpt.contains("opaque-secret-value"));
1200 assert_eq!(prepared.summary_images, vec!["aGVsbG8=".to_string()]);
1201 let summary =
1202 build_summary_request(&request.chat, &prepared, None, CompactionPolicy::default());
1203 assert_eq!(
1204 summary.messages[0].images.as_deref(),
1205 Some(prepared.summary_images.as_slice())
1206 );
1207 }
1208
1209 #[test]
1210 fn complete_summary_request_fits_known_window() {
1211 let messages = vec![
1212 ChatMessage::user("old ".repeat(40_000)),
1213 ChatMessage::assistant("old answer ".repeat(20_000)),
1214 ChatMessage::user("second"),
1215 ChatMessage::assistant("second answer"),
1216 ChatMessage::user("third"),
1217 ];
1218 let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1219 let window = 32_000;
1220 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1221 let summary = build_summary_request(
1222 &request.chat,
1223 &prepared,
1224 request.instructions.as_deref(),
1225 request.policy,
1226 );
1227 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1228 assert!(usage.used_tokens.saturating_add(summary.max_tokens) <= window);
1229 }
1230
1231 #[test]
1232 fn complete_summary_request_with_images_fits_known_window() {
1233 let mut old = ChatMessage::user("old ".repeat(40_000));
1234 old.images = Some(vec!["i".repeat(40_000), "j".repeat(40_000)]);
1235 let messages = vec![
1236 old,
1237 ChatMessage::assistant("old answer ".repeat(20_000)),
1238 ChatMessage::user("second"),
1239 ChatMessage::assistant("second answer"),
1240 ChatMessage::user("third"),
1241 ];
1242 let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1243 let window = 32_000;
1244 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1245 let summary = build_summary_request(
1246 &request.chat,
1247 &prepared,
1248 request.instructions.as_deref(),
1249 request.policy,
1250 );
1251 assert!(
1252 !prepared.summary_images.is_empty(),
1253 "the newest image fits the budget and must be attached"
1254 );
1255 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1256 assert!(
1257 usage.used_tokens.saturating_add(summary.max_tokens) <= window,
1258 "used {} + max_tokens {} > window {}",
1259 usage.used_tokens,
1260 summary.max_tokens,
1261 window
1262 );
1263 }
1264
1265 #[test]
1266 fn image_budget_keeps_every_fitting_image_newest_first() {
1267 let mut old = ChatMessage::user("inspect");
1268 old.images = Some(vec!["a".repeat(400), "b".repeat(400_000)]);
1273 let messages = vec![
1274 old,
1275 ChatMessage::assistant("looked"),
1276 ChatMessage::user("second"),
1277 ChatMessage::assistant("second answer"),
1278 ChatMessage::user("third"),
1279 ];
1280 let request = CompactionRequest::manual(request_with(messages), None);
1281 let prepared = prepare_compaction(&request, None).expect("prepared");
1282 assert_eq!(prepared.summary_images, vec!["a".repeat(400)]);
1283 assert!(
1284 prepared
1285 .history_excerpt
1286 .contains("1 of 2 archived image attachment(s)")
1287 );
1288 }
1289
1290 #[test]
1291 fn summary_structure_requires_ordered_non_placeholder_sections() {
1292 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";
1293 assert!(validate_summary_structure(valid).is_ok());
1294 assert!(validate_summary_structure("## Goal\n- [single-sentence task summary]").is_err());
1295 }
1296
1297 #[test]
1298 fn summary_structure_tolerates_quoted_markdown_in_bodies() {
1299 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";
1303 assert!(validate_summary_structure(with_quoted_heading).is_ok());
1304 }
1305
1306 fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
1307 crate::models::tool_call::ToolCall {
1308 id: Some(id.to_string()),
1309 function: crate::models::tool_call::FunctionCall {
1310 name: name.to_string(),
1311 arguments: serde_json::json!({}),
1312 },
1313 }
1314 }
1315
1316 #[test]
1317 fn prepare_strips_orphan_tool_call_from_preserved_tail() {
1318 let mut orphan = ChatMessage::assistant("calling a tool");
1321 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1322 let messages = vec![
1323 ChatMessage::user("one"),
1324 ChatMessage::assistant("one answer"),
1325 ChatMessage::user("two"),
1326 orphan,
1327 ChatMessage::user("three"),
1328 ];
1329 let request = CompactionRequest::manual(request_with(messages), None);
1330 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1331 let has_orphan = prepared
1332 .preserved_messages
1333 .iter()
1334 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1335 assert!(
1336 !has_orphan,
1337 "orphan tool_use must be stripped from the tail"
1338 );
1339 assert!(
1341 prepared
1342 .preserved_messages
1343 .iter()
1344 .any(|m| m.content == "calling a tool")
1345 );
1346 }
1347
1348 #[test]
1349 fn prepare_keeps_paired_tool_call_in_tail() {
1350 let mut asst = ChatMessage::assistant("calling");
1352 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1353 let messages = vec![
1354 ChatMessage::user("one"),
1355 ChatMessage::assistant("one answer"),
1356 ChatMessage::user("two"),
1357 asst,
1358 ChatMessage::tool("call_1", "do_thing", "ok"),
1359 ChatMessage::user("three"),
1360 ];
1361 let request = CompactionRequest::manual(request_with(messages), None);
1362 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1363 let kept = prepared
1364 .preserved_messages
1365 .iter()
1366 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1367 assert!(kept, "a tool_call paired with its result must be preserved");
1368 }
1369
1370 #[test]
1371 fn normalize_history_drops_orphan_assistant_tool_use() {
1372 let mut orphan = ChatMessage::assistant("calling a tool");
1373 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1374 let mut messages = vec![ChatMessage::user("hi"), orphan];
1375 normalize_history(&mut messages);
1376 assert!(
1377 messages
1378 .iter()
1379 .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
1380 "dangling tool_use must be dropped"
1381 );
1382 assert!(
1383 messages.iter().any(|m| m.content == "calling a tool"),
1384 "the assistant text is preserved — only the unpaired call is removed"
1385 );
1386 }
1387
1388 #[test]
1389 fn normalize_history_drops_matching_meta_replay_function_call() {
1390 let mut orphan = ChatMessage::assistant("calling a tool");
1391 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1392 orphan.provider_continuation = Some(crate::models::ProviderContinuation::MetaResponses {
1393 output: vec![crate::models::MetaResponseItem::from_wire(
1394 serde_json::json!({
1395 "type": "function_call",
1396 "call_id": "call_1",
1397 "name": "do_thing",
1398 "arguments": "{}"
1399 }),
1400 )],
1401 });
1402 let mut messages = vec![ChatMessage::user("hi"), orphan];
1403 normalize_history(&mut messages);
1404 let output = messages[1]
1405 .provider_continuation
1406 .as_ref()
1407 .and_then(crate::models::ProviderContinuation::meta_output)
1408 .unwrap();
1409 assert!(
1410 output.is_empty(),
1411 "orphan Meta function_call must also drop"
1412 );
1413 }
1414
1415 #[test]
1416 fn normalize_history_drops_orphan_tool_result() {
1417 let mut messages = vec![
1418 ChatMessage::user("hi"),
1419 ChatMessage::tool("call_ghost", "do_thing", "result with no call"),
1420 ];
1421 normalize_history(&mut messages);
1422 assert!(
1423 !messages.iter().any(|m| m.role == MessageRole::Tool),
1424 "a tool_result whose call is absent must be dropped"
1425 );
1426 }
1427
1428 #[test]
1429 fn normalize_history_keeps_well_paired_tool_calls() {
1430 let mut asst = ChatMessage::assistant("calling");
1431 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1432 let mut messages = vec![
1433 ChatMessage::user("hi"),
1434 asst,
1435 ChatMessage::tool("call_1", "do_thing", "ok"),
1436 ];
1437 let before = messages.len();
1438 normalize_history(&mut messages);
1439 assert_eq!(
1440 messages.len(),
1441 before,
1442 "a paired call+result survives intact"
1443 );
1444 assert!(
1445 messages[1]
1446 .tool_calls
1447 .as_ref()
1448 .is_some_and(|c| c.len() == 1)
1449 );
1450 }
1451
1452 #[test]
1453 fn normalize_history_drops_idless_tool_use() {
1454 let mut asst = ChatMessage::assistant("calling");
1455 asst.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1456 id: None,
1457 function: crate::models::tool_call::FunctionCall {
1458 name: "do_thing".into(),
1459 arguments: serde_json::json!({}),
1460 },
1461 }]);
1462 let mut messages = vec![asst];
1463 normalize_history(&mut messages);
1464 assert!(
1465 messages[0].tool_calls.as_ref().is_none_or(|c| c.is_empty()),
1466 "an id-less tool_use is inherently unpaired → dropped"
1467 );
1468 }
1469
1470 #[test]
1471 fn prepare_drops_reverse_orphan_tool_result_from_tail() {
1472 let mut asst = ChatMessage::assistant("calling");
1477 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1478 let messages = vec![
1479 ChatMessage::user("one"),
1480 asst,
1481 ChatMessage::user("two"),
1482 ChatMessage::tool("call_1", "do_thing", "result"),
1483 ChatMessage::user("three"),
1484 ];
1485 let request = CompactionRequest::manual(request_with(messages), None);
1488 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1489 assert!(
1490 prepared
1491 .preserved_messages
1492 .iter()
1493 .all(|m| m.role != MessageRole::Tool),
1494 "an orphan tool_result whose tool_use was archived must be dropped"
1495 );
1496 }
1497
1498 #[test]
1499 fn prepare_keeps_pending_trailing_tool_use_on_retry() {
1500 let mut pending = ChatMessage::assistant("calling a tool");
1504 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1505 let messages = vec![
1506 ChatMessage::user("one"),
1507 ChatMessage::assistant("a1"),
1508 ChatMessage::user("two"),
1509 ChatMessage::assistant("a2"),
1510 ChatMessage::user("three"),
1511 pending,
1512 ];
1513 let request =
1514 CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
1515 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1516 let last = prepared
1517 .preserved_messages
1518 .last()
1519 .expect("non-empty preserved tail");
1520 assert!(
1521 last.tool_calls
1522 .as_ref()
1523 .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
1524 "a pending trailing tool_use must be preserved across a retry compaction"
1525 );
1526 }
1527
1528 #[test]
1529 fn prepare_drops_trailing_tool_use_on_manual_compaction() {
1530 let mut pending = ChatMessage::assistant("calling a tool");
1534 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1535 let messages = vec![
1536 ChatMessage::user("one"),
1537 ChatMessage::assistant("a1"),
1538 ChatMessage::user("two"),
1539 ChatMessage::assistant("a2"),
1540 ChatMessage::user("three"),
1541 pending,
1542 ];
1543 let request = CompactionRequest::manual(request_with(messages), None);
1544 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1545 assert!(
1546 !prepared
1547 .preserved_messages
1548 .iter()
1549 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1550 "manual compaction must scrub the trailing orphan tool_use"
1551 );
1552 assert!(
1553 prepared
1554 .preserved_messages
1555 .iter()
1556 .any(|m| m.content == "calling a tool"),
1557 "the assistant text is kept even though the orphan call is dropped"
1558 );
1559 }
1560
1561 #[test]
1562 fn replacement_starts_with_checkpoint_and_ack() {
1563 let prepared = PreparedCompaction {
1564 archived_messages: vec![ChatMessage::user("old")],
1565 preserved_messages: vec![ChatMessage::user("new")],
1566 previous_summary: None,
1567 history_excerpt: "old".to_string(),
1568 summary_images: Vec::new(),
1569 };
1570 let record = CompactionRecord {
1571 id: "c1".to_string(),
1572 trigger: CompactionTrigger::Manual,
1573 created_at: Local::now(),
1574 before_tokens: 100,
1575 after_tokens: 25,
1576 archived_message_count: 1,
1577 preserved_message_count: 1,
1578 preserved_turn_count: 1,
1579 summary_tokens: 10,
1580 duration_secs: 1.0,
1581 review_status: CompactionReviewStatus::Reviewed,
1582 review_error: None,
1583 focus: None,
1584 archive_path: None,
1585 };
1586 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1587 assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
1588 assert!(messages[0].content.contains(CHECKPOINT_MARKER));
1589 assert_eq!(messages[2].content, "new");
1590 }
1591
1592 #[test]
1593 fn replacement_metadata_records_review_status() {
1594 let prepared = PreparedCompaction {
1595 archived_messages: vec![ChatMessage::user("old")],
1596 preserved_messages: vec![ChatMessage::user("new")],
1597 previous_summary: None,
1598 history_excerpt: "old".to_string(),
1599 summary_images: Vec::new(),
1600 };
1601 let record = CompactionRecord {
1602 id: "c1".to_string(),
1603 trigger: CompactionTrigger::Manual,
1604 created_at: Local::now(),
1605 before_tokens: 100,
1606 after_tokens: 25,
1607 archived_message_count: 1,
1608 preserved_message_count: 1,
1609 preserved_turn_count: 1,
1610 summary_tokens: 10,
1611 duration_secs: 1.0,
1612 review_status: CompactionReviewStatus::DraftValidated,
1613 review_error: Some("provider overloaded".to_string()),
1614 focus: None,
1615 archive_path: None,
1616 };
1617 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1618 let metadata = messages[0].metadata.as_ref().expect("metadata");
1619 assert_eq!(
1620 metadata.get("review_status").and_then(|v| v.as_str()),
1621 Some("draft_validated")
1622 );
1623 assert_eq!(
1624 metadata.get("review_error").and_then(|v| v.as_str()),
1625 Some("provider overloaded")
1626 );
1627 assert!(messages[1].content.contains("structurally validated draft"));
1628 }
1629}