返回 DeepSeek-TUI-2026
compaction.rs
根目录 / crates / tui / src / compaction.rs
1 //! Context compaction for long conversations.
2
3 use anyhow::Result;
4 use regex::Regex;
5 use std::collections::{BTreeSet, HashMap, HashSet};
6 use std::fmt::Write;
7 use std::path::{Path, PathBuf};
8 use std::sync::OnceLock;
9 use std::time::Duration;
10
11 use crate::client::DeepSeekClient;
12 use crate::config::DEFAULT_TEXT_MODEL;
13 use crate::llm_client::LlmClient;
14 use crate::logging;
15 use crate::models::{
16 CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
17 context_window_for_model,
18 };
19
20 /// Configuration for conversation compaction behavior.
21 ///
22 /// v0.8.11 simplified this from the prior token-OR-message-count trigger
23 /// to a token-only trigger gated by an absolute floor. The
24 /// `message_threshold` field was removed: its only purpose was to fire
25 /// compaction on long sessions of small messages, which is exactly the
26 /// case where rewriting the V4 prefix cache is least valuable. Token
27 /// budget is the right signal; message count was a 128K-era heuristic.
28 #[derive(Debug, Clone, PartialEq)]
29 pub struct CompactionConfig {
30 pub enabled: bool,
31 pub token_threshold: usize,
32 pub model: String,
33 pub cache_summary: bool,
34 /// Hard floor — `should_compact` returns `false` when total session
35 /// tokens fall below this number, regardless of `enabled` or
36 /// `token_threshold`. Defaults to [`MINIMUM_AUTO_COMPACTION_TOKENS`]
37 /// (500K) for v0.8.11+. Tests that want to exercise the threshold
38 /// logic at small fixture sizes can set this to `0` to disable the
39 /// floor.
40 pub auto_floor_tokens: usize,
41 }
42
43 impl Default for CompactionConfig {
44 fn default() -> Self {
45 Self {
46 // ON BY DEFAULT since v0.8.6 (#402 P0 survivability) — but the
47 // engine-level `auto_compact` setting was flipped OFF in v0.8.11
48 // (#665) so this default is mostly a fallback for code paths
49 // that build a `CompactionConfig` without going through
50 // `compaction_threshold_for_model_and_effort`. Real per-model
51 // values are still derived through that helper.
52 enabled: true,
53 // v0.8.11: 50K was a 128K-era leftover that biased every
54 // unconfigured caller toward "compact almost immediately on V4."
55 // Bumped to 800K (80% of V4's 1M window) so the dead-code
56 // default no longer lies. Real call sites override this via
57 // `compaction_threshold_for_model_and_effort`.
58 token_threshold: 800_000,
59 model: DEFAULT_TEXT_MODEL.to_string(),
60 cache_summary: true,
61 auto_floor_tokens: MINIMUM_AUTO_COMPACTION_TOKENS,
62 }
63 }
64 }
65
66 /// Hard floor for automatic compaction in v0.8.11+.
67 ///
68 /// Below this token count, `should_compact` returns `false` regardless of
69 /// `enabled` or `token_threshold`. The point of the floor is V4 prefix-cache
70 /// economics: compaction rewrites the stable prefix, which destroys the KV
71 /// cache. At low token counts the prefix cache is healthy and compaction's
72 /// cost (full re-prefill at miss prices) dwarfs its benefit (a tiny budget
73 /// reclaim). Above the floor compaction can still be net-positive — cache
74 /// is already pressured, the prefix has drifted, and freeing budget matters.
75 ///
76 /// Manual `/compact` slash command bypasses this floor with explicit user
77 /// agency.
78 ///
79 /// Constant rather than configurable for v0.8.11. If anyone needs to dial
80 /// it (smaller models, opinionated workflows), we can add a setting later.
81 pub const MINIMUM_AUTO_COMPACTION_TOKENS: usize = 500_000;
82
83 pub const KEEP_RECENT_MESSAGES: usize = 4;
84 const RECENT_WORKING_SET_WINDOW: usize = 12;
85 const MAX_WORKING_SET_PATHS: usize = 24;
86 const MIN_SUMMARIZE_MESSAGES: usize = 6;
87 const SUMMARY_TEXT_SNIPPET_CHARS: usize = 800;
88 const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
89 const SUMMARY_INPUT_MAX_CHARS: usize = 24_000;
90 const SUMMARY_INPUT_HEAD_CHARS: usize = 14_000;
91 const SUMMARY_INPUT_TAIL_CHARS: usize = 6_000;
92 const LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS: usize = 2_000;
93 const LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
94 const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
95 const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
96 const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
97 const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
98 const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
99 const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85;
100
101 #[derive(Debug, Clone, Copy)]
102 struct SummaryInputLimits {
103 text_snippet_chars: usize,
104 tool_result_snippet_chars: usize,
105 input_max_chars: usize,
106 input_head_chars: usize,
107 input_tail_chars: usize,
108 max_tokens: u32,
109 word_limit: usize,
110 }
111
112 fn summary_input_limits_for_model(model: &str) -> SummaryInputLimits {
113 let is_large_context =
114 context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
115 if is_large_context {
116 SummaryInputLimits {
117 text_snippet_chars: LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS,
118 tool_result_snippet_chars: LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
119 input_max_chars: LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS,
120 input_head_chars: LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS,
121 input_tail_chars: LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS,
122 max_tokens: LARGE_CONTEXT_SUMMARY_MAX_TOKENS,
123 word_limit: 900,
124 }
125 } else {
126 SummaryInputLimits {
127 text_snippet_chars: SUMMARY_TEXT_SNIPPET_CHARS,
128 tool_result_snippet_chars: SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
129 input_max_chars: SUMMARY_INPUT_MAX_CHARS,
130 input_head_chars: SUMMARY_INPUT_HEAD_CHARS,
131 input_tail_chars: SUMMARY_INPUT_TAIL_CHARS,
132 max_tokens: 1_024,
133 word_limit: 500,
134 }
135 }
136 }
137
138 #[derive(Debug, Clone, Default)]
139 pub struct CompactionPlan {
140 pub pinned_indices: BTreeSet<usize>,
141 pub summarize_indices: Vec<usize>,
142 }
143
144 fn path_regex() -> &'static Regex {
145 static PATH_RE: OnceLock<Regex> = OnceLock::new();
146 PATH_RE.get_or_init(|| {
147 Regex::new(
148 r"(?x)
149 (?:
150 (?P<root>
151 Cargo\.toml|
152 Cargo\.lock|
153 README\.md|
154 CHANGELOG\.md|
155 AGENTS\.md|
156 config\.example\.toml
157 )
158 )
159 |
160 (?P<path>
161 (?:[A-Za-z0-9._-]+/)+
162 [A-Za-z0-9._-]+
163 \.(?:rs|toml|md|json|ya?ml|txt|lock)
164 )
165 ",
166 )
167 .expect("path regex is valid")
168 })
169 }
170
171 fn normalize_path_candidate(candidate: &str, workspace: Option<&Path>) -> Option<String> {
172 if candidate.is_empty() {
173 return None;
174 }
175
176 let cleaned = candidate.replace('\\', "/");
177 let mut path = PathBuf::from(cleaned);
178
179 if path.is_absolute() {
180 let ws = workspace?;
181 if let Ok(stripped) = path.strip_prefix(ws) {
182 path = stripped.to_path_buf();
183 } else {
184 return None;
185 }
186 }
187
188 let rel = path.to_string_lossy().trim_start_matches("./").to_string();
189 if rel.is_empty() || rel.contains("..") {
190 return None;
191 }
192
193 if let Some(ws) = workspace {
194 let repo_path = ws.join(&rel);
195 if repo_path.exists() || looks_repo_relative(&rel) {
196 return Some(rel);
197 }
198 return None;
199 }
200
201 if looks_repo_relative(&rel) {
202 return Some(rel);
203 }
204
205 None
206 }
207
208 fn looks_repo_relative(path: &str) -> bool {
209 matches!(
210 path,
211 "Cargo.toml"
212 | "Cargo.lock"
213 | "README.md"
214 | "CHANGELOG.md"
215 | "AGENTS.md"
216 | "config.example.toml"
217 ) || path.starts_with("src/")
218 || path.starts_with("tests/")
219 || path.starts_with("docs/")
220 || path.starts_with("examples/")
221 || path.starts_with("benches/")
222 || path.starts_with("crates/")
223 || path.starts_with(".github/")
224 || (path.contains('/') && path.rsplit('.').next().is_some())
225 }
226
227 fn extract_paths_from_text(text: &str, workspace: Option<&Path>) -> Vec<String> {
228 path_regex()
229 .captures_iter(text)
230 .filter_map(|caps| {
231 let candidate = caps
232 .name("path")
233 .or_else(|| caps.name("root"))
234 .map(|m| m.as_str())?;
235 normalize_path_candidate(candidate, workspace)
236 })
237 .collect()
238 }
239
240 fn extract_paths_from_tool_input(
241 input: &serde_json::Value,
242 workspace: Option<&Path>,
243 ) -> Vec<String> {
244 let mut out = Vec::new();
245 let Some(obj) = input.as_object() else {
246 return out;
247 };
248
249 for key in ["path", "file", "target", "cwd"] {
250 if let Some(val) = obj.get(key).and_then(serde_json::Value::as_str)
251 && let Some(path) = normalize_path_candidate(val, workspace)
252 {
253 out.push(path);
254 }
255 }
256
257 for key in ["paths", "files", "targets"] {
258 if let Some(vals) = obj.get(key).and_then(serde_json::Value::as_array) {
259 for val in vals {
260 if let Some(s) = val.as_str()
261 && let Some(path) = normalize_path_candidate(s, workspace)
262 {
263 out.push(path);
264 }
265 }
266 }
267 }
268
269 out
270 }
271
272 fn message_text(msg: &Message) -> String {
273 let mut text = String::new();
274 for block in &msg.content {
275 match block {
276 ContentBlock::Text { text: t, .. } => {
277 let _ = writeln!(text, "{t}");
278 }
279 ContentBlock::Thinking { .. } => {}
280 ContentBlock::ToolUse { name, input, .. } => {
281 let _ = writeln!(text, "[tool_use:{name}] {input}");
282 }
283 ContentBlock::ToolResult { content, .. } => {
284 let _ = writeln!(text, "{content}");
285 }
286 ContentBlock::ServerToolUse { .. }
287 | ContentBlock::ToolSearchToolResult { .. }
288 | ContentBlock::CodeExecutionToolResult { .. } => {}
289 }
290 }
291 text
292 }
293
294 fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Vec<String> {
295 let mut paths = Vec::new();
296 for block in &message.content {
297 let candidates = match block {
298 ContentBlock::Text { text, .. } => extract_paths_from_text(text, workspace),
299 ContentBlock::ToolResult { content, .. } => extract_paths_from_text(content, workspace),
300 ContentBlock::ToolUse { input, .. } => extract_paths_from_tool_input(input, workspace),
301 ContentBlock::Thinking { .. } => Vec::new(),
302 ContentBlock::ServerToolUse { .. }
303 | ContentBlock::ToolSearchToolResult { .. }
304 | ContentBlock::CodeExecutionToolResult { .. } => Vec::new(),
305 };
306 paths.extend(candidates);
307 }
308 paths
309 }
310
311 fn derive_working_set_paths(
312 messages: &[Message],
313 workspace: Option<&Path>,
314 seed_indices: &[usize],
315 ) -> HashSet<String> {
316 let mut paths: Vec<String> = Vec::new();
317 let mut seen: HashSet<String> = HashSet::new();
318
319 let mut seeds: Vec<usize> = seed_indices
320 .iter()
321 .copied()
322 .filter(|idx| *idx < messages.len())
323 .collect();
324 seeds.sort_unstable_by(|a, b| b.cmp(a));
325
326 for idx in seeds {
327 for candidate in extract_paths_from_message(&messages[idx], workspace) {
328 if seen.insert(candidate.clone()) {
329 paths.push(candidate);
330 if paths.len() >= MAX_WORKING_SET_PATHS {
331 return paths.into_iter().collect();
332 }
333 }
334 }
335 }
336
337 for msg in messages.iter().rev().take(RECENT_WORKING_SET_WINDOW) {
338 for candidate in extract_paths_from_message(msg, workspace) {
339 if seen.insert(candidate.clone()) {
340 paths.push(candidate);
341 if paths.len() >= MAX_WORKING_SET_PATHS {
342 return paths.into_iter().collect();
343 }
344 }
345 }
346 }
347
348 paths.into_iter().collect()
349 }
350
351 fn should_pin_message(text: &str, working_set_paths: &HashSet<String>) -> bool {
352 let lower = text.to_lowercase();
353
354 let mentions_working_set = working_set_paths.iter().any(|p| text.contains(p));
355 if mentions_working_set {
356 return true;
357 }
358
359 let error_markers = [
360 "error:",
361 "error ",
362 "failed",
363 "panic",
364 "traceback",
365 "stack trace",
366 "assertion failed",
367 "test failed",
368 ];
369 if error_markers.iter().any(|m| lower.contains(m)) {
370 return true;
371 }
372
373 let patch_markers = [
374 "diff --git",
375 "+++ b/",
376 "--- a/",
377 "*** begin patch",
378 "*** update file:",
379 "*** add file:",
380 "*** delete file:",
381 "```diff",
382 "apply_patch",
383 ];
384 patch_markers.iter().any(|m| lower.contains(m))
385 }
386
387 pub fn plan_compaction(
388 messages: &[Message],
389 workspace: Option<&Path>,
390 keep_recent: usize,
391 external_pins: Option<&[usize]>,
392 external_working_set_paths: Option<&[String]>,
393 ) -> CompactionPlan {
394 let mut pinned_indices: BTreeSet<usize> = BTreeSet::new();
395 let len = messages.len();
396 if len == 0 {
397 return CompactionPlan::default();
398 }
399
400 // Always pin the tail of the conversation to preserve immediate context.
401 let recent_start = len.saturating_sub(keep_recent);
402 pinned_indices.extend(recent_start..len);
403
404 // Derive a repo-aware working set from recent messages/tool calls and
405 // merge it with any externally provided working-set paths.
406 let seed_indices = external_pins.unwrap_or(&[]);
407 let mut working_set_paths = derive_working_set_paths(messages, workspace, seed_indices);
408 if let Some(paths) = external_working_set_paths {
409 for path in paths {
410 if let Some(normalized) = normalize_path_candidate(path, workspace) {
411 let _ = working_set_paths.insert(normalized);
412 }
413 }
414 }
415
416 for (idx, msg) in messages.iter().enumerate() {
417 if pinned_indices.contains(&idx) {
418 continue;
419 }
420 let text = message_text(msg);
421 if should_pin_message(&text, &working_set_paths) {
422 pinned_indices.insert(idx);
423 }
424 }
425
426 // External pins are authoritative and should be preserved even if they
427 // were not detected by the heuristics above.
428 if let Some(pins) = external_pins {
429 pinned_indices.extend(pins.iter().copied().filter(|idx| *idx < len));
430 }
431
432 // Ensure tool result messages are not kept without their corresponding tool call.
433 enforce_tool_call_pairs(messages, &mut pinned_indices);
434
435 let summarize_indices = (0..len)
436 .filter(|idx| !pinned_indices.contains(idx))
437 .collect();
438
439 // `working_set_paths` was used only for pinning decisions above.
440 drop(working_set_paths);
441
442 CompactionPlan {
443 pinned_indices,
444 summarize_indices,
445 }
446 }
447
448 fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<usize>) {
449 if pinned_indices.is_empty() {
450 return;
451 }
452
453 // Build maps: tool_id → message index across ALL messages (not just pinned).
454 let mut call_id_to_idx: HashMap<String, usize> = HashMap::new();
455 let mut result_id_to_idx: HashMap<String, usize> = HashMap::new();
456
457 for (idx, msg) in messages.iter().enumerate() {
458 for block in &msg.content {
459 match block {
460 ContentBlock::ToolUse { id, .. } => {
461 call_id_to_idx.insert(id.clone(), idx);
462 }
463 ContentBlock::ToolResult { tool_use_id, .. } => {
464 result_id_to_idx.insert(tool_use_id.clone(), idx);
465 }
466 _ => {}
467 }
468 }
469 }
470
471 // Fixpoint loop: re-check until stable.
472 // Newly pinned messages may introduce new pair requirements;
473 // removed messages may orphan their counterparts.
474 // Track permanently removed indices so they cannot be re-added
475 // by a counterpart in a later iteration (prevents oscillation).
476 let mut permanently_removed: HashSet<usize> = HashSet::new();
477
478 let max_iters = messages.len().max(10);
479 let mut converged = false;
480 for _ in 0..max_iters {
481 let mut to_add = Vec::new();
482 let mut to_remove = Vec::new();
483
484 let snapshot: Vec<usize> = pinned_indices.iter().copied().collect();
485
486 for idx in snapshot {
487 let msg = &messages[idx];
488 for block in &msg.content {
489 match block {
490 // Pinned result → its call must also be pinned (or remove result)
491 ContentBlock::ToolResult { tool_use_id, .. } => {
492 match call_id_to_idx.get(tool_use_id) {
493 Some(&call_idx) if !permanently_removed.contains(&call_idx) => {
494 to_add.push(call_idx);
495 }
496 _ => {
497 to_remove.push(idx);
498 }
499 }
500 }
501 // Pinned call → its result must also be pinned (or remove call)
502 ContentBlock::ToolUse { id, .. } => match result_id_to_idx.get(id) {
503 Some(&result_idx) if !permanently_removed.contains(&result_idx) => {
504 to_add.push(result_idx);
505 }
506 _ => {
507 to_remove.push(idx);
508 }
509 },
510 _ => {}
511 }
512 }
513 }
514
515 // Removals take priority: if a message is both needed and orphaned,
516 // remove it now; the fixpoint loop will cascade the orphaning.
517 let remove_set: HashSet<usize> = to_remove.iter().copied().collect();
518 let mut changed = false;
519 for idx in to_add {
520 if !remove_set.contains(&idx) && pinned_indices.insert(idx) {
521 changed = true;
522 }
523 }
524 for idx in to_remove {
525 if pinned_indices.remove(&idx) {
526 permanently_removed.insert(idx);
527 changed = true;
528 }
529 }
530
531 if !changed {
532 converged = true;
533 break;
534 }
535 }
536 if !converged {
537 logging::warn(format!(
538 "enforce_tool_call_pairs did not converge after {max_iters} iterations \
539 ({} messages, {} pinned)",
540 messages.len(),
541 pinned_indices.len()
542 ));
543 }
544 }
545
546 fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
547 message
548 .content
549 .iter()
550 .map(|c| match c {
551 ContentBlock::Text { text, .. } => text.len() / 4,
552 // Historical reasoning blocks are UI/session metadata for DeepSeek.
553 // Only current-turn tool-call reasoning is sent back to the API.
554 ContentBlock::Thinking { thinking } if include_thinking => thinking.len() / 4,
555 ContentBlock::Thinking { .. } => 0,
556 ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
557 .map(|s| s.len() / 4)
558 .unwrap_or(100),
559 ContentBlock::ToolResult { content, .. } => content.len() / 4,
560 ContentBlock::ServerToolUse { .. }
561 | ContentBlock::ToolSearchToolResult { .. }
562 | ContentBlock::CodeExecutionToolResult { .. } => 0,
563 })
564 .sum::<usize>()
565 }
566
567 pub fn estimate_tokens(messages: &[Message]) -> usize {
568 // Rough estimate: ~4 chars per token. DeepSeek thinking-mode rule: any
569 // assistant message with tool_calls keeps its reasoning_content forever
570 // (replayed in all subsequent requests). Final text-only answers drop it.
571 messages
572 .iter()
573 .map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
574 .sum()
575 }
576
577 fn message_has_tool_use(message: &Message) -> bool {
578 message
579 .content
580 .iter()
581 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
582 }
583
584 fn estimate_text_tokens_conservative(text: &str) -> usize {
585 text.chars().count().div_ceil(3)
586 }
587
588 fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
589 match system {
590 Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
591 Some(SystemPrompt::Blocks(blocks)) => blocks
592 .iter()
593 .map(|block| estimate_text_tokens_conservative(&block.text))
594 .sum(),
595 None => 0,
596 }
597 }
598
599 /// Conservative estimate for full request input tokens (messages + system + framing).
600 #[must_use]
601 pub fn estimate_input_tokens_conservative(
602 messages: &[Message],
603 system: Option<&SystemPrompt>,
604 ) -> usize {
605 let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
606 let system_tokens = estimate_system_tokens_conservative(system);
607 let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
608 message_tokens
609 .saturating_add(system_tokens)
610 .saturating_add(framing_overhead)
611 }
612
613 pub fn should_compact(
614 messages: &[Message],
615 config: &CompactionConfig,
616 workspace: Option<&Path>,
617 external_pins: Option<&[usize]>,
618 external_working_set_paths: Option<&[String]>,
619 ) -> bool {
620 if !config.enabled {
621 return false;
622 }
623
624 // v0.8.11: hard floor enforcement. Below the floor (default 500K tokens
625 // — see `MINIMUM_AUTO_COMPACTION_TOKENS`), automatic compaction is
626 // refused because rewriting the prefix kills V4's prefix cache for
627 // little budget recovery. Manual `/compact` and the `compact_now` tool
628 // bypass this floor by going through different code paths.
629 if config.auto_floor_tokens > 0 {
630 let total_session_tokens: usize = messages
631 .iter()
632 .map(|m| estimate_tokens_for_message(m, false))
633 .sum();
634 if total_session_tokens < config.auto_floor_tokens {
635 return false;
636 }
637 }
638
639 let plan = plan_compaction(
640 messages,
641 workspace,
642 KEEP_RECENT_MESSAGES,
643 external_pins,
644 external_working_set_paths,
645 );
646 let pinned_tokens: usize = plan
647 .pinned_indices
648 .iter()
649 .map(|&idx| estimate_tokens_for_message(&messages[idx], false))
650 .sum();
651
652 let token_estimate: usize = plan
653 .summarize_indices
654 .iter()
655 .map(|&idx| estimate_tokens_for_message(&messages[idx], false))
656 .sum();
657 let message_count = plan.summarize_indices.len();
658
659 // Pinned messages consume part of the budget, so compact earlier when needed.
660 let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
661
662 // Token-only trigger (v0.8.11): the prior message-count branch was a
663 // 128K-era heuristic that fired compaction on long chats of small
664 // messages — exactly the case where rewriting the V4 prefix cache is
665 // most wasteful. Token budget is the only signal that maps to actual
666 // model context pressure.
667 if effective_token_threshold == 0 {
668 return message_count >= MIN_SUMMARIZE_MESSAGES;
669 }
670 if message_count < MIN_SUMMARIZE_MESSAGES {
671 return false;
672 }
673 token_estimate > effective_token_threshold
674 }
675
676 fn truncate_chars(text: &str, max_chars: usize) -> &str {
677 if max_chars == 0 {
678 return "";
679 }
680 match text.char_indices().nth(max_chars) {
681 Some((idx, _)) => &text[..idx],
682 None => text,
683 }
684 }
685
686 fn tail_chars(text: &str, max_chars: usize) -> String {
687 if max_chars == 0 {
688 return String::new();
689 }
690 let total_chars = text.chars().count();
691 if total_chars <= max_chars {
692 return text.to_string();
693 }
694 let start_char = total_chars.saturating_sub(max_chars);
695 let start_idx = text
696 .char_indices()
697 .nth(start_char)
698 .map_or(0, |(idx, _)| idx);
699 text[start_idx..].to_string()
700 }
701
702 #[derive(Debug, Clone)]
703 struct ToolUseInfo {
704 name: String,
705 key: String,
706 args_preview: String,
707 }
708
709 fn tool_use_key(name: &str, input: &serde_json::Value) -> String {
710 format!(
711 "{name}:{}",
712 serde_json::to_string(input).unwrap_or_else(|_| input.to_string())
713 )
714 }
715
716 fn tool_args_preview(input: &serde_json::Value) -> String {
717 let raw = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
718 truncate_chars(&raw, 120).to_string()
719 }
720
721 fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
722 let mut tool_uses = HashMap::new();
723 for message in messages {
724 for block in &message.content {
725 if let ContentBlock::ToolUse {
726 id, name, input, ..
727 } = block
728 {
729 tool_uses.insert(
730 id.clone(),
731 ToolUseInfo {
732 name: name.clone(),
733 key: tool_use_key(name, input),
734 args_preview: tool_args_preview(input),
735 },
736 );
737 }
738 }
739 }
740 tool_uses
741 }
742
743 struct ToolResultPruneCandidate {
744 message_idx: usize,
745 block_idx: usize,
746 key: String,
747 tool_name: String,
748 args_preview: String,
749 original_len: usize,
750 }
751
752 /// Mechanically prune old verbose tool results before paying for an LLM summary.
753 ///
754 /// The most recent `protected_window` messages stay byte-for-byte intact. Older
755 /// duplicate tool results keep the freshest full body and replace earlier
756 /// copies with one-line summaries; non-duplicate old results are summarized only
757 /// when they exceed the normal summary snippet size.
758 pub fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize {
759 let cutoff = messages.len().saturating_sub(protected_window);
760 if cutoff == 0 {
761 return 0;
762 }
763
764 let tool_uses = collect_tool_uses(messages);
765 let mut candidates = Vec::new();
766 let mut latest_by_key: HashMap<String, usize> = HashMap::new();
767 let mut count_by_key: HashMap<String, usize> = HashMap::new();
768
769 for (message_idx, message) in messages.iter().take(cutoff).enumerate() {
770 for (block_idx, block) in message.content.iter().enumerate() {
771 let ContentBlock::ToolResult {
772 tool_use_id,
773 content,
774 ..
775 } = block
776 else {
777 continue;
778 };
779 let Some(info) = tool_uses.get(tool_use_id) else {
780 continue;
781 };
782 latest_by_key.insert(info.key.clone(), message_idx);
783 *count_by_key.entry(info.key.clone()).or_insert(0) += 1;
784 candidates.push(ToolResultPruneCandidate {
785 message_idx,
786 block_idx,
787 key: info.key.clone(),
788 tool_name: info.name.clone(),
789 args_preview: info.args_preview.clone(),
790 original_len: content.len(),
791 });
792 }
793 }
794
795 let mut bytes_saved = 0usize;
796 for candidate in candidates {
797 let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0);
798 let is_latest_duplicate = duplicate_count > 1
799 && latest_by_key.get(&candidate.key) == Some(&candidate.message_idx);
800 if is_latest_duplicate {
801 continue;
802 }
803 if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS {
804 continue;
805 }
806
807 let summary = format!(
808 "[{}] tool result pruned ({} bytes; args: {})",
809 candidate.tool_name, candidate.original_len, candidate.args_preview
810 );
811 if summary.len() >= candidate.original_len {
812 continue;
813 }
814
815 if let ContentBlock::ToolResult {
816 content,
817 content_blocks,
818 ..
819 } = &mut messages[candidate.message_idx].content[candidate.block_idx]
820 {
821 bytes_saved = bytes_saved.saturating_add(content.len().saturating_sub(summary.len()));
822 *content = summary;
823 *content_blocks = None;
824 }
825 }
826
827 bytes_saved
828 }
829
830 /// Result of a compaction operation with metadata.
831 #[derive(Debug)]
832 pub struct CompactionResult {
833 /// Compacted messages
834 pub messages: Vec<Message>,
835 /// Summary system prompt
836 pub summary_prompt: Option<SystemPrompt>,
837 /// Messages that were removed from the active window
838 #[allow(dead_code)]
839 pub removed_messages: Vec<Message>,
840 /// Number of retries used before success
841 pub retries_used: u32,
842 }
843
844 /// Check if an error is transient and worth retrying. Categories that map to
845 /// transient retry: Network, RateLimit, Timeout. Anything else (auth, parse,
846 /// invalid request, etc.) is permanent and propagates.
847 fn is_transient_error(e: &anyhow::Error) -> bool {
848 let category = crate::error_taxonomy::classify_error_message(&e.to_string());
849 matches!(
850 category,
851 crate::error_taxonomy::ErrorCategory::Network
852 | crate::error_taxonomy::ErrorCategory::RateLimit
853 | crate::error_taxonomy::ErrorCategory::Timeout
854 )
855 }
856
857 /// Compact messages with retry and backoff for transient errors.
858 ///
859 /// This function wraps `compact_messages` with retry logic to handle
860 /// transient network errors and rate limits. It uses exponential backoff
861 /// with delays of 1s, 2s, 4s between retries.
862 ///
863 /// # Safety
864 /// - Never panics
865 /// - Never corrupts the original messages (returns error instead)
866 /// - Only retries on transient errors (network, rate limit, etc.)
867 pub async fn compact_messages_safe(
868 client: &DeepSeekClient,
869 messages: &[Message],
870 config: &CompactionConfig,
871 workspace: Option<&Path>,
872 external_pins: Option<&[usize]>,
873 external_working_set_paths: Option<&[String]>,
874 ) -> Result<CompactionResult> {
875 const MAX_RETRIES: u32 = 3;
876 const BASE_DELAY_MS: u64 = 1000;
877
878 let mut pruned_messages = messages.to_vec();
879 let pruned_bytes = prune_tool_results(&mut pruned_messages, KEEP_RECENT_MESSAGES);
880 let compaction_input: &[Message] = if pruned_bytes > 0 {
881 logging::info(format!(
882 "Local tool-result prune saved {pruned_bytes} bytes before LLM compaction"
883 ));
884 let was_over_threshold = should_compact(
885 messages,
886 config,
887 workspace,
888 external_pins,
889 external_working_set_paths,
890 );
891 let now_under_threshold = !should_compact(
892 &pruned_messages,
893 config,
894 workspace,
895 external_pins,
896 external_working_set_paths,
897 );
898 if was_over_threshold && now_under_threshold {
899 return Ok(CompactionResult {
900 messages: pruned_messages,
901 summary_prompt: None,
902 removed_messages: Vec::new(),
903 retries_used: 0,
904 });
905 }
906 &pruned_messages
907 } else {
908 messages
909 };
910
911 let mut last_error: Option<anyhow::Error> = None;
912
913 for attempt in 0..MAX_RETRIES {
914 if attempt > 0 {
915 // Exponential backoff: 1s, 2s, 4s
916 let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1)));
917 tokio::time::sleep(delay).await;
918 }
919
920 match compact_messages(
921 client,
922 compaction_input,
923 config,
924 workspace,
925 external_pins,
926 external_working_set_paths,
927 )
928 .await
929 {
930 Ok((msgs, prompt, removed)) => {
931 return Ok(CompactionResult {
932 messages: msgs,
933 summary_prompt: prompt,
934 removed_messages: removed,
935 retries_used: attempt,
936 });
937 }
938 Err(e) => {
939 // Only retry on transient errors
940 if !is_transient_error(&e) {
941 return Err(e);
942 }
943 last_error = Some(e);
944 }
945 }
946 }
947
948 Err(last_error
949 .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
950 }
951
952 pub async fn compact_messages(
953 client: &DeepSeekClient,
954 messages: &[Message],
955 config: &CompactionConfig,
956 workspace: Option<&Path>,
957 external_pins: Option<&[usize]>,
958 external_working_set_paths: Option<&[String]>,
959 ) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
960 if messages.is_empty() {
961 return Ok((Vec::new(), None, Vec::new()));
962 }
963
964 let plan = plan_compaction(
965 messages,
966 workspace,
967 KEEP_RECENT_MESSAGES,
968 external_pins,
969 external_working_set_paths,
970 );
971 if plan.summarize_indices.is_empty() {
972 return Ok((messages.to_vec(), None, Vec::new()));
973 }
974
975 let to_summarize: Vec<Message> = plan
976 .summarize_indices
977 .iter()
978 .map(|&idx| messages[idx].clone())
979 .collect();
980
981 // Create a summary of the unpinned portion of the conversation
982 let summary = create_summary(client, &to_summarize, &config.model).await?;
983
984 // Extract workflow context (files touched, tasks in progress, etc.)
985 let workflow_context = extract_workflow_context(&to_summarize, workspace);
986
987 // Build new message list with enhanced summary as system block
988 let summary_block = SystemBlock {
989 block_type: "text".to_string(),
990 text: format!(
991 "## 📋 Conversation Summary (Auto-Generated)\n\n\
992 {summary}\n\n\
993 ---\n\n\
994 ## 🔍 Workflow Context\n\n\
995 {workflow_context}\n\n\
996 ---\n\n\
997 ## 💡 What to Do Next\n\n\
998 You have just resumed from a context compaction. The conversation above was summarized to save space. \
999 Review the summary and workflow context, then continue helping the user with their task. \
1000 If you need more details about the summarized portion, ask the user to clarify.\n\n\
1001 ---\n\n\
1002 Pinned messages follow:"
1003 ),
1004 cache_control: if config.cache_summary {
1005 Some(CacheControl {
1006 cache_type: "ephemeral".to_string(),
1007 })
1008 } else {
1009 None
1010 },
1011 };
1012
1013 let pinned_messages = messages
1014 .iter()
1015 .enumerate()
1016 .filter_map(|(idx, msg)| plan.pinned_indices.contains(&idx).then_some(msg.clone()))
1017 .collect();
1018
1019 Ok((
1020 pinned_messages,
1021 Some(SystemPrompt::Blocks(vec![summary_block])),
1022 to_summarize,
1023 ))
1024 }
1025
1026 async fn create_summary(
1027 client: &DeepSeekClient,
1028 messages: &[Message],
1029 model: &str,
1030 ) -> Result<String> {
1031 let limits = summary_input_limits_for_model(model);
1032 let used_cache_aligned = should_use_cache_aligned_summary(model, messages);
1033 let request = if used_cache_aligned {
1034 build_cache_aligned_summary_request(model, messages, limits)
1035 } else {
1036 build_formatted_summary_request(model, messages, limits)
1037 };
1038
1039 let response = client.create_message(request).await?;
1040 // Compaction summary calls are billed by DeepSeek; route the
1041 // tokens through the side-channel so the dashboard total
1042 // matches the website (#526).
1043 crate::cost_status::report(&response.model, &response.usage);
1044
1045 // #584: emit one debug-level event per summary call so the
1046 // V4 cache-aligned win is observable post-deploy without
1047 // adding UI surface. The event is emitted with
1048 // `target = "compaction"`, so the filter is
1049 // `RUST_LOG=compaction=debug` (the module-path form
1050 // `deepseek_tui::compaction=debug` does NOT match — `EnvFilter`
1051 // matches the explicit target string when one is set).
1052 log_summary_cache_telemetry(used_cache_aligned, &response.usage);
1053
1054 // Extract text from response
1055 let summary = response
1056 .content
1057 .iter()
1058 .filter_map(|block| match block {
1059 ContentBlock::Text { text, .. } => Some(text.clone()),
1060 _ => None,
1061 })
1062 .collect::<Vec<_>>()
1063 .join("\n");
1064
1065 Ok(summary)
1066 }
1067
1068 /// Cache-hit percentage for a compaction summary call.
1069 ///
1070 /// Denominator is `input_tokens` (the total prompt size), not
1071 /// `cache_hit + cache_miss`. Some providers populate
1072 /// `prompt_cache_hit_tokens` but not `prompt_cache_miss_tokens` — using
1073 /// the sum as the denominator there reports an inflated 100% even when
1074 /// most of the prompt was uncached. Anchoring on `input_tokens` matches
1075 /// how the rest of the codebase (cost reporting, `/cache`) infers
1076 /// missing miss counts. (#584)
1077 fn summary_cache_hit_percent(cache_hit: u32, input_tokens: u32) -> f64 {
1078 if input_tokens > 0 {
1079 (f64::from(cache_hit) * 100.0) / f64::from(input_tokens)
1080 } else {
1081 0.0
1082 }
1083 }
1084
1085 /// Emit one `tracing::debug!` event per compaction summary call so the
1086 /// path choice (cache-aligned vs fallback) and the resulting cache-hit
1087 /// rate are observable. Both raw token counts and the percentage are
1088 /// included; on providers that don't return cache-token fields the
1089 /// counts are reported as `0` and the percentage as `0.0`. (#584)
1090 fn log_summary_cache_telemetry(used_cache_aligned: bool, usage: &crate::models::Usage) {
1091 let path = if used_cache_aligned {
1092 "cache_aligned"
1093 } else {
1094 "fallback"
1095 };
1096 let cache_hit = usage.prompt_cache_hit_tokens.unwrap_or(0);
1097 let cache_miss = usage.prompt_cache_miss_tokens.unwrap_or(0);
1098 let cache_hit_pct = summary_cache_hit_percent(cache_hit, usage.input_tokens);
1099 tracing::debug!(
1100 target: "compaction",
1101 "compaction summary call: path={} prompt_tokens={} cache_hit_tokens={} cache_miss_tokens={} cache_hit_pct={:.1}",
1102 path,
1103 usage.input_tokens,
1104 cache_hit,
1105 cache_miss,
1106 cache_hit_pct,
1107 );
1108 }
1109
1110 /// Decide whether to use the cache-aligned summary path
1111 /// ([`build_cache_aligned_summary_request`]) or the fallback
1112 /// ([`build_formatted_summary_request`]). Returns `true` when both
1113 /// gates hold:
1114 ///
1115 /// 1. The model has a known large context window
1116 /// (≥ `LARGE_CONTEXT_WINDOW_TOKENS`, currently V4-scale).
1117 /// 2. Replaying the message prefix plus a ~512-token instruction
1118 /// still fits within `CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT`
1119 /// of that budget.
1120 ///
1121 /// ## Why the two paths produce slightly different prompts (#584)
1122 ///
1123 /// The two summary requests are *intentionally* framed differently:
1124 ///
1125 /// - **Cache-aligned** replays the original `messages` verbatim
1126 /// with `system: None` and appends the summary instruction as
1127 /// the final `user` turn. The model sees the conversation as if
1128 /// it were its own history. This is what lets the V4 prefix cache
1129 /// hit on the bulk of the request (#572).
1130 /// - **Fallback** reformats the conversation into a flat
1131 /// `User:/Assistant:` transcript inside a single `user` message
1132 /// and adds a "You are a helpful assistant that creates concise
1133 /// conversation summaries." system prompt. The model sees a
1134 /// transcript of someone else's conversation.
1135 ///
1136 /// The empirical bar is that V4 produces equivalent summaries
1137 /// either way; the post-#572 review noted this fork is worth
1138 /// documenting but not yet worth unifying. The fallback's
1139 /// external-transcript framing is also more conservative for the
1140 /// older / smaller models the cache-aligned path explicitly
1141 /// excludes, so dropping the system prompt would risk regressing
1142 /// those models without a corresponding gain. If we ever want to
1143 /// unify, land it in a separate PR backed by an A/B summary-quality
1144 /// evaluation rather than as a drive-by cleanup.
1145 ///
1146 /// `create_summary` emits a `tracing::debug!` event under
1147 /// `target = "compaction"` after each call so the path choice and
1148 /// cache-hit rate are observable post-deploy without UI surface.
1149 fn should_use_cache_aligned_summary(model: &str, messages: &[Message]) -> bool {
1150 let Some(window) = context_window_for_model(model) else {
1151 return false;
1152 };
1153 if window < LARGE_CONTEXT_WINDOW_TOKENS {
1154 return false;
1155 }
1156
1157 let budget = usize::try_from(window).unwrap_or(usize::MAX)
1158 * CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT
1159 / 100;
1160 let summary_prompt_tokens = 512usize;
1161 estimate_tokens(messages).saturating_add(summary_prompt_tokens) <= budget
1162 }
1163
1164 fn summary_instruction(word_limit: usize) -> String {
1165 format!(
1166 "Summarize the conversation above in a concise but comprehensive way. \
1167 Preserve key information, decisions made, exact file paths, commands, \
1168 errors, and tool-result facts needed to continue the work. \
1169 Tool outputs may be abbreviated only when they are repetitive. \
1170 Keep it under {word_limit} words."
1171 )
1172 }
1173
1174 fn build_cache_aligned_summary_request(
1175 model: &str,
1176 messages: &[Message],
1177 limits: SummaryInputLimits,
1178 ) -> MessageRequest {
1179 let mut request_messages = messages.to_vec();
1180 request_messages.push(Message {
1181 role: "user".to_string(),
1182 content: vec![ContentBlock::Text {
1183 text: summary_instruction(limits.word_limit),
1184 cache_control: None,
1185 }],
1186 });
1187
1188 MessageRequest {
1189 model: model.to_string(),
1190 messages: request_messages,
1191 max_tokens: limits.max_tokens,
1192 system: None,
1193 tools: None,
1194 tool_choice: None,
1195 metadata: None,
1196 thinking: None,
1197 reasoning_effort: None,
1198 stream: Some(false),
1199 temperature: Some(0.3),
1200 top_p: None,
1201 }
1202 }
1203
1204 fn build_formatted_summary_request(
1205 model: &str,
1206 messages: &[Message],
1207 limits: SummaryInputLimits,
1208 ) -> MessageRequest {
1209 // Format messages for summarization
1210 let mut conversation_text = String::new();
1211 for msg in messages {
1212 let role = if msg.role == "user" {
1213 "User"
1214 } else {
1215 "Assistant"
1216 };
1217 for block in &msg.content {
1218 match block {
1219 ContentBlock::Text { text, .. } => {
1220 let snippet = truncate_chars(text, limits.text_snippet_chars);
1221 let _ = write!(conversation_text, "{role}: {snippet}\n\n");
1222 }
1223 ContentBlock::ToolUse { name, .. } => {
1224 let _ = write!(conversation_text, "{role}: [Used tool: {name}]\n\n");
1225 }
1226 ContentBlock::ToolResult { content, .. } => {
1227 let snippet = truncate_chars(content, limits.tool_result_snippet_chars);
1228 let _ = write!(conversation_text, "Tool result: {}\n\n", snippet);
1229 }
1230 ContentBlock::Thinking { .. } => {
1231 // Skip thinking blocks in summary
1232 }
1233 ContentBlock::ServerToolUse { .. }
1234 | ContentBlock::ToolSearchToolResult { .. }
1235 | ContentBlock::CodeExecutionToolResult { .. } => {}
1236 }
1237 }
1238 }
1239
1240 let conversation_chars = conversation_text.chars().count();
1241 if conversation_chars > limits.input_max_chars {
1242 let head = truncate_chars(&conversation_text, limits.input_head_chars).to_string();
1243 let tail = tail_chars(&conversation_text, limits.input_tail_chars);
1244 let omitted = conversation_chars
1245 .saturating_sub(head.chars().count())
1246 .saturating_sub(tail.chars().count());
1247 conversation_text =
1248 format!("{head}\n\n[... {omitted} characters omitted before summary ...]\n\n{tail}");
1249 }
1250
1251 MessageRequest {
1252 model: model.to_string(),
1253 messages: vec![Message {
1254 role: "user".to_string(),
1255 content: vec![ContentBlock::Text {
1256 text: format!(
1257 "{}\n\n---\n\n{conversation_text}",
1258 summary_instruction(limits.word_limit)
1259 ),
1260 cache_control: None,
1261 }],
1262 }],
1263 max_tokens: limits.max_tokens,
1264 system: Some(SystemPrompt::Text(
1265 "You are a helpful assistant that creates concise conversation summaries.".to_string(),
1266 )),
1267 tools: None,
1268 tool_choice: None,
1269 metadata: None,
1270 thinking: None,
1271 reasoning_effort: None,
1272 stream: Some(false),
1273 temperature: Some(0.3),
1274 top_p: None,
1275 }
1276 }
1277
1278 /// Extract workflow context from messages (files touched, tasks, etc.)
1279 fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> String {
1280 let mut files_touched: Vec<String> = Vec::new();
1281 let mut tools_used: Vec<String> = Vec::new();
1282 let mut tasks_identified: Vec<String> = Vec::new();
1283
1284 for msg in messages {
1285 for block in &msg.content {
1286 match block {
1287 ContentBlock::ToolUse { name, input, .. } => {
1288 tools_used.push(name.clone());
1289
1290 // Extract file paths from tool inputs
1291 if let Some(path) = extract_path_from_input(input)
1292 && !files_touched.contains(&path)
1293 {
1294 files_touched.push(path);
1295 }
1296 }
1297 ContentBlock::Text { text, .. }
1298 // Look for task/todo mentions
1299 if (text.contains("TODO") || text.contains("task") || text.contains("need to")) => {
1300 let task = truncate_chars(text, 200).to_string();
1301 if !tasks_identified.contains(&task) {
1302 tasks_identified.push(task);
1303 }
1304 }
1305 _ => {}
1306 }
1307 }
1308 }
1309
1310 let mut context = String::new();
1311
1312 if !files_touched.is_empty() {
1313 context.push_str("**Files Modified/Read:**\n");
1314 for file in &files_touched {
1315 if let Some(ws) = workspace {
1316 let relative = Path::new(file)
1317 .strip_prefix(ws)
1318 .unwrap_or(Path::new(file))
1319 .display();
1320 context.push_str(&format!("- `{}`\n", relative));
1321 } else {
1322 context.push_str(&format!("- `{}`\n", file));
1323 }
1324 }
1325 context.push('\n');
1326 }
1327
1328 if !tools_used.is_empty() {
1329 context.push_str("**Tools Used:** ");
1330 context.push_str(&tools_used.join(", "));
1331 context.push_str("\n\n");
1332 }
1333
1334 if !tasks_identified.is_empty() {
1335 context.push_str("**Tasks/TODOs Identified:**\n");
1336 for task in &tasks_identified {
1337 context.push_str(&format!("- {}\n", task));
1338 }
1339 context.push('\n');
1340 }
1341
1342 if context.is_empty() {
1343 context.push_str("No specific workflow context detected. Continue assisting the user with their current task.\n");
1344 }
1345
1346 context
1347 }
1348
1349 /// Extract file path from tool input JSON
1350 fn extract_path_from_input(input: &serde_json::Value) -> Option<String> {
1351 // Try common path field names
1352 for key in ["path", "file", "file_path", "filename"] {
1353 if let Some(path) = input.get(key).and_then(|v| v.as_str()) {
1354 return Some(path.to_string());
1355 }
1356 }
1357
1358 // Try to find path in nested objects
1359 if let Some(obj) = input.as_object() {
1360 for (_, value) in obj {
1361 if let Some(path) = value.as_str()
1362 && (path.contains('/') || path.contains('\\') || path.contains('.'))
1363 {
1364 return Some(path.to_string());
1365 }
1366 }
1367 }
1368
1369 None
1370 }
1371
1372 pub fn merge_system_prompts(
1373 original: Option<&SystemPrompt>,
1374 summary: Option<SystemPrompt>,
1375 ) -> Option<SystemPrompt> {
1376 match (original, summary) {
1377 (None, None) => None,
1378 (Some(orig), None) => Some(orig.clone()),
1379 (None, Some(sum)) => Some(sum),
1380 (Some(SystemPrompt::Text(orig_text)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
1381 // Prepend original system prompt
1382 sum_blocks.insert(
1383 0,
1384 SystemBlock {
1385 block_type: "text".to_string(),
1386 text: orig_text.clone(),
1387 cache_control: None,
1388 },
1389 );
1390 Some(SystemPrompt::Blocks(sum_blocks))
1391 }
1392 (Some(SystemPrompt::Blocks(orig_blocks)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
1393 // Prepend original blocks
1394 for (i, block) in orig_blocks.iter().enumerate() {
1395 sum_blocks.insert(i, block.clone());
1396 }
1397 Some(SystemPrompt::Blocks(sum_blocks))
1398 }
1399 (Some(orig), Some(SystemPrompt::Text(sum_text))) => {
1400 let mut blocks = match orig {
1401 SystemPrompt::Text(t) => vec![SystemBlock {
1402 block_type: "text".to_string(),
1403 text: t.clone(),
1404 cache_control: None,
1405 }],
1406 SystemPrompt::Blocks(b) => b.clone(),
1407 };
1408 blocks.push(SystemBlock {
1409 block_type: "text".to_string(),
1410 text: sum_text,
1411 cache_control: None,
1412 });
1413 Some(SystemPrompt::Blocks(blocks))
1414 }
1415 }
1416 }
1417
1418 #[cfg(test)]
1419 mod tests {
1420 use super::*;
1421 use serde_json::json;
1422
1423 fn msg(role: &str, text: &str) -> Message {
1424 Message {
1425 role: role.to_string(),
1426 content: vec![ContentBlock::Text {
1427 text: text.to_string(),
1428 cache_control: None,
1429 }],
1430 }
1431 }
1432
1433 fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
1434 Message {
1435 role: "assistant".to_string(),
1436 content: vec![ContentBlock::ToolUse {
1437 id: id.to_string(),
1438 name: name.to_string(),
1439 input,
1440 caller: None,
1441 }],
1442 }
1443 }
1444
1445 fn tool_result(id: &str, content: &str) -> Message {
1446 Message {
1447 role: "user".to_string(),
1448 content: vec![ContentBlock::ToolResult {
1449 tool_use_id: id.to_string(),
1450 content: content.to_string(),
1451 is_error: None,
1452 content_blocks: None,
1453 }],
1454 }
1455 }
1456
1457 #[test]
1458 fn truncate_chars_respects_unicode_boundaries() {
1459 let text = "abc😀é";
1460 assert_eq!(truncate_chars(text, 0), "");
1461 assert_eq!(truncate_chars(text, 1), "a");
1462 assert_eq!(truncate_chars(text, 3), "abc");
1463 assert_eq!(truncate_chars(text, 4), "abc😀");
1464 assert_eq!(truncate_chars(text, 5), "abc😀é");
1465 }
1466
1467 #[test]
1468 fn prune_tool_results_summarizes_old_verbose_outputs() {
1469 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
1470 let mut messages = vec![
1471 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1472 tool_result("call-1", &verbose),
1473 msg("user", "recent question"),
1474 msg("assistant", "recent answer"),
1475 ];
1476
1477 let saved = prune_tool_results(&mut messages, 2);
1478
1479 assert!(saved > 0);
1480 let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else {
1481 panic!("expected tool result");
1482 };
1483 assert!(content.contains("[read_file] tool result pruned"));
1484 assert!(content.contains("Cargo.toml"));
1485 assert!(content.len() < verbose.len());
1486 }
1487
1488 #[test]
1489 fn prune_tool_results_preserves_protected_tail() {
1490 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
1491 let mut messages = vec![
1492 msg("user", "older context"),
1493 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1494 tool_result("call-1", &verbose),
1495 ];
1496
1497 let saved = prune_tool_results(&mut messages, 2);
1498
1499 assert_eq!(saved, 0);
1500 let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else {
1501 panic!("expected tool result");
1502 };
1503 assert_eq!(content, &verbose);
1504 }
1505
1506 #[test]
1507 fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() {
1508 let first = "first ".repeat(80);
1509 let second = "second ".repeat(80);
1510 let mut messages = vec![
1511 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1512 tool_result("call-1", &first),
1513 tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
1514 tool_result("call-2", &second),
1515 msg("user", "tail"),
1516 ];
1517
1518 let saved = prune_tool_results(&mut messages, 1);
1519
1520 assert!(saved > 0);
1521 let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else {
1522 panic!("expected older tool result");
1523 };
1524 assert!(older.contains("tool result pruned"));
1525 let ContentBlock::ToolResult {
1526 content: latest, ..
1527 } = &messages[3].content[0]
1528 else {
1529 panic!("expected latest tool result");
1530 };
1531 assert_eq!(latest, &second);
1532 }
1533
1534 #[test]
1535 fn is_transient_error_detects_network_issues() {
1536 let timeout_err = anyhow::anyhow!("Connection timeout");
1537 assert!(is_transient_error(&timeout_err));
1538
1539 let rate_limit_err = anyhow::anyhow!("429 Too Many Requests");
1540 assert!(is_transient_error(&rate_limit_err));
1541
1542 let service_err = anyhow::anyhow!("503 Service Unavailable");
1543 assert!(is_transient_error(&service_err));
1544
1545 let network_err = anyhow::anyhow!("network error: connection refused");
1546 assert!(is_transient_error(&network_err));
1547 }
1548
1549 #[test]
1550 fn is_transient_error_rejects_permanent_errors() {
1551 let auth_err = anyhow::anyhow!("401 Unauthorized: Invalid API key");
1552 assert!(!is_transient_error(&auth_err));
1553
1554 let parse_err = anyhow::anyhow!("Failed to parse JSON response");
1555 assert!(!is_transient_error(&parse_err));
1556
1557 let validation_err = anyhow::anyhow!("Invalid request: missing required field");
1558 assert!(!is_transient_error(&validation_err));
1559 }
1560
1561 #[test]
1562 fn summary_limits_expand_for_v4_context() {
1563 let legacy = summary_input_limits_for_model("deepseek-v3.2-128k");
1564 let v4 = summary_input_limits_for_model("deepseek-v4-pro");
1565
1566 assert!(v4.input_max_chars > legacy.input_max_chars);
1567 assert!(v4.tool_result_snippet_chars > legacy.tool_result_snippet_chars);
1568 assert!(v4.max_tokens > legacy.max_tokens);
1569 }
1570
1571 #[test]
1572 fn cache_aligned_summary_is_used_for_v4_scale_contexts() {
1573 let messages = vec![msg("user", "Please edit crates/tui/src/compaction.rs")];
1574
1575 assert!(should_use_cache_aligned_summary(
1576 "deepseek-v4-flash",
1577 &messages
1578 ));
1579 assert!(!should_use_cache_aligned_summary(
1580 "deepseek-v3.2-128k",
1581 &messages
1582 ));
1583 }
1584
1585 /// #584: the summary cache-hit percentage must be computed against
1586 /// `input_tokens`, not `cache_hit + cache_miss`. Providers that
1587 /// only populate `prompt_cache_hit_tokens` (and leave the miss
1588 /// field at `None`) would otherwise be reported as a flat 100%
1589 /// hit rate even when most of the prompt was uncached.
1590 #[test]
1591 fn summary_cache_hit_percent_uses_input_tokens_as_denominator() {
1592 // Both fields populated and consistent.
1593 assert!((summary_cache_hit_percent(800, 1000) - 80.0).abs() < f64::EPSILON);
1594 // No cache hit at all.
1595 assert!((summary_cache_hit_percent(0, 1000) - 0.0).abs() < f64::EPSILON);
1596 // Full cache hit.
1597 assert!((summary_cache_hit_percent(1000, 1000) - 100.0).abs() < f64::EPSILON);
1598 // Partial-telemetry guard: provider reports `cache_hit` only,
1599 // miss is unknown (treated as 0 by the caller). Naive
1600 // `hit / (hit + miss)` would have reported 100%; against
1601 // `input_tokens` the answer is the real share.
1602 assert!((summary_cache_hit_percent(200, 1000) - 20.0).abs() < f64::EPSILON);
1603 // Defensive: zero `input_tokens` short-circuits without a
1604 // divide-by-zero.
1605 assert!((summary_cache_hit_percent(0, 0) - 0.0).abs() < f64::EPSILON);
1606 assert!((summary_cache_hit_percent(50, 0) - 0.0).abs() < f64::EPSILON);
1607 }
1608
1609 #[test]
1610 fn cache_aligned_summary_request_preserves_message_prefix() {
1611 let messages = vec![
1612 msg("user", "Please edit crates/tui/src/compaction.rs"),
1613 msg("assistant", "I will inspect the file."),
1614 ];
1615 let limits = summary_input_limits_for_model("deepseek-v4-pro");
1616 let request = build_cache_aligned_summary_request("deepseek-v4-pro", &messages, limits);
1617
1618 assert_eq!(request.system, None);
1619 assert_eq!(&request.messages[..messages.len()], &messages[..]);
1620 assert_eq!(request.messages.len(), messages.len() + 1);
1621 let last = request.messages.last().expect("summary instruction");
1622 assert_eq!(last.role, "user");
1623 assert!(matches!(
1624 &last.content[..],
1625 [ContentBlock::Text { text, .. }] if text.contains("conversation above")
1626 ));
1627 }
1628
1629 #[test]
1630 fn estimate_tokens_empty_messages() {
1631 let messages: Vec<Message> = vec![];
1632 assert_eq!(estimate_tokens(&messages), 0);
1633 }
1634
1635 #[test]
1636 fn estimate_tokens_with_text() {
1637 let messages = vec![Message {
1638 role: "user".to_string(),
1639 content: vec![ContentBlock::Text {
1640 text: "Hello, world!".to_string(), // 13 chars = ~3 tokens
1641 cache_control: None,
1642 }],
1643 }];
1644 let tokens = estimate_tokens(&messages);
1645 assert!(tokens > 0 && tokens < 10);
1646 }
1647
1648 #[test]
1649 fn estimate_tokens_counts_tool_round_thinking_across_turns() {
1650 // Per DeepSeek thinking-mode rules, any assistant message that
1651 // performed a tool call keeps its reasoning_content in the request
1652 // forever, including across new user turns. Token estimates must
1653 // count those bytes.
1654 let thinking = "reasoning ".repeat(800);
1655 let current_messages = vec![
1656 Message {
1657 role: "user".to_string(),
1658 content: vec![ContentBlock::Text {
1659 text: "Use a tool".to_string(),
1660 cache_control: None,
1661 }],
1662 },
1663 Message {
1664 role: "assistant".to_string(),
1665 content: vec![
1666 ContentBlock::Thinking {
1667 thinking: thinking.clone(),
1668 },
1669 ContentBlock::ToolUse {
1670 id: "tool-1".to_string(),
1671 name: "read_file".to_string(),
1672 input: serde_json::json!({"path": "Cargo.toml"}),
1673 caller: None,
1674 },
1675 ],
1676 },
1677 Message {
1678 role: "user".to_string(),
1679 content: vec![ContentBlock::ToolResult {
1680 tool_use_id: "tool-1".to_string(),
1681 content: "manifest".to_string(),
1682 is_error: None,
1683 content_blocks: None,
1684 }],
1685 },
1686 ];
1687 let historical_messages = {
1688 let mut messages = current_messages.clone();
1689 messages.push(Message {
1690 role: "assistant".to_string(),
1691 content: vec![ContentBlock::Text {
1692 text: "Done.".to_string(),
1693 cache_control: None,
1694 }],
1695 });
1696 messages.push(Message {
1697 role: "user".to_string(),
1698 content: vec![ContentBlock::Text {
1699 text: "Next question.".to_string(),
1700 cache_control: None,
1701 }],
1702 });
1703 messages
1704 };
1705 let completed_messages = {
1706 let mut messages = current_messages.clone();
1707 messages.push(Message {
1708 role: "assistant".to_string(),
1709 content: vec![ContentBlock::Text {
1710 text: "Done.".to_string(),
1711 cache_control: None,
1712 }],
1713 });
1714 messages
1715 };
1716
1717 let lower_bound = thinking.len() / 5;
1718 assert!(estimate_tokens(&current_messages) > lower_bound);
1719 assert!(estimate_tokens(&completed_messages) > lower_bound);
1720 assert!(estimate_tokens(&historical_messages) > lower_bound);
1721 }
1722
1723 #[test]
1724 fn should_compact_respects_enabled_flag() {
1725 let config = CompactionConfig {
1726 enabled: false,
1727 ..Default::default()
1728 };
1729 // Even with many messages, disabled compaction should return false
1730 let messages: Vec<Message> = (0..100)
1731 .map(|_| Message {
1732 role: "user".to_string(),
1733 content: vec![ContentBlock::Text {
1734 text: "test".to_string(),
1735 cache_control: None,
1736 }],
1737 })
1738 .collect();
1739 assert!(!should_compact(&messages, &config, None, None, None));
1740 }
1741
1742 /// v0.8.11: message-count is no longer a compaction trigger. Long
1743 /// chats of small messages stay uncompacted because rewriting the V4
1744 /// prefix cache for a tiny budget reclaim is net-negative. Only token
1745 /// pressure (and the explicit `/compact` slash command) trigger
1746 /// compaction.
1747 #[test]
1748 fn message_count_no_longer_triggers_compaction() {
1749 let config = CompactionConfig {
1750 enabled: true,
1751 token_threshold: 1_000_000,
1752 auto_floor_tokens: 0,
1753 ..Default::default()
1754 };
1755
1756 // 200 tiny messages, well above the prior message threshold.
1757 let many_messages: Vec<Message> = (0..200)
1758 .map(|_| Message {
1759 role: "user".to_string(),
1760 content: vec![ContentBlock::Text {
1761 text: "x".to_string(),
1762 cache_control: None,
1763 }],
1764 })
1765 .collect();
1766 // Token total stays minuscule so the token threshold is not hit;
1767 // without the prior message-count trigger, no compaction.
1768 assert!(!should_compact(&many_messages, &config, None, None, None));
1769 }
1770
1771 #[test]
1772 fn plan_compaction_pins_recent_and_working_set_paths() {
1773 let messages = vec![
1774 msg("user", "General discussion"),
1775 msg("assistant", "Unrelated note"),
1776 msg("user", "Earlier we touched src/core/engine.rs"),
1777 msg("assistant", "More unrelated chatter"),
1778 msg("user", "Let's keep working on src/core/engine.rs"),
1779 msg("assistant", "Tool output mentions src/core/engine.rs too"),
1780 msg("assistant", "Recent reasoning"),
1781 msg("user", "Final recent instruction"),
1782 ];
1783
1784 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
1785
1786 assert!(plan.pinned_indices.contains(&2));
1787 for idx in 4..messages.len() {
1788 assert!(plan.pinned_indices.contains(&idx));
1789 }
1790 assert!(plan.summarize_indices.contains(&0));
1791 assert!(plan.summarize_indices.contains(&1));
1792 assert!(plan.summarize_indices.contains(&3));
1793 }
1794
1795 #[test]
1796 fn plan_compaction_respects_external_pins() {
1797 let messages = vec![
1798 msg("user", "noise 0"),
1799 msg("assistant", "noise 1"),
1800 msg("user", "noise 2"),
1801 msg("assistant", "noise 3"),
1802 msg("user", "recent 4"),
1803 msg("assistant", "recent 5"),
1804 msg("assistant", "recent 6"),
1805 msg("user", "recent 7"),
1806 ];
1807
1808 let pins = vec![1usize];
1809 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, Some(&pins), None);
1810
1811 assert!(plan.pinned_indices.contains(&1));
1812 assert!(!plan.summarize_indices.contains(&1));
1813 }
1814
1815 #[test]
1816 fn plan_compaction_uses_external_working_set_paths() {
1817 let mut messages = vec![msg("user", "edit src/core/engine.rs now")];
1818 messages.extend((1..20).map(|i| msg("assistant", &format!("noise {i}"))));
1819
1820 let working_set_paths = vec!["src/core/engine.rs".to_string()];
1821 let plan = plan_compaction(
1822 &messages,
1823 None,
1824 KEEP_RECENT_MESSAGES,
1825 None,
1826 Some(&working_set_paths),
1827 );
1828
1829 assert!(plan.pinned_indices.contains(&0));
1830 }
1831
1832 #[test]
1833 fn plan_compaction_pins_tool_calls_for_tool_results() {
1834 let messages = vec![
1835 msg("user", "noise"),
1836 Message {
1837 role: "assistant".to_string(),
1838 content: vec![ContentBlock::ToolUse {
1839 id: "tool-1".to_string(),
1840 name: "read_file".to_string(),
1841 input: json!({"path": "src/main.rs"}),
1842 caller: None,
1843 }],
1844 },
1845 Message {
1846 role: "user".to_string(),
1847 content: vec![ContentBlock::ToolResult {
1848 tool_use_id: "tool-1".to_string(),
1849 content: "ok src/main.rs".to_string(),
1850 is_error: None,
1851 content_blocks: None,
1852 }],
1853 },
1854 ];
1855
1856 let plan = plan_compaction(&messages, None, 1, None, None);
1857 assert!(plan.pinned_indices.contains(&2));
1858 assert!(plan.pinned_indices.contains(&1));
1859 }
1860
1861 #[test]
1862 fn should_compact_ignores_fully_pinned_context() {
1863 let config = CompactionConfig {
1864 enabled: true,
1865 token_threshold: 10,
1866 ..Default::default()
1867 };
1868
1869 let messages: Vec<Message> = (0..12)
1870 .map(|_| msg("user", "Work on src/compaction.rs right now"))
1871 .collect();
1872
1873 assert!(!should_compact(&messages, &config, None, None, None));
1874 }
1875
1876 // v0.8.11: removed `should_compact_counts_only_unpinned_messages` and
1877 // `should_compact_when_pins_consume_budget` — both tested the
1878 // message-count compaction trigger that v0.8.11 deleted. The
1879 // pinned-tokens accounting they exercised is still tested by
1880 // `should_compact_ignores_fully_pinned_context` below; the rest of
1881 // their setup has no contemporary contract to pin.
1882
1883 #[test]
1884 fn enforce_tool_call_pairs_removes_orphaned_tool_call() {
1885 // An assistant message with a tool call but no matching result anywhere
1886 // in the history should be removed from the pinned set.
1887 let messages = vec![
1888 msg("user", "noise"),
1889 Message {
1890 role: "assistant".to_string(),
1891 content: vec![ContentBlock::ToolUse {
1892 id: "orphan-call".to_string(),
1893 name: "read_file".to_string(),
1894 input: json!({"path": "src/main.rs"}),
1895 caller: None,
1896 }],
1897 },
1898 msg("assistant", "recent"),
1899 ];
1900
1901 let mut pinned = BTreeSet::from([0, 1, 2]);
1902 enforce_tool_call_pairs(&messages, &mut pinned);
1903
1904 // The orphaned tool call message (index 1) should be removed.
1905 assert!(
1906 !pinned.contains(&1),
1907 "orphaned tool call should be removed from pinned set"
1908 );
1909 // Other messages stay.
1910 assert!(pinned.contains(&0));
1911 assert!(pinned.contains(&2));
1912 }
1913
1914 #[test]
1915 fn enforce_tool_call_pairs_removes_orphaned_tool_result() {
1916 // A tool result whose call doesn't exist anywhere should be removed.
1917 let messages = vec![
1918 msg("user", "noise"),
1919 Message {
1920 role: "user".to_string(),
1921 content: vec![ContentBlock::ToolResult {
1922 tool_use_id: "orphan-result".to_string(),
1923 content: "ok".to_string(),
1924 is_error: None,
1925 content_blocks: None,
1926 }],
1927 },
1928 msg("assistant", "recent"),
1929 ];
1930
1931 let mut pinned = BTreeSet::from([0, 1, 2]);
1932 enforce_tool_call_pairs(&messages, &mut pinned);
1933
1934 assert!(
1935 !pinned.contains(&1),
1936 "orphaned tool result should be removed from pinned set"
1937 );
1938 assert!(pinned.contains(&0));
1939 assert!(pinned.contains(&2));
1940 }
1941
1942 #[test]
1943 fn enforce_tool_call_pairs_preserves_valid_pairs() {
1944 // A complete call+result pair should remain intact.
1945 let messages = vec![
1946 msg("user", "do something"),
1947 Message {
1948 role: "assistant".to_string(),
1949 content: vec![ContentBlock::ToolUse {
1950 id: "tool-ok".to_string(),
1951 name: "list_dir".to_string(),
1952 input: json!({}),
1953 caller: None,
1954 }],
1955 },
1956 Message {
1957 role: "user".to_string(),
1958 content: vec![ContentBlock::ToolResult {
1959 tool_use_id: "tool-ok".to_string(),
1960 content: "files here".to_string(),
1961 is_error: None,
1962 content_blocks: None,
1963 }],
1964 },
1965 msg("assistant", "done"),
1966 ];
1967
1968 let mut pinned = BTreeSet::from([1, 2, 3]);
1969 enforce_tool_call_pairs(&messages, &mut pinned);
1970
1971 assert!(pinned.contains(&1), "tool call should stay pinned");
1972 assert!(pinned.contains(&2), "tool result should stay pinned");
1973 assert!(pinned.contains(&3));
1974 }
1975
1976 #[test]
1977 fn enforce_tool_call_pairs_pins_transitive_pairs() {
1978 // If only the result is initially pinned, the call should be pulled in.
1979 // The call message may also contain another tool call whose result should
1980 // then be pulled in transitively.
1981 let messages = vec![
1982 msg("user", "start"),
1983 Message {
1984 role: "assistant".to_string(),
1985 content: vec![
1986 ContentBlock::ToolUse {
1987 id: "t1".to_string(),
1988 name: "read_file".to_string(),
1989 input: json!({"path": "a.rs"}),
1990 caller: None,
1991 },
1992 ContentBlock::ToolUse {
1993 id: "t2".to_string(),
1994 name: "read_file".to_string(),
1995 input: json!({"path": "b.rs"}),
1996 caller: None,
1997 },
1998 ],
1999 },
2000 Message {
2001 role: "user".to_string(),
2002 content: vec![ContentBlock::ToolResult {
2003 tool_use_id: "t1".to_string(),
2004 content: "content of a.rs".to_string(),
2005 is_error: None,
2006 content_blocks: None,
2007 }],
2008 },
2009 Message {
2010 role: "user".to_string(),
2011 content: vec![ContentBlock::ToolResult {
2012 tool_use_id: "t2".to_string(),
2013 content: "content of b.rs".to_string(),
2014 is_error: None,
2015 content_blocks: None,
2016 }],
2017 },
2018 msg("assistant", "done"),
2019 ];
2020
2021 // Only pin the result for t1 initially.
2022 let mut pinned = BTreeSet::from([2, 4]);
2023 enforce_tool_call_pairs(&messages, &mut pinned);
2024
2025 // The call message (index 1) should be pulled in because t1's result is pinned.
2026 assert!(
2027 pinned.contains(&1),
2028 "call message should be transitively pinned"
2029 );
2030 // Since the call message also contains t2, t2's result (index 3) should also be pinned.
2031 assert!(
2032 pinned.contains(&3),
2033 "t2 result should be transitively pinned via the call message"
2034 );
2035 }
2036
2037 #[test]
2038 fn enforce_tool_call_pairs_cascading_removal() {
2039 // Removing an orphaned call should cascade to remove its result.
2040 // Message 1: assistant with t1 (call) — t1 has a result at index 2
2041 // Message 2: user with t1 (result)
2042 // Message 3: assistant with t2 (call) — t2 has NO result
2043 // Message 4: user with t2 result referencing the call
2044 //
2045 // If t2 has no result in history, message 3 is removed. That's straightforward.
2046 // Here we test: if a call message is removed because ONE of its calls is orphaned,
2047 // the result for the other call also gets removed in subsequent iterations.
2048 let messages = vec![
2049 msg("user", "start"),
2050 Message {
2051 role: "assistant".to_string(),
2052 content: vec![
2053 ContentBlock::ToolUse {
2054 id: "good".to_string(),
2055 name: "read_file".to_string(),
2056 input: json!({}),
2057 caller: None,
2058 },
2059 ContentBlock::ToolUse {
2060 id: "orphan".to_string(),
2061 name: "shell".to_string(),
2062 input: json!({}),
2063 caller: None,
2064 },
2065 ],
2066 },
2067 Message {
2068 role: "user".to_string(),
2069 content: vec![ContentBlock::ToolResult {
2070 tool_use_id: "good".to_string(),
2071 content: "ok".to_string(),
2072 is_error: None,
2073 content_blocks: None,
2074 }],
2075 },
2076 // Note: NO result for "orphan" exists anywhere
2077 msg("assistant", "done"),
2078 ];
2079
2080 let mut pinned = BTreeSet::from([1, 2, 3]);
2081 enforce_tool_call_pairs(&messages, &mut pinned);
2082
2083 // Message 1 has an orphaned tool call ("orphan"), so it's removed.
2084 assert!(
2085 !pinned.contains(&1),
2086 "message with orphaned call should be removed"
2087 );
2088 // Message 2 (result for "good") now has no matching call pinned, so it's also removed.
2089 assert!(
2090 !pinned.contains(&2),
2091 "result whose call was removed should cascade-remove"
2092 );
2093 // Message 3 (plain text) stays.
2094 assert!(pinned.contains(&3));
2095 }
2096
2097 #[test]
2098 fn enforce_tool_call_pairs_converges_long_chain() {
2099 let mut messages = vec![msg("user", "start")];
2100 for i in 0..15 {
2101 messages.push(Message {
2102 role: "assistant".to_string(),
2103 content: vec![ContentBlock::ToolUse {
2104 id: format!("t{i}"),
2105 name: "read_file".to_string(),
2106 input: json!({}),
2107 caller: None,
2108 }],
2109 });
2110 messages.push(Message {
2111 role: "user".to_string(),
2112 content: vec![ContentBlock::ToolResult {
2113 tool_use_id: format!("t{i}"),
2114 content: format!("result {i}"),
2115 is_error: None,
2116 content_blocks: None,
2117 }],
2118 });
2119 }
2120 messages.push(msg("assistant", "done"));
2121
2122 let mut pinned: BTreeSet<usize> = (0..messages.len()).collect();
2123 enforce_tool_call_pairs(&messages, &mut pinned);
2124
2125 // All pairs should remain intact (no orphans)
2126 assert_eq!(pinned.len(), messages.len());
2127 }
2128
2129 // ========================================================================
2130 // Additional Compaction Trigger Tests
2131 // ========================================================================
2132
2133 #[test]
2134 fn test_should_compact_token_threshold_triggers() {
2135 let config = CompactionConfig {
2136 enabled: true,
2137 token_threshold: 100, // Low threshold for testing
2138 auto_floor_tokens: 0,
2139 ..Default::default()
2140 };
2141
2142 // Create messages that exceed token threshold
2143 let messages: Vec<Message> = (0..10)
2144 .map(|_| msg("user", &"x".repeat(50))) // 50 chars = ~12 tokens each
2145 .collect();
2146
2147 // Total tokens: ~120, which exceeds 100
2148 assert!(should_compact(&messages, &config, None, None, None));
2149 }
2150
2151 #[test]
2152 fn test_should_compact_below_token_threshold() {
2153 let config = CompactionConfig {
2154 enabled: true,
2155 token_threshold: 1000,
2156 ..Default::default()
2157 };
2158
2159 // Create short messages
2160 let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
2161
2162 assert!(!should_compact(&messages, &config, None, None, None));
2163 }
2164
2165 /// v0.8.11: the 500K hard floor blocks auto-compaction even when the
2166 /// token-percentage threshold would otherwise fire. This is the V4
2167 /// prefix-cache protection — below 500K total tokens, rewriting the
2168 /// prefix loses cache for tiny budget gains.
2169 #[test]
2170 fn auto_compaction_floor_blocks_below_500k_even_when_threshold_says_yes() {
2171 let config = CompactionConfig {
2172 enabled: true,
2173 token_threshold: 100, // would normally fire instantly
2174 // Use the production default explicitly so this test pins the
2175 // floor's contract rather than relying on `Default`.
2176 auto_floor_tokens: MINIMUM_AUTO_COMPACTION_TOKENS,
2177 ..Default::default()
2178 };
2179
2180 let messages: Vec<Message> = (0..10).map(|_| msg("user", &"x".repeat(50))).collect();
2181 // Total tokens way under 500K, so floor blocks compaction.
2182 assert!(!should_compact(&messages, &config, None, None, None));
2183 }
2184
2185 /// v0.8.11: when total tokens cross the 500K floor, the existing
2186 /// threshold/message-count logic takes over again.
2187 #[test]
2188 fn auto_compaction_floor_yields_to_threshold_logic_above_500k() {
2189 let config = CompactionConfig {
2190 enabled: true,
2191 token_threshold: 2_000_000,
2192 auto_floor_tokens: MINIMUM_AUTO_COMPACTION_TOKENS,
2193 ..Default::default()
2194 };
2195
2196 // Each message ~500 tokens; 1100 messages → ~550K total tokens.
2197 // That's above the floor (500K) AND below the deliberately high
2198 // token_threshold, so auto-compaction stays off — by threshold,
2199 // not floor.
2200 let messages: Vec<Message> = (0..1100).map(|_| msg("user", &"x".repeat(2000))).collect();
2201 assert!(!should_compact(&messages, &config, None, None, None));
2202
2203 // Crank threshold below total → compaction fires now that we're
2204 // past the floor.
2205 let config_lower = CompactionConfig {
2206 token_threshold: 100_000,
2207 ..config
2208 };
2209 assert!(should_compact(&messages, &config_lower, None, None, None));
2210 }
2211
2212 /// `CompactionConfig::default()` ships with the 500K floor on by
2213 /// default — production callers via `..Default::default()` get the
2214 /// safety guarantee automatically.
2215 #[test]
2216 fn compaction_config_default_carries_500k_floor() {
2217 let config = CompactionConfig::default();
2218 assert_eq!(config.auto_floor_tokens, MINIMUM_AUTO_COMPACTION_TOKENS);
2219 assert_eq!(config.auto_floor_tokens, 500_000);
2220 }
2221
2222 #[test]
2223 fn test_plan_compaction_pins_error_messages() {
2224 let messages = vec![
2225 msg("user", "normal message"),
2226 msg("assistant", "error: compilation failed"),
2227 msg("user", "another message"),
2228 msg("assistant", "panic at src/main.rs:42"),
2229 msg("user", "more chat"),
2230 msg("assistant", "Traceback (most recent call last):"),
2231 msg("user", "recent 1"),
2232 msg("assistant", "recent 2"),
2233 ];
2234
2235 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
2236
2237 // Error messages should be pinned
2238 assert!(plan.pinned_indices.contains(&1)); // error:
2239 assert!(plan.pinned_indices.contains(&3)); // panic
2240 assert!(plan.pinned_indices.contains(&5)); // traceback
2241 }
2242
2243 #[test]
2244 fn test_plan_compaction_pins_patch_messages() {
2245 let messages = vec![
2246 msg("user", "normal chat"),
2247 msg("assistant", "diff --git a/src/main.rs b/src/main.rs"),
2248 msg("user", "more chat"),
2249 msg("assistant", "+++ b/src/core.rs"),
2250 msg("user", "chat"),
2251 msg("assistant", "```diff\n-some code\n+new code\n```"),
2252 msg("user", "recent 1"),
2253 msg("assistant", "recent 2"),
2254 ];
2255
2256 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
2257
2258 // Patch/diff messages should be pinned
2259 assert!(plan.pinned_indices.contains(&1)); // diff --git
2260 assert!(plan.pinned_indices.contains(&3)); // +++ b/
2261 assert!(plan.pinned_indices.contains(&5)); // ```diff
2262 }
2263
2264 #[test]
2265 fn test_plan_compaction_pins_apply_patch_tool_calls() {
2266 let messages = vec![
2267 msg("user", "normal chat"),
2268 Message {
2269 role: "assistant".to_string(),
2270 content: vec![ContentBlock::ToolUse {
2271 id: "patch-1".to_string(),
2272 name: "apply_patch".to_string(),
2273 input: json!({"patch": "diff content"}),
2274 caller: None,
2275 }],
2276 },
2277 Message {
2278 role: "user".to_string(),
2279 content: vec![ContentBlock::ToolResult {
2280 tool_use_id: "patch-1".to_string(),
2281 content: "Patch applied successfully".to_string(),
2282 is_error: None,
2283 content_blocks: None,
2284 }],
2285 },
2286 msg("assistant", "more chat"),
2287 msg("user", "even more"),
2288 msg("assistant", "recent 1"),
2289 msg("user", "recent 2"),
2290 msg("assistant", "recent 3"),
2291 ];
2292
2293 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
2294
2295 // Message 1 contains apply_patch tool call with matching result (message 2)
2296 // Both should be pinned due to tool call pairing
2297 // Messages 5, 6, 7, 8 are recent (last 4 messages)
2298 eprintln!("Pinned indices: {:?}", plan.pinned_indices);
2299
2300 // apply_patch tool call and its result should be pinned
2301 assert!(
2302 plan.pinned_indices.contains(&1),
2303 "apply_patch tool call should be pinned"
2304 );
2305 assert!(
2306 plan.pinned_indices.contains(&2),
2307 "apply_patch tool result should be pinned"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_extract_paths_from_text_finds_various_formats() {
2313 let text = r#"
2314 I'm working on src/main.rs
2315 Also check Cargo.toml
2316 The error is in src/core/engine.rs:42
2317 See docs/API.md for details
2318 Config at config.example.toml
2319 "#;
2320
2321 let paths = extract_paths_from_text(text, None);
2322
2323 assert!(paths.iter().any(|p| p == "src/main.rs"));
2324 assert!(paths.iter().any(|p| p == "Cargo.toml"));
2325 assert!(paths.iter().any(|p| p == "src/core/engine.rs"));
2326 assert!(paths.iter().any(|p| p == "docs/API.md"));
2327 assert!(paths.iter().any(|p| p == "config.example.toml"));
2328 }
2329
2330 #[test]
2331 fn test_extract_paths_from_tool_input_finds_path_field() {
2332 let input = json!({
2333 "path": "src/main.rs",
2334 "content": "test"
2335 });
2336
2337 let paths = extract_paths_from_tool_input(&input, None);
2338 assert!(paths.iter().any(|p| p == "src/main.rs"));
2339 }
2340
2341 #[test]
2342 fn test_extract_paths_from_tool_input_finds_paths_array() {
2343 let input = json!({
2344 "paths": ["src/main.rs", "src/core.rs", "tests/test.rs"]
2345 });
2346
2347 let paths = extract_paths_from_tool_input(&input, None);
2348 assert_eq!(paths.len(), 3);
2349 assert!(paths.iter().any(|p| p == "src/main.rs"));
2350 assert!(paths.iter().any(|p| p == "src/core.rs"));
2351 assert!(paths.iter().any(|p| p == "tests/test.rs"));
2352 }
2353
2354 #[test]
2355 fn test_extract_paths_from_tool_input_finds_cwd() {
2356 let input = json!({
2357 "cwd": "src/core",
2358 "command": "cargo build"
2359 });
2360
2361 let paths = extract_paths_from_tool_input(&input, None);
2362 assert!(paths.iter().any(|p| p == "src/core"));
2363 }
2364
2365 #[test]
2366 fn test_normalize_path_candidate_handles_absolute_paths() {
2367 use std::env;
2368 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2369
2370 // Create an absolute path
2371 let absolute_path = current_dir.join("src/main.rs");
2372 let absolute_path_str = absolute_path.to_string_lossy();
2373
2374 let normalized = normalize_path_candidate(&absolute_path_str, Some(&current_dir));
2375
2376 assert_eq!(normalized, Some("src/main.rs".to_string()));
2377 }
2378
2379 #[test]
2380 fn test_normalize_path_candidate_rejects_parent_refs() {
2381 let normalized = normalize_path_candidate("../outside/file.rs", Some(&PathBuf::from(".")));
2382 assert_eq!(normalized, None);
2383 }
2384
2385 #[test]
2386 fn test_normalize_path_candidate_cleans_backslashes() {
2387 let normalized = normalize_path_candidate("src\\main.rs", Some(&PathBuf::from(".")));
2388 assert_eq!(normalized, Some("src/main.rs".to_string()));
2389 }
2390
2391 #[test]
2392 fn test_merge_system_prompts_none_none() {
2393 let result = merge_system_prompts(None, None);
2394 assert!(result.is_none());
2395 }
2396
2397 #[test]
2398 fn test_merge_system_prompts_some_text_none() {
2399 let original = Some(SystemPrompt::Text("original".to_string()));
2400 let result = merge_system_prompts(original.as_ref(), None);
2401 assert!(matches!(result, Some(SystemPrompt::Text(s)) if s == "original"));
2402 }
2403
2404 #[test]
2405 fn test_merge_system_prompts_none_some_blocks() {
2406 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
2407 block_type: "text".to_string(),
2408 text: "summary".to_string(),
2409 cache_control: None,
2410 }]));
2411 let result = merge_system_prompts(None, summary);
2412 assert!(matches!(result, Some(SystemPrompt::Blocks(b)) if b.len() == 1));
2413 }
2414
2415 #[test]
2416 fn test_merge_system_prompts_text_plus_blocks() {
2417 let original = Some(SystemPrompt::Text("original".to_string()));
2418 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
2419 block_type: "text".to_string(),
2420 text: "summary".to_string(),
2421 cache_control: None,
2422 }]));
2423
2424 let result = merge_system_prompts(original.as_ref(), summary);
2425
2426 match result {
2427 Some(SystemPrompt::Blocks(blocks)) => {
2428 assert_eq!(blocks.len(), 2);
2429 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
2430 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
2431 }
2432 _ => panic!("Expected Blocks"),
2433 }
2434 }
2435
2436 #[test]
2437 fn test_merge_system_prompts_blocks_plus_blocks() {
2438 let original = Some(SystemPrompt::Blocks(vec![
2439 SystemBlock {
2440 block_type: "text".to_string(),
2441 text: "orig1".to_string(),
2442 cache_control: None,
2443 },
2444 SystemBlock {
2445 block_type: "text".to_string(),
2446 text: "orig2".to_string(),
2447 cache_control: None,
2448 },
2449 ]));
2450
2451 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
2452 block_type: "text".to_string(),
2453 text: "summary".to_string(),
2454 cache_control: None,
2455 }]));
2456
2457 let result = merge_system_prompts(original.as_ref(), summary);
2458
2459 match result {
2460 Some(SystemPrompt::Blocks(blocks)) => {
2461 assert_eq!(blocks.len(), 3);
2462 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "orig1"));
2463 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "orig2"));
2464 assert!(matches!(&blocks[2], SystemBlock { text, .. } if text == "summary"));
2465 }
2466 _ => panic!("Expected Blocks"),
2467 }
2468 }
2469
2470 #[test]
2471 fn test_merge_system_prompts_blocks_plus_text() {
2472 let original = Some(SystemPrompt::Blocks(vec![SystemBlock {
2473 block_type: "text".to_string(),
2474 text: "original".to_string(),
2475 cache_control: None,
2476 }]));
2477
2478 let summary = Some(SystemPrompt::Text("summary".to_string()));
2479
2480 let result = merge_system_prompts(original.as_ref(), summary);
2481
2482 match result {
2483 Some(SystemPrompt::Blocks(blocks)) => {
2484 assert_eq!(blocks.len(), 2);
2485 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
2486 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
2487 }
2488 _ => panic!("Expected Blocks"),
2489 }
2490 }
2491
2492 #[test]
2493 fn test_compaction_result_retries_used() {
2494 // This test verifies the CompactionResult structure
2495 let result = CompactionResult {
2496 messages: vec![],
2497 summary_prompt: None,
2498 removed_messages: vec![],
2499 retries_used: 2,
2500 };
2501
2502 assert_eq!(result.retries_used, 2);
2503 assert!(result.messages.is_empty());
2504 assert!(result.removed_messages.is_empty());
2505 }
2506
2507 #[test]
2508 fn test_should_compact_with_workspace_path_detection() {
2509 use std::env;
2510 let workspace = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2511
2512 let _config = CompactionConfig {
2513 enabled: true,
2514 token_threshold: 1000,
2515 ..Default::default()
2516 };
2517
2518 // Create messages mentioning workspace paths
2519 let messages = vec![
2520 msg("user", "working on src/main.rs"),
2521 msg("assistant", "noise 1"),
2522 msg("user", "noise 2"),
2523 msg("assistant", "noise 3"),
2524 msg("user", "noise 4"),
2525 msg("assistant", "noise 5"),
2526 msg("user", "recent 1"),
2527 msg("assistant", "recent 2"),
2528 ];
2529
2530 // src/main.rs mention should pin message 0 in the plan.
2531 let plan = plan_compaction(
2532 &messages,
2533 Some(&workspace),
2534 KEEP_RECENT_MESSAGES,
2535 None,
2536 None,
2537 );
2538 assert!(plan.pinned_indices.contains(&0)); // src/main.rs mention
2539 }
2540 }
2541
2541 lines RUST