1use std::sync::Arc;
32
33use async_trait::async_trait;
34
35use crate::error::Result;
36use crate::memory::{ContextProvider, SessionContext};
37use crate::types::{Content, Message, Role};
38
39pub trait Tokenizer: Send + Sync {
42 fn count_tokens(&self, text: &str) -> usize;
44}
45
46#[derive(Debug, Clone, Copy, Default)]
49pub struct ApproxTokenizer;
50
51impl Tokenizer for ApproxTokenizer {
52 fn count_tokens(&self, text: &str) -> usize {
53 text.chars().count().div_ceil(4)
54 }
55}
56
57pub fn count_message_tokens(tokenizer: &dyn Tokenizer, message: &Message) -> usize {
60 message
61 .contents
62 .iter()
63 .map(|content| count_content_tokens(tokenizer, content))
64 .sum()
65}
66
67fn count_content_tokens(tokenizer: &dyn Tokenizer, content: &Content) -> usize {
80 fn value_text(value: &serde_json::Value) -> String {
81 match value {
82 serde_json::Value::String(s) => s.clone(),
83 other => other.to_string(),
84 }
85 }
86
87 match content {
88 Content::Text(t) => tokenizer.count_tokens(&t.text),
89 Content::TextReasoning(t) => tokenizer.count_tokens(&t.text),
90 Content::FunctionCall(fc) => {
91 let arguments = match &fc.arguments {
92 Some(crate::types::FunctionArguments::Raw(raw)) => raw.clone(),
93 Some(crate::types::FunctionArguments::Object(map)) => {
94 serde_json::to_string(map).unwrap_or_default()
95 }
96 None => String::new(),
97 };
98 tokenizer.count_tokens(&fc.name) + tokenizer.count_tokens(&arguments)
99 }
100 Content::FunctionResult(fr) => {
101 let mut total = fr
102 .result
103 .as_ref()
104 .map_or(0, |v| tokenizer.count_tokens(&value_text(v)));
105 if let Some(exception) = &fr.exception {
106 total += tokenizer.count_tokens(exception);
107 }
108 total
109 }
110 _ => 0,
111 }
112}
113
114pub trait CompactionStrategy: Send + Sync {
120 fn compact(&self, messages: &[Message], tokenizer: &dyn Tokenizer) -> Vec<Message>;
122}
123
124fn leading_system_count(messages: &[Message]) -> usize {
126 messages
127 .iter()
128 .take_while(|m| m.role == Role::system())
129 .count()
130}
131
132#[derive(Debug, Clone, Copy)]
135pub struct Truncation {
136 pub max_messages: usize,
137}
138
139impl Truncation {
140 pub fn new(max_messages: usize) -> Self {
141 Self { max_messages }
142 }
143}
144
145impl CompactionStrategy for Truncation {
146 fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
147 if messages.len() <= self.max_messages {
148 return messages.to_vec();
149 }
150 let sys_count = leading_system_count(messages);
151 let mut out: Vec<Message> = messages[..sys_count].to_vec();
152
153 if sys_count >= self.max_messages {
154 out.truncate(self.max_messages);
157 return out;
158 }
159
160 let remaining_budget = self.max_messages - sys_count;
161 let rest = &messages[sys_count..];
162 let start = rest.len().saturating_sub(remaining_budget);
163 out.extend_from_slice(&rest[start..]);
164 out
165 }
166}
167
168#[derive(Debug, Clone, Copy)]
171pub struct SlidingWindow {
172 pub window: usize,
173}
174
175impl SlidingWindow {
176 pub fn new(window: usize) -> Self {
177 Self { window }
178 }
179}
180
181impl CompactionStrategy for SlidingWindow {
182 fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
183 let sys_count = leading_system_count(messages);
184 let mut out: Vec<Message> = messages[..sys_count].to_vec();
185 let rest = &messages[sys_count..];
186 let start = rest.len().saturating_sub(self.window);
187 out.extend_from_slice(&rest[start..]);
188 out
189 }
190}
191
192#[derive(Debug, Clone, Copy)]
197pub struct TokenBudget {
198 pub max_tokens: usize,
199}
200
201impl TokenBudget {
202 pub fn new(max_tokens: usize) -> Self {
203 Self { max_tokens }
204 }
205}
206
207impl CompactionStrategy for TokenBudget {
208 fn compact(&self, messages: &[Message], tokenizer: &dyn Tokenizer) -> Vec<Message> {
209 let sys_count = leading_system_count(messages);
210 let system_prefix = &messages[..sys_count];
211 let rest = &messages[sys_count..];
212
213 let mut used: usize = system_prefix
214 .iter()
215 .map(|m| count_message_tokens(tokenizer, m))
216 .sum();
217
218 let mut kept_rest: Vec<&Message> = Vec::new();
224 for message in rest.iter().rev() {
225 let cost = count_message_tokens(tokenizer, message);
226 if !kept_rest.is_empty() && used + cost > self.max_tokens {
227 break;
228 }
229 used += cost;
230 kept_rest.push(message);
231 }
232 kept_rest.reverse();
233
234 let mut out: Vec<Message> = system_prefix.to_vec();
235 out.extend(kept_rest.into_iter().cloned());
236 out
237 }
238}
239
240fn has_tool_result(message: &Message) -> bool {
243 message
244 .contents
245 .iter()
246 .any(|c| matches!(c, Content::FunctionResult(_)))
247}
248
249#[derive(Debug, Clone, Copy)]
265pub struct SelectiveToolResult {
266 pub keep_last: usize,
267}
268
269impl SelectiveToolResult {
270 pub fn new(keep_last: usize) -> Self {
271 Self { keep_last }
272 }
273}
274
275pub const OMITTED_TOOL_RESULT: &str = "[tool result omitted by compaction]";
280
281impl CompactionStrategy for SelectiveToolResult {
282 fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
283 let tool_result_count = messages.iter().filter(|m| has_tool_result(m)).count();
284 let mut strip_budget = tool_result_count.saturating_sub(self.keep_last);
285
286 let mut out = Vec::with_capacity(messages.len());
287 for message in messages {
288 if has_tool_result(message) && strip_budget > 0 {
289 strip_budget -= 1;
290 let mut compacted = message.clone();
291 for content in &mut compacted.contents {
292 if let Content::FunctionResult(fr) = content {
293 fr.result =
294 Some(serde_json::Value::String(OMITTED_TOOL_RESULT.to_string()));
295 if fr.exception.is_some() {
304 fr.exception = Some(OMITTED_TOOL_RESULT.to_string());
305 }
306 }
307 }
308 out.push(compacted);
309 } else {
310 out.push(message.clone());
311 }
312 }
313 out
314 }
315}
316
317fn drop_orphaned_tool_exchanges(
340 messages: &mut Vec<Message>,
341 origins: &mut Vec<Option<usize>>,
342 pending: &[Message],
343) {
344 use std::collections::HashSet;
345
346 let paired: HashSet<Site> = {
354 let (pairs, _) = pair_tool_exchanges(messages.iter().chain(pending.iter()).enumerate());
358 pairs
359 .into_iter()
360 .flat_map(|(call_site, result_site)| [call_site, result_site])
361 .collect()
362 };
363
364 let has_orphan = messages.iter().enumerate().any(|(mi, message)| {
367 message.contents.iter().enumerate().any(|(ci, content)| {
368 matches!(
369 content,
370 Content::FunctionCall(_) | Content::FunctionResult(_)
371 ) && !paired.contains(&(mi, ci))
372 })
373 });
374 if !has_orphan {
375 return;
376 }
377
378 let mut mi = 0;
382 let mut kept = 0;
383 messages.retain_mut(|message| {
384 let this = mi;
385 mi += 1;
386 let had_contents = !message.contents.is_empty();
387 let mut ci = 0;
388 message.contents.retain(|content| {
389 let keep = !matches!(
390 content,
391 Content::FunctionCall(_) | Content::FunctionResult(_)
392 ) || paired.contains(&(this, ci));
393 ci += 1;
394 keep
395 });
396 let keep_message = !(had_contents && message.contents.is_empty());
399 if keep_message {
400 origins.swap(kept, this);
401 kept += 1;
402 }
403 keep_message
404 });
405 origins.truncate(kept);
406}
407
408fn ensure_non_system_message(
422 original: &[Message],
423 mut retained: Vec<Message>,
424 origins: &[Option<usize>],
425) -> Vec<Message> {
426 if retained
430 .iter()
431 .any(|m| m.role != Role::system() && message_renders_on_every_provider(m))
432 {
433 return retained;
434 }
435 let plain = original
440 .iter()
441 .enumerate()
442 .rev()
443 .filter(|(_, m)| m.role != Role::system() && m.role != Role::tool())
450 .find_map(|(index, m)| {
451 let mut candidate = m.clone();
452 let role = candidate.role.clone();
460 candidate.contents.retain(|c| {
461 renders_on_every_provider(c)
462 && !matches!(c, Content::FunctionCall(_) | Content::FunctionResult(_))
463 && !(matches!(c, Content::Data(_)) && role == Role::assistant())
467 });
468 (!candidate.contents.is_empty()).then_some((index, candidate))
469 });
470 if let Some((index, plain)) = plain {
471 let position = chronological_position(origins, index);
472 retained.insert(position, plain);
473 return retained;
474 }
475 if let Some((origin_index, exchange)) = latest_complete_tool_exchange(original) {
481 let position = chronological_position(origins, origin_index);
482 for (offset, message) in exchange.into_iter().enumerate() {
483 retained.insert(position + offset, message);
484 }
485 }
486 retained
487}
488
489type Site = (usize, usize);
491
492type ExchangePair = (Site, Site);
494
495type UnansweredCall<'a> = (Site, &'a str);
497
498fn pair_tool_exchanges<'a>(
507 messages: impl Iterator<Item = (usize, &'a Message)>,
508) -> (Vec<ExchangePair>, Vec<UnansweredCall<'a>>) {
509 use std::collections::{HashMap, VecDeque};
510
511 let mut unanswered: HashMap<&str, VecDeque<Site>> = HashMap::new();
512 let mut pairs: Vec<ExchangePair> = Vec::new();
513 for (mi, message) in messages {
514 for (ci, content) in message.contents.iter().enumerate() {
515 match content {
516 Content::FunctionCall(fc) if !fc.call_id.is_empty() => {
517 unanswered
518 .entry(fc.call_id.as_str())
519 .or_default()
520 .push_back((mi, ci));
521 }
522 Content::FunctionResult(fr) if !fr.call_id.is_empty() => {
523 if let Some(call_site) = unanswered
524 .get_mut(fr.call_id.as_str())
525 .and_then(VecDeque::pop_back)
526 {
527 pairs.push((call_site, (mi, ci)));
528 }
529 }
530 _ => {}
531 }
532 }
533 }
534 let mut leftover: Vec<UnansweredCall<'_>> = unanswered
535 .into_iter()
536 .flat_map(|(id, sites)| sites.into_iter().map(move |site| (site, id)))
537 .collect();
538 leftover.sort_unstable_by_key(|(site, _)| *site);
539 (pairs, leftover)
540}
541
542fn origin_indices(retained: &[Message], original: &[Message]) -> Vec<Option<usize>> {
556 let mut assigned: Vec<Option<usize>> = vec![None; retained.len()];
557 let mut limit = original.len();
558 for index in (0..retained.len()).rev() {
559 match original[..limit]
560 .iter()
561 .rposition(|candidate| *candidate == retained[index])
562 {
563 Some(found) => {
564 assigned[index] = Some(found);
565 limit = found;
566 }
567 None => break,
568 }
569 }
570 assigned
571}
572
573fn chronological_position(origins: &[Option<usize>], origin_index: usize) -> usize {
584 origins
585 .iter()
586 .position(|slot| matches!(slot, Some(index) if *index > origin_index))
587 .unwrap_or(origins.len())
588}
589
590fn latest_complete_tool_exchange(messages: &[Message]) -> Option<(usize, Vec<Message>)> {
596 let (pairs, _) = pair_tool_exchanges(
597 messages
598 .iter()
599 .enumerate()
600 .filter(|(_, m)| m.role != Role::system()),
601 );
602 let ((call_mi, call_ci), (result_mi, result_ci)) = pairs.last().copied()?;
603 let messages = if call_mi == result_mi {
606 vec![reduced_call_with_signature(
607 &messages[call_mi],
608 call_ci,
609 &[result_ci],
610 )]
611 } else {
612 vec![
613 reduced_call_with_signature(&messages[call_mi], call_ci, &[]),
614 reduced_to(&messages[result_mi], &[result_ci]),
615 ]
616 };
617 Some((call_mi, messages))
618}
619
620fn renders_on_every_provider(content: &Content) -> bool {
627 content.renders_on_every_provider()
628}
629
630fn message_renders_on_every_provider(message: &Message) -> bool {
639 message.contents.iter().any(|content| {
640 content.renders_on_every_provider()
641 && !(matches!(content, Content::Data(_)) && message.role == Role::assistant())
642 })
643}
644
645fn reduced_call_with_signature(message: &Message, call_ci: usize, also_keep: &[usize]) -> Message {
654 let mut keep = vec![call_ci];
655 keep.extend_from_slice(also_keep);
656 keep.sort_unstable();
657 let mut out = reduced_to(message, &keep);
658 if let Some(Content::FunctionCall(fc)) = out
659 .contents
660 .iter_mut()
661 .find(|c| matches!(c, Content::FunctionCall(_)))
662 {
663 if fc.protected_data.is_none() {
664 fc.protected_data = preceding_reasoning_signature(message, call_ci);
665 }
666 }
667 out
668}
669
670fn reduced_to(message: &Message, keep: &[usize]) -> Message {
672 let mut out = message.clone();
673 let mut index = 0;
674 out.contents.retain(|_| {
675 let keep_this = keep.contains(&index);
676 index += 1;
677 keep_this
678 });
679 out
680}
681
682fn reinstate_calls_answered_by_pending(
697 original: &[Message],
698 retained: &mut Vec<Message>,
699 origins: &mut Vec<Option<usize>>,
700 pending: &[Message],
701) {
702 let mut wanted = unanswered_result_counts(pending);
709 if wanted.is_empty() {
710 return;
711 }
712 let (_, retained_unanswered) = pair_tool_exchanges(retained.iter().enumerate());
719 let mut supplied: Vec<&crate::types::FunctionCallContent> = retained_unanswered
720 .iter()
721 .filter_map(|((mi, ci), _)| retained[*mi].contents[*ci].as_function_call())
722 .collect();
723
724 let (_, original_unanswered) = pair_tool_exchanges(original.iter().enumerate());
727 let mut chosen: Vec<Site> = Vec::new();
728 for (site, call_id) in original_unanswered.into_iter().rev() {
729 let Some(call) = original[site.0].contents[site.1].as_function_call() else {
730 continue;
731 };
732 if let Some(position) = supplied.iter().position(|supplied| *supplied == call) {
733 supplied.remove(position);
736 continue;
737 }
738 let Some(count) = wanted.get_mut(call_id) else {
739 continue;
740 };
741 if *count == 0 {
742 continue;
743 }
744 *count -= 1;
745 chosen.push(site);
746 }
747 chosen.sort_unstable();
754 let mut index = 0;
755 while index < chosen.len() {
756 let (mi, _) = chosen[index];
757 let mut group = Vec::new();
758 while index < chosen.len() && chosen[index].0 == mi {
759 group.push(chosen[index].1);
760 index += 1;
761 }
762 let (first, rest) = group.split_first().expect("a group has at least one call");
763 retained.push(reduced_call_with_signature(&original[mi], *first, rest));
764 origins.push(Some(mi));
765 }
766}
767
768fn preceding_reasoning_signature(message: &Message, index: usize) -> Option<String> {
775 for content in message.contents[..index].iter().rev() {
776 match content {
777 Content::TextReasoning(t) => {
778 if let Some(signature) = t.protected_data.as_deref().filter(|s| !s.is_empty()) {
779 return Some(signature.to_string());
780 }
781 }
782 Content::FunctionCall(_) => {}
786 _ => return None,
787 }
788 }
789 None
790}
791
792fn unanswered_result_counts(messages: &[Message]) -> std::collections::HashMap<&str, usize> {
799 use std::collections::HashMap;
800
801 let mut available_calls: HashMap<&str, usize> = HashMap::new();
802 let mut unanswered: HashMap<&str, usize> = HashMap::new();
803 for content in messages.iter().flat_map(|m| m.contents.iter()) {
804 match content {
805 Content::FunctionCall(fc) if !fc.call_id.is_empty() => {
806 *available_calls.entry(fc.call_id.as_str()).or_insert(0) += 1;
807 }
808 Content::FunctionResult(fr) if !fr.call_id.is_empty() => {
809 match available_calls.get_mut(fr.call_id.as_str()) {
810 Some(count) if *count > 0 => *count -= 1,
811 _ => *unanswered.entry(fr.call_id.as_str()).or_insert(0) += 1,
812 }
813 }
814 _ => {}
815 }
816 }
817 unanswered.retain(|_, count| *count > 0);
818 unanswered
819}
820
821fn finalize_compaction(
837 original: &[Message],
838 retained: Vec<Message>,
839 pending: &[Message],
840) -> Vec<Message> {
841 let mut origins = origin_indices(&retained, original);
843 let mut retained = retained;
844 reinstate_calls_answered_by_pending(original, &mut retained, &mut origins, pending);
845 drop_orphaned_tool_exchanges(&mut retained, &mut origins, pending);
846 if pending
853 .iter()
854 .any(|m| m.role != Role::system() && message_renders_on_every_provider(m))
855 {
856 return retained;
857 }
858 ensure_non_system_message(original, retained, &origins)
859}
860
861pub fn compact(
868 messages: &[Message],
869 strategy: &dyn CompactionStrategy,
870 tokenizer: &dyn Tokenizer,
871) -> Vec<Message> {
872 finalize_compaction(messages, strategy.compact(messages, tokenizer), &[])
873}
874
875pub struct CompactionProvider {
889 strategy: Arc<dyn CompactionStrategy>,
890 tokenizer: Box<dyn Tokenizer>,
891}
892
893impl CompactionProvider {
894 pub fn new(strategy: impl CompactionStrategy + 'static) -> Self {
897 Self::with_tokenizer(strategy, ApproxTokenizer)
898 }
899
900 pub fn with_tokenizer(
902 strategy: impl CompactionStrategy + 'static,
903 tokenizer: impl Tokenizer + 'static,
904 ) -> Self {
905 Self {
906 strategy: Arc::new(strategy),
907 tokenizer: Box::new(tokenizer),
908 }
909 }
910}
911
912#[async_trait]
913impl ContextProvider for CompactionProvider {
914 async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
917 let retained = self.strategy.compact(&ctx.messages, &*self.tokenizer);
918 ctx.messages = finalize_compaction(&ctx.messages, retained, &ctx.input_messages);
926 Ok(())
927 }
928
929 }
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use crate::types::FunctionResultContent;
938 use serde_json::json;
939
940 fn text(role: Role, s: &str) -> Message {
941 Message::new(role, s)
942 }
943
944 fn tool_result_message(call_id: &str, result: &str) -> Message {
945 Message::with_contents(
946 Role::tool(),
947 vec![Content::FunctionResult(FunctionResultContent::new(
948 call_id,
949 Some(json!(result)),
950 ))],
951 )
952 }
953
954 fn tool_call_message(call_id: &str, name: &str) -> Message {
955 Message::with_contents(
956 Role::assistant(),
957 vec![Content::FunctionCall(
958 crate::types::FunctionCallContent::new(call_id, name, None),
959 )],
960 )
961 }
962
963 fn call_ids_in(messages: &[Message], f: fn(&Content) -> bool) -> Vec<String> {
964 messages
965 .iter()
966 .flat_map(|m| m.contents.iter())
967 .filter(|c| f(c))
968 .filter_map(|c| match c {
969 Content::FunctionCall(fc) => Some(fc.call_id.clone()),
970 Content::FunctionResult(fr) => Some(fr.call_id.clone()),
971 _ => None,
972 })
973 .collect()
974 }
975
976 #[test]
979 fn selective_tool_result_compacts_a_failed_calls_exception_too() {
980 let failed = Message::with_contents(
984 Role::tool(),
985 vec![Content::FunctionResult(FunctionResultContent {
986 call_id: "c1".into(),
987 result: None,
988 exception: Some("Traceback: ...a very long stack trace...".into()),
989 })],
990 );
991 let messages = vec![
992 tool_call_message("c1", "t1"),
993 failed,
994 tool_call_message("c2", "t2"),
995 tool_result_message("c2", "fresh"),
996 ];
997 let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
998
999 let compacted = &out[1].function_results()[0];
1000 assert_eq!(compacted.exception.as_deref(), Some(OMITTED_TOOL_RESULT));
1002 assert_eq!(out[3].function_results()[0].result, Some(json!("fresh")));
1004 }
1005
1006 #[test]
1007 fn selective_tool_result_does_not_invent_an_exception_on_a_successful_result() {
1008 let messages = vec![
1009 tool_call_message("c1", "t1"),
1010 tool_result_message("c1", "stale"),
1011 tool_call_message("c2", "t2"),
1012 tool_result_message("c2", "fresh"),
1013 ];
1014 let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
1015 assert!(out[1].function_results()[0].exception.is_none());
1016 }
1017
1018 #[test]
1019 fn selective_tool_result_compacts_the_payload_without_orphaning_the_call() {
1020 let messages = vec![
1024 tool_call_message("call_1", "get_weather"),
1025 tool_result_message("call_1", "a very long stale payload"),
1026 tool_call_message("call_2", "get_time"),
1027 tool_result_message("call_2", "noon"),
1028 ];
1029 let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
1030
1031 assert_eq!(
1032 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1033 vec!["call_1", "call_2"]
1034 );
1035 assert_eq!(
1036 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1037 vec!["call_1", "call_2"]
1038 );
1039 assert_eq!(
1040 out[1].function_results()[0].result,
1041 Some(json!(OMITTED_TOOL_RESULT))
1042 );
1043 assert_eq!(out[3].function_results()[0].result, Some(json!("noon")));
1045 }
1046
1047 #[test]
1048 fn a_budget_cut_between_call_and_result_orphans_neither() {
1049 let mut call_msg = tool_call_message("call_1", "get_weather");
1050 call_msg
1051 .contents
1052 .push(Content::text("let me look that up for you right now"));
1053 let messages = vec![
1054 text(Role::system(), "sys"),
1055 text(Role::user(), "hi"),
1056 call_msg,
1057 tool_result_message("call_1", "sunny"),
1058 ];
1059 let out = compact(&messages, &TokenBudget::new(4), &ApproxTokenizer);
1060 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1063 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1064 }
1065
1066 #[test]
1067 fn a_reused_call_id_is_paired_by_occurrence_not_by_id() {
1068 let messages = vec![
1072 tool_call_message("c1", "get_weather"),
1073 tool_result_message("c1", "sunny"),
1074 tool_call_message("c1", "get_weather"),
1075 ];
1076 let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1077 assert_eq!(
1078 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1079 vec!["c1"],
1080 "the second, unanswered c1 call must be dropped"
1081 );
1082 assert_eq!(
1083 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1084 vec!["c1"]
1085 );
1086 }
1087
1088 #[test]
1089 fn two_full_exchanges_reusing_one_call_id_both_survive() {
1090 let messages = vec![
1091 tool_call_message("c1", "get_weather"),
1092 tool_result_message("c1", "sunny"),
1093 tool_call_message("c1", "get_weather"),
1094 tool_result_message("c1", "rainy"),
1095 ];
1096 let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1097 assert_eq!(out.len(), 4);
1098 }
1099
1100 #[test]
1101 fn a_result_preceding_its_call_is_not_treated_as_paired() {
1102 let messages = vec![
1104 tool_result_message("c1", "sunny"),
1105 tool_call_message("c1", "get_weather"),
1106 ];
1107 let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1108 assert!(out.is_empty(), "both halves are orphans, got {out:?}");
1109 }
1110
1111 #[test]
1112 fn an_intact_tool_exchange_is_left_alone() {
1113 let messages = vec![
1114 tool_call_message("call_1", "get_weather"),
1115 tool_result_message("call_1", "sunny"),
1116 ];
1117 let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1118 assert_eq!(out.len(), 2);
1119 assert_eq!(
1120 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1121 vec!["call_1"]
1122 );
1123 assert_eq!(
1124 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1125 vec!["call_1"]
1126 );
1127 }
1128
1129 #[test]
1130 fn an_orphan_strip_keeps_the_rest_of_its_message() {
1131 let mut call_msg = tool_call_message("call_1", "get_weather");
1134 call_msg.contents.push(Content::text("checking now"));
1135 let out = compact(&[call_msg], &SlidingWindow::new(10), &ApproxTokenizer);
1136 assert_eq!(out.len(), 1);
1137 assert_eq!(out[0].text(), "checking now");
1138 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1139 }
1140
1141 #[test]
1142 fn compaction_never_returns_system_messages_only() {
1143 let messages = vec![
1146 text(Role::system(), "sys"),
1147 text(Role::user(), "hello"),
1148 text(Role::assistant(), "hi"),
1149 ];
1150 for out in [
1151 compact(&messages, &Truncation::new(1), &ApproxTokenizer),
1152 compact(&messages, &SlidingWindow::new(0), &ApproxTokenizer),
1153 ] {
1154 assert!(
1155 out.iter().any(|m| m.role != Role::system()),
1156 "expected a non-system message to be retained, got {:?}",
1157 out.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1158 );
1159 assert_eq!(out.last().unwrap().text(), "hi");
1162 }
1163 }
1164
1165 #[test]
1166 fn the_minimum_retained_message_is_never_a_half_exchange() {
1167 let mut call_msg = tool_call_message("call_1", "get_weather");
1170 call_msg.contents.push(Content::text("checking now"));
1171 let messages = vec![
1172 text(Role::system(), "sys"),
1173 call_msg,
1174 tool_result_message("call_1", "sunny"),
1175 ];
1176 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1177 assert!(out.iter().any(|m| m.role != Role::system()));
1178 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1179 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1180 }
1181
1182 #[test]
1183 fn a_call_answered_by_the_runs_input_is_not_dropped_as_an_orphan() {
1184 let history = vec![
1191 text(Role::user(), "what's the weather?"),
1192 tool_call_message("c1", "get_weather"),
1193 ];
1194 let input = vec![tool_result_message("c1", "sunny")];
1195
1196 let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1197 let out = finalize_compaction(&history, retained, &input);
1198
1199 assert_eq!(
1200 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1201 vec!["c1"],
1202 "the call must survive: its result arrives in this run's input"
1203 );
1204 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1206
1207 let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1210 let blind = finalize_compaction(&history, retained, &[]);
1211 assert!(call_ids_in(&blind, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1212 }
1213
1214 #[test]
1215 fn a_call_the_strategy_dropped_is_reinstated_when_the_input_answers_it() {
1216 let history = vec![
1220 text(Role::user(), "what's the weather?"),
1221 tool_call_message("c1", "get_weather"),
1222 ];
1223 let input = vec![tool_result_message("c1", "sunny")];
1224
1225 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1226 assert!(
1227 call_ids_in(&retained, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1228 "precondition: the strategy really did drop the call"
1229 );
1230
1231 let out = finalize_compaction(&history, retained, &input);
1232 assert_eq!(
1233 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1234 vec!["c1"],
1235 "the call must be put back so the incoming result answers something"
1236 );
1237 }
1238
1239 #[test]
1240 fn a_reused_call_id_reinstates_the_unanswered_occurrence() {
1241 let mut old_call = tool_call_message("c1", "get_weather");
1246 old_call.contents = vec![Content::FunctionCall(
1247 crate::types::FunctionCallContent::new(
1248 "c1",
1249 "get_weather",
1250 Some(crate::types::FunctionArguments::Raw(
1251 "{\"city\":\"old\"}".into(),
1252 )),
1253 ),
1254 )];
1255 let mut new_call = tool_call_message("c1", "get_weather");
1256 new_call.contents = vec![Content::FunctionCall(
1257 crate::types::FunctionCallContent::new(
1258 "c1",
1259 "get_weather",
1260 Some(crate::types::FunctionArguments::Raw(
1261 "{\"city\":\"new\"}".into(),
1262 )),
1263 ),
1264 )];
1265 let history = vec![old_call, tool_result_message("c1", "old answer"), new_call];
1266 let input = vec![tool_result_message("c1", "new answer")];
1267
1268 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1269 let out = finalize_compaction(&history, retained, &input);
1270
1271 let calls: Vec<&crate::types::FunctionCallContent> = out
1272 .iter()
1273 .flat_map(|m| m.contents.iter())
1274 .filter_map(Content::as_function_call)
1275 .collect();
1276 assert_eq!(calls.len(), 1);
1277 let args = calls[0].parse_arguments().unwrap();
1278 assert_eq!(
1279 args.get("city").and_then(|v| v.as_str()),
1280 Some("new"),
1281 "the unanswered (new) occurrence must be the one reinstated"
1282 );
1283 }
1284
1285 #[test]
1286 fn a_self_contained_pending_exchange_reinstates_nothing() {
1287 let history = vec![tool_call_message("c1", "get_weather")];
1293 let input = vec![
1294 tool_call_message("c1", "get_weather"),
1295 tool_result_message("c1", "sunny"),
1296 ];
1297 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1298 let out = finalize_compaction(&history, retained, &input);
1299 assert!(
1300 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1301 "pending answers itself; nothing should be reinstated, got {out:?}"
1302 );
1303 }
1304
1305 #[test]
1306 fn a_pending_result_beyond_what_pending_answers_still_reinstates() {
1307 let history = vec![tool_call_message("c1", "get_weather")];
1310 let input = vec![
1311 tool_call_message("c1", "get_weather"),
1312 tool_result_message("c1", "first"),
1313 tool_result_message("c1", "second"),
1314 ];
1315 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1316 let out = finalize_compaction(&history, retained, &input);
1317 assert_eq!(
1318 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1319 vec!["c1"]
1320 );
1321 }
1322
1323 #[test]
1324 fn a_pending_exchange_pairs_with_its_own_call_not_a_historical_one() {
1325 let history = vec![tool_call_message("c1", "get_weather")];
1330 let input = vec![
1331 tool_call_message("c1", "get_weather"),
1332 tool_result_message("c1", "sunny"),
1333 ];
1334 let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1335 let out = finalize_compaction(&history, retained, &input);
1336 assert!(
1337 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1338 "the unanswered historical call must be stripped, got {out:?}"
1339 );
1340 }
1341
1342 #[test]
1343 fn a_reinstated_call_keeps_its_reasoning_signature() {
1344 let signed = Message::with_contents(
1349 Role::assistant(),
1350 vec![
1351 Content::TextReasoning(crate::types::TextReasoningContent {
1352 text: "thinking".into(),
1353 protected_data: Some("c2ln".into()),
1354 ..Default::default()
1355 }),
1356 Content::FunctionCall(crate::types::FunctionCallContent::new(
1357 "c1",
1358 "get_weather",
1359 None,
1360 )),
1361 ],
1362 );
1363 let history = vec![signed];
1364 let input = vec![tool_result_message("c1", "sunny")];
1365 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1366 let out = finalize_compaction(&history, retained, &input);
1367
1368 let call = out
1369 .iter()
1370 .flat_map(|m| m.contents.iter())
1371 .find_map(Content::as_function_call)
1372 .expect("the call is reinstated");
1373 assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
1374 }
1375
1376 #[test]
1377 fn a_reinstated_call_gains_no_signature_from_unrelated_content() {
1378 let mut msg = Message::with_contents(
1379 Role::assistant(),
1380 vec![
1381 Content::TextReasoning(crate::types::TextReasoningContent {
1382 text: "thinking".into(),
1383 protected_data: Some("c2ln".into()),
1384 ..Default::default()
1385 }),
1386 Content::text("intervening"),
1387 ],
1388 );
1389 msg.contents.push(Content::FunctionCall(
1390 crate::types::FunctionCallContent::new("c1", "get_weather", None),
1391 ));
1392 let history = vec![msg];
1393 let input = vec![tool_result_message("c1", "sunny")];
1394 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1395 let out = finalize_compaction(&history, retained, &input);
1396
1397 let call = out
1398 .iter()
1399 .flat_map(|m| m.contents.iter())
1400 .find_map(Content::as_function_call)
1401 .expect("the call is reinstated");
1402 assert!(call.protected_data.is_none());
1403 }
1404
1405 #[test]
1406 fn the_fallback_restores_the_nearest_reused_call_not_the_oldest() {
1407 let mk = |city: &str| {
1412 Message::with_contents(
1413 Role::assistant(),
1414 vec![Content::FunctionCall(
1415 crate::types::FunctionCallContent::new(
1416 "c1",
1417 "get_weather",
1418 Some(crate::types::FunctionArguments::Raw(format!(
1419 "{{\"city\":\"{city}\"}}"
1420 ))),
1421 ),
1422 )],
1423 )
1424 };
1425 let messages = vec![
1426 text(Role::system(), "sys"),
1427 mk("old"),
1428 mk("new"),
1429 tool_result_message("c1", "sunny"),
1430 ];
1431 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1432
1433 let call = out
1434 .iter()
1435 .flat_map(|m| m.contents.iter())
1436 .find_map(Content::as_function_call)
1437 .expect("the exchange is restored");
1438 assert_eq!(
1439 call.parse_arguments()
1440 .unwrap()
1441 .get("city")
1442 .and_then(|v| v.as_str()),
1443 Some("new")
1444 );
1445 }
1446
1447 #[test]
1448 fn unknown_content_does_not_satisfy_the_retention_check() {
1449 let messages = vec![
1453 text(Role::system(), "sys"),
1454 text(Role::user(), "a real earlier turn"),
1455 Message::with_contents(Role::assistant(), vec![Content::Unknown]),
1456 ];
1457 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1458 assert!(
1459 out.iter()
1460 .any(|m| m.contents.iter().any(|c| matches!(c, Content::Text(_)))
1461 && m.role != Role::system()),
1462 "expected a real turn to be restored, got {out:?}"
1463 );
1464 }
1465
1466 struct KeepIndices(Vec<usize>);
1469 impl CompactionStrategy for KeepIndices {
1470 fn compact(&self, messages: &[Message], _t: &dyn Tokenizer) -> Vec<Message> {
1471 messages
1472 .iter()
1473 .enumerate()
1474 .filter(|(i, _)| self.0.contains(i))
1475 .map(|(_, m)| m.clone())
1476 .collect()
1477 }
1478 }
1479
1480 #[test]
1481 fn an_older_retained_occurrence_does_not_suppress_reinstating_the_newer() {
1482 let mk = |city: &str| {
1487 Message::with_contents(
1488 Role::assistant(),
1489 vec![Content::FunctionCall(
1490 crate::types::FunctionCallContent::new(
1491 "c1",
1492 "get_weather",
1493 Some(crate::types::FunctionArguments::Raw(format!(
1494 "{{\"city\":\"{city}\"}}"
1495 ))),
1496 ),
1497 )],
1498 )
1499 };
1500 let history = vec![mk("old"), mk("new")];
1501 let input = vec![tool_result_message("c1", "sunny")];
1502
1503 let retained = KeepIndices(vec![0]).compact(&history, &ApproxTokenizer);
1504 let out = finalize_compaction(&history, retained, &input);
1505
1506 let cities: Vec<String> = out
1507 .iter()
1508 .flat_map(|m| m.contents.iter())
1509 .filter_map(Content::as_function_call)
1510 .filter_map(|c| {
1511 c.parse_arguments()
1512 .ok()?
1513 .get("city")?
1514 .as_str()
1515 .map(str::to_string)
1516 })
1517 .collect();
1518 assert!(
1519 cities.contains(&"new".to_string()),
1520 "the newer occurrence the result answers must be present, got {cities:?}"
1521 );
1522 }
1523
1524 #[test]
1525 fn non_image_media_alone_does_not_satisfy_the_retention_check() {
1526 for media in [
1530 Content::Data(crate::types::DataContent::from_bytes(b"aud", "audio/wav")),
1531 Content::Uri(crate::types::UriContent {
1532 uri: "https://example.com/doc.pdf".into(),
1533 media_type: "application/pdf".into(),
1534 }),
1535 ] {
1536 let messages = vec![
1537 text(Role::system(), "sys"),
1538 text(Role::user(), "a real earlier turn"),
1539 Message::with_contents(Role::assistant(), vec![media]),
1540 ];
1541 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1542 assert!(
1543 out.iter().any(|m| m.role != Role::system()
1544 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1545 "expected a real turn to be restored, got {out:?}"
1546 );
1547 }
1548 }
1549
1550 #[test]
1551 fn a_hosted_file_alone_does_not_satisfy_the_retention_check() {
1552 let hosted = Message::with_contents(
1556 Role::assistant(),
1557 vec![Content::HostedFile(crate::types::HostedFileContent {
1558 file_id: "file_1".into(),
1559 })],
1560 );
1561 let messages = vec![
1562 text(Role::system(), "sys"),
1563 text(Role::user(), "a real earlier turn"),
1564 hosted,
1565 ];
1566 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1567 assert!(
1568 out.iter().any(|m| m.role != Role::system()
1569 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1570 "expected a real turn to be restored, got {out:?}"
1571 );
1572 }
1573
1574 #[test]
1575 fn a_completed_exchange_in_history_is_not_reinstated() {
1576 let history = vec![
1579 tool_call_message("c1", "get_weather"),
1580 tool_result_message("c1", "sunny"),
1581 ];
1582 let input = vec![tool_result_message("c2", "noon")];
1583 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1584 let out = finalize_compaction(&history, retained, &input);
1585 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1586 }
1587
1588 #[test]
1589 fn a_reinstated_call_carries_nothing_else_from_its_message() {
1590 let mut call_msg = tool_call_message("c1", "get_weather");
1591 call_msg
1592 .contents
1593 .push(Content::text("a large unrelated payload"));
1594 let history = vec![call_msg];
1595 let input = vec![tool_result_message("c1", "sunny")];
1596
1597 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1598 let out = finalize_compaction(&history, retained, &input);
1599 assert_eq!(out.len(), 1);
1600 assert_eq!(out[0].contents.len(), 1, "only the call rides back in");
1601 assert!(out[0].text().is_empty());
1602 }
1603
1604 #[test]
1605 fn nothing_is_reinstated_when_the_input_carries_no_results() {
1606 let history = vec![tool_call_message("c1", "get_weather")];
1607 let input = vec![text(Role::user(), "hello")];
1608 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1609 let out = finalize_compaction(&history, retained, &input);
1610 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1611 }
1612
1613 #[test]
1614 fn an_already_retained_call_is_not_reinstated_twice() {
1615 let history = vec![tool_call_message("c1", "get_weather")];
1616 let input = vec![tool_result_message("c1", "sunny")];
1617 let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1618 let out = finalize_compaction(&history, retained, &input);
1619 assert_eq!(
1620 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1621 vec!["c1"]
1622 );
1623 }
1624
1625 #[test]
1626 fn the_minimum_retention_fallback_never_picks_a_tool_role_message() {
1627 let mut result_msg = tool_result_message("c1", "sunny");
1632 result_msg.contents.push(Content::text("explanatory text"));
1633 let messages = vec![
1634 text(Role::system(), "sys"),
1635 tool_call_message("c1", "get_weather"),
1636 result_msg,
1637 ];
1638 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1639 assert_eq!(
1641 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1642 vec!["c1"]
1643 );
1644 assert_eq!(
1645 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1646 vec!["c1"]
1647 );
1648 for m in &out {
1649 if m.role == Role::tool() {
1650 assert!(
1651 m.contents
1652 .iter()
1653 .any(|c| matches!(c, Content::FunctionResult(_))),
1654 "a tool-role message must carry its result"
1655 );
1656 }
1657 }
1658 }
1659
1660 #[test]
1661 fn a_call_with_no_answer_anywhere_is_still_dropped() {
1662 let history = vec![tool_call_message("c1", "get_weather")];
1663 let input = vec![text(Role::user(), "never mind")];
1664 let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1665 let out = finalize_compaction(&history, retained, &input);
1666 assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1667 }
1668
1669 #[test]
1670 fn pending_input_satisfies_the_minimum_retention_rule() {
1671 let history = vec![text(Role::system(), "sys"), text(Role::user(), "older")];
1674 let input = vec![text(Role::user(), "current question")];
1675 let retained = Truncation::new(1).compact(&history, &ApproxTokenizer);
1676 let out = finalize_compaction(&history, retained, &input);
1677 assert!(out.iter().all(|m| m.role == Role::system()));
1678 }
1679
1680 #[test]
1681 fn a_conversation_of_only_a_tool_exchange_retains_it_whole() {
1682 let messages = vec![
1686 text(Role::system(), "sys"),
1687 tool_call_message("c1", "get_weather"),
1688 tool_result_message("c1", "sunny"),
1689 ];
1690 for out in [
1691 compact(&messages, &Truncation::new(1), &ApproxTokenizer),
1692 compact(&messages, &SlidingWindow::new(0), &ApproxTokenizer),
1693 ] {
1694 assert!(
1695 out.iter().any(|m| m.role != Role::system()),
1696 "expected the tool exchange to be retained, got {:?}",
1697 out.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1698 );
1699 assert_eq!(
1700 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1701 vec!["c1"]
1702 );
1703 assert_eq!(
1704 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1705 vec!["c1"]
1706 );
1707 }
1708 }
1709
1710 #[test]
1711 fn the_latest_complete_exchange_is_the_one_reinstated() {
1712 let messages = vec![
1713 text(Role::system(), "sys"),
1714 tool_call_message("c1", "get_weather"),
1715 tool_result_message("c1", "sunny"),
1716 tool_call_message("c2", "get_time"),
1717 tool_result_message("c2", "noon"),
1718 ];
1719 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1720 assert_eq!(
1721 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1722 vec!["c2"]
1723 );
1724 }
1725
1726 #[test]
1727 fn an_incomplete_tool_exchange_is_not_reinstated() {
1728 let messages = vec![
1732 text(Role::system(), "sys"),
1733 tool_call_message("c1", "get_weather"),
1734 ];
1735 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1736 assert!(out.iter().all(|m| m.role == Role::system()));
1737 }
1738
1739 #[test]
1740 fn reasoning_alone_does_not_qualify_as_the_minimum_retained_message() {
1741 let signed = Message::with_contents(
1746 Role::assistant(),
1747 vec![
1748 Content::TextReasoning(crate::types::TextReasoningContent {
1749 text: "thinking".into(),
1750 ..Default::default()
1751 }),
1752 Content::FunctionCall(crate::types::FunctionCallContent::new(
1753 "c1",
1754 "get_weather",
1755 None,
1756 )),
1757 ],
1758 );
1759 let messages = vec![
1760 text(Role::system(), "sys"),
1761 signed,
1762 tool_result_message("c1", "sunny"),
1763 ];
1764 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1765
1766 assert_eq!(
1767 call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1768 vec!["c1"],
1769 "the complete exchange must be restored, got {out:?}"
1770 );
1771 assert_eq!(
1772 call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1773 vec!["c1"]
1774 );
1775 }
1776
1777 #[test]
1778 fn text_beside_reasoning_still_qualifies() {
1779 let msg = Message::with_contents(
1780 Role::assistant(),
1781 vec![
1782 Content::TextReasoning(crate::types::TextReasoningContent {
1783 text: "thinking".into(),
1784 ..Default::default()
1785 }),
1786 Content::text("the answer"),
1787 ],
1788 );
1789 let messages = vec![text(Role::system(), "sys"), msg];
1790 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1791 assert_eq!(out.last().unwrap().text(), "the answer");
1792 }
1793
1794 #[test]
1795 fn the_complete_exchange_fallback_carries_the_reasoning_signature() {
1796 let signed = Message::with_contents(
1798 Role::assistant(),
1799 vec![
1800 Content::TextReasoning(crate::types::TextReasoningContent {
1801 text: "thinking".into(),
1802 protected_data: Some("c2ln".into()),
1803 ..Default::default()
1804 }),
1805 Content::FunctionCall(crate::types::FunctionCallContent::new(
1806 "c1",
1807 "get_weather",
1808 None,
1809 )),
1810 ],
1811 );
1812 let messages = vec![
1813 text(Role::system(), "sys"),
1814 signed,
1815 tool_result_message("c1", "sunny"),
1816 ];
1817 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1818
1819 let call = out
1820 .iter()
1821 .flat_map(|m| m.contents.iter())
1822 .find_map(Content::as_function_call)
1823 .expect("the exchange is restored");
1824 assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
1825 }
1826
1827 #[test]
1828 fn a_retained_message_that_renders_nothing_does_not_satisfy_retention() {
1829 let reasoning_plus_orphan = Message::with_contents(
1833 Role::assistant(),
1834 vec![
1835 Content::TextReasoning(crate::types::TextReasoningContent {
1836 text: "thinking".into(),
1837 ..Default::default()
1838 }),
1839 Content::FunctionCall(crate::types::FunctionCallContent::new(
1840 "orphan",
1841 "get_weather",
1842 None,
1843 )),
1844 ],
1845 );
1846 let messages = vec![
1847 text(Role::system(), "sys"),
1848 tool_call_message("c1", "get_weather"),
1849 tool_result_message("c1", "sunny"),
1850 reasoning_plus_orphan,
1851 ];
1852 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1853
1854 assert!(
1855 out.iter().any(|m| m.role != Role::system()
1856 && m.contents
1857 .iter()
1858 .any(|c| !matches!(c, Content::TextReasoning(_)))),
1859 "expected something that actually renders, got {out:?}"
1860 );
1861 }
1862
1863 #[test]
1864 fn retained_reasoning_is_never_discarded_by_the_retention_check() {
1865 let reasoning_only = Message::with_contents(
1870 Role::assistant(),
1871 vec![Content::TextReasoning(crate::types::TextReasoningContent {
1872 text: "thinking".into(),
1873 raw_representation: Some(json!({"id": "rs_1"})),
1874 ..Default::default()
1875 })],
1876 );
1877 let messages = vec![text(Role::system(), "sys"), reasoning_only];
1878 let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1879
1880 let kept = out
1881 .iter()
1882 .flat_map(|m| m.contents.iter())
1883 .any(|c| matches!(c, Content::TextReasoning(_)));
1884 assert!(kept, "reasoning must survive, got {out:?}");
1885 }
1886
1887 #[test]
1888 fn the_fallback_is_inserted_in_chronological_order() {
1889 let messages = vec![
1894 text(Role::user(), "old question"),
1895 Message::with_contents(
1896 Role::assistant(),
1897 vec![Content::TextReasoning(crate::types::TextReasoningContent {
1898 text: "newer thinking".into(),
1899 ..Default::default()
1900 })],
1901 ),
1902 ];
1903 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1904 assert_eq!(out.len(), 2);
1905 assert_eq!(out[0].text(), "old question", "got {out:?}");
1906 assert!(matches!(out[1].contents[0], Content::TextReasoning(_)));
1907 }
1908
1909 #[test]
1910 fn a_fallback_newer_than_the_retained_message_is_appended() {
1911 let messages = vec![
1912 Message::with_contents(
1913 Role::assistant(),
1914 vec![Content::TextReasoning(crate::types::TextReasoningContent {
1915 text: "older thinking".into(),
1916 ..Default::default()
1917 })],
1918 ),
1919 text(Role::user(), "newer question"),
1920 ];
1921 let retained = KeepIndices(vec![0]).compact(&messages, &ApproxTokenizer);
1922 let out = finalize_compaction(&messages, retained, &[]);
1923 assert_eq!(out.len(), 2);
1924 assert!(matches!(out[0].contents[0], Content::TextReasoning(_)));
1925 assert_eq!(out[1].text(), "newer question");
1926 }
1927
1928 #[test]
1929 fn wire_empty_pending_input_does_not_satisfy_retention() {
1930 let history = vec![text(Role::system(), "sys"), text(Role::user(), "real turn")];
1933 let input = vec![Message::with_contents(
1934 Role::user(),
1935 vec![Content::HostedFile(crate::types::HostedFileContent {
1936 file_id: "file_1".into(),
1937 })],
1938 )];
1939 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1940 let out = finalize_compaction(&history, retained, &input);
1941 assert!(
1942 out.iter().any(|m| m.role != Role::system()
1943 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1944 "expected the real turn to be restored, got {out:?}"
1945 );
1946 }
1947
1948 #[test]
1949 fn tool_payloads_count_against_the_token_budget() {
1950 let t = ApproxTokenizer;
1954 let bulky = Message::with_contents(
1955 Role::tool(),
1956 vec![Content::FunctionResult(FunctionResultContent::new(
1957 "c1",
1958 Some(json!("x".repeat(400))),
1959 ))],
1960 );
1961 assert!(
1962 count_message_tokens(&t, &bulky) >= 100,
1963 "a large tool result must cost tokens, got {}",
1964 count_message_tokens(&t, &bulky)
1965 );
1966
1967 let call = Message::with_contents(
1968 Role::assistant(),
1969 vec![Content::FunctionCall(
1970 crate::types::FunctionCallContent::new(
1971 "c1",
1972 "get_weather",
1973 Some(crate::types::FunctionArguments::Raw("y".repeat(400))),
1974 ),
1975 )],
1976 );
1977 assert!(count_message_tokens(&t, &call) >= 100);
1978
1979 let messages = vec![call.clone(), bulky.clone(), text(Role::user(), "tiny")];
1981 let out = TokenBudget::new(10).compact(&messages, &t);
1982 assert!(
1983 out.len() < 3,
1984 "the bulky exchange must not fit a 10-token budget, got {out:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn duplicate_messages_do_not_alias_the_fallback_position() {
1990 let reasoning = || {
1995 Message::with_contents(
1996 Role::assistant(),
1997 vec![Content::TextReasoning(crate::types::TextReasoningContent {
1998 text: "same thinking".into(),
1999 ..Default::default()
2000 })],
2001 )
2002 };
2003 let messages = vec![reasoning(), text(Role::user(), "old question"), reasoning()];
2004 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2005 assert_eq!(out.len(), 2);
2006 assert_eq!(
2007 out[0].text(),
2008 "old question",
2009 "the fallback must precede the retained later reasoning, got {out:?}"
2010 );
2011 }
2012
2013 #[test]
2014 fn the_complete_exchange_fallback_is_also_inserted_chronologically() {
2015 let messages = vec![
2019 tool_call_message("c1", "get_weather"),
2020 tool_result_message("c1", "sunny"),
2021 Message::with_contents(
2022 Role::assistant(),
2023 vec![Content::TextReasoning(crate::types::TextReasoningContent {
2024 text: "newer thinking".into(),
2025 ..Default::default()
2026 })],
2027 ),
2028 ];
2029 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2030
2031 let reasoning_at = out
2032 .iter()
2033 .position(|m| {
2034 m.contents
2035 .iter()
2036 .any(|c| matches!(c, Content::TextReasoning(_)))
2037 })
2038 .expect("the retained reasoning is still present");
2039 let call_at = out
2040 .iter()
2041 .position(|m| {
2042 m.contents
2043 .iter()
2044 .any(|c| matches!(c, Content::FunctionCall(_)))
2045 })
2046 .expect("the exchange is restored");
2047 assert!(
2048 call_at < reasoning_at,
2049 "the older exchange must precede the newer reasoning, got {out:?}"
2050 );
2051 let result_at = out
2053 .iter()
2054 .position(|m| {
2055 m.contents
2056 .iter()
2057 .any(|c| matches!(c, Content::FunctionResult(_)))
2058 })
2059 .expect("the result is restored");
2060 assert!(
2061 call_at < result_at && result_at < reasoning_at,
2062 "got {out:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn parallel_reinstated_calls_stay_in_one_message() {
2068 let parallel = Message::with_contents(
2072 Role::assistant(),
2073 vec![
2074 Content::FunctionCall(crate::types::FunctionCallContent::new(
2075 "c1",
2076 "get_weather",
2077 None,
2078 )),
2079 Content::FunctionCall(crate::types::FunctionCallContent::new(
2080 "c2", "get_time", None,
2081 )),
2082 ],
2083 );
2084 let history = vec![parallel];
2085 let input = vec![
2086 tool_result_message("c1", "sunny"),
2087 tool_result_message("c2", "noon"),
2088 ];
2089 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
2090 let out = finalize_compaction(&history, retained, &input);
2091
2092 let with_calls: Vec<&Message> = out
2093 .iter()
2094 .filter(|m| {
2095 m.contents
2096 .iter()
2097 .any(|c| matches!(c, Content::FunctionCall(_)))
2098 })
2099 .collect();
2100 assert_eq!(
2101 with_calls.len(),
2102 1,
2103 "both calls must be reinstated in one message, got {out:?}"
2104 );
2105 assert_eq!(with_calls[0].contents.len(), 2);
2106 }
2107
2108 #[test]
2109 fn chronology_survives_the_orphan_repair_rewriting_a_message() {
2110 let newer = Message::with_contents(
2115 Role::assistant(),
2116 vec![
2117 Content::TextReasoning(crate::types::TextReasoningContent {
2118 text: "newer thinking".into(),
2119 ..Default::default()
2120 }),
2121 Content::FunctionCall(crate::types::FunctionCallContent::new(
2122 "orphan",
2123 "get_weather",
2124 None,
2125 )),
2126 ],
2127 );
2128 let messages = vec![text(Role::user(), "old question"), newer];
2129 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2130
2131 assert_eq!(out.len(), 2);
2132 assert_eq!(
2133 out[0].text(),
2134 "old question",
2135 "the fallback must precede the retained (rewritten) message, got {out:?}"
2136 );
2137 }
2138
2139 #[test]
2140 fn an_assistant_image_turn_does_not_satisfy_retention() {
2141 let messages = vec![
2146 text(Role::system(), "sys"),
2147 text(Role::user(), "a real earlier turn"),
2148 Message::with_contents(
2149 Role::assistant(),
2150 vec![Content::Data(crate::types::DataContent::from_bytes(
2151 b"img",
2152 "image/png",
2153 ))],
2154 ),
2155 ];
2156 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2157 assert!(
2158 out.iter().any(|m| m.role != Role::system()
2159 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2160 "expected the real turn restored, got {out:?}"
2161 );
2162 }
2163
2164 #[test]
2165 fn an_image_turn_satisfies_the_retention_check() {
2166 let messages = vec![
2170 text(Role::system(), "sys"),
2171 text(Role::user(), "an older turn"),
2172 Message::with_contents(
2173 Role::user(),
2174 vec![Content::Data(crate::types::DataContent::from_bytes(
2175 b"img",
2176 "image/png",
2177 ))],
2178 ),
2179 ];
2180 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2181 assert_eq!(out.len(), 2, "no fallback should be added, got {out:?}");
2182 assert!(out[1]
2183 .contents
2184 .iter()
2185 .any(|c| matches!(c, Content::Data(_))));
2186 }
2187
2188 #[test]
2189 fn a_remote_image_uri_does_not_satisfy_retention() {
2190 let messages = vec![
2194 text(Role::system(), "sys"),
2195 text(Role::user(), "an older turn"),
2196 Message::with_contents(
2197 Role::user(),
2198 vec![Content::Uri(crate::types::UriContent {
2199 uri: "https://example.com/a.png".into(),
2200 media_type: "image/png".into(),
2201 })],
2202 ),
2203 ];
2204 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2205 assert!(
2206 out.iter().any(|m| m.role != Role::system()
2207 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2208 "expected the real turn restored, got {out:?}"
2209 );
2210 }
2211
2212 #[test]
2213 fn an_image_format_outside_the_universal_set_does_not_satisfy_retention() {
2214 let messages = vec![
2217 text(Role::system(), "sys"),
2218 text(Role::user(), "an older turn"),
2219 Message::with_contents(
2220 Role::user(),
2221 vec![Content::Data(crate::types::DataContent::from_bytes(
2222 b"<svg/>",
2223 "image/svg+xml",
2224 ))],
2225 ),
2226 ];
2227 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2228 assert!(
2229 out.iter().any(|m| m.role != Role::system()
2230 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2231 "expected the real turn restored, got {out:?}"
2232 );
2233 }
2234
2235 #[test]
2236 fn a_reinstated_parallel_call_inherits_the_groups_reasoning_signature() {
2237 let group = Message::with_contents(
2241 Role::assistant(),
2242 vec![
2243 Content::TextReasoning(crate::types::TextReasoningContent {
2244 text: "thinking".into(),
2245 protected_data: Some("c2ln".into()),
2246 ..Default::default()
2247 }),
2248 Content::FunctionCall(crate::types::FunctionCallContent::new(
2249 "c1",
2250 "get_weather",
2251 None,
2252 )),
2253 Content::FunctionCall(crate::types::FunctionCallContent::new(
2254 "c2", "get_time", None,
2255 )),
2256 ],
2257 );
2258 let history = vec![group];
2259 let input = vec![tool_result_message("c2", "noon")];
2261 let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
2262 let out = finalize_compaction(&history, retained, &input);
2263
2264 let call = out
2265 .iter()
2266 .flat_map(|m| m.contents.iter())
2267 .find_map(Content::as_function_call)
2268 .expect("c2 is reinstated");
2269 assert_eq!(call.call_id, "c2");
2270 assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
2271 }
2272
2273 #[test]
2274 fn an_image_type_on_a_malformed_uri_does_not_satisfy_retention() {
2275 for uri in ["not-a-data-uri", "data:image/png,AAAA", ""] {
2278 let messages = vec![
2279 text(Role::system(), "sys"),
2280 text(Role::user(), "a real earlier turn"),
2281 Message::with_contents(
2282 Role::user(),
2283 vec![Content::Data(crate::types::DataContent {
2284 uri: uri.into(),
2285 media_type: Some("image/png".into()),
2286 })],
2287 ),
2288 ];
2289 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2290 assert!(
2291 out.iter().any(|m| m.role != Role::system()
2292 && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2293 "expected a real turn restored for {uri:?}, got {out:?}"
2294 );
2295 }
2296 }
2297
2298 #[test]
2299 fn blank_text_does_not_satisfy_the_retention_check() {
2300 for blank in ["", " \n\t"] {
2304 let messages = vec![
2305 text(Role::system(), "sys"),
2306 text(Role::user(), "a real earlier turn"),
2307 text(Role::assistant(), blank),
2308 ];
2309 let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2310 assert!(
2311 out.iter()
2312 .any(|m| m.role != Role::system() && !m.text().trim().is_empty()),
2313 "expected the real turn restored for {blank:?}, got {out:?}"
2314 );
2315 }
2316 }
2317
2318 #[test]
2319 fn an_all_system_conversation_stays_all_system() {
2320 let messages = vec![text(Role::system(), "a"), text(Role::system(), "b")];
2322 let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
2323 assert!(out.iter().all(|m| m.role == Role::system()));
2324 }
2325
2326 #[test]
2329 fn approx_tokenizer_uses_four_chars_per_token_ceiling() {
2330 let t = ApproxTokenizer;
2331 assert_eq!(t.count_tokens(""), 0);
2332 assert_eq!(t.count_tokens("abcd"), 1);
2333 assert_eq!(t.count_tokens("abcde"), 2); assert_eq!(t.count_tokens("abcdefgh"), 2);
2335 assert_eq!(t.count_tokens("abcdefghi"), 3); }
2337
2338 #[test]
2339 fn approx_tokenizer_does_not_inflate_non_ascii_text() {
2340 let t = ApproxTokenizer;
2346 assert_eq!(t.count_tokens("日本語訳"), t.count_tokens("abcd"));
2347
2348 let msg = Message::with_contents(Role::user(), vec![Content::text("日本語訳")]);
2349 assert_eq!(count_message_tokens(&t, &msg), 1);
2350 }
2351
2352 #[test]
2353 fn count_message_tokens_sums_text_content() {
2354 let t = ApproxTokenizer;
2355 let msg = Message::with_contents(
2356 Role::user(),
2357 vec![Content::text("abcd"), Content::text("abcdefgh")],
2358 );
2359 assert_eq!(count_message_tokens(&t, &msg), 3);
2361 }
2362
2363 #[test]
2366 fn truncation_keeps_most_recent_messages() {
2367 let messages = vec![
2368 text(Role::user(), "1"),
2369 text(Role::assistant(), "2"),
2370 text(Role::user(), "3"),
2371 text(Role::assistant(), "4"),
2372 ];
2373 let strategy = Truncation::new(2);
2374 let out = compact(&messages, &strategy, &ApproxTokenizer);
2375 assert_eq!(out.len(), 2);
2376 assert_eq!(out[0].text(), "3");
2377 assert_eq!(out[1].text(), "4");
2378 }
2379
2380 #[test]
2381 fn truncation_preserves_leading_system_messages() {
2382 let messages = vec![
2383 text(Role::system(), "sys"),
2384 text(Role::user(), "1"),
2385 text(Role::assistant(), "2"),
2386 text(Role::user(), "3"),
2387 text(Role::assistant(), "4"),
2388 ];
2389 let strategy = Truncation::new(2);
2390 let out = compact(&messages, &strategy, &ApproxTokenizer);
2391 assert_eq!(out.len(), 2);
2393 assert_eq!(out[0].role, Role::system());
2394 assert_eq!(out[0].text(), "sys");
2395 assert_eq!(out[1].text(), "4");
2396 }
2397
2398 #[test]
2399 fn truncation_preserves_multiple_leading_system_messages() {
2400 let messages = vec![
2401 text(Role::system(), "sys1"),
2402 text(Role::system(), "sys2"),
2403 text(Role::user(), "1"),
2404 text(Role::assistant(), "2"),
2405 ];
2406 let strategy = Truncation::new(3);
2407 let out = compact(&messages, &strategy, &ApproxTokenizer);
2408 assert_eq!(out.len(), 3);
2409 assert_eq!(out[0].text(), "sys1");
2410 assert_eq!(out[1].text(), "sys2");
2411 assert_eq!(out[2].text(), "2");
2412 }
2413
2414 #[test]
2415 fn truncation_noop_when_under_budget() {
2416 let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2417 let strategy = Truncation::new(10);
2418 let out = compact(&messages, &strategy, &ApproxTokenizer);
2419 assert_eq!(out, messages);
2420 }
2421
2422 #[test]
2425 fn sliding_window_keeps_system_plus_last_n_non_system() {
2426 let messages = vec![
2427 text(Role::system(), "sys"),
2428 text(Role::user(), "1"),
2429 text(Role::assistant(), "2"),
2430 text(Role::user(), "3"),
2431 ];
2432 let strategy = SlidingWindow::new(2);
2433 let out = compact(&messages, &strategy, &ApproxTokenizer);
2434 assert_eq!(out.len(), 3);
2435 assert_eq!(out[0].text(), "sys");
2436 assert_eq!(out[1].text(), "2");
2437 assert_eq!(out[2].text(), "3");
2438 }
2439
2440 #[test]
2441 fn sliding_window_with_no_system_message() {
2442 let messages = vec![
2443 text(Role::user(), "1"),
2444 text(Role::assistant(), "2"),
2445 text(Role::user(), "3"),
2446 ];
2447 let strategy = SlidingWindow::new(1);
2448 let out = compact(&messages, &strategy, &ApproxTokenizer);
2449 assert_eq!(out.len(), 1);
2450 assert_eq!(out[0].text(), "3");
2451 }
2452
2453 struct FixedTokenizer(usize);
2458 impl Tokenizer for FixedTokenizer {
2459 fn count_tokens(&self, _text: &str) -> usize {
2460 self.0
2461 }
2462 }
2463
2464 #[test]
2465 fn token_budget_keeps_only_what_fits_from_the_newest_backward() {
2466 let messages = vec![
2467 text(Role::user(), "1"),
2468 text(Role::assistant(), "2"),
2469 text(Role::user(), "3"),
2470 text(Role::assistant(), "4"),
2471 ];
2472 let tokenizer = FixedTokenizer(10);
2474 let strategy = TokenBudget::new(25);
2475 let out = compact(&messages, &strategy, &tokenizer);
2476 assert_eq!(out.len(), 2);
2477 assert_eq!(out[0].text(), "3");
2478 assert_eq!(out[1].text(), "4");
2479 }
2480
2481 #[test]
2482 fn token_budget_preserves_leading_system_message_and_counts_it() {
2483 let messages = vec![
2484 text(Role::system(), "sys"),
2485 text(Role::user(), "1"),
2486 text(Role::assistant(), "2"),
2487 text(Role::user(), "3"),
2488 ];
2489 let tokenizer = FixedTokenizer(10);
2490 let strategy = TokenBudget::new(20);
2492 let out = compact(&messages, &strategy, &tokenizer);
2493 assert_eq!(out.len(), 2);
2494 assert_eq!(out[0].role, Role::system());
2495 assert_eq!(out[1].text(), "3");
2496 }
2497
2498 #[test]
2499 fn token_budget_keeps_at_least_the_newest_message_even_if_it_alone_exceeds_budget() {
2500 let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2501 let tokenizer = FixedTokenizer(100);
2502 let strategy = TokenBudget::new(1);
2503 let out = compact(&messages, &strategy, &tokenizer);
2504 assert_eq!(out.len(), 1);
2505 assert_eq!(out[0].text(), "2");
2506 }
2507
2508 #[test]
2509 fn token_budget_keeps_everything_when_it_all_fits() {
2510 let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2511 let tokenizer = FixedTokenizer(1);
2512 let strategy = TokenBudget::new(1000);
2513 let out = compact(&messages, &strategy, &tokenizer);
2514 assert_eq!(out, messages);
2515 }
2516
2517 #[test]
2520 fn selective_tool_result_strips_stale_results_and_keeps_recent_ones() {
2521 let messages = vec![
2522 tool_call_message("c1", "t1"),
2523 tool_result_message("c1", "result 1"),
2524 tool_call_message("c2", "t2"),
2525 tool_result_message("c2", "result 2"),
2526 tool_call_message("c3", "t3"),
2527 tool_result_message("c3", "result 3"),
2528 ];
2529 let strategy = SelectiveToolResult::new(1);
2530 let out = compact(&messages, &strategy, &ApproxTokenizer);
2531
2532 assert_eq!(out.len(), 6);
2535 assert_eq!(
2536 out[1].function_results()[0].result,
2537 Some(json!(OMITTED_TOOL_RESULT))
2538 );
2539 assert_eq!(
2540 out[3].function_results()[0].result,
2541 Some(json!(OMITTED_TOOL_RESULT))
2542 );
2543 assert_eq!(out[5].function_results()[0].result, Some(json!("result 3")));
2544 }
2545
2546 #[test]
2547 fn selective_tool_result_keeps_text_alongside_a_stripped_tool_result() {
2548 let mixed = Message::with_contents(
2549 Role::tool(),
2550 vec![
2551 Content::text("some accompanying text"),
2552 Content::FunctionResult(FunctionResultContent::new("c1", Some(json!("r1")))),
2553 ],
2554 );
2555 let messages = vec![
2556 tool_call_message("c1", "t1"),
2557 mixed,
2558 tool_call_message("c2", "t2"),
2559 tool_result_message("c2", "result 2"),
2560 tool_call_message("c3", "t3"),
2561 tool_result_message("c3", "result 3"),
2562 ];
2563 let strategy = SelectiveToolResult::new(2);
2564 let out = compact(&messages, &strategy, &ApproxTokenizer);
2565
2566 assert_eq!(out.len(), 6);
2570 assert_eq!(out[1].text(), "some accompanying text");
2571 assert_eq!(
2572 out[1].function_results()[0].result,
2573 Some(json!(OMITTED_TOOL_RESULT))
2574 );
2575 assert_eq!(out[3].function_results()[0].result, Some(json!("result 2")));
2576 assert_eq!(out[5].function_results()[0].result, Some(json!("result 3")));
2577 }
2578
2579 #[test]
2580 fn selective_tool_result_noop_when_keep_last_covers_all() {
2581 let messages = vec![
2582 tool_call_message("c1", "t1"),
2583 tool_result_message("c1", "result 1"),
2584 tool_call_message("c2", "t2"),
2585 tool_result_message("c2", "result 2"),
2586 ];
2587 let strategy = SelectiveToolResult::new(5);
2588 let out = compact(&messages, &strategy, &ApproxTokenizer);
2589 assert_eq!(out, messages);
2590 }
2591
2592 #[test]
2593 fn selective_tool_result_ignores_messages_without_tool_results() {
2594 let messages = vec![
2595 text(Role::system(), "sys"),
2596 text(Role::user(), "hi"),
2597 text(Role::assistant(), "hello"),
2598 ];
2599 let strategy = SelectiveToolResult::new(0);
2600 let out = compact(&messages, &strategy, &ApproxTokenizer);
2601 assert_eq!(out, messages);
2602 }
2603
2604 #[tokio::test]
2607 async fn compaction_provider_before_run_replaces_ctx_messages_with_compacted_subset() {
2608 let provider = CompactionProvider::new(Truncation::new(2));
2609 let mut ctx = SessionContext::new(vec![]);
2610 ctx.messages = vec![
2611 text(Role::user(), "1"),
2612 text(Role::assistant(), "2"),
2613 text(Role::user(), "3"),
2614 text(Role::assistant(), "4"),
2615 ];
2616 provider.before_run(&mut ctx).await.unwrap();
2617 assert_eq!(ctx.messages.len(), 2);
2618 assert_eq!(ctx.messages[0].text(), "3");
2619 assert_eq!(ctx.messages[1].text(), "4");
2620 }
2621
2622 #[tokio::test]
2623 async fn compaction_provider_with_tokenizer_uses_the_supplied_tokenizer() {
2624 struct FixedTokenizer(usize);
2625 impl Tokenizer for FixedTokenizer {
2626 fn count_tokens(&self, _text: &str) -> usize {
2627 self.0
2628 }
2629 }
2630 let provider = CompactionProvider::with_tokenizer(TokenBudget::new(25), FixedTokenizer(10));
2631 let mut ctx = SessionContext::new(vec![]);
2632 ctx.messages = vec![
2633 text(Role::user(), "1"),
2634 text(Role::assistant(), "2"),
2635 text(Role::user(), "3"),
2636 text(Role::assistant(), "4"),
2637 ];
2638 provider.before_run(&mut ctx).await.unwrap();
2639 assert_eq!(ctx.messages.len(), 2);
2641 assert_eq!(ctx.messages[0].text(), "3");
2642 assert_eq!(ctx.messages[1].text(), "4");
2643 }
2644
2645 #[tokio::test]
2646 async fn compaction_provider_after_run_is_a_noop() {
2647 let provider = CompactionProvider::new(Truncation::new(1));
2648 provider
2649 .after_run(&[Message::new(Role::user(), "hi")], &[], None)
2650 .await
2651 .unwrap();
2652 }
2653}