Skip to main content

pi/core/compaction/
mod.rs

1//! Context compaction for long sessions.
2//!
3//! Pure functions for compaction logic. Session I/O lives in
4//! [`crate::core::sessions`]. Ports
5//! `.references/pi/packages/coding-agent/src/core/compaction/compaction.ts`.
6
7mod branch;
8mod utils;
9
10pub use branch::{
11    BRANCH_SUMMARY_PREAMBLE, BRANCH_SUMMARY_PROMPT, BranchPreparation, BranchSummaryDetails,
12    BranchSummaryResult, CollectEntriesResult, DEFAULT_BRANCH_CONTEXT_WINDOW,
13    DEFAULT_BRANCH_MAX_TOKENS, DEFAULT_BRANCH_RESERVE_TOKENS, GenerateBranchSummaryOptions,
14    collect_entries_for_branch_summary, generate_branch_summary, prepare_branch_entries,
15};
16pub use utils::{
17    FileOperations, SUMMARIZATION_SYSTEM_PROMPT, TOOL_RESULT_MAX_CHARS, compute_file_lists,
18    create_file_ops, extract_file_ops_from_message, format_file_operations, serialize_conversation,
19};
20
21use std::collections::BTreeMap;
22use std::fmt::Write as _;
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26
27use futures::StreamExt;
28use pi_agent::AgentMessage;
29use pi_ai::{
30    AssistantContent, AssistantMessage, AssistantMessageEvent, Context, Message, Model,
31    ModelThinkingLevel, ProviderError, StopReason, StreamOptions, TextContent, Usage, UserContent,
32    UserMessage, UserMessageContent,
33};
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use thiserror::Error;
37use tokio_util::sync::CancellationToken;
38
39use crate::core::messages::{MessageConversionError, convert_to_llm};
40use crate::core::sessions::{
41    LeafRef, SessionEntry, build_session_context, session_entry_to_context_messages,
42};
43
44// ---------------------------------------------------------------------------
45// Constants / settings
46// ---------------------------------------------------------------------------
47
48/// Default compaction settings (settings.json + pure defaults).
49pub const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = CompactionSettings {
50    enabled: true,
51    reserve_tokens: 16_384,
52    keep_recent_tokens: 20_000,
53};
54
55/// Estimated character weight of one image when estimating tokens.
56const ESTIMATED_IMAGE_CHARS: usize = 4800;
57
58/// Initial history summarization prompt (exact TS text).
59pub const 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.";
60
61/// Update history summarization prompt when a previous summary exists.
62pub const 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.";
63
64/// Turn-prefix summarization prompt used when a cut splits a turn.
65pub const 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.";
66
67// ---------------------------------------------------------------------------
68// Types
69// ---------------------------------------------------------------------------
70
71/// Compaction settings (`enabled`, reserve, keep-recent).
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct CompactionSettings {
75    /// Whether automatic compaction is enabled.
76    pub enabled: bool,
77    /// Tokens reserved for the prompt + summary response.
78    pub reserve_tokens: u64,
79    /// Approximate tokens of recent context to keep after the cut.
80    pub keep_recent_tokens: u64,
81}
82
83impl Default for CompactionSettings {
84    fn default() -> Self {
85        DEFAULT_COMPACTION_SETTINGS
86    }
87}
88
89/// Details stored on a compaction entry for cumulative file tracking.
90#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct CompactionDetails {
93    /// Paths that were only read (not modified).
94    pub read_files: Vec<String>,
95    /// Paths that were edited or written.
96    pub modified_files: Vec<String>,
97}
98
99/// Result returned by [`compact`] before `SessionManager` assigns ids.
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct CompactionResult {
103    /// Structured summary text (with optional file-op appendices).
104    pub summary: String,
105    /// First kept entry id after the cut.
106    pub first_kept_entry_id: String,
107    /// Estimated/observed context tokens before compaction.
108    pub tokens_before: u64,
109    /// Optional estimated tokens after compaction (extension-filled).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub estimated_tokens_after: Option<u64>,
112    /// File tracking / extension details.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub details: Option<Value>,
115    /// `true` when an extension hook supplied this result.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub from_hook: Option<bool>,
118}
119
120/// Context-token estimate anchored on the last valid assistant usage.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct ContextUsageEstimate {
123    /// `usage_tokens + trailing_tokens`.
124    pub tokens: u64,
125    /// Tokens from the last valid assistant usage (0 when none).
126    pub usage_tokens: u64,
127    /// Estimated tokens after the usage anchor (or all messages when none).
128    pub trailing_tokens: u64,
129    /// Index of the last valid assistant usage, or `None`.
130    pub last_usage_index: Option<usize>,
131}
132
133/// Cut-point selection result.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct CutPointResult {
136    /// Index of the first entry to keep.
137    pub first_kept_entry_index: usize,
138    /// Index of the turn-start entry when splitting, else `usize::MAX` (`-1`).
139    pub turn_start_index: usize,
140    /// Whether the cut splits a multi-message turn.
141    pub is_split_turn: bool,
142}
143
144/// Pure preparation produced by [`prepare_compaction`] for hooks / [`compact`].
145#[derive(Clone, Debug)]
146pub struct CompactionPreparation {
147    /// UUID of the first kept entry.
148    pub first_kept_entry_id: String,
149    /// Messages that will be summarized and discarded.
150    pub messages_to_summarize: Vec<AgentMessage>,
151    /// Turn-prefix messages when splitting a turn.
152    pub turn_prefix_messages: Vec<AgentMessage>,
153    /// Whether this is a split-turn cut.
154    pub is_split_turn: bool,
155    /// Context tokens before compaction.
156    pub tokens_before: u64,
157    /// Previous compaction summary for iterative update.
158    pub previous_summary: Option<String>,
159    /// File operations extracted from messages / previous details.
160    pub file_ops: FileOperations,
161    /// Compaction settings used for this preparation.
162    pub settings: CompactionSettings,
163}
164
165/// Errors from pure compaction / summarization.
166#[derive(Debug, Error)]
167pub enum CompactionError {
168    /// Last path entry is already a compaction summary.
169    #[error("Already compacted")]
170    AlreadyCompacted,
171    /// Session is too small to compact (nothing outside the keep window).
172    #[error("Nothing to compact (session too small)")]
173    NothingToCompact,
174    /// First kept entry is missing an id (pre-migration session).
175    #[error("First kept entry has no UUID - session may need migration")]
176    MissingFirstKeptId,
177    /// History summarization failed.
178    #[error("Summarization failed: {0}")]
179    SummarizationFailed(String),
180    /// Turn-prefix summarization failed.
181    #[error("Turn prefix summarization failed: {0}")]
182    TurnPrefixSummarizationFailed(String),
183    /// Summarization was cancelled.
184    #[error("Summarization cancelled")]
185    Cancelled,
186    /// Message conversion / session context projection failed.
187    #[error(transparent)]
188    MessageConversion(#[from] MessageConversionError),
189    /// Provider stream failed before producing a terminal message.
190    #[error("Summarization failed: {0}")]
191    Provider(#[from] ProviderError),
192}
193
194/// Outcome of a [`CompactionHooks::before_compact`] call.
195#[derive(Clone, Debug, Default)]
196pub struct BeforeCompactResult {
197    /// When true, compaction is cancelled by the extension.
198    pub cancel: bool,
199    /// When set, replaces the LLM-generated compaction result (`fromHook`).
200    pub compaction: Option<CompactionResult>,
201}
202
203/// Extension hook seam for compaction (default no-op; no pi-ext dependency).
204pub trait CompactionHooks: Send + Sync {
205    /// Called after preparation, before summarization.
206    fn before_compact(
207        &self,
208        _preparation: &CompactionPreparation,
209        _custom_instructions: Option<&str>,
210        _cancel: &CancellationToken,
211    ) -> Pin<Box<dyn Future<Output = Result<BeforeCompactResult, CompactionError>> + Send + '_>>
212    {
213        Box::pin(async { Ok(BeforeCompactResult::default()) })
214    }
215
216    /// Called after a compaction result is produced (including hook replacement).
217    fn after_compact(
218        &self,
219        _result: &CompactionResult,
220        _from_hook: bool,
221    ) -> Pin<Box<dyn Future<Output = Result<(), CompactionError>> + Send + '_>> {
222        Box::pin(async { Ok(()) })
223    }
224}
225
226/// No-op default hooks.
227#[derive(Clone, Copy, Debug, Default)]
228pub struct NoopCompactionHooks;
229
230impl CompactionHooks for NoopCompactionHooks {}
231
232/// Injected summarizer stream: `(model, context, options) -> stream of events`.
233pub type SummarizeStreamFn = Arc<
234    dyn Fn(
235            Model,
236            Context,
237            StreamOptions,
238        ) -> Pin<
239            Box<
240                dyn Future<
241                        Output = Pin<
242                            Box<
243                                dyn futures::Stream<
244                                        Item = Result<AssistantMessageEvent, ProviderError>,
245                                    > + Send,
246                            >,
247                        >,
248                    > + Send,
249            >,
250        > + Send
251        + Sync,
252>;
253
254// ---------------------------------------------------------------------------
255// Token calculation
256// ---------------------------------------------------------------------------
257
258/// Calculate total context tokens from usage.
259///
260/// Uses `total_tokens` when non-zero, else `input + output + cache_read + cache_write`.
261#[must_use]
262pub fn calculate_context_tokens(usage: &Usage) -> u64 {
263    if usage.total_tokens != 0 {
264        usage.total_tokens
265    } else {
266        usage
267            .input
268            .saturating_add(usage.output)
269            .saturating_add(usage.cache_read)
270            .saturating_add(usage.cache_write)
271    }
272}
273
274fn get_assistant_usage(msg: &AgentMessage) -> Option<&Usage> {
275    let AgentMessage::Llm(llm) = msg else {
276        return None;
277    };
278    let Message::Assistant(assistant) = llm.as_ref() else {
279        return None;
280    };
281    if matches!(
282        assistant.stop_reason,
283        StopReason::Aborted | StopReason::Error
284    ) {
285        return None;
286    }
287    if calculate_context_tokens(&assistant.usage) == 0 {
288        return None;
289    }
290    Some(&assistant.usage)
291}
292
293/// Find the last valid assistant message usage from session entries.
294#[must_use]
295pub fn get_last_assistant_usage(entries: &[&SessionEntry]) -> Option<Usage> {
296    for entry in entries.iter().rev() {
297        if let SessionEntry::Message(m) = entry
298            && let Some(usage) = get_assistant_usage(&m.message)
299        {
300            return Some(usage.clone());
301        }
302    }
303    None
304}
305
306fn get_last_assistant_usage_info(messages: &[AgentMessage]) -> Option<(Usage, usize)> {
307    for (i, msg) in messages.iter().enumerate().rev() {
308        if let Some(usage) = get_assistant_usage(msg) {
309            return Some((usage.clone(), i));
310        }
311    }
312    None
313}
314
315/// Estimate context tokens from messages, using the last assistant usage when available.
316#[must_use]
317pub fn estimate_context_tokens(messages: &[AgentMessage]) -> ContextUsageEstimate {
318    match get_last_assistant_usage_info(messages) {
319        None => {
320            let estimated: u64 = messages.iter().map(estimate_tokens).sum();
321            ContextUsageEstimate {
322                tokens: estimated,
323                usage_tokens: 0,
324                trailing_tokens: estimated,
325                last_usage_index: None,
326            }
327        }
328        Some((usage, index)) => {
329            let usage_tokens = calculate_context_tokens(&usage);
330            let trailing_tokens: u64 = messages
331                .iter()
332                .skip(index.saturating_add(1))
333                .map(estimate_tokens)
334                .sum();
335            ContextUsageEstimate {
336                tokens: usage_tokens.saturating_add(trailing_tokens),
337                usage_tokens,
338                trailing_tokens,
339                last_usage_index: Some(index),
340            }
341        }
342    }
343}
344
345/// Check if compaction should trigger based on context usage.
346///
347/// Threshold is strict `>`: `context_tokens > context_window - reserve_tokens`.
348#[must_use]
349pub fn should_compact(
350    context_tokens: u64,
351    context_window: u64,
352    settings: &CompactionSettings,
353) -> bool {
354    if !settings.enabled {
355        return false;
356    }
357    i128::from(context_tokens) > i128::from(context_window) - i128::from(settings.reserve_tokens)
358}
359
360// ---------------------------------------------------------------------------
361// Token estimation (chars / 4)
362// ---------------------------------------------------------------------------
363
364fn js_string_len(text: &str) -> usize {
365    text.encode_utf16().count()
366}
367
368fn estimate_text_and_image_user_content(content: &UserMessageContent) -> usize {
369    match content {
370        UserMessageContent::Text(text) => js_string_len(text),
371        UserMessageContent::Blocks(blocks) => blocks
372            .iter()
373            .map(|block| match block {
374                UserContent::Text(text) => js_string_len(&text.text.read()),
375                UserContent::Image(_) => ESTIMATED_IMAGE_CHARS,
376            })
377            .sum(),
378    }
379}
380
381fn estimate_tool_result_chars(content: &[pi_ai::ToolResultContent]) -> usize {
382    content
383        .iter()
384        .map(|block| match block {
385            pi_ai::ToolResultContent::Text(text) => js_string_len(&text.text.read()),
386            pi_ai::ToolResultContent::Image(_) => ESTIMATED_IMAGE_CHARS,
387        })
388        .sum()
389}
390
391fn ceil_div4(chars: usize) -> u64 {
392    if chars == 0 {
393        0
394    } else {
395        u64::try_from(chars.div_ceil(4)).unwrap_or(u64::MAX)
396    }
397}
398
399/// Estimate token count for a message using the chars/4 heuristic.
400#[must_use]
401pub fn estimate_tokens(message: &AgentMessage) -> u64 {
402    match message {
403        AgentMessage::Llm(llm) => match llm.as_ref() {
404            Message::User(user) => ceil_div4(estimate_text_and_image_user_content(&user.content)),
405            Message::Assistant(assistant) => {
406                let mut chars = 0usize;
407                for block in &assistant.content {
408                    match block {
409                        AssistantContent::Text(text) => chars += js_string_len(&text.text.read()),
410                        AssistantContent::Thinking(thinking) => {
411                            chars += js_string_len(&thinking.thinking.read());
412                        }
413                        AssistantContent::ToolCall(call) => {
414                            chars += js_string_len(&call.name);
415                            chars += serde_json::to_string(&call.arguments)
416                                .map_or(0, |serialized| js_string_len(&serialized));
417                        }
418                    }
419                }
420                ceil_div4(chars)
421            }
422            Message::ToolResult(result) => ceil_div4(estimate_tool_result_chars(&result.content)),
423        },
424        AgentMessage::Custom(custom) => match custom.role.as_str() {
425            "custom" => {
426                let chars = custom
427                    .payload
428                    .get("content")
429                    .map_or(0, estimate_custom_content_chars);
430                ceil_div4(chars)
431            }
432            "bashExecution" => {
433                let command = custom
434                    .payload
435                    .get("command")
436                    .and_then(Value::as_str)
437                    .unwrap_or("");
438                let output = custom
439                    .payload
440                    .get("output")
441                    .and_then(Value::as_str)
442                    .unwrap_or("");
443                ceil_div4(js_string_len(command) + js_string_len(output))
444            }
445            "branchSummary" | "compactionSummary" => {
446                let summary = custom
447                    .payload
448                    .get("summary")
449                    .and_then(Value::as_str)
450                    .unwrap_or("");
451                ceil_div4(js_string_len(summary))
452            }
453            _ => 0,
454        },
455    }
456}
457
458fn estimate_custom_content_chars(content: &Value) -> usize {
459    match content {
460        Value::String(text) => js_string_len(text),
461        Value::Array(blocks) => blocks
462            .iter()
463            .map(|block| {
464                let kind = block.get("type").and_then(Value::as_str).unwrap_or("");
465                if kind == "text" {
466                    block
467                        .get("text")
468                        .and_then(Value::as_str)
469                        .map_or(0, js_string_len)
470                } else if kind == "image" {
471                    ESTIMATED_IMAGE_CHARS
472                } else {
473                    0
474                }
475            })
476            .sum(),
477        _ => 0,
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Cut point detection
483// ---------------------------------------------------------------------------
484
485fn is_cut_point_message(message: &AgentMessage) -> bool {
486    matches!(
487        message.role(),
488        "user" | "assistant" | "bashExecution" | "custom" | "branchSummary" | "compactionSummary"
489    )
490}
491
492fn is_turn_start_message(message: &AgentMessage) -> bool {
493    matches!(
494        message.role(),
495        "user" | "bashExecution" | "custom" | "branchSummary" | "compactionSummary"
496    )
497}
498
499fn entry_context_messages(entry: &SessionEntry) -> Vec<AgentMessage> {
500    session_entry_to_context_messages(entry).unwrap_or_default()
501}
502
503fn is_turn_start_entry(entry: &SessionEntry) -> bool {
504    if entry.discriminant() == "compaction" {
505        return false;
506    }
507    entry_context_messages(entry)
508        .iter()
509        .any(is_turn_start_message)
510}
511
512fn find_valid_cut_points(
513    entries: &[&SessionEntry],
514    start_index: usize,
515    end_index: usize,
516) -> Vec<usize> {
517    let mut cut_points = Vec::new();
518    let end = end_index.min(entries.len());
519    for (i, entry) in entries.iter().enumerate().take(end).skip(start_index) {
520        if entry.discriminant() == "compaction" {
521            continue;
522        }
523        if entry_context_messages(entry)
524            .iter()
525            .any(is_cut_point_message)
526        {
527            cut_points.push(i);
528        }
529    }
530    cut_points
531}
532
533/// Find the context-visible turn-start entry that starts the turn containing `entry_index`.
534///
535/// Returns `usize::MAX` when no turn start is found (TS `-1`).
536#[must_use]
537pub fn find_turn_start_index(
538    entries: &[&SessionEntry],
539    entry_index: usize,
540    start_index: usize,
541) -> usize {
542    let mut i = entry_index;
543    loop {
544        if i < start_index || i >= entries.len() {
545            return usize::MAX;
546        }
547        if is_turn_start_entry(entries[i]) {
548            return i;
549        }
550        if i == 0 {
551            return usize::MAX;
552        }
553        i -= 1;
554    }
555}
556
557/// Find the cut point that keeps approximately `keep_recent_tokens`.
558///
559/// Walks newest→oldest accumulating `estimate_tokens`. Never cuts at
560/// `toolResult`. Expands backward over metadata-only entries. Detects split
561/// turns when the cut entry is not a turn start.
562#[must_use]
563pub fn find_cut_point(
564    entries: &[&SessionEntry],
565    start_index: usize,
566    end_index: usize,
567    keep_recent_tokens: u64,
568) -> CutPointResult {
569    let cut_points = find_valid_cut_points(entries, start_index, end_index);
570    if cut_points.is_empty() {
571        return CutPointResult {
572            first_kept_entry_index: start_index,
573            turn_start_index: usize::MAX,
574            is_split_turn: false,
575        };
576    }
577
578    let mut accumulated_tokens = 0u64;
579    let mut cut_index = cut_points[0];
580    let end = end_index.min(entries.len());
581
582    if end > start_index {
583        let mut i = end - 1;
584        loop {
585            let entry = entries[i];
586            let message_tokens: u64 = entry_context_messages(entry)
587                .iter()
588                .map(estimate_tokens)
589                .sum();
590            if message_tokens > 0 {
591                accumulated_tokens = accumulated_tokens.saturating_add(message_tokens);
592                if accumulated_tokens >= keep_recent_tokens {
593                    for &cp in &cut_points {
594                        if cp >= i {
595                            cut_index = cp;
596                            break;
597                        }
598                    }
599                    break;
600                }
601            }
602            if i == start_index {
603                break;
604            }
605            i -= 1;
606        }
607    }
608
609    // Expand cut index backward over adjacent metadata-only entries.
610    while cut_index > start_index {
611        let prev = entries[cut_index - 1];
612        if prev.discriminant() == "compaction" || !entry_context_messages(prev).is_empty() {
613            break;
614        }
615        cut_index -= 1;
616    }
617
618    let cut_entry = entries[cut_index];
619    let starts_turn = is_turn_start_entry(cut_entry);
620    let turn_start_index = if starts_turn {
621        usize::MAX
622    } else {
623        find_turn_start_index(entries, cut_index, start_index)
624    };
625
626    CutPointResult {
627        first_kept_entry_index: cut_index,
628        turn_start_index,
629        is_split_turn: !starts_turn && turn_start_index != usize::MAX,
630    }
631}
632
633// ---------------------------------------------------------------------------
634// Message extraction / file ops
635// ---------------------------------------------------------------------------
636
637fn get_message_from_entry_for_compaction(entry: &SessionEntry) -> Option<AgentMessage> {
638    if entry.discriminant() == "compaction" {
639        return None;
640    }
641    entry_context_messages(entry).into_iter().next()
642}
643
644fn extract_file_operations(
645    messages: &[AgentMessage],
646    entries: &[&SessionEntry],
647    prev_compaction_index: Option<usize>,
648) -> FileOperations {
649    let mut file_ops = create_file_ops();
650
651    if let Some(idx) = prev_compaction_index
652        && let Some(SessionEntry::Compaction(prev)) = entries.get(idx).copied()
653    {
654        let from_hook = prev.from_hook.unwrap_or(false);
655        if !from_hook && let Some(details) = prev.details.as_ref() {
656            merge_details_into_file_ops(details, &mut file_ops);
657        }
658    }
659
660    for msg in messages {
661        extract_file_ops_from_message(msg, &mut file_ops);
662    }
663    file_ops
664}
665
666fn merge_details_into_file_ops(details: &Value, file_ops: &mut FileOperations) {
667    if let Some(arr) = details.get("readFiles").and_then(Value::as_array) {
668        for f in arr {
669            if let Some(path) = f.as_str() {
670                file_ops.read.insert(path.to_owned());
671            }
672        }
673    }
674    if let Some(arr) = details.get("modifiedFiles").and_then(Value::as_array) {
675        for f in arr {
676            if let Some(path) = f.as_str() {
677                // Modified files go into edited for proper deduplication.
678                file_ops.edited.insert(path.to_owned());
679            }
680        }
681    }
682}
683
684// ---------------------------------------------------------------------------
685// Preparation
686// ---------------------------------------------------------------------------
687
688/// Prepare compaction inputs without calling the LLM.
689///
690/// Returns `None` when the last entry is already a compaction, the session is
691/// too small, or the first kept entry lacks an id.
692///
693/// # Errors
694///
695/// Returns [`CompactionError::MessageConversion`] when session context
696/// projection fails while estimating `tokens_before`.
697pub fn prepare_compaction(
698    path_entries: &[&SessionEntry],
699    settings: CompactionSettings,
700) -> Result<Option<CompactionPreparation>, CompactionError> {
701    if path_entries
702        .last()
703        .is_some_and(|e| e.discriminant() == "compaction")
704    {
705        return Ok(None);
706    }
707
708    let mut prev_compaction_index = None;
709    for (i, entry) in path_entries.iter().enumerate().rev() {
710        if entry.discriminant() == "compaction" {
711            prev_compaction_index = Some(i);
712            break;
713        }
714    }
715
716    let mut previous_summary = None;
717    let mut boundary_start = 0usize;
718    if let Some(idx) = prev_compaction_index
719        && let SessionEntry::Compaction(prev) = path_entries[idx]
720    {
721        previous_summary = Some(prev.summary.clone());
722        let first_kept_idx = path_entries
723            .iter()
724            .position(|e| e.id() == Some(prev.first_kept_entry_id.as_str()));
725        boundary_start = first_kept_idx.unwrap_or(idx.saturating_add(1));
726    }
727    let boundary_end = path_entries.len();
728
729    let session_ctx = build_session_context(path_entries, LeafRef::Last)?;
730    let tokens_before = estimate_context_tokens(&session_ctx.messages).tokens;
731
732    let cut_point = find_cut_point(
733        path_entries,
734        boundary_start,
735        boundary_end,
736        settings.keep_recent_tokens,
737    );
738
739    let first_kept_entry = path_entries.get(cut_point.first_kept_entry_index).copied();
740    let Some(first_kept_entry) = first_kept_entry else {
741        return Ok(None);
742    };
743    let Some(first_kept_entry_id) = first_kept_entry.id().map(str::to_owned) else {
744        return Ok(None);
745    };
746
747    let history_end = if cut_point.is_split_turn {
748        cut_point.turn_start_index
749    } else {
750        cut_point.first_kept_entry_index
751    };
752
753    let mut messages_to_summarize = Vec::new();
754    for entry in path_entries.iter().take(history_end).skip(boundary_start) {
755        if let Some(msg) = get_message_from_entry_for_compaction(entry) {
756            messages_to_summarize.push(msg);
757        }
758    }
759
760    let mut turn_prefix_messages = Vec::new();
761    if cut_point.is_split_turn {
762        for entry in path_entries
763            .iter()
764            .take(cut_point.first_kept_entry_index)
765            .skip(cut_point.turn_start_index)
766        {
767            if let Some(msg) = get_message_from_entry_for_compaction(entry) {
768                turn_prefix_messages.push(msg);
769            }
770        }
771    }
772
773    if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
774        return Ok(None);
775    }
776
777    let mut file_ops =
778        extract_file_operations(&messages_to_summarize, path_entries, prev_compaction_index);
779    if cut_point.is_split_turn {
780        for msg in &turn_prefix_messages {
781            extract_file_ops_from_message(msg, &mut file_ops);
782        }
783    }
784
785    Ok(Some(CompactionPreparation {
786        first_kept_entry_id,
787        messages_to_summarize,
788        turn_prefix_messages,
789        is_split_turn: cut_point.is_split_turn,
790        tokens_before,
791        previous_summary,
792        file_ops,
793        settings,
794    }))
795}
796
797/// Classify a `None` preparation into the exact manual-compact error strings.
798#[must_use]
799pub fn preparation_none_error(path_entries: &[&SessionEntry]) -> CompactionError {
800    if path_entries
801        .last()
802        .is_some_and(|e| e.discriminant() == "compaction")
803    {
804        CompactionError::AlreadyCompacted
805    } else {
806        CompactionError::NothingToCompact
807    }
808}
809
810// ---------------------------------------------------------------------------
811// Summarization helpers
812// ---------------------------------------------------------------------------
813
814fn thinking_level_from_str(level: &str) -> Option<ModelThinkingLevel> {
815    match level {
816        "off" => Some(ModelThinkingLevel::Off),
817        "minimal" => Some(ModelThinkingLevel::Minimal),
818        "low" => Some(ModelThinkingLevel::Low),
819        "medium" => Some(ModelThinkingLevel::Medium),
820        "high" => Some(ModelThinkingLevel::High),
821        "xhigh" => Some(ModelThinkingLevel::Xhigh),
822        "max" => Some(ModelThinkingLevel::Max),
823        _ => None,
824    }
825}
826
827fn create_summarization_options(
828    model: &Model,
829    max_tokens: u64,
830    api_key: Option<String>,
831    headers: Option<BTreeMap<String, Option<String>>>,
832    env: Option<BTreeMap<String, String>>,
833    signal: Option<CancellationToken>,
834    thinking_level: Option<&str>,
835) -> StreamOptions {
836    let mut options = StreamOptions {
837        max_tokens: Some(max_tokens),
838        signal,
839        api_key,
840        headers,
841        env,
842        ..StreamOptions::default()
843    };
844    if model.reasoning
845        && let Some(level) = thinking_level.filter(|level| *level != "off")
846        && thinking_level_from_str(level).is_some()
847    {
848        options
849            .extra
850            .insert("reasoning".to_owned(), Value::String(level.to_owned()));
851    }
852    options
853}
854
855async fn complete_summarization(
856    model: &Model,
857    context: Context,
858    options: StreamOptions,
859    stream_fn: &SummarizeStreamFn,
860) -> Result<AssistantMessage, CompactionError> {
861    ensure_not_cancelled(options.signal.as_ref())?;
862
863    let mut stream = stream_fn(model.clone(), context, options).await;
864    let mut last: Option<AssistantMessage> = None;
865
866    while let Some(item) = stream.next().await {
867        match item {
868            Ok(AssistantMessageEvent::Done { message, .. }) => {
869                return Ok(message);
870            }
871            Ok(AssistantMessageEvent::Error { error, .. }) => {
872                return Ok(error);
873            }
874            Ok(other) => {
875                // Keep last partial as a fallback if the stream ends without Done.
876                if let Some(partial) = event_partial_message(&other) {
877                    last = Some(partial.clone());
878                }
879            }
880            Err(err) => return Err(CompactionError::Provider(err)),
881        }
882    }
883
884    last.ok_or_else(|| CompactionError::SummarizationFailed("Unknown error".to_owned()))
885}
886
887fn event_partial_message(event: &AssistantMessageEvent) -> Option<&AssistantMessage> {
888    match event {
889        AssistantMessageEvent::Start { partial }
890        | AssistantMessageEvent::TextStart { partial, .. }
891        | AssistantMessageEvent::TextDelta { partial, .. }
892        | AssistantMessageEvent::TextEnd { partial, .. }
893        | AssistantMessageEvent::ThinkingStart { partial, .. }
894        | AssistantMessageEvent::ThinkingDelta { partial, .. }
895        | AssistantMessageEvent::ThinkingEnd { partial, .. }
896        | AssistantMessageEvent::ToolCallStart { partial, .. }
897        | AssistantMessageEvent::ToolCallDelta { partial, .. }
898        | AssistantMessageEvent::ToolCallEnd { partial, .. } => Some(partial),
899        AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. } => None,
900    }
901}
902
903fn assistant_text(message: &AssistantMessage) -> String {
904    message
905        .content
906        .iter()
907        .filter_map(|block| match block {
908            AssistantContent::Text(text) => Some(text.text.to_string()),
909            _ => None,
910        })
911        .collect::<Vec<_>>()
912        .join("\n")
913}
914
915fn history_max_tokens(reserve_tokens: u64, model: &Model) -> u64 {
916    let budget = (reserve_tokens / 5) * 4 + (reserve_tokens % 5) * 4 / 5;
917    if model.max_tokens > 0 {
918        budget.min(model.max_tokens)
919    } else {
920        budget
921    }
922}
923
924fn turn_prefix_max_tokens(reserve_tokens: u64, model: &Model) -> u64 {
925    let budget = reserve_tokens / 2;
926    if model.max_tokens > 0 {
927        budget.min(model.max_tokens)
928    } else {
929        budget
930    }
931}
932
933fn ensure_not_cancelled(signal: Option<&CancellationToken>) -> Result<(), CompactionError> {
934    if signal.is_some_and(CancellationToken::is_cancelled) {
935        Err(CompactionError::Cancelled)
936    } else {
937        Ok(())
938    }
939}
940
941/// Generate a summary of the conversation using the injected stream function.
942///
943/// If `previous_summary` is provided, uses the update prompt. Custom
944/// instructions are always **appended** (`\n\nAdditional focus: …`).
945///
946/// # Errors
947///
948/// Returns [`CompactionError::SummarizationFailed`], [`CompactionError::Cancelled`],
949/// or conversion/provider errors.
950pub async fn generate_summary(
951    preparation: &CompactionPreparation,
952    options: &CompactOptions<'_>,
953) -> Result<String, CompactionError> {
954    ensure_not_cancelled(options.signal.as_ref())?;
955
956    let max_tokens = history_max_tokens(preparation.settings.reserve_tokens, options.model);
957    let nonempty_previous_summary = preparation
958        .previous_summary
959        .as_deref()
960        .filter(|summary| !summary.is_empty());
961    let mut base_prompt = if nonempty_previous_summary.is_some() {
962        UPDATE_SUMMARIZATION_PROMPT.to_owned()
963    } else {
964        SUMMARIZATION_PROMPT.to_owned()
965    };
966    if let Some(custom) = options.custom_instructions.filter(|s| !s.is_empty()) {
967        base_prompt = format!("{base_prompt}\n\nAdditional focus: {custom}");
968    }
969
970    let llm_messages = convert_to_llm(&preparation.messages_to_summarize)?;
971    let conversation_text = serialize_conversation(&llm_messages);
972
973    let mut prompt_text = format!("<conversation>\n{conversation_text}\n</conversation>\n\n");
974    if let Some(prev) = nonempty_previous_summary {
975        let _ = write!(
976            prompt_text,
977            "<previous-summary>\n{prev}\n</previous-summary>\n\n"
978        );
979    }
980    prompt_text.push_str(&base_prompt);
981
982    let summarization_messages = vec![Message::User(UserMessage::new(
983        UserMessageContent::Blocks(vec![UserContent::Text(TextContent::new(prompt_text))]),
984        now_millis(),
985    ))];
986
987    let completion_options = create_summarization_options(
988        options.model,
989        max_tokens,
990        options.api_key.clone(),
991        options.headers.clone(),
992        options.env.clone(),
993        options.signal.clone(),
994        options.thinking_level,
995    );
996
997    ensure_not_cancelled(options.signal.as_ref())?;
998
999    let response = complete_summarization(
1000        options.model,
1001        Context {
1002            system_prompt: Some(SUMMARIZATION_SYSTEM_PROMPT.to_owned()),
1003            messages: summarization_messages,
1004            tools: None,
1005        },
1006        completion_options,
1007        &options.stream_fn,
1008    )
1009    .await?;
1010
1011    ensure_not_cancelled(options.signal.as_ref())?;
1012
1013    if response.stop_reason == StopReason::Aborted {
1014        return Err(CompactionError::Cancelled);
1015    }
1016    if response.stop_reason == StopReason::Error {
1017        let msg = response
1018            .error_message
1019            .clone()
1020            .unwrap_or_else(|| "Unknown error".to_owned());
1021        return Err(CompactionError::SummarizationFailed(msg));
1022    }
1023
1024    Ok(assistant_text(&response))
1025}
1026
1027async fn generate_turn_prefix_summary(
1028    preparation: &CompactionPreparation,
1029    options: &CompactOptions<'_>,
1030) -> Result<String, CompactionError> {
1031    ensure_not_cancelled(options.signal.as_ref())?;
1032
1033    let max_tokens = turn_prefix_max_tokens(preparation.settings.reserve_tokens, options.model);
1034    let llm_messages = convert_to_llm(&preparation.turn_prefix_messages)?;
1035    let conversation_text = serialize_conversation(&llm_messages);
1036    let prompt_text = format!(
1037        "<conversation>\n{conversation_text}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
1038    );
1039    let summarization_messages = vec![Message::User(UserMessage::new(
1040        UserMessageContent::Blocks(vec![UserContent::Text(TextContent::new(prompt_text))]),
1041        now_millis(),
1042    ))];
1043
1044    ensure_not_cancelled(options.signal.as_ref())?;
1045
1046    let response = complete_summarization(
1047        options.model,
1048        Context {
1049            system_prompt: Some(SUMMARIZATION_SYSTEM_PROMPT.to_owned()),
1050            messages: summarization_messages,
1051            tools: None,
1052        },
1053        create_summarization_options(
1054            options.model,
1055            max_tokens,
1056            options.api_key.clone(),
1057            options.headers.clone(),
1058            options.env.clone(),
1059            options.signal.clone(),
1060            options.thinking_level,
1061        ),
1062        &options.stream_fn,
1063    )
1064    .await?;
1065
1066    ensure_not_cancelled(options.signal.as_ref())?;
1067
1068    if response.stop_reason == StopReason::Aborted {
1069        return Err(CompactionError::Cancelled);
1070    }
1071    if response.stop_reason == StopReason::Error {
1072        let msg = response
1073            .error_message
1074            .clone()
1075            .unwrap_or_else(|| "Unknown error".to_owned());
1076        return Err(CompactionError::TurnPrefixSummarizationFailed(msg));
1077    }
1078
1079    Ok(assistant_text(&response))
1080}
1081
1082fn now_millis() -> i64 {
1083    use std::time::{SystemTime, UNIX_EPOCH};
1084    SystemTime::now()
1085        .duration_since(UNIX_EPOCH)
1086        .map_or(0, |duration| {
1087            i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1088        })
1089}
1090
1091async fn summarize_preparation(
1092    preparation: &CompactionPreparation,
1093    options: &CompactOptions<'_>,
1094) -> Result<String, CompactionError> {
1095    if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
1096        let history_result = if preparation.messages_to_summarize.is_empty() {
1097            "No prior history.".to_owned()
1098        } else {
1099            generate_summary(preparation, options).await?
1100        };
1101
1102        ensure_not_cancelled(options.signal.as_ref())?;
1103        let turn_prefix_result = generate_turn_prefix_summary(preparation, options).await?;
1104        Ok(format!(
1105            "{history_result}\n\n---\n\n**Turn Context (split turn):**\n\n{turn_prefix_result}"
1106        ))
1107    } else {
1108        generate_summary(preparation, options).await
1109    }
1110}
1111
1112/// Options for [`compact`].
1113pub struct CompactOptions<'a> {
1114    /// Active model.
1115    pub model: &'a Model,
1116    /// Explicit API key.
1117    pub api_key: Option<String>,
1118    /// Optional request headers (`None` value suppresses a default).
1119    pub headers: Option<BTreeMap<String, Option<String>>>,
1120    /// Optional custom focus (always appended for compaction).
1121    pub custom_instructions: Option<&'a str>,
1122    /// Cancellation token.
1123    pub signal: Option<CancellationToken>,
1124    /// Thinking level string (`"off"`, `"high"`, …).
1125    pub thinking_level: Option<&'a str>,
1126    /// Injected summarizer stream.
1127    pub stream_fn: SummarizeStreamFn,
1128    /// Provider-scoped environment overrides.
1129    pub env: Option<BTreeMap<String, String>>,
1130    /// Extension hooks (default no-op).
1131    pub hooks: Option<&'a dyn CompactionHooks>,
1132}
1133
1134/// Generate summaries for compaction using prepared data.
1135///
1136/// # Errors
1137///
1138/// Returns exact contract error strings for cancellation, summarization
1139/// failure, missing first-kept id, and message conversion failures.
1140pub async fn compact(
1141    preparation: &CompactionPreparation,
1142    options: CompactOptions<'_>,
1143) -> Result<CompactionResult, CompactionError> {
1144    ensure_not_cancelled(options.signal.as_ref())?;
1145
1146    let hooks = options.hooks.unwrap_or(&NoopCompactionHooks);
1147    let before = hooks
1148        .before_compact(
1149            preparation,
1150            options.custom_instructions,
1151            options.signal.as_ref().unwrap_or(&CancellationToken::new()),
1152        )
1153        .await?;
1154    if before.cancel {
1155        return Err(CompactionError::Cancelled);
1156    }
1157    if let Some(mut replaced) = before.compaction {
1158        replaced.from_hook = Some(true);
1159        hooks.after_compact(&replaced, true).await?;
1160        return Ok(replaced);
1161    }
1162
1163    ensure_not_cancelled(options.signal.as_ref())?;
1164
1165    if preparation.first_kept_entry_id.is_empty() {
1166        return Err(CompactionError::MissingFirstKeptId);
1167    }
1168
1169    let mut summary = summarize_preparation(preparation, &options).await?;
1170
1171    ensure_not_cancelled(options.signal.as_ref())?;
1172
1173    let (read_files, modified_files) = compute_file_lists(&preparation.file_ops);
1174    summary.push_str(&format_file_operations(&read_files, &modified_files));
1175
1176    let details = CompactionDetails {
1177        read_files,
1178        modified_files,
1179    };
1180    let details_value = serde_json::to_value(&details).unwrap_or(Value::Null);
1181
1182    let result = CompactionResult {
1183        summary,
1184        first_kept_entry_id: preparation.first_kept_entry_id.clone(),
1185        tokens_before: preparation.tokens_before,
1186        estimated_tokens_after: None,
1187        details: Some(details_value),
1188        from_hook: None,
1189    };
1190
1191    hooks.after_compact(&result, false).await?;
1192    Ok(result)
1193}
1194
1195// ---------------------------------------------------------------------------
1196// Tests
1197// ---------------------------------------------------------------------------
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202    use pi_ai::{AssistantMessage, TextContent, ToolCall, ToolResultContent, ToolResultMessage};
1203    use serde_json::Map;
1204    use serde_json::json;
1205
1206    fn result_ok<T, E>(result: Result<T, E>) -> T {
1207        assert!(result.is_ok());
1208        match result {
1209            Ok(value) => value,
1210            Err(_) => unreachable!(),
1211        }
1212    }
1213
1214    fn result_err<T, E>(result: Result<T, E>) -> E {
1215        assert!(result.is_err());
1216        match result {
1217            Ok(_) => unreachable!(),
1218            Err(error) => error,
1219        }
1220    }
1221
1222    fn option_some<T>(option: Option<T>) -> T {
1223        assert!(option.is_some());
1224        match option {
1225            Some(value) => value,
1226            None => unreachable!(),
1227        }
1228    }
1229
1230    fn usage(input: u64, output: u64, cache_read: u64, cache_write: u64) -> Usage {
1231        Usage {
1232            input,
1233            output,
1234            cache_read,
1235            cache_write,
1236            cache_write1h: None,
1237            reasoning: None,
1238            total_tokens: input + output + cache_read + cache_write,
1239            cost: pi_ai::UsageCost::default(),
1240        }
1241    }
1242
1243    fn user_msg(text: &str) -> AgentMessage {
1244        AgentMessage::Llm(Box::new(Message::User(UserMessage::new(
1245            UserMessageContent::Text(text.to_owned()),
1246            1,
1247        ))))
1248    }
1249
1250    fn assistant_msg(text: &str, u: Usage) -> AgentMessage {
1251        let mut msg =
1252            AssistantMessage::new("anthropic-messages", "anthropic", "claude-sonnet-4-5", 1);
1253        msg.content = vec![AssistantContent::Text(TextContent::new(text))];
1254        msg.usage = u;
1255        msg.stop_reason = StopReason::Stop;
1256        AgentMessage::Llm(Box::new(Message::Assistant(msg)))
1257    }
1258
1259    fn tool_result_msg(text: &str) -> AgentMessage {
1260        AgentMessage::Llm(Box::new(Message::ToolResult(ToolResultMessage::new(
1261            "tc1",
1262            "read",
1263            vec![ToolResultContent::Text(TextContent::new(text))],
1264            false,
1265            1,
1266        ))))
1267    }
1268
1269    fn custom_msg(content: &str) -> AgentMessage {
1270        let mut payload = Map::new();
1271        payload.insert("customType".into(), Value::String("test".into()));
1272        payload.insert("content".into(), Value::String(content.into()));
1273        payload.insert("display".into(), Value::Bool(true));
1274        payload.insert("timestamp".into(), Value::from(1));
1275        AgentMessage::Custom(pi_agent::CustomAgentMessage::new("custom", payload))
1276    }
1277
1278    fn bash_msg(command: &str, output: &str) -> AgentMessage {
1279        let mut payload = Map::new();
1280        payload.insert("command".into(), Value::String(command.into()));
1281        payload.insert("output".into(), Value::String(output.into()));
1282        payload.insert("cancelled".into(), Value::Bool(false));
1283        payload.insert("truncated".into(), Value::Bool(false));
1284        payload.insert("timestamp".into(), Value::from(1));
1285        AgentMessage::Custom(pi_agent::CustomAgentMessage::new("bashExecution", payload))
1286    }
1287
1288    fn branch_summary_msg(summary: &str) -> AgentMessage {
1289        let mut payload = Map::new();
1290        payload.insert("summary".into(), Value::String(summary.into()));
1291        payload.insert("fromId".into(), Value::String("root".into()));
1292        payload.insert("timestamp".into(), Value::from(1));
1293        AgentMessage::Custom(pi_agent::CustomAgentMessage::new("branchSummary", payload))
1294    }
1295
1296    fn compaction_summary_msg(summary: &str) -> AgentMessage {
1297        let mut payload = Map::new();
1298        payload.insert("summary".into(), Value::String(summary.into()));
1299        payload.insert("tokensBefore".into(), Value::from(100));
1300        payload.insert("timestamp".into(), Value::from(1));
1301        AgentMessage::Custom(pi_agent::CustomAgentMessage::new(
1302            "compactionSummary",
1303            payload,
1304        ))
1305    }
1306
1307    fn message_entry(id: &str, parent: Option<&str>, message: AgentMessage) -> SessionEntry {
1308        let message = result_ok(serde_json::to_value(message));
1309        result_ok(serde_json::from_value(json!({
1310            "type": "message",
1311            "id": id,
1312            "parentId": parent,
1313            "timestamp": "2025-01-01T00:00:00.000Z",
1314            "message": message,
1315        })))
1316    }
1317
1318    fn compaction_entry(
1319        id: &str,
1320        parent: Option<&str>,
1321        summary: &str,
1322        first_kept: &str,
1323    ) -> SessionEntry {
1324        result_ok(serde_json::from_value(json!({
1325            "type": "compaction",
1326            "id": id,
1327            "parentId": parent,
1328            "timestamp": "2025-01-01T00:00:00.000Z",
1329            "summary": summary,
1330            "firstKeptEntryId": first_kept,
1331            "tokensBefore": 10000,
1332        })))
1333    }
1334
1335    fn model_change_entry(id: &str, parent: Option<&str>) -> SessionEntry {
1336        result_ok(serde_json::from_value(json!({
1337            "type": "model_change",
1338            "id": id,
1339            "parentId": parent,
1340            "timestamp": "2025-01-01T00:00:00.000Z",
1341            "provider": "openai",
1342            "modelId": "gpt-4",
1343        })))
1344    }
1345
1346    fn custom_message_entry(id: &str, parent: Option<&str>, content: &str) -> SessionEntry {
1347        result_ok(serde_json::from_value(json!({
1348            "type": "custom_message",
1349            "id": id,
1350            "parentId": parent,
1351            "timestamp": "2025-01-01T00:00:00.000Z",
1352            "customType": "test",
1353            "content": content,
1354            "display": true,
1355        })))
1356    }
1357
1358    fn test_model(max_tokens: u64, context_window: u64) -> Model {
1359        Model {
1360            id: "test".into(),
1361            name: "Test".into(),
1362            api: "anthropic-messages".into(),
1363            provider: "anthropic".into(),
1364            base_url: "https://example.test".into(),
1365            reasoning: false,
1366            thinking_level_map: None,
1367            input: vec![pi_ai::ModelInput::Text],
1368            cost: pi_ai::ModelCost::default(),
1369            context_window,
1370            max_tokens,
1371            headers: None,
1372            compat: None,
1373            extra: BTreeMap::new(),
1374        }
1375    }
1376
1377    fn mock_stream_fn(text: &str, stop: StopReason) -> SummarizeStreamFn {
1378        let text = text.to_owned();
1379        Arc::new(move |_model, _ctx, _opts| {
1380            let text = text.clone();
1381            Box::pin(async move {
1382                let mut msg = AssistantMessage::new("a", "p", "m", 1);
1383                msg.content = vec![AssistantContent::Text(TextContent::new(text))];
1384                msg.stop_reason = stop;
1385                if stop == StopReason::Error {
1386                    msg.error_message = Some("boom".into());
1387                }
1388                let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1389                    reason: pi_ai::DoneReason::Stop,
1390                    message: msg,
1391                })]);
1392                Box::pin(stream)
1393                    as Pin<
1394                        Box<
1395                            dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1396                                + Send,
1397                        >,
1398                    >
1399            })
1400        })
1401    }
1402
1403    #[test]
1404    fn calculate_context_tokens_prefers_total() {
1405        let mut u = usage(1000, 500, 200, 100);
1406        assert_eq!(calculate_context_tokens(&u), 1800);
1407        u.total_tokens = 0;
1408        assert_eq!(calculate_context_tokens(&u), 1800);
1409        let zero = usage(0, 0, 0, 0);
1410        assert_eq!(calculate_context_tokens(&zero), 0);
1411    }
1412
1413    #[test]
1414    fn get_last_assistant_usage_skips_aborted_error_zero() {
1415        let a1 = message_entry("a1", None, assistant_msg("Hi", usage(100, 50, 0, 0)));
1416        let a2 = message_entry("a2", Some("a1"), {
1417            let mut m = AssistantMessage::new("anthropic-messages", "anthropic", "m", 1);
1418            m.usage = usage(300, 150, 0, 0);
1419            m.stop_reason = StopReason::Aborted;
1420            AgentMessage::Llm(Box::new(Message::Assistant(m)))
1421        });
1422        let entries = [&a1, &a2];
1423        let found = option_some(get_last_assistant_usage(&entries));
1424        assert_eq!(found.input, 100);
1425
1426        let zero = message_entry("z", Some("a1"), assistant_msg("Partial", usage(0, 0, 0, 0)));
1427        let entries = [&a1, &zero];
1428        let found = option_some(get_last_assistant_usage(&entries));
1429        assert_eq!(found.input, 100);
1430
1431        let only_user = message_entry("u", None, user_msg("Hello"));
1432        assert!(get_last_assistant_usage(&[&only_user]).is_none());
1433    }
1434
1435    #[test]
1436    fn estimate_context_tokens_anchors_usage() {
1437        let messages = vec![
1438            user_msg("Hello"),
1439            assistant_msg("Hi", usage(100, 50, 0, 0)),
1440            user_msg("continue"),
1441            assistant_msg("Partial thinking", usage(0, 0, 0, 0)),
1442        ];
1443        let estimate = estimate_context_tokens(&messages);
1444        assert_eq!(estimate.usage_tokens, 150);
1445        assert_eq!(estimate.last_usage_index, Some(1));
1446        assert!(estimate.trailing_tokens > 0);
1447        assert_eq!(estimate.tokens, 150 + estimate.trailing_tokens);
1448    }
1449
1450    #[test]
1451    fn estimate_tokens_every_role() {
1452        assert!(estimate_tokens(&user_msg("abcd")) >= 1);
1453        let with_image = UserMessage::new(
1454            UserMessageContent::Blocks(vec![
1455                UserContent::Text(TextContent::new("hi")),
1456                UserContent::Image(pi_ai::ImageContent::new("abc", "image/png")),
1457            ]),
1458            1,
1459        );
1460        let img_tokens = estimate_tokens(&AgentMessage::Llm(Box::new(Message::User(with_image))));
1461        assert!(img_tokens >= 4800 / 4);
1462
1463        let mut asst = AssistantMessage::new("a", "p", "m", 1);
1464        asst.content = vec![
1465            AssistantContent::Text(TextContent::new("hello")),
1466            AssistantContent::Thinking(pi_ai::ThinkingContent::new("think")),
1467            AssistantContent::ToolCall(ToolCall::new(
1468                "1",
1469                "read",
1470                Map::from_iter([("path".into(), Value::String("x".into()))]),
1471            )),
1472        ];
1473        assert!(estimate_tokens(&AgentMessage::Llm(Box::new(Message::Assistant(asst)))) > 0);
1474
1475        assert!(estimate_tokens(&tool_result_msg("result text")) > 0);
1476        assert!(estimate_tokens(&custom_msg("custom body")) > 0);
1477        assert!(estimate_tokens(&bash_msg("ls", "out")) > 0);
1478        assert!(estimate_tokens(&branch_summary_msg("branch")) > 0);
1479        assert!(estimate_tokens(&compaction_summary_msg("compact")) > 0);
1480        // Non-BMP emoji: one scalar, two UTF-16 code units → ceil(2/4)=1.
1481        assert_eq!(estimate_tokens(&user_msg("😀")), 1);
1482        // Two emoji = 4 UTF-16 units → 1 token.
1483        assert_eq!(estimate_tokens(&user_msg("😀😀")), 1);
1484        // Five emoji = 10 UTF-16 units → 3 tokens.
1485        assert_eq!(estimate_tokens(&user_msg("😀😀😀😀😀")), 3);
1486    }
1487
1488    #[test]
1489    fn should_compact_strict_gt_and_disabled() {
1490        let settings = CompactionSettings {
1491            enabled: true,
1492            reserve_tokens: 10_000,
1493            keep_recent_tokens: 20_000,
1494        };
1495        assert!(should_compact(95_000, 100_000, &settings));
1496        assert!(!should_compact(90_000, 100_000, &settings)); // equal threshold
1497        assert!(!should_compact(89_000, 100_000, &settings));
1498        // JS signed subtraction: reserve > window yields a negative threshold.
1499        let oversized_reserve = CompactionSettings {
1500            enabled: true,
1501            reserve_tokens: 10,
1502            keep_recent_tokens: 20_000,
1503        };
1504        assert!(should_compact(0, 5, &oversized_reserve));
1505        let disabled = CompactionSettings {
1506            enabled: false,
1507            ..settings
1508        };
1509        assert!(!should_compact(95_000, 100_000, &disabled));
1510    }
1511
1512    #[test]
1513    fn find_cut_point_never_cuts_tool_result() {
1514        let u = message_entry("u", None, user_msg("hi"));
1515        let a = message_entry("a", Some("u"), {
1516            let mut m = AssistantMessage::new("a", "p", "m", 1);
1517            m.content = vec![AssistantContent::ToolCall(ToolCall::new(
1518                "1",
1519                "read",
1520                Map::from_iter([("path".into(), Value::String("f".into()))]),
1521            ))];
1522            m.usage = usage(0, 0, 0, 0);
1523            AgentMessage::Llm(Box::new(Message::Assistant(m)))
1524        });
1525        let tr = message_entry("tr", Some("a"), tool_result_msg(&"x".repeat(8000)));
1526        let a2 = message_entry("a2", Some("tr"), assistant_msg("done", usage(0, 50, 0, 0)));
1527        let entries = [&u, &a, &tr, &a2];
1528        let result = find_cut_point(&entries, 0, entries.len(), 1);
1529        // Cut should never land on toolResult.
1530        assert_ne!(entries[result.first_kept_entry_index].id(), Some("tr"));
1531    }
1532
1533    #[test]
1534    fn find_cut_point_metadata_expansion_and_split_turn() {
1535        let u1 = message_entry("u1", None, user_msg("Turn 1"));
1536        let a1 = message_entry(
1537            "a1",
1538            Some("u1"),
1539            assistant_msg("A1", usage(0, 100, 1000, 0)),
1540        );
1541        let u2 = message_entry("u2", Some("a1"), user_msg("Turn 2"));
1542        let mc = model_change_entry("mc", Some("u2")); // metadata before cut
1543        let a2 = message_entry(
1544            "a2",
1545            Some("mc"),
1546            assistant_msg("A2", usage(0, 100, 8000, 0)),
1547        );
1548        let a3 = message_entry(
1549            "a3",
1550            Some("a2"),
1551            assistant_msg("A3", usage(0, 100, 10000, 0)),
1552        );
1553        let entries = [&u1, &a1, &u2, &mc, &a2, &a3];
1554        let result = find_cut_point(&entries, 0, entries.len(), 3000);
1555        // If cut lands on assistant, split turn should point at turn start u2.
1556        if entries[result.first_kept_entry_index]
1557            .id()
1558            .is_some_and(|id| id == "a2" || id == "a3")
1559        {
1560            assert!(result.is_split_turn);
1561            assert_eq!(entries[result.turn_start_index].id(), Some("u2"));
1562        }
1563    }
1564
1565    #[test]
1566    fn find_cut_point_custom_message_budget() {
1567        let u = message_entry("u", None, user_msg("hi"));
1568        let a = message_entry("a", Some("u"), assistant_msg("hello", usage(100, 50, 0, 0)));
1569        let c = custom_message_entry("c", Some("a"), &"x".repeat(4000));
1570        let a2 = message_entry("a2", Some("c"), assistant_msg("ok", usage(100, 50, 0, 0)));
1571        let entries = [&u, &a, &c, &a2];
1572        let tiny = find_cut_point(&entries, 0, entries.len(), 1);
1573        assert_eq!(tiny.first_kept_entry_index, 3);
1574        assert!(tiny.is_split_turn);
1575        assert_eq!(tiny.turn_start_index, 2);
1576        let fits = find_cut_point(&entries, 0, entries.len(), 2);
1577        assert_eq!(fits.first_kept_entry_index, 2);
1578        assert!(!fits.is_split_turn);
1579    }
1580
1581    #[test]
1582    fn prepare_compaction_previous_boundary_and_nothing() {
1583        let u1 = message_entry("u1", None, user_msg("user msg 1"));
1584        let a1 = message_entry(
1585            "a1",
1586            Some("u1"),
1587            assistant_msg("assistant msg 1", usage(100, 50, 0, 0)),
1588        );
1589        let u2 = message_entry("u2", Some("a1"), user_msg("user msg 2 - kept"));
1590        let a2 = message_entry(
1591            "a2",
1592            Some("u2"),
1593            assistant_msg("assistant msg 2", usage(100, 50, 0, 0)),
1594        );
1595        let compaction = compaction_entry("c1", Some("a2"), "First summary", "u2");
1596        let u3 = message_entry("u3", Some("c1"), user_msg("new"));
1597        let a3 = message_entry(
1598            "a3",
1599            Some("u3"),
1600            assistant_msg("new a", usage(100, 50, 0, 0)),
1601        );
1602        let path = [&u1, &a1, &u2, &a2, &compaction, &u3, &a3];
1603        let _default = result_ok(prepare_compaction(&path, DEFAULT_COMPACTION_SETTINGS));
1604        // With default keepRecent 20000, this tiny session may be None — force tiny keep.
1605        let settings = CompactionSettings {
1606            keep_recent_tokens: 1,
1607            ..DEFAULT_COMPACTION_SETTINGS
1608        };
1609        let prep = option_some(result_ok(prepare_compaction(&path, settings)));
1610        assert_eq!(prep.previous_summary.as_deref(), Some("First summary"));
1611        assert!(!prep.first_kept_entry_id.is_empty());
1612
1613        // Already compacted
1614        let path2 = [&u1, &a1, &compaction];
1615        assert!(result_ok(prepare_compaction(&path2, settings)).is_none());
1616        assert!(matches!(
1617            preparation_none_error(&path2),
1618            CompactionError::AlreadyCompacted
1619        ));
1620    }
1621
1622    #[test]
1623    fn prepare_compaction_nothing_to_compact_error() {
1624        let u = message_entry("u", None, user_msg("hi"));
1625        let a = message_entry("a", Some("u"), assistant_msg("yo", usage(10, 5, 0, 0)));
1626        let path = [&u, &a];
1627        let settings = CompactionSettings {
1628            keep_recent_tokens: 50_000,
1629            ..DEFAULT_COMPACTION_SETTINGS
1630        };
1631        let prep = result_ok(prepare_compaction(&path, settings));
1632        assert!(prep.is_none());
1633        assert!(matches!(
1634            preparation_none_error(&path),
1635            CompactionError::NothingToCompact
1636        ));
1637    }
1638
1639    #[tokio::test]
1640    async fn compact_prompts_caps_custom_and_split_merge() {
1641        let captured_prompts = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1642        let captured_max = Arc::new(std::sync::Mutex::new(Vec::<u64>::new()));
1643        let prompts = Arc::clone(&captured_prompts);
1644        let maxes = Arc::clone(&captured_max);
1645
1646        let stream_fn: SummarizeStreamFn = Arc::new(move |model, ctx, opts| {
1647            let prompts = Arc::clone(&prompts);
1648            let maxes = Arc::clone(&maxes);
1649            Box::pin(async move {
1650                if let Some(max) = opts.max_tokens {
1651                    result_ok(maxes.lock()).push(max);
1652                }
1653                if let Some(Message::User(user)) = ctx.messages.first() {
1654                    let text = match &user.content {
1655                        UserMessageContent::Text(t) => t.clone(),
1656                        UserMessageContent::Blocks(blocks) => blocks
1657                            .iter()
1658                            .filter_map(|b| match b {
1659                                UserContent::Text(t) => Some(t.text.to_string()),
1660                                UserContent::Image(_) => None,
1661                            })
1662                            .collect::<String>(),
1663                    };
1664                    result_ok(prompts.lock()).push(text);
1665                }
1666                let _ = model;
1667                let mut msg = AssistantMessage::new("a", "p", "m", 1);
1668                msg.content = vec![AssistantContent::Text(TextContent::new("SUMMARY"))];
1669                msg.stop_reason = StopReason::Stop;
1670                let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1671                    reason: pi_ai::DoneReason::Stop,
1672                    message: msg,
1673                })]);
1674                Box::pin(stream)
1675                    as Pin<
1676                        Box<
1677                            dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1678                                + Send,
1679                        >,
1680                    >
1681            })
1682        });
1683
1684        let model = test_model(4096, 128_000);
1685        let prep = CompactionPreparation {
1686            first_kept_entry_id: "keep".into(),
1687            messages_to_summarize: vec![
1688                user_msg("history user"),
1689                assistant_msg("hist a", usage(10, 5, 0, 0)),
1690            ],
1691            turn_prefix_messages: vec![
1692                user_msg("turn prefix"),
1693                assistant_msg("prefix a", usage(10, 5, 0, 0)),
1694            ],
1695            is_split_turn: true,
1696            tokens_before: 999,
1697            previous_summary: Some("Prev".into()),
1698            file_ops: FileOperations::default(),
1699            settings: CompactionSettings {
1700                enabled: true,
1701                reserve_tokens: 1000,
1702                keep_recent_tokens: 100,
1703            },
1704        };
1705
1706        let result = result_ok(
1707            compact(
1708                &prep,
1709                CompactOptions {
1710                    model: &model,
1711                    api_key: None,
1712                    headers: None,
1713                    custom_instructions: Some("focus on tests"),
1714                    signal: None,
1715                    thinking_level: None,
1716                    stream_fn,
1717                    env: None,
1718                    hooks: None,
1719                },
1720            )
1721            .await,
1722        );
1723
1724        assert!(result.summary.contains("SUMMARY"));
1725        assert!(result.summary.contains("**Turn Context (split turn):**"));
1726        assert_eq!(result.first_kept_entry_id, "keep");
1727        assert_eq!(result.tokens_before, 999);
1728
1729        let prompts = result_ok(captured_prompts.lock());
1730        assert_eq!(prompts.len(), 2);
1731        // History uses UPDATE prompt + previous-summary + custom append.
1732        assert!(prompts[0].contains("<previous-summary>"));
1733        assert!(prompts[0].contains(UPDATE_SUMMARIZATION_PROMPT));
1734        assert!(prompts[0].contains("Additional focus: focus on tests"));
1735        // Turn prefix uses TURN_PREFIX prompt, no custom append.
1736        assert!(prompts[1].contains(TURN_PREFIX_SUMMARIZATION_PROMPT));
1737        assert!(!prompts[1].contains("Additional focus"));
1738
1739        let maxes = result_ok(captured_max.lock());
1740        // history: floor(0.8 * 1000)=800, turn: floor(0.5*1000)=500, both capped by model 4096
1741        assert_eq!(maxes[0], 800);
1742        assert_eq!(maxes[1], 500);
1743    }
1744
1745    #[tokio::test]
1746    async fn empty_previous_summary_uses_initial_prompt() {
1747        let captured_prompts = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1748        let prompts = Arc::clone(&captured_prompts);
1749        let stream_fn: SummarizeStreamFn = Arc::new(move |_model, ctx, _opts| {
1750            let prompts = Arc::clone(&prompts);
1751            Box::pin(async move {
1752                if let Some(Message::User(user)) = ctx.messages.first() {
1753                    let text = match &user.content {
1754                        UserMessageContent::Text(t) => t.clone(),
1755                        UserMessageContent::Blocks(blocks) => blocks
1756                            .iter()
1757                            .filter_map(|b| match b {
1758                                UserContent::Text(t) => Some(t.text.to_string()),
1759                                UserContent::Image(_) => None,
1760                            })
1761                            .collect::<String>(),
1762                    };
1763                    result_ok(prompts.lock()).push(text);
1764                }
1765                let mut msg = AssistantMessage::new("a", "p", "m", 1);
1766                msg.content = vec![AssistantContent::Text(TextContent::new("SUMMARY"))];
1767                msg.stop_reason = StopReason::Stop;
1768                let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1769                    reason: pi_ai::DoneReason::Stop,
1770                    message: msg,
1771                })]);
1772                Box::pin(stream)
1773                    as Pin<
1774                        Box<
1775                            dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1776                                + Send,
1777                        >,
1778                    >
1779            })
1780        });
1781
1782        let model = test_model(2048, 128_000);
1783        let prep = CompactionPreparation {
1784            first_kept_entry_id: "k".into(),
1785            messages_to_summarize: vec![user_msg("x")],
1786            turn_prefix_messages: vec![],
1787            is_split_turn: false,
1788            tokens_before: 1,
1789            previous_summary: Some(String::new()),
1790            file_ops: FileOperations::default(),
1791            settings: DEFAULT_COMPACTION_SETTINGS,
1792        };
1793        result_ok(
1794            compact(
1795                &prep,
1796                CompactOptions {
1797                    model: &model,
1798                    api_key: None,
1799                    headers: None,
1800                    custom_instructions: None,
1801                    signal: None,
1802                    thinking_level: None,
1803                    stream_fn,
1804                    env: None,
1805                    hooks: None,
1806                },
1807            )
1808            .await,
1809        );
1810
1811        let prompts = result_ok(captured_prompts.lock());
1812        assert_eq!(prompts.len(), 1);
1813        assert!(prompts[0].contains(SUMMARIZATION_PROMPT));
1814        assert!(!prompts[0].contains(UPDATE_SUMMARIZATION_PROMPT));
1815        assert!(!prompts[0].contains("<previous-summary>"));
1816    }
1817
1818    #[test]
1819    fn emoji_utf16_cut_point_budget() {
1820        // Each 😀 is 2 UTF-16 units → 1 token. Budget 2 keeps last two user msgs.
1821        let u1 = message_entry("u1", None, user_msg("😀"));
1822        let u2 = message_entry("u2", Some("u1"), user_msg("😀"));
1823        let u3 = message_entry("u3", Some("u2"), user_msg("😀"));
1824        let entries = [&u1, &u2, &u3];
1825        let cut = find_cut_point(&entries, 0, entries.len(), 2);
1826        assert_eq!(cut.first_kept_entry_index, 1);
1827    }
1828
1829    #[tokio::test]
1830    async fn compact_summarizer_failure_and_cancel() {
1831        let model = test_model(2048, 128_000);
1832        let prep = CompactionPreparation {
1833            first_kept_entry_id: "k".into(),
1834            messages_to_summarize: vec![user_msg("x")],
1835            turn_prefix_messages: vec![],
1836            is_split_turn: false,
1837            tokens_before: 1,
1838            previous_summary: None,
1839            file_ops: FileOperations::default(),
1840            settings: DEFAULT_COMPACTION_SETTINGS,
1841        };
1842
1843        let err = result_err(
1844            compact(
1845                &prep,
1846                CompactOptions {
1847                    model: &model,
1848                    api_key: None,
1849                    headers: None,
1850                    custom_instructions: None,
1851                    signal: None,
1852                    thinking_level: None,
1853                    stream_fn: mock_stream_fn("x", StopReason::Error),
1854                    env: None,
1855                    hooks: None,
1856                },
1857            )
1858            .await,
1859        );
1860        assert_eq!(err.to_string(), "Summarization failed: boom");
1861
1862        let cancel = CancellationToken::new();
1863        cancel.cancel();
1864        let err = result_err(
1865            compact(
1866                &prep,
1867                CompactOptions {
1868                    model: &model,
1869                    api_key: None,
1870                    headers: None,
1871                    custom_instructions: None,
1872                    signal: Some(cancel),
1873                    thinking_level: None,
1874                    stream_fn: mock_stream_fn("x", StopReason::Stop),
1875                    env: None,
1876                    hooks: None,
1877                },
1878            )
1879            .await,
1880        );
1881        assert!(matches!(err, CompactionError::Cancelled));
1882    }
1883
1884    #[tokio::test]
1885    async fn compact_hook_replacement_from_hook() {
1886        struct ReplaceHooks;
1887        impl CompactionHooks for ReplaceHooks {
1888            fn before_compact(
1889                &self,
1890                prep: &CompactionPreparation,
1891                _custom: Option<&str>,
1892                _cancel: &CancellationToken,
1893            ) -> Pin<
1894                Box<dyn Future<Output = Result<BeforeCompactResult, CompactionError>> + Send + '_>,
1895            > {
1896                let result = CompactionResult {
1897                    summary: "hook summary".into(),
1898                    first_kept_entry_id: prep.first_kept_entry_id.clone(),
1899                    tokens_before: prep.tokens_before,
1900                    estimated_tokens_after: None,
1901                    details: None,
1902                    from_hook: None,
1903                };
1904                Box::pin(async move {
1905                    Ok(BeforeCompactResult {
1906                        cancel: false,
1907                        compaction: Some(result),
1908                    })
1909                })
1910            }
1911        }
1912
1913        let model = test_model(2048, 128_000);
1914        let prep = CompactionPreparation {
1915            first_kept_entry_id: "k".into(),
1916            messages_to_summarize: vec![user_msg("x")],
1917            turn_prefix_messages: vec![],
1918            is_split_turn: false,
1919            tokens_before: 42,
1920            previous_summary: None,
1921            file_ops: FileOperations::default(),
1922            settings: DEFAULT_COMPACTION_SETTINGS,
1923        };
1924        let hooks = ReplaceHooks;
1925        let result = result_ok(
1926            compact(
1927                &prep,
1928                CompactOptions {
1929                    model: &model,
1930                    api_key: None,
1931                    headers: None,
1932                    custom_instructions: None,
1933                    signal: None,
1934                    thinking_level: None,
1935                    stream_fn: mock_stream_fn("should not run", StopReason::Stop),
1936                    env: None,
1937                    hooks: Some(&hooks),
1938                },
1939            )
1940            .await,
1941        );
1942        assert_eq!(result.summary, "hook summary");
1943        assert_eq!(result.from_hook, Some(true));
1944    }
1945
1946    #[test]
1947    fn prompt_constants_exact() {
1948        assert!(SUMMARIZATION_PROMPT.contains("## Goal"));
1949        assert!(UPDATE_SUMMARIZATION_PROMPT.contains("PRESERVE all existing information"));
1950        assert!(TURN_PREFIX_SUMMARIZATION_PROMPT.contains("## Original Request"));
1951        assert_eq!(DEFAULT_COMPACTION_SETTINGS.reserve_tokens, 16_384);
1952        assert_eq!(DEFAULT_COMPACTION_SETTINGS.keep_recent_tokens, 20_000);
1953        assert!(std::hint::black_box(DEFAULT_COMPACTION_SETTINGS).enabled);
1954    }
1955}