use serde_json::Value;
use std::pin::Pin;
use std::sync::Arc;
pub type SummarizeFn = Arc<
dyn Fn(String) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>> + Send + Sync,
>;
pub(crate) const COMPACTION_MARKER: &str = "[CONTEXT COMPACTION — REFERENCE ONLY]";
const SUMMARY_PREFIX: &str = "\
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \
into the summary below. This is a handoff from a previous context \
window — treat it as background reference, NOT as active instructions. \
Do NOT answer questions or fulfill requests mentioned in this summary; \
they were already addressed. \
Your current task is identified in the '## Active Task' section of the \
summary — resume exactly from there. \
Respond ONLY to the latest user message \
that appears AFTER this summary. The current session state (files, \
config, etc.) may reflect work described here — avoid repeating it:";
const MIN_SUMMARY_TOKENS: u64 = 2000;
const SUMMARY_RATIO: f64 = 0.20;
const SUMMARY_TOKENS_CEILING: u64 = 12_000;
pub const TURN_END_RESULT_CAP_TOKENS: u64 = 3000;
pub const AGGRESSIVE_CAP_THRESHOLD: f64 = 0.60;
pub const AGGRESSIVE_RESULT_CAP_TOKENS: u64 = 1000;
pub fn tiered_result_cap(estimate_tokens: u64, ctx_max: u64) -> u64 {
let ratio = estimate_tokens as f64 / ctx_max.max(1) as f64;
if ratio > AGGRESSIVE_CAP_THRESHOLD {
AGGRESSIVE_RESULT_CAP_TOKENS
} else {
TURN_END_RESULT_CAP_TOKENS
}
}
pub const SNIP_SUFFICIENT_FRACTION: f64 = 0.10;
pub fn snip_bought_enough(freed: u64, ctx_max: u64, aggressive: bool) -> bool {
!aggressive && (freed as f64 / ctx_max.max(1) as f64) > SNIP_SUFFICIENT_FRACTION
}
const CHARS_PER_TOKEN: u64 = 4;
#[allow(dead_code)]
const MINIMUM_CONTEXT_LENGTH: u64 = 64_000;
pub const PROTECT_HEAD_DEFAULT: usize = 2;
pub const PROTECT_TAIL_DEFAULT: usize = 5;
pub fn should_compress(prompt_tokens: u64, context_window: u64) -> bool {
should_compress_with_threshold(prompt_tokens, context_window, None)
}
pub fn should_compress_with_threshold(
prompt_tokens: u64,
context_window: u64,
fold_threshold_override: Option<f64>,
) -> bool {
use crate::agent::agent_loop::context_manager::effective_fold_threshold;
let threshold =
(effective_fold_threshold(fold_threshold_override) * context_window as f64) as u64;
prompt_tokens > threshold
}
pub fn estimate_messages_tokens(messages: &[Value]) -> u64 {
let total_chars: usize = messages
.iter()
.map(|m| content_chars(m.get("content")))
.sum();
(total_chars as u64).div_ceil(CHARS_PER_TOKEN)
}
fn content_chars(content: Option<&Value>) -> usize {
match content {
Some(Value::String(s)) => s.len(),
Some(Value::Array(blocks)) => blocks.iter().filter_map(text_of_block).map(str::len).sum(),
_ => 0,
}
}
pub fn cap_oversized_tool_results(messages: &[Value], max_tokens: u64) -> Vec<Value> {
let max_chars = max_tokens.saturating_mul(CHARS_PER_TOKEN) as usize;
if max_chars == 0 {
return messages.to_vec();
}
messages
.iter()
.map(|msg| {
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "tool" && role != "toolResult" {
return msg.clone();
}
let Some(content) = msg.get("content") else {
return msg.clone();
};
match content {
Value::String(s) => {
if s.len() <= max_chars {
return msg.clone();
}
let mut new_msg = msg.clone();
new_msg["content"] = Value::String(truncate_with_head_tail(s, max_chars));
new_msg
}
Value::Array(blocks) => {
let total_text_len: usize = blocks
.iter()
.filter_map(text_of_block)
.map(|t| t.len())
.sum();
if total_text_len <= max_chars {
return msg.clone();
}
let text_block_count =
blocks.iter().filter(|b| text_of_block(b).is_some()).count();
let per_block_budget = if text_block_count == 0 {
return msg.clone();
} else {
std::cmp::max(max_chars / text_block_count, MIN_PER_BLOCK_BUDGET)
};
let new_blocks: Vec<Value> = blocks
.iter()
.map(|b| {
let Some(text) = text_of_block(b) else {
return b.clone();
};
if text.len() <= per_block_budget {
return b.clone();
}
let truncated = truncate_with_head_tail(text, per_block_budget);
let mut new_block = b.clone();
new_block["text"] = Value::String(truncated);
new_block
})
.collect();
let mut new_msg = msg.clone();
new_msg["content"] = Value::Array(new_blocks);
new_msg
}
_ => msg.clone(),
}
})
.collect()
}
pub fn cap_oversized_tool_results_counted(
messages: &[Value],
max_tokens: u64,
) -> (Vec<Value>, u64) {
let before = estimate_messages_tokens(messages);
let capped = cap_oversized_tool_results(messages, max_tokens);
let after = estimate_messages_tokens(&capped);
let freed = before.saturating_sub(after);
(capped, freed)
}
pub const POST_COMPACT_MAX_FILES: usize = 5;
pub const POST_COMPACT_MAX_TOKENS_PER_FILE: u64 = 5_000;
pub fn build_post_compact_snapshots(files: &[(std::path::PathBuf, String)]) -> Vec<Value> {
let per_file_chars = (POST_COMPACT_MAX_TOKENS_PER_FILE * CHARS_PER_TOKEN) as usize;
files
.iter()
.take(POST_COMPACT_MAX_FILES)
.map(|(path, content)| {
let body = if content.len() > per_file_chars {
truncate_with_head_tail(content, per_file_chars)
} else {
content.clone()
};
serde_json::json!({
"role": "system",
"content": format!(
"[Post-compaction file snapshot: {}]\n{}",
path.display(),
body
),
})
})
.collect()
}
const MIN_PER_BLOCK_BUDGET: usize = 256;
fn text_of_block(block: &Value) -> Option<&str> {
let obj = block.as_object()?;
if obj.get("type").and_then(|t| t.as_str())? != "text" {
return None;
}
obj.get("text").and_then(|t| t.as_str())
}
fn content_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.filter_map(text_of_block)
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn truncate_with_head_tail(s: &str, max_chars: usize) -> String {
const MARKER_OVERHEAD: usize = 160;
if max_chars <= MARKER_OVERHEAD {
return format!(
"[…truncated {} chars — call the tool with a narrower scope (filter, head, pagination) if you need more…]",
s.len(),
);
}
let content_budget = max_chars - MARKER_OVERHEAD;
let tail_budget = std::cmp::min(1024, content_budget / 10);
let head_budget = content_budget.saturating_sub(tail_budget);
let head_end = crate::text::char_boundary_at_or_before(s, head_budget);
let tail_start = crate::text::char_boundary_at_or_after(s, s.len().saturating_sub(tail_budget));
let head = &s[..head_end];
let tail = &s[tail_start..];
let dropped = s.len().saturating_sub(head.len() + tail.len());
format!(
"{head}\n\n[…truncated {dropped} chars — call the tool with a narrower scope (filter, head, pagination) if you need more…]\n\n{tail}"
)
}
pub fn prune_tool_outputs(messages: &[Value], protect_tail: usize) -> Vec<Value> {
let n = messages.len();
if n <= protect_tail {
return messages.to_vec();
}
let end = n.saturating_sub(protect_tail);
let mut pruned = 0usize;
messages
.iter()
.enumerate()
.map(|(i, msg)| {
if i >= end {
return msg.clone();
}
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "tool" && role != "toolResult" {
return msg.clone();
}
let content = msg.get("content");
let text = content_text(content);
if text.len() <= 500 {
return msg.clone();
}
let tool_name = msg
.get("tool_name")
.or_else(|| msg.get("toolName"))
.and_then(|v| v.as_str())
.unwrap_or("tool");
pruned += 1;
let summary = summarize_tool_result(tool_name, &text);
let mut new_msg = msg.clone();
new_msg["content"] = match content {
Some(Value::Array(_)) => {
Value::Array(vec![serde_json::json!({"type": "text", "text": summary})])
}
_ => Value::String(summary),
};
new_msg
})
.collect()
}
fn fmt_count(n: usize) -> String {
if n < 1000 {
return n.to_string();
}
let s = n.to_string();
let mut result = String::new();
let len = s.len();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result
}
fn summarize_tool_result(tool_name: &str, content: &str) -> String {
let content_len = content.len();
let line_count = content.lines().count();
let clen = fmt_count(content_len);
let lc = line_count;
match tool_name {
"bash" => {
let cmd = content
.lines()
.next()
.map(|l| l.trim_start_matches("$ ").trim_start_matches("> "))
.unwrap_or("?");
let cmd_short = if cmd.len() > 80 {
format!("{}…", &cmd[..77])
} else {
cmd.to_string()
};
format!("[bash] ran `{cmd_short}` -> {lc} lines, {clen} chars")
}
"read" => {
format!("[read] {clen} chars, {lc} lines")
}
"write" => {
format!("[write] wrote {clen} chars")
}
"edit" => {
format!("[edit] patched {clen} chars")
}
"grep" => {
format!("[grep] {lc} matches, {clen} chars")
}
"glob" | "find_files" | "list_dir" => {
let first_line = content.lines().next().unwrap_or("");
format!("[{}] {first_line}", tool_name)
}
"task" | "task_status" => {
format!("[{tool_name}] {clen} chars result")
}
_ => {
let preview: String = content.chars().take(80).collect();
format!(
"[{tool_name}] {preview}{} ({clen} chars)",
if content.len() > 80 { "…" } else { "" }
)
}
}
}
pub fn build_summary_prompt(
turns_to_summarize: &[Value],
summary_budget: u64,
previous_summary: Option<&str>,
focus_topic: Option<&str>,
) -> String {
let _summarizer_preamble = "\
You are a summarization agent creating a context checkpoint. \
Treat the conversation turns below as source material for a \
compact record of prior work. \
Produce only the structured summary; do not add a greeting, \
preamble, or prefix. \
Write the summary in the same language the user was using in the \
conversation — do not translate or switch to English.";
let focus_block: String = match focus_topic.map(|t| t.trim()).filter(|t| !t.is_empty()) {
Some(topic) => format!(
"\n\nFOCUS TOPIC: \"{topic}\"\nThe user has requested that this \
compaction PRIORITISE preserving all information related to the focus \
topic above. For content related to \"{topic}\", include full detail — \
exact values, file paths, command outputs, error messages, and \
decisions. For content NOT related to the focus topic, summarise more \
aggressively (brief one-liners or omit if truly irrelevant). The focus \
topic sections should receive roughly 60-70% of the summary token \
budget. Even for the focus topic, NEVER preserve API keys, tokens, \
passwords, or credentials — use [REDACTED]."
),
None => String::new(),
};
let _template_sections = format!(
"## Active Task\n\
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or\n\
task assignment verbatim — the exact words they used. If multiple tasks\n\
were requested and only some are done, list only the ones NOT yet completed.\n\
If no outstanding task exists, write \"None.\"]\n\
\n\
## Goal\n\
[What the user is trying to accomplish overall]\n\
\n\
## Constraints & Preferences\n\
[User preferences, coding style, constraints, important decisions]\n\
\n\
## Completed Actions\n\
[Numbered list of concrete actions taken — include tool used, target, and outcome.]\n\
\n\
## Active State\n\
[Current working state — directory, branch, modified files, test status]\n\
\n\
## In Progress\n\
[Work currently underway — what was being done when compaction fired]\n\
\n\
## Blocked\n\
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]\n\
\n\
## Key Decisions\n\
[Important technical decisions and WHY they were made]\n\
\n\
## Resolved Questions\n\
[Questions already answered — include the answer]\n\
\n\
## Pending User Asks\n\
[Questions or requests NOT yet answered. If none, write \"None.\"]\n\
\n\
## Relevant Files\n\
[Files read, modified, or created — with brief note on each]\n\
\n\
## Remaining Work\n\
[What remains to be done — framed as context, not instructions]\n\
\n\
## Critical Context\n\
[Specific values, error messages, config details that would be lost\n\
without explicit preservation]\n\
\n\
Target ~{summary_budget} tokens. Be CONCRETE — include file paths,\n\
command outputs, error messages, line numbers, and specific values.\n\
Write only the summary body. Do not include any preamble or prefix."
);
let serialized = serialize_turns_for_summary(turns_to_summarize);
if let Some(prev) = previous_summary {
format!(
"{_summarizer_preamble}\n\n\
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{prev}\n\n\
NEW TURNS TO INCORPORATE:\n{serialized}{focus_block}\n\n\
Update the summary using this exact structure. PRESERVE all existing \
information that is still relevant. CRITICAL: Update \"## Active Task\" \
to reflect the user's most recent unfulfilled request.\n\n\
{_template_sections}"
)
} else {
format!(
"{_summarizer_preamble}\n\n\
Create a structured checkpoint summary for the conversation after earlier \
turns are compacted. The summary should preserve enough detail for \
continuity without re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{serialized}{focus_block}\n\n\
Use this exact structure:\n\n\
{_template_sections}"
)
}
}
fn serialize_turns_for_summary(turns: &[Value]) -> String {
let mut out = String::new();
for (i, turn) in turns.iter().enumerate() {
let role = turn.get("role").and_then(|v| v.as_str()).unwrap_or("?");
let content = turn.get("content").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!("[{i}] {role}: "));
if content.len() > 2000 {
let truncated: String = content.chars().take(2000).collect();
out.push_str(&format!(
"{truncated}… [truncated, {} total chars]\n",
content.len()
));
} else {
out.push_str(content);
out.push('\n');
}
}
out
}
pub fn summary_budget(compressed_tokens: u64) -> u64 {
let ratio_budget = (SUMMARY_RATIO * compressed_tokens as f64) as u64;
ratio_budget.clamp(MIN_SUMMARY_TOKENS, SUMMARY_TOKENS_CEILING)
}
pub fn validate_summary(summary: &str) -> bool {
if summary.is_empty() {
return false;
}
let required = ["Active Task", "Goal", "Completed Actions", "Remaining Work"];
required.iter().any(|s| summary.contains(s))
}
pub fn find_previous_summary(messages: &[Value]) -> Option<(usize, String)> {
messages.iter().enumerate().rev().find_map(|(i, m)| {
let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("");
if role != "system" {
return None;
}
let content = m.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.starts_with(SUMMARY_PREFIX) {
let body = content
.strip_prefix(SUMMARY_PREFIX)
.unwrap_or("")
.trim()
.to_string();
Some((i, body))
} else {
None
}
})
}
pub fn apply_summary(
messages: &[Value],
summary: &str,
compress_start: usize,
compress_end: usize,
) -> Vec<Value> {
let n = messages.len();
let compress_start = compress_start.min(n);
let compress_end = compress_end.min(n).max(compress_start);
let mut out: Vec<Value> =
Vec::with_capacity(n.saturating_sub(compress_end - compress_start) + 1);
for msg in messages.iter().take(compress_start) {
out.push(msg.clone());
}
let summary_msg = serde_json::json!({
"role": "system",
"content": format!("{}{}", SUMMARY_PREFIX, summary),
});
out.push(summary_msg);
for msg in messages.iter().skip(compress_end) {
out.push(msg.clone());
}
out
}
pub fn apply_checkpoint_summary(
messages: &[Value],
summary: &str,
boundary: usize,
) -> Option<(Vec<Value>, usize)> {
let n = messages.len();
if boundary == 0 || boundary > n {
return None;
}
let cut = snap_backward_to_user(messages, boundary);
if cut == 0 {
return None;
}
let out = apply_summary(messages, summary, 0, cut);
Some((out, cut))
}
pub fn compute_compress_window(
messages: &[Value],
protect_head: usize,
protect_tail: usize,
) -> (usize, usize) {
let n = messages.len();
if n < protect_head + protect_tail + 1 {
return (0, 0);
}
let raw_start = protect_head;
let raw_end = n.saturating_sub(protect_tail);
if raw_start >= raw_end {
return (0, 0);
}
let start = snap_forward_to_user(messages, raw_start);
let end = snap_backward_to_user(messages, raw_end);
if start >= end {
return (0, 0);
}
(start, end)
}
fn is_user_msg(msg: &Value) -> bool {
msg.get("role").and_then(|r| r.as_str()) == Some("user")
}
fn snap_forward_to_user(messages: &[Value], idx: usize) -> usize {
let n = messages.len();
let mut i = idx.min(n);
while i < n && !is_user_msg(&messages[i]) {
i += 1;
}
i
}
fn snap_backward_to_user(messages: &[Value], idx: usize) -> usize {
let mut i = idx.min(messages.len().saturating_sub(1));
loop {
if is_user_msg(&messages[i]) {
return i;
}
if i == 0 {
return 0;
}
i -= 1;
}
}
pub fn rotate_session_id() -> String {
format!(
"compacted-{}",
uuid::Uuid::new_v4()
.to_string()
.chars()
.take(8)
.collect::<String>()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_prefix_starts_with_compaction_marker() {
assert!(SUMMARY_PREFIX.starts_with(COMPACTION_MARKER));
}
#[test]
fn tiered_result_cap_switches_at_threshold() {
let ctx = 128_000u64;
assert_eq!(
tiered_result_cap(ctx / 4, ctx), TURN_END_RESULT_CAP_TOKENS
);
assert_eq!(
tiered_result_cap((ctx as f64 * 0.60) as u64, ctx),
TURN_END_RESULT_CAP_TOKENS
);
assert_eq!(
tiered_result_cap((ctx as f64 * 0.70) as u64, ctx),
AGGRESSIVE_RESULT_CAP_TOKENS
);
let _ = tiered_result_cap(100, 0);
}
#[test]
fn snip_bought_enough_gates_normal_folds_only() {
let ctx = 128_000u64;
assert!(snip_bought_enough((ctx as f64 * 0.11) as u64, ctx, false));
assert!(!snip_bought_enough((ctx as f64 * 0.05) as u64, ctx, false));
assert!(!snip_bought_enough(ctx, ctx, true));
assert!(!snip_bought_enough(0, 0, false));
}
#[test]
fn cap_counted_reports_freed_tokens() {
let big = "x".repeat(40_000); let msgs = vec![serde_json::json!({"role": "tool", "content": big})];
let (capped, freed) = cap_oversized_tool_results_counted(&msgs, 1000);
assert_eq!(capped, cap_oversized_tool_results(&msgs, 1000));
assert!(
freed > 5_000,
"expected substantial freed tokens, got {freed}"
);
let small = vec![serde_json::json!({"role": "tool", "content": "ok"})];
let (_, freed0) = cap_oversized_tool_results_counted(&small, 1000);
assert_eq!(freed0, 0);
}
#[test]
fn build_post_compact_snapshots_caps_count_and_truncates() {
use std::path::PathBuf;
let files: Vec<(PathBuf, String)> = (0..8)
.map(|i| (PathBuf::from(format!("f{i}.rs")), "x".repeat(100)))
.collect();
let snaps = build_post_compact_snapshots(&files);
assert_eq!(snaps.len(), POST_COMPACT_MAX_FILES, "capped at MAX_FILES");
for (i, s) in snaps.iter().enumerate() {
assert_eq!(s["role"], "system");
let c = s["content"].as_str().unwrap();
assert!(
c.contains(&format!("[Post-compaction file snapshot: f{i}.rs]")),
"snapshot marker + path missing: {c}"
);
}
let per_file_chars = (POST_COMPACT_MAX_TOKENS_PER_FILE * CHARS_PER_TOKEN) as usize;
let big = (PathBuf::from("big.rs"), "y".repeat(per_file_chars * 4));
let snaps = build_post_compact_snapshots(std::slice::from_ref(&big));
let c = snaps[0]["content"].as_str().unwrap();
assert!(
c.len() < per_file_chars + 1_000,
"oversized file must be truncated to ~budget; got {} chars",
c.len()
);
}
#[test]
fn below_75pct_no_compress() {
assert!(!should_compress(50_000, 128_000));
}
#[test]
fn at_threshold_no_compress() {
assert!(!should_compress(96_000, 128_000));
}
#[test]
fn above_threshold_compress() {
assert!(should_compress(96_001, 128_000));
}
#[test]
fn should_compress_tracks_history_fold_threshold() {
use crate::agent::agent_loop::context_manager::HISTORY_FOLD_THRESHOLD;
let win = 200_000u64;
let at = (HISTORY_FOLD_THRESHOLD * win as f64) as u64;
assert!(!should_compress(at, win)); assert!(should_compress(at + 1, win)); }
#[test]
fn exactly_at_threshold_edge() {
assert!(!should_compress(96_000, 128_000));
assert!(should_compress(96_001, 128_000));
}
#[test]
fn budget_minimum() {
assert_eq!(summary_budget(1_000), MIN_SUMMARY_TOKENS);
}
#[test]
fn budget_proportional() {
assert_eq!(summary_budget(50_000), 10_000);
}
#[test]
fn budget_ceiling() {
assert_eq!(summary_budget(500_000), SUMMARY_TOKENS_CEILING);
}
#[test]
fn budget_clamp() {
assert_eq!(summary_budget(0), MIN_SUMMARY_TOKENS);
assert_eq!(summary_budget(1_000_000), SUMMARY_TOKENS_CEILING);
}
#[test]
fn prune_large_tool_results() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({"role": "assistant", "content": "hi"}),
serde_json::json!({"role": "tool", "content": "x".repeat(1000), "tool_name": "read"}),
serde_json::json!({"role": "tool", "content": "small", "tool_name": "grep"}),
serde_json::json!({"role": "user", "content": "tail"}),
];
let pruned = prune_tool_outputs(&msgs, 2);
let tool1 = &pruned[2];
assert!(tool1["content"].as_str().unwrap().contains("[read]"));
assert!(!tool1["content"].as_str().unwrap().contains("xxxxx"));
assert_eq!(pruned[3]["content"].as_str().unwrap(), "small");
assert_eq!(pruned[4]["content"].as_str().unwrap(), "tail");
}
#[test]
fn prune_handles_tool_result_role_and_camelcase_toolname() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({"role": "toolResult", "content": "x".repeat(1000), "toolName": "bash"}),
serde_json::json!({"role": "toolResult", "content": "small", "toolName": "grep"}),
serde_json::json!({"role": "user", "content": "tail"}),
];
let pruned = prune_tool_outputs(&msgs, 2);
let summary = pruned[1]["content"].as_str().unwrap();
assert!(
summary.contains("[bash]"),
"should summarize bash tool result: {summary}"
);
assert!(
summary.len() < 500,
"summary should be under 500 chars: {}",
summary.len()
);
assert_eq!(pruned[2]["content"].as_str().unwrap(), "small");
}
#[test]
fn prune_handles_block_array_content() {
let big = "y".repeat(1000);
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({
"role": "toolResult",
"toolName": "read",
"content": [{"type": "text", "text": big}],
}),
serde_json::json!({"role": "user", "content": "tail"}),
];
let pruned = prune_tool_outputs(&msgs, 1);
let content = &pruned[1]["content"];
let blocks = content.as_array().expect("content stays a block array");
assert_eq!(blocks.len(), 1);
let text = blocks[0]["text"].as_str().unwrap();
assert!(text.contains("[read]"), "summarized: {text}");
assert!(!text.contains("yyyyy"), "raw content dropped: {text}");
assert!(text.len() < 500);
}
#[test]
fn prune_leaves_small_block_array_untouched() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({
"role": "toolResult",
"toolName": "grep",
"content": [{"type": "text", "text": "two matches"}],
}),
serde_json::json!({"role": "user", "content": "tail"}),
];
let pruned = prune_tool_outputs(&msgs, 1);
assert_eq!(pruned[1], msgs[1], "small block-array result is unchanged");
}
#[test]
fn cap_passes_small_tool_results_through() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({"role": "toolResult", "content": "tiny", "toolName": "read"}),
];
let capped = cap_oversized_tool_results(&msgs, 1000);
assert_eq!(capped, msgs, "no message should change under the cap");
}
#[test]
fn cap_truncates_oversized_tool_result_with_head_tail_marker() {
let big = "x".repeat(40_000);
let msgs = vec![
serde_json::json!({"role": "toolResult", "content": big.clone(), "toolName": "read"}),
];
let capped = cap_oversized_tool_results(&msgs, 100);
let content = capped[0]["content"].as_str().unwrap();
assert!(
content.len() < big.len(),
"capped content must be shorter: got {} vs {}",
content.len(),
big.len(),
);
assert!(
content.contains("truncated"),
"must mention truncation: {content:?}",
);
assert!(content.starts_with('x'), "head preserved: {content:?}");
assert!(content.ends_with('x'), "tail preserved: {content:?}");
}
#[test]
fn cap_handles_both_tool_role_shapes() {
let big = "y".repeat(40_000);
let msgs = vec![
serde_json::json!({"role": "tool", "content": big.clone(), "tool_name": "bash"}),
serde_json::json!({"role": "toolResult", "content": big.clone(), "toolName": "bash"}),
];
let capped = cap_oversized_tool_results(&msgs, 100);
for (i, msg) in capped.iter().enumerate() {
let content = msg["content"].as_str().unwrap();
assert!(
content.len() < big.len(),
"message {i} must be capped: len={}",
content.len()
);
assert!(content.contains("truncated"), "message {i} missing marker");
}
}
#[test]
fn cap_never_touches_non_tool_messages() {
let big = "z".repeat(40_000);
let msgs = vec![
serde_json::json!({"role": "user", "content": big.clone()}),
serde_json::json!({"role": "assistant", "content": big.clone()}),
serde_json::json!({"role": "system", "content": big.clone()}),
];
let capped = cap_oversized_tool_results(&msgs, 100);
assert_eq!(capped, msgs, "non-tool messages must pass through verbatim");
}
#[test]
fn cap_is_idempotent_on_already_capped_results() {
let big = "a".repeat(40_000);
let msgs =
vec![serde_json::json!({"role": "toolResult", "content": big, "toolName": "read"})];
let first = cap_oversized_tool_results(&msgs, 100);
let second = cap_oversized_tool_results(&first, 100);
assert_eq!(
first, second,
"second pass must produce no change: first={first:?} second={second:?}",
);
}
#[test]
fn cap_applies_to_every_position_including_last() {
let big = "b".repeat(40_000);
let msgs = vec![
serde_json::json!({"role": "toolResult", "content": big.clone(), "toolName": "read"}),
serde_json::json!({"role": "user", "content": "next"}),
serde_json::json!({"role": "toolResult", "content": big.clone(), "toolName": "read"}),
];
let capped = cap_oversized_tool_results(&msgs, 100);
for i in [0, 2] {
let content = capped[i]["content"].as_str().unwrap();
assert!(
content.len() < big.len(),
"tool result at index {i} must be capped",
);
}
}
#[test]
fn cap_truncates_borderline_oversized_content() {
let content = "c".repeat(250);
let msgs =
vec![serde_json::json!({"role": "toolResult", "content": content, "toolName": "read"})];
let capped = cap_oversized_tool_results(&msgs, 50);
let s = capped[0]["content"].as_str().unwrap();
assert!(
s.contains("truncated"),
"borderline content must trigger cap: {s}"
);
}
#[test]
fn cap_truncates_oversized_text_inside_block_array() {
let big = "d".repeat(40_000);
let msgs = vec![serde_json::json!({
"role": "toolResult",
"content": [{"type": "text", "text": big}],
"toolName": "read",
})];
let capped = cap_oversized_tool_results(&msgs, 100);
let text = capped[0]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() < 40_000,
"block text must be capped: {}",
text.len()
);
assert!(
text.contains("truncated"),
"marker must be present: {text:?}"
);
assert_eq!(capped[0]["content"][0]["type"].as_str(), Some("text"));
}
#[test]
fn cap_handles_multi_block_content_with_mixed_types() {
let big = "e".repeat(20_000);
let msgs = vec![serde_json::json!({
"role": "toolResult",
"content": [
{"type": "text", "text": big.clone()},
{"type": "image", "source": "ignored"},
{"type": "text", "text": big.clone()},
],
"toolName": "bash",
})];
let capped = cap_oversized_tool_results(&msgs, 500);
let blocks = capped[0]["content"].as_array().unwrap();
assert_eq!(blocks[1]["type"].as_str(), Some("image"));
assert_eq!(blocks[1]["source"].as_str(), Some("ignored"));
assert!(blocks[0]["text"].as_str().unwrap().len() < 20_000);
assert!(blocks[2]["text"].as_str().unwrap().len() < 20_000);
}
#[test]
fn cap_passes_small_block_arrays_through() {
let msgs = vec![serde_json::json!({
"role": "toolResult",
"content": [{"type": "text", "text": "small"}],
"toolName": "read",
})];
let capped = cap_oversized_tool_results(&msgs, 100);
assert_eq!(capped, msgs);
}
#[test]
fn prune_protects_tail() {
let msgs = vec![
serde_json::json!({"role": "tool", "content": "x".repeat(1000), "tool_name": "bash"}),
serde_json::json!({"role": "tool", "content": "y".repeat(1000), "tool_name": "read"}),
serde_json::json!({"role": "user", "content": "protected"}),
serde_json::json!({"role": "assistant", "content": "protected"}),
];
let pruned = prune_tool_outputs(&msgs, 3);
assert!(pruned[0]["content"].as_str().unwrap().contains("[bash]"));
assert!(pruned[1]["content"].as_str().unwrap().contains("yyyy"));
}
#[test]
fn estimate_tokens_from_content() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello world"}),
serde_json::json!({"role": "assistant", "content": "0123456789012345"}),
];
assert_eq!(estimate_messages_tokens(&msgs), 7);
}
#[test]
fn estimate_tokens_handles_missing_content() {
let msgs = vec![serde_json::json!({"role": "system"})];
assert_eq!(estimate_messages_tokens(&msgs), 0);
}
#[test]
fn estimate_tokens_counts_text_inside_block_arrays() {
let big = "x".repeat(40);
let msgs = vec![serde_json::json!({
"role": "toolResult",
"content": [{"type": "text", "text": big.clone()}],
"toolName": "read",
})];
assert_eq!(estimate_messages_tokens(&msgs), 10);
}
#[test]
fn estimate_tokens_sums_multi_block_skipping_non_text() {
let msgs = vec![serde_json::json!({
"role": "toolResult",
"content": [
{"type": "text", "text": "a".repeat(20)},
{"type": "image", "source": "ignored"},
{"type": "text", "text": "b".repeat(20)},
],
"toolName": "bash",
})];
assert_eq!(estimate_messages_tokens(&msgs), 10);
}
#[test]
fn estimate_tokens_mixed_string_and_block_messages() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "hello"}), serde_json::json!({
"role": "toolResult",
"content": [{"type": "text", "text": "x".repeat(11)}],
"toolName": "read",
}), ];
assert_eq!(estimate_messages_tokens(&msgs), 4);
}
#[test]
fn valid_summary_passes() {
assert!(validate_summary(
"## Active Task\nRefactor auth module\n\n## Completed Actions\n1. READ config.py"
));
}
#[test]
fn empty_summary_fails() {
assert!(!validate_summary(""));
}
#[test]
fn irrelevant_text_fails() {
assert!(!validate_summary("just some random text with no structure"));
}
#[test]
fn prompt_contains_filter_safe_preamble() {
let turns = vec![
serde_json::json!({"role": "user", "content": "fix the bug"}),
serde_json::json!({"role": "assistant", "content": "ok let me read the file"}),
];
let prompt = build_summary_prompt(&turns, 2000, None, None);
assert!(prompt.contains("summarization agent"));
assert!(prompt.contains("TURNS TO SUMMARIZE"));
assert!(prompt.contains("## Active Task"));
assert!(prompt.contains("## Remaining Work"));
assert!(prompt.contains("fix the bug"));
assert!(prompt.contains("ok let me read the file"));
}
#[test]
fn iterative_prompt_includes_previous_summary() {
let turns = vec![serde_json::json!({"role": "user", "content": "new stuff"})];
let prompt = build_summary_prompt(&turns, 2000, Some("Old summary"), None);
assert!(prompt.contains("PREVIOUS SUMMARY"));
assert!(prompt.contains("Old summary"));
assert!(prompt.contains("NEW TURNS TO INCORPORATE"));
}
#[test]
fn prompt_truncates_long_content() {
let long = "x".repeat(3000);
let turns = vec![serde_json::json!({"role": "assistant", "content": long})];
let prompt = build_summary_prompt(&turns, 2000, None, None);
assert!(prompt.contains("truncated"));
assert!(prompt.len() < 10_000, "prompt should be under 10K chars");
}
#[test]
fn finds_latest_summary() {
let msgs = vec![
serde_json::json!({"role": "system", "content": "system prompt"}),
serde_json::json!({"role": "user", "content": "hello"}),
serde_json::json!({"role": "system", "content": format!("{}## Active Task\nfix the bug", SUMMARY_PREFIX)}),
];
let found = find_previous_summary(&msgs);
assert!(found.is_some());
let (_idx, body) = found.unwrap();
assert!(body.contains("fix the bug"));
}
#[test]
fn no_summary_returns_none() {
let msgs = vec![
serde_json::json!({"role": "system", "content": "system prompt"}),
serde_json::json!({"role": "user", "content": "hello"}),
];
assert!(find_previous_summary(&msgs).is_none());
}
#[test]
fn apply_summary_inserts_system_message_with_prefix() {
let msgs = vec![
serde_json::json!({"role": "system", "content": "you are an agent"}),
serde_json::json!({"role": "user", "content": "first user msg"}),
serde_json::json!({"role": "assistant", "content": "old assistant"}),
serde_json::json!({"role": "user", "content": "old user"}),
serde_json::json!({"role": "assistant", "content": "old assistant 2"}),
serde_json::json!({"role": "user", "content": "recent user"}),
serde_json::json!({"role": "assistant", "content": "recent assistant"}),
];
let summary = "## Active Task\nfix the bug\n## Remaining Work\nrun tests";
let out = apply_summary(&msgs, summary, 2, 5);
assert_eq!(out.len(), 5);
assert_eq!(out[0]["content"].as_str().unwrap(), "you are an agent");
assert_eq!(out[1]["content"].as_str().unwrap(), "first user msg");
assert_eq!(out[2]["role"].as_str().unwrap(), "system");
let s = out[2]["content"].as_str().unwrap();
assert!(
s.starts_with(SUMMARY_PREFIX),
"summary should start with prefix"
);
assert!(s.contains("## Active Task"));
assert!(s.contains("fix the bug"));
assert_eq!(out[3]["content"].as_str().unwrap(), "recent user");
assert_eq!(out[4]["content"].as_str().unwrap(), "recent assistant");
}
#[test]
fn checkpoint_reuse_folds_covered_prefix_and_keeps_tail() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "u0"}),
serde_json::json!({"role": "assistant", "content": "a0"}),
serde_json::json!({"role": "user", "content": "u1"}),
serde_json::json!({"role": "assistant", "content": "a1"}),
serde_json::json!({"role": "user", "content": "u2"}),
serde_json::json!({"role": "assistant", "content": "a2"}),
];
let summary = "## Active Task\nport the loop\n## Remaining Work\nwire it";
let (out, first_kept) = apply_checkpoint_summary(&msgs, summary, 4).unwrap();
assert_eq!(first_kept, 4);
assert_eq!(out.len(), 3);
assert_eq!(out[0]["role"].as_str().unwrap(), "system");
assert!(
out[0]["content"]
.as_str()
.unwrap()
.starts_with(SUMMARY_PREFIX)
);
assert!(
out[0]["content"]
.as_str()
.unwrap()
.contains("port the loop")
);
assert_eq!(out[1]["content"].as_str().unwrap(), "u2");
assert_eq!(out[2]["content"].as_str().unwrap(), "a2");
}
#[test]
fn checkpoint_reuse_snaps_cut_back_to_user_boundary() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "u0"}),
serde_json::json!({"role": "assistant", "content": "a0"}),
serde_json::json!({"role": "user", "content": "u1"}),
serde_json::json!({"role": "assistant", "content": "a1"}),
serde_json::json!({"role": "tool", "content": "t1"}),
];
let summary = "## Goal\nx";
let (out, first_kept) = apply_checkpoint_summary(&msgs, summary, 5).unwrap();
assert_eq!(first_kept, 2, "cut snaps back to the user turn at index 2");
assert_eq!(out.len(), 4);
assert_eq!(out[1]["content"].as_str().unwrap(), "u1");
}
#[test]
fn checkpoint_reuse_rejects_out_of_range_or_zero_boundary() {
let msgs = vec![
serde_json::json!({"role": "user", "content": "u0"}),
serde_json::json!({"role": "assistant", "content": "a0"}),
];
assert!(apply_checkpoint_summary(&msgs, "## Goal\nx", 0).is_none());
assert!(apply_checkpoint_summary(&msgs, "## Goal\nx", 99).is_none());
}
#[test]
fn checkpoint_reuse_rejects_when_snap_collapses_to_zero() {
let msgs = vec![
serde_json::json!({"role": "assistant", "content": "a0"}),
serde_json::json!({"role": "assistant", "content": "a1"}),
serde_json::json!({"role": "assistant", "content": "a2"}),
];
assert!(apply_checkpoint_summary(&msgs, "## Goal\nx", 2).is_none());
}
#[test]
fn compute_window_partitions_correctly() {
let msgs: Vec<Value> = (0..10)
.map(|i| serde_json::json!({"role": "user", "content": format!("msg {i}")}))
.collect();
let (start, end) = compute_compress_window(&msgs, 2, 3);
assert_eq!(start, 2);
assert_eq!(end, 7);
}
#[test]
fn compute_window_snaps_off_tool_pairs() {
let msgs = vec![
serde_json::json!({"role": "system", "content": "s"}),
serde_json::json!({"role": "user", "content": "u0"}),
serde_json::json!({"role": "assistant", "content": "a0", "tool_calls": [{"id": "c0"}]}),
serde_json::json!({"role": "toolResult", "toolCallId": "c0", "content": "t0"}),
serde_json::json!({"role": "assistant", "content": "a0-final"}),
serde_json::json!({"role": "user", "content": "u1"}),
serde_json::json!({"role": "assistant", "content": "a1", "tool_calls": [{"id": "c1"}]}),
serde_json::json!({"role": "toolResult", "toolCallId": "c1", "content": "t1"}),
serde_json::json!({"role": "assistant", "content": "a1-final"}),
serde_json::json!({"role": "user", "content": "u2"}),
serde_json::json!({"role": "assistant", "content": "a2"}),
serde_json::json!({"role": "user", "content": "u3 latest"}),
];
let (start, end) = compute_compress_window(&msgs, 2, 2);
assert!(start < end);
assert_eq!(msgs[start]["role"].as_str().unwrap(), "user");
assert_eq!(msgs[end]["role"].as_str().unwrap(), "user");
let out = apply_summary(&msgs, "S", start, end);
for (i, m) in out.iter().enumerate() {
if m["role"].as_str() == Some("toolResult") {
assert_eq!(
out[i - 1]["role"].as_str(),
Some("assistant"),
"toolResult at {i} must follow an assistant: {out:?}"
);
}
}
let summary_idx = out
.iter()
.position(|m| {
m["content"]
.as_str()
.is_some_and(|c| c.starts_with(SUMMARY_PREFIX))
})
.unwrap();
assert!(out[summary_idx - 1]["tool_calls"].is_null());
}
#[test]
fn compute_window_short_list_returns_zero() {
let msgs: Vec<Value> = (0..3)
.map(|i| serde_json::json!({"role": "user", "content": format!("msg {i}")}))
.collect();
assert_eq!(compute_compress_window(&msgs, 2, 3), (0, 0));
}
#[test]
fn rotate_session_id_prefix_and_length() {
let id = rotate_session_id();
assert!(id.starts_with("compacted-"));
assert_eq!(id.len(), 18);
}
#[tokio::test]
async fn full_compaction_wire_with_mock_summarizer() {
let mut msgs: Vec<Value> = vec![
serde_json::json!({"role": "system", "content": "you are an agent"}),
serde_json::json!({"role": "user", "content": "initial task"}),
];
for i in 0..18 {
let role = if i % 2 == 0 { "assistant" } else { "user" };
msgs.push(serde_json::json!({
"role": role,
"content": format!("turn {i} content with some length to make tokens"),
}));
}
msgs.push(serde_json::json!({"role": "user", "content": "latest user request"}));
let n_before = msgs.len();
let tokens = estimate_messages_tokens(&msgs);
let _ = tokens;
let (start, end) =
compute_compress_window(&msgs, PROTECT_HEAD_DEFAULT, PROTECT_TAIL_DEFAULT);
assert!(start < end);
let middle = &msgs[start..end];
assert!(!middle.is_empty());
let prompt = build_summary_prompt(
middle,
summary_budget(estimate_messages_tokens(middle)),
None,
None,
);
assert!(prompt.contains("TURNS TO SUMMARIZE"));
assert!(
prompt.contains("turn "),
"prompt should include the middle turns: {prompt}"
);
let summarizer: SummarizeFn = Arc::new(|_prompt| {
Box::pin(async move {
Ok("## Active Task\nlatest user request\n\n\
## Completed Actions\n1. turn 0\n2. turn 1\n\n\
## Remaining Work\nfinish the task"
.to_string())
})
});
let summary = summarizer(prompt.clone()).await.expect("summarizer ok");
assert!(validate_summary(&summary));
let compressed = apply_summary(&msgs, &summary, start, end);
assert_eq!(compressed.len(), start + 1 + (n_before - end));
assert!(compressed.len() < n_before);
let summary_msg = &compressed[start];
assert_eq!(summary_msg["role"].as_str().unwrap(), "system");
let body = summary_msg["content"].as_str().unwrap();
assert!(body.starts_with(SUMMARY_PREFIX));
assert!(body.contains("## Active Task"));
assert_eq!(
compressed
.iter()
.filter(|m| m["content"]
.as_str()
.is_some_and(|c| c.starts_with(SUMMARY_PREFIX)))
.count(),
1
);
let last = compressed.last().unwrap();
assert_eq!(last["content"].as_str().unwrap(), "latest user request");
let new_id = rotate_session_id();
assert!(new_id.starts_with("compacted-"));
}
}