1use crate::llm::types::{
4 CompletionRequest, ContentBlock, Message, ReasoningEffort, Role, ToolDefinition, ToolResult,
5};
6
7use super::token_estimator::{estimate_message_tokens, estimate_tokens};
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum ContextStrategy {
12 Unlimited,
14 SlidingWindow {
16 max_tokens: u32,
18 },
19}
20
21pub(crate) struct AgentContext {
23 system: String,
24 messages: Vec<Message>,
25 tools: Vec<ToolDefinition>,
26 max_turns: usize,
27 max_tokens: u32,
28 current_turn: usize,
29 context_strategy: ContextStrategy,
30 reasoning_effort: Option<ReasoningEffort>,
31}
32
33impl AgentContext {
34 pub(crate) fn new(
35 system: impl Into<String>,
36 task: impl Into<String>,
37 tools: Vec<ToolDefinition>,
38 ) -> Self {
39 Self {
40 system: system.into(),
41 messages: vec![Message::user(task)],
42 tools,
43 max_turns: 10,
44 max_tokens: 4096,
45 current_turn: 0,
46 context_strategy: ContextStrategy::Unlimited,
47 reasoning_effort: None,
48 }
49 }
50
51 pub(crate) fn from_content(
53 system: impl Into<String>,
54 content: Vec<ContentBlock>,
55 tools: Vec<ToolDefinition>,
56 ) -> Self {
57 Self {
58 system: system.into(),
59 messages: vec![Message {
60 role: Role::User,
61 content,
62 }],
63 tools,
64 max_turns: 10,
65 max_tokens: 4096,
66 current_turn: 0,
67 context_strategy: ContextStrategy::Unlimited,
68 reasoning_effort: None,
69 }
70 }
71
72 pub(crate) fn evict_media(&mut self) {
76 let last_user_idx = self.messages.iter().rposition(|m| m.role == Role::User);
78
79 for (i, msg) in self.messages.iter_mut().enumerate() {
80 if Some(i) == last_user_idx {
81 continue;
82 }
83 for block in &mut msg.content {
84 match block {
85 ContentBlock::Image { .. } => {
86 *block = ContentBlock::Text {
87 text: "[image previously sent]".into(),
88 };
89 }
90 ContentBlock::Audio { .. } => {
91 *block = ContentBlock::Text {
92 text: "[audio previously sent]".into(),
93 };
94 }
95 _ => {}
96 }
97 }
98 }
99 }
100
101 pub(crate) fn with_max_turns(mut self, max_turns: usize) -> Self {
102 self.max_turns = max_turns;
103 self
104 }
105
106 pub(crate) fn with_max_tokens(mut self, max_tokens: u32) -> Self {
107 self.max_tokens = max_tokens;
108 self
109 }
110
111 pub(crate) fn with_context_strategy(mut self, strategy: ContextStrategy) -> Self {
112 self.context_strategy = strategy;
113 self
114 }
115
116 pub(crate) fn with_reasoning_effort(mut self, effort: Option<ReasoningEffort>) -> Self {
117 self.reasoning_effort = effort;
118 self
119 }
120
121 pub(crate) fn message_count(&self) -> usize {
122 self.messages.len()
123 }
124
125 pub(crate) fn current_turn(&self) -> usize {
126 self.current_turn
127 }
128
129 pub(crate) fn max_turns(&self) -> usize {
130 self.max_turns
131 }
132
133 pub(crate) fn increment_turn(&mut self) {
134 self.current_turn += 1;
135 }
136
137 pub(crate) fn add_assistant_message(&mut self, message: Message) {
138 self.messages.push(message);
139 }
140
141 pub(crate) fn add_user_message(&mut self, text: impl Into<String>) {
142 self.messages.push(Message::user(text));
143 }
144
145 pub(crate) fn add_tool_results(&mut self, results: Vec<ToolResult>) {
146 self.messages.push(Message::tool_results(results));
147 }
148
149 pub(crate) fn last_assistant_text(&self) -> Option<String> {
151 self.messages.iter().rev().find_map(|m| {
152 if m.role == Role::Assistant {
153 let text: String = m
154 .content
155 .iter()
156 .filter_map(|b| match b {
157 ContentBlock::Text { text } => Some(text.as_str()),
158 _ => None,
159 })
160 .collect();
161 Some(text)
162 } else {
163 None
164 }
165 })
166 }
167
168 pub(crate) fn total_tokens(&self) -> u32 {
170 self.messages
171 .iter()
172 .map(estimate_message_tokens)
173 .sum::<u32>()
174 + estimate_tokens(&self.system)
175 }
176
177 pub(crate) fn needs_compaction(&self, max_tokens: u32) -> bool {
179 self.total_tokens() > max_tokens
180 }
181
182 pub(crate) fn inject_summary(&mut self, summary: String, keep_last_n: usize) {
191 let Some(first) = self.messages.first() else {
193 return;
194 };
195 let original_task: String = first
196 .content
197 .iter()
198 .filter_map(|b| match b {
199 ContentBlock::Text { text } => Some(text.as_str()),
200 _ => None,
201 })
202 .collect();
203
204 inject_summary_into_messages(&mut self.messages, &original_task, &summary, keep_last_n);
205 }
206
207 pub(crate) fn conversation_text(&self) -> String {
209 messages_to_text(&self.messages)
210 }
211
212 pub(crate) fn messages_to_be_compacted(&self, keep_last_n: usize) -> &[Message] {
216 if self.messages.len() <= 1 + keep_last_n {
217 return &[];
218 }
219 let tail_start = self.messages.len().saturating_sub(keep_last_n);
220 if tail_start <= 1 {
222 return &[];
223 }
224 &self.messages[1..tail_start]
225 }
226
227 pub(crate) fn to_request(&self) -> CompletionRequest {
228 let messages = match &self.context_strategy {
229 ContextStrategy::Unlimited => self.messages.clone(),
230 ContextStrategy::SlidingWindow { max_tokens } => {
231 apply_sliding_window(&self.messages, *max_tokens)
232 }
233 };
234
235 CompletionRequest {
236 system: self.system.clone(),
237 messages,
238 tools: self.tools.clone(),
239 max_tokens: self.max_tokens,
240 tool_choice: None,
241 reasoning_effort: self.reasoning_effort,
242 }
243 }
244}
245
246pub fn inject_summary_into_messages(
254 messages: &mut Vec<Message>,
255 original_task: &str,
256 summary: &str,
257 keep_last_n: usize,
258) {
259 if messages.is_empty() {
260 return;
261 }
262 let total = messages.len();
263 if total <= 1 + keep_last_n {
265 return;
266 }
267
268 let combined = Message::user(format!(
269 "{original_task}\n\n[Previous conversation summary]\n{summary}"
270 ));
271
272 let mut tail_start = total.saturating_sub(keep_last_n);
275 while tail_start < total && messages[tail_start].role == Role::User && tail_start > 1 {
280 tail_start -= 1;
281 }
282 let last_messages: Vec<Message> = messages[tail_start..].to_vec();
283
284 messages.clear();
285 messages.push(combined);
286 messages.extend(last_messages);
287}
288
289pub fn messages_to_text(messages: &[Message]) -> String {
293 let mut parts = Vec::with_capacity(messages.len());
294 for msg in messages {
295 let role = match msg.role {
296 Role::User => "User",
297 Role::Assistant => "Assistant",
298 };
299 let text: String = msg
300 .content
301 .iter()
302 .map(|b| match b {
303 ContentBlock::Text { text } => text.as_str().into(),
304 ContentBlock::ToolUse { name, input, .. } => {
305 format!("[Tool call: {name}({input})]")
306 }
307 ContentBlock::ToolResult { content, .. } => {
308 format!("[Tool result: {content}]")
309 }
310 ContentBlock::Image { media_type, .. } => {
311 format!("[Image: {media_type}]")
312 }
313 ContentBlock::Audio { format, .. } => {
314 format!("[Audio: {format}]")
315 }
316 })
317 .collect::<Vec<String>>()
318 .join(" ");
319 parts.push(format!("{role}: {text}"));
320 }
321 parts.join("\n")
322}
323
324pub fn apply_sliding_window(messages: &[Message], max_tokens: u32) -> Vec<Message> {
331 if messages.len() <= 1 {
332 return messages.to_vec();
333 }
334
335 let first = &messages[0];
336 let first_tokens = estimate_message_tokens(first);
337 if first_tokens >= max_tokens {
338 return vec![first.clone()];
339 }
340
341 let mut budget = max_tokens - first_tokens;
342 let tail = &messages[1..];
343
344 let mut included_from = tail.len();
346 let mut i = tail.len();
347 while i > 0 {
348 i -= 1;
349 let msg = &tail[i];
350 let msg_tokens = estimate_message_tokens(msg);
351
352 let is_tool_result = msg.role == Role::User
356 && msg
357 .content
358 .iter()
359 .any(|b| matches!(b, ContentBlock::ToolResult { .. }));
360
361 if is_tool_result && i > 0 {
362 let prev = &tail[i - 1];
363 let prev_tokens = estimate_message_tokens(prev);
364 let pair_tokens = msg_tokens + prev_tokens;
365
366 if pair_tokens <= budget {
367 budget -= pair_tokens;
368 i -= 1;
369 included_from = i;
370 } else {
371 break;
372 }
373 } else if msg_tokens <= budget {
374 budget -= msg_tokens;
375 included_from = i;
376 } else {
377 break;
378 }
379 }
380
381 let mut result = vec![first.clone()];
382 result.extend_from_slice(&tail[included_from..]);
383 result
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use serde_json::json;
390
391 #[test]
392 fn new_context_has_user_message() {
393 let ctx = AgentContext::new("system", "do something", vec![]);
394 let req = ctx.to_request();
395
396 assert_eq!(req.system, "system");
397 assert_eq!(req.messages.len(), 1);
398 assert_eq!(req.messages[0].role, Role::User);
399 }
400
401 #[test]
402 fn with_max_turns_overrides_default() {
403 let ctx = AgentContext::new("sys", "task", vec![]).with_max_turns(5);
404 assert_eq!(ctx.max_turns(), 5);
405 }
406
407 #[test]
408 fn with_max_tokens_overrides_default() {
409 let ctx = AgentContext::new("sys", "task", vec![]).with_max_tokens(8192);
410 let req = ctx.to_request();
411 assert_eq!(req.max_tokens, 8192);
412 }
413
414 #[test]
415 fn default_max_tokens_is_4096() {
416 let ctx = AgentContext::new("sys", "task", vec![]);
417 let req = ctx.to_request();
418 assert_eq!(req.max_tokens, 4096);
419 }
420
421 #[test]
422 fn turn_tracking() {
423 let mut ctx = AgentContext::new("sys", "task", vec![]);
424 assert_eq!(ctx.current_turn(), 0);
425 ctx.increment_turn();
426 assert_eq!(ctx.current_turn(), 1);
427 }
428
429 #[test]
430 fn add_user_message_creates_user_message() {
431 let mut ctx = AgentContext::new("sys", "task", vec![]);
432 ctx.add_user_message("follow up question");
433
434 let req = ctx.to_request();
435 assert_eq!(req.messages.len(), 2); assert_eq!(req.messages[1].role, Role::User);
437 }
438
439 #[test]
440 fn add_tool_results_creates_user_message() {
441 let mut ctx = AgentContext::new("sys", "task", vec![]);
442 ctx.add_tool_results(vec![ToolResult::success("call-1", "result")]);
443
444 let req = ctx.to_request();
445 assert_eq!(req.messages.len(), 2);
446 assert_eq!(req.messages[1].role, Role::User);
447 }
448
449 #[test]
450 fn request_includes_tools() {
451 let tools = vec![ToolDefinition {
452 name: "search".into(),
453 description: "Search".into(),
454 input_schema: json!({"type": "object"}),
455 }];
456 let ctx = AgentContext::new("sys", "task", tools);
457 let req = ctx.to_request();
458 assert_eq!(req.tools.len(), 1);
459 assert_eq!(req.tools[0].name, "search");
460 }
461
462 #[test]
463 fn default_is_unlimited() {
464 let ctx = AgentContext::new("sys", "task", vec![]);
465 assert!(matches!(ctx.context_strategy, ContextStrategy::Unlimited));
466 }
467
468 #[test]
469 fn unlimited_passes_all() {
470 let mut ctx = AgentContext::new("sys", "task", vec![]);
471 ctx.add_assistant_message(Message::assistant("response 1"));
472 ctx.add_assistant_message(Message::assistant("response 2"));
473 ctx.add_assistant_message(Message::assistant("response 3"));
474
475 let req = ctx.to_request();
476 assert_eq!(req.messages.len(), 4); }
478
479 #[test]
480 fn sliding_window_preserves_first() {
481 let mut ctx = AgentContext::new("sys", "initial task", vec![])
482 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 20 });
483
484 ctx.add_assistant_message(Message::assistant("a".repeat(100)));
485 ctx.add_assistant_message(Message::assistant("recent"));
486
487 let req = ctx.to_request();
488 assert_eq!(req.messages[0].role, Role::User);
490 assert!(
491 req.messages[0]
492 .content
493 .iter()
494 .any(|b| matches!(b, ContentBlock::Text { text } if text == "initial task"))
495 );
496 }
497
498 #[test]
499 fn sliding_window_trims_old() {
500 let mut ctx = AgentContext::new("sys", "task", vec![])
501 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 50 });
502
503 for i in 0..10 {
505 ctx.add_assistant_message(Message::assistant(format!("response {i} with some text")));
506 }
507
508 let req = ctx.to_request();
509 assert!(req.messages.len() < 11);
511 assert_eq!(req.messages[0].role, Role::User);
513 }
514
515 #[test]
516 fn sliding_window_keeps_tool_pairs() {
517 let mut ctx = AgentContext::new("sys", "task", vec![])
518 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 200 });
519
520 ctx.add_assistant_message(Message {
522 role: Role::Assistant,
523 content: vec![ContentBlock::ToolUse {
524 id: "c1".into(),
525 name: "search".into(),
526 input: json!({"q": "test"}),
527 }],
528 });
529 ctx.add_tool_results(vec![ToolResult::success("c1", "found it")]);
530 ctx.add_assistant_message(Message::assistant("Based on the search results..."));
531
532 let req = ctx.to_request();
533 let has_tool_use = req.messages.iter().any(|m| {
535 m.content
536 .iter()
537 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
538 });
539 let has_tool_result = req.messages.iter().any(|m| {
540 m.content
541 .iter()
542 .any(|b| matches!(b, ContentBlock::ToolResult { .. }))
543 });
544 assert_eq!(
545 has_tool_use, has_tool_result,
546 "tool_use and tool_result must be kept together"
547 );
548 }
549
550 #[test]
551 fn sliding_window_single_message() {
552 let ctx = AgentContext::new("sys", "task", vec![])
553 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 10 });
554
555 let req = ctx.to_request();
556 assert_eq!(req.messages.len(), 1);
557 }
558
559 #[test]
560 fn needs_compaction_below_threshold() {
561 let ctx = AgentContext::new("sys", "task", vec![]);
562 assert!(!ctx.needs_compaction(10000));
563 }
564
565 #[test]
566 fn needs_compaction_above_threshold() {
567 let mut ctx = AgentContext::new("sys", "task", vec![]);
568 for _ in 0..50 {
569 ctx.add_assistant_message(Message::assistant("a".repeat(200)));
570 }
571 assert!(ctx.needs_compaction(100));
572 }
573
574 #[test]
575 fn inject_summary_replaces_middle() {
576 let mut ctx = AgentContext::new("sys", "initial task", vec![]);
577 ctx.add_assistant_message(Message::assistant("msg 1"));
578 ctx.add_assistant_message(Message::assistant("msg 2"));
579 ctx.add_assistant_message(Message::assistant("msg 3"));
580 ctx.add_assistant_message(Message::assistant("msg 4"));
581 ctx.add_assistant_message(Message::assistant("msg 5"));
582
583 ctx.inject_summary("summary of earlier conversation".into(), 2);
584
585 assert_eq!(ctx.messages.len(), 3);
587 let first_text: String = ctx.messages[0]
589 .content
590 .iter()
591 .filter_map(|b| match b {
592 ContentBlock::Text { text } => Some(text.as_str()),
593 _ => None,
594 })
595 .collect::<Vec<_>>()
596 .join("");
597 assert!(first_text.contains("initial task"));
598 assert!(first_text.contains("summary of earlier"));
599 }
600
601 #[test]
602 fn inject_summary_preserves_first_and_last() {
603 let mut ctx = AgentContext::new("sys", "first task", vec![]);
604 ctx.add_assistant_message(Message::assistant("old 1"));
605 ctx.add_assistant_message(Message::assistant("old 2"));
606 ctx.add_assistant_message(Message::assistant("recent 1"));
607 ctx.add_assistant_message(Message::assistant("recent 2"));
608 ctx.add_assistant_message(Message::assistant("recent 3"));
609
610 ctx.inject_summary("compressed".into(), 3);
611
612 assert_eq!(ctx.messages.len(), 4);
614 assert!(
616 ctx.messages[3]
617 .content
618 .iter()
619 .any(|b| matches!(b, ContentBlock::Text { text } if text == "recent 3"))
620 );
621 }
622
623 #[test]
624 fn inject_summary_noop_few_messages() {
625 let mut ctx = AgentContext::new("sys", "task", vec![]);
626 ctx.add_assistant_message(Message::assistant("only one"));
627
628 ctx.inject_summary("summary".into(), 4);
629
630 assert_eq!(ctx.messages.len(), 2);
632 }
633
634 #[test]
635 fn inject_summary_maintains_alternating_roles() {
636 let mut ctx = AgentContext::new("sys", "task", vec![]);
638 ctx.add_assistant_message(Message::assistant("a1"));
639 ctx.add_assistant_message(Message::assistant("a2"));
640 ctx.add_assistant_message(Message::assistant("a3"));
641 ctx.add_assistant_message(Message::assistant("a4"));
642
643 ctx.inject_summary("summary".into(), 2);
644
645 assert_eq!(ctx.messages[0].role, Role::User);
647 assert_eq!(ctx.messages[1].role, Role::Assistant);
649 }
650
651 #[test]
652 fn inject_summary_adjusts_tail_when_starting_with_user() {
653 let mut ctx = AgentContext::new("sys", "task", vec![]);
657 ctx.add_assistant_message(Message::assistant("a1"));
658 ctx.add_tool_results(vec![ToolResult::success("c1", "result1")]);
659 ctx.add_assistant_message(Message::assistant("a2"));
660 ctx.add_tool_results(vec![ToolResult::success("c2", "result2")]);
661 ctx.add_assistant_message(Message::assistant("a3"));
662 ctx.inject_summary("summary".into(), 2);
668
669 assert_eq!(ctx.messages[0].role, Role::User);
671 assert_eq!(ctx.messages[1].role, Role::Assistant);
672 for w in ctx.messages.windows(2) {
674 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
675 }
676 }
677
678 #[test]
679 fn total_tokens_grows_with_messages() {
680 let mut ctx = AgentContext::new("sys", "task", vec![]);
681 let initial = ctx.total_tokens();
682
683 ctx.add_assistant_message(Message::assistant("a".repeat(100)));
684 assert!(ctx.total_tokens() > initial);
685 }
686
687 #[test]
688 fn shared_inject_summary_preserves_alternation() {
689 let mut messages = vec![
691 Message::user("original task"),
692 Message::assistant("a1"),
693 Message::tool_results(vec![ToolResult::success("c1", "result1")]),
694 Message::assistant("a2"),
695 Message::tool_results(vec![ToolResult::success("c2", "result2")]),
696 Message::assistant("a3"),
697 ];
698
699 inject_summary_into_messages(&mut messages, "original task", "summary of conversation", 2);
700
701 assert_eq!(messages[0].role, Role::User);
703 assert_eq!(messages[1].role, Role::Assistant);
704 for w in messages.windows(2) {
705 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
706 }
707 let first_text: String = messages[0]
709 .content
710 .iter()
711 .filter_map(|b| match b {
712 ContentBlock::Text { text } => Some(text.as_str()),
713 _ => None,
714 })
715 .collect::<Vec<_>>()
716 .join("");
717 assert!(first_text.contains("original task"));
718 assert!(first_text.contains("summary of conversation"));
719 }
720
721 #[test]
722 fn inject_summary_tail_start_near_beginning() {
723 let mut messages = vec![
727 Message::user("original task"),
728 Message::assistant("first response"),
729 Message::assistant("second response"),
730 Message::assistant("third response"),
731 ];
732
733 inject_summary_into_messages(&mut messages, "original task", "summary", 2);
734
735 assert_eq!(messages.len(), 3);
737 assert_eq!(messages[0].role, Role::User);
738 assert_eq!(messages[1].role, Role::Assistant);
739 let first_text: String = messages[0]
741 .content
742 .iter()
743 .filter_map(|b| match b {
744 ContentBlock::Text { text } => Some(text.as_str()),
745 _ => None,
746 })
747 .collect::<Vec<_>>()
748 .join("");
749 assert!(first_text.contains("original task"));
750 assert!(first_text.contains("summary"));
751 }
752
753 #[test]
754 fn from_content_creates_multimodal_message() {
755 let content = vec![
756 ContentBlock::Text {
757 text: "describe this".into(),
758 },
759 ContentBlock::Image {
760 media_type: "image/jpeg".into(),
761 data: "base64data".into(),
762 },
763 ];
764 let ctx = AgentContext::from_content("system", content, vec![]);
765 let req = ctx.to_request();
766 assert_eq!(req.messages.len(), 1);
767 assert_eq!(req.messages[0].role, Role::User);
768 assert_eq!(req.messages[0].content.len(), 2);
769 assert!(matches!(
770 &req.messages[0].content[1],
771 ContentBlock::Image { .. }
772 ));
773 }
774
775 #[test]
776 fn evict_media_replaces_old_images_with_placeholder() {
777 let mut ctx = AgentContext::from_content(
778 "sys",
779 vec![
780 ContentBlock::Text {
781 text: "describe this".into(),
782 },
783 ContentBlock::Image {
784 media_type: "image/jpeg".into(),
785 data: "data1".into(),
786 },
787 ],
788 vec![],
789 );
790 ctx.add_assistant_message(Message::assistant("It shows a cat."));
791 ctx.messages.push(Message {
793 role: Role::User,
794 content: vec![ContentBlock::Image {
795 media_type: "image/png".into(),
796 data: "data2".into(),
797 }],
798 });
799
800 ctx.evict_media();
801
802 assert_eq!(
804 ctx.messages[0].content[1],
805 ContentBlock::Text {
806 text: "[image previously sent]".into()
807 }
808 );
809 assert!(matches!(
811 &ctx.messages[2].content[0],
812 ContentBlock::Image { media_type, .. } if media_type == "image/png"
813 ));
814 }
815
816 #[test]
817 fn evict_media_replaces_old_audio_with_placeholder() {
818 let mut ctx = AgentContext::from_content(
819 "sys",
820 vec![
821 ContentBlock::Text {
822 text: "listen to this".into(),
823 },
824 ContentBlock::Audio {
825 format: "ogg".into(),
826 data: "audiodata1".into(),
827 },
828 ],
829 vec![],
830 );
831 ctx.add_assistant_message(Message::assistant("I heard it."));
832 ctx.messages.push(Message {
833 role: Role::User,
834 content: vec![ContentBlock::Audio {
835 format: "mp3".into(),
836 data: "audiodata2".into(),
837 }],
838 });
839
840 ctx.evict_media();
841
842 assert_eq!(
844 ctx.messages[0].content[1],
845 ContentBlock::Text {
846 text: "[audio previously sent]".into()
847 }
848 );
849 assert!(matches!(
851 &ctx.messages[2].content[0],
852 ContentBlock::Audio { format, .. } if format == "mp3"
853 ));
854 }
855
856 #[test]
857 fn evict_media_noop_when_no_media() {
858 let mut ctx = AgentContext::new("sys", "task", vec![]);
859 ctx.add_assistant_message(Message::assistant("reply"));
860 let msg_count = ctx.message_count();
861 ctx.evict_media();
862 assert_eq!(ctx.message_count(), msg_count);
863 }
864
865 #[test]
866 fn inject_summary_empty_messages_is_noop() {
867 let mut messages = vec![];
868 inject_summary_into_messages(&mut messages, "task", "summary", 2);
869 assert!(messages.is_empty());
870 }
871
872 #[test]
873 fn inject_summary_while_loop_steps_back_to_assistant() {
874 let mut messages = vec![
877 Message::user("original task"),
878 Message::assistant("a1"),
879 Message::tool_results(vec![ToolResult::success("c1", "r1")]),
880 Message::assistant("a2"),
881 Message::tool_results(vec![ToolResult::success("c2", "r2")]),
882 Message::assistant("a3"),
883 Message::tool_results(vec![ToolResult::success("c3", "r3")]),
884 Message::assistant("a4"),
885 ];
886 inject_summary_into_messages(&mut messages, "original task", "summary", 2);
889
890 assert_eq!(messages[0].role, Role::User);
891 assert_eq!(messages[1].role, Role::Assistant);
892 for w in messages.windows(2) {
893 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
894 }
895 }
896
897 #[test]
898 fn messages_to_be_compacted_returns_middle() {
899 let mut ctx = AgentContext::new("sys", "task", vec![]);
900 ctx.add_assistant_message(Message::assistant("a1"));
901 ctx.add_assistant_message(Message::assistant("a2"));
902 ctx.add_assistant_message(Message::assistant("a3"));
903 ctx.add_assistant_message(Message::assistant("a4"));
904
905 let compacted = ctx.messages_to_be_compacted(2);
907 assert_eq!(compacted.len(), 2);
908 }
909
910 #[test]
911 fn messages_to_be_compacted_empty_when_few_messages() {
912 let mut ctx = AgentContext::new("sys", "task", vec![]);
913 ctx.add_assistant_message(Message::assistant("a1"));
914
915 let compacted = ctx.messages_to_be_compacted(2);
917 assert!(compacted.is_empty());
918 }
919
920 #[test]
921 fn messages_to_be_compacted_excludes_first_and_last() {
922 let mut ctx = AgentContext::new("sys", "task", vec![]);
923 ctx.add_assistant_message(Message::assistant("old1"));
924 ctx.add_assistant_message(Message::assistant("old2"));
925 ctx.add_assistant_message(Message::assistant("recent1"));
926 ctx.add_assistant_message(Message::assistant("recent2"));
927
928 let compacted = ctx.messages_to_be_compacted(2);
929 for msg in compacted {
931 let text: String = msg
932 .content
933 .iter()
934 .filter_map(|b| match b {
935 ContentBlock::Text { text } => Some(text.as_str()),
936 _ => None,
937 })
938 .collect();
939 assert!(
940 text.starts_with("old"),
941 "compacted messages should be old ones, got: {text}"
942 );
943 }
944 }
945}