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::pruner::truncate_with_marker_ext;
8use super::token_estimator::{estimate_message_tokens, estimate_tokens};
9
10/// Truncate every `ToolResult` block in `msg` exceeding `max_bytes`.
11/// Returns bytes saved. Message role and block count are never changed.
12fn 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/// Strategy for managing the context window.
35#[derive(Debug, Clone, PartialEq)]
36pub enum ContextStrategy {
37    /// No trimming — all messages are sent (current default behavior).
38    Unlimited,
39    /// Keep first message + as many recent messages as fit in `max_tokens`.
40    SlidingWindow {
41        /// Maximum token budget for the sliding window.
42        max_tokens: u32,
43    },
44}
45
46/// Conversation context for an agent run.
47pub(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    /// Create a context from pre-built content blocks (for multimodal messages).
77    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    /// Replace `ContentBlock::Image` and `ContentBlock::Audio` blocks in all
98    /// messages except the last user message with text placeholders. Prevents
99    /// large base64 payloads from accumulating in the conversation history.
100    pub(crate) fn evict_media(&mut self) {
101        // Find the index of the last user message
102        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    /// The live conversation — snapshotted at tool dispatch for
147    /// `ExecutionContext.transcript` (introspection tools like the advisor).
148    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    /// Cap every `ToolResult` block in the LAST message to `max_bytes`.
181    ///
182    /// Deterministic safety net applied right after tool-result ingestion so a
183    /// single giant fresh result can never blow the model's context window.
184    /// `restorable` selects the marker text (see
185    /// [`pruner::truncate_with_marker_ext`]). Returns total bytes saved.
186    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    /// Truncate every `ToolResult` block exceeding `max_bytes` across ALL
196    /// messages. Emergency recovery for context overflow — deterministic, no
197    /// LLM call. Returns total bytes saved.
198    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    /// Get the text from the last assistant message (avoids re-cloning the response).
210    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    /// Estimate total tokens across all messages.
229    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    /// Check whether the context exceeds a token threshold and needs compaction.
238    pub(crate) fn needs_compaction(&self, max_tokens: u32) -> bool {
239        self.total_tokens() > max_tokens
240    }
241
242    /// Replace old messages with a summary, keeping the initial task context
243    /// and the last `keep_last_n` messages.
244    ///
245    /// The summary is merged into the first user message to maintain the required
246    /// alternating user/assistant role sequence (Anthropic API constraint).
247    ///
248    /// If there aren't enough messages to compact (first + keep_last_n >= total),
249    /// this is a no-op.
250    pub(crate) fn inject_summary(&mut self, summary: String, keep_last_n: usize) {
251        // Extract the original task text from the first message
252        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    /// Render all messages as a plain text transcript for summarization.
268    pub(crate) fn conversation_text(&self) -> String {
269        messages_to_text(&self.messages)
270    }
271
272    /// Return the messages that would be discarded by `inject_summary(keep_last_n)`.
273    ///
274    /// This is the "middle" of the message list (excluding first and last N messages).
275    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        // Middle = messages[1..tail_start]
281        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
306/// Inject a summary into a message list, replacing middle messages.
307///
308/// Keeps the original task from `messages[0]` and merges it with the summary
309/// into a single User message. Then appends the last `keep_last_n` messages.
310/// Adjusts the tail start to ensure User/Assistant alternation is preserved.
311///
312/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
313pub 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    // Need at least: first(1) + something_to_summarize(1) + keep_last_n
324    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    // Determine tail start, then adjust to maintain alternating User/Assistant roles.
333    // After the combined User message, the tail must start with an Assistant message.
334    let mut tail_start = total.saturating_sub(keep_last_n);
335    // Guard: `tail_start > 1` because index 0 is the original first message being
336    // replaced — including it in the tail would duplicate content. In valid conversations,
337    // messages[1] is always Assistant (first LLM response after the user task), so
338    // `tail_start == 1 && User` cannot occur with well-formed input.
339    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
349/// Render a message list as a plain text transcript for summarization.
350///
351/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
352pub 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
384/// Apply sliding window to a message list: always keep the first message (initial task),
385/// then include as many recent messages as fit within `max_tokens`.
386///
387/// Tool use/result pairs are kept together to avoid orphaned tool references.
388///
389/// Shared between standalone (`AgentContext`) and durable (`AgentWorkflow`) paths.
390pub 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    // Walk backward, accumulating messages. Keep tool_use/tool_result pairs together.
405    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        // Check if this message is a tool_result (User with ToolResult blocks)
413        // and the previous message is the corresponding tool_use (Assistant with ToolUse).
414        // If so, they must be included together.
415        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); // initial + added
496        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); // 1 user + 3 assistant
634    }
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        // First message must always be preserved
646        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        // Add many messages to exceed the window
661        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        // Should have fewer messages than the full 11
667        assert!(req.messages.len() < 11);
668        // First message always preserved
669        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        // Add a tool use + result pair
678        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        // Check that tool_use and tool_result are both present or both absent
691        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        // Should have: combined_first(1) + last 2 = 3 messages
743        assert_eq!(ctx.messages.len(), 3);
744        // First message contains both original task and summary
745        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        // combined_first(1) + last 3 = 4
770        assert_eq!(ctx.messages.len(), 4);
771        // Last message should be "recent 3"
772        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        // Not enough messages to compact (total=2, need > 1 + 4)
788        assert_eq!(ctx.messages.len(), 2);
789    }
790
791    #[test]
792    fn inject_summary_maintains_alternating_roles() {
793        // After summarization, message roles must alternate (user/assistant)
794        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        // First message is User (combined task+summary)
803        assert_eq!(ctx.messages[0].role, Role::User);
804        // The remaining messages should start with assistant
805        assert_eq!(ctx.messages[1].role, Role::Assistant);
806    }
807
808    #[test]
809    fn inject_summary_adjusts_tail_when_starting_with_user() {
810        // Regression: if keep_last_n tail starts with a User message (tool_result),
811        // the combined User + User sequence violates the alternating-role invariant.
812        // inject_summary must include the preceding Assistant to maintain alternation.
813        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        // Messages: User, A, U(tool), A, U(tool), A
820        // Total = 6, keep_last_n = 2 → tail_start = 4 → messages[4] = U(tool)
821        // Without fix: combined(U) + U(tool) + A → role violation
822        // With fix: combined(U) + A + U(tool) + A → correct alternation
823
824        ctx.inject_summary("summary".into(), 2);
825
826        // First must be User, second must be Assistant
827        assert_eq!(ctx.messages[0].role, Role::User);
828        assert_eq!(ctx.messages[1].role, Role::Assistant);
829        // Verify alternation throughout
830        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        // Test the shared function directly (used by both standalone and Restate paths)
847        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        // First must be User (combined), then alternating
859        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        // Combined message contains both task and summary
865        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        // With total = 4 and keep_last_n = 2, tail_start = 2.
881        // messages[2] is Assistant (valid), so no role adjustment needed.
882        // This tests the boundary near the start of the conversation.
883        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        // combined(User) + last 2 = 3 messages
893        assert_eq!(messages.len(), 3);
894        assert_eq!(messages[0].role, Role::User);
895        assert_eq!(messages[1].role, Role::Assistant);
896        // Combined message has task + summary
897        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        // Add a second user message with another image (this is now the "last" user)
949        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        // First user message's image should be replaced
960        assert_eq!(
961            ctx.messages[0].content[1],
962            ContentBlock::Text {
963                text: "[image previously sent]".into()
964            }
965        );
966        // Last user message's image should be preserved
967        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        // First user message's audio should be replaced
1000        assert_eq!(
1001            ctx.messages[0].content[1],
1002            ContentBlock::Text {
1003                text: "[audio previously sent]".into()
1004            }
1005        );
1006        // Last user message's audio should be preserved
1007        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        // The `while` loop (vs single `if`) ensures the tail always starts with
1032        // an Assistant message even when keep_last_n produces a tail starting with User.
1033        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        // total=8, keep_last_n=2 → tail_start=6 → messages[6]=U(tool) → step back
1044        // messages[5]=A → stop. Correct.
1045        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        // total=5, keep_last_n=2 → tail_start=3 → middle=[1..3]
1063        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        // total=2, 1 + keep_last_n=2 = 3 > 2 → empty
1073        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        // Should contain old1 and old2 (indices 1,2), not task (0) or recent (3,4)
1087        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}