use crate::compact::ContextCompactor;
use crate::compact::types::{CompactionContext, CompactionOutcome};
use crate::message::{Message, MessagePart, Role};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
#[derive(Debug, Clone, Copy)]
enum PartMate {
Paired {
message: usize,
},
LoneCall,
LoneResult,
}
struct ToolPairing {
mates: Vec<Vec<Option<PartMate>>>,
}
impl ToolPairing {
fn scan(messages: &[Message]) -> Self {
let mut mates: Vec<Vec<Option<PartMate>>> = messages
.iter()
.map(|msg| msg.parts.iter().map(|_| None).collect())
.collect();
let mut pending: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for (i, msg) in messages.iter().enumerate() {
for (p, part) in msg.parts.iter().enumerate() {
match part {
MessagePart::ToolCall { id, .. } => {
pending.entry(id.clone()).or_default().push((i, p));
}
MessagePart::ToolResult { call_id, .. } => {
let claimed = pending.get_mut(call_id).and_then(Vec::pop);
let state = match claimed {
Some((cm, cp)) => {
if let Some(slot) =
mates.get_mut(cm).and_then(|row| row.get_mut(cp))
{
*slot = Some(PartMate::Paired { message: i });
}
PartMate::Paired { message: cm }
}
None => PartMate::LoneResult,
};
if let Some(slot) = mates.get_mut(i).and_then(|row| row.get_mut(p)) {
*slot = Some(state);
}
}
_ => {}
}
}
}
for (i, p) in pending.into_values().flatten() {
if let Some(slot) = mates.get_mut(i).and_then(|row| row.get_mut(p)) {
*slot = Some(PartMate::LoneCall);
}
}
Self { mates }
}
fn adjusted_split(&self, split: usize) -> usize {
let mut new_split = split;
for row in self.mates.iter().skip(split) {
for mate in row.iter().flatten() {
if let PartMate::Paired { message: m, .. } = mate
&& *m < split
&& *m < new_split
{
new_split = *m;
}
}
}
new_split
}
fn boundary_pair_safe(&self, index: usize) -> bool {
!self.mates.iter().enumerate().skip(index).any(|(i, row)| {
row.iter().flatten().any(|mate| {
matches!(
mate,
PartMate::Paired { message: m, .. } if *m < index && *m < i
)
})
})
}
fn first_message_dropped_result_indices(&self, split: usize) -> Vec<usize> {
let mut indices: Vec<usize> = Vec::new();
if let Some(first_row) = self.mates.first() {
for mate in first_row.iter().flatten() {
if let PartMate::Paired { message: m, .. } = mate
&& *m > 0
&& *m < split
&& !indices.contains(m)
{
indices.push(*m);
}
}
}
indices.sort_unstable();
indices
}
fn all_parts_lone_results(&self) -> bool {
self.mates
.iter()
.flatten()
.all(|state| matches!(state, Some(PartMate::LoneResult)))
}
}
#[derive(Debug, Clone)]
pub struct TruncatingCompactor {
preserve_recent: usize,
min_messages: usize,
}
impl TruncatingCompactor {
#[must_use]
pub fn new() -> Self {
Self {
preserve_recent: 4,
min_messages: 6,
}
}
#[must_use]
pub fn with_preserve_recent(mut self, count: usize) -> Self {
self.preserve_recent = count.max(1);
self
}
#[must_use]
pub fn with_min_messages(mut self, count: usize) -> Self {
self.min_messages = count.max(2);
self
}
#[must_use]
pub fn preserve_recent(&self) -> usize {
self.preserve_recent
}
#[must_use]
pub fn min_messages(&self) -> usize {
self.min_messages
}
}
impl Default for TruncatingCompactor {
fn default() -> Self {
Self::new()
}
}
impl ContextCompactor for TruncatingCompactor {
fn compact(
&self,
messages: Vec<Message>,
_target_tokens: u64,
context: CompactionContext,
) -> Pin<Box<dyn Future<Output = CompactionOutcome> + Send + '_>> {
Box::pin(async move {
let total = messages.len();
if total <= self.min_messages {
return Self::unchanged(messages, &context);
}
let initial_split = total.saturating_sub(self.preserve_recent);
let mut split = initial_split;
loop {
let adjusted = Self::adjust_for_tool_pairs(&messages, split);
if adjusted == split {
break;
}
split = adjusted;
}
if split == 0 {
return Self::unchanged(messages, &context);
}
let recent: Vec<Message> = messages.get(split..).unwrap_or_default().to_vec();
let mut preserved: Vec<Message> = Vec::with_capacity(recent.len().saturating_add(1));
if let Some(first) = messages.first() {
preserved.push(first.clone());
}
preserved.extend(recent);
let preserved = Self::reattach_dropped_results(&messages, split, preserved);
if preserved.is_empty() {
return Self::unchanged(messages, &context);
}
let tokens_after = context.counter.count(&preserved);
CompactionOutcome {
messages: preserved,
tokens_after,
tokens_saved: context.tokens_before.saturating_sub(tokens_after),
success: true,
error: None,
}
})
}
}
impl TruncatingCompactor {
fn adjust_for_tool_pairs(messages: &[Message], split: usize) -> usize {
if split == 0 {
return 0;
}
ToolPairing::scan(messages).adjusted_split(split)
}
fn reattach_dropped_results(
messages: &[Message],
split: usize,
kept: Vec<Message>,
) -> Vec<Message> {
let pairing = ToolPairing::scan(messages);
let pull_indices = pairing.first_message_dropped_result_indices(split);
let pulled: Vec<Message> = pull_indices
.iter()
.filter_map(|&i| messages.get(i).cloned())
.collect();
let mut kept_iter = kept.into_iter();
let Some(first) = kept_iter.next() else {
return pulled;
};
let pulled_len = pulled.len();
let mut out =
Vec::with_capacity(pulled_len.saturating_add(kept_iter.len()).saturating_add(1));
let mut origins: Vec<usize> = Vec::with_capacity(out.capacity());
out.push(first);
origins.push(0);
out.extend(pulled);
origins.extend(pull_indices.iter().copied());
origins.extend(split..messages.len());
out.extend(kept_iter);
Self::sanitize_tool_parts(&mut out, &origins, &pairing, pulled_len);
out
}
fn unchanged(messages: Vec<Message>, context: &CompactionContext) -> CompactionOutcome {
let pairing = ToolPairing::scan(&messages);
if pairing.all_parts_lone_results() {
return CompactionOutcome::no_change(messages);
}
let input_len = messages.len();
let input_parts = messages
.iter()
.map(|msg| msg.parts.len())
.fold(0usize, usize::saturating_add);
let origins: Vec<usize> = (0..input_len).collect();
let mut out = messages;
Self::sanitize_tool_parts(&mut out, &origins, &pairing, 0);
let filtered_parts = out
.iter()
.map(|msg| msg.parts.len())
.fold(0usize, usize::saturating_add);
if out.len() == input_len && filtered_parts == input_parts {
return CompactionOutcome::no_change(out);
}
let tokens_after = context.counter.count(&out);
CompactionOutcome {
messages: out,
tokens_after,
tokens_saved: context.tokens_before.saturating_sub(tokens_after),
success: true,
error: None,
}
}
fn sanitize_tool_parts(
out: &mut Vec<Message>,
origins: &[usize],
pairing: &ToolPairing,
pulled_len: usize,
) {
let live: HashSet<usize> = origins.iter().copied().collect();
let pulled_end = pulled_len.saturating_add(1);
for (slot, msg) in out.iter_mut().enumerate() {
let Some(&origin) = origins.get(slot) else {
continue;
};
let strict = slot > 0 && slot < pulled_end;
let keeps: Vec<bool> = (0..msg.parts.len())
.map(|p| {
let state = pairing
.mates
.get(origin)
.and_then(|row| row.get(p))
.and_then(|s| s.as_ref());
match state {
Some(PartMate::Paired { message: m, .. }) if strict => live.contains(m),
Some(PartMate::LoneCall) => !strict,
Some(PartMate::LoneResult) => false,
_ => true,
}
})
.collect();
msg.parts = msg
.parts
.iter()
.zip(keeps)
.filter(|(_, keep)| *keep)
.map(|(part, _)| part.clone())
.collect();
}
out.retain(|msg| !msg.parts.is_empty());
}
}
#[derive(Debug, Clone)]
pub struct TokenSplitter {
preserve_recent: usize,
min_messages: usize,
}
#[derive(Debug, Clone)]
pub struct SplitResult {
pub to_compact: Vec<Message>,
pub preserved: Vec<Message>,
pub compact_tokens: u64,
pub preserved_tokens: u64,
pub split_index: usize,
}
impl TokenSplitter {
#[must_use]
pub fn new() -> Self {
Self {
preserve_recent: 4,
min_messages: 6,
}
}
#[must_use]
pub fn with_preserve_recent(mut self, count: usize) -> Self {
self.preserve_recent = count.max(1);
self
}
#[must_use]
pub fn with_min_messages(mut self, count: usize) -> Self {
self.min_messages = count.max(2);
self
}
#[must_use]
pub fn split(&self, messages: &[Message]) -> SplitResult {
if messages.len() <= self.min_messages {
return SplitResult {
to_compact: vec![],
preserved: messages.to_vec(),
compact_tokens: 0,
preserved_tokens: CompactionOutcome::estimate_tokens(messages),
split_index: 0,
};
}
let target_split = messages.len().saturating_sub(self.preserve_recent);
let split_index = Self::find_turn_boundary(messages, target_split);
let (to_compact, preserved) = messages.split_at(split_index);
SplitResult {
to_compact: to_compact.to_vec(),
preserved: preserved.to_vec(),
compact_tokens: CompactionOutcome::estimate_tokens(to_compact),
preserved_tokens: CompactionOutcome::estimate_tokens(preserved),
split_index,
}
}
fn find_turn_boundary(messages: &[Message], target: usize) -> usize {
if target == 0 {
return 0;
}
for i in (1..=target).rev() {
if i >= messages.len() {
continue;
}
let Some(prev) = messages.get(i.saturating_sub(1)) else {
continue;
};
let Some(curr) = messages.get(i) else {
continue;
};
if prev.role == Role::Assistant
&& curr.role == Role::User
&& Self::split_is_pair_safe(messages, i)
{
return i;
}
}
0
}
fn split_is_pair_safe(messages: &[Message], index: usize) -> bool {
ToolPairing::scan(messages).boundary_pair_safe(index)
}
}
impl Default for TokenSplitter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compact::ContextCompactor;
use crate::compact::types::{CompactReason, CompactionContext};
use crate::message::{Message, MessagePart, Role, ToolContent};
use serde_json::json;
fn tool_text(s: &str) -> ToolContent {
ToolContent::from_string(s)
}
fn make_context(msgs: &[Message]) -> CompactionContext {
CompactionContext {
tokens_before: CompactionOutcome::estimate_tokens(msgs),
reason: CompactReason::ThresholdExceeded,
context_window: 1_000,
turn: 5,
counter: std::sync::Arc::new(crate::compact::HeuristicTokenCounter),
instructions: None,
additional_context: Vec::new(),
}
}
fn convo_with_straddling_tool_pair() -> Vec<Message> {
vec![
Message::user("msg0"),
Message::assistant("reply0"),
Message::user("msg1"),
Message::assistant("reply1"),
Message::user("msg2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"call_a",
"search",
json!({"q": "rust"}),
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"call_a",
"search",
tool_text("result data"),
false,
)],
),
Message::assistant("final reply"),
]
}
fn has_tool_call(msgs: &[Message], id: &str) -> bool {
msgs.iter()
.flat_map(|m| m.parts.iter())
.any(|p| matches!(p, MessagePart::ToolCall { id: tool_id, .. } if tool_id == id))
}
fn has_tool_result(msgs: &[Message], call_id: &str) -> bool {
msgs.iter()
.flat_map(|m| m.parts.iter())
.any(|p| matches!(p, MessagePart::ToolResult { call_id: cid, .. } if cid == call_id))
}
#[tokio::test]
async fn compact_preserves_tool_call_when_result_is_in_recent() {
let messages = convo_with_straddling_tool_pair();
let compactor = TruncatingCompactor::new()
.with_preserve_recent(2)
.with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 500, context).await;
assert!(
has_tool_call(&outcome.messages, "call_a"),
"tool-call 'call_a' must be preserved"
);
assert!(
has_tool_result(&outcome.messages, "call_a"),
"tool-result for 'call_a' must be preserved"
);
}
#[tokio::test]
async fn compact_does_not_orphan_when_pairs_are_together_in_recent() {
let messages = vec![
Message::user("msg0"),
Message::assistant("reply0"),
Message::user("msg1"),
Message::assistant("reply1"),
Message::user("msg2"),
Message::assistant("reply2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("call_b", "calc", json!({}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"call_b",
"calc",
tool_text("42"),
false,
)],
),
];
let compactor = TruncatingCompactor::new()
.with_preserve_recent(2)
.with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 500, context).await;
assert!(
has_tool_call(&outcome.messages, "call_b"),
"tool-call 'call_b' must be preserved"
);
assert!(
has_tool_result(&outcome.messages, "call_b"),
"tool-result for 'call_b' must be preserved"
);
}
#[tokio::test]
async fn compact_drops_both_call_and_result_when_in_old_portion() {
let messages = vec![
Message::user("msg0"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("call_c", "tool", json!({}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"call_c",
"tool",
tool_text("done"),
false,
)],
),
Message::assistant("reply1"),
Message::user("msg2"),
Message::assistant("reply2"),
Message::user("msg3"),
Message::assistant("reply3"),
];
let compactor = TruncatingCompactor::new()
.with_preserve_recent(4)
.with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 500, context).await;
assert!(
!has_tool_call(&outcome.messages, "call_c"),
"tool-call 'call_c' should be dropped"
);
assert!(
!has_tool_result(&outcome.messages, "call_c"),
"tool-result for 'call_c' should be dropped"
);
}
#[tokio::test]
async fn compact_rechecks_newly_admitted_messages_for_orphaned_results() {
let messages = vec![
Message::user("q0"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("w", "Read", json!({"path": "w.rs"}))],
),
Message::user("intermediate"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("z", "Read", json!({"path": "z.rs"}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"w",
"Read",
tool_text("ok"),
false,
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"z",
"Read",
tool_text("ok"),
false,
)],
),
Message::assistant("done"),
];
let compactor = TruncatingCompactor::new()
.with_preserve_recent(2)
.with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 500, context).await;
let call_ids: Vec<&str> = outcome
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolCall { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
let result_ids: Vec<&str> = outcome
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
let orphaned_results: Vec<&str> = result_ids
.iter()
.filter(|id| !call_ids.contains(id))
.copied()
.collect();
assert!(
orphaned_results.is_empty(),
"newly admitted messages must be rechecked for orphaned results: {orphaned_results:?}"
);
assert!(
has_tool_call(&outcome.messages, "w") && has_tool_result(&outcome.messages, "w"),
"the second adjustment must pull in call 'w' alongside its admitted result"
);
}
#[test]
fn adjust_for_tool_pairs_returns_zero_when_split_is_zero() {
let messages = convo_with_straddling_tool_pair();
assert_eq!(TruncatingCompactor::adjust_for_tool_pairs(&messages, 0), 0);
}
#[test]
fn adjust_for_tool_pairs_no_orphans_returns_original_split() {
let messages = vec![
Message::user("a"),
Message::assistant("b"),
Message::user("c"),
Message::assistant("d"),
Message::user("e"),
Message::assistant("f"),
];
assert_eq!(TruncatingCompactor::adjust_for_tool_pairs(&messages, 4), 4);
}
#[test]
fn adjust_for_tool_pairs_moves_split_back_for_orphaned_result() {
let messages = convo_with_straddling_tool_pair();
assert_eq!(TruncatingCompactor::adjust_for_tool_pairs(&messages, 6), 5);
}
#[tokio::test]
async fn compact_short_conversation_returns_unchanged() {
let messages = vec![
Message::user("hello"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("call_d", "tool", json!({}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"call_d",
"tool",
tool_text("ok"),
false,
)],
),
];
let compactor = TruncatingCompactor::new()
.with_preserve_recent(2)
.with_min_messages(6);
let context = make_context(&messages);
let outcome = compactor.compact(messages.clone(), 500, context).await;
assert_eq!(outcome.messages.len(), messages.len());
}
#[tokio::test]
async fn preserved_first_message_does_not_orphan_its_tool_call() {
let messages = vec![
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"c1",
"Read",
tool_text("ok"),
false,
)],
),
Message::user("q2"),
Message::assistant("a2"),
Message::user("q3"),
Message::assistant("a3"),
Message::user("q4"),
Message::assistant("a4"),
];
let compactor = TruncatingCompactor::new().with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
let has_call = outcome.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolCall { id, .. } if id == "c1"))
});
let has_result = outcome.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolResult { call_id, .. } if call_id == "c1"))
});
assert!(
!has_call || has_result,
"module doc: the split adjustment avoids orphaning tool-call/result pairs — kept the call but dropped its result: {:?}",
outcome.messages.len()
);
}
#[tokio::test]
async fn straddling_call_at_index_zero_reports_no_action() {
let messages = vec![
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::user("q1"),
Message::assistant("a1"),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"c1",
"Read",
tool_text("ok"),
false,
)],
),
Message::user("q2"),
Message::assistant("a2"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(4)
.with_preserve_recent(3);
let context = make_context(&messages);
let outcome = compactor.compact(messages.clone(), 1, context).await;
assert!(outcome.success);
assert_eq!(
outcome.messages.len(),
messages.len(),
"a split of 0 keeps every message — the outcome must be the unchanged list"
);
assert_eq!(
outcome.tokens_saved, 0,
"no-action passes must claim zero savings"
);
}
#[tokio::test]
async fn pulled_result_message_does_not_strand_foreign_results() {
let messages = vec![
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c2",
"Read",
json!({"path": "b.rs"}),
)],
),
Message::new(
Role::User,
vec![
MessagePart::tool_result("c1", "Read", tool_text("ok"), false),
MessagePart::tool_result("c2", "Read", tool_text("ok"), false),
],
),
Message::user("q2"),
Message::assistant("a2"),
Message::user("q3"),
Message::assistant("a3"),
];
let compactor = TruncatingCompactor::new().with_min_messages(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
let call_ids: Vec<&str> = outcome
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolCall { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
let result_ids: Vec<&str> = outcome
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
let orphaned_results: Vec<&str> = result_ids
.iter()
.filter(|id| !call_ids.contains(id))
.copied()
.collect();
let orphaned_calls: Vec<&str> = call_ids
.iter()
.filter(|id| !result_ids.contains(id))
.copied()
.collect();
assert!(
orphaned_results.is_empty() && orphaned_calls.is_empty(),
"module doc: tool-call/result pairs are never split — the compacted \
list carries results without their calls {orphaned_results:?} and \
calls without their results {orphaned_calls:?}"
);
assert!(
call_ids.contains(&"c1") && result_ids.contains(&"c1"),
"the pair the pull exists to repair must survive it"
);
}
#[test]
fn token_splitter_does_not_separate_a_call_from_its_result() {
let messages = vec![
Message::user("q1"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"c1",
"Read",
tool_text("ok"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::assistant("a2"),
Message::user("q3"),
];
let splitter = TokenSplitter::new()
.with_min_messages(4)
.with_preserve_recent(5);
let split = splitter.split(&messages);
let old_calls: Vec<&str> = split
.to_compact
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolCall { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
let new_results: Vec<&str> = split
.preserved
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
MessagePart::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
let separated: Vec<&str> = new_results
.iter()
.filter(|id| old_calls.contains(id))
.copied()
.collect();
assert!(
separated.is_empty(),
"the split must never put a call into to_compact while its \
result stays in preserved (split_index {}); the splitter \
keeps the whole conversation instead",
split.split_index
);
}
#[test]
fn splitter_skips_boundaries_that_straddle_a_later_result() {
let messages = vec![
Message::user("q1"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::user("ack"),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"c1",
"Read",
tool_text("ok"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::assistant("a2"),
];
let splitter = TokenSplitter::new()
.with_min_messages(4)
.with_preserve_recent(5);
let split = splitter.split(&messages);
assert_eq!(
split.split_index, 0,
"a boundary that strands a call behind a result delivered in a \
later message is not pair-safe — nothing is split"
);
assert!(
split.to_compact.is_empty(),
"the whole conversation stays preserved"
);
}
fn part_counts(messages: &[Message]) -> Vec<(String, usize, usize)> {
let mut calls: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
let mut results: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for msg in messages {
for part in &msg.parts {
match part {
MessagePart::ToolCall { id, .. } => {
let counter = calls.entry(id.clone()).or_insert(0);
*counter = counter.saturating_add(1);
}
MessagePart::ToolResult { call_id, .. } => {
let counter = results.entry(call_id.clone()).or_insert(0);
*counter = counter.saturating_add(1);
}
_ => {}
}
}
}
let orphaned: Vec<(String, usize, usize)> = results
.iter()
.filter(|(id, _)| !calls.contains_key(id.as_str()))
.map(|(id, r)| (id.clone(), 0, *r))
.collect();
calls
.into_iter()
.map(|(id, c)| {
let r = results.get(&id).copied().unwrap_or(0);
(id, c, r)
})
.chain(orphaned)
.collect()
}
#[tokio::test]
async fn reused_call_id_across_turns_keeps_pairs_distinct() {
let messages = vec![
Message::user("q1"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("x", "Read", json!({"path": "x.rs"}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("ok"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"x",
"Read",
json!({"path": "x2.rs"}),
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("ok2"),
false,
)],
),
Message::assistant("a2"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(4)
.with_preserve_recent(6);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
for (id, calls, results) in part_counts(&outcome.messages) {
assert_eq!(
calls, results,
"each occurrence of reused call id {id:?} must keep its own \
pair — output carries {calls} calls and {results} results"
);
}
let turn_one_call_kept = outcome.messages.iter().any(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolCall { id, input, .. }
if id == "x" && input.get("path").is_some_and(|v| v == "x.rs")
)
})
});
let turn_one_result_kept = outcome.messages.iter().any(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolResult { call_id, output, .. }
if call_id == "x" && output.to_string().contains("ok")
)
})
});
assert!(
turn_one_call_kept && turn_one_result_kept,
"the straddled first-turn pair is kept whole, not dropped to the \
second turn's reused id"
);
}
#[tokio::test]
async fn first_message_pull_targets_its_own_result_occurrence() {
let messages = vec![
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("x", "Read", json!({"path": "x.rs"}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("first"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("x", "Read", json!({"path": "y.rs"}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("second"),
false,
)],
),
Message::assistant("a2"),
Message::user("q3"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(4)
.with_preserve_recent(4);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
for (id, calls, results) in part_counts(&outcome.messages) {
assert_eq!(
calls, results,
"id {id:?}: every kept call occurrence must have its own \
result occurrence — {calls} calls vs {results} results"
);
}
assert!(
outcome
.messages
.iter()
.any(|m| m.parts.iter().any(|p| matches!(
p,
MessagePart::ToolResult { output, .. } if output.to_string().contains("first")
))),
"the first message's own result is pulled back alongside its call"
);
}
#[test]
fn splitter_allows_a_split_between_turns_reusing_one_call_id() {
let messages = vec![
Message::user("q1"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("x", "Read", json!({"path": "x.rs"}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("ok"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"x",
"Read",
json!({"path": "x2.rs"}),
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"x",
"Read",
tool_text("ok2"),
false,
)],
),
Message::assistant("a2"),
];
let splitter = TokenSplitter::new()
.with_min_messages(4)
.with_preserve_recent(4);
let split = splitter.split(&messages);
assert!(
split.split_index > 0,
"a boundary between two complete turns is pair-safe even when \
they reuse one call id — refusing it keeps the whole \
conversation"
);
for (id, calls, results) in part_counts(&split.preserved) {
assert_eq!(
calls, results,
"id {id:?}: the kept portion holds complete pairs — {calls} \
calls vs {results} results"
);
}
}
#[tokio::test]
async fn orphaned_result_in_the_recent_slice_is_dropped() {
let messages = vec![
Message::user("q1"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("a", "Read", json!({"i": 1}))],
),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("b", "Read", json!({"i": 2}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"b",
"Read",
ToolContent::from_string("r3"),
false,
)],
),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("a", "Read", json!({"i": 4}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"b",
"Read",
ToolContent::from_string("r5"),
false,
)],
),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(2)
.with_preserve_recent(1);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
for (id, calls, results) in part_counts(&outcome.messages) {
assert_eq!(
calls, results,
"id {id:?}: the output must not carry a result with no call \
— {calls} calls vs {results} results"
);
}
}
#[tokio::test]
async fn pending_call_without_a_result_is_preserved() {
let messages = vec![
Message::user("q1"),
Message::assistant("a1"),
Message::user("q2"),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("c", "Read", json!({"i": 3}))],
),
Message::user("q3"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(2)
.with_preserve_recent(2);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
let pending_call_kept = outcome.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolCall { id, .. } if id == "c"))
});
assert!(
pending_call_kept,
"a recent call awaiting its result is a legal in-flight state \
and must survive compaction"
);
}
#[tokio::test]
async fn garbage_only_messages_are_dropped_rather_than_emptied() {
let messages = vec![
Message::new(
Role::User,
vec![MessagePart::tool_result(
"ghost",
"Read",
ToolContent::from_string("nowhere"),
false,
)],
),
Message::assistant("a1"),
Message::user("q2"),
Message::assistant("a2"),
Message::user("q3"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(2)
.with_preserve_recent(2);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
assert!(
outcome.messages.iter().all(|m| !m.parts.is_empty()),
"no message may survive as an empty shell after filtering"
);
assert!(
!outcome
.messages
.iter()
.any(|m| m.parts.iter().any(|p| matches!(
p,
MessagePart::ToolResult { call_id, .. } if call_id == "ghost"
))),
"the orphaned result is dropped with its emptied message"
);
}
#[tokio::test]
async fn compaction_never_returns_an_empty_list() {
let messages = vec![
Message::new(
Role::User,
vec![MessagePart::tool_result(
"ghost0",
"Read",
ToolContent::from_string("nowhere"),
false,
)],
),
Message::new(
Role::Assistant,
vec![MessagePart::tool_call("a", "Read", json!({"i": 1}))],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"a",
"Read",
tool_text("ok"),
false,
)],
),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"ghost3",
"Read",
ToolContent::from_string("nowhere"),
false,
)],
),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(2)
.with_preserve_recent(1);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
assert!(
!outcome.messages.is_empty(),
"compaction must never replace a non-empty history with an \
empty list (got {} messages)",
outcome.messages.len()
);
assert!(
outcome.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolCall { id, .. } if id == "a"))
}),
"the fallback keeps the valid pair rather than the garbage"
);
}
#[tokio::test]
async fn no_change_paths_do_not_carry_orphaned_results() {
let straddle = vec![
Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"c1",
"Read",
json!({"path": "a.rs"}),
)],
),
Message::user("q1"),
Message::assistant("a1"),
Message::new(
Role::User,
vec![
MessagePart::tool_result("c1", "Read", tool_text("ok"), false),
MessagePart::tool_result(
"ghost",
"Read",
ToolContent::from_string("nowhere"),
false,
),
],
),
Message::user("q2"),
Message::assistant("a2"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(4)
.with_preserve_recent(3);
let context = make_context(&straddle);
let outcome = compactor.compact(straddle, 1, context).await;
assert!(outcome.success);
assert!(
!outcome.messages.iter().any(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolResult { call_id, .. } if call_id == "ghost"
)
})
}),
"the split-zero no-change pass filters orphaned results like \
every other path"
);
let short = vec![
Message::user("q1"),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"ghost",
"Read",
ToolContent::from_string("nowhere"),
false,
)],
),
Message::assistant("a1"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(6)
.with_preserve_recent(3);
let context = make_context(&short);
let outcome = compactor.compact(short, 1, context).await;
assert!(outcome.success);
assert!(
!outcome.messages.iter().any(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolResult { call_id, .. } if call_id == "ghost"
)
})
}),
"the short-conversation no-change pass filters orphaned \
results like every other path"
);
}
#[tokio::test]
async fn first_message_calls_resolving_in_one_message_pull_it_once() {
let messages = vec![
Message::new(
Role::Assistant,
vec![
MessagePart::tool_call("c1", "Read", json!({"path": "a.rs"})),
MessagePart::tool_call("c2", "Read", json!({"path": "b.rs"})),
],
),
Message::new(
Role::User,
vec![
MessagePart::tool_result("c1", "Read", tool_text("ok1"), false),
MessagePart::tool_result("c2", "Read", tool_text("ok2"), false),
],
),
Message::user("q2"),
Message::assistant("a2"),
Message::user("q3"),
Message::assistant("a3"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(4)
.with_preserve_recent(3);
let context = make_context(&messages);
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
let shared_pulls = outcome
.messages
.iter()
.filter(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolResult { call_id, .. } if call_id == "c1"
)
})
})
.count();
assert_eq!(
shared_pulls, 1,
"the shared result message is pulled back exactly once"
);
for (id, calls, results) in part_counts(&outcome.messages) {
assert_eq!(
calls, results,
"id {id:?}: both first-message calls keep their results — \
{calls} calls vs {results} results"
);
}
}
#[tokio::test]
async fn sanitized_no_change_passes_report_their_savings() {
let messages = vec![
Message::user("a reasonably long first message"),
Message::new(
Role::User,
vec![MessagePart::tool_result(
"ghost",
"Read",
ToolContent::from_string("a sizeable orphaned payload"),
false,
)],
),
Message::assistant("a1"),
];
let compactor = TruncatingCompactor::new()
.with_min_messages(6)
.with_preserve_recent(3);
let tokens_before = crate::compact::CompactionOutcome::estimate_tokens(&messages);
let context = CompactionContext {
tokens_before,
reason: CompactReason::ThresholdExceeded,
context_window: 8_000,
turn: 3,
counter: std::sync::Arc::new(crate::compact::HeuristicTokenCounter),
instructions: None,
additional_context: Vec::new(),
};
let outcome = compactor.compact(messages, 1, context).await;
assert!(outcome.success);
assert_eq!(outcome.messages.len(), 2, "the garbage message is dropped");
assert!(
outcome.tokens_saved > 0,
"a pass that dropped content must report the reduction — \
claimed {} saved with tokens_after {}",
outcome.tokens_saved,
outcome.tokens_after
);
let clean = vec![Message::user("q1"), Message::assistant("a1")];
let tokens_before = crate::compact::CompactionOutcome::estimate_tokens(&clean);
let context = CompactionContext {
tokens_before,
reason: CompactReason::ThresholdExceeded,
context_window: 8_000,
turn: 3,
counter: std::sync::Arc::new(crate::compact::HeuristicTokenCounter),
instructions: None,
additional_context: Vec::new(),
};
let outcome = compactor.compact(clean, 1, context).await;
assert!(outcome.success);
assert_eq!(
outcome.tokens_saved, 0,
"nothing was filtered, so nothing is claimed as saved"
);
}
}