1pub mod micro;
15pub mod prompt;
16pub mod reinject;
17pub mod retry;
18
19pub use micro::{Microcompactor, MICROCOMPACT_PLACEHOLDER};
20pub use reinject::{
21 build_skill_reinjector_from_env, FileReinjector, PlanTodoReinjector, SkillReinjector,
22};
23pub use retry::{truncate_head_for_retry, MAX_PTL_RETRIES};
24
25pub const MAX_CONSECUTIVE_COMPACT_FAILURES: u32 = 3;
29
30use crate::error::Result;
31use crate::llm::{ChatProvider, StructuredRequest, ToolSpec};
32use crate::message::Message;
33
34#[derive(Debug, Clone)]
36pub struct Compactor {
37 pub threshold_chars: usize,
45 pub threshold_prompt_tokens: Option<u32>,
53 pub keep_recent_n: usize,
55}
56
57impl Default for Compactor {
58 fn default() -> Self {
59 Self {
60 threshold_chars: usize::MAX,
61 threshold_prompt_tokens: None,
62 keep_recent_n: 8,
63 }
64 }
65}
66
67impl Compactor {
68 pub fn new(threshold_chars: usize) -> Self {
70 Self {
71 threshold_chars,
72 threshold_prompt_tokens: None,
73 keep_recent_n: 8,
74 }
75 }
76
77 pub fn threshold_prompt_tokens(mut self, n: u32) -> Self {
83 self.threshold_prompt_tokens = Some(n);
84 self
85 }
86
87 pub fn keep_recent_n(mut self, n: usize) -> Self {
89 self.keep_recent_n = n;
90 self
91 }
92
93 pub fn estimate_bytes(transcript: &[Message]) -> usize {
103 transcript
104 .iter()
105 .map(|m| {
106 m.content.len()
107 + m.tool_calls
108 .iter()
109 .map(|tc| tc.name.len() + tc.arguments.to_string().len() + 32)
110 .sum::<usize>()
111 + m.reasoning_content.as_deref().map_or(0, |r| r.len())
112 })
113 .sum()
114 }
115
116 pub fn should_compact(&self, estimate_bytes: usize, last_prompt_tokens: u32) -> bool {
123 match (self.threshold_prompt_tokens, last_prompt_tokens) {
124 (Some(token_threshold), actual) if actual > 0 => actual >= token_threshold,
125 _ => estimate_bytes >= self.threshold_chars,
126 }
127 }
128
129 const COMPACT_SCHEMA: &'static str = r#"{"type":"object","properties":{"summary":{"type":"string","description":"1-3 paragraph summary of the conversation so far, preserving key decisions, file paths touched, and outcomes."},"kept_facts":{"type":"array","items":{"type":"string"},"description":"Discrete facts worth remembering across compaction (e.g. 'goal=add_X_to_Y', 'compaction happened at step N', 'tool X failed 3 times')."},"next_steps":{"type":"array","items":{"type":"string"},"description":"Outstanding TODOs the agent identified before compaction (each one a single-sentence imperative)."}},"required":["summary","kept_facts"]}"#;
131
132 fn render_structured(
134 summary: &str,
135 kept_facts: &[String],
136 next_steps: &[String],
137 step: usize,
138 ) -> String {
139 let mut rendered = format!(
140 "[compacted: structured at step {step}]\n\nSummary: {summary}\n\nKey facts to remember:\n"
141 );
142 for fact in kept_facts {
143 rendered.push_str(&format!("- {fact}\n"));
144 }
145 if !next_steps.is_empty() {
146 rendered.push_str("\nOutstanding TODOs:\n");
147 for step in next_steps {
148 rendered.push_str(&format!("- {step}\n"));
149 }
150 }
151 rendered
152 }
153
154 async fn try_structured_compact(
157 &self,
158 provider: &dyn ChatProvider,
159 older_text: &str,
160 step: usize,
161 ) -> Option<String> {
162 let structured_prompt = format!(
163 "Summarize the following conversation. \
164 Preserve: file paths modified, key technical decisions, test \
165 outcomes, and any errors not yet resolved. Drop: file contents, \
166 repeated tool errors, exploratory dead-ends.\n\n\
167 Conversation to summarize:\n{older_text}"
168 );
169
170 let schema = match serde_json::from_str(Self::COMPACT_SCHEMA) {
171 Ok(v) => v,
172 Err(e) => {
173 tracing::error!(error = %e, "COMPACT_SCHEMA is invalid JSON, falling back");
174 return None;
175 }
176 };
177 let structured_req = StructuredRequest {
178 messages: vec![Message::user(structured_prompt)],
179 schema,
180 schema_name: "compaction_result".to_string(),
181 };
182
183 let json_val = match provider.complete_structured(structured_req).await {
184 Ok(v) => v,
185 Err(e) => {
186 tracing::info!(error = %e, "structured compaction not available, falling back to free-text");
187 return None;
188 }
189 };
190
191 let obj = match json_val.as_object() {
192 Some(o) => o,
193 None => {
194 tracing::warn!(
195 "structured compaction returned non-object, falling back to free-text"
196 );
197 return None;
198 }
199 };
200
201 let summary = match obj.get("summary").and_then(|v| v.as_str()) {
202 Some(s) => s.to_string(),
203 None => {
204 tracing::warn!(
205 "structured compaction missing 'summary' field, falling back to free-text"
206 );
207 return None;
208 }
209 };
210
211 let kept_facts: Vec<String> = obj
212 .get("kept_facts")
213 .and_then(|v| v.as_array())
214 .map(|arr| {
215 arr.iter()
216 .filter_map(|v| v.as_str().map(|s| s.to_string()))
217 .collect()
218 })
219 .unwrap_or_default();
220
221 let next_steps: Vec<String> = obj
222 .get("next_steps")
223 .and_then(|v| v.as_array())
224 .map(|arr| {
225 arr.iter()
226 .filter_map(|v| v.as_str().map(|s| s.to_string()))
227 .collect()
228 })
229 .unwrap_or_default();
230
231 Some(Self::render_structured(
232 &summary,
233 &kept_facts,
234 &next_steps,
235 step,
236 ))
237 }
238
239 pub fn safe_split_point(transcript: &[Message], keep_n: usize) -> usize {
254 let mut split = transcript.len().saturating_sub(keep_n);
255 loop {
257 if split == 0 || split >= transcript.len() {
258 break;
259 }
260 let msg = &transcript[split];
261 let should_back_up = msg.role == crate::message::Role::Tool
262 || (msg.role == crate::message::Role::Assistant && !msg.tool_calls.is_empty());
263 if should_back_up {
264 split -= 1;
265 } else {
266 break;
267 }
268 }
269 while split < transcript.len() && transcript[split].role == crate::message::Role::System {
274 split += 1;
275 }
276 split
277 }
278
279 const MIN_CONVERSATIONAL_IN_OLDER: usize = 2;
293
294 fn split_for_compaction(transcript: &[Message], keep_n: usize) -> (usize, bool) {
302 if transcript.len() < keep_n + 2 {
303 return (0, false);
304 }
305 let split = Self::safe_split_point(transcript, keep_n);
306 let acceptable = if split > 0 {
307 let conversational_count = transcript[..split]
308 .iter()
309 .filter(|m| {
310 m.role == crate::message::Role::User
311 || m.role == crate::message::Role::Assistant
312 })
313 .count();
314 conversational_count >= Self::MIN_CONVERSATIONAL_IN_OLDER
315 } else {
316 true
323 };
324 (split, acceptable)
325 }
326
327 pub fn would_compact(&self, transcript: &[Message]) -> bool {
337 Self::split_for_compaction(transcript, self.keep_recent_n).1
338 }
339
340 pub async fn apply_to_transcript(
341 &self,
342 provider: &dyn ChatProvider,
343 transcript: &mut Vec<Message>,
344 step: usize,
345 ) -> Result<Option<(usize, usize)>> {
346 let (split, acceptable) = Self::split_for_compaction(transcript, self.keep_recent_n);
350 if !acceptable {
351 return Ok(None);
352 }
353
354 let summary_msg = self.compact(provider, transcript, step).await?;
355 let summary_chars = summary_msg.content.len();
356 let removed = split;
357 transcript.drain(..split);
358 transcript.insert(0, summary_msg);
359 Ok(Some((removed, summary_chars)))
360 }
361
362 fn render_for_summarize(messages: &[Message]) -> String {
366 messages
367 .iter()
368 .map(|m| {
369 let role_tag = match m.role {
370 crate::message::Role::System => "system",
371 crate::message::Role::User => "user",
372 crate::message::Role::Assistant => "assistant",
373 crate::message::Role::Tool => "tool",
374 };
375 let mut body = m.content.replace('<', "<").replace('>', ">");
376 if !m.tool_calls.is_empty() {
377 let calls_summary: Vec<String> = m
378 .tool_calls
379 .iter()
380 .map(|tc| format!("{}({})", tc.name, tc.arguments))
381 .collect();
382 body.push_str(&format!("\n[tool_calls: {}]", calls_summary.join(", ")));
383 }
384 format!("<{role_tag}>{body}</{role_tag}>")
385 })
386 .collect::<Vec<_>>()
387 .join("\n")
388 }
389
390 async fn summarize(
393 &self,
394 provider: &dyn ChatProvider,
395 messages: &[Message],
396 step: usize,
397 ) -> Result<String> {
398 let older_text = Self::render_for_summarize(messages);
399
400 match self
402 .try_structured_compact(provider, &older_text, step)
403 .await
404 {
405 Some(rendered) => Ok(rendered),
406 None => {
407 let summary_prompt = format!(
409 "{}\n\nConversation to summarize:\n{}",
410 crate::compact::prompt::FREE_TEXT_COMPACT_PROMPT,
411 older_text,
412 );
413 let completion = provider
414 .complete(&[Message::user(summary_prompt)], &[] as &[ToolSpec])
415 .await?;
416 Ok(crate::compact::prompt::format_compact_summary(
417 &completion.content,
418 ))
419 }
420 }
421 }
422
423 #[tracing::instrument(skip(self, provider, transcript))]
436 pub async fn compact(
437 &self,
438 provider: &dyn ChatProvider,
439 transcript: &[Message],
440 step: usize,
441 ) -> Result<Message> {
442 let split = Self::safe_split_point(transcript, self.keep_recent_n);
443 let older = &transcript[..split];
444 let _recent = &transcript[split..];
445
446 let mut to_summarize: Vec<Message> = older.to_vec();
449 let mut ptl_attempts = 0_usize;
450
451 let summary = loop {
452 match self.summarize(provider, &to_summarize, step).await {
453 Ok(text) => break text,
454 Err(e) if crate::error::is_context_window_exceeded(&e) => {
455 ptl_attempts += 1;
456 if ptl_attempts > MAX_PTL_RETRIES {
457 return Err(e);
458 }
459 let target = retry::estimate_target_from_error(&e);
460 match retry::truncate_head_for_retry(&to_summarize, target) {
461 Some(truncated) => {
462 tracing::info!(
463 target: "recursive::compact",
464 ptl_attempts,
465 original_len = to_summarize.len(),
466 truncated_len = truncated.len(),
467 "compaction PTL retry: dropped oldest groups"
468 );
469 to_summarize = truncated;
470 }
471 None => {
472 return Err(e);
474 }
475 }
476 }
477 Err(e) => return Err(e),
478 }
479 };
480
481 let summary_chars = summary.len();
482
483 let header = format!(
484 "[compacted: {} messages → {} chars at step {step}]\n{}",
485 older.len(),
486 summary_chars,
487 summary
488 );
489
490 Ok(Message::system(header).with_compaction_summary())
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::llm::{Completion, MockProvider};
498
499 #[tokio::test]
500 async fn compact_returns_system_message_with_summary() {
501 let provider = MockProvider::new(vec![Completion {
502 content: "Key decisions: added adder tool. Tests pass.".to_string(),
503 tool_calls: vec![],
504 finish_reason: Some("stop".to_string()),
505 usage: None,
506 reasoning_content: None,
507 }]);
508
509 let transcript = vec![
510 Message::system("You are a coding agent.".to_string()),
511 Message::user("Add an adder tool".to_string()),
512 Message::assistant("Let me create the tool.".to_string()),
513 Message::user("Done. Now test it.".to_string()),
514 Message::assistant("Tests pass.".to_string()),
515 ];
516
517 let compactor = Compactor::new(200).keep_recent_n(2);
518 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
519
520 assert_eq!(summary_msg.role, crate::message::Role::System);
521 assert!(
522 summary_msg.is_compaction_summary,
523 "compactor must mark summary message with the bit"
524 );
525 assert!(
526 summary_msg.content.contains("[compacted:"),
527 "compactor must still include the [compacted: header for debuggability"
528 );
529 assert!(summary_msg.content.contains("Key decisions:"));
530 assert!(summary_msg.content.contains("Tests pass."));
531 }
532
533 #[tokio::test]
534 async fn compact_preserves_recent_messages() {
535 let provider = MockProvider::new(vec![Completion {
536 content: "Summary of older messages.".to_string(),
537 tool_calls: vec![],
538 finish_reason: Some("stop".to_string()),
539 usage: None,
540 reasoning_content: None,
541 }]);
542
543 let transcript = vec![
544 Message::system("sys".to_string()),
545 Message::user("old goal".to_string()),
546 Message::assistant("old reply".to_string()),
547 Message::user("recent goal".to_string()),
548 Message::assistant("recent reply".to_string()),
549 ];
550
551 let compactor = Compactor::new(100).keep_recent_n(2);
553 let summary_msg = compactor.compact(&provider, &transcript, 5).await.unwrap();
554
555 assert!(summary_msg.content.contains("[compacted: 3 messages →"));
556 assert!(summary_msg.content.contains("Summary of older messages."));
558 }
559
560 #[tokio::test]
561 async fn compact_handles_empty_older_portion() {
562 let provider = MockProvider::new(vec![Completion {
563 content: "nothing to summarize".to_string(),
564 tool_calls: vec![],
565 finish_reason: Some("stop".to_string()),
566 usage: None,
567 reasoning_content: None,
568 }]);
569
570 let transcript = vec![Message::user("only message".to_string())];
571
572 let compactor = Compactor::new(100).keep_recent_n(5);
574 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
575
576 assert_eq!(summary_msg.role, crate::message::Role::System);
578 assert!(
579 summary_msg.is_compaction_summary,
580 "compactor must mark summary message with the bit"
581 );
582 assert!(
583 summary_msg.content.contains("[compacted:"),
584 "compactor must still include the [compacted: header for debuggability"
585 );
586 }
587
588 #[test]
589 fn estimate_bytes_sums_content_lengths() {
590 let transcript = vec![
591 Message::user("hello".to_string()),
592 Message::assistant("world".to_string()),
593 ];
594 assert_eq!(Compactor::estimate_bytes(&transcript), 10);
595 }
596
597 #[test]
598 fn default_threshold_is_max() {
599 let c = Compactor::default();
600 assert_eq!(c.threshold_chars, usize::MAX);
601 assert_eq!(c.threshold_prompt_tokens, None);
602 assert_eq!(c.keep_recent_n, 8);
603 }
604
605 #[test]
606 fn builder_methods_work() {
607 let c = Compactor::new(500).keep_recent_n(4);
608 assert_eq!(c.threshold_chars, 500);
609 assert_eq!(c.threshold_prompt_tokens, None);
610 assert_eq!(c.keep_recent_n, 4);
611 }
612
613 #[test]
614 fn threshold_prompt_tokens_setter_works() {
615 let c = Compactor::new(500_000).threshold_prompt_tokens(144_000);
616 assert_eq!(c.threshold_chars, 500_000);
617 assert_eq!(c.threshold_prompt_tokens, Some(144_000));
618 assert_eq!(c.keep_recent_n, 8);
619 }
620
621 #[test]
626 fn should_compact_uses_token_threshold_when_available() {
627 let c = Compactor::new(usize::MAX).threshold_prompt_tokens(1000);
628 assert!(c.should_compact(0, 1000));
630 assert!(!c.should_compact(0, 999));
632 assert!(!c.should_compact(0, 1));
634 }
635
636 #[test]
637 fn should_compact_falls_back_to_chars_when_tokens_zero() {
638 let c = Compactor::new(500).threshold_prompt_tokens(1000);
639 assert!(c.should_compact(600, 0));
642 assert!(!c.should_compact(400, 0));
644 }
645
646 #[test]
647 fn should_compact_falls_back_to_chars_when_threshold_none() {
648 let c = Compactor::new(500);
649 assert_eq!(c.threshold_prompt_tokens, None);
650 assert!(c.should_compact(600, 42));
652 assert!(!c.should_compact(400, 42));
654 }
655
656 #[test]
657 fn should_compact_zero_token_threshold_fires_on_zero() {
658 let c = Compactor::new(usize::MAX).threshold_prompt_tokens(0);
662 assert!(!c.should_compact(0, 0));
665 assert!(c.should_compact(0, 1));
667 }
668
669 #[test]
681 fn cross_turn_compact_uses_last_not_accumulated_prompt_tokens() {
682 let threshold = 784_000u32;
684 let c = Compactor::new(usize::MAX).threshold_prompt_tokens(threshold);
685
686 let per_call_tokens: u32 = 100_000;
687 let llm_calls_in_turn: u32 = 8;
688 let accumulated_tokens = per_call_tokens * llm_calls_in_turn; let last_call_tokens = per_call_tokens; assert!(
694 c.should_compact(0, accumulated_tokens),
695 "accumulated tokens ({accumulated_tokens}) exceeds threshold \
696 ({threshold}): should_compact is true — shows why old code was wrong"
697 );
698
699 assert!(
702 !c.should_compact(0, last_call_tokens),
703 "last_call tokens ({last_call_tokens}) is well below threshold \
704 ({threshold}): compaction must NOT fire"
705 );
706 }
707
708 #[tokio::test]
713 async fn compactor_structured_happy_path() {
714 let json = serde_json::json!({
715 "summary": "Added adder tool and verified tests pass.",
716 "kept_facts": [
717 "goal=add_adder_tool",
718 "tool adder created successfully",
719 "tests pass"
720 ],
721 "next_steps": [
722 "Add subtractor tool",
723 "Run integration tests"
724 ]
725 });
726 let provider = MockProvider::new(vec![]).with_structured_responses(vec![Ok(json)]);
727
728 let transcript = vec![
729 Message::system("You are a coding agent.".to_string()),
730 Message::user("Add an adder tool".to_string()),
731 Message::assistant("Let me create the tool.".to_string()),
732 Message::user("Done. Now test it.".to_string()),
733 Message::assistant("Tests pass.".to_string()),
734 ];
735
736 let compactor = Compactor::new(200).keep_recent_n(2);
737 let summary_msg = compactor.compact(&provider, &transcript, 3).await.unwrap();
738
739 assert_eq!(summary_msg.role, crate::message::Role::System);
740 assert!(summary_msg
742 .content
743 .contains("[compacted: structured at step 3]"));
744 assert!(summary_msg.content.contains("Summary: Added adder tool"));
745 assert!(summary_msg.content.contains("Key facts to remember:"));
746 assert!(summary_msg.content.contains("- goal=add_adder_tool"));
747 assert!(summary_msg
748 .content
749 .contains("- tool adder created successfully"));
750 assert!(summary_msg.content.contains("- tests pass"));
751 assert!(summary_msg.content.contains("Outstanding TODOs:"));
752 assert!(summary_msg.content.contains("- Add subtractor tool"));
753 assert!(summary_msg.content.contains("- Run integration tests"));
754 }
755
756 #[tokio::test]
757 async fn compactor_falls_back_on_structured_error() {
758 let provider = MockProvider::new(vec![Completion {
760 content: "Free-text fallback summary.".to_string(),
761 tool_calls: vec![],
762 finish_reason: Some("stop".to_string()),
763 usage: None,
764 reasoning_content: None,
765 }]);
766
767 let transcript = vec![
768 Message::user("goal".to_string()),
769 Message::assistant("response".to_string()),
770 ];
771
772 let compactor = Compactor::new(100).keep_recent_n(1);
773 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
774
775 assert_eq!(summary_msg.role, crate::message::Role::System);
776 assert!(
778 summary_msg.is_compaction_summary,
779 "compactor must mark summary message with the bit"
780 );
781 assert!(
782 summary_msg.content.contains("[compacted:"),
783 "compactor must still include the [compacted: header for debuggability"
784 );
785 assert!(summary_msg.content.contains("Free-text fallback summary."));
786 }
787
788 #[tokio::test]
789 async fn compactor_structured_invalid_response_falls_back() {
790 let json = serde_json::json!({
792 "foo": "bar"
793 });
794 let provider = MockProvider::new(vec![Completion {
795 content: "Fallback after invalid structured response.".to_string(),
796 tool_calls: vec![],
797 finish_reason: Some("stop".to_string()),
798 usage: None,
799 reasoning_content: None,
800 }])
801 .with_structured_responses(vec![Ok(json)]);
802
803 let transcript = vec![
804 Message::user("goal".to_string()),
805 Message::assistant("response".to_string()),
806 ];
807
808 let compactor = Compactor::new(100).keep_recent_n(1);
809 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
810
811 assert_eq!(summary_msg.role, crate::message::Role::System);
812 assert!(
814 summary_msg.is_compaction_summary,
815 "compactor must mark summary message with the bit"
816 );
817 assert!(
818 summary_msg.content.contains("[compacted:"),
819 "compactor must still include the [compacted: header for debuggability"
820 );
821 assert!(summary_msg
822 .content
823 .contains("Fallback after invalid structured response."));
824 }
825
826 #[tokio::test]
827 async fn compact_freetext_fallback_uses_structured_prompt_and_formats() {
828 let provider = MockProvider::new(vec![Completion {
835 content: "<analysis>scratchpad thinking</analysis>\n\
836 <summary>Added feature X. Tests pass.</summary>"
837 .to_string(),
838 tool_calls: vec![],
839 finish_reason: Some("stop".to_string()),
840 usage: None,
841 reasoning_content: None,
842 }]);
843
844 let transcript = vec![
845 Message::user("Add feature X".to_string()),
846 Message::assistant("Working on it.".to_string()),
847 Message::user("Test it.".to_string()),
848 Message::assistant("Tests pass.".to_string()),
849 ];
850
851 let compactor = Compactor::new(100).keep_recent_n(2);
852 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
853
854 assert_eq!(summary_msg.role, crate::message::Role::System);
855 assert!(
857 summary_msg.content.contains("[compacted:"),
858 "compactor must include the [compacted: header, got: {}",
859 summary_msg.content
860 );
861 assert!(
863 !summary_msg.content.contains("scratchpad thinking"),
864 "analysis scratchpad must be stripped"
865 );
866 assert!(
868 summary_msg.content.contains("Summary:"),
869 "summary must contain 'Summary:' header, got: {}",
870 summary_msg.content
871 );
872 assert!(
874 summary_msg.content.contains("Added feature X"),
875 "summary content must be preserved, got: {}",
876 summary_msg.content
877 );
878 assert!(
879 summary_msg.content.contains("Tests pass."),
880 "summary content must be preserved, got: {}",
881 summary_msg.content
882 );
883 }
884
885 #[test]
886 fn safe_split_point_keep_n_zero_does_not_panic() {
887 let msgs = vec![
890 Message::user("a".to_string()),
891 Message::assistant("b".to_string()),
892 ];
893 let split = Compactor::safe_split_point(&msgs, 0);
894 assert_eq!(split, msgs.len(), "keep_n=0 should return full length");
895 }
896
897 #[test]
898 fn safe_split_point_empty_transcript_does_not_panic() {
899 let split = Compactor::safe_split_point(&[], 0);
900 assert_eq!(split, 0);
901 let split = Compactor::safe_split_point(&[], 5);
902 assert_eq!(split, 0);
903 }
904
905 #[test]
906 fn safe_split_backs_up_past_tool_and_assistant_with_tool_calls() {
907 use crate::llm::ToolCall;
912 let asst_with_calls = Message::assistant_with_tool_calls(
913 "thinking".to_string(),
914 vec![ToolCall {
915 id: "call_1".into(),
916 name: "Read".into(),
917 arguments: serde_json::json!("{}"),
918 }],
919 );
920 let msgs = vec![
921 Message::user("start".to_string()),
922 asst_with_calls,
923 Message::tool_result("call_1", "file contents"),
924 Message::user("continue".to_string()),
925 Message::assistant("done".to_string()),
926 ];
927 let split = Compactor::safe_split_point(&msgs, 3);
928 assert_eq!(
929 split, 0,
930 "should back up past both Tool and Assistant-with-tool-calls to index 0"
931 );
932 }
933
934 #[test]
935 fn safe_split_backs_up_when_landing_directly_on_assistant_with_tool_calls() {
936 use crate::llm::ToolCall;
940 let asst_with_calls = Message::assistant_with_tool_calls(
941 "planning".to_string(),
942 vec![ToolCall {
943 id: "c1".into(),
944 name: "Glob".into(),
945 arguments: serde_json::json!("{}"),
946 }],
947 );
948 let msgs = vec![
949 Message::user("first question".to_string()),
950 asst_with_calls,
951 Message::user("follow-up".to_string()),
952 Message::assistant("answer".to_string()),
953 ];
954 let split = Compactor::safe_split_point(&msgs, 3);
955 assert_eq!(
956 split, 0,
957 "split landing directly on Assistant+tool_calls should retreat to 0"
958 );
959 }
960
961 #[test]
962 fn safe_split_no_backup_when_split_is_already_valid() {
963 use crate::llm::ToolCall;
966 let asst_with_calls = Message::assistant_with_tool_calls(
967 "".to_string(),
968 vec![ToolCall {
969 id: "x".into(),
970 name: "Write".into(),
971 arguments: serde_json::json!("{}"),
972 }],
973 );
974 let msgs = vec![
975 Message::user("start".to_string()),
976 asst_with_calls,
977 Message::tool_result("x", "ok"),
978 Message::user("continue".to_string()),
979 Message::assistant("done".to_string()),
980 ];
981 let split = Compactor::safe_split_point(&msgs, 2);
982 assert_eq!(split, 3, "split at a User message should not retreat");
983 }
984
985 #[test]
990 fn estimate_bytes_includes_tool_calls() {
991 use crate::llm::ToolCall;
992 let tool_call = ToolCall {
995 id: "c1".into(),
996 name: "Read".into(),
997 arguments: serde_json::json!("path"),
998 };
999 let msg = Message::assistant_with_tool_calls("think".to_string(), vec![tool_call]);
1000 let args_len = msg.tool_calls[0].arguments.to_string().len();
1002 let expected = 5 + (4 + args_len + 32);
1003 assert_eq!(
1004 Compactor::estimate_bytes(&[msg]),
1005 expected,
1006 "tool_calls contribution must be counted"
1007 );
1008 }
1009
1010 #[test]
1011 fn estimate_bytes_includes_reasoning_content() {
1012 let mut msg = Message::assistant("hello".to_string());
1013 msg.reasoning_content = Some("some reasoning".to_string());
1014 assert_eq!(
1016 Compactor::estimate_bytes(&[msg]),
1017 5 + 14,
1018 "reasoning_content must be counted"
1019 );
1020 }
1021
1022 #[test]
1023 fn estimate_bytes_tool_calls_plus_reasoning_combined() {
1024 use crate::llm::ToolCall;
1025 let tool_call = ToolCall {
1026 id: "c2".into(),
1027 name: "Write".into(),
1028 arguments: serde_json::json!({"path": "x"}),
1029 };
1030 let mut msg = Message::assistant_with_tool_calls("".to_string(), vec![tool_call]);
1031 msg.reasoning_content = Some("reason".to_string());
1032 let args_len = msg.tool_calls[0].arguments.to_string().len();
1033 let expected = (5 + args_len + 32) + 6;
1035 assert_eq!(Compactor::estimate_bytes(&[msg]), expected);
1036 }
1037
1038 #[tokio::test]
1043 async fn apply_to_transcript_splices_and_returns_counts() {
1044 let provider = MockProvider::new(vec![Completion {
1045 content: "summary of first three".to_string(),
1046 tool_calls: vec![],
1047 finish_reason: Some("stop".to_string()),
1048 usage: None,
1049 reasoning_content: None,
1050 }]);
1051
1052 let mut transcript = vec![
1053 Message::system("sys".to_string()),
1054 Message::user("msg1".to_string()),
1055 Message::assistant("rep1".to_string()),
1056 Message::user("msg2".to_string()),
1057 Message::assistant("rep2".to_string()),
1058 Message::user("msg3".to_string()),
1059 ];
1060
1061 let compactor = Compactor::new(50).keep_recent_n(2);
1063 let result = compactor
1064 .apply_to_transcript(&provider, &mut transcript, 5)
1065 .await
1066 .unwrap();
1067
1068 let (removed, summary_chars) =
1070 result.expect("should compact when transcript is long enough");
1071 assert!(removed > 0, "removed must be > 0 when compaction ran");
1072 assert!(summary_chars > 0, "summary_chars must be > 0");
1073
1074 assert_eq!(transcript[0].role, crate::message::Role::System);
1076 assert!(transcript[0].is_compaction_summary);
1077 assert!(transcript.len() >= 3, "summary + at least 2 recent");
1079 }
1080
1081 #[tokio::test]
1082 async fn apply_to_transcript_too_short_returns_none() {
1083 let provider = MockProvider::new(vec![]);
1085
1086 let mut transcript = vec![
1087 Message::user("only one".to_string()),
1088 Message::assistant("reply".to_string()),
1089 ];
1090
1091 let compactor = Compactor::new(50).keep_recent_n(8);
1093 let result = compactor
1094 .apply_to_transcript(&provider, &mut transcript, 0)
1095 .await
1096 .unwrap();
1097
1098 assert!(result.is_none(), "transcript too short must return None");
1099 assert_eq!(transcript.len(), 2);
1101 }
1102
1103 #[tokio::test]
1104 async fn compact_includes_tool_calls_in_older_text() {
1105 use crate::llm::{Completion, MockProvider, ToolCall};
1112 let provider = MockProvider::new(vec![Completion {
1113 content: "summary".to_string(),
1114 tool_calls: vec![],
1115 finish_reason: Some("stop".to_string()),
1116 usage: None,
1117 reasoning_content: None,
1118 }]);
1119
1120 let tool_call = ToolCall {
1121 id: "c1".into(),
1122 name: "Read".into(),
1123 arguments: serde_json::json!({"path": "src/lib.rs"}),
1124 };
1125 let asst_with_calls =
1126 Message::assistant_with_tool_calls("thinking".to_string(), vec![tool_call]);
1127 let transcript = vec![
1133 Message::user("start".to_string()), asst_with_calls, Message::tool_result("c1", "file contents"), Message::user("continue".to_string()), Message::assistant("done".to_string()), Message::user("next".to_string()), ];
1140
1141 let compactor = Compactor::new(0).keep_recent_n(2);
1142 let _ = compactor.compact(&provider, &transcript, 0).await.unwrap();
1143
1144 let calls = provider.calls();
1145 assert!(!calls.is_empty(), "provider must have been called");
1146 let prompt_msg = &calls[0][0];
1148 assert!(
1149 prompt_msg.content.contains("Read"),
1150 "older_text must include the tool call name 'Read'; got: {}",
1151 prompt_msg.content
1152 );
1153 assert!(
1154 prompt_msg.content.contains("[tool_calls:"),
1155 "older_text must include the [tool_calls: ...] marker; got: {}",
1156 prompt_msg.content
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn apply_to_transcript_minimum_length_boundary() {
1162 let provider = MockProvider::new(vec![Completion {
1165 content: "boundary summary".to_string(),
1166 tool_calls: vec![],
1167 finish_reason: Some("stop".to_string()),
1168 usage: None,
1169 reasoning_content: None,
1170 }]);
1171
1172 let keep_n = 1_usize;
1173 let mut transcript = vec![
1174 Message::system("s".to_string()),
1175 Message::user("u".to_string()),
1176 Message::assistant("a".to_string()),
1177 Message::user("u2".to_string()),
1178 ];
1179
1180 let compactor = Compactor::new(0).keep_recent_n(keep_n);
1181 let result = compactor
1182 .apply_to_transcript(&provider, &mut transcript, 1)
1183 .await
1184 .unwrap();
1185
1186 assert!(
1187 result.is_some(),
1188 "should compact when older has ≥2 conversational msgs"
1189 );
1190 }
1191
1192 #[test]
1197 fn render_structured_with_next_steps_includes_todos_section() {
1198 let rendered = Compactor::render_structured(
1199 "summary text",
1200 &["fact A".into(), "fact B".into()],
1201 &["step 1".into()],
1202 5,
1203 );
1204 assert!(rendered.contains("[compacted: structured at step 5]"));
1205 assert!(rendered.contains("Summary: summary text"));
1206 assert!(rendered.contains("- fact A"));
1207 assert!(rendered.contains("- fact B"));
1208 assert!(
1209 rendered.contains("Outstanding TODOs:"),
1210 "non-empty next_steps must show TODOs section"
1211 );
1212 assert!(rendered.contains("- step 1"));
1213 }
1214
1215 #[test]
1216 fn render_structured_without_next_steps_omits_todos_section() {
1217 let rendered = Compactor::render_structured(
1220 "summary",
1221 &["fact".into()],
1222 &[], 1,
1224 );
1225 assert!(
1226 !rendered.contains("Outstanding TODOs:"),
1227 "empty next_steps must NOT include TODOs section (kills delete-! mutation)"
1228 );
1229 }
1230
1231 #[tokio::test]
1232 async fn apply_to_transcript_boundary_plus_not_times() {
1233 let provider = MockProvider::new(vec![Completion {
1239 content: "summary".to_string(),
1240 tool_calls: vec![],
1241 finish_reason: Some("stop".to_string()),
1242 usage: None,
1243 reasoning_content: None,
1244 }]);
1245
1246 let keep_n = 2_usize;
1247 let mut transcript = vec![
1249 Message::system("s0".to_string()),
1250 Message::user("u1".to_string()),
1251 Message::assistant("a1".to_string()),
1252 Message::user("u2".to_string()),
1253 Message::assistant("a2".to_string()),
1254 ];
1255
1256 let compactor = Compactor::new(0).keep_recent_n(keep_n);
1257 let result = compactor
1258 .apply_to_transcript(&provider, &mut transcript, 1)
1259 .await
1260 .unwrap();
1261
1262 assert!(
1263 result.is_some(),
1264 "must compact when older has ≥2 conversational msgs"
1265 );
1266 }
1267
1268 #[test]
1273 fn safe_split_point_keeps_system_message_in_kept() {
1274 let msgs = vec![
1279 Message::system("You are a coding agent. Be thorough.".to_string()),
1280 Message::user("Do task A".to_string()),
1281 Message::assistant("Working on A".to_string()),
1282 Message::user("Now do task B".to_string()),
1283 Message::assistant("Working on B".to_string()),
1284 Message::user("Now do task C".to_string()),
1285 Message::assistant("Working on C".to_string()),
1286 ];
1287 let split = Compactor::safe_split_point(&msgs, 4);
1290 assert_eq!(split, 3, "valid split must not be disturbed");
1291 }
1292
1293 #[test]
1294 fn safe_split_point_advances_past_system_at_boundary() {
1295 let msgs = vec![
1299 Message::system("SYSTEM PROMPT — keep me verbatim".to_string()),
1300 Message::user("Hello".to_string()),
1301 Message::assistant("Hi!".to_string()),
1302 ];
1303 let split = Compactor::safe_split_point(&msgs, 3);
1307 assert_eq!(
1308 split, 1,
1309 "system message at split boundary must be advanced past"
1310 );
1311 }
1312
1313 #[tokio::test]
1314 async fn apply_to_transcript_rejects_system_only_older() {
1315 let provider = MockProvider::new(vec![]); let mut transcript = vec![
1322 Message::system(
1323 "VERY LONG SYSTEM PROMPT — AGENTS.md, CLAUDE.md, journal, skills...".to_string(),
1324 ),
1325 Message::user("Add a feature".to_string()),
1326 Message::assistant("I'll work on it.".to_string()),
1327 Message::user("Status?".to_string()),
1328 Message::assistant("Almost done.".to_string()),
1329 Message::user("Now test it.".to_string()),
1330 Message::assistant("Tests pass.".to_string()),
1331 Message::user("Commit.".to_string()),
1332 Message::assistant("Done.".to_string()),
1333 ];
1334 let compactor = Compactor::new(0).keep_recent_n(8);
1340 let result = compactor
1341 .apply_to_transcript(&provider, &mut transcript, 0)
1342 .await
1343 .unwrap();
1344
1345 assert!(
1346 result.is_none(),
1347 "system-only older (0 conversational msgs < 2 min) must be rejected"
1348 );
1349 assert_eq!(transcript.len(), 9, "transcript must be unchanged");
1351 }
1352
1353 #[tokio::test]
1354 async fn apply_to_transcript_compacts_normally_with_real_conversation() {
1355 let provider = MockProvider::new(vec![Completion {
1358 content: "We added a feature and tested it.".to_string(),
1359 tool_calls: vec![],
1360 finish_reason: Some("stop".to_string()),
1361 usage: None,
1362 reasoning_content: None,
1363 }]);
1364
1365 let mut transcript = vec![
1366 Message::system("System prompt".to_string()),
1367 Message::user("Add feature X".to_string()),
1368 Message::assistant("Working on X".to_string()),
1369 Message::user("Add feature Y".to_string()),
1370 Message::assistant("Working on Y".to_string()),
1371 Message::user("Test everything".to_string()),
1372 Message::assistant("All tests pass".to_string()),
1373 ];
1374 let compactor = Compactor::new(0).keep_recent_n(2);
1377 let result = compactor
1378 .apply_to_transcript(&provider, &mut transcript, 1)
1379 .await
1380 .unwrap();
1381
1382 let (removed, summary_chars) =
1383 result.expect("should compact when older has real conversation");
1384 assert!(removed > 0);
1385 assert!(summary_chars > 0);
1386 assert_eq!(transcript[0].role, crate::message::Role::System);
1387 assert!(transcript[0].is_compaction_summary);
1388 }
1389
1390 #[tokio::test]
1391 async fn apply_to_transcript_rejects_below_min_conversational() {
1392 let provider = MockProvider::new(vec![]); let mut transcript = vec![
1396 Message::system("System prompt".to_string()),
1397 Message::user("The only user message".to_string()),
1398 Message::assistant("Reply".to_string()),
1399 Message::user("Another user message".to_string()),
1400 Message::assistant("Another reply".to_string()),
1401 Message::user("Yet another".to_string()),
1402 Message::assistant("Final reply".to_string()),
1403 ];
1404 let compactor = Compactor::new(0).keep_recent_n(5);
1410 let result = compactor
1411 .apply_to_transcript(&provider, &mut transcript, 0)
1412 .await
1413 .unwrap();
1414
1415 assert!(
1416 result.is_none(),
1417 "older with 1 conversational msg (< 2 min) must be rejected"
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn apply_to_transcript_accepts_exactly_min_conversational() {
1423 let provider = MockProvider::new(vec![Completion {
1425 content: "Conversation about two messages.".to_string(),
1426 tool_calls: vec![],
1427 finish_reason: Some("stop".to_string()),
1428 usage: None,
1429 reasoning_content: None,
1430 }]);
1431
1432 let mut transcript = vec![
1433 Message::system("System prompt".to_string()),
1434 Message::user("Hello".to_string()),
1435 Message::assistant("Hi there!".to_string()),
1436 Message::user("How are you?".to_string()),
1437 Message::assistant("I'm well, thanks.".to_string()),
1438 ];
1439 let compactor = Compactor::new(0).keep_recent_n(2);
1445 let result = compactor
1446 .apply_to_transcript(&provider, &mut transcript, 1)
1447 .await
1448 .unwrap();
1449
1450 let (removed, _summary_chars) =
1451 result.expect("should compact when older has exactly 2 conversational msgs");
1452 assert!(removed > 0);
1453 }
1454
1455 #[tokio::test]
1460 async fn compact_retries_on_ptl_then_succeeds() {
1461 let provider = MockProvider::new(vec![Completion {
1463 content: "Second attempt summary.".to_string(),
1464 tool_calls: vec![],
1465 finish_reason: Some("stop".to_string()),
1466 usage: None,
1467 reasoning_content: None,
1468 }])
1469 .with_errors(vec![crate::error::Error::Llm {
1470 provider: "mock".into(),
1471 message: "HTTP 400: context_length_exceeded, prompt is too long".into(),
1472 }]);
1473
1474 let transcript = vec![
1475 Message::system("sys".to_string()),
1476 Message::user("msg1".to_string()),
1477 Message::assistant("rep1".to_string()),
1478 Message::user("msg2".to_string()),
1479 Message::assistant("rep2".to_string()),
1480 Message::user("msg3".to_string()),
1481 Message::assistant("rep3".to_string()),
1482 Message::user("msg4".to_string()),
1483 Message::assistant("rep4".to_string()),
1484 Message::user("msg5".to_string()),
1485 Message::assistant("rep5".to_string()),
1486 ];
1487
1488 let compactor = Compactor::new(0).keep_recent_n(2);
1489 let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
1490
1491 assert_eq!(summary_msg.role, crate::message::Role::System);
1492 assert!(
1493 summary_msg.content.contains("Second attempt summary."),
1494 "should contain the retry's summary, got: {}",
1495 summary_msg.content
1496 );
1497
1498 let calls = provider.calls();
1500 assert_eq!(
1501 calls.len(),
1502 2,
1503 "provider must be called twice (first error, then retry)"
1504 );
1505 }
1506
1507 #[tokio::test]
1508 async fn compact_gives_up_after_max_retries() {
1509 let mut errors = Vec::new();
1511 for _ in 0..=MAX_PTL_RETRIES {
1512 errors.push(crate::error::Error::Llm {
1513 provider: "mock".into(),
1514 message: "HTTP 400: context_length_exceeded, prompt is too long".into(),
1515 });
1516 }
1517 let provider = MockProvider::new(vec![]).with_errors(errors);
1520
1521 let transcript = vec![
1522 Message::system("sys".to_string()),
1523 Message::user("long msg1".to_string()),
1524 Message::assistant("long rep1".to_string()),
1525 Message::user("long msg2".to_string()),
1526 Message::assistant("long rep2".to_string()),
1527 ];
1528
1529 let compactor = Compactor::new(0).keep_recent_n(1);
1530 let result = compactor.compact(&provider, &transcript, 0).await;
1531 assert!(
1532 result.is_err(),
1533 "should return error after exhausting retries"
1534 );
1535 let err = result.unwrap_err();
1536 assert!(
1537 err.to_string().contains("context_length_exceeded"),
1538 "error should contain the PTL signal"
1539 );
1540 }
1541
1542 #[tokio::test]
1543 async fn compact_non_ptl_error_propagates_immediately() {
1544 let provider = MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Llm {
1546 provider: "mock".into(),
1547 message: "HTTP 400: invalid request".into(),
1548 }]);
1549
1550 let transcript = vec![
1551 Message::system("sys".to_string()),
1552 Message::user("msg1".to_string()),
1553 Message::assistant("rep1".to_string()),
1554 ];
1555
1556 let compactor = Compactor::new(0).keep_recent_n(1);
1557 let result = compactor.compact(&provider, &transcript, 0).await;
1558 assert!(
1559 result.is_err(),
1560 "non-PTL error should propagate immediately"
1561 );
1562
1563 let calls = provider.calls();
1565 assert_eq!(calls.len(), 1, "non-PTL error must not trigger retry");
1566 }
1567
1568 async fn validate_boundary_preserving(
1591 transcript: &[Message],
1592 keep_n: usize,
1593 summary_content: &str,
1594 ) {
1595 let split = Compactor::safe_split_point(transcript, keep_n);
1596
1597 let expected_recent_json: Vec<String> = transcript[split..]
1599 .iter()
1600 .map(|m| serde_json::to_string(m).unwrap())
1601 .collect();
1602
1603 let mut compacted = transcript.to_vec();
1606 let summary_msg = Message::system(format!(
1607 "[compacted: test summary at step 1]\n{}",
1608 summary_content
1609 ))
1610 .with_compaction_summary();
1611 compacted.drain(..split);
1612 compacted.insert(0, summary_msg);
1613
1614 let recent_after_json: Vec<String> = compacted[1..]
1616 .iter()
1617 .map(|m| serde_json::to_string(m).unwrap())
1618 .collect();
1619
1620 assert_eq!(
1621 recent_after_json, expected_recent_json,
1622 "recent region must be byte-identical after compaction splice"
1623 );
1624 }
1625
1626 #[tokio::test]
1627 async fn boundary_preserving_recent_region_is_byte_identical() {
1628 let transcript = vec![
1630 Message::system("System prompt.".to_string()),
1631 Message::user("Old goal: add feature X".to_string()),
1632 Message::assistant("Working on feature X.".to_string()),
1633 Message::user("Old goal: test it.".to_string()),
1634 Message::assistant("Recent: I fixed a test.".to_string()),
1636 Message::user("Recent: Let's commit.".to_string()),
1637 ];
1638
1639 validate_boundary_preserving(
1640 &transcript,
1641 2, "Summary of the old goals: added feature X, tests pass.",
1643 )
1644 .await;
1645 }
1646
1647 #[tokio::test]
1648 async fn boundary_preserving_with_tool_calls_in_older() {
1649 use crate::llm::ToolCall;
1653
1654 let tool_call = ToolCall {
1655 id: "c1".into(),
1656 name: "Read".into(),
1657 arguments: serde_json::json!({"path": "src/lib.rs"}),
1658 };
1659 let transcript = vec![
1660 Message::system("sys".to_string()),
1661 Message::user("Read the file".to_string()),
1662 Message::assistant_with_tool_calls("reading".to_string(), vec![tool_call]),
1663 Message::tool_result("c1", "file contents here"),
1664 Message::user("Recent: now edit".to_string()),
1666 Message::assistant("Recent: done".to_string()),
1667 ];
1668
1669 validate_boundary_preserving(
1670 &transcript,
1671 2, "Summary of read-file steps.",
1673 )
1674 .await;
1675 }
1676
1677 #[tokio::test]
1678 async fn mock_provider_accepts_post_compact_transcript() {
1679 let provider = MockProvider::new(vec![
1688 Completion {
1689 content: "Summary: added feature, tests pass.".to_string(),
1690 tool_calls: vec![],
1691 finish_reason: Some("stop".to_string()),
1692 usage: None,
1693 reasoning_content: None,
1694 },
1695 Completion {
1696 content: "This is a response to the post-compact transcript.".to_string(),
1697 tool_calls: vec![],
1698 finish_reason: Some("stop".to_string()),
1699 usage: None,
1700 reasoning_content: None,
1701 },
1702 ]);
1703
1704 let mut transcript = vec![
1705 Message::system("System prompt.".to_string()),
1706 Message::user("Goal: add feature".to_string()),
1707 Message::assistant("Working on it.".to_string()),
1708 Message::user("Test it.".to_string()),
1709 Message::assistant("Tests pass.".to_string()),
1710 Message::user("Recent: commit.".to_string()),
1711 Message::assistant("Recent: done.".to_string()),
1712 ];
1713
1714 let compactor = Compactor::new(0).keep_recent_n(2);
1716 let result = compactor
1717 .apply_to_transcript(&provider, &mut transcript, 5)
1718 .await
1719 .unwrap();
1720 assert!(
1721 result.is_some(),
1722 "compaction must succeed for the test transcript"
1723 );
1724
1725 let completion = provider.complete(&transcript, &[]).await;
1730 assert!(
1731 completion.is_ok(),
1732 "mock provider must accept post-compaction transcript"
1733 );
1734
1735 let calls = provider.calls();
1737 assert!(
1738 !calls.is_empty(),
1739 "provider should have been called via complete()"
1740 );
1741 let sent_messages = &calls[calls.len() - 1]; let first_non_system = sent_messages
1745 .iter()
1746 .find(|m| m.role != crate::message::Role::System);
1747 assert!(
1748 first_non_system.is_some(),
1749 "post-compact transcript must contain at least one non-System message"
1750 );
1751 assert_eq!(
1752 first_non_system.unwrap().role,
1753 crate::message::Role::User,
1754 "first non-system message must be User (provider ordering invariant)"
1755 );
1756
1757 for (i, msg) in sent_messages.iter().enumerate() {
1760 if msg.role == crate::message::Role::Tool {
1761 if i > 0 {
1763 let prev = &sent_messages[i - 1];
1764 assert_eq!(
1765 prev.role,
1766 crate::message::Role::Assistant,
1767 "Tool message at index {i} must be preceded by Assistant"
1768 );
1769 }
1770 }
1771 }
1772 }
1773
1774 #[tokio::test]
1775 async fn mock_provider_accepts_compact_with_system_summary_user_ordering() {
1776 let provider = MockProvider::new(vec![Completion {
1782 content: "provider accepted the sequence".to_string(),
1783 tool_calls: vec![],
1784 finish_reason: Some("stop".to_string()),
1785 usage: None,
1786 reasoning_content: None,
1787 }]);
1788
1789 let transcript = vec![
1791 Message::system("[compacted: summary at step 5] Summary text.")
1792 .with_compaction_summary(),
1793 Message::system("Original system prompt.".to_string()),
1794 Message::user("Post-compact user message.".to_string()),
1795 Message::assistant("Post-compact assistant response.".to_string()),
1796 ];
1797
1798 let completion = provider.complete(&transcript, &[]).await;
1799 assert!(
1800 completion.is_ok(),
1801 "mock provider must accept transcript with multiple leading System messages"
1802 );
1803
1804 let calls = provider.calls();
1805 let sent = calls.last().expect("provider must have been called");
1806 let first_non_system = sent.iter().find(|m| m.role != crate::message::Role::System);
1807 assert!(
1808 first_non_system.is_some(),
1809 "transcript must have a non-System message"
1810 );
1811 assert_eq!(
1812 first_non_system.unwrap().role,
1813 crate::message::Role::User,
1814 "first non-System must be User even with multiple leading System messages"
1815 );
1816 }
1817}