use crate::conversation::message::{self, Message, MessageContent, Role};
use crate::conversation::{ContextStats, Conversation, KEEP_MESSAGES};
pub(crate) fn apply_model_directives(system_prompt: &str, model_id: &str) -> String {
let mut out = String::with_capacity(system_prompt.len() + 512);
out.push_str(system_prompt);
let needs_cn_lock = model_id.contains("minimax")
|| model_id.contains("qwen")
|| model_id.contains("deepseek")
|| model_id.contains("kimi");
if needs_cn_lock {
out.push_str("\n用户可见的输出请用中文。工具调用和代码保持原样。\n");
}
if model_id.contains("minimax") {
out.push_str(
"\n<system-reminder>\n\
THINKING 简洁纪律:内部思考(<think> 块)必须极简,\
只写必要的决策线索,不要复述工具结果、不要分点展开、不要自问自答。\
目标 ≤ 3 句话。冗长 thinking 视为严重问题。\n\
</system-reminder>\n",
);
}
out
}
pub fn build_messages(
conv: &Conversation,
system_prompt: &str,
token_budget: usize,
turn_reminder: &str,
) -> (Vec<Message>, ContextStats) {
if conv.messages.is_empty() {
return (
vec![Message::new(Role::System, system_prompt)],
ContextStats::default(),
);
}
let system_msg = Message::new(Role::System, system_prompt);
let system_tokens = system_msg.estimate_tokens();
let turns = &conv.turn_tracker.turns;
if turns.is_empty() {
let remaining = token_budget.saturating_sub(system_tokens);
return (
build_messages_fallback(conv, system_msg, remaining),
ContextStats::default(),
);
}
let mut result = Vec::with_capacity(conv.messages.len() + 3);
result.push(system_msg);
if !conv.cold_summaries.is_empty() {
let cold_text = format!(
"[Earlier conversation history ({} compression{})]\n{}",
conv.cold_summaries.len(),
if conv.cold_summaries.len() > 1 {
"s"
} else {
""
},
conv.cold_summaries.join("\n---\n")
);
result.push(Message::new(Role::System, cold_text));
}
result.extend(conv.messages.iter().cloned());
let budget_80pct = (token_budget * 80 / 100).min(60000);
let total_tokens: usize = result.iter().map(|m| m.estimate_tokens()).sum();
let mut dropped_tokens = 0usize;
if total_tokens > budget_80pct && conv.cold_summaries.is_empty() {
let tokens_to_drop = total_tokens - budget_80pct;
let last_turn_idx = turns.len().saturating_sub(1);
let last_turn_start = turns
.get(last_turn_idx)
.map(|t| t.start_idx)
.unwrap_or(0)
.min(conv.messages.len());
let mut drop_summaries: Vec<String> = Vec::new();
let mut drop_count = 0usize;
for ti in 0..turns.len().saturating_sub(1) {
if dropped_tokens >= tokens_to_drop {
break;
}
let turn = &turns[ti];
let end = turn.end_idx().min(conv.messages.len());
if turn.start_idx >= conv.messages.len() {
continue;
}
let turn_msgs = &conv.messages[turn.start_idx..end];
let mut parts: Vec<String> = Vec::new();
for msg in turn_msgs {
match &msg.content {
MessageContent::Text(t) if msg.role == Role::Assistant => {
let short: String = t.chars().take(150).collect();
if !short.trim().is_empty() {
parts.push(short);
}
}
MessageContent::AssistantWithToolCalls {
text, tool_calls, ..
} => {
if let Some(t) = text {
let short: String = t.chars().take(150).collect();
if !short.trim().is_empty() {
parts.push(short);
}
}
let tools: Vec<&str> =
tool_calls.iter().map(|tc| tc.name.as_str()).collect();
if !tools.is_empty() {
parts.push(format!("tools: {}", tools.join(", ")));
}
}
_ => {}
}
}
if !parts.is_empty() {
drop_summaries.push(parts.join(" | "));
}
dropped_tokens += turn_msgs.iter().map(|m| m.estimate_tokens()).sum::<usize>();
drop_count += 1;
}
let cold_msgs = if conv.cold_summaries.is_empty() { 1 } else { 2 };
result.truncate(cold_msgs);
if !drop_summaries.is_empty() {
let digest = format!(
"[Context overflow: {} earlier turns compressed]\n{}",
drop_count,
drop_summaries
.iter()
.enumerate()
.map(|(i, s)| format!("{}. {}", i + 1, s))
.collect::<Vec<_>>()
.join("\n")
);
result.push(Message::new(Role::System, digest));
}
let mut survived_start = 0;
let mut skipped = 0usize;
for ti in 0..turns.len() {
let turn = &turns[ti];
let end = turn.end_idx().min(conv.messages.len());
if turn.start_idx >= conv.messages.len() {
continue;
}
let t: usize = conv.messages[turn.start_idx..end]
.iter()
.map(|m| m.estimate_tokens())
.sum();
skipped += t;
if skipped >= dropped_tokens {
survived_start = if ti + 1 < turns.len() {
turns[ti + 1].start_idx
} else {
last_turn_start
};
break;
}
}
survived_start = survived_start.min(last_turn_start);
result.extend(conv.messages[survived_start..].iter().cloned());
}
let microcompact_threshold =
((token_budget as u64 * 4 * 70 / 100) as usize).min(100_000);
microcompact(&mut result, conv.messages.len(), microcompact_threshold);
replace_stale_reads(&mut result);
sanitize_messages(&mut result);
clean_message_pipeline(&mut result);
let non_system_count = result
.iter()
.filter(|m| !matches!(m.role, Role::System))
.count();
if non_system_count == 0 {
if let Some(last_user) =
conv.messages.iter().rev().find(|m| {
matches!(m.role, Role::User) && matches!(m.content, MessageContent::Text(..))
})
{
result.push(Message::new(
Role::System,
"[Emergency: prior conversation was dropped during compaction. Only the latest user message is preserved.]"
));
result.push(last_user.clone());
}
}
let token_ceiling = token_budget.saturating_mul(80) / 100;
let keep_tail = 4.min(result.len());
let shrinkable_end = result.len().saturating_sub(keep_tail);
let call_id_to_tool: std::collections::HashMap<String, String> = result
.iter()
.filter_map(|m| {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &m.content {
Some(tool_calls.iter().map(|tc| (tc.id.clone(), tc.name.clone())))
} else {
None
}
})
.flatten()
.collect();
for i in 1..shrinkable_end {
let total: usize = result.iter().map(|m| m.estimate_tokens()).sum();
if total <= token_ceiling {
break;
}
let tool_name = match &result[i].content {
MessageContent::ToolResult(r) => call_id_to_tool
.get(&r.call_id)
.map(|s| s.as_str())
.unwrap_or(""),
_ => continue,
};
let before = result[i].estimate_tokens();
let condensed = result[i].condensed(tool_name);
if condensed.estimate_tokens() < before {
result[i] = condensed;
}
}
if !turn_reminder.is_empty() {
for msg in result.iter_mut().rev() {
if matches!(msg.role, Role::User) {
if let MessageContent::Text(ref mut text) = msg.content {
*text = format!("{}\n{}", turn_reminder, text);
break;
}
}
}
}
let sent_tokens: usize = result
.iter()
.map(|m| m.estimate_tokens())
.sum::<usize>()
.saturating_sub(system_tokens);
let msg_count = result.len();
(
result,
ContextStats {
system_tokens,
sent_tokens,
dropped_tokens,
total_messages: msg_count,
},
)
}
pub const AUTO_COMPACT_BUFFER_LARGE: usize = 13_000;
pub const AUTO_COMPACT_BUFFER_SMALL: usize = 5_000;
pub const AUTO_COMPACT_LARGE_WINDOW_FROM: usize = 100_000;
pub fn auto_compact_threshold(token_budget: usize) -> usize {
let raw_buffer = if token_budget > AUTO_COMPACT_LARGE_WINDOW_FROM {
AUTO_COMPACT_BUFFER_LARGE
} else {
AUTO_COMPACT_BUFFER_SMALL
};
let buffer = raw_buffer.min(token_budget / 4);
token_budget.saturating_sub(buffer)
}
pub fn needs_compression(
conv: &Conversation,
system_prompt_tokens: usize,
token_budget: usize,
) -> bool {
if conv.messages.len() < 12 {
return false;
}
let total: usize = system_prompt_tokens
+ conv
.messages
.iter()
.map(|m| m.estimate_tokens())
.sum::<usize>();
total > auto_compact_threshold(token_budget)
}
pub fn build_compression_content(conv: &Conversation) -> (String, usize) {
if conv.messages.len() <= KEEP_MESSAGES {
return (String::new(), 0);
}
let mut compress_end_idx = conv.messages.len() - KEEP_MESSAGES;
while compress_end_idx < conv.messages.len() {
match &conv.messages[compress_end_idx].content {
message::MessageContent::ToolResult(_) | message::MessageContent::ToolResultRef(_) => {
compress_end_idx += 1;
}
_ => break,
}
}
if compress_end_idx >= conv.messages.len() {
return (String::new(), 0);
}
let mut content = String::new();
let mut round = 0usize;
let compress_msgs = &conv.messages[..compress_end_idx];
let mut i = 0;
while i < compress_msgs.len() {
let round_start = i;
i += 1;
while i < compress_msgs.len() {
match compress_msgs[i].role {
message::Role::User | message::Role::Assistant => break,
_ => i += 1,
}
}
round += 1;
let round_msgs = &compress_msgs[round_start..i];
content.push_str(&compress_turn(round, round_msgs));
content.push('\n');
}
(content, compress_end_idx)
}
fn compress_turn(turn_num: usize, turn_msgs: &[Message]) -> String {
let mut user_text = String::new();
let mut assistant_text = String::new();
let mut tools: Vec<String> = Vec::new();
for msg in turn_msgs {
match (&msg.role, &msg.content) {
(Role::User, MessageContent::Text(s)) => {
if !s.starts_with('[') {
user_text = if s.chars().count() > 60 {
format!("{}...", s.chars().take(57).collect::<String>())
} else {
s.clone()
};
}
}
(
_,
MessageContent::AssistantWithToolCalls {
text, tool_calls, ..
},
) => {
if let Some(t) = text {
let trimmed = t.trim();
if !trimmed.is_empty() && assistant_text.is_empty() {
assistant_text = if trimmed.chars().count() > 80 {
format!("{}...", trimmed.chars().take(77).collect::<String>())
} else {
trimmed.to_string()
};
}
}
for tc in tool_calls {
let short = if let Ok(args) =
serde_json::from_str::<serde_json::Value>(&tc.arguments)
{
let fp = args.get("file_path").and_then(|v| v.as_str()).map(|p| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| p.to_string())
});
match (tc.name.as_str(), fp) {
("read_file", Some(f)) => format!("read {}", f),
("edit_file", Some(f)) => format!("edit {}", f),
("write_file", Some(f)) => format!("write {}", f),
("grep", _) => {
let pat =
args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
format!("grep({})", pat)
}
("bash", _) => {
let cmd =
args.get("command").and_then(|v| v.as_str()).unwrap_or("?");
let short_cmd: String = cmd.chars().take(30).collect();
format!("bash({})", short_cmd)
}
(name, _) => name.to_string(),
}
} else {
tc.name.clone()
};
if !tools.contains(&short) {
tools.push(short);
}
}
}
(Role::Assistant, MessageContent::Text(s)) => {
if assistant_text.is_empty() {
let trimmed = s.trim();
if !trimmed.is_empty() {
assistant_text = if trimmed.chars().count() > 80 {
format!("{}...", trimmed.chars().take(77).collect::<String>())
} else {
trimmed.to_string()
};
}
}
}
(_, MessageContent::ToolResult(r)) if !r.success => {
tools.push("FAILED".to_string());
}
_ => {}
}
}
let tools_str = if tools.is_empty() {
"no tools".to_string()
} else {
tools.join(", ")
};
let prefix = if !user_text.is_empty() {
format!("\"{}\" ", user_text)
} else {
String::new()
};
let conclusion = if !assistant_text.is_empty() {
format!("[{}] ", assistant_text)
} else {
String::new()
};
format!(
"- Turn {}: {}{}→ {}",
turn_num, prefix, conclusion, tools_str
)
}
fn build_messages_fallback(
conv: &Conversation,
system_msg: Message,
remaining_budget: usize,
) -> Vec<Message> {
let budget = remaining_budget * 60 / 100;
let mut used = 0usize;
let mut start = conv.messages.len();
for i in (0..conv.messages.len()).rev() {
let msg_tokens = conv.messages[i].estimate_tokens();
if used + msg_tokens > budget {
break;
}
used += msg_tokens;
start = i;
}
start = snap_to_valid_boundary(&conv.messages, start);
let mut result = Vec::with_capacity(conv.messages.len() - start + 1);
result.push(system_msg);
result.extend(conv.messages[start..].iter().cloned());
sanitize_messages(&mut result);
result
}
fn snap_to_valid_boundary(messages: &[Message], idx: usize) -> usize {
let mut start = idx.min(messages.len());
while start < messages.len() {
match &messages[start].content {
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => start += 1,
_ => break,
}
}
let original = start;
while start < messages.len() {
if matches!(messages[start].role, Role::User | Role::System) {
break;
}
start += 1;
if start > original + 5 {
return original;
}
}
start
}
pub(crate) const MIN_COLLAPSE_SIZE: usize = 500;
pub(crate) fn build_compact_stub(tool_name: &str, output: &str, success: bool) -> String {
let line_count = output.lines().count();
let first_line: String = {
let mut iter = output.lines();
let l1 = iter.next().unwrap_or("(empty)");
let chosen = if l1.starts_with("[elapsed:") {
iter.next().unwrap_or(l1)
} else {
l1
};
chosen.chars().take(80).collect()
};
let status = if success { "ok" } else { "FAILED" };
format!(
"[{} {}: {} lines, first: {}]",
tool_name, status, line_count, first_line,
)
}
fn build_call_id_to_tool_map(
msgs: &[Message],
) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for msg in msgs {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
map.insert(tc.id.clone(), tc.name.clone());
}
}
}
map
}
pub(crate) fn compact_old_tool_results_in_place(
conv: &mut crate::conversation::Conversation,
keep_recent_turns: usize,
) {
let turns = &conv.turn_tracker.turns;
if turns.len() <= keep_recent_turns {
return;
}
let cutoff_turn = turns.len() - keep_recent_turns;
let cutoff_msg = turns[cutoff_turn].start_idx.min(conv.messages.len());
let call_id_to_tool = build_call_id_to_tool_map(&conv.messages);
for i in 0..cutoff_msg {
let MessageContent::ToolResult(ref tr) = conv.messages[i].content else {
continue;
};
if tr.output.len() <= MIN_COLLAPSE_SIZE {
continue;
}
let tool_name = call_id_to_tool
.get(&tr.call_id)
.map(|s| s.as_str())
.unwrap_or("tool");
let summary = build_compact_stub(tool_name, &tr.output, tr.success);
conv.messages[i].content = MessageContent::ToolResult(crate::tool::ToolResult {
call_id: tr.call_id.clone(),
output: summary,
success: tr.success,
});
}
}
fn microcompact(msgs: &mut Vec<Message>, _total_msg_count: usize, threshold_chars: usize) {
let total_chars: usize = msgs
.iter()
.map(|m| match &m.content {
MessageContent::ToolResult(r) => r.output.len(),
MessageContent::Text(t) => t.len(),
_ => 100,
})
.sum();
if total_chars < threshold_chars {
return;
}
let current_turn_start = match msgs
.iter()
.rposition(|m| matches!(m.role, Role::User))
{
Some(i) => i,
None => return,
};
let cold_msgs = msgs
.iter()
.position(|m| !matches!(m.role, Role::System))
.unwrap_or(0);
if cold_msgs >= current_turn_start {
return; }
let call_id_to_tool = build_call_id_to_tool_map(msgs);
for i in cold_msgs..current_turn_start {
let MessageContent::ToolResult(ref r) = msgs[i].content else {
continue;
};
if r.output.len() <= MIN_COLLAPSE_SIZE {
continue;
}
let tool_name = call_id_to_tool
.get(&r.call_id)
.map(|s| s.as_str())
.unwrap_or("tool");
if tool_name == "read_file" {
continue;
}
let summary = build_compact_stub(tool_name, &r.output, r.success);
msgs[i].content = MessageContent::ToolResult(crate::tool::ToolResult {
call_id: r.call_id.clone(),
output: summary,
success: r.success,
});
}
}
fn replace_stale_reads(msgs: &mut Vec<Message>) {
struct ReadInfo {
file_path: String,
offset: Option<usize>,
limit: Option<usize>,
}
let mut call_id_to_read: std::collections::HashMap<String, ReadInfo> =
std::collections::HashMap::new();
let mut edit_call_to_file: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut edited_files: std::collections::HashSet<String> = std::collections::HashSet::new();
for msg in msgs.iter() {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
if let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.arguments) {
let file_path = args
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if tc.name == "read_file" && !file_path.is_empty() {
let offset = args
.get("offset")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
call_id_to_read.insert(
tc.id.clone(),
ReadInfo {
file_path: file_path.clone(),
offset,
limit,
},
);
}
if matches!(tc.name.as_str(), "edit_file" | "write_file" | "create_file")
&& !file_path.is_empty()
{
edit_call_to_file.insert(tc.id.clone(), file_path);
}
}
}
}
if let MessageContent::ToolResult(ref r) = msg.content {
if let Some(file_path) = edit_call_to_file.get(&r.call_id) {
if !r.output.starts_with("Error") {
edited_files.insert(file_path.clone());
}
}
}
}
if edited_files.is_empty() {
return;
}
for msg in msgs.iter_mut() {
if let MessageContent::ToolResult(ref mut r) = msg.content {
if let Some(info) = call_id_to_read.get(&r.call_id) {
if !edited_files.contains(&info.file_path) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&info.file_path) {
let all_lines: Vec<&str> = content.lines().collect();
let total = all_lines.len();
if info.offset.is_some() || info.limit.is_some() {
let start = info.offset.unwrap_or(1).max(1) - 1;
let start = start.min(total);
let end = info.limit.map(|l| (start + l).min(total)).unwrap_or(total);
let display: String = all_lines[start..end]
.iter()
.enumerate()
.map(|(i, l)| format!("{:>4}| {}", start + i + 1, l))
.collect::<Vec<_>>()
.join("\n");
r.output = display;
} else if total <= 300 {
r.output = all_lines
.iter()
.enumerate()
.map(|(i, l)| format!("{:>4}| {}", i + 1, l))
.collect::<Vec<_>>()
.join("\n");
}
}
}
}
}
}
fn sanitize_messages(msgs: &mut Vec<Message>) {
let mut to_remove: Vec<usize> = Vec::new();
let mut expecting_tool_results = 0usize;
let mut current_atc_idx: Option<usize> = None;
let mut current_atc_results: Vec<usize> = Vec::new();
for i in 0..msgs.len() {
match &msgs[i].content {
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => {
if expecting_tool_results > 0 {
expecting_tool_results -= 1;
current_atc_results.push(i);
} else {
to_remove.push(i);
}
}
MessageContent::AssistantWithToolCalls { tool_calls, .. } => {
if expecting_tool_results > 0 {
if let Some(idx) = current_atc_idx {
to_remove.push(idx);
}
to_remove.extend(current_atc_results.drain(..));
} else {
current_atc_results.clear();
}
expecting_tool_results = tool_calls.len();
current_atc_idx = Some(i);
}
MessageContent::Text(_) | MessageContent::MultiPart { .. } => {
if expecting_tool_results > 0 {
if let Some(idx) = current_atc_idx {
to_remove.push(idx);
}
to_remove.extend(current_atc_results.drain(..));
} else {
current_atc_results.clear();
}
expecting_tool_results = 0;
current_atc_idx = None;
}
}
}
if expecting_tool_results > 0 {
for i in (0..msgs.len()).rev() {
match &msgs[i].content {
MessageContent::AssistantWithToolCalls { .. } => {
to_remove.push(i);
break;
}
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_) => {
to_remove.push(i);
}
_ => break,
}
}
}
to_remove.sort_unstable();
to_remove.dedup();
for &idx in to_remove.iter().rev() {
msgs.remove(idx);
}
}
fn clean_message_pipeline(msgs: &mut Vec<Message>) {
msgs.retain(|m| {
if m.role == Role::Assistant {
match &m.content {
MessageContent::Text(t) => !t.trim().is_empty(),
_ => true,
}
} else {
true
}
});
let mut valid_call_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for msg in msgs.iter() {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
for tc in tool_calls {
valid_call_ids.insert(tc.id.clone());
}
}
}
msgs.retain(|m| {
if let MessageContent::ToolResult(ref r) = m.content {
valid_call_ids.contains(&r.call_id)
} else if let MessageContent::ToolResultRef(ref r) = m.content {
valid_call_ids.contains(&r.call_id)
} else {
true
}
});
let mut i = 1;
while i < msgs.len() {
if msgs[i].role == Role::User && msgs[i - 1].role == Role::User {
if let (MessageContent::Text(prev), MessageContent::Text(curr)) =
(&msgs[i - 1].content, &msgs[i].content)
{
let merged = format!("{}\n{}", prev, curr);
msgs[i - 1].content = MessageContent::Text(merged);
msgs.remove(i);
continue;
}
}
i += 1;
}
let mut i = 1;
while i < msgs.len() {
if msgs[i].role == Role::System && msgs[i - 1].role == Role::System {
if let (MessageContent::Text(prev), MessageContent::Text(curr)) =
(&msgs[i - 1].content, &msgs[i].content)
{
let merged = format!("{}\n\n{}", prev, curr);
msgs[i - 1].content = MessageContent::Text(merged);
msgs.remove(i);
continue;
}
}
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{Message, Role};
use crate::conversation::Conversation;
#[test]
fn apply_model_directives_noop_for_generic_model() {
let out = apply_model_directives("SYS", "gpt-4o");
assert_eq!(out, "SYS");
let out = apply_model_directives("SYS", "claude-opus-4-7");
assert_eq!(out, "SYS");
}
#[test]
fn auto_compact_threshold_large_window_uses_large_buffer() {
assert_eq!(auto_compact_threshold(200_000), 187_000);
assert_eq!(auto_compact_threshold(131_072), 118_072);
}
#[test]
fn auto_compact_threshold_small_window_uses_small_buffer() {
assert_eq!(auto_compact_threshold(65_000), 60_000);
assert_eq!(auto_compact_threshold(100_000), 95_000);
assert_eq!(auto_compact_threshold(101_000), 88_000);
}
#[test]
fn auto_compact_threshold_tiny_window_caps_at_quarter() {
assert_eq!(auto_compact_threshold(8_000), 6_000);
assert_eq!(auto_compact_threshold(16_000), 12_000);
assert_eq!(auto_compact_threshold(20_000), 15_000);
}
#[test]
fn auto_compact_threshold_handles_degenerate_window() {
assert_eq!(auto_compact_threshold(0), 0);
}
#[test]
fn needs_compression_fires_at_absolute_headroom_not_percentage() {
let mut conv = Conversation::new();
for i in 0..8 {
conv.messages.push(Message::new(Role::User, format!("u{}", i)));
conv.messages.push(Message::new(Role::Assistant, format!("a{}", i)));
}
assert_eq!(conv.messages.len(), 16);
assert!(!needs_compression(&conv, 0, 131_072));
conv.messages
.push(Message::new(Role::User, "x".repeat(500_000)));
assert!(needs_compression(&conv, 0, 131_072));
}
#[test]
fn tool_result_ref_token_estimate_uses_summary_not_byte_size() {
use crate::conversation::message::MessageContent;
use crate::tool::result_store::ToolResultRef;
let big_ref = ToolResultRef {
call_id: "call_1".into(),
hash: "deadbeef".into(),
summary: "hello".into(), byte_size: 200_000, success: true,
};
let msg = Message {
role: Role::User,
content: MessageContent::ToolResultRef(big_ref),
};
assert!(
msg.estimate_tokens() < 20,
"expected estimate to track summary size, got {}",
msg.estimate_tokens()
);
}
#[test]
fn apply_model_directives_cn_lock_for_cjk_tier() {
for id in ["qwen3-max", "deepseek-v3", "kimi-k2"] {
let out = apply_model_directives("SYS", id);
assert!(
out.contains("用户可见的输出请用中文"),
"model {id} missing CN lock"
);
assert!(
!out.contains("THINKING 简洁纪律"),
"model {id} got MiniMax directive erroneously"
);
}
}
#[test]
fn apply_model_directives_minimax_gets_both_blocks() {
let out = apply_model_directives("SYS", "minimax-m2");
assert!(out.contains("用户可见的输出请用中文"));
assert!(out.contains("THINKING 简洁纪律"));
let cn_idx = out.find("用户可见的输出").unwrap();
let thinking_idx = out.find("THINKING").unwrap();
assert!(thinking_idx > cn_idx);
}
#[test]
fn apply_model_directives_preserves_system_prompt_prefix() {
let sys = "You are AtomCode. Working directory: /tmp\n";
let out = apply_model_directives(sys, "minimax-m2");
assert!(out.starts_with(sys));
}
#[test]
fn test_budgeted_empty_conversation() {
let conv = Conversation::new();
let (msgs, _stats) = build_messages(&conv, "system prompt", 8000, "");
assert_eq!(msgs.len(), 1);
assert!(matches!(msgs[0].role, Role::System));
}
#[test]
fn test_budgeted_includes_recent_messages() {
let mut conv = Conversation::new();
conv.add_user_message("hello");
conv.messages
.push(Message::new(Role::Assistant, "hi there"));
conv.add_user_message("do something");
let (msgs, _stats) = build_messages(&conv, "sys", 8000, "");
assert_eq!(msgs.len(), 4); assert!(matches!(msgs[0].role, Role::System));
}
#[test]
fn test_budgeted_sends_all_when_under_80pct() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for turn in 0..2 {
conv.add_user_message(&format!("task {}", turn));
let call = ToolCall {
id: format!("call_{}", turn),
name: "read_file".to_string(),
arguments: format!(r#"{{"file_path":"/tmp/file_{}.rs"}}"#, turn),
};
conv.add_assistant_tool_calls(None, vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("call_{}", turn),
output: "short result".to_string(),
success: true,
});
}
conv.add_user_message("now what?");
let (msgs, stats) = build_messages(&conv, "sys", 100000, "");
assert_eq!(msgs.len(), 8);
assert!(matches!(msgs[0].role, Role::System));
assert_eq!(msgs.last().unwrap().text(), Some("now what?"));
assert_eq!(stats.dropped_tokens, 0, "Nothing should be dropped");
}
#[test]
fn test_budgeted_drops_oldest_turns_when_over_budget() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for turn in 0..5 {
conv.add_user_message(&format!("task {}", turn));
for i in 0..4 {
let idx = turn * 4 + i;
let call = ToolCall {
id: format!("call_{}", idx),
name: "read_file".to_string(),
arguments: format!(r#"{{"file_path":"/tmp/file_{}.rs"}}"#, idx),
};
conv.add_assistant_tool_calls(None, vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("call_{}", idx),
output: "x".repeat(2000),
success: true,
});
}
}
conv.add_user_message("now what?");
let (msgs, stats) = build_messages(&conv, "sys", 4000, "");
assert!(
stats.dropped_tokens > 0,
"Some turns should have been dropped"
);
assert_eq!(msgs.last().unwrap().text(), Some("now what?"));
assert!(matches!(msgs[0].role, Role::System));
}
#[test]
fn test_budgeted_always_keeps_latest_turn() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
conv.add_user_message("big task");
let call = ToolCall {
id: "c0".to_string(),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("running..."), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: "c0".to_string(),
output: "z".repeat(50000),
success: true,
});
let (msgs, _stats) = build_messages(&conv, "sys", 1000, "");
assert!(!msgs.is_empty(), "Must at least have system prompt");
assert!(matches!(msgs[0].role, Role::System));
}
#[test]
fn test_budgeted_never_returns_system_only_when_messages_exist() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for i in 0..5 {
conv.add_user_message(&format!("task {}", i));
let call = ToolCall {
id: format!("c{}", i),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("ok"), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", i),
output: "x".repeat(500),
success: true,
});
}
conv.add_user_message("find everything");
let call = ToolCall {
id: "c5".to_string(),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("finding..."), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: "c5".to_string(),
output: "z".repeat(200_000), success: true,
});
let (msgs, _stats) = build_messages(&conv, "sys", 10_000, "");
let non_system = msgs
.iter()
.filter(|m| !matches!(m.role, Role::System))
.count();
assert!(
non_system > 0,
"never return system-only result when messages exist — got msgs.len()={}",
msgs.len()
);
}
#[test]
fn test_budgeted_emergency_restores_last_user_when_all_else_dropped() {
let mut conv = Conversation::new();
conv.add_user_message("original question");
for i in 0..20 {
use crate::tool::{ToolCall, ToolResult};
conv.add_assistant_tool_calls(
Some(&format!("reasoning {}", i)),
vec![ToolCall {
id: format!("c{}", i),
name: "bash".to_string(),
arguments: "{}".to_string(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", i),
output: "y".repeat(10_000),
success: true,
});
}
let (msgs, _stats) = build_messages(&conv, "sys", 5_000, "");
let has_user = msgs.iter().any(|m| matches!(m.role, Role::User));
assert!(
has_user,
"last user message must always survive, got {} msgs",
msgs.len()
);
}
#[test]
fn microcompact_uses_generic_format_with_tool_label_from_call_id() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs: Vec<Message> = vec![Message::new(Role::System, "sys")];
msgs.push(Message::new(Role::User, "explore"));
let kinds = [
("c_bok", "bash", true),
("c_bfail", "bash", false),
("c_grep", "grep", true),
("c_mcp", "mcp_remote.exec", true),
];
for (id, name, success) in &kinds {
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: (*id).to_string(),
name: (*name).to_string(),
arguments: "{}".into(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
});
msgs.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: (*id).to_string(),
output: format!("first line for {}\n{}", name, "x".repeat(4_000)),
success: *success,
}),
});
}
msgs.push(Message::new(Role::User, "now what"));
let n = msgs.len();
microcompact(&mut msgs, n, 1_000);
let find_by_id = |id: &str| -> Option<String> {
msgs.iter().find_map(|m| {
if let MessageContent::ToolResult(r) = &m.content {
if r.call_id == id {
return Some(r.output.clone());
}
}
None
})
};
let bok = find_by_id("c_bok").expect("c_bok must survive");
assert!(
bok.starts_with("[bash ok: ") && bok.contains("first: "),
"bash success format mismatch: {}",
bok
);
let bfail = find_by_id("c_bfail").expect("c_bfail must survive");
assert!(
bfail.starts_with("[bash FAILED: ") && bfail.contains("first: "),
"bash failure format mismatch: {}",
bfail
);
for (id, expected_label) in [
("c_grep", "grep"),
("c_mcp", "mcp_remote.exec"),
] {
let body = find_by_id(id).unwrap_or_else(|| panic!("{} must survive", id));
assert!(
body.starts_with(&format!("[{} ok: ", expected_label)),
"{} expected generic `[{} ok: ...]` format, got: {}",
id,
expected_label,
body
);
assert!(
body.contains("first: first line for"),
"{} should preserve first-line snippet, got: {}",
id,
body
);
}
}
#[test]
fn build_compact_stub_skips_bash_elapsed_metadata() {
let bash_failure = "[elapsed: 1.9s, exit: 101]\nerror: cannot find type `Foo` in this scope";
let stub = build_compact_stub("bash", bash_failure, false);
assert!(
stub.contains("error: cannot find type"),
"bash stub must surface the actual error, not the elapsed metadata: {}",
stub
);
assert!(
!stub.contains("first: [elapsed:"),
"bash stub first-line must skip the elapsed metadata: {}",
stub
);
}
#[test]
fn build_compact_stub_falls_back_to_line1_when_only_one_line() {
let one_liner = "42";
let stub = build_compact_stub("bash", one_liner, true);
assert!(stub.contains("first: 42"), "got: {}", stub);
}
#[test]
fn build_compact_stub_unaffected_for_non_bash_tools() {
let grep = "src/foo.rs:42: fn bar() {}\nsrc/baz.rs:10: fn baz()";
let stub = build_compact_stub("grep", grep, true);
assert!(
stub.contains("first: src/foo.rs:42:"),
"grep stub must keep line 1 intact: {}",
stub
);
let edit = "Edited /path/to/file.rs (-3 +5 lines).";
let stub = build_compact_stub("edit_file", edit, true);
assert!(stub.contains("first: Edited /path"), "got: {}", stub);
}
#[test]
fn microcompact_skips_read_file_to_preserve_long_session_context() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
conv.add_user_message("explore");
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: "c_read".into(),
name: "read_file".into(),
arguments: "{}".into(),
}],
None,
);
let read_body = format!("first line of read\n{}", "x".repeat(5_000));
conv.add_tool_result(ToolResult {
call_id: "c_read".into(),
output: read_body.clone(),
success: true,
});
for i in 0..30 {
let id = format!("c_pad{}", i);
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: id.clone(),
name: "bash".into(),
arguments: "{}".into(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: id,
output: format!("[elapsed: 0.0s, exit: 0]\n{}", "x".repeat(4_000)),
success: true,
});
}
conv.add_user_message("now what");
let (msgs, _) = build_messages(&conv, "sys", 40_000, "");
let body = msgs
.iter()
.find_map(|m| {
if let MessageContent::ToolResult(r) = &m.content {
if r.call_id == "c_read" {
return Some(r.output.clone());
}
}
None
})
.expect("c_read must survive in rendered messages");
assert!(
!body.starts_with("[read_file "),
"read_file got compacted (伪自信 risk): {}",
&body[..body.len().min(200)]
);
assert_eq!(
body.len(),
read_body.len(),
"read_file body length must equal original (uncompacted)"
);
assert!(
body.contains("first line of read"),
"first line lost: {}",
&body[..body.len().min(200)]
);
let any_bash_compacted = msgs.iter().any(|m| {
if let MessageContent::ToolResult(r) = &m.content {
r.output.starts_with("[bash ok: ")
} else {
false
}
});
assert!(
any_bash_compacted,
"bash padding should have been compacted; if not, the \
threshold isn't actually triggering and read_file passing \
through is a false positive"
);
}
#[test]
fn microcompact_preserves_current_turn_in_full() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs: Vec<Message> = vec![Message::new(Role::System, "sys")];
msgs.push(Message::new(Role::User, "first task"));
for i in 0..15 {
let id = format!("prior_{}", i);
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: id.clone(),
name: "bash".into(),
arguments: "{}".into(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
});
msgs.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: id,
output: format!("[elapsed: 0.0s, exit: 0]\n{}", "p".repeat(4_000)),
success: true,
}),
});
}
msgs.push(Message::new(Role::User, "second task"));
for i in 0..10 {
let id = format!("current_{}", i);
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: id.clone(),
name: "bash".into(),
arguments: "{}".into(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
});
msgs.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: id,
output: format!("[elapsed: 0.0s, exit: 0]\n{}", "c".repeat(4_000)),
success: true,
}),
});
}
let total_chars: usize = msgs
.iter()
.map(|m| match &m.content {
MessageContent::ToolResult(r) => r.output.len(),
MessageContent::Text(t) => t.len(),
_ => 100,
})
.sum();
let n = msgs.len();
microcompact(&mut msgs, n, 1_000);
let collect = |prefix: &str| -> Vec<(String, String)> {
msgs.iter()
.filter_map(|m| match &m.content {
MessageContent::ToolResult(r) if r.call_id.starts_with(prefix) => {
Some((r.call_id.clone(), r.output.clone()))
}
_ => None,
})
.collect()
};
let prior = collect("prior_");
assert_eq!(prior.len(), 15, "expected 15 prior tool results");
for (cid, body) in &prior {
assert!(
body.starts_with("[bash "),
"prior turn `{}` must be stubbed; got body of len={} starting {:?}\n\
(total_chars before microcompact was {})",
cid,
body.len(),
&body[..body.len().min(80)],
total_chars
);
assert!(
body.len() < 200,
"prior stub should be < 200 bytes, got {}",
body.len()
);
}
let current = collect("current_");
assert_eq!(current.len(), 10, "expected 10 current tool results");
for (cid, body) in ¤t {
assert!(
!body.starts_with("[bash "),
"current turn `{}` must NOT be stubbed (turn-aware preservation): \
got {:?}",
cid,
&body[..body.len().min(80)]
);
assert!(
body.len() > 4_000,
"current tool result must keep its full payload (>4K chars), \
got {} bytes",
body.len()
);
}
}
#[test]
fn microcompact_is_idempotent_no_double_stub() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
conv.add_user_message("trigger");
for i in 0..30 {
let id = format!("c{}", i);
conv.add_assistant_tool_calls(
None,
vec![ToolCall {
id: id.clone(),
name: "bash".into(),
arguments: "{}".into(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: id,
output: format!("first line\n{}", "x".repeat(4_000)),
success: true,
});
}
conv.add_user_message("done");
let (msgs1, _) = build_messages(&conv, "sys", 131_072, "");
let (msgs2, _) = build_messages(&conv, "sys", 131_072, "");
let collect_tr = |m: &[Message]| -> Vec<String> {
m.iter()
.filter_map(|m| {
if let MessageContent::ToolResult(r) = &m.content {
Some(r.output.clone())
} else {
None
}
})
.collect()
};
assert_eq!(collect_tr(&msgs1), collect_tr(&msgs2));
for body in collect_tr(&msgs1) {
if body.starts_with("[bash") {
assert!(
body.contains("first: "),
"stub lost its first-line slot: {}",
body
);
}
}
}
#[test]
fn test_cold_zone_compression() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for turn in 0..8 {
conv.add_user_message(&format!("task {}", turn));
let call = ToolCall {
id: format!("c{}", turn),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("ok"), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", turn),
output: "x".repeat(100),
success: true,
});
}
conv.apply_compression(9, "User ran tasks 0, 1, 2 with bash.".to_string());
assert_eq!(conv.cold_summaries.len(), 1);
assert_eq!(conv.turn_tracker.turns.len(), 5);
let (msgs, _stats) = build_messages(&conv, "sys", 100000, "");
let has_cold = msgs.iter().any(|m| {
m.text()
.map_or(false, |t| t.contains("Earlier conversation history"))
});
assert!(has_cold, "Cold zone summary should appear in output");
}
#[test]
fn test_no_consecutive_system_messages_after_compression() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for turn in 0..8 {
conv.add_user_message(&format!("task {}", turn));
let call = ToolCall {
id: format!("c{}", turn),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("ok"), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", turn),
output: "x".repeat(100),
success: true,
});
}
conv.apply_compression(9, "User ran tasks 0, 1, 2 with bash.".to_string());
assert_eq!(conv.cold_summaries.len(), 1);
let (msgs, _stats) = build_messages(&conv, "you are atomcode", 100_000, "");
for pair in msgs.windows(2) {
assert!(
!(pair[0].role == Role::System && pair[1].role == Role::System),
"consecutive system messages found at the wire boundary"
);
}
let merged = msgs
.iter()
.find(|m| matches!(m.role, Role::System))
.and_then(|m| m.text())
.expect("at least one system message");
assert!(
merged.contains("you are atomcode"),
"merged system must keep original prompt"
);
assert!(
merged.contains("Earlier conversation history"),
"merged system must keep cold-zone summary"
);
}
#[test]
fn test_budgeted_drops_when_no_summary_and_over_budget() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
for turn in 0..3 {
conv.add_user_message(&format!("task {}", turn));
let call = ToolCall {
id: format!("c{}", turn),
name: "bash".to_string(),
arguments: "{}".to_string(),
};
conv.add_assistant_tool_calls(Some("ok"), vec![call], None);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", turn),
output: "x".repeat(4000),
success: true,
});
}
let (msgs, stats) = build_messages(&conv, "sys", 2000, "");
assert!(
stats.dropped_tokens > 0,
"Should drop turns when over budget"
);
assert!(matches!(msgs[0].role, Role::System));
}
#[test]
fn test_final_byte_ceiling_condenses_oversized_recent_toolresults() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
conv.cold_summaries.push("earlier task summary".to_string());
for turn in 0..20 {
conv.add_user_message(&format!("task {}", turn));
conv.add_assistant_tool_calls(
Some("ok"),
vec![ToolCall {
id: format!("c{}", turn),
name: "bash".to_string(),
arguments: "{}".to_string(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: format!("c{}", turn),
output: "x".repeat(6000),
success: true,
});
}
let (msgs, _stats) = build_messages(&conv, "sys", 10_000, "");
let total_tokens: usize = msgs.iter().map(|m| m.estimate_tokens()).sum();
assert!(
total_tokens <= 8_000,
"Total estimated tokens {} exceeded 80% ceiling 8000 — \
final byte ceiling did not run",
total_tokens,
);
let newest_still_full = msgs
.iter()
.any(|m| m.text().map_or(false, |t| t.contains(&"x".repeat(100))));
assert!(
newest_still_full,
"Newest turn's full-size tool result must be preserved",
);
}
#[test]
fn test_budgeted_preserves_message_order() {
let mut conv = Conversation::new();
conv.add_user_message("first");
conv.messages
.push(Message::new(Role::Assistant, "response 1"));
conv.add_user_message("second");
conv.messages
.push(Message::new(Role::Assistant, "response 2"));
conv.add_user_message("third");
let (msgs, _stats) = build_messages(&conv, "sys", 100000, "");
assert_eq!(msgs.len(), 6);
assert_eq!(msgs[1].text(), Some("first"));
assert_eq!(msgs[2].text(), Some("response 1"));
assert_eq!(msgs[3].text(), Some("second"));
assert_eq!(msgs[4].text(), Some("response 2"));
assert_eq!(msgs[5].text(), Some("third"));
}
#[test]
fn test_sanitize_removes_orphan_tool_results() {
use crate::tool::ToolResult;
let mut msgs = vec![
Message::new(Role::System, "sys"),
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "orphan_1".to_string(),
output: "some output".to_string(),
success: true,
}),
},
Message::new(Role::User, "hello"),
];
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0].role, Role::System));
assert!(matches!(msgs[1].role, Role::User));
}
#[test]
fn test_sanitize_preserves_valid_pairs() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs = vec![
Message::new(Role::System, "sys"),
Message::new(Role::User, "do it"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: "c1".to_string(),
name: "bash".to_string(),
arguments: "{}".to_string(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c1".to_string(),
output: "ok".to_string(),
success: true,
}),
},
];
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), 4);
}
#[test]
fn test_sanitize_drops_under_paired_atc_in_middle_of_history() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs = vec![
Message::new(Role::System, "sys"),
Message::new(Role::User, "first"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![
ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: "{}".into(),
},
ToolCall {
id: "c2".into(),
name: "bash".into(),
arguments: "{}".into(),
},
ToolCall {
id: "c3".into(),
name: "bash".into(),
arguments: "{}".into(),
},
],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c1".into(),
output: "ok1".into(),
success: true,
}),
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c2".into(),
output: "ok2".into(),
success: true,
}),
},
Message::new(Role::User, "second"),
];
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), 3, "got: {:?}", msgs);
assert!(matches!(msgs[0].role, Role::System));
assert_eq!(msgs[1].text(), Some("first"));
assert_eq!(msgs[2].text(), Some("second"));
}
#[test]
fn test_sanitize_drops_under_paired_atc_when_followed_by_another_atc() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs = vec![
Message::new(Role::User, "go"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![
ToolCall {
id: "a1".into(),
name: "bash".into(),
arguments: "{}".into(),
},
ToolCall {
id: "a2".into(),
name: "bash".into(),
arguments: "{}".into(),
},
],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "a1".into(),
output: "ok".into(),
success: true,
}),
},
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: "b1".into(),
name: "bash".into(),
arguments: "{}".into(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "b1".into(),
output: "ok".into(),
success: true,
}),
},
];
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), 3, "got: {:?}", msgs);
assert_eq!(msgs[0].text(), Some("go"));
assert!(matches!(
msgs[1].content,
MessageContent::AssistantWithToolCalls { .. }
));
assert!(matches!(msgs[2].content, MessageContent::ToolResult(_)));
}
#[test]
fn test_sanitize_drops_under_paired_atc_at_tail() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs = vec![
Message::new(Role::User, "go"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![
ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: "{}".into(),
},
ToolCall {
id: "c2".into(),
name: "bash".into(),
arguments: "{}".into(),
},
],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c1".into(),
output: "ok".into(),
success: true,
}),
},
];
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), Some("go"));
}
#[test]
fn test_sanitize_preserves_fully_paired_history_through_text_boundaries() {
use crate::tool::{ToolCall, ToolResult};
let mut msgs = vec![
Message::new(Role::User, "first"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![
ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: "{}".into(),
},
ToolCall {
id: "c2".into(),
name: "bash".into(),
arguments: "{}".into(),
},
],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c1".into(),
output: "ok1".into(),
success: true,
}),
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c2".into(),
output: "ok2".into(),
success: true,
}),
},
Message::new(Role::Assistant, "done"),
Message::new(Role::User, "second"),
];
let len_before = msgs.len();
sanitize_messages(&mut msgs);
assert_eq!(msgs.len(), len_before, "must not drop fully-paired history");
}
#[test]
fn build_messages_satisfies_atc_pairing_after_under_paired_mid_history() {
use crate::tool::{ToolCall, ToolResult};
let mut conv = Conversation::new();
conv.add_user_message("first task");
conv.add_assistant_tool_calls(
None,
vec![
ToolCall { id: "c1".into(), name: "bash".into(), arguments: "{}".into() },
ToolCall { id: "c2".into(), name: "bash".into(), arguments: "{}".into() },
ToolCall { id: "c3".into(), name: "bash".into(), arguments: "{}".into() },
],
None,
);
conv.add_tool_result(ToolResult {
call_id: "c1".into(),
output: "ok1".into(),
success: true,
});
conv.add_tool_result(ToolResult {
call_id: "c2".into(),
output: "ok2".into(),
success: true,
});
conv.add_user_message("second task");
let (msgs, _stats) = build_messages(&conv, "sys", 8000, "");
let mut i = 0;
while i < msgs.len() {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msgs[i].content {
let n = tool_calls.len();
for j in 0..n {
let next_idx = i + 1 + j;
assert!(
next_idx < msgs.len(),
"ATC at {} expects {} tool_results but messages end at {}: {:?}",
i,
n,
msgs.len(),
msgs.iter().map(|m| &m.role).collect::<Vec<_>>()
);
assert!(
matches!(
msgs[next_idx].content,
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_)
),
"ATC at {} expects tool_result at {} but found {:?}",
i,
next_idx,
msgs[next_idx].role
);
}
i += 1 + n;
} else {
i += 1;
}
}
for m in &msgs {
if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &m.content {
for tc in tool_calls {
assert_ne!(tc.id, "c3", "dropped ATC's call_ids must not survive");
assert_ne!(tc.id, "c1");
assert_ne!(tc.id, "c2");
}
}
if let MessageContent::ToolResult(r) = &m.content {
assert_ne!(r.call_id, "c1", "partial tool_results must not survive");
assert_ne!(r.call_id, "c2");
}
}
}
#[test]
fn microcompact_respects_threshold_parameter() {
use crate::tool::{ToolCall, ToolResult};
fn build_msgs() -> Vec<Message> {
let mut msgs = vec![Message::new(Role::System, "sys")];
for i in 0..25 {
msgs.push(Message::new(Role::User, format!("task {}", i)));
msgs.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: format!("c{}", i),
name: "bash".to_string(),
arguments: "{}".to_string(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
});
msgs.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: format!("c{}", i),
output: "x".repeat(1000),
success: true,
}),
});
}
msgs
}
fn total_tool_bytes(msgs: &[Message]) -> usize {
msgs.iter()
.map(|m| match &m.content {
MessageContent::ToolResult(r) => r.output.len(),
_ => 0,
})
.sum()
}
let mut msgs_high = build_msgs();
let before_high_len = msgs_high.len();
let before_high_bytes = total_tool_bytes(&msgs_high);
let msg_count_high = msgs_high.len();
microcompact(&mut msgs_high, msg_count_high, 100_000);
assert_eq!(
msgs_high.len(),
before_high_len,
"high-threshold run must not drop msgs"
);
assert_eq!(
total_tool_bytes(&msgs_high),
before_high_bytes,
"high threshold (25K < 100K) must leave tool_result bytes untouched"
);
let mut msgs_low = build_msgs();
let before_low_bytes = total_tool_bytes(&msgs_low);
let msg_count_low = msgs_low.len();
microcompact(&mut msgs_low, msg_count_low, 10_000);
let after_low_bytes = total_tool_bytes(&msgs_low);
assert!(
after_low_bytes < before_low_bytes,
"low threshold (25K > 10K) must shrink tool_result bytes, before={} after={}",
before_low_bytes,
after_low_bytes
);
}
#[test]
fn compression_cut_never_splits_tool_use_result_pair() {
use crate::tool::{ToolCall, ToolResult};
let build_conv = || {
let mut conv = Conversation::new();
for i in 0..10 {
conv.add_user_message(&format!("prefix task {}", i));
conv.push_delta(&format!("prefix reply {}", i));
conv.finalize_stream();
}
conv.add_user_message("trigger tool"); conv.add_assistant_tool_calls(
Some("r"),
vec![ToolCall {
id: "call_would_orphan".to_string(),
name: "bash".to_string(),
arguments: "{}".to_string(),
}],
None,
);
conv.add_tool_result(ToolResult {
call_id: "call_would_orphan".to_string(),
output: "tool output that must not be lost".to_string(),
success: true,
});
for i in 0..9 {
conv.add_user_message(&format!("suffix task {}", i));
conv.push_delta(&format!("suffix reply {}", i));
conv.finalize_stream();
}
conv.add_user_message("final task");
conv
};
let conv = build_conv();
let len = conv.messages.len();
assert_eq!(len, 42, "conv layout wrong");
let naive_cut = len - KEEP_MESSAGES;
assert_eq!(naive_cut, 22);
assert!(
matches!(conv.messages[22].content, MessageContent::ToolResult(_)),
"test layout broken: msg[22] should be ToolResult"
);
let (_summary, actual_cut) = build_compression_content(&conv);
if actual_cut < conv.messages.len() {
let first_survivor = &conv.messages[actual_cut];
let is_tool_result = matches!(
first_survivor.content,
MessageContent::ToolResult(_) | MessageContent::ToolResultRef(_)
);
assert!(
!is_tool_result,
"cut index {} lands on ToolResult (naive was {}); \
surviving range would start with orphan",
actual_cut, naive_cut
);
}
let mut c2 = build_conv();
c2.apply_compression(actual_cut, "summary".to_string());
let mut live_call_ids = std::collections::HashSet::<String>::new();
for msg in &c2.messages {
match &msg.content {
MessageContent::AssistantWithToolCalls { tool_calls, .. } => {
for tc in tool_calls {
live_call_ids.insert(tc.id.clone());
}
}
MessageContent::ToolResult(r) => {
assert!(
live_call_ids.contains(&r.call_id),
"orphan ToolResult({}) in surviving range — its ATC was dropped",
r.call_id
);
}
_ => {}
}
}
}
#[test]
fn compression_must_be_judged_by_wire_token_savings() {
let mut conv = Conversation::new();
for i in 0..16 {
conv.add_user_message(&format!("task {}", i));
conv.push_delta("ok");
conv.finalize_stream();
}
let before_tokens: usize = build_messages(&conv, "sys", 64_000, "")
.0
.iter()
.map(|m| m.estimate_tokens())
.sum();
let (_mechanical_summary, remove_count) = build_compression_content(&conv);
assert!(remove_count > 0, "test conversation should be compressible");
conv.apply_compression(remove_count, "expanded summary ".repeat(2_000));
conv.add_user_message(
"[Context was compressed. Here is your current state:]\n\
TASK: continue the current issue analysis\n\
RECENTLY READ: crates/atomcode-core/src/agent/mod.rs",
);
let after_tokens: usize = build_messages(&conv, "sys", 64_000, "")
.0
.iter()
.map(|m| m.estimate_tokens())
.sum();
assert!(
after_tokens > before_tokens,
"dropped messages alone is not a valid compaction success metric: \
before={before_tokens}, after={after_tokens}"
);
}
}