use std::collections::HashSet;
use std::sync::Arc;
use awaken_contract::contract::message::{Message, Role, Visibility};
pub fn find_compaction_boundary(
messages: &[Arc<Message>],
start: usize,
end: usize,
) -> Option<usize> {
let mut open_calls = HashSet::<String>::new();
let mut best_boundary = None;
for (idx, msg) in messages.iter().enumerate().skip(start).take(end - start) {
if let Some(ref calls) = msg.tool_calls {
for call in calls {
open_calls.insert(call.id.clone());
}
}
if msg.role == Role::Tool
&& let Some(ref call_id) = msg.tool_call_id
{
open_calls.remove(call_id);
}
let next_is_tool = messages
.get(idx + 1)
.is_some_and(|next| next.role == Role::Tool);
if open_calls.is_empty() && !next_is_tool {
best_boundary = Some(idx);
}
}
best_boundary
}
pub fn trim_to_compaction_boundary(messages: &mut Vec<Arc<Message>>) {
let last_summary_idx = messages.iter().rposition(|m| {
m.role == Role::System
&& m.visibility == Visibility::Internal
&& m.text().contains("<conversation-summary>")
});
if let Some(idx) = last_summary_idx
&& idx > 0
{
messages.drain(..idx);
}
}
pub fn record_compaction_boundary(
boundary: super::plugin::CompactionBoundary,
) -> super::plugin::CompactionAction {
super::plugin::CompactionAction::RecordBoundary(boundary)
}
#[cfg(test)]
mod tests {
use super::*;
use awaken_contract::contract::message::ToolCall;
use serde_json::json;
#[test]
fn find_compaction_boundary_respects_tool_pairs() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("start")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c1", "search", json!({}))],
)),
Arc::new(Message::tool("c1", "found")),
Arc::new(Message::user("next")), Arc::new(Message::assistant("reply")),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
assert!(boundary.is_some());
let b = boundary.unwrap();
assert!(b >= 3);
}
#[test]
fn trim_to_compaction_boundary_drops_pre_summary() {
let mut messages = vec![
Arc::new(Message::user("old msg 1")),
Arc::new(Message::assistant("old reply")),
Arc::new(Message::internal_system(
"<conversation-summary>\nSummary of old messages\n</conversation-summary>",
)),
Arc::new(Message::user("new msg")),
Arc::new(Message::assistant("new reply")),
];
trim_to_compaction_boundary(&mut messages);
assert_eq!(messages.len(), 3);
assert!(messages[0].text().contains("conversation-summary"));
assert_eq!(messages[1].text(), "new msg");
}
#[test]
fn trim_to_compaction_boundary_noop_without_summary() {
let mut messages = vec![
Arc::new(Message::user("hello")),
Arc::new(Message::assistant("hi")),
];
let len_before = messages.len();
trim_to_compaction_boundary(&mut messages);
assert_eq!(messages.len(), len_before);
}
#[test]
fn find_compaction_boundary_does_not_cut_open_tool_round() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("start")),
Arc::new(Message::assistant("reply")),
Arc::new(Message::user("next")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c1", "search", json!({}))],
)),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
if let Some(b) = boundary {
assert!(b <= 2, "boundary should not include open tool round");
}
}
#[test]
fn trim_to_compaction_boundary_idempotent() {
let mut messages = vec![
Arc::new(Message::user("old")),
Arc::new(Message::internal_system(
"<conversation-summary>\nSummary\n</conversation-summary>",
)),
Arc::new(Message::user("new")),
];
trim_to_compaction_boundary(&mut messages);
let len_after_first = messages.len();
trim_to_compaction_boundary(&mut messages);
assert_eq!(
messages.len(),
len_after_first,
"second trim should be noop"
);
}
#[test]
fn find_boundary_skips_open_tool_rounds() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("start")),
Arc::new(Message::assistant("ok")),
Arc::new(Message::user("do something")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c1", "search", json!({}))],
)),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
if let Some(b) = boundary {
assert!(b < 3, "boundary {b} must be before open tool call at idx 3");
}
}
#[test]
fn find_boundary_respects_suffix_messages() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("old1")),
Arc::new(Message::assistant("reply1")),
Arc::new(Message::user("old2")),
Arc::new(Message::assistant("reply2")),
Arc::new(Message::user("recent")),
Arc::new(Message::assistant("recent_reply")),
];
let suffix_count = 2;
let search_end = messages.len().saturating_sub(suffix_count);
let boundary = find_compaction_boundary(&messages, 0, search_end);
if let Some(b) = boundary {
assert!(
b < search_end,
"boundary {b} must be before suffix start {search_end}"
);
}
}
#[test]
fn find_boundary_returns_none_when_too_few_messages() {
let messages: Vec<Arc<Message>> = vec![Arc::new(Message::user("only message"))];
let boundary = find_compaction_boundary(&messages, 0, 0);
assert!(boundary.is_none(), "empty range should yield no boundary");
let messages2: Vec<Arc<Message>> = vec![Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c1", "fn", json!({}))],
))];
let boundary2 = find_compaction_boundary(&messages2, 0, messages2.len());
assert!(
boundary2.is_none(),
"single open tool call should yield no boundary"
);
}
#[test]
fn find_compaction_boundary_multiple_complete_tool_rounds() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("start")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c1", "search", json!({}))],
)),
Arc::new(Message::tool("c1", "found it")),
Arc::new(Message::user("next")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![ToolCall::new("c2", "read", json!({}))],
)),
Arc::new(Message::tool("c2", "content")),
Arc::new(Message::user("last")),
Arc::new(Message::assistant("done")),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
assert!(boundary.is_some());
let b = boundary.unwrap();
assert!(
b >= 6,
"boundary should be after all tool rounds: got {}",
b
);
}
#[test]
fn find_compaction_boundary_empty_range() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("hello")),
Arc::new(Message::assistant("hi")),
];
let boundary = find_compaction_boundary(&messages, 0, 0);
assert!(boundary.is_none(), "empty range should yield no boundary");
}
#[test]
fn find_compaction_boundary_range_start_equals_end() {
let messages: Vec<Arc<Message>> = vec![Arc::new(Message::user("only"))];
let boundary = find_compaction_boundary(&messages, 1, 1);
assert!(boundary.is_none());
}
#[test]
fn trim_to_compaction_boundary_uses_last_summary() {
let mut messages = vec![
Arc::new(Message::user("old msg 1")),
Arc::new(Message::internal_system(
"<conversation-summary>\nFirst summary\n</conversation-summary>",
)),
Arc::new(Message::user("mid msg")),
Arc::new(Message::internal_system(
"<conversation-summary>\nSecond summary\n</conversation-summary>",
)),
Arc::new(Message::user("new msg")),
];
trim_to_compaction_boundary(&mut messages);
assert_eq!(messages.len(), 2);
assert!(messages[0].text().contains("Second summary"));
assert_eq!(messages[1].text(), "new msg");
}
#[test]
fn find_compaction_boundary_with_multiple_tool_calls_in_one_round() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("do things")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![
ToolCall::new("c1", "search", json!({})),
ToolCall::new("c2", "read", json!({})),
],
)),
Arc::new(Message::tool("c1", "found")),
Arc::new(Message::tool("c2", "content")),
Arc::new(Message::user("thanks")),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
assert!(boundary.is_some());
let b = boundary.unwrap();
assert!(
b >= 3,
"boundary should be after all tool results: got {}",
b
);
}
#[test]
fn find_compaction_boundary_partial_tool_results() {
let messages: Vec<Arc<Message>> = vec![
Arc::new(Message::user("start")),
Arc::new(Message::assistant_with_tool_calls(
"",
vec![
ToolCall::new("c1", "search", json!({})),
ToolCall::new("c2", "read", json!({})),
],
)),
Arc::new(Message::tool("c1", "found")),
];
let boundary = find_compaction_boundary(&messages, 0, messages.len());
if let Some(b) = boundary {
assert!(b < 1, "boundary should not include incomplete tool round");
}
}
}