1use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::sync::{Arc, Mutex};
16
17use agent_base::llm_trait::LlmProvider;
18use agent_base::{AgentResult, AgentRuntime, ChatMessage, ContextWindowManager, SessionId};
19
20use crate::compression::config::CompressionConfig;
21use crate::compression::events::{CompressionEvent, CompressionTrigger};
22use crate::compression::filter::{SUMMARY_PREFIX, is_summary_message, split_system_prompt};
23use crate::compression::summarizer::summarize;
24
25type CompactionCache = std::collections::HashMap<(u64, u64), (usize, String)>;
32
33pub struct ContextCompactor {
43 client: Arc<dyn LlmProvider>,
44 config: CompressionConfig,
45 cache: Arc<Mutex<CompactionCache>>,
48}
49
50#[allow(missing_docs)]
51impl ContextCompactor {
52 pub fn new(client: Arc<dyn LlmProvider>, config: CompressionConfig) -> Self {
53 Self {
54 client,
55 config,
56 cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
57 }
58 }
59
60 pub fn clone_handle(&self) -> Self {
65 Self {
66 client: self.client.clone(),
67 config: self.config.clone(),
68 cache: self.cache.clone(),
69 }
70 }
71
72 pub fn config(&self) -> &CompressionConfig {
73 &self.config
74 }
75
76 const MAX_PRESERVED_USER_TOKENS: usize = 20_000;
85
86 pub async fn compact(
105 &self,
106 session_id: u64,
107 messages: &[ChatMessage],
108 trigger: CompressionTrigger,
109 emit_fn: Option<&(dyn Fn(CompressionEvent) + Sync)>,
110 ) -> AgentResult<Option<Vec<ChatMessage>>> {
111 let t0 = std::time::Instant::now();
112 if !self.config.enabled {
113 return Ok(None);
114 }
115
116 let total_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
117 tracing::info!(
118 total_tokens,
119 trigger = self.config.trigger_tokens,
120 "[compact-timing] enter compact()"
121 );
122 if total_tokens <= self.config.trigger_tokens {
123 return Ok(None);
124 }
125
126 let (system_msgs, conversation) = split_system_prompt(messages);
128 tracing::info!(
129 elapsed_ms = t0.elapsed().as_millis() as u64,
130 conv_len = conversation.len(),
131 keep = self.config.keep_recent_messages,
132 "[compact-timing] after split_system_prompt"
133 );
134 if conversation.len() <= self.config.keep_recent_messages + 1 {
135 return Ok(None);
136 }
137
138 let mut recent_start = conversation.len() - self.config.keep_recent_messages;
140 recent_start = safe_cut_index(conversation, 0, recent_start);
141 tracing::info!(
142 elapsed_ms = t0.elapsed().as_millis() as u64,
143 recent_start,
144 "[compact-timing] after split old/recent"
145 );
146 if recent_start == 0 {
147 return Ok(None);
148 }
149
150 let old = &conversation[..recent_start];
153 let recent = &conversation[recent_start..];
154
155 if matches!(old.first(), Some(ChatMessage::Tool { .. })) {
157 tracing::warn!("context compression skipped: old block starts with a tool result");
158 return Ok(None);
159 }
160
161 let old: Vec<&ChatMessage> = old.iter().filter(|m| !is_summary_message(m)).collect();
163 let recent: Vec<&ChatMessage> = recent.iter().filter(|m| !is_summary_message(m)).collect();
164
165 let original_goal = old
167 .iter()
168 .find_map(|m| match m {
169 ChatMessage::User { content, .. } if !content.starts_with(SUMMARY_PREFIX) => {
170 Some(content.as_str())
171 }
172 _ => None,
173 })
174 .unwrap_or("(unknown goal)");
175 let original_goal = truncate_str(original_goal, 400);
176
177 let preserved_user_msgs: Vec<&ChatMessage> = old
180 .iter()
181 .filter(|m| matches!(m, ChatMessage::User { .. }))
182 .copied()
183 .collect();
184 let assistant_tool_msgs: Vec<&ChatMessage> = old
185 .iter()
186 .filter(|m| !matches!(m, ChatMessage::User { .. }))
187 .copied()
188 .collect();
189
190 let transcript = truncate_to_chars(&assistant_tool_msgs, self.config.max_transcript_chars);
192 if transcript.trim().is_empty() && preserved_user_msgs.is_empty() {
193 return Ok(None);
194 }
195
196 let msg_count = messages.len();
198 if let Some(emit) = emit_fn {
199 emit(CompressionEvent::Preparing {
200 session_id,
201 tokens_before: total_tokens,
202 msg_count,
203 trigger: trigger.clone(),
204 });
205 emit(CompressionEvent::Started {
206 session_id,
207 tokens_before: total_tokens,
208 msg_count,
209 trigger: trigger.clone(),
210 });
211 }
212
213 const CACHE_PREFIX_CHARS: usize = 4096;
214 let prefix: String = transcript.chars().take(CACHE_PREFIX_CHARS).collect();
215 let key = (session_id, hash_str(&prefix));
216
217 let cached = self.cache.lock().ok().and_then(|c| c.get(&key).cloned());
218
219 let cache_effective = matches!(cached, Some((cl, _)) if cl == transcript.len() || transcript.len() >= self.config.max_transcript_chars);
223
224 let summary = if transcript.trim().is_empty() {
225 String::new()
227 } else {
228 match cached {
229 Some((_, ref s)) if cache_effective => s.clone(),
230 _ => {
231 if let Some(emit) = emit_fn {
233 emit(CompressionEvent::Progress {
234 session_id,
235 chars: 0,
236 });
237 }
238 tracing::info!(
239 elapsed_ms = t0.elapsed().as_millis() as u64,
240 "[compact-timing] before summarize() LLM call"
241 );
242 let s = if let Some(emit) = emit_fn {
245 let progress_fn = |chars: usize| {
246 emit(CompressionEvent::Progress { session_id, chars });
247 };
248 summarize(
249 self.client.as_ref(),
250 &transcript,
251 &original_goal,
252 self.config.max_summary_chars,
253 Some(&progress_fn),
254 )
255 .await
256 } else {
257 summarize(
258 self.client.as_ref(),
259 &transcript,
260 &original_goal,
261 self.config.max_summary_chars,
262 None,
263 )
264 .await
265 };
266 let s = match s {
267 Ok(s) => {
268 tracing::info!(
269 elapsed_ms = t0.elapsed().as_millis() as u64,
270 "[compact-timing] summarize() returned Ok"
271 );
272 s
273 }
274 Err(e) => {
275 tracing::warn!(
276 session_id,
277 "summarisation failed, falling back to dropping old block: {e}"
278 );
279 String::new()
280 }
281 };
282 if !s.is_empty()
283 && let Ok(mut cache) = self.cache.lock()
284 {
285 cache.insert(key, (transcript.len(), s.clone()));
286 }
287 s
288 }
289 }
290 };
291
292 let mut new_messages: Vec<ChatMessage> = system_msgs.to_vec();
296
297 let truncated_user =
299 truncate_user_messages(&preserved_user_msgs, Self::MAX_PRESERVED_USER_TOKENS);
300
301 let trimmed = summary.trim();
302
303 let old_block_chars: usize = old.iter().map(|m| message_content_len(m)).sum();
309 let replacement_chars: usize = truncated_user
310 .iter()
311 .map(|m| message_content_len(m))
312 .sum::<usize>()
313 + if trimmed.is_empty() {
314 0
315 } else {
316 SUMMARY_PREFIX.len() + 1 + trimmed.len()
317 };
318 if replacement_chars >= old_block_chars {
319 tracing::info!(
320 session_id,
321 old_block_chars,
322 replacement_chars,
323 "[compact-timing] replacement larger than original, skipping compression"
324 );
325 return Ok(None);
326 }
327
328 new_messages.extend(truncated_user.into_iter().cloned());
329
330 if !trimmed.is_empty() {
331 new_messages.push(ChatMessage::user(format!("{SUMMARY_PREFIX}\n{trimmed}")));
332 }
333 new_messages.extend(recent.into_iter().cloned());
334
335 let compressed_tokens: usize = new_messages.iter().map(estimate_message_tokens).sum();
337 tracing::info!(
338 session_id,
339 tokens_before = total_tokens,
340 tokens_after = compressed_tokens,
341 kept_recent = self.config.keep_recent_messages,
342 summary_len = trimmed.len(),
343 preserved_user_count = preserved_user_msgs.len(),
344 old_block_chars,
345 replacement_chars,
346 cache_hit = cache_effective,
347 elapsed_ms = t0.elapsed().as_millis() as u64,
348 "[compact-timing] compression complete"
349 );
350
351 Ok(Some(new_messages))
352 }
353
354 pub fn clear_cache(&self) {
358 if let Ok(mut cache) = self.cache.lock() {
359 cache.clear();
360 }
361 }
362
363 pub async fn compact_session(
374 &self,
375 runtime: &AgentRuntime,
376 session_id: &SessionId,
377 emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
378 ) -> AgentResult<bool> {
379 let sid = session_id.id;
380 let trigger = CompressionTrigger::Manual;
381
382 let messages = runtime
384 .with_session_mut(session_id, |session| session.chat_messages().to_vec())
385 .await?;
386 let msg_count_before = messages.len();
387 let tokens_before: usize = messages.iter().map(estimate_message_tokens).sum();
388
389 let filtered: Vec<ChatMessage> = messages
392 .iter()
393 .filter(|m| !is_summary_message(m))
394 .cloned()
395 .collect();
396
397 let compressed = if let Some(ref ef) = emit_fn {
399 let ef = ef.clone();
400 self.compact(
401 sid,
402 &filtered,
403 trigger.clone(),
404 Some(&move |ev: CompressionEvent| {
405 ef(ev.into_user_event());
406 }),
407 )
408 .await?
409 } else {
410 self.compact(sid, &filtered, trigger.clone(), None).await?
411 };
412
413 let compressed = match compressed {
414 Some(msgs) => msgs,
415 None => {
416 return Ok(false);
418 }
419 };
420
421 let tokens_after: usize = compressed.iter().map(estimate_message_tokens).sum();
422 let msg_count_after = compressed.len();
423
424 runtime
427 .with_session_mut(session_id, |session| {
428 let msg_count_now = session.chat_messages().len();
429 if msg_count_now != msg_count_before {
430 return Err(agent_base::AgentError::internal(format!(
431 "session modified concurrently ({} → {} messages), aborting write-back",
432 msg_count_before, msg_count_now
433 )));
434 }
435 session.set_chat_messages(compressed).map_err(|e| {
436 agent_base::AgentError::internal(format!(
437 "set_chat_messages validation failed: {e}"
438 ))
439 })
440 })
441 .await??;
442
443 let reduction_pct = if tokens_before > 0 {
445 ((tokens_before as f64 - tokens_after as f64) / tokens_before as f64 * 100.0).round()
446 as i32
447 } else {
448 0
449 };
450 if let Some(ref f) = emit_fn {
451 f(CompressionEvent::Completed {
452 session_id: sid,
453 tokens_before,
454 tokens_after,
455 reduction_pct,
456 msg_count_before,
457 msg_count_after,
458 trigger,
459 }
460 .into_user_event());
461 }
462
463 Ok(true)
464 }
465}
466
467fn message_content_len(msg: &ChatMessage) -> usize {
474 match msg {
475 ChatMessage::System { content, .. } => content.len(),
476 ChatMessage::User { content, .. } => content.len(),
477 ChatMessage::Assistant {
478 content,
479 reasoning_content,
480 tool_calls,
481 thinking_signature: _,
482 } => {
483 let mut len = content.as_deref().map(|c| c.len()).unwrap_or(0);
484 if let Some(rc) = reasoning_content {
485 len += rc.len();
486 }
487 if let Some(calls) = tool_calls {
488 for c in calls {
489 len += c.id.len() + c.name.len() + c.arguments.len();
490 }
491 }
492 len
493 }
494 ChatMessage::Tool {
495 tool_call_id,
496 content,
497 ..
498 } => tool_call_id.len() + content.len(),
499 ChatMessage::Custom { role, data } => role.len() + data.to_string().len(),
500 }
501}
502
503pub fn safe_cut_index(messages: &[ChatMessage], start: usize, mut cut: usize) -> usize {
513 while cut > start {
514 let left_is_tool_call = matches!(
515 messages[cut - 1],
516 ChatMessage::Assistant {
517 tool_calls: Some(_),
518 ..
519 }
520 );
521 let right_is_tool = matches!(messages[cut], ChatMessage::Tool { .. });
522 if !left_is_tool_call && !right_is_tool {
523 break;
524 }
525 cut -= 1;
526 }
527 cut
528}
529
530pub(crate) fn estimate_message_tokens(msg: &ChatMessage) -> usize {
535 match msg {
536 ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
537 ChatMessage::User {
538 content, images, ..
539 } => ContextWindowManager::estimate_tokens(content) + images.len() * 85,
540 ChatMessage::Assistant {
541 content,
542 reasoning_content,
543 tool_calls,
544 thinking_signature: _,
545 } => {
546 let mut tokens = content
547 .as_deref()
548 .map(ContextWindowManager::estimate_tokens)
549 .unwrap_or(0);
550 if let Some(rc) = reasoning_content {
551 tokens += ContextWindowManager::estimate_tokens(rc);
552 }
553 if let Some(calls) = tool_calls {
554 for c in calls {
555 tokens += ContextWindowManager::estimate_tokens(&c.id);
556 tokens += ContextWindowManager::estimate_tokens(&c.name);
557 tokens += ContextWindowManager::estimate_tokens(&c.arguments);
558 }
559 }
560 tokens
561 }
562 ChatMessage::Tool {
563 tool_call_id,
564 content,
565 ..
566 } => {
567 ContextWindowManager::estimate_tokens(tool_call_id)
568 + ContextWindowManager::estimate_tokens(content)
569 }
570 ChatMessage::Custom { role, data } => {
571 ContextWindowManager::estimate_tokens(role)
572 + ContextWindowManager::estimate_tokens(&data.to_string())
573 }
574 }
575}
576
577#[allow(dead_code)]
579fn estimate_total_tokens(messages: &[ChatMessage]) -> usize {
580 messages.iter().map(estimate_message_tokens).sum()
581}
582
583fn hash_str(s: &str) -> u64 {
585 let mut h = DefaultHasher::new();
586 s.hash(&mut h);
587 h.finish()
588}
589
590pub fn serialize_block(messages: &[&ChatMessage], max_chars: usize) -> String {
595 let mut parts: Vec<String> = Vec::with_capacity(messages.len());
596 for msg in messages {
597 let line = match msg {
598 ChatMessage::System { content, .. } => {
599 format!("[system] {}", truncate_str(content, 400))
600 }
601 ChatMessage::User { content, .. } => format!("[user] {}", truncate_str(content, 400)),
602 ChatMessage::Assistant {
603 content,
604 tool_calls,
605 ..
606 } => match tool_calls {
607 Some(calls) if !calls.is_empty() => {
608 let calls: Vec<String> = calls
609 .iter()
610 .map(|c| format!("{}({})", c.name, truncate_str(&c.arguments, 150)))
611 .collect();
612 format!("[assistant tool_call] {}", calls.join("; "))
613 }
614 _ => format!(
615 "[assistant] {}",
616 content
617 .as_deref()
618 .map(|c| truncate_str(c, 400))
619 .unwrap_or_default()
620 ),
621 },
622 ChatMessage::Tool {
623 tool_call_id,
624 content,
625 ..
626 } => format!(
627 "[tool:{}] {}",
628 truncate_str(tool_call_id, 50),
629 truncate_str(content, 300)
630 ),
631 ChatMessage::Custom { role, data } => {
632 format!("[custom:{}] {}", role, truncate_str(&data.to_string(), 400))
633 }
634 };
635 parts.push(line);
636 }
637 let joined = parts.join("\n");
638 truncate_str(&joined, max_chars)
639}
640
641pub fn truncate_str(s: &str, max_chars: usize) -> String {
643 let count = s.chars().count();
644 if count <= max_chars {
645 return s.to_string();
646 }
647 let head: String = s.chars().take(max_chars).collect();
648 format!("{head}…")
649}
650
651fn truncate_to_chars(messages: &[&ChatMessage], max_chars: usize) -> String {
653 serialize_block(messages, max_chars)
654}
655
656fn truncate_user_messages<'a>(
662 messages: &[&'a ChatMessage],
663 max_tokens: usize,
664) -> Vec<&'a ChatMessage> {
665 if max_tokens == 0 {
666 return Vec::new();
667 }
668 let mut result: Vec<&ChatMessage> = Vec::with_capacity(messages.len());
669 let mut remaining = max_tokens;
670 for msg in messages.iter().rev() {
672 let tokens = estimate_message_tokens(msg);
673 if tokens <= remaining {
674 result.push(msg);
675 remaining -= tokens;
676 }
677 }
679 result.reverse();
680 result
681}
682
683#[async_trait::async_trait]
688impl agent_base::ContextCompaction for ContextCompactor {
689 async fn compact(
690 &self,
691 session_id: &SessionId,
692 messages: &[ChatMessage],
693 ) -> Option<Vec<ChatMessage>> {
694 match self
696 .compact(
697 session_id.id,
698 messages,
699 CompressionTrigger::InlineCompaction,
700 None,
701 )
702 .await
703 {
704 Ok(result) => result,
705 Err(e) => {
706 tracing::warn!(
707 session_id = session_id.id,
708 error = %e,
709 "inline compaction failed"
710 );
711 None
712 }
713 }
714 }
715
716 fn token_count_hint(&self, _session_id: &SessionId) -> Option<usize> {
717 None
719 }
720}
721
722#[cfg(test)]
725mod tests {
726 use super::*;
727 use agent_base::ToolCallMessage;
728 use agent_base::llm_trait::response::FinishReason;
729 use agent_base::llm_trait::types::UsageInfo;
730 use agent_base::llm_trait::{
731 Capabilities, ChatRequest, ChatResponse, ChatStream, LlmError, LlmProvider, ProviderInfo,
732 };
733
734 struct MockClient(&'static str);
738
739 #[async_trait::async_trait]
740 impl LlmProvider for MockClient {
741 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
742 let response = self.0.to_string();
743 Ok(ChatStream::new(Box::pin(futures_util::stream::once(
744 async move { Ok(agent_base::StreamChunk::Text(response)) },
745 ))))
746 }
747
748 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
749 Ok(ChatResponse {
750 content: self.0.to_string(),
751 tool_calls: vec![],
752 usage: UsageInfo::default(),
753 finish_reason: FinishReason::Stop,
754 raw: None,
755 reasoning_content: None,
756 thinking_signature: None,
757 })
758 }
759
760 fn capabilities(&self) -> Capabilities {
761 Capabilities::default()
762 }
763
764 fn info(&self) -> ProviderInfo {
765 ProviderInfo {
766 name: "stub".to_string(),
767 model: "stub-model".to_string(),
768 version: None,
769 }
770 }
771 }
772
773 struct CountingClient {
775 response: &'static str,
776 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
777 }
778
779 #[async_trait::async_trait]
780 impl LlmProvider for CountingClient {
781 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
782 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
783 let response = self.response.to_string();
784 Ok(ChatStream::new(Box::pin(futures_util::stream::once(
785 async move { Ok(agent_base::StreamChunk::Text(response)) },
786 ))))
787 }
788
789 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
790 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
791 Ok(ChatResponse {
792 content: self.response.to_string(),
793 tool_calls: vec![],
794 usage: UsageInfo::default(),
795 finish_reason: FinishReason::Stop,
796 raw: None,
797 reasoning_content: None,
798 thinking_signature: None,
799 })
800 }
801
802 fn capabilities(&self) -> Capabilities {
803 Capabilities::default()
804 }
805
806 fn info(&self) -> ProviderInfo {
807 ProviderInfo {
808 name: "stub".to_string(),
809 model: "stub-model".to_string(),
810 version: None,
811 }
812 }
813 }
814
815 struct FailingClient;
817
818 #[async_trait::async_trait]
819 impl LlmProvider for FailingClient {
820 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
821 Err(LlmError::llm("summarisation failed"))
822 }
823
824 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
825 Err(LlmError::llm("summarisation failed"))
826 }
827
828 fn capabilities(&self) -> Capabilities {
829 Capabilities::default()
830 }
831
832 fn info(&self) -> ProviderInfo {
833 ProviderInfo {
834 name: "stub".to_string(),
835 model: "stub-model".to_string(),
836 version: None,
837 }
838 }
839 }
840
841 fn make_messages(count: usize) -> Vec<ChatMessage> {
842 let mut msgs = vec![ChatMessage::system("You are a test agent.")];
843 for i in 0..count {
844 msgs.push(ChatMessage::user(format!("question {i}")));
845 msgs.push(ChatMessage::assistant(format!(
846 "answer {i} with some extra content to make it longer"
847 )));
848 }
849 msgs
850 }
851
852 #[test]
855 fn test_safe_cut_never_splits_tool_pair() {
856 let msgs = vec![
857 ChatMessage::user("hi"),
858 ChatMessage::assistant(""),
859 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
860 ChatMessage::tool("call_1", "result"),
861 ChatMessage::user("next"),
862 ];
863 let cut = super::safe_cut_index(&msgs, 0, 3);
866 assert!(
867 cut <= 2,
868 "cut should walk back before assistant{{tool_call}}"
869 );
870 assert!(
871 !matches!(
872 &msgs[cut - 1],
873 ChatMessage::Assistant {
874 tool_calls: Some(_),
875 ..
876 }
877 ),
878 "left boundary must not be assistant with pending tool calls"
879 );
880 }
881
882 #[test]
883 fn test_safe_cut_prefers_given_boundary_when_safe() {
884 let msgs = vec![
885 ChatMessage::user("hi"),
886 ChatMessage::assistant("hello"),
887 ChatMessage::user("next"),
888 ChatMessage::assistant("world"),
889 ];
890 assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
892 }
893
894 #[test]
895 fn test_safe_cut_walks_back_from_orphan_tool() {
896 let msgs = vec![
897 ChatMessage::user("a"),
898 ChatMessage::assistant("b"),
899 ChatMessage::tool("c1", "r1"),
900 ChatMessage::user("d"),
901 ];
902 assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
904 }
905
906 #[test]
909 fn test_estimate_tokens_cjk_and_tool() {
910 let msg = ChatMessage::tool("t1", "你好世界");
911 let tokens = super::estimate_message_tokens(&msg);
912 assert!((2..=10).contains(&tokens));
914 }
915
916 #[test]
917 fn test_estimate_tokens_user_with_images() {
918 let msg = ChatMessage::user_with_images(
919 "describe this",
920 vec![agent_base::ImageAttachment::Url {
921 url: "http://example.com/img.png".into(),
922 detail: Some(agent_base::ImageDetail::Auto),
923 }],
924 );
925 let tokens = super::estimate_message_tokens(&msg);
926 assert!(tokens >= 85);
928 }
929
930 #[test]
933 fn test_serialize_block_preserves_tool_calls() {
934 let msgs = [
935 ChatMessage::assistant(""),
936 ChatMessage::tool("c1", "result"),
937 ];
938 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
939 let out = super::serialize_block(&msg_refs, 2000);
940 assert!(out.contains("[tool:c1]"), "got: {out}");
941 assert!(out.contains("result"), "got: {out}");
942 }
943
944 #[test]
945 fn test_serialize_block_truncates_oversized_fields() {
946 let long_arg = "x".repeat(500);
947 let msgs = [ChatMessage::Assistant {
948 content: None,
949 reasoning_content: None,
950 tool_calls: Some(vec![ToolCallMessage {
951 id: "t1".into(),
952 name: "bash".into(),
953 arguments: long_arg,
954 }]),
955 thinking_signature: None,
956 }];
957 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
958 let out = super::serialize_block(&msg_refs, 2000);
959 assert!(out.contains("bash("));
961 assert!(out.contains("…"));
962 assert!(out.len() < 400);
963 }
964
965 #[tokio::test]
968 async fn test_compact_noop_when_disabled() {
969 let config = CompressionConfig::default().with_enabled(false);
970 let client = std::sync::Arc::new(MockClient("summary"));
971 let compactor = ContextCompactor::new(client, config);
972 let msgs = make_messages(100);
973 let result = compactor
974 .compact(1, &msgs, CompressionTrigger::Auto, None)
975 .await
976 .unwrap();
977 assert!(result.is_none());
978 }
979
980 #[tokio::test]
981 async fn test_compact_noop_when_below_threshold() {
982 let config = CompressionConfig::default().with_trigger_tokens(999_999);
983 let client = std::sync::Arc::new(MockClient("summary"));
984 let compactor = ContextCompactor::new(client, config);
985 let msgs = make_messages(10);
986 let result = compactor
987 .compact(1, &msgs, CompressionTrigger::Auto, None)
988 .await
989 .unwrap();
990 assert!(result.is_none());
991 }
992
993 #[tokio::test]
994 async fn test_compact_produces_valid_output() {
995 let config = CompressionConfig::default()
996 .with_trigger_tokens(1) .with_keep_recent_messages(4);
998 let client = std::sync::Arc::new(MockClient("test summary text"));
999 let compactor = ContextCompactor::new(client, config);
1000 let msgs = make_messages(20);
1001 let result = compactor
1002 .compact(1, &msgs, CompressionTrigger::Auto, None)
1003 .await
1004 .unwrap()
1005 .unwrap();
1006
1007 assert!(matches!(&result[0], ChatMessage::System { .. }));
1009
1010 let has_summary = result.iter().any(|m| match m {
1012 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1013 _ => false,
1014 });
1015 assert!(has_summary, "expected summary message in output");
1016
1017 let last = result.last().unwrap();
1019 assert!(matches!(
1020 last,
1021 ChatMessage::User { .. } | ChatMessage::Assistant { .. }
1022 ));
1023
1024 for (i, msg) in result.iter().enumerate() {
1026 assert!(
1027 !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
1028 "orphan Tool at index 0"
1029 );
1030 }
1031 }
1032
1033 #[tokio::test]
1036 async fn test_compact_fallback_on_summarisation_failure() {
1037 let config = CompressionConfig::default()
1038 .with_trigger_tokens(1)
1039 .with_keep_recent_messages(4);
1040 let client = std::sync::Arc::new(FailingClient);
1041 let compactor = ContextCompactor::new(client, config);
1042 let msgs = make_messages(20);
1043 let result = compactor
1044 .compact(1, &msgs, CompressionTrigger::Auto, None)
1045 .await
1046 .unwrap()
1047 .unwrap();
1048
1049 assert!(matches!(&result[0], ChatMessage::System { .. }));
1051 let has_summary = result.iter().any(|m| match m {
1052 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1053 _ => false,
1054 });
1055 assert!(!has_summary, "should have no summary on failure");
1056 }
1057
1058 #[tokio::test]
1061 async fn test_compact_caches_summary() {
1062 let config = CompressionConfig::default()
1063 .with_trigger_tokens(1)
1064 .with_keep_recent_messages(4);
1065 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1066 let client = std::sync::Arc::new(CountingClient {
1067 response: "cached summary",
1068 calls: calls.clone(),
1069 });
1070 let compactor = ContextCompactor::new(client, config);
1071 let msgs = make_messages(20);
1072
1073 let _ = compactor
1075 .compact(1, &msgs, CompressionTrigger::Auto, None)
1076 .await
1077 .unwrap();
1078 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1079
1080 let _ = compactor
1082 .compact(1, &msgs, CompressionTrigger::Auto, None)
1083 .await
1084 .unwrap();
1085 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1086
1087 let mut grown = msgs.clone();
1089 grown.push(ChatMessage::user("new question"));
1090 grown.push(ChatMessage::assistant("new answer"));
1091 let _ = compactor
1092 .compact(1, &grown, CompressionTrigger::Auto, None)
1093 .await
1094 .unwrap();
1095 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1096 }
1097
1098 #[tokio::test]
1101 async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1102 let config = CompressionConfig::default()
1106 .with_trigger_tokens(1)
1107 .with_keep_recent_messages(4)
1108 .with_max_transcript_chars(60_000); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1110 let client = std::sync::Arc::new(CountingClient {
1111 response: "summary",
1112 calls: calls.clone(),
1113 });
1114 let compactor = ContextCompactor::new(client, config);
1115
1116 let mut msgs = vec![ChatMessage::system("sys")];
1119 for i in 0..200 {
1120 msgs.push(ChatMessage::user(format!("question {i:04}")));
1121 msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1122 }
1123
1124 let _ = compactor
1126 .compact(1, &msgs, CompressionTrigger::Auto, None)
1127 .await
1128 .unwrap();
1129 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1130
1131 let _ = compactor
1133 .compact(1, &msgs, CompressionTrigger::Auto, None)
1134 .await
1135 .unwrap();
1136 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1137
1138 msgs.push(ChatMessage::user("extra question"));
1141 msgs.push(ChatMessage::assistant("extra answer"));
1142 let _ = compactor
1143 .compact(1, &msgs, CompressionTrigger::Auto, None)
1144 .await
1145 .unwrap();
1146 assert_eq!(
1147 calls.load(std::sync::atomic::Ordering::SeqCst),
1148 2,
1149 "length guard should have triggered re-summarisation"
1150 );
1151 }
1152
1153 #[tokio::test]
1156 async fn test_compact_no_orphan_tool_messages() {
1157 let config = CompressionConfig::default()
1158 .with_trigger_tokens(1)
1159 .with_keep_recent_messages(4);
1160 let client = std::sync::Arc::new(MockClient("summary"));
1161 let compactor = ContextCompactor::new(client, config);
1162
1163 let mut msgs = vec![ChatMessage::system("sys")];
1165 for i in 0..10 {
1166 msgs.push(ChatMessage::user(format!("q{i}")));
1167 msgs.push(ChatMessage::assistant_tool_call(
1168 format!("tc{i}"),
1169 "bash",
1170 format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1171 ));
1172 msgs.push(ChatMessage::tool(
1173 format!("tc{i}"),
1174 format!("result {i} with some extra output content"),
1175 ));
1176 }
1177
1178 let result = compactor
1179 .compact(1, &msgs, CompressionTrigger::Auto, None)
1180 .await
1181 .unwrap()
1182 .unwrap();
1183
1184 for (i, msg) in result.iter().enumerate() {
1187 if let ChatMessage::Tool { tool_call_id, .. } = msg {
1188 assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1189 let prev = &result[i - 1];
1190 match prev {
1191 ChatMessage::Assistant {
1192 tool_calls: Some(calls),
1193 ..
1194 } => {
1195 let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1196 assert!(
1197 ids.contains(&tool_call_id.as_str()),
1198 "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1199 );
1200 }
1201 other => panic!(
1202 "Tool {tool_call_id} at index {i} not preceded by \
1203 Assistant{{tool_calls}}, got: {other:?}"
1204 ),
1205 }
1206 }
1207 }
1208 }
1209
1210 #[tokio::test]
1213 async fn test_compact_noop_empty_messages() {
1214 let config = CompressionConfig::default().with_trigger_tokens(1);
1215 let client = std::sync::Arc::new(MockClient("summary"));
1216 let compactor = ContextCompactor::new(client, config);
1217 let result = compactor
1218 .compact(1, &[], CompressionTrigger::Auto, None)
1219 .await
1220 .unwrap();
1221 assert!(result.is_none());
1222 }
1223
1224 #[tokio::test]
1225 async fn test_compact_noop_system_only() {
1226 let config = CompressionConfig::default().with_trigger_tokens(1);
1227 let client = std::sync::Arc::new(MockClient("summary"));
1228 let compactor = ContextCompactor::new(client, config);
1229 let msgs = vec![ChatMessage::system("You are helpful.")];
1230 let result = compactor
1231 .compact(1, &msgs, CompressionTrigger::Auto, None)
1232 .await
1233 .unwrap();
1234 assert!(result.is_none());
1235 }
1236
1237 #[tokio::test]
1238 async fn test_compact_noop_conversation_equals_keep() {
1239 let config = CompressionConfig::default()
1241 .with_trigger_tokens(1)
1242 .with_keep_recent_messages(100);
1243 let client = std::sync::Arc::new(MockClient("summary"));
1244 let compactor = ContextCompactor::new(client, config);
1245 let msgs = make_messages(3); let result = compactor
1247 .compact(1, &msgs, CompressionTrigger::Auto, None)
1248 .await
1249 .unwrap();
1250 assert!(result.is_none());
1251 }
1252
1253 #[tokio::test]
1254 async fn test_compact_filters_old_summary_in_old_block() {
1255 let config = CompressionConfig::default()
1256 .with_trigger_tokens(1)
1257 .with_keep_recent_messages(4);
1258 let client = std::sync::Arc::new(MockClient("new summary"));
1259 let compactor = ContextCompactor::new(client, config);
1260
1261 let mut msgs = vec![ChatMessage::system("sys")];
1263 msgs.push(ChatMessage::user(format!(
1265 "{SUMMARY_PREFIX}\nprevious summary text"
1266 )));
1267 for i in 0..10 {
1268 msgs.push(ChatMessage::user(format!("question {i}")));
1269 msgs.push(ChatMessage::assistant(format!(
1270 "answer {i} with some extra content to make the old block large enough"
1271 )));
1272 }
1273
1274 let result = compactor
1275 .compact(1, &msgs, CompressionTrigger::Auto, None)
1276 .await
1277 .unwrap()
1278 .unwrap();
1279
1280 let summary_count = result
1282 .iter()
1283 .filter(|m| match m {
1284 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1285 _ => false,
1286 })
1287 .count();
1288 assert_eq!(
1289 summary_count, 1,
1290 "should have exactly 1 summary (the new one), old one must be filtered"
1291 );
1292 }
1293
1294 #[tokio::test]
1295 async fn test_compact_filters_old_summary_in_recent_block() {
1296 let config = CompressionConfig::default()
1297 .with_trigger_tokens(1)
1298 .with_keep_recent_messages(4);
1299 let client = std::sync::Arc::new(MockClient("new summary"));
1300 let compactor = ContextCompactor::new(client, config);
1301
1302 let mut msgs = vec![ChatMessage::system("sys")];
1304 for i in 0..5 {
1305 msgs.push(ChatMessage::user(format!("question {i}")));
1306 msgs.push(ChatMessage::assistant(format!(
1307 "answer {i} with some extra content to make the old block large enough"
1308 )));
1309 }
1310 msgs.push(ChatMessage::user(format!(
1312 "{SUMMARY_PREFIX}\nold summary in recent"
1313 )));
1314 for i in 5..10 {
1315 msgs.push(ChatMessage::user(format!("question {i}")));
1316 msgs.push(ChatMessage::assistant(format!(
1317 "answer {i} with some extra content to make the old block large enough"
1318 )));
1319 }
1320
1321 let result = compactor
1322 .compact(1, &msgs, CompressionTrigger::Auto, None)
1323 .await
1324 .unwrap()
1325 .unwrap();
1326
1327 let summary_count = result
1329 .iter()
1330 .filter(|m| match m {
1331 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1332 _ => false,
1333 })
1334 .count();
1335 assert_eq!(
1336 summary_count, 1,
1337 "old summary in recent block must be filtered"
1338 );
1339 }
1340
1341 #[tokio::test]
1342 #[ignore] async fn test_compact_with_real_deepseek_api() {
1344 let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1348 if api_key.is_empty() {
1349 eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1350 return;
1351 }
1352
1353 let _base_url = std::env::var("DEEPSEEK_BASE_URL")
1354 .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1355
1356 return; }
1365
1366 mod proptest_tests {
1369 use super::*;
1370 use proptest::prelude::*;
1371
1372 proptest! {
1373 #[test]
1374 fn truncate_str_chars_count_bounded(s in ".*", max in 0usize..500) {
1375 let result = truncate_str(&s, max);
1376 let char_count = result.chars().count();
1377 let original_count = s.chars().count();
1378 if original_count <= max {
1379 assert_eq!(char_count, original_count);
1380 } else {
1381 assert!(char_count <= max + 1,
1383 "truncated {} chars > max {} + 1", char_count, max);
1384 }
1385 }
1386
1387 #[test]
1388 fn truncate_str_short_string_unchanged(s in "[a-zA-Z\u{4e00}-\u{9fff}]{0,50}", max in 50usize..200) {
1389 let result = truncate_str(&s, max);
1390 assert_eq!(result, s, "short string should be unchanged");
1391 }
1392
1393 #[test]
1394 fn truncate_str_cjk_char_boundary_safe(s in "[\u{4e00}-\u{9fff}]{1,200}", max in 1usize..100) {
1395 let result = truncate_str(&s, max);
1397 assert!(result.chars().count() <= max + 1);
1398 }
1399
1400 #[test]
1401 fn truncate_str_result_is_valid_utf8(s in ".*", max in 0usize..500) {
1402 let result = truncate_str(&s, max);
1403 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1405 let _ = result.len(); let _ = result.chars().count(); }
1408 }
1409
1410 fn arb_messages() -> impl Strategy<Value = Vec<ChatMessage>> {
1412 prop::collection::vec(
1413 prop_oneof![
1414 "[a-z ]{0,50}".prop_map(|s| ChatMessage::user(&s)),
1415 "[a-z ]{0,50}".prop_map(|s| ChatMessage::assistant(&s)),
1416 ("[a-z]{1,10}", "[a-z ]{0,50}")
1417 .prop_map(|(id, content)| ChatMessage::tool(&id, &content)),
1418 ],
1419 0..15,
1420 )
1421 }
1422
1423 proptest! {
1424 #[test]
1425 fn safe_cut_index_never_panics(
1426 messages in arb_messages(),
1427 start in 0usize..15,
1428 ) {
1429 if messages.is_empty() || start >= messages.len() {
1430 return Ok(());
1431 }
1432 let cut = start + (messages.len() - start) / 2;
1433 if cut > messages.len() {
1434 return Ok(());
1435 }
1436 let _result = super::safe_cut_index(&messages, start, cut);
1437 }
1438
1439 #[test]
1440 fn safe_cut_index_result_in_bounds(
1441 messages in arb_messages(),
1442 start in 0usize..15,
1443 ) {
1444 if messages.is_empty() || start >= messages.len() {
1445 return Ok(());
1446 }
1447 let cut = start + (messages.len() - start) / 2;
1448 if cut > messages.len() || cut < start {
1449 return Ok(());
1450 }
1451 let result = super::safe_cut_index(&messages, start, cut);
1452 assert!(result >= start, "result {} < start {}", result, start);
1453 assert!(result <= messages.len(), "result {} > len {}", result, messages.len());
1454 }
1455
1456 #[test]
1457 fn safe_cut_index_never_ends_on_tool_call(
1458 messages in arb_messages(),
1459 start in 0usize..15,
1460 ) {
1461 if messages.is_empty() || start >= messages.len() {
1462 return Ok(());
1463 }
1464 let cut = start + (messages.len() - start) / 2;
1465 if cut > messages.len() || cut < start {
1466 return Ok(());
1467 }
1468 let result = super::safe_cut_index(&messages, start, cut);
1469 if result > 0 && result < messages.len() {
1471 assert!(
1472 !matches!(&messages[result - 1], ChatMessage::Assistant { tool_calls: Some(_), .. }),
1473 "safe_cut left boundary should not be assistant with tool_calls"
1474 );
1475 }
1476 }
1477 }
1478 }
1479}