返回 CodeWhale
compaction.rs
根目录 / crates / tui / src / compaction.rs
1 //! Context compaction for long conversations.
2
3 use anyhow::Result;
4 use std::collections::HashMap;
5 use std::fmt::Write;
6 use std::time::Duration;
7
8 use crate::config::DEFAULT_TEXT_MODEL;
9 use crate::core::model_client::ModelClient;
10 use crate::logging;
11 use codewhale_models::Role;
12 use codewhale_models::{
13 CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt, Tool, Usage,
14 };
15
16 #[path = "compaction/last_round.rs"]
17 mod last_round;
18 #[cfg(test)]
19 #[path = "compaction/survival_contract.rs"]
20 mod survival_contract;
21 pub(crate) use last_round::last_round_start;
22 pub use last_round::{
23 CompactionCoverage, CompactionKeep, CompactionPath, LastCompactionSnapshot,
24 inspect_compaction_keep, last_round_kept_count, pinned_anchors_text,
25 };
26
27 /// Configuration for conversation compaction behavior.
28 ///
29 /// v0.8.11 simplified this from the prior token-OR-message-count trigger
30 /// to a token-only trigger. The
31 /// `message_threshold` field was removed: its only purpose was to fire
32 /// compaction on long sessions of small messages, which is exactly the
33 /// case where rewriting the prefix cache is least valuable. Token
34 /// budget is the right signal; message count was a 128K-era heuristic.
35 #[derive(Debug, Clone, PartialEq)]
36 pub struct CompactionConfig {
37 pub enabled: bool,
38 pub token_threshold: usize,
39 pub model: String,
40 /// Exact route image-input fact for the summarizer's outbound history.
41 pub image_input: crate::model_profile::SupportState,
42 /// Route-effective context window. `None` preserves compatibility for
43 /// callers that have not resolved a provider route yet.
44 pub effective_context_window: Option<u32>,
45 pub cache_summary: bool,
46 /// Optional user-supplied focus for a manual `/compact <focus>`: injected
47 /// into the summary request so the checkpoint weights what the user
48 /// said matters. `None` for automatic compaction.
49 pub focus: Option<String>,
50 /// Runtime turn that owns provider calls made by this compaction pass.
51 /// `None` for the foreground TUI. This is accounting provenance only and
52 /// is never included in a provider request.
53 pub runtime_cost_owner: Option<String>,
54 /// Workspace root, used only to re-state the user's `/anchor` file after
55 /// the summary. `None` skips anchors.
56 pub workspace: Option<std::path::PathBuf>,
57 /// Standing operator instructions from `[compaction] summary_instructions`
58 /// (#5956), appended to the summarizer prompt on every pass — manual and
59 /// automatic. `None` keeps the built-in prompt byte-identical. A manual
60 /// `/compact <focus>` still composes after this text.
61 pub summary_instructions: Option<String>,
62 /// Verbatim retention budget for recent plain user messages in the
63 /// replacement history (`[compaction] retained_user_message_tokens`,
64 /// #5956). Defaults to [`COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS`].
65 pub retained_user_message_tokens: usize,
66 }
67
68 /// Host-prepared configuration carried from compaction eligibility through
69 /// the replacement-history commit.
70 #[derive(Debug, Clone, PartialEq)]
71 pub struct PreparedCompactionEnvelope {
72 pub config: CompactionConfig,
73 /// Durable handoff owner; set by the engine, never added to the stable prefix.
74 pub session_id: Option<String>,
75 /// Exact tool prefix of the interrupted request. Tool execution remains
76 /// disabled on the summary call; retaining schemas preserves cache reuse.
77 pub tools: Option<Vec<Tool>>,
78 }
79
80 impl PreparedCompactionEnvelope {
81 #[must_use]
82 pub fn new(config: CompactionConfig) -> Self {
83 Self {
84 config,
85 session_id: None,
86 tools: None,
87 }
88 }
89 }
90
91 impl Default for CompactionConfig {
92 fn default() -> Self {
93 Self {
94 // ON BY DEFAULT since v0.8.6 (#402 P0 survivability). v0.8.64
95 // resolves the user-facing default through the active model's
96 // known context window, while explicit `auto_compact = false`
97 // remains the opt-out. This fallback covers code paths that build
98 // a `CompactionConfig` directly; real per-model values are still
99 // derived through the threshold helpers.
100 enabled: true,
101 // v0.8.11: 50K was a 128K-era leftover that biased every
102 // unconfigured caller toward "compact almost immediately on large-context routes."
103 // Bumped to 800K (80% of a 1M window) so the fallback
104 // default matches the hard automatic compaction guardrail. This
105 // keeps replacement compaction a late continuity guardrail.
106 // Real call sites override this via
107 // `compaction_threshold_for_model_and_effort`.
108 token_threshold: 800_000,
109 model: DEFAULT_TEXT_MODEL.to_string(),
110 image_input: crate::model_profile::SupportState::Unknown,
111 effective_context_window: None,
112 cache_summary: true,
113 focus: None,
114 runtime_cost_owner: None,
115 workspace: None,
116 summary_instructions: None,
117 retained_user_message_tokens: COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
118 }
119 }
120 }
121
122 /// A provider can return HTTP success with an empty, non-text, or known
123 /// placeholder response. Committing that response would discard the useful
124 /// history while leaving only a placeholder checkpoint. Keep this deliberately
125 /// conservative: it is a corruption guard, not a prose-length or language
126 /// scorer.
127 const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \
128 substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \
129 commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \
130 scaffolding is not a request to switch languages.";
131
132 /// Failure kind for compaction LLM calls (deterministic vs transient).
133 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
134 pub enum CompactionFailureKind {
135 /// Same payload will fail again — do not sleep/retry unchanged.
136 Deterministic,
137 /// May resolve on retry (network, rate limit, timeout).
138 Transient,
139 /// Context overflow — drop the oldest history item and retry.
140 ContextOverflow,
141 }
142
143 impl CompactionFailureKind {
144 #[must_use]
145 pub fn is_transient(self) -> bool {
146 matches!(self, Self::Transient)
147 }
148 }
149
150 pub const KEEP_RECENT_MESSAGES: usize = 4;
151 const MIN_SUMMARIZE_MESSAGES: usize = 6;
152 const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
153 const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
154 const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
155 /// Token budget for the recent user messages retained verbatim in the
156 /// replacement history (Codex parity: COMPACT_USER_MESSAGE_MAX_TOKENS).
157 ///
158 /// This is now the *default* only: `[compaction] retained_user_message_tokens`
159 /// overrides it per session (#5956). Aliased to the config default so the two
160 /// names cannot drift apart.
161 pub(crate) const COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS: usize =
162 crate::config::DEFAULT_COMPACTION_RETAINED_USER_MESSAGE_TOKENS;
163 /// Handoff summarization prompt, appended to the live conversation as the
164 /// final user message (ported from Codex `templates/compact/prompt.md`).
165 const COMPACT_PROMPT: &str = "You are performing a context checkpoint compaction. Create a \
166 handoff summary for another LLM that will resume the task.\n\nInclude:\n\
167 - Current progress and key decisions made\n\
168 - Important context, constraints, or user preferences\n\
169 - What remains to be done (clear next steps)\n\
170 - The user's current objective, latest corrections, and already-granted permissions or explicit prohibitions\n\
171 - Active commands, task and session handles, changed files, and the exact verification still needed\n\
172 - Any critical data, examples, or references needed to continue (exact file paths, commands, and error text)\n\n\
173 Be concise, structured, and focused on helping the next LLM seamlessly continue the work. Do not call tools.\n\
174 Summarize the task, not the checkpoint machinery: do not mention compaction, checkpoints, or \
175 context management, and do not carry forward meta-commentary about them (e.g. \"context intact\") \
176 from earlier turns.";
177
178 /// Preamble for the one conversation-history checkpoint created by compaction.
179 /// This intentionally follows Codex's `templates/compact/summary_prefix.md`:
180 /// the checkpoint is a user-history item, never standing system-prompt prose.
181 const SUMMARY_HEADER: &str = "Another language model started to solve this problem and produced \
182 a summary of its thinking process. You also have access to the state of the tools that were used \
183 by that language model. Use this to build on the work that has already been done and avoid \
184 duplicating work. Here is the summary produced by the other language model, use the information \
185 in this summary to assist with your own analysis:";
186
187 /// Detection marker for committed compaction-summary text: the stable first
188 /// sentence of [`SUMMARY_HEADER`]. `engine/context.rs` restores summaries by
189 /// the same marker on session load.
190 pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to solve this problem";
191 /// Marker written by pre-v0.9.6 compaction; sessions saved under the old
192 /// format must still be recognized so their summary is replaced, not stacked.
193 pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
194 const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkpoint.v1 -->";
195 const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->";
196 const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->";
197
198 /// Whether a system-prompt text block is a committed compaction summary.
199 #[must_use]
200 pub fn is_compaction_summary_text(text: &str) -> bool {
201 text.contains(COMPACTION_SUMMARY_MARKER) || text.contains(LEGACY_COMPACTION_SUMMARY_MARKER)
202 }
203
204 fn summary_section(text: &str) -> Option<&str> {
205 let begin = text.find(COMPACTION_SUMMARY_BEGIN)? + COMPACTION_SUMMARY_BEGIN.len();
206 let remainder = &text[begin..];
207 let end = remainder.find(COMPACTION_SUMMARY_END)?;
208 let summary = remainder[..end].trim();
209 (!summary.is_empty()).then_some(summary)
210 }
211
212 fn strip_summary_text(mut text: String) -> Option<String> {
213 while let Some(begin) = text.find(COMPACTION_SUMMARY_BEGIN) {
214 let after_begin = begin + COMPACTION_SUMMARY_BEGIN.len();
215 let end = text[after_begin..]
216 .find(COMPACTION_SUMMARY_END)
217 .map_or(text.len(), |offset| {
218 after_begin + offset + COMPACTION_SUMMARY_END.len()
219 });
220 text.replace_range(begin..end, "");
221 }
222 if let Some(marker) = text
223 .find(COMPACTION_SUMMARY_MARKER)
224 .or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
225 {
226 text.truncate(marker);
227 }
228 let text = text.trim().to_string();
229 (!text.is_empty()).then_some(text)
230 }
231
232 /// Extract the persisted checkpoint payload from a legacy system-prompt
233 /// carrier. Runtime-thread storage used that carrier before checkpoints moved
234 /// into conversation history; the engine strips it before provider dispatch.
235 #[must_use]
236 pub fn extract_compaction_summary(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> {
237 match prompt? {
238 SystemPrompt::Text(text) => summary_section(text)
239 .map(str::to_string)
240 .or_else(|| {
241 text.find(COMPACTION_SUMMARY_MARKER)
242 .or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
243 .map(|start| text[start..].trim().to_string())
244 })
245 .map(SystemPrompt::Text),
246 SystemPrompt::Blocks(blocks) => {
247 let blocks = blocks
248 .iter()
249 .filter_map(|block| {
250 let text = summary_section(&block.text)
251 .map(str::to_string)
252 .or_else(|| {
253 block
254 .text
255 .find(COMPACTION_SUMMARY_MARKER)
256 .or_else(|| block.text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
257 .map(|start| block.text[start..].trim().to_string())
258 })?;
259 let mut summary = block.clone();
260 summary.text = text;
261 Some(summary)
262 })
263 .collect::<Vec<_>>();
264 (!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks))
265 }
266 }
267 }
268
269 /// Remove every committed compaction-summary block from a system prompt.
270 ///
271 /// Compaction commits exactly one live summary: the newest one replaces its
272 /// predecessors. Before this existed, each compaction appended another
273 /// summary block to the successor system prompt, so the stable prefix grew by
274 /// up to a full summary per pass — which re-latched compaction pressure and
275 /// retriggered compaction on the next turn, forever.
276 #[must_use]
277 pub fn strip_compaction_summaries(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> {
278 match prompt.cloned()? {
279 SystemPrompt::Text(text) => strip_summary_text(text).map(SystemPrompt::Text),
280 SystemPrompt::Blocks(blocks) => {
281 let blocks = blocks
282 .into_iter()
283 .filter_map(|mut block| {
284 block.text = strip_summary_text(block.text)?;
285 Some(block)
286 })
287 .collect::<Vec<_>>();
288 (!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks))
289 }
290 }
291 }
292
293 /// Flatten a committed summary prompt to the text stored in history.
294 #[must_use]
295 pub fn summary_prompt_text(prompt: &SystemPrompt) -> String {
296 match prompt {
297 SystemPrompt::Text(text) => text.clone(),
298 SystemPrompt::Blocks(blocks) => blocks
299 .iter()
300 .map(|block| block.text.as_str())
301 .collect::<Vec<_>>()
302 .join("\n\n"),
303 }
304 }
305
306 #[must_use]
307 pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message {
308 Message {
309 role: Role::User,
310 content: vec![
311 ContentBlock::Text {
312 text: summary_prompt_text(prompt),
313 cache_control: None,
314 },
315 ContentBlock::Text {
316 text: COMPACTION_CHECKPOINT_PROVENANCE.to_string(),
317 cache_control: None,
318 },
319 ],
320 }
321 }
322
323 #[must_use]
324 pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool {
325 user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text))
326 }
327
328 /// Request-time recognition is narrower than legacy summary replacement:
329 /// user text merely quoting the marker must keep its original wire position.
330 pub(crate) fn is_wire_compaction_checkpoint_message(message: &Message) -> bool {
331 let [
332 ContentBlock::Text {
333 text,
334 cache_control: None,
335 },
336 ContentBlock::Text {
337 text: provenance,
338 cache_control: None,
339 },
340 ] = message.content.as_slice()
341 else {
342 return false;
343 };
344 message.role == Role::User
345 && text.starts_with(SUMMARY_HEADER)
346 && provenance == COMPACTION_CHECKPOINT_PROVENANCE
347 }
348
349 /// Keep the checkpoint at its original historical boundary on session load.
350 /// Later user turns must remain after the saved compaction boundary.
351 pub(crate) fn restore_compaction_checkpoint(
352 mut messages: Vec<Message>,
353 checkpoint: Option<&SystemPrompt>,
354 ) -> Vec<Message> {
355 let typed_position = messages
356 .iter()
357 .position(is_wire_compaction_checkpoint_message);
358 let checkpoint_index = if let Some(index) = typed_position {
359 messages.retain(|message| !is_wire_compaction_checkpoint_message(message));
360 index
361 } else {
362 // Legacy sessions have no independent provenance. Preserve their
363 // existing broad cleanup behavior; identical user text is ambiguous.
364 messages.retain(|message| !is_compaction_checkpoint_message(message));
365 messages.len()
366 };
367 if let Some(checkpoint) = checkpoint {
368 messages.insert(
369 checkpoint_index.min(messages.len()),
370 compaction_checkpoint_message(checkpoint),
371 );
372 }
373 messages
374 }
375
376 pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
377 message
378 .content
379 .iter()
380 .map(|c| match c {
381 ContentBlock::Text { text, .. } => text.len() / 4,
382 // Replay-capable routes retain reasoning even on text-only
383 // assistant messages and across later user turns.
384 ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4,
385 ContentBlock::Thinking { .. } => 0,
386 ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
387 .map(|s| s.len() / 4)
388 .unwrap_or(100),
389 ContentBlock::ToolResult {
390 content,
391 content_blocks,
392 ..
393 } => {
394 let images = content_blocks.as_ref().map_or(0, |blocks| {
395 blocks
396 .iter()
397 .filter(|block| {
398 block.get("type").and_then(serde_json::Value::as_str) == Some("image")
399 })
400 .count()
401 });
402 content.len() / 4 + images * IMAGE_TOKEN_ESTIMATE
403 }
404 // An inline image is real input the model pays for; estimating it
405 // at 0 undercounts the budget and risks overflow in image-heavy
406 // sessions. Use a conservative flat per-image estimate (vision
407 // tiles are typically ~1k tokens); erring high compacts slightly
408 // early rather than overflowing.
409 ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
410 ContentBlock::ServerToolUse { input, .. } => input.to_string().len() / 4,
411 ContentBlock::ToolSearchToolResult { content, .. }
412 | ContentBlock::CodeExecutionToolResult { content, .. } => {
413 content.to_string().len() / 4
414 }
415 })
416 .sum::<usize>()
417 }
418
419 /// Conservative flat token estimate for an inline image (`ContentBlock::ImageUrl`).
420 /// Vision models bill images by resized tile count; ~1k tokens is a safe
421 /// mid-range estimate that keeps the compaction trigger from under-reading an
422 /// image-heavy session.
423 const IMAGE_TOKEN_ESTIMATE: usize = 1000;
424
425 pub fn estimate_tokens(messages: &[Message]) -> usize {
426 // Rough estimate: ~4 bytes per token. Count every retained reasoning
427 // block: DeepSeek/Kimi replay text-only assistant reasoning too. This
428 // route-neutral estimate cannot assume a transport will omit it.
429 messages
430 .iter()
431 .map(|message| estimate_tokens_for_message(message, true))
432 .sum()
433 }
434
435 pub(crate) fn message_has_tool_use(message: &Message) -> bool {
436 message
437 .content
438 .iter()
439 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
440 }
441
442 pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize {
443 text.chars().count().div_ceil(3)
444 }
445
446 fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
447 match system {
448 Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
449 Some(SystemPrompt::Blocks(blocks)) => blocks
450 .iter()
451 .map(|block| estimate_text_tokens_conservative(&block.text))
452 .sum(),
453 None => 0,
454 }
455 }
456
457 /// Conservative estimate for full request input tokens (messages + system + framing).
458 #[must_use]
459 pub fn estimate_input_tokens_conservative(
460 messages: &[Message],
461 system: Option<&SystemPrompt>,
462 ) -> usize {
463 let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
464 let system_tokens = estimate_system_tokens_conservative(system);
465 let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
466 message_tokens
467 .saturating_add(system_tokens)
468 .saturating_add(framing_overhead)
469 }
470
471 /// Best-effort estimate of real request input tokens, without the 1.5×
472 /// safety inflation used by overflow math.
473 ///
474 /// Compaction *pressure* compares against a threshold whose percentage means
475 /// "fraction of the context window" on the user-facing meter. Feeding the
476 /// inflated overflow estimate into that comparison made an 80% setting fire
477 /// at roughly half the real usage. Overflow protection keeps its inflated
478 /// estimator; the pressure trigger uses this one, preferring provider-billed
479 /// prompt tokens when the caller has them.
480 #[must_use]
481 pub fn estimate_input_tokens_for_pressure(
482 messages: &[Message],
483 system: Option<&SystemPrompt>,
484 ) -> usize {
485 let message_tokens = estimate_tokens(messages);
486 let system_tokens = estimate_system_tokens_conservative(system);
487 let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
488 message_tokens
489 .saturating_add(system_tokens)
490 .saturating_add(framing_overhead)
491 }
492
493 fn estimate_retained_floor_conservative(
494 messages: &[Message],
495 system_prompt: Option<&SystemPrompt>,
496 prepared: &PreparedCompactionEnvelope,
497 ) -> usize {
498 let config = &prepared.config;
499 let retained = last_round::replacement_messages(messages, config.retained_user_message_tokens);
500 let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2);
501 let framing = retained.len().saturating_mul(12).saturating_add(48);
502 let anchors = user_anchors_section(config.workspace.as_deref());
503 let summary_scaffolding_tokens =
504 estimate_text_tokens_conservative(&build_compaction_summary_block_text("", &anchors));
505
506 // Post-compaction the committed summary is REPLACED, not stacked, so prior
507 // summary blocks must not inflate the floor. Count only the exact installed
508 // scaffolding here; the model owns the concise summary length, just as it
509 // owns the answer length on an ordinary turn.
510 let retained_system_prompt = strip_compaction_summaries(system_prompt);
511 retained_tokens
512 .saturating_add(estimate_system_tokens_conservative(
513 retained_system_prompt.as_ref(),
514 ))
515 .saturating_add(framing)
516 .saturating_add(summary_scaffolding_tokens)
517 }
518
519 /// Whether the current canonical request has reached the configured automatic
520 /// compaction pressure. This deliberately excludes eligibility/reclaimability:
521 /// local tool-result pruning uses it to decide when pressure has actually
522 /// cleared, even if the remaining transcript cannot support an LLM summary.
523 #[must_use]
524 pub fn compaction_pressure_reached(
525 messages: &[Message],
526 system_prompt: Option<&SystemPrompt>,
527 config: &CompactionConfig,
528 ) -> bool {
529 compaction_pressure_reached_with_billed(messages, system_prompt, config, None)
530 }
531
532 /// Pressure check that additionally honors provider-billed prompt tokens.
533 ///
534 /// Billed usage is the ground truth for how large the context actually is;
535 /// the estimator undercounts non-ASCII text and cannot see server-side
536 /// framing. Whichever signal is higher decides, so an undercounting estimate
537 /// cannot hide pressure the provider already billed for. Callers must only
538 /// pass a billed count that describes the message list being checked —
539 /// post-prune re-checks pass `None` and fall back to the estimate.
540 #[must_use]
541 pub fn compaction_pressure_reached_with_billed(
542 messages: &[Message],
543 system_prompt: Option<&SystemPrompt>,
544 config: &CompactionConfig,
545 billed_input_tokens: Option<u64>,
546 ) -> bool {
547 if !config.enabled {
548 return false;
549 }
550 let billed = billed_input_tokens
551 .and_then(|tokens| usize::try_from(tokens).ok())
552 .unwrap_or(0);
553 // Billing alone proving pressure short-circuits the walk (#perf-r5):
554 // `estimated.max(billed) >= threshold` is unconditionally true when
555 // `billed >= threshold`, so estimating cannot change the answer and the
556 // O(transcript) pass is skipped. Over-pressure sessions pay this check
557 // multiple times per step (pressure gate + decision re-check).
558 if billed >= config.token_threshold {
559 return true;
560 }
561 let estimated = estimate_input_tokens_for_pressure(messages, system_prompt);
562 estimated.max(billed) >= config.token_threshold
563 }
564
565 /// Estimate-only eligibility check ([`should_compact_with_billed`] with no
566 /// billed tokens): used by the request preview, where no provider bill exists.
567 pub fn should_compact(
568 messages: &[Message],
569 system_prompt: Option<&SystemPrompt>,
570 prepared: &PreparedCompactionEnvelope,
571 ) -> bool {
572 should_compact_with_billed(messages, system_prompt, prepared, None)
573 }
574
575 /// Why an over-pressure context still did not start an automatic pass.
576 ///
577 /// A refusal is not a bug by itself — each guard exists for a reason — but a
578 /// silent refusal is: the user watches a full context meter while
579 /// auto-compaction appears broken (#5577). Callers surface these.
580 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
581 pub enum CompactionRefusal {
582 /// Too few messages for a summary pass to mean anything.
583 TooFewMessages { count: usize },
584 /// The conservative retained floor (system prompt + kept messages +
585 /// summary allowance) cannot get below the trigger, so a pass would
586 /// recur on every step without relieving pressure.
587 RetainedFloor { floor: usize, threshold: usize },
588 }
589
590 /// Outcome of the automatic-compaction eligibility check.
591 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
592 pub enum CompactionDecision {
593 /// Disabled, or pressure not reached: nothing to do, nothing to explain.
594 NotNeeded,
595 /// Start a pass.
596 Compact,
597 /// Pressure is real but a guard declined; the reason names the guard.
598 Refused(CompactionRefusal),
599 }
600
601 /// Eligibility check that honors provider-billed prompt tokens for the
602 /// pressure gate, mirroring [`compaction_pressure_reached_with_billed`].
603 pub fn should_compact_with_billed(
604 messages: &[Message],
605 system_prompt: Option<&SystemPrompt>,
606 prepared: &PreparedCompactionEnvelope,
607 billed_input_tokens: Option<u64>,
608 ) -> bool {
609 matches!(
610 compaction_decision_with_billed(messages, system_prompt, prepared, billed_input_tokens),
611 CompactionDecision::Compact
612 )
613 }
614
615 /// Full eligibility decision, including *why* an over-pressure context was
616 /// refused, so hosts can tell the user instead of silently holding.
617 #[must_use]
618 pub fn compaction_decision_with_billed(
619 messages: &[Message],
620 system_prompt: Option<&SystemPrompt>,
621 prepared: &PreparedCompactionEnvelope,
622 billed_input_tokens: Option<u64>,
623 ) -> CompactionDecision {
624 let config = &prepared.config;
625 if !config.enabled {
626 return CompactionDecision::NotNeeded;
627 }
628 // Pressure gate + prune projection share one estimate (#perf-r5): both
629 // consume `estimate_input_tokens_for_pressure` over the same
630 // `(messages, system_prompt)`, a pure function, so it is computed at
631 // most once. `billed >= threshold` proves pressure without estimating
632 // (max is unconditionally >= threshold then); the estimate is deferred
633 // until something actually needs it — the prune projection below — so
634 // the billed-corner still reaches the TooFew and RetainedFloor guards
635 // unchanged, and skips the walk entirely when no prune candidates exist.
636 let billed = billed_input_tokens
637 .and_then(|tokens| usize::try_from(tokens).ok())
638 .unwrap_or(0);
639 let estimated: Option<usize> = if billed < config.token_threshold {
640 let estimate = estimate_input_tokens_for_pressure(messages, system_prompt);
641 if estimate.max(billed) < config.token_threshold {
642 return CompactionDecision::NotNeeded;
643 }
644 Some(estimate)
645 } else {
646 None
647 };
648
649 // The execution path mechanically prunes old verbose tool results before
650 // asking the model for a summary. Local pruning alone may be enough to
651 // clear pressure even when the transcript is too small for an LLM pass.
652 // Project that outcome from the measured plan — per-block deltas use the
653 // estimator's own arithmetic, so this equals re-estimating a pruned copy
654 // without cloning a multi-megabyte transcript on every step.
655 let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES);
656 if !prune_plan.is_empty() {
657 let estimate = match estimated {
658 Some(value) => value,
659 None => estimate_input_tokens_for_pressure(messages, system_prompt),
660 };
661 let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum();
662 let projected = estimate.saturating_sub(reclaimed_tokens);
663 if projected < config.token_threshold.saturating_mul(4) / 5 {
664 return CompactionDecision::Compact;
665 }
666 }
667
668 if messages.len() < MIN_SUMMARIZE_MESSAGES {
669 return CompactionDecision::Refused(CompactionRefusal::TooFewMessages {
670 count: messages.len(),
671 });
672 }
673
674 // Reclaimability guard: do not start a pass whose replacement request
675 // (system prompt + retained user messages + summary allowance)
676 // cannot get below the trigger, or a large stable prefix would cause
677 // auto-compaction on every tool step.
678 let floor = estimate_retained_floor_conservative(messages, system_prompt, prepared);
679 if floor >= config.token_threshold {
680 return CompactionDecision::Refused(CompactionRefusal::RetainedFloor {
681 floor,
682 threshold: config.token_threshold,
683 });
684 }
685 CompactionDecision::Compact
686 }
687
688 fn truncate_chars(text: &str, max_chars: usize) -> &str {
689 if max_chars == 0 {
690 return "";
691 }
692 match text.char_indices().nth(max_chars) {
693 Some((idx, _)) => &text[..idx],
694 None => text,
695 }
696 }
697
698 fn tail_chars(text: &str, max_chars: usize) -> String {
699 if max_chars == 0 {
700 return String::new();
701 }
702 let total_chars = text.chars().count();
703 if total_chars <= max_chars {
704 return text.to_string();
705 }
706 let start_char = total_chars.saturating_sub(max_chars);
707 let start_idx = text
708 .char_indices()
709 .nth(start_char)
710 .map_or(0, |(idx, _)| idx);
711 text[start_idx..].to_string()
712 }
713
714 #[derive(Debug, Clone)]
715 struct ToolUseInfo {
716 name: String,
717 key: String,
718 args_preview: String,
719 }
720
721 fn tool_use_key(name: &str, input: &serde_json::Value) -> String {
722 format!(
723 "{name}:{}",
724 serde_json::to_string(input).unwrap_or_else(|_| input.to_string())
725 )
726 }
727
728 fn tool_args_preview(input: &serde_json::Value) -> String {
729 let redacted = codewhale_config::persistence::redact_json_secrets(input);
730 let raw = serde_json::to_string(&redacted).unwrap_or_else(|_| redacted.to_string());
731 truncate_chars(&raw, 120).to_string()
732 }
733
734 fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
735 let mut tool_uses = HashMap::new();
736 for message in messages {
737 for block in &message.content {
738 if let ContentBlock::ToolUse {
739 id, name, input, ..
740 } = block
741 {
742 tool_uses.insert(
743 id.clone(),
744 ToolUseInfo {
745 name: name.clone(),
746 key: tool_use_key(name, input),
747 args_preview: tool_args_preview(input),
748 },
749 );
750 }
751 }
752 }
753 tool_uses
754 }
755
756 struct ToolResultPruneCandidate {
757 message_idx: usize,
758 block_idx: usize,
759 key: String,
760 tool_name: String,
761 args_preview: String,
762 original_len: usize,
763 }
764
765 fn tool_result_content_blocks_len(content_blocks: Option<&[serde_json::Value]>) -> usize {
766 content_blocks
767 .and_then(|blocks| serde_json::to_vec(blocks).ok())
768 .map_or(0, |bytes| bytes.len())
769 }
770
771 #[cfg(test)]
772 fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize {
773 prune_tool_results_until(messages, protected_window, |_, _| false)
774 }
775
776 /// Mechanically prune old verbose tool results before paying for an LLM summary.
777 ///
778 /// The most recent `protected_window` messages stay byte-for-byte intact. Older
779 /// duplicate tool results keep the freshest full body and replace earlier
780 /// copies with one-line summaries; non-duplicate old results are summarized only
781 /// when they exceed the normal summary snippet size.
782 fn prune_tool_results_until<F>(
783 messages: &mut [Message],
784 protected_window: usize,
785 mut should_stop: F,
786 ) -> usize
787 where
788 F: FnMut(&[Message], usize) -> bool,
789 {
790 let plan = plan_tool_result_prunes(messages, protected_window);
791 let mut bytes_saved = 0usize;
792 for planned in plan {
793 if let ContentBlock::ToolResult {
794 content,
795 content_blocks,
796 ..
797 } = &mut messages[planned.message_idx].content[planned.block_idx]
798 {
799 bytes_saved = bytes_saved.saturating_add(planned.bytes_reclaimed());
800 *content = planned.summary;
801 *content_blocks = None;
802
803 if should_stop(messages, bytes_saved) {
804 break;
805 }
806 }
807 }
808 bytes_saved
809 }
810
811 /// One tool-result replacement the pruner has decided on, measured up front
812 /// so eligibility checks can project the outcome without cloning a
813 /// multi-megabyte transcript ([`compaction_decision_with_billed`] used to
814 /// copy the entire message list every over-pressure step just to ask "would
815 /// pruning be enough?").
816 struct PlannedPrune {
817 message_idx: usize,
818 block_idx: usize,
819 summary: String,
820 content_len: usize,
821 blocks_len: usize,
822 image_count: usize,
823 }
824
825 impl PlannedPrune {
826 /// Byte reduction this replacement realizes, matching the pruner's
827 /// accounting exactly.
828 fn bytes_reclaimed(&self) -> usize {
829 self.content_len
830 .saturating_sub(self.summary.len())
831 .saturating_add(self.blocks_len)
832 }
833
834 /// Estimator-token reduction, using the same per-block arithmetic as
835 /// [`estimate_tokens_for_message`] so a projection built from these
836 /// deltas equals re-estimating the pruned transcript.
837 fn tokens_reclaimed(&self) -> usize {
838 let before = self.content_len / 4 + self.image_count * IMAGE_TOKEN_ESTIMATE;
839 let after = self.summary.len() / 4;
840 before.saturating_sub(after)
841 }
842 }
843
844 /// Decide, without mutating anything, which old tool results pruning would
845 /// replace. The most recent `protected_window` messages stay untouched; older
846 /// duplicate results keep the freshest full body; non-duplicates are replaced
847 /// only when they exceed the summary snippet size.
848 fn plan_tool_result_prunes(messages: &[Message], protected_window: usize) -> Vec<PlannedPrune> {
849 let cutoff = messages.len().saturating_sub(protected_window);
850 if cutoff == 0 {
851 return Vec::new();
852 }
853
854 let tool_uses = collect_tool_uses(messages);
855 let mut candidates = Vec::new();
856 let mut latest_by_key: HashMap<String, usize> = HashMap::new();
857 let mut count_by_key: HashMap<String, usize> = HashMap::new();
858
859 for (message_idx, message) in messages.iter().take(cutoff).enumerate() {
860 for (block_idx, block) in message.content.iter().enumerate() {
861 let ContentBlock::ToolResult {
862 tool_use_id,
863 content,
864 content_blocks,
865 ..
866 } = block
867 else {
868 continue;
869 };
870 let Some(info) = tool_uses.get(tool_use_id) else {
871 continue;
872 };
873 latest_by_key.insert(info.key.clone(), message_idx);
874 *count_by_key.entry(info.key.clone()).or_insert(0) += 1;
875 candidates.push(ToolResultPruneCandidate {
876 message_idx,
877 block_idx,
878 key: info.key.clone(),
879 tool_name: info.name.clone(),
880 args_preview: info.args_preview.clone(),
881 original_len: content
882 .len()
883 .saturating_add(tool_result_content_blocks_len(content_blocks.as_deref())),
884 });
885 }
886 }
887
888 // The maps above are fully populated before planning completes, so the order
889 // below only changes which message bytes are rewritten first. Planning from
890 // newest to oldest lets the pruner stop as soon as enough bytes were saved,
891 // preserving the earlier JSON request prefix for byte-level KV caches.
892 candidates.reverse();
893
894 let mut plan = Vec::new();
895 for candidate in candidates {
896 let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0);
897 let is_latest_duplicate = duplicate_count > 1
898 && latest_by_key.get(&candidate.key) == Some(&candidate.message_idx);
899 if is_latest_duplicate {
900 continue;
901 }
902 if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS {
903 continue;
904 }
905
906 let summary = format!(
907 "[{}] tool result pruned ({} bytes; args: {})",
908 candidate.tool_name, candidate.original_len, candidate.args_preview
909 );
910 if summary.len() >= candidate.original_len {
911 continue;
912 }
913
914 let ContentBlock::ToolResult {
915 content,
916 content_blocks,
917 ..
918 } = &messages[candidate.message_idx].content[candidate.block_idx]
919 else {
920 continue;
921 };
922 plan.push(PlannedPrune {
923 message_idx: candidate.message_idx,
924 block_idx: candidate.block_idx,
925 summary,
926 content_len: content.len(),
927 blocks_len: tool_result_content_blocks_len(content_blocks.as_deref()),
928 image_count: content_blocks.as_ref().map_or(0, |blocks| {
929 blocks
930 .iter()
931 .filter(|block| {
932 block.get("type").and_then(serde_json::Value::as_str) == Some("image")
933 })
934 .count()
935 }),
936 });
937 }
938 plan
939 }
940
941 fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
942 let char_count = content.chars().count();
943 if char_count <= max_chars {
944 return false;
945 }
946
947 let snippet_budget = max_chars.saturating_sub(256).max(1024);
948 let head_chars = snippet_budget / 2;
949 let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
950 let head = truncate_chars(content, head_chars).to_string();
951 let tail = tail_chars(content, tail_chars_budget);
952 *content =
953 format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
954 true
955 }
956
957 // Retained reasoning is replay protocol even without a signature (DeepSeek
958 // tool turns). Summarize older exchanges as units; do not rewrite their peers.
959 fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
960 for message in &mut messages {
961 for block in &mut message.content {
962 if let ContentBlock::ToolResult {
963 content,
964 content_blocks,
965 ..
966 } = block
967 && truncate_retained_block("tool result", content, RETAINED_TOOL_RESULT_MAX_CHARS)
968 {
969 *content_blocks = None;
970 }
971 }
972 }
973 messages
974 }
975
976 /// Result of a compaction operation with metadata.
977 #[derive(Debug)]
978 pub struct CompactionResult {
979 /// Compacted messages
980 pub messages: Vec<Message>,
981 /// Host-persistence copy of the history checkpoint.
982 pub summary_prompt: Option<SystemPrompt>,
983 /// Number of retries used before success
984 pub retries_used: u32,
985 /// Last-round coverage for inspector receipts.
986 pub coverage: CompactionCoverage,
987 }
988
989 /// Classify a compaction LLM failure for the retry / input-ladder policy.
990 fn classify_compaction_failure(e: &anyhow::Error) -> CompactionFailureKind {
991 if let Some(error) = llm_error_in_chain(e) {
992 return match error {
993 crate::llm_client::LlmError::ContextLengthError(_) => {
994 CompactionFailureKind::ContextOverflow
995 }
996 crate::llm_client::LlmError::QuotaExhausted(_) => CompactionFailureKind::Deterministic,
997 error if error.is_retryable() => CompactionFailureKind::Transient,
998 _ => CompactionFailureKind::Deterministic,
999 };
1000 }
1001
1002 let text = e.to_string();
1003 if is_context_window_error_message(&text) {
1004 return CompactionFailureKind::ContextOverflow;
1005 }
1006 let category = crate::error_taxonomy::classify_error_message(&text);
1007 match category {
1008 crate::error_taxonomy::ErrorCategory::Network
1009 | crate::error_taxonomy::ErrorCategory::RateLimit
1010 | crate::error_taxonomy::ErrorCategory::Timeout => CompactionFailureKind::Transient,
1011 _ => CompactionFailureKind::Deterministic,
1012 }
1013 }
1014
1015 fn llm_error_in_chain(error: &anyhow::Error) -> Option<&crate::llm_client::LlmError> {
1016 error
1017 .chain()
1018 .find_map(|cause| cause.downcast_ref::<crate::llm_client::LlmError>())
1019 }
1020
1021 /// Record and render a compaction failure as actionable, credential-safe text.
1022 ///
1023 /// This classifies only the error supplied by the failed request; it never
1024 /// infers a cause from later provider failures. Unknown diagnostics stay
1025 /// visible after central secret/path redaction, and the same safe detail is
1026 /// written to the runtime log so a transient status message remains auditable.
1027 #[must_use]
1028 pub fn report_compaction_failure(
1029 prefix: &str,
1030 id: &str,
1031 auto: bool,
1032 error: &anyhow::Error,
1033 ) -> String {
1034 let raw = error.to_string();
1035 let safe_raw = crate::safe_label::safe_error_text(&raw);
1036 tracing::warn!(
1037 compaction_id = %id,
1038 auto,
1039 error = %safe_raw,
1040 "context compaction failed"
1041 );
1042 let detail = match llm_error_in_chain(error) {
1043 Some(crate::llm_client::LlmError::QuotaExhausted(_)) => {
1044 "provider plan quota exhausted — switch provider/model or renew the provider plan"
1045 .to_string()
1046 }
1047 Some(crate::llm_client::LlmError::RateLimited { .. }) => {
1048 "provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
1049 .to_string()
1050 }
1051 Some(crate::llm_client::LlmError::AuthenticationError(_)) => {
1052 "provider authentication failed — sign in or replace the credential, then retry"
1053 .to_string()
1054 }
1055 Some(crate::llm_client::LlmError::AuthorizationError(_)) => {
1056 "provider authorization rejected compaction — verify account access or switch provider/model"
1057 .to_string()
1058 }
1059 _ => match crate::error_taxonomy::classify_error_message(&raw) {
1060 crate::error_taxonomy::ErrorCategory::RateLimit => {
1061 "provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
1062 .to_string()
1063 }
1064 crate::error_taxonomy::ErrorCategory::Authentication => {
1065 "provider authentication failed — sign in or replace the credential, then retry"
1066 .to_string()
1067 }
1068 crate::error_taxonomy::ErrorCategory::Authorization => {
1069 "provider authorization rejected compaction — verify account access or switch provider/model"
1070 .to_string()
1071 }
1072 _ => safe_raw,
1073 },
1074 };
1075
1076 format!("{prefix}: {detail}")
1077 }
1078
1079 /// Check if an error is transient and worth retrying. Categories that map to
1080 /// transient retry: Network, RateLimit, Timeout. Context overflow is *not*
1081 /// transient — it needs a smaller input (ladder), not the same payload.
1082 fn is_transient_error(e: &anyhow::Error) -> bool {
1083 classify_compaction_failure(e).is_transient()
1084 }
1085
1086 fn is_context_window_error_message(text: &str) -> bool {
1087 let lower = text.to_lowercase();
1088 lower.contains("too long for this model")
1089 || lower.contains("prompt is too long")
1090 || lower.contains("maximum prompt length")
1091 || lower.contains("maximum context length")
1092 || lower.contains("context_length_exceeded")
1093 || lower.contains("context window")
1094 || (lower.contains("context")
1095 && (lower.contains("token") || lower.contains("too long") || lower.contains("maximum")))
1096 }
1097
1098 /// Compact messages with retry and backoff for transient errors.
1099 ///
1100 /// This function wraps `compact_messages` with retry logic to handle
1101 /// transient network errors and rate limits. It uses exponential backoff
1102 /// with delays of 1s, 2s, 4s between retries.
1103 ///
1104 /// # Safety
1105 /// - Never panics
1106 /// - Never corrupts the original messages (returns error instead)
1107 /// - Only retries on transient errors (network, rate limit, etc.)
1108 ///
1109 /// `invocation_usage` retains every decoded response, including rejected
1110 /// summaries, across retries and cancellation of this future.
1111 pub async fn compact_messages_safe(
1112 client: &dyn ModelClient,
1113 messages: &[Message],
1114 system_prompt: Option<&SystemPrompt>,
1115 prepared: &PreparedCompactionEnvelope,
1116 invocation_usage: &mut Usage,
1117 ) -> Result<CompactionResult> {
1118 const MAX_RETRIES: u32 = 3;
1119 const BASE_DELAY_MS: u64 = 1000;
1120
1121 // Persist the complete pre-compaction history before any pruning or provider
1122 // call. Failure leaves the original context intact. The model-authored
1123 // handoff is saved separately before replacement is returned to the engine.
1124 let checkpoint_id = uuid::Uuid::new_v4().to_string();
1125 if let Some(session_id) = prepared.session_id.as_deref() {
1126 let bytes = serde_json::to_vec(&codewhale_config::persistence::redact_json_secrets(
1127 &serde_json::to_value(messages)?,
1128 ))?;
1129 crate::artifacts::write_session_relative_immutable(
1130 session_id,
1131 &std::path::PathBuf::from("artifacts")
1132 .join(format!("context-transfer-{checkpoint_id}.json")),
1133 &bytes,
1134 )?;
1135 }
1136
1137 let config = &prepared.config;
1138 let was_over_threshold = compaction_pressure_reached(messages, system_prompt, config);
1139 // Leave room for useful work after a local prune. Clearing the trigger
1140 // by a few tokens caused another prefix rewrite on the next tool result.
1141 let prune_target = config.token_threshold.saturating_mul(4) / 5;
1142 let mut pruned_messages = messages.to_vec();
1143 let mut now_under_threshold = false;
1144 let mut next_stop_check_bytes = 0usize;
1145 let pruned_bytes = prune_tool_results_until(
1146 &mut pruned_messages,
1147 KEEP_RECENT_MESSAGES,
1148 |candidate_messages, bytes_saved| {
1149 if !was_over_threshold || bytes_saved < next_stop_check_bytes {
1150 return false;
1151 }
1152
1153 // Stop at the first suffix-side prune check that clears the target.
1154 // The check itself is a full compaction-plan pass, so bound it by saved
1155 // bytes instead of running it after every candidate in huge sessions.
1156 next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES);
1157 now_under_threshold =
1158 estimate_input_tokens_for_pressure(candidate_messages, system_prompt)
1159 < prune_target;
1160 now_under_threshold
1161 },
1162 );
1163 if was_over_threshold && pruned_bytes > 0 && !now_under_threshold {
1164 // The throttled in-loop check may skip the exact candidate that clears the
1165 // budget. Do one final pass so a successful local prune still avoids LLM compaction.
1166 now_under_threshold =
1167 estimate_input_tokens_for_pressure(&pruned_messages, system_prompt) < prune_target;
1168 }
1169
1170 if pruned_bytes > 0 {
1171 logging::info(format!(
1172 "Local tool-result prune saved {pruned_bytes} bytes before LLM compaction"
1173 ));
1174 if was_over_threshold && now_under_threshold {
1175 let kept = sanitize_retained_messages(pruned_messages);
1176 last_round::validate_last_round_coverage(messages, &kept)?;
1177 let coverage = last_round::measure_coverage(
1178 messages,
1179 &kept,
1180 CompactionPath::PruneOnly,
1181 pinned_anchors_text(config.workspace.as_deref())
1182 .map(|text| text.chars().count())
1183 .unwrap_or(0),
1184 );
1185 return Ok(CompactionResult {
1186 messages: kept,
1187 summary_prompt: None,
1188 retries_used: 0,
1189 coverage,
1190 });
1191 }
1192 }
1193
1194 let mut last_error: Option<anyhow::Error> = None;
1195 let mut quality_retries = 0u32;
1196
1197 for attempt in 0..MAX_RETRIES {
1198 if attempt > 0 {
1199 // Exponential backoff: 1s, 2s, 4s
1200 let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1)));
1201 tokio::time::sleep(delay).await;
1202 }
1203
1204 match compact_messages_with_metadata(
1205 client,
1206 // If a local prune cannot clear pressure, summarize the original
1207 // evidence. Pruning first both erased facts the handoff needs and
1208 // invalidated the cached history prefix for the summary request.
1209 messages,
1210 config,
1211 system_prompt,
1212 prepared.tools.as_deref(),
1213 &mut quality_retries,
1214 invocation_usage,
1215 )
1216 .await
1217 {
1218 Ok((msgs, prompt, mut coverage)) => {
1219 let kept = sanitize_retained_messages(msgs);
1220 last_round::validate_last_round_coverage(messages, &kept)?;
1221 if config.enabled
1222 && compaction_pressure_reached(messages, system_prompt, config)
1223 && estimate_input_tokens_for_pressure(&kept, system_prompt)
1224 >= estimate_input_tokens_for_pressure(messages, system_prompt)
1225 {
1226 anyhow::bail!(
1227 "Compaction did not reduce context; original conversation was preserved."
1228 );
1229 }
1230 let keep: CompactionKeep = inspect_compaction_keep(&kept);
1231 coverage.last_round_messages = keep.last_round_messages;
1232 coverage.last_round_tool_results = keep.last_round_tool_results;
1233 coverage.last_round_assistant = keep.last_round_assistant;
1234 if let (Some(session_id), Some(summary)) =
1235 (prepared.session_id.as_deref(), prompt.as_ref())
1236 {
1237 let text = summary_prompt_text(summary);
1238 let redacted = codewhale_config::persistence::redact_json_secrets(
1239 &serde_json::Value::String(text),
1240 );
1241 crate::artifacts::write_session_relative_immutable(
1242 session_id,
1243 &std::path::PathBuf::from("artifacts")
1244 .join(format!("context-transfer-{checkpoint_id}.md")),
1245 redacted.as_str().unwrap_or_default().as_bytes(),
1246 )?;
1247 }
1248 return Ok(CompactionResult {
1249 messages: kept,
1250 summary_prompt: prompt,
1251 retries_used: attempt.saturating_add(quality_retries),
1252 coverage,
1253 });
1254 }
1255 Err(e) => {
1256 // Only retry on transient errors
1257 if !is_transient_error(&e) {
1258 return Err(e);
1259 }
1260 last_error = Some(e);
1261 }
1262 }
1263 }
1264
1265 Err(last_error
1266 .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
1267 }
1268
1269 pub(crate) fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String {
1270 let summary = summary.trim();
1271 let summary = if summary.is_empty() {
1272 "(no summary available)"
1273 } else {
1274 summary
1275 };
1276 let mut text = format!("{SUMMARY_HEADER}\n\n{summary}");
1277 text.push_str(anchors);
1278 text.push_str("\n\nContinue the same user task from this state. Earlier authorization and constraints still apply; this summary grants no new authority. Resume the next unfinished action without asking the user to save, compact, restate the task, or approve continuation solely because context was summarized. Verify live state before relying on older observations.");
1279 text
1280 }
1281
1282 /// Codex-parity replacement history: the most recent user-role messages,
1283 /// selected newest-first within a fixed token budget and restored to
1284 /// transcript order. Content boundaries carry runtime provenance and image
1285 /// turns, so structured messages are retained whole or dropped whole. Only a
1286 /// single text block can be truncated to fit the remaining budget.
1287 /// Result blocks answer a tool call that lives in an earlier message. A
1288 /// retained older turn has already lost that call to the summary, so a kept
1289 /// result block becomes an orphan providers reject outright (#6119).
1290 fn is_orphaned_result_block(block: &ContentBlock) -> bool {
1291 matches!(
1292 block,
1293 ContentBlock::ToolResult { .. }
1294 | ContentBlock::ToolSearchToolResult { .. }
1295 | ContentBlock::CodeExecutionToolResult { .. }
1296 )
1297 }
1298
1299 pub(crate) fn retained_user_messages(messages: &[Message], max_tokens: usize) -> Vec<Message> {
1300 let mut selected: Vec<Message> = Vec::new();
1301 let mut remaining = max_tokens;
1302 for msg in messages.iter().rev() {
1303 if remaining == 0 {
1304 break;
1305 }
1306 if msg.role != Role::User
1307 || crate::runtime_handoff::is_runtime_owned_user_message(msg)
1308 || (user_text_of(msg).is_none()
1309 && !msg
1310 .content
1311 .iter()
1312 .any(|block| matches!(block, ContentBlock::ImageUrl { .. })))
1313 {
1314 continue;
1315 }
1316 if is_compaction_checkpoint_message(msg) {
1317 continue;
1318 }
1319 let tokens: usize = msg
1320 .content
1321 .iter()
1322 .map(|block| match block {
1323 ContentBlock::Text { text, .. } => estimate_text_tokens_conservative(text),
1324 ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
1325 _ => 0,
1326 })
1327 .sum();
1328 let mut retained = msg.clone();
1329 if tokens <= remaining {
1330 // Keep the text and images; never a result block whose call was
1331 // summarized away with the region around it (#6119).
1332 retained
1333 .content
1334 .retain(|block| !is_orphaned_result_block(block));
1335 remaining -= tokens;
1336 } else {
1337 let [ContentBlock::Text { text, .. }] = retained.content.as_mut_slice() else {
1338 // Never flatten or partially retain an engine metadata block:
1339 // that would turn runtime-owned traffic into a user prompt.
1340 break;
1341 };
1342 *text = truncate_chars(text, remaining.saturating_mul(3)).to_string();
1343 remaining = 0;
1344 }
1345 selected.push(retained);
1346 }
1347 selected.reverse();
1348 selected
1349 }
1350
1351 /// User-pinned facts from `/anchor` (`.codewhale/anchors.md`). These are the
1352 /// user's own words, re-stated after the summary because the command promises
1353 /// they survive compaction.
1354 fn user_anchors_section(workspace: Option<&std::path::Path>) -> String {
1355 match pinned_anchors_text(workspace) {
1356 Some(contents) => format!("\n\nUser-pinned anchors (verbatim):\n{contents}"),
1357 None => String::new(),
1358 }
1359 }
1360
1361 #[cfg(test)]
1362 async fn compact_messages(
1363 client: &dyn ModelClient,
1364 messages: &[Message],
1365 config: &CompactionConfig,
1366 ) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
1367 let mut quality_retries = 0;
1368 let mut invocation_usage = Usage::default();
1369 let (messages, summary_prompt, _coverage) = compact_messages_with_metadata(
1370 client,
1371 messages,
1372 config,
1373 None,
1374 None,
1375 &mut quality_retries,
1376 &mut invocation_usage,
1377 )
1378 .await?;
1379 Ok((messages, summary_prompt, Vec::new()))
1380 }
1381
1382 async fn compact_messages_with_metadata(
1383 client: &dyn ModelClient,
1384 messages: &[Message],
1385 config: &CompactionConfig,
1386 system_prompt: Option<&SystemPrompt>,
1387 tools: Option<&[Tool]>,
1388 quality_retries: &mut u32,
1389 invocation_usage: &mut Usage,
1390 ) -> Result<(Vec<Message>, Option<SystemPrompt>, CompactionCoverage)> {
1391 if messages.is_empty() {
1392 return Ok((Vec::new(), None, CompactionCoverage::default()));
1393 }
1394
1395 let summary = create_summary(
1396 client,
1397 messages,
1398 config,
1399 system_prompt,
1400 tools,
1401 quality_retries,
1402 invocation_usage,
1403 )
1404 .await?;
1405 let anchors = user_anchors_section(config.workspace.as_deref());
1406 let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors);
1407 let summary_block = SystemBlock {
1408 block_type: "text".to_string(),
1409 text: checkpoint_text.clone(),
1410 cache_control: config.cache_summary.then(|| CacheControl {
1411 cache_type: "ephemeral".to_string(),
1412 }),
1413 };
1414
1415 let retained = last_round::build_replacement_history(
1416 messages,
1417 &checkpoint_text,
1418 pinned_anchors_text(config.workspace.as_deref()).as_deref(),
1419 config.retained_user_message_tokens,
1420 )?;
1421 let mut coverage = last_round::measure_coverage(
1422 messages,
1423 &retained,
1424 CompactionPath::Summary,
1425 pinned_anchors_text(config.workspace.as_deref())
1426 .map(|text| text.chars().count())
1427 .unwrap_or(0),
1428 );
1429 // Report the tuning actually in force so the receipt shows the operator
1430 // their knobs took effect (#5956).
1431 coverage.retained_user_message_tokens = config.retained_user_message_tokens;
1432 coverage.operator_instructions_applied =
1433 operator_instructions_section(config.summary_instructions.as_deref()).is_some();
1434 Ok((
1435 retained,
1436 Some(SystemPrompt::Blocks(vec![summary_block])),
1437 coverage,
1438 ))
1439 }
1440
1441 /// Delimiters around the operator's standing summarizer instructions. The
1442 /// summarizer sees a plain user message, so the section must announce itself:
1443 /// unfenced free text reads as more conversation to summarize.
1444 const OPERATOR_INSTRUCTIONS_HEADER: &str = "--- Additional instructions from the operator ---";
1445 const OPERATOR_INSTRUCTIONS_FOOTER: &str = "--- End of additional instructions ---";
1446
1447 /// Render `[compaction] summary_instructions` as a delimited prompt suffix.
1448 ///
1449 /// `None` (unset, or whitespace-only) produces no section at all, which is
1450 /// what keeps the default prompt byte-identical to the pre-#5956 constant.
1451 /// The cap is enforced here rather than at config load so the warning fires
1452 /// once per compaction pass instead of once per turn.
1453 fn operator_instructions_section(instructions: Option<&str>) -> Option<String> {
1454 let text = instructions
1455 .map(str::trim)
1456 .filter(|text| !text.is_empty())?;
1457 let max_chars = crate::config::COMPACTION_SUMMARY_INSTRUCTIONS_MAX_CHARS;
1458 let text = if text.chars().count() > max_chars {
1459 logging::warn(format!(
1460 "[compaction] summary_instructions is longer than {max_chars} characters; \
1461 the summarizer prompt suffix was truncated"
1462 ));
1463 truncate_chars(text, max_chars)
1464 } else {
1465 text
1466 };
1467 Some(format!(
1468 "\n\n{OPERATOR_INSTRUCTIONS_HEADER}\n{text}\n{OPERATOR_INSTRUCTIONS_FOOTER}"
1469 ))
1470 }
1471
1472 fn compact_prompt(focus: Option<&str>, instructions: Option<&str>) -> String {
1473 let mut prompt = format!("{COMPACT_PROMPT} {COMPACTION_LANGUAGE_CONTRACT}");
1474 if let Some(section) = operator_instructions_section(instructions) {
1475 prompt.push_str(&section);
1476 }
1477 if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
1478 let _ = write!(
1479 prompt,
1480 "\n\nThe user asked this compaction to focus on: {focus}"
1481 );
1482 }
1483 prompt
1484 }
1485
1486 fn compact_quality_retry_prompt(focus: Option<&str>, instructions: Option<&str>) -> String {
1487 let mut prompt = format!(
1488 "The previous handoff response was empty or a placeholder. Return a substantive factual \
1489 continuation handoff. State the user objective, completed and current work, hard constraints, verified \
1490 evidence, unresolved failures, and the single next action. Do not refuse, call tools, discuss \
1491 checkpoint machinery, or return a placeholder. {COMPACTION_LANGUAGE_CONTRACT}"
1492 );
1493 if let Some(section) = operator_instructions_section(instructions) {
1494 prompt.push_str(&section);
1495 }
1496 if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
1497 let _ = write!(
1498 prompt,
1499 "\n\nThe user asked this compaction to focus on: {focus}"
1500 );
1501 }
1502 prompt
1503 }
1504
1505 fn validate_compaction_summary(summary: &str) -> Result<()> {
1506 let trimmed = summary.trim();
1507 if trimmed.is_empty() {
1508 anyhow::bail!("Compaction summary response was unusable: no text was returned.");
1509 }
1510
1511 // Strip every non-word edge, not just ASCII punctuation. Providers can
1512 // return visually non-empty Unicode punctuation or emoji-only payloads;
1513 // neither is a usable continuation checkpoint. `is_alphanumeric` keeps
1514 // this language-neutral for CJK and other scripts without imposing a
1515 // prose-length heuristic.
1516 let normalized = trimmed
1517 .trim_matches(|ch: char| !ch.is_alphanumeric())
1518 .to_ascii_lowercase();
1519 if normalized.is_empty() {
1520 anyhow::bail!(
1521 "Compaction summary response was unusable: only whitespace or punctuation was returned."
1522 );
1523 }
1524 if matches!(
1525 normalized.as_str(),
1526 "no summary available"
1527 | "summary unavailable"
1528 | "no summary"
1529 | "n/a"
1530 | "na"
1531 | "not available"
1532 | "i cannot provide a summary"
1533 | "i can't provide a summary"
1534 | "unable to provide a summary"
1535 ) {
1536 anyhow::bail!("Compaction summary response was unusable: a placeholder was returned.");
1537 }
1538 Ok(())
1539 }
1540
1541 /// Drop the oldest history message before retrying an over-window summary
1542 /// request (Codex parity: `history.remove_first_item()`), plus any tool
1543 /// results the removal orphans — strict providers reject unpaired results.
1544 fn drop_oldest_history_messages(messages: &mut Vec<Message>) {
1545 if messages.len() <= 1 {
1546 return;
1547 }
1548 messages.remove(0);
1549 while messages.len() > 1
1550 && messages[0]
1551 .content
1552 .iter()
1553 .any(|block| matches!(block, ContentBlock::ToolResult { .. }))
1554 {
1555 messages.remove(0);
1556 }
1557 }
1558
1559 async fn create_summary(
1560 client: &dyn ModelClient,
1561 messages: &[Message],
1562 config: &CompactionConfig,
1563 system_prompt: Option<&SystemPrompt>,
1564 tools: Option<&[Tool]>,
1565 quality_retries: &mut u32,
1566 invocation_usage: &mut Usage,
1567 ) -> Result<String> {
1568 // The summarization request IS the live conversation plus one final user
1569 // message asking for the handoff summary, so the provider's prefix cache
1570 // covers everything already sent this session.
1571 let mut request_messages = messages.to_vec();
1572 let stripped_images = crate::image_attach::strip_images_when_unsupported(
1573 &mut request_messages,
1574 config.image_input,
1575 &config.model,
1576 );
1577 if stripped_images > 0 {
1578 logging::warn(format!(
1579 "Compaction omitted {stripped_images} image block(s) unsupported by its route"
1580 ));
1581 }
1582 request_messages.push(Message {
1583 role: Role::User,
1584 content: vec![ContentBlock::Text {
1585 text: compact_prompt(
1586 config.focus.as_deref(),
1587 config.summary_instructions.as_deref(),
1588 ),
1589 cache_control: None,
1590 }],
1591 });
1592
1593 let mut quality_retry_used = false;
1594 loop {
1595 // Codex compaction is a normal model generation over the existing
1596 // cached prefix. Do the same here: the resolved route decides how
1597 // much output the model may need instead of imposing a smaller,
1598 // compaction-only ceiling that can be consumed by hidden reasoning.
1599 let cost_route = client.effective_route_envelope(&config.model, chrono::Utc::now());
1600 let request = MessageRequest {
1601 model: config.model.clone(),
1602 messages: request_messages.clone(),
1603 max_tokens: client.effective_max_output_tokens(&cost_route.model),
1604 system: system_prompt.cloned(),
1605 tools: tools.map(<[Tool]>::to_vec),
1606 tool_choice: tools
1607 .filter(|tools| !tools.is_empty())
1608 .map(|_| serde_json::json!("none")),
1609 metadata: None,
1610 thinking: None,
1611 reasoning_effort: None,
1612 stream: Some(false),
1613 // Route parity with ordinary turns: turns send no sampling
1614 // params, so every provider's own normalization/defaults apply.
1615 // A hard-coded 0.3 leaked to the wire on routes that pass
1616 // temperature through (e.g. Kimi Code membership), where the
1617 // fixed-sampling contract rejects it and the whole compaction
1618 // pass fails.
1619 temperature: None,
1620 top_p: None,
1621 };
1622
1623 // Capture the session scope before awaiting so a late response cannot
1624 // accrue into a subsequently loaded/new session.
1625 let cost_scope = crate::cost_status::scope_token();
1626 let response = match client.create_message(request).await {
1627 Ok(response) => response,
1628 Err(err) if is_context_window_error(&err) && request_messages.len() > 2 => {
1629 logging::warn(format!(
1630 "Compaction summary input over the context window ({err}); \
1631 dropping the oldest history item and retrying"
1632 ));
1633 drop_oldest_history_messages(&mut request_messages);
1634 continue;
1635 }
1636 Err(err) => return Err(err),
1637 };
1638
1639 // Keep the caller's total before any validation or subsequent await.
1640 // A rejected summary or canceled retry still consumed these tokens.
1641 crate::core::turn::add_usage_to(invocation_usage, &response.usage);
1642
1643 // Compaction summary calls are billed; route the tokens through the
1644 // side-channel so the dashboard total matches the website (#526).
1645 crate::cost_status::report_effective_route_for_runtime(
1646 cost_scope,
1647 config.runtime_cost_owner.as_deref(),
1648 &format!(
1649 "compaction:dispatch:{}:response:{}",
1650 cost_route
1651 .dispatched_at
1652 .timestamp_nanos_opt()
1653 .unwrap_or_default(),
1654 response.id
1655 ),
1656 &cost_route,
1657 &response.usage,
1658 );
1659
1660 // Usage above is already billed; a provider-declared incomplete
1661 // summary must still fail rather than replace the session history
1662 // with a fragment.
1663 if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
1664 anyhow::bail!(
1665 "Compaction summary response incomplete: provider stop reason `{}`; the partial summary was not accepted.",
1666 codewhale_models::stop_reason_detail(response.stop_reason.as_deref())
1667 );
1668 }
1669 if response
1670 .content
1671 .iter()
1672 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
1673 {
1674 anyhow::bail!(
1675 "Compaction returned a tool call instead of a completed handoff; original conversation was preserved."
1676 );
1677 }
1678
1679 let summary = response
1680 .content
1681 .iter()
1682 .filter_map(|block| match block {
1683 ContentBlock::Text { text, .. } => Some(text.clone()),
1684 _ => None,
1685 })
1686 .collect::<Vec<_>>()
1687 .join("\n");
1688
1689 if let Err(error) = validate_compaction_summary(&summary) {
1690 if quality_retry_used {
1691 return Err(error.context(
1692 "Compaction summary remained unusable after one conservative retry; \
1693 no replacement checkpoint was committed",
1694 ));
1695 }
1696
1697 quality_retry_used = true;
1698 *quality_retries = (*quality_retries).saturating_add(1);
1699 logging::warn(
1700 "Compaction provider returned an unusable successful response; retrying once with the conservative handoff prompt",
1701 );
1702 let Some(instruction) = request_messages.last_mut() else {
1703 return Err(error.context(
1704 "Compaction summary validation failed and the retry instruction was missing",
1705 ));
1706 };
1707 instruction.content = vec![ContentBlock::Text {
1708 text: compact_quality_retry_prompt(
1709 config.focus.as_deref(),
1710 config.summary_instructions.as_deref(),
1711 ),
1712 cache_control: None,
1713 }];
1714 continue;
1715 }
1716
1717 return Ok(summary);
1718 }
1719 }
1720
1721 fn is_context_window_error(e: &anyhow::Error) -> bool {
1722 let text = e.to_string();
1723 if crate::error_taxonomy::classify_error_message(&text)
1724 != crate::error_taxonomy::ErrorCategory::InvalidInput
1725 {
1726 return false;
1727 }
1728
1729 let lower = text.to_lowercase();
1730 lower.contains("context")
1731 || lower.contains("token")
1732 || lower.contains("prompt is too long")
1733 || lower.contains("requested")
1734 || lower.contains("maximum")
1735 }
1736
1737 /// Collect text from a user message without treating tool-result payloads
1738 /// as new user instructions.
1739 fn user_text_of(msg: &Message) -> Option<String> {
1740 if msg.role != "user" {
1741 return None;
1742 }
1743 let text = msg
1744 .content
1745 .iter()
1746 .filter_map(|block| match block {
1747 ContentBlock::Text { text, .. } => Some(text.as_str()),
1748 _ => None,
1749 })
1750 .collect::<Vec<_>>()
1751 .join("\n");
1752 let text = text.trim();
1753 (!text.is_empty()).then(|| text.to_string())
1754 }
1755
1756 #[cfg(test)]
1757 #[path = "compaction/tests.rs"]
1758 mod quota_tests;
1759
1760 #[cfg(test)]
1761 mod tests {
1762 use codewhale_models::{ImageUrlContent, Message};
1763
1764 #[test]
1765 fn restore_replaces_duplicate_generated_checkpoints_without_deleting_user_quote() {
1766 let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", ""));
1767 let generated = compaction_checkpoint_message(&summary);
1768 let user_quote = Message {
1769 role: Role::User,
1770 content: vec![ContentBlock::Text {
1771 text: summary_prompt_text(&summary),
1772 cache_control: None,
1773 }],
1774 };
1775 assert!(!is_wire_compaction_checkpoint_message(&user_quote));
1776 let restored = restore_compaction_checkpoint(
1777 vec![generated.clone(), user_quote.clone(), generated],
1778 Some(&summary),
1779 );
1780 assert_eq!(restored.len(), 2);
1781 assert!(is_wire_compaction_checkpoint_message(&restored[0]));
1782 assert_eq!(restored[1], user_quote);
1783
1784 // No provenance means the historical broad cleanup remains in force.
1785 let legacy = Message {
1786 role: Role::User,
1787 content: vec![ContentBlock::Text {
1788 text: format!("{COMPACTION_SUMMARY_MARKER}\nold summary"),
1789 cache_control: None,
1790 }],
1791 };
1792 let legacy_restored =
1793 restore_compaction_checkpoint(vec![legacy.clone(), legacy], Some(&summary));
1794 assert_eq!(legacy_restored.len(), 1);
1795 assert!(is_wire_compaction_checkpoint_message(&legacy_restored[0]));
1796 }
1797
1798 #[test]
1799 fn inline_image_estimates_nonzero_tokens() {
1800 let msg = Message {
1801 role: Role::User,
1802 content: vec![ContentBlock::ImageUrl {
1803 image_url: ImageUrlContent {
1804 url: "data:image/png;base64,AAAA".to_string(),
1805 },
1806 }],
1807 };
1808 assert!(
1809 estimate_tokens_for_message(&msg, false) >= IMAGE_TOKEN_ESTIMATE,
1810 "an inline image must not estimate to 0 tokens"
1811 );
1812 }
1813
1814 use super::*;
1815 use serde_json::json;
1816
1817 fn msg(role: &str, text: &str) -> Message {
1818 Message {
1819 role: Role::from(role),
1820 content: vec![ContentBlock::Text {
1821 text: text.to_string(),
1822 cache_control: None,
1823 }],
1824 }
1825 }
1826
1827 fn prepared(config: &CompactionConfig) -> PreparedCompactionEnvelope {
1828 PreparedCompactionEnvelope::new(config.clone())
1829 }
1830
1831 fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
1832 Message {
1833 role: Role::Assistant,
1834 content: vec![ContentBlock::ToolUse {
1835 id: id.to_string(),
1836 name: name.to_string(),
1837 input,
1838 caller: None,
1839 thought_signature: None,
1840 }],
1841 }
1842 }
1843
1844 fn tool_result(id: &str, content: &str) -> Message {
1845 Message {
1846 role: Role::User,
1847 content: vec![ContentBlock::ToolResult {
1848 tool_use_id: id.to_string(),
1849 content: content.to_string(),
1850 is_error: None,
1851 content_blocks: None,
1852 }],
1853 }
1854 }
1855
1856 #[test]
1857 fn truncate_chars_respects_unicode_boundaries() {
1858 let text = "abc😀é";
1859 assert_eq!(truncate_chars(text, 0), "");
1860 assert_eq!(truncate_chars(text, 1), "a");
1861 assert_eq!(truncate_chars(text, 3), "abc");
1862 assert_eq!(truncate_chars(text, 4), "abc😀");
1863 assert_eq!(truncate_chars(text, 5), "abc😀é");
1864 }
1865
1866 #[test]
1867 fn prune_tool_results_summarizes_old_verbose_outputs() {
1868 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
1869 let mut messages = vec![
1870 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1871 tool_result("call-1", &verbose),
1872 msg("user", "recent question"),
1873 msg("assistant", "recent answer"),
1874 ];
1875
1876 let saved = prune_tool_results(&mut messages, 2);
1877
1878 assert!(saved > 0);
1879 let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else {
1880 panic!("expected tool result");
1881 };
1882 assert!(content.contains("[read_file] tool result pruned"));
1883 assert!(content.contains("Cargo.toml"));
1884 assert!(content.len() < verbose.len());
1885 }
1886
1887 #[test]
1888 fn prune_tool_results_preserves_protected_tail() {
1889 let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
1890 let mut messages = vec![
1891 msg("user", "older context"),
1892 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1893 tool_result("call-1", &verbose),
1894 ];
1895
1896 let saved = prune_tool_results(&mut messages, 2);
1897
1898 assert_eq!(saved, 0);
1899 let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else {
1900 panic!("expected tool result");
1901 };
1902 assert_eq!(content, &verbose);
1903 }
1904
1905 #[test]
1906 fn prune_tool_results_preserves_prefix_bytes_when_reverse_prune_is_enough() {
1907 let older_verbose = "old ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
1908 let newer_verbose = "new ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
1909 let mut messages = vec![
1910 tool_use("call-old", "read_file", json!({"path": "old.txt"})),
1911 tool_result("call-old", &older_verbose),
1912 tool_use("call-new", "read_file", json!({"path": "new.txt"})),
1913 tool_result("call-new", &newer_verbose),
1914 msg("user", "protected tail"),
1915 ];
1916 let original = messages.clone();
1917
1918 // Simulate the caller clearing its token budget after one suffix prune.
1919 let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
1920
1921 assert!(saved > 0);
1922 assert_eq!(&messages[..3], &original[..3]);
1923 assert_eq!(&messages[4..], &original[4..]);
1924 let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
1925 panic!("expected pruned tool result");
1926 };
1927 assert!(content.contains("[read_file] tool result pruned"));
1928 assert!(content.contains("new.txt"));
1929 assert!(content.len() < newer_verbose.len());
1930 }
1931
1932 #[test]
1933 fn prune_tool_results_stops_after_newest_duplicate_prune() {
1934 let oldest = "oldest ".repeat(80);
1935 let middle = "middle ".repeat(80);
1936 let latest = "latest ".repeat(80);
1937 let mut messages = vec![
1938 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1939 tool_result("call-1", &oldest),
1940 tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
1941 tool_result("call-2", &middle),
1942 tool_use("call-3", "read_file", json!({"path": "Cargo.toml"})),
1943 tool_result("call-3", &latest),
1944 msg("user", "protected tail"),
1945 ];
1946 let original = messages.clone();
1947
1948 let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
1949
1950 assert!(saved > 0);
1951 assert_eq!(&messages[..3], &original[..3]);
1952 assert_eq!(&messages[4..], &original[4..]);
1953 let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
1954 panic!("expected middle duplicate to be pruned");
1955 };
1956 assert!(content.contains("[read_file] tool result pruned"));
1957 }
1958
1959 #[test]
1960 fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() {
1961 let first = "first ".repeat(80);
1962 let second = "second ".repeat(80);
1963 let mut messages = vec![
1964 tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
1965 tool_result("call-1", &first),
1966 tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
1967 tool_result("call-2", &second),
1968 msg("user", "tail"),
1969 ];
1970
1971 let saved = prune_tool_results(&mut messages, 1);
1972
1973 assert!(saved > 0);
1974 let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else {
1975 panic!("expected older tool result");
1976 };
1977 assert!(older.contains("tool result pruned"));
1978 let ContentBlock::ToolResult {
1979 content: latest, ..
1980 } = &messages[3].content[0]
1981 else {
1982 panic!("expected latest tool result");
1983 };
1984 assert_eq!(latest, &second);
1985 }
1986
1987 #[test]
1988 fn context_window_errors_are_detected_for_summary_fallback() {
1989 for msg in [
1990 "HTTP 400 Bad Request: maximum context length is 1000000 tokens",
1991 "invalid_request_error: prompt is too long for the current model",
1992 "You requested 1000001 tokens but the maximum is 1000000",
1993 "request exceeds context window",
1994 ] {
1995 assert!(
1996 is_context_window_error(&anyhow::anyhow!(msg)),
1997 "expected context-window detection for `{msg}`",
1998 );
1999 }
2000
2001 assert!(!is_context_window_error(&anyhow::anyhow!(
2002 "Invalid request: missing required field"
2003 )));
2004 assert!(!is_context_window_error(&anyhow::anyhow!(
2005 "503 Service Unavailable"
2006 )));
2007 }
2008
2009 #[test]
2010 fn tool_args_preview_redacts_sensitive_first_without_dropping_siblings() {
2011 let input: serde_json::Value = serde_json::from_str(
2012 r#"{"api_key":"sk-tool-secret-value","command":"cargo test -p auth"}"#,
2013 )
2014 .unwrap();
2015
2016 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2017
2018 assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
2019 assert_eq!(preview["command"], "cargo test -p auth");
2020 }
2021
2022 #[test]
2023 fn tool_args_preview_redacts_sensitive_later_without_touching_earlier_fields() {
2024 let input: serde_json::Value =
2025 serde_json::from_str(r#"{"command":"cargo test","api_key":"plain-secret-value"}"#)
2026 .unwrap();
2027
2028 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2029
2030 assert_eq!(preview["command"], "cargo test");
2031 assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
2032 }
2033
2034 #[test]
2035 fn tool_args_preview_redacts_nested_sensitive_values_recursively() {
2036 let input: serde_json::Value = serde_json::from_str(
2037 r#"{"meta":{"token":"nested-secret","keep":"yes"},"steps":[{"password":"pw","name":"a"}]}"#,
2038 )
2039 .unwrap();
2040
2041 let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
2042
2043 assert_eq!(
2044 preview["meta"]["token"],
2045 codewhale_config::persistence::REDACTED
2046 );
2047 assert_eq!(preview["meta"]["keep"], "yes");
2048 assert_eq!(
2049 preview["steps"][0]["password"],
2050 codewhale_config::persistence::REDACTED
2051 );
2052 assert_eq!(preview["steps"][0]["name"], "a");
2053 }
2054
2055 #[test]
2056 fn tool_args_preview_redacts_complete_multi_word_secret_value() {
2057 let input: serde_json::Value =
2058 serde_json::from_str(r#"{"command":"run this","password":"hunter two words"}"#)
2059 .unwrap();
2060
2061 let serialized = tool_args_preview(&input);
2062 let preview: serde_json::Value = serde_json::from_str(&serialized).unwrap();
2063
2064 assert_eq!(preview["command"], "run this");
2065 assert_eq!(preview["password"], codewhale_config::persistence::REDACTED);
2066 assert!(!serialized.contains("hunter"));
2067 assert!(!serialized.contains("two words"));
2068 }
2069
2070 struct FixedSummaryClient {
2071 request: std::sync::Mutex<Option<MessageRequest>>,
2072 provider: &'static str,
2073 model: &'static str,
2074 }
2075
2076 impl Default for FixedSummaryClient {
2077 fn default() -> Self {
2078 Self {
2079 request: std::sync::Mutex::new(None),
2080 provider: "test",
2081 model: "test-model",
2082 }
2083 }
2084 }
2085
2086 impl FixedSummaryClient {
2087 fn for_route(provider: &'static str, model: &'static str) -> Self {
2088 Self {
2089 request: std::sync::Mutex::new(None),
2090 provider,
2091 model,
2092 }
2093 }
2094 }
2095
2096 const FIXED_SUMMARY: &str = "1. Primary request and intent — migrate the session store. \
2097 2. Key technical concepts — sqlite. 7. Pending tasks — finish the fixed clock. \
2098 8. Current work — rerunning the session tests.";
2099
2100 struct ScriptedSummaryClient {
2101 responses: std::sync::Mutex<std::collections::VecDeque<anyhow::Result<Vec<ContentBlock>>>>,
2102 requests: std::sync::Mutex<Vec<MessageRequest>>,
2103 retry_started: Option<std::sync::Arc<tokio::sync::Notify>>,
2104 }
2105
2106 impl ScriptedSummaryClient {
2107 fn new(responses: Vec<Vec<ContentBlock>>) -> Self {
2108 Self::with_outcomes(responses.into_iter().map(Ok).collect())
2109 }
2110
2111 fn with_outcomes(responses: Vec<anyhow::Result<Vec<ContentBlock>>>) -> Self {
2112 Self {
2113 responses: std::sync::Mutex::new(responses.into()),
2114 requests: std::sync::Mutex::new(Vec::new()),
2115 retry_started: None,
2116 }
2117 }
2118 }
2119
2120 #[async_trait::async_trait]
2121 impl crate::core::model_client::ModelClient for ScriptedSummaryClient {
2122 fn provider_name(&self) -> &str {
2123 "test"
2124 }
2125
2126 fn model(&self) -> &str {
2127 "test-model"
2128 }
2129
2130 async fn create_message(
2131 &self,
2132 request: MessageRequest,
2133 ) -> anyhow::Result<codewhale_models::MessageResponse> {
2134 self.requests
2135 .lock()
2136 .expect("capture scripted summary request")
2137 .push(request);
2138 let outcome = self
2139 .responses
2140 .lock()
2141 .expect("read scripted summary response")
2142 .pop_front();
2143 if outcome.is_none()
2144 && let Some(retry_started) = &self.retry_started
2145 {
2146 retry_started.notify_one();
2147 return std::future::pending().await;
2148 }
2149 let content = outcome
2150 .ok_or_else(|| anyhow::anyhow!("scripted summary responses exhausted"))??;
2151 Ok(codewhale_models::MessageResponse {
2152 id: "summary-scripted".to_string(),
2153 r#type: "message".to_string(),
2154 role: "assistant".to_string(),
2155 content,
2156 model: "test-model".to_string(),
2157 stop_reason: None,
2158 stop_sequence: None,
2159 container: None,
2160 usage: Usage {
2161 input_tokens: 17,
2162 output_tokens: 3,
2163 prompt_cache_hit_tokens: Some(5),
2164 reasoning_tokens: Some(2),
2165 ..Usage::default()
2166 },
2167 })
2168 }
2169
2170 async fn create_message_stream(
2171 &self,
2172 _request: MessageRequest,
2173 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
2174 anyhow::bail!("streaming is unused by compaction")
2175 }
2176
2177 async fn health_check(&self) -> anyhow::Result<bool> {
2178 Ok(true)
2179 }
2180 }
2181
2182 #[async_trait::async_trait]
2183 impl crate::core::model_client::ModelClient for FixedSummaryClient {
2184 fn provider_name(&self) -> &str {
2185 self.provider
2186 }
2187
2188 fn model(&self) -> &str {
2189 self.model
2190 }
2191
2192 async fn create_message(
2193 &self,
2194 request: MessageRequest,
2195 ) -> anyhow::Result<codewhale_models::MessageResponse> {
2196 *self.request.lock().expect("capture summary request") = Some(request);
2197 Ok(codewhale_models::MessageResponse {
2198 id: "summary-fixture".to_string(),
2199 r#type: "message".to_string(),
2200 role: "assistant".to_string(),
2201 content: vec![ContentBlock::Text {
2202 text: FIXED_SUMMARY.to_string(),
2203 cache_control: None,
2204 }],
2205 model: self.model.to_string(),
2206 stop_reason: None,
2207 stop_sequence: None,
2208 container: None,
2209 usage: codewhale_models::Usage::default(),
2210 })
2211 }
2212
2213 async fn create_message_stream(
2214 &self,
2215 _request: MessageRequest,
2216 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
2217 anyhow::bail!("streaming is unused by compaction")
2218 }
2219
2220 async fn health_check(&self) -> anyhow::Result<bool> {
2221 Ok(true)
2222 }
2223 }
2224
2225 #[tokio::test]
2226 async fn compaction_persists_original_and_model_handoff_before_returning_replacement() {
2227 let _environment = crate::test_support::lock_test_env();
2228 let root = tempfile::tempdir().unwrap();
2229 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
2230 let original = (0..12)
2231 .map(|i| {
2232 msg(
2233 if i % 2 == 0 { "user" } else { "assistant" },
2234 &format!("Work item {i}"),
2235 )
2236 })
2237 .collect::<Vec<_>>();
2238 let mut envelope = prepared(&CompactionConfig::default());
2239 envelope.session_id = Some("handoff-test".into());
2240 let client = FixedSummaryClient::default();
2241 let mut usage = Usage::default();
2242 let result = compact_messages_safe(&client, &original, None, &envelope, &mut usage)
2243 .await
2244 .unwrap();
2245 assert!(result.summary_prompt.is_some());
2246 let files = std::fs::read_dir(root.path().join("sessions/handoff-test/artifacts"))
2247 .unwrap()
2248 .map(|entry| entry.unwrap().path())
2249 .collect::<Vec<_>>();
2250 let json = files
2251 .iter()
2252 .find(|path| path.extension().is_some_and(|ext| ext == "json"))
2253 .unwrap();
2254 let restored: Vec<Message> = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
2255 assert_eq!(restored, original);
2256 let markdown = files
2257 .iter()
2258 .find(|path| path.extension().is_some_and(|ext| ext == "md"))
2259 .unwrap();
2260 assert!(
2261 std::fs::read_to_string(markdown)
2262 .unwrap()
2263 .contains("migrate the session store")
2264 );
2265 // An unwritable artifact destination must abort before a provider call.
2266 envelope.session_id = Some("blocked-handoff".into());
2267 std::fs::write(
2268 root.path().join("sessions/blocked-handoff"),
2269 b"not a directory",
2270 )
2271 .unwrap();
2272 let blocked = FixedSummaryClient::default();
2273 assert!(
2274 compact_messages_safe(&blocked, &original, None, &envelope, &mut usage)
2275 .await
2276 .is_err()
2277 );
2278 assert!(blocked.request.lock().unwrap().is_none());
2279 }
2280
2281 #[tokio::test]
2282 async fn compaction_commits_summary_and_retains_recent_user_messages() {
2283 let messages = vec![
2284 msg(
2285 "user",
2286 "Objective: migrate the session store to sqlite without breaking existing logins",
2287 ),
2288 msg("assistant", "Working on it."),
2289 tool_use(
2290 "t1",
2291 "Bash",
2292 json!({"command": "cargo test -p session-store"}),
2293 ),
2294 tool_result("t1", "test session_store::roundtrip ... ok\nexit code 0"),
2295 msg("user", "Sounds good, do it"),
2296 msg("assistant", "Nearly done, rerunning the suite."),
2297 ];
2298 let config = CompactionConfig {
2299 model: "test-model".to_string(),
2300 cache_summary: false,
2301 ..Default::default()
2302 };
2303 let client = FixedSummaryClient::default();
2304
2305 let (retained, summary_prompt, _) =
2306 compact_messages(&client, &messages, &config).await.unwrap();
2307
2308 let request = client
2309 .request
2310 .lock()
2311 .expect("read summary request")
2312 .clone()
2313 .expect("summary request was captured");
2314 assert_eq!(&request.messages[..messages.len()], messages.as_slice());
2315 assert_eq!(request.messages.len(), messages.len() + 1);
2316 let ContentBlock::Text { text, .. } = &request.messages.last().unwrap().content[0] else {
2317 panic!("final compaction instruction must be text");
2318 };
2319 assert!(!text.contains(COMPACTION_SUMMARY_MARKER));
2320 assert_eq!(request.temperature, None);
2321 assert_eq!(request.top_p, None);
2322 assert_eq!(
2323 request.max_tokens,
2324 crate::route_budget::effective_max_output_tokens_for_route(
2325 crate::config::ApiProvider::Custom,
2326 "test-model",
2327 None,
2328 )
2329 );
2330
2331 let Some(SystemPrompt::Blocks(blocks)) = summary_prompt else {
2332 panic!("compaction must produce a summary system block");
2333 };
2334 let text = &blocks[0].text;
2335 assert!(text.contains(FIXED_SUMMARY));
2336 assert!(text.contains("Another language model"));
2337
2338 // Replacement history keeps older user turns, then the open round
2339 // verbatim (user + assistant + tools), then one checkpoint.
2340 assert!(retained.iter().any(|message| {
2341 user_text_of(message).is_some_and(|text| text.contains("Objective: migrate"))
2342 }));
2343 assert!(
2344 retained
2345 .iter()
2346 .any(|message| { user_text_of(message).as_deref() == Some("Sounds good, do it") })
2347 );
2348 assert!(retained.iter().any(|message| {
2349 message.role.is_assistant_like()
2350 && message.content.iter().any(|block| {
2351 matches!(
2352 block,
2353 ContentBlock::Text { text, .. }
2354 if text.contains("Nearly done, rerunning the suite.")
2355 )
2356 })
2357 }));
2358 assert!(retained.iter().any(|message| {
2359 message.content.iter().any(|block| {
2360 matches!(
2361 block,
2362 ContentBlock::ToolResult { content, .. }
2363 if content.contains("session_store::roundtrip")
2364 )
2365 })
2366 }));
2367 assert!(is_compaction_checkpoint_message(retained.last().unwrap()));
2368 assert!(is_wire_compaction_checkpoint_message(
2369 retained.last().unwrap()
2370 ));
2371 assert!(matches!(
2372 &retained.last().unwrap().content[0],
2373 ContentBlock::Text { text: checkpoint, .. } if checkpoint == text
2374 ));
2375 last_round::validate_last_round_coverage(&messages, &retained[..retained.len() - 1])
2376 .unwrap();
2377 }
2378
2379 #[tokio::test]
2380 async fn uninterrupted_task_compacts_repeatedly_with_original_prefix_and_recent_tool_pairs() {
2381 let system = SystemPrompt::Text("stable project instructions and permissions".into());
2382 let mut prepared = PreparedCompactionEnvelope::new(CompactionConfig {
2383 token_threshold: 40_000,
2384 model: "test-model".into(),
2385 ..Default::default()
2386 });
2387 prepared.tools = Some(vec![
2388 serde_json::from_value(json!({
2389 "name": "File", "description": "Read a file", "input_schema": {"type": "object"}
2390 }))
2391 .unwrap(),
2392 ]);
2393 let client = FixedSummaryClient::default();
2394 let mut messages = vec![msg(
2395 "user",
2396 "Finish the migration. Preserve logins; do not publish.",
2397 )];
2398 for epoch in 0..3 {
2399 for step in 0..20 {
2400 let id = format!("{epoch}-{step}");
2401 let mut call = tool_use(&id, "File", json!({"path":"session.rs"}));
2402 call.content.insert(
2403 0,
2404 ContentBlock::Text {
2405 text: format!("Evidence {id}: {}", "x".repeat(12_000)),
2406 cache_control: None,
2407 },
2408 );
2409 if step == 19 {
2410 call.content.insert(
2411 0,
2412 ContentBlock::Thinking {
2413 thinking: "retained reasoning ".repeat(2000),
2414 signature: None,
2415 state: None,
2416 },
2417 );
2418 }
2419 messages.push(call);
2420 messages.push(tool_result(
2421 &id,
2422 &format!("Observed {id}: {}", "e".repeat(1000)),
2423 ));
2424 }
2425 let original = messages.clone();
2426 let mut usage = Usage::default();
2427 let result =
2428 compact_messages_safe(&client, &messages, Some(&system), &prepared, &mut usage)
2429 .await
2430 .unwrap();
2431 let request = client.request.lock().unwrap().clone().unwrap();
2432 assert_eq!(request.system.as_ref(), Some(&system));
2433 assert_eq!(request.tools, prepared.tools);
2434 assert_eq!(request.tool_choice, Some(json!("none")));
2435 assert_eq!(
2436 &request.messages[..original.len()],
2437 original.as_slice(),
2438 "summary must see the original evidence and reusable history prefix"
2439 );
2440 assert!(estimate_tokens(&result.messages) < estimate_tokens(&original) / 2);
2441 assert_eq!(
2442 result
2443 .messages
2444 .iter()
2445 .filter(|m| is_compaction_checkpoint_message(m))
2446 .count(),
2447 1
2448 );
2449 for step in [18, 19] {
2450 let id = format!("{epoch}-{step}");
2451 let expected = original.iter().find(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolUse { id: found, .. } if found == &id))).unwrap();
2452 assert!(
2453 result.messages.contains(expected),
2454 "retained assistant text, calls and reasoning must survive unchanged"
2455 );
2456 assert!(result.messages.iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == &id))));
2457 }
2458 assert_eq!(result.messages[0], original[0]);
2459 messages = result.messages;
2460 }
2461 }
2462
2463 #[test]
2464 fn coverage_floor_rejects_a_replacement_that_drops_last_round_assistant() {
2465 let original = vec![
2466 msg("user", "What failed?"),
2467 msg("assistant", "session_store::roundtrip panics on reload."),
2468 ];
2469 let gutting = vec![msg("user", "What failed?")];
2470 let error = last_round::validate_last_round_coverage(&original, &gutting)
2471 .expect_err("dropping last-round assistant text must fail closed");
2472 assert!(error.to_string().contains("assistant"), "{error}");
2473 }
2474
2475 #[tokio::test]
2476 async fn compaction_preserves_runtime_provenance_and_saved_user_title() {
2477 let runtime = crate::runtime_handoff::operate_contract_runtime_message();
2478 let mut user = msg("user", "Build a focus timer");
2479 user.content.push(ContentBlock::Text {
2480 text: "<turn_meta>\nInput provenance: external_user\nInput authority: external_current_turn\n</turn_meta>".to_string(),
2481 cache_control: None,
2482 });
2483 let messages = vec![
2484 runtime.clone(),
2485 msg("assistant", "Ready."),
2486 user.clone(),
2487 msg("assistant", "Building the timer."),
2488 msg("user", "Keep the controls simple"),
2489 msg("assistant", "Adding start and stop."),
2490 ];
2491 let (retained, summary, _) = compact_messages(
2492 &FixedSummaryClient::default(),
2493 &messages,
2494 &CompactionConfig::default(),
2495 )
2496 .await
2497 .unwrap();
2498 assert_eq!(retained.first(), Some(&runtime));
2499 assert!(retained.contains(&user));
2500 assert!(crate::runtime_handoff::is_operate_contract_message(
2501 &retained[0]
2502 ));
2503 let saved = crate::session_manager::create_saved_session_with_mode(
2504 &retained,
2505 "test-model",
2506 std::path::Path::new("."),
2507 0,
2508 summary.as_ref(),
2509 None,
2510 );
2511 let restored: crate::session_manager::SavedSession =
2512 serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
2513 assert_eq!(restored.metadata.title, "Build a focus timer");
2514 assert_eq!(restored.messages, retained);
2515 }
2516
2517 #[test]
2518 fn retained_literal_runtime_xml_remains_user_authored() {
2519 let runtime = crate::runtime_handoff::operate_contract_runtime_message();
2520 // A user may paste the exact bytes, including a metadata example, in
2521 // one ordinary text block. Compaction must not split it into authority.
2522 let literal = user_text_of(&runtime).unwrap();
2523 let user = msg("user", &literal);
2524 let retained = retained_user_messages(std::slice::from_ref(&user), usize::MAX);
2525 assert_eq!(retained, vec![user]);
2526 assert_eq!(
2527 crate::runtime_handoff::classify_user_turn_prompt(&retained[0]),
2528 crate::runtime_handoff::UserTurnPromptKind::Editable,
2529 );
2530 assert_eq!(
2531 crate::session_manager::conversation_title_prompt(&retained),
2532 Some(literal.as_str()),
2533 );
2534 assert!(!crate::runtime_handoff::is_operate_contract_message(
2535 &retained[0]
2536 ));
2537 }
2538
2539 #[test]
2540 fn retained_image_turn_and_structured_content_keep_their_boundaries() {
2541 let image = Message {
2542 role: Role::User,
2543 content: vec![ContentBlock::ImageUrl {
2544 image_url: ImageUrlContent {
2545 url: "data:image/png;base64,AAAA".to_string(),
2546 },
2547 }],
2548 };
2549 let mut mixed = msg("user", "Compare these views");
2550 mixed.content.extend(image.content.clone());
2551 mixed.content.push(ContentBlock::Text {
2552 text: "Keep the original colors".to_string(),
2553 cache_control: Some(CacheControl {
2554 cache_type: "ephemeral".to_string(),
2555 }),
2556 });
2557 let messages = vec![image, mixed];
2558 let retained = retained_user_messages(&messages, 3_000);
2559 assert_eq!(retained, messages);
2560 let saved = crate::session_manager::create_saved_session_with_mode(
2561 &retained,
2562 "test-model",
2563 std::path::Path::new("."),
2564 0,
2565 None,
2566 None,
2567 );
2568 assert_eq!(
2569 saved.metadata.title,
2570 crate::session_manager::DEFAULT_SESSION_TITLE
2571 );
2572 assert!(retained_user_messages(&messages[..1], IMAGE_TOKEN_ESTIMATE - 1).is_empty());
2573 }
2574
2575 #[test]
2576 fn retained_budget_never_partially_promotes_structural_metadata() {
2577 let runtime = crate::runtime_handoff::operate_contract_runtime_message();
2578 let text = msg("user", "αβγδεζηθικ");
2579 let retained = retained_user_messages(&[runtime.clone(), text.clone()], 5);
2580 assert_eq!(retained, vec![text]);
2581 assert!(retained_user_messages(&[runtime], 1).is_empty());
2582 assert_eq!(
2583 user_text_of(&retained_user_messages(&[msg("user", "αβγδεζηθικ")], 2)[0]).as_deref(),
2584 Some("αβγδεζ"),
2585 );
2586 }
2587
2588 #[test]
2589 fn retained_older_turn_drops_result_blocks_whose_call_was_summarized() {
2590 // #6119: a host-supplied user message can mix text with a tool
2591 // result; the tool_use it answers lives in the summarized region, so
2592 // the retained copy must keep the text and drop the orphaned result.
2593 let mut mixed = msg("user", "Please keep this context.");
2594 mixed.content.push(ContentBlock::ToolResult {
2595 tool_use_id: "toolu_orphan_1".to_string(),
2596 content: "{\"ok\":true}".to_string(),
2597 is_error: None,
2598 content_blocks: None,
2599 });
2600 let retained = retained_user_messages(std::slice::from_ref(&mixed), usize::MAX);
2601 assert_eq!(retained.len(), 1);
2602 assert!(
2603 !retained[0]
2604 .content
2605 .iter()
2606 .any(|block| matches!(block, ContentBlock::ToolResult { .. })),
2607 "the retained copy must not keep an orphaned tool_result"
2608 );
2609 assert_eq!(
2610 user_text_of(&retained[0]).as_deref(),
2611 Some("Please keep this context.")
2612 );
2613 // Insufficient budget still refuses to partially retain a multi-block
2614 // turn; the structural-metadata guard is unchanged.
2615 assert!(retained_user_messages(std::slice::from_ref(&mixed), 1).is_empty());
2616 }
2617
2618 #[test]
2619 fn summary_quality_gate_rejects_empty_and_known_placeholder_text() {
2620 for summary in [
2621 "",
2622 " \n\t ",
2623 "...",
2624 "。。。",
2625 "🫧",
2626 "N/A",
2627 "(no summary available)",
2628 "I cannot provide a summary.",
2629 ] {
2630 let error = validate_compaction_summary(summary)
2631 .expect_err("degenerate summary must fail closed");
2632 assert!(error.to_string().contains("unusable"), "{error}");
2633 }
2634 validate_compaction_summary(FIXED_SUMMARY)
2635 .expect("a substantive continuation handoff must be accepted");
2636 validate_compaction_summary(
2637 "目的: #4394の空要約を防止。完了: 検証と実装。制約: 履歴を変更しない。次: テスト実行。",
2638 )
2639 .expect("a concise multilingual handoff must not be rejected by prose length");
2640 }
2641
2642 #[tokio::test]
2643 async fn empty_successful_summary_retries_once_without_replacing_history() {
2644 let original = vec![
2645 msg(
2646 "user",
2647 "Keep the migration transactional and preserve existing sessions.",
2648 ),
2649 msg(
2650 "assistant",
2651 "I am updating the session store and its fixtures.",
2652 ),
2653 ];
2654 let client = ScriptedSummaryClient::new(vec![
2655 vec![ContentBlock::Text {
2656 text: " \n\t ".to_string(),
2657 cache_control: None,
2658 }],
2659 vec![ContentBlock::Text {
2660 text: FIXED_SUMMARY.to_string(),
2661 cache_control: None,
2662 }],
2663 ]);
2664 let config = CompactionConfig {
2665 model: "test-model".to_string(),
2666 cache_summary: false,
2667 ..Default::default()
2668 };
2669
2670 let mut invocation_usage = Usage::default();
2671 let result = compact_messages_safe(
2672 &client,
2673 &original,
2674 None,
2675 &prepared(&config),
2676 &mut invocation_usage,
2677 )
2678 .await
2679 .expect("the conservative retry should recover a usable summary");
2680 assert_eq!(invocation_usage.input_tokens, 34);
2681 assert_eq!(invocation_usage.output_tokens, 6);
2682 assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
2683 assert_eq!(invocation_usage.reasoning_tokens, Some(4));
2684
2685 let requests = client
2686 .requests
2687 .lock()
2688 .expect("read scripted summary requests");
2689 assert_eq!(requests.len(), 2, "quality failure retries exactly once");
2690 let ContentBlock::Text { text, .. } = &requests[1]
2691 .messages
2692 .last()
2693 .expect("retry instruction")
2694 .content[0]
2695 else {
2696 panic!("retry instruction must be text");
2697 };
2698 assert!(text.contains("previous handoff response was empty"));
2699 drop(requests);
2700
2701 assert_eq!(
2702 result.retries_used, 1,
2703 "quality retry must reach diagnostics"
2704 );
2705 assert_eq!(original[0].role, "user", "source history remains untouched");
2706 assert!(result.messages.iter().any(is_compaction_checkpoint_message));
2707 let Some(SystemPrompt::Blocks(blocks)) = result.summary_prompt else {
2708 panic!("recovered summary must be committed");
2709 };
2710 assert!(blocks[0].text.contains(FIXED_SUMMARY));
2711 assert!(!blocks[0].text.contains("(no summary available)"));
2712 }
2713
2714 #[tokio::test]
2715 async fn quality_retry_count_survives_a_later_transient_failure() {
2716 let client = ScriptedSummaryClient::with_outcomes(vec![
2717 Ok(vec![ContentBlock::Text {
2718 text: "...".to_string(),
2719 cache_control: None,
2720 }]),
2721 Err(anyhow::anyhow!("request timed out")),
2722 Ok(vec![ContentBlock::Text {
2723 text: FIXED_SUMMARY.to_string(),
2724 cache_control: None,
2725 }]),
2726 ]);
2727 let config = CompactionConfig {
2728 model: "test-model".to_string(),
2729 cache_summary: false,
2730 ..Default::default()
2731 };
2732
2733 let mut invocation_usage = Usage::default();
2734 let result = compact_messages_safe(
2735 &client,
2736 &[msg("user", "Preserve the current migration state.")],
2737 None,
2738 &prepared(&config),
2739 &mut invocation_usage,
2740 )
2741 .await
2742 .expect("the outer retry should recover after the transient failure");
2743 assert_eq!(invocation_usage.input_tokens, 34);
2744 assert_eq!(invocation_usage.output_tokens, 6);
2745 assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
2746 assert_eq!(invocation_usage.reasoning_tokens, Some(4));
2747
2748 assert_eq!(
2749 result.retries_used, 2,
2750 "one quality retry plus one outer transient retry must be reported"
2751 );
2752 assert_eq!(
2753 client
2754 .requests
2755 .lock()
2756 .expect("read scripted summary requests")
2757 .len(),
2758 3,
2759 "the diagnostic count must match the two calls after the initial request"
2760 );
2761 }
2762
2763 #[tokio::test]
2764 async fn compaction_usage_survives_cancellation_during_quality_retry() {
2765 let _cost_scope = crate::cost_status::test_scope();
2766 let retry_started = std::sync::Arc::new(tokio::sync::Notify::new());
2767 let mut client = ScriptedSummaryClient::new(vec![vec![ContentBlock::Text {
2768 text: "...".to_string(),
2769 cache_control: None,
2770 }]]);
2771 client.retry_started = Some(std::sync::Arc::clone(&retry_started));
2772 let messages = vec![msg("user", "Preserve the migration state.")];
2773 let prepared = prepared(&CompactionConfig::default());
2774 let mut invocation_usage = Usage::default();
2775 {
2776 let compaction =
2777 compact_messages_safe(&client, &messages, None, &prepared, &mut invocation_usage);
2778 tokio::pin!(compaction);
2779 tokio::select! {
2780 result = &mut compaction => panic!("retry must remain pending: {result:?}"),
2781 _ = retry_started.notified() => {},
2782 _ = tokio::time::sleep(Duration::from_secs(10)) => panic!("quality retry did not start"),
2783 }
2784 }
2785 assert_eq!(invocation_usage.input_tokens, 17);
2786 assert_eq!(invocation_usage.output_tokens, 3);
2787 assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(5));
2788 assert_eq!(invocation_usage.reasoning_tokens, Some(2));
2789 assert_eq!(client.requests.lock().unwrap().len(), 2);
2790 }
2791
2792 #[tokio::test]
2793 async fn non_text_summary_failure_preserves_history_after_one_retry() {
2794 let original = vec![
2795 msg(
2796 "user",
2797 "Do not lose the current branch or the failing test name.",
2798 ),
2799 msg("assistant", "The failing test is session_store::roundtrip."),
2800 ];
2801 let client = ScriptedSummaryClient::new(vec![
2802 vec![ContentBlock::thinking("internal-only response")],
2803 vec![ContentBlock::thinking("still no user-visible handoff")],
2804 ]);
2805 let config = CompactionConfig {
2806 model: "test-model".to_string(),
2807 cache_summary: false,
2808 ..Default::default()
2809 };
2810
2811 let mut invocation_usage = Usage::default();
2812 let error = compact_messages_safe(
2813 &client,
2814 &original,
2815 None,
2816 &prepared(&config),
2817 &mut invocation_usage,
2818 )
2819 .await
2820 .expect_err("two non-text responses must not replace history");
2821 assert_eq!(invocation_usage.input_tokens, 34);
2822 assert_eq!(invocation_usage.output_tokens, 6);
2823 assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
2824 assert_eq!(invocation_usage.reasoning_tokens, Some(4));
2825
2826 assert!(
2827 error
2828 .to_string()
2829 .contains("remained unusable after one conservative retry"),
2830 "{error}"
2831 );
2832 assert_eq!(
2833 client
2834 .requests
2835 .lock()
2836 .expect("read scripted summary requests")
2837 .len(),
2838 2,
2839 "quality failure gets one retry, not the transient retry ladder"
2840 );
2841 assert_eq!(
2842 original,
2843 vec![
2844 msg(
2845 "user",
2846 "Do not lose the current branch or the failing test name."
2847 ),
2848 msg("assistant", "The failing test is session_store::roundtrip."),
2849 ],
2850 "borrowed source history must remain byte-for-byte unchanged"
2851 );
2852 }
2853 #[tokio::test]
2854 async fn compaction_uses_the_resolved_route_output_allowance() {
2855 for (route_label, provider, model) in [
2856 (
2857 "thinking-default route",
2858 crate::config::ApiProvider::Deepseek,
2859 "deepseek-v4-flash",
2860 ),
2861 (
2862 "fixed-sampling route",
2863 crate::config::ApiProvider::Moonshot,
2864 "k3",
2865 ),
2866 ] {
2867 let client = FixedSummaryClient::for_route(provider.as_str(), model);
2868 let config = CompactionConfig {
2869 model: model.to_string(),
2870 cache_summary: false,
2871 ..Default::default()
2872 };
2873 compact_messages(&client, &[msg("user", "summarize this task")], &config)
2874 .await
2875 .expect("route compaction should complete");
2876
2877 let request = client
2878 .request
2879 .lock()
2880 .expect("read summary request")
2881 .clone()
2882 .expect("summary request was captured");
2883 assert_eq!(
2884 request.max_tokens,
2885 crate::route_budget::effective_max_output_tokens_for_route(provider, model, None),
2886 "{route_label} must use the ordinary route output policy"
2887 );
2888 assert_eq!(request.temperature, None);
2889 assert_eq!(request.top_p, None);
2890 }
2891 }
2892
2893 struct TruncatedSummaryClient;
2894
2895 #[async_trait::async_trait]
2896 impl crate::core::model_client::ModelClient for TruncatedSummaryClient {
2897 fn provider_name(&self) -> &str {
2898 "test"
2899 }
2900
2901 fn model(&self) -> &str {
2902 "test-model"
2903 }
2904
2905 async fn create_message(
2906 &self,
2907 _request: MessageRequest,
2908 ) -> anyhow::Result<codewhale_models::MessageResponse> {
2909 Ok(codewhale_models::MessageResponse {
2910 id: "summary-truncated".to_string(),
2911 r#type: "message".to_string(),
2912 role: "assistant".to_string(),
2913 content: vec![ContentBlock::Text {
2914 text: "1. Primary request and intent — mig".to_string(),
2915 cache_control: None,
2916 }],
2917 model: "test-model".to_string(),
2918 stop_reason: Some("max_tokens".to_string()),
2919 stop_sequence: None,
2920 container: None,
2921 usage: codewhale_models::Usage::default(),
2922 })
2923 }
2924
2925 async fn create_message_stream(
2926 &self,
2927 _request: MessageRequest,
2928 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
2929 anyhow::bail!("streaming is unused by compaction")
2930 }
2931
2932 async fn health_check(&self) -> anyhow::Result<bool> {
2933 Ok(true)
2934 }
2935 }
2936
2937 /// A provider-truncated summary must fail compaction instead of replacing
2938 /// session history with a fragment.
2939 #[tokio::test]
2940 async fn truncated_summary_response_fails_compaction() {
2941 let messages: Vec<Message> = (0..40)
2942 .map(|index| {
2943 msg(
2944 if index % 2 == 0 { "user" } else { "assistant" },
2945 &format!("padding message {index} with enough text to compact"),
2946 )
2947 })
2948 .collect();
2949 let config = CompactionConfig {
2950 model: "test-model".to_string(),
2951 cache_summary: false,
2952 ..Default::default()
2953 };
2954
2955 let error = compact_messages(&TruncatedSummaryClient, &messages, &config)
2956 .await
2957 .expect_err("a truncated summary must not be committed");
2958 let text = error.to_string();
2959 assert!(text.contains("incomplete"), "{text}");
2960 assert!(text.contains("max_tokens"), "{text}");
2961 }
2962
2963 #[test]
2964 fn estimate_tokens_empty_messages() {
2965 let messages: Vec<Message> = vec![];
2966 assert_eq!(estimate_tokens(&messages), 0);
2967 }
2968
2969 #[test]
2970 fn estimate_tokens_with_text() {
2971 let messages = vec![Message {
2972 role: Role::User,
2973 content: vec![ContentBlock::Text {
2974 text: "Hello, world!".to_string(), // 13 chars = ~3 tokens
2975 cache_control: None,
2976 }],
2977 }];
2978 let tokens = estimate_tokens(&messages);
2979 assert!(tokens > 0 && tokens < 10);
2980 }
2981
2982 #[test]
2983 fn pressure_counts_text_only_reasoning_and_server_tool_payloads() {
2984 let payload = "retained evidence ".repeat(1000);
2985 let blocks = vec![
2986 ContentBlock::thinking(payload.clone()),
2987 ContentBlock::ServerToolUse {
2988 id: "server-call".into(),
2989 name: "code_execution".into(),
2990 input: json!({"code": payload}),
2991 },
2992 ContentBlock::CodeExecutionToolResult {
2993 tool_use_id: "server-call".into(),
2994 content: json!({"stdout": payload}),
2995 },
2996 ContentBlock::ToolSearchToolResult {
2997 tool_use_id: "search-call".into(),
2998 content: json!({"description": payload}),
2999 },
3000 ];
3001 for block in blocks {
3002 let messages = vec![Message {
3003 role: Role::Assistant,
3004 content: vec![block],
3005 }];
3006 assert!(estimate_tokens(&messages) >= payload.len() / 4);
3007 assert!(estimate_input_tokens_for_pressure(&messages, None) >= payload.len() / 4);
3008 }
3009 }
3010
3011 #[test]
3012 fn estimate_tokens_counts_tool_round_thinking_across_turns() {
3013 // Per DeepSeek thinking-mode rules, any assistant message that
3014 // performed a tool call keeps its reasoning_content in the request
3015 // forever, including across new user turns. Token estimates must
3016 // count those bytes.
3017 let thinking = "reasoning ".repeat(800);
3018 let current_messages = vec![
3019 Message {
3020 role: Role::User,
3021 content: vec![ContentBlock::Text {
3022 text: "Use a tool".to_string(),
3023 cache_control: None,
3024 }],
3025 },
3026 Message {
3027 role: Role::Assistant,
3028 content: vec![
3029 ContentBlock::Thinking {
3030 signature: None,
3031 state: None,
3032 thinking: thinking.clone(),
3033 },
3034 ContentBlock::ToolUse {
3035 id: "tool-1".to_string(),
3036 name: "read_file".to_string(),
3037 input: serde_json::json!({"path": "Cargo.toml"}),
3038 caller: None,
3039 thought_signature: None,
3040 },
3041 ],
3042 },
3043 Message {
3044 role: Role::User,
3045 content: vec![ContentBlock::ToolResult {
3046 tool_use_id: "tool-1".to_string(),
3047 content: "manifest".to_string(),
3048 is_error: None,
3049 content_blocks: None,
3050 }],
3051 },
3052 ];
3053 let historical_messages = {
3054 let mut messages = current_messages.clone();
3055 messages.push(Message {
3056 role: Role::Assistant,
3057 content: vec![ContentBlock::Text {
3058 text: "Done.".to_string(),
3059 cache_control: None,
3060 }],
3061 });
3062 messages.push(Message {
3063 role: Role::User,
3064 content: vec![ContentBlock::Text {
3065 text: "Next question.".to_string(),
3066 cache_control: None,
3067 }],
3068 });
3069 messages
3070 };
3071 let completed_messages = {
3072 let mut messages = current_messages.clone();
3073 messages.push(Message {
3074 role: Role::Assistant,
3075 content: vec![ContentBlock::Text {
3076 text: "Done.".to_string(),
3077 cache_control: None,
3078 }],
3079 });
3080 messages
3081 };
3082
3083 let lower_bound = thinking.len() / 5;
3084 assert!(estimate_tokens(&current_messages) > lower_bound);
3085 assert!(estimate_tokens(&completed_messages) > lower_bound);
3086 assert!(estimate_tokens(&historical_messages) > lower_bound);
3087 }
3088
3089 #[test]
3090 fn should_compact_respects_enabled_flag() {
3091 let config = CompactionConfig {
3092 enabled: false,
3093 ..Default::default()
3094 };
3095 // Even with many messages, disabled compaction should return false
3096 let messages: Vec<Message> = (0..100)
3097 .map(|_| Message {
3098 role: Role::User,
3099 content: vec![ContentBlock::Text {
3100 text: "test".to_string(),
3101 cache_control: None,
3102 }],
3103 })
3104 .collect();
3105 assert!(!should_compact(&messages, None, &prepared(&config)));
3106 }
3107
3108 /// The #5577 acceptance case: a session whose provider bills 842K prompt
3109 /// tokens on a 1M window (threshold 800K) MUST compact even when the
3110 /// local estimate is far lower — the bounded working list undercounts
3111 /// what the provider actually saw, and billed truth wins.
3112 #[test]
3113 fn billed_842k_on_a_1m_window_compacts_despite_a_small_estimate() {
3114 let config = CompactionConfig {
3115 enabled: true,
3116 token_threshold: 800_000,
3117 ..Default::default()
3118 };
3119 let messages: Vec<Message> = (0..40)
3120 .map(|i| Message {
3121 role: if i % 2 == 0 {
3122 Role::User
3123 } else {
3124 Role::Assistant
3125 },
3126 content: vec![ContentBlock::Text {
3127 text: format!("short message {i}"),
3128 cache_control: None,
3129 }],
3130 })
3131 .collect();
3132 // Estimate alone stays far under the trigger…
3133 assert!(!should_compact(&messages, None, &prepared(&config)));
3134 // …but the provider's billed prompt total decides.
3135 assert_eq!(
3136 compaction_decision_with_billed(&messages, None, &prepared(&config), Some(842_000)),
3137 CompactionDecision::Compact
3138 );
3139 }
3140
3141 /// A refusal under real pressure must name its guard so the host can
3142 /// tell the user, instead of the silent hold that reads as a broken
3143 /// auto-compactor (#5577).
3144 #[test]
3145 fn refusals_under_pressure_name_their_guard() {
3146 let config = CompactionConfig {
3147 enabled: true,
3148 token_threshold: 100,
3149 ..Default::default()
3150 };
3151 // Too few messages to summarize: over-pressure, short transcript.
3152 let few: Vec<Message> = (0..3)
3153 .map(|i| Message {
3154 role: Role::User,
3155 content: vec![ContentBlock::Text {
3156 text: format!("message {i} {}", "x".repeat(300)),
3157 cache_control: None,
3158 }],
3159 })
3160 .collect();
3161 assert_eq!(
3162 compaction_decision_with_billed(&few, None, &prepared(&config), None),
3163 CompactionDecision::Refused(CompactionRefusal::TooFewMessages { count: few.len() })
3164 );
3165
3166 // Retained floor above the trigger: a giant system prompt no pass
3167 // can reclaim. The refusal carries the numbers the user needs.
3168 let many: Vec<Message> = (0..12)
3169 .map(|i| Message {
3170 role: if i % 2 == 0 {
3171 Role::User
3172 } else {
3173 Role::Assistant
3174 },
3175 content: vec![ContentBlock::Text {
3176 text: format!("message {i} {}", "y".repeat(200)),
3177 cache_control: None,
3178 }],
3179 })
3180 .collect();
3181 let system = SystemPrompt::Text("s".repeat(4_000));
3182 match compaction_decision_with_billed(&many, Some(&system), &prepared(&config), None) {
3183 CompactionDecision::Refused(CompactionRefusal::RetainedFloor { floor, threshold }) => {
3184 assert_eq!(threshold, 100);
3185 assert!(floor >= threshold, "floor {floor} must be over {threshold}");
3186 }
3187 other => panic!("expected a retained-floor refusal, got {other:?}"),
3188 }
3189 }
3190
3191 /// v0.8.11: message-count is no longer a compaction trigger. Long
3192 /// chats of small messages stay uncompacted because rewriting the
3193 /// prefix cache for a tiny budget reclaim is net-negative. Only token
3194 /// pressure (and the explicit `/compact` slash command) trigger
3195 /// compaction.
3196 #[test]
3197 fn message_count_no_longer_triggers_compaction() {
3198 let config = CompactionConfig {
3199 enabled: true,
3200 token_threshold: 1_000_000,
3201 ..Default::default()
3202 };
3203
3204 // 200 tiny messages, well above the prior message threshold.
3205 let many_messages: Vec<Message> = (0..200)
3206 .map(|_| Message {
3207 role: Role::User,
3208 content: vec![ContentBlock::Text {
3209 text: "x".to_string(),
3210 cache_control: None,
3211 }],
3212 })
3213 .collect();
3214 // Token total stays minuscule so the token threshold is not hit;
3215 // without the prior message-count trigger, no compaction.
3216 assert!(!should_compact(&many_messages, None, &prepared(&config)));
3217 }
3218
3219 // ========================================================================
3220 // Additional Compaction Trigger Tests
3221 // ========================================================================
3222
3223 #[test]
3224 fn full_request_pressure_crosses_token_threshold() {
3225 let config = CompactionConfig {
3226 enabled: true,
3227 token_threshold: 20_000,
3228 ..Default::default()
3229 };
3230
3231 // Create messages that exceed token threshold
3232 let messages: Vec<Message> = (0..20).map(|_| msg("user", &"x".repeat(5_000))).collect();
3233
3234 assert!(compaction_pressure_reached(&messages, None, &config));
3235 }
3236
3237 #[test]
3238 fn auto_compaction_uses_full_request_pressure_across_context_sizes() {
3239 for (window, output_reserve) in [
3240 (128_000_u64, 4_096_u64),
3241 (272_000, 4_096),
3242 // Large windows use the same ordinary request reservation; there
3243 // is no second, non-wire reasoning allowance.
3244 (1_000_000, 65_536),
3245 ] {
3246 let budget = crate::context_budget::ContextBudget::new(window, 0, output_reserve);
3247 let threshold = usize::try_from(budget.compaction_trigger_for_percent(80.0))
3248 .expect("test threshold fits usize");
3249 let raw_target = threshold.saturating_mul(7) / 10;
3250 let chars_per_message = raw_target.saturating_mul(4) / 14;
3251 let messages: Vec<Message> = (0..14)
3252 .map(|index| {
3253 msg(
3254 if index % 2 == 0 { "user" } else { "assistant" },
3255 &"x".repeat(chars_per_message),
3256 )
3257 })
3258 .collect();
3259 let raw = estimate_tokens(&messages);
3260 let full = estimate_input_tokens_for_pressure(&messages, None);
3261 let config = CompactionConfig {
3262 enabled: true,
3263 token_threshold: threshold,
3264 ..Default::default()
3265 };
3266
3267 assert!(
3268 raw < threshold,
3269 "raw message estimator alone must not cross {window}"
3270 );
3271 // The pressure estimate adds per-message framing on top of the
3272 // raw message tokens; billed usage from the provider can also
3273 // cross the trigger on its own.
3274 assert!(
3275 full < threshold,
3276 "70%-filled fixture must stay under the {window} trigger: {full} >= {threshold}"
3277 );
3278 assert!(
3279 crate::compaction::compaction_pressure_reached_with_billed(
3280 &messages,
3281 None,
3282 &config,
3283 Some(threshold as u64),
3284 ),
3285 "billed prompt tokens at the trigger must reach pressure for {window}"
3286 );
3287 assert!(
3288 crate::compaction::should_compact_with_billed(
3289 &messages,
3290 None,
3291 &prepared(&config),
3292 Some(threshold as u64),
3293 ),
3294 "billed pressure must trigger eligibility for a {window}-token route"
3295 );
3296 }
3297 }
3298
3299 #[test]
3300 fn auto_compaction_skips_pressure_that_cannot_be_reclaimed_below_trigger() {
3301 let messages: Vec<Message> = (0..20)
3302 .map(|index| {
3303 msg(
3304 if index % 2 == 0 { "user" } else { "assistant" },
3305 &"x".repeat(500),
3306 )
3307 })
3308 .collect();
3309 let system = SystemPrompt::Text("s".repeat(24_000));
3310 let config = CompactionConfig {
3311 enabled: true,
3312 token_threshold: 10_000,
3313 ..Default::default()
3314 };
3315
3316 assert!(
3317 estimate_input_tokens_conservative(&messages, Some(&system)) >= config.token_threshold,
3318 "fixture must be under full-request pressure"
3319 );
3320 assert!(
3321 !should_compact(&messages, Some(&system), &prepared(&config)),
3322 "a pinned/system floor above the trigger would loop every tool step"
3323 );
3324 }
3325
3326 #[test]
3327 fn full_request_threshold_is_inclusive() {
3328 let messages: Vec<Message> = (0..10)
3329 .map(|index| msg(if index % 2 == 0 { "user" } else { "assistant" }, "payload"))
3330 .collect();
3331 let threshold = estimate_input_tokens_for_pressure(&messages, None);
3332 let config = CompactionConfig {
3333 enabled: true,
3334 token_threshold: threshold,
3335 ..Default::default()
3336 };
3337
3338 assert!(compaction_pressure_reached(&messages, None, &config));
3339 }
3340
3341 #[test]
3342 fn test_should_compact_below_token_threshold() {
3343 let config = CompactionConfig {
3344 enabled: true,
3345 token_threshold: 1000,
3346 ..Default::default()
3347 };
3348
3349 // Create short messages
3350 let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
3351
3352 assert!(!should_compact(&messages, None, &prepared(&config)));
3353 }
3354
3355 #[test]
3356 fn auto_compaction_uses_token_threshold_without_fixed_floor() {
3357 let config = CompactionConfig {
3358 enabled: true,
3359 token_threshold: 20_000,
3360 ..Default::default()
3361 };
3362
3363 // Long sessions are dominated by assistant/tool output; the retained
3364 // user tail stays small, so the pass is reclaimable.
3365 let messages: Vec<Message> = (0..20)
3366 .map(|index| {
3367 if index % 2 == 0 {
3368 msg("user", &"x".repeat(100))
3369 } else {
3370 msg("assistant", &"x".repeat(10_000))
3371 }
3372 })
3373 .collect();
3374 assert!(should_compact(&messages, None, &prepared(&config)));
3375 }
3376
3377 #[test]
3378 fn test_compaction_result_retries_used() {
3379 // This test verifies the CompactionResult structure
3380 let result = CompactionResult {
3381 messages: vec![],
3382 summary_prompt: None,
3383 retries_used: 2,
3384 coverage: CompactionCoverage::default(),
3385 };
3386
3387 assert_eq!(result.retries_used, 2);
3388 assert!(result.messages.is_empty());
3389 }
3390 }
3391
3391 lines RUST