1use crate::message::{Message, MessagePart, MessageRole};
2
3pub const KEEP_RECENT_MESSAGES: usize = 10;
4pub const KEEP_RECENT_USER_TURNS: usize = 5;
5const KEEP_RECENT_TOKEN_FRACTION: f64 = 0.05;
6const COMPACTION_SAFETY_MARGIN_MIN: u64 = 2_000;
7const COMPACTION_SAFETY_MARGIN_MAX_RATIO: f64 = 0.05;
8
9#[derive(Debug, Clone, Copy, Default)]
10pub struct CompactionBudgetContext {
11 pub fixed_input_tokens: Option<u64>,
12}
13
14impl CompactionBudgetContext {
15 pub fn estimated_input_tokens(self, message_tokens: u64) -> u64 {
16 message_tokens.saturating_add(self.fixed_input_tokens.unwrap_or(0))
17 }
18
19 pub fn history_budget(self, info: &crate::model_registry::ModelInfo) -> Option<u64> {
20 let fixed_input_tokens = self.fixed_input_tokens?;
21 let output_cap = (info.context_budget as f64 * 0.20) as u64;
22 let output_floor = 8_000_u64.min(output_cap);
23 let output_reserve = (info.max_output_tokens.unwrap_or(32_000) as u64)
24 .max(output_floor)
25 .min(output_cap);
26 let safety_cap = (info.context_budget as f64 * COMPACTION_SAFETY_MARGIN_MAX_RATIO) as u64;
27 let safety_margin = ((info.context_budget as f64 * 0.02) as u64)
28 .max(COMPACTION_SAFETY_MARGIN_MIN.min(safety_cap))
29 .min(safety_cap);
30 Some(
31 info.context_budget
32 .saturating_sub(output_reserve)
33 .saturating_sub(safety_margin)
34 .saturating_sub(fixed_input_tokens),
35 )
36 }
37}
38
39pub fn estimate_tokens_for_message(msg: &Message) -> u64 {
40 let mut chars = 0usize;
41 let mut fixed_tokens = 0u64;
42 for part in &msg.parts {
43 chars += match part {
44 MessagePart::FinalAnswerSummary { .. } => 0,
45 MessagePart::ContextRecord(record) => record.render_for_model().len(),
46 MessagePart::CompactSummary { summary, .. } => summary.len(),
47 MessagePart::Text { text } => text.len(),
48 MessagePart::Thinking { thinking, .. } => thinking.len(),
49 MessagePart::ToolResult { content, .. } => content.len(),
50 MessagePart::Image { source } => {
51 fixed_tokens = fixed_tokens.saturating_add(match source.detail {
52 crate::provider::ImageDetail::Low => 85,
53 crate::provider::ImageDetail::Auto => 1_024,
54 crate::provider::ImageDetail::High => 1_536,
55 crate::provider::ImageDetail::Original => 2_048,
56 });
57 0
58 }
59 MessagePart::ToolUse {
60 name,
61 input,
62 intent,
63 ..
64 } => {
65 name.len()
66 + input.to_string().len()
67 + intent.as_ref().map_or(0, |intent| {
68 crate::message::TOOL_CALL_INTENT_FIELD.len() + intent.as_str().len() + 5
69 })
70 }
71 };
72 }
73 chars = chars.saturating_add(estimate_role_overhead(msg.role));
74 (chars as f64 / 3.5).ceil() as u64 + fixed_tokens
75}
76
77fn estimate_role_overhead(role: MessageRole) -> usize {
78 match role {
79 MessageRole::System => 12,
80 MessageRole::User => 8,
81 MessageRole::Assistant => 8,
82 MessageRole::Tool => 16,
83 }
84}
85
86pub fn estimate_tokens_for_messages(messages: &[Message]) -> u64 {
87 messages.iter().map(estimate_tokens_for_message).sum()
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CompactRange {
92 pub start: usize,
93 pub end: usize,
94 pub tokens_saved_estimate: u64,
95}
96
97pub fn is_plan_related(msg: &Message) -> bool {
98 for part in &msg.parts {
99 match part {
100 MessagePart::ToolUse { name, .. } if name.starts_with("plan.") => return true,
101 MessagePart::ToolResult { content, .. } if content.starts_with("# Plan:") => {
102 return true;
103 }
104 _ => {}
105 }
106 }
107 false
108}
109
110pub fn is_compaction_summary(msg: &Message) -> bool {
111 if !matches!(msg.role, MessageRole::System) {
112 return false;
113 }
114 msg.parts
115 .iter()
116 .any(|part| matches!(part, MessagePart::CompactSummary { .. }))
117}
118
119fn find_kth_recent_user(messages: &[Message], k: usize) -> usize {
120 let mut user_count = 0;
121 for (index, message) in messages.iter().enumerate().rev() {
122 if message.role == MessageRole::User {
123 user_count += 1;
124 if user_count == k {
125 return index;
126 }
127 }
128 }
129 0
130}
131
132fn align_compact_end_to_tool_transactions(messages: &[Message], end: usize) -> usize {
133 let mut tool_use_messages = std::collections::HashMap::new();
134 for (index, message) in messages.iter().take(end).enumerate() {
135 for part in &message.parts {
136 if let MessagePart::ToolUse { id, .. } = part {
137 tool_use_messages.entry(id.as_str()).or_insert(index);
138 }
139 }
140 }
141
142 let mut aligned_end = end;
143 for message in messages.iter().skip(end) {
144 for part in &message.parts {
145 if let MessagePart::ToolResult { tool_use_id, .. } = part
146 && let Some(&use_index) = tool_use_messages.get(tool_use_id.as_str())
147 {
148 aligned_end = aligned_end.min(use_index);
149 }
150 }
151 }
152 aligned_end
153}
154
155pub fn find_compact_range(messages: &[Message], budget: u64) -> Option<CompactRange> {
156 let total = estimate_tokens_for_messages(messages);
157 if total <= budget || messages.len() < 4 {
158 return None;
159 }
160
161 let start = messages
162 .iter()
163 .rposition(is_compaction_summary)
164 .unwrap_or(0);
165 let keep_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
166 let mut recent_tokens = 0u64;
167 let mut token_end = messages.len();
168 for (index, message) in messages.iter().enumerate().rev() {
169 recent_tokens = recent_tokens.saturating_add(estimate_tokens_for_message(message));
170 token_end = index;
171 if recent_tokens >= keep_recent_tokens {
172 break;
173 }
174 }
175 let message_end = messages.len().saturating_sub(KEEP_RECENT_MESSAGES);
176 let end = message_end
177 .min(token_end)
178 .min(find_kth_recent_user(messages, KEEP_RECENT_USER_TURNS));
179 let end = align_compact_end_to_tool_transactions(messages, end);
180 if end < start + 2 {
181 return None;
182 }
183
184 let tokens_saved_estimate = messages[start..end]
185 .iter()
186 .map(estimate_tokens_for_message)
187 .sum();
188 Some(CompactRange {
189 start,
190 end,
191 tokens_saved_estimate,
192 })
193}
194
195pub fn estimate_compacted_message_tokens(
196 messages: &[Message],
197 range: &CompactRange,
198 summary: &str,
199) -> u64 {
200 let turn_id = messages
201 .get(range.start)
202 .map(|m| m.turn_id.clone())
203 .unwrap_or_else(crate::event::TurnId::now);
204 let after = replace_range_with_summary(messages, range, summary.to_string(), turn_id);
205 estimate_tokens_for_messages(&after)
206}
207
208pub fn filter_orphan_tool_messages(messages: &mut Vec<Message>) {
209 crate::message::retain_complete_tool_pairs(messages);
210}
211
212pub fn find_compact_summaries(messages: &[Message]) -> Vec<CompactSummary> {
213 let mut out = Vec::new();
214 for (idx, msg) in messages.iter().enumerate() {
215 if let Some(summary) = compact_summary(msg) {
216 out.push(CompactSummary {
217 message_index: idx,
218 seq_start: summary.seq_start,
219 seq_end: summary.seq_end,
220 count: summary.count,
221 });
222 }
223 }
224 out
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct CompactSummary {
229 pub message_index: usize,
230 pub seq_start: u64,
231 pub seq_end: u64,
232 pub count: usize,
233}
234
235struct CompactSummaryPart {
236 seq_start: u64,
237 seq_end: u64,
238 count: usize,
239}
240
241fn extract_anchor(messages: &[Message]) -> Option<(String, &[Message])> {
242 let first = messages.first()?;
243 let summary = first.parts.iter().find_map(|part| match part {
244 MessagePart::CompactSummary { summary, .. } => Some(summary.clone()),
245 _ => None,
246 })?;
247 Some((summary, &messages[1..]))
248}
249
250fn compact_summary(msg: &Message) -> Option<CompactSummaryPart> {
251 if msg.role != MessageRole::System {
252 return None;
253 }
254 msg.parts.iter().find_map(|part| match part {
255 MessagePart::CompactSummary {
256 seq_start,
257 seq_end,
258 count,
259 ..
260 } => Some(CompactSummaryPart {
261 seq_start: *seq_start,
262 seq_end: *seq_end,
263 count: *count,
264 }),
265 _ => None,
266 })
267}
268
269pub async fn maybe_auto_compact(
270 session: &crate::session::Session,
271 model: &str,
272 providers: &crate::provider::ProviderRegistry,
273) {
274 maybe_auto_compact_with_budget(
275 session,
276 model,
277 providers,
278 CompactionBudgetContext::default(),
279 )
280 .await;
281}
282
283pub async fn maybe_auto_compact_with_budget(
284 session: &crate::session::Session,
285 model: &str,
286 providers: &crate::provider::ProviderRegistry,
287 budget_context: CompactionBudgetContext,
288) {
289 let _compact_guard = session.acquire_compact_lock().await;
290 maybe_auto_compact_locked(session, model, providers, budget_context).await;
291}
292
293pub fn spawn_auto_compact(
294 session: std::sync::Arc<crate::session::Session>,
295 model: String,
296 providers: crate::provider::ProviderRegistry,
297) {
298 tokio::task::spawn_blocking(move || {
299 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
300 .enable_all()
301 .build()
302 else {
303 session.push_system_note("compaction skipped: background runtime init failed".into());
304 return;
305 };
306 rt.block_on(async move {
307 maybe_auto_compact(&session, &model, &providers).await;
308 });
309 });
310}
311
312pub async fn start_auto_compact(
313 session: std::sync::Arc<crate::session::Session>,
314 model: String,
315 providers: crate::provider::ProviderRegistry,
316) {
317 start_auto_compact_with_budget(
318 session,
319 model,
320 providers,
321 CompactionBudgetContext::default(),
322 )
323 .await;
324}
325
326pub async fn start_auto_compact_with_budget(
327 session: std::sync::Arc<crate::session::Session>,
328 model: String,
329 providers: crate::provider::ProviderRegistry,
330 budget_context: CompactionBudgetContext,
331) {
332 let compact_guard = session.acquire_compact_lock_owned().await;
333 tokio::task::spawn_blocking(move || {
334 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
335 .enable_all()
336 .build()
337 else {
338 drop(compact_guard);
339 session.push_system_note("compaction skipped: background runtime init failed".into());
340 return;
341 };
342 rt.block_on(async move {
343 maybe_auto_compact_locked(&session, &model, &providers, budget_context).await;
344 drop(compact_guard);
345 });
346 });
347}
348
349async fn maybe_auto_compact_locked(
350 session: &crate::session::Session,
351 model: &str,
352 providers: &crate::provider::ProviderRegistry,
353 budget_context: CompactionBudgetContext,
354) {
355 let forced = session.take_manual_compact_request();
356 let info = crate::model_registry::model_info(model);
357 let trigger = info.compaction_trigger_threshold();
358 let target = budget_context
359 .history_budget(&info)
360 .map(|budget| budget.min(info.compaction_target_after()))
361 .unwrap_or_else(|| info.compaction_target_after());
362 let msgs = session.messages();
363 let window_tokens = estimate_tokens_for_messages(&msgs);
364 let estimate = budget_context.estimated_input_tokens(window_tokens);
365 let current = providers.resolve(model).map_or(estimate, |provider| {
366 session.calibrated_context_input_estimate(provider.name(), model, estimate)
367 });
368 if !forced && current <= trigger {
369 return;
370 }
371 if !forced && !session.approval_cooldown_ok_for_compact() {
372 return;
373 }
374 let Some(range) = find_compact_range(&msgs, target) else {
375 let (replacement, rewritten_count) =
376 build_budgeted_turn_rewrite(&msgs, target, model, providers).await;
377 let after_tokens = estimate_tokens_for_messages(&replacement);
378 if rewritten_count == 0 || after_tokens >= window_tokens || after_tokens > target {
379 session.emit_compact_warning(
380 model,
381 current,
382 trigger,
383 info.context_budget,
384 "no compactible span — retained user content cannot fit the history budget",
385 );
386 return;
387 }
388 match session.commit_rewritten_window(
389 replacement,
390 window_tokens,
391 window_tokens,
392 rewritten_count,
393 ) {
394 Some(_) => {}
395 None => {
396 session.emit_compact_warning(
397 model,
398 current,
399 trigger,
400 info.context_budget,
401 "retained turn output rewrite did not shrink the transcript",
402 );
403 }
404 }
405 return;
406 };
407 let _ = session
408 .stream_tx()
409 .send(crate::stream::StreamFrame::CompactionSummary {
410 phase: crate::stream::CompactionPhase::Running,
411 range_start: range.start,
412 range_end: range.end.saturating_sub(1),
413 summary: String::new(),
414 before_tokens: current,
415 after_tokens: 0,
416 compacted_count: range.end - range.start,
417 });
418 let send_failed = |session: &crate::session::Session, reason: &str| {
419 let _ = session
420 .stream_tx()
421 .send(crate::stream::StreamFrame::CompactionSummary {
422 phase: crate::stream::CompactionPhase::Failed,
423 range_start: range.start,
424 range_end: range.end.saturating_sub(1),
425 summary: reason.to_string(),
426 before_tokens: current,
427 after_tokens: current,
428 compacted_count: range.end - range.start,
429 });
430 };
431 let mut filtered: Vec<Message> = msgs[range.start..range.end].to_vec();
432 filter_orphan_tool_messages(&mut filtered);
433 let (anchor, new_messages) = extract_anchor(&filtered)
434 .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
435 .unwrap_or_else(|| (None, filtered.clone()));
436 let range_start = range.start;
437 let range_end = range.end.saturating_sub(1);
438 let stream_tx = session.stream_tx();
439 let on_delta: std::sync::Arc<dyn Fn(String) + Send + Sync> = std::sync::Arc::new(move |text| {
440 let _ = stream_tx.send(crate::stream::StreamFrame::CompactionDelta {
441 range_start,
442 range_end,
443 text,
444 });
445 });
446 let summary = match generate_llm_summary_with_delta(
447 anchor.as_deref(),
448 &new_messages,
449 model,
450 providers,
451 Some(on_delta),
452 )
453 .await
454 {
455 Ok(text) => text,
456 Err(err) => {
457 send_failed(session, &format!("LLM summary failed: {err}"));
458 session.emit_compact_warning(
459 model,
460 current,
461 trigger,
462 info.context_budget,
463 &format!("LLM summary failed: {err}; keeping full transcript"),
464 );
465 return;
466 }
467 };
468 let final_summary =
469 match request_review_if_enabled(session, forced, &filtered, &range, current, summary).await
470 {
471 ReviewOutcome::Commit(s) => s,
472 ReviewOutcome::Rejected => {
473 send_failed(
474 session,
475 "compaction rejected by user; keeping full transcript",
476 );
477 session.push_system_note(
478 "compaction rejected by user; keeping full transcript".into(),
479 );
480 return;
481 }
482 };
483 let replacement =
484 build_budgeted_replacement(&msgs, &range, &final_summary, target, model, providers).await;
485 let after_tokens = estimate_tokens_for_messages(&replacement);
486 if after_tokens >= window_tokens {
487 send_failed(
488 session,
489 &format!(
490 "compaction skipped: replacement would not shrink transcript ({} >= {} tokens)",
491 after_tokens, window_tokens
492 ),
493 );
494 session.push_system_note(format!(
495 "compaction skipped: replacement would not shrink transcript ({} >= {} tokens)",
496 after_tokens, window_tokens
497 ));
498 return;
499 }
500 match session.commit_compacted_window(
501 final_summary,
502 replacement,
503 range,
504 window_tokens,
505 window_tokens,
506 ) {
507 Some(result) => {
508 session.push_system_note(format!(
509 "auto-compacted {}..{} — {} → {} tokens",
510 result.compacted_start,
511 result.compacted_end,
512 result.before_tokens,
513 result.after_tokens
514 ));
515 }
516 None => {
517 session.emit_compact_warning(
518 model,
519 current,
520 trigger,
521 info.context_budget,
522 "no compactible span — history too short or already fully compacted",
523 );
524 }
525 }
526}
527
528enum ReviewOutcome {
529 Commit(String),
530 Rejected,
531}
532
533async fn request_review_if_enabled(
534 session: &crate::session::Session,
535 forced: bool,
536 slice: &[Message],
537 range: &CompactRange,
538 tokens_before: u64,
539 summary: String,
540) -> ReviewOutcome {
541 if !session.compact_review_mode().should_review(forced) {
542 return ReviewOutcome::Commit(summary);
543 }
544 let reviews = session.compact_reviews();
545 if reviews.subscriber_count() == 0 {
546 return ReviewOutcome::Commit(summary);
547 }
548 let pending = crate::session::PendingCompactReview {
549 review_id: uuid::Uuid::now_v7().to_string(),
550 summary: summary.clone(),
551 slice_preview: format_slice_for_preview(slice),
552 slice_count: slice.len(),
553 range_start: range.start,
554 range_end: range.end,
555 tokens_before,
556 emitted_at: chrono::Utc::now(),
557 };
558 let rx = reviews.request(pending);
559 match rx.await {
560 Ok(crate::session::CompactReviewDecision::AcceptAsIs) => ReviewOutcome::Commit(summary),
561 Ok(crate::session::CompactReviewDecision::AcceptEdited { summary: edited }) => {
562 ReviewOutcome::Commit(edited)
563 }
564 Ok(crate::session::CompactReviewDecision::Reject) | Err(_) => ReviewOutcome::Rejected,
565 }
566}
567
568fn format_slice_for_preview(slice: &[Message]) -> String {
569 let mut out = String::new();
570 for (i, msg) in slice.iter().enumerate() {
571 let role = msg.role.as_str();
572 let body = serialize_message_for_summary(msg);
573 let truncated: String = body.chars().take(400).collect();
574 out.push_str(&format!("[{i}] {role}: {truncated}\n"));
575 }
576 out.chars().take(16_000).collect()
577}
578
579const SUMMARY_SYSTEM_PROMPT: &str =
580 "You are an anchored context summarization assistant for coding sessions.";
581
582const SUMMARY_INSTRUCTIONS: &str = r#"You are an anchored context summarization assistant.
583
584Below is:
5851. <current-anchor>: the existing handoff state, which is authoritative and must be preserved.
5862. <new-messages>: only the messages that arrived since the anchor was written.
587
588Merge the NEW facts from <new-messages> INTO the current anchor, producing an upgraded full anchor.
589
590STRUCTURAL RULES (data model, not optional style):
591- ## Objective: unchanged unless the new messages show the user explicitly redirected.
592- ### Completed: ONLY ADD newly completed items. Never remove or re-evaluate an existing completed item. If a completed item is now in question, add it to ### Active or ### Blocked instead. NEVER delete from Completed.
593- ### Active: update based on new messages; move newly-done items to Completed.
594- ### Blocked: update based on new messages; remove resolved ones.
595- ## Decisions: only add new decisions. Never remove old ones.
596- ## Next Move: replace based on current end state.
597- Keep every section, even when empty.
598- Preserve exact file paths, symbols, commands, error strings, identifiers.
599
600Output exactly this Markdown structure:
601## Objective
602## Important Details
603## Work State
604### Completed
605### Active
606### Blocked
607## Decisions
608## Next Move
609## Relevant Files
610
611Do not mention the summary process or that context was compacted.
612Respond in the same language as the conversation."#;
613
614async fn generate_llm_summary(
615 anchor: Option<&str>,
616 slice: &[Message],
617 model: &str,
618 providers: &crate::provider::ProviderRegistry,
619) -> Result<String, crate::error::RuntimeError> {
620 generate_llm_summary_with_delta(anchor, slice, model, providers, None).await
621}
622
623async fn generate_llm_summary_with_delta(
624 anchor: Option<&str>,
625 slice: &[Message],
626 model: &str,
627 providers: &crate::provider::ProviderRegistry,
628 on_delta: Option<std::sync::Arc<dyn Fn(String) + Send + Sync>>,
629) -> Result<String, crate::error::RuntimeError> {
630 let provider = providers.resolve(model).ok_or_else(|| {
631 crate::error::RuntimeError::ToolFailed(format!("no provider for {model}"))
632 })?;
633 let payload = format_slice_for_summary(slice);
634 let (messages, dump_user) = if let Some(anchor) = anchor {
635 let anchor_user = format!("<current-anchor>\n{anchor}\n</current-anchor>");
636 let new_user =
637 format!("<new-messages>\n{payload}\n</new-messages>\n\n{SUMMARY_INSTRUCTIONS}");
638 (
639 vec![
640 Message::user_text(crate::event::TurnId::now(), anchor_user.clone()),
641 Message::user_text(crate::event::TurnId::now(), new_user.clone()),
642 ],
643 format!("{anchor_user}\n\n{new_user}"),
644 )
645 } else {
646 let user = format!(
647 "<conversation_history>\n{payload}\n</conversation_history>\n\n{SUMMARY_INSTRUCTIONS}"
648 );
649 (
650 vec![Message::user_text(
651 crate::event::TurnId::now(),
652 user.clone(),
653 )],
654 user,
655 )
656 };
657 if let Ok(dir) = std::env::var("ATMAN_COMPACT_DUMP") {
658 let _ = std::fs::write(
659 format!("{dir}/compact_request.txt"),
660 format!("=== SYSTEM ===\n{SUMMARY_SYSTEM_PROMPT}\n\n=== USER ===\n{dump_user}"),
661 );
662 }
663 let req = crate::provider::LlmRequest {
664 model: crate::model_registry::api_model_id(model),
665 messages,
666 system: Some(SUMMARY_SYSTEM_PROMPT.into()),
667 input: crate::value::Value::Unit,
668 schema: None,
669 cache_prompt: false,
670 prompt_cache_key: None,
671 tools: Vec::new(),
672 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
673 stall_timeout_secs: 0,
674 };
675 let outcome = if let Some(on_delta) = on_delta {
676 let observable = provider.call_streaming(req);
677 let mut events = observable.events;
678 let mut output = observable.output;
679 let outcome = loop {
680 tokio::select! {
681 event = events.recv() => match event {
682 Ok(crate::event::NodeEvent::LlmChunk { text, .. }) => {
683 on_delta(text);
684 }
685 Ok(_) => {}
686 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
687 Err(tokio::sync::broadcast::error::RecvError::Closed) => break output.await?,
688 },
689 result = &mut output => break result?,
690 }
691 };
692 while let Ok(event) = events.try_recv() {
693 if let crate::event::NodeEvent::LlmChunk { text, .. } = event {
694 on_delta(text);
695 }
696 }
697 outcome
698 } else {
699 provider.call(req).await?
700 };
701 let text = outcome.text_concat();
702 if text.trim().is_empty() {
703 return Err(crate::error::RuntimeError::ToolFailed(
704 "empty summary from provider".into(),
705 ));
706 }
707 Ok(text)
708}
709
710async fn build_budgeted_turn_rewrite(
711 messages: &[Message],
712 history_budget: u64,
713 model: &str,
714 providers: &crate::provider::ProviderRegistry,
715) -> (Vec<Message>, usize) {
716 let mut replacement = messages.to_vec();
717 let mut group_index = 0;
718 let mut rewritten_count = 0;
719 while estimate_tokens_for_messages(&replacement) > history_budget {
720 let groups = user_turn_ranges(&replacement);
721 let Some((start, end)) = groups.get(group_index).copied() else {
722 break;
723 };
724 let output = replacement[start + 1..end].to_vec();
725 if output.is_empty() {
726 group_index += 1;
727 continue;
728 }
729 let output_tokens = estimate_tokens_for_messages(&output);
730 let summary = generate_llm_summary(None, &output, model, providers)
731 .await
732 .unwrap_or_else(|_| deterministic_turn_omission(&output));
733 let mut summary_message = Message::assistant_text(
734 replacement[start].turn_id.clone(),
735 format!("[atman: compacted turn output]\n{summary}\n[/atman: compacted turn output]"),
736 );
737 if estimate_tokens_for_message(&summary_message) >= output_tokens {
738 summary_message = Message::assistant_text(
739 replacement[start].turn_id.clone(),
740 deterministic_turn_omission(&output),
741 );
742 }
743 rewritten_count += output.len();
744 replacement.splice(start + 1..end, [summary_message]);
745 group_index += 1;
746 }
747 filter_orphan_tool_messages(&mut replacement);
748 (replacement, rewritten_count)
749}
750
751async fn build_budgeted_replacement(
752 messages: &[Message],
753 range: &CompactRange,
754 anchor_summary: &str,
755 history_budget: u64,
756 model: &str,
757 providers: &crate::provider::ProviderRegistry,
758) -> Vec<Message> {
759 let turn_id = messages
760 .get(range.start)
761 .map(|message| message.turn_id.clone())
762 .unwrap_or_else(crate::event::TurnId::now);
763 let mut replacement =
764 replace_range_with_summary(messages, range, anchor_summary.to_string(), turn_id);
765 filter_orphan_tool_messages(&mut replacement);
766 if estimate_tokens_for_messages(&replacement) <= history_budget {
767 return replacement;
768 }
769
770 let mut group_index = 0;
771 loop {
772 let groups = user_turn_ranges(&replacement);
773 if group_index >= groups.len()
774 || estimate_tokens_for_messages(&replacement) <= history_budget
775 {
776 break;
777 }
778 let (start, end) = groups[group_index];
779 let output: Vec<Message> = replacement[start + 1..end].to_vec();
780 if output.is_empty() {
781 group_index += 1;
782 continue;
783 }
784 let output_tokens = estimate_tokens_for_messages(&output);
785 let summary = generate_llm_summary(None, &output, model, providers)
786 .await
787 .unwrap_or_else(|_| deterministic_turn_omission(&output));
788 let mut summary_message = Message::assistant_text(
789 replacement[start].turn_id.clone(),
790 format!("[atman: compacted turn output]\n{summary}\n[/atman: compacted turn output]"),
791 );
792 if estimate_tokens_for_message(&summary_message) >= output_tokens {
793 summary_message = Message::assistant_text(
794 replacement[start].turn_id.clone(),
795 deterministic_turn_omission(&output),
796 );
797 }
798 replacement.splice(start + 1..end, [summary_message]);
799 group_index += 1;
800 }
801
802 if estimate_tokens_for_messages(&replacement) > history_budget {
803 let groups = user_turn_ranges(&replacement);
804 for (start, end) in groups.into_iter().rev() {
805 let output = replacement[start + 1..end].to_vec();
806 if !output.is_empty() {
807 replacement.splice(
808 start + 1..end,
809 [Message::assistant_text(
810 replacement[start].turn_id.clone(),
811 deterministic_turn_omission(&output),
812 )],
813 );
814 }
815 }
816 }
817
818 if estimate_tokens_for_messages(&replacement) > history_budget {
819 replacement = compaction_floor(&replacement);
820 }
821
822 filter_orphan_tool_messages(&mut replacement);
823 replacement
824}
825
826fn user_turn_ranges(messages: &[Message]) -> Vec<(usize, usize)> {
827 let starts: Vec<usize> = messages
828 .iter()
829 .enumerate()
830 .filter_map(|(index, message)| (message.role == MessageRole::User).then_some(index))
831 .collect();
832 starts
833 .iter()
834 .enumerate()
835 .map(|(index, start)| {
836 (
837 *start,
838 starts.get(index + 1).copied().unwrap_or(messages.len()),
839 )
840 })
841 .collect()
842}
843
844fn deterministic_turn_omission(messages: &[Message]) -> String {
845 format!(
846 "[atman: omitted {} oversized assistant/system/tool messages during persistent compaction]",
847 messages.len()
848 )
849}
850
851fn compaction_floor(messages: &[Message]) -> Vec<Message> {
852 let mut out = Vec::new();
853 if let Some(anchor) = messages
854 .iter()
855 .find(|message| is_compaction_summary(message))
856 {
857 out.push(anchor.clone());
858 }
859
860 let mut latest =
861 std::collections::HashMap::<&str, (&crate::context_plan::ContextRecord, &Message)>::new();
862 for message in messages {
863 for part in &message.parts {
864 if let MessagePart::ContextRecord(record) = part {
865 latest
866 .entry(record.key())
867 .and_modify(|(current, source)| {
868 if record.revision() >= current.revision() {
869 *current = record;
870 *source = message;
871 }
872 })
873 .or_insert((record, message));
874 }
875 }
876 }
877 let mut records: Vec<_> = latest.into_values().collect();
878 records.sort_by(|(left, _), (right, _)| left.key().cmp(right.key()));
879 out.extend(
880 records.into_iter().map(|(record, source)| {
881 Message::context_record(source.turn_id.clone(), record.clone())
882 }),
883 );
884
885 out.extend(messages.iter().filter_map(|message| {
886 if message.role != MessageRole::User {
887 return None;
888 }
889 let mut user = message.clone();
890 user.parts.retain(|part| {
891 !matches!(
892 part,
893 MessagePart::CompactSummary { .. } | MessagePart::ContextRecord(_)
894 )
895 });
896 (!user.parts.is_empty()).then_some(user)
897 }));
898 out
899}
900
901fn format_slice_for_summary(slice: &[Message]) -> String {
902 let mut out = String::new();
903 for (i, msg) in slice.iter().enumerate() {
904 let role = msg.role.as_str();
905 let body = serialize_message_for_summary(msg);
906 let truncated: String = body.chars().take(4000).collect();
907 out.push_str(&format!("[{i}] {role}: {truncated}\n\n"));
908 }
909 out.chars().take(120_000).collect()
910}
911
912fn serialize_message_for_summary(msg: &Message) -> String {
913 let mut parts = Vec::new();
914 for part in &msg.parts {
915 match part {
916 MessagePart::FinalAnswerSummary { .. } => {}
917 MessagePart::ContextRecord(record) => {
918 if record.retention() == crate::context_plan::ContextRecordRetention::Timeline {
919 parts.push(record.render_for_model());
920 }
921 }
922 MessagePart::CompactSummary { summary, .. } => {
923 parts.push(summary.clone());
924 }
925 MessagePart::Text { text } => {
926 parts.push(text.clone());
927 }
928 MessagePart::Thinking { thinking, .. } => {
929 let truncated: String = thinking.chars().take(1000).collect();
930 parts.push(format!("[thinking: {truncated}]"));
931 }
932 MessagePart::ToolUse {
933 name,
934 input,
935 intent,
936 ..
937 } => {
938 let input_str = if input.is_null() {
939 String::new()
940 } else {
941 input.to_string()
942 };
943 let truncated: String = input_str.chars().take(2000).collect();
944 let purpose = intent
945 .as_ref()
946 .map(|intent| format!(" purpose={}", intent.as_str()))
947 .unwrap_or_default();
948 parts.push(format!("[tool_call: {name}{purpose}({truncated})]"));
949 }
950 MessagePart::ToolResult {
951 content,
952 is_error,
953 tool_use_id,
954 } => {
955 let truncated: String = content.chars().take(3000).collect();
956 let marker = if *is_error { "ERROR" } else { "ok" };
957 let id_short: String = tool_use_id.chars().take(12).collect();
958 parts.push(format!("[tool_result {id_short}… {marker}: {truncated}]"));
959 }
960 MessagePart::Image { .. } => {
961 parts.push("[image]".into());
962 }
963 }
964 }
965 parts.join(" ")
966}
967
968pub fn replace_range_with_summary(
969 messages: &[Message],
970 range: &CompactRange,
971 summary: String,
972 turn_id: crate::event::TurnId,
973) -> Vec<Message> {
974 let retained_records = latest_context_records_before(messages, range.end);
975 let mut out =
976 Vec::with_capacity(1 + retained_records.len() + messages.len().saturating_sub(range.end));
977 out.push(Message::system_compact_summary(
978 turn_id,
979 summary,
980 range.start as u64,
981 range.end.saturating_sub(1) as u64,
982 range.end - range.start,
983 ));
984 out.extend(retained_records);
985 out.extend_from_slice(&messages[range.end..]);
986 out
987}
988
989fn latest_context_records_before(messages: &[Message], end: usize) -> Vec<Message> {
990 let suffix_keys: std::collections::HashSet<&str> = messages[end..]
991 .iter()
992 .flat_map(|message| &message.parts)
993 .filter_map(|part| match part {
994 MessagePart::ContextRecord(record) => Some(record.key()),
995 _ => None,
996 })
997 .collect();
998 let mut latest = std::collections::HashMap::<&str, (usize, &Message, &MessagePart)>::new();
999 for (index, message) in messages[..end].iter().enumerate() {
1000 for part in &message.parts {
1001 if let MessagePart::ContextRecord(record) = part
1002 && record.retention() == crate::context_plan::ContextRecordRetention::Latest
1003 && !suffix_keys.contains(record.key())
1004 {
1005 latest.insert(record.key(), (index, message, part));
1006 }
1007 }
1008 }
1009 let mut retained: Vec<_> = latest.into_values().collect();
1010 retained.sort_by_key(|(index, _, _)| *index);
1011 retained
1012 .into_iter()
1013 .map(|(_, message, part)| Message {
1014 role: MessageRole::System,
1015 parts: vec![part.clone()],
1016 turn_id: message.turn_id.clone(),
1017 origin: crate::message::MessageOrigin::Internal,
1018 })
1019 .collect()
1020}
1021
1022#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct HandleCompactResult {
1025 pub before_tokens: u64,
1026 pub after_tokens: u64,
1027 pub compacted_start: usize,
1028 pub compacted_end: usize,
1029}
1030
1031#[derive(Debug, Clone, PartialEq)]
1034pub struct HandleAutoCompactResult {
1035 pub before_tokens: u64,
1036 pub after_tokens: u64,
1037 pub compacted_start: usize,
1038 pub compacted_end: usize,
1039 pub compacted_count: usize,
1040 pub summary: String,
1041 pub checkpoint_messages: Vec<Message>,
1042}
1043
1044pub async fn maybe_auto_compact_handle_locked(
1048 handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
1049 model: &str,
1050 providers: &crate::provider::ProviderRegistry,
1051 budget_context: CompactionBudgetContext,
1052 forced: bool,
1053) -> Option<HandleAutoCompactResult> {
1054 let snapshot = handle.lock().unwrap().clone();
1055 let info = crate::model_registry::model_info(model);
1056 let trigger = info.compaction_trigger_threshold();
1057 let target = budget_context
1058 .history_budget(&info)
1059 .map(|budget| budget.min(info.compaction_target_after()))
1060 .unwrap_or_else(|| info.compaction_target_after());
1061 let before_tokens = estimate_tokens_for_messages(&snapshot);
1062 let current = budget_context.estimated_input_tokens(before_tokens);
1063 if !forced && current <= trigger {
1064 return None;
1065 }
1066
1067 let (replacement, summary, compacted_start, compacted_end, compacted_count, must_fit_target) =
1068 if let Some(range) = find_compact_range(&snapshot, target) {
1069 let mut filtered = snapshot[range.start..range.end].to_vec();
1070 filter_orphan_tool_messages(&mut filtered);
1071 let (anchor, new_messages) = extract_anchor(&filtered)
1072 .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
1073 .unwrap_or_else(|| (None, filtered));
1074 let summary = generate_llm_summary(anchor.as_deref(), &new_messages, model, providers)
1075 .await
1076 .ok()?;
1077 let replacement =
1078 build_budgeted_replacement(&snapshot, &range, &summary, target, model, providers)
1079 .await;
1080 let compacted_end = range.end.saturating_sub(1);
1081 let compacted_count = range.end - range.start;
1082 (
1083 replacement,
1084 summary,
1085 range.start,
1086 compacted_end,
1087 compacted_count,
1088 false,
1089 )
1090 } else {
1091 let (replacement, rewritten_count) =
1092 build_budgeted_turn_rewrite(&snapshot, target, model, providers).await;
1093 if rewritten_count == 0 {
1094 return None;
1095 }
1096 (
1097 replacement,
1098 format!(
1099 "[atman: persistently compacted output from {rewritten_count} retained messages]"
1100 ),
1101 0,
1102 0,
1103 rewritten_count,
1104 true,
1105 )
1106 };
1107 let after_tokens = estimate_tokens_for_messages(&replacement);
1108 if after_tokens >= before_tokens || (must_fit_target && after_tokens > target) {
1109 return None;
1110 }
1111
1112 let mut messages = handle.lock().unwrap();
1113 if *messages != snapshot {
1114 return None;
1115 }
1116 *messages = replacement.clone();
1117 Some(HandleAutoCompactResult {
1118 before_tokens,
1119 after_tokens,
1120 compacted_start,
1121 compacted_end,
1122 compacted_count,
1123 summary,
1124 checkpoint_messages: replacement,
1125 })
1126}
1127
1128pub fn compact_messages_on_handle(
1132 handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
1133 summary: String,
1134 budget: u64,
1135) -> Option<HandleCompactResult> {
1136 let mut msgs = handle.lock().unwrap();
1137 let before_tokens = estimate_tokens_for_messages(&msgs);
1138 let range = find_compact_range(&msgs, budget)?;
1139 let turn_id = msgs
1140 .get(range.start)
1141 .map(|m| m.turn_id.clone())
1142 .unwrap_or_else(crate::event::TurnId::now);
1143 let after = replace_range_with_summary(&msgs, &range, summary, turn_id);
1144 let after_tokens = estimate_tokens_for_messages(&after);
1145 if after_tokens >= before_tokens {
1146 return None;
1147 }
1148 let result = HandleCompactResult {
1149 before_tokens,
1150 after_tokens,
1151 compacted_start: range.start,
1152 compacted_end: range.end.saturating_sub(1),
1153 };
1154 *msgs = after;
1155 Some(result)
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use crate::event::TurnId;
1162 use crate::message::MessageOrigin;
1163
1164 fn user(text: &str) -> Message {
1165 Message::user_text(TurnId::now(), text)
1166 }
1167 fn assistant(text: &str) -> Message {
1168 Message::assistant_text(TurnId::now(), text)
1169 }
1170 fn system(text: &str) -> Message {
1171 Message::system_text(TurnId::now(), text)
1172 }
1173
1174 fn context_record(key: &str, revision: u64, text: &str) -> Message {
1175 Message::context_record(
1176 TurnId::now(),
1177 crate::context_plan::ContextRecord::new(
1178 key,
1179 revision,
1180 crate::context_plan::ContextRecordAuthority::Runtime,
1181 crate::context_plan::ContextRecordRetention::Latest,
1182 crate::context_plan::ContextRecordBody::text(text),
1183 ),
1184 )
1185 }
1186
1187 fn context_tombstone(key: &str, revision: u64) -> Message {
1188 Message::context_record(
1189 TurnId::now(),
1190 crate::context_plan::ContextRecord::new(
1191 key,
1192 revision,
1193 crate::context_plan::ContextRecordAuthority::Runtime,
1194 crate::context_plan::ContextRecordRetention::Latest,
1195 crate::context_plan::ContextRecordBody::tombstone(),
1196 ),
1197 )
1198 }
1199
1200 #[test]
1201 fn replacement_keeps_only_the_latest_live_record_per_key() {
1202 let messages = vec![
1203 user("old"),
1204 context_record("session.goal", 1, "first"),
1205 context_record("session.goal", 2, "second"),
1206 assistant("old answer"),
1207 user("current"),
1208 ];
1209 let replacement = replace_range_with_summary(
1210 &messages,
1211 &CompactRange {
1212 start: 0,
1213 end: 4,
1214 tokens_saved_estimate: 1,
1215 },
1216 "summary".into(),
1217 TurnId::now(),
1218 );
1219
1220 assert_eq!(replacement.len(), 3);
1221 assert!(is_compaction_summary(&replacement[0]));
1222 assert!(matches!(
1223 replacement[1].parts.as_slice(),
1224 [MessagePart::ContextRecord(record)]
1225 if record.key() == "session.goal" && record.revision() == 2
1226 ));
1227 assert_eq!(replacement[2].text_concat(), "current");
1228 }
1229
1230 #[test]
1231 fn compaction_budget_reserves_output_safety_and_fixed_input_only_at_compact_time() {
1232 let info = crate::model_registry::ModelInfo {
1233 name: "test".into(),
1234 context_budget: 100_000,
1235 compact_threshold_ratio: 0.8,
1236 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1237 capabilities: crate::provider::ModelCapabilities::default(),
1238 image_detail: crate::provider::ImageDetail::Auto,
1239 max_output_tokens: Some(10_000),
1240 };
1241 let budget = CompactionBudgetContext {
1242 fixed_input_tokens: Some(5_000),
1243 }
1244 .history_budget(&info);
1245 assert_eq!(budget, Some(100_000 - 10_000 - 2_000 - 5_000));
1246 }
1247
1248 #[test]
1249 fn compaction_budget_saturates_when_fixed_input_exceeds_context() {
1250 let info = crate::model_registry::ModelInfo {
1251 name: "test".into(),
1252 context_budget: 20_000,
1253 compact_threshold_ratio: 0.8,
1254 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1255 capabilities: crate::provider::ModelCapabilities::default(),
1256 image_detail: crate::provider::ImageDetail::Auto,
1257 max_output_tokens: None,
1258 };
1259 assert_eq!(
1260 CompactionBudgetContext {
1261 fixed_input_tokens: Some(100_000)
1262 }
1263 .history_budget(&info),
1264 Some(0)
1265 );
1266 }
1267
1268 #[test]
1269 fn compaction_preflight_estimate_uses_current_messages_and_fixed_prefix() {
1270 let budget = CompactionBudgetContext {
1271 fixed_input_tokens: Some(7_000),
1272 };
1273 assert_eq!(budget.estimated_input_tokens(11_000), 18_000);
1274 assert_eq!(
1275 CompactionBudgetContext::default().estimated_input_tokens(11_000),
1276 11_000
1277 );
1278 }
1279
1280 #[tokio::test]
1281 async fn budgeted_replacement_groups_by_user_boundary_and_persists_omission() {
1282 let first_turn = TurnId::now();
1283 let second_turn = TurnId::now();
1284 let mut messages = vec![system(&"old".repeat(20_000)), assistant("old answer")];
1285 messages.push(Message::user_text(first_turn.clone(), "first user"));
1286 messages.push(Message::assistant_text(
1287 second_turn.clone(),
1288 "first output".repeat(4_000),
1289 ));
1290 messages.push(assistant_with_tool_use(
1291 "calling tool",
1292 "fs.read",
1293 serde_json::json!({"path": "/tmp/example"}),
1294 ));
1295 messages.push(tool_result(
1296 "call_test",
1297 &"tool output".repeat(4_000),
1298 false,
1299 ));
1300 messages.push(Message::user_text(second_turn.clone(), "current user"));
1301 messages.push(Message::assistant_text(
1302 first_turn,
1303 "current output".repeat(4_000),
1304 ));
1305 let range = CompactRange {
1306 start: 0,
1307 end: 2,
1308 tokens_saved_estimate: 1,
1309 };
1310
1311 let replacement = build_budgeted_replacement(
1312 &messages,
1313 &range,
1314 "anchor",
1315 500,
1316 "missing-provider",
1317 &crate::provider::ProviderRegistry::default(),
1318 )
1319 .await;
1320
1321 let texts: Vec<String> = replacement.iter().map(Message::text_concat).collect();
1322 assert!(texts.iter().any(|text| text == "current user"));
1323 assert!(
1324 texts
1325 .iter()
1326 .any(|text| text.contains("omitted 3 oversized"))
1327 );
1328 assert!(!replacement.iter().any(|message| {
1329 message.parts.iter().any(|part| {
1330 matches!(
1331 part,
1332 MessagePart::ToolUse { .. } | MessagePart::ToolResult { .. }
1333 )
1334 })
1335 }));
1336 assert_eq!(user_turn_ranges(&replacement).len(), 2);
1337 }
1338
1339 #[tokio::test]
1340 async fn budgeted_replacement_floor_keeps_anchor_records_and_user_inputs() {
1341 let messages = vec![
1342 system("old system"),
1343 context_record("session.goal", 1, "old goal"),
1344 context_record("session.goal", 2, "current goal"),
1345 context_tombstone("session.workspace", 3),
1346 user("first user"),
1347 assistant("first output"),
1348 user("current user"),
1349 assistant("current output"),
1350 ];
1351 let replacement = build_budgeted_replacement(
1352 &messages,
1353 &CompactRange {
1354 start: 0,
1355 end: 4,
1356 tokens_saved_estimate: 1,
1357 },
1358 "anchor",
1359 1,
1360 "missing-provider",
1361 &crate::provider::ProviderRegistry::default(),
1362 )
1363 .await;
1364
1365 assert!(is_compaction_summary(&replacement[0]));
1366 let records: Vec<_> = replacement
1367 .iter()
1368 .flat_map(|message| &message.parts)
1369 .filter_map(|part| match part {
1370 MessagePart::ContextRecord(record) => Some(record),
1371 _ => None,
1372 })
1373 .collect();
1374 assert_eq!(records.len(), 2);
1375 assert_eq!(records[0].key(), "session.goal");
1376 assert_eq!(records[0].revision(), 2);
1377 assert_eq!(records[1].key(), "session.workspace");
1378 assert!(records[1].body().is_tombstone());
1379 assert_eq!(
1380 replacement
1381 .iter()
1382 .filter(|message| message.role == MessageRole::User)
1383 .map(Message::text_concat)
1384 .collect::<Vec<_>>(),
1385 ["first user", "current user"]
1386 );
1387 assert!(
1388 !replacement
1389 .iter()
1390 .any(|message| message.role == MessageRole::Assistant)
1391 );
1392 }
1393
1394 #[tokio::test]
1395 async fn turn_rewrite_compacts_oversized_tool_output_without_dropping_recent_users() {
1396 let first = TurnId::now();
1397 let current = TurnId::now();
1398 let messages = vec![
1399 Message::user_text(first, "first user"),
1400 assistant_with_tool_use(
1401 &"calling tool".repeat(2_000),
1402 "fs.read",
1403 serde_json::json!({"path": "/tmp/example"}),
1404 ),
1405 tool_result("call_test", &"tool output".repeat(8_000), false),
1406 Message::user_text(current, "current user"),
1407 ];
1408
1409 assert!(find_compact_range(&messages, 500).is_none());
1410 let (replacement, rewritten_count) = build_budgeted_turn_rewrite(
1411 &messages,
1412 500,
1413 "missing-provider",
1414 &crate::provider::ProviderRegistry::default(),
1415 )
1416 .await;
1417
1418 assert_eq!(rewritten_count, 2);
1419 assert!(estimate_tokens_for_messages(&replacement) <= 500);
1420 let users: Vec<String> = replacement
1421 .iter()
1422 .filter(|message| message.role == MessageRole::User)
1423 .map(Message::text_concat)
1424 .collect();
1425 assert_eq!(users, vec!["first user", "current user"]);
1426 assert!(replacement.iter().any(|message| {
1427 message
1428 .text_concat()
1429 .contains("omitted 2 oversized assistant/system/tool messages")
1430 }));
1431 assert!(!replacement.iter().any(|message| {
1432 message.parts.iter().any(|part| {
1433 matches!(
1434 part,
1435 MessagePart::ToolUse { .. } | MessagePart::ToolResult { .. }
1436 )
1437 })
1438 }));
1439 }
1440
1441 #[test]
1442 fn user_turn_ranges_ignore_misanchored_turn_ids() {
1443 let first = TurnId::now();
1444 let second = TurnId::now();
1445 let messages = vec![
1446 Message::user_text(first.clone(), "u1"),
1447 Message::assistant_text(second.clone(), "a1"),
1448 Message::user_text(second, "u2"),
1449 Message::assistant_text(first, "a2"),
1450 ];
1451 assert_eq!(user_turn_ranges(&messages), vec![(0, 2), (2, 4)]);
1452 }
1453
1454 #[test]
1455 fn summary_instructions_keep_decisions_before_next_move() {
1456 let objective = SUMMARY_INSTRUCTIONS
1457 .find("## Objective")
1458 .expect("objective");
1459 let decisions = SUMMARY_INSTRUCTIONS
1460 .find("## Decisions")
1461 .expect("decisions");
1462 let next_move = SUMMARY_INSTRUCTIONS
1463 .find("## Next Move")
1464 .expect("next move");
1465 assert!(objective < decisions);
1466 assert!(decisions < next_move);
1467 }
1468
1469 #[test]
1470 fn estimate_scales_with_char_length() {
1471 let short = user("hi");
1472 let long = user(&"x".repeat(3500));
1473 assert!(estimate_tokens_for_message(&long) > estimate_tokens_for_message(&short) * 100);
1474 }
1475
1476 #[test]
1477 fn find_compact_returns_none_when_under_budget() {
1478 let msgs = vec![user("a"), assistant("b"), user("c"), assistant("d")];
1479 assert!(find_compact_range(&msgs, 1000).is_none());
1480 }
1481
1482 #[test]
1483 fn find_compact_returns_none_for_short_history() {
1484 let msgs = vec![user(&"x".repeat(9000))];
1485 assert!(find_compact_range(&msgs, 100).is_none());
1486 }
1487
1488 #[test]
1489 fn find_kth_recent_user_handles_exact_excess_and_mixed_history() {
1490 let exact = vec![
1491 user("u0"),
1492 assistant("a0"),
1493 system("s0"),
1494 tool_result("call-0", "result", false),
1495 user("u1"),
1496 assistant("a1"),
1497 user("u2"),
1498 system("s1"),
1499 user("u3"),
1500 assistant("a3"),
1501 user("u4"),
1502 ];
1503 assert_eq!(find_kth_recent_user(&exact, KEEP_RECENT_USER_TURNS), 0);
1505
1506 let mut excess = exact.clone();
1507 excess.push(user("u5"));
1508 assert_eq!(find_kth_recent_user(&excess, KEEP_RECENT_USER_TURNS), 4);
1509
1510 let too_few = vec![user("a"), assistant("b"), assistant("c")];
1511 assert_eq!(find_kth_recent_user(&too_few, KEEP_RECENT_USER_TURNS), 0);
1512 }
1513
1514 #[test]
1515 fn find_compact_range_preserves_minimum_recent_messages_without_anchor() {
1516 let mut msgs = vec![system("head")];
1517 msgs.extend((0..25).map(|index| assistant(&format!("old {index}"))));
1518 msgs.extend(
1519 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1520 );
1521 let range = find_compact_range(&msgs, 1).expect("range");
1522 assert_eq!(range.start, 0);
1523 assert_eq!(range.end, msgs.len() - KEEP_RECENT_MESSAGES);
1524 }
1525
1526 #[test]
1527 fn find_compact_range_keeps_tool_use_with_later_result() {
1528 let mut msgs = (0..11)
1529 .map(|index| assistant(&format!("old {index}")))
1530 .collect::<Vec<_>>();
1531 msgs.push(assistant_with_tool_use(
1532 "calling tool",
1533 "fs.read",
1534 serde_json::json!({"path": "/tmp/example"}),
1535 ));
1536 msgs.push(tool_result("call_test", "result", false));
1537 msgs.extend((0..9).map(|index| {
1538 if index % 2 == 0 {
1539 user(&format!("recent user {index}"))
1540 } else {
1541 assistant(&format!("recent assistant {index}"))
1542 }
1543 }));
1544
1545 let range = find_compact_range(&msgs, 1).expect("range");
1546 assert_eq!(range.end, 11);
1547 assert!(matches!(
1548 msgs[range.end].parts.as_slice(),
1549 [MessagePart::Text { .. }, MessagePart::ToolUse { id, .. }] if id == "call_test"
1550 ));
1551 }
1552
1553 #[test]
1554 fn find_compact_range_keeps_parallel_tool_batch_together() {
1555 let mut msgs = (0..10)
1556 .map(|index| assistant(&format!("old {index}")))
1557 .collect::<Vec<_>>();
1558 msgs.push(assistant_with_tool_uses(&["call_a", "call_b"]));
1559 msgs.push(tool_result("call_a", "first result", false));
1560 msgs.push(tool_result("call_b", "second result", false));
1561 msgs.extend((0..9).map(|index| {
1562 if index % 2 == 0 {
1563 user(&format!("recent user {index}"))
1564 } else {
1565 assistant(&format!("recent assistant {index}"))
1566 }
1567 }));
1568
1569 let range = find_compact_range(&msgs, 1).expect("range");
1570 assert_eq!(range.end, 10);
1571 assert_eq!(
1572 msgs[range.end]
1573 .parts
1574 .iter()
1575 .filter(|part| matches!(part, MessagePart::ToolUse { .. }))
1576 .count(),
1577 2
1578 );
1579 }
1580
1581 #[test]
1582 fn find_compact_range_keeps_boundary_after_closed_tool_batch() {
1583 let mut msgs = (0..9)
1584 .map(|index| assistant(&format!("old {index}")))
1585 .collect::<Vec<_>>();
1586 msgs.push(assistant_with_tool_uses(&["call_a", "call_b"]));
1587 msgs.push(Message {
1588 role: MessageRole::Tool,
1589 parts: vec![
1590 MessagePart::ToolResult {
1591 tool_use_id: "call_a".into(),
1592 content: "first result".into(),
1593 is_error: false,
1594 },
1595 MessagePart::ToolResult {
1596 tool_use_id: "call_b".into(),
1597 content: "second result".into(),
1598 is_error: false,
1599 },
1600 ],
1601 turn_id: TurnId::now(),
1602 origin: MessageOrigin::User,
1603 });
1604 msgs.push(assistant("batch complete"));
1605 msgs.extend((0..10).map(|index| {
1606 if index % 2 == 0 {
1607 user(&format!("recent user {index}"))
1608 } else {
1609 assistant(&format!("recent assistant {index}"))
1610 }
1611 }));
1612
1613 let range = find_compact_range(&msgs, 1).expect("range");
1614 assert_eq!(range.end, 12);
1615 assert_eq!(msgs[range.end].role, MessageRole::User);
1616 }
1617
1618 #[test]
1619 fn find_compact_range_preserves_minimum_window_for_large_tool_result() {
1620 let mut msgs = vec![user(&"h".repeat(500_000))];
1621 for index in 1..31 {
1622 if matches!(index, 20 | 22 | 24 | 26 | 28 | 30) {
1623 msgs.push(user(&"u".repeat(800)));
1624 } else {
1625 msgs.push(assistant(&"a".repeat(800)));
1626 }
1627 }
1628 msgs.push(tool_result("call-large", &"t".repeat(22_000), false));
1629
1630 let budget = 120_000;
1631 let minimum_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
1632 assert!(estimate_tokens_for_message(msgs.last().unwrap()) > minimum_recent_tokens);
1633
1634 let range = find_compact_range(&msgs, budget).expect("range");
1635 assert!(
1636 range.end <= msgs.len() - KEEP_RECENT_MESSAGES,
1637 "range was {range:?}"
1638 );
1639 assert!(msgs.len() - range.end >= KEEP_RECENT_MESSAGES);
1640 assert!(estimate_tokens_for_messages(&msgs[range.end..]) >= minimum_recent_tokens);
1641 }
1642
1643 #[test]
1644 fn find_compact_range_handles_four_to_twenty_one_message_histories() {
1645 for len in 4..=21 {
1646 let msgs = (0..len)
1647 .map(|_| user(&"x".repeat(5000)))
1648 .collect::<Vec<_>>();
1649 assert_eq!(
1650 find_compact_range(&msgs, 1).is_some(),
1651 len >= KEEP_RECENT_MESSAGES + 2,
1652 "len={len}"
1653 );
1654 }
1655 }
1656
1657 #[test]
1658 fn find_compact_range_recent_users_limit_mixed_history() {
1659 let msgs = vec![
1660 system("head"),
1661 assistant("a0"),
1662 user("u0"),
1663 tool_result("call-0", "r0", false),
1664 assistant("a1"),
1665 user("u1"),
1666 assistant("a2"),
1667 tool_result("call-1", "r1", false),
1668 user("u2"),
1669 assistant("a3"),
1670 system("note"),
1671 user("u3"),
1672 tool_result("call-2", "r2", false),
1673 assistant("a4"),
1674 user("u4"),
1675 assistant("a5"),
1676 tool_result("call-3", "r3", false),
1677 user("u5"),
1678 assistant("a6"),
1679 system("tail"),
1680 assistant("a7"),
1681 ];
1682
1683 let range = find_compact_range(&msgs, 1).expect("range");
1684 assert_eq!(range.end, 5);
1685 assert_eq!(msgs[range.end].role, MessageRole::User);
1686 }
1687
1688 #[test]
1689 fn find_compact_range_preserves_recent_user_turns_after_anchor() {
1690 let mut msgs = vec![system("head"), compaction_summary("summary")];
1691 msgs.extend((0..6).flat_map(|index| {
1692 [
1693 user(&format!("user {index}")),
1694 assistant("assistant"),
1695 assistant("tool fragment"),
1696 ]
1697 }));
1698 msgs.extend((0..12).map(|_| assistant("recent fragment")));
1699 let range = find_compact_range(&msgs, 1).expect("range");
1700 assert_eq!(range.start, 1);
1701 assert_eq!(range.end, 5);
1702 assert_eq!(msgs[range.end].role, MessageRole::User);
1703 }
1704
1705 #[test]
1706 fn find_compact_range_returns_none_when_end_cannot_cover_two_messages() {
1707 let msgs = vec![
1708 system("head"),
1709 compaction_summary("summary"),
1710 assistant("tail"),
1711 user("tail"),
1712 ];
1713 assert!(find_compact_range(&msgs, 1).is_none());
1714 }
1715
1716 #[test]
1717 fn extract_anchor_removes_leading_compact_summary() {
1718 let messages = vec![compaction_summary("anchor"), user("new")];
1719 let (anchor, remaining) = extract_anchor(&messages).expect("anchor");
1720 assert_eq!(anchor, "anchor");
1721 assert_eq!(remaining, &messages[1..]);
1722 }
1723
1724 #[test]
1725 fn extract_anchor_returns_none_without_leading_summary() {
1726 let messages = vec![user("new")];
1727 assert!(extract_anchor(&messages).is_none());
1728 }
1729
1730 #[test]
1731 fn compact_messages_on_handle_replaces_range_in_place() {
1732 let mut messages = vec![system("head")];
1733 messages.extend((0..9).map(|index| assistant(&format!("old {index}"))));
1734 messages.extend(
1735 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(4000))]),
1736 );
1737 messages.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1738 messages.push(user("tail"));
1739 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1740 std::sync::Arc::new(std::sync::Mutex::new(messages));
1741 let result = compact_messages_on_handle(&handle, "gist".into(), 100);
1744 let result = result.expect("should compact");
1745 assert!(result.after_tokens < result.before_tokens);
1746 let msgs = handle.lock().unwrap();
1747 assert_eq!(result.compacted_start, 0);
1748 assert_eq!(result.compacted_end, 13);
1749 assert!(is_compaction_summary(&msgs[0]));
1750 assert_eq!(msgs.last().unwrap().text_concat(), "tail");
1751 }
1752
1753 #[test]
1754 fn compact_messages_on_handle_none_when_under_budget() {
1755 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1756 std::sync::Arc::new(std::sync::Mutex::new(vec![user("short")]));
1757 assert!(compact_messages_on_handle(&handle, "g".into(), 100_000).is_none());
1758 }
1759
1760 #[test]
1761 fn compact_messages_on_handle_none_when_summary_would_not_shrink() {
1762 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1768 std::sync::Arc::new(std::sync::Mutex::new(vec![
1769 system("h"),
1770 user("."),
1771 assistant("."),
1772 user("."),
1773 assistant("."),
1774 user("t"),
1775 ]));
1776 let result = compact_messages_on_handle(&handle, "x".into(), 1);
1778 let before_len = handle.lock().unwrap().len();
1781 if result.is_none() {
1782 assert_eq!(handle.lock().unwrap().len(), before_len);
1783 }
1784 }
1785
1786 #[test]
1787 fn replace_range_puts_summary_system_message_in_place() {
1788 let msgs = vec![
1789 system("head"),
1790 user("m1"),
1791 assistant("m2"),
1792 user("m3"),
1793 assistant("m4"),
1794 user("tail"),
1795 ];
1796 let range = CompactRange {
1797 start: 1,
1798 end: 5,
1799 tokens_saved_estimate: 100,
1800 };
1801 let out = replace_range_with_summary(
1802 &msgs,
1803 &range,
1804 "gist: talked about m1..m4".into(),
1805 TurnId::now(),
1806 );
1807 assert_eq!(out.len(), 2, "summary + tail");
1808 assert_eq!(out[0].role, MessageRole::System);
1809 assert!(out[0].text_concat().contains("gist: talked about"));
1810 assert!(matches!(
1811 out[0].parts.as_slice(),
1812 [MessagePart::CompactSummary {
1813 seq_start: 1,
1814 seq_end: 4,
1815 count: 4,
1816 ..
1817 }]
1818 ));
1819 assert_eq!(out[1].role, MessageRole::User);
1820 assert_eq!(out[1].text_concat(), "tail");
1821 }
1822
1823 #[test]
1824 fn find_compact_range_anchors_on_latest_structured_summary() {
1825 let mut msgs = vec![
1826 system("head"),
1827 Message::system_compact_summary(TurnId::now(), "old", 0, 1, 2),
1828 ];
1829 msgs.extend(
1830 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1831 );
1832 msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
1833 let range = find_compact_range(&msgs, 1).expect("range");
1834 assert_eq!(range.start, 1);
1835 assert_eq!(range.end, 4);
1836 }
1837
1838 fn assistant_with_tool_use(text: &str, tool_name: &str, input: serde_json::Value) -> Message {
1839 Message {
1840 role: MessageRole::Assistant,
1841 parts: vec![
1842 MessagePart::Text { text: text.into() },
1843 MessagePart::ToolUse {
1844 id: "call_test".into(),
1845 name: tool_name.into(),
1846 input,
1847 intent: None,
1848 },
1849 ],
1850 turn_id: TurnId::now(),
1851 origin: MessageOrigin::User,
1852 }
1853 }
1854
1855 fn assistant_with_tool_uses(ids: &[&str]) -> Message {
1856 Message {
1857 role: MessageRole::Assistant,
1858 parts: ids
1859 .iter()
1860 .map(|id| MessagePart::ToolUse {
1861 id: (*id).into(),
1862 name: "fs.read".into(),
1863 input: serde_json::json!({"path": format!("/tmp/{id}")}),
1864 intent: None,
1865 })
1866 .collect(),
1867 turn_id: TurnId::now(),
1868 origin: MessageOrigin::User,
1869 }
1870 }
1871
1872 fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
1873 Message {
1874 role: MessageRole::Tool,
1875 parts: vec![MessagePart::ToolResult {
1876 tool_use_id: id.into(),
1877 content: content.into(),
1878 is_error,
1879 }],
1880 turn_id: TurnId::now(),
1881 origin: MessageOrigin::User,
1882 }
1883 }
1884
1885 fn thinking(text: &str) -> Message {
1886 Message {
1887 role: MessageRole::Assistant,
1888 parts: vec![
1889 MessagePart::Thinking {
1890 thinking: text.into(),
1891 signature: None,
1892 },
1893 MessagePart::Text {
1894 text: "after thinking".into(),
1895 },
1896 ],
1897 turn_id: TurnId::now(),
1898 origin: MessageOrigin::User,
1899 }
1900 }
1901
1902 #[test]
1903 fn format_slice_for_summary_includes_tool_use() {
1904 let slice = vec![
1905 user("read the file"),
1906 assistant_with_tool_use(
1907 "let me check",
1908 "fs.read",
1909 serde_json::json!({"path": "/tmp/foo.rs"}),
1910 ),
1911 tool_result("call_test", "fn main() {}", false),
1912 ];
1913 let out = format_slice_for_summary(&slice);
1914 assert!(out.contains("fs.read"), "missing tool name: {out}");
1915 assert!(out.contains("/tmp/foo.rs"), "missing tool input: {out}");
1916 assert!(
1917 out.contains("fn main()"),
1918 "missing tool_result content: {out}"
1919 );
1920 assert!(out.contains("tool_call"), "missing tool_call marker: {out}");
1921 assert!(
1922 out.contains("tool_result"),
1923 "missing tool_result marker: {out}"
1924 );
1925 }
1926
1927 #[test]
1928 fn format_slice_for_summary_includes_thinking() {
1929 let slice = vec![thinking("I should consider the edge case")];
1930 let out = format_slice_for_summary(&slice);
1931 assert!(out.contains("thinking"), "missing thinking marker: {out}");
1932 assert!(out.contains("edge case"), "missing thinking content: {out}");
1933 }
1934
1935 #[test]
1936 fn format_slice_for_summary_marks_error_tool_results() {
1937 let slice = vec![tool_result("call_1", "permission denied", true)];
1938 let out = format_slice_for_summary(&slice);
1939 assert!(out.contains("ERROR"), "missing ERROR marker: {out}");
1940 }
1941
1942 #[test]
1943 fn format_slice_for_summary_truncates_long_tool_input() {
1944 let long_input = serde_json::json!({"content": "x".repeat(5000)});
1945 let slice = vec![assistant_with_tool_use("check", "fs.write", long_input)];
1946 let out = format_slice_for_summary(&slice);
1947 let tool_call_line = out
1948 .lines()
1949 .find(|l| l.contains("tool_call"))
1950 .unwrap_or_else(|| panic!("no tool_call line in {out}"));
1951 assert!(
1952 tool_call_line.chars().count() < 2200,
1953 "tool_call line not truncated: {tool_call_line}"
1954 );
1955 }
1956
1957 fn compaction_summary(text: &str) -> Message {
1958 Message::system_compact_summary(TurnId::now(), text, 1, 5, 5)
1959 }
1960
1961 #[test]
1962 fn is_compaction_summary_detects_structured_variant() {
1963 assert!(is_compaction_summary(&compaction_summary("gist")));
1964 assert!(!is_compaction_summary(&system("plain system msg")));
1965 assert!(!is_compaction_summary(&user("user msg")));
1966 }
1967
1968 #[test]
1969 fn find_compact_range_spans_across_compaction_summaries() {
1970 let mut msgs = vec![
1971 system("head"),
1972 user(&"x".repeat(3000)),
1973 assistant(&"y".repeat(3000)),
1974 user(&"z".repeat(3000)),
1975 compaction_summary("first compaction summary"),
1976 ];
1977 msgs.extend(
1978 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(3000))]),
1979 );
1980 msgs.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1981 let range = find_compact_range(&msgs, 500).expect("expected range across summary");
1982 assert_eq!(
1983 range.start, 4,
1984 "range should anchor at the structured summary"
1985 );
1986 assert!(
1987 range.end > 4,
1988 "range should include later work, got {range:?}"
1989 );
1990 assert!(
1991 range.end - range.start >= 3,
1992 "range must cover >= 3 msgs, got {}",
1993 range.end - range.start
1994 );
1995 }
1996
1997 #[test]
1998 fn find_compact_starts_from_summary() {
1999 let mut msgs = vec![user("a"), assistant("b"), compaction_summary("summary 1")];
2000 msgs.extend(
2001 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
2002 );
2003 msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
2004 let range = find_compact_range(&msgs, 10).expect("expected range");
2005 assert_eq!(
2006 range.start, 2,
2007 "should start from the compact summary anchor"
2008 );
2009 assert_eq!(range.end, 5, "the fifth recent user is retained");
2010 }
2011
2012 #[test]
2013 fn find_compact_range_includes_older_compaction_summaries() {
2014 let mut msgs = vec![compaction_summary("summary 0")];
2015 msgs.extend((0..6).flat_map(|index| {
2016 [
2017 user(&format!("old user {index}")),
2018 assistant(&"x".repeat(2000)),
2019 ]
2020 }));
2021 msgs.push(compaction_summary("summary 1"));
2022 msgs.extend((0..6).flat_map(|index| {
2023 [
2024 user(&format!("new user {index}")),
2025 assistant(&"z".repeat(2000)),
2026 ]
2027 }));
2028 msgs.extend((0..10).map(|index| assistant(&format!("tail {index}"))));
2029 let range = find_compact_range(&msgs, 500).expect("expected range");
2030 assert_eq!(range.start, 13, "should compact from the latest summary");
2031 assert!(
2032 range.end > range.start,
2033 "should include work after the latest summary"
2034 );
2035 }
2036
2037 #[test]
2038 fn compacted_message_tokens_detects_growth() {
2039 let msgs = vec![compaction_summary("summary 0"), user("a"), assistant("b")];
2040 let range = CompactRange {
2041 start: 1,
2042 end: 3,
2043 tokens_saved_estimate: 0,
2044 };
2045 let before = estimate_tokens_for_messages(&msgs);
2046 let after = estimate_compacted_message_tokens(
2047 &msgs,
2048 &range,
2049 "a very long summary that expands the transcript a lot",
2050 );
2051 assert!(after > before, "expected growth to be detectable");
2052 }
2053
2054 #[test]
2055 fn find_compact_starts_from_zero_without_summary() {
2056 let mut msgs = (0..26)
2057 .map(|index| assistant(&format!("old {index}")))
2058 .collect::<Vec<_>>();
2059 msgs.extend(
2060 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
2061 );
2062 let range = find_compact_range(&msgs, 10).expect("expected range");
2063 assert_eq!(range.start, 0, "should start from 0 without summary");
2064 assert_eq!(range.end, 28, "the recent-message limit is retained");
2065 }
2066
2067 #[test]
2068 fn filter_orphan_tool_messages_removes_orphan_results() {
2069 use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2070 let turn = TurnId::now();
2071 let msgs = vec![
2072 Message {
2073 role: MessageRole::Tool,
2074 parts: vec![MessagePart::ToolResult {
2075 tool_use_id: "orphan".into(),
2076 content: "no matching use".into(),
2077 is_error: false,
2078 }],
2079 turn_id: turn.clone(),
2080 origin: MessageOrigin::User,
2081 },
2082 Message {
2083 role: MessageRole::Assistant,
2084 parts: vec![MessagePart::ToolUse {
2085 id: "call_1".into(),
2086 name: "fs.read".into(),
2087 input: serde_json::json!({}),
2088 intent: None,
2089 }],
2090 turn_id: turn.clone(),
2091 origin: MessageOrigin::User,
2092 },
2093 Message {
2094 role: MessageRole::Tool,
2095 parts: vec![MessagePart::ToolResult {
2096 tool_use_id: "call_1".into(),
2097 content: "ok".into(),
2098 is_error: false,
2099 }],
2100 turn_id: turn,
2101 origin: MessageOrigin::User,
2102 },
2103 ];
2104 let mut filtered = msgs;
2105 filter_orphan_tool_messages(&mut filtered);
2106 assert_eq!(filtered.len(), 2, "orphan result should be removed");
2107 }
2108
2109 #[test]
2110 fn filter_orphan_tool_parts_preserves_valid_mixed_message_content() {
2111 use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2112 let turn = TurnId::now();
2113 let mut messages = vec![
2114 Message {
2115 role: MessageRole::Assistant,
2116 parts: vec![
2117 MessagePart::Text {
2118 text: "keep assistant text".into(),
2119 },
2120 MessagePart::ToolUse {
2121 id: "valid".into(),
2122 name: "fs.read".into(),
2123 input: serde_json::json!({}),
2124 intent: None,
2125 },
2126 MessagePart::ToolUse {
2127 id: "orphan-use".into(),
2128 name: "fs.read".into(),
2129 input: serde_json::json!({}),
2130 intent: None,
2131 },
2132 ],
2133 turn_id: turn.clone(),
2134 origin: MessageOrigin::User,
2135 },
2136 Message {
2137 role: MessageRole::Tool,
2138 parts: vec![
2139 MessagePart::Text {
2140 text: "keep tool text".into(),
2141 },
2142 MessagePart::ToolResult {
2143 tool_use_id: "valid".into(),
2144 content: "ok".into(),
2145 is_error: false,
2146 },
2147 MessagePart::ToolResult {
2148 tool_use_id: "orphan-result".into(),
2149 content: "drop".into(),
2150 is_error: false,
2151 },
2152 ],
2153 turn_id: turn,
2154 origin: MessageOrigin::User,
2155 },
2156 ];
2157
2158 filter_orphan_tool_messages(&mut messages);
2159
2160 assert_eq!(messages.len(), 2);
2161 assert!(
2162 matches!(&messages[0].parts[..], [MessagePart::Text { .. }, MessagePart::ToolUse { id, .. }] if id == "valid")
2163 );
2164 assert!(
2165 matches!(&messages[1].parts[..], [MessagePart::Text { .. }, MessagePart::ToolResult { tool_use_id, .. }] if tool_use_id == "valid")
2166 );
2167 }
2168}