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. \
The work described here — INCLUDING the original task — may already be \
complete: the '## Completed Actions' and '## Active State' sections record \
what is already done, so do NOT redo it. \
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 = match max_chars.checked_div(text_block_count) {
Some(d) => std::cmp::max(d, MIN_PER_BLOCK_BUDGET),
None => return msg.clone(),
};
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. State what should happen NEXT — the\n\
immediate piece of work in flight right now, in plain terms. This is NOT\n\
necessarily the user's original wording: the current work is often an\n\
emergent follow-up (e.g. debugging a failing test) that arose mid-session\n\
and was never an explicit user request — capture THAT, not the original\n\
assignment, when that is what is actually underway. If the user's original\n\
request is already COMPLETE and only follow-up work remains, the Active\n\
Task IS that follow-up; state plainly that the original request is already\n\
done so the next context does not redo it. If multiple tasks were requested\n\
and only some are done, list only the ones NOT yet completed. If nothing is\n\
outstanding, 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 summary_prefix_warns_original_task_may_be_complete() {
assert!(SUMMARY_PREFIX.starts_with(COMPACTION_MARKER));
assert!(SUMMARY_PREFIX.contains("Completed Actions"));
let lower = SUMMARY_PREFIX.to_lowercase();
assert!(lower.contains("already"));
assert!(lower.contains("complete") || lower.contains("done"));
assert!(lower.contains("do not redo") || lower.contains("not redo"));
}
#[test]
fn active_task_section_frames_followup_not_verbatim() {
let turns: Vec<Value> = vec![serde_json::json!({
"role": "user",
"content": "convert this to stdlib"
})];
let prompt = build_summary_prompt(&turns, 2000, None, None);
assert!(prompt.contains("## Active Task"));
assert!(prompt.contains("follow-up"));
let lower = prompt.to_lowercase();
assert!(lower.contains("already") || lower.contains("complete"));
assert!(!prompt.contains("verbatim — the exact words they used"));
}
#[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-"));
}
}