1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::pin::Pin;
4use std::time::Duration;
5
6use thiserror::Error;
7
8use crate::client::models::{Message as LLMMessage, MessageOptions};
9use crate::client::LLMClient;
10
11use crate::controller::types::{ContentBlock, Message, TextBlock, TurnId, UserMessage};
12
13#[derive(Error, Debug)]
15pub enum CompactorConfigError {
16 #[error("Invalid threshold {0}: must be between 0.0 and 1.0 (exclusive)")]
18 InvalidThreshold(f64),
19
20 #[error("Invalid keep_recent_turns {0}: must be at least 1")]
22 InvalidKeepRecentTurns(usize),
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ToolCompaction {
28 Summarize,
30 Redact,
32}
33
34impl std::fmt::Display for ToolCompaction {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 ToolCompaction::Summarize => write!(f, "summarize"),
38 ToolCompaction::Redact => write!(f, "redact"),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct CompactionResult {
46 pub tool_results_summarized: usize,
48 pub tool_results_redacted: usize,
50 pub turns_compacted: usize,
52}
53
54impl CompactionResult {
55 pub fn total_compacted(&self) -> usize {
57 self.tool_results_summarized + self.tool_results_redacted
58 }
59}
60
61#[derive(Debug)]
63pub enum CompactionError {
64 LLMError(String),
66 Timeout,
68 ConfigError(String),
70}
71
72impl std::fmt::Display for CompactionError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 CompactionError::LLMError(msg) => write!(f, "LLM error: {}", msg),
76 CompactionError::Timeout => write!(f, "compaction timed out"),
77 CompactionError::ConfigError(msg) => write!(f, "config error: {}", msg),
78 }
79 }
80}
81
82impl std::error::Error for CompactionError {}
83
84pub trait Compactor: Send + Sync {
88 fn should_compact(&self, context_used: i64, context_limit: i32) -> bool;
94
95 fn compact(
102 &self,
103 conversation: &mut Vec<Message>,
104 compact_summaries: &HashMap<String, String>,
105 ) -> CompactionResult;
106
107 fn is_async(&self) -> bool {
110 false
111 }
112}
113
114pub trait AsyncCompactor: Compactor {
117 fn compact_async<'a>(
123 &'a self,
124 conversation: Vec<Message>,
125 compact_summaries: &'a HashMap<String, String>,
126 ) -> Pin<Box<dyn Future<Output = Result<(Vec<Message>, CompactionResult), CompactionError>> + Send + 'a>>;
127}
128
129pub struct ThresholdCompactor {
133 threshold: f64,
136
137 keep_recent_turns: usize,
140
141 tool_compaction: ToolCompaction,
143}
144
145impl ThresholdCompactor {
146 pub fn new(
156 threshold: f64,
157 keep_recent_turns: usize,
158 tool_compaction: ToolCompaction,
159 ) -> Result<Self, CompactorConfigError> {
160 if threshold <= 0.0 || threshold >= 1.0 {
161 return Err(CompactorConfigError::InvalidThreshold(threshold));
162 }
163
164 Ok(Self {
165 threshold,
166 keep_recent_turns,
167 tool_compaction,
168 })
169 }
170
171 fn unique_turn_ids(&self, conversation: &[Message]) -> Vec<TurnId> {
173 let mut seen = HashSet::new();
174 let mut ids = Vec::new();
175
176 for msg in conversation {
177 let turn_id = msg.turn_id();
178 if seen.insert(turn_id.clone()) {
179 ids.push(turn_id.clone());
180 }
181 }
182
183 ids
184 }
185
186 fn compact_message(
188 &self,
189 msg: &mut Message,
190 compact_summaries: &HashMap<String, String>,
191 ) -> (usize, usize) {
192 let mut summarized = 0;
193 let mut redacted = 0;
194
195 for block in msg.content_mut() {
196 if let ContentBlock::ToolResult(tool_result) = block {
197 match self.tool_compaction {
198 ToolCompaction::Summarize => {
199 if let Some(summary) = compact_summaries.get(&tool_result.tool_use_id) {
201 tool_result.content = summary.clone();
202 summarized += 1;
203 tracing::debug!(
204 tool_use_id = %tool_result.tool_use_id,
205 "Tool result summarized"
206 );
207 }
208 }
210 ToolCompaction::Redact => {
211 tool_result.content =
212 "[Tool result redacted during compaction]".to_string();
213 redacted += 1;
214 tracing::debug!(
215 tool_use_id = %tool_result.tool_use_id,
216 "Tool result redacted"
217 );
218 }
219 }
220 }
221 }
222
223 (summarized, redacted)
224 }
225}
226
227impl Compactor for ThresholdCompactor {
228 fn should_compact(&self, context_used: i64, context_limit: i32) -> bool {
229 if context_limit == 0 {
230 return false;
231 }
232
233 let utilization = context_used as f64 / context_limit as f64;
234 let should_compact = utilization > self.threshold;
235
236 if should_compact {
237 tracing::info!(
238 utilization = utilization,
239 threshold = self.threshold,
240 context_used,
241 context_limit,
242 "Compaction triggered - context utilization exceeded threshold"
243 );
244 }
245
246 should_compact
247 }
248
249 fn compact(
250 &self,
251 conversation: &mut Vec<Message>,
252 compact_summaries: &HashMap<String, String>,
253 ) -> CompactionResult {
254 if conversation.is_empty() {
255 return CompactionResult::default();
256 }
257
258 let turn_ids = self.unique_turn_ids(conversation);
260
261 if turn_ids.len() <= self.keep_recent_turns {
263 tracing::debug!(
264 total_turns = turn_ids.len(),
265 keep_recent = self.keep_recent_turns,
266 "Skipping compaction - not enough turns"
267 );
268 return CompactionResult::default();
269 }
270
271 let start_idx = turn_ids.len() - self.keep_recent_turns;
273 let turns_to_keep: HashSet<_> = turn_ids[start_idx..].iter().cloned().collect();
274 let turns_compacted = start_idx;
275
276 tracing::info!(
277 total_turns = turn_ids.len(),
278 keep_recent = self.keep_recent_turns,
279 compacting_turns = turns_compacted,
280 tool_compaction_strategy = %self.tool_compaction,
281 "Starting conversation compaction"
282 );
283
284 let mut total_summarized = 0;
286 let mut total_redacted = 0;
287
288 for msg in conversation.iter_mut() {
289 let turn_id = msg.turn_id();
290 if turns_to_keep.contains(turn_id) {
291 continue; }
293
294 let (summarized, redacted) = self.compact_message(msg, compact_summaries);
295 total_summarized += summarized;
296 total_redacted += redacted;
297 }
298
299 tracing::info!(
300 tool_results_summarized = total_summarized,
301 tool_results_redacted = total_redacted,
302 turns_compacted,
303 "Conversation compaction completed"
304 );
305
306 CompactionResult {
307 tool_results_summarized: total_summarized,
308 tool_results_redacted: total_redacted,
309 turns_compacted,
310 }
311 }
312}
313
314pub const DEFAULT_SUMMARY_SYSTEM_PROMPT: &str = r#"You are a conversation summarizer. Your task is to create a concise summary of the conversation history provided.
320
321Guidelines:
322- Capture the key topics discussed, decisions made, and important context
323- Preserve any technical details, file paths, code snippets, or specific values that would be needed to continue the conversation
324- Include the user's original goals and any progress made toward them
325- Note any pending tasks or unresolved questions
326- Keep the summary focused and actionable
327- Format the summary as a narrative that provides context for continuing the conversation
328
329Respond with only the summary, no additional commentary."#;
330
331pub const DEFAULT_MAX_SUMMARY_TOKENS: i64 = 2048;
333
334pub const DEFAULT_SUMMARY_TIMEOUT: Duration = Duration::from_secs(60);
336
337#[derive(Debug, Clone)]
339pub struct LLMCompactorConfig {
340 pub threshold: f64,
342
343 pub keep_recent_turns: usize,
345
346 pub summary_system_prompt: Option<String>,
348
349 pub max_summary_tokens: Option<i64>,
351
352 pub summary_timeout: Option<Duration>,
354}
355
356impl LLMCompactorConfig {
357 pub fn new(threshold: f64, keep_recent_turns: usize) -> Self {
359 Self {
360 threshold,
361 keep_recent_turns,
362 summary_system_prompt: None,
363 max_summary_tokens: None,
364 summary_timeout: None,
365 }
366 }
367
368 pub fn validate(&self) -> Result<(), CompactorConfigError> {
370 if self.threshold <= 0.0 || self.threshold >= 1.0 {
371 return Err(CompactorConfigError::InvalidThreshold(self.threshold));
372 }
373 Ok(())
374 }
375
376 pub fn system_prompt(&self) -> &str {
378 self.summary_system_prompt
379 .as_deref()
380 .unwrap_or(DEFAULT_SUMMARY_SYSTEM_PROMPT)
381 }
382
383 pub fn max_tokens(&self) -> i64 {
385 self.max_summary_tokens.unwrap_or(DEFAULT_MAX_SUMMARY_TOKENS)
386 }
387
388 pub fn timeout(&self) -> Duration {
390 self.summary_timeout.unwrap_or(DEFAULT_SUMMARY_TIMEOUT)
391 }
392}
393
394impl Default for LLMCompactorConfig {
395 fn default() -> Self {
396 Self::new(0.75, 5)
397 }
398}
399
400pub struct LLMCompactor {
403 client: LLMClient,
405
406 config: LLMCompactorConfig,
408}
409
410impl LLMCompactor {
411 pub fn new(client: LLMClient, config: LLMCompactorConfig) -> Result<Self, CompactorConfigError> {
420 config.validate()?;
421
422 tracing::info!(
423 threshold = config.threshold,
424 keep_recent_turns = config.keep_recent_turns,
425 max_summary_tokens = config.max_tokens(),
426 "LLM compactor created"
427 );
428
429 Ok(Self { client, config })
430 }
431
432 fn unique_turn_ids(&self, conversation: &[Message]) -> Vec<TurnId> {
434 let mut seen = HashSet::new();
435 let mut ids = Vec::new();
436
437 for msg in conversation {
438 let turn_id = msg.turn_id();
439 if seen.insert(turn_id.clone()) {
440 ids.push(turn_id.clone());
441 }
442 }
443
444 ids
445 }
446
447 fn format_messages_for_summary(&self, messages: &[Message]) -> String {
449 let mut builder = String::new();
450
451 for msg in messages {
452 if msg.is_user() {
454 builder.push_str("User: ");
455 } else {
456 builder.push_str("Assistant: ");
457 }
458
459 for block in msg.content() {
461 match block {
462 ContentBlock::Text(text) => {
463 builder.push_str(&text.text);
464 }
465 ContentBlock::ToolUse(tool_use) => {
466 builder.push_str(&format!(
467 "[Called tool: {} with input: {:?}]",
468 tool_use.name, tool_use.input
469 ));
470 }
471 ContentBlock::ToolResult(tool_result) => {
472 let content = truncate_content(&tool_result.content, 1000);
473 if tool_result.is_error {
474 builder.push_str(&format!("[Tool error: {}]", content));
475 } else {
476 builder.push_str(&format!("[Tool result: {}]", content));
477 }
478 }
479 }
480 }
481 builder.push_str("\n\n");
482 }
483
484 builder
485 }
486
487 fn create_summary_message(&self, summary: &str, session_id: &str) -> Message {
489 let turn_id = TurnId::new_user_turn(0);
491
492 let now = std::time::SystemTime::now()
494 .duration_since(std::time::UNIX_EPOCH)
495 .unwrap_or_default();
496
497 Message::User(UserMessage {
498 id: format!("summary-{}", now.as_nanos()),
499 session_id: session_id.to_string(),
500 turn_id,
501 created_at: now.as_secs() as i64,
502 content: vec![ContentBlock::Text(TextBlock {
503 text: format!("[Previous conversation summary]:\n\n{}", summary),
504 })],
505 })
506 }
507
508 fn get_session_id(&self, conversation: &[Message]) -> String {
510 conversation
511 .first()
512 .map(|m| m.session_id().to_string())
513 .unwrap_or_default()
514 }
515}
516
517impl Compactor for LLMCompactor {
518 fn should_compact(&self, context_used: i64, context_limit: i32) -> bool {
519 if context_limit == 0 {
520 return false;
521 }
522
523 let utilization = context_used as f64 / context_limit as f64;
524 let should_compact = utilization > self.config.threshold;
525
526 if should_compact {
527 tracing::info!(
528 utilization = utilization,
529 threshold = self.config.threshold,
530 context_used,
531 context_limit,
532 "LLM compaction triggered - context utilization exceeded threshold"
533 );
534 }
535
536 should_compact
537 }
538
539 fn compact(
540 &self,
541 _conversation: &mut Vec<Message>,
542 _compact_summaries: &HashMap<String, String>,
543 ) -> CompactionResult {
544 tracing::warn!("LLMCompactor::compact() called - use compact_async() instead");
546 CompactionResult::default()
547 }
548
549 fn is_async(&self) -> bool {
550 true
551 }
552}
553
554impl AsyncCompactor for LLMCompactor {
555 fn compact_async<'a>(
556 &'a self,
557 conversation: Vec<Message>,
558 _compact_summaries: &'a HashMap<String, String>,
559 ) -> Pin<Box<dyn Future<Output = Result<(Vec<Message>, CompactionResult), CompactionError>> + Send + 'a>>
560 {
561 Box::pin(async move {
562 if conversation.is_empty() {
563 return Ok((conversation, CompactionResult::default()));
564 }
565
566 let turn_ids = self.unique_turn_ids(&conversation);
568
569 if turn_ids.len() <= self.config.keep_recent_turns {
571 tracing::debug!(
572 total_turns = turn_ids.len(),
573 keep_recent = self.config.keep_recent_turns,
574 "Skipping LLM compaction - not enough turns"
575 );
576 return Ok((conversation, CompactionResult::default()));
577 }
578
579 let start_idx = turn_ids.len() - self.config.keep_recent_turns;
581 let turns_to_keep: HashSet<_> = turn_ids[start_idx..].iter().cloned().collect();
582
583 let mut old_messages = Vec::new();
584 let mut recent_messages = Vec::new();
585
586 for msg in conversation {
587 if turns_to_keep.contains(msg.turn_id()) {
588 recent_messages.push(msg);
589 } else {
590 old_messages.push(msg);
591 }
592 }
593
594 if old_messages.is_empty() {
595 return Ok((recent_messages, CompactionResult::default()));
596 }
597
598 let session_id = self.get_session_id(&old_messages);
599
600 let formatted_conversation = self.format_messages_for_summary(&old_messages);
602
603 tracing::info!(
604 total_turns = turn_ids.len(),
605 turns_to_summarize = start_idx,
606 turns_to_keep = self.config.keep_recent_turns,
607 messages_to_summarize = old_messages.len(),
608 formatted_length = formatted_conversation.len(),
609 "Starting LLM conversation compaction"
610 );
611
612 let options = MessageOptions {
614 max_tokens: Some(self.config.max_tokens() as u32),
615 ..Default::default()
616 };
617
618 let llm_messages = vec![
620 LLMMessage::system(self.config.system_prompt()),
621 LLMMessage::user(formatted_conversation),
622 ];
623
624 let result = tokio::time::timeout(
626 self.config.timeout(),
627 self.client.send_message(&llm_messages, &options),
628 )
629 .await;
630
631 let response = match result {
632 Ok(Ok(msg)) => msg,
633 Ok(Err(e)) => {
634 tracing::error!(error = %e, "LLM compaction failed");
635 return Err(CompactionError::LLMError(e.to_string()));
636 }
637 Err(_) => {
638 tracing::error!("LLM compaction timed out");
639 return Err(CompactionError::Timeout);
640 }
641 };
642
643 let summary_text = response
645 .content
646 .iter()
647 .filter_map(|c| {
648 if let crate::client::models::Content::Text(t) = c {
649 Some(t.as_str())
650 } else {
651 None
652 }
653 })
654 .collect::<Vec<_>>()
655 .join("");
656
657 let summary_message = self.create_summary_message(&summary_text, &session_id);
659
660 let mut new_conversation = Vec::with_capacity(1 + recent_messages.len());
662 new_conversation.push(summary_message);
663 new_conversation.extend(recent_messages);
664
665 let result = CompactionResult {
666 tool_results_summarized: 0,
667 tool_results_redacted: 0,
668 turns_compacted: start_idx,
669 };
670
671 tracing::info!(
672 original_messages = old_messages.len() + result.turns_compacted,
673 new_messages = new_conversation.len(),
674 summary_length = summary_text.len(),
675 turns_compacted = result.turns_compacted,
676 "LLM compaction completed"
677 );
678
679 Ok((new_conversation, result))
680 })
681 }
682}
683
684fn truncate_content(content: &str, max_len: usize) -> String {
686 if content.len() <= max_len {
687 content.to_string()
688 } else {
689 format!("{}...", &content[..max_len.saturating_sub(3)])
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::controller::types::{UserMessage, AssistantMessage};
697
698 fn make_user_message(turn_id: TurnId) -> Message {
699 Message::User(UserMessage {
700 id: format!("msg_{}", turn_id),
701 session_id: "test_session".to_string(),
702 turn_id,
703 created_at: 0,
704 content: vec![ContentBlock::Text(crate::controller::types::TextBlock {
705 text: "test".to_string(),
706 })],
707 })
708 }
709
710 fn make_assistant_message(turn_id: TurnId) -> Message {
711 Message::Assistant(AssistantMessage {
712 id: format!("msg_{}", turn_id),
713 session_id: "test_session".to_string(),
714 turn_id,
715 parent_id: String::new(),
716 created_at: 0,
717 completed_at: None,
718 model_id: "test_model".to_string(),
719 provider_id: "test_provider".to_string(),
720 input_tokens: 0,
721 output_tokens: 0,
722 cache_read_tokens: 0,
723 cache_write_tokens: 0,
724 finish_reason: None,
725 error: None,
726 content: vec![ContentBlock::Text(crate::controller::types::TextBlock {
727 text: "test".to_string(),
728 })],
729 })
730 }
731
732 fn make_tool_result_message(tool_use_id: &str, content: &str, turn_id: TurnId) -> Message {
733 Message::User(UserMessage {
734 id: format!("msg_{}", turn_id),
735 session_id: "test_session".to_string(),
736 turn_id,
737 created_at: 0,
738 content: vec![ContentBlock::ToolResult(crate::controller::types::ToolResultBlock {
739 tool_use_id: tool_use_id.to_string(),
740 content: content.to_string(),
741 is_error: false,
742 compact_summary: None,
743 })],
744 })
745 }
746
747 #[test]
748 fn test_threshold_compactor_creation() {
749 let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact);
751 assert!(compactor.is_ok());
752
753 let compactor = ThresholdCompactor::new(0.0, 3, ToolCompaction::Redact);
755 assert!(compactor.is_err());
756
757 let compactor = ThresholdCompactor::new(1.0, 3, ToolCompaction::Redact);
759 assert!(compactor.is_err());
760 }
761
762 #[test]
763 fn test_should_compact() {
764 let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact).unwrap();
765
766 assert!(!compactor.should_compact(7000, 10000));
768
769 assert!(compactor.should_compact(8000, 10000));
771
772 assert!(!compactor.should_compact(8000, 0));
774 }
775
776 #[test]
777 fn test_compact_not_enough_turns() {
778 let compactor = ThresholdCompactor::new(0.75, 3, ToolCompaction::Redact).unwrap();
779
780 let mut conversation = vec![
781 make_user_message(TurnId::new_user_turn(1)),
782 make_assistant_message(TurnId::new_assistant_turn(1)),
783 ];
784
785 let summaries = std::collections::HashMap::new();
786 let result = compactor.compact(&mut conversation, &summaries);
787
788 assert_eq!(result.turns_compacted, 0);
790 }
791
792 #[test]
793 fn test_compact_redacts_old_tool_results() {
794 let compactor = ThresholdCompactor::new(0.75, 2, ToolCompaction::Redact).unwrap();
796
797 let mut conversation = vec![
798 make_tool_result_message("tool_1", "old result", TurnId::new_user_turn(1)),
800 make_assistant_message(TurnId::new_assistant_turn(1)),
801 make_tool_result_message("tool_2", "new result", TurnId::new_user_turn(2)),
803 make_assistant_message(TurnId::new_assistant_turn(2)),
804 ];
805
806 let summaries = std::collections::HashMap::new();
807 let result = compactor.compact(&mut conversation, &summaries);
808
809 assert_eq!(result.tool_results_redacted, 1);
810 assert_eq!(result.turns_compacted, 2); if let ContentBlock::ToolResult(tr) = &conversation[0].content()[0] {
814 assert!(tr.content.contains("redacted"));
815 } else {
816 panic!("Expected ToolResult");
817 }
818
819 if let ContentBlock::ToolResult(tr) = &conversation[2].content()[0] {
821 assert_eq!(tr.content, "new result");
822 } else {
823 panic!("Expected ToolResult");
824 }
825 }
826
827 #[test]
828 fn test_compact_summarizes_with_summary() {
829 let compactor = ThresholdCompactor::new(0.75, 2, ToolCompaction::Summarize).unwrap();
831
832 let mut conversation = vec![
833 make_tool_result_message("tool_1", "very long result", TurnId::new_user_turn(1)),
834 make_assistant_message(TurnId::new_assistant_turn(1)),
835 make_user_message(TurnId::new_user_turn(2)),
836 make_assistant_message(TurnId::new_assistant_turn(2)),
837 ];
838
839 let mut summaries = std::collections::HashMap::new();
840 summaries.insert("tool_1".to_string(), "[summary]".to_string());
841
842 let result = compactor.compact(&mut conversation, &summaries);
843
844 assert_eq!(result.tool_results_summarized, 1);
845
846 if let ContentBlock::ToolResult(tr) = &conversation[0].content()[0] {
848 assert_eq!(tr.content, "[summary]");
849 } else {
850 panic!("Expected ToolResult");
851 }
852 }
853
854 #[test]
859 fn test_llm_compactor_config_creation() {
860 let config = LLMCompactorConfig::new(0.75, 5);
861 assert_eq!(config.threshold, 0.75);
862 assert_eq!(config.keep_recent_turns, 5);
863 assert!(config.summary_system_prompt.is_none());
864 assert!(config.max_summary_tokens.is_none());
865 assert!(config.summary_timeout.is_none());
866 }
867
868 #[test]
869 fn test_llm_compactor_config_validation() {
870 let config = LLMCompactorConfig::new(0.75, 5);
872 assert!(config.validate().is_ok());
873
874 let config = LLMCompactorConfig::new(0.0, 5);
876 assert!(config.validate().is_err());
877
878 let config = LLMCompactorConfig::new(1.0, 5);
880 assert!(config.validate().is_err());
881
882 let config = LLMCompactorConfig::new(0.01, 5);
884 assert!(config.validate().is_ok());
885
886 let config = LLMCompactorConfig::new(0.99, 5);
888 assert!(config.validate().is_ok());
889 }
890
891 #[test]
892 fn test_llm_compactor_config_defaults() {
893 let config = LLMCompactorConfig::default();
894 assert_eq!(config.threshold, 0.75);
895 assert_eq!(config.keep_recent_turns, 5);
896
897 assert_eq!(config.system_prompt(), DEFAULT_SUMMARY_SYSTEM_PROMPT);
899 assert_eq!(config.max_tokens(), DEFAULT_MAX_SUMMARY_TOKENS);
900 assert_eq!(config.timeout(), DEFAULT_SUMMARY_TIMEOUT);
901 }
902
903 #[test]
904 fn test_llm_compactor_config_custom_values() {
905 let config = LLMCompactorConfig {
906 threshold: 0.8,
907 keep_recent_turns: 3,
908 summary_system_prompt: Some("Custom prompt".to_string()),
909 max_summary_tokens: Some(4096),
910 summary_timeout: Some(Duration::from_secs(120)),
911 };
912
913 assert_eq!(config.system_prompt(), "Custom prompt");
914 assert_eq!(config.max_tokens(), 4096);
915 assert_eq!(config.timeout(), Duration::from_secs(120));
916 }
917
918 #[test]
919 fn test_truncate_content() {
920 assert_eq!(truncate_content("hello", 10), "hello");
922
923 assert_eq!(truncate_content("hello", 5), "hello");
925
926 assert_eq!(truncate_content("hello world", 8), "hello...");
928
929 assert_eq!(truncate_content("hello", 3), "...");
931 }
932}