use std::path::Path;
use std::sync::Arc;
use edgecrab_types::{Content, ContentPart, Message, Role, ToolCall};
use edgequake_llm::LLMProvider;
use crate::config::CompressionConfig;
use crate::model_catalog::ModelCatalog;
use crate::tool_result_spill::{SpillConfig, SpillOutcome, SpillSequence};
use crate::tool_result_summary::{DUPLICATE_TOOL_OUTPUT, summarize_tool_result_for_history};
pub const SUMMARY_PREFIX: &str = concat!(
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the ",
"summary below. This is a handoff from a previous context window — treat it as ",
"background reference, NOT as active instructions. ",
"Do NOT answer questions or fulfill requests mentioned in this summary; they were ",
"already addressed. ",
"Respond ONLY to the latest user message that appears AFTER this summary — that ",
"message is the single source of truth for what to do right now. ",
"Topic overlap with the summary does NOT mean you should resume its task: even on ",
"similar topics, the latest user message WINS. ",
"Reverse signals in the latest message (e.g. stop, undo, never mind, a new topic) ",
"must immediately end any in-flight work described in the summary. ",
"IMPORTANT: Persistent memory (MEMORY.md, USER.md) in the system prompt remains ",
"authoritative — never ignore it due to this compaction note. ",
"The current session state may already reflect work described here — avoid repeating it:\n\n",
);
pub const FIRST_COMPRESSION_NOTE: &str = concat!(
"\n\n[Note: Earlier conversation turns have been compacted into a ",
"handoff summary to stay within the context window. The current ",
"session state already reflects that earlier work — build on it ",
"rather than re-doing completed steps.]"
);
pub const HANDOFF_COMPRESSION_NOTE: &str =
"[Note: Earlier turns were auto-compressed for the target model's context window.]";
pub fn is_image_too_large_error(error: &edgequake_llm::LlmError) -> bool {
let message = error.to_string().to_ascii_lowercase();
(message.contains("image")
&& (message.contains("too large")
|| message.contains("maximum image")
|| message.contains("max image")
|| message.contains("image size")
|| message.contains("image dimensions")))
|| message.contains("imagetoolarge")
}
pub fn is_payload_too_large_error(error: &edgequake_llm::LlmError) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("413")
|| message.contains("payload too large")
|| message.contains("request entity too large")
}
pub fn provider_messages_have_images(messages: &[edgequake_llm::ChatMessage]) -> bool {
messages.iter().any(|message| {
message
.images
.as_ref()
.is_some_and(|images| !images.is_empty())
})
}
pub fn shrink_provider_images_for_retry(messages: &mut [edgequake_llm::ChatMessage]) -> bool {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use image::ImageReader;
use std::io::Cursor;
const MAX_DIMENSION: u32 = 768;
const JPEG_QUALITY: u8 = 70;
let mut changed = false;
for message in messages {
let Some(images) = message.images.take() else {
continue;
};
let mut recovered = Vec::with_capacity(images.len());
for mut image_data in images {
let decoded = STANDARD.decode(&image_data.data);
let resized = decoded.ok().and_then(|bytes| {
ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?
.decode()
.ok()
});
let Some(decoded) = resized else {
changed = true;
continue;
};
let resized = decoded.resize(
MAX_DIMENSION,
MAX_DIMENSION,
image::imageops::FilterType::Triangle,
);
let mut jpeg = Vec::new();
let encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
if resized.write_with_encoder(encoder).is_ok() && !jpeg.is_empty() {
image_data.data = STANDARD.encode(jpeg);
image_data.mime_type = "image/jpeg".into();
image_data.detail = Some("low".into());
recovered.push(image_data);
changed = true;
} else {
changed = true;
}
}
if recovered.is_empty() {
if !message
.content
.contains("image omitted after provider size rejection")
{
message
.content
.push_str("\n[image omitted after provider size rejection]");
}
} else {
message.images = Some(recovered);
}
}
changed
}
pub fn todo_snapshot_user_message(store: &edgecrab_tools::TodoStore) -> Option<Message> {
store
.format_for_injection()
.map(|snapshot| Message::user(&snapshot))
}
pub fn apply_first_compression_system_note(
first_compression_done: &mut bool,
cached_system_prompt: &mut Option<String>,
) {
if *first_compression_done {
return;
}
*first_compression_done = true;
append_to_combined_system_prompt(cached_system_prompt, FIRST_COMPRESSION_NOTE);
}
#[derive(Debug, Clone, Default)]
pub struct CompressionRuntimeState {
pub compression_count: u32,
pub awaiting_real_usage_after_compression: bool,
pub last_real_prompt_tokens: u64,
pub last_rough_tokens_when_real_prompt_fit: usize,
pub last_compression_rough_tokens: usize,
pub ineffective_compression_count: u32,
pub fallback_compression_streak: u32,
pub verify_compaction_pending: bool,
}
pub fn append_to_combined_system_prompt(cached_system_prompt: &mut Option<String>, note: &str) {
if note.is_empty() {
return;
}
if let Some(sys) = cached_system_prompt.as_mut() {
if note.starts_with('\n') {
sys.push_str(note);
} else {
if !sys.is_empty() {
sys.push_str("\n\n");
}
sys.push_str(note);
}
}
}
pub const GATEWAY_HYGIENE_THRESHOLD: f32 = 0.85;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionHygieneOutcome {
Skipped,
Compressed {
before_msgs: usize,
after_msgs: usize,
approx_tokens_before: usize,
},
}
pub fn should_run_session_hygiene(
message_count: usize,
approx_tokens: usize,
context_window: usize,
compression_enabled: bool,
hard_message_limit: usize,
) -> bool {
if !compression_enabled || message_count < 4 {
return false;
}
let compress_at = ((context_window as f32) * GATEWAY_HYGIENE_THRESHOLD) as usize;
approx_tokens >= compress_at || message_count >= hard_message_limit.max(4)
}
pub fn apply_post_compress_message_hooks(
messages: &mut Vec<Message>,
todo_store: &edgecrab_tools::TodoStore,
conversation_session_id: &str,
) {
if let Some(snapshot) = todo_snapshot_user_message(todo_store) {
messages.push(snapshot);
}
edgecrab_tools::read_tracker::reset_read_dedup(conversation_session_id);
}
pub const PRUNE_MIN_TOOL_CHARS: usize = 200;
#[derive(Debug, Clone, Copy)]
pub struct PruneToolOutputsOptions {
pub protect_tail_count: usize,
pub protect_tail_tokens: Option<usize>,
}
impl Default for PruneToolOutputsOptions {
fn default() -> Self {
Self {
protect_tail_count: DEFAULT_PROTECT_LAST_N,
protect_tail_tokens: None,
}
}
}
pub const DEFAULT_PROTECT_LAST_N: usize = 20;
pub const PRUNED_TOOL_PLACEHOLDER: &str = "[tool output pruned — reclaimed context window space]";
pub struct PruneSpillContext<'a> {
pub session_id: &'a str,
pub cwd: &'a Path,
pub config: &'a SpillConfig,
pub seq: &'a SpillSequence,
}
impl<'a> PruneSpillContext<'a> {
pub fn new(
session_id: &'a str,
cwd: &'a Path,
config: &'a SpillConfig,
seq: &'a SpillSequence,
) -> Self {
Self {
session_id,
cwd,
config,
seq,
}
}
}
const PROTECT_FIRST_N: usize = 3;
const SMALL_CTX_WINDOW_LIMIT: usize = 512_000;
const SMALL_CTX_THRESHOLD: f32 = 0.75;
pub fn effective_threshold(context_window: usize, configured: f32) -> f32 {
let configured = configured.clamp(0.01, 1.0);
if context_window > 0 && context_window < SMALL_CTX_WINDOW_LIMIT {
configured.max(SMALL_CTX_THRESHOLD)
} else {
configured
}
}
pub fn effective_protect_first_n(compression_count: u32, has_prior_summary: bool) -> usize {
if compression_count >= 1 || has_prior_summary {
0
} else {
PROTECT_FIRST_N
}
}
pub fn should_defer_preflight_to_real_usage(
state: &CompressionRuntimeState,
rough_tokens: usize,
threshold_tokens: usize,
) -> bool {
if rough_tokens < threshold_tokens {
return false;
}
if state.awaiting_real_usage_after_compression {
return true;
}
if state.last_real_prompt_tokens == 0 {
return false;
}
if state.last_real_prompt_tokens as usize >= threshold_tokens {
return false;
}
let baseline = if state.last_rough_tokens_when_real_prompt_fit > 0 {
state.last_rough_tokens_when_real_prompt_fit
} else {
state.last_compression_rough_tokens
};
if baseline == 0 {
return false;
}
let growth = rough_tokens.saturating_sub(baseline);
let tolerated = 4096.max(threshold_tokens / 20);
growth <= tolerated
}
pub fn automatic_compression_blocked(state: &CompressionRuntimeState) -> bool {
state.ineffective_compression_count >= 2 || state.fallback_compression_streak >= 2
}
pub fn record_completed_compaction(
state: &mut CompressionRuntimeState,
compressed_rough_tokens: usize,
used_structural_fallback: bool,
) {
state.compression_count = state.compression_count.saturating_add(1);
state.last_compression_rough_tokens = compressed_rough_tokens;
state.awaiting_real_usage_after_compression = true;
state.verify_compaction_pending = true;
if used_structural_fallback {
state.fallback_compression_streak = state.fallback_compression_streak.saturating_add(1);
} else {
state.fallback_compression_streak = 0;
}
}
pub fn update_compression_from_response(
state: &mut CompressionRuntimeState,
real_prompt_tokens: u64,
threshold_tokens: usize,
rough_tokens: usize,
) {
if real_prompt_tokens == 0 {
return;
}
state.last_real_prompt_tokens = real_prompt_tokens;
state.awaiting_real_usage_after_compression = false;
if (real_prompt_tokens as usize) < threshold_tokens {
state.last_rough_tokens_when_real_prompt_fit = rough_tokens.max(1);
}
if state.verify_compaction_pending {
state.verify_compaction_pending = false;
if real_prompt_tokens as usize >= threshold_tokens {
state.ineffective_compression_count =
state.ineffective_compression_count.saturating_add(1);
tracing::warn!(
real_prompt_tokens,
threshold_tokens,
ineffective = state.ineffective_compression_count,
"compaction did not clear compression threshold"
);
} else {
state.ineffective_compression_count = 0;
}
}
}
const MIN_SUMMARY_TOKENS: usize = 2_000;
const SUMMARY_RATIO: f32 = 0.20;
const SUMMARY_TOKENS_CEILING: usize = 12_000;
const CHARS_PER_TOKEN: usize = 4;
const STUB_TOOL_RESULT: &str = "[Result from earlier conversation — see context summary above]";
const SUMMARY_TEMPLATE: &str = "\
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Progress
### Done
[Completed work — include specific file paths, commands run, results obtained]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why they were made]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next to continue the work]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]";
#[derive(Debug, Clone)]
pub struct CompressionParams {
pub context_window: usize,
pub threshold: f32,
pub target_ratio: f32,
pub protect_last_n: usize,
}
const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
impl Default for CompressionParams {
fn default() -> Self {
Self {
context_window: DEFAULT_CONTEXT_WINDOW,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 20,
}
}
}
impl CompressionParams {
pub fn from_model_config(model: &str, cfg: &CompressionConfig) -> Self {
let context_window = ModelCatalog::context_window_for_spec(model)
.map(|tokens| tokens as usize)
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
Self {
context_window,
threshold: effective_threshold(context_window, cfg.threshold),
target_ratio: cfg.target_ratio.clamp(0.01, 1.0),
protect_last_n: cfg.protect_last_n.max(1),
}
}
pub fn reapply_threshold_floor(&mut self, configured_threshold: f32) {
self.threshold = effective_threshold(self.context_window, configured_threshold);
}
pub fn threshold_tokens(&self) -> usize {
(self.context_window as f32 * self.threshold) as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionStatus {
Ok,
PressureWarning,
NeedsCompression,
}
pub fn estimate_tokens(messages: &[Message]) -> usize {
messages.iter().map(estimate_message_tokens).sum()
}
pub fn estimate_message_tokens(m: &Message) -> usize {
use edgecrab_types::{Content, ContentPart, MULTIMODAL_IMAGE_TOKEN_ESTIMATE, Role};
let text_len = m.text_content().len();
let mut tokens = (text_len / 4) + 4;
if let Some(Content::Parts(parts)) = &m.content {
tokens += parts
.iter()
.filter(|p| matches!(p, ContentPart::ImageUrl { .. }))
.count()
* MULTIMODAL_IMAGE_TOKEN_ESTIMATE;
}
if m.role == Role::Tool
&& m.name.as_deref() == Some("computer_use")
&& let Some(Content::Text(text)) = &m.content
&& (edgecrab_types::multimodal_has_image(text)
|| edgecrab_types::multimodal_disk_image_from_content(text).is_some())
{
tokens += MULTIMODAL_IMAGE_TOKEN_ESTIMATE;
}
tokens
}
pub fn maybe_prune_computer_use_screenshots(messages: &mut Vec<Message>, keep_last_n: u32) {
if keep_last_n > 0 {
*messages = prune_computer_use_screenshots(messages, keep_last_n);
}
}
pub fn needs_compression(messages: &[Message], params: &CompressionParams) -> bool {
matches!(
check_compression_status(messages, params),
CompressionStatus::NeedsCompression
)
}
pub fn check_compression_status(
messages: &[Message],
params: &CompressionParams,
) -> CompressionStatus {
let estimated = estimate_tokens(messages);
check_compression_status_for_estimate(estimated, params)
}
pub fn check_compression_status_for_estimate(
estimated: usize,
params: &CompressionParams,
) -> CompressionStatus {
let threshold_tokens = (params.context_window as f32 * params.threshold) as usize;
let warning_tokens = (threshold_tokens as f32 * 0.85) as usize;
if estimated >= threshold_tokens {
CompressionStatus::NeedsCompression
} else if estimated >= warning_tokens {
CompressionStatus::PressureWarning
} else {
CompressionStatus::Ok
}
}
pub fn compress_messages(messages: &[Message], params: &CompressionParams) -> Vec<Message> {
if messages.len() <= params.protect_last_n {
return messages.to_vec();
}
let split_point = messages.len().saturating_sub(params.protect_last_n);
let old_messages = &messages[..split_point];
let recent_messages = &messages[split_point..];
let summary = build_summary(old_messages);
let mut compressed = Vec::with_capacity(1 + recent_messages.len());
compressed.push(Message::system_summary(summary));
compressed.extend_from_slice(recent_messages);
compressed
}
fn build_summary(messages: &[Message]) -> String {
let mut parts = Vec::new();
parts.push("[Context Summary — earlier messages compressed]".to_string());
let mut user_count = 0u32;
let mut assistant_count = 0u32;
let mut tool_count = 0u32;
for m in messages {
match m.role {
edgecrab_types::Role::User => user_count += 1,
edgecrab_types::Role::Assistant => assistant_count += 1,
edgecrab_types::Role::Tool => tool_count += 1,
edgecrab_types::Role::System => {}
}
}
parts.push(format!(
"Compressed {user_count} user messages, {assistant_count} assistant \
responses, and {tool_count} tool results."
));
if let Some(first_user) = messages
.iter()
.find(|m| m.role == edgecrab_types::Role::User)
{
let preview = first_user.text_content();
let truncated = if preview.len() > 200 {
format!("{}...", crate::safe_truncate(&preview, 200))
} else {
preview
};
parts.push(format!("First user message: {truncated}"));
}
parts.join("\n")
}
pub async fn compress_with_llm(
messages: &[Message],
params: &CompressionParams,
provider: &Arc<dyn LLMProvider>,
spill_ctx: Option<&PruneSpillContext<'_>>,
) -> (Vec<Message>, bool) {
compress_with_llm_counted(messages, params, provider, spill_ctx, 0, None).await
}
pub async fn compress_with_llm_counted(
messages: &[Message],
params: &CompressionParams,
provider: &Arc<dyn LLMProvider>,
spill_ctx: Option<&PruneSpillContext<'_>>,
compression_count: u32,
focus: Option<&str>,
) -> (Vec<Message>, bool) {
let n = messages.len();
let has_prior = extract_prior_summary(messages).is_some();
let protect_first = effective_protect_first_n(compression_count, has_prior);
if n <= protect_first.max(1) + params.protect_last_n {
return (messages.to_vec(), true);
}
let threshold_tokens = params.threshold_tokens();
let tail_token_budget = (threshold_tokens as f32 * params.target_ratio) as usize;
let prune_options = PruneToolOutputsOptions {
protect_tail_count: params.protect_last_n,
protect_tail_tokens: Some(tail_token_budget),
};
let pruned = prune_tool_outputs_with_options(messages, spill_ctx, &prune_options);
let head_idx = if protect_first == 0 {
usize::from(
pruned
.first()
.is_some_and(|m| m.role == edgecrab_types::Role::System),
)
} else {
protect_first
};
let head_end = align_boundary_forward(&pruned, head_idx);
let tail_start =
find_tail_cut_by_tokens(&pruned, head_end, tail_token_budget, params.protect_last_n);
if head_end >= tail_start {
return (messages.to_vec(), true);
}
let turns_to_summarize = &pruned[head_end..tail_start];
let prior_summary = extract_prior_summary(messages);
let (summary_text, llm_succeeded) = match llm_summarize(
turns_to_summarize,
params.context_window,
provider,
prior_summary.as_deref(),
focus,
)
.await
{
Ok(text) => (text, true),
Err(e) => {
tracing::warn!(error = %e, "LLM compression failed, using structural fallback");
(build_summary(turns_to_summarize), false)
}
};
let prefixed = format!("{SUMMARY_PREFIX}{summary_text}");
let last_head_role = pruned
.get(head_end.saturating_sub(1))
.map(|m| m.role.clone());
let summary_msg = if last_head_role == Some(edgecrab_types::Role::System) {
Message::user(&prefixed)
} else {
Message::system_summary(prefixed)
};
let mut result = Vec::with_capacity(head_end + 1 + (n - tail_start));
result.extend_from_slice(&pruned[..head_end]);
result.push(summary_msg);
result.extend_from_slice(&pruned[tail_start..]);
(sanitize_orphan_pairs(result), llm_succeeded)
}
pub fn compress_structural_only(
messages: &[Message],
params: &CompressionParams,
spill_ctx: Option<&PruneSpillContext<'_>>,
) -> Vec<Message> {
compress_structural_only_counted(messages, params, spill_ctx, 0)
}
pub fn compress_structural_only_counted(
messages: &[Message],
params: &CompressionParams,
spill_ctx: Option<&PruneSpillContext<'_>>,
compression_count: u32,
) -> Vec<Message> {
let n = messages.len();
let has_prior = extract_prior_summary(messages).is_some();
let protect_first = effective_protect_first_n(compression_count, has_prior);
if n <= protect_first.max(1) + params.protect_last_n {
return messages.to_vec();
}
let threshold_tokens = params.threshold_tokens();
let tail_token_budget = (threshold_tokens as f32 * params.target_ratio) as usize;
let prune_options = PruneToolOutputsOptions {
protect_tail_count: params.protect_last_n,
protect_tail_tokens: Some(tail_token_budget),
};
let pruned = prune_tool_outputs_with_options(messages, spill_ctx, &prune_options);
let head_idx = if protect_first == 0 {
usize::from(
pruned
.first()
.is_some_and(|m| m.role == edgecrab_types::Role::System),
)
} else {
protect_first
};
let head_end = align_boundary_forward(&pruned, head_idx);
let tail_start =
find_tail_cut_by_tokens(&pruned, head_end, tail_token_budget, params.protect_last_n);
if head_end >= tail_start {
return messages.to_vec();
}
let turns_to_summarize = &pruned[head_end..tail_start];
let summary_text = build_summary(turns_to_summarize);
let prefixed = format!("{SUMMARY_PREFIX}{summary_text}");
let mut result = Vec::with_capacity(head_end + 1 + (n - tail_start));
result.extend_from_slice(&pruned[..head_end]);
result.push(Message::system_summary(prefixed));
result.extend_from_slice(&pruned[tail_start..]);
sanitize_orphan_pairs(result)
}
pub fn count_long_tool_outputs(messages: &[Message]) -> usize {
messages
.iter()
.filter(|m| is_prunable_tool_message(m))
.count()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StructuralPruneOutcome {
pub tools_pruned: usize,
pub message_tokens_before: usize,
pub message_tokens_after: usize,
pub long_tool_outputs_remaining: usize,
}
pub fn structural_prefill_prune(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
token_budget: usize,
) -> (Vec<Message>, usize) {
prune_tool_results_to_budget(messages, spill_ctx, token_budget)
}
pub fn apply_structural_tool_output_prune(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
token_budget: usize,
) -> Option<(Vec<Message>, StructuralPruneOutcome)> {
if count_long_tool_outputs(messages) == 0 {
return None;
}
let message_tokens_before = estimate_tokens(messages);
if token_budget > 0 && message_tokens_before <= token_budget {
return None;
}
let (pruned, tools_pruned) = structural_prefill_prune(messages, spill_ctx, token_budget);
if tools_pruned == 0 {
return None;
}
let message_tokens_after = estimate_tokens(&pruned);
let long_tool_outputs_remaining = count_long_tool_outputs(&pruned);
Some((
pruned,
StructuralPruneOutcome {
tools_pruned,
message_tokens_before,
message_tokens_after,
long_tool_outputs_remaining,
},
))
}
pub fn prune_tool_outputs(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
) -> Vec<Message> {
prune_tool_outputs_with_options(messages, spill_ctx, &PruneToolOutputsOptions::default())
}
pub fn prune_tool_outputs_with_options(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
options: &PruneToolOutputsOptions,
) -> Vec<Message> {
let (pruned, _) = prune_old_tool_results(messages, spill_ctx, options);
pruned
}
pub fn prune_old_tool_results(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
options: &PruneToolOutputsOptions,
) -> (Vec<Message>, usize) {
if messages.is_empty() {
return (messages.to_vec(), 0);
}
let mut result: Vec<Message> = messages.to_vec();
let mut pruned = 0;
let call_index = build_tool_call_index(&result);
let prune_boundary = compute_prune_boundary(
&result,
options.protect_tail_count,
options.protect_tail_tokens,
);
let mut content_hashes: std::collections::HashMap<String, ()> =
std::collections::HashMap::new();
for i in (0..result.len()).rev() {
let Some(content) = result[i].tool_text_content() else {
continue;
};
if content.chars().count() < PRUNE_MIN_TOOL_CHARS {
continue;
}
let h = hash_tool_content(&content);
if content_hashes.insert(h, ()).is_some()
&& let Some(m) = result.get_mut(i)
{
replace_tool_content(m, DUPLICATE_TOOL_OUTPUT);
pruned += 1;
}
}
for msg in result.iter_mut().take(prune_boundary) {
if !is_prunable_tool_message(msg) {
continue;
}
if replace_tool_with_summary(msg, spill_ctx, &call_index) {
pruned += 1;
}
}
for msg in result.iter_mut().take(prune_boundary) {
if msg.role != Role::Assistant || !msg.has_tool_calls() {
continue;
}
if truncate_assistant_tool_calls(msg) {
pruned += 1;
}
}
(result, pruned)
}
pub fn prune_tool_results_to_budget(
messages: &[Message],
spill_ctx: Option<&PruneSpillContext<'_>>,
token_budget: usize,
) -> (Vec<Message>, usize) {
let mut result = messages.to_vec();
let mut pruned = 0;
let call_index = build_tool_call_index(&result);
while estimate_tokens(&result) > token_budget {
let Some(idx) = result.iter().position(is_prunable_tool_message) else {
break;
};
if replace_tool_with_summary(&mut result[idx], spill_ctx, &call_index) {
pruned += 1;
} else {
break;
}
}
(result, pruned)
}
fn compute_prune_boundary(
messages: &[Message],
protect_tail_count: usize,
protect_tail_tokens: Option<usize>,
) -> usize {
let n = messages.len();
if let Some(t) = protect_tail_tokens.filter(|&t| t > 0) {
find_tail_cut_by_tokens(messages, 0, t, protect_tail_count)
} else {
n.saturating_sub(protect_tail_count)
}
}
fn build_tool_call_index(
messages: &[Message],
) -> std::collections::HashMap<String, (String, String)> {
let mut map = std::collections::HashMap::new();
for msg in messages {
if msg.role != Role::Assistant {
continue;
}
for tc in msg.tool_calls() {
let id = tc.id.clone();
let name = tc.function.name.clone();
let args = tc.function.arguments.clone();
map.insert(id, (name, args));
}
}
map
}
fn is_prunable_tool_message(msg: &Message) -> bool {
let Some(content) = msg.tool_text_content() else {
return false;
};
if content.chars().count() <= PRUNE_MIN_TOOL_CHARS {
return false;
}
!crate::tool_result_summary::is_already_pruned_marker(&content)
}
fn hash_tool_content(content: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn replace_tool_with_summary(
msg: &mut Message,
spill_ctx: Option<&PruneSpillContext<'_>>,
call_index: &std::collections::HashMap<String, (String, String)>,
) -> bool {
let Some(content) = msg.tool_text_content() else {
return false;
};
let tool_call_id = msg.tool_call_id.as_deref().unwrap_or("unknown");
let tool_name = msg
.name
.as_deref()
.or_else(|| call_index.get(tool_call_id).map(|(n, _)| n.as_str()))
.unwrap_or("tool");
let (name, args) = call_index
.get(tool_call_id)
.map(|(n, a)| (n.as_str(), a.as_str()))
.unwrap_or((tool_name, ""));
if let Some(ctx) = spill_ctx
&& ctx.config.enabled
&& let SpillOutcome::Spilled { stub, .. } = crate::tool_result_spill::maybe_spill(
name,
tool_call_id,
content.to_string(),
ctx.session_id,
ctx.cwd,
ctx.config,
ctx.seq,
None,
)
{
replace_tool_content(msg, &stub);
return true;
}
let is_error = edgecrab_types::parse_tool_error_payload(&content).is_some();
let summary = summarize_tool_result_for_history(name, args, &content, is_error);
replace_tool_content(msg, &summary);
true
}
fn replace_tool_content(msg: &mut Message, body: &str) {
let tool_call_id = msg.tool_call_id.clone().unwrap_or_else(|| "unknown".into());
let name = msg.name.clone().unwrap_or_else(|| "tool".into());
*msg = Message::tool_result(&tool_call_id, &name, body);
}
fn truncate_assistant_tool_calls(msg: &mut Message) -> bool {
let Some(calls) = msg.tool_calls.as_mut() else {
return false;
};
let mut changed = false;
for tc in calls.iter_mut() {
if tc.function.arguments.chars().count() > 500 {
tc.function.arguments = truncate_tool_call_args_json(&tc.function.arguments);
changed = true;
}
}
changed
}
fn truncate_tool_call_args_json(args: &str) -> String {
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(args) else {
return args.to_string();
};
shrink_json_strings(&mut parsed, 200);
serde_json::to_string(&parsed).unwrap_or_else(|_| args.to_string())
}
fn shrink_json_strings(value: &mut serde_json::Value, head_chars: usize) {
match value {
serde_json::Value::String(s) if s.chars().count() > head_chars => {
let truncated: String = s.chars().take(head_chars).collect();
*s = format!("{truncated}... [truncated]");
}
serde_json::Value::Object(map) => {
for v in map.values_mut() {
shrink_json_strings(v, head_chars);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
shrink_json_strings(v, head_chars);
}
}
_ => {}
}
}
trait ToolTextContent {
fn tool_text_content(&self) -> Option<String>;
fn tool_calls(&self) -> &[ToolCall];
}
impl ToolTextContent for Message {
fn tool_text_content(&self) -> Option<String> {
if self.role != Role::Tool {
return None;
}
let text = self.text_content();
if text.is_empty() { None } else { Some(text) }
}
fn tool_calls(&self) -> &[ToolCall] {
self.tool_calls.as_deref().unwrap_or(&[])
}
}
pub fn prune_computer_use_screenshots(messages: &[Message], keep_last_n: u32) -> Vec<Message> {
if keep_last_n == 0 {
return messages
.iter()
.map(strip_computer_use_screenshot_images)
.collect();
}
let mut screenshot_indices = Vec::new();
for (i, m) in messages.iter().enumerate() {
if computer_use_message_has_screenshot(m) {
screenshot_indices.push(i);
}
}
let keep = keep_last_n as usize;
if screenshot_indices.len() <= keep {
return messages.to_vec();
}
let strip_count = screenshot_indices.len().saturating_sub(keep);
let strip_set: std::collections::HashSet<usize> = screenshot_indices
.iter()
.take(strip_count)
.copied()
.collect();
messages
.iter()
.enumerate()
.map(|(i, m)| {
if strip_set.contains(&i) {
strip_computer_use_screenshot_images(m)
} else {
m.clone()
}
})
.collect()
}
fn computer_use_message_has_screenshot(msg: &Message) -> bool {
if msg.role != Role::Tool || msg.name.as_deref() != Some("computer_use") {
return false;
}
match &msg.content {
Some(Content::Parts(parts)) => parts
.iter()
.any(|p| matches!(p, ContentPart::ImageUrl { .. })),
Some(Content::Text(text)) => edgecrab_types::capture_has_image_reference(text),
_ => false,
}
}
fn strip_computer_use_screenshot_images(msg: &Message) -> Message {
if !computer_use_message_has_screenshot(msg) {
return msg.clone();
}
let tool_call_id = msg.tool_call_id.as_deref().unwrap_or("unknown");
let name = msg.name.as_deref().unwrap_or("computer_use");
match &msg.content {
Some(Content::Parts(parts)) => {
let mut text_parts: Vec<String> = parts
.iter()
.filter_map(|p| match p {
ContentPart::Text { text } => Some(text.clone()),
_ => None,
})
.collect();
if !text_parts.iter().any(|t| t.contains("[screenshot pruned]")) {
text_parts.push("[screenshot pruned — retained text summary only]".into());
}
Message {
role: Role::Tool,
content: Some(Content::Text(text_parts.join("\n"))),
tool_call_id: Some(tool_call_id.to_string()),
name: Some(name.to_string()),
..Default::default()
}
}
Some(Content::Text(text)) => {
if edgecrab_types::is_multimodal_tool_json(text) {
let summary = edgecrab_types::multimodal_text_summary(text).unwrap_or_default();
let body = if summary.is_empty() {
"[screenshot pruned — retained text summary only]".into()
} else {
format!("{summary}\n[screenshot pruned — retained text summary only]")
};
return Message::tool_result(tool_call_id, name, &body);
}
msg.clone()
}
_ => msg.clone(),
}
}
fn extract_prior_summary(messages: &[Message]) -> Option<String> {
messages
.iter()
.find(|m| {
m.role == edgecrab_types::Role::System && m.text_content().starts_with(SUMMARY_PREFIX)
})
.map(|m| {
m.text_content()
.strip_prefix(SUMMARY_PREFIX)
.unwrap_or(&m.text_content())
.to_string()
})
}
fn align_boundary_forward(messages: &[Message], idx: usize) -> usize {
let mut i = idx;
while i < messages.len() && messages[i].role == edgecrab_types::Role::Tool {
i += 1;
}
i
}
fn align_boundary_backward(messages: &[Message], idx: usize) -> usize {
if idx == 0 || idx >= messages.len() {
return idx;
}
let mut check = idx.saturating_sub(1);
while check > 0 && messages[check].role == edgecrab_types::Role::Tool {
check -= 1;
}
if messages[check].role == edgecrab_types::Role::Assistant && messages[check].has_tool_calls() {
check
} else {
idx
}
}
fn find_tail_cut_by_tokens(
messages: &[Message],
head_end: usize,
token_budget: usize,
protect_last_n: usize,
) -> usize {
let n = messages.len();
let mut accumulated: usize = 0;
let mut cut_idx = n;
for i in (head_end..n).rev() {
let msg_tokens = messages[i].text_content().len() / CHARS_PER_TOKEN + 10;
let protected_count = n - i;
if accumulated + msg_tokens > token_budget && protected_count >= protect_last_n {
break;
}
accumulated += msg_tokens;
cut_idx = i;
}
let fallback = n.saturating_sub(protect_last_n);
let cut_idx = cut_idx.min(fallback);
let cut_idx = if cut_idx <= head_end {
fallback
} else {
cut_idx
};
let cut_idx = align_boundary_backward(messages, cut_idx);
cut_idx.max(head_end + 1)
}
pub fn ensure_api_safe_tool_pairs(messages: Vec<Message>) -> Vec<Message> {
sanitize_orphan_pairs(messages)
}
fn sanitize_orphan_pairs(messages: Vec<Message>) -> Vec<Message> {
use std::collections::HashSet;
let call_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == edgecrab_types::Role::Assistant)
.flat_map(|m| m.tool_calls.iter().flatten().map(|tc| tc.id.clone()))
.collect();
let result_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == edgecrab_types::Role::Tool)
.filter_map(|m| m.tool_call_id.clone())
.collect();
let orphaned_results: HashSet<String> = result_ids.difference(&call_ids).cloned().collect();
let messages: Vec<Message> = if orphaned_results.is_empty() {
messages
} else {
tracing::debug!(
count = orphaned_results.len(),
"sanitizer: dropped orphaned tool results"
);
messages
.into_iter()
.filter(|m| {
m.role != edgecrab_types::Role::Tool
|| m.tool_call_id
.as_ref()
.map(|id| !orphaned_results.contains(id))
.unwrap_or(true)
})
.collect()
};
let result_ids_after: HashSet<String> = messages
.iter()
.filter(|m| m.role == edgecrab_types::Role::Tool)
.filter_map(|m| m.tool_call_id.clone())
.collect();
let missing_results: HashSet<String> =
call_ids.difference(&result_ids_after).cloned().collect();
if missing_results.is_empty() {
return messages;
}
tracing::debug!(
count = missing_results.len(),
"sanitizer: injected stub tool results"
);
let mut patched = Vec::with_capacity(messages.len() + missing_results.len());
for m in messages {
let is_assistant = m.role == edgecrab_types::Role::Assistant;
let tool_calls = m.tool_calls.clone();
patched.push(m);
if is_assistant && let Some(tcs) = tool_calls {
for tc in tcs {
if missing_results.contains(&tc.id) {
patched.push(Message::tool_result(
&tc.id,
&tc.function.name,
STUB_TOOL_RESULT,
));
}
}
}
}
patched
}
fn compute_summary_budget(content_tokens: usize, context_window: usize) -> usize {
let budget = (content_tokens as f32 * SUMMARY_RATIO) as usize;
let ceiling = ((context_window as f32 * 0.05) as usize).min(SUMMARY_TOKENS_CEILING);
budget.max(MIN_SUMMARY_TOKENS).min(ceiling)
}
fn serialize_for_summary(messages: &[Message]) -> String {
const MAX_MSG_CHARS: usize = 3_000;
const HEAD_CHARS: usize = 2_000;
const TAIL_CHARS: usize = 800;
messages
.iter()
.filter(|m| m.role != edgecrab_types::Role::System)
.map(|m| {
let text = m.text_content();
let content = if text.len() > MAX_MSG_CHARS {
let head = crate::safe_truncate(&text, HEAD_CHARS.min(text.len()));
let tail_start =
crate::safe_char_start(&text, text.len().saturating_sub(TAIL_CHARS));
format!("{}…[truncated]…{}", head, &text[tail_start..])
} else {
text
};
match m.role {
edgecrab_types::Role::Tool => {
let id = m.tool_call_id.as_deref().unwrap_or("");
format!("[TOOL RESULT {id}]: {content}")
}
edgecrab_types::Role::Assistant => {
let mut line = format!("[ASSISTANT]: {content}");
if let Some(tcs) = &m.tool_calls {
let calls: Vec<String> = tcs
.iter()
.map(|tc| {
let args = if tc.function.arguments.len() > 500 {
format!(
"{}…",
crate::safe_truncate(&tc.function.arguments, 400)
)
} else {
tc.function.arguments.clone()
};
format!(" {}({})", tc.function.name, args)
})
.collect();
line.push_str("\n[Tool calls:\n");
line.push_str(&calls.join("\n"));
line.push(']');
}
line
}
edgecrab_types::Role::User => format!("[USER]: {content}"),
edgecrab_types::Role::System => unreachable!("filtered above"),
}
})
.collect::<Vec<_>>()
.join("\n\n")
}
async fn llm_summarize(
messages: &[Message],
context_window: usize,
provider: &Arc<dyn LLMProvider>,
prior_summary: Option<&str>,
focus: Option<&str>,
) -> Result<String, edgequake_llm::LlmError> {
let content = serialize_for_summary(messages);
let content_tokens = estimate_tokens(messages);
let mut summary_budget = compute_summary_budget(content_tokens, context_window);
let focus = focus.map(str::trim).filter(|s| !s.is_empty());
let focus_block = if let Some(topic) = focus {
summary_budget = ((summary_budget as f32) * 0.65).round() as usize;
format!(
"\n\nFOCUS TOPIC (allocate most of the summary to this): {topic}\n\
Still preserve critical blockers and file paths outside the focus.\n"
)
} else {
String::new()
};
let prompt = match prior_summary {
Some(prior) => format!(
"You are updating a context compaction summary. A previous compaction produced \
the summary below. New conversation turns have occurred since then and need to \
be incorporated.\n\n\
PREVIOUS SUMMARY:\n{prior}\n\n\
NEW TURNS TO INCORPORATE:\n{content}\n\n\
Update the summary using this exact structure. PRESERVE all existing information \
that is still relevant. ADD new progress. Move items from \"In Progress\" to \
\"Done\" when completed. Remove information only if it is clearly obsolete.\n\n\
{SUMMARY_TEMPLATE}\n\n\
Target ~{summary_budget} tokens. Be specific — include file paths, command \
outputs, error messages, and concrete values rather than vague descriptions.\
{focus_block}\n\
Write only the summary body. Do not include any preamble or prefix."
),
None => format!(
"Create a structured handoff summary for a later assistant that will continue \
this conversation after earlier turns are compacted.\n\n\
TURNS TO SUMMARIZE:\n{content}\n\n\
Use this exact structure:\n\n\
{SUMMARY_TEMPLATE}\n\n\
Target ~{summary_budget} tokens. Be specific — include file paths, command \
outputs, error messages, and concrete values rather than vague descriptions. \
The goal is to prevent the next assistant from repeating work or losing \
important details.\
{focus_block}\n\
Write only the summary body. Do not include any preamble or prefix."
),
};
let options = edgequake_llm::CompletionOptions {
max_tokens: Some(summary_budget * 2),
temperature: Some(0.3),
..Default::default()
};
let llm_messages = vec![edgequake_llm::ChatMessage::user(&prompt)];
let response = provider.chat(&llm_messages, Some(&options)).await?;
Ok(response.content.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_too_large_recovery_shrinks_provider_payload() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
use std::io::Cursor;
let image = DynamicImage::ImageRgb8(ImageBuffer::from_fn(1200, 900, |x, y| {
Rgb([(x % 251) as u8, (y % 241) as u8, ((x + y) % 239) as u8])
}));
let mut png = Vec::new();
image
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.expect("png");
let original_b64 = STANDARD.encode(&png);
let mut messages = vec![edgequake_llm::ChatMessage::user_with_images(
"inspect",
vec![edgequake_llm::ImageData::new(&original_b64, "image/png")],
)];
assert!(shrink_provider_images_for_retry(&mut messages));
let recovered = &messages[0].images.as_ref().expect("image retained")[0];
assert_eq!(recovered.mime_type, "image/jpeg");
assert_eq!(recovered.detail.as_deref(), Some("low"));
assert!(recovered.data.len() < original_b64.len());
}
#[test]
fn detects_image_too_large_provider_errors() {
let error = edgequake_llm::LlmError::InvalidRequest("maximum image size exceeded".into());
assert!(is_image_too_large_error(&error));
let unrelated = edgequake_llm::LlmError::InvalidRequest("invalid temperature".into());
assert!(!is_image_too_large_error(&unrelated));
let payload = edgequake_llm::LlmError::ApiError("HTTP 413 Request Entity Too Large".into());
assert!(is_payload_too_large_error(&payload));
}
fn make_messages(n: usize) -> Vec<Message> {
(0..n)
.map(|i| {
if i % 2 == 0 {
Message::user(&format!("question {i}"))
} else {
Message::assistant(&format!("answer {i}"))
}
})
.collect()
}
#[test]
fn estimate_tokens_basic() {
let msgs = vec![Message::user("hello world")]; let tokens = estimate_tokens(&msgs);
assert!(tokens > 0);
assert!(tokens < 20);
}
#[test]
fn needs_compression_under_threshold() {
let msgs = make_messages(5);
let params = CompressionParams {
context_window: 128_000,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 20,
};
assert!(!needs_compression(&msgs, ¶ms));
}
#[test]
fn needs_compression_over_threshold() {
let msgs: Vec<Message> = (0..1000)
.map(|i| Message::user(&format!("{}{}", "a".repeat(500), i)))
.collect();
let params = CompressionParams {
context_window: 1000, threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 5,
};
assert!(needs_compression(&msgs, ¶ms));
}
#[test]
fn check_status_pressure_warning() {
let msgs = vec![Message::user(&"x".repeat(1_700))];
let params = CompressionParams {
context_window: 1_000,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 5,
};
assert_eq!(
check_compression_status(&msgs, ¶ms),
CompressionStatus::PressureWarning
);
}
#[test]
fn check_status_needs_compression() {
let msgs: Vec<Message> = (0..1000)
.map(|i| Message::user(&"a".repeat(500 + i)))
.collect();
let params = CompressionParams {
context_window: 1_000,
threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 5,
};
assert_eq!(
check_compression_status(&msgs, ¶ms),
CompressionStatus::NeedsCompression
);
}
#[test]
fn check_status_ok_below_warning() {
let msgs = make_messages(2);
let params = CompressionParams::default();
assert_eq!(
check_compression_status(&msgs, ¶ms),
CompressionStatus::Ok
);
}
#[test]
fn check_status_for_estimate_reuses_threshold_logic() {
let params = CompressionParams {
context_window: 1_000,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 5,
};
assert_eq!(
check_compression_status_for_estimate(430, ¶ms),
CompressionStatus::PressureWarning
);
assert_eq!(
check_compression_status_for_estimate(500, ¶ms),
CompressionStatus::NeedsCompression
);
}
#[test]
fn compression_params_from_model_config_uses_runtime_values() {
let cfg = CompressionConfig {
enabled: true,
threshold: 0.75,
target_ratio: 0.33,
protect_last_n: 12,
summary_model: None,
hygiene_hard_message_limit: 5000,
};
let params = CompressionParams::from_model_config("anthropic/claude-opus-4.6", &cfg);
assert_eq!(params.threshold, 0.75);
assert_eq!(params.target_ratio, 0.33);
assert_eq!(params.protect_last_n, 12);
assert_eq!(
params.context_window,
ModelCatalog::context_window("anthropic", "claude-opus-4.6").expect("catalog context")
as usize
);
}
#[test]
fn compress_preserves_recent() {
let msgs = make_messages(30);
let params = CompressionParams {
protect_last_n: 10,
..Default::default()
};
let compressed = compress_messages(&msgs, ¶ms);
assert_eq!(compressed.len(), 11);
assert_eq!(compressed[0].role, edgecrab_types::Role::System);
assert!(compressed[0].text_content().contains("Context Summary"));
assert_eq!(
compressed.last().expect("last").text_content(),
msgs.last().expect("last").text_content()
);
}
#[test]
fn compress_small_history_is_noop() {
let msgs = make_messages(5);
let params = CompressionParams {
protect_last_n: 20,
..Default::default()
};
let compressed = compress_messages(&msgs, ¶ms);
assert_eq!(compressed.len(), msgs.len());
}
#[test]
fn summary_contains_counts() {
let msgs = make_messages(10);
let summary = build_summary(&msgs);
assert!(summary.contains("5 user messages"));
assert!(summary.contains("5 assistant responses"));
}
#[test]
fn align_forward_skips_leading_tool_messages() {
let msgs = vec![
Message::user("q"),
Message::tool_result("c1", "t", "r1"),
Message::tool_result("c2", "t", "r2"),
Message::user("follow-up"),
];
assert_eq!(align_boundary_forward(&msgs, 1), 3);
assert_eq!(align_boundary_forward(&msgs, 0), 0);
assert_eq!(align_boundary_forward(&msgs, 4), 4); }
#[test]
fn align_backward_pulls_before_assistant_with_tool_calls() {
let tc = edgecrab_types::ToolCall {
id: "c1".into(),
r#type: "function".into(),
function: edgecrab_types::FunctionCall {
name: "my_tool".into(),
arguments: "{}".into(),
},
thought_signature: None,
};
let msgs = vec![
Message::user("q"),
Message::assistant_with_tool_calls("", vec![tc]),
Message::tool_result("c1", "my_tool", "result"),
Message::user("next"),
];
assert_eq!(align_boundary_backward(&msgs, 3), 1);
assert_eq!(align_boundary_backward(&msgs, 0), 0);
}
#[test]
fn align_backward_noop_without_tool_calls() {
let msgs = vec![
Message::user("q"),
Message::assistant("a"),
Message::user("next"),
];
assert_eq!(align_boundary_backward(&msgs, 2), 2);
}
#[test]
fn find_tail_cut_returns_more_than_head_end() {
let msgs = make_messages(10);
let cut = find_tail_cut_by_tokens(&msgs, 2, 0, 2);
assert!(cut > 2, "cut={cut} must be > head_end=2");
assert!(cut <= msgs.len());
}
#[test]
fn find_tail_cut_respects_protect_last_n() {
let msgs = make_messages(20);
let cut = find_tail_cut_by_tokens(&msgs, 0, usize::MAX, 5);
assert!(cut <= 15, "cut={cut}");
}
#[test]
fn sanitize_removes_orphaned_tool_result() {
let messages = vec![
Message::user("do something"),
Message::tool_result("call_999", "some_tool", "output"),
];
let sanitized = sanitize_orphan_pairs(messages);
assert_eq!(sanitized.len(), 1);
assert_eq!(sanitized[0].role, edgecrab_types::Role::User);
}
#[test]
fn sanitize_injects_stub_for_missing_tool_result() {
let tc = edgecrab_types::ToolCall {
id: "call_1".into(),
r#type: "function".into(),
function: edgecrab_types::FunctionCall {
name: "my_tool".into(),
arguments: "{}".into(),
},
thought_signature: None,
};
let messages = vec![
Message::user("do something"),
Message::assistant_with_tool_calls("", vec![tc]),
];
let sanitized = sanitize_orphan_pairs(messages);
assert_eq!(sanitized.len(), 3);
assert_eq!(sanitized[2].role, edgecrab_types::Role::Tool);
assert_eq!(sanitized[2].tool_call_id.as_deref(), Some("call_1"));
assert!(sanitized[2].text_content().contains("earlier conversation"));
}
#[test]
fn sanitize_noop_on_well_formed_pairs() {
let tc = edgecrab_types::ToolCall {
id: "call_x".into(),
r#type: "function".into(),
function: edgecrab_types::FunctionCall {
name: "search".into(),
arguments: "{}".into(),
},
thought_signature: None,
};
let messages = vec![
Message::user("query"),
Message::assistant_with_tool_calls("", vec![tc]),
Message::tool_result("call_x", "search", "results"),
];
let len = messages.len();
let sanitized = sanitize_orphan_pairs(messages);
assert_eq!(sanitized.len(), len);
}
#[test]
fn sanitize_empty_input_is_noop() {
let sanitized = sanitize_orphan_pairs(vec![]);
assert!(sanitized.is_empty());
}
#[test]
fn budget_clamps_to_minimum() {
assert_eq!(compute_summary_budget(10, 128_000), MIN_SUMMARY_TOKENS);
}
#[test]
fn budget_clamps_to_ceiling_from_context() {
let budget = compute_summary_budget(1_000_000, 128_000);
assert_eq!(budget, 6_400);
}
#[test]
fn budget_hard_cap_limits_huge_windows() {
let budget = compute_summary_budget(1_000_000, 4_000_000);
assert!(budget <= SUMMARY_TOKENS_CEILING, "budget={budget}");
}
#[test]
fn serialize_labels_user_and_assistant() {
let msgs = vec![Message::user("hello"), Message::assistant("world")];
let text = serialize_for_summary(&msgs);
assert!(text.contains("[USER]: hello"), "text={text}");
assert!(text.contains("[ASSISTANT]: world"), "text={text}");
}
#[test]
fn serialize_skips_system_messages() {
let msgs = vec![Message::system("You are an AI"), Message::user("hi")];
let text = serialize_for_summary(&msgs);
assert!(!text.contains("You are an AI"));
assert!(text.contains("[USER]: hi"));
}
#[test]
fn serialize_truncates_long_content() {
let long_content = "z".repeat(5_000);
let msgs = vec![Message::user(&long_content)];
let text = serialize_for_summary(&msgs);
assert!(
text.contains("[truncated]"),
"should truncate long messages"
);
}
#[test]
fn serialize_truncates_long_unicode_content_without_panicking() {
let prefix = "z".repeat(1_999);
let long_content = format!("{prefix}étail{}", "y".repeat(5_000));
let msgs = vec![Message::user(&long_content)];
let text = serialize_for_summary(&msgs);
assert!(text.contains("[truncated]"));
assert!(!text.contains('�'));
}
#[test]
fn summary_includes_first_user_message() {
let msgs = vec![
Message::user("What is the meaning of life?"),
Message::assistant("42"),
];
let summary = build_summary(&msgs);
assert!(summary.contains("What is the meaning of life?"));
}
#[test]
fn summary_truncates_long_first_message() {
let long_msg = "x".repeat(500);
let msgs = vec![Message::user(&long_msg)];
let summary = build_summary(&msgs);
assert!(summary.contains("..."));
assert!(summary.len() < 600);
}
#[test]
fn summary_prefix_constant_starts_correctly() {
assert!(SUMMARY_PREFIX.starts_with("[CONTEXT COMPACTION — REFERENCE ONLY]"));
assert!(SUMMARY_PREFIX.contains("latest user message WINS"));
}
#[test]
fn pruned_tool_placeholder_is_short() {
assert!(PRUNED_TOOL_PLACEHOLDER.len() < 100);
}
#[test]
fn prune_tool_outputs_replaces_long_results_outside_tail() {
let mut messages = vec![Message::user("run commands")];
for i in 0..25 {
messages.push(Message::tool_result(
&format!("id{i}"),
"shell_exec",
&format!("output {i} {}", "x".repeat(500)),
));
}
let pruned = prune_tool_outputs_with_options(
&messages,
None,
&PruneToolOutputsOptions {
protect_tail_count: 5,
protect_tail_tokens: None,
},
);
assert_eq!(pruned.len(), messages.len());
let first_tool = pruned[1].text_content();
assert!(
first_tool.contains("shell_exec") || first_tool.contains("terminal"),
"expected semantic summary, got: {first_tool}"
);
assert_ne!(first_tool, "x".repeat(500));
assert!(pruned[25].text_content().contains(&"x".repeat(100)));
}
#[test]
fn structural_prefill_prune_reclaims_tool_output_tokens() {
let messages: Vec<Message> = (0..8)
.map(|i| {
Message::tool_result(
&format!("id{i}"),
"web_extract",
&format!("page body {}\n", "x".repeat(8_000)),
)
})
.collect();
let tokens_before = estimate_tokens(&messages);
assert_eq!(count_long_tool_outputs(&messages), 8);
let budget = 4_000;
let (pruned, replaced) = structural_prefill_prune(&messages, None, budget);
assert!(replaced >= 1, "expected at least one pruned tool output");
let tokens_after = estimate_tokens(&pruned);
assert!(
tokens_after <= budget,
"expected under budget: before={tokens_before} after={tokens_after} budget={budget}"
);
assert!(
tokens_after < tokens_before / 2,
"expected large token drop: before={tokens_before} after={tokens_after}"
);
}
#[test]
fn apply_structural_tool_output_prune_returns_none_when_nothing_long() {
let messages = vec![Message::tool_result("id", "shell_exec", "ok")];
assert!(apply_structural_tool_output_prune(&messages, None, 32_000).is_none());
}
#[test]
fn apply_structural_tool_output_prune_reports_outcome() {
let messages = vec![Message::tool_result("id", "web_extract", &"x".repeat(500))];
let (pruned, outcome) = apply_structural_tool_output_prune(&messages, None, 0)
.expect("long tool output should prune");
assert_eq!(outcome.tools_pruned, 1);
assert_eq!(outcome.long_tool_outputs_remaining, 0);
assert!(outcome.message_tokens_after < outcome.message_tokens_before);
assert_eq!(count_long_tool_outputs(&pruned), 0);
}
#[test]
fn prune_tool_outputs_keeps_short_results() {
let messages = vec![Message::tool_result("id1", "shell_exec", "ok")];
let pruned = prune_tool_outputs(&messages, None);
assert_eq!(pruned[0].text_content(), "ok");
}
#[test]
fn prune_computer_use_screenshots_keeps_last_three() {
use edgecrab_types::{Content, ContentPart, ImageUrl};
let make_capture = |id: &str, label: &str| Message {
role: Role::Tool,
content: Some(Content::Parts(vec![
ContentPart::Text {
text: format!("capture {label}"),
},
ContentPart::ImageUrl {
image_url: ImageUrl {
url: format!("data:image/png;base64,{label}"),
detail: None,
},
},
])),
tool_call_id: Some(id.into()),
name: Some("computer_use".into()),
..Default::default()
};
let messages: Vec<Message> = (0..4)
.map(|i| make_capture(&format!("call_{i}"), &format!("img{i}")))
.collect();
let pruned = prune_computer_use_screenshots(&messages, 3);
assert_eq!(pruned.len(), 4);
assert!(!pruned[0].text_content().contains("data:image"));
assert!(pruned[0].text_content().contains("[screenshot pruned"));
for msg in &pruned[1..] {
match &msg.content {
Some(Content::Parts(parts)) => {
assert!(
parts
.iter()
.any(|p| matches!(p, ContentPart::ImageUrl { .. }))
);
}
other => panic!("expected Parts, got {other:?}"),
}
}
}
#[test]
fn prune_computer_use_screenshots_zero_strips_all() {
use edgecrab_types::{Content, ContentPart, ImageUrl};
let msg = Message {
role: Role::Tool,
content: Some(Content::Parts(vec![
ContentPart::Text {
text: "capture".into(),
},
ContentPart::ImageUrl {
image_url: ImageUrl {
url: "data:image/png;base64,abc".into(),
detail: None,
},
},
])),
tool_call_id: Some("call_0".into()),
name: Some("computer_use".into()),
..Default::default()
};
let pruned = prune_computer_use_screenshots(&[msg], 0);
assert!(matches!(pruned[0].content, Some(Content::Text(_))));
assert!(pruned[0].text_content().contains("[screenshot pruned"));
}
#[test]
fn prune_tool_outputs_spills_when_context_provided() {
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let seq = crate::tool_result_spill::SpillSequence::new();
let spill_config = crate::tool_result_spill::SpillConfig {
enabled: true,
threshold: 100, preview_lines: 3,
};
let spill_ctx = PruneSpillContext {
session_id: "test-session",
cwd: tmp.path(),
config: &spill_config,
seq: &seq,
};
let big_result: String = (1..=50)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let messages = vec![
Message::user("search files"),
Message::tool_result("id1", "file_search", &big_result),
];
let pruned = prune_tool_outputs_with_options(
&messages,
Some(&spill_ctx),
&PruneToolOutputsOptions {
protect_tail_count: 0,
protect_tail_tokens: None,
},
);
assert_eq!(pruned.len(), 2);
assert_eq!(pruned[0].text_content(), "search files");
let result_content = pruned[1].text_content();
assert!(
result_content.contains("[tool_result_spill]"),
"expected spill stub, got: {result_content}"
);
assert!(result_content.contains("tool: file_search"));
assert!(result_content.contains("--- BEGIN PREVIEW"));
assert!(!result_content.contains(PRUNED_TOOL_PLACEHOLDER));
}
#[test]
fn prune_tool_outputs_falls_back_to_placeholder_when_below_spill_threshold() {
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let seq = crate::tool_result_spill::SpillSequence::new();
let spill_config = crate::tool_result_spill::SpillConfig {
enabled: true,
threshold: 10_000, preview_lines: 5,
};
let spill_ctx = PruneSpillContext {
session_id: "test-session",
cwd: tmp.path(),
config: &spill_config,
seq: &seq,
};
let messages = vec![Message::tool_result("id1", "shell_exec", &"x".repeat(500))];
let pruned = prune_tool_outputs_with_options(
&messages,
Some(&spill_ctx),
&PruneToolOutputsOptions {
protect_tail_count: 0,
protect_tail_tokens: None,
},
);
let summary = pruned[0].text_content();
assert!(
summary.contains("shell_exec"),
"expected semantic summary, got: {summary}"
);
assert!(!summary.contains(&"x".repeat(100)));
}
#[test]
fn prune_tool_outputs_skips_spill_when_disabled() {
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let seq = crate::tool_result_spill::SpillSequence::new();
let spill_config = crate::tool_result_spill::SpillConfig {
enabled: false, threshold: 100,
preview_lines: 5,
};
let spill_ctx = PruneSpillContext {
session_id: "test-session",
cwd: tmp.path(),
config: &spill_config,
seq: &seq,
};
let messages = vec![Message::tool_result("id1", "shell_exec", &"x".repeat(500))];
let pruned = prune_tool_outputs_with_options(
&messages,
Some(&spill_ctx),
&PruneToolOutputsOptions {
protect_tail_count: 0,
protect_tail_tokens: None,
},
);
let summary = pruned[0].text_content();
assert!(
summary.contains("shell_exec"),
"expected semantic summary when spill disabled, got: {summary}"
);
assert_ne!(summary, PRUNED_TOOL_PLACEHOLDER);
}
#[test]
fn extract_prior_summary_finds_prefixed_block() {
let summary_text = "Prior summary content";
let messages = vec![
Message::system_summary(format!("{SUMMARY_PREFIX}{summary_text}")),
Message::user("hello"),
];
let extracted = extract_prior_summary(&messages);
assert_eq!(extracted.as_deref(), Some(summary_text));
}
#[test]
fn extract_prior_summary_returns_none_without_prefix() {
let messages = vec![
Message::system_summary("Regular context summary".to_string()),
Message::user("hello"),
];
let extracted = extract_prior_summary(&messages);
assert!(extracted.is_none());
}
#[test]
fn structural_only_returns_original_when_too_few_messages() {
let msgs = make_messages(5);
let params = CompressionParams {
context_window: 128_000,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 20,
};
let result = compress_structural_only(&msgs, ¶ms, None);
assert_eq!(
result.len(),
msgs.len(),
"should return original when below protect threshold"
);
}
#[test]
fn structural_only_compresses_large_history() {
let msgs: Vec<Message> = (0..200)
.map(|i| {
if i % 2 == 0 {
Message::user(&format!("question {i} {}", "x".repeat(100)))
} else {
Message::assistant(&format!("answer {i} {}", "y".repeat(100)))
}
})
.collect();
let params = CompressionParams {
context_window: 500,
threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 5,
};
let result = compress_structural_only(&msgs, ¶ms, None);
assert!(result.len() < msgs.len(), "should produce fewer messages");
let has_summary = result
.iter()
.any(|m| m.text_content().contains(SUMMARY_PREFIX));
assert!(has_summary, "should contain a structural summary message");
}
#[test]
fn structural_only_preserves_recent_messages() {
let msgs: Vec<Message> = (0..100)
.map(|i| {
if i % 2 == 0 {
Message::user(&format!("q{i}"))
} else {
Message::assistant(&format!("a{i}"))
}
})
.collect();
let params = CompressionParams {
context_window: 200,
threshold: 0.05,
target_ratio: 0.20,
protect_last_n: 5,
};
let result = compress_structural_only(&msgs, ¶ms, None);
let last_original = msgs
.last()
.expect("test messages should contain a last item")
.text_content();
let last_result = result
.last()
.expect("compressed result should preserve the last item")
.text_content();
assert_eq!(last_original, last_result, "last message must be preserved");
}
#[tokio::test]
async fn compress_with_llm_returns_false_on_llm_failure() {
use async_trait::async_trait;
use edgequake_llm::error::LlmError;
use edgequake_llm::traits::{
ChatMessage, CompletionOptions, LLMProvider, LLMResponse, ToolChoice, ToolDefinition,
};
struct FailingProvider;
#[async_trait]
impl LLMProvider for FailingProvider {
fn name(&self) -> &str {
"failing"
}
fn model(&self) -> &str {
"test-model"
}
fn max_context_length(&self) -> usize {
128_000
}
async fn complete(&self, _: &str) -> edgequake_llm::Result<LLMResponse> {
Err(LlmError::ApiError("simulated failure".to_string()))
}
async fn complete_with_options(
&self,
_: &str,
_: &CompletionOptions,
) -> edgequake_llm::Result<LLMResponse> {
Err(LlmError::ApiError("simulated failure".to_string()))
}
async fn chat(
&self,
_: &[ChatMessage],
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Err(LlmError::ApiError("simulated failure".to_string()))
}
async fn chat_with_tools(
&self,
_: &[ChatMessage],
_: &[ToolDefinition],
_: Option<ToolChoice>,
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Err(LlmError::ApiError("simulated failure".to_string()))
}
}
let msgs: Vec<Message> = (0..40)
.map(|i| {
if i % 2 == 0 {
Message::user(&format!("question {i} {}", "x".repeat(200)))
} else {
Message::assistant(&format!("answer {i} {}", "y".repeat(200)))
}
})
.collect();
let params = CompressionParams {
context_window: 1_000,
threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 5,
};
let provider: Arc<dyn LLMProvider> = Arc::new(FailingProvider);
let (compressed, llm_succeeded) = compress_with_llm(&msgs, ¶ms, &provider, None).await;
assert!(
!llm_succeeded,
"expected llm_succeeded=false when provider errors"
);
assert!(
compressed.len() < msgs.len(),
"structural fallback must still compress"
);
}
#[tokio::test]
async fn compress_with_llm_returns_true_on_llm_success() {
use async_trait::async_trait;
use edgequake_llm::traits::{
ChatMessage, CompletionOptions, LLMProvider, LLMResponse, ToolChoice, ToolDefinition,
};
struct SuccessProvider;
#[async_trait]
impl LLMProvider for SuccessProvider {
fn name(&self) -> &str {
"success"
}
fn model(&self) -> &str {
"test-model"
}
fn max_context_length(&self) -> usize {
128_000
}
async fn complete(&self, _: &str) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn complete_with_options(
&self,
_: &str,
_: &CompletionOptions,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn chat(
&self,
_: &[ChatMessage],
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn chat_with_tools(
&self,
_: &[ChatMessage],
_: &[ToolDefinition],
_: Option<ToolChoice>,
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
}
let msgs: Vec<Message> = (0..40)
.map(|i| {
if i % 2 == 0 {
Message::user(&format!("question {i} {}", "x".repeat(200)))
} else {
Message::assistant(&format!("answer {i} {}", "y".repeat(200)))
}
})
.collect();
let params = CompressionParams {
context_window: 1_000,
threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 5,
};
let provider: Arc<dyn LLMProvider> = Arc::new(SuccessProvider);
let (_compressed, llm_succeeded) = compress_with_llm(&msgs, ¶ms, &provider, None).await;
assert!(
llm_succeeded,
"expected llm_succeeded=true when provider succeeds"
);
}
#[tokio::test]
async fn compress_with_llm_fp30_avoids_adjacent_system_messages() {
use async_trait::async_trait;
use edgequake_llm::traits::{
ChatMessage, CompletionOptions, LLMProvider, LLMResponse, ToolChoice, ToolDefinition,
};
struct SuccessProvider;
#[async_trait]
impl LLMProvider for SuccessProvider {
fn name(&self) -> &str {
"success"
}
fn model(&self) -> &str {
"test-model"
}
fn max_context_length(&self) -> usize {
128_000
}
async fn complete(&self, _: &str) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn complete_with_options(
&self,
_: &str,
_: &CompletionOptions,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn chat(
&self,
_: &[ChatMessage],
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
async fn chat_with_tools(
&self,
_: &[ChatMessage],
_: &[ToolDefinition],
_: Option<ToolChoice>,
_: Option<&CompletionOptions>,
) -> edgequake_llm::Result<LLMResponse> {
Ok(LLMResponse::new("summary text", "test-model"))
}
}
let mut msgs = vec![
Message::user("system context"), Message::assistant("ok"), Message::user("follow up"), Message::system("extra system"), ];
for i in 0..30 {
if i % 2 == 0 {
msgs.push(Message::user(&format!("body q{i} {}", "x".repeat(300))));
} else {
msgs.push(Message::assistant(&format!(
"body a{i} {}",
"y".repeat(300)
)));
}
}
let params = CompressionParams {
context_window: 2_000,
threshold: 0.10,
target_ratio: 0.20,
protect_last_n: 3,
};
let provider: Arc<dyn LLMProvider> = Arc::new(SuccessProvider);
let (compressed, _) = compress_with_llm(&msgs, ¶ms, &provider, None).await;
let summary_msg = compressed
.iter()
.find(|m| m.text_content().contains(SUMMARY_PREFIX));
if let Some(_s) = summary_msg {
for window in compressed.windows(2) {
assert!(
!(window[0].role == edgecrab_types::Role::System
&& window[1].role == edgecrab_types::Role::System),
"FP30: adjacent system+system messages found in compressed output"
);
}
}
assert!(!compressed.is_empty());
}
#[test]
fn ha19_todo_snapshot_user_message_active_only() {
use edgecrab_tools::TodoStore;
use edgecrab_tools::tools::todo::TodoItem;
let store = TodoStore::new();
store.write(vec![
TodoItem {
id: 1,
title: "Start preview server".into(),
status: "in-progress".into(),
},
TodoItem {
id: 2,
title: "Done item".into(),
status: "completed".into(),
},
]);
let msg = todo_snapshot_user_message(&store).expect("snapshot");
assert_eq!(msg.role, edgecrab_types::Role::User);
let text = msg.text_content();
assert!(text.contains("in-progress"));
assert!(!text.contains("Done item"));
}
#[test]
fn ha19_apply_post_compress_hooks_injects_todo_and_clears_read_dedup() {
use edgecrab_tools::TodoStore;
use edgecrab_tools::read_tracker::{check_read_dedup, record_read_dedup, reset_read_dedup};
use edgecrab_tools::tools::todo::TodoItem;
use tempfile::TempDir;
let store = TodoStore::new();
store.write(vec![TodoItem {
id: 1,
title: "Verify preview".into(),
status: "not-started".into(),
}]);
let session_id = "compress-hook-test";
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("ha19.txt");
std::fs::write(&path, "seed").expect("write");
record_read_dedup(session_id, &path, None, None);
assert!(check_read_dedup(session_id, &path, None, None).is_some());
let mut messages = vec![Message::user("hello")];
apply_post_compress_message_hooks(&mut messages, &store, session_id);
assert_eq!(messages.len(), 2);
assert!(messages[1].text_content().contains("Verify preview"));
assert!(
check_read_dedup(session_id, &path, None, None).is_none(),
"read dedup must reset after compress hooks"
);
reset_read_dedup(session_id);
}
#[test]
fn session_hygiene_skips_short_or_disabled() {
assert!(!should_run_session_hygiene(3, 200_000, 128_000, true, 5000));
assert!(!should_run_session_hygiene(
10, 200_000, 128_000, false, 5000
));
assert!(!should_run_session_hygiene(10, 50_000, 128_000, true, 5000));
}
#[test]
fn session_hygiene_fires_at_85_pct_or_hard_msg_limit() {
let ctx = 128_000;
let at_85 = (ctx as f32 * GATEWAY_HYGIENE_THRESHOLD) as usize;
assert!(should_run_session_hygiene(10, at_85, ctx, true, 5000));
assert!(should_run_session_hygiene(5000, 100, ctx, true, 5000));
assert!(!should_run_session_hygiene(
4999,
at_85 - 1,
ctx,
true,
5000
));
}
#[test]
fn append_to_combined_keeps_stable_prefix_intact() {
let stable = "STABLE LAW";
let semi = "skills: foo";
let dynamic = "date + memory";
let mut combined = Some(format!("{stable}\n\n{semi}\n\n{dynamic}"));
append_to_combined_system_prompt(&mut combined, "runtime note");
let combined = combined.expect("combined");
assert!(combined.starts_with(stable));
let rest = combined[stable.len()..].trim_start_matches('\n');
assert!(rest.starts_with(semi));
assert!(combined.contains("runtime note"));
}
#[test]
fn small_ctx_threshold_floor_raises_to_75() {
assert!((effective_threshold(128_000, 0.50) - 0.75).abs() < f32::EPSILON);
assert!((effective_threshold(600_000, 0.50) - 0.50).abs() < f32::EPSILON);
assert!((effective_threshold(128_000, 0.85) - 0.85).abs() < f32::EPSILON);
}
#[test]
fn protect_first_n_decays_after_first_compression() {
assert_eq!(effective_protect_first_n(0, false), 3);
assert_eq!(effective_protect_first_n(1, false), 0);
assert_eq!(effective_protect_first_n(0, true), 0);
}
#[test]
fn defer_preflight_and_anti_thrash_gates() {
let mut state = CompressionRuntimeState::default();
assert!(!should_defer_preflight_to_real_usage(
&state, 100_000, 50_000
));
state.awaiting_real_usage_after_compression = true;
assert!(should_defer_preflight_to_real_usage(
&state, 100_000, 50_000
));
state = CompressionRuntimeState::default();
state.last_real_prompt_tokens = 40_000;
state.last_rough_tokens_when_real_prompt_fit = 90_000;
assert!(should_defer_preflight_to_real_usage(&state, 94_000, 80_000));
assert!(!should_defer_preflight_to_real_usage(
&state, 120_000, 80_000
));
state.ineffective_compression_count = 2;
assert!(automatic_compression_blocked(&state));
}
}