use anyhow::Result;
use regex::Regex;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use crate::config::DEFAULT_TEXT_MODEL;
use crate::core::model_client::ModelClient;
use crate::logging;
use crate::models::{
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
context_window_for_model,
};
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfig {
pub enabled: bool,
pub token_threshold: usize,
pub model: String,
pub effective_context_window: Option<u32>,
pub cache_summary: bool,
pub focus: Option<String>,
pub live_state: Option<CompactionLiveState>,
pub runtime_cost_owner: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CompactionLiveState {
pub mode: Option<String>,
pub permission_posture: Option<String>,
pub background_shells: Vec<String>,
pub running_workers: Vec<String>,
pub open_approvals: Vec<String>,
}
impl CompactionLiveState {
#[must_use]
pub fn is_empty(&self) -> bool {
self.mode.is_none()
&& self.permission_posture.is_none()
&& self.background_shells.is_empty()
&& self.running_workers.is_empty()
&& self.open_approvals.is_empty()
}
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
enabled: true,
token_threshold: 800_000,
model: DEFAULT_TEXT_MODEL.to_string(),
effective_context_window: None,
cache_summary: true,
focus: None,
live_state: None,
runtime_cost_owner: None,
}
}
}
const MIN_SUMMARY_SEED_CHARS: usize = 80;
const DEGENERATE_SUMMARY_REQUIRED_MARKERS: &[&str] =
&["Primary request", "Pending tasks", "Current work"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionFailureKind {
Deterministic,
Transient,
ContextOverflow,
}
impl CompactionFailureKind {
#[must_use]
pub fn is_transient(self) -> bool {
matches!(self, Self::Transient)
}
#[must_use]
pub fn allows_input_ladder(self) -> bool {
matches!(self, Self::ContextOverflow)
}
}
pub const KEEP_RECENT_MESSAGES: usize = 4;
const RECENT_WORKING_SET_WINDOW: usize = 12;
const MAX_WORKING_SET_PATHS: usize = 24;
const MIN_SUMMARIZE_MESSAGES: usize = 6;
const SUMMARY_TEXT_SNIPPET_CHARS: usize = 800;
const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
const SUMMARY_INPUT_MAX_CHARS: usize = 24_000;
const SUMMARY_INPUT_HEAD_CHARS: usize = 14_000;
const SUMMARY_INPUT_TAIL_CHARS: usize = 6_000;
const LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS: usize = 2_000;
const LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
const RETAINED_THINKING_MAX_CHARS: usize = 16 * 1024;
const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85;
const WORKING_SET_EXTENSIONS: &[&str] = &[
"rs", "toml", "md", "json", "yaml", "yml", "txt", "py", "pyi", "ipynb", "js", "jsx", "ts",
"tsx", "mjs", "cjs", "go", "java", "kt", "kts", "c", "h", "cc", "cpp", "hpp", "cs", "rb",
"php", "swift", "m", "mm", "scala", "sh", "bash", "zsh", "ps1", "sql", "proto", "tf", "vue",
"svelte", "dart", "lua", "r", "jl", "ex", "exs", "erl", "hs", "zig",
];
#[derive(Debug, Clone, Copy)]
struct SummaryInputLimits {
text_snippet_chars: usize,
tool_result_snippet_chars: usize,
input_max_chars: usize,
input_head_chars: usize,
input_tail_chars: usize,
max_tokens: u32,
word_limit: usize,
}
fn summary_input_limits_for_model(
model: &str,
effective_context_window: Option<u32>,
) -> SummaryInputLimits {
let is_large_context = effective_context_window
.or_else(|| context_window_for_model(model))
.is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
if is_large_context {
SummaryInputLimits {
text_snippet_chars: LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS,
tool_result_snippet_chars: LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
input_max_chars: LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS,
input_head_chars: LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS,
input_tail_chars: LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS,
max_tokens: LARGE_CONTEXT_SUMMARY_MAX_TOKENS,
word_limit: 900,
}
} else {
SummaryInputLimits {
text_snippet_chars: SUMMARY_TEXT_SNIPPET_CHARS,
tool_result_snippet_chars: SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
input_max_chars: SUMMARY_INPUT_MAX_CHARS,
input_head_chars: SUMMARY_INPUT_HEAD_CHARS,
input_tail_chars: SUMMARY_INPUT_TAIL_CHARS,
max_tokens: 1_024,
word_limit: 500,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CompactionPlan {
pub pinned_indices: BTreeSet<usize>,
pub summarize_indices: Vec<usize>,
}
fn path_regex() -> &'static Regex {
static PATH_RE: OnceLock<Regex> = OnceLock::new();
PATH_RE.get_or_init(|| {
Regex::new(
r"(?x)
(?:
(?P<root>
Cargo\.toml|
Cargo\.lock|
README\.md|
CHANGELOG\.md|
AGENTS\.md|
config\.example\.toml
)
)
|
(?P<path>
(?:[A-Za-z0-9._-]+/)+
[A-Za-z0-9._-]+
\.[A-Za-z0-9]+
)
",
)
.expect("path regex is valid")
})
}
fn normalize_path_candidate(candidate: &str, workspace: Option<&Path>) -> Option<String> {
if candidate.is_empty() {
return None;
}
let cleaned = candidate.replace('\\', "/");
let mut path = PathBuf::from(cleaned);
if path.is_absolute() {
let ws = workspace?;
if let Ok(stripped) = path.strip_prefix(ws) {
path = stripped.to_path_buf();
} else {
return None;
}
}
let rel = path.to_string_lossy().trim_start_matches("./").to_string();
if rel.is_empty() || rel.contains("..") {
return None;
}
if let Some(ws) = workspace {
let repo_path = ws.join(&rel);
if repo_path.exists() || looks_repo_relative(&rel) {
return Some(rel);
}
return None;
}
if looks_repo_relative(&rel) {
return Some(rel);
}
None
}
fn looks_repo_relative(path: &str) -> bool {
matches!(
path,
"Cargo.toml"
| "Cargo.lock"
| "README.md"
| "CHANGELOG.md"
| "AGENTS.md"
| "config.example.toml"
) || path.starts_with("src/")
|| path.starts_with("tests/")
|| path.starts_with("docs/")
|| path.starts_with("examples/")
|| path.starts_with("benches/")
|| path.starts_with("crates/")
|| path.starts_with(".github/")
|| (path.contains('/') && path.rsplit('.').next().is_some())
}
fn is_working_set_path(path: &str) -> bool {
if path.split('/').any(|component| {
matches!(
component,
"node_modules" | "target" | "vendor" | "dist" | "build"
)
}) {
return false;
}
let file_name = path.rsplit('/').next().unwrap_or(path);
if file_name.ends_with(".min.js") || file_name.ends_with(".min.css") {
return false;
}
if file_name == "Cargo.lock" {
return true;
}
if file_name.ends_with(".lock") {
return false;
}
let Some(extension) = file_name.rsplit('.').next() else {
return false;
};
let extension = extension.to_ascii_lowercase();
WORKING_SET_EXTENSIONS.contains(&extension.as_str())
}
fn extract_paths_from_text(text: &str, workspace: Option<&Path>) -> Vec<String> {
path_regex()
.captures_iter(text)
.filter_map(|caps| {
let candidate = caps
.name("path")
.or_else(|| caps.name("root"))
.map(|m| m.as_str())?;
normalize_path_candidate(candidate, workspace)
})
.collect()
}
fn extract_paths_from_tool_input(
input: &serde_json::Value,
workspace: Option<&Path>,
) -> Vec<String> {
let mut out = Vec::new();
let Some(obj) = input.as_object() else {
return out;
};
for key in ["path", "file", "target", "cwd"] {
if let Some(val) = obj.get(key).and_then(serde_json::Value::as_str)
&& let Some(path) = normalize_path_candidate(val, workspace)
{
out.push(path);
}
}
for key in ["paths", "files", "targets"] {
if let Some(vals) = obj.get(key).and_then(serde_json::Value::as_array) {
for val in vals {
if let Some(s) = val.as_str()
&& let Some(path) = normalize_path_candidate(s, workspace)
{
out.push(path);
}
}
}
}
out
}
fn message_text(msg: &Message) -> String {
let mut text = String::new();
for block in &msg.content {
match block {
ContentBlock::Text { text: t, .. } => {
let _ = writeln!(text, "{t}");
}
ContentBlock::Thinking { .. } => {}
ContentBlock::ToolUse { name, input, .. } => {
let _ = writeln!(text, "[tool_use:{name}] {input}");
}
ContentBlock::ToolResult { content, .. } => {
let _ = writeln!(text, "{content}");
}
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => {}
}
}
text
}
fn is_user_text_query(msg: &Message) -> bool {
msg.role == "user"
&& msg
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text { .. }))
}
fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Vec<String> {
let mut paths = Vec::new();
for block in &message.content {
let candidates = match block {
ContentBlock::Text { text, .. } => extract_paths_from_text(text, workspace),
ContentBlock::ToolResult { content, .. } => extract_paths_from_text(content, workspace),
ContentBlock::ToolUse { input, .. } => extract_paths_from_tool_input(input, workspace),
ContentBlock::Thinking { .. } => Vec::new(),
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => Vec::new(),
};
paths.extend(candidates);
}
paths
}
fn derive_working_set_paths(
messages: &[Message],
workspace: Option<&Path>,
seed_indices: &[usize],
) -> HashSet<String> {
let mut paths: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut seeds: Vec<usize> = seed_indices
.iter()
.copied()
.filter(|idx| *idx < messages.len())
.collect();
seeds.sort_unstable_by(|a, b| b.cmp(a));
for idx in seeds {
for candidate in extract_paths_from_message(&messages[idx], workspace) {
if !is_working_set_path(&candidate) {
continue;
}
if seen.insert(candidate.clone()) {
paths.push(candidate);
if paths.len() >= MAX_WORKING_SET_PATHS {
return paths.into_iter().collect();
}
}
}
}
for msg in messages.iter().rev().take(RECENT_WORKING_SET_WINDOW) {
for candidate in extract_paths_from_message(msg, workspace) {
if !is_working_set_path(&candidate) {
continue;
}
if seen.insert(candidate.clone()) {
paths.push(candidate);
if paths.len() >= MAX_WORKING_SET_PATHS {
return paths.into_iter().collect();
}
}
}
}
paths.into_iter().collect()
}
fn should_pin_message(text: &str, working_set_paths: &HashSet<String>) -> bool {
let lower = text.to_lowercase();
let mentions_working_set = working_set_paths.iter().any(|p| text.contains(p));
if mentions_working_set {
return true;
}
let error_markers = [
"error:",
"error ",
"failed",
"panic",
"traceback",
"stack trace",
"assertion failed",
"test failed",
];
if error_markers.iter().any(|m| lower.contains(m)) {
return true;
}
let patch_markers = [
"diff --git",
"+++ b/",
"--- a/",
"*** begin patch",
"*** update file:",
"*** add file:",
"*** delete file:",
"```diff",
"apply_patch",
];
patch_markers.iter().any(|m| lower.contains(m))
}
pub fn plan_compaction(
messages: &[Message],
workspace: Option<&Path>,
keep_recent: usize,
external_pins: Option<&[usize]>,
external_working_set_paths: Option<&[String]>,
) -> CompactionPlan {
let mut pinned_indices: BTreeSet<usize> = BTreeSet::new();
let len = messages.len();
if len == 0 {
return CompactionPlan::default();
}
let recent_start = len.saturating_sub(keep_recent);
pinned_indices.extend(recent_start..len);
let seed_indices = external_pins.unwrap_or(&[]);
let mut working_set_paths = derive_working_set_paths(messages, workspace, seed_indices);
if let Some(paths) = external_working_set_paths {
for path in paths {
if let Some(normalized) = normalize_path_candidate(path, workspace)
&& is_working_set_path(&normalized)
{
let _ = working_set_paths.insert(normalized);
}
}
}
for (idx, msg) in messages.iter().enumerate() {
if pinned_indices.contains(&idx) {
continue;
}
let text = message_text(msg);
if should_pin_message(&text, &working_set_paths) {
pinned_indices.insert(idx);
}
}
if let Some(pins) = external_pins {
pinned_indices.extend(pins.iter().copied().filter(|idx| *idx < len));
}
enforce_tool_call_pairs(messages, &mut pinned_indices);
if !pinned_indices
.iter()
.any(|&idx| is_user_text_query(&messages[idx]))
&& let Some(idx) = messages
.iter()
.enumerate()
.rev()
.find_map(|(idx, msg)| is_user_text_query(msg).then_some(idx))
{
pinned_indices.insert(idx);
}
let summarize_indices = (0..len)
.filter(|idx| !pinned_indices.contains(idx))
.collect();
drop(working_set_paths);
CompactionPlan {
pinned_indices,
summarize_indices,
}
}
#[allow(dead_code)]
fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<usize>) {
if pinned_indices.is_empty() {
return;
}
let mut call_id_to_idx: HashMap<String, usize> = HashMap::new();
let mut result_id_to_idx: HashMap<String, usize> = HashMap::new();
for (idx, msg) in messages.iter().enumerate() {
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, .. } => {
call_id_to_idx.insert(id.clone(), idx);
}
ContentBlock::ToolResult { tool_use_id, .. } => {
result_id_to_idx.insert(tool_use_id.clone(), idx);
}
_ => {}
}
}
}
let mut permanently_removed: HashSet<usize> = HashSet::new();
let max_iters = messages.len().max(10);
let mut converged = false;
for _ in 0..max_iters {
let mut to_add = Vec::new();
let mut to_remove = Vec::new();
let snapshot: Vec<usize> = pinned_indices.iter().copied().collect();
for idx in snapshot {
let msg = &messages[idx];
for block in &msg.content {
match block {
ContentBlock::ToolResult { tool_use_id, .. } => {
match call_id_to_idx.get(tool_use_id) {
Some(&call_idx) if !permanently_removed.contains(&call_idx) => {
to_add.push(call_idx);
}
_ => {
to_remove.push(idx);
}
}
}
ContentBlock::ToolUse { id, .. } => match result_id_to_idx.get(id) {
Some(&result_idx) if !permanently_removed.contains(&result_idx) => {
to_add.push(result_idx);
}
_ => {
to_remove.push(idx);
}
},
_ => {}
}
}
}
let remove_set: HashSet<usize> = to_remove.iter().copied().collect();
let mut changed = false;
for idx in to_add {
if !remove_set.contains(&idx) && pinned_indices.insert(idx) {
changed = true;
}
}
for idx in to_remove {
if pinned_indices.remove(&idx) {
permanently_removed.insert(idx);
changed = true;
}
}
if !changed {
converged = true;
break;
}
}
if !converged {
logging::warn(format!(
"enforce_tool_call_pairs did not converge after {max_iters} iterations \
({} messages, {} pinned)",
messages.len(),
pinned_indices.len()
));
}
}
fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
message
.content
.iter()
.map(|c| match c {
ContentBlock::Text { text, .. } => text.len() / 4,
ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4,
ContentBlock::Thinking { .. } => 0,
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
.map(|s| s.len() / 4)
.unwrap_or(100),
ContentBlock::ToolResult { content, .. } => content.len() / 4,
ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => 0,
})
.sum::<usize>()
}
const IMAGE_TOKEN_ESTIMATE: usize = 1000;
pub fn estimate_tokens(messages: &[Message]) -> usize {
messages
.iter()
.map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
.sum()
}
fn message_has_tool_use(message: &Message) -> bool {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
}
pub fn estimate_text_tokens_conservative(text: &str) -> usize {
text.chars().count().div_ceil(3)
}
fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
match system {
Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
Some(SystemPrompt::Blocks(blocks)) => blocks
.iter()
.map(|block| estimate_text_tokens_conservative(&block.text))
.sum(),
None => 0,
}
}
#[must_use]
pub fn estimate_input_tokens_conservative(
messages: &[Message],
system: Option<&SystemPrompt>,
) -> usize {
let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
let system_tokens = estimate_system_tokens_conservative(system);
let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
message_tokens
.saturating_add(system_tokens)
.saturating_add(framing_overhead)
}
pub fn should_compact(
messages: &[Message],
config: &CompactionConfig,
workspace: Option<&Path>,
external_pins: Option<&[usize]>,
external_working_set_paths: Option<&[String]>,
) -> bool {
if !config.enabled {
return false;
}
let plan = plan_compaction(
messages,
workspace,
KEEP_RECENT_MESSAGES,
external_pins,
external_working_set_paths,
);
let pinned_tokens: usize = plan
.pinned_indices
.iter()
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
.sum();
let token_estimate: usize = plan
.summarize_indices
.iter()
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
.sum();
let message_count = plan.summarize_indices.len();
let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
if effective_token_threshold == 0 {
return message_count >= MIN_SUMMARIZE_MESSAGES;
}
if message_count < MIN_SUMMARIZE_MESSAGES {
return false;
}
token_estimate > effective_token_threshold
}
fn truncate_chars(text: &str, max_chars: usize) -> &str {
if max_chars == 0 {
return "";
}
match text.char_indices().nth(max_chars) {
Some((idx, _)) => &text[..idx],
None => text,
}
}
fn tail_chars(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let total_chars = text.chars().count();
if total_chars <= max_chars {
return text.to_string();
}
let start_char = total_chars.saturating_sub(max_chars);
let start_idx = text
.char_indices()
.nth(start_char)
.map_or(0, |(idx, _)| idx);
text[start_idx..].to_string()
}
#[derive(Debug, Clone)]
struct ToolUseInfo {
name: String,
key: String,
args_preview: String,
}
fn tool_use_key(name: &str, input: &serde_json::Value) -> String {
format!(
"{name}:{}",
serde_json::to_string(input).unwrap_or_else(|_| input.to_string())
)
}
fn tool_args_preview(input: &serde_json::Value) -> String {
let raw = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
truncate_chars(&raw, 120).to_string()
}
fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
let mut tool_uses = HashMap::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolUse {
id, name, input, ..
} = block
{
tool_uses.insert(
id.clone(),
ToolUseInfo {
name: name.clone(),
key: tool_use_key(name, input),
args_preview: tool_args_preview(input),
},
);
}
}
}
tool_uses
}
struct ToolResultPruneCandidate {
message_idx: usize,
block_idx: usize,
key: String,
tool_name: String,
args_preview: String,
original_len: usize,
}
#[cfg(test)]
fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize {
prune_tool_results_until(messages, protected_window, |_, _| false)
}
fn prune_tool_results_until<F>(
messages: &mut [Message],
protected_window: usize,
mut should_stop: F,
) -> usize
where
F: FnMut(&[Message], usize) -> bool,
{
let cutoff = messages.len().saturating_sub(protected_window);
if cutoff == 0 {
return 0;
}
let tool_uses = collect_tool_uses(messages);
let mut candidates = Vec::new();
let mut latest_by_key: HashMap<String, usize> = HashMap::new();
let mut count_by_key: HashMap<String, usize> = HashMap::new();
for (message_idx, message) in messages.iter().take(cutoff).enumerate() {
for (block_idx, block) in message.content.iter().enumerate() {
let ContentBlock::ToolResult {
tool_use_id,
content,
..
} = block
else {
continue;
};
let Some(info) = tool_uses.get(tool_use_id) else {
continue;
};
latest_by_key.insert(info.key.clone(), message_idx);
*count_by_key.entry(info.key.clone()).or_insert(0) += 1;
candidates.push(ToolResultPruneCandidate {
message_idx,
block_idx,
key: info.key.clone(),
tool_name: info.name.clone(),
args_preview: info.args_preview.clone(),
original_len: content.len(),
});
}
}
candidates.reverse();
let mut bytes_saved = 0usize;
for candidate in candidates {
let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0);
let is_latest_duplicate = duplicate_count > 1
&& latest_by_key.get(&candidate.key) == Some(&candidate.message_idx);
if is_latest_duplicate {
continue;
}
if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS {
continue;
}
let summary = format!(
"[{}] tool result pruned ({} bytes; args: {})",
candidate.tool_name, candidate.original_len, candidate.args_preview
);
if summary.len() >= candidate.original_len {
continue;
}
if let ContentBlock::ToolResult {
content,
content_blocks,
..
} = &mut messages[candidate.message_idx].content[candidate.block_idx]
{
bytes_saved = bytes_saved.saturating_add(content.len().saturating_sub(summary.len()));
*content = summary;
*content_blocks = None;
if should_stop(messages, bytes_saved) {
break;
}
}
}
bytes_saved
}
fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
let char_count = content.chars().count();
if char_count <= max_chars {
return false;
}
let snippet_budget = max_chars.saturating_sub(256).max(1024);
let head_chars = snippet_budget / 2;
let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
let head = truncate_chars(content, head_chars).to_string();
let tail = tail_chars(content, tail_chars_budget);
*content =
format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
true
}
#[allow(clippy::collapsible_match)]
fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
for message in &mut messages {
for block in &mut message.content {
match block {
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
if truncate_retained_block(
"tool result",
content,
RETAINED_TOOL_RESULT_MAX_CHARS,
) {
*content_blocks = None;
}
}
ContentBlock::Thinking {
thinking,
signature,
} if signature.is_none() => {
truncate_retained_block(
"thinking block",
thinking,
RETAINED_THINKING_MAX_CHARS,
);
}
_ => {}
}
}
}
messages
}
#[derive(Debug)]
pub struct CompactionResult {
pub messages: Vec<Message>,
pub summary_prompt: Option<SystemPrompt>,
pub retries_used: u32,
}
fn classify_compaction_failure(e: &anyhow::Error) -> CompactionFailureKind {
let text = e.to_string();
if is_context_window_error_message(&text) {
return CompactionFailureKind::ContextOverflow;
}
let category = crate::error_taxonomy::classify_error_message(&text);
match category {
crate::error_taxonomy::ErrorCategory::Network
| crate::error_taxonomy::ErrorCategory::RateLimit
| crate::error_taxonomy::ErrorCategory::Timeout => CompactionFailureKind::Transient,
_ => CompactionFailureKind::Deterministic,
}
}
#[must_use]
pub fn report_compaction_failure(
prefix: &str,
id: &str,
auto: bool,
error: &anyhow::Error,
) -> String {
let raw = error.to_string();
let safe_raw = crate::safe_label::safe_error_text(&raw);
tracing::warn!(
compaction_id = %id,
auto,
error = %safe_raw,
"context compaction failed"
);
let lower = raw.to_ascii_lowercase();
let plan_exhausted = lower.contains("usage limit")
|| lower.contains("insufficient_quota")
|| lower.contains("insufficientquota")
|| lower.contains("quota exhausted")
|| lower.contains("quota has been exceeded");
let detail = if plan_exhausted {
"provider plan quota exhausted — switch provider/model or renew the provider plan"
.to_string()
} else {
match crate::error_taxonomy::classify_error_message(&raw) {
crate::error_taxonomy::ErrorCategory::RateLimit => {
"provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
.to_string()
}
crate::error_taxonomy::ErrorCategory::Authentication => {
"provider authentication failed — sign in or replace the credential, then retry"
.to_string()
}
crate::error_taxonomy::ErrorCategory::Authorization => {
"provider authorization rejected compaction — verify account access or switch provider/model"
.to_string()
}
_ => safe_raw,
}
};
format!("{prefix}: {detail}")
}
fn is_transient_error(e: &anyhow::Error) -> bool {
classify_compaction_failure(e).is_transient()
}
fn is_context_window_error_message(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("too long for this model")
|| lower.contains("prompt is too long")
|| lower.contains("maximum prompt length")
|| lower.contains("maximum context length")
|| lower.contains("context_length_exceeded")
|| lower.contains("context window")
|| (lower.contains("context")
&& (lower.contains("token") || lower.contains("too long") || lower.contains("maximum")))
}
pub async fn compact_messages_safe(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
workspace: Option<&Path>,
external_pins: Option<&[usize]>,
external_working_set_paths: Option<&[String]>,
) -> Result<CompactionResult> {
const MAX_RETRIES: u32 = 3;
const BASE_DELAY_MS: u64 = 1000;
let was_over_threshold = should_compact(
messages,
config,
workspace,
external_pins,
external_working_set_paths,
);
let mut pruned_messages = messages.to_vec();
let mut now_under_threshold = false;
let mut next_stop_check_bytes = 0usize;
let pruned_bytes = prune_tool_results_until(
&mut pruned_messages,
KEEP_RECENT_MESSAGES,
|candidate_messages, bytes_saved| {
if !was_over_threshold || bytes_saved < next_stop_check_bytes {
return false;
}
next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES);
now_under_threshold = !should_compact(
candidate_messages,
config,
workspace,
external_pins,
external_working_set_paths,
);
now_under_threshold
},
);
if was_over_threshold && pruned_bytes > 0 && !now_under_threshold {
now_under_threshold = !should_compact(
&pruned_messages,
config,
workspace,
external_pins,
external_working_set_paths,
);
}
let compaction_input: &[Message] = if pruned_bytes > 0 {
logging::info(format!(
"Local tool-result prune saved {pruned_bytes} bytes before LLM compaction"
));
if was_over_threshold && now_under_threshold {
return Ok(CompactionResult {
messages: sanitize_retained_messages(pruned_messages),
summary_prompt: None,
retries_used: 0,
});
}
&pruned_messages
} else {
messages
};
let mut last_error: Option<anyhow::Error> = None;
for attempt in 0..MAX_RETRIES {
if attempt > 0 {
let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1)));
tokio::time::sleep(delay).await;
}
match compact_messages(
client,
compaction_input,
config,
workspace,
external_pins,
external_working_set_paths,
)
.await
{
Ok((msgs, prompt, removed)) => {
drop(removed);
return Ok(CompactionResult {
messages: sanitize_retained_messages(msgs),
summary_prompt: prompt,
retries_used: attempt,
});
}
Err(e) => {
if !is_transient_error(&e) {
return Err(e);
}
last_error = Some(e);
}
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
}
fn read_workspace_anchors(workspace: Option<&Path>) -> Vec<String> {
let Some(ws) = workspace else {
return Vec::new();
};
let primary = ws.join(".codewhale").join("anchors.md");
let anchors_path = if primary.exists() {
primary
} else {
ws.join(".deepseek").join("anchors.md")
};
let Ok(content) = std::fs::read_to_string(anchors_path) else {
return Vec::new();
};
content
.split("\n---\n")
.map(str::trim)
.filter(|anchor| !anchor.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn anchor_summary_section(workspace: Option<&Path>) -> String {
let anchors = read_workspace_anchors(workspace);
if anchors.is_empty() {
return String::new();
}
let mut section = String::from(
"## Pinned Facts (User Anchors)\n\n\
The following facts were explicitly anchored by the user with `/anchor`. \
Preserve them across compaction cycles.\n\n",
);
for anchor in anchors {
let _ = writeln!(section, "- {anchor}");
}
section.push_str("\n---\n\n");
section
}
pub async fn compact_messages(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
workspace: Option<&Path>,
external_pins: Option<&[usize]>,
external_working_set_paths: Option<&[String]>,
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
if messages.is_empty() {
return Ok((Vec::new(), None, Vec::new()));
}
let plan = plan_compaction(
messages,
workspace,
KEEP_RECENT_MESSAGES,
external_pins,
external_working_set_paths,
);
if plan.summarize_indices.is_empty() {
return Ok((messages.to_vec(), None, Vec::new()));
}
let to_summarize: Vec<Message> = plan
.summarize_indices
.iter()
.map(|&idx| messages[idx].clone())
.collect();
let summary = create_summary_with_ladder(
client,
&to_summarize,
&config.model,
config.effective_context_window,
config.focus.as_deref(),
config.runtime_cost_owner.as_deref(),
)
.await?;
let workflow_context = extract_workflow_context(&to_summarize, workspace);
drop(to_summarize);
let anchors_section = anchor_summary_section(workspace);
let project_instructions = project_instructions_section(workspace);
let live_reminder = config
.live_state
.as_ref()
.map(format_live_state_reminder)
.filter(|s| !s.is_empty())
.unwrap_or_default();
let summary_block = SystemBlock {
block_type: "text".to_string(),
text: format!(
"{anchors_section}\
## 📋 Conversation Summary (Auto-Generated)\n\n\
{summary}\n\n\
---\n\n\
## 🔍 Workflow Context\n\n\
{workflow_context}\n\n\
---\n\n\
{live_reminder}\
{project_instructions}\
## 💡 What to Do Next\n\n\
You have just resumed from a context compaction. The conversation above was summarized to save space. \
Review the summary, live state, and project instructions, then continue the same task. \
Prefer exact paths and commands from the summary over re-discovery. \
If you need more details about the summarized portion, ask the user to clarify.\n\n\
---\n\n\
Pinned messages follow:"
),
cache_control: if config.cache_summary {
Some(CacheControl {
cache_type: "ephemeral".to_string(),
})
} else {
None
},
};
let pinned_messages = messages
.iter()
.enumerate()
.filter_map(|(idx, msg)| plan.pinned_indices.contains(&idx).then_some(msg.clone()))
.collect();
Ok((
sanitize_retained_messages(pinned_messages),
Some(SystemPrompt::Blocks(vec![summary_block])),
Vec::new(),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SummaryInputRung {
Full,
Lossy,
Extreme,
}
async fn create_summary_with_ladder(
client: &dyn ModelClient,
messages: &[Message],
model: &str,
effective_context_window: Option<u32>,
focus: Option<&str>,
runtime_cost_owner: Option<&str>,
) -> Result<String> {
let rungs = [
SummaryInputRung::Full,
SummaryInputRung::Lossy,
SummaryInputRung::Extreme,
];
let mut last_err: Option<anyhow::Error> = None;
for (idx, rung) in rungs.iter().enumerate() {
match create_summary(
client,
messages,
model,
effective_context_window,
focus,
runtime_cost_owner,
*rung,
)
.await
{
Ok(summary) if is_degenerate_summary(&summary) => {
logging::warn(format!(
"Compaction summary rung {rung:?} produced degenerate output \
({} chars); retrying next ladder rung",
summary.chars().count()
));
if idx + 1 < rungs.len() {
last_err = Some(anyhow::anyhow!(
"degenerate compaction summary ({} chars)",
summary.chars().count()
));
continue;
}
match create_summary(
client,
messages,
model,
effective_context_window,
focus,
runtime_cost_owner,
SummaryInputRung::Extreme,
)
.await
{
Ok(retry) if !is_degenerate_summary(&retry) => return Ok(retry),
Ok(retry) if !retry.trim().is_empty() => {
logging::warn(
"Compaction summary still thin after ladder; shipping best effort",
);
return Ok(retry);
}
Ok(_) => {
return Err(anyhow::anyhow!(
"compaction summary empty after failure ladder"
));
}
Err(err) => return Err(err),
}
}
Ok(summary) => return Ok(summary),
Err(err) => {
let kind = classify_compaction_failure(&err);
if kind.allows_input_ladder() && idx + 1 < rungs.len() {
logging::warn(format!(
"Compaction summary rung {rung:?} hit context overflow ({err}); \
retrying smaller input ladder rung"
));
last_err = Some(err);
continue;
}
if kind.is_transient() && idx + 1 < rungs.len() {
last_err = Some(err);
continue;
}
return Err(err);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("compaction summary ladder exhausted")))
}
fn is_degenerate_summary(summary: &str) -> bool {
let trimmed = summary.trim();
if trimmed.is_empty() {
return true;
}
let seed_chars = trimmed.chars().filter(|c| !c.is_whitespace()).count();
if seed_chars < MIN_SUMMARY_SEED_CHARS {
return true;
}
let lower = trimmed.to_ascii_lowercase();
!DEGENERATE_SUMMARY_REQUIRED_MARKERS
.iter()
.any(|marker| lower.contains(&marker.to_ascii_lowercase()))
}
async fn create_summary(
client: &dyn ModelClient,
messages: &[Message],
model: &str,
effective_context_window: Option<u32>,
focus: Option<&str>,
runtime_cost_owner: Option<&str>,
rung: SummaryInputRung,
) -> Result<String> {
let mut limits = summary_input_limits_for_model(model, effective_context_window);
match rung {
SummaryInputRung::Full => {}
SummaryInputRung::Lossy => {
limits.input_max_chars /= 2;
limits.input_head_chars /= 2;
limits.input_tail_chars /= 2;
limits.text_snippet_chars /= 2;
limits.tool_result_snippet_chars /= 2;
}
SummaryInputRung::Extreme => {
limits.input_max_chars = (limits.input_max_chars / 4).max(4_000);
limits.input_head_chars = (limits.input_head_chars / 4).max(2_000);
limits.input_tail_chars = (limits.input_tail_chars / 4).max(1_500);
limits.text_snippet_chars = (limits.text_snippet_chars / 4).max(200);
limits.tool_result_snippet_chars = (limits.tool_result_snippet_chars / 4).max(120);
}
}
let used_cache_aligned = matches!(rung, SummaryInputRung::Full)
&& should_use_cache_aligned_summary(model, effective_context_window, messages);
let request = if used_cache_aligned {
build_cache_aligned_summary_request(model, messages, limits, focus)
} else {
build_formatted_summary_request(model, messages, limits, focus)
};
let cost_scope = crate::cost_status::scope_token();
let mut cost_route = client.effective_route_envelope(model, chrono::Utc::now());
let mut telemetry_cache_aligned = used_cache_aligned;
let response = match client.create_message(request).await {
Ok(response) => response,
Err(err) if used_cache_aligned => {
logging::warn(format!(
"Cache-aligned compaction summary failed ({err}); retrying with \
bounded formatted summary input"
));
telemetry_cache_aligned = false;
let fallback_request = build_formatted_summary_request(model, messages, limits, focus);
cost_route = client.effective_route_envelope(model, chrono::Utc::now());
client.create_message(fallback_request).await?
}
Err(err) => return Err(err),
};
crate::cost_status::report_effective_route_for_runtime(
cost_scope,
runtime_cost_owner,
&format!(
"compaction:dispatch:{}:response:{}",
cost_route
.dispatched_at
.timestamp_nanos_opt()
.unwrap_or_default(),
response.id
),
&cost_route,
&response.usage,
);
log_summary_cache_telemetry(telemetry_cache_aligned, &response.usage);
let summary = response
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
Ok(summary)
}
pub fn format_live_state_reminder(state: &CompactionLiveState) -> String {
if state.is_empty() {
return String::new();
}
let mut out = String::from(
"## 🔄 Live State (post-compact rehydrate)\n\n\
These facts come from the live runtime, not the summary model. Trust them over prose guesses.\n\n",
);
if let Some(mode) = state.mode.as_deref() {
let _ = writeln!(out, "- Mode: `{mode}`");
}
if let Some(posture) = state.permission_posture.as_deref() {
let _ = writeln!(out, "- Permission posture: `{posture}`");
}
if !state.background_shells.is_empty() {
out.push_str("\n### Running background shells\n");
for line in &state.background_shells {
let _ = writeln!(out, "- {line}");
}
}
if !state.running_workers.is_empty() {
out.push_str("\n### Running workers / sub-agents\n");
for line in &state.running_workers {
let _ = writeln!(out, "- {line}");
}
}
if !state.open_approvals.is_empty() {
out.push_str("\n### Open approvals\n");
for line in &state.open_approvals {
let _ = writeln!(out, "- {line}");
}
}
out.push_str("\n---\n\n");
out
}
fn project_instructions_section(workspace: Option<&Path>) -> String {
let Some(ws) = workspace else {
return String::new();
};
const CANDIDATES: &[&str] = &["AGENTS.md", "CLAUDE.md", "Claude.md"];
for name in CANDIDATES {
let path = ws.join(name);
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let trimmed = content.trim();
if trimmed.is_empty() {
continue;
}
const MAX_CHARS: usize = 12_000;
let body = if trimmed.chars().count() > MAX_CHARS {
let head: String = trimmed.chars().take(MAX_CHARS).collect();
format!("{head}\n\n[… project instructions truncated for compaction budget …]")
} else {
trimmed.to_string()
};
return format!(
"## 📜 Project instructions (verbatim rehydrate)\n\n\
<project_instructions source=\"{name}\">\n{body}\n</project_instructions>\n\n\
---\n\n"
);
}
String::new()
}
#[cfg(test)]
fn is_context_window_error(e: &anyhow::Error) -> bool {
let text = e.to_string();
if crate::error_taxonomy::classify_error_message(&text)
!= crate::error_taxonomy::ErrorCategory::InvalidInput
{
return false;
}
let lower = text.to_lowercase();
lower.contains("context")
|| lower.contains("token")
|| lower.contains("prompt is too long")
|| lower.contains("requested")
|| lower.contains("maximum")
}
fn summary_cache_hit_percent(cache_hit: u32, input_tokens: u32) -> f64 {
if input_tokens > 0 {
(f64::from(cache_hit) * 100.0) / f64::from(input_tokens)
} else {
0.0
}
}
fn log_summary_cache_telemetry(used_cache_aligned: bool, usage: &crate::models::Usage) {
let path = if used_cache_aligned {
"cache_aligned"
} else {
"fallback"
};
let cache_hit = usage.prompt_cache_hit_tokens.unwrap_or(0);
let cache_miss = usage.prompt_cache_miss_tokens.unwrap_or(0);
let cache_hit_pct = summary_cache_hit_percent(cache_hit, usage.input_tokens);
tracing::debug!(
target: "compaction",
"compaction summary call: path={} prompt_tokens={} cache_hit_tokens={} cache_miss_tokens={} cache_hit_pct={:.1}",
path,
usage.input_tokens,
cache_hit,
cache_miss,
cache_hit_pct,
);
}
fn should_use_cache_aligned_summary(
model: &str,
effective_context_window: Option<u32>,
messages: &[Message],
) -> bool {
let Some(window) = effective_context_window.or_else(|| context_window_for_model(model)) else {
return false;
};
if window < LARGE_CONTEXT_WINDOW_TOKENS {
return false;
}
let budget = usize::try_from(window).unwrap_or(usize::MAX)
* CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT
/ 100;
let summary_prompt_tokens = 512usize;
estimate_tokens(messages).saturating_add(summary_prompt_tokens) <= budget
}
fn summary_instruction(word_limit: usize, focus: Option<&str>) -> String {
let mut instruction = format!(
"Produce a successor briefing for the agent that will continue this session after \
compaction. Structure it with exactly these numbered sections (write \"None\" when a \
section is empty):\n\
1. Primary request and intent — what the user is ultimately asking for, in their terms.\n\
2. Key technical concepts — systems, APIs, and domain facts the successor must know.\n\
3. Files and code sections — exact paths, with the important identifiers or snippets per file.\n\
4. Errors and fixes — each error hit, its cause, and how (or whether) it was fixed.\n\
5. Problem solving — approaches tried, decisions made, and why alternatives were rejected.\n\
6. User messages — every non-tool user instruction, condensed but none omitted.\n\
7. Pending tasks — work explicitly requested but not yet done.\n\
8. Current work — precisely what was in flight when compaction hit.\n\
9. Next step — only if one is directly implied; ground it in a short verbatim quote from \
the most recent work.\n\
If the conversation already contains an earlier compaction summary, treat it as \
authoritative for the history it covers and carry its facts forward. Preserve exact \
file paths, commands, and tool-result facts; abbreviate tool outputs only when they \
are repetitive. Do not call tools. Keep the whole briefing under {word_limit} words."
);
if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
let _ = write!(
instruction,
"\n\nThe user asked this compaction to focus on: {focus}"
);
}
instruction
}
fn build_cache_aligned_summary_request(
model: &str,
messages: &[Message],
limits: SummaryInputLimits,
focus: Option<&str>,
) -> MessageRequest {
let mut request_messages = messages.to_vec();
request_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: summary_instruction(limits.word_limit, focus),
cache_control: None,
}],
});
MessageRequest {
model: model.to_string(),
messages: request_messages,
max_tokens: limits.max_tokens,
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: Some(0.3),
top_p: None,
}
}
fn build_formatted_summary_request(
model: &str,
messages: &[Message],
limits: SummaryInputLimits,
focus: Option<&str>,
) -> MessageRequest {
let mut conversation_text = String::new();
for msg in messages {
let role = if msg.role == "user" {
"User"
} else {
"Assistant"
};
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
let snippet = truncate_chars(text, limits.text_snippet_chars);
let _ = write!(conversation_text, "{role}: {snippet}\n\n");
}
ContentBlock::ToolUse { name, .. } => {
let _ = write!(conversation_text, "{role}: [Used tool: {name}]\n\n");
}
ContentBlock::ToolResult { content, .. } => {
let snippet = truncate_chars(content, limits.tool_result_snippet_chars);
let _ = write!(conversation_text, "Tool result: {snippet}\n\n");
}
ContentBlock::Thinking { .. } => {
}
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => {}
}
}
}
let conversation_chars = conversation_text.chars().count();
if conversation_chars > limits.input_max_chars {
let head = truncate_chars(&conversation_text, limits.input_head_chars).to_string();
let tail = tail_chars(&conversation_text, limits.input_tail_chars);
let omitted = conversation_chars
.saturating_sub(head.chars().count())
.saturating_sub(tail.chars().count());
conversation_text =
format!("{head}\n\n[... {omitted} characters omitted before summary ...]\n\n{tail}");
}
MessageRequest {
model: model.to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: format!(
"{}\n\n---\n\n{conversation_text}",
summary_instruction(limits.word_limit, focus)
),
cache_control: None,
}],
}],
max_tokens: limits.max_tokens,
system: Some(SystemPrompt::Text(
"You are a helpful assistant that creates concise conversation summaries.".to_string(),
)),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: Some(0.3),
top_p: None,
}
}
fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> String {
let mut files_touched: Vec<String> = Vec::new();
let mut tools_used: Vec<String> = Vec::new();
let mut tasks_identified: Vec<String> = Vec::new();
for msg in messages {
for block in &msg.content {
match block {
ContentBlock::ToolUse { name, input, .. } => {
tools_used.push(name.clone());
if let Some(path) = extract_path_from_input(input)
&& !files_touched.contains(&path)
{
files_touched.push(path);
}
}
ContentBlock::Text { text, .. }
if (text.contains("TODO") || text.contains("task") || text.contains("need to")) => {
let task = truncate_chars(text, 200).to_string();
if !tasks_identified.contains(&task) {
tasks_identified.push(task);
}
}
_ => {}
}
}
}
let mut context = String::new();
if !files_touched.is_empty() {
context.push_str("**Files Modified/Read:**\n");
for file in &files_touched {
if let Some(ws) = workspace {
let relative = Path::new(file)
.strip_prefix(ws)
.unwrap_or(Path::new(file))
.display();
context.push_str(&format!("- `{relative}`\n"));
} else {
context.push_str(&format!("- `{file}`\n"));
}
}
context.push('\n');
}
if !tools_used.is_empty() {
context.push_str("**Tools Used:** ");
context.push_str(&tools_used.join(", "));
context.push_str("\n\n");
}
if !tasks_identified.is_empty() {
context.push_str("**Tasks/TODOs Identified:**\n");
for task in &tasks_identified {
context.push_str(&format!("- {task}\n"));
}
context.push('\n');
}
if context.is_empty() {
context.push_str("No specific workflow context detected. Continue assisting the user with their current task.\n");
}
context
}
fn extract_path_from_input(input: &serde_json::Value) -> Option<String> {
for key in ["path", "file", "file_path", "filename"] {
if let Some(path) = input.get(key).and_then(|v| v.as_str()) {
return Some(path.to_string());
}
}
if let Some(obj) = input.as_object() {
for (_, value) in obj {
if let Some(path) = value.as_str()
&& (path.contains('/') || path.contains('\\') || path.contains('.'))
{
return Some(path.to_string());
}
}
}
None
}
pub fn merge_system_prompts(
original: Option<&SystemPrompt>,
summary: Option<SystemPrompt>,
) -> Option<SystemPrompt> {
match (original, summary) {
(None, None) => None,
(Some(orig), None) => Some(orig.clone()),
(None, Some(sum)) => Some(sum),
(Some(SystemPrompt::Text(orig_text)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
sum_blocks.insert(
0,
SystemBlock {
block_type: "text".to_string(),
text: orig_text.clone(),
cache_control: None,
},
);
Some(SystemPrompt::Blocks(sum_blocks))
}
(Some(SystemPrompt::Blocks(orig_blocks)), Some(SystemPrompt::Blocks(mut sum_blocks))) => {
for (i, block) in orig_blocks.iter().enumerate() {
sum_blocks.insert(i, block.clone());
}
Some(SystemPrompt::Blocks(sum_blocks))
}
(Some(orig), Some(SystemPrompt::Text(sum_text))) => {
let mut blocks = match orig {
SystemPrompt::Text(t) => vec![SystemBlock {
block_type: "text".to_string(),
text: t.clone(),
cache_control: None,
}],
SystemPrompt::Blocks(b) => b.clone(),
};
blocks.push(SystemBlock {
block_type: "text".to_string(),
text: sum_text,
cache_control: None,
});
Some(SystemPrompt::Blocks(blocks))
}
}
}
#[cfg(test)]
mod tests {
use crate::models::{ImageUrlContent, Message};
#[test]
fn inline_image_estimates_nonzero_tokens() {
let msg = Message {
role: "user".to_string(),
content: vec![ContentBlock::ImageUrl {
image_url: ImageUrlContent {
url: "data:image/png;base64,AAAA".to_string(),
},
}],
};
assert!(
estimate_tokens_for_message(&msg, false) >= IMAGE_TOKEN_ESTIMATE,
"an inline image must not estimate to 0 tokens"
);
}
use super::*;
use serde_json::json;
fn msg(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
caller: None,
}],
}
}
fn tool_result(id: &str, content: &str) -> Message {
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
content_blocks: None,
}],
}
}
#[test]
fn anchor_summary_section_is_empty_without_workspace_or_file() {
assert!(anchor_summary_section(None).is_empty());
let tmpdir = tempfile::TempDir::new().unwrap();
assert!(anchor_summary_section(Some(tmpdir.path())).is_empty());
}
#[test]
fn anchor_summary_section_parses_anchor_file_into_bullets() {
let tmpdir = tempfile::TempDir::new().unwrap();
let deepseek_dir = tmpdir.path().join(".deepseek");
std::fs::create_dir_all(&deepseek_dir).unwrap();
std::fs::write(
deepseek_dir.join("anchors.md"),
"\n---\nDo not touch .ssh\n---\nStatus field is unreliable\n",
)
.unwrap();
let section = anchor_summary_section(Some(tmpdir.path()));
assert!(section.contains("## Pinned Facts (User Anchors)"));
assert!(section.contains("- Do not touch .ssh\n"));
assert!(section.contains("- Status field is unreliable\n"));
assert!(!section.contains("\n---\nDo not touch"));
}
#[test]
fn truncate_chars_respects_unicode_boundaries() {
let text = "abc😀é";
assert_eq!(truncate_chars(text, 0), "");
assert_eq!(truncate_chars(text, 1), "a");
assert_eq!(truncate_chars(text, 3), "abc");
assert_eq!(truncate_chars(text, 4), "abc😀");
assert_eq!(truncate_chars(text, 5), "abc😀é");
}
#[test]
fn prune_tool_results_summarizes_old_verbose_outputs() {
let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &verbose),
msg("user", "recent question"),
msg("assistant", "recent answer"),
];
let saved = prune_tool_results(&mut messages, 2);
assert!(saved > 0);
let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else {
panic!("expected tool result");
};
assert!(content.contains("[read_file] tool result pruned"));
assert!(content.contains("Cargo.toml"));
assert!(content.len() < verbose.len());
}
#[test]
fn prune_tool_results_preserves_protected_tail() {
let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
let mut messages = vec![
msg("user", "older context"),
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &verbose),
];
let saved = prune_tool_results(&mut messages, 2);
assert_eq!(saved, 0);
let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else {
panic!("expected tool result");
};
assert_eq!(content, &verbose);
}
#[test]
fn compaction_failure_names_plan_exhaustion_behind_auth_prefix() {
let error = anyhow::anyhow!(
"[auth] Authorization failed: You've reached your usage limit for this billing cycle"
);
let message = report_compaction_failure(
"Manual context compaction failed",
"compact_fixture",
false,
&error,
);
assert_eq!(
message,
"Manual context compaction failed: provider plan quota exhausted — switch provider/model or renew the provider plan"
);
assert!(!message.contains("Authorization failed"));
}
#[test]
fn compaction_failure_keeps_unknown_diagnostic() {
let error = anyhow::anyhow!("summary response was structurally empty");
assert_eq!(
report_compaction_failure("Auto-compaction failed", "compact_fixture", true, &error,),
"Auto-compaction failed: summary response was structurally empty"
);
}
#[test]
fn prune_tool_results_preserves_prefix_bytes_when_reverse_prune_is_enough() {
let older_verbose = "old ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
let newer_verbose = "new ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
let mut messages = vec![
tool_use("call-old", "read_file", json!({"path": "old.txt"})),
tool_result("call-old", &older_verbose),
tool_use("call-new", "read_file", json!({"path": "new.txt"})),
tool_result("call-new", &newer_verbose),
msg("user", "protected tail"),
];
let original = messages.clone();
let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
assert!(saved > 0);
assert_eq!(&messages[..3], &original[..3]);
assert_eq!(&messages[4..], &original[4..]);
let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
panic!("expected pruned tool result");
};
assert!(content.contains("[read_file] tool result pruned"));
assert!(content.contains("new.txt"));
assert!(content.len() < newer_verbose.len());
}
#[test]
fn prune_tool_results_stops_after_newest_duplicate_prune() {
let oldest = "oldest ".repeat(80);
let middle = "middle ".repeat(80);
let latest = "latest ".repeat(80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &oldest),
tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-2", &middle),
tool_use("call-3", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-3", &latest),
msg("user", "protected tail"),
];
let original = messages.clone();
let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
assert!(saved > 0);
assert_eq!(&messages[..3], &original[..3]);
assert_eq!(&messages[4..], &original[4..]);
let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
panic!("expected middle duplicate to be pruned");
};
assert!(content.contains("[read_file] tool result pruned"));
}
#[test]
fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() {
let first = "first ".repeat(80);
let second = "second ".repeat(80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &first),
tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-2", &second),
msg("user", "tail"),
];
let saved = prune_tool_results(&mut messages, 1);
assert!(saved > 0);
let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else {
panic!("expected older tool result");
};
assert!(older.contains("tool result pruned"));
let ContentBlock::ToolResult {
content: latest, ..
} = &messages[3].content[0]
else {
panic!("expected latest tool result");
};
assert_eq!(latest, &second);
}
#[test]
fn is_transient_error_detects_network_issues() {
let timeout_err = anyhow::anyhow!("Connection timeout");
assert!(is_transient_error(&timeout_err));
let rate_limit_err = anyhow::anyhow!("429 Too Many Requests");
assert!(is_transient_error(&rate_limit_err));
let service_err = anyhow::anyhow!("503 Service Unavailable");
assert!(is_transient_error(&service_err));
let network_err = anyhow::anyhow!("network error: connection refused");
assert!(is_transient_error(&network_err));
}
#[test]
fn is_transient_error_rejects_permanent_errors() {
let auth_err = anyhow::anyhow!("401 Unauthorized: Invalid API key");
assert!(!is_transient_error(&auth_err));
let parse_err = anyhow::anyhow!("Failed to parse JSON response");
assert!(!is_transient_error(&parse_err));
let validation_err = anyhow::anyhow!("Invalid request: missing required field");
assert!(!is_transient_error(&validation_err));
}
#[test]
fn summary_limits_expand_for_v4_context() {
let legacy = summary_input_limits_for_model("deepseek-v3.2-128k", None);
let v4 = summary_input_limits_for_model("deepseek-v4-pro", None);
assert!(v4.input_max_chars > legacy.input_max_chars);
assert!(v4.tool_result_snippet_chars > legacy.tool_result_snippet_chars);
assert!(v4.max_tokens > legacy.max_tokens);
}
#[test]
fn route_effective_window_bounds_same_id_oauth_summary() {
let api = summary_input_limits_for_model("gpt-5.5", None);
let oauth = summary_input_limits_for_model("gpt-5.5", Some(272_000));
let messages = vec![msg("user", "summarize this route")];
assert!(api.input_max_chars > oauth.input_max_chars);
assert!(should_use_cache_aligned_summary("gpt-5.5", None, &messages));
assert!(!should_use_cache_aligned_summary(
"gpt-5.5",
Some(272_000),
&messages
));
}
#[test]
fn cache_aligned_summary_is_used_for_v4_scale_contexts() {
let messages = vec![msg("user", "Please edit crates/tui/src/compaction.rs")];
assert!(should_use_cache_aligned_summary(
"deepseek-v4-flash",
None,
&messages
));
assert!(!should_use_cache_aligned_summary(
"deepseek-v3.2-128k",
None,
&messages
));
}
#[test]
fn summary_cache_hit_percent_uses_input_tokens_as_denominator() {
assert!((summary_cache_hit_percent(800, 1000) - 80.0).abs() < f64::EPSILON);
assert!((summary_cache_hit_percent(0, 1000) - 0.0).abs() < f64::EPSILON);
assert!((summary_cache_hit_percent(1000, 1000) - 100.0).abs() < f64::EPSILON);
assert!((summary_cache_hit_percent(200, 1000) - 20.0).abs() < f64::EPSILON);
assert!((summary_cache_hit_percent(0, 0) - 0.0).abs() < f64::EPSILON);
assert!((summary_cache_hit_percent(50, 0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn context_window_errors_are_detected_for_summary_fallback() {
for msg in [
"HTTP 400 Bad Request: maximum context length is 1000000 tokens",
"invalid_request_error: prompt is too long for the current model",
"You requested 1000001 tokens but the maximum is 1000000",
"request exceeds context window",
] {
assert!(
is_context_window_error(&anyhow::anyhow!(msg)),
"expected context-window detection for `{msg}`",
);
}
assert!(!is_context_window_error(&anyhow::anyhow!(
"Invalid request: missing required field"
)));
assert!(!is_context_window_error(&anyhow::anyhow!(
"503 Service Unavailable"
)));
}
#[test]
fn live_state_reminder_formats_typed_runtime_facts() {
let state = CompactionLiveState {
mode: Some("operate".into()),
permission_posture: Some("Ask".into()),
background_shells: vec!["`sh_1`: `cargo test -p foo`".into()],
running_workers: vec!["`agent_a` (role: implementer) — fix flaky".into()],
open_approvals: vec!["shell: git push".into()],
};
let text = format_live_state_reminder(&state);
assert!(text.contains("Live State"));
assert!(text.contains("operate"));
assert!(text.contains("Ask"));
assert!(text.contains("cargo test"));
assert!(text.contains("agent_a"));
assert!(text.contains("git push"));
assert!(format_live_state_reminder(&CompactionLiveState::default()).is_empty());
}
#[test]
fn project_instructions_section_reinjects_agents_md_verbatim() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("AGENTS.md"),
"# Project rules\n\nNever force-push main.\n",
)
.unwrap();
let section = project_instructions_section(Some(tmp.path()));
assert!(section.contains("Project instructions"));
assert!(section.contains("<project_instructions source=\"AGENTS.md\">"));
assert!(section.contains("Never force-push main."));
assert!(project_instructions_section(None).is_empty());
}
#[test]
fn degenerate_summary_detects_empty_short_and_unstructured() {
assert!(is_degenerate_summary(""));
assert!(is_degenerate_summary(" ok "));
assert!(is_degenerate_summary(
"This is a long enough free-form paragraph that has many characters but no section \
headings at all so the successor cannot recover open items or in-flight edits \
from structure alone."
));
assert!(!is_degenerate_summary(
"1. Primary request and intent — fix the flaky test in auth.\n\
2. Key technical concepts — tokio, race on mutex.\n\
7. Pending tasks — re-run cargo test -p auth.\n\
8. Current work — editing crates/auth/src/lib.rs.\n\
More padding so non-whitespace length clears the seed floor for the ladder."
));
}
#[test]
fn classify_compaction_failure_splits_overflow_transient_and_deterministic() {
let overflow = anyhow::anyhow!("prompt is too long for this model's context window");
assert_eq!(
classify_compaction_failure(&overflow),
CompactionFailureKind::ContextOverflow
);
let network = anyhow::anyhow!("connection timed out contacting api");
let kind = classify_compaction_failure(&network);
assert_ne!(kind, CompactionFailureKind::ContextOverflow);
let auth = anyhow::anyhow!("401 unauthorized: invalid api key");
assert_eq!(
classify_compaction_failure(&auth),
CompactionFailureKind::Deterministic
);
}
#[test]
fn summary_instruction_is_a_structured_successor_brief_with_optional_focus() {
let brief = summary_instruction(500, None);
for section in [
"1. Primary request and intent",
"2. Key technical concepts",
"3. Files and code sections",
"4. Errors and fixes",
"5. Problem solving",
"6. User messages",
"7. Pending tasks",
"8. Current work",
"9. Next step",
] {
assert!(
brief.contains(section),
"missing section {section:?}: {brief}"
);
}
assert!(brief.contains("under 500 words"), "{brief}");
assert!(brief.contains("Do not call tools"), "{brief}");
assert!(brief.contains("earlier compaction summary"), "{brief}");
assert!(!brief.contains("focus on:"), "{brief}");
let focused = summary_instruction(500, Some(" the auth refactor "));
assert!(focused.contains("focus on: the auth refactor"), "{focused}");
let blank = summary_instruction(500, Some(" "));
assert!(!blank.contains("focus on:"), "{blank}");
}
#[test]
fn formatted_summary_request_bounds_large_input() {
let messages = (0..90)
.map(|idx| {
msg(
"user",
&format!("turn {idx}: {}", "中文上下文 ".repeat(1_000)),
)
})
.collect::<Vec<_>>();
let limits = summary_input_limits_for_model("deepseek-v4-pro", None);
let request = build_formatted_summary_request("deepseek-v4-pro", &messages, limits, None);
assert_eq!(request.messages.len(), 1);
let ContentBlock::Text { text, .. } = &request.messages[0].content[0] else {
panic!("expected summary text request");
};
assert!(text.contains("characters omitted before summary"));
assert!(text.chars().count() <= limits.input_max_chars + 2_000);
}
#[test]
fn cache_aligned_summary_request_preserves_message_prefix() {
let messages = vec![
msg("user", "Please edit crates/tui/src/compaction.rs"),
msg("assistant", "I will inspect the file."),
];
let limits = summary_input_limits_for_model("deepseek-v4-pro", None);
let request =
build_cache_aligned_summary_request("deepseek-v4-pro", &messages, limits, None);
assert_eq!(request.system, None);
assert_eq!(&request.messages[..messages.len()], &messages[..]);
assert_eq!(request.messages.len(), messages.len() + 1);
let last = request.messages.last().expect("summary instruction");
assert_eq!(last.role, "user");
assert!(matches!(
&last.content[..],
[ContentBlock::Text { text, .. }] if text.contains("successor briefing")
));
}
#[test]
fn estimate_tokens_empty_messages() {
let messages: Vec<Message> = vec![];
assert_eq!(estimate_tokens(&messages), 0);
}
#[test]
fn estimate_tokens_with_text() {
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello, world!".to_string(), cache_control: None,
}],
}];
let tokens = estimate_tokens(&messages);
assert!(tokens > 0 && tokens < 10);
}
#[test]
fn estimate_tokens_counts_tool_round_thinking_across_turns() {
let thinking = "reasoning ".repeat(800);
let current_messages = vec![
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Use a tool".to_string(),
cache_control: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
signature: None,
thinking: thinking.clone(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "Cargo.toml"}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "manifest".to_string(),
is_error: None,
content_blocks: None,
}],
},
];
let historical_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Next question.".to_string(),
cache_control: None,
}],
});
messages
};
let completed_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages
};
let lower_bound = thinking.len() / 5;
assert!(estimate_tokens(¤t_messages) > lower_bound);
assert!(estimate_tokens(&completed_messages) > lower_bound);
assert!(estimate_tokens(&historical_messages) > lower_bound);
}
#[test]
fn should_compact_respects_enabled_flag() {
let config = CompactionConfig {
enabled: false,
..Default::default()
};
let messages: Vec<Message> = (0..100)
.map(|_| Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "test".to_string(),
cache_control: None,
}],
})
.collect();
assert!(!should_compact(&messages, &config, None, None, None));
}
#[test]
fn message_count_no_longer_triggers_compaction() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1_000_000,
..Default::default()
};
let many_messages: Vec<Message> = (0..200)
.map(|_| Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "x".to_string(),
cache_control: None,
}],
})
.collect();
assert!(!should_compact(&many_messages, &config, None, None, None));
}
#[test]
fn plan_compaction_pins_recent_and_working_set_paths() {
let messages = vec![
msg("user", "General discussion"),
msg("assistant", "Unrelated note"),
msg("user", "Earlier we touched src/core/engine.rs"),
msg("assistant", "More unrelated chatter"),
msg("user", "Let's keep working on src/core/engine.rs"),
msg("assistant", "Tool output mentions src/core/engine.rs too"),
msg("assistant", "Recent reasoning"),
msg("user", "Final recent instruction"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
assert!(plan.pinned_indices.contains(&2));
for idx in 4..messages.len() {
assert!(plan.pinned_indices.contains(&idx));
}
assert!(plan.summarize_indices.contains(&0));
assert!(plan.summarize_indices.contains(&1));
assert!(plan.summarize_indices.contains(&3));
}
#[test]
fn plan_compaction_respects_external_pins() {
let messages = vec![
msg("user", "noise 0"),
msg("assistant", "noise 1"),
msg("user", "noise 2"),
msg("assistant", "noise 3"),
msg("user", "recent 4"),
msg("assistant", "recent 5"),
msg("assistant", "recent 6"),
msg("user", "recent 7"),
];
let pins = vec![1usize];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, Some(&pins), None);
assert!(plan.pinned_indices.contains(&1));
assert!(!plan.summarize_indices.contains(&1));
}
#[test]
fn plan_compaction_uses_external_working_set_paths() {
let mut messages = vec![msg("user", "edit src/core/engine.rs now")];
messages.extend((1..20).map(|i| msg("assistant", &format!("noise {i}"))));
let working_set_paths = vec!["src/core/engine.rs".to_string()];
let plan = plan_compaction(
&messages,
None,
KEEP_RECENT_MESSAGES,
None,
Some(&working_set_paths),
);
assert!(plan.pinned_indices.contains(&0));
}
#[test]
fn plan_compaction_pins_edited_python_typescript_and_go_paths() {
let messages = vec![
msg("user", "start working"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "py-edit".to_string(),
name: "write_file".to_string(),
input: json!({"path": "src/worker.py"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "py-edit".to_string(),
content: "wrote src/worker.py".to_string(),
is_error: None,
content_blocks: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "ts-edit".to_string(),
name: "write_file".to_string(),
input: json!({"path": "web/app.tsx"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "ts-edit".to_string(),
content: "wrote web/app.tsx".to_string(),
is_error: None,
content_blocks: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "go-edit".to_string(),
name: "write_file".to_string(),
input: json!({"path": "cmd/server/main.go"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "go-edit".to_string(),
content: "wrote cmd/server/main.go".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("user", "continue with the next task"),
];
let plan = plan_compaction(&messages, None, 1, None, None);
for idx in [1, 2, 3, 4, 5, 6] {
assert!(
plan.pinned_indices.contains(&idx),
"edited source message {idx} should be pinned"
);
}
}
#[test]
fn plan_compaction_excludes_dependency_build_lock_and_minified_paths() {
let messages = vec![
msg("user", "start working"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "junk-1".to_string(),
name: "write_file".to_string(),
input: json!({"path": "node_modules/pkg/index.js"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "junk-1".to_string(),
content: "wrote node_modules/pkg/index.js".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "target/debug/generated.rs"),
msg("assistant", "dist/app.min.js"),
msg("assistant", "package-lock.json"),
msg("assistant", "workspace.lock"),
msg("user", "continue with the next task"),
];
let plan = plan_compaction(&messages, None, 1, None, None);
for idx in 1..7 {
assert!(
!plan.pinned_indices.contains(&idx),
"junk path message {idx} should not be newly pinned"
);
}
}
#[test]
fn plan_compaction_pins_tool_calls_for_tool_results() {
let messages = vec![
msg("user", "noise"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "src/main.rs"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "ok src/main.rs".to_string(),
is_error: None,
content_blocks: None,
}],
},
];
let plan = plan_compaction(&messages, None, 1, None, None);
assert!(plan.pinned_indices.contains(&2));
assert!(plan.pinned_indices.contains(&1));
}
#[test]
fn should_compact_ignores_fully_pinned_context() {
let config = CompactionConfig {
enabled: true,
token_threshold: 10,
..Default::default()
};
let messages: Vec<Message> = (0..12)
.map(|_| msg("user", "Work on src/compaction.rs right now"))
.collect();
assert!(!should_compact(&messages, &config, None, None, None));
}
#[test]
fn enforce_tool_call_pairs_removes_orphaned_tool_call() {
let messages = vec![
msg("user", "noise"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "orphan-call".to_string(),
name: "read_file".to_string(),
input: json!({"path": "src/main.rs"}),
caller: None,
}],
},
msg("assistant", "recent"),
];
let mut pinned = BTreeSet::from([0, 1, 2]);
enforce_tool_call_pairs(&messages, &mut pinned);
assert!(
!pinned.contains(&1),
"orphaned tool call should be removed from pinned set"
);
assert!(pinned.contains(&0));
assert!(pinned.contains(&2));
}
#[test]
fn enforce_tool_call_pairs_removes_orphaned_tool_result() {
let messages = vec![
msg("user", "noise"),
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "orphan-result".to_string(),
content: "ok".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "recent"),
];
let mut pinned = BTreeSet::from([0, 1, 2]);
enforce_tool_call_pairs(&messages, &mut pinned);
assert!(
!pinned.contains(&1),
"orphaned tool result should be removed from pinned set"
);
assert!(pinned.contains(&0));
assert!(pinned.contains(&2));
}
#[test]
fn enforce_tool_call_pairs_preserves_valid_pairs() {
let messages = vec![
msg("user", "do something"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-ok".to_string(),
name: "list_dir".to_string(),
input: json!({}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-ok".to_string(),
content: "files here".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "done"),
];
let mut pinned = BTreeSet::from([1, 2, 3]);
enforce_tool_call_pairs(&messages, &mut pinned);
assert!(pinned.contains(&1), "tool call should stay pinned");
assert!(pinned.contains(&2), "tool result should stay pinned");
assert!(pinned.contains(&3));
}
#[test]
fn enforce_tool_call_pairs_pins_transitive_pairs() {
let messages = vec![
msg("user", "start"),
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::ToolUse {
id: "t1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "a.rs"}),
caller: None,
},
ContentBlock::ToolUse {
id: "t2".to_string(),
name: "read_file".to_string(),
input: json!({"path": "b.rs"}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
content: "content of a.rs".to_string(),
is_error: None,
content_blocks: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "t2".to_string(),
content: "content of b.rs".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "done"),
];
let mut pinned = BTreeSet::from([2, 4]);
enforce_tool_call_pairs(&messages, &mut pinned);
assert!(
pinned.contains(&1),
"call message should be transitively pinned"
);
assert!(
pinned.contains(&3),
"t2 result should be transitively pinned via the call message"
);
}
#[test]
fn enforce_tool_call_pairs_cascading_removal() {
let messages = vec![
msg("user", "start"),
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::ToolUse {
id: "good".to_string(),
name: "read_file".to_string(),
input: json!({}),
caller: None,
},
ContentBlock::ToolUse {
id: "orphan".to_string(),
name: "shell".to_string(),
input: json!({}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "good".to_string(),
content: "ok".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "done"),
];
let mut pinned = BTreeSet::from([1, 2, 3]);
enforce_tool_call_pairs(&messages, &mut pinned);
assert!(
!pinned.contains(&1),
"message with orphaned call should be removed"
);
assert!(
!pinned.contains(&2),
"result whose call was removed should cascade-remove"
);
assert!(pinned.contains(&3));
}
#[test]
fn enforce_tool_call_pairs_converges_long_chain() {
let mut messages = vec![msg("user", "start")];
for i in 0..15 {
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: format!("t{i}"),
name: "read_file".to_string(),
input: json!({}),
caller: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: format!("t{i}"),
content: format!("result {i}"),
is_error: None,
content_blocks: None,
}],
});
}
messages.push(msg("assistant", "done"));
let mut pinned: BTreeSet<usize> = (0..messages.len()).collect();
enforce_tool_call_pairs(&messages, &mut pinned);
assert_eq!(pinned.len(), messages.len());
}
#[test]
fn plan_compaction_keeps_at_least_one_user_text_query() {
let mut messages = vec![msg(
"user",
"This is the original query that started the chain.",
)];
for i in 0..10 {
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: format!("call-{i}"),
name: "test_tool".to_string(),
input: json!({}),
caller: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: format!("call-{i}"),
content: "tool output".to_string(),
is_error: None,
content_blocks: None,
}],
});
}
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
assert!(plan.pinned_indices.contains(&0));
}
#[test]
fn test_should_compact_token_threshold_triggers() {
let config = CompactionConfig {
enabled: true,
token_threshold: 100, ..Default::default()
};
let messages: Vec<Message> = (0..10)
.map(|_| msg("user", &"x".repeat(50))) .collect();
assert!(should_compact(&messages, &config, None, None, None));
}
#[test]
fn test_should_compact_below_token_threshold() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1000,
..Default::default()
};
let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
assert!(!should_compact(&messages, &config, None, None, None));
}
#[test]
fn auto_compaction_uses_token_threshold_without_fixed_floor() {
let config = CompactionConfig {
enabled: true,
token_threshold: 100,
..Default::default()
};
let messages: Vec<Message> = (0..10).map(|_| msg("user", &"x".repeat(50))).collect();
assert!(should_compact(&messages, &config, None, None, None));
}
#[test]
fn test_plan_compaction_pins_error_messages() {
let messages = vec![
msg("user", "normal message"),
msg("assistant", "error: compilation failed"),
msg("user", "another message"),
msg("assistant", "panic at src/main.rs:42"),
msg("user", "more chat"),
msg("assistant", "Traceback (most recent call last):"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
assert!(plan.pinned_indices.contains(&1)); assert!(plan.pinned_indices.contains(&3)); assert!(plan.pinned_indices.contains(&5)); }
#[test]
fn test_plan_compaction_pins_patch_messages() {
let messages = vec![
msg("user", "normal chat"),
msg("assistant", "diff --git a/src/main.rs b/src/main.rs"),
msg("user", "more chat"),
msg("assistant", "+++ b/src/core.rs"),
msg("user", "chat"),
msg("assistant", "```diff\n-some code\n+new code\n```"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
assert!(plan.pinned_indices.contains(&1)); assert!(plan.pinned_indices.contains(&3)); assert!(plan.pinned_indices.contains(&5)); }
#[test]
fn test_plan_compaction_pins_apply_patch_tool_calls() {
let messages = vec![
msg("user", "normal chat"),
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "patch-1".to_string(),
name: "apply_patch".to_string(),
input: json!({"patch": "diff content"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "patch-1".to_string(),
content: "Patch applied successfully".to_string(),
is_error: None,
content_blocks: None,
}],
},
msg("assistant", "more chat"),
msg("user", "even more"),
msg("assistant", "recent 1"),
msg("user", "recent 2"),
msg("assistant", "recent 3"),
];
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
eprintln!("Pinned indices: {:?}", plan.pinned_indices);
assert!(
plan.pinned_indices.contains(&1),
"apply_patch tool call should be pinned"
);
assert!(
plan.pinned_indices.contains(&2),
"apply_patch tool result should be pinned"
);
}
#[test]
fn test_extract_paths_from_text_finds_various_formats() {
let text = r#"
I'm working on src/main.rs
Also check Cargo.toml
The error is in src/core/engine.rs:42
See docs/API.md for details
Config at config.example.toml
"#;
let paths = extract_paths_from_text(text, None);
assert!(paths.iter().any(|p| p == "src/main.rs"));
assert!(paths.iter().any(|p| p == "Cargo.toml"));
assert!(paths.iter().any(|p| p == "src/core/engine.rs"));
assert!(paths.iter().any(|p| p == "docs/API.md"));
assert!(paths.iter().any(|p| p == "config.example.toml"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_path_field() {
let input = json!({
"path": "src/main.rs",
"content": "test"
});
let paths = extract_paths_from_tool_input(&input, None);
assert!(paths.iter().any(|p| p == "src/main.rs"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_paths_array() {
let input = json!({
"paths": ["src/main.rs", "src/core.rs", "tests/test.rs"]
});
let paths = extract_paths_from_tool_input(&input, None);
assert_eq!(paths.len(), 3);
assert!(paths.iter().any(|p| p == "src/main.rs"));
assert!(paths.iter().any(|p| p == "src/core.rs"));
assert!(paths.iter().any(|p| p == "tests/test.rs"));
}
#[test]
fn test_extract_paths_from_tool_input_finds_cwd() {
let input = json!({
"cwd": "src/core",
"command": "cargo build"
});
let paths = extract_paths_from_tool_input(&input, None);
assert!(paths.iter().any(|p| p == "src/core"));
}
#[test]
fn test_normalize_path_candidate_handles_absolute_paths() {
use std::env;
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let absolute_path = current_dir.join("src/main.rs");
let absolute_path_str = absolute_path.to_string_lossy();
let normalized = normalize_path_candidate(&absolute_path_str, Some(¤t_dir));
assert_eq!(normalized, Some("src/main.rs".to_string()));
}
#[test]
fn test_normalize_path_candidate_rejects_parent_refs() {
let normalized = normalize_path_candidate("../outside/file.rs", Some(&PathBuf::from(".")));
assert_eq!(normalized, None);
}
#[test]
fn test_normalize_path_candidate_cleans_backslashes() {
let normalized = normalize_path_candidate("src\\main.rs", Some(&PathBuf::from(".")));
assert_eq!(normalized, Some("src/main.rs".to_string()));
}
#[test]
fn test_merge_system_prompts_none_none() {
let result = merge_system_prompts(None, None);
assert!(result.is_none());
}
#[test]
fn test_merge_system_prompts_some_text_none() {
let original = Some(SystemPrompt::Text("original".to_string()));
let result = merge_system_prompts(original.as_ref(), None);
assert!(matches!(result, Some(SystemPrompt::Text(s)) if s == "original"));
}
#[test]
fn test_merge_system_prompts_none_some_blocks() {
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(None, summary);
assert!(matches!(result, Some(SystemPrompt::Blocks(b)) if b.len() == 1));
}
#[test]
fn test_merge_system_prompts_text_plus_blocks() {
let original = Some(SystemPrompt::Text("original".to_string()));
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_merge_system_prompts_blocks_plus_blocks() {
let original = Some(SystemPrompt::Blocks(vec![
SystemBlock {
block_type: "text".to_string(),
text: "orig1".to_string(),
cache_control: None,
},
SystemBlock {
block_type: "text".to_string(),
text: "orig2".to_string(),
cache_control: None,
},
]));
let summary = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "summary".to_string(),
cache_control: None,
}]));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "orig1"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "orig2"));
assert!(matches!(&blocks[2], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_merge_system_prompts_blocks_plus_text() {
let original = Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "original".to_string(),
cache_control: None,
}]));
let summary = Some(SystemPrompt::Text("summary".to_string()));
let result = merge_system_prompts(original.as_ref(), summary);
match result {
Some(SystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], SystemBlock { text, .. } if text == "original"));
assert!(matches!(&blocks[1], SystemBlock { text, .. } if text == "summary"));
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_compaction_result_retries_used() {
let result = CompactionResult {
messages: vec![],
summary_prompt: None,
retries_used: 2,
};
assert_eq!(result.retries_used, 2);
assert!(result.messages.is_empty());
}
#[test]
fn test_should_compact_with_workspace_path_detection() {
use std::env;
let workspace = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let _config = CompactionConfig {
enabled: true,
token_threshold: 1000,
..Default::default()
};
let messages = vec![
msg("user", "working on src/main.rs"),
msg("assistant", "noise 1"),
msg("user", "noise 2"),
msg("assistant", "noise 3"),
msg("user", "noise 4"),
msg("assistant", "noise 5"),
msg("user", "recent 1"),
msg("assistant", "recent 2"),
];
let plan = plan_compaction(
&messages,
Some(&workspace),
KEEP_RECENT_MESSAGES,
None,
None,
);
assert!(plan.pinned_indices.contains(&0)); }
}