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;
6
7pub fn estimate_tokens_for_message(msg: &Message) -> u64 {
8 let mut chars = 0usize;
9 for part in &msg.parts {
10 chars += match part {
11 MessagePart::CompactSummary { summary, .. } => summary.len(),
12 MessagePart::Text { text } => text.len(),
13 MessagePart::Thinking { thinking, .. } => thinking.len(),
14 MessagePart::ToolResult { content, .. } => content.len(),
15 MessagePart::Image { .. } => 512,
16 MessagePart::ToolUse { name, input, .. } => name.len() + input.to_string().len(),
17 };
18 }
19 chars = chars.saturating_add(estimate_role_overhead(msg.role));
20 (chars as f64 / 3.5).ceil() as u64
21}
22
23fn estimate_role_overhead(role: MessageRole) -> usize {
24 match role {
25 MessageRole::System => 12,
26 MessageRole::User => 8,
27 MessageRole::Assistant => 8,
28 MessageRole::Tool => 16,
29 }
30}
31
32pub fn estimate_tokens_for_messages(messages: &[Message]) -> u64 {
33 messages.iter().map(estimate_tokens_for_message).sum()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CompactRange {
38 pub start: usize,
39 pub end: usize,
40 pub tokens_saved_estimate: u64,
41}
42
43pub fn is_plan_related(msg: &Message) -> bool {
44 for part in &msg.parts {
45 match part {
46 MessagePart::ToolUse { name, .. } if name.starts_with("plan.") => return true,
47 MessagePart::ToolResult { content, .. } if content.starts_with("# Plan:") => {
48 return true;
49 }
50 _ => {}
51 }
52 }
53 false
54}
55
56pub fn is_compaction_summary(msg: &Message) -> bool {
57 if !matches!(msg.role, MessageRole::System) {
58 return false;
59 }
60 msg.parts
61 .iter()
62 .any(|part| matches!(part, MessagePart::CompactSummary { .. }))
63}
64
65fn find_kth_recent_user(messages: &[Message], k: usize) -> usize {
66 let mut user_count = 0;
67 for (index, message) in messages.iter().enumerate().rev() {
68 if message.role == MessageRole::User {
69 user_count += 1;
70 if user_count == k {
71 return index;
72 }
73 }
74 }
75 0
76}
77
78pub fn find_compact_range(messages: &[Message], budget: u64) -> Option<CompactRange> {
79 let total = estimate_tokens_for_messages(messages);
80 if total <= budget || messages.len() < 4 {
81 return None;
82 }
83
84 let start = messages
85 .iter()
86 .rposition(is_compaction_summary)
87 .unwrap_or(0);
88 let keep_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
89 let mut recent_tokens = 0u64;
90 let mut token_end = messages.len();
91 for (index, message) in messages.iter().enumerate().rev() {
92 recent_tokens = recent_tokens.saturating_add(estimate_tokens_for_message(message));
93 token_end = index;
94 if recent_tokens >= keep_recent_tokens {
95 break;
96 }
97 }
98 let message_end = messages.len().saturating_sub(KEEP_RECENT_MESSAGES);
99 let end = message_end
100 .min(token_end)
101 .min(find_kth_recent_user(messages, KEEP_RECENT_USER_TURNS));
102 if end < start + 2 {
103 return None;
104 }
105
106 let tokens_saved_estimate = messages[start..end]
107 .iter()
108 .map(estimate_tokens_for_message)
109 .sum();
110 Some(CompactRange {
111 start,
112 end,
113 tokens_saved_estimate,
114 })
115}
116
117pub fn estimate_compacted_message_tokens(
118 messages: &[Message],
119 range: &CompactRange,
120 summary: &str,
121) -> u64 {
122 let turn_id = messages
123 .get(range.start)
124 .map(|m| m.turn_id.clone())
125 .unwrap_or_else(crate::event::TurnId::now);
126 let after = replace_range_with_summary(messages, range, summary.to_string(), turn_id);
127 estimate_tokens_for_messages(&after)
128}
129
130pub fn filter_orphan_tool_messages(messages: &mut Vec<Message>) {
131 let use_ids: std::collections::HashSet<String> = messages
132 .iter()
133 .flat_map(|m| {
134 m.parts.iter().filter_map(|p| match p {
135 MessagePart::ToolUse { id, .. } => Some(id.clone()),
136 _ => None,
137 })
138 })
139 .collect();
140 let mut seen_results: std::collections::HashSet<String> = std::collections::HashSet::new();
141 messages.retain(|m| {
142 for p in &m.parts {
143 if let MessagePart::ToolResult { tool_use_id, .. } = p {
144 if !use_ids.contains(tool_use_id) {
145 return false;
146 }
147 if !seen_results.insert(tool_use_id.clone()) {
148 return false;
149 }
150 }
151 }
152 true
153 });
154}
155
156pub fn find_compact_summaries(messages: &[Message]) -> Vec<CompactSummary> {
157 let mut out = Vec::new();
158 for (idx, msg) in messages.iter().enumerate() {
159 if let Some(summary) = compact_summary(msg) {
160 out.push(CompactSummary {
161 message_index: idx,
162 seq_start: summary.seq_start,
163 seq_end: summary.seq_end,
164 count: summary.count,
165 });
166 }
167 }
168 out
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct CompactSummary {
173 pub message_index: usize,
174 pub seq_start: u64,
175 pub seq_end: u64,
176 pub count: usize,
177}
178
179struct CompactSummaryPart {
180 seq_start: u64,
181 seq_end: u64,
182 count: usize,
183}
184
185fn extract_anchor(messages: &[Message]) -> Option<(String, &[Message])> {
186 let first = messages.first()?;
187 let summary = first.parts.iter().find_map(|part| match part {
188 MessagePart::CompactSummary { summary, .. } => Some(summary.clone()),
189 _ => None,
190 })?;
191 Some((summary, &messages[1..]))
192}
193
194fn compact_summary(msg: &Message) -> Option<CompactSummaryPart> {
195 if msg.role != MessageRole::System {
196 return None;
197 }
198 msg.parts.iter().find_map(|part| match part {
199 MessagePart::CompactSummary {
200 seq_start,
201 seq_end,
202 count,
203 ..
204 } => Some(CompactSummaryPart {
205 seq_start: *seq_start,
206 seq_end: *seq_end,
207 count: *count,
208 }),
209 _ => None,
210 })
211}
212
213pub async fn maybe_auto_compact(
214 session: &crate::session::Session,
215 model: &str,
216 providers: &crate::provider::ProviderRegistry,
217) {
218 let _compact_guard = session.acquire_compact_lock().await;
219 maybe_auto_compact_locked(session, model, providers).await;
220}
221
222pub fn spawn_auto_compact(
223 session: std::sync::Arc<crate::session::Session>,
224 model: String,
225 providers: crate::provider::ProviderRegistry,
226) {
227 tokio::task::spawn_blocking(move || {
228 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
229 .enable_all()
230 .build()
231 else {
232 session.push_system_note("compaction skipped: background runtime init failed".into());
233 return;
234 };
235 rt.block_on(async move {
236 maybe_auto_compact(&session, &model, &providers).await;
237 });
238 });
239}
240
241pub async fn start_auto_compact(
242 session: std::sync::Arc<crate::session::Session>,
243 model: String,
244 providers: crate::provider::ProviderRegistry,
245) {
246 let compact_guard = session.acquire_compact_lock_owned().await;
247 tokio::task::spawn_blocking(move || {
248 let Ok(rt) = tokio::runtime::Builder::new_current_thread()
249 .enable_all()
250 .build()
251 else {
252 drop(compact_guard);
253 session.push_system_note("compaction skipped: background runtime init failed".into());
254 return;
255 };
256 rt.block_on(async move {
257 maybe_auto_compact_locked(&session, &model, &providers).await;
258 drop(compact_guard);
259 });
260 });
261}
262
263async fn maybe_auto_compact_locked(
264 session: &crate::session::Session,
265 model: &str,
266 providers: &crate::provider::ProviderRegistry,
267) {
268 let forced = session.take_manual_compact_request();
269 let info = crate::model_registry::model_info(model);
270 let trigger = info.compaction_trigger_threshold();
271 let target = info.compaction_target_after();
272 let msgs = session.messages();
273 let provider_tokens = session.last_input_tokens();
274 let current = if provider_tokens > 0 {
275 provider_tokens
276 } else {
277 estimate_tokens_for_messages(&msgs)
278 };
279 if !forced && current <= trigger {
280 return;
281 }
282 if !forced && !session.approval_cooldown_ok_for_compact() {
283 return;
284 }
285 let Some(range) = find_compact_range(&msgs, target) else {
286 session.emit_compact_warning(
287 model,
288 current,
289 trigger,
290 info.context_budget,
291 "no compactible span — history too short or already fully compacted",
292 );
293 return;
294 };
295 let _ = session
296 .stream_tx()
297 .send(crate::stream::StreamFrame::CompactionSummary {
298 phase: crate::stream::CompactionPhase::Running,
299 range_start: range.start,
300 range_end: range.end.saturating_sub(1),
301 summary: String::new(),
302 before_tokens: current,
303 after_tokens: 0,
304 compacted_count: range.end - range.start,
305 });
306 let send_failed = |session: &crate::session::Session, reason: &str| {
307 let _ = session
308 .stream_tx()
309 .send(crate::stream::StreamFrame::CompactionSummary {
310 phase: crate::stream::CompactionPhase::Failed,
311 range_start: range.start,
312 range_end: range.end.saturating_sub(1),
313 summary: reason.to_string(),
314 before_tokens: current,
315 after_tokens: current,
316 compacted_count: range.end - range.start,
317 });
318 };
319 let mut filtered: Vec<Message> = msgs[range.start..range.end].to_vec();
320 filter_orphan_tool_messages(&mut filtered);
321 let (anchor, new_messages) = extract_anchor(&filtered)
322 .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
323 .unwrap_or_else(|| (None, filtered.clone()));
324 let summary =
325 match generate_llm_summary(anchor.as_deref(), &new_messages, model, providers).await {
326 Ok(text) => text,
327 Err(err) => {
328 session.emit_compact_warning(
329 model,
330 current,
331 trigger,
332 info.context_budget,
333 &format!("LLM summary failed: {err}. Degraded to placeholder."),
334 );
335 format!(
336 "[atman: compacted {} messages, LLM summary unavailable at {}]",
337 range.end - range.start,
338 chrono::Utc::now().to_rfc3339()
339 )
340 }
341 };
342 let final_summary =
343 match request_review_if_enabled(session, forced, &filtered, &range, current, summary).await
344 {
345 ReviewOutcome::Commit(s) => s,
346 ReviewOutcome::Rejected => {
347 send_failed(
348 session,
349 "compaction rejected by user; keeping full transcript",
350 );
351 session.push_system_note(
352 "compaction rejected by user; keeping full transcript".into(),
353 );
354 return;
355 }
356 };
357 let after_tokens = estimate_compacted_message_tokens(&msgs, &range, &final_summary);
358 if after_tokens >= current {
359 send_failed(
360 session,
361 &format!(
362 "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
363 after_tokens, current
364 ),
365 );
366 session.push_system_note(format!(
367 "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
368 after_tokens, current
369 ));
370 return;
371 }
372 match session.compact_messages(final_summary, range, current) {
373 Some(result) => {
374 session.push_system_note(format!(
375 "auto-compacted {}..{} — {} → {} tokens",
376 result.compacted_start,
377 result.compacted_end,
378 result.before_tokens,
379 result.after_tokens
380 ));
381 }
382 None => {
383 session.emit_compact_warning(
384 model,
385 current,
386 trigger,
387 info.context_budget,
388 "no compactible span — history too short or already fully compacted",
389 );
390 }
391 }
392}
393
394enum ReviewOutcome {
395 Commit(String),
396 Rejected,
397}
398
399async fn request_review_if_enabled(
400 session: &crate::session::Session,
401 forced: bool,
402 slice: &[Message],
403 range: &CompactRange,
404 tokens_before: u64,
405 summary: String,
406) -> ReviewOutcome {
407 if !session.compact_review_mode().should_review(forced) {
408 return ReviewOutcome::Commit(summary);
409 }
410 let reviews = session.compact_reviews();
411 if reviews.subscriber_count() == 0 {
412 return ReviewOutcome::Commit(summary);
413 }
414 let pending = crate::session::PendingCompactReview {
415 review_id: uuid::Uuid::now_v7().to_string(),
416 summary: summary.clone(),
417 slice_preview: format_slice_for_preview(slice),
418 slice_count: slice.len(),
419 range_start: range.start,
420 range_end: range.end,
421 tokens_before,
422 emitted_at: chrono::Utc::now(),
423 };
424 let rx = reviews.request(pending);
425 match rx.await {
426 Ok(crate::session::CompactReviewDecision::AcceptAsIs) => ReviewOutcome::Commit(summary),
427 Ok(crate::session::CompactReviewDecision::AcceptEdited { summary: edited }) => {
428 ReviewOutcome::Commit(edited)
429 }
430 Ok(crate::session::CompactReviewDecision::Reject) | Err(_) => ReviewOutcome::Rejected,
431 }
432}
433
434fn format_slice_for_preview(slice: &[Message]) -> String {
435 let mut out = String::new();
436 for (i, msg) in slice.iter().enumerate() {
437 let role = msg.role.as_str();
438 let body = serialize_message_for_summary(msg);
439 let truncated: String = body.chars().take(400).collect();
440 out.push_str(&format!("[{i}] {role}: {truncated}\n"));
441 }
442 out.chars().take(16_000).collect()
443}
444
445const SUMMARY_SYSTEM_PROMPT: &str =
446 "You are an anchored context summarization assistant for coding sessions.";
447
448const SUMMARY_INSTRUCTIONS: &str = r#"You are an anchored context summarization assistant.
449
450Below is:
4511. <current-anchor>: the existing handoff state, which is authoritative and must be preserved.
4522. <new-messages>: only the messages that arrived since the anchor was written.
453
454Merge the NEW facts from <new-messages> INTO the current anchor, producing an upgraded full anchor.
455
456STRUCTURAL RULES (data model, not optional style):
457- ## Objective: unchanged unless the new messages show the user explicitly redirected.
458- ### 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.
459- ### Active: update based on new messages; move newly-done items to Completed.
460- ### Blocked: update based on new messages; remove resolved ones.
461- ## Decisions: only add new decisions. Never remove old ones.
462- ## Next Move: replace based on current end state.
463- Keep every section, even when empty.
464- Preserve exact file paths, symbols, commands, error strings, identifiers.
465
466Output exactly this Markdown structure:
467## Objective
468## Important Details
469## Work State
470### Completed
471### Active
472### Blocked
473## Decisions
474## Next Move
475## Relevant Files
476
477Do not mention the summary process or that context was compacted.
478Respond in the same language as the conversation."#;
479
480async fn generate_llm_summary(
481 anchor: Option<&str>,
482 slice: &[Message],
483 model: &str,
484 providers: &crate::provider::ProviderRegistry,
485) -> Result<String, crate::error::RuntimeError> {
486 let provider = providers.resolve(model).ok_or_else(|| {
487 crate::error::RuntimeError::ToolFailed(format!("no provider for {model}"))
488 })?;
489 let payload = format_slice_for_summary(slice);
490 let (messages, dump_user) = if let Some(anchor) = anchor {
491 let anchor_user = format!("<current-anchor>\n{anchor}\n</current-anchor>");
492 let new_user =
493 format!("<new-messages>\n{payload}\n</new-messages>\n\n{SUMMARY_INSTRUCTIONS}");
494 (
495 vec![
496 Message::user_text(crate::event::TurnId::now(), anchor_user.clone()),
497 Message::user_text(crate::event::TurnId::now(), new_user.clone()),
498 ],
499 format!("{anchor_user}\n\n{new_user}"),
500 )
501 } else {
502 let user = format!(
503 "<conversation_history>\n{payload}\n</conversation_history>\n\n{SUMMARY_INSTRUCTIONS}"
504 );
505 (
506 vec![Message::user_text(
507 crate::event::TurnId::now(),
508 user.clone(),
509 )],
510 user,
511 )
512 };
513 if let Ok(dir) = std::env::var("ATMAN_COMPACT_DUMP") {
514 let _ = std::fs::write(
515 format!("{dir}/compact_request.txt"),
516 format!("=== SYSTEM ===\n{SUMMARY_SYSTEM_PROMPT}\n\n=== USER ===\n{dump_user}"),
517 );
518 }
519 let req = crate::provider::LlmRequest {
520 model: model.into(),
521 messages,
522 system: Some(SUMMARY_SYSTEM_PROMPT.into()),
523 input: crate::value::Value::Unit,
524 schema: None,
525 cache_prompt: false,
526 tools: Vec::new(),
527 thinking_enabled: false,
528 stall_timeout_secs: 0,
529 };
530 let outcome = provider.call(req).await?;
531 let text = outcome.text_concat();
532 if text.trim().is_empty() {
533 return Err(crate::error::RuntimeError::ToolFailed(
534 "empty summary from provider".into(),
535 ));
536 }
537 Ok(text)
538}
539
540fn format_slice_for_summary(slice: &[Message]) -> String {
541 let mut out = String::new();
542 for (i, msg) in slice.iter().enumerate() {
543 let role = msg.role.as_str();
544 let body = serialize_message_for_summary(msg);
545 let truncated: String = body.chars().take(4000).collect();
546 out.push_str(&format!("[{i}] {role}: {truncated}\n\n"));
547 }
548 out.chars().take(120_000).collect()
549}
550
551fn serialize_message_for_summary(msg: &Message) -> String {
552 let mut parts = Vec::new();
553 for part in &msg.parts {
554 match part {
555 MessagePart::CompactSummary { summary, .. } => {
556 parts.push(summary.clone());
557 }
558 MessagePart::Text { text } => {
559 parts.push(text.clone());
560 }
561 MessagePart::Thinking { thinking, .. } => {
562 let truncated: String = thinking.chars().take(1000).collect();
563 parts.push(format!("[thinking: {truncated}]"));
564 }
565 MessagePart::ToolUse { name, input, .. } => {
566 let input_str = if input.is_null() {
567 String::new()
568 } else {
569 input.to_string()
570 };
571 let truncated: String = input_str.chars().take(2000).collect();
572 parts.push(format!("[tool_call: {name}({truncated})]"));
573 }
574 MessagePart::ToolResult {
575 content,
576 is_error,
577 tool_use_id,
578 } => {
579 let truncated: String = content.chars().take(3000).collect();
580 let marker = if *is_error { "ERROR" } else { "ok" };
581 let id_short: String = tool_use_id.chars().take(12).collect();
582 parts.push(format!("[tool_result {id_short}… {marker}: {truncated}]"));
583 }
584 MessagePart::Image { .. } => {
585 parts.push("[image]".into());
586 }
587 }
588 }
589 parts.join(" ")
590}
591
592pub fn replace_range_with_summary(
593 messages: &[Message],
594 range: &CompactRange,
595 summary: String,
596 turn_id: crate::event::TurnId,
597) -> Vec<Message> {
598 let mut out = Vec::with_capacity(1 + messages.len().saturating_sub(range.end));
599 out.push(Message::system_compact_summary(
600 turn_id,
601 summary,
602 range.start as u64,
603 range.end.saturating_sub(1) as u64,
604 range.end - range.start,
605 ));
606 out.extend_from_slice(&messages[range.end..]);
607 out
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct HandleCompactResult {
613 pub before_tokens: u64,
614 pub after_tokens: u64,
615 pub compacted_start: usize,
616 pub compacted_end: usize,
617}
618
619pub fn compact_messages_on_handle(
623 handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
624 summary: String,
625 budget: u64,
626) -> Option<HandleCompactResult> {
627 let mut msgs = handle.lock().unwrap();
628 let before_tokens = estimate_tokens_for_messages(&msgs);
629 let range = find_compact_range(&msgs, budget)?;
630 let turn_id = msgs
631 .get(range.start)
632 .map(|m| m.turn_id.clone())
633 .unwrap_or_else(crate::event::TurnId::now);
634 let after = replace_range_with_summary(&msgs, &range, summary, turn_id);
635 let after_tokens = estimate_tokens_for_messages(&after);
636 if after_tokens >= before_tokens {
637 return None;
638 }
639 let result = HandleCompactResult {
640 before_tokens,
641 after_tokens,
642 compacted_start: range.start,
643 compacted_end: range.end.saturating_sub(1),
644 };
645 *msgs = after;
646 Some(result)
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::event::TurnId;
653
654 fn user(text: &str) -> Message {
655 Message::user_text(TurnId::now(), text)
656 }
657 fn assistant(text: &str) -> Message {
658 Message::assistant_text(TurnId::now(), text)
659 }
660 fn system(text: &str) -> Message {
661 Message::system_text(TurnId::now(), text)
662 }
663
664 #[test]
665 fn summary_instructions_keep_decisions_before_next_move() {
666 let objective = SUMMARY_INSTRUCTIONS
667 .find("## Objective")
668 .expect("objective");
669 let decisions = SUMMARY_INSTRUCTIONS
670 .find("## Decisions")
671 .expect("decisions");
672 let next_move = SUMMARY_INSTRUCTIONS
673 .find("## Next Move")
674 .expect("next move");
675 assert!(objective < decisions);
676 assert!(decisions < next_move);
677 }
678
679 #[test]
680 fn estimate_scales_with_char_length() {
681 let short = user("hi");
682 let long = user(&"x".repeat(3500));
683 assert!(estimate_tokens_for_message(&long) > estimate_tokens_for_message(&short) * 100);
684 }
685
686 #[test]
687 fn find_compact_returns_none_when_under_budget() {
688 let msgs = vec![user("a"), assistant("b"), user("c"), assistant("d")];
689 assert!(find_compact_range(&msgs, 1000).is_none());
690 }
691
692 #[test]
693 fn find_compact_returns_none_for_short_history() {
694 let msgs = vec![user(&"x".repeat(9000))];
695 assert!(find_compact_range(&msgs, 100).is_none());
696 }
697
698 #[test]
699 fn find_kth_recent_user_handles_exact_excess_and_mixed_history() {
700 let exact = vec![
701 user("u0"),
702 assistant("a0"),
703 system("s0"),
704 tool_result("call-0", "result", false),
705 user("u1"),
706 assistant("a1"),
707 user("u2"),
708 system("s1"),
709 user("u3"),
710 assistant("a3"),
711 user("u4"),
712 ];
713 assert_eq!(find_kth_recent_user(&exact, KEEP_RECENT_USER_TURNS), 0);
715
716 let mut excess = exact.clone();
717 excess.push(user("u5"));
718 assert_eq!(find_kth_recent_user(&excess, KEEP_RECENT_USER_TURNS), 4);
719
720 let too_few = vec![user("a"), assistant("b"), assistant("c")];
721 assert_eq!(find_kth_recent_user(&too_few, KEEP_RECENT_USER_TURNS), 0);
722 }
723
724 #[test]
725 fn find_compact_range_preserves_minimum_recent_messages_without_anchor() {
726 let mut msgs = vec![system("head")];
727 msgs.extend((0..25).map(|index| assistant(&format!("old {index}"))));
728 msgs.extend(
729 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
730 );
731 let range = find_compact_range(&msgs, 1).expect("range");
732 assert_eq!(range.start, 0);
733 assert_eq!(range.end, msgs.len() - KEEP_RECENT_MESSAGES);
734 }
735
736 #[test]
737 fn find_compact_range_preserves_minimum_window_for_large_tool_result() {
738 let mut msgs = vec![user(&"h".repeat(500_000))];
739 for index in 1..31 {
740 if matches!(index, 20 | 22 | 24 | 26 | 28 | 30) {
741 msgs.push(user(&"u".repeat(800)));
742 } else {
743 msgs.push(assistant(&"a".repeat(800)));
744 }
745 }
746 msgs.push(tool_result("call-large", &"t".repeat(22_000), false));
747
748 let budget = 120_000;
749 let minimum_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
750 assert!(estimate_tokens_for_message(msgs.last().unwrap()) > minimum_recent_tokens);
751
752 let range = find_compact_range(&msgs, budget).expect("range");
753 assert!(
754 range.end <= msgs.len() - KEEP_RECENT_MESSAGES,
755 "range was {range:?}"
756 );
757 assert!(msgs.len() - range.end >= KEEP_RECENT_MESSAGES);
758 assert!(estimate_tokens_for_messages(&msgs[range.end..]) >= minimum_recent_tokens);
759 }
760
761 #[test]
762 fn find_compact_range_handles_four_to_twenty_one_message_histories() {
763 for len in 4..=21 {
764 let msgs = (0..len)
765 .map(|_| user(&"x".repeat(5000)))
766 .collect::<Vec<_>>();
767 assert_eq!(
768 find_compact_range(&msgs, 1).is_some(),
769 len >= KEEP_RECENT_MESSAGES + 2,
770 "len={len}"
771 );
772 }
773 }
774
775 #[test]
776 fn find_compact_range_recent_users_limit_mixed_history() {
777 let msgs = vec![
778 system("head"),
779 assistant("a0"),
780 user("u0"),
781 tool_result("call-0", "r0", false),
782 assistant("a1"),
783 user("u1"),
784 assistant("a2"),
785 tool_result("call-1", "r1", false),
786 user("u2"),
787 assistant("a3"),
788 system("note"),
789 user("u3"),
790 tool_result("call-2", "r2", false),
791 assistant("a4"),
792 user("u4"),
793 assistant("a5"),
794 tool_result("call-3", "r3", false),
795 user("u5"),
796 assistant("a6"),
797 system("tail"),
798 assistant("a7"),
799 ];
800
801 let range = find_compact_range(&msgs, 1).expect("range");
802 assert_eq!(range.end, 5);
803 assert_eq!(msgs[range.end].role, MessageRole::User);
804 }
805
806 #[test]
807 fn find_compact_range_preserves_recent_user_turns_after_anchor() {
808 let mut msgs = vec![system("head"), compaction_summary("summary")];
809 msgs.extend((0..6).flat_map(|index| {
810 [
811 user(&format!("user {index}")),
812 assistant("assistant"),
813 assistant("tool fragment"),
814 ]
815 }));
816 msgs.extend((0..12).map(|_| assistant("recent fragment")));
817 let range = find_compact_range(&msgs, 1).expect("range");
818 assert_eq!(range.start, 1);
819 assert_eq!(range.end, 5);
820 assert_eq!(msgs[range.end].role, MessageRole::User);
821 }
822
823 #[test]
824 fn find_compact_range_returns_none_when_end_cannot_cover_two_messages() {
825 let msgs = vec![
826 system("head"),
827 compaction_summary("summary"),
828 assistant("tail"),
829 user("tail"),
830 ];
831 assert!(find_compact_range(&msgs, 1).is_none());
832 }
833
834 #[test]
835 fn extract_anchor_removes_leading_compact_summary() {
836 let messages = vec![compaction_summary("anchor"), user("new")];
837 let (anchor, remaining) = extract_anchor(&messages).expect("anchor");
838 assert_eq!(anchor, "anchor");
839 assert_eq!(remaining, &messages[1..]);
840 }
841
842 #[test]
843 fn extract_anchor_returns_none_without_leading_summary() {
844 let messages = vec![user("new")];
845 assert!(extract_anchor(&messages).is_none());
846 }
847
848 #[test]
849 fn compact_messages_on_handle_replaces_range_in_place() {
850 let mut messages = vec![system("head")];
851 messages.extend((0..9).map(|index| assistant(&format!("old {index}"))));
852 messages.extend(
853 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(4000))]),
854 );
855 messages.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
856 messages.push(user("tail"));
857 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
858 std::sync::Arc::new(std::sync::Mutex::new(messages));
859 let result = compact_messages_on_handle(&handle, "gist".into(), 100);
862 let result = result.expect("should compact");
863 assert!(result.after_tokens < result.before_tokens);
864 let msgs = handle.lock().unwrap();
865 assert_eq!(result.compacted_start, 0);
866 assert_eq!(result.compacted_end, 13);
867 assert!(is_compaction_summary(&msgs[0]));
868 assert_eq!(msgs.last().unwrap().text_concat(), "tail");
869 }
870
871 #[test]
872 fn compact_messages_on_handle_none_when_under_budget() {
873 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
874 std::sync::Arc::new(std::sync::Mutex::new(vec![user("short")]));
875 assert!(compact_messages_on_handle(&handle, "g".into(), 100_000).is_none());
876 }
877
878 #[test]
879 fn compact_messages_on_handle_none_when_summary_would_not_shrink() {
880 let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
886 std::sync::Arc::new(std::sync::Mutex::new(vec![
887 system("h"),
888 user("."),
889 assistant("."),
890 user("."),
891 assistant("."),
892 user("t"),
893 ]));
894 let result = compact_messages_on_handle(&handle, "x".into(), 1);
896 let before_len = handle.lock().unwrap().len();
899 if result.is_none() {
900 assert_eq!(handle.lock().unwrap().len(), before_len);
901 }
902 }
903
904 #[test]
905 fn replace_range_puts_summary_system_message_in_place() {
906 let msgs = vec![
907 system("head"),
908 user("m1"),
909 assistant("m2"),
910 user("m3"),
911 assistant("m4"),
912 user("tail"),
913 ];
914 let range = CompactRange {
915 start: 1,
916 end: 5,
917 tokens_saved_estimate: 100,
918 };
919 let out = replace_range_with_summary(
920 &msgs,
921 &range,
922 "gist: talked about m1..m4".into(),
923 TurnId::now(),
924 );
925 assert_eq!(out.len(), 2, "summary + tail");
926 assert_eq!(out[0].role, MessageRole::System);
927 assert!(out[0].text_concat().contains("gist: talked about"));
928 assert!(matches!(
929 out[0].parts.as_slice(),
930 [MessagePart::CompactSummary {
931 seq_start: 1,
932 seq_end: 4,
933 count: 4,
934 ..
935 }]
936 ));
937 assert_eq!(out[1].role, MessageRole::User);
938 assert_eq!(out[1].text_concat(), "tail");
939 }
940
941 #[test]
942 fn find_compact_range_anchors_on_latest_structured_summary() {
943 let mut msgs = vec![
944 system("head"),
945 Message::system_compact_summary(TurnId::now(), "old", 0, 1, 2),
946 ];
947 msgs.extend(
948 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
949 );
950 msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
951 let range = find_compact_range(&msgs, 1).expect("range");
952 assert_eq!(range.start, 1);
953 assert_eq!(range.end, 4);
954 }
955
956 fn assistant_with_tool_use(text: &str, tool_name: &str, input: serde_json::Value) -> Message {
957 Message {
958 role: MessageRole::Assistant,
959 parts: vec![
960 MessagePart::Text { text: text.into() },
961 MessagePart::ToolUse {
962 id: "call_test".into(),
963 name: tool_name.into(),
964 input,
965 },
966 ],
967 turn_id: TurnId::now(),
968 }
969 }
970
971 fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
972 Message {
973 role: MessageRole::Tool,
974 parts: vec![MessagePart::ToolResult {
975 tool_use_id: id.into(),
976 content: content.into(),
977 is_error,
978 }],
979 turn_id: TurnId::now(),
980 }
981 }
982
983 fn thinking(text: &str) -> Message {
984 Message {
985 role: MessageRole::Assistant,
986 parts: vec![
987 MessagePart::Thinking {
988 thinking: text.into(),
989 signature: None,
990 },
991 MessagePart::Text {
992 text: "after thinking".into(),
993 },
994 ],
995 turn_id: TurnId::now(),
996 }
997 }
998
999 #[test]
1000 fn format_slice_for_summary_includes_tool_use() {
1001 let slice = vec![
1002 user("read the file"),
1003 assistant_with_tool_use(
1004 "let me check",
1005 "fs.read",
1006 serde_json::json!({"path": "/tmp/foo.rs"}),
1007 ),
1008 tool_result("call_test", "fn main() {}", false),
1009 ];
1010 let out = format_slice_for_summary(&slice);
1011 assert!(out.contains("fs.read"), "missing tool name: {out}");
1012 assert!(out.contains("/tmp/foo.rs"), "missing tool input: {out}");
1013 assert!(
1014 out.contains("fn main()"),
1015 "missing tool_result content: {out}"
1016 );
1017 assert!(out.contains("tool_call"), "missing tool_call marker: {out}");
1018 assert!(
1019 out.contains("tool_result"),
1020 "missing tool_result marker: {out}"
1021 );
1022 }
1023
1024 #[test]
1025 fn format_slice_for_summary_includes_thinking() {
1026 let slice = vec![thinking("I should consider the edge case")];
1027 let out = format_slice_for_summary(&slice);
1028 assert!(out.contains("thinking"), "missing thinking marker: {out}");
1029 assert!(out.contains("edge case"), "missing thinking content: {out}");
1030 }
1031
1032 #[test]
1033 fn format_slice_for_summary_marks_error_tool_results() {
1034 let slice = vec![tool_result("call_1", "permission denied", true)];
1035 let out = format_slice_for_summary(&slice);
1036 assert!(out.contains("ERROR"), "missing ERROR marker: {out}");
1037 }
1038
1039 #[test]
1040 fn format_slice_for_summary_truncates_long_tool_input() {
1041 let long_input = serde_json::json!({"content": "x".repeat(5000)});
1042 let slice = vec![assistant_with_tool_use("check", "fs.write", long_input)];
1043 let out = format_slice_for_summary(&slice);
1044 let tool_call_line = out
1045 .lines()
1046 .find(|l| l.contains("tool_call"))
1047 .unwrap_or_else(|| panic!("no tool_call line in {out}"));
1048 assert!(
1049 tool_call_line.chars().count() < 2200,
1050 "tool_call line not truncated: {tool_call_line}"
1051 );
1052 }
1053
1054 fn compaction_summary(text: &str) -> Message {
1055 Message::system_compact_summary(TurnId::now(), text, 1, 5, 5)
1056 }
1057
1058 #[test]
1059 fn is_compaction_summary_detects_structured_variant() {
1060 assert!(is_compaction_summary(&compaction_summary("gist")));
1061 assert!(!is_compaction_summary(&system("plain system msg")));
1062 assert!(!is_compaction_summary(&user("user msg")));
1063 }
1064
1065 #[test]
1066 fn find_compact_range_spans_across_compaction_summaries() {
1067 let mut msgs = vec![
1068 system("head"),
1069 user(&"x".repeat(3000)),
1070 assistant(&"y".repeat(3000)),
1071 user(&"z".repeat(3000)),
1072 compaction_summary("first compaction summary"),
1073 ];
1074 msgs.extend(
1075 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(3000))]),
1076 );
1077 msgs.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1078 let range = find_compact_range(&msgs, 500).expect("expected range across summary");
1079 assert_eq!(
1080 range.start, 4,
1081 "range should anchor at the structured summary"
1082 );
1083 assert!(
1084 range.end > 4,
1085 "range should include later work, got {range:?}"
1086 );
1087 assert!(
1088 range.end - range.start >= 3,
1089 "range must cover >= 3 msgs, got {}",
1090 range.end - range.start
1091 );
1092 }
1093
1094 #[test]
1095 fn find_compact_starts_from_summary() {
1096 let mut msgs = vec![user("a"), assistant("b"), compaction_summary("summary 1")];
1097 msgs.extend(
1098 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1099 );
1100 msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
1101 let range = find_compact_range(&msgs, 10).expect("expected range");
1102 assert_eq!(
1103 range.start, 2,
1104 "should start from the compact summary anchor"
1105 );
1106 assert_eq!(range.end, 5, "the fifth recent user is retained");
1107 }
1108
1109 #[test]
1110 fn find_compact_range_includes_older_compaction_summaries() {
1111 let mut msgs = vec![compaction_summary("summary 0")];
1112 msgs.extend((0..6).flat_map(|index| {
1113 [
1114 user(&format!("old user {index}")),
1115 assistant(&"x".repeat(2000)),
1116 ]
1117 }));
1118 msgs.push(compaction_summary("summary 1"));
1119 msgs.extend((0..6).flat_map(|index| {
1120 [
1121 user(&format!("new user {index}")),
1122 assistant(&"z".repeat(2000)),
1123 ]
1124 }));
1125 msgs.extend((0..10).map(|index| assistant(&format!("tail {index}"))));
1126 let range = find_compact_range(&msgs, 500).expect("expected range");
1127 assert_eq!(range.start, 13, "should compact from the latest summary");
1128 assert!(
1129 range.end > range.start,
1130 "should include work after the latest summary"
1131 );
1132 }
1133
1134 #[test]
1135 fn compacted_message_tokens_detects_growth() {
1136 let msgs = vec![compaction_summary("summary 0"), user("a"), assistant("b")];
1137 let range = CompactRange {
1138 start: 1,
1139 end: 3,
1140 tokens_saved_estimate: 0,
1141 };
1142 let before = estimate_tokens_for_messages(&msgs);
1143 let after = estimate_compacted_message_tokens(
1144 &msgs,
1145 &range,
1146 "a very long summary that expands the transcript a lot",
1147 );
1148 assert!(after > before, "expected growth to be detectable");
1149 }
1150
1151 #[test]
1152 fn find_compact_starts_from_zero_without_summary() {
1153 let mut msgs = (0..26)
1154 .map(|index| assistant(&format!("old {index}")))
1155 .collect::<Vec<_>>();
1156 msgs.extend(
1157 (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1158 );
1159 let range = find_compact_range(&msgs, 10).expect("expected range");
1160 assert_eq!(range.start, 0, "should start from 0 without summary");
1161 assert_eq!(range.end, 28, "the recent-message limit is retained");
1162 }
1163
1164 #[test]
1165 fn filter_orphan_tool_messages_removes_orphan_results() {
1166 use crate::message::{Message, MessagePart, MessageRole};
1167 let turn = TurnId::now();
1168 let msgs = vec![
1169 Message {
1170 role: MessageRole::Tool,
1171 parts: vec![MessagePart::ToolResult {
1172 tool_use_id: "orphan".into(),
1173 content: "no matching use".into(),
1174 is_error: false,
1175 }],
1176 turn_id: turn.clone(),
1177 },
1178 Message {
1179 role: MessageRole::Assistant,
1180 parts: vec![MessagePart::ToolUse {
1181 id: "call_1".into(),
1182 name: "fs.read".into(),
1183 input: serde_json::json!({}),
1184 }],
1185 turn_id: turn.clone(),
1186 },
1187 Message {
1188 role: MessageRole::Tool,
1189 parts: vec![MessagePart::ToolResult {
1190 tool_use_id: "call_1".into(),
1191 content: "ok".into(),
1192 is_error: false,
1193 }],
1194 turn_id: turn,
1195 },
1196 ];
1197 let mut filtered = msgs;
1198 filter_orphan_tool_messages(&mut filtered);
1199 assert_eq!(filtered.len(), 2, "orphan result should be removed");
1200 }
1201}