Skip to main content

heartbit_core/agent/
context.rs

1//! Context management strategy for long-running agent sessions.
2
3use crate::llm::types::{
4    CompletionRequest, ContentBlock, Message, ReasoningEffort, Role, ToolDefinition, ToolResult,
5};
6
7use super::token_estimator::{estimate_message_tokens, estimate_tokens};
8
9/// Strategy for managing the context window.
10#[derive(Debug, Clone, PartialEq)]
11pub enum ContextStrategy {
12    /// No trimming — all messages are sent (current default behavior).
13    Unlimited,
14    /// Keep first message + as many recent messages as fit in `max_tokens`.
15    SlidingWindow {
16        /// Maximum token budget for the sliding window.
17        max_tokens: u32,
18    },
19}
20
21/// Conversation context for an agent run.
22pub(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    /// Create a context from pre-built content blocks (for multimodal messages).
52    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    /// Replace `ContentBlock::Image` and `ContentBlock::Audio` blocks in all
73    /// messages except the last user message with text placeholders. Prevents
74    /// large base64 payloads from accumulating in the conversation history.
75    pub(crate) fn evict_media(&mut self) {
76        // Find the index of the last user message
77        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    /// Get the text from the last assistant message (avoids re-cloning the response).
150    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    /// Estimate total tokens across all messages.
169    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    /// Check whether the context exceeds a token threshold and needs compaction.
178    pub(crate) fn needs_compaction(&self, max_tokens: u32) -> bool {
179        self.total_tokens() > max_tokens
180    }
181
182    /// Replace old messages with a summary, keeping the initial task context
183    /// and the last `keep_last_n` messages.
184    ///
185    /// The summary is merged into the first user message to maintain the required
186    /// alternating user/assistant role sequence (Anthropic API constraint).
187    ///
188    /// If there aren't enough messages to compact (first + keep_last_n >= total),
189    /// this is a no-op.
190    pub(crate) fn inject_summary(&mut self, summary: String, keep_last_n: usize) {
191        // Extract the original task text from the first message
192        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    /// Render all messages as a plain text transcript for summarization.
208    pub(crate) fn conversation_text(&self) -> String {
209        messages_to_text(&self.messages)
210    }
211
212    /// Return the messages that would be discarded by `inject_summary(keep_last_n)`.
213    ///
214    /// This is the "middle" of the message list (excluding first and last N messages).
215    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        // Middle = messages[1..tail_start]
221        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
246/// Inject a summary into a message list, replacing middle messages.
247///
248/// Keeps the original task from `messages[0]` and merges it with the summary
249/// into a single User message. Then appends the last `keep_last_n` messages.
250/// Adjusts the tail start to ensure User/Assistant alternation is preserved.
251///
252/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
253pub 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    // Need at least: first(1) + something_to_summarize(1) + keep_last_n
264    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    // Determine tail start, then adjust to maintain alternating User/Assistant roles.
273    // After the combined User message, the tail must start with an Assistant message.
274    let mut tail_start = total.saturating_sub(keep_last_n);
275    // Guard: `tail_start > 1` because index 0 is the original first message being
276    // replaced — including it in the tail would duplicate content. In valid conversations,
277    // messages[1] is always Assistant (first LLM response after the user task), so
278    // `tail_start == 1 && User` cannot occur with well-formed input.
279    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
289/// Render a message list as a plain text transcript for summarization.
290///
291/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
292pub 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
324/// Apply sliding window to a message list: always keep the first message (initial task),
325/// then include as many recent messages as fit within `max_tokens`.
326///
327/// Tool use/result pairs are kept together to avoid orphaned tool references.
328///
329/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
330pub 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    // Walk backward, accumulating messages. Keep tool_use/tool_result pairs together.
345    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        // Check if this message is a tool_result (User with ToolResult blocks)
353        // and the previous message is the corresponding tool_use (Assistant with ToolUse).
354        // If so, they must be included together.
355        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); // initial + added
436        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); // 1 user + 3 assistant
477    }
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        // First message must always be preserved
489        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        // Add many messages to exceed the window
504        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        // Should have fewer messages than the full 11
510        assert!(req.messages.len() < 11);
511        // First message always preserved
512        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        // Add a tool use + result pair
521        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        // Check that tool_use and tool_result are both present or both absent
534        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        // Should have: combined_first(1) + last 2 = 3 messages
586        assert_eq!(ctx.messages.len(), 3);
587        // First message contains both original task and summary
588        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        // combined_first(1) + last 3 = 4
613        assert_eq!(ctx.messages.len(), 4);
614        // Last message should be "recent 3"
615        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        // Not enough messages to compact (total=2, need > 1 + 4)
631        assert_eq!(ctx.messages.len(), 2);
632    }
633
634    #[test]
635    fn inject_summary_maintains_alternating_roles() {
636        // After summarization, message roles must alternate (user/assistant)
637        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        // First message is User (combined task+summary)
646        assert_eq!(ctx.messages[0].role, Role::User);
647        // The remaining messages should start with assistant
648        assert_eq!(ctx.messages[1].role, Role::Assistant);
649    }
650
651    #[test]
652    fn inject_summary_adjusts_tail_when_starting_with_user() {
653        // Regression: if keep_last_n tail starts with a User message (tool_result),
654        // the combined User + User sequence violates the alternating-role invariant.
655        // inject_summary must include the preceding Assistant to maintain alternation.
656        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        // Messages: User, A, U(tool), A, U(tool), A
663        // Total = 6, keep_last_n = 2 → tail_start = 4 → messages[4] = U(tool)
664        // Without fix: combined(U) + U(tool) + A → role violation
665        // With fix: combined(U) + A + U(tool) + A → correct alternation
666
667        ctx.inject_summary("summary".into(), 2);
668
669        // First must be User, second must be Assistant
670        assert_eq!(ctx.messages[0].role, Role::User);
671        assert_eq!(ctx.messages[1].role, Role::Assistant);
672        // Verify alternation throughout
673        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        // Test the shared function directly (used by both standalone and Restate paths)
690        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        // First must be User (combined), then alternating
702        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        // Combined message contains both task and summary
708        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        // With total = 4 and keep_last_n = 2, tail_start = 2.
724        // messages[2] is Assistant (valid), so no role adjustment needed.
725        // This tests the boundary near the start of the conversation.
726        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        // combined(User) + last 2 = 3 messages
736        assert_eq!(messages.len(), 3);
737        assert_eq!(messages[0].role, Role::User);
738        assert_eq!(messages[1].role, Role::Assistant);
739        // Combined message has task + summary
740        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        // Add a second user message with another image (this is now the "last" user)
792        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        // First user message's image should be replaced
803        assert_eq!(
804            ctx.messages[0].content[1],
805            ContentBlock::Text {
806                text: "[image previously sent]".into()
807            }
808        );
809        // Last user message's image should be preserved
810        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        // First user message's audio should be replaced
843        assert_eq!(
844            ctx.messages[0].content[1],
845            ContentBlock::Text {
846                text: "[audio previously sent]".into()
847            }
848        );
849        // Last user message's audio should be preserved
850        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        // The `while` loop (vs single `if`) ensures the tail always starts with
875        // an Assistant message even when keep_last_n produces a tail starting with User.
876        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        // total=8, keep_last_n=2 → tail_start=6 → messages[6]=U(tool) → step back
887        // messages[5]=A → stop. Correct.
888        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        // total=5, keep_last_n=2 → tail_start=3 → middle=[1..3]
906        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        // total=2, 1 + keep_last_n=2 = 3 > 2 → empty
916        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        // Should contain old1 and old2 (indices 1,2), not task (0) or recent (3,4)
930        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}