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<agent_base::CompactionOutcome> {
694 match self
696 .compact(
697 session_id.id,
698 messages,
699 CompressionTrigger::InlineCompaction,
700 None,
701 )
702 .await
703 {
704 Ok(Some(result)) => {
705 Some(agent_base::CompactionOutcome {
709 kind: agent_base::CompactionKind::Reset,
710 messages: result,
711 })
712 }
713 Ok(None) => None,
714 Err(e) => {
715 tracing::warn!(
716 session_id = session_id.id,
717 error = %e,
718 "inline compaction failed"
719 );
720 None
721 }
722 }
723 }
724
725 fn token_count_hint(&self, _session_id: &SessionId) -> Option<usize> {
726 None
728 }
729}
730
731#[cfg(test)]
734mod tests {
735 use super::*;
736 use agent_base::ToolCallMessage;
737 use agent_base::llm_trait::response::FinishReason;
738 use agent_base::llm_trait::types::UsageInfo;
739 use agent_base::llm_trait::{
740 Capabilities, ChatRequest, ChatResponse, ChatStream, LlmError, LlmProvider, ProviderInfo,
741 };
742
743 struct MockClient(&'static str);
747
748 #[async_trait::async_trait]
749 impl LlmProvider for MockClient {
750 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
751 let response = self.0.to_string();
752 Ok(ChatStream::new(Box::pin(futures_util::stream::once(
753 async move { Ok(agent_base::StreamChunk::Text(response)) },
754 ))))
755 }
756
757 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
758 Ok(ChatResponse {
759 content: self.0.to_string(),
760 tool_calls: vec![],
761 usage: UsageInfo::default(),
762 finish_reason: FinishReason::Stop,
763 raw: None,
764 reasoning_content: None,
765 thinking_signature: None,
766 })
767 }
768
769 fn capabilities(&self) -> Capabilities {
770 Capabilities::default()
771 }
772
773 fn info(&self) -> ProviderInfo {
774 ProviderInfo {
775 name: "stub".to_string(),
776 model: "stub-model".to_string(),
777 version: None,
778 }
779 }
780 }
781
782 struct CountingClient {
784 response: &'static str,
785 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
786 }
787
788 #[async_trait::async_trait]
789 impl LlmProvider for CountingClient {
790 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
791 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
792 let response = self.response.to_string();
793 Ok(ChatStream::new(Box::pin(futures_util::stream::once(
794 async move { Ok(agent_base::StreamChunk::Text(response)) },
795 ))))
796 }
797
798 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
799 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
800 Ok(ChatResponse {
801 content: self.response.to_string(),
802 tool_calls: vec![],
803 usage: UsageInfo::default(),
804 finish_reason: FinishReason::Stop,
805 raw: None,
806 reasoning_content: None,
807 thinking_signature: None,
808 })
809 }
810
811 fn capabilities(&self) -> Capabilities {
812 Capabilities::default()
813 }
814
815 fn info(&self) -> ProviderInfo {
816 ProviderInfo {
817 name: "stub".to_string(),
818 model: "stub-model".to_string(),
819 version: None,
820 }
821 }
822 }
823
824 struct FailingClient;
826
827 #[async_trait::async_trait]
828 impl LlmProvider for FailingClient {
829 async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
830 Err(LlmError::llm("summarisation failed"))
831 }
832
833 async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
834 Err(LlmError::llm("summarisation failed"))
835 }
836
837 fn capabilities(&self) -> Capabilities {
838 Capabilities::default()
839 }
840
841 fn info(&self) -> ProviderInfo {
842 ProviderInfo {
843 name: "stub".to_string(),
844 model: "stub-model".to_string(),
845 version: None,
846 }
847 }
848 }
849
850 fn make_messages(count: usize) -> Vec<ChatMessage> {
851 let mut msgs = vec![ChatMessage::system("You are a test agent.")];
852 for i in 0..count {
853 msgs.push(ChatMessage::user(format!("question {i}")));
854 msgs.push(ChatMessage::assistant(format!(
855 "answer {i} with some extra content to make it longer"
856 )));
857 }
858 msgs
859 }
860
861 #[test]
864 fn test_safe_cut_never_splits_tool_pair() {
865 let msgs = vec![
866 ChatMessage::user("hi"),
867 ChatMessage::assistant(""),
868 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
869 ChatMessage::tool("call_1", "result"),
870 ChatMessage::user("next"),
871 ];
872 let cut = super::safe_cut_index(&msgs, 0, 3);
875 assert!(
876 cut <= 2,
877 "cut should walk back before assistant{{tool_call}}"
878 );
879 assert!(
880 !matches!(
881 &msgs[cut - 1],
882 ChatMessage::Assistant {
883 tool_calls: Some(_),
884 ..
885 }
886 ),
887 "left boundary must not be assistant with pending tool calls"
888 );
889 }
890
891 #[test]
892 fn test_safe_cut_prefers_given_boundary_when_safe() {
893 let msgs = vec![
894 ChatMessage::user("hi"),
895 ChatMessage::assistant("hello"),
896 ChatMessage::user("next"),
897 ChatMessage::assistant("world"),
898 ];
899 assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
901 }
902
903 #[test]
904 fn test_safe_cut_walks_back_from_orphan_tool() {
905 let msgs = vec![
906 ChatMessage::user("a"),
907 ChatMessage::assistant("b"),
908 ChatMessage::tool("c1", "r1"),
909 ChatMessage::user("d"),
910 ];
911 assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
913 }
914
915 #[test]
918 fn test_estimate_tokens_cjk_and_tool() {
919 let msg = ChatMessage::tool("t1", "你好世界");
920 let tokens = super::estimate_message_tokens(&msg);
921 assert!((2..=10).contains(&tokens));
923 }
924
925 #[test]
926 fn test_estimate_tokens_user_with_images() {
927 let msg = ChatMessage::user_with_images(
928 "describe this",
929 vec![agent_base::ImageAttachment::Url {
930 url: "http://example.com/img.png".into(),
931 detail: Some(agent_base::ImageDetail::Auto),
932 }],
933 );
934 let tokens = super::estimate_message_tokens(&msg);
935 assert!(tokens >= 85);
937 }
938
939 #[test]
942 fn test_serialize_block_preserves_tool_calls() {
943 let msgs = [
944 ChatMessage::assistant(""),
945 ChatMessage::tool("c1", "result"),
946 ];
947 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
948 let out = super::serialize_block(&msg_refs, 2000);
949 assert!(out.contains("[tool:c1]"), "got: {out}");
950 assert!(out.contains("result"), "got: {out}");
951 }
952
953 #[test]
954 fn test_serialize_block_truncates_oversized_fields() {
955 let long_arg = "x".repeat(500);
956 let msgs = [ChatMessage::Assistant {
957 content: None,
958 reasoning_content: None,
959 tool_calls: Some(vec![ToolCallMessage {
960 id: "t1".into(),
961 name: "bash".into(),
962 arguments: long_arg,
963 }]),
964 thinking_signature: None,
965 }];
966 let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
967 let out = super::serialize_block(&msg_refs, 2000);
968 assert!(out.contains("bash("));
970 assert!(out.contains("…"));
971 assert!(out.len() < 400);
972 }
973
974 #[tokio::test]
977 async fn test_compact_noop_when_disabled() {
978 let config = CompressionConfig::default().with_enabled(false);
979 let client = std::sync::Arc::new(MockClient("summary"));
980 let compactor = ContextCompactor::new(client, config);
981 let msgs = make_messages(100);
982 let result = compactor
983 .compact(1, &msgs, CompressionTrigger::Auto, None)
984 .await
985 .unwrap();
986 assert!(result.is_none());
987 }
988
989 #[tokio::test]
990 async fn test_compact_noop_when_below_threshold() {
991 let config = CompressionConfig::default().with_trigger_tokens(999_999);
992 let client = std::sync::Arc::new(MockClient("summary"));
993 let compactor = ContextCompactor::new(client, config);
994 let msgs = make_messages(10);
995 let result = compactor
996 .compact(1, &msgs, CompressionTrigger::Auto, None)
997 .await
998 .unwrap();
999 assert!(result.is_none());
1000 }
1001
1002 #[tokio::test]
1003 async fn test_compact_produces_valid_output() {
1004 let config = CompressionConfig::default()
1005 .with_trigger_tokens(1) .with_keep_recent_messages(4);
1007 let client = std::sync::Arc::new(MockClient("test summary text"));
1008 let compactor = ContextCompactor::new(client, config);
1009 let msgs = make_messages(20);
1010 let result = compactor
1011 .compact(1, &msgs, CompressionTrigger::Auto, None)
1012 .await
1013 .unwrap()
1014 .unwrap();
1015
1016 assert!(matches!(&result[0], ChatMessage::System { .. }));
1018
1019 let has_summary = result.iter().any(|m| match m {
1021 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1022 _ => false,
1023 });
1024 assert!(has_summary, "expected summary message in output");
1025
1026 let last = result.last().unwrap();
1028 assert!(matches!(
1029 last,
1030 ChatMessage::User { .. } | ChatMessage::Assistant { .. }
1031 ));
1032
1033 for (i, msg) in result.iter().enumerate() {
1035 assert!(
1036 !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
1037 "orphan Tool at index 0"
1038 );
1039 }
1040 }
1041
1042 #[tokio::test]
1045 async fn test_compact_fallback_on_summarisation_failure() {
1046 let config = CompressionConfig::default()
1047 .with_trigger_tokens(1)
1048 .with_keep_recent_messages(4);
1049 let client = std::sync::Arc::new(FailingClient);
1050 let compactor = ContextCompactor::new(client, config);
1051 let msgs = make_messages(20);
1052 let result = compactor
1053 .compact(1, &msgs, CompressionTrigger::Auto, None)
1054 .await
1055 .unwrap()
1056 .unwrap();
1057
1058 assert!(matches!(&result[0], ChatMessage::System { .. }));
1060 let has_summary = result.iter().any(|m| match m {
1061 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1062 _ => false,
1063 });
1064 assert!(!has_summary, "should have no summary on failure");
1065 }
1066
1067 #[tokio::test]
1070 async fn test_compact_caches_summary() {
1071 let config = CompressionConfig::default()
1072 .with_trigger_tokens(1)
1073 .with_keep_recent_messages(4);
1074 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1075 let client = std::sync::Arc::new(CountingClient {
1076 response: "cached summary",
1077 calls: calls.clone(),
1078 });
1079 let compactor = ContextCompactor::new(client, config);
1080 let msgs = make_messages(20);
1081
1082 let _ = compactor
1084 .compact(1, &msgs, CompressionTrigger::Auto, None)
1085 .await
1086 .unwrap();
1087 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1088
1089 let _ = compactor
1091 .compact(1, &msgs, CompressionTrigger::Auto, None)
1092 .await
1093 .unwrap();
1094 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1095
1096 let mut grown = msgs.clone();
1098 grown.push(ChatMessage::user("new question"));
1099 grown.push(ChatMessage::assistant("new answer"));
1100 let _ = compactor
1101 .compact(1, &grown, CompressionTrigger::Auto, None)
1102 .await
1103 .unwrap();
1104 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1105 }
1106
1107 #[tokio::test]
1110 async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1111 let config = CompressionConfig::default()
1115 .with_trigger_tokens(1)
1116 .with_keep_recent_messages(4)
1117 .with_max_transcript_chars(60_000); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1119 let client = std::sync::Arc::new(CountingClient {
1120 response: "summary",
1121 calls: calls.clone(),
1122 });
1123 let compactor = ContextCompactor::new(client, config);
1124
1125 let mut msgs = vec![ChatMessage::system("sys")];
1128 for i in 0..200 {
1129 msgs.push(ChatMessage::user(format!("question {i:04}")));
1130 msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1131 }
1132
1133 let _ = compactor
1135 .compact(1, &msgs, CompressionTrigger::Auto, None)
1136 .await
1137 .unwrap();
1138 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1139
1140 let _ = compactor
1142 .compact(1, &msgs, CompressionTrigger::Auto, None)
1143 .await
1144 .unwrap();
1145 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1146
1147 msgs.push(ChatMessage::user("extra question"));
1150 msgs.push(ChatMessage::assistant("extra answer"));
1151 let _ = compactor
1152 .compact(1, &msgs, CompressionTrigger::Auto, None)
1153 .await
1154 .unwrap();
1155 assert_eq!(
1156 calls.load(std::sync::atomic::Ordering::SeqCst),
1157 2,
1158 "length guard should have triggered re-summarisation"
1159 );
1160 }
1161
1162 #[tokio::test]
1165 async fn test_compact_no_orphan_tool_messages() {
1166 let config = CompressionConfig::default()
1167 .with_trigger_tokens(1)
1168 .with_keep_recent_messages(4);
1169 let client = std::sync::Arc::new(MockClient("summary"));
1170 let compactor = ContextCompactor::new(client, config);
1171
1172 let mut msgs = vec![ChatMessage::system("sys")];
1174 for i in 0..10 {
1175 msgs.push(ChatMessage::user(format!("q{i}")));
1176 msgs.push(ChatMessage::assistant_tool_call(
1177 format!("tc{i}"),
1178 "bash",
1179 format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1180 ));
1181 msgs.push(ChatMessage::tool(
1182 format!("tc{i}"),
1183 format!("result {i} with some extra output content"),
1184 ));
1185 }
1186
1187 let result = compactor
1188 .compact(1, &msgs, CompressionTrigger::Auto, None)
1189 .await
1190 .unwrap()
1191 .unwrap();
1192
1193 for (i, msg) in result.iter().enumerate() {
1196 if let ChatMessage::Tool { tool_call_id, .. } = msg {
1197 assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1198 let prev = &result[i - 1];
1199 match prev {
1200 ChatMessage::Assistant {
1201 tool_calls: Some(calls),
1202 ..
1203 } => {
1204 let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1205 assert!(
1206 ids.contains(&tool_call_id.as_str()),
1207 "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1208 );
1209 }
1210 other => panic!(
1211 "Tool {tool_call_id} at index {i} not preceded by \
1212 Assistant{{tool_calls}}, got: {other:?}"
1213 ),
1214 }
1215 }
1216 }
1217 }
1218
1219 #[tokio::test]
1222 async fn test_compact_noop_empty_messages() {
1223 let config = CompressionConfig::default().with_trigger_tokens(1);
1224 let client = std::sync::Arc::new(MockClient("summary"));
1225 let compactor = ContextCompactor::new(client, config);
1226 let result = compactor
1227 .compact(1, &[], CompressionTrigger::Auto, None)
1228 .await
1229 .unwrap();
1230 assert!(result.is_none());
1231 }
1232
1233 #[tokio::test]
1234 async fn test_compact_noop_system_only() {
1235 let config = CompressionConfig::default().with_trigger_tokens(1);
1236 let client = std::sync::Arc::new(MockClient("summary"));
1237 let compactor = ContextCompactor::new(client, config);
1238 let msgs = vec![ChatMessage::system("You are helpful.")];
1239 let result = compactor
1240 .compact(1, &msgs, CompressionTrigger::Auto, None)
1241 .await
1242 .unwrap();
1243 assert!(result.is_none());
1244 }
1245
1246 #[tokio::test]
1247 async fn test_compact_noop_conversation_equals_keep() {
1248 let config = CompressionConfig::default()
1250 .with_trigger_tokens(1)
1251 .with_keep_recent_messages(100);
1252 let client = std::sync::Arc::new(MockClient("summary"));
1253 let compactor = ContextCompactor::new(client, config);
1254 let msgs = make_messages(3); let result = compactor
1256 .compact(1, &msgs, CompressionTrigger::Auto, None)
1257 .await
1258 .unwrap();
1259 assert!(result.is_none());
1260 }
1261
1262 #[tokio::test]
1263 async fn test_compact_filters_old_summary_in_old_block() {
1264 let config = CompressionConfig::default()
1265 .with_trigger_tokens(1)
1266 .with_keep_recent_messages(4);
1267 let client = std::sync::Arc::new(MockClient("new summary"));
1268 let compactor = ContextCompactor::new(client, config);
1269
1270 let mut msgs = vec![ChatMessage::system("sys")];
1272 msgs.push(ChatMessage::user(format!(
1274 "{SUMMARY_PREFIX}\nprevious summary text"
1275 )));
1276 for i in 0..10 {
1277 msgs.push(ChatMessage::user(format!("question {i}")));
1278 msgs.push(ChatMessage::assistant(format!(
1279 "answer {i} with some extra content to make the old block large enough"
1280 )));
1281 }
1282
1283 let result = compactor
1284 .compact(1, &msgs, CompressionTrigger::Auto, None)
1285 .await
1286 .unwrap()
1287 .unwrap();
1288
1289 let summary_count = result
1291 .iter()
1292 .filter(|m| match m {
1293 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1294 _ => false,
1295 })
1296 .count();
1297 assert_eq!(
1298 summary_count, 1,
1299 "should have exactly 1 summary (the new one), old one must be filtered"
1300 );
1301 }
1302
1303 #[tokio::test]
1304 async fn test_compact_filters_old_summary_in_recent_block() {
1305 let config = CompressionConfig::default()
1306 .with_trigger_tokens(1)
1307 .with_keep_recent_messages(4);
1308 let client = std::sync::Arc::new(MockClient("new summary"));
1309 let compactor = ContextCompactor::new(client, config);
1310
1311 let mut msgs = vec![ChatMessage::system("sys")];
1313 for i in 0..5 {
1314 msgs.push(ChatMessage::user(format!("question {i}")));
1315 msgs.push(ChatMessage::assistant(format!(
1316 "answer {i} with some extra content to make the old block large enough"
1317 )));
1318 }
1319 msgs.push(ChatMessage::user(format!(
1321 "{SUMMARY_PREFIX}\nold summary in recent"
1322 )));
1323 for i in 5..10 {
1324 msgs.push(ChatMessage::user(format!("question {i}")));
1325 msgs.push(ChatMessage::assistant(format!(
1326 "answer {i} with some extra content to make the old block large enough"
1327 )));
1328 }
1329
1330 let result = compactor
1331 .compact(1, &msgs, CompressionTrigger::Auto, None)
1332 .await
1333 .unwrap()
1334 .unwrap();
1335
1336 let summary_count = result
1338 .iter()
1339 .filter(|m| match m {
1340 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1341 _ => false,
1342 })
1343 .count();
1344 assert_eq!(
1345 summary_count, 1,
1346 "old summary in recent block must be filtered"
1347 );
1348 }
1349
1350 #[tokio::test]
1351 #[ignore] async fn test_compact_with_real_deepseek_api() {
1353 let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1357 if api_key.is_empty() {
1358 eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1359 return;
1360 }
1361
1362 let _base_url = std::env::var("DEEPSEEK_BASE_URL")
1363 .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1364
1365 return; }
1374
1375 mod proptest_tests {
1378 use super::*;
1379 use proptest::prelude::*;
1380
1381 proptest! {
1382 #[test]
1383 fn truncate_str_chars_count_bounded(s in ".*", max in 0usize..500) {
1384 let result = truncate_str(&s, max);
1385 let char_count = result.chars().count();
1386 let original_count = s.chars().count();
1387 if original_count <= max {
1388 assert_eq!(char_count, original_count);
1389 } else {
1390 assert!(char_count <= max + 1,
1392 "truncated {} chars > max {} + 1", char_count, max);
1393 }
1394 }
1395
1396 #[test]
1397 fn truncate_str_short_string_unchanged(s in "[a-zA-Z\u{4e00}-\u{9fff}]{0,50}", max in 50usize..200) {
1398 let result = truncate_str(&s, max);
1399 assert_eq!(result, s, "short string should be unchanged");
1400 }
1401
1402 #[test]
1403 fn truncate_str_cjk_char_boundary_safe(s in "[\u{4e00}-\u{9fff}]{1,200}", max in 1usize..100) {
1404 let result = truncate_str(&s, max);
1406 assert!(result.chars().count() <= max + 1);
1407 }
1408
1409 #[test]
1410 fn truncate_str_result_is_valid_utf8(s in ".*", max in 0usize..500) {
1411 let result = truncate_str(&s, max);
1412 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1414 let _ = result.len(); let _ = result.chars().count(); }
1417 }
1418
1419 fn arb_messages() -> impl Strategy<Value = Vec<ChatMessage>> {
1421 prop::collection::vec(
1422 prop_oneof![
1423 "[a-z ]{0,50}".prop_map(|s| ChatMessage::user(&s)),
1424 "[a-z ]{0,50}".prop_map(|s| ChatMessage::assistant(&s)),
1425 ("[a-z]{1,10}", "[a-z ]{0,50}")
1426 .prop_map(|(id, content)| ChatMessage::tool(&id, &content)),
1427 ],
1428 0..15,
1429 )
1430 }
1431
1432 proptest! {
1433 #[test]
1434 fn safe_cut_index_never_panics(
1435 messages in arb_messages(),
1436 start in 0usize..15,
1437 ) {
1438 if messages.is_empty() || start >= messages.len() {
1439 return Ok(());
1440 }
1441 let cut = start + (messages.len() - start) / 2;
1442 if cut > messages.len() {
1443 return Ok(());
1444 }
1445 let _result = super::safe_cut_index(&messages, start, cut);
1446 }
1447
1448 #[test]
1449 fn safe_cut_index_result_in_bounds(
1450 messages in arb_messages(),
1451 start in 0usize..15,
1452 ) {
1453 if messages.is_empty() || start >= messages.len() {
1454 return Ok(());
1455 }
1456 let cut = start + (messages.len() - start) / 2;
1457 if cut > messages.len() || cut < start {
1458 return Ok(());
1459 }
1460 let result = super::safe_cut_index(&messages, start, cut);
1461 assert!(result >= start, "result {} < start {}", result, start);
1462 assert!(result <= messages.len(), "result {} > len {}", result, messages.len());
1463 }
1464
1465 #[test]
1466 fn safe_cut_index_never_ends_on_tool_call(
1467 messages in arb_messages(),
1468 start in 0usize..15,
1469 ) {
1470 if messages.is_empty() || start >= messages.len() {
1471 return Ok(());
1472 }
1473 let cut = start + (messages.len() - start) / 2;
1474 if cut > messages.len() || cut < start {
1475 return Ok(());
1476 }
1477 let result = super::safe_cut_index(&messages, start, cut);
1478 if result > 0 && result < messages.len() {
1480 assert!(
1481 !matches!(&messages[result - 1], ChatMessage::Assistant { tool_calls: Some(_), .. }),
1482 "safe_cut left boundary should not be assistant with tool_calls"
1483 );
1484 }
1485 }
1486 }
1487 }
1488}