1use crate::llm::types::{
4 CompletionRequest, ContentBlock, Message, ReasoningEffort, Role, ToolDefinition, ToolResult,
5};
6
7use super::pruner::truncate_with_marker_ext;
8use super::token_estimator::{estimate_message_tokens, estimate_tokens};
9
10fn truncate_tool_results_in_message(
13 msg: &mut Message,
14 max_bytes: usize,
15 restorable: bool,
16) -> usize {
17 let mut saved = 0;
18 for block in &mut msg.content {
19 if let ContentBlock::ToolResult {
20 tool_use_id,
21 content,
22 ..
23 } = block
24 && content.len() > max_bytes
25 {
26 let truncated = truncate_with_marker_ext(content, max_bytes, tool_use_id, restorable);
27 saved += content.len().saturating_sub(truncated.len());
28 *content = truncated;
29 }
30 }
31 saved
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum ContextStrategy {
37 Unlimited,
39 SlidingWindow {
41 max_tokens: u32,
43 },
44}
45
46pub(crate) struct AgentContext {
48 system: String,
49 messages: Vec<Message>,
50 tools: Vec<ToolDefinition>,
51 max_turns: usize,
52 max_tokens: u32,
53 current_turn: usize,
54 context_strategy: ContextStrategy,
55 reasoning_effort: Option<ReasoningEffort>,
56}
57
58impl AgentContext {
59 pub(crate) fn new(
60 system: impl Into<String>,
61 task: impl Into<String>,
62 tools: Vec<ToolDefinition>,
63 ) -> Self {
64 Self {
65 system: system.into(),
66 messages: vec![Message::user(task)],
67 tools,
68 max_turns: 10,
69 max_tokens: 4096,
70 current_turn: 0,
71 context_strategy: ContextStrategy::Unlimited,
72 reasoning_effort: None,
73 }
74 }
75
76 pub(crate) fn from_content(
78 system: impl Into<String>,
79 content: Vec<ContentBlock>,
80 tools: Vec<ToolDefinition>,
81 ) -> Self {
82 Self {
83 system: system.into(),
84 messages: vec![Message {
85 role: Role::User,
86 content,
87 }],
88 tools,
89 max_turns: 10,
90 max_tokens: 4096,
91 current_turn: 0,
92 context_strategy: ContextStrategy::Unlimited,
93 reasoning_effort: None,
94 }
95 }
96
97 pub(crate) fn evict_media(&mut self) {
101 let last_user_idx = self.messages.iter().rposition(|m| m.role == Role::User);
103
104 for (i, msg) in self.messages.iter_mut().enumerate() {
105 if Some(i) == last_user_idx {
106 continue;
107 }
108 for block in &mut msg.content {
109 match block {
110 ContentBlock::Image { .. } => {
111 *block = ContentBlock::Text {
112 text: "[image previously sent]".into(),
113 };
114 }
115 ContentBlock::Audio { .. } => {
116 *block = ContentBlock::Text {
117 text: "[audio previously sent]".into(),
118 };
119 }
120 _ => {}
121 }
122 }
123 }
124 }
125
126 pub(crate) fn with_max_turns(mut self, max_turns: usize) -> Self {
127 self.max_turns = max_turns;
128 self
129 }
130
131 pub(crate) fn with_max_tokens(mut self, max_tokens: u32) -> Self {
132 self.max_tokens = max_tokens;
133 self
134 }
135
136 pub(crate) fn with_context_strategy(mut self, strategy: ContextStrategy) -> Self {
137 self.context_strategy = strategy;
138 self
139 }
140
141 pub(crate) fn with_reasoning_effort(mut self, effort: Option<ReasoningEffort>) -> Self {
142 self.reasoning_effort = effort;
143 self
144 }
145
146 pub(crate) fn messages(&self) -> &[Message] {
149 &self.messages
150 }
151
152 pub(crate) fn message_count(&self) -> usize {
153 self.messages.len()
154 }
155
156 pub(crate) fn current_turn(&self) -> usize {
157 self.current_turn
158 }
159
160 pub(crate) fn max_turns(&self) -> usize {
161 self.max_turns
162 }
163
164 pub(crate) fn increment_turn(&mut self) {
165 self.current_turn += 1;
166 }
167
168 pub(crate) fn add_assistant_message(&mut self, message: Message) {
169 self.messages.push(message);
170 }
171
172 pub(crate) fn add_user_message(&mut self, text: impl Into<String>) {
173 self.messages.push(Message::user(text));
174 }
175
176 pub(crate) fn add_tool_results(&mut self, results: Vec<ToolResult>) {
177 self.messages.push(Message::tool_results(results));
178 }
179
180 pub(crate) fn cap_last_tool_results(&mut self, max_bytes: usize, restorable: bool) -> usize {
187 match self.messages.last_mut() {
188 Some(msg) if msg.role == Role::User => {
189 truncate_tool_results_in_message(msg, max_bytes, restorable)
190 }
191 _ => 0,
192 }
193 }
194
195 pub(crate) fn truncate_oversized_tool_results(
199 &mut self,
200 max_bytes: usize,
201 restorable: bool,
202 ) -> usize {
203 self.messages
204 .iter_mut()
205 .map(|msg| truncate_tool_results_in_message(msg, max_bytes, restorable))
206 .sum()
207 }
208
209 pub(crate) fn last_assistant_text(&self) -> Option<String> {
211 self.messages.iter().rev().find_map(|m| {
212 if m.role == Role::Assistant {
213 let text: String = m
214 .content
215 .iter()
216 .filter_map(|b| match b {
217 ContentBlock::Text { text } => Some(text.as_str()),
218 _ => None,
219 })
220 .collect();
221 Some(text)
222 } else {
223 None
224 }
225 })
226 }
227
228 pub(crate) fn total_tokens(&self) -> u32 {
230 self.messages
231 .iter()
232 .map(estimate_message_tokens)
233 .sum::<u32>()
234 + estimate_tokens(&self.system)
235 }
236
237 pub(crate) fn needs_compaction(&self, max_tokens: u32) -> bool {
239 self.total_tokens() > max_tokens
240 }
241
242 pub(crate) fn inject_summary(&mut self, summary: String, keep_last_n: usize) {
251 let Some(first) = self.messages.first() else {
253 return;
254 };
255 let original_task: String = first
256 .content
257 .iter()
258 .filter_map(|b| match b {
259 ContentBlock::Text { text } => Some(text.as_str()),
260 _ => None,
261 })
262 .collect();
263
264 inject_summary_into_messages(&mut self.messages, &original_task, &summary, keep_last_n);
265 }
266
267 pub(crate) fn conversation_text(&self) -> String {
269 messages_to_text(&self.messages)
270 }
271
272 pub(crate) fn messages_to_be_compacted(&self, keep_last_n: usize) -> &[Message] {
276 if self.messages.len() <= 1 + keep_last_n {
277 return &[];
278 }
279 let tail_start = self.messages.len().saturating_sub(keep_last_n);
280 if tail_start <= 1 {
282 return &[];
283 }
284 &self.messages[1..tail_start]
285 }
286
287 pub(crate) fn to_request(&self) -> CompletionRequest {
288 let messages = match &self.context_strategy {
289 ContextStrategy::Unlimited => self.messages.clone(),
290 ContextStrategy::SlidingWindow { max_tokens } => {
291 apply_sliding_window(&self.messages, *max_tokens)
292 }
293 };
294
295 CompletionRequest {
296 system: self.system.clone(),
297 messages,
298 tools: self.tools.clone(),
299 max_tokens: self.max_tokens,
300 tool_choice: None,
301 reasoning_effort: self.reasoning_effort,
302 }
303 }
304}
305
306pub fn inject_summary_into_messages(
314 messages: &mut Vec<Message>,
315 original_task: &str,
316 summary: &str,
317 keep_last_n: usize,
318) {
319 if messages.is_empty() {
320 return;
321 }
322 let total = messages.len();
323 if total <= 1 + keep_last_n {
325 return;
326 }
327
328 let combined = Message::user(format!(
329 "{original_task}\n\n[Previous conversation summary]\n{summary}"
330 ));
331
332 let mut tail_start = total.saturating_sub(keep_last_n);
335 while tail_start < total && messages[tail_start].role == Role::User && tail_start > 1 {
340 tail_start -= 1;
341 }
342 let last_messages: Vec<Message> = messages[tail_start..].to_vec();
343
344 messages.clear();
345 messages.push(combined);
346 messages.extend(last_messages);
347}
348
349pub fn messages_to_text(messages: &[Message]) -> String {
353 let mut parts = Vec::with_capacity(messages.len());
354 for msg in messages {
355 let role = match msg.role {
356 Role::User => "User",
357 Role::Assistant => "Assistant",
358 };
359 let text: String = msg
360 .content
361 .iter()
362 .map(|b| match b {
363 ContentBlock::Text { text } => text.as_str().into(),
364 ContentBlock::ToolUse { name, input, .. } => {
365 format!("[Tool call: {name}({input})]")
366 }
367 ContentBlock::ToolResult { content, .. } => {
368 format!("[Tool result: {content}]")
369 }
370 ContentBlock::Image { media_type, .. } => {
371 format!("[Image: {media_type}]")
372 }
373 ContentBlock::Audio { format, .. } => {
374 format!("[Audio: {format}]")
375 }
376 })
377 .collect::<Vec<String>>()
378 .join(" ");
379 parts.push(format!("{role}: {text}"));
380 }
381 parts.join("\n")
382}
383
384pub fn apply_sliding_window(messages: &[Message], max_tokens: u32) -> Vec<Message> {
391 if messages.len() <= 1 {
392 return messages.to_vec();
393 }
394
395 let first = &messages[0];
396 let first_tokens = estimate_message_tokens(first);
397 if first_tokens >= max_tokens {
398 return vec![first.clone()];
399 }
400
401 let mut budget = max_tokens - first_tokens;
402 let tail = &messages[1..];
403
404 let mut included_from = tail.len();
406 let mut i = tail.len();
407 while i > 0 {
408 i -= 1;
409 let msg = &tail[i];
410 let msg_tokens = estimate_message_tokens(msg);
411
412 let is_tool_result = msg.role == Role::User
416 && msg
417 .content
418 .iter()
419 .any(|b| matches!(b, ContentBlock::ToolResult { .. }));
420
421 if is_tool_result && i > 0 {
422 let prev = &tail[i - 1];
423 let prev_tokens = estimate_message_tokens(prev);
424 let pair_tokens = msg_tokens + prev_tokens;
425
426 if pair_tokens <= budget {
427 budget -= pair_tokens;
428 i -= 1;
429 included_from = i;
430 } else {
431 break;
432 }
433 } else if msg_tokens <= budget {
434 budget -= msg_tokens;
435 included_from = i;
436 } else {
437 break;
438 }
439 }
440
441 let mut result = vec![first.clone()];
442 result.extend_from_slice(&tail[included_from..]);
443 result
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use serde_json::json;
450
451 #[test]
452 fn new_context_has_user_message() {
453 let ctx = AgentContext::new("system", "do something", vec![]);
454 let req = ctx.to_request();
455
456 assert_eq!(req.system, "system");
457 assert_eq!(req.messages.len(), 1);
458 assert_eq!(req.messages[0].role, Role::User);
459 }
460
461 #[test]
462 fn with_max_turns_overrides_default() {
463 let ctx = AgentContext::new("sys", "task", vec![]).with_max_turns(5);
464 assert_eq!(ctx.max_turns(), 5);
465 }
466
467 #[test]
468 fn with_max_tokens_overrides_default() {
469 let ctx = AgentContext::new("sys", "task", vec![]).with_max_tokens(8192);
470 let req = ctx.to_request();
471 assert_eq!(req.max_tokens, 8192);
472 }
473
474 #[test]
475 fn default_max_tokens_is_4096() {
476 let ctx = AgentContext::new("sys", "task", vec![]);
477 let req = ctx.to_request();
478 assert_eq!(req.max_tokens, 4096);
479 }
480
481 #[test]
482 fn turn_tracking() {
483 let mut ctx = AgentContext::new("sys", "task", vec![]);
484 assert_eq!(ctx.current_turn(), 0);
485 ctx.increment_turn();
486 assert_eq!(ctx.current_turn(), 1);
487 }
488
489 #[test]
490 fn add_user_message_creates_user_message() {
491 let mut ctx = AgentContext::new("sys", "task", vec![]);
492 ctx.add_user_message("follow up question");
493
494 let req = ctx.to_request();
495 assert_eq!(req.messages.len(), 2); assert_eq!(req.messages[1].role, Role::User);
497 }
498
499 #[test]
500 fn add_tool_results_creates_user_message() {
501 let mut ctx = AgentContext::new("sys", "task", vec![]);
502 ctx.add_tool_results(vec![ToolResult::success("call-1", "result")]);
503
504 let req = ctx.to_request();
505 assert_eq!(req.messages.len(), 2);
506 assert_eq!(req.messages[1].role, Role::User);
507 }
508
509 #[test]
510 fn cap_last_tool_results_truncates_oversized() {
511 let mut ctx = AgentContext::new("sys", "task", vec![]);
512 ctx.add_tool_results(vec![
513 ToolResult::success("c1", "x".repeat(10_000)),
514 ToolResult::success("c2", "small"),
515 ]);
516
517 let saved = ctx.cap_last_tool_results(1_000, false);
518 assert!(saved > 8_000, "bytes saved should be substantial: {saved}");
519
520 let msgs = ctx.messages();
521 assert_eq!(msgs.len(), 2, "message count unchanged");
522 let ContentBlock::ToolResult { content, .. } = &msgs[1].content[0] else {
523 panic!("expected tool result");
524 };
525 assert!(content.len() <= 1_000, "capped: {} bytes", content.len());
526 assert!(content.contains("[truncated:"), "non-restorable marker");
527 let ContentBlock::ToolResult { content, .. } = &msgs[1].content[1] else {
528 panic!("expected tool result");
529 };
530 assert_eq!(content, "small", "sub-cap result untouched");
531 }
532
533 #[test]
534 fn cap_last_tool_results_restorable_marker_names_ref() {
535 let mut ctx = AgentContext::new("sys", "task", vec![]);
536 ctx.add_tool_results(vec![ToolResult::success("tc_big", "y".repeat(5_000))]);
537
538 ctx.cap_last_tool_results(500, true);
539
540 let ContentBlock::ToolResult { content, .. } = &ctx.messages()[1].content[0] else {
541 panic!("expected tool result");
542 };
543 assert!(content.contains("fetch_full_output(\"tc_big\")"));
544 }
545
546 #[test]
547 fn cap_last_tool_results_noop_when_small_or_not_tool_results() {
548 let mut ctx = AgentContext::new("sys", "task", vec![]);
549 ctx.add_tool_results(vec![ToolResult::success("c1", "tiny")]);
550 assert_eq!(ctx.cap_last_tool_results(1_000, false), 0);
551
552 ctx.add_user_message("plain user text");
553 assert_eq!(
554 ctx.cap_last_tool_results(1, false),
555 0,
556 "non-tool-result tail is a no-op"
557 );
558 }
559
560 #[test]
561 fn cap_last_tool_results_utf8_safe() {
562 let mut ctx = AgentContext::new("sys", "task", vec![]);
563 ctx.add_tool_results(vec![ToolResult::success("c1", "🦀".repeat(1_000))]);
564
565 ctx.cap_last_tool_results(500, false);
566
567 let ContentBlock::ToolResult { content, .. } = &ctx.messages()[1].content[0] else {
568 panic!("expected tool result");
569 };
570 assert!(content.starts_with('🦀'));
571 for _ in content.chars() {}
572 }
573
574 #[test]
575 fn truncate_oversized_tool_results_walks_all_messages() {
576 let mut ctx = AgentContext::new("sys", "task", vec![]);
577 ctx.add_tool_results(vec![ToolResult::success("c1", "a".repeat(8_000))]);
578 ctx.add_assistant_message(Message::assistant("thinking"));
579 ctx.add_tool_results(vec![ToolResult::success("c2", "b".repeat(8_000))]);
580
581 let saved = ctx.truncate_oversized_tool_results(1_000, false);
582 assert!(saved > 12_000, "both oversized results truncated: {saved}");
583
584 let roles: Vec<_> = ctx.messages().iter().map(|m| m.role.clone()).collect();
585 assert_eq!(
586 roles,
587 vec![Role::User, Role::User, Role::Assistant, Role::User],
588 "message count and roles unchanged"
589 );
590 for msg in ctx.messages() {
591 for block in &msg.content {
592 if let ContentBlock::ToolResult { content, .. } = block {
593 assert!(content.len() <= 1_000, "all results capped");
594 }
595 }
596 }
597 }
598
599 #[test]
600 fn truncate_oversized_tool_results_noop_returns_zero() {
601 let mut ctx = AgentContext::new("sys", "task", vec![]);
602 ctx.add_tool_results(vec![ToolResult::success("c1", "small")]);
603 assert_eq!(ctx.truncate_oversized_tool_results(1_000, false), 0);
604 }
605
606 #[test]
607 fn request_includes_tools() {
608 let tools = vec![ToolDefinition {
609 name: "search".into(),
610 description: "Search".into(),
611 input_schema: json!({"type": "object"}),
612 }];
613 let ctx = AgentContext::new("sys", "task", tools);
614 let req = ctx.to_request();
615 assert_eq!(req.tools.len(), 1);
616 assert_eq!(req.tools[0].name, "search");
617 }
618
619 #[test]
620 fn default_is_unlimited() {
621 let ctx = AgentContext::new("sys", "task", vec![]);
622 assert!(matches!(ctx.context_strategy, ContextStrategy::Unlimited));
623 }
624
625 #[test]
626 fn unlimited_passes_all() {
627 let mut ctx = AgentContext::new("sys", "task", vec![]);
628 ctx.add_assistant_message(Message::assistant("response 1"));
629 ctx.add_assistant_message(Message::assistant("response 2"));
630 ctx.add_assistant_message(Message::assistant("response 3"));
631
632 let req = ctx.to_request();
633 assert_eq!(req.messages.len(), 4); }
635
636 #[test]
637 fn sliding_window_preserves_first() {
638 let mut ctx = AgentContext::new("sys", "initial task", vec![])
639 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 20 });
640
641 ctx.add_assistant_message(Message::assistant("a".repeat(100)));
642 ctx.add_assistant_message(Message::assistant("recent"));
643
644 let req = ctx.to_request();
645 assert_eq!(req.messages[0].role, Role::User);
647 assert!(
648 req.messages[0]
649 .content
650 .iter()
651 .any(|b| matches!(b, ContentBlock::Text { text } if text == "initial task"))
652 );
653 }
654
655 #[test]
656 fn sliding_window_trims_old() {
657 let mut ctx = AgentContext::new("sys", "task", vec![])
658 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 50 });
659
660 for i in 0..10 {
662 ctx.add_assistant_message(Message::assistant(format!("response {i} with some text")));
663 }
664
665 let req = ctx.to_request();
666 assert!(req.messages.len() < 11);
668 assert_eq!(req.messages[0].role, Role::User);
670 }
671
672 #[test]
673 fn sliding_window_keeps_tool_pairs() {
674 let mut ctx = AgentContext::new("sys", "task", vec![])
675 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 200 });
676
677 ctx.add_assistant_message(Message {
679 role: Role::Assistant,
680 content: vec![ContentBlock::ToolUse {
681 id: "c1".into(),
682 name: "search".into(),
683 input: json!({"q": "test"}),
684 }],
685 });
686 ctx.add_tool_results(vec![ToolResult::success("c1", "found it")]);
687 ctx.add_assistant_message(Message::assistant("Based on the search results..."));
688
689 let req = ctx.to_request();
690 let has_tool_use = req.messages.iter().any(|m| {
692 m.content
693 .iter()
694 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
695 });
696 let has_tool_result = req.messages.iter().any(|m| {
697 m.content
698 .iter()
699 .any(|b| matches!(b, ContentBlock::ToolResult { .. }))
700 });
701 assert_eq!(
702 has_tool_use, has_tool_result,
703 "tool_use and tool_result must be kept together"
704 );
705 }
706
707 #[test]
708 fn sliding_window_single_message() {
709 let ctx = AgentContext::new("sys", "task", vec![])
710 .with_context_strategy(ContextStrategy::SlidingWindow { max_tokens: 10 });
711
712 let req = ctx.to_request();
713 assert_eq!(req.messages.len(), 1);
714 }
715
716 #[test]
717 fn needs_compaction_below_threshold() {
718 let ctx = AgentContext::new("sys", "task", vec![]);
719 assert!(!ctx.needs_compaction(10000));
720 }
721
722 #[test]
723 fn needs_compaction_above_threshold() {
724 let mut ctx = AgentContext::new("sys", "task", vec![]);
725 for _ in 0..50 {
726 ctx.add_assistant_message(Message::assistant("a".repeat(200)));
727 }
728 assert!(ctx.needs_compaction(100));
729 }
730
731 #[test]
732 fn inject_summary_replaces_middle() {
733 let mut ctx = AgentContext::new("sys", "initial task", vec![]);
734 ctx.add_assistant_message(Message::assistant("msg 1"));
735 ctx.add_assistant_message(Message::assistant("msg 2"));
736 ctx.add_assistant_message(Message::assistant("msg 3"));
737 ctx.add_assistant_message(Message::assistant("msg 4"));
738 ctx.add_assistant_message(Message::assistant("msg 5"));
739
740 ctx.inject_summary("summary of earlier conversation".into(), 2);
741
742 assert_eq!(ctx.messages.len(), 3);
744 let first_text: String = ctx.messages[0]
746 .content
747 .iter()
748 .filter_map(|b| match b {
749 ContentBlock::Text { text } => Some(text.as_str()),
750 _ => None,
751 })
752 .collect::<Vec<_>>()
753 .join("");
754 assert!(first_text.contains("initial task"));
755 assert!(first_text.contains("summary of earlier"));
756 }
757
758 #[test]
759 fn inject_summary_preserves_first_and_last() {
760 let mut ctx = AgentContext::new("sys", "first task", vec![]);
761 ctx.add_assistant_message(Message::assistant("old 1"));
762 ctx.add_assistant_message(Message::assistant("old 2"));
763 ctx.add_assistant_message(Message::assistant("recent 1"));
764 ctx.add_assistant_message(Message::assistant("recent 2"));
765 ctx.add_assistant_message(Message::assistant("recent 3"));
766
767 ctx.inject_summary("compressed".into(), 3);
768
769 assert_eq!(ctx.messages.len(), 4);
771 assert!(
773 ctx.messages[3]
774 .content
775 .iter()
776 .any(|b| matches!(b, ContentBlock::Text { text } if text == "recent 3"))
777 );
778 }
779
780 #[test]
781 fn inject_summary_noop_few_messages() {
782 let mut ctx = AgentContext::new("sys", "task", vec![]);
783 ctx.add_assistant_message(Message::assistant("only one"));
784
785 ctx.inject_summary("summary".into(), 4);
786
787 assert_eq!(ctx.messages.len(), 2);
789 }
790
791 #[test]
792 fn inject_summary_maintains_alternating_roles() {
793 let mut ctx = AgentContext::new("sys", "task", vec![]);
795 ctx.add_assistant_message(Message::assistant("a1"));
796 ctx.add_assistant_message(Message::assistant("a2"));
797 ctx.add_assistant_message(Message::assistant("a3"));
798 ctx.add_assistant_message(Message::assistant("a4"));
799
800 ctx.inject_summary("summary".into(), 2);
801
802 assert_eq!(ctx.messages[0].role, Role::User);
804 assert_eq!(ctx.messages[1].role, Role::Assistant);
806 }
807
808 #[test]
809 fn inject_summary_adjusts_tail_when_starting_with_user() {
810 let mut ctx = AgentContext::new("sys", "task", vec![]);
814 ctx.add_assistant_message(Message::assistant("a1"));
815 ctx.add_tool_results(vec![ToolResult::success("c1", "result1")]);
816 ctx.add_assistant_message(Message::assistant("a2"));
817 ctx.add_tool_results(vec![ToolResult::success("c2", "result2")]);
818 ctx.add_assistant_message(Message::assistant("a3"));
819 ctx.inject_summary("summary".into(), 2);
825
826 assert_eq!(ctx.messages[0].role, Role::User);
828 assert_eq!(ctx.messages[1].role, Role::Assistant);
829 for w in ctx.messages.windows(2) {
831 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
832 }
833 }
834
835 #[test]
836 fn total_tokens_grows_with_messages() {
837 let mut ctx = AgentContext::new("sys", "task", vec![]);
838 let initial = ctx.total_tokens();
839
840 ctx.add_assistant_message(Message::assistant("a".repeat(100)));
841 assert!(ctx.total_tokens() > initial);
842 }
843
844 #[test]
845 fn shared_inject_summary_preserves_alternation() {
846 let mut messages = vec![
848 Message::user("original task"),
849 Message::assistant("a1"),
850 Message::tool_results(vec![ToolResult::success("c1", "result1")]),
851 Message::assistant("a2"),
852 Message::tool_results(vec![ToolResult::success("c2", "result2")]),
853 Message::assistant("a3"),
854 ];
855
856 inject_summary_into_messages(&mut messages, "original task", "summary of conversation", 2);
857
858 assert_eq!(messages[0].role, Role::User);
860 assert_eq!(messages[1].role, Role::Assistant);
861 for w in messages.windows(2) {
862 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
863 }
864 let first_text: String = messages[0]
866 .content
867 .iter()
868 .filter_map(|b| match b {
869 ContentBlock::Text { text } => Some(text.as_str()),
870 _ => None,
871 })
872 .collect::<Vec<_>>()
873 .join("");
874 assert!(first_text.contains("original task"));
875 assert!(first_text.contains("summary of conversation"));
876 }
877
878 #[test]
879 fn inject_summary_tail_start_near_beginning() {
880 let mut messages = vec![
884 Message::user("original task"),
885 Message::assistant("first response"),
886 Message::assistant("second response"),
887 Message::assistant("third response"),
888 ];
889
890 inject_summary_into_messages(&mut messages, "original task", "summary", 2);
891
892 assert_eq!(messages.len(), 3);
894 assert_eq!(messages[0].role, Role::User);
895 assert_eq!(messages[1].role, Role::Assistant);
896 let first_text: String = messages[0]
898 .content
899 .iter()
900 .filter_map(|b| match b {
901 ContentBlock::Text { text } => Some(text.as_str()),
902 _ => None,
903 })
904 .collect::<Vec<_>>()
905 .join("");
906 assert!(first_text.contains("original task"));
907 assert!(first_text.contains("summary"));
908 }
909
910 #[test]
911 fn from_content_creates_multimodal_message() {
912 let content = vec![
913 ContentBlock::Text {
914 text: "describe this".into(),
915 },
916 ContentBlock::Image {
917 media_type: "image/jpeg".into(),
918 data: "base64data".into(),
919 },
920 ];
921 let ctx = AgentContext::from_content("system", content, vec![]);
922 let req = ctx.to_request();
923 assert_eq!(req.messages.len(), 1);
924 assert_eq!(req.messages[0].role, Role::User);
925 assert_eq!(req.messages[0].content.len(), 2);
926 assert!(matches!(
927 &req.messages[0].content[1],
928 ContentBlock::Image { .. }
929 ));
930 }
931
932 #[test]
933 fn evict_media_replaces_old_images_with_placeholder() {
934 let mut ctx = AgentContext::from_content(
935 "sys",
936 vec![
937 ContentBlock::Text {
938 text: "describe this".into(),
939 },
940 ContentBlock::Image {
941 media_type: "image/jpeg".into(),
942 data: "data1".into(),
943 },
944 ],
945 vec![],
946 );
947 ctx.add_assistant_message(Message::assistant("It shows a cat."));
948 ctx.messages.push(Message {
950 role: Role::User,
951 content: vec![ContentBlock::Image {
952 media_type: "image/png".into(),
953 data: "data2".into(),
954 }],
955 });
956
957 ctx.evict_media();
958
959 assert_eq!(
961 ctx.messages[0].content[1],
962 ContentBlock::Text {
963 text: "[image previously sent]".into()
964 }
965 );
966 assert!(matches!(
968 &ctx.messages[2].content[0],
969 ContentBlock::Image { media_type, .. } if media_type == "image/png"
970 ));
971 }
972
973 #[test]
974 fn evict_media_replaces_old_audio_with_placeholder() {
975 let mut ctx = AgentContext::from_content(
976 "sys",
977 vec![
978 ContentBlock::Text {
979 text: "listen to this".into(),
980 },
981 ContentBlock::Audio {
982 format: "ogg".into(),
983 data: "audiodata1".into(),
984 },
985 ],
986 vec![],
987 );
988 ctx.add_assistant_message(Message::assistant("I heard it."));
989 ctx.messages.push(Message {
990 role: Role::User,
991 content: vec![ContentBlock::Audio {
992 format: "mp3".into(),
993 data: "audiodata2".into(),
994 }],
995 });
996
997 ctx.evict_media();
998
999 assert_eq!(
1001 ctx.messages[0].content[1],
1002 ContentBlock::Text {
1003 text: "[audio previously sent]".into()
1004 }
1005 );
1006 assert!(matches!(
1008 &ctx.messages[2].content[0],
1009 ContentBlock::Audio { format, .. } if format == "mp3"
1010 ));
1011 }
1012
1013 #[test]
1014 fn evict_media_noop_when_no_media() {
1015 let mut ctx = AgentContext::new("sys", "task", vec![]);
1016 ctx.add_assistant_message(Message::assistant("reply"));
1017 let msg_count = ctx.message_count();
1018 ctx.evict_media();
1019 assert_eq!(ctx.message_count(), msg_count);
1020 }
1021
1022 #[test]
1023 fn inject_summary_empty_messages_is_noop() {
1024 let mut messages = vec![];
1025 inject_summary_into_messages(&mut messages, "task", "summary", 2);
1026 assert!(messages.is_empty());
1027 }
1028
1029 #[test]
1030 fn inject_summary_while_loop_steps_back_to_assistant() {
1031 let mut messages = vec![
1034 Message::user("original task"),
1035 Message::assistant("a1"),
1036 Message::tool_results(vec![ToolResult::success("c1", "r1")]),
1037 Message::assistant("a2"),
1038 Message::tool_results(vec![ToolResult::success("c2", "r2")]),
1039 Message::assistant("a3"),
1040 Message::tool_results(vec![ToolResult::success("c3", "r3")]),
1041 Message::assistant("a4"),
1042 ];
1043 inject_summary_into_messages(&mut messages, "original task", "summary", 2);
1046
1047 assert_eq!(messages[0].role, Role::User);
1048 assert_eq!(messages[1].role, Role::Assistant);
1049 for w in messages.windows(2) {
1050 assert_ne!(w[0].role, w[1].role, "adjacent messages have same role");
1051 }
1052 }
1053
1054 #[test]
1055 fn messages_to_be_compacted_returns_middle() {
1056 let mut ctx = AgentContext::new("sys", "task", vec![]);
1057 ctx.add_assistant_message(Message::assistant("a1"));
1058 ctx.add_assistant_message(Message::assistant("a2"));
1059 ctx.add_assistant_message(Message::assistant("a3"));
1060 ctx.add_assistant_message(Message::assistant("a4"));
1061
1062 let compacted = ctx.messages_to_be_compacted(2);
1064 assert_eq!(compacted.len(), 2);
1065 }
1066
1067 #[test]
1068 fn messages_to_be_compacted_empty_when_few_messages() {
1069 let mut ctx = AgentContext::new("sys", "task", vec![]);
1070 ctx.add_assistant_message(Message::assistant("a1"));
1071
1072 let compacted = ctx.messages_to_be_compacted(2);
1074 assert!(compacted.is_empty());
1075 }
1076
1077 #[test]
1078 fn messages_to_be_compacted_excludes_first_and_last() {
1079 let mut ctx = AgentContext::new("sys", "task", vec![]);
1080 ctx.add_assistant_message(Message::assistant("old1"));
1081 ctx.add_assistant_message(Message::assistant("old2"));
1082 ctx.add_assistant_message(Message::assistant("recent1"));
1083 ctx.add_assistant_message(Message::assistant("recent2"));
1084
1085 let compacted = ctx.messages_to_be_compacted(2);
1086 for msg in compacted {
1088 let text: String = msg
1089 .content
1090 .iter()
1091 .filter_map(|b| match b {
1092 ContentBlock::Text { text } => Some(text.as_str()),
1093 _ => None,
1094 })
1095 .collect();
1096 assert!(
1097 text.starts_with("old"),
1098 "compacted messages should be old ones, got: {text}"
1099 );
1100 }
1101 }
1102}