1use std::collections::HashSet;
26
27use futures::StreamExt as _;
28
29use crate::event::LlmStreamEvent;
30use crate::message::{AgentMessage, ContentBlock, Usage};
31use crate::session::Session;
32use crate::streamfn::{LlmOpts, StreamFn};
33
34pub fn safe_cut(ctx: &[AgentMessage], initial_cut: usize) -> usize {
42 let cut = initial_cut.min(ctx.len());
43
44 let mut available: HashSet<&str> = HashSet::new();
45 let mut missing: HashSet<&str> = HashSet::new();
46 for msg in &ctx[cut..] {
47 absorb(msg, &mut available, &mut missing);
48 }
49 if missing.is_empty() {
50 return cut;
51 }
52
53 for idx in (0..cut).rev() {
57 absorb(&ctx[idx], &mut available, &mut missing);
58 if missing.is_empty() {
59 return idx;
60 }
61 }
62
63 0
64}
65
66fn absorb<'a>(
68 msg: &'a AgentMessage,
69 available: &mut HashSet<&'a str>,
70 missing: &mut HashSet<&'a str>,
71) {
72 match msg {
73 AgentMessage::Assistant { content, .. } => {
74 for block in content {
75 if let ContentBlock::ToolCall { id, .. } = block {
76 missing.remove(id.as_str());
77 available.insert(id.as_str());
78 }
79 }
80 }
81 AgentMessage::ToolResult { tool_call_id, .. }
82 if !available.contains(tool_call_id.as_str()) =>
83 {
84 missing.insert(tool_call_id.as_str());
85 }
86 _ => {}
87 }
88}
89
90pub const DEFAULT_MIN_REMAINING: u64 = 16_384;
93
94pub const DEFAULT_KEEP_RECENT_TOKENS: u64 = 20_000;
97
98pub const DEFAULT_KEEP_RECENT_TURNS: usize = 2;
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub struct CompactionPolicy {
111 pub window: Option<u64>,
115 pub budget: Option<u64>,
118 pub min_remaining: u64,
121 pub keep_recent_tokens: u64,
123 pub keep_recent_turns: usize,
125}
126
127impl Default for CompactionPolicy {
128 fn default() -> Self {
129 Self {
130 window: None,
131 budget: None,
132 min_remaining: DEFAULT_MIN_REMAINING,
133 keep_recent_tokens: DEFAULT_KEEP_RECENT_TOKENS,
134 keep_recent_turns: DEFAULT_KEEP_RECENT_TURNS,
135 }
136 }
137}
138
139impl CompactionPolicy {
140 pub fn ceiling(&self, window: u64) -> u64 {
142 self.budget.map_or(window, |b| b.min(window))
143 }
144
145 pub fn context_window(&self, base_url: Option<&str>, model: &str) -> u64 {
150 crate::limits::model_limit(base_url, model)
151 .map(|l| l.context)
152 .or(self.window)
153 .unwrap_or(crate::limits::DEFAULT_CONTEXT_WINDOW)
154 }
155
156 pub fn should_compact(&self, window: u64, used: u64) -> bool {
159 used >= self.ceiling(window).saturating_sub(self.min_remaining)
160 }
161}
162
163pub fn estimate_tokens(msg: &AgentMessage) -> u64 {
167 let bytes = match msg {
168 AgentMessage::User { content } | AgentMessage::Assistant { content, .. } => {
169 content.iter().map(block_bytes).sum()
170 }
171 AgentMessage::ToolResult {
172 output,
173 tool_call_id,
174 name,
175 ..
176 } => (output.len() + tool_call_id.len() + name.len()) as u64,
177 };
178 bytes / 4 + 4
179}
180
181fn block_bytes(block: &ContentBlock) -> u64 {
182 match block {
183 ContentBlock::Text { text } | ContentBlock::Thinking { text } => text.len() as u64,
184 ContentBlock::ToolCall {
185 name, arguments, ..
186 } => (name.len() + arguments.to_string().len()) as u64,
187 }
188}
189
190#[cfg(test)]
194fn cut_for_budget(ctx: &[AgentMessage], keep_budget: u64) -> usize {
195 safe_cut(ctx, token_cut(ctx, keep_budget))
196}
197
198fn token_cut(ctx: &[AgentMessage], budget: u64) -> usize {
201 let mut acc = 0u64;
202 let mut idx = ctx.len();
203 while idx > 0 && acc < budget {
204 idx -= 1;
205 acc += estimate_tokens(&ctx[idx]);
206 }
207 idx
208}
209
210fn turn_start_cut(ctx: &[AgentMessage], turns: usize) -> usize {
215 if turns == 0 {
216 return ctx.len();
217 }
218 let mut seen = 0usize;
219 for idx in (0..ctx.len()).rev() {
220 if matches!(&ctx[idx], AgentMessage::User { .. }) {
221 seen += 1;
222 if seen == turns {
223 return idx;
224 }
225 }
226 }
227 0
228}
229
230pub fn cut_for_policy(ctx: &[AgentMessage], policy: &CompactionPolicy) -> usize {
233 let by_tokens = token_cut(ctx, policy.keep_recent_tokens);
234 let by_turns = turn_start_cut(ctx, policy.keep_recent_turns);
235 safe_cut(ctx, by_tokens.min(by_turns))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::message::StopReason;
242
243 fn user(text: &str) -> AgentMessage {
244 AgentMessage::user_text(text)
245 }
246
247 fn assistant_text(text: &str) -> AgentMessage {
248 AgentMessage::Assistant {
249 content: vec![ContentBlock::Text { text: text.into() }],
250 stop_reason: StopReason::Stop,
251 usage: None,
252 model: None,
253 }
254 }
255
256 fn assistant_calls(ids: &[&str]) -> AgentMessage {
257 AgentMessage::Assistant {
258 content: ids
259 .iter()
260 .map(|id| ContentBlock::ToolCall {
261 id: (*id).into(),
262 name: "t".into(),
263 arguments: serde_json::json!({}),
264 })
265 .collect(),
266 stop_reason: StopReason::ToolUse,
267 usage: None,
268 model: None,
269 }
270 }
271
272 fn tool_result(id: &str) -> AgentMessage {
273 AgentMessage::ToolResult {
274 tool_call_id: id.into(),
275 name: "t".into(),
276 output: "o".into(),
277 is_error: false,
278 }
279 }
280
281 #[test]
282 fn empty_and_out_of_range_are_clamped() {
283 assert_eq!(safe_cut(&[], 0), 0);
284 assert_eq!(safe_cut(&[], 5), 0);
285 let ctx = vec![user("a")];
286 assert_eq!(safe_cut(&ctx, 0), 0);
287 assert_eq!(safe_cut(&ctx, 1), 1);
288 assert_eq!(safe_cut(&ctx, 9), 1, "clamps to ctx.len()");
289 }
290
291 #[test]
292 fn tool_free_context_keeps_initial_cut() {
293 let ctx = vec![user("a"), assistant_text("x"), user("b"), assistant_text("y")];
294 assert_eq!(safe_cut(&ctx, 2), 2);
295 assert_eq!(safe_cut(&ctx, 3), 3);
296 }
297
298 #[test]
299 fn cut_inside_a_tool_exchange_pulls_in_the_call() {
300 let ctx = vec![
302 user("q"),
303 assistant_calls(&["c1"]),
304 tool_result("c1"),
305 assistant_text("done"),
306 ];
307 assert_eq!(safe_cut(&ctx, 2), 1);
309 assert_eq!(safe_cut(&ctx, 3), 3, "cut past the exchange drops it whole");
310 assert_eq!(safe_cut(&ctx, 1), 1);
312 assert_eq!(safe_cut(&ctx, 4), 4);
314 }
315
316 #[test]
317 fn multi_call_assistant_is_pulled_in_whole() {
318 let ctx = vec![
320 user("q"),
321 assistant_calls(&["c1", "c2"]),
322 tool_result("c1"),
323 tool_result("c2"),
324 assistant_text("done"),
325 ];
326 assert_eq!(safe_cut(&ctx, 3), 1);
328 assert_eq!(safe_cut(&ctx, 2), 1);
329 assert_eq!(safe_cut(&ctx, 5), 5);
331 }
332
333 #[test]
334 fn cut_at_a_user_boundary_after_a_completed_turn_is_kept() {
335 let ctx = vec![
336 user("q1"),
337 assistant_calls(&["c1"]),
338 tool_result("c1"),
339 user("q2"),
340 assistant_text("done"),
341 ];
342 assert_eq!(safe_cut(&ctx, 3), 3, "the q2 boundary needs no adjustment");
343 }
344
345 #[test]
346 fn unpaired_result_walks_all_the_way_back() {
347 let ctx = vec![user("q"), tool_result("ghost"), assistant_text("x")];
350 assert_eq!(safe_cut(&ctx, 1), 0);
351 assert_eq!(safe_cut(&ctx, 2), 2, "dropping the orphan is fine");
352 }
353
354 #[test]
355 fn kept_call_with_its_results_needs_no_extension() {
356 let ctx = vec![
358 user("q"),
359 assistant_calls(&["c1"]),
360 tool_result("c1"),
361 user("next"),
362 ];
363 assert_eq!(safe_cut(&ctx, 3), 3, "keeps only the trailing user message");
364 assert_eq!(safe_cut(&ctx, 1), 1, "keeps the whole tool exchange");
365 }
366}
367
368#[cfg(test)]
369mod policy_tests {
370 use super::*;
371 use crate::message::StopReason;
372
373 fn user(text: &str) -> AgentMessage {
374 AgentMessage::user_text(text)
375 }
376
377 fn assistant_text(text: &str) -> AgentMessage {
378 AgentMessage::Assistant {
379 content: vec![ContentBlock::Text { text: text.into() }],
380 stop_reason: StopReason::Stop,
381 usage: None,
382 model: None,
383 }
384 }
385
386 fn assistant_tool_call(id: &str, args: String) -> AgentMessage {
387 AgentMessage::Assistant {
388 content: vec![ContentBlock::ToolCall {
389 id: id.into(),
390 name: "t".into(),
391 arguments: serde_json::json!({ "d": args }),
392 }],
393 stop_reason: StopReason::ToolUse,
394 usage: None,
395 model: None,
396 }
397 }
398
399 fn tool_result(id: &str, output: String) -> AgentMessage {
400 AgentMessage::ToolResult {
401 tool_call_id: id.into(),
402 name: "t".into(),
403 output,
404 is_error: false,
405 }
406 }
407
408 #[test]
409 fn trigger_uses_min_remaining_and_budget() {
410 let p = CompactionPolicy {
412 min_remaining: 100,
413 ..Default::default()
414 };
415 assert!(!p.should_compact(1000, 899));
416 assert!(p.should_compact(1000, 900), "window - min_remaining");
417 assert!(p.should_compact(1000, 5000), "past the window still triggers");
418
419 let p = CompactionPolicy {
421 budget: Some(500),
422 min_remaining: 100,
423 ..Default::default()
424 };
425 assert!(!p.should_compact(1_000_000, 399));
426 assert!(p.should_compact(1_000_000, 400));
427
428 let p = CompactionPolicy {
430 budget: Some(2_000_000),
431 min_remaining: 100,
432 ..Default::default()
433 };
434 assert!(!p.should_compact(1000, 899));
435 assert!(p.should_compact(1000, 900));
436 }
437
438 #[test]
439 fn ceiling_is_budget_capped_to_window() {
440 let p = CompactionPolicy {
441 budget: Some(500),
442 ..Default::default()
443 };
444 assert_eq!(p.ceiling(1_000_000), 500);
445 let p = CompactionPolicy {
446 budget: Some(2_000_000),
447 ..Default::default()
448 };
449 assert_eq!(p.ceiling(1_000_000), 1_000_000);
450 let p = CompactionPolicy::default();
451 assert_eq!(p.ceiling(1234), 1234);
452 }
453
454 #[test]
455 fn estimate_grows_with_content_and_counts_tool_output() {
456 assert!(estimate_tokens(&user("hello there")) > estimate_tokens(&user("hi")));
457 let big = AgentMessage::ToolResult {
458 tool_call_id: "c".into(),
459 name: "t".into(),
460 output: "x".repeat(400),
461 is_error: false,
462 };
463 assert!(estimate_tokens(&big) >= 100, "~400 chars -> ~100 tokens");
464 }
465
466 #[test]
467 fn cut_keeps_the_newest_within_budget() {
468 let ctx = vec![
470 user(&"a".repeat(200)),
471 assistant_text(&"b".repeat(200)),
472 user(&"c".repeat(200)),
473 assistant_text(&"d".repeat(200)),
474 user(&"e".repeat(200)),
475 assistant_text(&"f".repeat(200)),
476 ];
477 assert_eq!(cut_for_budget(&ctx, 120), 3, "keeps the newest that fit");
478 }
479
480 #[test]
481 fn cut_boundary_is_made_tool_safe() {
482 let ctx = vec![
484 user("q"),
485 assistant_tool_call("c1", "x".repeat(400)),
486 tool_result("c1", "y".repeat(400)),
487 assistant_text("done"),
488 ];
489 assert_eq!(cut_for_budget(&ctx, 1), ctx.len() - 1);
491 assert_eq!(cut_for_budget(&ctx, 120), 1, "the call joins the suffix");
493 }
494
495 #[test]
496 fn cut_for_policy_keeps_recent_tokens() {
497 let ctx = vec![
498 user(&"a".repeat(200)),
499 assistant_text(&"b".repeat(200)),
500 user(&"c".repeat(200)),
501 assistant_text(&"d".repeat(200)),
502 ];
503 let p = CompactionPolicy {
505 keep_recent_tokens: 100,
506 keep_recent_turns: 0,
507 ..Default::default()
508 };
509 assert_eq!(cut_for_policy(&ctx, &p), 2);
510 }
511
512 #[test]
513 fn cut_for_policy_keeps_at_least_the_turn_floor() {
514 let ctx = vec![
516 user("u1"),
517 assistant_text("a1"),
518 user("u2"),
519 assistant_text("a2"),
520 user("u3"),
521 assistant_text("a3"),
522 ];
523 let p = CompactionPolicy {
524 keep_recent_tokens: 0,
525 keep_recent_turns: 2,
526 ..Default::default()
527 };
528 assert_eq!(cut_for_policy(&ctx, &p), 2, "last two turns start at u2");
529 }
530}
531
532const SUMMARY_PROMPT: &str = "\
534You are summarizing a coding-agent conversation so the agent can continue the \
535work later with this summary in place of the summarized messages. Write \
536concise plain prose under these headings, preserving concrete details (file \
537paths, names, decisions, errors) and omitting pleasantries:
538- Context: what we are working on and why.
539- What we did: key actions taken, files changed, problems solved.
540- Current state: what works, what is broken, what is next.
541- User preferences: requirements or decisions the user made. The turns you \
542summarize are not lost: they remain in this session's transcript and can be \
543searched later with the session_search tool (use scope=\"current\").";
544
545const MAX_BLOCK_CHARS: usize = 4_000;
548
549const MAX_CONVERSATION_CHARS: usize = 400_000;
552
553#[derive(Debug, Clone)]
555pub struct Summary {
556 pub text: String,
557 pub usage: Option<Usage>,
558}
559
560#[derive(Debug, Clone)]
562pub enum CompactOutcome {
563 NothingToDo,
565 Done {
568 summarized: usize,
569 kept: usize,
570 usage: Option<Usage>,
571 },
572}
573
574pub async fn compact_ctx(
582 ctx: &mut Vec<AgentMessage>,
583 policy: &CompactionPolicy,
584 stream_fn: &StreamFn,
585 opts: &LlmOpts,
586 instructions: Option<&str>,
587 session: Option<&mut Session>,
588) -> Result<CompactOutcome, String> {
589 let cut = cut_for_policy(ctx, policy);
590 if cut == 0 {
591 return Ok(CompactOutcome::NothingToDo);
592 }
593 let prefix = ctx[..cut].to_vec();
594 let summary = summarize(stream_fn, opts, &prefix, instructions).await?;
595 let kept = ctx.len() - cut;
596
597 if let Some(session) = session {
598 let tokens_before = ctx.iter().rev().find_map(|m| match m {
599 AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
600 _ => None,
601 });
602 session
603 .record_compaction(&summary.text, kept, tokens_before, summary.usage)
604 .map_err(|e| e.to_string())?;
605 }
606
607 let mut next = Vec::with_capacity(kept + 1);
608 next.push(summary_message(&summary.text));
609 next.extend_from_slice(&ctx[cut..]);
610 *ctx = next;
611
612 Ok(CompactOutcome::Done {
613 summarized: cut,
614 kept,
615 usage: summary.usage,
616 })
617}
618
619pub fn summary_message(summary: &str) -> AgentMessage {
621 AgentMessage::summary(summary)
622}
623
624pub async fn summarize(
629 stream_fn: &StreamFn,
630 opts: &LlmOpts,
631 conversation: &[AgentMessage],
632 instructions: Option<&str>,
633) -> Result<Summary, String> {
634 let mut system = String::from(SUMMARY_PROMPT);
635 if let Some(extra) = instructions.map(str::trim).filter(|s| !s.is_empty()) {
636 system.push_str("\n\nAdditional focus for this summary: ");
637 system.push_str(extra);
638 }
639 let user = format!(
640 "Summarize this conversation:\n\n{}",
641 render_conversation(conversation)
642 );
643
644 let mut stream = stream_fn(&[AgentMessage::user_text(user)], &system, &[], opts);
645 let mut text = String::new();
646 let mut usage = None;
647 while let Some(event) = stream.next().await {
648 match event {
649 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
650 LlmStreamEvent::Done { usage: u, .. } => {
651 usage = u;
652 break;
653 }
654 LlmStreamEvent::Error { message, .. } => return Err(message),
655 _ => {}
656 }
657 }
658
659 let text = text.trim().to_string();
660 if text.is_empty() {
661 return Err("summarizer produced no text".to_string());
662 }
663 Ok(Summary { text, usage })
664}
665
666fn render_conversation(messages: &[AgentMessage]) -> String {
668 let mut out = String::new();
669 for msg in messages {
670 match msg {
671 AgentMessage::User { content } => {
672 out.push_str("USER:\n");
673 render_blocks(&mut out, content);
674 }
675 AgentMessage::Assistant { content, .. } => {
676 out.push_str("ASSISTANT:\n");
677 render_blocks(&mut out, content);
678 }
679 AgentMessage::ToolResult {
680 tool_call_id,
681 name,
682 output,
683 is_error,
684 } => {
685 out.push_str("TOOL RESULT ");
686 out.push_str(name);
687 out.push_str(" (");
688 out.push_str(tool_call_id);
689 out.push(')');
690 if *is_error {
691 out.push_str(" [error]");
692 }
693 out.push_str(":\n");
694 push_capped(&mut out, output, MAX_BLOCK_CHARS);
695 out.push('\n');
696 }
697 }
698 out.push('\n');
699 }
700 cap_total(out)
701}
702
703fn render_blocks(out: &mut String, content: &[ContentBlock]) {
704 for block in content {
705 match block {
706 ContentBlock::Text { text } => {
707 push_capped(out, text, MAX_BLOCK_CHARS);
708 out.push('\n');
709 }
710 ContentBlock::Thinking { text } => {
711 out.push_str("[thinking] ");
712 push_capped(out, text, MAX_BLOCK_CHARS);
713 out.push('\n');
714 }
715 ContentBlock::ToolCall {
716 id,
717 name,
718 arguments,
719 } => {
720 out.push_str("[tool call ");
721 out.push_str(name);
722 out.push_str(" (");
723 out.push_str(id);
724 out.push_str(") args: ");
725 push_capped(out, &arguments.to_string(), MAX_BLOCK_CHARS);
726 out.push_str("]\n");
727 }
728 }
729 }
730}
731
732fn push_capped(out: &mut String, s: &str, max: usize) {
734 if s.len() <= max {
735 out.push_str(s);
736 return;
737 }
738 let mut end = max;
739 while end > 0 && !s.is_char_boundary(end) {
740 end -= 1;
741 }
742 out.push_str(&s[..end]);
743 out.push_str(&format!("\n… [{} chars truncated]", s.len() - end));
744}
745
746fn cap_total(s: String) -> String {
748 if s.len() <= MAX_CONVERSATION_CHARS {
749 return s;
750 }
751 let half = MAX_CONVERSATION_CHARS / 2;
752 let mut head_end = half;
753 while head_end > 0 && !s.is_char_boundary(head_end) {
754 head_end -= 1;
755 }
756 let mut tail_start = s.len() - half;
757 while tail_start < s.len() && !s.is_char_boundary(tail_start) {
758 tail_start += 1;
759 }
760 format!(
761 "{}\n… [conversation truncated to fit the summarizer] …\n{}",
762 &s[..head_end],
763 &s[tail_start..]
764 )
765}
766
767#[cfg(test)]
768mod summarize_tests {
769 use super::*;
770 use crate::message::StopReason;
771 use crate::streamfn::LlmStream;
772 use std::sync::{Arc, Mutex};
773
774 fn scripted(events: Vec<LlmStreamEvent>, seen: Arc<Mutex<Vec<String>>>) -> StreamFn {
777 Arc::new(move |ctx, system, _tools, _opts| {
778 seen.lock()
779 .unwrap()
780 .push(format!("{system}\n---\n{}", ctx[0].as_text()));
781 Box::pin(futures::stream::iter(events.clone())) as LlmStream
782 })
783 }
784
785 fn done() -> LlmStreamEvent {
786 LlmStreamEvent::Done {
787 stop_reason: StopReason::Stop,
788 usage: Some(Usage::default()),
789 }
790 }
791
792 #[tokio::test]
793 async fn summarize_collects_text_usage_and_rendered_conversation() {
794 let seen = Arc::new(Mutex::new(Vec::new()));
795 let stream_fn = scripted(
796 vec![
797 LlmStreamEvent::TextDelta("Context: ".into()),
798 LlmStreamEvent::TextDelta("do X".into()),
799 done(),
800 ],
801 seen.clone(),
802 );
803 let convo = vec![
804 AgentMessage::user_text("please do X"),
805 AgentMessage::user_text("and Y"),
806 ];
807 let s = summarize(&stream_fn, &LlmOpts::default(), &convo, None)
808 .await
809 .unwrap();
810 assert_eq!(s.text, "Context: do X");
811 assert!(s.usage.is_some());
812 let prompt = seen.lock().unwrap()[0].clone();
813 assert!(prompt.contains("USER:"), "conversation reached the prompt");
814 assert!(prompt.contains("please do X"));
815 }
816
817 #[tokio::test]
818 async fn summarize_folds_instructions_into_the_system_prompt() {
819 let seen = Arc::new(Mutex::new(Vec::new()));
820 let stream_fn = scripted(vec![LlmStreamEvent::TextDelta("x".into()), done()], seen.clone());
821 summarize(
822 &stream_fn,
823 &LlmOpts::default(),
824 &[AgentMessage::user_text("hi")],
825 Some("focus on the API design"),
826 )
827 .await
828 .unwrap();
829 let prompt = seen.lock().unwrap()[0].clone();
830 assert!(prompt.contains("Additional focus for this summary: focus on the API design"));
831 }
832
833 #[tokio::test]
834 async fn summarize_surfaces_stream_error() {
835 let stream_fn = scripted(
836 vec![LlmStreamEvent::Error {
837 message: "boom".into(),
838 fatal: true,
839 }],
840 Arc::new(Mutex::new(Vec::new())),
841 );
842 let err = summarize(
843 &stream_fn,
844 &LlmOpts::default(),
845 &[AgentMessage::user_text("x")],
846 None,
847 )
848 .await
849 .unwrap_err();
850 assert_eq!(err, "boom");
851 }
852
853 #[tokio::test]
854 async fn summarize_rejects_empty_output() {
855 let stream_fn = scripted(vec![done()], Arc::new(Mutex::new(Vec::new())));
856 let err = summarize(
857 &stream_fn,
858 &LlmOpts::default(),
859 &[AgentMessage::user_text("x")],
860 None,
861 )
862 .await
863 .unwrap_err();
864 assert!(err.contains("no text"), "got: {err}");
865 }
866
867 #[test]
868 fn render_truncates_a_giant_tool_output() {
869 let big = AgentMessage::ToolResult {
870 tool_call_id: "c".into(),
871 name: "t".into(),
872 output: "x".repeat(10_000),
873 is_error: false,
874 };
875 let rendered = render_conversation(&[big]);
876 assert!(rendered.contains("chars truncated"));
877 assert!(rendered.len() < 5_000, "len was {}", rendered.len());
878 }
879
880 #[test]
881 fn summary_message_is_a_user_message_with_a_header() {
882 let m = summary_message("did things");
883 assert!(matches!(m, AgentMessage::User { .. }));
884 assert!(m.as_text().contains("did things"));
885 assert!(m.as_text().starts_with("Summary"));
886 }
887}
888
889#[cfg(test)]
890mod window_tests {
891 use super::*;
892
893 #[test]
894 fn context_window_prefers_catalog_then_override_then_default() {
895 let p = CompactionPolicy {
897 window: Some(999),
898 ..Default::default()
899 };
900 assert_eq!(
901 p.context_window(Some("https://opencode.ai/zen/go/v1"), "deepseek-v4.1-flash"),
902 1_000_000
903 );
904
905 let p = CompactionPolicy {
907 window: Some(999),
908 ..Default::default()
909 };
910 assert_eq!(p.context_window(Some("https://api.example.com/v1"), "who"), 999);
911
912 let p = CompactionPolicy::default();
914 assert_eq!(
915 p.context_window(None, "who"),
916 crate::limits::DEFAULT_CONTEXT_WINDOW
917 );
918 }
919}