use std::sync::Arc;
use edgecrab_types::Message;
use edgequake_llm::LLMProvider;
use crate::config::CompressionConfig;
use crate::model_catalog::ModelCatalog;
pub const SUMMARY_PREFIX: &str =
"[CONTEXT COMPACTION] Earlier turns were summarised to reclaim context window space.\n\n";
pub const PRUNED_TOOL_PLACEHOLDER: &str = "[tool output pruned — reclaimed context window space]";
const PROTECT_FIRST_N: usize = 3;
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 = model
.split_once('/')
.and_then(|(provider, name)| ModelCatalog::context_window(provider, name))
.map(|tokens| tokens as usize)
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
Self {
context_window,
threshold: cfg.threshold.clamp(0.01, 1.0),
target_ratio: cfg.target_ratio.clamp(0.01, 1.0),
protect_last_n: cfg.protect_last_n.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionStatus {
Ok,
PressureWarning,
NeedsCompression,
}
pub fn estimate_tokens(messages: &[Message]) -> usize {
messages
.iter()
.map(|m| {
let text_len = m.text_content().len();
(text_len / 4) + 4
})
.sum()
}
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>,
) -> Vec<Message> {
let n = messages.len();
if n <= PROTECT_FIRST_N + params.protect_last_n {
return messages.to_vec();
}
let pruned = prune_tool_outputs(messages);
let head_end = align_boundary_forward(&pruned, PROTECT_FIRST_N);
let threshold_tokens = (params.context_window as f32 * params.threshold) as usize;
let tail_token_budget = (threshold_tokens as f32 * params.target_ratio) as usize;
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 prior_summary = extract_prior_summary(messages);
let summary_text = llm_summarize(
turns_to_summarize,
params.context_window,
provider,
prior_summary.as_deref(),
)
.await
.unwrap_or_else(|e: edgequake_llm::LlmError| {
tracing::warn!(error = %e, "LLM compression failed, using structural fallback");
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 prune_tool_outputs(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.map(|m| {
if m.role == edgecrab_types::Role::Tool && m.text_content().len() > 200 {
Message::tool_result(
m.tool_call_id.as_deref().unwrap_or("unknown"),
m.name.as_deref().unwrap_or("tool"),
PRUNED_TOOL_PLACEHOLDER,
)
} else {
m.clone()
}
})
.collect()
}
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)
}
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 {
if 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>,
) -> Result<String, edgequake_llm::LlmError> {
let content = serialize_for_summary(messages);
let content_tokens = estimate_tokens(messages);
let summary_budget = compute_summary_budget(content_tokens, context_window);
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.\n\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.\n\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::*;
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,
};
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]"));
}
#[test]
fn pruned_tool_placeholder_is_short() {
assert!(PRUNED_TOOL_PLACEHOLDER.len() < 100);
}
#[test]
fn prune_tool_outputs_replaces_long_results() {
let messages = vec![
Message::user("run a command"),
Message::tool_result("id1", "shell_exec", &"x".repeat(500)),
];
let pruned = prune_tool_outputs(&messages);
assert_eq!(pruned.len(), 2);
assert_eq!(pruned[0].text_content(), "run a command");
assert_eq!(pruned[1].text_content(), PRUNED_TOOL_PLACEHOLDER);
}
#[test]
fn prune_tool_outputs_keeps_short_results() {
let messages = vec![Message::tool_result("id1", "shell_exec", "ok")];
let pruned = prune_tool_outputs(&messages);
assert_eq!(pruned[0].text_content(), "ok");
}
#[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());
}
}