返回 CodeWhale
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::config::DEFAULT_TEXT_MODEL;
12 use crate::core::model_client::ModelClient;
13 use crate::logging;
14 use crate::models::{
15 CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
16 context_window_for_model,
17 };
18
19 /// Configuration for conversation compaction behavior.
20 ///
21 /// v0.8.11 simplified this from the prior token-OR-message-count trigger
22 /// to a token-only trigger. The
23 /// `message_threshold` field was removed: its only purpose was to fire
24 /// compaction on long sessions of small messages, which is exactly the
25 /// case where rewriting the prefix cache is least valuable. Token
26 /// budget is the right signal; message count was a 128K-era heuristic.
27 #[derive(Debug, Clone, PartialEq)]
28 pub struct CompactionConfig {
29 pub enabled: bool,
30 pub token_threshold: usize,
31 pub model: String,
32 /// Route-effective context window. `None` preserves compatibility for
33 /// callers that have not resolved a provider route yet.
34 pub effective_context_window: Option<u32>,
35 pub cache_summary: bool,
36 /// Optional user-supplied focus for a manual `/compact <focus>`: injected
37 /// into the successor-brief prompt so the summary weights what the user
38 /// said matters. `None` for automatic compaction.
39 pub focus: Option<String>,
40 /// Typed live runtime state for post-compact rehydrate (workers, shells,
41 /// approvals, mode/permission). Canonical To-do state is appended fresh at
42 /// the request tail instead of being frozen into the stable prefix.
43 /// Host-owned snapshot; pure format lives in [`format_live_state_reminder`].
44 pub live_state: Option<CompactionLiveState>,
45 /// Runtime turn that owns provider calls made by this compaction pass.
46 /// `None` for the foreground TUI. This is accounting provenance only and
47 /// is never included in a provider request.
48 pub runtime_cost_owner: Option<String>,
49 }
50
51 /// Host-captured live state injected after compaction so the successor agent
52 /// does not reconstruct workers/mode from prose alone (compactionidea P1).
53 #[derive(Debug, Clone, PartialEq, Eq, Default)]
54 pub struct CompactionLiveState {
55 pub mode: Option<String>,
56 pub permission_posture: Option<String>,
57 /// Running background shell commands (id + command).
58 pub background_shells: Vec<String>,
59 /// Running sub-agents / fleet workers (id + role + objective).
60 pub running_workers: Vec<String>,
61 /// Open approval prompts still awaiting the user.
62 pub open_approvals: Vec<String>,
63 }
64
65 impl CompactionLiveState {
66 #[must_use]
67 pub fn is_empty(&self) -> bool {
68 self.mode.is_none()
69 && self.permission_posture.is_none()
70 && self.background_shells.is_empty()
71 && self.running_workers.is_empty()
72 && self.open_approvals.is_empty()
73 }
74 }
75
76 impl Default for CompactionConfig {
77 fn default() -> Self {
78 Self {
79 // ON BY DEFAULT since v0.8.6 (#402 P0 survivability). v0.8.64
80 // resolves the user-facing default through the active model's
81 // known context window, while explicit `auto_compact = false`
82 // remains the opt-out. This fallback covers code paths that build
83 // a `CompactionConfig` directly; real per-model values are still
84 // derived through the threshold helpers.
85 enabled: true,
86 // v0.8.11: 50K was a 128K-era leftover that biased every
87 // unconfigured caller toward "compact almost immediately on large-context routes."
88 // Bumped to 800K (80% of a 1M window) so the fallback
89 // default matches the hard automatic compaction guardrail. This
90 // is intentionally later than the model-visible 60% "suggest
91 // /compact during sustained work" guidance so automatic
92 // replacement compaction stays a late continuity guardrail.
93 // Real call sites override this via
94 // `compaction_threshold_for_model_and_effort`.
95 token_threshold: 800_000,
96 model: DEFAULT_TEXT_MODEL.to_string(),
97 effective_context_window: None,
98 cache_summary: true,
99 focus: None,
100 live_state: None,
101 runtime_cost_owner: None,
102 }
103 }
104 }
105
106 /// Minimum non-whitespace characters for a usable successor summary.
107 /// Below this (or missing required section headings), treat as degenerate and
108 /// retry once rather than shipping amnesia (compactionidea failure ladder).
109 const MIN_SUMMARY_SEED_CHARS: usize = 80;
110 const DEGENERATE_SUMMARY_REQUIRED_MARKERS: &[&str] =
111 &["Primary request", "Pending tasks", "Current work"];
112 const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \
113 substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \
114 commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \
115 scaffolding is not a request to switch languages.";
116
117 /// Failure kind for compaction LLM calls (deterministic vs transient).
118 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119 pub enum CompactionFailureKind {
120 /// Same payload will fail again — do not sleep/retry unchanged.
121 Deterministic,
122 /// May resolve on retry (network, rate limit, timeout).
123 Transient,
124 /// Context overflow — rebuild a smaller summary input before retry.
125 ContextOverflow,
126 }
127
128 impl CompactionFailureKind {
129 #[must_use]
130 pub fn is_transient(self) -> bool {
131 matches!(self, Self::Transient)
132 }
133
134 #[must_use]
135 pub fn allows_input_ladder(self) -> bool {
136 matches!(self, Self::ContextOverflow)
137 }
138 }
139
140 pub const KEEP_RECENT_MESSAGES: usize = 4;
141 const RECENT_WORKING_SET_WINDOW: usize = 12;
142 const MAX_WORKING_SET_PATHS: usize = 24;
143 const MIN_SUMMARIZE_MESSAGES: usize = 6;
144 const SUMMARY_TEXT_SNIPPET_CHARS: usize = 800;
145 const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
146 const SUMMARY_INPUT_MAX_CHARS: usize = 24_000;
147 const SUMMARY_INPUT_HEAD_CHARS: usize = 14_000;
148 const SUMMARY_INPUT_TAIL_CHARS: usize = 6_000;
149 const LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS: usize = 2_000;
150 const LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
151 const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
152 const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
153 const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
154 const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
155 const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
156 const RETAINED_THINKING_MAX_CHARS: usize = 16 * 1024;
157 const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
158 const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
159 const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85;
160
161 // File types whose contents are useful working-set context after compaction.
162 // Keep this structural table separate from the path-extraction regex so new
163 // source languages do not require another large regex alternation.
164 const WORKING_SET_EXTENSIONS: &[&str] = &[
165 "rs", "toml", "md", "json", "yaml", "yml", "txt", "py", "pyi", "ipynb", "js", "jsx", "ts",
166 "tsx", "mjs", "cjs", "go", "java", "kt", "kts", "c", "h", "cc", "cpp", "hpp", "cs", "rb",
167 "php", "swift", "m", "mm", "scala", "sh", "bash", "zsh", "ps1", "sql", "proto", "tf", "vue",
168 "svelte", "dart", "lua", "r", "jl", "ex", "exs", "erl", "hs", "zig",
169 ];
170
171 #[derive(Debug, Clone, Copy)]
172 struct SummaryInputLimits {
173 text_snippet_chars: usize,
174 tool_result_snippet_chars: usize,
175 input_max_chars: usize,
176 input_head_chars: usize,
177 input_tail_chars: usize,
178 max_tokens: u32,
179 word_limit: usize,
180 }
181
182 fn summary_input_limits_for_model(
183 model: &str,
184 effective_context_window: Option<u32>,
185 ) -> SummaryInputLimits {
186 let is_large_context = effective_context_window
187 .or_else(|| context_window_for_model(model))
188 .is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
189 if is_large_context {
190 SummaryInputLimits {
191 text_snippet_chars: LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS,
192 tool_result_snippet_chars: LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
193 input_max_chars: LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS,
194 input_head_chars: LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS,
195 input_tail_chars: LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS,
196 max_tokens: LARGE_CONTEXT_SUMMARY_MAX_TOKENS,
197 word_limit: 900,
198 }
199 } else {
200 SummaryInputLimits {
201 text_snippet_chars: SUMMARY_TEXT_SNIPPET_CHARS,
202 tool_result_snippet_chars: SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
203 input_max_chars: SUMMARY_INPUT_MAX_CHARS,
204 input_head_chars: SUMMARY_INPUT_HEAD_CHARS,
205 input_tail_chars: SUMMARY_INPUT_TAIL_CHARS,
206 max_tokens: 1_024,
207 word_limit: 500,
208 }
209 }
210 }
211
212 #[derive(Debug, Clone, Default)]
213 pub struct CompactionPlan {
214 pub pinned_indices: BTreeSet<usize>,
215 pub summarize_indices: Vec<usize>,
216 }
217
218 fn path_regex() -> &'static Regex {
219 static PATH_RE: OnceLock<Regex> = OnceLock::new();
220 PATH_RE.get_or_init(|| {
221 Regex::new(
222 r"(?x)
223 (?:
224 (?P<root>
225 Cargo\.toml|
226 Cargo\.lock|
227 README\.md|
228 CHANGELOG\.md|
229 AGENTS\.md|
230 config\.example\.toml
231 )
232 )
233 |
234 (?P<path>
235 (?:[A-Za-z0-9._-]+/)+
236 [A-Za-z0-9._-]+
237 \.[A-Za-z0-9]+
238 )
239 ",
240 )
241 .expect("path regex is valid")
242 })
243 }
244
245 fn normalize_path_candidate(candidate: &str, workspace: Option<&Path>) -> Option<String> {
246 if candidate.is_empty() {
247 return None;
248 }
249
250 let cleaned = candidate.replace('\\', "/");
251 let mut path = PathBuf::from(cleaned);
252
253 if path.is_absolute() {
254 let ws = workspace?;
255 if let Ok(stripped) = path.strip_prefix(ws) {
256 path = stripped.to_path_buf();
257 } else {
258 return None;
259 }
260 }
261
262 let rel = path.to_string_lossy().trim_start_matches("./").to_string();
263 if rel.is_empty() || rel.contains("..") {
264 return None;
265 }
266
267 if let Some(ws) = workspace {
268 let repo_path = ws.join(&rel);
269 if repo_path.exists() || looks_repo_relative(&rel) {
270 return Some(rel);
271 }
272 return None;
273 }
274
275 if looks_repo_relative(&rel) {
276 return Some(rel);
277 }
278
279 None
280 }
281
282 fn looks_repo_relative(path: &str) -> bool {
283 matches!(
284 path,
285 "Cargo.toml"
286 | "Cargo.lock"
287 | "README.md"
288 | "CHANGELOG.md"
289 | "AGENTS.md"
290 | "config.example.toml"
291 ) || path.starts_with("src/")
292 || path.starts_with("tests/")
293 || path.starts_with("docs/")
294 || path.starts_with("examples/")
295 || path.starts_with("benches/")
296 || path.starts_with("crates/")
297 || path.starts_with(".github/")
298 || (path.contains('/') && path.rsplit('.').next().is_some())
299 }
300
301 fn is_working_set_path(path: &str) -> bool {
302 // Do not spend the fixed working-set budget on dependencies or build
303 // output, even when those trees contain source-looking file names.
304 if path.split('/').any(|component| {
305 matches!(
306 component,
307 "node_modules" | "target" | "vendor" | "dist" | "build"
308 )
309 }) {
310 return false;
311 }
312
313 let file_name = path.rsplit('/').next().unwrap_or(path);
314 if file_name.ends_with(".min.js") || file_name.ends_with(".min.css") {
315 return false;
316 }
317 // Cargo.lock is an existing explicitly recognized project anchor. Other
318 // lockfiles are dependency snapshots rather than edited source context.
319 if file_name == "Cargo.lock" {
320 return true;
321 }
322 if file_name.ends_with(".lock") {
323 return false;
324 }
325
326 let Some(extension) = file_name.rsplit('.').next() else {
327 return false;
328 };
329 let extension = extension.to_ascii_lowercase();
330 WORKING_SET_EXTENSIONS.contains(&extension.as_str())
331 }
332
333 fn extract_paths_from_text(text: &str, workspace: Option<&Path>) -> Vec<String> {
334 path_regex()
335 .captures_iter(text)
336 .filter_map(|caps| {
337 let candidate = caps
338 .name("path")
339 .or_else(|| caps.name("root"))
340 .map(|m| m.as_str())?;
341 normalize_path_candidate(candidate, workspace)
342 })
343 .collect()
344 }
345
346 fn extract_paths_from_tool_input(
347 input: &serde_json::Value,
348 workspace: Option<&Path>,
349 ) -> Vec<String> {
350 let mut out = Vec::new();
351 let Some(obj) = input.as_object() else {
352 return out;
353 };
354
355 for key in ["path", "file", "target", "cwd"] {
356 if let Some(val) = obj.get(key).and_then(serde_json::Value::as_str)
357 && let Some(path) = normalize_path_candidate(val, workspace)
358 {
359 out.push(path);
360 }
361 }
362
363 for key in ["paths", "files", "targets"] {
364 if let Some(vals) = obj.get(key).and_then(serde_json::Value::as_array) {
365 for val in vals {
366 if let Some(s) = val.as_str()
367 && let Some(path) = normalize_path_candidate(s, workspace)
368 {
369 out.push(path);
370 }
371 }
372 }
373 }
374
375 out
376 }
377
378 fn message_text(msg: &Message) -> String {
379 let mut text = String::new();
380 for block in &msg.content {
381 match block {
382 ContentBlock::Text { text: t, .. } => {
383 let _ = writeln!(text, "{t}");
384 }
385 ContentBlock::Thinking { .. } => {}
386 ContentBlock::ToolUse { name, input, .. } => {
387 let _ = writeln!(text, "[tool_use:{name}] {input}");
388 }
389 ContentBlock::ToolResult { content, .. } => {
390 let _ = writeln!(text, "{content}");
391 }
392 ContentBlock::ServerToolUse { .. }
393 | ContentBlock::ToolSearchToolResult { .. }
394 | ContentBlock::CodeExecutionToolResult { .. }
395 | ContentBlock::ImageUrl { .. } => {}
396 }
397 }
398 text
399 }
400
401 fn is_user_text_query(msg: &Message) -> bool {
402 msg.role == "user"
403 && msg
404 .content
405 .iter()
406 .any(|block| matches!(block, ContentBlock::Text { .. }))
407 }
408
409 fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Vec<String> {
410 let mut paths = Vec::new();
411 for block in &message.content {
412 let candidates = match block {
413 ContentBlock::Text { text, .. } => extract_paths_from_text(text, workspace),
414 ContentBlock::ToolResult { content, .. } => extract_paths_from_text(content, workspace),
415 ContentBlock::ToolUse { input, .. } => extract_paths_from_tool_input(input, workspace),
416 ContentBlock::Thinking { .. } => Vec::new(),
417 ContentBlock::ServerToolUse { .. }
418 | ContentBlock::ToolSearchToolResult { .. }
419 | ContentBlock::CodeExecutionToolResult { .. }
420 | ContentBlock::ImageUrl { .. } => Vec::new(),
421 };
422 paths.extend(candidates);
423 }
424 paths
425 }
426
427 fn derive_working_set_paths(
428 messages: &[Message],
429 workspace: Option<&Path>,
430 seed_indices: &[usize],
431 ) -> HashSet<String> {
432 let mut paths: Vec<String> = Vec::new();
433 let mut seen: HashSet<String> = HashSet::new();
434
435 let mut seeds: Vec<usize> = seed_indices
436 .iter()
437 .copied()
438 .filter(|idx| *idx < messages.len())
439 .collect();
440 seeds.sort_unstable_by(|a, b| b.cmp(a));
441
442 for idx in seeds {
443 for candidate in extract_paths_from_message(&messages[idx], workspace) {
444 if !is_working_set_path(&candidate) {
445 continue;
446 }
447 if seen.insert(candidate.clone()) {
448 paths.push(candidate);
449 if paths.len() >= MAX_WORKING_SET_PATHS {
450 return paths.into_iter().collect();
451 }
452 }
453 }
454 }
455
456 for msg in messages.iter().rev().take(RECENT_WORKING_SET_WINDOW) {
457 for candidate in extract_paths_from_message(msg, workspace) {
458 if !is_working_set_path(&candidate) {
459 continue;
460 }
461 if seen.insert(candidate.clone()) {
462 paths.push(candidate);
463 if paths.len() >= MAX_WORKING_SET_PATHS {
464 return paths.into_iter().collect();
465 }
466 }
467 }
468 }
469
470 paths.into_iter().collect()
471 }
472
473 fn should_pin_message(text: &str, working_set_paths: &HashSet<String>) -> bool {
474 let lower = text.to_lowercase();
475
476 let mentions_working_set = working_set_paths.iter().any(|p| text.contains(p));
477 if mentions_working_set {
478 return true;
479 }
480
481 let error_markers = [
482 "error:",
483 "error ",
484 "failed",
485 "panic",
486 "traceback",
487 "stack trace",
488 "assertion failed",
489 "test failed",
490 ];
491 if error_markers.iter().any(|m| lower.contains(m)) {
492 return true;
493 }
494
495 let patch_markers = [
496 "diff --git",
497 "+++ b/",
498 "--- a/",
499 "*** begin patch",
500 "*** update file:",
501 "*** add file:",
502 "*** delete file:",
503 "```diff",
504 "apply_patch",
505 ];
506 patch_markers.iter().any(|m| lower.contains(m))
507 }
508
509 pub fn plan_compaction(
510 messages: &[Message],
511 workspace: Option<&Path>,
512 keep_recent: usize,
513 external_pins: Option<&[usize]>,
514 external_working_set_paths: Option<&[String]>,
515 ) -> CompactionPlan {
516 let mut pinned_indices: BTreeSet<usize> = BTreeSet::new();
517 let len = messages.len();
518 if len == 0 {
519 return CompactionPlan::default();
520 }
521
522 // Always pin the tail of the conversation to preserve immediate context.
523 let recent_start = len.saturating_sub(keep_recent);
524 pinned_indices.extend(recent_start..len);
525
526 // Derive a repo-aware working set from recent messages/tool calls and
527 // merge it with any externally provided working-set paths.
528 let seed_indices = external_pins.unwrap_or(&[]);
529 let mut working_set_paths = derive_working_set_paths(messages, workspace, seed_indices);
530 if let Some(paths) = external_working_set_paths {
531 for path in paths {
532 if let Some(normalized) = normalize_path_candidate(path, workspace)
533 && is_working_set_path(&normalized)
534 {
535 let _ = working_set_paths.insert(normalized);
536 }
537 }
538 }
539
540 for (idx, msg) in messages.iter().enumerate() {
541 if pinned_indices.contains(&idx) {
542 continue;
543 }
544 let text = message_text(msg);
545 if should_pin_message(&text, &working_set_paths) {
546 pinned_indices.insert(idx);
547 }
548 }
549
550 // External pins are authoritative and should be preserved even if they
551 // were not detected by the heuristics above.
552 if let Some(pins) = external_pins {
553 pinned_indices.extend(pins.iter().copied().filter(|idx| *idx < len));
554 }
555
556 // Ensure tool result messages are not kept without their corresponding tool call.
557 enforce_tool_call_pairs(messages, &mut pinned_indices);
558
559 // Some OpenAI-compatible chat templates require at least one user text
560 // message. Tool-heavy tails can otherwise compact down to only tool calls
561 // and tool results, which makes those backends reject the next request.
562 if !pinned_indices
563 .iter()
564 .any(|&idx| is_user_text_query(&messages[idx]))
565 && let Some(idx) = messages
566 .iter()
567 .enumerate()
568 .rev()
569 .find_map(|(idx, msg)| is_user_text_query(msg).then_some(idx))
570 {
571 pinned_indices.insert(idx);
572 }
573
574 let summarize_indices = (0..len)
575 .filter(|idx| !pinned_indices.contains(idx))
576 .collect();
577
578 // `working_set_paths` was used only for pinning decisions above.
579 drop(working_set_paths);
580
581 CompactionPlan {
582 pinned_indices,
583 summarize_indices,
584 }
585 }
586
587 #[allow(dead_code)]
588 fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<usize>) {
589 if pinned_indices.is_empty() {
590 return;
591 }
592
593 // Build maps: tool_id → message index across ALL messages (not just pinned).
594 let mut call_id_to_idx: HashMap<String, usize> = HashMap::new();
595 let mut result_id_to_idx: HashMap<String, usize> = HashMap::new();
596
597 for (idx, msg) in messages.iter().enumerate() {
598 for block in &msg.content {
599 match block {
600 ContentBlock::ToolUse { id, .. } => {
601 call_id_to_idx.insert(id.clone(), idx);
602 }
603 ContentBlock::ToolResult { tool_use_id, .. } => {
604 result_id_to_idx.insert(tool_use_id.clone(), idx);
605 }
606 _ => {}
607 }
608 }
609 }
610
611 // Fixpoint loop: re-check until stable.
612 // Newly pinned messages may introduce new pair requirements;
613 // removed messages may orphan their counterparts.
614 // Track permanently removed indices so they cannot be re-added
615 // by a counterpart in a later iteration (prevents oscillation).
616 let mut permanently_removed: HashSet<usize> = HashSet::new();
617
618 let max_iters = messages.len().max(10);
619 let mut converged = false;
620 for _ in 0..max_iters {
621 let mut to_add = Vec::new();
622 let mut to_remove = Vec::new();
623
624 let snapshot: Vec<usize> = pinned_indices.iter().copied().collect();
625
626 for idx in snapshot {
627 let msg = &messages[idx];
628 for block in &msg.content {
629 match block {
630 // Pinned result → its call must also be pinned (or remove result)
631 ContentBlock::ToolResult { tool_use_id, .. } => {
632 match call_id_to_idx.get(tool_use_id) {
633 Some(&call_idx) if !permanently_removed.contains(&call_idx) => {
634 to_add.push(call_idx);
635 }
636 _ => {
637 to_remove.push(idx);
638 }
639 }
640 }
641 // Pinned call → its result must also be pinned (or remove call)
642 ContentBlock::ToolUse { id, .. } => match result_id_to_idx.get(id) {
643 Some(&result_idx) if !permanently_removed.contains(&result_idx) => {
644 to_add.push(result_idx);
645 }
646 _ => {
647 to_remove.push(idx);
648 }
649 },
650 _ => {}
651 }
652 }
653 }
654
655 // Removals take priority: if a message is both needed and orphaned,
656 // remove it now; the fixpoint loop will cascade the orphaning.
657 let remove_set: HashSet<usize> = to_remove.iter().copied().collect();
658 let mut changed = false;
659 for idx in to_add {
660 if !remove_set.contains(&idx) && pinned_indices.insert(idx) {
661 changed = true;
662 }
663 }
664 for idx in to_remove {
665 if pinned_indices.remove(&idx) {
666 permanently_removed.insert(idx);
667 changed = true;
668 }
669 }
670
671 if !changed {
672 converged = true;
673 break;
674 }
675 }
676 if !converged {
677 logging::warn(format!(
678 "enforce_tool_call_pairs did not converge after {max_iters} iterations \
679 ({} messages, {} pinned)",
680 messages.len(),
681 pinned_indices.len()
682 ));
683 }
684 }
685
686 fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
687 message
688 .content
689 .iter()
690 .map(|c| match c {
691 ContentBlock::Text { text, .. } => text.len() / 4,
692 // Historical reasoning blocks are UI/session metadata for DeepSeek.
693 // Only current-turn tool-call reasoning is sent back to the API.
694 ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4,
695 ContentBlock::Thinking { .. } => 0,
696 ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
697 .map(|s| s.len() / 4)
698 .unwrap_or(100),
699 ContentBlock::ToolResult { content, .. } => content.len() / 4,
700 // An inline image is real input the model pays for; estimating it
701 // at 0 undercounts the budget and risks overflow in image-heavy
702 // sessions. Use a conservative flat per-image estimate (vision
703 // tiles are typically ~1k tokens); erring high compacts slightly
704 // early rather than overflowing.
705 ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
706 ContentBlock::ServerToolUse { .. }
707 | ContentBlock::ToolSearchToolResult { .. }
708 | ContentBlock::CodeExecutionToolResult { .. } => 0,
709 })
710 .sum::<usize>()
711 }
712
713 /// Conservative flat token estimate for an inline image (`ContentBlock::ImageUrl`).
714 /// Vision models bill images by resized tile count; ~1k tokens is a safe
715 /// mid-range estimate that keeps the compaction trigger from under-reading an
716 /// image-heavy session.
717 const IMAGE_TOKEN_ESTIMATE: usize = 1000;
718
719 pub fn estimate_tokens(messages: &[Message]) -> usize {
720 // Rough estimate: ~4 chars per token. DeepSeek thinking-mode rule: any
721 // assistant message with tool_calls keeps its reasoning_content forever
722 // (replayed in all subsequent requests). Final text-only answers drop it.
723 messages
724 .iter()
725 .map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
726 .sum()
727 }
728
729 fn message_has_tool_use(message: &Message) -> bool {
730 message
731 .content
732 .iter()
733 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
734 }
735
736 pub fn estimate_text_tokens_conservative(text: &str) -> usize {
737 text.chars().count().div_ceil(3)
738 }
739
740 fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
741 match system {
742 Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
743 Some(SystemPrompt::Blocks(blocks)) => blocks
744 .iter()
745 .map(|block| estimate_text_tokens_conservative(&block.text))
746 .sum(),
747 None => 0,
748 }
749 }
750
751 /// Conservative estimate for full request input tokens (messages + system + framing).
752 #[must_use]
753 pub fn estimate_input_tokens_conservative(
754 messages: &[Message],
755 system: Option<&SystemPrompt>,
756 ) -> usize {
757 let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
758 let system_tokens = estimate_system_tokens_conservative(system);
759 let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
760 message_tokens
761 .saturating_add(system_tokens)
762 .saturating_add(framing_overhead)
763 }
764
765 pub fn should_compact(
766 messages: &[Message],
767 config: &CompactionConfig,
768 workspace: Option<&Path>,
769 external_pins: Option<&[usize]>,
770 external_working_set_paths: Option<&[String]>,
771 ) -> bool {
772 if !config.enabled {
773 return false;
774 }
775
776 let plan = plan_compaction(
777 messages,
778 workspace,
779 KEEP_RECENT_MESSAGES,
780 external_pins,
781 external_working_set_paths,
782 );
783 let pinned_tokens: usize = plan
784 .pinned_indices
785 .iter()
786 .map(|&idx| estimate_tokens_for_message(&messages[idx], false))
787 .sum();
788
789 let token_estimate: usize = plan
790 .summarize_indices
791 .iter()
792 .map(|&idx| estimate_tokens_for_message(&messages[idx], false))
793 .sum();
794 let message_count = plan.summarize_indices.len();
795
796 // Pinned messages consume part of the budget, so compact earlier when needed.
797 let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
798
799 // Token-only trigger (v0.8.11): the prior message-count branch was a
800 // 128K-era heuristic that fired compaction on long chats of small
801 // messages — exactly the case where rewriting the prefix cache is
802 // most wasteful. Token budget is the only signal that maps to actual
803 // model context pressure.
804 if effective_token_threshold == 0 {
805 return message_count >= MIN_SUMMARIZE_MESSAGES;
806 }
807 if message_count < MIN_SUMMARIZE_MESSAGES {
808 return false;
809 }
810 token_estimate > effective_token_threshold
811 }
812
813 fn truncate_chars(text: &str, max_chars: usize) -> &str {
814 if max_chars == 0 {
815 return "";
816 }
817 match text.char_indices().nth(max_chars) {
818 Some((idx, _)) => &text[..idx],
819 None => text,
820 }
821 }
822
823 fn tail_chars(text: &str, max_chars: usize) -> String {
824 if max_chars == 0 {
825 return String::new();
826 }
827 let total_chars = text.chars().count();
828 if total_chars <= max_chars {
829 return text.to_string();
830 }
831 let start_char = total_chars.saturating_sub(max_chars);
832 let start_idx = text
833 .char_indices()
834 .nth(start_char)
835 .map_or(0, |(idx, _)| idx);
836 text[start_idx..].to_string()
837 }
838
839 #[derive(Debug, Clone)]
840 struct ToolUseInfo {
841 name: String,
842 key: String,
843 args_preview: String,
844 }
845
846 fn tool_use_key(name: &str, input: &serde_json::Value) -> String {
847 format!(
848 "{name}:{}",
849 serde_json::to_string(input).unwrap_or_else(|_| input.to_string())
850 )
851 }
852
853 fn tool_args_preview(input: &serde_json::Value) -> String {
854 let redacted = codewhale_config::persistence::redact_json_secrets(input);
855 let raw = serde_json::to_string(&redacted).unwrap_or_else(|_| redacted.to_string());
856 truncate_chars(&raw, 120).to_string()
857 }
858
859 fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
860 let mut tool_uses = HashMap::new();
861 for message in messages {
862 for block in &message.content {
863 if let ContentBlock::ToolUse {
864 id, name, input, ..
865 } = block
866 {
867 tool_uses.insert(
868 id.clone(),
869 ToolUseInfo {
870 name: name.clone(),
871 key: tool_use_key(name, input),
872 args_preview: tool_args_preview(input),
873 },
874 );
875 }
876 }
877 }
878 tool_uses
879 }
880
881 struct ToolResultPruneCandidate {
882 message_idx: usize,
883 block_idx: usize,
884 key: String,
885 tool_name: String,
886 args_preview: String,
887 original_len: usize,
888 }
889
890 #[cfg(test)]
891 fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize {
892 prune_tool_results_until(messages, protected_window, |_, _| false)
893 }
894
895 /// Mechanically prune old verbose tool results before paying for an LLM summary.
896 ///
897 /// The most recent `protected_window` messages stay byte-for-byte intact. Older
898 /// duplicate tool results keep the freshest full body and replace earlier
899 /// copies with one-line summaries; non-duplicate old results are summarized only
900 /// when they exceed the normal summary snippet size.
901 fn prune_tool_results_until<F>(
902 messages: &mut [Message],
903 protected_window: usize,
904 mut should_stop: F,
905 ) -> usize
906 where
907 F: FnMut(&[Message], usize) -> bool,
908 {
909 let cutoff = messages.len().saturating_sub(protected_window);
910 if cutoff == 0 {
911 return 0;
912 }
913
914 let tool_uses = collect_tool_uses(messages);
915 let mut candidates = Vec::new();
916 let mut latest_by_key: HashMap<String, usize> = HashMap::new();
917 let mut count_by_key: HashMap<String, usize> = HashMap::new();
918
919 for (message_idx, message) in messages.iter().take(cutoff).enumerate() {
920 for (block_idx, block) in message.content.iter().enumerate() {
921 let ContentBlock::ToolResult {
922 tool_use_id,
923 content,
924 ..
925 } = block
926 else {
927 continue;
928 };
929 let Some(info) = tool_uses.get(tool_use_id) else {
930 continue;
931 };
932 latest_by_key.insert(info.key.clone(), message_idx);
933 *count_by_key.entry(info.key.clone()).or_insert(0) += 1;
934 candidates.push(ToolResultPruneCandidate {
935 message_idx,
936 block_idx,
937 key: info.key.clone(),
938 tool_name: info.name.clone(),
939 args_preview: info.args_preview.clone(),
940 original_len: content.len(),
941 });
942 }
943 }
944
945 // The maps above are fully populated before pruning starts, so the order below
946 // only changes which message bytes are rewritten first. Pruning from newest to
947 // oldest lets callers stop as soon as enough bytes were saved, preserving the
948 // earlier JSON request prefix for byte-level KV caches.
949 candidates.reverse();
950
951 let mut bytes_saved = 0usize;
952 for candidate in candidates {
953 let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0);
954 let is_latest_duplicate = duplicate_count > 1
955 && latest_by_key.get(&candidate.key) == Some(&candidate.message_idx);
956 if is_latest_duplicate {
957 continue;
958 }
959 if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS {
960 continue;
961 }
962
963 let summary = format!(
964 "[{}] tool result pruned ({} bytes; args: {})",
965 candidate.tool_name, candidate.original_len, candidate.args_preview
966 );
967 if summary.len() >= candidate.original_len {
968 continue;
969 }
970
971 if let ContentBlock::ToolResult {
972 content,
973 content_blocks,
974 ..
975 } = &mut messages[candidate.message_idx].content[candidate.block_idx]
976 {
977 bytes_saved = bytes_saved.saturating_add(content.len().saturating_sub(summary.len()));
978 *content = summary;
979 *content_blocks = None;
980
981 if should_stop(messages, bytes_saved) {
982 break;
983 }
984 }
985 }
986
987 bytes_saved
988 }
989
990 fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
991 let char_count = content.chars().count();
992 if char_count <= max_chars {
993 return false;
994 }
995
996 let snippet_budget = max_chars.saturating_sub(256).max(1024);
997 let head_chars = snippet_budget / 2;
998 let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
999 let head = truncate_chars(content, head_chars).to_string();
1000 let tail = tail_chars(content, tail_chars_budget);
1001 *content =
1002 format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
1003 true
1004 }
1005
1006 // A match guard cannot mutably borrow `content`; keeping the mutation inside
1007 // the arm updates both retained representations together without indirection.
1008 #[allow(clippy::collapsible_match)]
1009 fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
1010 for message in &mut messages {
1011 for block in &mut message.content {
1012 match block {
1013 ContentBlock::ToolResult {
1014 content,
1015 content_blocks,
1016 ..
1017 } => {
1018 if truncate_retained_block(
1019 "tool result",
1020 content,
1021 RETAINED_TOOL_RESULT_MAX_CHARS,
1022 ) {
1023 *content_blocks = None;
1024 }
1025 }
1026 // Signed thinking must stay byte-for-byte valid for providers that
1027 // verify replay signatures. Unsigned thinking is local memory pressure
1028 // and can be capped once compaction has summarized the old turn.
1029 ContentBlock::Thinking {
1030 thinking,
1031 signature,
1032 } if signature.is_none() => {
1033 truncate_retained_block(
1034 "thinking block",
1035 thinking,
1036 RETAINED_THINKING_MAX_CHARS,
1037 );
1038 }
1039 _ => {}
1040 }
1041 }
1042 }
1043 messages
1044 }
1045
1046 /// Result of a compaction operation with metadata.
1047 #[derive(Debug)]
1048 pub struct CompactionResult {
1049 /// Compacted messages
1050 pub messages: Vec<Message>,
1051 /// Summary system prompt
1052 pub summary_prompt: Option<SystemPrompt>,
1053 /// Number of retries used before success
1054 pub retries_used: u32,
1055 }
1056
1057 /// Classify a compaction LLM failure for the retry / input-ladder policy.
1058 fn classify_compaction_failure(e: &anyhow::Error) -> CompactionFailureKind {
1059 if let Some(error) = llm_error_in_chain(e) {
1060 return match error {
1061 crate::llm_client::LlmError::ContextLengthError(_) => {
1062 CompactionFailureKind::ContextOverflow
1063 }
1064 crate::llm_client::LlmError::QuotaExhausted(_) => CompactionFailureKind::Deterministic,
1065 error if error.is_retryable() => CompactionFailureKind::Transient,
1066 _ => CompactionFailureKind::Deterministic,
1067 };
1068 }
1069
1070 let text = e.to_string();
1071 if is_context_window_error_message(&text) {
1072 return CompactionFailureKind::ContextOverflow;
1073 }
1074 let category = crate::error_taxonomy::classify_error_message(&text);
1075 match category {
1076 crate::error_taxonomy::ErrorCategory::Network
1077 | crate::error_taxonomy::ErrorCategory::RateLimit
1078 | crate::error_taxonomy::ErrorCategory::Timeout => CompactionFailureKind::Transient,
1079 _ => CompactionFailureKind::Deterministic,
1080 }
1081 }
1082
1083 fn llm_error_in_chain(error: &anyhow::Error) -> Option<&crate::llm_client::LlmError> {
1084 error
1085 .chain()
1086 .find_map(|cause| cause.downcast_ref::<crate::llm_client::LlmError>())
1087 }
1088
1089 fn should_retry_cache_aligned_with_formatted(error: &anyhow::Error) -> bool {
1090 !matches!(
1091 llm_error_in_chain(error),
1092 Some(crate::llm_client::LlmError::QuotaExhausted(_))
1093 )
1094 }
1095
1096 /// Record and render a compaction failure as actionable, credential-safe text.
1097 ///
1098 /// This classifies only the error supplied by the failed request; it never
1099 /// infers a cause from later provider failures. Unknown diagnostics stay
1100 /// visible after central secret/path redaction, and the same safe detail is
1101 /// written to the runtime log so a transient status message remains auditable.
1102 #[must_use]
1103 pub fn report_compaction_failure(
1104 prefix: &str,
1105 id: &str,
1106 auto: bool,
1107 error: &anyhow::Error,
1108 ) -> String {
1109 let raw = error.to_string();
1110 let safe_raw = crate::safe_label::safe_error_text(&raw);
1111 tracing::warn!(
1112 compaction_id = %id,
1113 auto,
1114 error = %safe_raw,
1115 "context compaction failed"
1116 );
1117 let detail = match llm_error_in_chain(error) {
1118 Some(crate::llm_client::LlmError::QuotaExhausted(_)) => {
1119 "provider plan quota exhausted — switch provider/model or renew the provider plan"
1120 .to_string()
1121 }
1122 Some(crate::llm_client::LlmError::RateLimited { .. }) => {
1123 "provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
1124 .to_string()
1125 }
1126 Some(crate::llm_client::LlmError::AuthenticationError(_)) => {
1127 "provider authentication failed — sign in or replace the credential, then retry"
1128 .to_string()
1129 }
1130 Some(crate::llm_client::LlmError::AuthorizationError(_)) => {
1131 "provider authorization rejected compaction — verify account access or switch provider/model"
1132 .to_string()
1133 }
1134 _ => match crate::error_taxonomy::classify_error_message(&raw) {
1135 crate::error_taxonomy::ErrorCategory::RateLimit => {
1136 "provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
1137 .to_string()
1138 }
1139 crate::error_taxonomy::ErrorCategory::Authentication => {
1140 "provider authentication failed — sign in or replace the credential, then retry"
1141 .to_string()
1142 }
1143 crate::error_taxonomy::ErrorCategory::Authorization => {
1144 "provider authorization rejected compaction — verify account access or switch provider/model"
1145 .to_string()
1146 }
1147 _ => safe_raw,
1148 },
1149 };
1150
1151 format!("{prefix}: {detail}")
1152 }
1153
1154 /// Check if an error is transient and worth retrying. Categories that map to
1155 /// transient retry: Network, RateLimit, Timeout. Context overflow is *not*
1156 /// transient — it needs a smaller input (ladder), not the same payload.
1157 fn is_transient_error(e: &anyhow::Error) -> bool {
1158 classify_compaction_failure(e).is_transient()
1159 }
1160
1161 fn is_context_window_error_message(text: &str) -> bool {
1162 let lower = text.to_lowercase();
1163 lower.contains("too long for this model")
1164 || lower.contains("prompt is too long")
1165 || lower.contains("maximum prompt length")
1166 || lower.contains("maximum context length")
1167 || lower.contains("context_length_exceeded")
1168 || lower.contains("context window")
1169 || (lower.contains("context")
1170 && (lower.contains("token") || lower.contains("too long") || lower.contains("maximum")))
1171 }
1172
1173 /// Compact messages with retry and backoff for transient errors.
1174 ///
1175 /// This function wraps `compact_messages` with retry logic to handle
1176 /// transient network errors and rate limits. It uses exponential backoff
1177 /// with delays of 1s, 2s, 4s between retries.
1178 ///
1179 /// # Safety
1180 /// - Never panics
1181 /// - Never corrupts the original messages (returns error instead)
1182 /// - Only retries on transient errors (network, rate limit, etc.)
1183 pub async fn compact_messages_safe(
1184 client: &dyn ModelClient,
1185 messages: &[Message],
1186 config: &CompactionConfig,
1187 workspace: Option<&Path>,
1188 external_pins: Option<&[usize]>,
1189 external_working_set_paths: Option<&[String]>,
1190 ) -> Result<CompactionResult> {
1191 const MAX_RETRIES: u32 = 3;
1192 const BASE_DELAY_MS: u64 = 1000;
1193
1194 let was_over_threshold = should_compact(
1195 messages,
1196 config,
1197 workspace,
1198 external_pins,
1199 external_working_set_paths,
1200 );
1201 let mut pruned_messages = messages.to_vec();
1202 let mut now_under_threshold = false;
1203 let mut next_stop_check_bytes = 0usize;
1204 let pruned_bytes = prune_tool_results_until(
1205 &mut pruned_messages,
1206 KEEP_RECENT_MESSAGES,
1207 |candidate_messages, bytes_saved| {
1208 if !was_over_threshold || bytes_saved < next_stop_check_bytes {
1209 return false;
1210 }
1211
1212 // Stop at the first suffix-side prune check that clears the threshold.
1213 // The check itself is a full compaction-plan pass, so bound it by saved
1214 // bytes instead of running it after every candidate in huge sessions.
1215 next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES);
1216 now_under_threshold = !should_compact(
1217 candidate_messages,
1218 config,
1219 workspace,
1220 external_pins,
1221 external_working_set_paths,
1222 );
1223 now_under_threshold
1224 },
1225 );
1226 if was_over_threshold && pruned_bytes > 0 && !now_under_threshold {
1227 // The throttled in-loop check may skip the exact candidate that clears the
1228 // budget. Do one final pass so a successful local prune still avoids LLM compaction.
1229 now_under_threshold = !should_compact(
1230 &pruned_messages,
1231 config,
1232 workspace,
1233 external_pins,
1234 external_working_set_paths,
1235 );
1236 }
1237
1238 let compaction_input: &[Message] = if pruned_bytes > 0 {
1239 logging::info(format!(
1240 "Local tool-result prune saved {pruned_bytes} bytes before LLM compaction"
1241 ));
1242 if was_over_threshold && now_under_threshold {
1243 return Ok(CompactionResult {
1244 messages: sanitize_retained_messages(pruned_messages),
1245 summary_prompt: None,
1246 retries_used: 0,
1247 });
1248 }
1249 &pruned_messages
1250 } else {
1251 messages
1252 };
1253
1254 let mut last_error: Option<anyhow::Error> = None;
1255
1256 for attempt in 0..MAX_RETRIES {
1257 if attempt > 0 {
1258 // Exponential backoff: 1s, 2s, 4s
1259 let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1)));
1260 tokio::time::sleep(delay).await;
1261 }
1262
1263 match compact_messages(
1264 client,
1265 compaction_input,
1266 config,
1267 workspace,
1268 external_pins,
1269 external_working_set_paths,
1270 )
1271 .await
1272 {
1273 Ok((msgs, prompt, removed)) => {
1274 drop(removed);
1275 return Ok(CompactionResult {
1276 messages: sanitize_retained_messages(msgs),
1277 summary_prompt: prompt,
1278 retries_used: attempt,
1279 });
1280 }
1281 Err(e) => {
1282 // Only retry on transient errors
1283 if !is_transient_error(&e) {
1284 return Err(e);
1285 }
1286 last_error = Some(e);
1287 }
1288 }
1289 }
1290
1291 Err(last_error
1292 .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
1293 }
1294
1295 fn read_workspace_anchors(workspace: Option<&Path>) -> Vec<String> {
1296 let Some(ws) = workspace else {
1297 return Vec::new();
1298 };
1299
1300 // Prefer .codewhale, fall back to .deepseek
1301 let primary = ws.join(".codewhale").join("anchors.md");
1302 let anchors_path = if primary.exists() {
1303 primary
1304 } else {
1305 ws.join(".deepseek").join("anchors.md")
1306 };
1307 let Ok(content) = std::fs::read_to_string(anchors_path) else {
1308 return Vec::new();
1309 };
1310
1311 content
1312 .split("\n---\n")
1313 .map(str::trim)
1314 .filter(|anchor| !anchor.is_empty())
1315 .map(ToOwned::to_owned)
1316 .collect()
1317 }
1318
1319 fn anchor_summary_section(workspace: Option<&Path>) -> String {
1320 let anchors = read_workspace_anchors(workspace);
1321 if anchors.is_empty() {
1322 return String::new();
1323 }
1324
1325 let mut section = String::from(
1326 "## Pinned Facts (User Anchors)\n\n\
1327 The following facts were explicitly anchored by the user with `/anchor`. \
1328 Preserve them across compaction cycles.\n\n",
1329 );
1330
1331 for anchor in anchors {
1332 let _ = writeln!(section, "- {anchor}");
1333 }
1334
1335 section.push_str("\n---\n\n");
1336 section
1337 }
1338
1339 pub async fn compact_messages(
1340 client: &dyn ModelClient,
1341 messages: &[Message],
1342 config: &CompactionConfig,
1343 workspace: Option<&Path>,
1344 external_pins: Option<&[usize]>,
1345 external_working_set_paths: Option<&[String]>,
1346 ) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
1347 if messages.is_empty() {
1348 return Ok((Vec::new(), None, Vec::new()));
1349 }
1350
1351 let plan = plan_compaction(
1352 messages,
1353 workspace,
1354 KEEP_RECENT_MESSAGES,
1355 external_pins,
1356 external_working_set_paths,
1357 );
1358 if plan.summarize_indices.is_empty() {
1359 return Ok((messages.to_vec(), None, Vec::new()));
1360 }
1361
1362 let to_summarize: Vec<Message> = plan
1363 .summarize_indices
1364 .iter()
1365 .map(|&idx| messages[idx].clone())
1366 .collect();
1367
1368 // Create a summary of the unpinned portion of the conversation.
1369 // Failure ladder: on context overflow, retry with a smaller input rung;
1370 // on degenerate output, resample once before shipping amnesia.
1371 let summary = create_summary_with_ladder(
1372 client,
1373 &to_summarize,
1374 &config.model,
1375 config.effective_context_window,
1376 config.focus.as_deref(),
1377 config.runtime_cost_owner.as_deref(),
1378 )
1379 .await?;
1380
1381 // Extract workflow context (files touched, tasks in progress, etc.)
1382 let workflow_context = extract_workflow_context(&to_summarize, workspace);
1383 drop(to_summarize);
1384
1385 // Deterministic continuation block over the FULL transcript (#5043):
1386 // intent, decisions, evidence, and in-flight tool state must survive
1387 // compaction even when the model summary is generic or lossy, and the
1388 // system-prompt-adjacent working contract (the first user request) must
1389 // never be dropped merely because its message index was summarized.
1390 let continuation = build_continuation_block(messages, &plan.pinned_indices);
1391
1392 let anchors_section = anchor_summary_section(workspace);
1393 let project_instructions = project_instructions_section(workspace);
1394 let live_reminder = config
1395 .live_state
1396 .as_ref()
1397 .map(format_live_state_reminder)
1398 .filter(|s| !s.is_empty())
1399 .unwrap_or_default();
1400
1401 // Build new message list with enhanced summary as system block
1402 let summary_block = SystemBlock {
1403 block_type: "text".to_string(),
1404 text: format!(
1405 "{anchors_section}\
1406 ## 📋 Conversation Summary (Auto-Generated)\n\n\
1407 {summary}\n\n\
1408 ---\n\n\
1409 {continuation}\
1410 ## 🔍 Workflow Context\n\n\
1411 {workflow_context}\n\n\
1412 ---\n\n\
1413 {live_reminder}\
1414 {project_instructions}\
1415 ## 💡 What to Do Next\n\n\
1416 You have just resumed from a context compaction. The conversation above was summarized to save space. \
1417 Review the summary, continuation contract, live state, and project instructions, then continue the same task. \
1418 {language_contract} \
1419 Prefer exact paths and commands from the summary over re-discovery. \
1420 If you need more details about the summarized portion, ask the user to clarify.\n\n\
1421 ---\n\n\
1422 Pinned messages follow:",
1423 language_contract = COMPACTION_LANGUAGE_CONTRACT,
1424 ),
1425 cache_control: if config.cache_summary {
1426 Some(CacheControl {
1427 cache_type: "ephemeral".to_string(),
1428 })
1429 } else {
1430 None
1431 },
1432 };
1433
1434 let pinned_messages = messages
1435 .iter()
1436 .enumerate()
1437 .filter_map(|(idx, msg)| plan.pinned_indices.contains(&idx).then_some(msg.clone()))
1438 .collect();
1439
1440 Ok((
1441 sanitize_retained_messages(pinned_messages),
1442 Some(SystemPrompt::Blocks(vec![summary_block])),
1443 Vec::new(),
1444 ))
1445 }
1446
1447 /// Summary input ladder rungs: full → lossy-formatted → extreme-truncate.
1448 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1449 enum SummaryInputRung {
1450 Full,
1451 Lossy,
1452 Extreme,
1453 }
1454
1455 async fn create_summary_with_ladder(
1456 client: &dyn ModelClient,
1457 messages: &[Message],
1458 model: &str,
1459 effective_context_window: Option<u32>,
1460 focus: Option<&str>,
1461 runtime_cost_owner: Option<&str>,
1462 ) -> Result<String> {
1463 let rungs = [
1464 SummaryInputRung::Full,
1465 SummaryInputRung::Lossy,
1466 SummaryInputRung::Extreme,
1467 ];
1468 let mut last_err: Option<anyhow::Error> = None;
1469
1470 for (idx, rung) in rungs.iter().enumerate() {
1471 match create_summary(
1472 client,
1473 messages,
1474 model,
1475 effective_context_window,
1476 focus,
1477 runtime_cost_owner,
1478 *rung,
1479 )
1480 .await
1481 {
1482 Ok(summary) if is_degenerate_summary(&summary) => {
1483 logging::warn(format!(
1484 "Compaction summary rung {rung:?} produced degenerate output \
1485 ({} chars); retrying next ladder rung",
1486 summary.chars().count()
1487 ));
1488 // Degenerate is not a hard error: try next rung, or if last, return
1489 // the best effort only when non-empty after a second full retry.
1490 if idx + 1 < rungs.len() {
1491 last_err = Some(anyhow::anyhow!(
1492 "degenerate compaction summary ({} chars)",
1493 summary.chars().count()
1494 ));
1495 continue;
1496 }
1497 // Final rung still degenerate: one explicit resample of Extreme.
1498 match create_summary(
1499 client,
1500 messages,
1501 model,
1502 effective_context_window,
1503 focus,
1504 runtime_cost_owner,
1505 SummaryInputRung::Extreme,
1506 )
1507 .await
1508 {
1509 Ok(retry) if !is_degenerate_summary(&retry) => return Ok(retry),
1510 Ok(retry) if !retry.trim().is_empty() => {
1511 logging::warn(
1512 "Compaction summary still thin after ladder; shipping best effort",
1513 );
1514 return Ok(retry);
1515 }
1516 Ok(_) => {
1517 return Err(anyhow::anyhow!(
1518 "compaction summary empty after failure ladder"
1519 ));
1520 }
1521 Err(err) => return Err(err),
1522 }
1523 }
1524 Ok(summary) => return Ok(summary),
1525 Err(err) => {
1526 let kind = classify_compaction_failure(&err);
1527 if kind.allows_input_ladder() && idx + 1 < rungs.len() {
1528 logging::warn(format!(
1529 "Compaction summary rung {rung:?} hit context overflow ({err}); \
1530 retrying smaller input ladder rung"
1531 ));
1532 last_err = Some(err);
1533 continue;
1534 }
1535 if kind.is_transient() && idx + 1 < rungs.len() {
1536 last_err = Some(err);
1537 continue;
1538 }
1539 return Err(err);
1540 }
1541 }
1542 }
1543
1544 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("compaction summary ladder exhausted")))
1545 }
1546
1547 /// True when the model returned an empty or useless successor brief.
1548 fn is_degenerate_summary(summary: &str) -> bool {
1549 let trimmed = summary.trim();
1550 if trimmed.is_empty() {
1551 return true;
1552 }
1553 let seed_chars = trimmed.chars().filter(|c| !c.is_whitespace()).count();
1554 if seed_chars < MIN_SUMMARY_SEED_CHARS {
1555 return true;
1556 }
1557 // Structured brief must land at least one load-bearing section heading.
1558 // Free-form walls of text that omit every marker still count as amnesia.
1559 let lower = trimmed.to_ascii_lowercase();
1560 !DEGENERATE_SUMMARY_REQUIRED_MARKERS
1561 .iter()
1562 .any(|marker| lower.contains(&marker.to_ascii_lowercase()))
1563 }
1564
1565 async fn create_summary(
1566 client: &dyn ModelClient,
1567 messages: &[Message],
1568 model: &str,
1569 effective_context_window: Option<u32>,
1570 focus: Option<&str>,
1571 runtime_cost_owner: Option<&str>,
1572 rung: SummaryInputRung,
1573 ) -> Result<String> {
1574 let mut limits = summary_input_limits_for_model(model, effective_context_window);
1575 match rung {
1576 SummaryInputRung::Full => {}
1577 SummaryInputRung::Lossy => {
1578 limits.input_max_chars /= 2;
1579 limits.input_head_chars /= 2;
1580 limits.input_tail_chars /= 2;
1581 limits.text_snippet_chars /= 2;
1582 limits.tool_result_snippet_chars /= 2;
1583 }
1584 SummaryInputRung::Extreme => {
1585 limits.input_max_chars = (limits.input_max_chars / 4).max(4_000);
1586 limits.input_head_chars = (limits.input_head_chars / 4).max(2_000);
1587 limits.input_tail_chars = (limits.input_tail_chars / 4).max(1_500);
1588 limits.text_snippet_chars = (limits.text_snippet_chars / 4).max(200);
1589 limits.tool_result_snippet_chars = (limits.tool_result_snippet_chars / 4).max(120);
1590 }
1591 }
1592
1593 // Cache-aligned only on the Full rung; smaller rungs always use formatted.
1594 let used_cache_aligned = matches!(rung, SummaryInputRung::Full)
1595 && should_use_cache_aligned_summary(model, effective_context_window, messages);
1596 let request = if used_cache_aligned {
1597 build_cache_aligned_summary_request(model, messages, limits, focus)
1598 } else {
1599 build_formatted_summary_request(model, messages, limits, focus)
1600 };
1601
1602 let cost_scope = crate::cost_status::scope_token();
1603 let mut cost_route = client.effective_route_envelope(model, chrono::Utc::now());
1604 let mut telemetry_cache_aligned = used_cache_aligned;
1605 let response = match client.create_message(request).await {
1606 Ok(response) => response,
1607 // The cache-aligned request replays a non-contiguous message
1608 // subsequence (pinned messages removed from the middle), which can
1609 // exceed the window OR violate strict role-ordering (a non-transient
1610 // InvalidInput). Fall back to the bounded formatted summary on ANY
1611 // request-shape failure rather than aborting compaction entirely and
1612 // letting context keep growing. Durable plan-quota exhaustion is not a
1613 // request-shape failure and must not issue a second provider request.
1614 Err(err) if used_cache_aligned && should_retry_cache_aligned_with_formatted(&err) => {
1615 logging::warn(format!(
1616 "Cache-aligned compaction summary failed ({err}); retrying with \
1617 bounded formatted summary input"
1618 ));
1619 telemetry_cache_aligned = false;
1620 let fallback_request = build_formatted_summary_request(model, messages, limits, focus);
1621 cost_route = client.effective_route_envelope(model, chrono::Utc::now());
1622 client.create_message(fallback_request).await?
1623 }
1624 Err(err) => return Err(err),
1625 };
1626 // Compaction summary calls are billed by DeepSeek; route the
1627 // tokens through the side-channel so the dashboard total
1628 // matches the website (#526).
1629 crate::cost_status::report_effective_route_for_runtime(
1630 cost_scope,
1631 runtime_cost_owner,
1632 &format!(
1633 "compaction:dispatch:{}:response:{}",
1634 cost_route
1635 .dispatched_at
1636 .timestamp_nanos_opt()
1637 .unwrap_or_default(),
1638 response.id
1639 ),
1640 &cost_route,
1641 &response.usage,
1642 );
1643
1644 // #584: emit one debug-level event per summary call so the
1645 // cache-aligned win is observable post-deploy without
1646 // adding UI surface. The event is emitted with
1647 // `target = "compaction"`, so the filter is
1648 // `RUST_LOG=compaction=debug` (the module-path form
1649 // `codewhale_tui::compaction=debug` does NOT match — `EnvFilter`
1650 // matches the explicit target string when one is set).
1651 log_summary_cache_telemetry(telemetry_cache_aligned, &response.usage);
1652
1653 // Extract text from response
1654 let summary = response
1655 .content
1656 .iter()
1657 .filter_map(|block| match block {
1658 ContentBlock::Text { text, .. } => Some(text.clone()),
1659 _ => None,
1660 })
1661 .collect::<Vec<_>>()
1662 .join("\n");
1663
1664 Ok(summary)
1665 }
1666
1667 /// Format a typed post-compact system-reminder from real runtime state.
1668 pub fn format_live_state_reminder(state: &CompactionLiveState) -> String {
1669 if state.is_empty() {
1670 return String::new();
1671 }
1672 let mut out = String::from(
1673 "## 🔄 Live State (post-compact rehydrate)\n\n\
1674 These facts come from the live runtime, not the summary model. Trust them over prose guesses.\n\n",
1675 );
1676 if let Some(mode) = state.mode.as_deref() {
1677 let _ = writeln!(out, "- Mode: `{mode}`");
1678 }
1679 if let Some(posture) = state.permission_posture.as_deref() {
1680 let _ = writeln!(out, "- Permission posture: `{posture}`");
1681 }
1682 if !state.background_shells.is_empty() {
1683 out.push_str("\n### Running background shells\n");
1684 for line in &state.background_shells {
1685 let _ = writeln!(out, "- {line}");
1686 }
1687 }
1688 if !state.running_workers.is_empty() {
1689 out.push_str("\n### Running workers / sub-agents\n");
1690 for line in &state.running_workers {
1691 let _ = writeln!(out, "- {line}");
1692 }
1693 }
1694 if !state.open_approvals.is_empty() {
1695 out.push_str("\n### Open approvals\n");
1696 for line in &state.open_approvals {
1697 let _ = writeln!(out, "- {line}");
1698 }
1699 }
1700 out.push_str("\n---\n\n");
1701 out
1702 }
1703
1704 /// Re-inject project instructions (AGENTS.md / CLAUDE.md) **verbatim** after
1705 /// compaction so they do not depend on the summarizer (compactionidea P1).
1706 fn project_instructions_section(workspace: Option<&Path>) -> String {
1707 let Some(ws) = workspace else {
1708 return String::new();
1709 };
1710 // Same precedence as project_context: AGENTS.md first, then CLAUDE.md.
1711 const CANDIDATES: &[&str] = &["AGENTS.md", "CLAUDE.md", "Claude.md"];
1712 for name in CANDIDATES {
1713 let path = ws.join(name);
1714 let Ok(content) = std::fs::read_to_string(&path) else {
1715 continue;
1716 };
1717 let trimmed = content.trim();
1718 if trimmed.is_empty() {
1719 continue;
1720 }
1721 // Bound size so a huge AGENTS file cannot blow the post-compact budget.
1722 const MAX_CHARS: usize = 12_000;
1723 let body = if trimmed.chars().count() > MAX_CHARS {
1724 let head: String = trimmed.chars().take(MAX_CHARS).collect();
1725 format!("{head}\n\n[… project instructions truncated for compaction budget …]")
1726 } else {
1727 trimmed.to_string()
1728 };
1729 return format!(
1730 "## 📜 Project instructions (verbatim rehydrate)\n\n\
1731 <project_instructions source=\"{name}\">\n{body}\n</project_instructions>\n\n\
1732 ---\n\n"
1733 );
1734 }
1735 String::new()
1736 }
1737
1738 // Retained for tests; production compaction now falls back on any
1739 // cache-aligned summary failure, not only context-window errors.
1740 #[cfg(test)]
1741 fn is_context_window_error(e: &anyhow::Error) -> bool {
1742 let text = e.to_string();
1743 if crate::error_taxonomy::classify_error_message(&text)
1744 != crate::error_taxonomy::ErrorCategory::InvalidInput
1745 {
1746 return false;
1747 }
1748
1749 let lower = text.to_lowercase();
1750 lower.contains("context")
1751 || lower.contains("token")
1752 || lower.contains("prompt is too long")
1753 || lower.contains("requested")
1754 || lower.contains("maximum")
1755 }
1756
1757 /// Cache-hit percentage for a compaction summary call.
1758 ///
1759 /// Denominator is `input_tokens` (the total prompt size), not
1760 /// `cache_hit + cache_miss`. Some providers populate
1761 /// `prompt_cache_hit_tokens` but not `prompt_cache_miss_tokens` — using
1762 /// the sum as the denominator there reports an inflated 100% even when
1763 /// most of the prompt was uncached. Anchoring on `input_tokens` matches
1764 /// how the rest of the codebase (cost reporting, `/cache`) infers
1765 /// missing miss counts. (#584)
1766 fn summary_cache_hit_percent(cache_hit: u32, input_tokens: u32) -> f64 {
1767 if input_tokens > 0 {
1768 (f64::from(cache_hit) * 100.0) / f64::from(input_tokens)
1769 } else {
1770 0.0
1771 }
1772 }
1773
1774 /// Emit one `tracing::debug!` event per compaction summary call so the
1775 /// path choice (cache-aligned vs fallback) and the resulting cache-hit
1776 /// rate are observable. Both raw token counts and the percentage are
1777 /// included; on providers that don't return cache-token fields the
1778 /// counts are reported as `0` and the percentage as `0.0`. (#584)
1779 fn log_summary_cache_telemetry(used_cache_aligned: bool, usage: &crate::models::Usage) {
1780 let path = if used_cache_aligned {
1781 "cache_aligned"
1782 } else {
1783 "fallback"
1784 };
1785 let cache_hit = usage.prompt_cache_hit_tokens.unwrap_or(0);
1786 let cache_miss = usage.prompt_cache_miss_tokens.unwrap_or(0);
1787 let cache_hit_pct = summary_cache_hit_percent(cache_hit, usage.input_tokens);
1788 tracing::debug!(
1789 target: "compaction",
1790 "compaction summary call: path={} prompt_tokens={} cache_hit_tokens={} cache_miss_tokens={} cache_hit_pct={:.1}",
1791 path,
1792 usage.input_tokens,
1793 cache_hit,
1794 cache_miss,
1795 cache_hit_pct,
1796 );
1797 }
1798
1799 /// Decide whether to use the cache-aligned summary path
1800 /// ([`build_cache_aligned_summary_request`]) or the fallback
1801 /// ([`build_formatted_summary_request`]). Returns `true` when both
1802 /// gates hold:
1803 ///
1804 /// 1. The model has a known large context window
1805 /// (≥ `LARGE_CONTEXT_WINDOW_TOKENS`).
1806 /// 2. Replaying the message prefix plus a ~512-token instruction
1807 /// still fits within `CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT`
1808 /// of that budget.
1809 ///
1810 /// ## Why the two paths produce slightly different prompts (#584)
1811 ///
1812 /// The two summary requests are *intentionally* framed differently:
1813 ///
1814 /// - **Cache-aligned** replays the original `messages` verbatim
1815 /// with `system: None` and appends the summary instruction as
1816 /// the final `user` turn. The model sees the conversation as if
1817 /// it were its own history. This is what lets the provider prefix cache
1818 /// hit on the bulk of the request (#572).
1819 /// - **Fallback** reformats the conversation into a flat
1820 /// `User:/Assistant:` transcript inside a single `user` message
1821 /// and adds a "You are a helpful assistant that creates concise
1822 /// conversation summaries." system prompt. The model sees a
1823 /// transcript of someone else's conversation.
1824 ///
1825 /// The empirical bar is that large-context models produce equivalent summaries
1826 /// either way; the post-#572 review noted this fork is worth
1827 /// documenting but not yet worth unifying. The fallback's
1828 /// external-transcript framing is also more conservative for the
1829 /// older / smaller models the cache-aligned path explicitly
1830 /// excludes, so dropping the system prompt would risk regressing
1831 /// those models without a corresponding gain. If we ever want to
1832 /// unify, land it in a separate PR backed by an A/B summary-quality
1833 /// evaluation rather than as a drive-by cleanup.
1834 ///
1835 /// `create_summary` emits a `tracing::debug!` event under
1836 /// `target = "compaction"` after each call so the path choice and
1837 /// cache-hit rate are observable post-deploy without UI surface.
1838 fn should_use_cache_aligned_summary(
1839 model: &str,
1840 effective_context_window: Option<u32>,
1841 messages: &[Message],
1842 ) -> bool {
1843 let Some(window) = effective_context_window.or_else(|| context_window_for_model(model)) else {
1844 return false;
1845 };
1846 if window < LARGE_CONTEXT_WINDOW_TOKENS {
1847 return false;
1848 }
1849
1850 let budget = usize::try_from(window).unwrap_or(usize::MAX)
1851 * CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT
1852 / 100;
1853 let summary_prompt_tokens = 512usize;
1854 estimate_tokens(messages).saturating_add(summary_prompt_tokens) <= budget
1855 }
1856
1857 /// Structured successor brief (2026-07-23 compaction cutover): the summary
1858 /// is written for the agent that resumes after compaction, in nine fixed
1859 /// sections, instead of a free-form "concise but comprehensive" paragraph.
1860 /// An optional user focus from `/compact <focus>` is appended verbatim.
1861 fn summary_instruction(word_limit: usize, focus: Option<&str>) -> String {
1862 let mut instruction = format!(
1863 "Produce a successor briefing for the agent that will continue this session after \
1864 compaction. Structure it with exactly these numbered sections (write \"None\" when a \
1865 section is empty):\n\
1866 1. Primary request and intent — what the user is ultimately asking for, in their terms.\n\
1867 2. Key technical concepts — systems, APIs, and domain facts the successor must know.\n\
1868 3. Files and code sections — exact paths, with the important identifiers or snippets per file.\n\
1869 4. Errors and fixes — each error hit, its cause, and how (or whether) it was fixed.\n\
1870 5. Problem solving — approaches tried, decisions made, and why alternatives were rejected.\n\
1871 6. User messages — every non-tool user instruction, condensed but none omitted.\n\
1872 7. Pending tasks — work explicitly requested but not yet done.\n\
1873 8. Current work — precisely what was in flight when compaction hit.\n\
1874 9. Next step — only if one is directly implied; ground it in a short verbatim quote from \
1875 the most recent work.\n\
1876 If the conversation already contains an earlier compaction summary, treat it as \
1877 authoritative for the history it covers and carry its facts forward. Preserve exact \
1878 file paths, commands, and tool-result facts; abbreviate tool outputs only when they \
1879 are repetitive. {language_contract} Do not call tools. Keep the whole briefing under \
1880 {word_limit} words.",
1881 language_contract = COMPACTION_LANGUAGE_CONTRACT,
1882 );
1883 if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
1884 let _ = write!(
1885 instruction,
1886 "\n\nThe user asked this compaction to focus on: {focus}"
1887 );
1888 }
1889 instruction
1890 }
1891
1892 fn build_cache_aligned_summary_request(
1893 model: &str,
1894 messages: &[Message],
1895 limits: SummaryInputLimits,
1896 focus: Option<&str>,
1897 ) -> MessageRequest {
1898 let mut request_messages = messages.to_vec();
1899 request_messages.push(Message {
1900 role: "user".to_string(),
1901 content: vec![ContentBlock::Text {
1902 text: summary_instruction(limits.word_limit, focus),
1903 cache_control: None,
1904 }],
1905 });
1906
1907 MessageRequest {
1908 model: model.to_string(),
1909 messages: request_messages,
1910 max_tokens: limits.max_tokens,
1911 system: None,
1912 tools: None,
1913 tool_choice: None,
1914 metadata: None,
1915 thinking: None,
1916 reasoning_effort: None,
1917 stream: Some(false),
1918 temperature: Some(0.3),
1919 top_p: None,
1920 }
1921 }
1922
1923 fn build_formatted_summary_request(
1924 model: &str,
1925 messages: &[Message],
1926 limits: SummaryInputLimits,
1927 focus: Option<&str>,
1928 ) -> MessageRequest {
1929 // Format messages for summarization
1930 let mut conversation_text = String::new();
1931 for msg in messages {
1932 let role = if msg.role == "user" {
1933 "User"
1934 } else {
1935 "Assistant"
1936 };
1937 for block in &msg.content {
1938 match block {
1939 ContentBlock::Text { text, .. } => {
1940 let snippet = truncate_chars(text, limits.text_snippet_chars);
1941 let _ = write!(conversation_text, "{role}: {snippet}\n\n");
1942 }
1943 ContentBlock::ToolUse { name, .. } => {
1944 let _ = write!(conversation_text, "{role}: [Used tool: {name}]\n\n");
1945 }
1946 ContentBlock::ToolResult { content, .. } => {
1947 let snippet = truncate_chars(content, limits.tool_result_snippet_chars);
1948 let _ = write!(conversation_text, "Tool result: {snippet}\n\n");
1949 }
1950 ContentBlock::Thinking { .. } => {
1951 // Skip thinking blocks in summary
1952 }
1953 ContentBlock::ServerToolUse { .. }
1954 | ContentBlock::ToolSearchToolResult { .. }
1955 | ContentBlock::CodeExecutionToolResult { .. }
1956 | ContentBlock::ImageUrl { .. } => {}
1957 }
1958 }
1959 }
1960
1961 let conversation_chars = conversation_text.chars().count();
1962 if conversation_chars > limits.input_max_chars {
1963 let head = truncate_chars(&conversation_text, limits.input_head_chars).to_string();
1964 let tail = tail_chars(&conversation_text, limits.input_tail_chars);
1965 let omitted = conversation_chars
1966 .saturating_sub(head.chars().count())
1967 .saturating_sub(tail.chars().count());
1968 conversation_text =
1969 format!("{head}\n\n[... {omitted} characters omitted before summary ...]\n\n{tail}");
1970 }
1971
1972 MessageRequest {
1973 model: model.to_string(),
1974 messages: vec![Message {
1975 role: "user".to_string(),
1976 content: vec![ContentBlock::Text {
1977 text: format!(
1978 "{}\n\n---\n\n{conversation_text}",
1979 summary_instruction(limits.word_limit, focus)
1980 ),
1981 cache_control: None,
1982 }],
1983 }],
1984 max_tokens: limits.max_tokens,
1985 system: Some(SystemPrompt::Text(
1986 "You are a helpful assistant that creates concise conversation summaries.".to_string(),
1987 )),
1988 tools: None,
1989 tool_choice: None,
1990 metadata: None,
1991 thinking: None,
1992 reasoning_effort: None,
1993 stream: Some(false),
1994 temperature: Some(0.3),
1995 top_p: None,
1996 }
1997 }
1998
1999 /// Bounds for the deterministic continuation block (#5043).
2000 const CONTINUATION_MAX_ITEMS: usize = 8;
2001 const CONTINUATION_ITEM_MAX_CHARS: usize = 240;
2002 const CONTINUATION_CONTRACT_MAX_CHARS: usize = 2_000;
2003 const CONTINUATION_MAX_INFLIGHT_TOOLS: usize = 6;
2004
2005 /// Assistant-prose markers that indicate an accepted decision or chosen
2006 /// approach worth carrying across compaction verbatim.
2007 const CONTINUATION_DECISION_MARKERS: &[&str] = &[
2008 "decision:",
2009 "decided",
2010 "we will",
2011 "i will",
2012 "i'll",
2013 "chose",
2014 "choosing",
2015 "instead of",
2016 "agreed",
2017 "approach:",
2018 "plan:",
2019 "going with",
2020 ];
2021
2022 /// Tool-result markers that indicate verification evidence (test outcomes,
2023 /// failures, exit codes) the successor must not lose.
2024 const CONTINUATION_EVIDENCE_MARKERS: &[&str] = &[
2025 "passed",
2026 "failed",
2027 "error",
2028 "exit code",
2029 "warning:",
2030 "assertion",
2031 "test result",
2032 ];
2033
2034 fn continuation_line(text: &str) -> String {
2035 let redacted = codewhale_config::persistence::redact_secrets(text);
2036 let flattened = redacted.trim().replace('\n', " ");
2037 truncate_chars(&flattened, CONTINUATION_ITEM_MAX_CHARS).to_string()
2038 }
2039
2040 fn quote_verbatim(text: &str, max_chars: usize) -> String {
2041 let redacted = codewhale_config::persistence::redact_secrets(text);
2042 truncate_chars(redacted.trim(), max_chars)
2043 .lines()
2044 .map(|line| format!("> {line}"))
2045 .collect::<Vec<_>>()
2046 .join("\n")
2047 }
2048
2049 fn user_text_of(msg: &Message) -> Option<String> {
2050 if msg.role != "user" {
2051 return None;
2052 }
2053 let text = msg
2054 .content
2055 .iter()
2056 .filter_map(|block| match block {
2057 ContentBlock::Text { text, .. } => Some(text.as_str()),
2058 _ => None,
2059 })
2060 .collect::<Vec<_>>()
2061 .join("\n");
2062 let text = text.trim();
2063 (!text.is_empty()).then(|| text.to_string())
2064 }
2065
2066 /// Build the deterministic continuation block from the transcript itself.
2067 ///
2068 /// Compaction must preserve accepted decisions, verification evidence, and
2069 /// in-flight tool state that are at risk of leaving the retained transcript,
2070 /// even when the summary model returns a generic or lossy result (#5043).
2071 /// Content already present in the pinned tail is not duplicated here. The
2072 /// working contract remains a deliberate exception: the first user request
2073 /// is always retained after credential redaction.
2074 fn build_continuation_block(messages: &[Message], pinned_indices: &BTreeSet<usize>) -> String {
2075 let mut first_user: Option<String> = None;
2076 let mut last_user: Option<(usize, String)> = None;
2077 for (index, msg) in messages.iter().enumerate() {
2078 let Some(text) = user_text_of(msg) else {
2079 continue;
2080 };
2081 if first_user.is_none() {
2082 first_user = Some(text.clone());
2083 }
2084 last_user = Some((index, text));
2085 }
2086
2087 // Decisions: assistant prose lines that record a choice or approach.
2088 let mut decisions: Vec<String> = Vec::new();
2089 let mut seen_decisions: HashSet<String> = HashSet::new();
2090 for (_, msg) in messages
2091 .iter()
2092 .enumerate()
2093 .filter(|(index, msg)| !pinned_indices.contains(index) && msg.role == "assistant")
2094 {
2095 for block in &msg.content {
2096 let ContentBlock::Text { text, .. } = block else {
2097 continue;
2098 };
2099 for line in text.lines() {
2100 let lower = line.to_lowercase();
2101 if !CONTINUATION_DECISION_MARKERS
2102 .iter()
2103 .any(|marker| lower.contains(marker))
2104 {
2105 continue;
2106 }
2107 let entry = continuation_line(line);
2108 if !entry.is_empty() && seen_decisions.insert(entry.clone()) {
2109 decisions.push(entry);
2110 }
2111 }
2112 }
2113 }
2114 // Keep the most recent at-risk decisions when over budget. Pinned-tail
2115 // decisions were excluded above, so these do not compete with duplicate
2116 // content that already survives compaction verbatim.
2117 if decisions.len() > CONTINUATION_MAX_ITEMS {
2118 decisions.drain(0..decisions.len() - CONTINUATION_MAX_ITEMS);
2119 }
2120
2121 // Evidence: tool-result lines carrying verification outcomes, attributed
2122 // to the tool that produced them.
2123 let tool_uses = collect_tool_uses(messages);
2124 let mut evidence: Vec<String> = Vec::new();
2125 let mut seen_evidence: HashSet<String> = HashSet::new();
2126 // Resolution is a transcript-wide fact even when the result itself is
2127 // pinned and therefore excluded from the duplicated evidence section.
2128 let resolved_tool_ids: HashSet<&str> = messages
2129 .iter()
2130 .flat_map(|msg| msg.content.iter())
2131 .filter_map(|block| match block {
2132 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
2133 _ => None,
2134 })
2135 .collect();
2136 for (_, msg) in messages
2137 .iter()
2138 .enumerate()
2139 .filter(|(index, _)| !pinned_indices.contains(index))
2140 {
2141 for block in &msg.content {
2142 let ContentBlock::ToolResult {
2143 tool_use_id,
2144 content,
2145 ..
2146 } = block
2147 else {
2148 continue;
2149 };
2150 let tool_name = tool_uses
2151 .get(tool_use_id)
2152 .map_or("tool", |info| info.name.as_str());
2153 for line in content.lines() {
2154 let lower = line.to_lowercase();
2155 if !CONTINUATION_EVIDENCE_MARKERS
2156 .iter()
2157 .any(|marker| lower.contains(marker))
2158 {
2159 continue;
2160 }
2161 let entry = format!("[{tool_name}] {}", continuation_line(line));
2162 if seen_evidence.insert(entry.clone()) {
2163 evidence.push(entry);
2164 }
2165 }
2166 }
2167 }
2168 if evidence.len() > CONTINUATION_MAX_ITEMS {
2169 evidence.drain(0..evidence.len() - CONTINUATION_MAX_ITEMS);
2170 }
2171
2172 // In-flight tool state: dispatched calls with no recorded result. These
2173 // are exactly the calls `enforce_tool_call_pairs` must drop from the
2174 // retained messages, so this block is their only surviving record.
2175 let mut in_flight: Vec<String> = Vec::new();
2176 for (_, msg) in messages
2177 .iter()
2178 .enumerate()
2179 .filter(|(index, _)| !pinned_indices.contains(index))
2180 {
2181 for block in &msg.content {
2182 let ContentBlock::ToolUse {
2183 id, name, input, ..
2184 } = block
2185 else {
2186 continue;
2187 };
2188 if resolved_tool_ids.contains(id.as_str()) {
2189 continue;
2190 }
2191 in_flight.push(format!(
2192 "{name} {} — dispatched, no result recorded; re-run if its outcome matters",
2193 tool_args_preview(input)
2194 ));
2195 }
2196 }
2197 if in_flight.len() > CONTINUATION_MAX_INFLIGHT_TOOLS {
2198 in_flight.drain(0..in_flight.len() - CONTINUATION_MAX_INFLIGHT_TOOLS);
2199 }
2200
2201 let mut body = String::new();
2202 if let Some(contract) = first_user.as_deref() {
2203 let _ = write!(
2204 body,
2205 "### Task, in progress\n\n{}\n\n",
2206 quote_verbatim(contract, CONTINUATION_CONTRACT_MAX_CHARS)
2207 );
2208 }
2209 if let Some((index, intent)) = last_user.as_ref()
2210 && !pinned_indices.contains(index)
2211 && first_user.as_deref() != Some(intent.as_str())
2212 {
2213 let _ = write!(
2214 body,
2215 "### Latest request\n\n{}\n\n",
2216 quote_verbatim(intent, CONTINUATION_CONTRACT_MAX_CHARS)
2217 );
2218 }
2219 if !decisions.is_empty() {
2220 body.push_str("### Decisions already made\n\n");
2221 for decision in &decisions {
2222 let _ = writeln!(body, "- {decision}");
2223 }
2224 body.push('\n');
2225 }
2226 if !evidence.is_empty() {
2227 body.push_str("### Evidence and verification\n\n");
2228 for item in &evidence {
2229 let _ = writeln!(body, "- {item}");
2230 }
2231 body.push('\n');
2232 }
2233 if !in_flight.is_empty() {
2234 body.push_str("### In-flight tool state\n\n");
2235 for item in &in_flight {
2236 let _ = writeln!(body, "- {item}");
2237 }
2238 body.push('\n');
2239 }
2240
2241 if body.is_empty() {
2242 return String::new();
2243 }
2244
2245 format!(
2246 "## 🧭 Continuation Contract (deterministic)\n\n\
2247 Extracted directly from the transcript by the runtime — not by the summary model. \
2248 If the auto-generated summary disagrees with this block, trust this block.\n\n\
2249 {body}---\n\n"
2250 )
2251 }
2252
2253 /// Extract workflow context from messages (files touched, tasks, etc.)
2254 fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> String {
2255 let mut files_touched: Vec<String> = Vec::new();
2256 let mut tools_used: Vec<String> = Vec::new();
2257 let mut tasks_identified: Vec<String> = Vec::new();
2258
2259 for msg in messages {
2260 for block in &msg.content {
2261 match block {
2262 ContentBlock::ToolUse { name, input, .. } => {
2263 tools_used.push(name.clone());
2264
2265 // Extract file paths from tool inputs
2266 if let Some(path) = extract_path_from_input(input)
2267 && !files_touched.contains(&path)
2268 {
2269 files_touched.push(path);
2270 }
2271 }
2272 ContentBlock::Text { text, .. }
2273 // Look for task/todo mentions
2274 if (text.contains("TODO") || text.contains("task") || text.contains("need to")) => {
2275 let task = truncate_chars(text, 200).to_string();
2276 if !tasks_identified.contains(&task) {
2277 tasks_identified.push(task);
2278 }
2279 }
2280 _ => {}
2281 }
2282 }
2283 }
2284
2285 let mut context = String::new();
2286
2287 if !files_touched.is_empty() {
2288 context.push_str("**Files Modified/Read:**\n");
2289 for file in &files_touched {
2290 if let Some(ws) = workspace {
2291 let relative = Path::new(file)
2292 .strip_prefix(ws)
2293 .unwrap_or(Path::new(file))
2294 .display();
2295 context.push_str(&format!("- `{relative}`\n"));
2296 } else {
2297 context.push_str(&format!("- `{file}`\n"));
2298 }
2299 }
2300 context.push('\n');
2301 }
2302
2303 if !tools_used.is_empty() {
2304 context.push_str("**Tools Used:** ");
2305 context.push_str(&tools_used.join(", "));
2306 context.push_str("\n\n");
2307 }
2308
2309 if !tasks_identified.is_empty() {
2310 context.push_str("**Tasks/TODOs Identified:**\n");
2311 for task in &tasks_identified {
2312 context.push_str(&format!("- {task}\n"));
2313 }
2314 context.push('\n');
2315 }
2316
2317 if context.is_empty() {
2318 context.push_str("No specific workflow context detected. Continue assisting the user with their current task.\n");
2319 }
2320
2321 context
2322 }
2323
2324 /// Extract file path from tool input JSON
2325 fn extract_path_from_input(input: &serde_json::Value) -> Option<String> {
2326 // Try common path field names
2327 for key in ["path", "file", "file_path", "filename"] {
2328 if let Some(path) = input.get(key).and_then(|v| v.as_str()) {
2329 return Some(path.to_string());
2330 }
2331 }
2332
2333 // Try to find path in nested objects
2334 if let Some(obj) = input.as_object() {
2335 for (_, value) in obj {
2336 if let Some(path) = value.as_str()
2337 && (path.contains('/') || path.contains('\\') || path.contains('.'))
2338 {
2339 return Some(path.to_string());
2340 }
2341 }
2342 }
2343
2344 None
2345 }
2346
2347 pub fn merge_system_prompts(
2348 original: Option<&SystemPrompt>,
2349 summary: Option<SystemPrompt>,
2350 ) -> Option<SystemPrompt> {
2351 match (original, summary) {
2352 (None, None) => None,
2353 (Some(orig), None) => Some(orig.clone()),
2354 (None, Some(sum)) => Some(sum),
2355 (Some(SystemPrompt::Text(orig_text)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
2356 // Prepend original system prompt
2357 sum_blocks.insert(
2358 0,
2359 SystemBlock {
2360 block_type: "text".to_string(),
2361 text: orig_text.clone(),
2362 cache_control: None,
2363 },
2364 );
2365 Some(SystemPrompt::Blocks(sum_blocks))
2366 }
2367 (Some(SystemPrompt::Blocks(orig_blocks)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
2368 // Prepend original blocks
2369 for (i, block) in orig_blocks.iter().enumerate() {
2370 sum_blocks.insert(i, block.clone());
2371 }
2372 Some(SystemPrompt::Blocks(sum_blocks))
2373 }
2374 (Some(orig), Some(SystemPrompt::Text(sum_text))) => {
2375 let mut blocks = match orig {
2376 SystemPrompt::Text(t) => vec![SystemBlock {
2377 block_type: "text".to_string(),
2378 text: t.clone(),
2379 cache_control: None,
2380 }],
2381 SystemPrompt::Blocks(b) => b.clone(),
2382 };
2383 blocks.push(SystemBlock {
2384 block_type: "text".to_string(),
2385 text: sum_text,
2386 cache_control: None,
2387 });
2388 Some(SystemPrompt::Blocks(blocks))
2389 }
2390 }
2391 }
2392
2393 #[cfg(test)]
2394 #[path = "compaction/tests.rs"]
2395 mod quota_tests;
2396
2397 #[cfg(test)]
2398 mod tests {
2399 use crate::models::{ImageUrlContent, Message};
2400
2401 #[test]
2402 fn inline_image_estimates_nonzero_tokens() {
2403 let msg = Message {
2404 role: "user".to_string(),
2405 content: vec![ContentBlock::ImageUrl {
2406 image_url: ImageUrlContent {
2407 url: "data:image/png;base64,AAAA".to_string(),
2408 },
2409 }],
2410 };
2411 assert!(
2412 estimate_tokens_for_message(&msg, false) >= IMAGE_TOKEN_ESTIMATE,
2413 "an inline image must not estimate to 0 tokens"
2414 );
2415 }
2416
2417 use super::*;
2418 use serde_json::json;
2419
2420 fn msg(role: &str, text: &str) -> Message {
2421 Message {
2422 role: role.to_string(),
2423 content: vec![ContentBlock::Text {
2424 text: text.to_string(),
2425 cache_control: None,
2426 }],
2427 }
2428 }
2429
2430 fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
2431 Message {
2432 role: "assistant".to_string(),
2433 content: vec![ContentBlock::ToolUse {
2434 id: id.to_string(),
2435 name: name.to_string(),
2436 input,
2437 caller: None,
2438 }],
2439 }
2440 }
2441
2442 fn tool_result(id: &str, content: &str) -> Message {
2443 Message {
2444 role: "user".to_string(),
2445 content: vec![ContentBlock::ToolResult {
2446 tool_use_id: id.to_string(),
2447 content: content.to_string(),
2448 is_error: None,
2449 content_blocks: None,
2450 }],
2451 }
2452 }
2453
2454 #[test]
2455 fn anchor_summary_section_is_empty_without_workspace_or_file() {
2456 assert!(anchor_summary_section(None).is_empty());
2457
2458 let tmpdir = tempfile::TempDir::new().unwrap();
2459 assert!(anchor_summary_section(Some(tmpdir.path())).is_empty());
2460 }
2461
2462 #[test]
2463 fn anchor_summary_section_parses_anchor_file_into_bullets() {
2464 let tmpdir = tempfile::TempDir::new().unwrap();
2465 let deepseek_dir = tmpdir.path().join(".deepseek");
2466 std::fs::create_dir_all(&deepseek_dir).unwrap();
2467 std::fs::write(
2468 deepseek_dir.join("anchors.md"),
2469 "\n---\nDo not touch .ssh\n---\nStatus field is unreliable\n",
2470 )
2471 .unwrap();
2472
2473 let section = anchor_summary_section(Some(tmpdir.path()));
2474
2475 assert!(section.contains("## Pinned Facts (User Anchors)"));
2476 assert!(section.contains("- Do not touch .ssh\n"));
2477 assert!(section.contains("- Status field is unreliable\n"));
2478 assert!(!section.contains("\n---\nDo not touch"));
2479 }
2480
2481 #[test]
2482 fn truncate_chars_respects_unicode_boundaries() {
2483 let text = "abc😀é";
2484 assert_eq!(truncate_chars(text, 0), "");
2485 assert_eq!(truncate_chars(text, 1), "a");
2486 assert_eq!(truncate_chars(text, 3), "abc");
2487 assert_eq!(truncate_chars(text, 4), "abc😀");
2488 assert_eq!(truncate_chars(text, 5), "abc😀é");
2489 }
2490
2491 #[test]
2492 fn prune_tool_results_summarizes_old_verbose_outputs() {
2493 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
2494 let mut messages = vec![
2495 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
2496 tool_result("call-1", &verbose),
2497 msg("user", "recent question"),
2498 msg("assistant", "recent answer"),
2499 ];
2500
2501 let saved = prune_tool_results(&mut messages, 2);
2502
2503 assert!(saved > 0);
2504 let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else {
2505 panic!("expected tool result");
2506 };
2507 assert!(content.contains("[read_file] tool result pruned"));
2508 assert!(content.contains("Cargo.toml"));
2509 assert!(content.len() < verbose.len());
2510 }
2511
2512 #[test]
2513 fn prune_tool_results_preserves_protected_tail() {
2514 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
2515 let mut messages = vec![
2516 msg("user", "older context"),
2517 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
2518 tool_result("call-1", &verbose),
2519 ];
2520
2521 let saved = prune_tool_results(&mut messages, 2);
2522
2523 assert_eq!(saved, 0);
2524 let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else {
2525 panic!("expected tool result");
2526 };
2527 assert_eq!(content, &verbose);
2528 }
2529
2530 #[test]
2531 fn prune_tool_results_preserves_prefix_bytes_when_reverse_prune_is_enough() {
2532 let older_verbose = "old ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
2533 let newer_verbose = "new ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
2534 let mut messages = vec![
2535 tool_use("call-old", "read_file", json!({"path": "old.txt"})),
2536 tool_result("call-old", &older_verbose),
2537 tool_use("call-new", "read_file", json!({"path": "new.txt"})),
2538 tool_result("call-new", &newer_verbose),
2539 msg("user", "protected tail"),
2540 ];
2541 let original = messages.clone();
2542
2543 // Simulate the caller clearing its token budget after one suffix prune.
2544 let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
2545
2546 assert!(saved > 0);
2547 assert_eq!(&messages[..3], &original[..3]);
2548 assert_eq!(&messages[4..], &original[4..]);
2549 let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
2550 panic!("expected pruned tool result");
2551 };
2552 assert!(content.contains("[read_file] tool result pruned"));
2553 assert!(content.contains("new.txt"));
2554 assert!(content.len() < newer_verbose.len());
2555 }
2556
2557 #[test]
2558 fn prune_tool_results_stops_after_newest_duplicate_prune() {
2559 let oldest = "oldest ".repeat(80);
2560 let middle = "middle ".repeat(80);
2561 let latest = "latest ".repeat(80);
2562 let mut messages = vec![
2563 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
2564 tool_result("call-1", &oldest),
2565 tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
2566 tool_result("call-2", &middle),
2567 tool_use("call-3", "read_file", json!({"path": "Cargo.toml"})),
2568 tool_result("call-3", &latest),
2569 msg("user", "protected tail"),
2570 ];
2571 let original = messages.clone();
2572
2573 let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
2574
2575 assert!(saved > 0);
2576 assert_eq!(&messages[..3], &original[..3]);
2577 assert_eq!(&messages[4..], &original[4..]);
2578 let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
2579 panic!("expected middle duplicate to be pruned");
2580 };
2581 assert!(content.contains("[read_file] tool result pruned"));
2582 }
2583
2584 #[test]
2585 fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() {
2586 let first = "first ".repeat(80);
2587 let second = "second ".repeat(80);
2588 let mut messages = vec![
2589 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
2590 tool_result("call-1", &first),
2591 tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
2592 tool_result("call-2", &second),
2593 msg("user", "tail"),
2594 ];
2595
2596 let saved = prune_tool_results(&mut messages, 1);
2597
2598 assert!(saved > 0);
2599 let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else {
2600 panic!("expected older tool result");
2601 };
2602 assert!(older.contains("tool result pruned"));
2603 let ContentBlock::ToolResult {
2604 content: latest, ..
2605 } = &messages[3].content[0]
2606 else {
2607 panic!("expected latest tool result");
2608 };
2609 assert_eq!(latest, &second);
2610 }
2611
2612 #[test]
2613 fn summary_limits_expand_for_v4_context() {
2614 let legacy = summary_input_limits_for_model("deepseek-v3.2-128k", None);
2615 let v4 = summary_input_limits_for_model("deepseek-v4-pro", None);
2616
2617 assert!(v4.input_max_chars > legacy.input_max_chars);
2618 assert!(v4.tool_result_snippet_chars > legacy.tool_result_snippet_chars);
2619 assert!(v4.max_tokens > legacy.max_tokens);
2620 }
2621
2622 #[test]
2623 fn route_effective_window_bounds_same_id_oauth_summary() {
2624 let api = summary_input_limits_for_model("gpt-5.5", None);
2625 let oauth = summary_input_limits_for_model("gpt-5.5", Some(272_000));
2626 let messages = vec![msg("user", "summarize this route")];
2627
2628 assert!(api.input_max_chars > oauth.input_max_chars);
2629 assert!(should_use_cache_aligned_summary("gpt-5.5", None, &messages));
2630 assert!(!should_use_cache_aligned_summary(
2631 "gpt-5.5",
2632 Some(272_000),
2633 &messages
2634 ));
2635 }
2636
2637 #[test]
2638 fn cache_aligned_summary_is_used_for_v4_scale_contexts() {
2639 let messages = vec![msg("user", "Please edit crates/tui/src/compaction.rs")];
2640
2641 assert!(should_use_cache_aligned_summary(
2642 "deepseek-v4-flash",
2643 None,
2644 &messages
2645 ));
2646 assert!(!should_use_cache_aligned_summary(
2647 "deepseek-v3.2-128k",
2648 None,
2649 &messages
2650 ));
2651 }
2652
2653 /// #584: the summary cache-hit percentage must be computed against
2654 /// `input_tokens`, not `cache_hit + cache_miss`. Providers that
2655 /// only populate `prompt_cache_hit_tokens` (and leave the miss
2656 /// field at `None`) would otherwise be reported as a flat 100%
2657 /// hit rate even when most of the prompt was uncached.
2658 #[test]
2659 fn summary_cache_hit_percent_uses_input_tokens_as_denominator() {
2660 // Both fields populated and consistent.
2661 assert!((summary_cache_hit_percent(800, 1000) - 80.0).abs() < f64::EPSILON);
2662 // No cache hit at all.
2663 assert!((summary_cache_hit_percent(0, 1000) - 0.0).abs() < f64::EPSILON);
2664 // Full cache hit.
2665 assert!((summary_cache_hit_percent(1000, 1000) - 100.0).abs() < f64::EPSILON);
2666 // Partial-telemetry guard: provider reports `cache_hit` only,
2667 // miss is unknown (treated as 0 by the caller). Naive
2668 // `hit / (hit + miss)` would have reported 100%; against
2669 // `input_tokens` the answer is the real share.
2670 assert!((summary_cache_hit_percent(200, 1000) - 20.0).abs() < f64::EPSILON);
2671 // Defensive: zero `input_tokens` short-circuits without a
2672 // divide-by-zero.
2673 assert!((summary_cache_hit_percent(0, 0) - 0.0).abs() < f64::EPSILON);
2674 assert!((summary_cache_hit_percent(50, 0) - 0.0).abs() < f64::EPSILON);
2675 }
2676
2677 #[test]
2678 fn context_window_errors_are_detected_for_summary_fallback() {
2679 for msg in [
2680 "HTTP 400 Bad Request: maximum context length is 1000000 tokens",
2681 "invalid_request_error: prompt is too long for the current model",
2682 "You requested 1000001 tokens but the maximum is 1000000",
2683 "request exceeds context window",
2684 ] {
2685 assert!(
2686 is_context_window_error(&anyhow::anyhow!(msg)),
2687 "expected context-window detection for `{msg}`",
2688 );
2689 }
2690
2691 assert!(!is_context_window_error(&anyhow::anyhow!(
2692 "Invalid request: missing required field"
2693 )));
2694 assert!(!is_context_window_error(&anyhow::anyhow!(
2695 "503 Service Unavailable"
2696 )));
2697 }
2698
2699 #[test]
2700 fn live_state_reminder_formats_typed_runtime_facts() {
2701 let state = CompactionLiveState {
2702 mode: Some("operate".into()),
2703 permission_posture: Some("Ask".into()),
2704 background_shells: vec!["`sh_1`: `cargo test -p foo`".into()],
2705 running_workers: vec!["`agent_a` (role: implementer) — fix flaky".into()],
2706 open_approvals: vec!["shell: git push".into()],
2707 };
2708 let text = format_live_state_reminder(&state);
2709 assert!(text.contains("Live State"));
2710 assert!(text.contains("operate"));
2711 assert!(text.contains("Ask"));
2712 assert!(text.contains("cargo test"));
2713 assert!(text.contains("agent_a"));
2714 assert!(text.contains("git push"));
2715 assert!(format_live_state_reminder(&CompactionLiveState::default()).is_empty());
2716 }
2717
2718 #[test]
2719 fn project_instructions_section_reinjects_agents_md_verbatim() {
2720 let tmp = tempfile::TempDir::new().unwrap();
2721 std::fs::write(
2722 tmp.path().join("AGENTS.md"),
2723 "# Project rules\n\nNever force-push main.\n",
2724 )
2725 .unwrap();
2726 let section = project_instructions_section(Some(tmp.path()));
2727 assert!(section.contains("Project instructions"));
2728 assert!(section.contains("<project_instructions source=\"AGENTS.md\">"));
2729 assert!(section.contains("Never force-push main."));
2730 assert!(project_instructions_section(None).is_empty());
2731 }
2732
2733 #[test]
2734 fn continuation_block_retains_intent_decisions_evidence_and_inflight_tools() {
2735 let messages = vec![
2736 msg(
2737 "user",
2738 "Ship the flaky auth fix; releases are blocked until login tests pass",
2739 ),
2740 msg(
2741 "assistant",
2742 "Decision: we will pin the mock clock instead of sleeping, because the sleep \
2743 race caused the flake.",
2744 ),
2745 tool_use("t1", "Bash", json!({"command": "cargo test -p auth"})),
2746 tool_result(
2747 "t1",
2748 "test auth::login_expiry ... FAILED\nerror: 1 test failed",
2749 ),
2750 msg("user", "Now make the fix and re-run only the login tests"),
2751 tool_use("t2", "Bash", json!({"command": "cargo test -p auth login"})),
2752 ];
2753
2754 let block = build_continuation_block(&messages, &BTreeSet::new());
2755
2756 // Intent: both the original working contract and the latest ask survive
2757 // after the credential-redaction boundary.
2758 assert!(block.contains("### Task, in progress"));
2759 assert!(block.contains("releases are blocked until login tests pass"));
2760 assert!(block.contains("### Latest request"));
2761 assert!(block.contains("re-run only the login tests"));
2762 // Decisions: the accepted approach and its rationale are carried forward.
2763 assert!(block.contains("Decisions already made"));
2764 assert!(block.contains("pin the mock clock instead of sleeping"));
2765 // Evidence: verification outcomes stay attributed to the producing tool.
2766 assert!(block.contains("Evidence and verification"));
2767 assert!(block.contains("[Bash] test auth::login_expiry ... FAILED"));
2768 // Tool continuity: the unresolved dispatch is recorded, the resolved one is not.
2769 assert!(block.contains("In-flight tool state"));
2770 assert!(block.contains("cargo test -p auth login"));
2771 assert!(!block.contains("t1 — dispatched"));
2772 }
2773
2774 #[test]
2775 fn continuation_block_is_empty_for_empty_transcript() {
2776 assert!(build_continuation_block(&[], &BTreeSet::new()).is_empty());
2777 // Tool-result-only user messages carry no user text; nothing to quote.
2778 assert!(
2779 build_continuation_block(&[tool_result("tX", "plain output")], &BTreeSet::new())
2780 .is_empty()
2781 );
2782 }
2783
2784 #[test]
2785 fn continuation_block_redacts_secrets_from_every_extracted_surface() {
2786 let messages = vec![
2787 msg(
2788 "user",
2789 "Ship the auth fix\nOPENAI_API_KEY=sk-user-secret-value",
2790 ),
2791 msg(
2792 "assistant",
2793 "Decision: use token=sk-decision-secret-value for the smoke test",
2794 ),
2795 tool_result("t1", "error: provider rejected sk-evidence-secret-value"),
2796 tool_use(
2797 "t2",
2798 "Bash",
2799 json!({"api_key": "sk-tool-secret-value", "command": "cargo test -p auth"}),
2800 ),
2801 ];
2802
2803 let block = build_continuation_block(&messages, &BTreeSet::new());
2804
2805 for secret in [
2806 "sk-user-secret-value",
2807 "sk-decision-secret-value",
2808 "sk-evidence-secret-value",
2809 "sk-tool-secret-value",
2810 ] {
2811 assert!(
2812 !block.contains(secret),
2813 "continuation block leaked {secret}"
2814 );
2815 }
2816 assert!(block.contains(codewhale_config::persistence::REDACTED));
2817 assert!(block.contains("Ship the auth fix"));
2818 assert!(
2819 block.contains(r#""command":"cargo test -p auth""#),
2820 "redacting a sibling must not discard the command: {block}"
2821 );
2822 }
2823
2824 #[test]
2825 fn tool_args_preview_redacts_sensitive_first_without_dropping_siblings() {
2826 let input: serde_json::Value = serde_json::from_str(
2827 r#"{"api_key":"sk-tool-secret-value","command":"cargo test -p auth"}"#,
2828 )
2829 .unwrap();
2830
2831 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2832
2833 assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
2834 assert_eq!(preview["command"], "cargo test -p auth");
2835 }
2836
2837 #[test]
2838 fn tool_args_preview_redacts_sensitive_later_without_touching_earlier_fields() {
2839 let input: serde_json::Value =
2840 serde_json::from_str(r#"{"command":"cargo test","api_key":"plain-secret-value"}"#)
2841 .unwrap();
2842
2843 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2844
2845 assert_eq!(preview["command"], "cargo test");
2846 assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
2847 }
2848
2849 #[test]
2850 fn tool_args_preview_redacts_nested_sensitive_values_recursively() {
2851 let input: serde_json::Value = serde_json::from_str(
2852 r#"{"meta":{"token":"nested-secret","keep":"yes"},"steps":[{"password":"pw","name":"a"}]}"#,
2853 )
2854 .unwrap();
2855
2856 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2857
2858 assert_eq!(
2859 preview["meta"]["token"],
2860 codewhale_config::persistence::REDACTED
2861 );
2862 assert_eq!(preview["meta"]["keep"], "yes");
2863 assert_eq!(
2864 preview["steps"][0]["password"],
2865 codewhale_config::persistence::REDACTED
2866 );
2867 assert_eq!(preview["steps"][0]["name"], "a");
2868 }
2869
2870 #[test]
2871 fn tool_args_preview_redacts_complete_multi_word_secret_value() {
2872 let input: serde_json::Value =
2873 serde_json::from_str(r#"{"command":"run this","password":"hunter two words"}"#)
2874 .unwrap();
2875
2876 let serialized = tool_args_preview(&input);
2877 let preview: serde_json::Value = serde_json::from_str(&serialized).unwrap();
2878
2879 assert_eq!(preview["command"], "run this");
2880 assert_eq!(preview["password"], codewhale_config::persistence::REDACTED);
2881 assert!(!serialized.contains("hunter"));
2882 assert!(!serialized.contains("two words"));
2883 }
2884
2885 #[test]
2886 fn continuation_block_excludes_content_that_already_survives_in_pinned_messages() {
2887 let messages = vec![
2888 msg("user", "Keep the release blocked until verification passes"),
2889 msg("assistant", "Decision: run the exact auth regression first"),
2890 tool_result("t1", "test result: 4 passed; 0 failed"),
2891 tool_use("t2", "Bash", json!({"command": "cargo test auth"})),
2892 tool_use(
2893 "t3",
2894 "Bash",
2895 json!({"command": "cargo test already resolved"}),
2896 ),
2897 msg("assistant", "Decision: pinned tail decision"),
2898 tool_result("t3", "test result: pinned evidence"),
2899 tool_use("t4", "Bash", json!({"command": "pinned command"})),
2900 msg("user", "Now rerun the final package check"),
2901 ];
2902 let pinned = BTreeSet::from([5, 6, 7, 8]);
2903
2904 let block = build_continuation_block(&messages, &pinned);
2905
2906 assert!(block.contains("Keep the release blocked"), "{block}");
2907 assert!(block.contains("run the exact auth regression"), "{block}");
2908 assert!(block.contains("4 passed; 0 failed"), "{block}");
2909 assert!(block.contains("cargo test auth"), "{block}");
2910 assert!(!block.contains("cargo test already resolved"), "{block}");
2911 assert!(!block.contains("pinned tail decision"), "{block}");
2912 assert!(!block.contains("pinned evidence"), "{block}");
2913 assert!(!block.contains("pinned command"), "{block}");
2914 assert!(
2915 !block.contains("Now rerun the final package check"),
2916 "pinned active intent must not be duplicated: {block}"
2917 );
2918 }
2919
2920 struct FixedSummaryClient;
2921
2922 const FIXED_SUMMARY: &str = "1. Primary request and intent — migrate the session store. \
2923 2. Key technical concepts — sqlite. 7. Pending tasks — finish the fixed clock. \
2924 8. Current work — rerunning the session tests.";
2925
2926 #[async_trait::async_trait]
2927 impl crate::core::model_client::ModelClient for FixedSummaryClient {
2928 fn provider_name(&self) -> &str {
2929 "test"
2930 }
2931
2932 fn model(&self) -> &str {
2933 "test-model"
2934 }
2935
2936 async fn create_message(
2937 &self,
2938 _request: MessageRequest,
2939 ) -> anyhow::Result<crate::models::MessageResponse> {
2940 Ok(crate::models::MessageResponse {
2941 id: "summary-fixture".to_string(),
2942 r#type: "message".to_string(),
2943 role: "assistant".to_string(),
2944 content: vec![ContentBlock::Text {
2945 text: FIXED_SUMMARY.to_string(),
2946 cache_control: None,
2947 }],
2948 model: "test-model".to_string(),
2949 stop_reason: None,
2950 stop_sequence: None,
2951 container: None,
2952 usage: crate::models::Usage::default(),
2953 })
2954 }
2955
2956 async fn create_message_stream(
2957 &self,
2958 _request: MessageRequest,
2959 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
2960 anyhow::bail!("streaming is unused by compaction")
2961 }
2962
2963 async fn health_check(&self) -> anyhow::Result<bool> {
2964 Ok(true)
2965 }
2966 }
2967
2968 /// Regression for #5043: compacting a synthetic session with an active
2969 /// task, decisions, and tool results must emit a continuation block that
2970 /// retains the intent, decision, and evidence markers — and the working
2971 /// contract must survive verbatim even though its message was summarized.
2972 #[tokio::test]
2973 async fn compaction_summary_carries_continuation_block_forward() {
2974 let messages = vec![
2975 msg(
2976 "user",
2977 "Objective: migrate the session store to sqlite without breaking existing logins",
2978 ),
2979 msg(
2980 "assistant",
2981 "Decision: we will keep the login table schema and add a sessions table, \
2982 instead of rewriting auth.",
2983 ),
2984 tool_use(
2985 "t1",
2986 "Bash",
2987 json!({"command": "cargo test -p session-store"}),
2988 ),
2989 tool_result("t1", "test session_store::roundtrip ... ok\nexit code 0"),
2990 msg(
2991 "assistant",
2992 "The flake comes from time-based expiry; adding a fixed clock.",
2993 ),
2994 msg("user", "Sounds good, do it"),
2995 msg("assistant", "Working on the fixed clock now."),
2996 msg("user", "Status?"),
2997 msg("assistant", "Nearly done, rerunning the suite."),
2998 tool_use(
2999 "t2",
3000 "Bash",
3001 json!({"command": "cargo test -p session-store roundtrip"}),
3002 ),
3003 ];
3004
3005 // Prove the working contract is genuinely in the summarized set, not
3006 // saved by a pin: the guarantee must come from the continuation block.
3007 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3008 assert!(plan.summarize_indices.contains(&0));
3009
3010 let config = CompactionConfig {
3011 model: "test-model".to_string(),
3012 cache_summary: false,
3013 ..Default::default()
3014 };
3015 let (retained, summary_prompt, _) =
3016 compact_messages(&FixedSummaryClient, &messages, &config, None, None, None)
3017 .await
3018 .unwrap();
3019
3020 let Some(SystemPrompt::Blocks(blocks)) = summary_prompt else {
3021 panic!("compaction must produce a summary system block");
3022 };
3023 let text = &blocks[0].text;
3024
3025 // The model summary and the deterministic continuation block coexist.
3026 assert!(text.contains("Conversation Summary"));
3027 assert!(text.contains(FIXED_SUMMARY));
3028 assert!(text.contains("Continuation Contract (deterministic)"));
3029 // Intent: working contract verbatim despite being summarized away.
3030 assert!(text.contains(
3031 "Objective: migrate the session store to sqlite without breaking existing logins"
3032 ));
3033 // Decision marker.
3034 assert!(text.contains("keep the login table schema"));
3035 // Evidence marker, attributed to the tool.
3036 assert!(text.contains("[Bash] exit code 0"));
3037 // In-flight tool continuity: the unresolved dispatch is recorded even
3038 // though enforce_tool_call_pairs drops the orphaned call message.
3039 assert!(text.contains("In-flight tool state"));
3040 assert!(text.contains("cargo test -p session-store roundtrip"));
3041 assert!(!retained.iter().any(|m| {
3042 m.content
3043 .iter()
3044 .any(|b| matches!(b, ContentBlock::ToolUse { id, .. } if id == "t2"))
3045 }));
3046 }
3047
3048 #[test]
3049 fn degenerate_summary_detects_empty_short_and_unstructured() {
3050 assert!(is_degenerate_summary(""));
3051 assert!(is_degenerate_summary(" ok "));
3052 assert!(is_degenerate_summary(
3053 "This is a long enough free-form paragraph that has many characters but no section \
3054 headings at all so the successor cannot recover open items or in-flight edits \
3055 from structure alone."
3056 ));
3057 assert!(!is_degenerate_summary(
3058 "1. Primary request and intent — fix the flaky test in auth.\n\
3059 2. Key technical concepts — tokio, race on mutex.\n\
3060 7. Pending tasks — re-run cargo test -p auth.\n\
3061 8. Current work — editing crates/auth/src/lib.rs.\n\
3062 More padding so non-whitespace length clears the seed floor for the ladder."
3063 ));
3064 }
3065
3066 #[test]
3067 fn summary_instruction_is_a_structured_successor_brief_with_optional_focus() {
3068 let brief = summary_instruction(500, None);
3069 for section in [
3070 "1. Primary request and intent",
3071 "2. Key technical concepts",
3072 "3. Files and code sections",
3073 "4. Errors and fixes",
3074 "5. Problem solving",
3075 "6. User messages",
3076 "7. Pending tasks",
3077 "8. Current work",
3078 "9. Next step",
3079 ] {
3080 assert!(
3081 brief.contains(section),
3082 "missing section {section:?}: {brief}"
3083 );
3084 }
3085 assert!(brief.contains("under 500 words"), "{brief}");
3086 assert!(brief.contains("Do not call tools"), "{brief}");
3087 assert!(brief.contains("earlier compaction summary"), "{brief}");
3088 assert!(brief.contains(COMPACTION_LANGUAGE_CONTRACT), "{brief}");
3089 assert!(!brief.contains("focus on:"), "{brief}");
3090
3091 let focused = summary_instruction(500, Some(" the auth refactor "));
3092 assert!(focused.contains("focus on: the auth refactor"), "{focused}");
3093 let blank = summary_instruction(500, Some(" "));
3094 assert!(!blank.contains("focus on:"), "{blank}");
3095 }
3096
3097 #[test]
3098 fn formatted_summary_request_bounds_large_input() {
3099 let messages = (0..90)
3100 .map(|idx| {
3101 msg(
3102 "user",
3103 &format!("turn {idx}: {}", "中文上下文 ".repeat(1_000)),
3104 )
3105 })
3106 .collect::<Vec<_>>();
3107 let limits = summary_input_limits_for_model("deepseek-v4-pro", None);
3108
3109 let request = build_formatted_summary_request("deepseek-v4-pro", &messages, limits, None);
3110
3111 assert_eq!(request.messages.len(), 1);
3112 let ContentBlock::Text { text, .. } = &request.messages[0].content[0] else {
3113 panic!("expected summary text request");
3114 };
3115 assert!(text.contains("characters omitted before summary"));
3116 assert!(text.chars().count() <= limits.input_max_chars + 2_000);
3117 }
3118
3119 #[test]
3120 fn cache_aligned_summary_request_preserves_message_prefix() {
3121 let messages = vec![
3122 msg("user", "Please edit crates/tui/src/compaction.rs"),
3123 msg("assistant", "I will inspect the file."),
3124 ];
3125 let limits = summary_input_limits_for_model("deepseek-v4-pro", None);
3126 let request =
3127 build_cache_aligned_summary_request("deepseek-v4-pro", &messages, limits, None);
3128
3129 assert_eq!(request.system, None);
3130 assert_eq!(&request.messages[..messages.len()], &messages[..]);
3131 assert_eq!(request.messages.len(), messages.len() + 1);
3132 let last = request.messages.last().expect("summary instruction");
3133 assert_eq!(last.role, "user");
3134 assert!(matches!(
3135 &last.content[..],
3136 [ContentBlock::Text { text, .. }] if text.contains("successor briefing")
3137 ));
3138 }
3139
3140 #[test]
3141 fn estimate_tokens_empty_messages() {
3142 let messages: Vec<Message> = vec![];
3143 assert_eq!(estimate_tokens(&messages), 0);
3144 }
3145
3146 #[test]
3147 fn estimate_tokens_with_text() {
3148 let messages = vec![Message {
3149 role: "user".to_string(),
3150 content: vec![ContentBlock::Text {
3151 text: "Hello, world!".to_string(), // 13 chars = ~3 tokens
3152 cache_control: None,
3153 }],
3154 }];
3155 let tokens = estimate_tokens(&messages);
3156 assert!(tokens > 0 && tokens < 10);
3157 }
3158
3159 #[test]
3160 fn estimate_tokens_counts_tool_round_thinking_across_turns() {
3161 // Per DeepSeek thinking-mode rules, any assistant message that
3162 // performed a tool call keeps its reasoning_content in the request
3163 // forever, including across new user turns. Token estimates must
3164 // count those bytes.
3165 let thinking = "reasoning ".repeat(800);
3166 let current_messages = vec![
3167 Message {
3168 role: "user".to_string(),
3169 content: vec![ContentBlock::Text {
3170 text: "Use a tool".to_string(),
3171 cache_control: None,
3172 }],
3173 },
3174 Message {
3175 role: "assistant".to_string(),
3176 content: vec![
3177 ContentBlock::Thinking {
3178 signature: None,
3179 thinking: thinking.clone(),
3180 },
3181 ContentBlock::ToolUse {
3182 id: "tool-1".to_string(),
3183 name: "read_file".to_string(),
3184 input: serde_json::json!({"path": "Cargo.toml"}),
3185 caller: None,
3186 },
3187 ],
3188 },
3189 Message {
3190 role: "user".to_string(),
3191 content: vec![ContentBlock::ToolResult {
3192 tool_use_id: "tool-1".to_string(),
3193 content: "manifest".to_string(),
3194 is_error: None,
3195 content_blocks: None,
3196 }],
3197 },
3198 ];
3199 let historical_messages = {
3200 let mut messages = current_messages.clone();
3201 messages.push(Message {
3202 role: "assistant".to_string(),
3203 content: vec![ContentBlock::Text {
3204 text: "Done.".to_string(),
3205 cache_control: None,
3206 }],
3207 });
3208 messages.push(Message {
3209 role: "user".to_string(),
3210 content: vec![ContentBlock::Text {
3211 text: "Next question.".to_string(),
3212 cache_control: None,
3213 }],
3214 });
3215 messages
3216 };
3217 let completed_messages = {
3218 let mut messages = current_messages.clone();
3219 messages.push(Message {
3220 role: "assistant".to_string(),
3221 content: vec![ContentBlock::Text {
3222 text: "Done.".to_string(),
3223 cache_control: None,
3224 }],
3225 });
3226 messages
3227 };
3228
3229 let lower_bound = thinking.len() / 5;
3230 assert!(estimate_tokens(&current_messages) > lower_bound);
3231 assert!(estimate_tokens(&completed_messages) > lower_bound);
3232 assert!(estimate_tokens(&historical_messages) > lower_bound);
3233 }
3234
3235 #[test]
3236 fn should_compact_respects_enabled_flag() {
3237 let config = CompactionConfig {
3238 enabled: false,
3239 ..Default::default()
3240 };
3241 // Even with many messages, disabled compaction should return false
3242 let messages: Vec<Message> = (0..100)
3243 .map(|_| Message {
3244 role: "user".to_string(),
3245 content: vec![ContentBlock::Text {
3246 text: "test".to_string(),
3247 cache_control: None,
3248 }],
3249 })
3250 .collect();
3251 assert!(!should_compact(&messages, &config, None, None, None));
3252 }
3253
3254 /// v0.8.11: message-count is no longer a compaction trigger. Long
3255 /// chats of small messages stay uncompacted because rewriting the
3256 /// prefix cache for a tiny budget reclaim is net-negative. Only token
3257 /// pressure (and the explicit `/compact` slash command) trigger
3258 /// compaction.
3259 #[test]
3260 fn message_count_no_longer_triggers_compaction() {
3261 let config = CompactionConfig {
3262 enabled: true,
3263 token_threshold: 1_000_000,
3264 ..Default::default()
3265 };
3266
3267 // 200 tiny messages, well above the prior message threshold.
3268 let many_messages: Vec<Message> = (0..200)
3269 .map(|_| Message {
3270 role: "user".to_string(),
3271 content: vec![ContentBlock::Text {
3272 text: "x".to_string(),
3273 cache_control: None,
3274 }],
3275 })
3276 .collect();
3277 // Token total stays minuscule so the token threshold is not hit;
3278 // without the prior message-count trigger, no compaction.
3279 assert!(!should_compact(&many_messages, &config, None, None, None));
3280 }
3281
3282 #[test]
3283 fn plan_compaction_pins_recent_and_working_set_paths() {
3284 let messages = vec![
3285 msg("user", "General discussion"),
3286 msg("assistant", "Unrelated note"),
3287 msg("user", "Earlier we touched src/core/engine.rs"),
3288 msg("assistant", "More unrelated chatter"),
3289 msg("user", "Let's keep working on src/core/engine.rs"),
3290 msg("assistant", "Tool output mentions src/core/engine.rs too"),
3291 msg("assistant", "Recent reasoning"),
3292 msg("user", "Final recent instruction"),
3293 ];
3294
3295 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3296
3297 assert!(plan.pinned_indices.contains(&2));
3298 for idx in 4..messages.len() {
3299 assert!(plan.pinned_indices.contains(&idx));
3300 }
3301 assert!(plan.summarize_indices.contains(&0));
3302 assert!(plan.summarize_indices.contains(&1));
3303 assert!(plan.summarize_indices.contains(&3));
3304 }
3305
3306 #[test]
3307 fn plan_compaction_respects_external_pins() {
3308 let messages = vec![
3309 msg("user", "noise 0"),
3310 msg("assistant", "noise 1"),
3311 msg("user", "noise 2"),
3312 msg("assistant", "noise 3"),
3313 msg("user", "recent 4"),
3314 msg("assistant", "recent 5"),
3315 msg("assistant", "recent 6"),
3316 msg("user", "recent 7"),
3317 ];
3318
3319 let pins = vec![1usize];
3320 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, Some(&pins), None);
3321
3322 assert!(plan.pinned_indices.contains(&1));
3323 assert!(!plan.summarize_indices.contains(&1));
3324 }
3325
3326 #[test]
3327 fn plan_compaction_uses_external_working_set_paths() {
3328 let mut messages = vec![msg("user", "edit src/core/engine.rs now")];
3329 messages.extend((1..20).map(|i| msg("assistant", &format!("noise {i}"))));
3330
3331 let working_set_paths = vec!["src/core/engine.rs".to_string()];
3332 let plan = plan_compaction(
3333 &messages,
3334 None,
3335 KEEP_RECENT_MESSAGES,
3336 None,
3337 Some(&working_set_paths),
3338 );
3339
3340 assert!(plan.pinned_indices.contains(&0));
3341 }
3342
3343 #[test]
3344 fn plan_compaction_pins_edited_python_typescript_and_go_paths() {
3345 let messages = vec![
3346 msg("user", "start working"),
3347 Message {
3348 role: "assistant".to_string(),
3349 content: vec![ContentBlock::ToolUse {
3350 id: "py-edit".to_string(),
3351 name: "write_file".to_string(),
3352 input: json!({"path": "src/worker.py"}),
3353 caller: None,
3354 }],
3355 },
3356 Message {
3357 role: "user".to_string(),
3358 content: vec![ContentBlock::ToolResult {
3359 tool_use_id: "py-edit".to_string(),
3360 content: "wrote src/worker.py".to_string(),
3361 is_error: None,
3362 content_blocks: None,
3363 }],
3364 },
3365 Message {
3366 role: "assistant".to_string(),
3367 content: vec![ContentBlock::ToolUse {
3368 id: "ts-edit".to_string(),
3369 name: "write_file".to_string(),
3370 input: json!({"path": "web/app.tsx"}),
3371 caller: None,
3372 }],
3373 },
3374 Message {
3375 role: "user".to_string(),
3376 content: vec![ContentBlock::ToolResult {
3377 tool_use_id: "ts-edit".to_string(),
3378 content: "wrote web/app.tsx".to_string(),
3379 is_error: None,
3380 content_blocks: None,
3381 }],
3382 },
3383 Message {
3384 role: "assistant".to_string(),
3385 content: vec![ContentBlock::ToolUse {
3386 id: "go-edit".to_string(),
3387 name: "write_file".to_string(),
3388 input: json!({"path": "cmd/server/main.go"}),
3389 caller: None,
3390 }],
3391 },
3392 Message {
3393 role: "user".to_string(),
3394 content: vec![ContentBlock::ToolResult {
3395 tool_use_id: "go-edit".to_string(),
3396 content: "wrote cmd/server/main.go".to_string(),
3397 is_error: None,
3398 content_blocks: None,
3399 }],
3400 },
3401 msg("user", "continue with the next task"),
3402 ];
3403
3404 let plan = plan_compaction(&messages, None, 1, None, None);
3405 for idx in [1, 2, 3, 4, 5, 6] {
3406 assert!(
3407 plan.pinned_indices.contains(&idx),
3408 "edited source message {idx} should be pinned"
3409 );
3410 }
3411 }
3412
3413 #[test]
3414 fn plan_compaction_excludes_dependency_build_lock_and_minified_paths() {
3415 let messages = vec![
3416 msg("user", "start working"),
3417 Message {
3418 role: "assistant".to_string(),
3419 content: vec![ContentBlock::ToolUse {
3420 id: "junk-1".to_string(),
3421 name: "write_file".to_string(),
3422 input: json!({"path": "node_modules/pkg/index.js"}),
3423 caller: None,
3424 }],
3425 },
3426 Message {
3427 role: "user".to_string(),
3428 content: vec![ContentBlock::ToolResult {
3429 tool_use_id: "junk-1".to_string(),
3430 content: "wrote node_modules/pkg/index.js".to_string(),
3431 is_error: None,
3432 content_blocks: None,
3433 }],
3434 },
3435 msg("assistant", "target/debug/generated.rs"),
3436 msg("assistant", "dist/app.min.js"),
3437 msg("assistant", "package-lock.json"),
3438 msg("assistant", "workspace.lock"),
3439 msg("user", "continue with the next task"),
3440 ];
3441
3442 let plan = plan_compaction(&messages, None, 1, None, None);
3443 for idx in 1..7 {
3444 assert!(
3445 !plan.pinned_indices.contains(&idx),
3446 "junk path message {idx} should not be newly pinned"
3447 );
3448 }
3449 }
3450
3451 #[test]
3452 fn plan_compaction_pins_tool_calls_for_tool_results() {
3453 let messages = vec![
3454 msg("user", "noise"),
3455 Message {
3456 role: "assistant".to_string(),
3457 content: vec![ContentBlock::ToolUse {
3458 id: "tool-1".to_string(),
3459 name: "read_file".to_string(),
3460 input: json!({"path": "src/main.rs"}),
3461 caller: None,
3462 }],
3463 },
3464 Message {
3465 role: "user".to_string(),
3466 content: vec![ContentBlock::ToolResult {
3467 tool_use_id: "tool-1".to_string(),
3468 content: "ok src/main.rs".to_string(),
3469 is_error: None,
3470 content_blocks: None,
3471 }],
3472 },
3473 ];
3474
3475 let plan = plan_compaction(&messages, None, 1, None, None);
3476 assert!(plan.pinned_indices.contains(&2));
3477 assert!(plan.pinned_indices.contains(&1));
3478 }
3479
3480 #[test]
3481 fn should_compact_ignores_fully_pinned_context() {
3482 let config = CompactionConfig {
3483 enabled: true,
3484 token_threshold: 10,
3485 ..Default::default()
3486 };
3487
3488 let messages: Vec<Message> = (0..12)
3489 .map(|_| msg("user", "Work on src/compaction.rs right now"))
3490 .collect();
3491
3492 assert!(!should_compact(&messages, &config, None, None, None));
3493 }
3494
3495 // v0.8.11: removed `should_compact_counts_only_unpinned_messages` and
3496 // `should_compact_when_pins_consume_budget` — both tested the
3497 // message-count compaction trigger that v0.8.11 deleted. The
3498 // pinned-tokens accounting they exercised is still tested by
3499 // `should_compact_ignores_fully_pinned_context` below; the rest of
3500 // their setup has no contemporary contract to pin.
3501
3502 #[test]
3503 fn enforce_tool_call_pairs_removes_orphaned_tool_call() {
3504 // An assistant message with a tool call but no matching result anywhere
3505 // in the history should be removed from the pinned set.
3506 let messages = vec![
3507 msg("user", "noise"),
3508 Message {
3509 role: "assistant".to_string(),
3510 content: vec![ContentBlock::ToolUse {
3511 id: "orphan-call".to_string(),
3512 name: "read_file".to_string(),
3513 input: json!({"path": "src/main.rs"}),
3514 caller: None,
3515 }],
3516 },
3517 msg("assistant", "recent"),
3518 ];
3519
3520 let mut pinned = BTreeSet::from([0, 1, 2]);
3521 enforce_tool_call_pairs(&messages, &mut pinned);
3522
3523 // The orphaned tool call message (index 1) should be removed.
3524 assert!(
3525 !pinned.contains(&1),
3526 "orphaned tool call should be removed from pinned set"
3527 );
3528 // Other messages stay.
3529 assert!(pinned.contains(&0));
3530 assert!(pinned.contains(&2));
3531 }
3532
3533 #[test]
3534 fn enforce_tool_call_pairs_removes_orphaned_tool_result() {
3535 // A tool result whose call doesn't exist anywhere should be removed.
3536 let messages = vec![
3537 msg("user", "noise"),
3538 Message {
3539 role: "user".to_string(),
3540 content: vec![ContentBlock::ToolResult {
3541 tool_use_id: "orphan-result".to_string(),
3542 content: "ok".to_string(),
3543 is_error: None,
3544 content_blocks: None,
3545 }],
3546 },
3547 msg("assistant", "recent"),
3548 ];
3549
3550 let mut pinned = BTreeSet::from([0, 1, 2]);
3551 enforce_tool_call_pairs(&messages, &mut pinned);
3552
3553 assert!(
3554 !pinned.contains(&1),
3555 "orphaned tool result should be removed from pinned set"
3556 );
3557 assert!(pinned.contains(&0));
3558 assert!(pinned.contains(&2));
3559 }
3560
3561 #[test]
3562 fn enforce_tool_call_pairs_preserves_valid_pairs() {
3563 // A complete call+result pair should remain intact.
3564 let messages = vec![
3565 msg("user", "do something"),
3566 Message {
3567 role: "assistant".to_string(),
3568 content: vec![ContentBlock::ToolUse {
3569 id: "tool-ok".to_string(),
3570 name: "list_dir".to_string(),
3571 input: json!({}),
3572 caller: None,
3573 }],
3574 },
3575 Message {
3576 role: "user".to_string(),
3577 content: vec![ContentBlock::ToolResult {
3578 tool_use_id: "tool-ok".to_string(),
3579 content: "files here".to_string(),
3580 is_error: None,
3581 content_blocks: None,
3582 }],
3583 },
3584 msg("assistant", "done"),
3585 ];
3586
3587 let mut pinned = BTreeSet::from([1, 2, 3]);
3588 enforce_tool_call_pairs(&messages, &mut pinned);
3589
3590 assert!(pinned.contains(&1), "tool call should stay pinned");
3591 assert!(pinned.contains(&2), "tool result should stay pinned");
3592 assert!(pinned.contains(&3));
3593 }
3594
3595 #[test]
3596 fn enforce_tool_call_pairs_pins_transitive_pairs() {
3597 // If only the result is initially pinned, the call should be pulled in.
3598 // The call message may also contain another tool call whose result should
3599 // then be pulled in transitively.
3600 let messages = vec![
3601 msg("user", "start"),
3602 Message {
3603 role: "assistant".to_string(),
3604 content: vec![
3605 ContentBlock::ToolUse {
3606 id: "t1".to_string(),
3607 name: "read_file".to_string(),
3608 input: json!({"path": "a.rs"}),
3609 caller: None,
3610 },
3611 ContentBlock::ToolUse {
3612 id: "t2".to_string(),
3613 name: "read_file".to_string(),
3614 input: json!({"path": "b.rs"}),
3615 caller: None,
3616 },
3617 ],
3618 },
3619 Message {
3620 role: "user".to_string(),
3621 content: vec![ContentBlock::ToolResult {
3622 tool_use_id: "t1".to_string(),
3623 content: "content of a.rs".to_string(),
3624 is_error: None,
3625 content_blocks: None,
3626 }],
3627 },
3628 Message {
3629 role: "user".to_string(),
3630 content: vec![ContentBlock::ToolResult {
3631 tool_use_id: "t2".to_string(),
3632 content: "content of b.rs".to_string(),
3633 is_error: None,
3634 content_blocks: None,
3635 }],
3636 },
3637 msg("assistant", "done"),
3638 ];
3639
3640 // Only pin the result for t1 initially.
3641 let mut pinned = BTreeSet::from([2, 4]);
3642 enforce_tool_call_pairs(&messages, &mut pinned);
3643
3644 // The call message (index 1) should be pulled in because t1's result is pinned.
3645 assert!(
3646 pinned.contains(&1),
3647 "call message should be transitively pinned"
3648 );
3649 // Since the call message also contains t2, t2's result (index 3) should also be pinned.
3650 assert!(
3651 pinned.contains(&3),
3652 "t2 result should be transitively pinned via the call message"
3653 );
3654 }
3655
3656 #[test]
3657 fn enforce_tool_call_pairs_cascading_removal() {
3658 // Removing an orphaned call should cascade to remove its result.
3659 // Message 1: assistant with t1 (call) — t1 has a result at index 2
3660 // Message 2: user with t1 (result)
3661 // Message 3: assistant with t2 (call) — t2 has NO result
3662 // Message 4: user with t2 result referencing the call
3663 //
3664 // If t2 has no result in history, message 3 is removed. That's straightforward.
3665 // Here we test: if a call message is removed because ONE of its calls is orphaned,
3666 // the result for the other call also gets removed in subsequent iterations.
3667 let messages = vec![
3668 msg("user", "start"),
3669 Message {
3670 role: "assistant".to_string(),
3671 content: vec![
3672 ContentBlock::ToolUse {
3673 id: "good".to_string(),
3674 name: "read_file".to_string(),
3675 input: json!({}),
3676 caller: None,
3677 },
3678 ContentBlock::ToolUse {
3679 id: "orphan".to_string(),
3680 name: "shell".to_string(),
3681 input: json!({}),
3682 caller: None,
3683 },
3684 ],
3685 },
3686 Message {
3687 role: "user".to_string(),
3688 content: vec![ContentBlock::ToolResult {
3689 tool_use_id: "good".to_string(),
3690 content: "ok".to_string(),
3691 is_error: None,
3692 content_blocks: None,
3693 }],
3694 },
3695 // Note: NO result for "orphan" exists anywhere
3696 msg("assistant", "done"),
3697 ];
3698
3699 let mut pinned = BTreeSet::from([1, 2, 3]);
3700 enforce_tool_call_pairs(&messages, &mut pinned);
3701
3702 // Message 1 has an orphaned tool call ("orphan"), so it's removed.
3703 assert!(
3704 !pinned.contains(&1),
3705 "message with orphaned call should be removed"
3706 );
3707 // Message 2 (result for "good") now has no matching call pinned, so it's also removed.
3708 assert!(
3709 !pinned.contains(&2),
3710 "result whose call was removed should cascade-remove"
3711 );
3712 // Message 3 (plain text) stays.
3713 assert!(pinned.contains(&3));
3714 }
3715
3716 #[test]
3717 fn enforce_tool_call_pairs_converges_long_chain() {
3718 let mut messages = vec![msg("user", "start")];
3719 for i in 0..15 {
3720 messages.push(Message {
3721 role: "assistant".to_string(),
3722 content: vec![ContentBlock::ToolUse {
3723 id: format!("t{i}"),
3724 name: "read_file".to_string(),
3725 input: json!({}),
3726 caller: None,
3727 }],
3728 });
3729 messages.push(Message {
3730 role: "user".to_string(),
3731 content: vec![ContentBlock::ToolResult {
3732 tool_use_id: format!("t{i}"),
3733 content: format!("result {i}"),
3734 is_error: None,
3735 content_blocks: None,
3736 }],
3737 });
3738 }
3739 messages.push(msg("assistant", "done"));
3740
3741 let mut pinned: BTreeSet<usize> = (0..messages.len()).collect();
3742 enforce_tool_call_pairs(&messages, &mut pinned);
3743
3744 // All pairs should remain intact (no orphans)
3745 assert_eq!(pinned.len(), messages.len());
3746 }
3747
3748 #[test]
3749 fn plan_compaction_keeps_at_least_one_user_text_query() {
3750 let mut messages = vec![msg(
3751 "user",
3752 "This is the original query that started the chain.",
3753 )];
3754
3755 for i in 0..10 {
3756 messages.push(Message {
3757 role: "assistant".to_string(),
3758 content: vec![ContentBlock::ToolUse {
3759 id: format!("call-{i}"),
3760 name: "test_tool".to_string(),
3761 input: json!({}),
3762 caller: None,
3763 }],
3764 });
3765 messages.push(Message {
3766 role: "user".to_string(),
3767 content: vec![ContentBlock::ToolResult {
3768 tool_use_id: format!("call-{i}"),
3769 content: "tool output".to_string(),
3770 is_error: None,
3771 content_blocks: None,
3772 }],
3773 });
3774 }
3775
3776 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3777
3778 assert!(plan.pinned_indices.contains(&0));
3779 }
3780
3781 // ========================================================================
3782 // Additional Compaction Trigger Tests
3783 // ========================================================================
3784
3785 #[test]
3786 fn test_should_compact_token_threshold_triggers() {
3787 let config = CompactionConfig {
3788 enabled: true,
3789 token_threshold: 100, // Low threshold for testing
3790 ..Default::default()
3791 };
3792
3793 // Create messages that exceed token threshold
3794 let messages: Vec<Message> = (0..10)
3795 .map(|_| msg("user", &"x".repeat(50))) // 50 chars = ~12 tokens each
3796 .collect();
3797
3798 // Total tokens: ~120, which exceeds 100
3799 assert!(should_compact(&messages, &config, None, None, None));
3800 }
3801
3802 #[test]
3803 fn test_should_compact_below_token_threshold() {
3804 let config = CompactionConfig {
3805 enabled: true,
3806 token_threshold: 1000,
3807 ..Default::default()
3808 };
3809
3810 // Create short messages
3811 let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
3812
3813 assert!(!should_compact(&messages, &config, None, None, None));
3814 }
3815
3816 #[test]
3817 fn auto_compaction_uses_token_threshold_without_fixed_floor() {
3818 let config = CompactionConfig {
3819 enabled: true,
3820 token_threshold: 100,
3821 ..Default::default()
3822 };
3823
3824 let messages: Vec<Message> = (0..10).map(|_| msg("user", &"x".repeat(50))).collect();
3825 assert!(should_compact(&messages, &config, None, None, None));
3826 }
3827
3828 #[test]
3829 fn test_plan_compaction_pins_error_messages() {
3830 let messages = vec![
3831 msg("user", "normal message"),
3832 msg("assistant", "error: compilation failed"),
3833 msg("user", "another message"),
3834 msg("assistant", "panic at src/main.rs:42"),
3835 msg("user", "more chat"),
3836 msg("assistant", "Traceback (most recent call last):"),
3837 msg("user", "recent 1"),
3838 msg("assistant", "recent 2"),
3839 ];
3840
3841 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3842
3843 // Error messages should be pinned
3844 assert!(plan.pinned_indices.contains(&1)); // error:
3845 assert!(plan.pinned_indices.contains(&3)); // panic
3846 assert!(plan.pinned_indices.contains(&5)); // traceback
3847 }
3848
3849 #[test]
3850 fn test_plan_compaction_pins_patch_messages() {
3851 let messages = vec![
3852 msg("user", "normal chat"),
3853 msg("assistant", "diff --git a/src/main.rs b/src/main.rs"),
3854 msg("user", "more chat"),
3855 msg("assistant", "+++ b/src/core.rs"),
3856 msg("user", "chat"),
3857 msg("assistant", "```diff\n-some code\n+new code\n```"),
3858 msg("user", "recent 1"),
3859 msg("assistant", "recent 2"),
3860 ];
3861
3862 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3863
3864 // Patch/diff messages should be pinned
3865 assert!(plan.pinned_indices.contains(&1)); // diff --git
3866 assert!(plan.pinned_indices.contains(&3)); // +++ b/
3867 assert!(plan.pinned_indices.contains(&5)); // ```diff
3868 }
3869
3870 #[test]
3871 fn test_plan_compaction_pins_apply_patch_tool_calls() {
3872 let messages = vec![
3873 msg("user", "normal chat"),
3874 Message {
3875 role: "assistant".to_string(),
3876 content: vec![ContentBlock::ToolUse {
3877 id: "patch-1".to_string(),
3878 name: "apply_patch".to_string(),
3879 input: json!({"patch": "diff content"}),
3880 caller: None,
3881 }],
3882 },
3883 Message {
3884 role: "user".to_string(),
3885 content: vec![ContentBlock::ToolResult {
3886 tool_use_id: "patch-1".to_string(),
3887 content: "Patch applied successfully".to_string(),
3888 is_error: None,
3889 content_blocks: None,
3890 }],
3891 },
3892 msg("assistant", "more chat"),
3893 msg("user", "even more"),
3894 msg("assistant", "recent 1"),
3895 msg("user", "recent 2"),
3896 msg("assistant", "recent 3"),
3897 ];
3898
3899 let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
3900
3901 // Message 1 contains apply_patch tool call with matching result (message 2)
3902 // Both should be pinned due to tool call pairing
3903 // Messages 5, 6, 7, 8 are recent (last 4 messages)
3904 eprintln!("Pinned indices: {:?}", plan.pinned_indices);
3905
3906 // apply_patch tool call and its result should be pinned
3907 assert!(
3908 plan.pinned_indices.contains(&1),
3909 "apply_patch tool call should be pinned"
3910 );
3911 assert!(
3912 plan.pinned_indices.contains(&2),
3913 "apply_patch tool result should be pinned"
3914 );
3915 }
3916
3917 #[test]
3918 fn test_extract_paths_from_text_finds_various_formats() {
3919 let text = r#"
3920 I'm working on src/main.rs
3921 Also check Cargo.toml
3922 The error is in src/core/engine.rs:42
3923 See docs/API.md for details
3924 Config at config.example.toml
3925 "#;
3926
3927 let paths = extract_paths_from_text(text, None);
3928
3929 assert!(paths.iter().any(|p| p == "src/main.rs"));
3930 assert!(paths.iter().any(|p| p == "Cargo.toml"));
3931 assert!(paths.iter().any(|p| p == "src/core/engine.rs"));
3932 assert!(paths.iter().any(|p| p == "docs/API.md"));
3933 assert!(paths.iter().any(|p| p == "config.example.toml"));
3934 }
3935
3936 #[test]
3937 fn test_extract_paths_from_tool_input_finds_path_field() {
3938 let input = json!({
3939 "path": "src/main.rs",
3940 "content": "test"
3941 });
3942
3943 let paths = extract_paths_from_tool_input(&input, None);
3944 assert!(paths.iter().any(|p| p == "src/main.rs"));
3945 }
3946
3947 #[test]
3948 fn test_extract_paths_from_tool_input_finds_paths_array() {
3949 let input = json!({
3950 "paths": ["src/main.rs", "src/core.rs", "tests/test.rs"]
3951 });
3952
3953 let paths = extract_paths_from_tool_input(&input, None);
3954 assert_eq!(paths.len(), 3);
3955 assert!(paths.iter().any(|p| p == "src/main.rs"));
3956 assert!(paths.iter().any(|p| p == "src/core.rs"));
3957 assert!(paths.iter().any(|p| p == "tests/test.rs"));
3958 }
3959
3960 #[test]
3961 fn test_extract_paths_from_tool_input_finds_cwd() {
3962 let input = json!({
3963 "cwd": "src/core",
3964 "command": "cargo build"
3965 });
3966
3967 let paths = extract_paths_from_tool_input(&input, None);
3968 assert!(paths.iter().any(|p| p == "src/core"));
3969 }
3970
3971 #[test]
3972 fn test_normalize_path_candidate_handles_absolute_paths() {
3973 use std::env;
3974 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3975
3976 // Create an absolute path
3977 let absolute_path = current_dir.join("src/main.rs");
3978 let absolute_path_str = absolute_path.to_string_lossy();
3979
3980 let normalized = normalize_path_candidate(&absolute_path_str, Some(&current_dir));
3981
3982 assert_eq!(normalized, Some("src/main.rs".to_string()));
3983 }
3984
3985 #[test]
3986 fn test_normalize_path_candidate_rejects_parent_refs() {
3987 let normalized = normalize_path_candidate("../outside/file.rs", Some(&PathBuf::from(".")));
3988 assert_eq!(normalized, None);
3989 }
3990
3991 #[test]
3992 fn test_normalize_path_candidate_cleans_backslashes() {
3993 let normalized = normalize_path_candidate("src\\main.rs", Some(&PathBuf::from(".")));
3994 assert_eq!(normalized, Some("src/main.rs".to_string()));
3995 }
3996
3997 #[test]
3998 fn test_merge_system_prompts_none_none() {
3999 let result = merge_system_prompts(None, None);
4000 assert!(result.is_none());
4001 }
4002
4003 #[test]
4004 fn test_merge_system_prompts_some_text_none() {
4005 let original = Some(SystemPrompt::Text("original".to_string()));
4006 let result = merge_system_prompts(original.as_ref(), None);
4007 assert!(matches!(result, Some(SystemPrompt::Text(s)) if s == "original"));
4008 }
4009
4010 #[test]
4011 fn test_merge_system_prompts_none_some_blocks() {
4012 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
4013 block_type: "text".to_string(),
4014 text: "summary".to_string(),
4015 cache_control: None,
4016 }]));
4017 let result = merge_system_prompts(None, summary);
4018 assert!(matches!(result, Some(SystemPrompt::Blocks(b)) if b.len() == 1));
4019 }
4020
4021 #[test]
4022 fn test_merge_system_prompts_text_plus_blocks() {
4023 let original = Some(SystemPrompt::Text("original".to_string()));
4024 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
4025 block_type: "text".to_string(),
4026 text: "summary".to_string(),
4027 cache_control: None,
4028 }]));
4029
4030 let result = merge_system_prompts(original.as_ref(), summary);
4031
4032 match result {
4033 Some(SystemPrompt::Blocks(blocks)) => {
4034 assert_eq!(blocks.len(), 2);
4035 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
4036 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
4037 }
4038 _ => panic!("Expected Blocks"),
4039 }
4040 }
4041
4042 #[test]
4043 fn test_merge_system_prompts_blocks_plus_blocks() {
4044 let original = Some(SystemPrompt::Blocks(vec![
4045 SystemBlock {
4046 block_type: "text".to_string(),
4047 text: "orig1".to_string(),
4048 cache_control: None,
4049 },
4050 SystemBlock {
4051 block_type: "text".to_string(),
4052 text: "orig2".to_string(),
4053 cache_control: None,
4054 },
4055 ]));
4056
4057 let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
4058 block_type: "text".to_string(),
4059 text: "summary".to_string(),
4060 cache_control: None,
4061 }]));
4062
4063 let result = merge_system_prompts(original.as_ref(), summary);
4064
4065 match result {
4066 Some(SystemPrompt::Blocks(blocks)) => {
4067 assert_eq!(blocks.len(), 3);
4068 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "orig1"));
4069 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "orig2"));
4070 assert!(matches!(&blocks[2], SystemBlock { text, .. } if text == "summary"));
4071 }
4072 _ => panic!("Expected Blocks"),
4073 }
4074 }
4075
4076 #[test]
4077 fn test_merge_system_prompts_blocks_plus_text() {
4078 let original = Some(SystemPrompt::Blocks(vec![SystemBlock {
4079 block_type: "text".to_string(),
4080 text: "original".to_string(),
4081 cache_control: None,
4082 }]));
4083
4084 let summary = Some(SystemPrompt::Text("summary".to_string()));
4085
4086 let result = merge_system_prompts(original.as_ref(), summary);
4087
4088 match result {
4089 Some(SystemPrompt::Blocks(blocks)) => {
4090 assert_eq!(blocks.len(), 2);
4091 assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
4092 assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
4093 }
4094 _ => panic!("Expected Blocks"),
4095 }
4096 }
4097
4098 #[test]
4099 fn test_compaction_result_retries_used() {
4100 // This test verifies the CompactionResult structure
4101 let result = CompactionResult {
4102 messages: vec![],
4103 summary_prompt: None,
4104 retries_used: 2,
4105 };
4106
4107 assert_eq!(result.retries_used, 2);
4108 assert!(result.messages.is_empty());
4109 }
4110
4111 #[test]
4112 fn test_should_compact_with_workspace_path_detection() {
4113 use std::env;
4114 let workspace = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4115
4116 let _config = CompactionConfig {
4117 enabled: true,
4118 token_threshold: 1000,
4119 ..Default::default()
4120 };
4121
4122 // Create messages mentioning workspace paths
4123 let messages = vec![
4124 msg("user", "working on src/main.rs"),
4125 msg("assistant", "noise 1"),
4126 msg("user", "noise 2"),
4127 msg("assistant", "noise 3"),
4128 msg("user", "noise 4"),
4129 msg("assistant", "noise 5"),
4130 msg("user", "recent 1"),
4131 msg("assistant", "recent 2"),
4132 ];
4133
4134 // src/main.rs mention should pin message 0 in the plan.
4135 let plan = plan_compaction(
4136 &messages,
4137 Some(&workspace),
4138 KEEP_RECENT_MESSAGES,
4139 None,
4140 None,
4141 );
4142 assert!(plan.pinned_indices.contains(&0)); // src/main.rs mention
4143 }
4144 }
4145
4145 lines RUST