use super::*;
use rig_core::message::{AssistantMessage, CallId, ToolCall, ToolFunction, ToolName, ToolResult};
fn call(id: &str, tool: &str, path: &str) -> Message {
Message::Assistant(AssistantMessage::new(vec![AssistantContent::ToolCall(
ToolCall::new(
CallId::from_wire(id),
ToolFunction::new(
ToolName::new(tool).expect("tool name"),
rig_core::serde_json::json!({ "path": path }),
),
),
)]))
}
fn result(id: &str, output: &str) -> Message {
Message::User {
content: vec![UserContent::ToolResult(ToolResult {
is_error: false,
call: CallId::from_wire(id),
name: ToolName::new("read").expect("tool name"),
content: vec![ToolResultContent::text(output)],
})],
}
}
#[test]
fn a_later_set_overrides_an_earlier_one_in_the_summary() {
let mut state = SummaryState {
summary: "did things".to_owned(),
tracked: Vec::new(),
};
let messages = [
call("1", "read", "a.rs"),
call("2", "read", "b.rs"),
call("3", "edit", "b.rs"),
];
state.track(
&messages,
&[
TrackArgument::new("read", "path", "read-files"),
TrackArgument::new("edit", "path", "modified-files"),
],
);
let text = state.message().expect("a summary");
assert!(text.contains("<read-files>\na.rs\n</read-files>"), "{text}");
assert!(
text.contains("<modified-files>\nb.rs\n</modified-files>"),
"{text}"
);
}
#[test]
fn a_cut_never_separates_a_call_from_its_result() {
let messages = [
Message::user("start"),
call("1", "read", "a.rs"),
result("1", "x"),
Message::assistant("done"),
]
.map(Arc::new);
let counter = HeuristicTokenCounter::default();
assert_eq!(cut_at(&messages, 0, 0, false, &counter), Some(3));
assert_eq!(cut_at(&messages, 0, usize::MAX, false, &counter), None);
assert_eq!(cut_at(&messages, 0, usize::MAX, true, &counter), Some(3));
}
fn spec(window: u32) -> Option<ModelSpec> {
rig_core::providers::registry::ProviderId::catalog("ollama")
.map(|vendor| ModelSpec::new(vendor, "test").with_context_window(window))
}
fn cut(messages: &[Arc<Message>], window: u32, reason: &CompactReason) -> Option<usize> {
let spec = spec(window);
assert!(spec.is_some());
CompactionPolicy::default().cut(messages, 0, reason, spec.as_ref())
}
fn turns(count: usize) -> Vec<Arc<Message>> {
let answer = "word ".repeat(2_000);
(0..count)
.flat_map(|turn| {
[
Message::user(format!("question {turn}")),
Message::assistant(answer.clone()),
]
})
.map(Arc::new)
.collect()
}
#[test]
fn a_compaction_keeps_the_recent_work_unless_asked() {
let (short, long) = (turns(2), turns(20));
let asked = CompactReason::Asked {
focus: String::new(),
};
assert_eq!(cut(&short, 200_000, &asked), Some(short.len() - 1));
let overflow = CompactReason::Overflow;
assert_eq!(cut(&short, 200_000, &overflow), Some(short.len() - 1));
assert_eq!(cut(&short, 200_000, &CompactReason::Threshold), None);
let at = cut(&long, 200_000, &overflow);
assert!(at.is_some_and(|at| at < long.len() - 2), "{at:?}");
let policy = CompactionPolicy::default();
let keep = |window| policy.keep(&CompactReason::Threshold, spec(window).as_ref());
assert_eq!((keep(200_000), keep(40_000)), (20_000, 10_000));
}
#[test]
fn clearing_keeps_the_newest_outputs_and_the_last_message() {
let big = "x".repeat(4_000);
let mut messages = [
call("1", "read", "a.rs"),
result("1", &big),
call("2", "read", "b.rs"),
result("2", &big),
result("3", &big),
]
.map(Arc::new);
let policy = ClearToolOutputs::new(1_500);
let cleared = policy.clear(&mut messages);
assert_eq!(cleared.results, 1);
assert_eq!(cleared.tokens, 1_000);
assert!(is_cleared(match &*messages[1] {
Message::User { content } => match &content[0] {
UserContent::ToolResult(result) => &result.content,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
}));
assert_eq!(policy.clear(&mut messages), Cleared::default());
}
#[test]
fn clearing_skips_errors_and_leaves_the_other_parts_alone() {
let big = "x".repeat(4_000);
let output = |id: &str, is_error: bool| {
UserContent::ToolResult(ToolResult {
is_error,
call: CallId::from_wire(id),
name: ToolName::new("read").expect("tool name"),
content: vec![ToolResultContent::text(big.as_str())],
})
};
let mut messages = [
call("1", "read", "a.rs"),
Message::User {
content: vec![
UserContent::text("see"),
output("1", false),
output("2", false),
],
},
Message::User {
content: vec![output("3", true)],
},
Message::assistant("done"),
]
.map(Arc::new);
let policy = ClearToolOutputs::new(1_500);
assert_eq!(policy.clear(&mut messages).results, 1);
let cleared = |message: &Message| -> Vec<bool> {
let Message::User { content } = message else {
return Vec::new();
};
content
.iter()
.map(|item| matches!(item, UserContent::ToolResult(result) if is_cleared(&result.content)))
.collect()
};
let parts: Vec<Vec<bool>> = messages.iter().map(|message| cleared(message)).collect();
assert_eq!(
parts,
[vec![], vec![false, true, false], vec![false], vec![]]
);
assert_eq!(policy.clear(&mut []), Cleared::default());
}
#[test]
fn a_policy_reads_from_a_table_keeping_what_it_does_not_name() {
let base = CompactionPolicy {
tracked: vec![TrackArgument::new("read", "path", "read-files")],
..CompactionPolicy::default()
};
let json = serde_json::to_value(&base).expect("serde");
assert!(json.get("limits").is_some(), "{json}");
let back: CompactionPolicy = serde_json::from_value(json).expect("serde");
assert_eq!(back, base);
let policy: CompactionPolicy =
serde_json::from_value(serde_json::json!({"keep_recent": 1000})).expect("table");
assert_eq!(policy.keep_recent, 1000);
assert_eq!(policy.summarizer, Summarizer::DEFAULT);
let reserve: CompactionPolicy =
serde_json::from_value(serde_json::json!({"limits": {"reserve": 1}})).expect("table");
let limits = reserve.summarizer.limits;
assert_eq!(
(limits.reserve, limits.summary_tokens),
(1, SummaryLimits::DEFAULT.summary_tokens)
);
assert!(serde_json::from_value::<CompactionPolicy>(serde_json::json!({"keep": 1})).is_err());
}
#[test]
fn a_summary_takes_the_place_of_what_it_summarized_by_id() {
let big = "x".repeat(4_000);
let mut conversation = Conversation::from_messages([
Message::user("start"),
call("1", "read", "a.rs"),
result("1", &big),
call("2", "read", "b.rs"),
result("2", &big),
Message::assistant("done"),
]);
let ids: Vec<MessageId> = conversation.entries().map(|entry| entry.id).collect();
let cleared =
ClearToolOutputs::new(1_500).clear_conversation(&mut conversation, summary_author());
assert_eq!(cleared.results, 1);
let kept: Vec<MessageId> = conversation.entries().map(|entry| entry.id).collect();
assert_eq!(kept, ids, "clearing keeps every id");
let last = |conversation: &mut Conversation| {
let mut changes = conversation.take_unsaved().into_iter().rev();
changes.find_map(|recorded| summary_in(&recorded.change))
};
assert_eq!(last(&mut conversation), None, "clearing is no compaction");
let state = SummaryState {
summary: "read two files".to_owned(),
tracked: Vec::new(),
};
let upto = conversation.id(5);
conversation.splice(0..1, [], Author::plugin("other"));
let spliced = apply_summary(&mut conversation, upto, &state).expect("applied");
assert_eq!(spliced.removed.len(), 4);
assert_eq!(conversation.messages().len(), 2);
assert_eq!(summarized_prefix(&conversation), 1);
assert_eq!(conversation.author(0), Some(&summary_author()));
assert_eq!(last(&mut conversation), conversation.id(0));
conversation.splice(1.., [], Author::plugin("other"));
assert_eq!(apply_summary(&mut conversation, upto, &state), None);
}
#[test]
fn compactions_are_undone_newest_first_with_their_ids_and_states() {
use crate::{MemoryStore, load, save};
let store = MemoryStore::default();
let mut conversation = load(&store, "agent").expect("loaded");
for text in ["one", "two", "three", "four"] {
conversation.push(Message::user(text));
}
let original = conversation.clone();
let state = |summary: &str| SummaryState {
summary: summary.to_owned(),
tracked: Vec::new(),
};
let upto = conversation.id(2);
apply_summary(&mut conversation, upto, &state("first")).expect("applied");
save(&store, "agent", &mut conversation).expect("saved");
let upto = conversation.id(2);
apply_summary(&mut conversation, upto, &state("second")).expect("applied");
save(&store, "agent", &mut conversation).expect("saved");
assert_eq!(conversation.messages().len(), 2);
assert_eq!(Compacted::of(&conversation).count(), 2);
let by = Author::plugin("undo");
let undone = undo_summary(&mut conversation, &store, "agent", by.clone()).expect("undone");
assert_eq!(undone.state, state("first"));
assert_eq!(summary_state(&conversation), state("first"));
assert_eq!(undone.restored.len(), 2, "the first summary and \"three\"");
assert_eq!(conversation.messages().len(), 3);
save(&store, "agent", &mut conversation).expect("saved");
let undone = undo_summary(&mut conversation, &store, "agent", by.clone()).expect("undone");
assert_eq!(undone.state, SummaryState::default());
assert_eq!(summary_state(&conversation), SummaryState::default());
assert_eq!(conversation.messages(), original.messages());
let ids = |conversation: &Conversation| -> Vec<MessageId> {
conversation.entries().map(|entry| entry.id).collect()
};
assert_eq!(ids(&conversation), ids(&original));
assert_eq!(Compacted::of(&conversation).count(), 0);
assert!(matches!(
undo_summary(&mut conversation, &store, "agent", by),
Err(UndoError::Nothing)
));
save(&store, "agent", &mut conversation).expect("saved");
assert_eq!(load(&store, "agent").expect("loaded"), conversation);
}
#[test]
fn a_clear_plan_keeps_small_outputs_and_its_notes_survive_the_next_clearing() {
let (big, small) = ("x".repeat(4_000), "y".repeat(400));
let mut conversation = Conversation::from_messages([
Message::user("start"),
call("1", "read", "a.rs"),
result("1", &big),
call("2", "read", "b.rs"),
result("2", &small),
call("3", "read", "c.rs"),
result("3", &big),
call("4", "read", "d.rs"),
result("4", &big),
Message::assistant("done"),
]);
let ids: Vec<MessageId> = conversation.entries().map(|entry| entry.id).collect();
let clearing = ClearToolOutputs {
keep_tokens: 1_500,
keep_under: 500,
};
let plan = clearing.plan(&conversation);
let planned: Vec<CallId> = plan
.results(&conversation)
.map(|result| result.call.clone())
.collect();
assert_eq!(planned, [CallId::from_wire("1"), CallId::from_wire("3")]);
assert_eq!(plan.through, conversation.id(6));
let cleared = plan.apply(
&mut conversation,
Some("a.rs and c.rs hold x"),
summary_author(),
);
assert_eq!(cleared, plan.cleared);
let kept: Vec<MessageId> = conversation.entries().map(|entry| entry.id).collect();
assert_eq!(kept, ids);
let parts = |conversation: &Conversation| -> Vec<String> {
let messages = conversation.messages().iter();
let content = messages.filter_map(|message| match &**message {
Message::User { content } => Some(content),
_ => None,
});
content
.flatten()
.map(|part| match part {
UserContent::ToolResult(result) if is_cleared(&result.content) => "cleared".into(),
UserContent::ToolResult(_) => "output".into(),
UserContent::Text(text) if text.text.contains("a.rs and c.rs hold x") => {
"notes".into()
}
_ => "text".into(),
})
.collect()
};
let expected = ["text", "cleared", "output", "cleared", "notes", "output"];
assert_eq!(parts(&conversation), expected);
let everything = ClearToolOutputs {
keep_tokens: 0,
keep_under: 0,
};
everything.clear_conversation(&mut conversation, summary_author());
let expected = ["text", "cleared", "cleared", "cleared", "notes", "cleared"];
assert_eq!(parts(&conversation), expected);
}