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<agent_base::CompactionOutcome> {
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(Some(result)) => {
705                // This compactor always replaces the history wholesale, which
706                // is the Reset kind for logging — the react loop must say
707                // "compaction" here (issue #33).
708                Some(agent_base::CompactionOutcome {
709                    kind: agent_base::CompactionKind::Reset,
710                    messages: result,
711                })
712            }
713            Ok(None) => None,
714            Err(e) => {
715                tracing::warn!(
716                    session_id = session_id.id,
717                    error = %e,
718                    "inline compaction failed"
719                );
720                None
721            }
722        }
723    }
724
725    fn token_count_hint(&self, _session_id: &SessionId) -> Option<usize> {
726        // The react loop uses ContextWindowManager for estimation
727        None
728    }
729}
730
731// ── Tests ────────────────────────────────────────────────────────────────────
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use agent_base::ToolCallMessage;
737    use agent_base::llm_trait::response::FinishReason;
738    use agent_base::llm_trait::types::UsageInfo;
739    use agent_base::llm_trait::{
740        Capabilities, ChatRequest, ChatResponse, ChatStream, LlmError, LlmProvider, ProviderInfo,
741    };
742
743    // ── Test helpers ──────────────────────────────────────────────────────
744
745    /// Minimal mock that returns a fixed string.
746    struct MockClient(&'static str);
747
748    #[async_trait::async_trait]
749    impl LlmProvider for MockClient {
750        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
751            let response = self.0.to_string();
752            Ok(ChatStream::new(Box::pin(futures_util::stream::once(
753                async move { Ok(agent_base::StreamChunk::Text(response)) },
754            ))))
755        }
756
757        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
758            Ok(ChatResponse {
759                content: self.0.to_string(),
760                tool_calls: vec![],
761                usage: UsageInfo::default(),
762                finish_reason: FinishReason::Stop,
763                raw: None,
764                reasoning_content: None,
765                thinking_signature: None,
766            })
767        }
768
769        fn capabilities(&self) -> Capabilities {
770            Capabilities::default()
771        }
772
773        fn info(&self) -> ProviderInfo {
774            ProviderInfo {
775                name: "stub".to_string(),
776                model: "stub-model".to_string(),
777                version: None,
778            }
779        }
780    }
781
782    /// Mock that counts invocations.
783    struct CountingClient {
784        response: &'static str,
785        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
786    }
787
788    #[async_trait::async_trait]
789    impl LlmProvider for CountingClient {
790        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
791            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
792            let response = self.response.to_string();
793            Ok(ChatStream::new(Box::pin(futures_util::stream::once(
794                async move { Ok(agent_base::StreamChunk::Text(response)) },
795            ))))
796        }
797
798        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
799            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
800            Ok(ChatResponse {
801                content: self.response.to_string(),
802                tool_calls: vec![],
803                usage: UsageInfo::default(),
804                finish_reason: FinishReason::Stop,
805                raw: None,
806                reasoning_content: None,
807                thinking_signature: None,
808            })
809        }
810
811        fn capabilities(&self) -> Capabilities {
812            Capabilities::default()
813        }
814
815        fn info(&self) -> ProviderInfo {
816            ProviderInfo {
817                name: "stub".to_string(),
818                model: "stub-model".to_string(),
819                version: None,
820            }
821        }
822    }
823
824    /// Mock that always returns an error.
825    struct FailingClient;
826
827    #[async_trait::async_trait]
828    impl LlmProvider for FailingClient {
829        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
830            Err(LlmError::llm("summarisation failed"))
831        }
832
833        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
834            Err(LlmError::llm("summarisation failed"))
835        }
836
837        fn capabilities(&self) -> Capabilities {
838            Capabilities::default()
839        }
840
841        fn info(&self) -> ProviderInfo {
842            ProviderInfo {
843                name: "stub".to_string(),
844                model: "stub-model".to_string(),
845                version: None,
846            }
847        }
848    }
849
850    fn make_messages(count: usize) -> Vec<ChatMessage> {
851        let mut msgs = vec![ChatMessage::system("You are a test agent.")];
852        for i in 0..count {
853            msgs.push(ChatMessage::user(format!("question {i}")));
854            msgs.push(ChatMessage::assistant(format!(
855                "answer {i} with some extra content to make it longer"
856            )));
857        }
858        msgs
859    }
860
861    // ── safe_cut_index ────────────────────────────────────────────────────
862
863    #[test]
864    fn test_safe_cut_never_splits_tool_pair() {
865        let msgs = vec![
866            ChatMessage::user("hi"),
867            ChatMessage::assistant(""),
868            ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
869            ChatMessage::tool("call_1", "result"),
870            ChatMessage::user("next"),
871        ];
872        // Cut at 3 (between assistant{tool_call} and tool result) → unsafe.
873        // Should walk back past the assistant{tool_call} message.
874        let cut = super::safe_cut_index(&msgs, 0, 3);
875        assert!(
876            cut <= 2,
877            "cut should walk back before assistant{{tool_call}}"
878        );
879        assert!(
880            !matches!(
881                &msgs[cut - 1],
882                ChatMessage::Assistant {
883                    tool_calls: Some(_),
884                    ..
885                }
886            ),
887            "left boundary must not be assistant with pending tool calls"
888        );
889    }
890
891    #[test]
892    fn test_safe_cut_prefers_given_boundary_when_safe() {
893        let msgs = vec![
894            ChatMessage::user("hi"),
895            ChatMessage::assistant("hello"),
896            ChatMessage::user("next"),
897            ChatMessage::assistant("world"),
898        ];
899        // Cut at 2 is safe: left=Assistant(no tool_calls), right=User.
900        assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
901    }
902
903    #[test]
904    fn test_safe_cut_walks_back_from_orphan_tool() {
905        let msgs = vec![
906            ChatMessage::user("a"),
907            ChatMessage::assistant("b"),
908            ChatMessage::tool("c1", "r1"),
909            ChatMessage::user("d"),
910        ];
911        // Cut=1 lands on assistant (no tool_calls), right=user → safe.
912        assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
913    }
914
915    // ── estimate_message_tokens ───────────────────────────────────────────
916
917    #[test]
918    fn test_estimate_tokens_cjk_and_tool() {
919        let msg = ChatMessage::tool("t1", "你好世界");
920        let tokens = super::estimate_message_tokens(&msg);
921        // CJK: ~4 chars / 1.5 ≈ 3; id "t1": 2 chars / 4 = 1
922        assert!((2..=10).contains(&tokens));
923    }
924
925    #[test]
926    fn test_estimate_tokens_user_with_images() {
927        let msg = ChatMessage::user_with_images(
928            "describe this",
929            vec![agent_base::ImageAttachment::Url {
930                url: "http://example.com/img.png".into(),
931                detail: Some(agent_base::ImageDetail::Auto),
932            }],
933        );
934        let tokens = super::estimate_message_tokens(&msg);
935        // Should include the 85-token per-image overhead.
936        assert!(tokens >= 85);
937    }
938
939    // ── serialize_block ───────────────────────────────────────────────────
940
941    #[test]
942    fn test_serialize_block_preserves_tool_calls() {
943        let msgs = [
944            ChatMessage::assistant(""),
945            ChatMessage::tool("c1", "result"),
946        ];
947        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
948        let out = super::serialize_block(&msg_refs, 2000);
949        assert!(out.contains("[tool:c1]"), "got: {out}");
950        assert!(out.contains("result"), "got: {out}");
951    }
952
953    #[test]
954    fn test_serialize_block_truncates_oversized_fields() {
955        let long_arg = "x".repeat(500);
956        let msgs = [ChatMessage::Assistant {
957            content: None,
958            reasoning_content: None,
959            tool_calls: Some(vec![ToolCallMessage {
960                id: "t1".into(),
961                name: "bash".into(),
962                arguments: long_arg,
963            }]),
964            thinking_signature: None,
965        }];
966        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
967        let out = super::serialize_block(&msg_refs, 2000);
968        // Should contain "bash(" and "…", but NOT all 500 chars.
969        assert!(out.contains("bash("));
970        assert!(out.contains("…"));
971        assert!(out.len() < 400);
972    }
973
974    // ── compact ───────────────────────────────────────────────────────────
975
976    #[tokio::test]
977    async fn test_compact_noop_when_disabled() {
978        let config = CompressionConfig::default().with_enabled(false);
979        let client = std::sync::Arc::new(MockClient("summary"));
980        let compactor = ContextCompactor::new(client, config);
981        let msgs = make_messages(100);
982        let result = compactor
983            .compact(1, &msgs, CompressionTrigger::Auto, None)
984            .await
985            .unwrap();
986        assert!(result.is_none());
987    }
988
989    #[tokio::test]
990    async fn test_compact_noop_when_below_threshold() {
991        let config = CompressionConfig::default().with_trigger_tokens(999_999);
992        let client = std::sync::Arc::new(MockClient("summary"));
993        let compactor = ContextCompactor::new(client, config);
994        let msgs = make_messages(10);
995        let result = compactor
996            .compact(1, &msgs, CompressionTrigger::Auto, None)
997            .await
998            .unwrap();
999        assert!(result.is_none());
1000    }
1001
1002    #[tokio::test]
1003    async fn test_compact_produces_valid_output() {
1004        let config = CompressionConfig::default()
1005            .with_trigger_tokens(1) // Always trigger.
1006            .with_keep_recent_messages(4);
1007        let client = std::sync::Arc::new(MockClient("test summary text"));
1008        let compactor = ContextCompactor::new(client, config);
1009        let msgs = make_messages(20);
1010        let result = compactor
1011            .compact(1, &msgs, CompressionTrigger::Auto, None)
1012            .await
1013            .unwrap()
1014            .unwrap();
1015
1016        // First message should be system prompt.
1017        assert!(matches!(&result[0], ChatMessage::System { .. }));
1018
1019        // Should contain a summary message and then recent messages.
1020        let has_summary = result.iter().any(|m| match m {
1021            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1022            _ => false,
1023        });
1024        assert!(has_summary, "expected summary message in output");
1025
1026        // Recent messages should be present.
1027        let last = result.last().unwrap();
1028        assert!(matches!(
1029            last,
1030            ChatMessage::User { .. } | ChatMessage::Assistant { .. }
1031        ));
1032
1033        // No orphan Tool messages: every Tool must have index > 0.
1034        for (i, msg) in result.iter().enumerate() {
1035            assert!(
1036                !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
1037                "orphan Tool at index 0"
1038            );
1039        }
1040    }
1041
1042    // ── compact with failing client ───────────────────────────────────────
1043
1044    #[tokio::test]
1045    async fn test_compact_fallback_on_summarisation_failure() {
1046        let config = CompressionConfig::default()
1047            .with_trigger_tokens(1)
1048            .with_keep_recent_messages(4);
1049        let client = std::sync::Arc::new(FailingClient);
1050        let compactor = ContextCompactor::new(client, config);
1051        let msgs = make_messages(20);
1052        let result = compactor
1053            .compact(1, &msgs, CompressionTrigger::Auto, None)
1054            .await
1055            .unwrap()
1056            .unwrap();
1057
1058        // Fallback: summary is empty, so we get system + recent only.
1059        assert!(matches!(&result[0], ChatMessage::System { .. }));
1060        let has_summary = result.iter().any(|m| match m {
1061            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1062            _ => false,
1063        });
1064        assert!(!has_summary, "should have no summary on failure");
1065    }
1066
1067    // ── compact cache ─────────────────────────────────────────────────────
1068
1069    #[tokio::test]
1070    async fn test_compact_caches_summary() {
1071        let config = CompressionConfig::default()
1072            .with_trigger_tokens(1)
1073            .with_keep_recent_messages(4);
1074        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1075        let client = std::sync::Arc::new(CountingClient {
1076            response: "cached summary",
1077            calls: calls.clone(),
1078        });
1079        let compactor = ContextCompactor::new(client, config);
1080        let msgs = make_messages(20);
1081
1082        // First call — should invoke LLM once.
1083        let _ = compactor
1084            .compact(1, &msgs, CompressionTrigger::Auto, None)
1085            .await
1086            .unwrap();
1087        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1088
1089        // Second call with same old block — should hit cache (no new LLM call).
1090        let _ = compactor
1091            .compact(1, &msgs, CompressionTrigger::Auto, None)
1092            .await
1093            .unwrap();
1094        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1095
1096        // Third call after appending messages (old block grows) — should re-summarize.
1097        let mut grown = msgs.clone();
1098        grown.push(ChatMessage::user("new question"));
1099        grown.push(ChatMessage::assistant("new answer"));
1100        let _ = compactor
1101            .compact(1, &grown, CompressionTrigger::Auto, None)
1102            .await
1103            .unwrap();
1104        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1105    }
1106
1107    // ── compact cache length guard ────────────────────────────────────────
1108
1109    #[tokio::test]
1110    async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1111        // Construct old block > 4096 chars (CACHE_PREFIX_CHARS) so the prefix
1112        // hash stays stable when we grow the tail.  The length guard
1113        // (cl == transcript.len()) should detect the growth and re-summarise.
1114        let config = CompressionConfig::default()
1115            .with_trigger_tokens(1)
1116            .with_keep_recent_messages(4)
1117            .with_max_transcript_chars(60_000); // High cap so we don't hit the freeze guard.
1118        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1119        let client = std::sync::Arc::new(CountingClient {
1120            response: "summary",
1121            calls: calls.clone(),
1122        });
1123        let compactor = ContextCompactor::new(client, config);
1124
1125        // Each pair "question NNNN\nanswer NNNN" serialises to ~30 chars.
1126        // 200 pairs → ~6000 chars transcript (> 4096 prefix).
1127        let mut msgs = vec![ChatMessage::system("sys")];
1128        for i in 0..200 {
1129            msgs.push(ChatMessage::user(format!("question {i:04}")));
1130            msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1131        }
1132
1133        // First call — cache miss, LLM called once.
1134        let _ = compactor
1135            .compact(1, &msgs, CompressionTrigger::Auto, None)
1136            .await
1137            .unwrap();
1138        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1139
1140        // Second call, identical — cache hit (same prefix + same length).
1141        let _ = compactor
1142            .compact(1, &msgs, CompressionTrigger::Auto, None)
1143            .await
1144            .unwrap();
1145        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1146
1147        // Grow the tail: prefix hash unchanged (first 4096 chars identical),
1148        // but transcript length increased → length guard should reject cache.
1149        msgs.push(ChatMessage::user("extra question"));
1150        msgs.push(ChatMessage::assistant("extra answer"));
1151        let _ = compactor
1152            .compact(1, &msgs, CompressionTrigger::Auto, None)
1153            .await
1154            .unwrap();
1155        assert_eq!(
1156            calls.load(std::sync::atomic::Ordering::SeqCst),
1157            2,
1158            "length guard should have triggered re-summarisation"
1159        );
1160    }
1161
1162    // ── compact orphan Tool assertion ─────────────────────────────────────
1163
1164    #[tokio::test]
1165    async fn test_compact_no_orphan_tool_messages() {
1166        let config = CompressionConfig::default()
1167            .with_trigger_tokens(1)
1168            .with_keep_recent_messages(4);
1169        let client = std::sync::Arc::new(MockClient("summary"));
1170        let compactor = ContextCompactor::new(client, config);
1171
1172        // Build messages with tool_calls + tool results in the old block.
1173        let mut msgs = vec![ChatMessage::system("sys")];
1174        for i in 0..10 {
1175            msgs.push(ChatMessage::user(format!("q{i}")));
1176            msgs.push(ChatMessage::assistant_tool_call(
1177                format!("tc{i}"),
1178                "bash",
1179                format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1180            ));
1181            msgs.push(ChatMessage::tool(
1182                format!("tc{i}"),
1183                format!("result {i} with some extra output content"),
1184            ));
1185        }
1186
1187        let result = compactor
1188            .compact(1, &msgs, CompressionTrigger::Auto, None)
1189            .await
1190            .unwrap()
1191            .unwrap();
1192
1193        // No orphan Tool: every Tool must be preceded by an Assistant whose
1194        // tool_calls reference the same tool_call_id.
1195        for (i, msg) in result.iter().enumerate() {
1196            if let ChatMessage::Tool { tool_call_id, .. } = msg {
1197                assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1198                let prev = &result[i - 1];
1199                match prev {
1200                    ChatMessage::Assistant {
1201                        tool_calls: Some(calls),
1202                        ..
1203                    } => {
1204                        let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1205                        assert!(
1206                            ids.contains(&tool_call_id.as_str()),
1207                            "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1208                        );
1209                    }
1210                    other => panic!(
1211                        "Tool {tool_call_id} at index {i} not preceded by \
1212                         Assistant{{tool_calls}}, got: {other:?}"
1213                    ),
1214                }
1215            }
1216        }
1217    }
1218
1219    // ── compact boundary conditions ─────────────────────────────────────
1220
1221    #[tokio::test]
1222    async fn test_compact_noop_empty_messages() {
1223        let config = CompressionConfig::default().with_trigger_tokens(1);
1224        let client = std::sync::Arc::new(MockClient("summary"));
1225        let compactor = ContextCompactor::new(client, config);
1226        let result = compactor
1227            .compact(1, &[], CompressionTrigger::Auto, None)
1228            .await
1229            .unwrap();
1230        assert!(result.is_none());
1231    }
1232
1233    #[tokio::test]
1234    async fn test_compact_noop_system_only() {
1235        let config = CompressionConfig::default().with_trigger_tokens(1);
1236        let client = std::sync::Arc::new(MockClient("summary"));
1237        let compactor = ContextCompactor::new(client, config);
1238        let msgs = vec![ChatMessage::system("You are helpful.")];
1239        let result = compactor
1240            .compact(1, &msgs, CompressionTrigger::Auto, None)
1241            .await
1242            .unwrap();
1243        assert!(result.is_none());
1244    }
1245
1246    #[tokio::test]
1247    async fn test_compact_noop_conversation_equals_keep() {
1248        // conversation.len() == keep_recent_messages → too few to split.
1249        let config = CompressionConfig::default()
1250            .with_trigger_tokens(1)
1251            .with_keep_recent_messages(100);
1252        let client = std::sync::Arc::new(MockClient("summary"));
1253        let compactor = ContextCompactor::new(client, config);
1254        let msgs = make_messages(3); // 1 system + 6 conversation = 7
1255        let result = compactor
1256            .compact(1, &msgs, CompressionTrigger::Auto, None)
1257            .await
1258            .unwrap();
1259        assert!(result.is_none());
1260    }
1261
1262    #[tokio::test]
1263    async fn test_compact_filters_old_summary_in_old_block() {
1264        let config = CompressionConfig::default()
1265            .with_trigger_tokens(1)
1266            .with_keep_recent_messages(4);
1267        let client = std::sync::Arc::new(MockClient("new summary"));
1268        let compactor = ContextCompactor::new(client, config);
1269
1270        // Build messages with an old summary in the old block.
1271        let mut msgs = vec![ChatMessage::system("sys")];
1272        // Old summary (should be filtered).
1273        msgs.push(ChatMessage::user(format!(
1274            "{SUMMARY_PREFIX}\nprevious summary text"
1275        )));
1276        for i in 0..10 {
1277            msgs.push(ChatMessage::user(format!("question {i}")));
1278            msgs.push(ChatMessage::assistant(format!(
1279                "answer {i} with some extra content to make the old block large enough"
1280            )));
1281        }
1282
1283        let result = compactor
1284            .compact(1, &msgs, CompressionTrigger::Auto, None)
1285            .await
1286            .unwrap()
1287            .unwrap();
1288
1289        // The old summary should NOT appear in the output.
1290        let summary_count = result
1291            .iter()
1292            .filter(|m| match m {
1293                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1294                _ => false,
1295            })
1296            .count();
1297        assert_eq!(
1298            summary_count, 1,
1299            "should have exactly 1 summary (the new one), old one must be filtered"
1300        );
1301    }
1302
1303    #[tokio::test]
1304    async fn test_compact_filters_old_summary_in_recent_block() {
1305        let config = CompressionConfig::default()
1306            .with_trigger_tokens(1)
1307            .with_keep_recent_messages(4);
1308        let client = std::sync::Arc::new(MockClient("new summary"));
1309        let compactor = ContextCompactor::new(client, config);
1310
1311        // Build messages where old summary falls in the recent window.
1312        let mut msgs = vec![ChatMessage::system("sys")];
1313        for i in 0..5 {
1314            msgs.push(ChatMessage::user(format!("question {i}")));
1315            msgs.push(ChatMessage::assistant(format!(
1316                "answer {i} with some extra content to make the old block large enough"
1317            )));
1318        }
1319        // Old summary at the end of old block / start of recent.
1320        msgs.push(ChatMessage::user(format!(
1321            "{SUMMARY_PREFIX}\nold summary in recent"
1322        )));
1323        for i in 5..10 {
1324            msgs.push(ChatMessage::user(format!("question {i}")));
1325            msgs.push(ChatMessage::assistant(format!(
1326                "answer {i} with some extra content to make the old block large enough"
1327            )));
1328        }
1329
1330        let result = compactor
1331            .compact(1, &msgs, CompressionTrigger::Auto, None)
1332            .await
1333            .unwrap()
1334            .unwrap();
1335
1336        // Only the new summary should remain.
1337        let summary_count = result
1338            .iter()
1339            .filter(|m| match m {
1340                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1341                _ => false,
1342            })
1343            .count();
1344        assert_eq!(
1345            summary_count, 1,
1346            "old summary in recent block must be filtered"
1347        );
1348    }
1349
1350    #[tokio::test]
1351    #[ignore] // Requires real API key: DEEPSEEK_API_KEY
1352    async fn test_compact_with_real_deepseek_api() {
1353        // This test calls the real DeepSeek API to verify compact() works end-to-end.
1354        // Run with: cargo test test_compact_with_real_deepseek_api -- --nocapture
1355
1356        let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1357        if api_key.is_empty() {
1358            eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1359            return;
1360        }
1361
1362        let _base_url = std::env::var("DEEPSEEK_BASE_URL")
1363            .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1364
1365        // Create a real DeepSeek provider.
1366        // TODO: Use llm-unified factory to create provider.
1367        // let client: std::sync::Arc<dyn agent_base::llm_trait::LlmProvider> = ...;
1368        return; // TODO: update to new LlmProvider API
1369
1370        // The rest of this test needs to be updated for the new LlmProvider API.
1371        // It previously used agent_base::llm::adapt(OpenAiClient::new(...)).
1372        // TODO: Use llm-unified factory to create a real provider for integration testing.
1373    }
1374
1375    // ── proptest: truncate_str / safe_cut_index ──
1376
1377    mod proptest_tests {
1378        use super::*;
1379        use proptest::prelude::*;
1380
1381        proptest! {
1382            #[test]
1383            fn truncate_str_chars_count_bounded(s in ".*", max in 0usize..500) {
1384                let result = truncate_str(&s, max);
1385                let char_count = result.chars().count();
1386                let original_count = s.chars().count();
1387                if original_count <= max {
1388                    assert_eq!(char_count, original_count);
1389                } else {
1390                    // +1 for the '…' character
1391                    assert!(char_count <= max + 1,
1392                        "truncated {} chars > max {} + 1", char_count, max);
1393                }
1394            }
1395
1396            #[test]
1397            fn truncate_str_short_string_unchanged(s in "[a-zA-Z\u{4e00}-\u{9fff}]{0,50}", max in 50usize..200) {
1398                let result = truncate_str(&s, max);
1399                assert_eq!(result, s, "short string should be unchanged");
1400            }
1401
1402            #[test]
1403            fn truncate_str_cjk_char_boundary_safe(s in "[\u{4e00}-\u{9fff}]{1,200}", max in 1usize..100) {
1404                // Must not panic on char boundary
1405                let result = truncate_str(&s, max);
1406                assert!(result.chars().count() <= max + 1);
1407            }
1408
1409            #[test]
1410            fn truncate_str_result_is_valid_utf8(s in ".*", max in 0usize..500) {
1411                let result = truncate_str(&s, max);
1412                // Verify the result is valid UTF-8 and doesn't panic on string operations
1413                assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1414                let _ = result.len(); // byte length accessible
1415                let _ = result.chars().count(); // char count accessible
1416            }
1417        }
1418
1419        /// Generate a simple message sequence for safe_cut_index testing.
1420        fn arb_messages() -> impl Strategy<Value = Vec<ChatMessage>> {
1421            prop::collection::vec(
1422                prop_oneof![
1423                    "[a-z ]{0,50}".prop_map(|s| ChatMessage::user(&s)),
1424                    "[a-z ]{0,50}".prop_map(|s| ChatMessage::assistant(&s)),
1425                    ("[a-z]{1,10}", "[a-z ]{0,50}")
1426                        .prop_map(|(id, content)| ChatMessage::tool(&id, &content)),
1427                ],
1428                0..15,
1429            )
1430        }
1431
1432        proptest! {
1433            #[test]
1434            fn safe_cut_index_never_panics(
1435                messages in arb_messages(),
1436                start in 0usize..15,
1437            ) {
1438                if messages.is_empty() || start >= messages.len() {
1439                    return Ok(());
1440                }
1441                let cut = start + (messages.len() - start) / 2;
1442                if cut > messages.len() {
1443                    return Ok(());
1444                }
1445                let _result = super::safe_cut_index(&messages, start, cut);
1446            }
1447
1448            #[test]
1449            fn safe_cut_index_result_in_bounds(
1450                messages in arb_messages(),
1451                start in 0usize..15,
1452            ) {
1453                if messages.is_empty() || start >= messages.len() {
1454                    return Ok(());
1455                }
1456                let cut = start + (messages.len() - start) / 2;
1457                if cut > messages.len() || cut < start {
1458                    return Ok(());
1459                }
1460                let result = super::safe_cut_index(&messages, start, cut);
1461                assert!(result >= start, "result {} < start {}", result, start);
1462                assert!(result <= messages.len(), "result {} > len {}", result, messages.len());
1463            }
1464
1465            #[test]
1466            fn safe_cut_index_never_ends_on_tool_call(
1467                messages in arb_messages(),
1468                start in 0usize..15,
1469            ) {
1470                if messages.is_empty() || start >= messages.len() {
1471                    return Ok(());
1472                }
1473                let cut = start + (messages.len() - start) / 2;
1474                if cut > messages.len() || cut < start {
1475                    return Ok(());
1476                }
1477                let result = super::safe_cut_index(&messages, start, cut);
1478                // The message left of the cut should not be Assistant with tool_calls
1479                if result > 0 && result < messages.len() {
1480                    assert!(
1481                        !matches!(&messages[result - 1], ChatMessage::Assistant { tool_calls: Some(_), .. }),
1482                        "safe_cut left boundary should not be assistant with tool_calls"
1483                    );
1484                }
1485            }
1486        }
1487    }
1488}