1use crate::error::{Error, Result};
11use crate::model::{
12 AssistantMessage, ContentBlock, Message, StopReason, TextContent, ThinkingLevel, ToolCall,
13 Usage, UserContent, UserMessage,
14};
15use crate::provider::{Context, Provider, StreamOptions};
16use crate::session::{SessionEntry, SessionMessage, session_message_to_model};
17use futures::StreamExt;
18use serde::Serialize;
19use serde_json::Value;
20use std::collections::{HashMap, HashSet};
21use std::fmt::Write as _;
22use std::sync::Arc;
23
24const CHARS_PER_TOKEN_ESTIMATE: usize = 3;
28
29const IMAGE_TOKEN_ESTIMATE: usize = 1200;
31
32const IMAGE_CHAR_ESTIMATE: usize = IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN_ESTIMATE;
34
35fn json_byte_len(value: &Value) -> usize {
40 struct Counter(usize);
41 impl std::io::Write for Counter {
42 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
43 self.0 += buf.len();
44 Ok(buf.len())
45 }
46 fn flush(&mut self) -> std::io::Result<()> {
47 Ok(())
48 }
49 }
50 let mut c = Counter(0);
51 if serde_json::to_writer(&mut c, value).is_err() {
52 }
54 c.0
55}
56
57#[derive(Debug, Clone)]
62pub struct ResolvedCompactionSettings {
63 pub enabled: bool,
64 pub context_window_tokens: u32,
65 pub reserve_tokens: u32,
66 pub keep_recent_tokens: u32,
67}
68
69impl Default for ResolvedCompactionSettings {
70 fn default() -> Self {
71 let context_window_tokens: u32 = 200_000;
72 Self {
73 enabled: true,
74 context_window_tokens,
75 reserve_tokens: 16_384,
77 keep_recent_tokens: 20_000,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct CompactionDetails {
87 pub read_files: Vec<String>,
88 pub modified_files: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct CompactionResult {
94 pub summary: String,
95 pub first_kept_entry_id: String,
96 pub tokens_before: u64,
97 pub details: CompactionDetails,
98}
99
100#[derive(Debug, Clone)]
101pub struct CompactionPreparation {
102 pub first_kept_entry_id: String,
103 pub messages_to_summarize: Vec<SessionMessage>,
104 pub turn_prefix_messages: Vec<SessionMessage>,
105 pub is_split_turn: bool,
106 pub tokens_before: u64,
107 pub previous_summary: Option<String>,
108 pub file_ops: FileOperations,
109 pub settings: ResolvedCompactionSettings,
110}
111
112#[derive(Debug, Clone, Default)]
117pub struct FileOperations {
118 read: HashSet<String>,
119 written: HashSet<String>,
120 edited: HashSet<String>,
121}
122
123impl FileOperations {
124 pub fn read_files(&self) -> impl Iterator<Item = &str> {
125 self.read.iter().map(String::as_str)
126 }
127}
128
129fn build_tool_status_map(messages: &[SessionMessage]) -> HashMap<String, bool> {
130 let mut status = HashMap::new();
131 for msg in messages {
132 if let SessionMessage::ToolResult {
133 tool_call_id,
134 is_error,
135 ..
136 } = msg
137 {
138 status.insert(tool_call_id.clone(), !*is_error);
139 }
140 }
141 status
142}
143
144fn extract_file_ops_from_message(
145 message: &SessionMessage,
146 file_ops: &mut FileOperations,
147 tool_status: &HashMap<String, bool>,
148) {
149 let SessionMessage::Assistant { message } = message else {
150 return;
151 };
152
153 for block in &message.content {
154 let ContentBlock::ToolCall(ToolCall {
155 id,
156 name,
157 arguments,
158 ..
159 }) = block
160 else {
161 continue;
162 };
163
164 if !tool_status.get(id).copied().unwrap_or(false) {
166 continue;
167 }
168
169 let Some(path) = arguments.get("path").and_then(Value::as_str) else {
170 continue;
171 };
172
173 match name.as_str() {
174 "read" | "grep" | "find" | "ls" => {
175 file_ops.read.insert(path.to_string());
176 }
177 "write" => {
178 file_ops.written.insert(path.to_string());
179 }
180 "edit" => {
181 file_ops.edited.insert(path.to_string());
182 }
183 _ => {}
184 }
185 }
186}
187
188fn compute_file_lists(file_ops: &FileOperations) -> (Vec<String>, Vec<String>) {
189 let modified: HashSet<&String> = file_ops
190 .edited
191 .iter()
192 .chain(file_ops.written.iter())
193 .collect();
194
195 let mut read_only = file_ops
196 .read
197 .iter()
198 .filter(|f| !modified.contains(f))
199 .cloned()
200 .collect::<Vec<_>>();
201 read_only.sort();
202
203 let mut modified_files = modified.into_iter().cloned().collect::<Vec<_>>();
204 modified_files.sort();
205
206 (read_only, modified_files)
207}
208
209fn write_escaped_file_list(out: &mut String, tag: &str, files: &[String]) {
210 out.push('<');
211 out.push_str(tag);
212 out.push_str(">\n");
213 for (i, file) in files.iter().enumerate() {
214 if i > 0 {
215 out.push('\n');
216 }
217 for ch in file.chars() {
219 match ch {
220 '<' => out.push_str("<"),
221 '>' => out.push_str(">"),
222 _ => out.push(ch),
223 }
224 }
225 }
226 out.push_str("\n</");
227 out.push_str(tag);
228 out.push('>');
229}
230
231fn format_file_operations(read_files: &[String], modified_files: &[String]) -> String {
232 if read_files.is_empty() && modified_files.is_empty() {
233 return String::new();
234 }
235
236 let mut out = String::from("\n\n");
237 if !read_files.is_empty() {
238 write_escaped_file_list(&mut out, "read-files", read_files);
239 }
240 if !modified_files.is_empty() {
241 if !read_files.is_empty() {
242 out.push_str("\n\n");
243 }
244 write_escaped_file_list(&mut out, "modified-files", modified_files);
245 }
246 out
247}
248
249const fn calculate_context_tokens(usage: &Usage) -> u64 {
254 if usage.total_tokens > 0 {
255 usage.total_tokens
256 } else {
257 usage.input + usage.output
258 }
259}
260
261const fn get_assistant_usage(message: &SessionMessage) -> Option<&Usage> {
262 let SessionMessage::Assistant { message } = message else {
263 return None;
264 };
265
266 if matches!(message.stop_reason, StopReason::Aborted | StopReason::Error) {
267 return None;
268 }
269
270 Some(&message.usage)
271}
272
273#[derive(Debug, Clone, Copy)]
274struct ContextUsageEstimate {
275 tokens: u64,
276 last_usage_index: Option<usize>,
277}
278
279fn estimate_context_tokens(messages: &[SessionMessage]) -> ContextUsageEstimate {
280 let mut last_usage: Option<(&Usage, usize)> = None;
281 for (idx, msg) in messages.iter().enumerate().rev() {
282 if let Some(usage) = get_assistant_usage(msg) {
283 last_usage = Some((usage, idx));
284 break;
285 }
286 }
287
288 let Some((usage, usage_index)) = last_usage else {
289 let total = messages.iter().map(estimate_tokens).sum();
290 return ContextUsageEstimate {
291 tokens: total,
292 last_usage_index: None,
293 };
294 };
295
296 let usage_tokens = calculate_context_tokens(usage);
297 let trailing_tokens = messages[usage_index + 1..]
298 .iter()
299 .map(estimate_tokens)
300 .sum::<u64>();
301 ContextUsageEstimate {
302 tokens: usage_tokens + trailing_tokens,
303 last_usage_index: Some(usage_index),
304 }
305}
306
307fn should_compact(
308 context_tokens: u64,
309 context_window: u32,
310 settings: &ResolvedCompactionSettings,
311) -> bool {
312 if !settings.enabled {
313 return false;
314 }
315 let reserve = u64::from(settings.reserve_tokens);
316 let window = u64::from(context_window);
317 context_tokens > window.saturating_sub(reserve)
318}
319
320fn estimate_tokens(message: &SessionMessage) -> u64 {
321 let mut chars: usize = 0;
322
323 match message {
324 SessionMessage::User { content, .. } => match content {
325 UserContent::Text(text) => chars = text.len(),
326 UserContent::Blocks(blocks) => {
327 for block in blocks {
328 match block {
329 ContentBlock::Text(text) => chars += text.text.len(),
330 ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
331 ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
332 ContentBlock::ToolCall(call) => {
333 chars += call.name.len();
334 chars += json_byte_len(&call.arguments);
335 }
336 }
337 }
338 }
339 },
340 SessionMessage::Assistant { message } => {
341 for block in &message.content {
342 match block {
343 ContentBlock::Text(text) => chars += text.text.len(),
344 ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
345 ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
346 ContentBlock::ToolCall(call) => {
347 chars += call.name.len();
348 chars += json_byte_len(&call.arguments);
349 }
350 }
351 }
352 }
353 SessionMessage::ToolResult { content, .. } => {
354 for block in content {
355 match block {
356 ContentBlock::Text(text) => chars += text.text.len(),
357 ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
358 ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
359 ContentBlock::ToolCall(call) => {
360 chars += call.name.len();
361 chars += json_byte_len(&call.arguments);
362 }
363 }
364 }
365 }
366 SessionMessage::Custom { content, .. } => chars = content.len(),
367 SessionMessage::BashExecution {
368 command, output, ..
369 } => chars = command.len() + output.len(),
370 SessionMessage::BranchSummary { summary, .. }
371 | SessionMessage::CompactionSummary { summary, .. } => chars = summary.len(),
372 }
373
374 u64::try_from(chars.div_ceil(CHARS_PER_TOKEN_ESTIMATE)).unwrap_or(u64::MAX)
375}
376
377#[derive(Debug, Clone, Copy)]
382struct CutPointResult {
383 first_kept_entry_index: usize,
384 turn_start_index: Option<usize>,
385 is_split_turn: bool,
386}
387
388fn message_from_entry(entry: &SessionEntry) -> Option<SessionMessage> {
389 match entry {
390 SessionEntry::Message(msg_entry) => Some(msg_entry.message.clone()),
391 SessionEntry::BranchSummary(summary) => Some(SessionMessage::BranchSummary {
392 summary: summary.summary.clone(),
393 from_id: summary.from_id.clone(),
394 }),
395 SessionEntry::Compaction(compaction) => Some(SessionMessage::CompactionSummary {
396 summary: compaction.summary.clone(),
397 tokens_before: compaction.tokens_before,
398 }),
399 _ => None,
400 }
401}
402
403const fn entry_is_message_like(entry: &SessionEntry) -> bool {
404 matches!(
405 entry,
406 SessionEntry::Message(_) | SessionEntry::BranchSummary(_)
407 )
408}
409
410const fn entry_is_compaction_boundary(entry: &SessionEntry) -> bool {
411 matches!(entry, SessionEntry::Compaction(_))
412}
413
414fn find_valid_cut_points(
415 entries: &[SessionEntry],
416 start_index: usize,
417 end_index: usize,
418) -> Vec<usize> {
419 let mut cut_points = Vec::new();
420 for (idx, entry) in entries.iter().enumerate().take(end_index).skip(start_index) {
421 match entry {
422 SessionEntry::Message(msg_entry) => match msg_entry.message {
423 SessionMessage::ToolResult { .. } => {}
424 _ => cut_points.push(idx),
425 },
426 SessionEntry::BranchSummary(_) => cut_points.push(idx),
427 _ => {}
428 }
429 }
430 cut_points
431}
432
433fn entry_has_tool_calls(entry: &SessionEntry) -> bool {
434 matches!(
435 entry,
436 SessionEntry::Message(msg) if matches!(
437 &msg.message,
438 SessionMessage::Assistant { message } if message.content.iter().any(|b| matches!(b, ContentBlock::ToolCall(_)))
439 )
440 )
441}
442
443const fn is_user_turn_start(entry: &SessionEntry) -> bool {
444 match entry {
445 SessionEntry::BranchSummary(_) => true,
446 SessionEntry::Message(msg_entry) => matches!(
447 msg_entry.message,
448 SessionMessage::User { .. } | SessionMessage::BashExecution { .. }
449 ),
450 _ => false,
451 }
452}
453
454fn find_turn_start_index(
455 entries: &[SessionEntry],
456 entry_index: usize,
457 start_index: usize,
458) -> Option<usize> {
459 (start_index..=entry_index)
460 .rev()
461 .find(|&idx| is_user_turn_start(&entries[idx]))
462}
463
464fn find_cut_point(
465 entries: &[SessionEntry],
466 start_index: usize,
467 end_index: usize,
468 keep_recent_tokens: u32,
469) -> CutPointResult {
470 let cut_points = find_valid_cut_points(entries, start_index, end_index);
471 if cut_points.is_empty() {
472 return CutPointResult {
473 first_kept_entry_index: start_index,
474 turn_start_index: None,
475 is_split_turn: false,
476 };
477 }
478
479 let mut accumulated_tokens: u64 = 0;
480 let mut cut_index = cut_points[0];
481
482 for i in (start_index..end_index).rev() {
483 let entry = &entries[i];
484 let SessionEntry::Message(msg_entry) = entry else {
485 continue;
486 };
487 accumulated_tokens = accumulated_tokens.saturating_add(estimate_tokens(&msg_entry.message));
488
489 if accumulated_tokens >= u64::from(keep_recent_tokens) {
490 let pos = cut_points.partition_point(|&cp| cp <= i);
494 if pos > 0 {
495 cut_index = cut_points[pos - 1];
496 }
497 break;
499 }
500 }
501
502 while cut_index > start_index {
503 let prev = &entries[cut_index - 1];
504 if entry_is_compaction_boundary(prev) {
505 break;
506 }
507 if entry_is_message_like(prev) {
508 break;
509 }
510 cut_index -= 1;
511 }
512
513 let is_user_message = is_user_turn_start(&entries[cut_index]);
514 let turn_start_index = if is_user_message {
515 None
516 } else {
517 find_turn_start_index(entries, cut_index, start_index)
518 };
519
520 CutPointResult {
521 first_kept_entry_index: cut_index,
522 turn_start_index,
523 is_split_turn: !is_user_message && turn_start_index.is_some(),
524 }
525}
526
527const SUMMARIZATION_SYSTEM_PROMPT: &str = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
532
533const SUMMARIZATION_PROMPT: &str = "The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.\n\nUse this EXACT format:\n\n## Goal\n[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned by user]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Ordered list of what should happen next]\n\n## Critical Context\n- [Any data, examples, or references needed to continue]\n- [Or \"(none)\" if not applicable]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
534
535const UPDATE_SUMMARIZATION_PROMPT: &str = "The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.\n\nUpdate the existing structured summary with new information. RULES:\n- PRESERVE all existing information from the previous summary\n- ADD new progress, decisions, and context from the new messages\n- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n- UPDATE \"Next Steps\" based on what was accomplished\n- PRESERVE exact file paths, function names, and error messages\n- If something is no longer relevant, you may remove it\n\nUse this EXACT format:\n\n## Goal\n[Preserve existing goals, add new ones if the task expanded]\n\n## Constraints & Preferences\n- [Preserve existing, add new ones discovered]\n\n## Progress\n### Done\n- [x] [Include previously done items AND newly completed items]\n\n### In Progress\n- [ ] [Current work - update based on progress]\n\n### Blocked\n- [Current blockers - remove if resolved]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale] (preserve all previous, add new)\n\n## Next Steps\n1. [Update based on current state]\n\n## Critical Context\n- [Preserve important context, add new if needed]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
536
537const TURN_PREFIX_SUMMARIZATION_PROMPT: &str = "This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.\n\nSummarize the prefix to provide context for the retained suffix:\n\n## Original Request\n[What did the user ask for in this turn?]\n\n## Early Progress\n- [Key decisions and work done in the prefix]\n\n## Context for Suffix\n- [Information needed to understand the retained recent work]\n\nBe concise. Focus on what's needed to understand the kept suffix.";
538
539fn push_message_separator(out: &mut String) {
540 if !out.is_empty() {
541 out.push_str("\n\n");
542 }
543}
544
545fn user_has_serializable_content(user: &UserMessage) -> bool {
546 match &user.content {
547 UserContent::Text(text) => !text.is_empty(),
548 UserContent::Blocks(blocks) => blocks
549 .iter()
550 .any(|c| matches!(c, ContentBlock::Text(t) if !t.text.is_empty())),
551 }
552}
553
554fn append_user_message(out: &mut String, user: &UserMessage) {
555 if !user_has_serializable_content(user) {
556 return;
557 }
558
559 push_message_separator(out);
560 out.push_str("[User]: ");
561 match &user.content {
562 UserContent::Text(text) => out.push_str(text),
563 UserContent::Blocks(blocks) => {
564 for block in blocks {
565 if let ContentBlock::Text(text) = block {
566 out.push_str(&text.text);
567 }
568 }
569 }
570 }
571}
572
573fn append_custom_message(out: &mut String, custom_type: &str, content: &str) {
574 if content.trim().is_empty() {
575 return;
576 }
577
578 push_message_separator(out);
579 out.push('[');
580 if custom_type.trim().is_empty() {
581 out.push_str("Custom");
582 } else {
583 out.push_str("Custom:");
584 out.push_str(custom_type);
585 }
586 out.push_str("]: ");
587 out.push_str(content);
588}
589
590fn assistant_content_flags(assistant: &AssistantMessage) -> (bool, bool, bool) {
591 let mut has_thinking = false;
592 let mut has_text = false;
593 let mut has_tools = false;
594 for block in &assistant.content {
595 match block {
596 ContentBlock::Thinking(_) => has_thinking = true,
597 ContentBlock::Text(_) => has_text = true,
598 ContentBlock::ToolCall(_) => has_tools = true,
599 ContentBlock::Image(_) => {}
600 }
601 }
602 (has_thinking, has_text, has_tools)
603}
604
605fn append_assistant_thinking(out: &mut String, assistant: &AssistantMessage) {
606 push_message_separator(out);
607 out.push_str("[Assistant thinking]: ");
608 let mut first = true;
609 for block in &assistant.content {
610 if let ContentBlock::Thinking(thinking) = block {
611 if !first {
612 out.push('\n');
613 }
614 out.push_str(&thinking.thinking);
615 first = false;
616 }
617 }
618}
619
620fn append_assistant_text(out: &mut String, assistant: &AssistantMessage) {
621 push_message_separator(out);
622 out.push_str("[Assistant]: ");
623 let mut first = true;
624 for block in &assistant.content {
625 if let ContentBlock::Text(text) = block {
626 if !first {
627 out.push('\n');
628 }
629 out.push_str(&text.text);
630 first = false;
631 }
632 }
633}
634
635fn append_tool_call_arguments(out: &mut String, arguments: &Value) {
636 if let Some(obj) = arguments.as_object() {
637 let mut first_kv = true;
638 for (k, v) in obj {
639 if !first_kv {
640 out.push_str(", ");
641 }
642 out.push_str(k);
643 out.push('=');
644 match serde_json::to_string(v) {
645 Ok(s) => out.push_str(&s),
646 Err(_) => {
647 let _ = write!(out, "{v}");
648 }
649 }
650 first_kv = false;
651 }
652 } else {
653 match serde_json::to_string(arguments) {
654 Ok(s) => out.push_str(&s),
655 Err(_) => {
656 let _ = write!(out, "{arguments}");
657 }
658 }
659 }
660}
661
662fn append_assistant_tool_calls(out: &mut String, assistant: &AssistantMessage) {
663 push_message_separator(out);
664 out.push_str("[Assistant tool calls]: ");
665 let mut first = true;
666 for block in &assistant.content {
667 if let ContentBlock::ToolCall(call) = block {
668 if !first {
669 out.push_str("; ");
670 }
671 out.push_str(&call.name);
672 out.push('(');
673 append_tool_call_arguments(out, &call.arguments);
674 out.push(')');
675 first = false;
676 }
677 }
678}
679
680fn append_assistant_message(out: &mut String, assistant: &AssistantMessage) {
681 let (has_thinking, has_text, has_tools) = assistant_content_flags(assistant);
682 if has_thinking {
683 append_assistant_thinking(out, assistant);
684 }
685 if has_text {
686 append_assistant_text(out, assistant);
687 }
688 if has_tools {
689 append_assistant_tool_calls(out, assistant);
690 }
691}
692
693fn tool_result_has_serializable_content(content: &[ContentBlock]) -> bool {
694 content
695 .iter()
696 .any(|c| matches!(c, ContentBlock::Text(t) if !t.text.is_empty()))
697}
698
699fn append_tool_result_message(out: &mut String, content: &[ContentBlock]) {
700 if !tool_result_has_serializable_content(content) {
701 return;
702 }
703
704 push_message_separator(out);
705 out.push_str("[Tool result]: ");
706 for block in content {
707 if let ContentBlock::Text(text) = block {
708 out.push_str(&text.text);
709 }
710 }
711}
712
713fn collect_text_blocks(blocks: &[ContentBlock]) -> String {
714 let mut out = String::new();
715 let mut first = true;
716 for block in blocks {
717 if let ContentBlock::Text(text) = block {
718 if !first {
719 out.push('\n');
720 }
721 out.push_str(&text.text);
722 first = false;
723 }
724 }
725 out
726}
727
728fn serialize_conversation(messages: &[Message]) -> String {
729 let mut out = String::new();
730
731 for msg in messages {
732 match msg {
733 Message::User(user) => append_user_message(&mut out, user),
734 Message::Custom(custom) => {
735 append_custom_message(&mut out, &custom.custom_type, &custom.content);
736 }
737 Message::Assistant(assistant) => append_assistant_message(&mut out, assistant),
738 Message::ToolResult(tool) => append_tool_result_message(&mut out, &tool.content),
739 }
740 }
741
742 out
743}
744
745async fn complete_simple(
746 provider: Arc<dyn Provider>,
747 system_prompt: &str,
748 prompt_text: String,
749 api_key: &str,
750 reserve_tokens: u32,
751 max_tokens_factor: f64,
752) -> Result<AssistantMessage> {
753 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
754 let max_tokens = (f64::from(reserve_tokens) * max_tokens_factor).floor() as u32;
755 let max_tokens = max_tokens.max(256);
756
757 let context = Context {
758 system_prompt: Some(system_prompt.to_string().into()),
759 messages: vec![Message::User(UserMessage {
760 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(prompt_text))]),
761 timestamp: chrono::Utc::now().timestamp_millis(),
762 })]
763 .into(),
764 tools: Vec::new().into(),
765 };
766
767 let options = StreamOptions {
768 api_key: Some(api_key.to_string()),
769 max_tokens: Some(max_tokens),
770 thinking_level: Some(ThinkingLevel::High),
771 ..Default::default()
772 };
773
774 let mut stream = provider.stream(&context, &options).await?;
775 let mut final_message: Option<AssistantMessage> = None;
776
777 while let Some(event) = stream.next().await {
778 match event? {
779 crate::model::StreamEvent::Done { message, .. } => {
780 final_message = Some(message);
781 }
782 crate::model::StreamEvent::Error { error, .. } => {
783 let msg = error
784 .error_message
785 .unwrap_or_else(|| "Summarization error".to_string());
786 return Err(Error::api(msg));
787 }
788 _ => {}
789 }
790 }
791
792 let message = final_message.ok_or_else(|| Error::api("Stream ended without Done event"))?;
793 if matches!(message.stop_reason, StopReason::Aborted | StopReason::Error) {
794 let msg = message
795 .error_message
796 .unwrap_or_else(|| "Summarization error".to_string());
797 return Err(Error::api(msg));
798 }
799 Ok(message)
800}
801
802async fn generate_summary(
803 messages: &[SessionMessage],
804 provider: Arc<dyn Provider>,
805 api_key: &str,
806 settings: &ResolvedCompactionSettings,
807 custom_instructions: Option<&str>,
808 previous_summary: Option<&str>,
809) -> Result<String> {
810 let base_prompt = if previous_summary.is_some() {
811 UPDATE_SUMMARIZATION_PROMPT
812 } else {
813 SUMMARIZATION_PROMPT
814 };
815
816 let mut prompt = base_prompt.to_string();
817 if let Some(custom) = custom_instructions.filter(|s| !s.trim().is_empty()) {
818 let _ = write!(prompt, "\n\nAdditional focus: {custom}");
819 }
820
821 let llm_messages = messages
822 .iter()
823 .filter_map(session_message_to_model)
824 .collect::<Vec<_>>();
825 let conversation_text = serialize_conversation(&llm_messages);
826
827 let mut prompt_text = format!("<conversation>\n{conversation_text}\n</conversation>\n\n");
828 if let Some(previous) = previous_summary {
829 let _ = write!(
830 prompt_text,
831 "<previous-summary>\n{previous}\n</previous-summary>\n\n"
832 );
833 }
834 prompt_text.push_str(&prompt);
835
836 let assistant = complete_simple(
837 provider,
838 SUMMARIZATION_SYSTEM_PROMPT,
839 prompt_text,
840 api_key,
841 settings.reserve_tokens,
842 0.8,
843 )
844 .await?;
845
846 let text = collect_text_blocks(&assistant.content);
847
848 if text.trim().is_empty() {
849 return Err(Error::api(
850 "Summarization returned empty text; refusing to store empty compaction summary",
851 ));
852 }
853
854 Ok(text)
855}
856
857async fn generate_turn_prefix_summary(
858 messages: &[SessionMessage],
859 provider: Arc<dyn Provider>,
860 api_key: &str,
861 settings: &ResolvedCompactionSettings,
862) -> Result<String> {
863 let llm_messages = messages
864 .iter()
865 .filter_map(session_message_to_model)
866 .collect::<Vec<_>>();
867 let conversation_text = serialize_conversation(&llm_messages);
868 let prompt_text = format!(
869 "<conversation>\n{conversation_text}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
870 );
871
872 let assistant = complete_simple(
873 provider,
874 SUMMARIZATION_SYSTEM_PROMPT,
875 prompt_text,
876 api_key,
877 settings.reserve_tokens,
878 0.5,
879 )
880 .await?;
881
882 let text = collect_text_blocks(&assistant.content);
883
884 if text.trim().is_empty() {
885 return Err(Error::api(
886 "Turn prefix summarization returned empty text; refusing to store empty summary",
887 ));
888 }
889
890 Ok(text)
891}
892
893#[allow(clippy::too_many_lines)]
898pub fn prepare_compaction(
899 path_entries: &[SessionEntry],
900 settings: ResolvedCompactionSettings,
901) -> Option<CompactionPreparation> {
902 if path_entries.is_empty() {
903 return None;
904 }
905
906 if path_entries
907 .last()
908 .is_some_and(|entry| matches!(entry, SessionEntry::Compaction(_)))
909 {
910 return None;
911 }
912
913 let mut prev_compaction_index: Option<usize> = None;
914 for (idx, entry) in path_entries.iter().enumerate().rev() {
915 if matches!(entry, SessionEntry::Compaction(_)) {
916 prev_compaction_index = Some(idx);
917 break;
918 }
919 }
920
921 let boundary_start = prev_compaction_index.map_or(0, |i| i + 1);
922 let boundary_end = path_entries.len();
923
924 let usage_start = prev_compaction_index.unwrap_or(0);
925 let mut usage_messages = Vec::new();
926 for entry in &path_entries[usage_start..boundary_end] {
927 if let Some(msg) = message_from_entry(entry) {
928 usage_messages.push(msg);
929 }
930 }
931 let tokens_before = estimate_context_tokens(&usage_messages).tokens;
936
937 if !should_compact(tokens_before, settings.context_window_tokens, &settings) {
938 return None;
939 }
940
941 let cut_point = find_cut_point(
942 path_entries,
943 boundary_start,
944 boundary_end,
945 settings.keep_recent_tokens,
946 );
947
948 let first_kept_entry = &path_entries[cut_point.first_kept_entry_index];
949 let first_kept_entry_id = first_kept_entry.base_id()?.clone();
950
951 let history_end = if cut_point.is_split_turn {
952 cut_point.turn_start_index?
953 } else {
954 cut_point.first_kept_entry_index
955 };
956
957 let mut messages_to_summarize = Vec::new();
958 for entry in &path_entries[boundary_start..history_end] {
959 if let Some(msg) = message_from_entry(entry) {
960 messages_to_summarize.push(msg);
961 }
962 }
963
964 let mut turn_prefix_messages = Vec::new();
965 if cut_point.is_split_turn {
966 let turn_start = cut_point.turn_start_index?;
967 for entry in &path_entries[turn_start..cut_point.first_kept_entry_index] {
968 if let Some(msg) = message_from_entry(entry) {
969 turn_prefix_messages.push(msg);
970 }
971 }
972 }
973
974 if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
977 return None;
978 }
979
980 let previous_summary = prev_compaction_index.and_then(|idx| match &path_entries[idx] {
981 SessionEntry::Compaction(entry) => Some(entry.summary.clone()),
982 _ => None,
983 });
984
985 let mut file_ops = FileOperations::default();
986
987 if let Some(idx) = prev_compaction_index {
989 if let SessionEntry::Compaction(entry) = &path_entries[idx] {
990 if !entry.from_hook.unwrap_or(false) {
991 if let Some(details) = entry.details.as_ref().and_then(Value::as_object) {
992 if let Some(read_files) = details.get("readFiles").and_then(Value::as_array) {
993 for item in read_files.iter().filter_map(Value::as_str) {
994 file_ops.read.insert(item.to_string());
995 }
996 }
997 if let Some(modified_files) =
998 details.get("modifiedFiles").and_then(Value::as_array)
999 {
1000 for item in modified_files.iter().filter_map(Value::as_str) {
1001 file_ops.edited.insert(item.to_string());
1002 }
1003 }
1004 }
1005 }
1006 }
1007 }
1008
1009 let mut tool_status = build_tool_status_map(&messages_to_summarize);
1010 tool_status.extend(build_tool_status_map(&turn_prefix_messages));
1011
1012 for msg in &messages_to_summarize {
1013 extract_file_ops_from_message(msg, &mut file_ops, &tool_status);
1014 }
1015 for msg in &turn_prefix_messages {
1016 extract_file_ops_from_message(msg, &mut file_ops, &tool_status);
1017 }
1018
1019 Some(CompactionPreparation {
1020 first_kept_entry_id,
1021 messages_to_summarize,
1022 turn_prefix_messages,
1023 is_split_turn: cut_point.is_split_turn,
1024 tokens_before,
1025 previous_summary,
1026 file_ops,
1027 settings,
1028 })
1029}
1030
1031pub async fn summarize_entries(
1032 entries: &[SessionEntry],
1033 provider: Arc<dyn Provider>,
1034 api_key: &str,
1035 reserve_tokens: u32,
1036 custom_instructions: Option<&str>,
1037) -> Result<Option<String>> {
1038 let mut messages = Vec::new();
1039 for entry in entries {
1040 if let Some(message) = message_from_entry(entry) {
1041 messages.push(message);
1042 }
1043 }
1044
1045 if messages.is_empty() {
1046 return Ok(None);
1047 }
1048
1049 let settings = ResolvedCompactionSettings {
1050 enabled: true,
1051 reserve_tokens,
1052 keep_recent_tokens: 0,
1053 ..Default::default()
1054 };
1055
1056 let summary = generate_summary(
1057 &messages,
1058 provider,
1059 api_key,
1060 &settings,
1061 custom_instructions,
1062 None,
1063 )
1064 .await?;
1065
1066 Ok(Some(summary))
1067}
1068
1069pub async fn compact(
1070 preparation: CompactionPreparation,
1071 provider: Arc<dyn Provider>,
1072 api_key: &str,
1073 custom_instructions: Option<&str>,
1074) -> Result<CompactionResult> {
1075 let summary = if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
1076 let history_summary = if preparation.messages_to_summarize.is_empty() {
1077 "No prior history.".to_string()
1078 } else {
1079 generate_summary(
1080 &preparation.messages_to_summarize,
1081 Arc::clone(&provider),
1082 api_key,
1083 &preparation.settings,
1084 custom_instructions,
1085 preparation.previous_summary.as_deref(),
1086 )
1087 .await?
1088 };
1089
1090 let turn_prefix_summary = generate_turn_prefix_summary(
1091 &preparation.turn_prefix_messages,
1092 Arc::clone(&provider),
1093 api_key,
1094 &preparation.settings,
1095 )
1096 .await?;
1097
1098 format!(
1099 "{history_summary}\n\n---\n\n**Turn Context (split turn):**\n\n{turn_prefix_summary}"
1100 )
1101 } else {
1102 generate_summary(
1103 &preparation.messages_to_summarize,
1104 Arc::clone(&provider),
1105 api_key,
1106 &preparation.settings,
1107 custom_instructions,
1108 preparation.previous_summary.as_deref(),
1109 )
1110 .await?
1111 };
1112
1113 let (read_files, modified_files) = compute_file_lists(&preparation.file_ops);
1114 let details = CompactionDetails {
1115 read_files: read_files.clone(),
1116 modified_files: modified_files.clone(),
1117 };
1118
1119 let mut summary = summary;
1120 summary.push_str(&format_file_operations(&read_files, &modified_files));
1121
1122 Ok(CompactionResult {
1123 summary,
1124 first_kept_entry_id: preparation.first_kept_entry_id,
1125 tokens_before: preparation.tokens_before,
1126 details,
1127 })
1128}
1129
1130pub fn compaction_details_to_value(details: &CompactionDetails) -> Result<Value> {
1131 serde_json::to_value(details).map_err(|e| Error::session(format!("Compaction details: {e}")))
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137 use crate::model::{AssistantMessage, ContentBlock, TextContent, Usage};
1138 use serde_json::json;
1139
1140 fn make_user_text(text: &str) -> SessionMessage {
1141 SessionMessage::User {
1142 content: UserContent::Text(text.to_string()),
1143 timestamp: Some(0),
1144 }
1145 }
1146
1147 fn make_assistant_text(text: &str, input: u64, output: u64) -> SessionMessage {
1148 SessionMessage::Assistant {
1149 message: AssistantMessage {
1150 content: vec![ContentBlock::Text(TextContent::new(text))],
1151 api: String::new(),
1152 provider: String::new(),
1153 model: String::new(),
1154 stop_reason: StopReason::Stop,
1155 error_message: None,
1156 timestamp: 0,
1157 usage: Usage {
1158 input,
1159 output,
1160 cache_read: 0,
1161 cache_write: 0,
1162 total_tokens: input + output,
1163 ..Default::default()
1164 },
1165 },
1166 }
1167 }
1168
1169 fn make_assistant_tool_call(name: &str, args: Value) -> SessionMessage {
1170 SessionMessage::Assistant {
1171 message: AssistantMessage {
1172 content: vec![ContentBlock::ToolCall(ToolCall {
1173 id: "call_1".to_string(),
1174 name: name.to_string(),
1175 arguments: args,
1176 thought_signature: None,
1177 })],
1178 api: String::new(),
1179 provider: String::new(),
1180 model: String::new(),
1181 stop_reason: StopReason::ToolUse,
1182 error_message: None,
1183 timestamp: 0,
1184 usage: Usage::default(),
1185 },
1186 }
1187 }
1188
1189 fn make_tool_result(text: &str) -> SessionMessage {
1190 SessionMessage::ToolResult {
1191 tool_call_id: "call_1".to_string(),
1192 tool_name: String::new(),
1193 content: vec![ContentBlock::Text(TextContent::new(text))],
1194 details: None,
1195 is_error: false,
1196 timestamp: None,
1197 }
1198 }
1199
1200 #[test]
1203 fn context_tokens_prefers_total_tokens() {
1204 let usage = Usage {
1205 input: 100,
1206 output: 50,
1207 total_tokens: 200,
1208 ..Default::default()
1209 };
1210 assert_eq!(calculate_context_tokens(&usage), 200);
1211 }
1212
1213 #[test]
1214 fn context_tokens_falls_back_to_input_plus_output() {
1215 let usage = Usage {
1216 input: 100,
1217 output: 50,
1218 total_tokens: 0,
1219 ..Default::default()
1220 };
1221 assert_eq!(calculate_context_tokens(&usage), 150);
1222 }
1223
1224 #[test]
1227 fn should_compact_when_over_threshold() {
1228 let settings = ResolvedCompactionSettings {
1229 enabled: true,
1230 reserve_tokens: 10_000,
1231 keep_recent_tokens: 5_000,
1232 ..Default::default()
1233 };
1234 assert!(should_compact(95_000, 100_000, &settings));
1236 }
1237
1238 #[test]
1239 fn should_not_compact_when_under_threshold() {
1240 let settings = ResolvedCompactionSettings {
1241 enabled: true,
1242 reserve_tokens: 10_000,
1243 keep_recent_tokens: 5_000,
1244 ..Default::default()
1245 };
1246 assert!(!should_compact(80_000, 100_000, &settings));
1248 }
1249
1250 #[test]
1251 fn should_not_compact_when_disabled() {
1252 let settings = ResolvedCompactionSettings {
1253 enabled: false,
1254 reserve_tokens: 0,
1255 keep_recent_tokens: 0,
1256 ..Default::default()
1257 };
1258 assert!(!should_compact(1_000_000, 100_000, &settings));
1259 }
1260
1261 #[test]
1262 fn should_compact_at_exact_threshold() {
1263 let settings = ResolvedCompactionSettings {
1264 enabled: true,
1265 reserve_tokens: 10_000,
1266 keep_recent_tokens: 5_000,
1267 ..Default::default()
1268 };
1269 assert!(!should_compact(90_000, 100_000, &settings));
1271 assert!(should_compact(90_001, 100_000, &settings));
1273 }
1274
1275 #[test]
1278 fn estimate_tokens_user_text() {
1279 let msg = make_user_text("hello world"); assert_eq!(estimate_tokens(&msg), 4);
1281 }
1282
1283 #[test]
1284 fn estimate_tokens_empty_text() {
1285 let msg = make_user_text(""); assert_eq!(estimate_tokens(&msg), 0);
1287 }
1288
1289 #[test]
1290 fn estimate_tokens_assistant_text() {
1291 let msg = make_assistant_text("hello", 10, 5); assert_eq!(estimate_tokens(&msg), 2);
1293 }
1294
1295 #[test]
1296 fn estimate_tokens_tool_result() {
1297 let msg = make_tool_result("file contents here"); assert_eq!(estimate_tokens(&msg), 6);
1299 }
1300
1301 #[test]
1302 fn estimate_tokens_custom_message() {
1303 let msg = SessionMessage::Custom {
1304 custom_type: "system".to_string(),
1305 content: "some custom content".to_string(),
1306 display: true,
1307 details: None,
1308 timestamp: Some(0),
1309 };
1310 assert_eq!(estimate_tokens(&msg), 7);
1312 }
1313
1314 #[test]
1317 fn estimate_context_with_assistant_usage() {
1318 let messages = vec![
1319 make_user_text("hi"),
1320 make_assistant_text("hello", 50, 10),
1321 make_user_text("bye"),
1322 ];
1323 let estimate = estimate_context_tokens(&messages);
1324 assert_eq!(estimate.tokens, 61);
1327 assert_eq!(estimate.last_usage_index, Some(1));
1328 }
1329
1330 #[test]
1331 fn estimate_context_no_assistant() {
1332 let messages = vec![make_user_text("hello"), make_user_text("world")];
1333 let estimate = estimate_context_tokens(&messages);
1334 assert_eq!(estimate.tokens, 4);
1336 assert!(estimate.last_usage_index.is_none());
1337 }
1338
1339 #[test]
1342 fn extract_file_ops_read() {
1343 let msg = make_assistant_tool_call("read", json!({"path": "/foo/bar.rs"}));
1344 let mut ops = FileOperations::default();
1345 let mut status = HashMap::new();
1346 status.insert("call_1".to_string(), true);
1347 extract_file_ops_from_message(&msg, &mut ops, &status);
1348 assert!(ops.read.contains("/foo/bar.rs"));
1349 assert!(ops.written.is_empty());
1350 assert!(ops.edited.is_empty());
1351 }
1352
1353 #[test]
1354 fn extract_file_ops_write() {
1355 let msg = make_assistant_tool_call("write", json!({"path": "/out.txt"}));
1356 let mut ops = FileOperations::default();
1357 let mut status = HashMap::new();
1358 status.insert("call_1".to_string(), true);
1359 extract_file_ops_from_message(&msg, &mut ops, &status);
1360 assert!(ops.written.contains("/out.txt"));
1361 assert!(ops.read.is_empty());
1362 }
1363
1364 #[test]
1365 fn extract_file_ops_edit() {
1366 let msg = make_assistant_tool_call("edit", json!({"path": "/src/main.rs"}));
1367 let mut ops = FileOperations::default();
1368 let mut status = HashMap::new();
1369 status.insert("call_1".to_string(), true);
1370 extract_file_ops_from_message(&msg, &mut ops, &status);
1371 assert!(ops.edited.contains("/src/main.rs"));
1372 }
1373
1374 #[test]
1375 fn extract_file_ops_ignores_failed_tools() {
1376 let msg = make_assistant_tool_call("read", json!({"path": "/secret.rs"}));
1377 let mut ops = FileOperations::default();
1378 let mut status = HashMap::new();
1379 status.insert("call_1".to_string(), false); extract_file_ops_from_message(&msg, &mut ops, &status);
1381 assert!(ops.read.is_empty());
1382 }
1383
1384 #[test]
1385 fn extract_file_ops_ignores_other_tools() {
1386 let msg = make_assistant_tool_call("bash", json!({"command": "ls"}));
1387 let mut ops = FileOperations::default();
1388 let mut status = HashMap::new();
1389 status.insert("call_1".to_string(), true);
1390 extract_file_ops_from_message(&msg, &mut ops, &status);
1391 assert!(ops.read.is_empty());
1392 assert!(ops.written.is_empty());
1393 assert!(ops.edited.is_empty());
1394 }
1395
1396 #[test]
1397 fn extract_file_ops_ignores_user_messages() {
1398 let msg = make_user_text("read the file /foo.rs");
1399 let mut ops = FileOperations::default();
1400 let status = HashMap::new();
1401 extract_file_ops_from_message(&msg, &mut ops, &status);
1402 assert!(ops.read.is_empty());
1403 }
1404
1405 #[test]
1408 fn compute_file_lists_separates_read_from_modified() {
1409 let mut ops = FileOperations::default();
1410 ops.read.insert("/a.rs".to_string());
1411 ops.read.insert("/b.rs".to_string());
1412 ops.written.insert("/b.rs".to_string());
1413 ops.edited.insert("/c.rs".to_string());
1414
1415 let (read_only, modified) = compute_file_lists(&ops);
1416 assert_eq!(read_only, vec!["/a.rs"]);
1418 assert!(modified.contains(&"/b.rs".to_string()));
1419 assert!(modified.contains(&"/c.rs".to_string()));
1420 }
1421
1422 #[test]
1423 fn compute_file_lists_empty() {
1424 let ops = FileOperations::default();
1425 let (read_only, modified) = compute_file_lists(&ops);
1426 assert!(read_only.is_empty());
1427 assert!(modified.is_empty());
1428 }
1429
1430 #[test]
1433 fn format_file_operations_empty() {
1434 assert_eq!(format_file_operations(&[], &[]), String::new());
1435 }
1436
1437 #[test]
1438 fn format_file_operations_read_only() {
1439 let result = format_file_operations(&["src/main.rs".to_string()], &[]);
1440 assert!(result.contains("<read-files>"));
1441 assert!(result.contains("src/main.rs"));
1442 assert!(!result.contains("<modified-files>"));
1443 }
1444
1445 #[test]
1446 fn format_file_operations_both() {
1447 let result = format_file_operations(&["a.rs".to_string()], &["b.rs".to_string()]);
1448 assert!(result.contains("<read-files>"));
1449 assert!(result.contains("a.rs"));
1450 assert!(result.contains("<modified-files>"));
1451 assert!(result.contains("b.rs"));
1452 }
1453
1454 #[test]
1457 fn compaction_details_serializes() {
1458 let details = CompactionDetails {
1459 read_files: vec!["a.rs".to_string()],
1460 modified_files: vec!["b.rs".to_string()],
1461 };
1462 let value = compaction_details_to_value(&details).unwrap();
1463 assert_eq!(value["readFiles"], json!(["a.rs"]));
1464 assert_eq!(value["modifiedFiles"], json!(["b.rs"]));
1465 }
1466
1467 #[test]
1470 fn default_settings() {
1471 let settings = ResolvedCompactionSettings::default();
1472 assert!(settings.enabled);
1473 assert_eq!(settings.reserve_tokens, 16_384);
1474 assert_eq!(settings.keep_recent_tokens, 20_000);
1475 }
1476
1477 use crate::model::{ImageContent, ThinkingContent};
1480 use crate::session::{
1481 BranchSummaryEntry, CompactionEntry, EntryBase, MessageEntry, ModelChangeEntry,
1482 };
1483 use std::collections::HashMap;
1484
1485 fn test_base(id: &str) -> EntryBase {
1486 EntryBase {
1487 id: Some(id.to_string()),
1488 parent_id: None,
1489 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
1490 }
1491 }
1492
1493 fn user_entry(id: &str, text: &str) -> SessionEntry {
1494 SessionEntry::Message(MessageEntry {
1495 base: test_base(id),
1496 message: make_user_text(text),
1497 })
1498 }
1499
1500 fn assistant_entry(id: &str, text: &str, input: u64, output: u64) -> SessionEntry {
1501 SessionEntry::Message(MessageEntry {
1502 base: test_base(id),
1503 message: make_assistant_text(text, input, output),
1504 })
1505 }
1506
1507 fn tool_call_entry(id: &str, tool_name: &str, path: &str) -> SessionEntry {
1508 SessionEntry::Message(MessageEntry {
1509 base: test_base(id),
1510 message: make_assistant_tool_call(tool_name, json!({"path": path})),
1511 })
1512 }
1513
1514 fn tool_result_entry(id: &str, text: &str) -> SessionEntry {
1515 SessionEntry::Message(MessageEntry {
1516 base: test_base(id),
1517 message: make_tool_result(text),
1518 })
1519 }
1520
1521 fn branch_entry(id: &str, summary: &str) -> SessionEntry {
1522 SessionEntry::BranchSummary(BranchSummaryEntry {
1523 base: test_base(id),
1524 from_id: "parent".to_string(),
1525 summary: summary.to_string(),
1526 details: None,
1527 from_hook: None,
1528 })
1529 }
1530
1531 fn compact_entry(id: &str, summary: &str, tokens: u64) -> SessionEntry {
1532 SessionEntry::Compaction(CompactionEntry {
1533 base: test_base(id),
1534 summary: summary.to_string(),
1535 first_kept_entry_id: "kept".to_string(),
1536 tokens_before: tokens,
1537 details: None,
1538 from_hook: None,
1539 })
1540 }
1541
1542 fn bash_entry(id: &str) -> SessionEntry {
1543 SessionEntry::Message(MessageEntry {
1544 base: test_base(id),
1545 message: SessionMessage::BashExecution {
1546 command: "ls".to_string(),
1547 output: "ok".to_string(),
1548 exit_code: 0,
1549 cancelled: None,
1550 truncated: None,
1551 full_output_path: None,
1552 timestamp: None,
1553 extra: HashMap::new(),
1554 },
1555 })
1556 }
1557
1558 #[test]
1561 fn get_assistant_usage_returns_usage_for_stop() {
1562 let msg = make_assistant_text("text", 100, 50);
1563 let usage = get_assistant_usage(&msg);
1564 assert!(usage.is_some());
1565 assert_eq!(usage.unwrap().input, 100);
1566 }
1567
1568 #[test]
1569 fn get_assistant_usage_none_for_aborted() {
1570 let msg = SessionMessage::Assistant {
1571 message: AssistantMessage {
1572 content: vec![ContentBlock::Text(TextContent::new("text"))],
1573 api: String::new(),
1574 provider: String::new(),
1575 model: String::new(),
1576 stop_reason: StopReason::Aborted,
1577 error_message: None,
1578 timestamp: 0,
1579 usage: Usage {
1580 input: 100,
1581 output: 50,
1582 total_tokens: 150,
1583 ..Default::default()
1584 },
1585 },
1586 };
1587 assert!(get_assistant_usage(&msg).is_none());
1588 }
1589
1590 #[test]
1591 fn get_assistant_usage_none_for_error() {
1592 let msg = SessionMessage::Assistant {
1593 message: AssistantMessage {
1594 content: vec![],
1595 api: String::new(),
1596 provider: String::new(),
1597 model: String::new(),
1598 stop_reason: StopReason::Error,
1599 error_message: None,
1600 timestamp: 0,
1601 usage: Usage::default(),
1602 },
1603 };
1604 assert!(get_assistant_usage(&msg).is_none());
1605 }
1606
1607 #[test]
1608 fn get_assistant_usage_none_for_user() {
1609 assert!(get_assistant_usage(&make_user_text("hello")).is_none());
1610 }
1611
1612 #[test]
1615 fn entry_is_message_like_for_message() {
1616 assert!(entry_is_message_like(&user_entry("1", "hi")));
1617 }
1618
1619 #[test]
1620 fn entry_is_message_like_for_branch_summary() {
1621 assert!(entry_is_message_like(&branch_entry("1", "sum")));
1622 }
1623
1624 #[test]
1625 fn entry_is_message_like_false_for_compaction() {
1626 assert!(!entry_is_message_like(&compact_entry("1", "sum", 100)));
1627 }
1628
1629 #[test]
1630 fn entry_is_message_like_false_for_model_change() {
1631 let entry = SessionEntry::ModelChange(ModelChangeEntry {
1632 base: test_base("1"),
1633 provider: "test".to_string(),
1634 model_id: "model-1".to_string(),
1635 });
1636 assert!(!entry_is_message_like(&entry));
1637 }
1638
1639 #[test]
1642 fn compaction_boundary_true_for_compaction() {
1643 assert!(entry_is_compaction_boundary(&compact_entry(
1644 "1", "sum", 100
1645 )));
1646 }
1647
1648 #[test]
1649 fn compaction_boundary_false_for_message() {
1650 assert!(!entry_is_compaction_boundary(&user_entry("1", "hi")));
1651 }
1652
1653 #[test]
1654 fn compaction_boundary_false_for_branch() {
1655 assert!(!entry_is_compaction_boundary(&branch_entry("1", "sum")));
1656 }
1657
1658 #[test]
1661 fn user_turn_start_for_user() {
1662 assert!(is_user_turn_start(&user_entry("1", "hello")));
1663 }
1664
1665 #[test]
1666 fn user_turn_start_for_branch() {
1667 assert!(is_user_turn_start(&branch_entry("1", "summary")));
1668 }
1669
1670 #[test]
1671 fn user_turn_start_for_bash() {
1672 assert!(is_user_turn_start(&bash_entry("1")));
1673 }
1674
1675 #[test]
1676 fn user_turn_start_false_for_assistant() {
1677 assert!(!is_user_turn_start(&assistant_entry("1", "resp", 10, 5)));
1678 }
1679
1680 #[test]
1681 fn user_turn_start_false_for_tool_result() {
1682 assert!(!is_user_turn_start(&tool_result_entry("1", "result")));
1683 }
1684
1685 #[test]
1686 fn user_turn_start_false_for_compaction() {
1687 assert!(!is_user_turn_start(&compact_entry("1", "sum", 100)));
1688 }
1689
1690 #[test]
1693 fn message_from_entry_user() {
1694 let entry = user_entry("1", "hello");
1695 let msg = message_from_entry(&entry);
1696 assert!(msg.is_some());
1697 assert!(matches!(msg.unwrap(), SessionMessage::User { .. }));
1698 }
1699
1700 #[test]
1701 fn message_from_entry_branch_summary() {
1702 let entry = branch_entry("1", "branch summary text");
1703 let msg = message_from_entry(&entry).unwrap();
1704 if let SessionMessage::BranchSummary { summary, from_id } = msg {
1705 assert_eq!(summary, "branch summary text");
1706 assert_eq!(from_id, "parent");
1707 } else {
1708 panic!("expected BranchSummary");
1709 }
1710 }
1711
1712 #[test]
1713 fn message_from_entry_compaction() {
1714 let entry = compact_entry("1", "compact summary", 500);
1715 let msg = message_from_entry(&entry).unwrap();
1716 if let SessionMessage::CompactionSummary {
1717 summary,
1718 tokens_before,
1719 } = msg
1720 {
1721 assert_eq!(summary, "compact summary");
1722 assert_eq!(tokens_before, 500);
1723 } else {
1724 panic!("expected CompactionSummary");
1725 }
1726 }
1727
1728 #[test]
1729 fn message_from_entry_model_change_is_none() {
1730 let entry = SessionEntry::ModelChange(ModelChangeEntry {
1731 base: test_base("1"),
1732 provider: "test".to_string(),
1733 model_id: "model".to_string(),
1734 });
1735 assert!(message_from_entry(&entry).is_none());
1736 }
1737
1738 #[test]
1741 fn find_valid_cut_points_empty() {
1742 assert!(find_valid_cut_points(&[], 0, 0).is_empty());
1743 }
1744
1745 #[test]
1746 fn find_valid_cut_points_skips_tool_results() {
1747 let entries = vec![
1748 user_entry("1", "hello"),
1749 assistant_entry("2", "resp", 10, 5),
1750 tool_result_entry("3", "result"),
1751 user_entry("4", "follow up"),
1752 ];
1753 let cuts = find_valid_cut_points(&entries, 0, entries.len());
1754 assert!(cuts.contains(&0)); assert!(cuts.contains(&1)); assert!(!cuts.contains(&2)); assert!(cuts.contains(&3)); }
1759
1760 #[test]
1761 fn find_valid_cut_points_includes_branch_summary() {
1762 let entries = vec![branch_entry("1", "summary"), user_entry("2", "hello")];
1763 let cuts = find_valid_cut_points(&entries, 0, entries.len());
1764 assert!(cuts.contains(&0));
1765 assert!(cuts.contains(&1));
1766 }
1767
1768 #[test]
1769 fn find_valid_cut_points_respects_range() {
1770 let entries = vec![
1771 user_entry("1", "a"),
1772 user_entry("2", "b"),
1773 user_entry("3", "c"),
1774 ];
1775 let cuts = find_valid_cut_points(&entries, 1, 2);
1776 assert!(!cuts.contains(&0));
1777 assert!(cuts.contains(&1));
1778 assert!(!cuts.contains(&2));
1779 }
1780
1781 #[test]
1784 fn find_turn_start_basic() {
1785 let entries = vec![
1786 user_entry("1", "hello"),
1787 assistant_entry("2", "resp", 10, 5),
1788 tool_result_entry("3", "result"),
1789 ];
1790 assert_eq!(find_turn_start_index(&entries, 2, 0), Some(0));
1791 }
1792
1793 #[test]
1794 fn find_turn_start_at_self() {
1795 let entries = vec![user_entry("1", "hello")];
1796 assert_eq!(find_turn_start_index(&entries, 0, 0), Some(0));
1797 }
1798
1799 #[test]
1800 fn find_turn_start_none_no_user() {
1801 let entries = vec![
1802 assistant_entry("1", "resp", 10, 5),
1803 tool_result_entry("2", "result"),
1804 ];
1805 assert_eq!(find_turn_start_index(&entries, 1, 0), None);
1806 }
1807
1808 #[test]
1809 fn find_turn_start_respects_start_index() {
1810 let entries = vec![
1811 user_entry("1", "old"),
1812 assistant_entry("2", "resp", 10, 5),
1813 user_entry("3", "new"),
1814 ];
1815 assert_eq!(find_turn_start_index(&entries, 2, 2), Some(2));
1817 assert_eq!(find_turn_start_index(&entries, 1, 2), None);
1819 }
1820
1821 #[test]
1824 fn serialize_conversation_user_text() {
1825 let messages = vec![Message::User(crate::model::UserMessage {
1826 content: UserContent::Text("hello world".to_string()),
1827 timestamp: 0,
1828 })];
1829 assert_eq!(serialize_conversation(&messages), "[User]: hello world");
1830 }
1831
1832 #[test]
1833 fn serialize_conversation_empty() {
1834 assert!(serialize_conversation(&[]).is_empty());
1835 }
1836
1837 #[test]
1838 fn serialize_conversation_skips_empty_user() {
1839 let messages = vec![Message::User(crate::model::UserMessage {
1840 content: UserContent::Text(String::new()),
1841 timestamp: 0,
1842 })];
1843 assert!(serialize_conversation(&messages).is_empty());
1844 }
1845
1846 #[test]
1847 fn serialize_conversation_assistant_text() {
1848 let messages = vec![Message::assistant(AssistantMessage {
1849 content: vec![ContentBlock::Text(TextContent::new("response"))],
1850 api: String::new(),
1851 provider: String::new(),
1852 model: String::new(),
1853 usage: Usage::default(),
1854 stop_reason: StopReason::Stop,
1855 error_message: None,
1856 timestamp: 0,
1857 })];
1858 assert!(serialize_conversation(&messages).contains("[Assistant]: response"));
1859 }
1860
1861 #[test]
1862 fn serialize_conversation_tool_calls() {
1863 let messages = vec![Message::assistant(AssistantMessage {
1864 content: vec![ContentBlock::ToolCall(ToolCall {
1865 id: "c1".to_string(),
1866 name: "read".to_string(),
1867 arguments: json!({"path": "/main.rs"}),
1868 thought_signature: None,
1869 })],
1870 api: String::new(),
1871 provider: String::new(),
1872 model: String::new(),
1873 usage: Usage::default(),
1874 stop_reason: StopReason::Stop,
1875 error_message: None,
1876 timestamp: 0,
1877 })];
1878 let result = serialize_conversation(&messages);
1879 assert!(result.contains("[Assistant tool calls]: read("));
1880 assert!(result.contains("path="));
1881 }
1882
1883 #[test]
1884 fn serialize_conversation_thinking() {
1885 let messages = vec![Message::assistant(AssistantMessage {
1886 content: vec![ContentBlock::Thinking(ThinkingContent {
1887 thinking: "let me think".to_string(),
1888 thinking_signature: None,
1889 })],
1890 api: String::new(),
1891 provider: String::new(),
1892 model: String::new(),
1893 usage: Usage::default(),
1894 stop_reason: StopReason::Stop,
1895 error_message: None,
1896 timestamp: 0,
1897 })];
1898 assert!(serialize_conversation(&messages).contains("[Assistant thinking]: let me think"));
1899 }
1900
1901 #[test]
1902 fn serialize_conversation_tool_result() {
1903 let messages = vec![Message::tool_result(crate::model::ToolResultMessage {
1904 tool_call_id: "c1".to_string(),
1905 tool_name: "read".to_string(),
1906 content: vec![ContentBlock::Text(TextContent::new("file contents"))],
1907 details: None,
1908 is_error: false,
1909 timestamp: 0,
1910 })];
1911 assert!(serialize_conversation(&messages).contains("[Tool result]: file contents"));
1912 }
1913
1914 #[test]
1917 fn estimate_tokens_image_block() {
1918 let msg = SessionMessage::User {
1919 content: UserContent::Blocks(vec![ContentBlock::Image(ImageContent {
1920 data: "base64data".to_string(),
1921 mime_type: "image/png".to_string(),
1922 })]),
1923 timestamp: None,
1924 };
1925 assert_eq!(estimate_tokens(&msg), 1200);
1927 }
1928
1929 #[test]
1930 fn estimate_tokens_thinking() {
1931 let msg = SessionMessage::User {
1932 content: UserContent::Blocks(vec![ContentBlock::Thinking(ThinkingContent {
1933 thinking: "a".repeat(20),
1934 thinking_signature: None,
1935 })]),
1936 timestamp: None,
1937 };
1938 assert_eq!(estimate_tokens(&msg), 7);
1940 }
1941
1942 #[test]
1943 fn estimate_tokens_bash_execution() {
1944 let msg = SessionMessage::BashExecution {
1945 command: "echo hi".to_string(),
1946 output: "hi\n".to_string(),
1947 exit_code: 0,
1948 cancelled: None,
1949 truncated: None,
1950 full_output_path: None,
1951 timestamp: None,
1952 extra: HashMap::new(),
1953 };
1954 assert_eq!(estimate_tokens(&msg), 4);
1956 }
1957
1958 #[test]
1959 fn estimate_tokens_branch_summary() {
1960 let msg = SessionMessage::BranchSummary {
1961 summary: "a".repeat(40),
1962 from_id: "id".to_string(),
1963 };
1964 assert_eq!(estimate_tokens(&msg), 14);
1966 }
1967
1968 #[test]
1969 fn estimate_tokens_compaction_summary() {
1970 let msg = SessionMessage::CompactionSummary {
1971 summary: "a".repeat(80),
1972 tokens_before: 5000,
1973 };
1974 assert_eq!(estimate_tokens(&msg), 27);
1976 }
1977
1978 #[test]
1981 fn prepare_compaction_empty() {
1982 assert!(prepare_compaction(&[], ResolvedCompactionSettings::default()).is_none());
1983 }
1984
1985 #[test]
1986 fn prepare_compaction_last_is_compaction_returns_none() {
1987 let entries = vec![user_entry("1", "hello"), compact_entry("2", "summary", 100)];
1988 assert!(prepare_compaction(&entries, ResolvedCompactionSettings::default()).is_none());
1989 }
1990
1991 #[test]
1992 fn prepare_compaction_no_messages_to_summarize_returns_none() {
1993 let entries = vec![SessionEntry::ModelChange(ModelChangeEntry {
1995 base: test_base("1"),
1996 provider: "test".to_string(),
1997 model_id: "model".to_string(),
1998 })];
1999 assert!(prepare_compaction(&entries, ResolvedCompactionSettings::default()).is_none());
2000 }
2001
2002 #[test]
2003 fn prepare_compaction_basic_returns_some() {
2004 let long_text = "a".repeat(100_000);
2005 let entries = vec![
2006 user_entry("1", &long_text),
2007 assistant_entry("2", &long_text, 50000, 25000),
2008 user_entry("3", &long_text),
2009 assistant_entry("4", &long_text, 80000, 30000),
2010 user_entry("5", "recent"),
2011 ];
2012 let settings = ResolvedCompactionSettings {
2013 enabled: true,
2014 context_window_tokens: 100_000,
2015 reserve_tokens: 1000,
2016 keep_recent_tokens: 100,
2017 };
2018 let prep = prepare_compaction(&entries, settings);
2019 assert!(prep.is_some());
2020 let p = prep.unwrap();
2021 assert!(!p.messages_to_summarize.is_empty());
2022 assert!(p.tokens_before > 0);
2023 assert!(p.previous_summary.is_none());
2024 }
2025
2026 #[test]
2027 fn prepare_compaction_after_previous_compaction() {
2028 let entries = vec![
2029 user_entry("1", "old message"),
2030 assistant_entry("2", "old response", 100, 50),
2031 compact_entry("3", "previous summary", 300),
2032 user_entry("4", &"x".repeat(100_000)),
2033 assistant_entry("5", &"y".repeat(100_000), 80000, 30000),
2034 user_entry("6", "recent"),
2035 ];
2036 let settings = ResolvedCompactionSettings {
2037 enabled: true,
2038 context_window_tokens: 100_000,
2039 reserve_tokens: 1000,
2040 keep_recent_tokens: 100,
2041 };
2042 let prep = prepare_compaction(&entries, settings);
2043 assert!(prep.is_some());
2044 let p = prep.unwrap();
2045 assert_eq!(p.previous_summary.as_deref(), Some("previous summary"));
2046 }
2047
2048 #[test]
2049 fn prepare_compaction_tracks_file_ops() {
2050 let entries = vec![
2051 tool_call_entry("1", "read", "/src/main.rs"),
2052 tool_result_entry("1r", "ok"),
2053 tool_call_entry("2", "edit", "/src/lib.rs"),
2054 tool_result_entry("2r", "ok"),
2055 user_entry("3", &"x".repeat(100_000)),
2056 assistant_entry("4", &"y".repeat(100_000), 80000, 30000),
2057 user_entry("5", "recent"),
2058 ];
2059 let settings = ResolvedCompactionSettings {
2060 enabled: true,
2061 reserve_tokens: 1000,
2062 keep_recent_tokens: 100,
2063 ..Default::default()
2064 };
2065 if let Some(prep) = prepare_compaction(&entries, settings) {
2066 let has_read = prep.file_ops.read.contains("/src/main.rs");
2067 let has_edit = prep.file_ops.edited.contains("/src/lib.rs");
2068 assert!(has_read || has_edit || prep.file_ops.read.is_empty());
2070 }
2071 }
2072
2073 #[test]
2076 fn file_operations_read_files_iterator() {
2077 let mut ops = FileOperations::default();
2078 ops.read.insert("/a.rs".to_string());
2079 ops.read.insert("/b.rs".to_string());
2080 let files: Vec<&str> = ops.read_files().collect();
2081 assert_eq!(files.len(), 2);
2082 assert!(files.contains(&"/a.rs"));
2083 assert!(files.contains(&"/b.rs"));
2084 }
2085
2086 #[test]
2087 fn find_cut_point_includes_tool_result_when_needed() {
2088 let tr_text = "x".repeat(400);
2111 let entries = vec![
2112 user_entry("0", "user"), assistant_entry("1", "call", 10, 10), tool_result_entry("2", &tr_text), user_entry("3", "user"), assistant_entry("4", "resp", 10, 10), ];
2118
2119 let settings = ResolvedCompactionSettings {
2129 enabled: true,
2130 context_window_tokens: 15,
2131 reserve_tokens: 0,
2132 keep_recent_tokens: 100,
2133 };
2134
2135 let prep = prepare_compaction(&entries, settings).expect("should compact");
2136
2137 assert_eq!(prep.first_kept_entry_id, "1");
2141
2142 assert!(
2145 prep.messages_to_summarize.is_empty(),
2146 "split turn: user goes into turn prefix, not summarize"
2147 );
2148
2149 assert_eq!(prep.turn_prefix_messages.len(), 1);
2151 match &prep.turn_prefix_messages[0] {
2152 SessionMessage::User { content, .. } => {
2153 if let UserContent::Text(t) = content {
2154 assert_eq!(t, "user");
2155 } else {
2156 panic!("wrong content");
2157 }
2158 }
2159 _ => panic!("expected user message in turn prefix"),
2160 }
2161 }
2162
2163 #[test]
2164 fn find_cut_point_should_not_discard_context_to_skip_tool_chain() {
2165 let entries = vec![
2181 user_entry("0", &"x".repeat(4000)), assistant_entry("1", &"x".repeat(400), 50, 50), tool_result_entry("2", &"x".repeat(400)), user_entry("3", "next"), ];
2186
2187 let settings = ResolvedCompactionSettings {
2188 enabled: true,
2189 context_window_tokens: 200,
2190 reserve_tokens: 0,
2191 keep_recent_tokens: 150,
2192 };
2193
2194 let prep = prepare_compaction(&entries, settings).expect("should compact");
2196
2197 assert_eq!(
2200 prep.first_kept_entry_id, "1",
2201 "Should start at Assistant message to preserve context"
2202 );
2203 assert!(
2204 prep.is_split_turn,
2205 "Cut should split the user/assistant turn"
2206 );
2207 assert_eq!(
2208 prep.turn_prefix_messages.len(),
2209 1,
2210 "User entry at index 0 should be in the turn prefix"
2211 );
2212 assert!(
2213 prep.messages_to_summarize.is_empty(),
2214 "Nothing before the turn to summarize"
2215 );
2216 }
2217
2218 mod proptest_compaction {
2219 use super::*;
2220 use proptest::prelude::*;
2221
2222 proptest! {
2223 #[test]
2225 fn calc_context_tokens_total_wins(
2226 input in 0..1_000_000u64,
2227 output in 0..1_000_000u64,
2228 total in 1..2_000_000u64,
2229 ) {
2230 let usage = Usage {
2231 input,
2232 output,
2233 total_tokens: total,
2234 ..Usage::default()
2235 };
2236 assert_eq!(calculate_context_tokens(&usage), total);
2237 }
2238
2239 #[test]
2241 fn calc_context_tokens_fallback(
2242 input in 0..1_000_000u64,
2243 output in 0..1_000_000u64,
2244 ) {
2245 let usage = Usage {
2246 input,
2247 output,
2248 total_tokens: 0,
2249 ..Usage::default()
2250 };
2251 assert_eq!(calculate_context_tokens(&usage), input + output);
2252 }
2253
2254 #[test]
2256 fn should_compact_disabled_returns_false(
2257 ctx_tokens in 0..1_000_000u64,
2258 window in 0..500_000u32,
2259 ) {
2260 let settings = ResolvedCompactionSettings {
2261 enabled: false,
2262 context_window_tokens: window,
2263 reserve_tokens: 16_384,
2264 keep_recent_tokens: 20_000,
2265 };
2266 assert!(!should_compact(ctx_tokens, window, &settings));
2267 }
2268
2269 #[test]
2271 fn should_compact_threshold(
2272 ctx_tokens in 0..500_000u64,
2273 window in 0..300_000u32,
2274 reserve in 0..100_000u32,
2275 ) {
2276 let settings = ResolvedCompactionSettings {
2277 enabled: true,
2278 context_window_tokens: window,
2279 reserve_tokens: reserve,
2280 keep_recent_tokens: 20_000,
2281 };
2282 let threshold = u64::from(window).saturating_sub(u64::from(reserve));
2283 let result = should_compact(ctx_tokens, window, &settings);
2284 assert_eq!(result, ctx_tokens > threshold);
2285 }
2286
2287 #[test]
2289 fn format_file_ops_empty(_dummy in 0..10u32) {
2290 let result = format_file_operations(&[], &[]);
2291 assert!(result.is_empty());
2292 }
2293
2294 #[test]
2296 fn format_file_ops_read_tag(
2297 files in prop::collection::vec("[a-z./]{1,20}", 1..5),
2298 ) {
2299 let result = format_file_operations(&files, &[]);
2300 assert!(result.contains("<read-files>"));
2301 assert!(result.contains("</read-files>"));
2302 assert!(!result.contains("<modified-files>"));
2303 for f in &files {
2304 assert!(result.contains(f.as_str()));
2305 }
2306 }
2307
2308 #[test]
2310 fn format_file_ops_modified_tag(
2311 files in prop::collection::vec("[a-z./]{1,20}", 1..5),
2312 ) {
2313 let result = format_file_operations(&[], &files);
2314 assert!(!result.contains("<read-files>"));
2315 assert!(result.contains("<modified-files>"));
2316 assert!(result.contains("</modified-files>"));
2317 for f in &files {
2318 assert!(result.contains(f.as_str()));
2319 }
2320 }
2321
2322 #[test]
2324 fn compute_file_lists_set_algebra(
2325 read in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2326 written in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2327 edited in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2328 ) {
2329 let file_ops = FileOperations {
2330 read: read.clone(),
2331 written: written.clone(),
2332 edited: edited.clone(),
2333 };
2334 let (read_only, modified) = compute_file_lists(&file_ops);
2335 let expected_modified: HashSet<&String> =
2337 edited.iter().chain(written.iter()).collect();
2338 let actual_modified: HashSet<&String> = modified.iter().collect();
2339 assert_eq!(actual_modified, expected_modified);
2340 for f in &read_only {
2342 assert!(!modified.contains(f), "overlap: {f}");
2343 assert!(read.contains(f));
2344 }
2345 for pair in read_only.windows(2) {
2347 assert!(pair[0] <= pair[1]);
2348 }
2349 for pair in modified.windows(2) {
2350 assert!(pair[0] <= pair[1]);
2351 }
2352 }
2353 }
2354 }
2355}