use crate::llm::{ChatOutcome, ChatRequest, Content, ContentBlock, LlmProvider, Message, Role};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use std::fmt::Write;
use std::sync::Arc;
use super::config::CompactionConfig;
use super::estimator::TokenEstimator;
const SUMMARY_PREFIX: &str = "[Previous conversation summary]\n\n";
const COMPACTION_SYSTEM_PROMPT: &str = "You are a precise summarizer. Your task is to create concise but complete summaries of conversations, preserving all technical details needed to continue the work.";
const COMPACTION_SUMMARY_PROMPT_PREFIX: &str = "Summarize this conversation concisely, preserving:\n- Key decisions and conclusions reached\n- Important file paths, code changes, and technical details\n- Current task context and what has been accomplished\n- Any pending items, errors encountered, or next steps\n\nBe specific about technical details (file names, function names, error messages) as these\nare critical for continuing the work.\n\nConversation:\n";
const COMPACTION_SUMMARY_PROMPT_SUFFIX: &str =
"Provide a concise summary (aim for 500-1000 words):";
const COMPACT_EMPTY_SUMMARY: &str = "No additional context was available to summarize; the previous messages were already compacted.";
const SUMMARY_ACKNOWLEDGMENT: &str =
"I understand the context from the summary. Let me continue from where we left off.";
const MAX_RETAINED_TAIL_MESSAGE_TOKENS: usize = 20_000;
const MAX_TOOL_RESULT_CHARS: usize = 500;
#[async_trait]
pub trait ContextCompactor: Send + Sync {
async fn compact(&self, messages: &[Message]) -> Result<String>;
fn estimate_tokens(&self, messages: &[Message]) -> usize;
fn needs_compaction(&self, messages: &[Message]) -> bool;
async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult>;
}
#[derive(Debug, Clone)]
pub struct CompactionResult {
pub messages: Vec<Message>,
pub original_count: usize,
pub new_count: usize,
pub original_tokens: usize,
pub new_tokens: usize,
}
pub struct LlmContextCompactor<P: LlmProvider> {
provider: Arc<P>,
config: CompactionConfig,
system_prompt: String,
summary_prompt_prefix: String,
summary_prompt_suffix: String,
}
impl<P: LlmProvider> LlmContextCompactor<P> {
#[must_use]
pub fn new(provider: Arc<P>, config: CompactionConfig) -> Self {
Self {
provider,
config,
system_prompt: COMPACTION_SYSTEM_PROMPT.to_string(),
summary_prompt_prefix: COMPACTION_SUMMARY_PROMPT_PREFIX.to_string(),
summary_prompt_suffix: COMPACTION_SUMMARY_PROMPT_SUFFIX.to_string(),
}
}
#[must_use]
pub fn with_defaults(provider: Arc<P>) -> Self {
Self::new(provider, CompactionConfig::default())
}
#[must_use]
pub const fn config(&self) -> &CompactionConfig {
&self.config
}
#[must_use]
pub fn with_prompts(
mut self,
system_prompt: impl Into<String>,
summary_prompt_prefix: impl Into<String>,
summary_prompt_suffix: impl Into<String>,
) -> Self {
self.system_prompt = system_prompt.into();
self.summary_prompt_prefix = summary_prompt_prefix.into();
self.summary_prompt_suffix = summary_prompt_suffix.into();
self
}
fn is_summary_message(content: &Content) -> bool {
match content {
Content::Text(text) => text.starts_with(SUMMARY_PREFIX),
Content::Blocks(blocks) => blocks.iter().any(|block| match block {
ContentBlock::Text { text } => text.starts_with(SUMMARY_PREFIX),
_ => false,
}),
}
}
fn has_tool_use(content: &Content) -> bool {
matches!(
content,
Content::Blocks(blocks)
if blocks
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
)
}
fn has_tool_result(content: &Content) -> bool {
matches!(
content,
Content::Blocks(blocks)
if blocks
.iter()
.any(|block| matches!(block, ContentBlock::ToolResult { .. }))
)
}
fn split_point_preserves_tool_pairs(messages: &[Message], mut split_point: usize) -> usize {
while split_point > 0 && split_point < messages.len() {
let prev = &messages[split_point - 1];
let next = &messages[split_point];
let crosses_tool_pair = (prev.role == Role::Assistant
&& Self::has_tool_use(&prev.content)
&& next.role == Role::User
&& Self::has_tool_result(&next.content))
|| (prev.role == Role::User
&& Self::has_tool_result(&prev.content)
&& next.role == Role::Assistant
&& Self::has_tool_use(&next.content));
if crosses_tool_pair {
split_point -= 1;
continue;
}
break;
}
split_point
}
fn split_point_preserves_tool_pairs_with_cap(
messages: &[Message],
mut split_point: usize,
max_tokens: usize,
) -> usize {
loop {
let candidate = Self::retain_tail_with_token_cap(messages, split_point, max_tokens);
let adjusted = Self::split_point_preserves_tool_pairs(messages, candidate);
if adjusted == split_point {
return candidate;
}
split_point = adjusted;
}
}
fn retain_tail_with_token_cap(messages: &[Message], start: usize, max_tokens: usize) -> usize {
if start >= messages.len() {
return messages.len();
}
if max_tokens == 0 {
return messages.len();
}
let mut used = 0usize;
let mut retained_start = messages.len();
for idx in (start..messages.len()).rev() {
let message_tokens = TokenEstimator::estimate_message(&messages[idx]);
if used + message_tokens > max_tokens {
break;
}
retained_start = idx;
used += message_tokens;
}
retained_start
}
fn format_messages_for_summary(messages: &[Message]) -> String {
let mut output = String::new();
for message in messages {
let role = match message.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
let _ = write!(output, "{role}: ");
match &message.content {
Content::Text(text) => {
let _ = writeln!(output, "{text}");
}
Content::Blocks(blocks) => {
for block in blocks {
match block {
ContentBlock::Text { text } => {
let _ = writeln!(output, "{text}");
}
ContentBlock::Thinking { thinking, .. } => {
let _ = writeln!(output, "[Thinking: {thinking}]");
}
ContentBlock::RedactedThinking { .. } => {
let _ = writeln!(output, "[Redacted thinking]");
}
ContentBlock::ToolUse { name, input, .. } => {
let _ = writeln!(
output,
"[Called tool: {name} with input: {}]",
serde_json::to_string(input).unwrap_or_default()
);
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
let status = if is_error.unwrap_or(false) {
"error"
} else {
"success"
};
let truncated = if content.chars().count() > MAX_TOOL_RESULT_CHARS {
let prefix: String =
content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
format!("{prefix}... (truncated)")
} else {
content.clone()
};
let _ = writeln!(output, "[Tool result ({status}): {truncated}]");
}
ContentBlock::Image { source } => {
let _ = writeln!(output, "[Image: {}]", source.media_type);
}
ContentBlock::Document { source } => {
let _ = writeln!(output, "[Document: {}]", source.media_type);
}
}
}
}
}
output.push('\n');
}
output
}
fn build_summary_prompt(&self, messages_text: &str) -> String {
format!(
"{}{}{}",
self.summary_prompt_prefix, messages_text, self.summary_prompt_suffix
)
}
}
#[async_trait]
impl<P: LlmProvider> ContextCompactor for LlmContextCompactor<P> {
async fn compact(&self, messages: &[Message]) -> Result<String> {
let messages_to_summarize: Vec<_> = messages
.iter()
.filter(|message| !Self::is_summary_message(&message.content))
.cloned()
.collect();
if messages_to_summarize.is_empty() {
return Ok(COMPACT_EMPTY_SUMMARY.to_string());
}
let messages_text = Self::format_messages_for_summary(&messages_to_summarize);
let prompt = self.build_summary_prompt(&messages_text);
let request = ChatRequest {
system: self.system_prompt.clone(),
messages: vec![Message::user(prompt)],
tools: None,
max_tokens: 2000,
max_tokens_explicit: true,
session_id: None,
cached_content: None,
thinking: None,
};
let outcome = self
.provider
.chat(request)
.await
.context("Failed to call LLM for summarization")?;
match outcome {
ChatOutcome::Success(response) => response
.first_text()
.map(String::from)
.context("No text in summarization response"),
ChatOutcome::RateLimited => {
bail!("Rate limited during summarization")
}
ChatOutcome::InvalidRequest(msg) => {
bail!("Invalid request during summarization: {msg}")
}
ChatOutcome::ServerError(msg) => {
bail!("Server error during summarization: {msg}")
}
}
}
fn estimate_tokens(&self, messages: &[Message]) -> usize {
TokenEstimator::estimate_history(messages)
}
fn needs_compaction(&self, messages: &[Message]) -> bool {
if !self.config.auto_compact {
return false;
}
if messages.len() < self.config.min_messages_for_compaction {
return false;
}
let estimated_tokens = self.estimate_tokens(messages);
estimated_tokens > self.config.threshold_tokens
}
async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult> {
let original_count = messages.len();
let original_tokens = self.estimate_tokens(&messages);
if messages.len() <= self.config.retain_recent {
return Ok(CompactionResult {
messages,
original_count,
new_count: original_count,
original_tokens,
new_tokens: original_tokens,
});
}
let mut split_point = messages.len().saturating_sub(self.config.retain_recent);
split_point = Self::split_point_preserves_tool_pairs_with_cap(
&messages,
split_point,
MAX_RETAINED_TAIL_MESSAGE_TOKENS,
);
let (to_summarize, to_keep) = messages.split_at(split_point);
let summary = self.compact(to_summarize).await?;
let mut new_messages = Vec::with_capacity(2 + to_keep.len());
new_messages.push(Message::user(format!("{SUMMARY_PREFIX}{summary}")));
if !to_keep.is_empty() {
new_messages.push(Message::assistant(SUMMARY_ACKNOWLEDGMENT));
}
new_messages.extend(to_keep.iter().cloned());
let new_count = new_messages.len();
let new_tokens = self.estimate_tokens(&new_messages);
Ok(CompactionResult {
messages: new_messages,
original_count,
new_count,
original_tokens,
new_tokens,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{ChatResponse, StopReason, Usage};
use std::sync::Mutex;
struct MockProvider {
summary_response: String,
requests: Option<Arc<Mutex<Vec<String>>>>,
}
impl MockProvider {
fn new(summary: &str) -> Self {
Self {
summary_response: summary.to_string(),
requests: None,
}
}
fn new_with_request_log(summary: &str, requests: Arc<Mutex<Vec<String>>>) -> Self {
Self {
summary_response: summary.to_string(),
requests: Some(requests),
}
}
}
#[async_trait]
impl LlmProvider for MockProvider {
async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
if let Some(requests) = &self.requests {
let mut entries = requests.lock().unwrap();
let user_prompt = request
.messages
.iter()
.find_map(|message| match &message.content {
Content::Text(text) => Some(text.clone()),
Content::Blocks(blocks) => {
let text = blocks
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
if text.is_empty() { None } else { Some(text) }
}
})
.unwrap_or_default();
entries.push(user_prompt);
}
Ok(ChatOutcome::Success(ChatResponse {
id: "test".to_string(),
content: vec![ContentBlock::Text {
text: self.summary_response.clone(),
}],
model: "mock".to_string(),
stop_reason: Some(StopReason::EndTurn),
usage: Usage {
input_tokens: 100,
output_tokens: 50,
cached_input_tokens: 0,
},
}))
}
fn model(&self) -> &'static str {
"mock-model"
}
fn provider(&self) -> &'static str {
"mock"
}
}
#[test]
fn test_needs_compaction_below_threshold() {
let provider = Arc::new(MockProvider::new("summary"));
let config = CompactionConfig::default()
.with_threshold_tokens(10_000)
.with_min_messages(5);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("Hello"),
Message::assistant("Hi"),
Message::user("How are you?"),
];
assert!(!compactor.needs_compaction(&messages));
}
#[test]
fn test_needs_compaction_above_threshold() {
let provider = Arc::new(MockProvider::new("summary"));
let config = CompactionConfig::default()
.with_threshold_tokens(50) .with_min_messages(3);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("Hello, this is a longer message to test compaction"),
Message::assistant(
"Hi there! This is also a longer response to help trigger compaction",
),
Message::user("Great, let's continue with even more text here"),
Message::assistant("Absolutely, adding more content to ensure we exceed the threshold"),
];
assert!(compactor.needs_compaction(&messages));
}
#[test]
fn test_needs_compaction_auto_disabled() {
let provider = Arc::new(MockProvider::new("summary"));
let config = CompactionConfig::default()
.with_threshold_tokens(10) .with_min_messages(1)
.with_auto_compact(false);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("Hello, this is a longer message"),
Message::assistant("Response here"),
];
assert!(!compactor.needs_compaction(&messages));
}
#[tokio::test]
async fn test_compact_history() -> Result<()> {
let provider = Arc::new(MockProvider::new(
"User asked about Rust programming. Assistant explained ownership, borrowing, and lifetimes.",
));
let config = CompactionConfig::default()
.with_retain_recent(2)
.with_min_messages(3);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user(
"What is Rust? I've heard it's a systems programming language but I don't know much about it. Can you explain the key features and why people are excited about it?",
),
Message::assistant(
"Rust is a systems programming language focused on safety, speed, and concurrency. It achieves memory safety without garbage collection through its ownership system. The key features include zero-cost abstractions, guaranteed memory safety, threads without data races, and minimal runtime.",
),
Message::user(
"Tell me about ownership in detail. How does it work and what are the rules? I want to understand this core concept thoroughly.",
),
Message::assistant(
"Ownership is Rust's central feature with three rules: each value has one owner, only one owner at a time, and the value is dropped when owner goes out of scope. This system prevents memory leaks, double frees, and dangling pointers at compile time.",
),
Message::user("What about borrowing?"), Message::assistant("Borrowing allows references to data without taking ownership."), ];
let result = compactor.compact_history(messages).await?;
assert_eq!(result.new_count, 4);
assert_eq!(result.original_count, 6);
assert!(
result.new_tokens < result.original_tokens,
"Expected fewer tokens after compaction: new={} < original={}",
result.new_tokens,
result.original_tokens
);
if let Content::Text(text) = &result.messages[0].content {
assert!(text.contains("Previous conversation summary"));
}
Ok(())
}
#[tokio::test]
async fn test_compact_history_too_few_messages() -> Result<()> {
let provider = Arc::new(MockProvider::new("summary"));
let config = CompactionConfig::default().with_retain_recent(5);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("Hello"),
Message::assistant("Hi"),
Message::user("Bye"),
];
let result = compactor.compact_history(messages.clone()).await?;
assert_eq!(result.new_count, 3);
assert_eq!(result.messages.len(), 3);
Ok(())
}
#[test]
fn test_format_messages_for_summary() {
let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
assert!(formatted.contains("User: Hello"));
assert!(formatted.contains("Assistant: Hi there!"));
}
#[test]
fn test_format_messages_for_summary_truncates_tool_results_unicode_safely() {
let long_unicode = "é".repeat(600);
let messages = vec![Message {
role: Role::Assistant,
content: Content::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: long_unicode,
is_error: Some(false),
}]),
}];
let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
assert!(formatted.contains("... (truncated)"));
}
#[tokio::test]
async fn test_compact_filters_summary_messages() -> Result<()> {
let requests = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(MockProvider::new_with_request_log(
"Fresh summary",
requests.clone(),
));
let config = CompactionConfig::default().with_min_messages(1);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
Message::assistant("Continue with the next task using this context."),
];
let summary = compactor.compact(&messages).await?;
{
let recorded = requests.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(summary, "Fresh summary");
assert!(recorded[0].contains("Continue with the next task using this context."));
assert!(!recorded[0].contains("already compacted context"));
drop(recorded);
}
Ok(())
}
#[tokio::test]
async fn test_compact_history_ignores_prior_summary_in_candidate_payload() -> Result<()> {
let requests = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(MockProvider::new_with_request_log(
"Fresh history summary",
requests.clone(),
));
let config = CompactionConfig::default()
.with_retain_recent(2)
.with_min_messages(1);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
Message::assistant("Current turn content from the latest exchange."),
Message::assistant("Recent message that should stay."),
Message::user("Newest note that should stay."),
];
let result = compactor.compact_history(messages).await?;
{
let recorded = requests.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert!(recorded[0].contains("Current turn content from the latest exchange."));
assert!(!recorded[0].contains("already compacted context"));
drop(recorded);
}
assert_eq!(result.new_count, 4);
Ok(())
}
#[tokio::test]
async fn test_compact_history_is_no_op_when_candidate_window_has_only_summaries() -> Result<()>
{
let requests = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(MockProvider::new_with_request_log(
"This summary should not be used",
requests.clone(),
));
let config = CompactionConfig::default()
.with_retain_recent(2)
.with_min_messages(1);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user(format!("{SUMMARY_PREFIX}first prior compacted section")),
Message::assistant(format!("{SUMMARY_PREFIX}second prior compacted section")),
Message::user(format!("{SUMMARY_PREFIX}third prior compacted section")),
Message::assistant("final short note"),
];
let result = compactor.compact_history(messages).await?;
{
let recorded = requests.lock().unwrap();
assert!(recorded.is_empty());
drop(recorded);
}
assert_eq!(result.new_count, 4);
assert_eq!(result.messages.len(), 4);
if let Content::Text(text) = &result.messages[0].content {
assert!(text.contains(COMPACT_EMPTY_SUMMARY));
} else {
panic!("Expected summary text in first message");
}
Ok(())
}
#[tokio::test]
async fn test_compact_history_preserves_tool_use_tool_result_pairs() -> Result<()> {
let provider = Arc::new(MockProvider::new("Summary of earlier conversation."));
let config = CompactionConfig::default()
.with_retain_recent(2)
.with_min_messages(3);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("What files are in the project?"),
Message::assistant("Let me check that for you."),
Message {
role: Role::Assistant,
content: Content::Blocks(vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "list_files".to_string(),
input: serde_json::json!({}),
thought_signature: None,
}]),
},
Message {
role: Role::User,
content: Content::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tool_1".to_string(),
content: "file1.rs\nfile2.rs".to_string(),
is_error: None,
}]),
},
Message::assistant("The project contains file1.rs and file2.rs."),
];
let result = compactor.compact_history(messages).await?;
assert_eq!(result.new_count, 5);
let kept_assistant = &result.messages[2];
if let Content::Blocks(blocks) = &kept_assistant.content {
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
"Expected assistant tool_use in kept messages"
);
} else {
panic!("Expected Blocks content for assistant tool_use message");
}
let kept_user = &result.messages[3];
if let Content::Blocks(blocks) = &kept_user.content {
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. })),
"Expected user tool_result in kept messages"
);
} else {
panic!("Expected Blocks content for user tool_result message");
}
Ok(())
}
#[tokio::test]
async fn test_compact_history_preserves_tool_result_tool_use_pairs() -> Result<()> {
let provider = Arc::new(MockProvider::new("Summary around tool pair."));
let config = CompactionConfig::default()
.with_retain_recent(2)
.with_min_messages(1);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::user("Start a workflow"),
Message {
role: Role::User,
content: Content::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tool_odd".to_string(),
content: "prior result".to_string(),
is_error: None,
}]),
},
Message {
role: Role::Assistant,
content: Content::Blocks(vec![ContentBlock::ToolUse {
id: "tool_odd".to_string(),
name: "follow_up".to_string(),
input: serde_json::json!({}),
thought_signature: None,
}]),
},
Message::assistant("Follow up done."),
];
let result = compactor.compact_history(messages).await?;
assert_eq!(result.new_count, 5);
let kept_result = &result.messages[2];
if let Content::Blocks(blocks) = &kept_result.content {
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. })),
"Expected kept user tool_result in retained tail"
);
} else {
panic!("Expected tool_result blocks in retained tail");
}
let kept_tool_use = &result.messages[3];
if let Content::Blocks(blocks) = &kept_tool_use.content {
assert!(
blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
"Expected kept assistant tool_use in retained tail"
);
} else {
panic!("Expected tool_use blocks in retained tail");
}
Ok(())
}
#[tokio::test]
async fn test_compact_history_retained_tail_is_token_capped() -> Result<()> {
let provider = Arc::new(MockProvider::new(
"Project summary with a long context and technical context.",
));
let config = CompactionConfig::default()
.with_retain_recent(8)
.with_min_messages(1)
.with_threshold_tokens(1);
let compactor = LlmContextCompactor::new(provider, config);
let mut messages = Vec::new();
messages.extend((0..6).map(|index| Message::user(format!("pre-compaction noise {index}"))));
messages.extend(
(0..8).map(|index| Message::assistant(format!("kept-{index}: {}", "x".repeat(12_000)))),
);
let result = compactor.compact_history(messages).await?;
let retained_tail = &result.messages[2..];
assert!(retained_tail.len() < 8);
let mut latest_index = -1i32;
let mut all_retained = true;
for message in retained_tail {
if let Content::Text(text) = &message.content {
if let Some(number) = text.split(':').next().and_then(|prefix| {
prefix
.strip_prefix("kept-")
.and_then(|rest| rest.parse::<i32>().ok())
}) {
if number >= 0 {
latest_index = latest_index.max(number);
}
} else {
all_retained = false;
}
} else {
all_retained = false;
}
}
assert!(all_retained);
assert_eq!(latest_index, 7);
assert!(
TokenEstimator::estimate_history(retained_tail) <= MAX_RETAINED_TAIL_MESSAGE_TOKENS
);
assert!(compactor.needs_compaction(&result.messages));
Ok(())
}
#[tokio::test]
async fn test_compact_history_skips_summary_ack_when_retained_tail_is_empty() -> Result<()> {
let provider = Arc::new(MockProvider::new("Summary for oversized user turn."));
let config = CompactionConfig::default()
.with_retain_recent(1)
.with_min_messages(1)
.with_threshold_tokens(1);
let compactor = LlmContextCompactor::new(provider, config);
let messages = vec![
Message::assistant("Earlier assistant context."),
Message::user(format!("oversized-user-turn: {}", "x".repeat(200_000))),
];
let result = compactor.compact_history(messages).await?;
assert_eq!(result.new_count, 1);
assert_eq!(result.messages.len(), 1);
let only_message = &result.messages[0];
assert_eq!(only_message.role, Role::User);
if let Content::Text(text) = &only_message.content {
assert!(text.contains("Previous conversation summary"));
assert!(!text.contains(SUMMARY_ACKNOWLEDGMENT));
} else {
panic!("Expected summary text when retained tail is empty");
}
Ok(())
}
}