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
85const SUMMARY_OUTPUT_WINDOW_SHARE: usize = 4;
93
94const SUMMARY_OUTPUT_FLOOR_TOKENS: usize = 512;
98
99impl CompactionPolicy {
100 pub fn summary_output_tokens(self, window: Option<usize>) -> usize {
108 match window {
109 None => self.summary_max_tokens,
110 Some(window) => self
111 .summary_max_tokens
112 .min(window / SUMMARY_OUTPUT_WINDOW_SHARE),
113 }
114 }
115
116 pub fn summary_input_budget(self, window: Option<usize>) -> usize {
126 match window {
127 None => self.summarizer_input_token_budget,
128 Some(size) => size
129 .saturating_sub(self.summary_output_tokens(window))
130 .min(self.summarizer_input_token_budget),
131 }
132 }
133
134 pub fn response_reserve(self, request: &ChatRequest) -> usize {
140 let desired = if request.max_tokens > 0 {
141 request.max_tokens
142 } else {
143 self.min_response_reserve_tokens
144 + crate::models::adapters::output_budget::reasoning_output_reserve(
145 request.reasoning,
146 )
147 };
148 desired
149 .max(self.min_response_reserve_tokens)
150 .min(self.max_response_reserve_tokens)
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum LengthCause {
157 OutputCapped,
160 ContextFull,
162 Unknown,
165}
166
167pub fn classify_length_stop(
174 usage: Option<&TokenUsage>,
175 window: Option<usize>,
176 reserve: usize,
177) -> LengthCause {
178 let Some(u) = usage else {
179 return LengthCause::Unknown;
180 };
181 match window {
182 None => LengthCause::OutputCapped,
183 Some(w) => {
184 if u.total_tokens().saturating_add(reserve) >= w {
185 LengthCause::ContextFull
186 } else {
187 LengthCause::OutputCapped
188 }
189 },
190 }
191}
192
193#[derive(Debug, Clone)]
194pub struct CompactionRequest {
195 pub chat: ChatRequest,
196 pub trigger: CompactionTrigger,
197 pub instructions: Option<String>,
198 pub policy: CompactionPolicy,
199}
200
201impl CompactionRequest {
202 pub fn manual(
207 chat: ChatRequest,
208 instructions: Option<String>,
209 policy: CompactionPolicy,
210 ) -> Self {
211 Self {
212 chat,
213 trigger: CompactionTrigger::Manual,
214 instructions,
215 policy,
216 }
217 }
218
219 pub fn auto(chat: ChatRequest, trigger: CompactionTrigger, policy: CompactionPolicy) -> Self {
220 Self {
221 chat,
222 trigger,
223 instructions: None,
224 policy,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum CompactionReviewStatus {
232 Reviewed,
233 DraftValidated,
234}
235
236impl CompactionReviewStatus {
237 pub fn as_str(self) -> &'static str {
238 match self {
239 Self::Reviewed => "reviewed",
240 Self::DraftValidated => "draft_validated",
241 }
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct CompactionRecord {
247 pub id: String,
248 pub trigger: CompactionTrigger,
249 pub created_at: DateTime<Local>,
250 pub before_tokens: usize,
251 pub after_tokens: usize,
252 pub archived_message_count: usize,
253 pub preserved_message_count: usize,
254 pub preserved_turn_count: usize,
255 pub summary_tokens: usize,
256 pub duration_secs: f64,
257 pub review_status: CompactionReviewStatus,
258 pub review_error: Option<String>,
259 #[serde(default)]
260 pub focus: Option<String>,
261 #[serde(default)]
262 pub archive_path: Option<String>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct CompactionArchive {
267 pub id: String,
268 pub conversation_id: String,
269 pub created_at: DateTime<Local>,
270 pub messages: Vec<ChatMessage>,
271}
272
273#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
274pub struct CompactionResult {
275 pub record: CompactionRecord,
276 pub replacement_messages: Vec<ChatMessage>,
277 pub archived_messages: Vec<ChatMessage>,
278 pub before_snapshot: ContextUsageSnapshot,
279 pub after_snapshot: ContextUsageSnapshot,
280 pub usage: Option<TokenUsage>,
281 pub source_boundaries: Vec<CompactionBoundary>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct CompactionBoundary {
286 pub fingerprint: String,
294}
295
296impl CompactionBoundary {
297 pub fn from_message(message: &ChatMessage) -> Self {
298 Self {
299 fingerprint: Self::fingerprint_of(message),
300 }
301 }
302
303 pub fn fingerprint_of(message: &ChatMessage) -> String {
304 use sha2::{Digest, Sha256};
305 use std::fmt::Write as _;
306 let mut hasher = Sha256::new();
307 hasher.update(format!("{:?}", message.role).as_bytes());
308 hasher.update([0u8]);
309 hasher.update(format!("{:?}", message.kind).as_bytes());
310 hasher.update([0u8]);
311 hasher.update(
312 message
313 .timestamp
314 .to_utc()
315 .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
316 .as_bytes(),
317 );
318 hasher.update([0u8]);
319 hasher.update(message.content.as_bytes());
320 let digest = hasher.finalize();
321 let mut out = String::with_capacity(digest.len() * 2);
322 for byte in digest {
323 let _ = write!(out, "{byte:02x}");
324 }
325 out
326 }
327
328 pub fn matches(&self, message: &ChatMessage) -> bool {
329 Self::fingerprint_of(message) == self.fingerprint
330 }
331}
332
333#[derive(Debug, Clone)]
334pub struct PreparedCompaction {
335 pub archived_messages: Vec<ChatMessage>,
336 pub preserved_messages: Vec<ChatMessage>,
337 pub previous_summary: Option<String>,
338 pub history_excerpt: String,
339 pub summary_images: Vec<String>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum CompactionSkip {
344 NoKnownContextLimit,
345 AutoDisabled,
346 Suppressed,
347 BelowThreshold,
348 NothingToCompact,
349 WindowTooSmall,
355}
356
357impl std::fmt::Display for CompactionSkip {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 match self {
360 Self::NoKnownContextLimit => write!(f, "model context limit is unknown"),
361 Self::AutoDisabled => write!(f, "automatic compaction is disabled"),
362 Self::Suppressed => write!(
363 f,
364 "automatic compaction is paused after a failed attempt; run /compact to retry"
365 ),
366 Self::BelowThreshold => write!(f, "context is below compaction threshold"),
367 Self::NothingToCompact => write!(f, "not enough conversation history to summarize"),
368 Self::WindowTooSmall => write!(
369 f,
370 "the model's context window is too small to hold a checkpoint"
371 ),
372 }
373 }
374}
375
376pub fn should_auto_compact(
377 snapshot: &ContextUsageSnapshot,
378 request: &ChatRequest,
379 policy: CompactionPolicy,
380) -> Result<(), CompactionSkip> {
381 if !policy.auto_enabled {
382 return Err(CompactionSkip::AutoDisabled);
383 }
384 if request.suppress_auto_compact {
385 return Err(CompactionSkip::Suppressed);
386 }
387 let Some(max_tokens) = snapshot.max_tokens else {
388 return Err(CompactionSkip::NoKnownContextLimit);
389 };
390 if max_tokens == 0 {
391 return Err(CompactionSkip::NoKnownContextLimit);
392 }
393
394 let reserve = policy.response_reserve(request);
395 let over_percent = snapshot
396 .used_percent
397 .is_some_and(|p| p >= policy.auto_threshold_percent);
398 let low_remaining = snapshot
399 .remaining_tokens
400 .is_some_and(|remaining| remaining <= reserve);
401 if over_percent || low_remaining {
402 Ok(())
403 } else {
404 Err(CompactionSkip::BelowThreshold)
405 }
406}
407
408pub fn context_exceeds_hard_limit(
409 snapshot: &ContextUsageSnapshot,
410 request: &ChatRequest,
411 policy: CompactionPolicy,
412) -> bool {
413 let Some(max_tokens) = snapshot.max_tokens else {
414 return false;
415 };
416 let reserve = policy.response_reserve(request);
417 snapshot.used_tokens.saturating_add(reserve) >= max_tokens
418}
419
420pub fn prepare_compaction(
421 request: &CompactionRequest,
422 max_context_tokens: Option<usize>,
423) -> Result<PreparedCompaction, CompactionSkip> {
424 let messages = &request.chat.messages;
425 if messages.len() < 3 {
426 return Err(CompactionSkip::NothingToCompact);
427 }
428
429 let split =
430 tail_start_index(messages, request.policy).ok_or(CompactionSkip::NothingToCompact)?;
431 if split == 0 {
432 return Err(CompactionSkip::NothingToCompact);
433 }
434
435 let archived_messages = messages[..split].to_vec();
436 let mut preserved_messages = messages[split..].to_vec();
437 if archived_messages.is_empty() || preserved_messages.is_empty() {
438 return Err(CompactionSkip::NothingToCompact);
439 }
440 let preserve_pending_tail = matches!(
449 request.trigger,
450 CompactionTrigger::ContextLimitRetry | CompactionTrigger::TruncationRecovery
451 );
452 drop_orphan_tool_calls(&mut preserved_messages, preserve_pending_tail);
453
454 let previous_summary = archived_messages
455 .iter()
456 .rev()
457 .find(|m| {
458 m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
459 })
460 .map(|m| m.content.clone());
461
462 if request
471 .policy
472 .summary_output_tokens(max_context_tokens)
473 .lt(&SUMMARY_OUTPUT_FLOOR_TOKENS)
474 {
475 return Err(CompactionSkip::WindowTooSmall);
476 }
477 let max_input_tokens = request.policy.summary_input_budget(max_context_tokens);
478 let sizing_prepared = PreparedCompaction {
479 archived_messages: Vec::new(),
480 preserved_messages: Vec::new(),
481 previous_summary: previous_summary.clone(),
482 history_excerpt: String::new(),
483 summary_images: Vec::new(),
484 };
485 let probe = build_summary_request(
492 &request.chat,
493 &sizing_prepared,
494 request.instructions.as_deref(),
495 request.policy,
496 max_context_tokens,
497 );
498 let fixed_tokens = super::state::estimate_context_usage_for_request(&probe, None).used_tokens;
499 let mut remaining_tokens = max_input_tokens.saturating_sub(fixed_tokens);
500
501 let all_images: Vec<String> = archived_messages
508 .iter()
509 .flat_map(|message| message.images.iter().flatten().cloned())
510 .collect();
511 let mut summary_images = Vec::new();
512 for image in all_images.iter().rev() {
513 let image_tokens = image.len().div_ceil(4);
514 if image_tokens <= remaining_tokens {
515 summary_images.push(image.clone());
516 remaining_tokens = remaining_tokens.saturating_sub(image_tokens);
517 }
518 }
519 summary_images.reverse();
520
521 let history = format_history_excerpt(
522 &archived_messages,
523 request.policy,
524 all_images.len(),
525 summary_images.len(),
526 );
527 let history_excerpt = truncate_middle(&history, remaining_tokens.saturating_mul(4));
528
529 Ok(PreparedCompaction {
530 archived_messages,
531 preserved_messages,
532 previous_summary,
533 history_excerpt,
534 summary_images,
535 })
536}
537
538pub fn build_summary_request(
539 base: &ChatRequest,
540 prepared: &PreparedCompaction,
541 focus: Option<&str>,
542 policy: CompactionPolicy,
543 window: Option<usize>,
546) -> ChatRequest {
547 let mut message = ChatMessage::user(summary_prompt(prepared, focus));
548 if !prepared.summary_images.is_empty() {
549 message.images = Some(prepared.summary_images.clone());
550 }
551 ChatRequest {
552 model_id: base.model_id.clone(),
553 messages: vec![message],
554 system_prompt: compaction_system_prompt().to_string(),
555 instructions: None,
556 reasoning: compaction_reasoning(base.reasoning),
557 temperature: 0.0,
558 max_tokens: policy.summary_output_tokens(window),
559 tools: Vec::new(),
560 ollama_num_ctx: base.ollama_num_ctx,
561 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
562 resolved_context_window: base.resolved_context_window,
563 resolved_max_output: base.resolved_max_output,
564 output_schema: None,
565 suppress_auto_compact: false,
566 suppressed_builtin_tools: Vec::new(),
567 }
568}
569
570pub fn build_verification_request(
571 base: &ChatRequest,
572 prepared: &PreparedCompaction,
573 draft_summary: &str,
574 focus: Option<&str>,
575 policy: CompactionPolicy,
576 window: Option<usize>,
577) -> ChatRequest {
578 let prompt = format!(
579 "{}\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.",
580 summary_prompt(prepared, focus),
581 draft_summary.trim()
582 );
583 let mut message = ChatMessage::user(prompt);
584 if !prepared.summary_images.is_empty() {
585 message.images = Some(prepared.summary_images.clone());
586 }
587 ChatRequest {
588 model_id: base.model_id.clone(),
589 messages: vec![message],
590 system_prompt: compaction_system_prompt().to_string(),
591 instructions: None,
592 reasoning: compaction_reasoning(base.reasoning),
593 temperature: 0.0,
594 max_tokens: policy.summary_output_tokens(window),
595 tools: Vec::new(),
596 ollama_num_ctx: base.ollama_num_ctx,
597 ollama_allow_ram_offload: base.ollama_allow_ram_offload,
598 resolved_context_window: base.resolved_context_window,
599 resolved_max_output: base.resolved_max_output,
600 output_schema: None,
601 suppress_auto_compact: false,
602 suppressed_builtin_tools: Vec::new(),
603 }
604}
605
606pub fn build_replacement_messages(
607 summary: &str,
608 prepared: &PreparedCompaction,
609 record: &CompactionRecord,
610) -> Vec<ChatMessage> {
611 let summary = crate::utils::redact_secrets(summary);
615 let summary = summary.as_str();
616 let checkpoint = format!(
617 "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
618 CHECKPOINT_MARKER,
619 record.id,
620 record.trigger.as_str(),
621 record.created_at.to_rfc3339(),
622 record.archived_message_count,
623 record.preserved_message_count,
624 summary.trim()
625 );
626 let mut user = ChatMessage::user(checkpoint);
627 user.kind = ChatMessageKind::ContextCheckpoint;
628 user.metadata = Some(serde_json::json!({
629 "compaction_id": record.id,
630 "trigger": record.trigger.as_str(),
631 "before_tokens": record.before_tokens,
632 "after_tokens": record.after_tokens,
633 "archived_message_count": record.archived_message_count,
634 "preserved_message_count": record.preserved_message_count,
635 "preserved_turn_count": record.preserved_turn_count,
636 "duration_secs": record.duration_secs,
637 "review_status": record.review_status.as_str(),
638 "review_error": record.review_error,
639 }));
640
641 let mut assistant = ChatMessage::assistant(compaction_receipt(record));
642 assistant.kind = ChatMessageKind::ContextCheckpoint;
643 assistant.metadata = user.metadata.clone();
644
645 let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
646 messages.push(user);
647 messages.push(assistant);
648 messages.extend(prepared.preserved_messages.clone());
649 messages
650}
651
652pub fn compaction_receipt(record: &CompactionRecord) -> String {
653 let review = match record.review_status {
654 CompactionReviewStatus::Reviewed => "Reviewed in a second pass.".to_string(),
655 CompactionReviewStatus::DraftValidated => match &record.review_error {
656 Some(error) => format!("Used the structurally validated draft: {error}."),
657 None => "Used the structurally validated draft.".to_string(),
658 },
659 };
660 format!(
661 "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
662 format_compact_count(record.before_tokens),
663 format_compact_count(record.after_tokens),
664 record.archived_message_count,
665 record.preserved_message_count,
666 record.duration_secs,
667 review
668 )
669}
670
671pub fn normalize_summary(text: &str) -> String {
672 let trimmed = text.trim();
673 if let Some(summary) = extract_tagged_summary(trimmed) {
674 return summary.trim().to_string();
675 }
676 trimmed.to_string()
677}
678
679pub fn validate_summary_structure(summary: &str) -> Result<(), String> {
680 const HEADINGS: [&str; 10] = [
681 "## Goal",
682 "## User Preferences And Constraints",
683 "## Project State",
684 "## Completed Work",
685 "## Current Work",
686 "## Key Decisions",
687 "## Critical Files And Symbols",
688 "## Commands Tests And Results",
689 "## Open Questions Or Risks",
690 "## Next Steps",
691 ];
692
693 let lines: Vec<&str> = summary.lines().collect();
697 let headings: Vec<(usize, &str)> = lines
698 .iter()
699 .enumerate()
700 .filter_map(|(index, line)| {
701 let trimmed = line.trim();
702 HEADINGS.contains(&trimmed).then_some((index, trimmed))
703 })
704 .collect();
705 let actual: Vec<&str> = headings.iter().map(|(_, heading)| *heading).collect();
706 if actual != HEADINGS {
707 return Err(format!(
708 "checkpoint headings must exactly match the required order; got {}",
709 actual.join(", ")
710 ));
711 }
712
713 for (index, (line_index, heading)) in headings.iter().enumerate() {
714 let body_end = headings
715 .get(index + 1)
716 .map(|(next_index, _)| *next_index)
717 .unwrap_or(lines.len());
718 let body = lines[line_index + 1..body_end].join("\n");
719 let body = body.trim();
720 if body.is_empty() || body == "-" || (body.starts_with("- [") && body.ends_with(']')) {
721 return Err(format!(
722 "checkpoint heading {heading} has placeholder content"
723 ));
724 }
725 }
726 Ok(())
727}
728
729pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
730 match (a, b) {
731 (None, None) => None,
732 (Some(u), None) | (None, Some(u)) => Some(u),
733 (Some(mut left), Some(right)) => {
734 left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
735 left.completion_tokens = left
736 .completion_tokens
737 .saturating_add(right.completion_tokens);
738 left.cached_input_tokens = left
739 .cached_input_tokens
740 .saturating_add(right.cached_input_tokens);
741 left.cache_creation_input_tokens = left
742 .cache_creation_input_tokens
743 .saturating_add(right.cache_creation_input_tokens);
744 left.reasoning_output_tokens = left
745 .reasoning_output_tokens
746 .saturating_add(right.reasoning_output_tokens);
747 Some(left)
748 },
749 }
750}
751
752pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
753 messages.iter().map(estimate_message_tokens).sum()
754}
755
756pub fn format_compact_count(value: usize) -> String {
763 if value >= 1_000_000 {
764 format_scaled(value, 1_000_000, "M")
765 } else if value >= 1_000 {
766 format_scaled(value, 1_000, "k")
767 } else {
768 value.to_string()
769 }
770}
771
772fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
773 let whole = value / divisor;
774 let decimal = ((value % divisor) * 10) / divisor;
775 if decimal == 0 {
776 format!("{}{}", whole, suffix)
777 } else {
778 format!("{}.{}{}", whole, decimal, suffix)
779 }
780}
781
782fn compaction_system_prompt() -> &'static str {
783 "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."
784}
785
786fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
787 match current {
788 ReasoningLevel::None | ReasoningLevel::Minimal => current,
789 _ => ReasoningLevel::Low,
790 }
791}
792
793fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
794 let anchor = prepared
795 .previous_summary
796 .as_deref()
797 .map(|summary| {
798 format!(
799 "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>",
800 summary.trim()
801 )
802 })
803 .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
804
805 let focus = focus
806 .filter(|s| !s.trim().is_empty())
807 .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
808 .unwrap_or_default();
809
810 format!(
811 "{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{}",
812 prepared.history_excerpt
813 )
814}
815
816pub(crate) fn normalize_history(messages: &mut Vec<ChatMessage>) {
850 drop_orphan_tool_calls(messages, false);
851}
852
853pub(crate) fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
854 let pending_tail = if preserve_pending_tail
855 && messages.last().is_some_and(|m| {
856 m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
857 }) {
858 Some(messages.len() - 1)
859 } else {
860 None
861 };
862
863 let answered: std::collections::HashSet<String> = messages
864 .iter()
865 .filter(|m| m.role == MessageRole::Tool)
866 .filter_map(|m| m.tool_call_id.clone())
867 .collect();
868
869 for (idx, m) in messages.iter_mut().enumerate() {
871 if Some(idx) == pending_tail {
872 continue;
873 }
874 let Some(calls) = m.tool_calls.as_mut() else {
875 continue;
876 };
877 calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
878 if calls.is_empty() {
879 m.tool_calls = None;
880 }
881 let kept: std::collections::HashSet<&str> = m
882 .tool_calls
883 .iter()
884 .flatten()
885 .filter_map(|call| call.id.as_deref())
886 .collect();
887 if let Some(continuation) = &mut m.provider_continuation {
888 continuation.retain_meta_function_calls(|call_id| kept.contains(call_id));
889 }
890 }
891
892 let emitted: std::collections::HashSet<String> = messages
895 .iter()
896 .filter_map(|m| m.tool_calls.as_ref())
897 .flat_map(|calls| calls.iter())
898 .filter_map(|c| c.id.clone())
899 .collect();
900 messages.retain(|m| {
901 m.role != MessageRole::Tool
902 || m.tool_call_id
903 .as_deref()
904 .is_some_and(|id| emitted.contains(id))
905 });
906}
907
908fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
909 let mut user_turns = 0usize;
910 let mut start = None;
911 for (idx, msg) in messages.iter().enumerate().rev() {
912 if msg.role == MessageRole::User {
913 user_turns += 1;
914 start = Some(idx);
915 if user_turns >= policy.tail_turns {
916 break;
917 }
918 }
919 }
920 let mut start = start?;
921 while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
922 let next_user = messages
923 .iter()
924 .enumerate()
925 .skip(start + 1)
926 .find(|(_, msg)| msg.role == MessageRole::User)
927 .map(|(idx, _)| idx);
928 match next_user {
929 Some(idx) => start = idx,
930 None => break,
931 }
932 }
933 Some(start)
934}
935
936fn format_history_excerpt(
937 messages: &[ChatMessage],
938 policy: CompactionPolicy,
939 total_images: usize,
940 included_images: usize,
941) -> String {
942 let mut out = String::new();
943 if total_images > 0 {
944 out.push_str(&format!(
945 "\n[Visual context: {included_images} of {total_images} archived image attachment(s) supplied with this request; {} omitted by the input budget.]\n",
946 total_images.saturating_sub(included_images)
947 ));
948 }
949 for (idx, msg) in messages.iter().enumerate() {
950 let role = match msg.role {
951 MessageRole::User => "USER",
952 MessageRole::Assistant => "ASSISTANT",
953 MessageRole::System => "SYSTEM",
954 MessageRole::Tool => "TOOL",
955 };
956 out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
957 if msg.kind != ChatMessageKind::Normal {
958 out.push_str(&format!("kind: {:?}\n", msg.kind));
959 }
960 if let Some(name) = &msg.tool_name {
961 out.push_str(&format!("tool_name: {}\n", name));
962 }
963 if let Some(id) = &msg.tool_call_id {
964 out.push_str(&format!("tool_call_id: {}\n", id));
965 }
966 if let Some(calls) = &msg.tool_calls {
967 for call in calls {
968 let mut arguments = call.function.arguments.clone();
969 crate::utils::redact_json(&mut arguments);
970 let arguments = truncate_middle(
971 &arguments.to_string(),
972 policy.tool_output_max_chars.saturating_mul(4),
973 );
974 out.push_str(&format!(
975 "tool_call: id={} name={} arguments={}\n",
976 call.id.as_deref().unwrap_or("<missing>"),
977 call.function.name,
978 arguments
979 ));
980 }
981 }
982 if let Some(images) = &msg.images
983 && !images.is_empty()
984 {
985 out.push_str(&format!(
986 "[{} image attachment(s) referenced above]\n",
987 images.len()
988 ));
989 }
990 for action in &msg.actions {
991 out.push_str(&format!(
992 "action: {}({}) duration={:?}\n",
993 action.action_type, action.target, action.duration_seconds
994 ));
995 if let Some(metadata) = &action.metadata {
996 out.push_str(&format!("action_metadata: {:?}\n", metadata));
997 }
998 }
999 let cap = if msg.role == MessageRole::Tool {
1000 policy.tool_output_max_chars
1001 } else {
1002 policy.tool_output_max_chars.saturating_mul(4)
1003 };
1004 out.push_str(&truncate_middle(&msg.content, cap));
1005 }
1006 out
1007}
1008
1009fn estimate_message_tokens(msg: &ChatMessage) -> usize {
1010 let mut chars = msg.content.len();
1011 chars = chars.saturating_add(format!("{:?}", msg.role).len());
1012 chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
1013 chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
1014 if let Some(images) = &msg.images {
1015 chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
1016 }
1017 if let Some(tool_calls) = &msg.tool_calls {
1022 for tc in tool_calls {
1023 chars = chars.saturating_add(tc.function.name.len());
1024 chars = chars.saturating_add(tc.function.arguments.to_string().len());
1025 chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
1026 }
1027 }
1028 chars.div_ceil(4)
1029}
1030
1031fn truncate_middle(text: &str, max_chars: usize) -> String {
1032 if text.chars().count() <= max_chars {
1033 return text.to_string();
1034 }
1035 if max_chars < 128 {
1036 return text.chars().take(max_chars).collect();
1037 }
1038 let marker = "\n\n[... truncated during context compaction ...]\n\n";
1039 let keep = max_chars.saturating_sub(marker.len());
1040 let head = keep / 2;
1041 let tail = keep.saturating_sub(head);
1042 let start: String = text.chars().take(head).collect();
1043 let end: String = text
1044 .chars()
1045 .rev()
1046 .take(tail)
1047 .collect::<Vec<_>>()
1048 .into_iter()
1049 .rev()
1050 .collect();
1051 format!("{start}{marker}{end}")
1052}
1053
1054fn extract_tagged_summary(text: &str) -> Option<&str> {
1055 let start_tag = "<summary>";
1056 let end_tag = "</summary>";
1057 let start = text.find(start_tag)? + start_tag.len();
1058 let end = text[start..].find(end_tag)? + start;
1059 Some(&text[start..end])
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
1067 ChatRequest {
1068 model_id: "ollama/test".to_string(),
1069 messages,
1070 system_prompt: "system".to_string(),
1071 instructions: None,
1072 reasoning: ReasoningLevel::Medium,
1073 temperature: 0.7,
1074 max_tokens: 4096,
1075 tools: Vec::new(),
1076 ollama_num_ctx: None,
1077 ollama_allow_ram_offload: None,
1078 resolved_context_window: None,
1079 resolved_max_output: None,
1080 output_schema: None,
1081 suppress_auto_compact: false,
1082 suppressed_builtin_tools: Vec::new(),
1083 }
1084 }
1085
1086 #[test]
1087 fn classify_length_stop_discriminates_output_cap_from_context_full() {
1088 let usage = TokenUsage::provider(16_600, 4_000);
1089 assert_eq!(
1091 classify_length_stop(None, Some(100_000), 4_000),
1092 LengthCause::Unknown
1093 );
1094 assert_eq!(
1097 classify_length_stop(Some(&usage), None, 4_000),
1098 LengthCause::OutputCapped
1099 );
1100 assert_eq!(
1102 classify_length_stop(Some(&usage), Some(1_000_000), 4_000),
1103 LengthCause::OutputCapped
1104 );
1105 assert_eq!(
1107 classify_length_stop(Some(&usage), Some(24_000), 4_000),
1108 LengthCause::ContextFull
1109 );
1110 }
1111
1112 #[test]
1113 fn classify_length_stop_counts_cached_and_reasoning_tokens() {
1114 let usage = TokenUsage::provider(100, 100)
1115 .with_cached_input(700)
1116 .with_cache_creation(50)
1117 .with_reasoning_output(50);
1118 assert_eq!(usage.total_tokens(), 1_000);
1119 assert_eq!(
1120 classify_length_stop(Some(&usage), Some(1_100), 100),
1121 LengthCause::ContextFull
1122 );
1123 }
1124
1125 #[test]
1126 fn summary_and_verification_requests_copy_resolved_limits() {
1127 let mut base = request_with(vec![ChatMessage::user("hello")]);
1131 base.resolved_context_window = Some(1_000_000);
1132 base.resolved_max_output = Some(128_000);
1133 let prepared = PreparedCompaction {
1134 archived_messages: vec![ChatMessage::user("old")],
1135 preserved_messages: vec![],
1136 previous_summary: None,
1137 history_excerpt: "excerpt".to_string(),
1138 summary_images: Vec::new(),
1139 };
1140 let policy = CompactionPolicy::default();
1141 let summary = build_summary_request(&base, &prepared, None, policy, None);
1142 assert_eq!(summary.resolved_context_window, Some(1_000_000));
1143 assert_eq!(summary.resolved_max_output, Some(128_000));
1144 let verify = build_verification_request(&base, &prepared, "draft", None, policy, None);
1145 assert_eq!(verify.resolved_context_window, Some(1_000_000));
1146 assert_eq!(verify.resolved_max_output, Some(128_000));
1147 }
1148
1149 #[test]
1150 fn response_reserve_is_reasoning_aware_on_auto() {
1151 let policy = CompactionPolicy::default();
1152 let mut req = request_with(vec![ChatMessage::user("hello")]);
1153
1154 req.max_tokens = 0;
1157 req.reasoning = ReasoningLevel::None;
1158 let base = policy.response_reserve(&req);
1159 assert_eq!(base, policy.min_response_reserve_tokens);
1160 req.reasoning = ReasoningLevel::Max;
1161 let deep = policy.response_reserve(&req);
1162 assert!(deep > base, "a Max-reasoning turn must reserve more room");
1163 assert!(deep <= policy.max_response_reserve_tokens);
1164
1165 req.max_tokens = 12_000;
1167 assert_eq!(policy.response_reserve(&req), 12_000);
1168 req.max_tokens = 1_000_000;
1169 assert_eq!(
1170 policy.response_reserve(&req),
1171 policy.max_response_reserve_tokens
1172 );
1173 }
1174
1175 #[test]
1176 fn auto_compaction_triggers_by_percent() {
1177 let snapshot = ContextUsageSnapshot::from_estimate(
1178 super::super::state::PromptTokenBreakdown {
1179 system_tokens: 0,
1180 instructions_tokens: 0,
1181 message_tokens: 86,
1182 tool_schema_tokens: 0,
1183 image_count: 0,
1184 message_count: 2,
1185 tool_count: 0,
1186 },
1187 Some(100),
1188 );
1189 let req = request_with(vec![ChatMessage::user("hello")]);
1190 assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
1191 }
1192
1193 #[test]
1194 fn auto_compaction_pause_rides_the_request() {
1195 let snapshot = ContextUsageSnapshot::from_estimate(
1196 super::super::state::PromptTokenBreakdown {
1197 system_tokens: 0,
1198 instructions_tokens: 0,
1199 message_tokens: 86,
1200 tool_schema_tokens: 0,
1201 image_count: 0,
1202 message_count: 2,
1203 tool_count: 0,
1204 },
1205 Some(100),
1206 );
1207 let mut req = request_with(vec![ChatMessage::user("hello")]);
1208 req.suppress_auto_compact = true;
1209 assert_eq!(
1210 should_auto_compact(&snapshot, &req, CompactionPolicy::default()),
1211 Err(CompactionSkip::Suppressed)
1212 );
1213 }
1214
1215 #[test]
1216 fn boundary_fingerprint_matches_only_the_identical_message() {
1217 let message = ChatMessage::user("hello");
1218 let boundary = CompactionBoundary::from_message(&message);
1219 assert!(boundary.matches(&message));
1220
1221 let mut other_content = message.clone();
1222 other_content.content = "hello!".to_string();
1223 assert!(!boundary.matches(&other_content));
1224
1225 let mut other_kind = message.clone();
1226 other_kind.kind = ChatMessageKind::ContextCheckpoint;
1227 assert!(!boundary.matches(&other_kind));
1228
1229 let mut other_time = message.clone();
1230 other_time.timestamp += chrono::Duration::nanoseconds(1);
1231 assert!(!boundary.matches(&other_time));
1232 }
1233
1234 #[test]
1235 fn prepare_preserves_recent_two_user_turns() {
1236 let messages = vec![
1237 ChatMessage::user("one"),
1238 ChatMessage::assistant("one answer"),
1239 ChatMessage::user("two"),
1240 ChatMessage::assistant("two answer"),
1241 ChatMessage::user("three"),
1242 ];
1243 let request =
1244 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1245 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1246 assert_eq!(prepared.archived_messages.len(), 2);
1247 assert_eq!(prepared.preserved_messages.len(), 3);
1248 assert_eq!(prepared.preserved_messages[0].content, "two");
1249 }
1250
1251 #[test]
1252 fn prepare_projects_redacted_tool_arguments_and_archived_images() {
1253 let mut old = ChatMessage::user("inspect the screenshot");
1254 old.images = Some(vec!["aGVsbG8=".to_string()]);
1255 let mut call = ChatMessage::assistant("");
1256 call.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1257 id: Some("call_1".to_string()),
1258 function: crate::models::tool_call::FunctionCall {
1259 name: "execute_command".to_string(),
1260 arguments: serde_json::json!({
1261 "cmd": "cargo test --workspace",
1262 "api_key": "opaque-secret-value"
1263 }),
1264 },
1265 }]);
1266 let messages = vec![
1267 old,
1268 call,
1269 ChatMessage::tool("call_1", "execute_command", "tests passed"),
1270 ChatMessage::user("second"),
1271 ChatMessage::assistant("second answer"),
1272 ChatMessage::user("third"),
1273 ];
1274 let request =
1275 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1276 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1277 assert!(prepared.history_excerpt.contains("cargo test --workspace"));
1278 assert!(prepared.history_excerpt.contains("[REDACTED]"));
1279 assert!(!prepared.history_excerpt.contains("opaque-secret-value"));
1280 assert_eq!(prepared.summary_images, vec!["aGVsbG8=".to_string()]);
1281 let summary = build_summary_request(
1282 &request.chat,
1283 &prepared,
1284 None,
1285 CompactionPolicy::default(),
1286 Some(100_000),
1287 );
1288 assert_eq!(
1289 summary.messages[0].images.as_deref(),
1290 Some(prepared.summary_images.as_slice())
1291 );
1292 }
1293
1294 fn oversized_conversation() -> Vec<ChatMessage> {
1297 vec![
1298 ChatMessage::user("old ".repeat(40_000)),
1299 ChatMessage::assistant("old answer ".repeat(20_000)),
1300 ChatMessage::user("second".to_string()),
1301 ChatMessage::assistant("second answer".to_string()),
1302 ChatMessage::user("third".to_string()),
1303 ]
1304 }
1305
1306 #[test]
1313 fn summary_request_shrinks_monotonically_with_the_window() {
1314 let mut previous: Option<(usize, usize)> = None;
1315 for window in [128_000usize, 32_000, 16_000, 9_000, 8_000, 4_000] {
1316 let request = CompactionRequest::manual(
1317 request_with(oversized_conversation()),
1318 None,
1319 CompactionPolicy::default(),
1320 );
1321 let prepared =
1322 prepare_compaction(&request, Some(window)).expect("window is large enough");
1323 let summary = build_summary_request(
1324 &request.chat,
1325 &prepared,
1326 request.instructions.as_deref(),
1327 request.policy,
1328 Some(window),
1329 );
1330 let used = crate::domain::estimate_context_usage_for_request(&summary, Some(window))
1331 .used_tokens;
1332 if let Some((prev_window, prev_used)) = previous {
1333 assert!(
1334 used <= prev_used,
1335 "shrinking the window {prev_window} -> {window} grew the request \
1336 {prev_used} -> {used} tokens",
1337 );
1338 }
1339 previous = Some((window, used));
1340 }
1341 }
1342
1343 #[test]
1347 fn complete_summary_request_fits_every_supported_window() {
1348 for window in [128_000usize, 32_000, 16_000, 9_000, 8_000, 4_000, 2_048] {
1349 let request = CompactionRequest::manual(
1350 request_with(oversized_conversation()),
1351 None,
1352 CompactionPolicy::default(),
1353 );
1354 let prepared =
1355 prepare_compaction(&request, Some(window)).expect("window is large enough");
1356 let summary = build_summary_request(
1357 &request.chat,
1358 &prepared,
1359 request.instructions.as_deref(),
1360 request.policy,
1361 Some(window),
1362 );
1363 let used = crate::domain::estimate_context_usage_for_request(&summary, Some(window))
1364 .used_tokens;
1365 assert!(
1366 used.saturating_add(summary.max_tokens) <= window,
1367 "window {window}: input {used} + output {} exceeds it",
1368 summary.max_tokens,
1369 );
1370 assert!(
1371 summary.max_tokens > 0,
1372 "window {window}: the summary must be allowed to produce output",
1373 );
1374 }
1375 }
1376
1377 #[test]
1380 fn a_window_too_small_to_compact_skips_instead_of_failing() {
1381 let request = CompactionRequest::manual(
1382 request_with(oversized_conversation()),
1383 None,
1384 CompactionPolicy::default(),
1385 );
1386 assert_eq!(
1387 prepare_compaction(&request, Some(1_000)).err(),
1388 Some(CompactionSkip::WindowTooSmall)
1389 );
1390 assert_eq!(
1393 CompactionSkip::WindowTooSmall.to_string(),
1394 "the model's context window is too small to hold a checkpoint"
1395 );
1396 }
1397
1398 #[test]
1401 fn unknown_window_keeps_the_flat_budget() {
1402 let policy = CompactionPolicy::default();
1403 assert_eq!(
1404 policy.summary_output_tokens(None),
1405 policy.summary_max_tokens
1406 );
1407 assert_eq!(
1408 policy.summary_input_budget(None),
1409 policy.summarizer_input_token_budget
1410 );
1411 assert_eq!(
1414 policy.summary_output_tokens(Some(1_000_000)),
1415 policy.summary_max_tokens
1416 );
1417 assert_eq!(
1418 policy.summary_input_budget(Some(1_000_000)),
1419 policy.summarizer_input_token_budget
1420 );
1421 }
1422
1423 #[test]
1424 fn complete_summary_request_fits_known_window() {
1425 let messages = vec![
1426 ChatMessage::user("old ".repeat(40_000)),
1427 ChatMessage::assistant("old answer ".repeat(20_000)),
1428 ChatMessage::user("second"),
1429 ChatMessage::assistant("second answer"),
1430 ChatMessage::user("third"),
1431 ];
1432 let request = CompactionRequest::manual(
1433 request_with(messages),
1434 Some("focus".repeat(500)),
1435 CompactionPolicy::default(),
1436 );
1437 let window = 32_000;
1438 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1439 let summary = build_summary_request(
1440 &request.chat,
1441 &prepared,
1442 request.instructions.as_deref(),
1443 request.policy,
1444 Some(window),
1445 );
1446 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1447 assert!(usage.used_tokens.saturating_add(summary.max_tokens) <= window);
1448 }
1449
1450 #[test]
1451 fn complete_summary_request_with_images_fits_known_window() {
1452 let mut old = ChatMessage::user("old ".repeat(40_000));
1453 old.images = Some(vec!["i".repeat(40_000), "j".repeat(40_000)]);
1454 let messages = vec![
1455 old,
1456 ChatMessage::assistant("old answer ".repeat(20_000)),
1457 ChatMessage::user("second"),
1458 ChatMessage::assistant("second answer"),
1459 ChatMessage::user("third"),
1460 ];
1461 let request = CompactionRequest::manual(
1462 request_with(messages),
1463 Some("focus".repeat(500)),
1464 CompactionPolicy::default(),
1465 );
1466 let window = 32_000;
1467 let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1468 let summary = build_summary_request(
1469 &request.chat,
1470 &prepared,
1471 request.instructions.as_deref(),
1472 request.policy,
1473 Some(window),
1474 );
1475 assert!(
1476 !prepared.summary_images.is_empty(),
1477 "the newest image fits the budget and must be attached"
1478 );
1479 let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1480 assert!(
1481 usage.used_tokens.saturating_add(summary.max_tokens) <= window,
1482 "used {} + max_tokens {} > window {}",
1483 usage.used_tokens,
1484 summary.max_tokens,
1485 window
1486 );
1487 }
1488
1489 #[test]
1490 fn image_budget_keeps_every_fitting_image_newest_first() {
1491 let mut old = ChatMessage::user("inspect");
1492 old.images = Some(vec!["a".repeat(400), "b".repeat(400_000)]);
1497 let messages = vec![
1498 old,
1499 ChatMessage::assistant("looked"),
1500 ChatMessage::user("second"),
1501 ChatMessage::assistant("second answer"),
1502 ChatMessage::user("third"),
1503 ];
1504 let request =
1505 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1506 let prepared = prepare_compaction(&request, None).expect("prepared");
1507 assert_eq!(prepared.summary_images, vec!["a".repeat(400)]);
1508 assert!(
1509 prepared
1510 .history_excerpt
1511 .contains("1 of 2 archived image attachment(s)")
1512 );
1513 }
1514
1515 #[test]
1516 fn summary_structure_requires_ordered_non_placeholder_sections() {
1517 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";
1518 assert!(validate_summary_structure(valid).is_ok());
1519 assert!(validate_summary_structure("## Goal\n- [single-sentence task summary]").is_err());
1520 }
1521
1522 #[test]
1523 fn summary_structure_tolerates_quoted_markdown_in_bodies() {
1524 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";
1528 assert!(validate_summary_structure(with_quoted_heading).is_ok());
1529 }
1530
1531 fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
1532 crate::models::tool_call::ToolCall {
1533 id: Some(id.to_string()),
1534 function: crate::models::tool_call::FunctionCall {
1535 name: name.to_string(),
1536 arguments: serde_json::json!({}),
1537 },
1538 }
1539 }
1540
1541 #[test]
1542 fn prepare_strips_orphan_tool_call_from_preserved_tail() {
1543 let mut orphan = ChatMessage::assistant("calling a tool");
1546 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1547 let messages = vec![
1548 ChatMessage::user("one"),
1549 ChatMessage::assistant("one answer"),
1550 ChatMessage::user("two"),
1551 orphan,
1552 ChatMessage::user("three"),
1553 ];
1554 let request =
1555 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1556 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1557 let has_orphan = prepared
1558 .preserved_messages
1559 .iter()
1560 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1561 assert!(
1562 !has_orphan,
1563 "orphan tool_use must be stripped from the tail"
1564 );
1565 assert!(
1567 prepared
1568 .preserved_messages
1569 .iter()
1570 .any(|m| m.content == "calling a tool")
1571 );
1572 }
1573
1574 #[test]
1575 fn prepare_keeps_paired_tool_call_in_tail() {
1576 let mut asst = ChatMessage::assistant("calling");
1578 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1579 let messages = vec![
1580 ChatMessage::user("one"),
1581 ChatMessage::assistant("one answer"),
1582 ChatMessage::user("two"),
1583 asst,
1584 ChatMessage::tool("call_1", "do_thing", "ok"),
1585 ChatMessage::user("three"),
1586 ];
1587 let request =
1588 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1589 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1590 let kept = prepared
1591 .preserved_messages
1592 .iter()
1593 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1594 assert!(kept, "a tool_call paired with its result must be preserved");
1595 }
1596
1597 #[test]
1598 fn normalize_history_drops_orphan_assistant_tool_use() {
1599 let mut orphan = ChatMessage::assistant("calling a tool");
1600 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1601 let mut messages = vec![ChatMessage::user("hi"), orphan];
1602 normalize_history(&mut messages);
1603 assert!(
1604 messages
1605 .iter()
1606 .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
1607 "dangling tool_use must be dropped"
1608 );
1609 assert!(
1610 messages.iter().any(|m| m.content == "calling a tool"),
1611 "the assistant text is preserved — only the unpaired call is removed"
1612 );
1613 }
1614
1615 #[test]
1616 fn normalize_history_drops_matching_meta_replay_function_call() {
1617 let mut orphan = ChatMessage::assistant("calling a tool");
1618 orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1619 orphan.provider_continuation = Some(crate::models::ProviderContinuation::MetaResponses {
1620 output: vec![crate::models::MetaResponseItem::from_wire(
1621 serde_json::json!({
1622 "type": "function_call",
1623 "call_id": "call_1",
1624 "name": "do_thing",
1625 "arguments": "{}"
1626 }),
1627 )],
1628 });
1629 let mut messages = vec![ChatMessage::user("hi"), orphan];
1630 normalize_history(&mut messages);
1631 let output = messages[1]
1632 .provider_continuation
1633 .as_ref()
1634 .and_then(crate::models::ProviderContinuation::meta_output)
1635 .unwrap();
1636 assert!(
1637 output.is_empty(),
1638 "orphan Meta function_call must also drop"
1639 );
1640 }
1641
1642 #[test]
1643 fn normalize_history_drops_orphan_tool_result() {
1644 let mut messages = vec![
1645 ChatMessage::user("hi"),
1646 ChatMessage::tool("call_ghost", "do_thing", "result with no call"),
1647 ];
1648 normalize_history(&mut messages);
1649 assert!(
1650 !messages.iter().any(|m| m.role == MessageRole::Tool),
1651 "a tool_result whose call is absent must be dropped"
1652 );
1653 }
1654
1655 #[test]
1656 fn normalize_history_keeps_well_paired_tool_calls() {
1657 let mut asst = ChatMessage::assistant("calling");
1658 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1659 let mut messages = vec![
1660 ChatMessage::user("hi"),
1661 asst,
1662 ChatMessage::tool("call_1", "do_thing", "ok"),
1663 ];
1664 let before = messages.len();
1665 normalize_history(&mut messages);
1666 assert_eq!(
1667 messages.len(),
1668 before,
1669 "a paired call+result survives intact"
1670 );
1671 assert!(
1672 messages[1]
1673 .tool_calls
1674 .as_ref()
1675 .is_some_and(|c| c.len() == 1)
1676 );
1677 }
1678
1679 #[test]
1680 fn normalize_history_drops_idless_tool_use() {
1681 let mut asst = ChatMessage::assistant("calling");
1682 asst.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1683 id: None,
1684 function: crate::models::tool_call::FunctionCall {
1685 name: "do_thing".into(),
1686 arguments: serde_json::json!({}),
1687 },
1688 }]);
1689 let mut messages = vec![asst];
1690 normalize_history(&mut messages);
1691 assert!(
1692 messages[0].tool_calls.as_ref().is_none_or(|c| c.is_empty()),
1693 "an id-less tool_use is inherently unpaired → dropped"
1694 );
1695 }
1696
1697 #[test]
1698 fn prepare_drops_reverse_orphan_tool_result_from_tail() {
1699 let mut asst = ChatMessage::assistant("calling");
1704 asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1705 let messages = vec![
1706 ChatMessage::user("one"),
1707 asst,
1708 ChatMessage::user("two"),
1709 ChatMessage::tool("call_1", "do_thing", "result"),
1710 ChatMessage::user("three"),
1711 ];
1712 let request =
1715 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1716 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1717 assert!(
1718 prepared
1719 .preserved_messages
1720 .iter()
1721 .all(|m| m.role != MessageRole::Tool),
1722 "an orphan tool_result whose tool_use was archived must be dropped"
1723 );
1724 }
1725
1726 #[test]
1727 fn prepare_keeps_pending_trailing_tool_use_on_retry() {
1728 let mut pending = ChatMessage::assistant("calling a tool");
1732 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1733 let messages = vec![
1734 ChatMessage::user("one"),
1735 ChatMessage::assistant("a1"),
1736 ChatMessage::user("two"),
1737 ChatMessage::assistant("a2"),
1738 ChatMessage::user("three"),
1739 pending,
1740 ];
1741 let request = CompactionRequest::auto(
1742 request_with(messages),
1743 CompactionTrigger::ContextLimitRetry,
1744 CompactionPolicy::default(),
1745 );
1746 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1747 let last = prepared
1748 .preserved_messages
1749 .last()
1750 .expect("non-empty preserved tail");
1751 assert!(
1752 last.tool_calls
1753 .as_ref()
1754 .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
1755 "a pending trailing tool_use must be preserved across a retry compaction"
1756 );
1757 }
1758
1759 #[test]
1760 fn prepare_drops_trailing_tool_use_on_manual_compaction() {
1761 let mut pending = ChatMessage::assistant("calling a tool");
1765 pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1766 let messages = vec![
1767 ChatMessage::user("one"),
1768 ChatMessage::assistant("a1"),
1769 ChatMessage::user("two"),
1770 ChatMessage::assistant("a2"),
1771 ChatMessage::user("three"),
1772 pending,
1773 ];
1774 let request =
1775 CompactionRequest::manual(request_with(messages), None, CompactionPolicy::default());
1776 let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1777 assert!(
1778 !prepared
1779 .preserved_messages
1780 .iter()
1781 .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1782 "manual compaction must scrub the trailing orphan tool_use"
1783 );
1784 assert!(
1785 prepared
1786 .preserved_messages
1787 .iter()
1788 .any(|m| m.content == "calling a tool"),
1789 "the assistant text is kept even though the orphan call is dropped"
1790 );
1791 }
1792
1793 #[test]
1794 fn replacement_starts_with_checkpoint_and_ack() {
1795 let prepared = PreparedCompaction {
1796 archived_messages: vec![ChatMessage::user("old")],
1797 preserved_messages: vec![ChatMessage::user("new")],
1798 previous_summary: None,
1799 history_excerpt: "old".to_string(),
1800 summary_images: Vec::new(),
1801 };
1802 let record = CompactionRecord {
1803 id: "c1".to_string(),
1804 trigger: CompactionTrigger::Manual,
1805 created_at: Local::now(),
1806 before_tokens: 100,
1807 after_tokens: 25,
1808 archived_message_count: 1,
1809 preserved_message_count: 1,
1810 preserved_turn_count: 1,
1811 summary_tokens: 10,
1812 duration_secs: 1.0,
1813 review_status: CompactionReviewStatus::Reviewed,
1814 review_error: None,
1815 focus: None,
1816 archive_path: None,
1817 };
1818 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1819 assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
1820 assert!(messages[0].content.contains(CHECKPOINT_MARKER));
1821 assert_eq!(messages[2].content, "new");
1822 }
1823
1824 #[test]
1825 fn replacement_metadata_records_review_status() {
1826 let prepared = PreparedCompaction {
1827 archived_messages: vec![ChatMessage::user("old")],
1828 preserved_messages: vec![ChatMessage::user("new")],
1829 previous_summary: None,
1830 history_excerpt: "old".to_string(),
1831 summary_images: Vec::new(),
1832 };
1833 let record = CompactionRecord {
1834 id: "c1".to_string(),
1835 trigger: CompactionTrigger::Manual,
1836 created_at: Local::now(),
1837 before_tokens: 100,
1838 after_tokens: 25,
1839 archived_message_count: 1,
1840 preserved_message_count: 1,
1841 preserved_turn_count: 1,
1842 summary_tokens: 10,
1843 duration_secs: 1.0,
1844 review_status: CompactionReviewStatus::DraftValidated,
1845 review_error: Some("provider overloaded".to_string()),
1846 focus: None,
1847 archive_path: None,
1848 };
1849 let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1850 let metadata = messages[0].metadata.as_ref().expect("metadata");
1851 assert_eq!(
1852 metadata.get("review_status").and_then(|v| v.as_str()),
1853 Some("draft_validated")
1854 );
1855 assert_eq!(
1856 metadata.get("review_error").and_then(|v| v.as_str()),
1857 Some("provider overloaded")
1858 );
1859 assert!(messages[1].content.contains("structurally validated draft"));
1860 }
1861}