Skip to main content

agent_works/compression/
compactor.rs

1//! Core compression logic — hybrid retention + stable-prefix cache.
2//!
3//! [`ContextCompactor`] takes a full message history and produces a compressed
4//! copy that preserves:
5//!
6//! 1. The system prompt (verbatim).
7//! 2. The most recent N messages (verbatim, including tool results).
8//! 3. A handoff summary of the older history (generated by LLM, cached).
9//!
10//! The compactor is **stateful** — it caches summaries keyed on a stable prefix
11//! hash so repeated calls with the same old block skip the LLM round-trip.
12
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::sync::{Arc, Mutex};
16
17use agent_base::llm_trait::LlmProvider;
18use agent_base::{AgentResult, AgentRuntime, ChatMessage, ContextWindowManager, SessionId};
19
20use crate::compression::config::CompressionConfig;
21use crate::compression::events::{CompressionEvent, CompressionTrigger};
22use crate::compression::filter::{SUMMARY_PREFIX, is_summary_message, split_system_prompt};
23use crate::compression::summarizer::summarize;
24
25// ── Public API ───────────────────────────────────────────────────────────────
26
27/// Cached summary entry: `(transcript_len, summary_text)`.
28///
29/// `transcript_len` guards against reusing a stale summary when the old block
30/// has grown significantly since the cache was populated.
31type CompactionCache = std::collections::HashMap<(u64, u64), (usize, String)>;
32
33/// Core compressor implementing the hybrid retention strategy.
34///
35/// Holds an [`LlmProvider`] for LLM summarisation and a thread-safe cache to
36/// avoid redundant calls.  Designed to be shared via `Arc` across middleware
37/// invocations.
38///
39/// Use `clone()` to create a second handle that shares the same
40/// underlying cache — useful when the compactor is registered as middleware *and*
41/// stored separately for manual `/compact` access.
42pub struct ContextCompactor {
43    client: Arc<dyn LlmProvider>,
44    config: CompressionConfig,
45    /// `(session_id, prefix_hash) → (transcript_len, summary)` cache.
46    /// Wrapped in `Arc` so that `clone()` shares the same cache.
47    cache: Arc<Mutex<CompactionCache>>,
48}
49
50#[allow(missing_docs)]
51impl ContextCompactor {
52    pub fn new(client: Arc<dyn LlmProvider>, config: CompressionConfig) -> Self {
53        Self {
54            client,
55            config,
56            cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
57        }
58    }
59
60    /// Create a second handle to the same compactor.
61    ///
62    /// The clone shares the same LLM client and summary cache — clearing the
63    /// cache through either handle affects both.
64    pub fn clone_handle(&self) -> Self {
65        Self {
66            client: self.client.clone(),
67            config: self.config.clone(),
68            cache: self.cache.clone(),
69        }
70    }
71
72    pub fn config(&self) -> &CompressionConfig {
73        &self.config
74    }
75
76    // ── Core compression ─────────────────────────────────────────────────
77
78    /// Maximum tokens for user messages preserved verbatim from the old block.
79    ///
80    /// Matches codex's `COMPACT_USER_MESSAGE_MAX_TOKENS`.  User messages are
81    /// typically short instructions, so 20k tokens is generous enough to never
82    /// truncate in practice while preventing pathological cases (e.g., user
83    /// pasting a huge code block).
84    const MAX_PRESERVED_USER_TOKENS: usize = 20_000;
85
86    /// Produce a compressed copy of `messages` for a single LLM call.
87    ///
88    /// Returns `None` when compression is skipped (disabled, below threshold,
89    /// or too few messages).  The caller should use the original `messages`
90    /// unchanged in that case.
91    ///
92    /// `session_id` is used as part of the cache key so different sessions
93    /// never share summaries.
94    ///
95    /// # Compression strategy
96    ///
97    /// User messages from the old block are preserved verbatim (truncated to
98    /// `MAX_PRESERVED_USER_TOKENS`).
99    /// Only assistant and tool responses are summarised by the LLM.  This
100    /// mirrors codex's compaction strategy and avoids the common pitfall of
101    /// the summarizer reproducing assistant-generated content (poems, code,
102    /// articles) verbatim, which can make the compressed output *larger* than
103    /// the original.
104    pub async fn compact(
105        &self,
106        session_id: u64,
107        messages: &[ChatMessage],
108        trigger: CompressionTrigger,
109        emit_fn: Option<&(dyn Fn(CompressionEvent) + Sync)>,
110    ) -> AgentResult<Option<Vec<ChatMessage>>> {
111        let t0 = std::time::Instant::now();
112        if !self.config.enabled {
113            return Ok(None);
114        }
115
116        let total_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
117        tracing::info!(
118            total_tokens,
119            trigger = self.config.trigger_tokens,
120            "[compact-timing] enter compact()"
121        );
122        if total_tokens <= self.config.trigger_tokens {
123            return Ok(None);
124        }
125
126        // 1. Split off system prompt.
127        let (system_msgs, conversation) = split_system_prompt(messages);
128        tracing::info!(
129            elapsed_ms = t0.elapsed().as_millis() as u64,
130            conv_len = conversation.len(),
131            keep = self.config.keep_recent_messages,
132            "[compact-timing] after split_system_prompt"
133        );
134        if conversation.len() <= self.config.keep_recent_messages + 1 {
135            return Ok(None);
136        }
137
138        // 2. Split conversation into old / recent.
139        let mut recent_start = conversation.len() - self.config.keep_recent_messages;
140        recent_start = safe_cut_index(conversation, 0, recent_start);
141        tracing::info!(
142            elapsed_ms = t0.elapsed().as_millis() as u64,
143            recent_start,
144            "[compact-timing] after split old/recent"
145        );
146        if recent_start == 0 {
147            return Ok(None);
148        }
149
150        // All checks passed — compression will proceed. Notify caller.
151
152        let old = &conversation[..recent_start];
153        let recent = &conversation[recent_start..];
154
155        // Defensive: never start the old block on an orphaned tool result.
156        if matches!(old.first(), Some(ChatMessage::Tool { .. })) {
157            tracing::warn!("context compression skipped: old block starts with a tool result");
158            return Ok(None);
159        }
160
161        // 3. Filter old summaries from both blocks.
162        let old: Vec<&ChatMessage> = old.iter().filter(|m| !is_summary_message(m)).collect();
163        let recent: Vec<&ChatMessage> = recent.iter().filter(|m| !is_summary_message(m)).collect();
164
165        // 4. Extract original goal (first user message in old block).
166        let original_goal = old
167            .iter()
168            .find_map(|m| match m {
169                ChatMessage::User { content, .. } if !content.starts_with(SUMMARY_PREFIX) => {
170                    Some(content.as_str())
171                }
172                _ => None,
173            })
174            .unwrap_or("(unknown goal)");
175        let original_goal = truncate_str(original_goal, 400);
176
177        // 5. Separate user messages (preserved verbatim) from assistant/tool
178        //    messages (sent to summarizer).
179        let preserved_user_msgs: Vec<&ChatMessage> = old
180            .iter()
181            .filter(|m| matches!(m, ChatMessage::User { .. }))
182            .copied()
183            .collect();
184        let assistant_tool_msgs: Vec<&ChatMessage> = old
185            .iter()
186            .filter(|m| !matches!(m, ChatMessage::User { .. }))
187            .copied()
188            .collect();
189
190        // Serialize only assistant/tool messages for the summarizer.
191        let transcript = truncate_to_chars(&assistant_tool_msgs, self.config.max_transcript_chars);
192        if transcript.trim().is_empty() && preserved_user_msgs.is_empty() {
193            return Ok(None);
194        }
195
196        // All early-exit checks passed — compression will proceed.
197        let msg_count = messages.len();
198        if let Some(emit) = emit_fn {
199            emit(CompressionEvent::Preparing {
200                session_id,
201                tokens_before: total_tokens,
202                msg_count,
203                trigger: trigger.clone(),
204            });
205            emit(CompressionEvent::Started {
206                session_id,
207                tokens_before: total_tokens,
208                msg_count,
209                trigger: trigger.clone(),
210            });
211        }
212
213        const CACHE_PREFIX_CHARS: usize = 4096;
214        let prefix: String = transcript.chars().take(CACHE_PREFIX_CHARS).collect();
215        let key = (session_id, hash_str(&prefix));
216
217        let cached = self.cache.lock().ok().and_then(|c| c.get(&key).cloned());
218
219        // Reuse cached summary when the transcript hasn't changed, OR when it
220        // has hit the max_transcript_chars cap (content is frozen at that point,
221        // further growth only affects the tail which is truncated away).
222        let cache_effective = matches!(cached, Some((cl, _)) if cl == transcript.len() || transcript.len() >= self.config.max_transcript_chars);
223
224        let summary = if transcript.trim().is_empty() {
225            // No assistant/tool content to summarize — skip LLM call.
226            String::new()
227        } else {
228            match cached {
229                Some((_, ref s)) if cache_effective => s.clone(),
230                _ => {
231                    // Notify caller that we're about to call the LLM.
232                    if let Some(emit) = emit_fn {
233                        emit(CompressionEvent::Progress {
234                            session_id,
235                            chars: 0,
236                        });
237                    }
238                    tracing::info!(
239                        elapsed_ms = t0.elapsed().as_millis() as u64,
240                        "[compact-timing] before summarize() LLM call"
241                    );
242                    // summarize() reports cumulative char count via on_progress.
243                    // Wrap it to emit CompressionEvent::Progress.
244                    let s = if let Some(emit) = emit_fn {
245                        let progress_fn = |chars: usize| {
246                            emit(CompressionEvent::Progress { session_id, chars });
247                        };
248                        summarize(
249                            self.client.as_ref(),
250                            &transcript,
251                            &original_goal,
252                            self.config.max_summary_chars,
253                            Some(&progress_fn),
254                        )
255                        .await
256                    } else {
257                        summarize(
258                            self.client.as_ref(),
259                            &transcript,
260                            &original_goal,
261                            self.config.max_summary_chars,
262                            None,
263                        )
264                        .await
265                    };
266                    let s = match s {
267                        Ok(s) => {
268                            tracing::info!(
269                                elapsed_ms = t0.elapsed().as_millis() as u64,
270                                "[compact-timing] summarize() returned Ok"
271                            );
272                            s
273                        }
274                        Err(e) => {
275                            tracing::warn!(
276                                session_id,
277                                "summarisation failed, falling back to dropping old block: {e}"
278                            );
279                            String::new()
280                        }
281                    };
282                    if !s.is_empty()
283                        && let Ok(mut cache) = self.cache.lock()
284                    {
285                        cache.insert(key, (transcript.len(), s.clone()));
286                    }
287                    s
288                }
289            }
290        };
291
292        // 6. Assemble: [system prompt] + [preserved user messages] + [summary] + [recent N].
293        //    User messages are preserved verbatim (truncated to MAX_PRESERVED_USER_TOKENS)
294        //    so the LLM always sees exact user instructions.
295        let mut new_messages: Vec<ChatMessage> = system_msgs.to_vec();
296
297        // Add preserved user messages, truncated from the start to fit.
298        let truncated_user =
299            truncate_user_messages(&preserved_user_msgs, Self::MAX_PRESERVED_USER_TOKENS);
300
301        let trimmed = summary.trim();
302
303        // 7. Guard: if the replacement (preserved users + summary) is larger
304        //    than the original old block, discard the compression and keep the
305        //    original messages.  This prevents negative compression on short
306        //    conversations where the summary overhead exceeds the original
307        //    assistant content.
308        let old_block_chars: usize = old.iter().map(|m| message_content_len(m)).sum();
309        let replacement_chars: usize = truncated_user
310            .iter()
311            .map(|m| message_content_len(m))
312            .sum::<usize>()
313            + if trimmed.is_empty() {
314                0
315            } else {
316                SUMMARY_PREFIX.len() + 1 + trimmed.len()
317            };
318        if replacement_chars >= old_block_chars {
319            tracing::info!(
320                session_id,
321                old_block_chars,
322                replacement_chars,
323                "[compact-timing] replacement larger than original, skipping compression"
324            );
325            return Ok(None);
326        }
327
328        new_messages.extend(truncated_user.into_iter().cloned());
329
330        if !trimmed.is_empty() {
331            new_messages.push(ChatMessage::user(format!("{SUMMARY_PREFIX}\n{trimmed}")));
332        }
333        new_messages.extend(recent.into_iter().cloned());
334
335        // Record what happened.
336        let compressed_tokens: usize = new_messages.iter().map(estimate_message_tokens).sum();
337        tracing::info!(
338            session_id,
339            tokens_before = total_tokens,
340            tokens_after = compressed_tokens,
341            kept_recent = self.config.keep_recent_messages,
342            summary_len = trimmed.len(),
343            preserved_user_count = preserved_user_msgs.len(),
344            old_block_chars,
345            replacement_chars,
346            cache_hit = cache_effective,
347            elapsed_ms = t0.elapsed().as_millis() as u64,
348            "[compact-timing] compression complete"
349        );
350
351        Ok(Some(new_messages))
352    }
353
354    // ── Cache management ─────────────────────────────────────────────────
355
356    /// Clear all cached summaries.
357    pub fn clear_cache(&self) {
358        if let Ok(mut cache) = self.cache.lock() {
359            cache.clear();
360        }
361    }
362
363    // ── Session-level compression ──────────────────────────────────────────
364
365    /// Read-compress-write-back: compress a session's messages in place.
366    ///
367    /// Returns `Ok(true)` if compression was applied (session was above the
368    /// threshold), `Ok(false)` if the session was already below the threshold.
369    ///
370    /// Returns `Err` if the write-back validation fails (unexpected) or if
371    /// the session was modified concurrently between read and write-back
372    /// (TOCTOU guard).
373    pub async fn compact_session(
374        &self,
375        runtime: &AgentRuntime,
376        session_id: &SessionId,
377        emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
378    ) -> AgentResult<bool> {
379        let sid = session_id.id;
380        let trigger = CompressionTrigger::Manual;
381
382        // Phase 1: read messages from the session (lock held briefly).
383        let messages = runtime
384            .with_session_mut(session_id, |session| session.chat_messages().to_vec())
385            .await?;
386        let msg_count_before = messages.len();
387        let tokens_before: usize = messages.iter().map(estimate_message_tokens).sum();
388
389        // Phase 2: compress (async LLM call, no session lock held).
390        // Filter out old summary messages before compressing (same as middleware).
391        let filtered: Vec<ChatMessage> = messages
392            .iter()
393            .filter(|m| !is_summary_message(m))
394            .cloned()
395            .collect();
396
397        // Build emit_fn wrapper: CompressionEvent → into_user_event() → caller's emit_fn.
398        let compressed = if let Some(ref ef) = emit_fn {
399            let ef = ef.clone();
400            self.compact(
401                sid,
402                &filtered,
403                trigger.clone(),
404                Some(&move |ev: CompressionEvent| {
405                    ef(ev.into_user_event());
406                }),
407            )
408            .await?
409        } else {
410            self.compact(sid, &filtered, trigger.clone(), None).await?
411        };
412
413        let compressed = match compressed {
414            Some(msgs) => msgs,
415            None => {
416                // Below threshold — no-op, no events needed.
417                return Ok(false);
418            }
419        };
420
421        let tokens_after: usize = compressed.iter().map(estimate_message_tokens).sum();
422        let msg_count_after = compressed.len();
423
424        // Phase 3: write back (lock held briefly for set_chat_messages + validation).
425        // TOCTOU guard: verify message count hasn't changed since Phase 1.
426        runtime
427            .with_session_mut(session_id, |session| {
428                let msg_count_now = session.chat_messages().len();
429                if msg_count_now != msg_count_before {
430                    return Err(agent_base::AgentError::internal(format!(
431                        "session modified concurrently ({} → {} messages), aborting write-back",
432                        msg_count_before, msg_count_now
433                    )));
434                }
435                session.set_chat_messages(compressed).map_err(|e| {
436                    agent_base::AgentError::internal(format!(
437                        "set_chat_messages validation failed: {e}"
438                    ))
439                })
440            })
441            .await??;
442
443        // Emit Completed event.
444        let reduction_pct = if tokens_before > 0 {
445            ((tokens_before as f64 - tokens_after as f64) / tokens_before as f64 * 100.0).round()
446                as i32
447        } else {
448            0
449        };
450        if let Some(ref f) = emit_fn {
451            f(CompressionEvent::Completed {
452                session_id: sid,
453                tokens_before,
454                tokens_after,
455                reduction_pct,
456                msg_count_before,
457                msg_count_after,
458                trigger,
459            }
460            .into_user_event());
461        }
462
463        Ok(true)
464    }
465}
466
467// ── Helpers ──────────────────────────────────────────────────────────────────
468
469/// Total character length of a message's text content.
470///
471/// Used for size-based comparison (before vs after compression) rather than
472/// token estimation.
473fn message_content_len(msg: &ChatMessage) -> usize {
474    match msg {
475        ChatMessage::System { content, .. } => content.len(),
476        ChatMessage::User { content, .. } => content.len(),
477        ChatMessage::Assistant {
478            content,
479            reasoning_content,
480            tool_calls,
481            thinking_signature: _,
482        } => {
483            let mut len = content.as_deref().map(|c| c.len()).unwrap_or(0);
484            if let Some(rc) = reasoning_content {
485                len += rc.len();
486            }
487            if let Some(calls) = tool_calls {
488                for c in calls {
489                    len += c.id.len() + c.name.len() + c.arguments.len();
490                }
491            }
492            len
493        }
494        ChatMessage::Tool {
495            tool_call_id,
496            content,
497            ..
498        } => tool_call_id.len() + content.len(),
499        ChatMessage::Custom { role, data } => role.len() + data.to_string().len(),
500    }
501}
502
503/// Walk `cut` backward until the boundary is tool-pairing safe.
504///
505/// A cut is *unsafe* when:
506/// - the message just left of the cut is an `Assistant` with pending tool calls, or
507/// - the message just right of the cut is a `Tool` result.
508///
509/// In either case we move the cut leftward until neither condition holds,
510/// ensuring the resulting message list never has orphaned `Tool` messages or
511/// `Assistant{tool_calls}` whose results are missing.
512pub fn safe_cut_index(messages: &[ChatMessage], start: usize, mut cut: usize) -> usize {
513    while cut > start {
514        let left_is_tool_call = matches!(
515            messages[cut - 1],
516            ChatMessage::Assistant {
517                tool_calls: Some(_),
518                ..
519            }
520        );
521        let right_is_tool = matches!(messages[cut], ChatMessage::Tool { .. });
522        if !left_is_tool_call && !right_is_tool {
523            break;
524        }
525        cut -= 1;
526    }
527    cut
528}
529
530/// CJK-aware token estimate for a single message.
531///
532/// Mirrors `ContextWindowManager::message_tokens` (which is `pub(crate)` in
533/// agent-base and not reachable from here).
534pub(crate) fn estimate_message_tokens(msg: &ChatMessage) -> usize {
535    match msg {
536        ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
537        ChatMessage::User {
538            content, images, ..
539        } => ContextWindowManager::estimate_tokens(content) + images.len() * 85,
540        ChatMessage::Assistant {
541            content,
542            reasoning_content,
543            tool_calls,
544            thinking_signature: _,
545        } => {
546            let mut tokens = content
547                .as_deref()
548                .map(ContextWindowManager::estimate_tokens)
549                .unwrap_or(0);
550            if let Some(rc) = reasoning_content {
551                tokens += ContextWindowManager::estimate_tokens(rc);
552            }
553            if let Some(calls) = tool_calls {
554                for c in calls {
555                    tokens += ContextWindowManager::estimate_tokens(&c.id);
556                    tokens += ContextWindowManager::estimate_tokens(&c.name);
557                    tokens += ContextWindowManager::estimate_tokens(&c.arguments);
558                }
559            }
560            tokens
561        }
562        ChatMessage::Tool {
563            tool_call_id,
564            content,
565            ..
566        } => {
567            ContextWindowManager::estimate_tokens(tool_call_id)
568                + ContextWindowManager::estimate_tokens(content)
569        }
570        ChatMessage::Custom { role, data } => {
571            ContextWindowManager::estimate_tokens(role)
572                + ContextWindowManager::estimate_tokens(&data.to_string())
573        }
574    }
575}
576
577/// Estimate total tokens across a message list.
578#[allow(dead_code)]
579fn estimate_total_tokens(messages: &[ChatMessage]) -> usize {
580    messages.iter().map(estimate_message_tokens).sum()
581}
582
583/// Hash a string into a `u64` using the default hasher.
584fn hash_str(s: &str) -> u64 {
585    let mut h = DefaultHasher::new();
586    s.hash(&mut h);
587    h.finish()
588}
589
590/// Serialise a block of messages into a compact human-readable transcript.
591///
592/// Each message is rendered as `[role] content` with individual fields
593/// truncated.  The entire output is then truncated to `max_chars`.
594pub fn serialize_block(messages: &[&ChatMessage], max_chars: usize) -> String {
595    let mut parts: Vec<String> = Vec::with_capacity(messages.len());
596    for msg in messages {
597        let line = match msg {
598            ChatMessage::System { content, .. } => {
599                format!("[system] {}", truncate_str(content, 400))
600            }
601            ChatMessage::User { content, .. } => format!("[user] {}", truncate_str(content, 400)),
602            ChatMessage::Assistant {
603                content,
604                tool_calls,
605                ..
606            } => match tool_calls {
607                Some(calls) if !calls.is_empty() => {
608                    let calls: Vec<String> = calls
609                        .iter()
610                        .map(|c| format!("{}({})", c.name, truncate_str(&c.arguments, 150)))
611                        .collect();
612                    format!("[assistant tool_call] {}", calls.join("; "))
613                }
614                _ => format!(
615                    "[assistant] {}",
616                    content
617                        .as_deref()
618                        .map(|c| truncate_str(c, 400))
619                        .unwrap_or_default()
620                ),
621            },
622            ChatMessage::Tool {
623                tool_call_id,
624                content,
625                ..
626            } => format!(
627                "[tool:{}] {}",
628                truncate_str(tool_call_id, 50),
629                truncate_str(content, 300)
630            ),
631            ChatMessage::Custom { role, data } => {
632                format!("[custom:{}] {}", role, truncate_str(&data.to_string(), 400))
633            }
634        };
635        parts.push(line);
636    }
637    let joined = parts.join("\n");
638    truncate_str(&joined, max_chars)
639}
640
641/// Truncate a string to `max_chars`, appending `…` if truncated.
642pub fn truncate_str(s: &str, max_chars: usize) -> String {
643    let count = s.chars().count();
644    if count <= max_chars {
645        return s.to_string();
646    }
647    let head: String = s.chars().take(max_chars).collect();
648    format!("{head}…")
649}
650
651/// Serialise messages into a transcript and truncate to `max_chars`.
652fn truncate_to_chars(messages: &[&ChatMessage], max_chars: usize) -> String {
653    serialize_block(messages, max_chars)
654}
655
656/// Truncate a list of user messages to fit within `max_tokens`.
657///
658/// Preserves messages from the *end* (most recent) and drops from the start
659/// when the budget is exceeded.  This mirrors codex's approach: recent user
660/// instructions are more valuable than older ones.
661fn truncate_user_messages<'a>(
662    messages: &[&'a ChatMessage],
663    max_tokens: usize,
664) -> Vec<&'a ChatMessage> {
665    if max_tokens == 0 {
666        return Vec::new();
667    }
668    let mut result: Vec<&ChatMessage> = Vec::with_capacity(messages.len());
669    let mut remaining = max_tokens;
670    // Walk from end to start, keeping messages that fit.
671    for msg in messages.iter().rev() {
672        let tokens = estimate_message_tokens(msg);
673        if tokens <= remaining {
674            result.push(msg);
675            remaining -= tokens;
676        }
677        // Skip oversized messages — don't break, earlier messages may still fit.
678    }
679    result.reverse();
680    result
681}
682
683// ── ContextCompaction trait implementation ──────────────────────────────────
684
685/// Implement the agent-base `ContextCompaction` trait so the react loop
686/// can trigger inline compaction without depending on agent-works directly.
687#[async_trait::async_trait]
688impl agent_base::ContextCompaction for ContextCompactor {
689    async fn compact(
690        &self,
691        session_id: &SessionId,
692        messages: &[ChatMessage],
693    ) -> Option<Vec<ChatMessage>> {
694        // Delegate to the existing compact() method with InlineCompaction trigger
695        match self
696            .compact(
697                session_id.id,
698                messages,
699                CompressionTrigger::InlineCompaction,
700                None,
701            )
702            .await
703        {
704            Ok(result) => result,
705            Err(e) => {
706                tracing::warn!(
707                    session_id = session_id.id,
708                    error = %e,
709                    "inline compaction failed"
710                );
711                None
712            }
713        }
714    }
715
716    fn token_count_hint(&self, _session_id: &SessionId) -> Option<usize> {
717        // The react loop uses ContextWindowManager for estimation
718        None
719    }
720}
721
722// ── Tests ────────────────────────────────────────────────────────────────────
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use agent_base::ToolCallMessage;
728    use agent_base::llm_trait::response::FinishReason;
729    use agent_base::llm_trait::types::UsageInfo;
730    use agent_base::llm_trait::{
731        Capabilities, ChatRequest, ChatResponse, ChatStream, LlmError, LlmProvider, ProviderInfo,
732    };
733
734    // ── Test helpers ──────────────────────────────────────────────────────
735
736    /// Minimal mock that returns a fixed string.
737    struct MockClient(&'static str);
738
739    #[async_trait::async_trait]
740    impl LlmProvider for MockClient {
741        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
742            let response = self.0.to_string();
743            Ok(ChatStream::new(Box::pin(futures_util::stream::once(
744                async move { Ok(agent_base::StreamChunk::Text(response)) },
745            ))))
746        }
747
748        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
749            Ok(ChatResponse {
750                content: self.0.to_string(),
751                tool_calls: vec![],
752                usage: UsageInfo::default(),
753                finish_reason: FinishReason::Stop,
754                raw: None,
755                reasoning_content: None,
756                thinking_signature: None,
757            })
758        }
759
760        fn capabilities(&self) -> Capabilities {
761            Capabilities::default()
762        }
763
764        fn info(&self) -> ProviderInfo {
765            ProviderInfo {
766                name: "stub".to_string(),
767                model: "stub-model".to_string(),
768                version: None,
769            }
770        }
771    }
772
773    /// Mock that counts invocations.
774    struct CountingClient {
775        response: &'static str,
776        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
777    }
778
779    #[async_trait::async_trait]
780    impl LlmProvider for CountingClient {
781        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
782            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
783            let response = self.response.to_string();
784            Ok(ChatStream::new(Box::pin(futures_util::stream::once(
785                async move { Ok(agent_base::StreamChunk::Text(response)) },
786            ))))
787        }
788
789        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
790            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
791            Ok(ChatResponse {
792                content: self.response.to_string(),
793                tool_calls: vec![],
794                usage: UsageInfo::default(),
795                finish_reason: FinishReason::Stop,
796                raw: None,
797                reasoning_content: None,
798                thinking_signature: None,
799            })
800        }
801
802        fn capabilities(&self) -> Capabilities {
803            Capabilities::default()
804        }
805
806        fn info(&self) -> ProviderInfo {
807            ProviderInfo {
808                name: "stub".to_string(),
809                model: "stub-model".to_string(),
810                version: None,
811            }
812        }
813    }
814
815    /// Mock that always returns an error.
816    struct FailingClient;
817
818    #[async_trait::async_trait]
819    impl LlmProvider for FailingClient {
820        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
821            Err(LlmError::llm("summarisation failed"))
822        }
823
824        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
825            Err(LlmError::llm("summarisation failed"))
826        }
827
828        fn capabilities(&self) -> Capabilities {
829            Capabilities::default()
830        }
831
832        fn info(&self) -> ProviderInfo {
833            ProviderInfo {
834                name: "stub".to_string(),
835                model: "stub-model".to_string(),
836                version: None,
837            }
838        }
839    }
840
841    fn make_messages(count: usize) -> Vec<ChatMessage> {
842        let mut msgs = vec![ChatMessage::system("You are a test agent.")];
843        for i in 0..count {
844            msgs.push(ChatMessage::user(format!("question {i}")));
845            msgs.push(ChatMessage::assistant(format!(
846                "answer {i} with some extra content to make it longer"
847            )));
848        }
849        msgs
850    }
851
852    // ── safe_cut_index ────────────────────────────────────────────────────
853
854    #[test]
855    fn test_safe_cut_never_splits_tool_pair() {
856        let msgs = vec![
857            ChatMessage::user("hi"),
858            ChatMessage::assistant(""),
859            ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
860            ChatMessage::tool("call_1", "result"),
861            ChatMessage::user("next"),
862        ];
863        // Cut at 3 (between assistant{tool_call} and tool result) → unsafe.
864        // Should walk back past the assistant{tool_call} message.
865        let cut = super::safe_cut_index(&msgs, 0, 3);
866        assert!(
867            cut <= 2,
868            "cut should walk back before assistant{{tool_call}}"
869        );
870        assert!(
871            !matches!(
872                &msgs[cut - 1],
873                ChatMessage::Assistant {
874                    tool_calls: Some(_),
875                    ..
876                }
877            ),
878            "left boundary must not be assistant with pending tool calls"
879        );
880    }
881
882    #[test]
883    fn test_safe_cut_prefers_given_boundary_when_safe() {
884        let msgs = vec![
885            ChatMessage::user("hi"),
886            ChatMessage::assistant("hello"),
887            ChatMessage::user("next"),
888            ChatMessage::assistant("world"),
889        ];
890        // Cut at 2 is safe: left=Assistant(no tool_calls), right=User.
891        assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
892    }
893
894    #[test]
895    fn test_safe_cut_walks_back_from_orphan_tool() {
896        let msgs = vec![
897            ChatMessage::user("a"),
898            ChatMessage::assistant("b"),
899            ChatMessage::tool("c1", "r1"),
900            ChatMessage::user("d"),
901        ];
902        // Cut=1 lands on assistant (no tool_calls), right=user → safe.
903        assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
904    }
905
906    // ── estimate_message_tokens ───────────────────────────────────────────
907
908    #[test]
909    fn test_estimate_tokens_cjk_and_tool() {
910        let msg = ChatMessage::tool("t1", "你好世界");
911        let tokens = super::estimate_message_tokens(&msg);
912        // CJK: ~4 chars / 1.5 ≈ 3; id "t1": 2 chars / 4 = 1
913        assert!((2..=10).contains(&tokens));
914    }
915
916    #[test]
917    fn test_estimate_tokens_user_with_images() {
918        let msg = ChatMessage::user_with_images(
919            "describe this",
920            vec![agent_base::ImageAttachment::Url {
921                url: "http://example.com/img.png".into(),
922                detail: Some(agent_base::ImageDetail::Auto),
923            }],
924        );
925        let tokens = super::estimate_message_tokens(&msg);
926        // Should include the 85-token per-image overhead.
927        assert!(tokens >= 85);
928    }
929
930    // ── serialize_block ───────────────────────────────────────────────────
931
932    #[test]
933    fn test_serialize_block_preserves_tool_calls() {
934        let msgs = [
935            ChatMessage::assistant(""),
936            ChatMessage::tool("c1", "result"),
937        ];
938        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
939        let out = super::serialize_block(&msg_refs, 2000);
940        assert!(out.contains("[tool:c1]"), "got: {out}");
941        assert!(out.contains("result"), "got: {out}");
942    }
943
944    #[test]
945    fn test_serialize_block_truncates_oversized_fields() {
946        let long_arg = "x".repeat(500);
947        let msgs = [ChatMessage::Assistant {
948            content: None,
949            reasoning_content: None,
950            tool_calls: Some(vec![ToolCallMessage {
951                id: "t1".into(),
952                name: "bash".into(),
953                arguments: long_arg,
954            }]),
955            thinking_signature: None,
956        }];
957        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
958        let out = super::serialize_block(&msg_refs, 2000);
959        // Should contain "bash(" and "…", but NOT all 500 chars.
960        assert!(out.contains("bash("));
961        assert!(out.contains("…"));
962        assert!(out.len() < 400);
963    }
964
965    // ── compact ───────────────────────────────────────────────────────────
966
967    #[tokio::test]
968    async fn test_compact_noop_when_disabled() {
969        let config = CompressionConfig::default().with_enabled(false);
970        let client = std::sync::Arc::new(MockClient("summary"));
971        let compactor = ContextCompactor::new(client, config);
972        let msgs = make_messages(100);
973        let result = compactor
974            .compact(1, &msgs, CompressionTrigger::Auto, None)
975            .await
976            .unwrap();
977        assert!(result.is_none());
978    }
979
980    #[tokio::test]
981    async fn test_compact_noop_when_below_threshold() {
982        let config = CompressionConfig::default().with_trigger_tokens(999_999);
983        let client = std::sync::Arc::new(MockClient("summary"));
984        let compactor = ContextCompactor::new(client, config);
985        let msgs = make_messages(10);
986        let result = compactor
987            .compact(1, &msgs, CompressionTrigger::Auto, None)
988            .await
989            .unwrap();
990        assert!(result.is_none());
991    }
992
993    #[tokio::test]
994    async fn test_compact_produces_valid_output() {
995        let config = CompressionConfig::default()
996            .with_trigger_tokens(1) // Always trigger.
997            .with_keep_recent_messages(4);
998        let client = std::sync::Arc::new(MockClient("test summary text"));
999        let compactor = ContextCompactor::new(client, config);
1000        let msgs = make_messages(20);
1001        let result = compactor
1002            .compact(1, &msgs, CompressionTrigger::Auto, None)
1003            .await
1004            .unwrap()
1005            .unwrap();
1006
1007        // First message should be system prompt.
1008        assert!(matches!(&result[0], ChatMessage::System { .. }));
1009
1010        // Should contain a summary message and then recent messages.
1011        let has_summary = result.iter().any(|m| match m {
1012            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1013            _ => false,
1014        });
1015        assert!(has_summary, "expected summary message in output");
1016
1017        // Recent messages should be present.
1018        let last = result.last().unwrap();
1019        assert!(matches!(
1020            last,
1021            ChatMessage::User { .. } | ChatMessage::Assistant { .. }
1022        ));
1023
1024        // No orphan Tool messages: every Tool must have index > 0.
1025        for (i, msg) in result.iter().enumerate() {
1026            assert!(
1027                !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
1028                "orphan Tool at index 0"
1029            );
1030        }
1031    }
1032
1033    // ── compact with failing client ───────────────────────────────────────
1034
1035    #[tokio::test]
1036    async fn test_compact_fallback_on_summarisation_failure() {
1037        let config = CompressionConfig::default()
1038            .with_trigger_tokens(1)
1039            .with_keep_recent_messages(4);
1040        let client = std::sync::Arc::new(FailingClient);
1041        let compactor = ContextCompactor::new(client, config);
1042        let msgs = make_messages(20);
1043        let result = compactor
1044            .compact(1, &msgs, CompressionTrigger::Auto, None)
1045            .await
1046            .unwrap()
1047            .unwrap();
1048
1049        // Fallback: summary is empty, so we get system + recent only.
1050        assert!(matches!(&result[0], ChatMessage::System { .. }));
1051        let has_summary = result.iter().any(|m| match m {
1052            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1053            _ => false,
1054        });
1055        assert!(!has_summary, "should have no summary on failure");
1056    }
1057
1058    // ── compact cache ─────────────────────────────────────────────────────
1059
1060    #[tokio::test]
1061    async fn test_compact_caches_summary() {
1062        let config = CompressionConfig::default()
1063            .with_trigger_tokens(1)
1064            .with_keep_recent_messages(4);
1065        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1066        let client = std::sync::Arc::new(CountingClient {
1067            response: "cached summary",
1068            calls: calls.clone(),
1069        });
1070        let compactor = ContextCompactor::new(client, config);
1071        let msgs = make_messages(20);
1072
1073        // First call — should invoke LLM once.
1074        let _ = compactor
1075            .compact(1, &msgs, CompressionTrigger::Auto, None)
1076            .await
1077            .unwrap();
1078        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1079
1080        // Second call with same old block — should hit cache (no new LLM call).
1081        let _ = compactor
1082            .compact(1, &msgs, CompressionTrigger::Auto, None)
1083            .await
1084            .unwrap();
1085        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1086
1087        // Third call after appending messages (old block grows) — should re-summarize.
1088        let mut grown = msgs.clone();
1089        grown.push(ChatMessage::user("new question"));
1090        grown.push(ChatMessage::assistant("new answer"));
1091        let _ = compactor
1092            .compact(1, &grown, CompressionTrigger::Auto, None)
1093            .await
1094            .unwrap();
1095        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1096    }
1097
1098    // ── compact cache length guard ────────────────────────────────────────
1099
1100    #[tokio::test]
1101    async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1102        // Construct old block > 4096 chars (CACHE_PREFIX_CHARS) so the prefix
1103        // hash stays stable when we grow the tail.  The length guard
1104        // (cl == transcript.len()) should detect the growth and re-summarise.
1105        let config = CompressionConfig::default()
1106            .with_trigger_tokens(1)
1107            .with_keep_recent_messages(4)
1108            .with_max_transcript_chars(60_000); // High cap so we don't hit the freeze guard.
1109        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1110        let client = std::sync::Arc::new(CountingClient {
1111            response: "summary",
1112            calls: calls.clone(),
1113        });
1114        let compactor = ContextCompactor::new(client, config);
1115
1116        // Each pair "question NNNN\nanswer NNNN" serialises to ~30 chars.
1117        // 200 pairs → ~6000 chars transcript (> 4096 prefix).
1118        let mut msgs = vec![ChatMessage::system("sys")];
1119        for i in 0..200 {
1120            msgs.push(ChatMessage::user(format!("question {i:04}")));
1121            msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1122        }
1123
1124        // First call — cache miss, LLM called once.
1125        let _ = compactor
1126            .compact(1, &msgs, CompressionTrigger::Auto, None)
1127            .await
1128            .unwrap();
1129        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1130
1131        // Second call, identical — cache hit (same prefix + same length).
1132        let _ = compactor
1133            .compact(1, &msgs, CompressionTrigger::Auto, None)
1134            .await
1135            .unwrap();
1136        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1137
1138        // Grow the tail: prefix hash unchanged (first 4096 chars identical),
1139        // but transcript length increased → length guard should reject cache.
1140        msgs.push(ChatMessage::user("extra question"));
1141        msgs.push(ChatMessage::assistant("extra answer"));
1142        let _ = compactor
1143            .compact(1, &msgs, CompressionTrigger::Auto, None)
1144            .await
1145            .unwrap();
1146        assert_eq!(
1147            calls.load(std::sync::atomic::Ordering::SeqCst),
1148            2,
1149            "length guard should have triggered re-summarisation"
1150        );
1151    }
1152
1153    // ── compact orphan Tool assertion ─────────────────────────────────────
1154
1155    #[tokio::test]
1156    async fn test_compact_no_orphan_tool_messages() {
1157        let config = CompressionConfig::default()
1158            .with_trigger_tokens(1)
1159            .with_keep_recent_messages(4);
1160        let client = std::sync::Arc::new(MockClient("summary"));
1161        let compactor = ContextCompactor::new(client, config);
1162
1163        // Build messages with tool_calls + tool results in the old block.
1164        let mut msgs = vec![ChatMessage::system("sys")];
1165        for i in 0..10 {
1166            msgs.push(ChatMessage::user(format!("q{i}")));
1167            msgs.push(ChatMessage::assistant_tool_call(
1168                format!("tc{i}"),
1169                "bash",
1170                format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1171            ));
1172            msgs.push(ChatMessage::tool(
1173                format!("tc{i}"),
1174                format!("result {i} with some extra output content"),
1175            ));
1176        }
1177
1178        let result = compactor
1179            .compact(1, &msgs, CompressionTrigger::Auto, None)
1180            .await
1181            .unwrap()
1182            .unwrap();
1183
1184        // No orphan Tool: every Tool must be preceded by an Assistant whose
1185        // tool_calls reference the same tool_call_id.
1186        for (i, msg) in result.iter().enumerate() {
1187            if let ChatMessage::Tool { tool_call_id, .. } = msg {
1188                assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1189                let prev = &result[i - 1];
1190                match prev {
1191                    ChatMessage::Assistant {
1192                        tool_calls: Some(calls),
1193                        ..
1194                    } => {
1195                        let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1196                        assert!(
1197                            ids.contains(&tool_call_id.as_str()),
1198                            "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1199                        );
1200                    }
1201                    other => panic!(
1202                        "Tool {tool_call_id} at index {i} not preceded by \
1203                         Assistant{{tool_calls}}, got: {other:?}"
1204                    ),
1205                }
1206            }
1207        }
1208    }
1209
1210    // ── compact boundary conditions ─────────────────────────────────────
1211
1212    #[tokio::test]
1213    async fn test_compact_noop_empty_messages() {
1214        let config = CompressionConfig::default().with_trigger_tokens(1);
1215        let client = std::sync::Arc::new(MockClient("summary"));
1216        let compactor = ContextCompactor::new(client, config);
1217        let result = compactor
1218            .compact(1, &[], CompressionTrigger::Auto, None)
1219            .await
1220            .unwrap();
1221        assert!(result.is_none());
1222    }
1223
1224    #[tokio::test]
1225    async fn test_compact_noop_system_only() {
1226        let config = CompressionConfig::default().with_trigger_tokens(1);
1227        let client = std::sync::Arc::new(MockClient("summary"));
1228        let compactor = ContextCompactor::new(client, config);
1229        let msgs = vec![ChatMessage::system("You are helpful.")];
1230        let result = compactor
1231            .compact(1, &msgs, CompressionTrigger::Auto, None)
1232            .await
1233            .unwrap();
1234        assert!(result.is_none());
1235    }
1236
1237    #[tokio::test]
1238    async fn test_compact_noop_conversation_equals_keep() {
1239        // conversation.len() == keep_recent_messages → too few to split.
1240        let config = CompressionConfig::default()
1241            .with_trigger_tokens(1)
1242            .with_keep_recent_messages(100);
1243        let client = std::sync::Arc::new(MockClient("summary"));
1244        let compactor = ContextCompactor::new(client, config);
1245        let msgs = make_messages(3); // 1 system + 6 conversation = 7
1246        let result = compactor
1247            .compact(1, &msgs, CompressionTrigger::Auto, None)
1248            .await
1249            .unwrap();
1250        assert!(result.is_none());
1251    }
1252
1253    #[tokio::test]
1254    async fn test_compact_filters_old_summary_in_old_block() {
1255        let config = CompressionConfig::default()
1256            .with_trigger_tokens(1)
1257            .with_keep_recent_messages(4);
1258        let client = std::sync::Arc::new(MockClient("new summary"));
1259        let compactor = ContextCompactor::new(client, config);
1260
1261        // Build messages with an old summary in the old block.
1262        let mut msgs = vec![ChatMessage::system("sys")];
1263        // Old summary (should be filtered).
1264        msgs.push(ChatMessage::user(format!(
1265            "{SUMMARY_PREFIX}\nprevious summary text"
1266        )));
1267        for i in 0..10 {
1268            msgs.push(ChatMessage::user(format!("question {i}")));
1269            msgs.push(ChatMessage::assistant(format!(
1270                "answer {i} with some extra content to make the old block large enough"
1271            )));
1272        }
1273
1274        let result = compactor
1275            .compact(1, &msgs, CompressionTrigger::Auto, None)
1276            .await
1277            .unwrap()
1278            .unwrap();
1279
1280        // The old summary should NOT appear in the output.
1281        let summary_count = result
1282            .iter()
1283            .filter(|m| match m {
1284                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1285                _ => false,
1286            })
1287            .count();
1288        assert_eq!(
1289            summary_count, 1,
1290            "should have exactly 1 summary (the new one), old one must be filtered"
1291        );
1292    }
1293
1294    #[tokio::test]
1295    async fn test_compact_filters_old_summary_in_recent_block() {
1296        let config = CompressionConfig::default()
1297            .with_trigger_tokens(1)
1298            .with_keep_recent_messages(4);
1299        let client = std::sync::Arc::new(MockClient("new summary"));
1300        let compactor = ContextCompactor::new(client, config);
1301
1302        // Build messages where old summary falls in the recent window.
1303        let mut msgs = vec![ChatMessage::system("sys")];
1304        for i in 0..5 {
1305            msgs.push(ChatMessage::user(format!("question {i}")));
1306            msgs.push(ChatMessage::assistant(format!(
1307                "answer {i} with some extra content to make the old block large enough"
1308            )));
1309        }
1310        // Old summary at the end of old block / start of recent.
1311        msgs.push(ChatMessage::user(format!(
1312            "{SUMMARY_PREFIX}\nold summary in recent"
1313        )));
1314        for i in 5..10 {
1315            msgs.push(ChatMessage::user(format!("question {i}")));
1316            msgs.push(ChatMessage::assistant(format!(
1317                "answer {i} with some extra content to make the old block large enough"
1318            )));
1319        }
1320
1321        let result = compactor
1322            .compact(1, &msgs, CompressionTrigger::Auto, None)
1323            .await
1324            .unwrap()
1325            .unwrap();
1326
1327        // Only the new summary should remain.
1328        let summary_count = result
1329            .iter()
1330            .filter(|m| match m {
1331                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1332                _ => false,
1333            })
1334            .count();
1335        assert_eq!(
1336            summary_count, 1,
1337            "old summary in recent block must be filtered"
1338        );
1339    }
1340
1341    #[tokio::test]
1342    #[ignore] // Requires real API key: DEEPSEEK_API_KEY
1343    async fn test_compact_with_real_deepseek_api() {
1344        // This test calls the real DeepSeek API to verify compact() works end-to-end.
1345        // Run with: cargo test test_compact_with_real_deepseek_api -- --nocapture
1346
1347        let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1348        if api_key.is_empty() {
1349            eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1350            return;
1351        }
1352
1353        let _base_url = std::env::var("DEEPSEEK_BASE_URL")
1354            .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1355
1356        // Create a real DeepSeek provider.
1357        // TODO: Use llm-unified factory to create provider.
1358        // let client: std::sync::Arc<dyn agent_base::llm_trait::LlmProvider> = ...;
1359        return; // TODO: update to new LlmProvider API
1360
1361        // The rest of this test needs to be updated for the new LlmProvider API.
1362        // It previously used agent_base::llm::adapt(OpenAiClient::new(...)).
1363        // TODO: Use llm-unified factory to create a real provider for integration testing.
1364    }
1365
1366    // ── proptest: truncate_str / safe_cut_index ──
1367
1368    mod proptest_tests {
1369        use super::*;
1370        use proptest::prelude::*;
1371
1372        proptest! {
1373            #[test]
1374            fn truncate_str_chars_count_bounded(s in ".*", max in 0usize..500) {
1375                let result = truncate_str(&s, max);
1376                let char_count = result.chars().count();
1377                let original_count = s.chars().count();
1378                if original_count <= max {
1379                    assert_eq!(char_count, original_count);
1380                } else {
1381                    // +1 for the '…' character
1382                    assert!(char_count <= max + 1,
1383                        "truncated {} chars > max {} + 1", char_count, max);
1384                }
1385            }
1386
1387            #[test]
1388            fn truncate_str_short_string_unchanged(s in "[a-zA-Z\u{4e00}-\u{9fff}]{0,50}", max in 50usize..200) {
1389                let result = truncate_str(&s, max);
1390                assert_eq!(result, s, "short string should be unchanged");
1391            }
1392
1393            #[test]
1394            fn truncate_str_cjk_char_boundary_safe(s in "[\u{4e00}-\u{9fff}]{1,200}", max in 1usize..100) {
1395                // Must not panic on char boundary
1396                let result = truncate_str(&s, max);
1397                assert!(result.chars().count() <= max + 1);
1398            }
1399
1400            #[test]
1401            fn truncate_str_result_is_valid_utf8(s in ".*", max in 0usize..500) {
1402                let result = truncate_str(&s, max);
1403                // Verify the result is valid UTF-8 and doesn't panic on string operations
1404                assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1405                let _ = result.len(); // byte length accessible
1406                let _ = result.chars().count(); // char count accessible
1407            }
1408        }
1409
1410        /// Generate a simple message sequence for safe_cut_index testing.
1411        fn arb_messages() -> impl Strategy<Value = Vec<ChatMessage>> {
1412            prop::collection::vec(
1413                prop_oneof![
1414                    "[a-z ]{0,50}".prop_map(|s| ChatMessage::user(&s)),
1415                    "[a-z ]{0,50}".prop_map(|s| ChatMessage::assistant(&s)),
1416                    ("[a-z]{1,10}", "[a-z ]{0,50}")
1417                        .prop_map(|(id, content)| ChatMessage::tool(&id, &content)),
1418                ],
1419                0..15,
1420            )
1421        }
1422
1423        proptest! {
1424            #[test]
1425            fn safe_cut_index_never_panics(
1426                messages in arb_messages(),
1427                start in 0usize..15,
1428            ) {
1429                if messages.is_empty() || start >= messages.len() {
1430                    return Ok(());
1431                }
1432                let cut = start + (messages.len() - start) / 2;
1433                if cut > messages.len() {
1434                    return Ok(());
1435                }
1436                let _result = super::safe_cut_index(&messages, start, cut);
1437            }
1438
1439            #[test]
1440            fn safe_cut_index_result_in_bounds(
1441                messages in arb_messages(),
1442                start in 0usize..15,
1443            ) {
1444                if messages.is_empty() || start >= messages.len() {
1445                    return Ok(());
1446                }
1447                let cut = start + (messages.len() - start) / 2;
1448                if cut > messages.len() || cut < start {
1449                    return Ok(());
1450                }
1451                let result = super::safe_cut_index(&messages, start, cut);
1452                assert!(result >= start, "result {} < start {}", result, start);
1453                assert!(result <= messages.len(), "result {} > len {}", result, messages.len());
1454            }
1455
1456            #[test]
1457            fn safe_cut_index_never_ends_on_tool_call(
1458                messages in arb_messages(),
1459                start in 0usize..15,
1460            ) {
1461                if messages.is_empty() || start >= messages.len() {
1462                    return Ok(());
1463                }
1464                let cut = start + (messages.len() - start) / 2;
1465                if cut > messages.len() || cut < start {
1466                    return Ok(());
1467                }
1468                let result = super::safe_cut_index(&messages, start, cut);
1469                // The message left of the cut should not be Assistant with tool_calls
1470                if result > 0 && result < messages.len() {
1471                    assert!(
1472                        !matches!(&messages[result - 1], ChatMessage::Assistant { tool_calls: Some(_), .. }),
1473                        "safe_cut left boundary should not be assistant with tool_calls"
1474                    );
1475                }
1476            }
1477        }
1478    }
1479}