1use std::collections::BTreeMap;
4use std::rc::Rc;
5
6use crate::llm::{vm_call_llm_full, vm_value_to_json};
7use crate::value::{VmError, VmValue};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum CompactStrategy {
11 Llm,
12 Truncate,
13 Custom,
14 ObservationMask,
15}
16
17pub fn parse_compact_strategy(value: &str) -> Result<CompactStrategy, VmError> {
18 match value {
19 "llm" => Ok(CompactStrategy::Llm),
20 "truncate" => Ok(CompactStrategy::Truncate),
21 "custom" => Ok(CompactStrategy::Custom),
22 "observation_mask" => Ok(CompactStrategy::ObservationMask),
23 other => Err(VmError::Runtime(format!(
24 "unknown compact_strategy '{other}' (expected 'llm', 'truncate', 'custom', or 'observation_mask')"
25 ))),
26 }
27}
28
29pub fn compact_strategy_name(strategy: &CompactStrategy) -> &'static str {
30 match strategy {
31 CompactStrategy::Llm => "llm",
32 CompactStrategy::Truncate => "truncate",
33 CompactStrategy::Custom => "custom",
34 CompactStrategy::ObservationMask => "observation_mask",
35 }
36}
37
38#[derive(Clone, Debug)]
48pub struct AutoCompactConfig {
49 pub keep_first: usize,
53 pub token_threshold: usize,
55 pub tool_output_max_chars: usize,
57 pub keep_last: usize,
59 pub compact_strategy: CompactStrategy,
61 pub hard_limit_tokens: Option<usize>,
65 pub hard_limit_strategy: CompactStrategy,
67 pub custom_compactor: Option<VmValue>,
69 pub mask_callback: Option<VmValue>,
76 pub compress_callback: Option<VmValue>,
82 pub summarize_prompt: Option<String>,
86 pub policy_strategy: String,
90}
91
92impl Default for AutoCompactConfig {
93 fn default() -> Self {
94 Self {
95 keep_first: 0,
96 token_threshold: 48_000,
97 tool_output_max_chars: 16_000,
98 keep_last: 12,
99 compact_strategy: CompactStrategy::ObservationMask,
100 hard_limit_tokens: None,
101 hard_limit_strategy: CompactStrategy::Llm,
102 custom_compactor: None,
103 mask_callback: None,
104 compress_callback: None,
105 summarize_prompt: None,
106 policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
107 }
108 }
109}
110
111pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
113 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
114}
115
116fn estimate_message_chars(message: &serde_json::Value) -> usize {
117 let mut total = message
118 .get("content")
119 .map(estimate_content_chars)
120 .unwrap_or_default();
121 if let Some(reasoning) = message.get("reasoning") {
122 total += estimate_content_chars(reasoning);
123 }
124 if let Some(tool_calls) = message.get("tool_calls") {
125 total += estimate_content_chars(tool_calls);
126 }
127 total
128}
129
130fn estimate_content_chars(value: &serde_json::Value) -> usize {
131 match value {
132 serde_json::Value::String(text) => text.len(),
133 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
134 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
135 serde_json::Value::Null => 0,
136 other => other.to_string().len(),
137 }
138}
139
140fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
141 let role = message
142 .get("role")
143 .and_then(|value| value.as_str())
144 .unwrap_or_default();
145 role == "tool"
146 || message.get("tool_calls").is_some()
147 || message
148 .get("reasoning")
149 .map(|value| !value.is_null())
150 .unwrap_or(false)
151}
152
153fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
154 (0..=start)
155 .rev()
156 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
157}
158
159pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
162 if output.len() <= max_chars || max_chars < 200 {
163 return output.to_string();
164 }
165 let diagnostic_lines = output
166 .lines()
167 .filter(|line| {
168 let trimmed = line.trim();
169 let lower = trimmed.to_lowercase();
170 let has_file_line = {
171 let bytes = trimmed.as_bytes();
172 let mut i = 0;
173 let mut found_colon = false;
174 while i < bytes.len() {
175 if bytes[i] == b':' {
176 found_colon = true;
177 break;
178 }
179 i += 1;
180 }
181 found_colon && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
182 };
183 let has_strong_keyword =
184 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
185 let has_weak_keyword = trimmed.contains("error")
186 || trimmed.contains("undefined")
187 || trimmed.contains("expected")
188 || trimmed.contains("got")
189 || lower.contains("cannot find")
190 || lower.contains("not found")
191 || lower.contains("no such")
192 || lower.contains("unresolved")
193 || lower.contains("missing")
194 || lower.contains("declared but not used")
195 || lower.contains("unused")
196 || lower.contains("mismatch");
197 let positional = lower.contains(" error ")
198 || lower.starts_with("error:")
199 || lower.starts_with("warning:")
200 || lower.starts_with("note:")
201 || lower.contains("panic:");
202 has_strong_keyword || (has_file_line && has_weak_keyword) || positional
203 })
204 .take(32)
205 .collect::<Vec<_>>();
206 if !diagnostic_lines.is_empty() {
207 let diagnostics = diagnostic_lines.join("\n");
208 let budget = max_chars.saturating_sub(diagnostics.len() + 64);
209 let keep = budget / 2;
210 if keep >= 80 && output.len() > keep * 2 {
211 let head = snap_to_line_end(output, keep);
212 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
213 return format!(
214 "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
215 );
216 }
217 }
218 let keep = max_chars / 2;
219 let head = snap_to_line_end(output, keep);
220 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
221 let snipped = output.len().saturating_sub(head.len() + tail.len());
222 format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
223}
224
225fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
229 if max_bytes >= s.len() {
230 return s;
231 }
232 let search_end = s.floor_char_boundary(max_bytes);
233 match s[..search_end].rfind('\n') {
234 Some(pos) => &s[..pos + 1],
235 None => &s[..search_end], }
237}
238
239fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
243 if start_byte == 0 {
244 return s;
245 }
246 let search_start = s.ceil_char_boundary(start_byte);
247 if search_start >= s.len() {
248 return "";
249 }
250 match s[search_start..].find('\n') {
251 Some(pos) => {
252 let line_start = search_start + pos + 1;
253 if line_start < s.len() {
254 &s[line_start..]
255 } else {
256 &s[search_start..]
257 }
258 }
259 None => &s[search_start..], }
261}
262
263fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
264 messages
265 .iter()
266 .map(|msg| {
267 let role = msg
268 .get("role")
269 .and_then(|v| v.as_str())
270 .unwrap_or("user")
271 .to_uppercase();
272 let content = msg
273 .get("content")
274 .and_then(|v| v.as_str())
275 .unwrap_or_default();
276 format!("{role}: {content}")
277 })
278 .collect::<Vec<_>>()
279 .join("\n")
280}
281
282fn truncate_compaction_summary(
283 old_messages: &[serde_json::Value],
284 archived_count: usize,
285) -> String {
286 truncate_compaction_summary_with_context(old_messages, archived_count, false)
287}
288
289fn truncate_compaction_summary_with_context(
290 old_messages: &[serde_json::Value],
291 archived_count: usize,
292 is_llm_fallback: bool,
293) -> String {
294 let per_msg_limit = 500_usize;
295 let summary_parts: Vec<String> = old_messages
296 .iter()
297 .filter_map(|m| {
298 let role = m.get("role")?.as_str()?;
299 let content = m.get("content")?.as_str()?;
300 if content.is_empty() {
301 return None;
302 }
303 let truncated = if content.len() > per_msg_limit {
304 format!(
305 "{}... [truncated from {} chars]",
306 &content[..content.floor_char_boundary(per_msg_limit)],
307 content.len()
308 )
309 } else {
310 content.to_string()
311 };
312 Some(format!("[{role}] {truncated}"))
313 })
314 .take(15)
315 .collect();
316 let header = if is_llm_fallback {
317 format!(
318 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
319 )
320 } else {
321 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
322 };
323 format!(
324 "{header}\n{}{}",
325 summary_parts.join("\n"),
326 if archived_count > 15 {
327 format!("\n... and {} more", archived_count - 15)
328 } else {
329 String::new()
330 }
331 )
332}
333
334fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
335 if let Some(map) = value.as_dict() {
336 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
337 return Ok(summary.display());
338 }
339 }
340 match value {
341 VmValue::String(text) => Ok(text.to_string()),
342 VmValue::Nil => Ok(String::new()),
343 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
344 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
345 }
346}
347
348async fn llm_compaction_summary(
349 old_messages: &[serde_json::Value],
350 archived_count: usize,
351 llm_opts: &crate::llm::api::LlmCallOptions,
352 summarize_prompt: Option<&str>,
353) -> Result<String, VmError> {
354 let mut compact_opts = llm_opts.clone();
355 let formatted = format_compaction_messages(old_messages);
356 compact_opts.system = None;
357 compact_opts.transcript_summary = None;
358 compact_opts.native_tools = None;
359 compact_opts.tool_choice = None;
360 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
361 compact_opts.response_format = None;
362 compact_opts.json_schema = None;
363 compact_opts.output_schema = None;
364 let prompt = render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count)?;
365 compact_opts.messages = vec![serde_json::json!({
366 "role": "user",
367 "content": prompt,
368 })];
369 let result = vm_call_llm_full(&compact_opts).await?;
370 let summary = result.text.trim();
371 if summary.is_empty() {
372 Ok(truncate_compaction_summary_with_context(
373 old_messages,
374 archived_count,
375 true,
376 ))
377 } else {
378 Ok(format!(
379 "[auto-compacted {archived_count} older messages]\n{summary}"
380 ))
381 }
382}
383
384fn render_llm_compaction_prompt(
385 summarize_prompt: Option<&str>,
386 formatted: &str,
387 archived_count: usize,
388) -> Result<String, VmError> {
389 let mut bindings = BTreeMap::new();
390 bindings.insert(
391 "formatted_messages".to_string(),
392 VmValue::String(Rc::from(formatted.to_string())),
393 );
394 bindings.insert(
395 "archived_count".to_string(),
396 VmValue::Int(archived_count as i64),
397 );
398 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
399 return crate::stdlib::template::render_stdlib_prompt_asset(
400 "orchestration/prompts/compaction_summary.harn.prompt",
401 Some(&bindings),
402 );
403 };
404
405 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
406 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
407 crate::stdlib::template::render_asset_result(&asset, Some(&bindings)).map_err(VmError::from)
408}
409
410async fn custom_compaction_summary(
411 old_messages: &[serde_json::Value],
412 archived_count: usize,
413 callback: &VmValue,
414) -> Result<String, VmError> {
415 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
416 return Err(VmError::Runtime(
417 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
418 ));
419 };
420 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
421 VmError::Runtime(
422 "custom transcript compaction requires an async builtin VM context".to_string(),
423 )
424 })?;
425 let messages_vm = VmValue::List(Rc::new(
426 old_messages
427 .iter()
428 .map(crate::stdlib::json_to_vm_value)
429 .collect(),
430 ));
431 let result = vm.call_closure_pub(&closure, &[messages_vm]).await;
432 let summary = compact_summary_text_from_value(&result?)?;
433 if summary.trim().is_empty() {
434 Ok(truncate_compaction_summary(old_messages, archived_count))
435 } else {
436 Ok(format!(
437 "[auto-compacted {archived_count} older messages]\n{summary}"
438 ))
439 }
440}
441
442fn content_should_preserve(content: &str) -> bool {
448 content.len() < 500
449}
450
451fn default_mask_tool_result(role: &str, content: &str) -> String {
453 let first_line = content.lines().next().unwrap_or(content);
454 let line_count = content.lines().count();
455 let char_count = content.len();
456 if line_count <= 3 {
457 format!("[{role}] {content}")
458 } else {
459 let preview = &first_line[..first_line.len().min(120)];
460 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
461 }
462}
463
464#[cfg(test)]
466pub(crate) fn observation_mask_compaction(
467 old_messages: &[serde_json::Value],
468 archived_count: usize,
469) -> String {
470 observation_mask_compaction_with_callback(old_messages, archived_count, None)
471}
472
473fn observation_mask_compaction_with_callback(
474 old_messages: &[serde_json::Value],
475 archived_count: usize,
476 mask_results: Option<&[Option<String>]>,
477) -> String {
478 let mut parts = Vec::new();
479 parts.push(format!(
480 "[auto-compacted {archived_count} older messages via observation masking]"
481 ));
482 for (idx, msg) in old_messages.iter().enumerate() {
483 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
484 let content = msg
485 .get("content")
486 .and_then(|v| v.as_str())
487 .unwrap_or_default();
488 if content.is_empty() {
489 continue;
490 }
491 if role == "assistant" {
492 parts.push(format!("[assistant] {content}"));
493 continue;
494 }
495 if content_should_preserve(content) {
496 parts.push(format!("[{role}] {content}"));
497 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
498 parts.push(custom.clone());
499 } else {
500 parts.push(default_mask_tool_result(role, content));
501 }
502 }
503 parts.join("\n")
504}
505
506async fn invoke_mask_callback(
508 callback: &VmValue,
509 old_messages: &[serde_json::Value],
510) -> Result<Vec<Option<String>>, VmError> {
511 let VmValue::Closure(closure) = callback.clone() else {
512 return Err(VmError::Runtime(
513 "mask_callback must be a closure".to_string(),
514 ));
515 };
516 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
517 VmError::Runtime("mask_callback requires an async builtin VM context".to_string())
518 })?;
519 let messages_vm = VmValue::List(Rc::new(
520 old_messages
521 .iter()
522 .map(crate::stdlib::json_to_vm_value)
523 .collect(),
524 ));
525 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
526 let list = match result {
527 VmValue::List(items) => items,
528 _ => return Ok(vec![None; old_messages.len()]),
529 };
530 Ok(list
531 .iter()
532 .map(|v| match v {
533 VmValue::String(s) => Some(s.to_string()),
534 VmValue::Nil => None,
535 _ => None,
536 })
537 .collect())
538}
539
540async fn apply_compaction_strategy(
542 strategy: &CompactStrategy,
543 old_messages: &[serde_json::Value],
544 archived_count: usize,
545 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
546 custom_compactor: Option<&VmValue>,
547 mask_callback: Option<&VmValue>,
548 summarize_prompt: Option<&str>,
549) -> Result<String, VmError> {
550 match strategy {
551 CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
552 CompactStrategy::Llm => {
553 llm_compaction_summary(
554 old_messages,
555 archived_count,
556 llm_opts.ok_or_else(|| {
557 VmError::Runtime(
558 "LLM transcript compaction requires active LLM call options".to_string(),
559 )
560 })?,
561 summarize_prompt,
562 )
563 .await
564 }
565 CompactStrategy::Custom => {
566 custom_compaction_summary(
567 old_messages,
568 archived_count,
569 custom_compactor.ok_or_else(|| {
570 VmError::Runtime(
571 "compact_callback is required when compact_strategy is 'custom'"
572 .to_string(),
573 )
574 })?,
575 )
576 .await
577 }
578 CompactStrategy::ObservationMask => {
579 let mask_results = if let Some(cb) = mask_callback {
580 Some(invoke_mask_callback(cb, old_messages).await?)
581 } else {
582 None
583 };
584 Ok(observation_mask_compaction_with_callback(
585 old_messages,
586 archived_count,
587 mask_results.as_deref(),
588 ))
589 }
590 }
591}
592
593pub(crate) async fn auto_compact_messages(
595 messages: &mut Vec<serde_json::Value>,
596 config: &AutoCompactConfig,
597 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
598) -> Result<Option<String>, VmError> {
599 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
600 return Ok(None);
601 }
602 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
603 return Ok(None);
604 }
605 let compact_start = config.keep_first.min(messages.len());
606 let original_split = messages.len().saturating_sub(config.keep_last);
607 let mut split_at = original_split;
608 while split_at > compact_start
612 && split_at < messages.len()
613 && messages[split_at]
614 .get("role")
615 .and_then(|r| r.as_str())
616 .is_none_or(|r| r != "user")
617 {
618 split_at -= 1;
619 }
620 if split_at == compact_start {
623 split_at = original_split;
624 }
625 if let Some(volatile_start) = messages[split_at..]
626 .iter()
627 .position(is_reasoning_or_tool_turn_message)
628 .map(|offset| split_at + offset)
629 {
630 if let Some(boundary) = volatile_start
631 .checked_sub(1)
632 .and_then(|idx| find_prev_user_boundary(messages, idx))
633 .filter(|boundary| *boundary > compact_start)
634 {
635 split_at = boundary;
636 }
637 }
638 if split_at <= compact_start {
639 return Ok(None);
640 }
641 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
642 let archived_count = old_messages.len();
643
644 let mut summary = apply_compaction_strategy(
645 &config.compact_strategy,
646 &old_messages,
647 archived_count,
648 llm_opts,
649 config.custom_compactor.as_ref(),
650 config.mask_callback.as_ref(),
651 config.summarize_prompt.as_deref(),
652 )
653 .await?;
654
655 if let Some(hard_limit) = config.hard_limit_tokens {
656 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
657 let mut estimate_msgs = vec![summary_msg];
658 estimate_msgs.extend_from_slice(messages.as_slice());
659 let estimated = estimate_message_tokens(&estimate_msgs);
660 if estimated > hard_limit {
661 let tier1_as_messages = vec![serde_json::json!({
662 "role": "user",
663 "content": summary,
664 })];
665 summary = apply_compaction_strategy(
666 &config.hard_limit_strategy,
667 &tier1_as_messages,
668 archived_count,
669 llm_opts,
670 config.custom_compactor.as_ref(),
671 None,
672 config.summarize_prompt.as_deref(),
673 )
674 .await?;
675 }
676 }
677
678 messages.insert(
679 compact_start,
680 serde_json::json!({
681 "role": "user",
682 "content": summary,
683 }),
684 );
685 Ok(Some(summary))
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn microcompact_short_output_unchanged() {
694 let output = "line1\nline2\nline3\n";
695 assert_eq!(microcompact_tool_output(output, 1000), output);
696 }
697
698 #[test]
699 fn microcompact_snaps_to_line_boundaries() {
700 let lines: Vec<String> = (0..20)
701 .map(|i| format!("line {:02} content here", i))
702 .collect();
703 let output = lines.join("\n");
704 let result = microcompact_tool_output(&output, 200);
705 assert!(result.contains("[... "), "should have snip marker");
706 let parts: Vec<&str> = result.split("\n\n[... ").collect();
707 assert!(parts.len() >= 2, "should split at marker");
708 let head = parts[0];
709 for line in head.lines() {
710 assert!(
711 line.starts_with("line "),
712 "head line should be complete: {line}"
713 );
714 }
715 }
716
717 #[test]
718 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
719 let mut lines = Vec::new();
720 for i in 0..50 {
721 lines.push(format!("verbose output line {i}"));
722 }
723 lines.push("src/main.rs:42: error: cannot find value".to_string());
724 for i in 50..100 {
725 lines.push(format!("verbose output line {i}"));
726 }
727 let output = lines.join("\n");
728 let result = microcompact_tool_output(&output, 600);
729 assert!(result.contains("cannot find value"), "diagnostic preserved");
730 assert!(
731 result.contains("[diagnostic lines preserved]"),
732 "has diagnostic marker"
733 );
734 }
735
736 #[test]
737 fn token_estimate_counts_structured_message_content() {
738 let text = "x".repeat(400);
739 let messages = vec![serde_json::json!({
740 "role": "user",
741 "content": [
742 {"type": "text", "text": text},
743 {"type": "input_text", "text": "tail"},
744 ],
745 "reasoning": {"text": "scratch"},
746 "tool_calls": [{
747 "id": "call_1",
748 "type": "function",
749 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
750 }],
751 })];
752
753 assert!(
754 estimate_message_tokens(&messages) >= 100,
755 "structured content must not count as zero"
756 );
757 }
758
759 #[test]
760 fn snap_to_line_end_finds_newline() {
761 let s = "line1\nline2\nline3\nline4\n";
762 let head = snap_to_line_end(s, 12);
763 assert!(head.ends_with('\n'), "should end at newline");
764 assert!(head.contains("line1"));
765 }
766
767 #[test]
768 fn snap_to_line_start_finds_newline() {
769 let s = "line1\nline2\nline3\nline4\n";
770 let tail = snap_to_line_start(s, 12);
771 assert!(
772 tail.starts_with("line"),
773 "should start at line boundary: {tail}"
774 );
775 }
776
777 #[test]
778 fn auto_compact_preserves_reasoning_tool_suffix() {
779 let mut messages = vec![
780 serde_json::json!({"role": "user", "content": "old task"}),
781 serde_json::json!({"role": "assistant", "content": "old reply"}),
782 serde_json::json!({"role": "user", "content": "new task"}),
783 serde_json::json!({
784 "role": "assistant",
785 "content": "",
786 "reasoning": "think first",
787 "tool_calls": [{
788 "id": "call_1",
789 "type": "function",
790 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
791 }],
792 }),
793 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
794 ];
795 let config = AutoCompactConfig {
796 token_threshold: 1,
797 keep_last: 2,
798 ..Default::default()
799 };
800
801 let runtime = tokio::runtime::Builder::new_current_thread()
802 .enable_all()
803 .build()
804 .expect("runtime");
805 let summary = runtime
806 .block_on(auto_compact_messages(&mut messages, &config, None))
807 .expect("compaction succeeds");
808
809 assert!(summary.is_some());
810 assert_eq!(messages[1]["role"], "user");
811 assert_eq!(messages[2]["role"], "assistant");
812 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
813 assert_eq!(messages[3]["role"], "tool");
814 assert_eq!(messages[3]["tool_call_id"], "call_1");
815 }
816}