Skip to main content

rig_memory/
lib.rs

1#![cfg_attr(
2    test,
3    allow(
4        clippy::expect_used,
5        clippy::indexing_slicing,
6        clippy::panic,
7        clippy::unwrap_used,
8        clippy::unreachable
9    )
10)]
11//! Conversation memory policies for the Rig agent framework.
12//!
13//! `rig-core` provides the [`ConversationMemory`] trait and an in-process
14//! [`InMemoryConversationMemory`] backend. This crate adds reusable, named
15//! transformations for shaping loaded history before it is sent to the model:
16//!
17//! - [`NoopMemoryPolicy`] — identity, returns input unchanged.
18//! - [`SlidingWindowMemory`] — retains the most recent `N` messages.
19//! - [`TokenWindowMemory`] — retains messages that fit within a token budget.
20//! - [`HeuristicTokenCounter`] — provider-agnostic, zero-dependency
21//!   [`TokenCounter`] that approximates token cost from character lengths.
22//! - [`DemotionHook`] + [`DemotingPolicyMemory`] — bridge truncated turns
23//!   from a [`MemoryPolicy`] into a long-tail store.
24//! - [`Compactor`] + [`CompactingMemory`] — replace truncated turns with a
25//!   derived summary artifact (rolling-summary semantics).
26//! - [`TemplateCompactor`] — zero-dependency reference [`Compactor`] that
27//!   produces a textual rollup without calling an LLM.
28//!
29//! All sliding policies drop a leading orphan tool-result message when the
30//! preceding assistant tool call has been truncated, since most providers
31//! reject unpaired tool results.
32//!
33//! # Example
34//!
35//! ```
36//! use rig_memory::{InMemoryConversationMemory, IntoFilter, SlidingWindowMemory};
37//!
38//! let memory = InMemoryConversationMemory::new()
39//!     .with_filter(SlidingWindowMemory::last_messages(20).into_filter());
40//! ```
41
42use std::{
43    borrow::Cow,
44    collections::HashMap,
45    sync::{Arc, Mutex as StdMutex},
46};
47
48/// Re-exports of the core memory abstractions so callers only need a single
49/// dependency on `rig-memory` for both the trait/backend and the policies.
50pub use rig_core::memory::{
51    Compactor, ConversationMemory, DemotionHook, InMemoryConversationMemory, MemoryError,
52    NoopDemotionHook,
53};
54
55use rig_core::completion::Message;
56use rig_core::message::UserContent;
57use rig_core::wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync};
58
59/// A transformation applied to messages loaded from a [`ConversationMemory`].
60///
61/// Policies typically truncate, summarize, or re-order history. They are
62/// pure, fallible message transformers: implementors that cannot fail should
63/// always return `Ok`.
64pub trait MemoryPolicy: WasmCompatSend + WasmCompatSync {
65    /// Transform `messages` into the history that should be returned to the
66    /// agent. This is the required method — every policy must implement it.
67    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError>;
68
69    /// Transform `messages` and report which messages were demoted (excluded
70    /// from the returned history).
71    ///
72    /// Returns `(kept, demoted)`. The default implementation returns
73    /// `(self.apply(messages)?, Vec::new())`, which is correct for
74    /// non-truncating policies. Truncating policies (sliding window, token
75    /// window, …) override this method to populate `demoted` with the
76    /// messages they evicted.
77    ///
78    /// Implementors must guarantee that `demoted` is the prefix of the
79    /// original input not retained in `kept`, in original order. Composing
80    /// adapters such as [`DemotingPolicyMemory`] rely on this contract to
81    /// track delivery watermarks correctly.
82    fn apply_with_demoted(
83        &self,
84        messages: Vec<Message>,
85    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
86        Ok((self.apply(messages)?, Vec::new()))
87    }
88}
89
90impl<P> MemoryPolicy for Arc<P>
91where
92    P: MemoryPolicy + ?Sized,
93{
94    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
95        (**self).apply(messages)
96    }
97
98    fn apply_with_demoted(
99        &self,
100        messages: Vec<Message>,
101    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
102        (**self).apply_with_demoted(messages)
103    }
104}
105
106impl<P> MemoryPolicy for Box<P>
107where
108    P: MemoryPolicy + ?Sized,
109{
110    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
111        (**self).apply(messages)
112    }
113
114    fn apply_with_demoted(
115        &self,
116        messages: Vec<Message>,
117    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
118        (**self).apply_with_demoted(messages)
119    }
120}
121
122/// Adapt a [`MemoryPolicy`] into a closure suitable for
123/// [`InMemoryConversationMemory::with_filter`].
124///
125/// Errors raised by the policy are swallowed because `with_filter` does not
126/// propagate failures. Use [`MemoryPolicy::apply`] directly when you need to
127/// observe policy errors.
128pub trait IntoFilter: MemoryPolicy + Sized + 'static {
129    /// Convert this policy into a filter closure.
130    ///
131    /// On policy error the original input is returned unchanged and a
132    /// `tracing::warn!` is emitted, so a transient policy bug degrades
133    /// gracefully (the model still sees the unfiltered history) instead of
134    /// silently erasing context.
135    fn into_filter(self) -> BoxedFilter {
136        let policy = Arc::new(self);
137        Box::new(move |msgs| {
138            let fallback = msgs.clone();
139            match policy.apply(msgs) {
140                Ok(out) => out,
141                Err(err) => {
142                    tracing::warn!(error = %err, "memory policy failed; returning unfiltered history");
143                    fallback
144                }
145            }
146        })
147    }
148}
149
150/// Boxed filter closure returned by [`IntoFilter::into_filter`].
151#[cfg(not(target_family = "wasm"))]
152pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message> + Send + Sync>;
153
154/// Boxed filter closure returned by [`IntoFilter::into_filter`].
155#[cfg(target_family = "wasm")]
156pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message>>;
157
158impl<P> IntoFilter for P where P: MemoryPolicy + 'static {}
159
160/// A [`MemoryPolicy`] that returns its input unchanged.
161#[derive(Debug, Default, Clone, Copy)]
162pub struct NoopMemoryPolicy;
163
164impl MemoryPolicy for NoopMemoryPolicy {
165    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
166        Ok(messages)
167    }
168}
169
170/// A [`MemoryPolicy`] that retains only the most recent `max_messages` entries.
171///
172/// When the window starts mid-conversation, a leading orphan tool-result
173/// message (a [`Message::User`] whose first content is a tool result without
174/// its preceding [`Message::Assistant`] tool call) is dropped to preserve the
175/// tool-call/result pairing required by most providers.
176#[derive(Debug, Clone, Copy)]
177pub struct SlidingWindowMemory {
178    max_messages: usize,
179}
180
181impl SlidingWindowMemory {
182    /// Keep at most `n` messages.
183    pub fn last_messages(n: usize) -> Self {
184        Self { max_messages: n }
185    }
186}
187
188impl MemoryPolicy for SlidingWindowMemory {
189    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
190        Ok(self.apply_with_demoted(messages)?.0)
191    }
192
193    fn apply_with_demoted(
194        &self,
195        messages: Vec<Message>,
196    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
197        if messages.len() <= self.max_messages {
198            return Ok((messages, Vec::new()));
199        }
200
201        let keep_from = messages.len() - self.max_messages;
202        Ok(split_window(messages, keep_from))
203    }
204}
205
206/// Split `messages` at `keep_from` into `(window, demoted)`.
207///
208/// A window that opens on a tool result has lost the assistant call it
209/// answers, which providers reject; that orphan joins the demoted set rather
210/// than being dropped, so the demotion hook still observes it end-to-end even
211/// though the model never sees it again.
212fn split_window(messages: Vec<Message>, keep_from: usize) -> (Vec<Message>, Vec<Message>) {
213    let mut iter = messages.into_iter();
214    let mut demoted: Vec<Message> = (&mut iter).take(keep_from).collect();
215    let mut window: Vec<Message> = iter.collect();
216
217    if let Some(Message::User { content }) = window.first()
218        && matches!(content.first(), Some(UserContent::ToolResult(_)))
219    {
220        demoted.push(window.remove(0));
221    }
222
223    (window, demoted)
224}
225
226/// Counts the tokens contributed by a single [`Message`].
227///
228/// Implementors should pick a counting strategy appropriate for their target
229/// provider (for example, `tiktoken-rs` for OpenAI). Counting must be cheap;
230/// it runs once per message on every memory load.
231pub trait TokenCounter: WasmCompatSend + WasmCompatSync {
232    /// Approximate the number of tokens contributed by `message`.
233    fn count(&self, message: &Message) -> usize;
234}
235
236impl<F> TokenCounter for F
237where
238    F: Fn(&Message) -> usize + WasmCompatSend + WasmCompatSync,
239{
240    fn count(&self, message: &Message) -> usize {
241        (self)(message)
242    }
243}
244
245impl<C> TokenCounter for Arc<C>
246where
247    C: TokenCounter + ?Sized,
248{
249    fn count(&self, message: &Message) -> usize {
250        (**self).count(message)
251    }
252}
253
254impl TokenCounter for Box<dyn TokenCounter> {
255    fn count(&self, message: &Message) -> usize {
256        (**self).count(message)
257    }
258}
259
260/// A provider-agnostic [`TokenCounter`] that approximates token counts from
261/// UTF-8 byte lengths.
262///
263/// This is intended as a zero-dependency default. It is **not** a substitute
264/// for a tokenizer and will under- or over-count by up to ~30 % on real
265/// content, but it is monotonic in message size and stable across runs, which
266/// is enough for [`TokenWindowMemory`] to enforce a budget that *trends*
267/// with provider billing.
268///
269/// # Strategy
270///
271/// For every text-bearing block (`Text`, reasoning text, tool-result text)
272/// the counter sums UTF-8 byte lengths (`str::len`, an O(1) call) and divides
273/// by `bytes_per_token`, rounded up. Bytes are used instead of Unicode
274/// scalars because the cost is O(1), modern BPE tokenizers operate on byte
275/// sequences, and per-message budgeting only needs the rough order of
276/// magnitude. For ASCII text bytes and characters coincide; for non-ASCII
277/// text the counter slightly over-estimates, which is the safe direction
278/// for a hard budget.
279///
280/// Tool calls are charged the JSON-serialised length of their `ToolFunction`
281/// payload. Each message is charged a flat `per_message_overhead` to model
282/// the per-turn role/separator tokens that providers add internally. Non-text
283/// blocks (images, audio, video, documents) are charged
284/// `per_attachment_tokens` each because their real cost is provider-specific
285/// and rarely text-derived.
286///
287/// # Presets
288///
289/// The defaults match OpenAI's published rule of thumb (~4 bytes per token,
290/// ~4 tokens of per-message overhead). [`HeuristicTokenCounter::anthropic`]
291/// uses a slightly denser ratio that better fits Claude's tokenizer.
292///
293/// # Example
294///
295/// ```
296/// use rig_memory::{HeuristicTokenCounter, TokenWindowMemory};
297///
298/// let policy = TokenWindowMemory::new(2_000, HeuristicTokenCounter::default());
299/// # let _ = policy;
300/// ```
301#[derive(Debug, Clone, Copy)]
302pub struct HeuristicTokenCounter {
303    bytes_per_token: f32,
304    per_message_overhead: usize,
305    per_attachment_tokens: usize,
306}
307
308impl HeuristicTokenCounter {
309    /// Create a counter with explicit parameters.
310    ///
311    /// `bytes_per_token` is clamped to a minimum of `1.0` so the counter
312    /// never panics or produces zero-cost messages on degenerate input.
313    pub fn new(
314        bytes_per_token: f32,
315        per_message_overhead: usize,
316        per_attachment_tokens: usize,
317    ) -> Self {
318        let bytes_per_token = if bytes_per_token.is_finite() && bytes_per_token >= 1.0 {
319            bytes_per_token
320        } else {
321            1.0
322        };
323        Self {
324            bytes_per_token,
325            per_message_overhead,
326            per_attachment_tokens,
327        }
328    }
329
330    /// Preset matching OpenAI's chat-completion token rule of thumb.
331    ///
332    /// Equivalent to [`HeuristicTokenCounter::default`].
333    pub fn openai() -> Self {
334        Self::new(4.0, 4, 256)
335    }
336
337    /// Preset tuned for Anthropic Claude's tokenizer.
338    pub fn anthropic() -> Self {
339        Self::new(3.5, 4, 256)
340    }
341
342    /// Preset tuned for Google Gemini.
343    pub fn gemini() -> Self {
344        Self::new(4.0, 4, 256)
345    }
346
347    fn bytes_to_tokens(&self, bytes: usize) -> usize {
348        // `bytes_per_token` is clamped to >= 1.0 in the constructor, so the
349        // division is well-defined. We round up so a single non-empty
350        // input still costs at least one token.
351        let tokens = (bytes as f32) / self.bytes_per_token;
352        tokens.ceil() as usize
353    }
354
355    fn count_user(&self, content: &rig_core::message::UserContent) -> usize {
356        use rig_core::message::UserContent;
357        match content {
358            UserContent::Text(text) => self.bytes_to_tokens(text.text.len()),
359            UserContent::ToolResult(result) => result
360                .content
361                .iter()
362                .map(|c| match c {
363                    rig_core::message::ToolResultContent::Text(t) => {
364                        self.bytes_to_tokens(t.text.len())
365                    }
366                    rig_core::message::ToolResultContent::Json { value } => {
367                        self.bytes_to_tokens(value.to_string().len())
368                    }
369                    rig_core::message::ToolResultContent::Image(_) => self.per_attachment_tokens,
370                })
371                .sum(),
372            UserContent::Image(_)
373            | UserContent::Audio(_)
374            | UserContent::Video(_)
375            | UserContent::Document(_) => self.per_attachment_tokens,
376        }
377    }
378
379    fn count_assistant(&self, content: &rig_core::message::AssistantContent) -> usize {
380        use rig_core::message::AssistantContent;
381        match content {
382            AssistantContent::Text(text) => self.bytes_to_tokens(text.text.len()),
383            AssistantContent::Reasoning(reasoning) => {
384                self.bytes_to_tokens(reasoning.display_text().len())
385            }
386            AssistantContent::ToolCall(call) => {
387                let name_bytes = call.function.name.len();
388                // `serde_json::Value::to_string` is the canonical compact JSON
389                // encoding and never fails, so we charge tool calls by the
390                // length of their serialised arguments without pulling in a
391                // direct `serde_json` dependency.
392                let args_bytes = call.function.arguments.to_string().len();
393                self.bytes_to_tokens(name_bytes + args_bytes)
394            }
395            AssistantContent::Image(_) => self.per_attachment_tokens,
396        }
397    }
398}
399
400impl Default for HeuristicTokenCounter {
401    fn default() -> Self {
402        Self::openai()
403    }
404}
405
406impl TokenCounter for HeuristicTokenCounter {
407    fn count(&self, message: &Message) -> usize {
408        let content_tokens: usize = match message {
409            Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
410            Message::Assistant { content, .. } => {
411                content.iter().map(|c| self.count_assistant(c)).sum()
412            }
413            Message::System { content } => self.bytes_to_tokens(content.len()),
414        };
415        content_tokens.saturating_add(self.per_message_overhead)
416    }
417}
418
419/// A [`MemoryPolicy`] that retains the most recent messages up to a token budget.
420///
421/// Messages are walked from newest to oldest, accumulating token counts
422/// produced by a [`TokenCounter`]. Once including a message would exceed
423/// `max_tokens`, the walk stops and the included messages are returned in
424/// original (oldest-first) order. As with [`SlidingWindowMemory`], a leading
425/// orphan tool-result is dropped when its paired assistant tool call has
426/// been truncated.
427pub struct TokenWindowMemory {
428    max_tokens: usize,
429    counter: Arc<dyn TokenCounter>,
430}
431
432impl TokenWindowMemory {
433    /// Create a new policy with a token budget and a counter.
434    pub fn new<C>(max_tokens: usize, counter: C) -> Self
435    where
436        C: TokenCounter + 'static,
437    {
438        Self {
439            max_tokens,
440            counter: Arc::new(counter),
441        }
442    }
443}
444
445impl std::fmt::Debug for TokenWindowMemory {
446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        f.debug_struct("TokenWindowMemory")
448            .field("max_tokens", &self.max_tokens)
449            .field("counter", &"<counter>")
450            .finish()
451    }
452}
453
454impl MemoryPolicy for TokenWindowMemory {
455    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
456        Ok(self.apply_with_demoted(messages)?.0)
457    }
458
459    fn apply_with_demoted(
460        &self,
461        messages: Vec<Message>,
462    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
463        let mut budget = self.max_tokens;
464        let mut keep_from = messages.len();
465
466        for (idx, msg) in messages.iter().enumerate().rev() {
467            let cost = self.counter.count(msg);
468            if cost > budget {
469                break;
470            }
471            budget -= cost;
472            keep_from = idx;
473        }
474
475        Ok(split_window(messages, keep_from))
476    }
477}
478
479/// Wrap a [`ConversationMemory`] backend with a [`MemoryPolicy`], propagating
480/// policy errors to the caller as [`MemoryError::Policy`].
481///
482/// This is the hard-fail counterpart to
483/// [`InMemoryConversationMemory::with_filter`] + [`IntoFilter::into_filter`].
484/// `with_filter` swallows policy errors and returns the unfiltered history;
485/// `PolicyMemory` surfaces them so callers can decide how to react.
486///
487/// # Example
488///
489/// ```no_run
490/// use rig_memory::{InMemoryConversationMemory, PolicyMemory, SlidingWindowMemory};
491///
492/// let memory = PolicyMemory::new(
493///     InMemoryConversationMemory::new(),
494///     SlidingWindowMemory::last_messages(20),
495/// );
496/// ```
497#[derive(Debug, Clone, Copy)]
498pub struct PolicyMemory<M, P> {
499    inner: M,
500    policy: P,
501}
502
503impl<M, P> PolicyMemory<M, P> {
504    /// Wrap `inner` so every loaded history is run through `policy`.
505    pub fn new(inner: M, policy: P) -> Self {
506        Self { inner, policy }
507    }
508
509    /// Return a reference to the wrapped backend.
510    pub fn inner(&self) -> &M {
511        &self.inner
512    }
513
514    /// Return a reference to the wrapped policy.
515    pub fn policy(&self) -> &P {
516        &self.policy
517    }
518
519    /// Consume the wrapper and return the underlying backend and policy.
520    pub fn into_inner(self) -> (M, P) {
521        (self.inner, self.policy)
522    }
523}
524
525impl<M, P> ConversationMemory for PolicyMemory<M, P>
526where
527    M: ConversationMemory,
528    P: MemoryPolicy,
529{
530    fn load<'a>(
531        &'a self,
532        conversation_id: &'a str,
533    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
534        Box::pin(async move {
535            let messages = self.inner.load(conversation_id).await?;
536            self.policy.apply(messages)
537        })
538    }
539
540    fn append<'a>(
541        &'a self,
542        conversation_id: &'a str,
543        messages: Vec<Message>,
544    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
545        self.inner.append(conversation_id, messages)
546    }
547
548    fn clear<'a>(
549        &'a self,
550        conversation_id: &'a str,
551    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
552        self.inner.clear(conversation_id)
553    }
554}
555
556/// A [`ConversationMemory`] adapter that wraps a backend with a
557/// [`MemoryPolicy`] **and** a [`DemotionHook`], so messages truncated by the
558/// policy flow into the hook before the active window is returned.
559///
560/// `DemotingPolicyMemory` is the bridge between the recent-turn store
561/// ([`InMemoryConversationMemory`] or any other [`ConversationMemory`]) and a
562/// long-tail store (`MemvidPersistHook`, vector RAG, archival storage, …).
563/// Compose it with any [`MemoryPolicy`] that overrides
564/// [`MemoryPolicy::apply_with_demoted`]; policies that rely on the default
565/// implementation will still load correctly but will never demote anything.
566///
567/// # Concurrency
568///
569/// Concurrent [`ConversationMemory::load`] calls on the same
570/// `conversation_id` are serialised at the demotion seam: only one call at
571/// a time delivers messages to the hook for a given conversation. Other
572/// concurrent loads for that conversation observe the in-flight delivery
573/// and return the truncated `kept` history immediately without firing the
574/// hook again. Pending demotions that were skipped this way are picked up
575/// by the next `load` after the in-flight delivery completes.
576///
577/// **Failure visibility.** A hook error is returned only to the caller
578/// whose `load` actually drove the delivery. Concurrent callers that
579/// short-circuited on `in_flight` see `Ok(kept)` even if the in-flight
580/// delivery ultimately failed; the watermark stays unchanged so the next
581/// `load` retries. Callers that rely on the hook for durability should
582/// treat a successful `load` as best-effort with respect to demotion and
583/// surface hook failures through the hook's own observability (logs,
584/// metrics, dead-letter buffer) rather than the `load` return value.
585///
586/// # Persistence
587///
588/// Delivery watermarks are kept in process memory only. Across process
589/// restarts, the hook will receive previously-delivered demotions again;
590/// see the [`DemotionHook`] idempotency contract.
591///
592/// # Example
593///
594/// ```no_run
595/// use rig_memory::{
596///     DemotingPolicyMemory, DemotionHook, InMemoryConversationMemory,
597///     MemoryError, NoopDemotionHook, SlidingWindowMemory,
598/// };
599///
600/// let memory = DemotingPolicyMemory::new(
601///     InMemoryConversationMemory::new(),
602///     SlidingWindowMemory::last_messages(20),
603///     NoopDemotionHook,
604/// );
605/// # let _ = memory;
606/// ```
607pub struct DemotingPolicyMemory<M, P, H> {
608    inner: M,
609    policy: P,
610    hook: H,
611    state: StdMutex<HashMap<String, ConversationDemotionState>>,
612}
613
614type InFlightReservation = Arc<()>;
615
616/// Emits the members shared by the stateful wrappers
617/// ([`DemotingPolicyMemory`], [`CompactingMemory`]): accessors,
618/// `forget`/`tracked_conversations` over the per-conversation state map, and
619/// a `Debug` impl that elides the non-`Debug` third component.
620macro_rules! stateful_wrapper_common {
621    ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
622        impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
623            /// Return a reference to the wrapped backend.
624            pub fn inner(&self) -> &M {
625                &self.inner
626            }
627
628            /// Return a reference to the wrapped policy.
629            pub fn policy(&self) -> &P {
630                &self.policy
631            }
632
633            /// Return a reference to the third component.
634            pub fn $third(&self) -> &$tgen {
635                &self.$third
636            }
637
638            /// Consume the wrapper and return its three components.
639            pub fn into_inner(self) -> (M, P, $tgen) {
640                (self.inner, self.policy, self.$third)
641            }
642
643            /// Drop the in-process state for `conversation_id`.
644            ///
645            /// Call this when a conversation has ended to bound memory usage;
646            /// the state map is otherwise unbounded — entries persist for the
647            /// lifetime of the wrapper. If the internal state lock has been
648            /// poisoned by a panic in another thread, this is a no-op (the
649            /// state will be dropped naturally when the wrapper itself is
650            /// dropped).
651            pub fn forget(&self, conversation_id: &str) {
652                if let Ok(mut guard) = self.state.lock() {
653                    guard.remove(conversation_id);
654                }
655            }
656
657            /// Number of conversations currently tracked in the state map.
658            /// Useful for telemetry and leak detection. Returns `0` if the
659            /// internal state lock is poisoned.
660            pub fn tracked_conversations(&self) -> usize {
661                self.state.lock().map(|g| g.len()).unwrap_or(0)
662            }
663        }
664
665        impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
666        where
667            M: std::fmt::Debug,
668            P: std::fmt::Debug,
669        {
670            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
671                f.debug_struct(stringify!($ty))
672                    .field("inner", &self.inner)
673                    .field("policy", &self.policy)
674                    .field(
675                        stringify!($third),
676                        &concat!("<", stringify!($third), ">"),
677                    )
678                    .finish()
679            }
680        }
681    };
682}
683
684/// Emits the delegating `append` and the `clear`-then-`forget` methods of a
685/// stateful wrapper's [`ConversationMemory`] impl.
686macro_rules! stateful_wrapper_append_clear {
687    () => {
688        fn append<'a>(
689            &'a self,
690            conversation_id: &'a str,
691            messages: Vec<Message>,
692        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
693            self.inner.append(conversation_id, messages)
694        }
695
696        fn clear<'a>(
697            &'a self,
698            conversation_id: &'a str,
699        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
700            Box::pin(async move {
701                self.inner.clear(conversation_id).await?;
702                self.forget(conversation_id);
703                Ok(())
704            })
705        }
706    };
707}
708
709#[derive(Debug, Default, Clone)]
710struct ConversationDemotionState {
711    /// Number of demoted messages already delivered to the hook within
712    /// this process lifetime. Advanced only on hook success.
713    delivered: usize,
714    /// Reservation held while a `load` is currently awaiting
715    /// `hook.on_demote(...)` for this conversation. Other concurrent loads
716    /// observe this and short-circuit without re-delivering the same messages.
717    in_flight: Option<InFlightReservation>,
718}
719
720impl<M, P, H> DemotingPolicyMemory<M, P, H> {
721    /// Wrap `inner` so every load runs through `policy` and demoted messages
722    /// flow into `hook`.
723    pub fn new(inner: M, policy: P, hook: H) -> Self {
724        Self {
725            inner,
726            policy,
727            hook,
728            state: StdMutex::new(HashMap::new()),
729        }
730    }
731}
732
733stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
734
735impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
736where
737    M: ConversationMemory,
738    P: MemoryPolicy,
739    H: DemotionHook,
740{
741    fn load<'a>(
742        &'a self,
743        conversation_id: &'a str,
744    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
745        Box::pin(async move {
746            let messages = self.inner.load(conversation_id).await?;
747            let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
748            let demoted_count = demoted.len();
749
750            // Reserve a delivery slot atomically. Decide-and-mark must
751            // happen under one short-lived lock so concurrent loads on
752            // the same conversation_id can't both observe the same
753            // delivered watermark and double-fire the hook.
754            //
755            // Fast path: if the conversation is already tracked, mutate in
756            // place. Only allocate a new `String` key when we are about to
757            // record state for a conversation we have not seen before *and*
758            // there is actually demotion work to track.
759            let (pending, reservation) = {
760                let mut guard = self.state.lock().map_err(poisoned)?;
761                if let Some(entry) = guard.get_mut(conversation_id) {
762                    if entry.in_flight.is_some() {
763                        // Another load is mid-delivery for this conversation;
764                        // skip and let the next load see whatever it leaves
765                        // behind.
766                        return Ok(kept);
767                    }
768                    if entry.delivered >= demoted_count {
769                        (Vec::new(), None)
770                    } else {
771                        let split = entry.delivered;
772                        let reservation = Arc::new(());
773                        entry.in_flight = Some(reservation.clone());
774                        (demoted.split_off(split), Some(reservation))
775                    }
776                } else if demoted_count == 0 {
777                    // First load for this conversation and nothing was
778                    // demoted: no need to allocate a tracking entry yet.
779                    (Vec::new(), None)
780                } else {
781                    let reservation = Arc::new(());
782                    guard.insert(
783                        conversation_id.to_string(),
784                        ConversationDemotionState {
785                            delivered: 0,
786                            in_flight: Some(reservation.clone()),
787                        },
788                    );
789                    (std::mem::take(&mut demoted), Some(reservation))
790                }
791            };
792
793            let Some(reservation) = reservation else {
794                return Ok(kept);
795            };
796
797            // Arm an RAII guard so the in-flight gate is released even if
798            // this future is dropped mid-await (caller cancellation) or the
799            // hook panics. The reservation token prevents stale guards from
800            // clearing newer in-flight loads after clear()/forget() reuse the
801            // same conversation id.
802            let in_flight_guard =
803                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
804
805            let result = self.hook.on_demote(conversation_id, pending).await;
806
807            // Reacquire briefly to advance the watermark on success and
808            // always clear the in-flight flag so a future load can retry.
809            //
810            // Only update if the entry still exists: a concurrent `clear`
811            // (and matching `forget`) for this `conversation_id` may have
812            // dropped the watermark entry while the hook was awaiting. In
813            // that case we must not resurrect it with a stale `delivered`
814            // count — the next load on a freshly-populated backend would
815            // then skip a real demotion.
816            release_in_flight(&self.state, conversation_id, &reservation, |entry| {
817                if result.is_ok() {
818                    entry.delivered = demoted_count;
819                }
820            })?;
821            in_flight_guard.disarm();
822            result?;
823            Ok(kept)
824        })
825    }
826
827    stateful_wrapper_append_clear!();
828}
829
830fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
831    MemoryError::Internal(err.to_string())
832}
833
834/// Clear the conversation's `in_flight` reservation if the entry still exists
835/// and still holds `reservation`, running `on_match` on the entry under the
836/// lock. Returns `on_match`'s value only when the reservation matched — a
837/// missing entry (concurrent `clear`) or a newer reservation is a no-op, so
838/// stale releases can never resurrect or clobber newer state.
839fn release_in_flight<S: InFlightSlot, T>(
840    state: &StdMutex<HashMap<String, S>>,
841    key: &str,
842    reservation: &InFlightReservation,
843    on_match: impl FnOnce(&mut S) -> T,
844) -> Result<Option<T>, MemoryError> {
845    let mut guard = state.lock().map_err(poisoned)?;
846    let Some(entry) = guard.get_mut(key) else {
847        return Ok(None);
848    };
849    if entry
850        .in_flight_mut()
851        .as_ref()
852        .is_some_and(|current| Arc::ptr_eq(current, reservation))
853    {
854        *entry.in_flight_mut() = None;
855        return Ok(Some(on_match(entry)));
856    }
857    Ok(None)
858}
859
860/// A per-conversation state entry with an `in_flight` delivery reservation,
861/// letting [`InFlightGuard`] work over both the demotion and compaction
862/// state maps.
863trait InFlightSlot {
864    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
865}
866
867impl InFlightSlot for ConversationDemotionState {
868    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
869        &mut self.in_flight
870    }
871}
872
873impl<A> InFlightSlot for ConversationCompactionState<A> {
874    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
875        &mut self.in_flight
876    }
877}
878
879/// RAII guard that clears the `in_flight` flag for a conversation in the
880/// shared demotion/compaction state map when dropped, unless the consumer
881/// explicitly disarms it after a successful post-await update.
882///
883/// This prevents the in-flight gate from leaking when the awaiting
884/// `load(...)` future is dropped (caller timeout, `tokio::select!`, etc.)
885/// or when the hook/compactor panics: in either case `Drop` runs and
886/// releases the gate so subsequent loads can retry. A missing entry is a
887/// no-op, covering the case where a concurrent `clear` removed the
888/// conversation while delivery was awaiting.
889struct InFlightGuard<'a, S: InFlightSlot> {
890    state: &'a StdMutex<HashMap<String, S>>,
891    key: &'a str,
892    reservation: InFlightReservation,
893    armed: bool,
894}
895
896impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
897    fn new(
898        state: &'a StdMutex<HashMap<String, S>>,
899        key: &'a str,
900        reservation: InFlightReservation,
901    ) -> Self {
902        Self {
903            state,
904            key,
905            reservation,
906            armed: true,
907        }
908    }
909
910    /// Disable the `Drop` clean-up. Call after the post-await state
911    /// update has already cleared `in_flight` while holding the lock.
912    fn disarm(mut self) {
913        self.armed = false;
914    }
915}
916
917impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
918    fn drop(&mut self) {
919        if !self.armed {
920            return;
921        }
922        // A poisoned lock is ignored, matching pre-guard behavior.
923        let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
924    }
925}
926
927/// A [`ConversationMemory`] adapter that wraps a backend with a
928/// [`MemoryPolicy`] **and** a [`Compactor`], replacing truncated turns with
929/// a summary artifact spliced at the front of the loaded history.
930///
931/// `CompactingMemory` is the next layer above [`DemotingPolicyMemory`]: a
932/// demotion hook only *observes* what the policy evicted, while a compactor
933/// *substitutes* the evicted prefix with a derived [`Message`]. The loaded
934/// history shape is therefore `[summary_message, ...kept_window]` whenever
935/// any compaction has occurred for the conversation, and just `kept_window`
936/// otherwise. The summary itself is recomputed (rolled forward) on every
937/// load that produces newly-evicted messages, so older summaries are folded
938/// into newer ones via the compactor's `carry_over` parameter.
939///
940/// # Concurrency
941///
942/// Concurrent [`ConversationMemory::load`] calls on the same
943/// `conversation_id` are serialised at the compaction seam: only one call
944/// at a time invokes the compactor for a given conversation. Other
945/// concurrent loads observe the in-flight compaction and immediately
946/// return the previously-stored summary spliced in front of `kept`,
947/// without re-running the compactor. Newly-evicted messages skipped this
948/// way are folded into the next compaction.
949///
950/// **Failure visibility.** A compactor error is returned only to the
951/// caller whose `load` actually drove the compaction. Concurrent callers
952/// that short-circuited on `in_flight` see `Ok([old_summary?, ...kept])`
953/// even if the in-flight compaction ultimately failed; the watermark
954/// stays unchanged so the next `load` retries.
955///
956/// # Persistence
957///
958/// The carry-over summary and delivery watermarks are kept in process
959/// memory only. Across process restarts, the first load on each
960/// conversation re-evicts and re-compacts the same prefix; compactors
961/// that have side effects (LLM calls, persistent writes) should
962/// deduplicate.
963///
964/// # Prompt shape and budgets
965///
966/// `CompactingMemory` is **policy-agnostic**: the wrapped
967/// [`MemoryPolicy`] decides which messages are kept versus demoted, and
968/// only the kept window is bounded by that policy. The summary artifact
969/// produced by the [`Compactor`] is spliced **outside** that budget — so
970/// the loaded prompt has shape `[summary, ...kept_window]` where
971/// `kept_window` respects the policy's bounds and `summary` adds an
972/// extra message on top of it.
973///
974/// Callers that combine `CompactingMemory` with a token-budgeted policy
975/// (e.g. [`TokenWindowMemory`]) **must use a [`Compactor`] that bounds
976/// its own artifact**, or accept that the loaded prompt may exceed the
977/// policy's budget by the size of the summary. The reference
978/// [`TemplateCompactor`] grows monotonically by default; configure it
979/// with [`TemplateCompactor::with_max_bytes`] to cap the rolled-up text.
980///
981/// # Example
982///
983/// ```no_run
984/// use rig_memory::{
985///     CompactingMemory, InMemoryConversationMemory, SlidingWindowMemory,
986///     TemplateCompactor,
987/// };
988///
989/// let memory = CompactingMemory::new(
990///     InMemoryConversationMemory::new(),
991///     SlidingWindowMemory::last_messages(20),
992///     TemplateCompactor::new(),
993/// );
994/// # let _ = memory;
995/// ```
996pub struct CompactingMemory<M, P, C: Compactor> {
997    inner: M,
998    policy: P,
999    compactor: C,
1000    state: StdMutex<HashMap<String, ConversationCompactionState<C::Artifact>>>,
1001}
1002
1003struct ConversationCompactionState<A> {
1004    /// Latest summary artifact for this conversation, if compaction has
1005    /// already happened. Cloned into the loaded history on every `load`.
1006    summary: Option<A>,
1007    /// Number of demoted messages already absorbed into `summary` within
1008    /// this process lifetime. Advanced only on compactor success.
1009    absorbed: usize,
1010    /// Reservation held while a `load` is currently awaiting the compactor for
1011    /// this conversation. Other concurrent loads observe this and short-circuit
1012    /// without re-running the compactor.
1013    in_flight: Option<InFlightReservation>,
1014}
1015
1016impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
1017    /// Wrap `inner` so every load runs through `policy` and demoted messages
1018    /// are summarised by `compactor`.
1019    pub fn new(inner: M, policy: P, compactor: C) -> Self {
1020        Self {
1021            inner,
1022            policy,
1023            compactor,
1024            state: StdMutex::new(HashMap::new()),
1025        }
1026    }
1027}
1028
1029stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
1030
1031impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
1032where
1033    M: ConversationMemory,
1034    P: MemoryPolicy,
1035    C: Compactor,
1036{
1037    fn load<'a>(
1038        &'a self,
1039        conversation_id: &'a str,
1040    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
1041        Box::pin(async move {
1042            let messages = self.inner.load(conversation_id).await?;
1043            let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
1044            let demoted_count = demoted.len();
1045
1046            // Decide-and-mark must happen under one short-lived lock so two
1047            // concurrent loads on the same conversation_id can't both
1048            // observe the same `absorbed` watermark and run the compactor
1049            // twice with the same input slice.
1050            //
1051            // Fast path: if the conversation is already tracked, mutate in
1052            // place. Only allocate a new `String` key when there is real
1053            // compaction work for a conversation we have not seen before.
1054            let plan = {
1055                let mut guard = self.state.lock().map_err(poisoned)?;
1056                if let Some(entry) = guard.get_mut(conversation_id) {
1057                    if entry.in_flight.is_some() {
1058                        // Another load is mid-compaction; return what we
1059                        // have so far. Newly-evicted messages will be
1060                        // folded in by the next load.
1061                        return Ok(splice(entry.summary.clone(), kept));
1062                    }
1063                    if demoted_count <= entry.absorbed {
1064                        // No new evictions to compact. Splice the existing
1065                        // summary (if any) and we're done.
1066                        return Ok(splice(entry.summary.clone(), kept));
1067                    }
1068                    let reservation = Arc::new(());
1069                    entry.in_flight = Some(reservation.clone());
1070                    CompactionPlan {
1071                        carry_over: entry.summary.clone(),
1072                        skip: entry.absorbed,
1073                        reservation,
1074                    }
1075                } else if demoted_count == 0 {
1076                    // First load for this conversation and nothing was
1077                    // demoted: no tracking entry needed yet.
1078                    return Ok(kept);
1079                } else {
1080                    let reservation = Arc::new(());
1081                    guard.insert(
1082                        conversation_id.to_string(),
1083                        ConversationCompactionState {
1084                            summary: None,
1085                            absorbed: 0,
1086                            in_flight: Some(reservation.clone()),
1087                        },
1088                    );
1089                    CompactionPlan {
1090                        carry_over: None,
1091                        skip: 0,
1092                        reservation,
1093                    }
1094                }
1095            };
1096
1097            // SAFETY: split_at(plan.skip) is sound because `plan.skip` was
1098            // sourced from the entry's `absorbed` watermark while we held
1099            // the lock, and we only set `absorbed = demoted_count` on
1100            // success — so `plan.skip <= demoted_count == demoted.len()`.
1101            let CompactionPlan {
1102                carry_over,
1103                skip,
1104                reservation,
1105            } = plan;
1106
1107            // Arm an RAII guard so the in-flight gate is released even if
1108            // this future is dropped mid-await (caller cancellation) or
1109            // the compactor panics. The guard is disarmed below once the
1110            // post-await state update has already cleared the flag under
1111            // the same lock acquisition that records the new watermark.
1112            let in_flight_guard =
1113                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
1114
1115            let new_slice = match demoted.get(skip..) {
1116                Some(s) => s,
1117                None => {
1118                    // Drop the guard explicitly so the gate is released
1119                    // before we surface the invariant break.
1120                    drop(in_flight_guard);
1121                    return Err(MemoryError::Internal(
1122                        "compaction watermark exceeds demoted slice length".into(),
1123                    ));
1124                }
1125            };
1126
1127            let result = self
1128                .compactor
1129                .compact(conversation_id, new_slice, carry_over.as_ref())
1130                .await;
1131
1132            // Reacquire briefly to advance the watermark on success and
1133            // always clear the in-flight flag so a future load can retry.
1134            //
1135            // Only update if the entry still exists: a concurrent `clear`
1136            // (and matching `forget`) for this `conversation_id` may have
1137            // dropped the state entry while the compactor was awaiting. In
1138            // that case we must not resurrect it with stale state — the
1139            // next load on a freshly-populated backend would then start
1140            // from a non-zero watermark and skip a real compaction.
1141            // A conversation cleared mid-compaction has no entry anymore; the
1142            // artifact is dropped rather than reviving stale state.
1143            let summary_for_splice = match result {
1144                Ok(artifact) => {
1145                    release_in_flight(&self.state, conversation_id, &reservation, |entry| {
1146                        entry.absorbed = demoted_count;
1147                        entry.summary = Some(artifact.clone());
1148                        artifact
1149                    })?
1150                }
1151                Err(err) => {
1152                    release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
1153                    return Err(err);
1154                }
1155            };
1156
1157            // Post-await state update completed under the lock above and
1158            // already cleared `in_flight`; disarm the RAII guard so its
1159            // `Drop` does not re-acquire the lock for a redundant clear.
1160            in_flight_guard.disarm();
1161
1162            Ok(splice(summary_for_splice, kept))
1163        })
1164    }
1165
1166    stateful_wrapper_append_clear!();
1167}
1168
1169struct CompactionPlan<A> {
1170    carry_over: Option<A>,
1171    skip: usize,
1172    reservation: InFlightReservation,
1173}
1174
1175fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
1176where
1177    A: Into<Message>,
1178{
1179    match summary {
1180        Some(artifact) => {
1181            let mut out = Vec::with_capacity(kept.len() + 1);
1182            out.push(artifact.into());
1183            out.extend(kept);
1184            out
1185        }
1186        None => kept,
1187    }
1188}
1189
1190/// A zero-dependency reference [`Compactor`] that produces a textual
1191/// rollup of evicted messages without calling an LLM.
1192///
1193/// The artifact is a single [`Message::System`] whose body concatenates a
1194/// header, the previous summary (if any), and the textual content of each
1195/// newly-evicted message. It is intentionally simple: useful as a default
1196/// for tests and examples, and as a placeholder before wiring a real
1197/// summarising LLM through a custom [`Compactor`] implementation.
1198///
1199/// # Bounding the summary
1200///
1201/// By default the summary grows monotonically: every compaction pass
1202/// embeds the previous summary verbatim and appends newly-evicted lines.
1203/// Long-running conversations should call [`Self::with_max_bytes`] to
1204/// cap the rolled-up text. When the cap is exceeded, the oldest portion
1205/// of the body (after the header) is dropped at a UTF-8 boundary and
1206/// replaced with a `"[…truncated…]"` marker, preserving the most recent
1207/// context.
1208///
1209/// # Example
1210///
1211/// ```
1212/// use rig_memory::TemplateCompactor;
1213///
1214/// // Default header is "[Conversation summary so far]", unbounded.
1215/// let _compactor = TemplateCompactor::new();
1216///
1217/// // Custom header plus a 4 KiB cap for use with token-budgeted policies.
1218/// let _bounded = TemplateCompactor::with_header("Earlier context")
1219///     .with_max_bytes(4 * 1024);
1220/// ```
1221#[derive(Debug, Clone)]
1222pub struct TemplateCompactor {
1223    header: String,
1224    max_bytes: Option<usize>,
1225}
1226
1227impl TemplateCompactor {
1228    /// Create a [`TemplateCompactor`] with the default header
1229    /// `"[Conversation summary so far]"` and no size cap.
1230    pub fn new() -> Self {
1231        Self::with_header("[Conversation summary so far]")
1232    }
1233
1234    /// Create a [`TemplateCompactor`] with a custom header line and no
1235    /// size cap.
1236    pub fn with_header(header: impl Into<String>) -> Self {
1237        Self {
1238            header: header.into(),
1239            max_bytes: None,
1240        }
1241    }
1242
1243    /// Cap the rolled-up summary at `max_bytes` bytes (UTF-8). When the
1244    /// assembled body exceeds the cap, the oldest portion after the
1245    /// header is dropped at a char boundary and replaced with a
1246    /// `"[…truncated…]"` marker.
1247    ///
1248    /// `max_bytes` of `0` disables truncation (equivalent to the default
1249    /// unbounded behaviour). The header line plus the marker are always
1250    /// preserved even if they exceed the cap.
1251    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1252        self.max_bytes = if max_bytes == 0 {
1253            None
1254        } else {
1255            Some(max_bytes)
1256        };
1257        self
1258    }
1259}
1260
1261impl Default for TemplateCompactor {
1262    fn default() -> Self {
1263        Self::new()
1264    }
1265}
1266
1267/// Plain-text artifact produced by [`TemplateCompactor`].
1268///
1269/// Convertible into a [`Message::System`] whose body is the rolled-up
1270/// text. The system role is used because the rollup represents
1271/// out-of-band context about the prior conversation, not a turn from
1272/// any participant.
1273#[derive(Debug, Clone)]
1274pub struct TextSummary(String);
1275
1276impl TextSummary {
1277    /// Borrow the underlying summary text.
1278    pub fn as_str(&self) -> &str {
1279        &self.0
1280    }
1281
1282    /// Consume the wrapper and return the underlying `String`.
1283    pub fn into_string(self) -> String {
1284        self.0
1285    }
1286}
1287
1288impl From<TextSummary> for Message {
1289    fn from(value: TextSummary) -> Self {
1290        Message::System { content: value.0 }
1291    }
1292}
1293
1294impl Compactor for TemplateCompactor {
1295    type Artifact = TextSummary;
1296
1297    fn compact<'a>(
1298        &'a self,
1299        _conversation_id: &'a str,
1300        evicted: &'a [Message],
1301        carry_over: Option<&'a Self::Artifact>,
1302    ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1303        Box::pin(async move {
1304            let mut buf = String::new();
1305            buf.push_str(&self.header);
1306            buf.push('\n');
1307            if let Some(prev) = carry_over {
1308                buf.push_str(prev.as_str());
1309                buf.push('\n');
1310            }
1311            for msg in evicted {
1312                let line = render_message_line(msg);
1313                if !line.is_empty() {
1314                    buf.push_str(&line);
1315                    buf.push('\n');
1316                }
1317            }
1318            if let Some(cap) = self.max_bytes
1319                && buf.len() > cap
1320            {
1321                buf = truncate_summary(&buf, cap);
1322            }
1323            Ok(TextSummary(buf))
1324        })
1325    }
1326}
1327
1328/// Truncate `buf` to fit within `cap` bytes by dropping the oldest
1329/// content after the header line. Always preserves the header plus a
1330/// `"[\u{2026}truncated\u{2026}]"` marker, even if they alone exceed `cap`.
1331///
1332/// The header boundary is located by scanning `buf` for the first `\n`
1333/// rather than by trusting any caller-supplied header length, so a
1334/// header containing embedded newlines does not mis-locate the body.
1335fn truncate_summary(buf: &str, cap: usize) -> String {
1336    const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1337    // Body starts right after the first newline in `buf`. If `buf` has
1338    // no newline at all there is no body to drop, so return as-is.
1339    let header_prefix_len = match buf.find('\n') {
1340        Some(i) => i + 1,
1341        None => return buf.to_string(),
1342    };
1343    if buf.len() <= header_prefix_len {
1344        return buf.to_string();
1345    }
1346    let preserved = header_prefix_len + MARKER.len();
1347    // Number of bytes of the body we can keep after the marker.
1348    let keep_bytes = cap.saturating_sub(preserved);
1349    let body_start = header_prefix_len;
1350    let body = match buf.get(body_start..) {
1351        Some(b) => b,
1352        None => return buf.to_string(),
1353    };
1354    // Take the suffix of `body` whose length is at most `keep_bytes`,
1355    // walking forward to a UTF-8 char boundary.
1356    let mut cut = body.len().saturating_sub(keep_bytes);
1357    while cut < body.len() && !body.is_char_boundary(cut) {
1358        cut += 1;
1359    }
1360    let suffix: &str = body.get(cut..).unwrap_or_default();
1361    let header_with_nl = match buf.get(..header_prefix_len) {
1362        Some(h) => h,
1363        None => return buf.to_string(),
1364    };
1365    let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1366    out.push_str(header_with_nl);
1367    out.push_str(MARKER);
1368    out.push_str(suffix);
1369    out
1370}
1371
1372/// Render a single message as a `"role: text"` line for [`TemplateCompactor`].
1373///
1374/// Non-textual content (tool calls, tool results, attachments) is rendered
1375/// as a short marker so the rollup does not silently drop them but also
1376/// does not balloon with serialized JSON.
1377fn render_message_line(msg: &Message) -> String {
1378    use rig_core::message::AssistantContent;
1379
1380    let (role, text) = match msg {
1381        Message::System { content } => ("system", content.clone()),
1382        Message::User { content } => (
1383            "user",
1384            join_parts(content.iter().map(|item| match item {
1385                UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1386                UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1387                _ => Cow::Borrowed("[attachment]"),
1388            })),
1389        ),
1390        Message::Assistant { content, .. } => (
1391            "assistant",
1392            join_parts(content.iter().map(|item| match item {
1393                AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1394                AssistantContent::ToolCall(call) => {
1395                    Cow::Owned(format!("[tool call: {}]", call.function.name))
1396                }
1397                _ => Cow::Borrowed("[reasoning]"),
1398            })),
1399        ),
1400    };
1401
1402    if text.is_empty() {
1403        String::new()
1404    } else {
1405        format!("{role}: {text}")
1406    }
1407}
1408
1409/// Space-join rendered parts, suppressing the separator while the line is
1410/// still empty.
1411///
1412/// Not `Vec::join(" ")`: that would emit a leading separator for a message
1413/// whose first part renders empty, while still collapsing nothing elsewhere.
1414/// The rollup text these lines feed is compared byte-for-byte by the
1415/// compaction tests, so the asymmetry is behavior, not an accident.
1416fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1417    let mut text = String::new();
1418    for part in parts {
1419        if !text.is_empty() {
1420            text.push(' ');
1421        }
1422        text.push_str(&part);
1423    }
1424    text
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use super::*;
1430    use rig_core::message::{
1431        AssistantContent, ToolCall, ToolCallId, ToolFunction, ToolResult, ToolResultContent,
1432        UserContent,
1433    };
1434    use std::sync::Mutex;
1435
1436    fn user(text: &str) -> Message {
1437        Message::user(text)
1438    }
1439
1440    fn assistant(text: &str) -> Message {
1441        Message::assistant(text)
1442    }
1443
1444    fn tool_call_msg() -> Message {
1445        Message::Assistant {
1446            id: None,
1447            content: vec![AssistantContent::ToolCall(ToolCall::new(
1448                ToolCallId::new_or_mint("call_1"),
1449                ToolFunction::new("t".into(), serde_json::json!({})),
1450            ))],
1451        }
1452    }
1453
1454    fn tool_result_msg() -> Message {
1455        Message::User {
1456            content: vec![UserContent::ToolResult(ToolResult {
1457                call: ToolCallId::new_or_mint("call_1"),
1458                provider: None,
1459                name: "t".into(),
1460                content: vec![ToolResultContent::text("ok")],
1461            })],
1462        }
1463    }
1464
1465    #[test]
1466    fn noop_policy_is_identity() {
1467        let msgs = vec![user("a"), assistant("b")];
1468        let out = NoopMemoryPolicy.apply(msgs).unwrap();
1469        assert_eq!(out.len(), 2);
1470    }
1471
1472    #[test]
1473    fn sliding_window_passthrough_when_under_limit() {
1474        let policy = SlidingWindowMemory::last_messages(5);
1475        let out = policy.apply(vec![user("1"), assistant("2")]).unwrap();
1476        assert_eq!(out.len(), 2);
1477    }
1478
1479    #[tokio::test]
1480    async fn sliding_window_truncates_via_filter() {
1481        let mem = InMemoryConversationMemory::new()
1482            .with_filter(SlidingWindowMemory::last_messages(2).into_filter());
1483
1484        mem.append(
1485            "c",
1486            vec![user("1"), assistant("2"), user("3"), assistant("4")],
1487        )
1488        .await
1489        .unwrap();
1490
1491        let loaded = mem.load("c").await.unwrap();
1492        assert_eq!(loaded.len(), 2);
1493    }
1494
1495    #[test]
1496    fn sliding_window_drops_leading_orphan_tool_result() {
1497        let policy = SlidingWindowMemory::last_messages(3);
1498        let out = policy
1499            .apply(vec![
1500                tool_call_msg(),
1501                tool_result_msg(),
1502                user("after"),
1503                assistant("done"),
1504            ])
1505            .unwrap();
1506
1507        assert_eq!(out.len(), 2);
1508        assert!(matches!(out.first(), Some(Message::User { content })
1509            if matches!(content.first(), Some(UserContent::Text(_)))));
1510    }
1511
1512    #[test]
1513    fn token_window_keeps_within_budget() {
1514        let msgs = vec![
1515            user("aaaa"),
1516            assistant("bbbb"),
1517            user("cccc"),
1518            assistant("dddd"),
1519        ];
1520        let policy = TokenWindowMemory::new(2, |_: &Message| 1);
1521        let out = policy.apply(msgs).unwrap();
1522        assert_eq!(out.len(), 2);
1523    }
1524
1525    #[test]
1526    fn token_window_passes_through_when_under_budget() {
1527        let msgs = vec![user("a"), assistant("b")];
1528        let policy = TokenWindowMemory::new(usize::MAX, |_: &Message| 1);
1529        let out = policy.apply(msgs).unwrap();
1530        assert_eq!(out.len(), 2);
1531    }
1532
1533    #[test]
1534    fn token_window_drops_leading_orphan_tool_result() {
1535        let policy = TokenWindowMemory::new(25, |_: &Message| 10);
1536        let out = policy
1537            .apply(vec![tool_call_msg(), tool_result_msg(), user("after")])
1538            .unwrap();
1539        assert_eq!(out.len(), 1);
1540        assert!(matches!(out.first(), Some(Message::User { content })
1541            if matches!(content.first(), Some(UserContent::Text(_)))));
1542    }
1543
1544    #[test]
1545    fn token_window_skips_message_larger_than_budget() {
1546        let policy = TokenWindowMemory::new(5, |_: &Message| 10);
1547        let out = policy.apply(vec![user("anything")]).unwrap();
1548        assert!(out.is_empty());
1549    }
1550
1551    #[test]
1552    fn heuristic_counter_charges_overhead_per_message() {
1553        let counter = HeuristicTokenCounter::default();
1554        let empty = counter.count(&user(""));
1555        assert!(
1556            empty >= 4,
1557            "default per-message overhead is at least 4 tokens"
1558        );
1559    }
1560
1561    #[test]
1562    fn heuristic_counter_is_monotonic_in_text_length() {
1563        let counter = HeuristicTokenCounter::default();
1564        let small = counter.count(&user("hi"));
1565        let big = counter.count(&user(&"x".repeat(400)));
1566        assert!(big > small);
1567    }
1568
1569    #[test]
1570    fn heuristic_counter_handles_tool_calls() {
1571        let counter = HeuristicTokenCounter::default();
1572        let cost = counter.count(&tool_call_msg());
1573        assert!(cost > 0);
1574    }
1575
1576    #[test]
1577    fn heuristic_counter_handles_system_messages() {
1578        let counter = HeuristicTokenCounter::default();
1579        let cost = counter.count(&Message::System {
1580            content: "you are helpful".into(),
1581        });
1582        assert!(cost > 0);
1583    }
1584
1585    #[test]
1586    fn heuristic_counter_clamps_invalid_bytes_per_token() {
1587        // Zero/NaN/negative ratios fall back to 1.0 instead of panicking.
1588        let counter = HeuristicTokenCounter::new(0.0, 0, 0);
1589        assert!(counter.count(&user("abcd")) >= 4);
1590        let nan = HeuristicTokenCounter::new(f32::NAN, 0, 0);
1591        assert!(nan.count(&user("abcd")) >= 4);
1592    }
1593
1594    #[test]
1595    fn heuristic_counter_drives_token_window() {
1596        let policy = TokenWindowMemory::new(100, HeuristicTokenCounter::default());
1597        let msgs = vec![user(&"a".repeat(2_000)), user("short")];
1598        let out = policy.apply(msgs).unwrap();
1599        // The huge message must be evicted; the short one retained.
1600        assert_eq!(out.len(), 1);
1601    }
1602
1603    #[test]
1604    fn arc_token_counter_can_drive_token_window() {
1605        let counter: Arc<dyn TokenCounter> = Arc::new(|_: &Message| 1);
1606        let policy = TokenWindowMemory::new(2, counter);
1607        let out = policy
1608            .apply(vec![user("a"), assistant("b"), user("c")])
1609            .unwrap();
1610
1611        assert_eq!(out.len(), 2);
1612    }
1613
1614    #[test]
1615    fn boxed_token_counter_forwards_count() {
1616        let counter: Box<dyn TokenCounter> = Box::new(|_: &Message| 7);
1617        assert_eq!(counter.count(&user("a")), 7);
1618    }
1619
1620    #[test]
1621    fn into_filter_returns_input_on_policy_error() {
1622        struct FailingPolicy;
1623        impl MemoryPolicy for FailingPolicy {
1624            fn apply(&self, _: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
1625                Err(MemoryError::Policy("intentional failure".into()))
1626            }
1627        }
1628
1629        let filter = FailingPolicy.into_filter();
1630        let input = vec![user("a"), assistant("b"), user("c")];
1631        let out = filter(input.clone());
1632        assert_eq!(
1633            out.len(),
1634            input.len(),
1635            "history must be preserved on policy error"
1636        );
1637    }
1638
1639    #[tokio::test]
1640    async fn policy_memory_truncates_loaded_history() {
1641        let mem = PolicyMemory::new(
1642            InMemoryConversationMemory::new(),
1643            SlidingWindowMemory::last_messages(2),
1644        );
1645
1646        mem.append(
1647            "c",
1648            vec![user("1"), assistant("2"), user("3"), assistant("4")],
1649        )
1650        .await
1651        .unwrap();
1652
1653        let loaded = mem.load("c").await.unwrap();
1654        assert_eq!(loaded.len(), 2);
1655    }
1656
1657    #[tokio::test]
1658    async fn policy_memory_propagates_policy_errors() {
1659        struct FailingPolicy;
1660        impl MemoryPolicy for FailingPolicy {
1661            fn apply(&self, _: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
1662                Err(MemoryError::Policy("intentional failure".into()))
1663            }
1664        }
1665
1666        let mem = PolicyMemory::new(InMemoryConversationMemory::new(), FailingPolicy);
1667        mem.append("c", vec![user("1"), assistant("2")])
1668            .await
1669            .unwrap();
1670
1671        let result = mem.load("c").await;
1672        assert!(matches!(result, Err(MemoryError::Policy(_))));
1673    }
1674
1675    #[tokio::test]
1676    async fn policy_memory_append_and_clear_delegate_to_inner() {
1677        let mem = PolicyMemory::new(InMemoryConversationMemory::new(), NoopMemoryPolicy);
1678        mem.append("c", vec![user("hi"), assistant("ok")])
1679            .await
1680            .unwrap();
1681        assert_eq!(mem.load("c").await.unwrap().len(), 2);
1682
1683        mem.clear("c").await.unwrap();
1684        assert!(mem.load("c").await.unwrap().is_empty());
1685    }
1686
1687    #[test]
1688    fn sliding_window_reports_demoted_prefix() {
1689        let policy = SlidingWindowMemory::last_messages(2);
1690        let (kept, demoted) = policy
1691            .apply_with_demoted(vec![
1692                user("oldest"),
1693                assistant("old"),
1694                user("recent"),
1695                assistant("latest"),
1696            ])
1697            .unwrap();
1698        assert_eq!(kept.len(), 2);
1699        assert_eq!(demoted.len(), 2);
1700    }
1701
1702    #[test]
1703    fn token_window_reports_demoted_prefix() {
1704        let policy = TokenWindowMemory::new(2, |_: &Message| 1);
1705        let (kept, demoted) = policy
1706            .apply_with_demoted(vec![user("a"), assistant("b"), user("c"), assistant("d")])
1707            .unwrap();
1708        assert_eq!(kept.len(), 2);
1709        assert_eq!(demoted.len(), 2);
1710    }
1711
1712    #[test]
1713    fn noop_policy_demotes_nothing() {
1714        let (kept, demoted) = NoopMemoryPolicy
1715            .apply_with_demoted(vec![user("a"), assistant("b")])
1716            .unwrap();
1717        assert_eq!(kept.len(), 2);
1718        assert!(demoted.is_empty());
1719    }
1720
1721    #[test]
1722    fn arc_memory_policy_preserves_demoted_metadata() {
1723        let policy: Arc<dyn MemoryPolicy> = Arc::new(SlidingWindowMemory::last_messages(1));
1724        let (kept, demoted) = policy
1725            .apply_with_demoted(vec![user("old"), assistant("new")])
1726            .unwrap();
1727
1728        assert_eq!(kept.len(), 1);
1729        assert_eq!(demoted.len(), 1);
1730    }
1731
1732    #[test]
1733    fn boxed_memory_policy_preserves_demoted_metadata() {
1734        let policy: Box<dyn MemoryPolicy> = Box::new(SlidingWindowMemory::last_messages(1));
1735        let (kept, demoted) = policy
1736            .apply_with_demoted(vec![user("old"), assistant("new")])
1737            .unwrap();
1738
1739        assert_eq!(kept.len(), 1);
1740        assert_eq!(demoted.len(), 1);
1741    }
1742
1743    #[test]
1744    fn sliding_window_demotes_orphan_tool_result_with_prefix() {
1745        // Window keeps the last 2 messages, but the leading message of that
1746        // window is an orphan tool result; it must be moved into `demoted`
1747        // so the hook can preserve it.
1748        let policy = SlidingWindowMemory::last_messages(2);
1749        let (kept, demoted) = policy
1750            .apply_with_demoted(vec![
1751                tool_call_msg(),
1752                tool_result_msg(),
1753                user("after"),
1754                assistant("done"),
1755            ])
1756            .unwrap();
1757        assert_eq!(kept.len(), 2);
1758        assert!(matches!(kept.first(), Some(Message::User { content })
1759            if matches!(content.first(), Some(UserContent::Text(_)))));
1760        assert_eq!(demoted.len(), 2);
1761    }
1762
1763    #[derive(Default)]
1764    struct CountingHook {
1765        seen: Mutex<Vec<(String, Vec<Message>)>>,
1766    }
1767
1768    impl CountingHook {
1769        fn calls(&self) -> usize {
1770            self.seen.lock().unwrap().len()
1771        }
1772        fn last_demoted_count(&self) -> usize {
1773            self.seen
1774                .lock()
1775                .unwrap()
1776                .last()
1777                .map(|(_, m)| m.len())
1778                .unwrap_or(0)
1779        }
1780    }
1781
1782    impl DemotionHook for CountingHook {
1783        fn on_demote<'a>(
1784            &'a self,
1785            conversation_id: &'a str,
1786            messages: Vec<Message>,
1787        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1788            Box::pin(async move {
1789                self.seen
1790                    .lock()
1791                    .unwrap()
1792                    .push((conversation_id.to_string(), messages));
1793                Ok(())
1794            })
1795        }
1796    }
1797
1798    #[tokio::test]
1799    async fn demoting_policy_memory_invokes_hook_on_truncation() {
1800        let hook = Arc::new(CountingHook::default());
1801        let mem = DemotingPolicyMemory::new(
1802            InMemoryConversationMemory::new(),
1803            SlidingWindowMemory::last_messages(2),
1804            hook.clone(),
1805        );
1806
1807        mem.append(
1808            "c",
1809            vec![user("1"), assistant("2"), user("3"), assistant("4")],
1810        )
1811        .await
1812        .unwrap();
1813
1814        let kept = mem.load("c").await.unwrap();
1815        assert_eq!(kept.len(), 2);
1816        assert_eq!(hook.calls(), 1);
1817        assert_eq!(hook.last_demoted_count(), 2);
1818    }
1819
1820    #[tokio::test]
1821    async fn demoting_policy_memory_does_not_replay_demotions() {
1822        let hook = Arc::new(CountingHook::default());
1823        let mem = DemotingPolicyMemory::new(
1824            InMemoryConversationMemory::new(),
1825            SlidingWindowMemory::last_messages(2),
1826            hook.clone(),
1827        );
1828
1829        mem.append(
1830            "c",
1831            vec![user("1"), assistant("2"), user("3"), assistant("4")],
1832        )
1833        .await
1834        .unwrap();
1835
1836        mem.load("c").await.unwrap();
1837        mem.load("c").await.unwrap();
1838        assert_eq!(hook.calls(), 1);
1839        assert_eq!(hook.last_demoted_count(), 2);
1840    }
1841
1842    #[tokio::test]
1843    async fn demoting_policy_memory_only_reports_newly_demoted_messages() {
1844        let hook = Arc::new(CountingHook::default());
1845        let mem = DemotingPolicyMemory::new(
1846            InMemoryConversationMemory::new(),
1847            SlidingWindowMemory::last_messages(2),
1848            hook.clone(),
1849        );
1850
1851        mem.append(
1852            "c",
1853            vec![user("1"), assistant("2"), user("3"), assistant("4")],
1854        )
1855        .await
1856        .unwrap();
1857        mem.load("c").await.unwrap();
1858
1859        mem.append("c", vec![user("5")]).await.unwrap();
1860        mem.load("c").await.unwrap();
1861
1862        assert_eq!(hook.calls(), 2);
1863        assert_eq!(hook.last_demoted_count(), 1);
1864    }
1865
1866    #[derive(Default)]
1867    struct FailingHook {
1868        calls: Mutex<usize>,
1869    }
1870
1871    impl DemotionHook for FailingHook {
1872        fn on_demote<'a>(
1873            &'a self,
1874            _conversation_id: &'a str,
1875            _messages: Vec<Message>,
1876        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1877            Box::pin(async move {
1878                *self.calls.lock().unwrap() += 1;
1879                Err(MemoryError::backend(std::io::Error::other("hook failed")))
1880            })
1881        }
1882    }
1883
1884    #[tokio::test]
1885    async fn demoting_policy_memory_does_not_advance_watermark_on_hook_failure() {
1886        let hook = Arc::new(FailingHook::default());
1887        let mem = DemotingPolicyMemory::new(
1888            InMemoryConversationMemory::new(),
1889            SlidingWindowMemory::last_messages(1),
1890            hook.clone(),
1891        );
1892        mem.append("c", vec![user("1"), assistant("2")])
1893            .await
1894            .unwrap();
1895
1896        assert!(mem.load("c").await.is_err());
1897        assert!(mem.load("c").await.is_err());
1898        assert_eq!(*hook.calls.lock().unwrap(), 2);
1899    }
1900
1901    #[tokio::test]
1902    async fn demoting_policy_memory_clear_resets_watermark() {
1903        let hook = Arc::new(CountingHook::default());
1904        let mem = DemotingPolicyMemory::new(
1905            InMemoryConversationMemory::new(),
1906            SlidingWindowMemory::last_messages(1),
1907            hook.clone(),
1908        );
1909
1910        mem.append("c", vec![user("1"), assistant("2")])
1911            .await
1912            .unwrap();
1913        mem.load("c").await.unwrap();
1914        mem.clear("c").await.unwrap();
1915        mem.append("c", vec![user("3"), assistant("4")])
1916            .await
1917            .unwrap();
1918        mem.load("c").await.unwrap();
1919
1920        assert_eq!(hook.calls(), 2);
1921        assert_eq!(hook.last_demoted_count(), 1);
1922    }
1923
1924    #[tokio::test]
1925    async fn demoting_policy_memory_skips_hook_when_nothing_evicted() {
1926        let hook = Arc::new(CountingHook::default());
1927        let mem = DemotingPolicyMemory::new(
1928            InMemoryConversationMemory::new(),
1929            SlidingWindowMemory::last_messages(10),
1930            hook.clone(),
1931        );
1932
1933        mem.append("c", vec![user("1"), assistant("2")])
1934            .await
1935            .unwrap();
1936        mem.load("c").await.unwrap();
1937        assert_eq!(hook.calls(), 0);
1938    }
1939
1940    #[tokio::test]
1941    async fn demoting_policy_memory_with_noop_hook_behaves_like_policy_memory() {
1942        let mem = DemotingPolicyMemory::new(
1943            InMemoryConversationMemory::new(),
1944            SlidingWindowMemory::last_messages(1),
1945            NoopDemotionHook,
1946        );
1947        mem.append("c", vec![user("a"), assistant("b"), user("c")])
1948            .await
1949            .unwrap();
1950        assert_eq!(mem.load("c").await.unwrap().len(), 1);
1951    }
1952
1953    /// Hook that blocks until the test releases it. Used to provoke the
1954    /// concurrent-load race against the in-flight gate.
1955    struct GatedHook {
1956        calls: Arc<std::sync::atomic::AtomicUsize>,
1957        rendezvous: Arc<tokio::sync::Notify>,
1958        release: Arc<tokio::sync::Notify>,
1959    }
1960
1961    impl DemotionHook for GatedHook {
1962        fn on_demote<'a>(
1963            &'a self,
1964            _conversation_id: &'a str,
1965            _messages: Vec<Message>,
1966        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1967            let calls = self.calls.clone();
1968            let rendezvous = self.rendezvous.clone();
1969            let release = self.release.clone();
1970            Box::pin(async move {
1971                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1972                rendezvous.notify_one();
1973                release.notified().await;
1974                Ok(())
1975            })
1976        }
1977    }
1978
1979    #[tokio::test]
1980    async fn demoting_policy_memory_serialises_concurrent_loads() {
1981        use std::sync::atomic::{AtomicUsize, Ordering};
1982
1983        let calls = Arc::new(AtomicUsize::new(0));
1984        let rendezvous = Arc::new(tokio::sync::Notify::new());
1985        let release = Arc::new(tokio::sync::Notify::new());
1986        let hook = GatedHook {
1987            calls: calls.clone(),
1988            rendezvous: rendezvous.clone(),
1989            release: release.clone(),
1990        };
1991
1992        let mem = Arc::new(DemotingPolicyMemory::new(
1993            InMemoryConversationMemory::new(),
1994            SlidingWindowMemory::last_messages(1),
1995            hook,
1996        ));
1997
1998        mem.append("c", vec![user("1"), assistant("2"), user("3")])
1999            .await
2000            .unwrap();
2001
2002        let m1 = mem.clone();
2003        let first = tokio::spawn(async move { m1.load("c").await });
2004
2005        // Wait until the first load has entered the hook.
2006        rendezvous.notified().await;
2007        assert_eq!(calls.load(Ordering::SeqCst), 1);
2008
2009        // Second concurrent load on the same conversation must skip the
2010        // hook entirely (in-flight gate) and return the truncated view.
2011        let kept = mem.load("c").await.unwrap();
2012        assert_eq!(kept.len(), 1);
2013        assert_eq!(calls.load(Ordering::SeqCst), 1, "hook must not double-fire");
2014
2015        // Release the first load and confirm it completes successfully.
2016        release.notify_one();
2017        let kept_first = first.await.unwrap().unwrap();
2018        assert_eq!(kept_first.len(), 1);
2019        assert_eq!(calls.load(Ordering::SeqCst), 1);
2020
2021        // Subsequent loads observe the watermark and don't re-fire.
2022        mem.load("c").await.unwrap();
2023        assert_eq!(calls.load(Ordering::SeqCst), 1);
2024    }
2025
2026    #[tokio::test]
2027    async fn demoting_policy_memory_dropped_load_releases_in_flight_gate() {
2028        // If a `load(...)` future is dropped while awaiting the hook, the
2029        // in-flight gate must not leak: subsequent loads on the same
2030        // conversation must be able to retry demotion.
2031        use std::sync::atomic::{AtomicUsize, Ordering};
2032
2033        let calls = Arc::new(AtomicUsize::new(0));
2034        let rendezvous = Arc::new(tokio::sync::Notify::new());
2035        let release = Arc::new(tokio::sync::Notify::new());
2036        let hook = GatedHook {
2037            calls: calls.clone(),
2038            rendezvous,
2039            release: release.clone(),
2040        };
2041
2042        let mem = Arc::new(DemotingPolicyMemory::new(
2043            InMemoryConversationMemory::new(),
2044            SlidingWindowMemory::last_messages(1),
2045            hook,
2046        ));
2047
2048        mem.append("c", vec![user("1"), assistant("2"), user("3")])
2049            .await
2050            .unwrap();
2051
2052        // Kick off a load that will block inside the hook, then abort it
2053        // while awaiting — simulating a caller-side timeout or
2054        // `tokio::select!` cancellation.
2055        let mem_load = mem.clone();
2056        let handle = tokio::spawn(async move { mem_load.load("c").await });
2057        while calls.load(Ordering::SeqCst) == 0 {
2058            tokio::task::yield_now().await;
2059        }
2060        handle.abort();
2061        let _ = handle.await;
2062
2063        // The aborted future was dropped without clearing in_flight via
2064        // the success/error branches; the RAII guard's `Drop` should have
2065        // released it. A new load must therefore be able to drive a fresh
2066        // demotion rather than short-circuiting forever.
2067        let mem_load = mem.clone();
2068        let retry = tokio::spawn(async move { mem_load.load("c").await });
2069        for _ in 0..1_000 {
2070            if calls.load(Ordering::SeqCst) >= 2 {
2071                break;
2072            }
2073            tokio::task::yield_now().await;
2074        }
2075        assert_eq!(
2076            calls.load(Ordering::SeqCst),
2077            2,
2078            "retry must re-enter the hook after cancellation"
2079        );
2080
2081        release.notify_one();
2082        let kept = retry.await.unwrap().unwrap();
2083        assert_eq!(kept.len(), 1);
2084
2085        // The successful retry advances the watermark, so future loads
2086        // should not fire the hook again.
2087        mem.load("c").await.unwrap();
2088        assert_eq!(calls.load(Ordering::SeqCst), 2);
2089    }
2090
2091    #[tokio::test]
2092    async fn demoting_stale_cancelled_load_does_not_clear_new_reservation() {
2093        use std::sync::atomic::{AtomicUsize, Ordering};
2094
2095        let calls = Arc::new(AtomicUsize::new(0));
2096        let rendezvous = Arc::new(tokio::sync::Notify::new());
2097        let release = Arc::new(tokio::sync::Notify::new());
2098        let hook = GatedHook {
2099            calls: calls.clone(),
2100            rendezvous: rendezvous.clone(),
2101            release: release.clone(),
2102        };
2103
2104        let mem = Arc::new(DemotingPolicyMemory::new(
2105            InMemoryConversationMemory::new(),
2106            SlidingWindowMemory::last_messages(1),
2107            hook,
2108        ));
2109
2110        mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
2111            .await
2112            .unwrap();
2113
2114        let mem_load = mem.clone();
2115        let stale = tokio::spawn(async move { mem_load.load("c").await });
2116        rendezvous.notified().await;
2117        assert_eq!(calls.load(Ordering::SeqCst), 1);
2118
2119        mem.clear("c").await.unwrap();
2120        mem.append(
2121            "c",
2122            vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
2123        )
2124        .await
2125        .unwrap();
2126
2127        let mem_load = mem.clone();
2128        let fresh = tokio::spawn(async move { mem_load.load("c").await });
2129        rendezvous.notified().await;
2130        assert_eq!(calls.load(Ordering::SeqCst), 2);
2131
2132        stale.abort();
2133        let _ = stale.await;
2134
2135        let mem_load = mem.clone();
2136        let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
2137        let concurrent_kept = tokio::select! {
2138            result = &mut concurrent => result.unwrap().unwrap(),
2139            _ = rendezvous.notified() => {
2140                panic!("stale guard must not clear the fresh in-flight reservation")
2141            }
2142        };
2143        assert_eq!(
2144            calls.load(Ordering::SeqCst),
2145            2,
2146            "stale guard must not clear the fresh in-flight reservation"
2147        );
2148
2149        release.notify_one();
2150        assert_eq!(fresh.await.unwrap().unwrap().len(), 1);
2151        assert_eq!(concurrent_kept.len(), 1);
2152        assert_eq!(calls.load(Ordering::SeqCst), 2);
2153    }
2154
2155    #[tokio::test]
2156    async fn demoting_stale_successful_load_does_not_clear_new_reservation() {
2157        #[derive(Default)]
2158        struct IndividuallyGatedHook {
2159            releases: Mutex<Vec<Arc<tokio::sync::Notify>>>,
2160        }
2161
2162        impl IndividuallyGatedHook {
2163            fn call_count(&self) -> usize {
2164                self.releases.lock().unwrap().len()
2165            }
2166
2167            async fn wait_for_call_count(&self, expected: usize) {
2168                while self.call_count() < expected {
2169                    tokio::task::yield_now().await;
2170                }
2171            }
2172
2173            fn release_call(&self, index: usize) {
2174                let release = self.releases.lock().unwrap()[index].clone();
2175                release.notify_one();
2176            }
2177        }
2178
2179        impl DemotionHook for IndividuallyGatedHook {
2180            fn on_demote<'a>(
2181                &'a self,
2182                _conversation_id: &'a str,
2183                _messages: Vec<Message>,
2184            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
2185                let release = Arc::new(tokio::sync::Notify::new());
2186                self.releases.lock().unwrap().push(release.clone());
2187                Box::pin(async move {
2188                    release.notified().await;
2189                    Ok(())
2190                })
2191            }
2192        }
2193
2194        let hook = Arc::new(IndividuallyGatedHook::default());
2195        let mem = Arc::new(DemotingPolicyMemory::new(
2196            InMemoryConversationMemory::new(),
2197            SlidingWindowMemory::last_messages(1),
2198            hook.clone(),
2199        ));
2200
2201        mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
2202            .await
2203            .unwrap();
2204
2205        let mem_load = mem.clone();
2206        let stale = tokio::spawn(async move { mem_load.load("c").await });
2207        hook.wait_for_call_count(1).await;
2208
2209        mem.clear("c").await.unwrap();
2210        mem.append(
2211            "c",
2212            vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
2213        )
2214        .await
2215        .unwrap();
2216
2217        let mem_load = mem.clone();
2218        let fresh = tokio::spawn(async move { mem_load.load("c").await });
2219        hook.wait_for_call_count(2).await;
2220
2221        // Let the stale load finish successfully after the conversation id has
2222        // been reused. Its post-await update must not clear the fresh in-flight
2223        // reservation.
2224        hook.release_call(0);
2225        assert_eq!(stale.await.unwrap().unwrap().len(), 1);
2226        assert_eq!(hook.call_count(), 2);
2227
2228        let mem_load = mem.clone();
2229        let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
2230        let hook_wait = hook.clone();
2231        let concurrent_kept = tokio::select! {
2232            result = &mut concurrent => result.unwrap().unwrap(),
2233            _ = hook_wait.wait_for_call_count(3) => {
2234                panic!("stale successful load must not clear the fresh in-flight reservation")
2235            }
2236        };
2237        assert_eq!(
2238            hook.call_count(),
2239            2,
2240            "stale successful load must not clear the fresh in-flight reservation"
2241        );
2242
2243        hook.release_call(1);
2244        assert_eq!(fresh.await.unwrap().unwrap().len(), 1);
2245        assert_eq!(concurrent_kept.len(), 1);
2246
2247        mem.load("c").await.unwrap();
2248        assert_eq!(hook.call_count(), 2);
2249    }
2250
2251    #[tokio::test]
2252    async fn forget_drops_in_process_watermark() {
2253        let hook = Arc::new(CountingHook::default());
2254        let mem = DemotingPolicyMemory::new(
2255            InMemoryConversationMemory::new(),
2256            SlidingWindowMemory::last_messages(1),
2257            hook.clone(),
2258        );
2259
2260        mem.append("c", vec![user("1"), assistant("2")])
2261            .await
2262            .unwrap();
2263        mem.load("c").await.unwrap();
2264        assert_eq!(mem.tracked_conversations(), 1);
2265        assert_eq!(hook.calls(), 1);
2266
2267        // After forgetting, the next load on the same (still-populated)
2268        // backend re-delivers the demotion. This is the documented
2269        // contract: forget()/restart re-fire the hook, hooks must be
2270        // idempotent.
2271        mem.forget("c");
2272        assert_eq!(mem.tracked_conversations(), 0);
2273        mem.load("c").await.unwrap();
2274        assert_eq!(hook.calls(), 2);
2275    }
2276
2277    // ----------------------------------------------------------------
2278    // CompactingMemory tests
2279    // ----------------------------------------------------------------
2280
2281    #[tokio::test]
2282    async fn compacting_no_demotion_returns_kept_only() {
2283        let mem = CompactingMemory::new(
2284            InMemoryConversationMemory::new(),
2285            SlidingWindowMemory::last_messages(10),
2286            TemplateCompactor::new(),
2287        );
2288
2289        mem.append("c", vec![user("hi"), assistant("hello")])
2290            .await
2291            .unwrap();
2292        let loaded = mem.load("c").await.unwrap();
2293        assert_eq!(loaded.len(), 2);
2294        // No tracking entry needed when nothing was demoted on the first load.
2295        // (We may have inserted a default entry; what matters is that no
2296        // summary message was spliced in.)
2297        assert!(matches!(&loaded[0], Message::User { .. }));
2298    }
2299
2300    #[tokio::test]
2301    async fn compacting_splices_summary_when_demoted() {
2302        let mem = CompactingMemory::new(
2303            InMemoryConversationMemory::new(),
2304            SlidingWindowMemory::last_messages(2),
2305            TemplateCompactor::new(),
2306        );
2307
2308        mem.append(
2309            "c",
2310            vec![
2311                user("first"),
2312                assistant("second"),
2313                user("third"),
2314                assistant("fourth"),
2315            ],
2316        )
2317        .await
2318        .unwrap();
2319
2320        let loaded = mem.load("c").await.unwrap();
2321        // Expected shape: [summary, third, fourth]
2322        assert_eq!(loaded.len(), 3);
2323        let Message::System { content } = &loaded[0] else {
2324            panic!("expected summary as system message");
2325        };
2326        assert!(content.contains("[Conversation summary so far]"));
2327        assert!(content.contains("user: first"));
2328        assert!(content.contains("assistant: second"));
2329        // The kept window is intact.
2330        let Message::User { content } = &loaded[1] else {
2331            panic!("expected kept user message");
2332        };
2333        let Some(UserContent::Text(t)) = content.first() else {
2334            panic!("expected text");
2335        };
2336        assert_eq!(t.text, "third");
2337    }
2338
2339    #[tokio::test]
2340    async fn compacting_rolls_summary_forward() {
2341        let mem = CompactingMemory::new(
2342            InMemoryConversationMemory::new(),
2343            SlidingWindowMemory::last_messages(2),
2344            TemplateCompactor::new(),
2345        );
2346
2347        mem.append(
2348            "c",
2349            vec![user("a"), assistant("b"), user("c"), assistant("d")],
2350        )
2351        .await
2352        .unwrap();
2353
2354        let first = mem.load("c").await.unwrap();
2355        let Message::System { content } = &first[0] else {
2356            panic!("summary missing");
2357        };
2358        let first_summary = content.clone();
2359        assert!(first_summary.contains("user: a"));
2360        assert!(first_summary.contains("assistant: b"));
2361
2362        // Append more turns; the next load should fold the previous summary
2363        // into a new one that also covers the newly-evicted prefix.
2364        mem.append("c", vec![user("e"), assistant("f")])
2365            .await
2366            .unwrap();
2367        let second = mem.load("c").await.unwrap();
2368        let Message::System { content } = &second[0] else {
2369            panic!("summary missing");
2370        };
2371        // The new summary contains the old summary text (carry_over) plus
2372        // the freshly-evicted lines.
2373        assert!(content.contains(&first_summary));
2374        assert!(content.contains("user: c"));
2375        assert!(content.contains("assistant: d"));
2376    }
2377
2378    #[tokio::test]
2379    async fn compacting_idempotent_within_process() {
2380        // Loading twice with no new evictions reuses the stored summary
2381        // and does not re-run the compactor (we observe this via the
2382        // produced text: a re-run with a non-None carry_over would double
2383        // the header line).
2384        let mem = CompactingMemory::new(
2385            InMemoryConversationMemory::new(),
2386            SlidingWindowMemory::last_messages(1),
2387            TemplateCompactor::new(),
2388        );
2389        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2390            .await
2391            .unwrap();
2392
2393        let first = mem.load("c").await.unwrap();
2394        let second = mem.load("c").await.unwrap();
2395        assert_eq!(first.len(), second.len());
2396        let Message::System { content: c1 } = &first[0] else {
2397            panic!()
2398        };
2399        let Message::System { content: c2 } = &second[0] else {
2400            panic!()
2401        };
2402        assert_eq!(c1, c2);
2403    }
2404
2405    #[tokio::test]
2406    async fn compacting_clear_drops_summary() {
2407        let mem = CompactingMemory::new(
2408            InMemoryConversationMemory::new(),
2409            SlidingWindowMemory::last_messages(1),
2410            TemplateCompactor::new(),
2411        );
2412        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2413            .await
2414            .unwrap();
2415        mem.load("c").await.unwrap();
2416        assert_eq!(mem.tracked_conversations(), 1);
2417
2418        mem.clear("c").await.unwrap();
2419        assert_eq!(mem.tracked_conversations(), 0);
2420        assert!(mem.load("c").await.unwrap().is_empty());
2421    }
2422
2423    // A compactor that fails the first call and succeeds afterwards, so we
2424    // can verify failure is propagated and the watermark is not advanced.
2425    #[derive(Default)]
2426    struct FlakyCompactor {
2427        calls: std::sync::atomic::AtomicUsize,
2428    }
2429
2430    impl Compactor for FlakyCompactor {
2431        type Artifact = TextSummary;
2432
2433        fn compact<'a>(
2434            &'a self,
2435            _conversation_id: &'a str,
2436            evicted: &'a [Message],
2437            _carry_over: Option<&'a Self::Artifact>,
2438        ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2439            Box::pin(async move {
2440                let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2441                if n == 0 {
2442                    Err(MemoryError::Policy("flaky".into()))
2443                } else {
2444                    Ok(TextSummary(format!("compacted {} messages", evicted.len())))
2445                }
2446            })
2447        }
2448    }
2449
2450    #[tokio::test]
2451    async fn compacting_failure_does_not_advance_watermark() {
2452        let mem = CompactingMemory::new(
2453            InMemoryConversationMemory::new(),
2454            SlidingWindowMemory::last_messages(1),
2455            FlakyCompactor::default(),
2456        );
2457        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2458            .await
2459            .unwrap();
2460
2461        let err = mem.load("c").await.unwrap_err();
2462        assert!(matches!(err, MemoryError::Policy(_)));
2463
2464        // Retry should succeed and produce a summary.
2465        let loaded = mem.load("c").await.unwrap();
2466        assert_eq!(loaded.len(), 2);
2467        let Message::System { content } = &loaded[0] else {
2468            panic!("expected summary")
2469        };
2470        assert!(content.contains("compacted"));
2471    }
2472
2473    // A compactor that records every invocation, including the lengths of
2474    // its `evicted` slice and whether `carry_over` was supplied.
2475    #[derive(Default)]
2476    struct CountingCompactor {
2477        log: Mutex<Vec<(usize, bool)>>,
2478    }
2479
2480    impl CountingCompactor {
2481        fn calls(&self) -> Vec<(usize, bool)> {
2482            self.log.lock().unwrap().clone()
2483        }
2484    }
2485
2486    impl Compactor for CountingCompactor {
2487        type Artifact = TextSummary;
2488
2489        fn compact<'a>(
2490            &'a self,
2491            _conversation_id: &'a str,
2492            evicted: &'a [Message],
2493            carry_over: Option<&'a Self::Artifact>,
2494        ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2495            Box::pin(async move {
2496                self.log
2497                    .lock()
2498                    .unwrap()
2499                    .push((evicted.len(), carry_over.is_some()));
2500                let prev = carry_over.map(|s| s.as_str()).unwrap_or("");
2501                Ok(TextSummary(format!("{prev}|{}", evicted.len())))
2502            })
2503        }
2504    }
2505
2506    #[tokio::test]
2507    async fn compacting_no_demotion_does_not_invoke_compactor() {
2508        let compactor = Arc::new(CountingCompactor::default());
2509        let mem = CompactingMemory::new(
2510            InMemoryConversationMemory::new(),
2511            SlidingWindowMemory::last_messages(10),
2512            compactor.clone(),
2513        );
2514
2515        mem.append("c", vec![user("a"), assistant("b")])
2516            .await
2517            .unwrap();
2518        mem.load("c").await.unwrap();
2519        mem.load("c").await.unwrap();
2520        mem.load("c").await.unwrap();
2521        assert!(compactor.calls().is_empty());
2522        // Fast path means we never installed a tracking entry either.
2523        assert_eq!(mem.tracked_conversations(), 0);
2524    }
2525
2526    #[tokio::test]
2527    async fn compacting_invokes_compactor_only_on_new_demotions() {
2528        let compactor = Arc::new(CountingCompactor::default());
2529        let mem = CompactingMemory::new(
2530            InMemoryConversationMemory::new(),
2531            SlidingWindowMemory::last_messages(2),
2532            compactor.clone(),
2533        );
2534
2535        // First eviction: 2 messages demoted.
2536        mem.append(
2537            "c",
2538            vec![user("a"), assistant("b"), user("c"), assistant("d")],
2539        )
2540        .await
2541        .unwrap();
2542        mem.load("c").await.unwrap();
2543        // Re-load: nothing new evicted; compactor must NOT run again.
2544        mem.load("c").await.unwrap();
2545        mem.load("c").await.unwrap();
2546        let calls = compactor.calls();
2547        assert_eq!(
2548            calls.len(),
2549            1,
2550            "compactor invoked more than once: {calls:?}"
2551        );
2552        assert_eq!(calls[0], (2, false));
2553
2554        // Append two more turns → another 2 demoted; compactor runs once
2555        // more, and this time `carry_over` must be present.
2556        mem.append("c", vec![user("e"), assistant("f")])
2557            .await
2558            .unwrap();
2559        mem.load("c").await.unwrap();
2560        mem.load("c").await.unwrap();
2561        let calls = compactor.calls();
2562        assert_eq!(calls.len(), 2, "expected exactly one new call: {calls:?}");
2563        // Second call only compacts the *newly* evicted prefix (2 msgs)
2564        // with the previous summary as carry-over.
2565        assert_eq!(calls[1], (2, true));
2566    }
2567
2568    #[tokio::test]
2569    async fn compacting_serialises_concurrent_loads() {
2570        // Many concurrent loads on the same conversation must produce at
2571        // most ONE compactor invocation per "epoch" of new evictions.
2572        let compactor = Arc::new(CountingCompactor::default());
2573        let mem = Arc::new(CompactingMemory::new(
2574            InMemoryConversationMemory::new(),
2575            SlidingWindowMemory::last_messages(2),
2576            compactor.clone(),
2577        ));
2578        mem.append(
2579            "c",
2580            vec![user("a"), assistant("b"), user("c"), assistant("d")],
2581        )
2582        .await
2583        .unwrap();
2584
2585        let mut handles = Vec::new();
2586        for _ in 0..32 {
2587            let mem = mem.clone();
2588            handles.push(tokio::spawn(async move {
2589                mem.load("c").await.unwrap();
2590            }));
2591        }
2592        for h in handles {
2593            h.await.unwrap();
2594        }
2595
2596        // Exactly one invocation: the first to acquire the lock runs the
2597        // compactor; the others see in_flight or the advanced watermark.
2598        let calls = compactor.calls();
2599        assert_eq!(calls.len(), 1, "expected exactly 1 call: {calls:?}");
2600    }
2601
2602    #[tokio::test]
2603    async fn compacting_clear_drops_summary_carry_over() {
2604        // After clear, the next load on a freshly-populated backend must
2605        // start compaction from scratch (carry_over=None), not roll the
2606        // old summary forward.
2607        let compactor = Arc::new(CountingCompactor::default());
2608        let mem = CompactingMemory::new(
2609            InMemoryConversationMemory::new(),
2610            SlidingWindowMemory::last_messages(1),
2611            compactor.clone(),
2612        );
2613        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2614            .await
2615            .unwrap();
2616        mem.load("c").await.unwrap();
2617        assert_eq!(compactor.calls()[0], (2, false));
2618
2619        mem.clear("c").await.unwrap();
2620        assert_eq!(mem.tracked_conversations(), 0);
2621
2622        mem.append("c", vec![user("x"), assistant("y"), user("z")])
2623            .await
2624            .unwrap();
2625        mem.load("c").await.unwrap();
2626        let calls = compactor.calls();
2627        assert_eq!(calls.len(), 2);
2628        // Crucial: no carry_over after clear.
2629        assert_eq!(calls[1], (2, false));
2630    }
2631
2632    #[tokio::test]
2633    async fn compacting_forget_drops_summary() {
2634        let compactor = Arc::new(CountingCompactor::default());
2635        let mem = CompactingMemory::new(
2636            InMemoryConversationMemory::new(),
2637            SlidingWindowMemory::last_messages(1),
2638            compactor.clone(),
2639        );
2640        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2641            .await
2642            .unwrap();
2643        mem.load("c").await.unwrap();
2644        assert_eq!(mem.tracked_conversations(), 1);
2645        mem.forget("c");
2646        assert_eq!(mem.tracked_conversations(), 0);
2647
2648        // Next load on the still-populated backend re-compacts from
2649        // scratch — same documented contract as DemotionHook.
2650        mem.load("c").await.unwrap();
2651        let calls = compactor.calls();
2652        assert_eq!(calls.len(), 2);
2653        assert_eq!(calls[1], (2, false));
2654    }
2655
2656    #[tokio::test]
2657    async fn compacting_arc_compactor_works() {
2658        // Arc<C> forwarding impl exists on Compactor, so CompactingMemory
2659        // must accept it.
2660        let compactor: Arc<dyn Compactor<Artifact = TextSummary>> =
2661            Arc::new(TemplateCompactor::new());
2662        let mem = CompactingMemory::new(
2663            InMemoryConversationMemory::new(),
2664            SlidingWindowMemory::last_messages(1),
2665            compactor,
2666        );
2667        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2668            .await
2669            .unwrap();
2670        let loaded = mem.load("c").await.unwrap();
2671        assert_eq!(loaded.len(), 2);
2672        assert!(matches!(&loaded[0], Message::System { .. }));
2673    }
2674
2675    #[tokio::test]
2676    async fn compacting_into_inner_returns_components() {
2677        let mem = CompactingMemory::new(
2678            InMemoryConversationMemory::new(),
2679            SlidingWindowMemory::last_messages(1),
2680            TemplateCompactor::new(),
2681        );
2682        let (_inner, _policy, _compactor) = mem.into_inner();
2683    }
2684
2685    #[tokio::test]
2686    async fn compacting_isolates_conversations() {
2687        let compactor = Arc::new(CountingCompactor::default());
2688        let mem = CompactingMemory::new(
2689            InMemoryConversationMemory::new(),
2690            SlidingWindowMemory::last_messages(1),
2691            compactor.clone(),
2692        );
2693        mem.append("a", vec![user("a1"), assistant("a2"), user("a3")])
2694            .await
2695            .unwrap();
2696        mem.append("b", vec![user("b1"), assistant("b2"), user("b3")])
2697            .await
2698            .unwrap();
2699
2700        let a = mem.load("a").await.unwrap();
2701        let b = mem.load("b").await.unwrap();
2702        // Each conversation gets its own summary.
2703        assert_eq!(a.len(), 2);
2704        assert_eq!(b.len(), 2);
2705        assert_eq!(compactor.calls().len(), 2);
2706        assert_eq!(mem.tracked_conversations(), 2);
2707    }
2708
2709    #[tokio::test]
2710    async fn compacting_composes_with_token_window() {
2711        // Verify CompactingMemory is policy-agnostic: works over a
2712        // TokenWindowMemory just as well as a SlidingWindowMemory.
2713        let mem = CompactingMemory::new(
2714            InMemoryConversationMemory::new(),
2715            TokenWindowMemory::new(30, HeuristicTokenCounter::openai()),
2716            TemplateCompactor::new(),
2717        );
2718        mem.append(
2719            "c",
2720            vec![
2721                user("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2722                assistant("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2723                user("cccccccccccccccccccc"),
2724                assistant("d"),
2725            ],
2726        )
2727        .await
2728        .unwrap();
2729        let loaded = mem.load("c").await.unwrap();
2730        // Some prefix should have been evicted; expect a summary in front.
2731        assert!(loaded.len() >= 2);
2732        assert!(matches!(&loaded[0], Message::System { .. }));
2733    }
2734
2735    #[tokio::test]
2736    async fn template_compactor_renders_system_messages() {
2737        let compactor = TemplateCompactor::new();
2738        let evicted = vec![
2739            Message::System {
2740                content: "you are helpful".into(),
2741            },
2742            user("hi"),
2743            assistant("hello"),
2744        ];
2745        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2746        let s = summary.as_str();
2747        assert!(s.contains("system: you are helpful"), "got: {s}");
2748        assert!(s.contains("user: hi"));
2749        assert!(s.contains("assistant: hello"));
2750    }
2751
2752    #[tokio::test]
2753    async fn template_compactor_renders_tool_call_marker() {
2754        let compactor = TemplateCompactor::new();
2755        let evicted = vec![tool_call_msg(), tool_result_msg()];
2756        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2757        let s = summary.as_str();
2758        assert!(s.contains("[tool call: t]"), "got: {s}");
2759        assert!(s.contains("[tool result]"), "got: {s}");
2760    }
2761
2762    /// The separator is suppressed only while the line is still empty, so an
2763    /// empty *leading* part contributes nothing while an empty *interior* one
2764    /// still spends its space. `Vec::join(" ")` would flatten that asymmetry;
2765    /// this pins the rendered bytes so a future tidy-up cannot.
2766    #[tokio::test]
2767    async fn template_compactor_separates_parts_asymmetrically_around_empty_text() {
2768        let compactor = TemplateCompactor::new();
2769        let evicted = vec![Message::User {
2770            content: vec![
2771                UserContent::text(""),
2772                UserContent::text("a"),
2773                UserContent::text(""),
2774                UserContent::text("b"),
2775            ],
2776        }];
2777        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2778        let rendered = summary.as_str();
2779        assert!(
2780            rendered.contains("user: a  b"),
2781            "leading empty part adds no separator, interior one adds its own; got: {rendered}"
2782        );
2783    }
2784
2785    #[tokio::test]
2786    async fn template_compactor_carry_over_threaded() {
2787        let compactor = TemplateCompactor::new();
2788        let first = compactor
2789            .compact("c", &[user("hello")], None)
2790            .await
2791            .unwrap();
2792        assert!(!first.as_str().is_empty());
2793
2794        let second = compactor
2795            .compact("c", &[assistant("world")], Some(&first))
2796            .await
2797            .unwrap();
2798        // Carry-over text appears in the new summary.
2799        assert!(second.as_str().contains(first.as_str()));
2800        assert!(second.as_str().contains("assistant: world"));
2801    }
2802
2803    #[tokio::test]
2804    async fn template_compactor_artifact_into_message() {
2805        let s = TextSummary("rolled-up text".into());
2806        let msg: Message = s.into();
2807        let Message::System { content } = msg else {
2808            panic!("expected system message");
2809        };
2810        assert_eq!(content, "rolled-up text");
2811    }
2812
2813    #[tokio::test]
2814    async fn template_compactor_caps_summary_at_max_bytes() {
2815        let cap = 256;
2816        let compactor = TemplateCompactor::new().with_max_bytes(cap);
2817        // Build an evicted history large enough to exceed `cap` on its own.
2818        let mut evicted = Vec::new();
2819        for i in 0..50 {
2820            evicted.push(user(&format!("message number {i} with some filler")));
2821        }
2822        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2823        assert!(
2824            summary.as_str().len()
2825                <= cap + "[Conversation summary so far]\n[\u{2026}truncated\u{2026}]\n".len(),
2826            "summary len {} exceeds cap {} (plus header+marker)",
2827            summary.as_str().len(),
2828            cap,
2829        );
2830        // Header is preserved.
2831        assert!(
2832            summary
2833                .as_str()
2834                .starts_with("[Conversation summary so far]\n")
2835        );
2836        // Truncation marker is present.
2837        assert!(summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2838        // Most recent line survives.
2839        assert!(summary.as_str().contains("message number 49"));
2840    }
2841
2842    #[tokio::test]
2843    async fn template_compactor_unbounded_by_default() {
2844        let compactor = TemplateCompactor::new();
2845        let mut evicted = Vec::new();
2846        for i in 0..200 {
2847            evicted.push(user(&format!("msg {i}")));
2848        }
2849        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2850        // Without a cap, no truncation marker should appear.
2851        assert!(!summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2852        // Both ends are present.
2853        assert!(summary.as_str().contains("msg 0"));
2854        assert!(summary.as_str().contains("msg 199"));
2855    }
2856
2857    #[tokio::test]
2858    async fn template_compactor_with_max_bytes_zero_is_unbounded() {
2859        let compactor = TemplateCompactor::new().with_max_bytes(0);
2860        let mut evicted = Vec::new();
2861        for i in 0..200 {
2862            evicted.push(user(&format!("msg {i}")));
2863        }
2864        let summary = compactor.compact("c", &evicted, None).await.unwrap();
2865        assert!(!summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2866    }
2867
2868    #[tokio::test]
2869    async fn compacting_summary_stays_bounded_across_rolls() {
2870        // With a capped TemplateCompactor, repeated rolling must not let
2871        // the summary grow without bound.
2872        let cap = 512;
2873        let mem = CompactingMemory::new(
2874            InMemoryConversationMemory::new(),
2875            SlidingWindowMemory::last_messages(2),
2876            TemplateCompactor::new().with_max_bytes(cap),
2877        );
2878        mem.append("c", vec![user("seed-a"), assistant("seed-b")])
2879            .await
2880            .unwrap();
2881        for i in 0..30 {
2882            mem.append(
2883                "c",
2884                vec![
2885                    user(&format!("user line {i} ----- padding padding padding")),
2886                    assistant(&format!("assistant line {i} ----- padding padding")),
2887                ],
2888            )
2889            .await
2890            .unwrap();
2891            mem.load("c").await.unwrap();
2892        }
2893        let loaded = mem.load("c").await.unwrap();
2894        let Message::System { content } = &loaded[0] else {
2895            panic!("expected summary");
2896        };
2897        // Allow some slack for header + marker overhead.
2898        let slack = "[Conversation summary so far]\n[\u{2026}truncated\u{2026}]\n".len();
2899        assert!(
2900            content.len() <= cap + slack,
2901            "summary grew to {} bytes (cap {}, slack {})",
2902            content.len(),
2903            cap,
2904            slack,
2905        );
2906    }
2907
2908    #[tokio::test]
2909    async fn compacting_concurrent_with_clear_does_not_resurrect_state() {
2910        // A clear that lands while compaction is in flight must not be
2911        // overwritten by the post-await state update.
2912        use std::sync::atomic::{AtomicBool, Ordering};
2913
2914        struct GatedCompactor {
2915            release: tokio::sync::Notify,
2916            entered: AtomicBool,
2917        }
2918
2919        impl Compactor for GatedCompactor {
2920            type Artifact = TextSummary;
2921
2922            fn compact<'a>(
2923                &'a self,
2924                _conversation_id: &'a str,
2925                _evicted: &'a [Message],
2926                _carry_over: Option<&'a Self::Artifact>,
2927            ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2928                Box::pin(async move {
2929                    self.entered.store(true, Ordering::SeqCst);
2930                    self.release.notified().await;
2931                    Ok(TextSummary("late summary".into()))
2932                })
2933            }
2934        }
2935
2936        let compactor = Arc::new(GatedCompactor {
2937            release: tokio::sync::Notify::new(),
2938            entered: AtomicBool::new(false),
2939        });
2940        let mem = Arc::new(CompactingMemory::new(
2941            InMemoryConversationMemory::new(),
2942            SlidingWindowMemory::last_messages(1),
2943            compactor.clone(),
2944        ));
2945        mem.append("c", vec![user("a"), assistant("b"), user("c")])
2946            .await
2947            .unwrap();
2948
2949        // Kick off a load that will block inside the compactor.
2950        let mem_load = mem.clone();
2951        let load_handle = tokio::spawn(async move { mem_load.load("c").await });
2952
2953        // Wait for the compactor to have entered.
2954        while !compactor.entered.load(Ordering::SeqCst) {
2955            tokio::task::yield_now().await;
2956        }
2957
2958        // Clear while the compaction is in flight.
2959        mem.clear("c").await.unwrap();
2960
2961        // Release the compactor; it should complete and *not* resurrect
2962        // the cleared state.
2963        compactor.release.notify_one();
2964        let _ = load_handle.await.unwrap();
2965
2966        assert_eq!(mem.tracked_conversations(), 0);
2967        // A subsequent load on the empty backend returns nothing.
2968        assert!(mem.load("c").await.unwrap().is_empty());
2969    }
2970
2971    #[tokio::test]
2972    async fn compacting_dropped_load_releases_in_flight_gate() {
2973        // If a `load(...)` future is dropped while awaiting the
2974        // compactor, the in-flight gate must not leak: subsequent loads
2975        // on the same conversation must be able to retry compaction.
2976        use std::sync::atomic::{AtomicUsize, Ordering};
2977
2978        struct GatedCompactor {
2979            release: tokio::sync::Notify,
2980            entered: AtomicUsize,
2981        }
2982
2983        impl Compactor for GatedCompactor {
2984            type Artifact = TextSummary;
2985
2986            fn compact<'a>(
2987                &'a self,
2988                _conversation_id: &'a str,
2989                _evicted: &'a [Message],
2990                _carry_over: Option<&'a Self::Artifact>,
2991            ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2992                Box::pin(async move {
2993                    self.entered.fetch_add(1, Ordering::SeqCst);
2994                    self.release.notified().await;
2995                    Ok(TextSummary("ran".into()))
2996                })
2997            }
2998        }
2999
3000        let compactor = Arc::new(GatedCompactor {
3001            release: tokio::sync::Notify::new(),
3002            entered: AtomicUsize::new(0),
3003        });
3004        let mem = Arc::new(CompactingMemory::new(
3005            InMemoryConversationMemory::new(),
3006            SlidingWindowMemory::last_messages(1),
3007            compactor.clone(),
3008        ));
3009        mem.append("c", vec![user("a"), assistant("b"), user("c")])
3010            .await
3011            .unwrap();
3012
3013        // Kick off a load that will block inside the compactor, then
3014        // abort it while awaiting — simulating a caller-side timeout
3015        // or `tokio::select!` cancellation.
3016        let mem_load = mem.clone();
3017        let handle = tokio::spawn(async move { mem_load.load("c").await });
3018        while compactor.entered.load(Ordering::SeqCst) == 0 {
3019            tokio::task::yield_now().await;
3020        }
3021        handle.abort();
3022        let _ = handle.await;
3023
3024        // The aborted future was dropped without clearing in_flight via
3025        // the success/error branches; the RAII guard's `Drop` should
3026        // have released it. A new load must therefore be able to drive
3027        // a fresh compaction rather than short-circuiting forever.
3028        let mem_load = mem.clone();
3029        let retry = tokio::spawn(async move { mem_load.load("c").await });
3030        // Wait for the compactor to be entered a second time. If the
3031        // gate had leaked, this would never happen — the load would
3032        // short-circuit on `in_flight = true` and return immediately.
3033        while compactor.entered.load(Ordering::SeqCst) < 2 {
3034            tokio::task::yield_now().await;
3035        }
3036        compactor.release.notify_one();
3037        let loaded = retry.await.unwrap().unwrap();
3038        assert_eq!(loaded.len(), 2);
3039        let Message::System { content } = &loaded[0] else {
3040            panic!("expected summary")
3041        };
3042        assert_eq!(content, "ran");
3043    }
3044
3045    #[tokio::test]
3046    async fn compacting_stale_cancelled_load_does_not_clear_new_reservation() {
3047        use std::sync::atomic::{AtomicUsize, Ordering};
3048
3049        struct GatedCompactor {
3050            release: tokio::sync::Notify,
3051            rendezvous: tokio::sync::Notify,
3052            entered: AtomicUsize,
3053        }
3054
3055        impl Compactor for GatedCompactor {
3056            type Artifact = TextSummary;
3057
3058            fn compact<'a>(
3059                &'a self,
3060                _conversation_id: &'a str,
3061                _evicted: &'a [Message],
3062                _carry_over: Option<&'a Self::Artifact>,
3063            ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
3064                Box::pin(async move {
3065                    self.entered.fetch_add(1, Ordering::SeqCst);
3066                    self.rendezvous.notify_one();
3067                    self.release.notified().await;
3068                    Ok(TextSummary("ran".into()))
3069                })
3070            }
3071        }
3072
3073        let compactor = Arc::new(GatedCompactor {
3074            release: tokio::sync::Notify::new(),
3075            rendezvous: tokio::sync::Notify::new(),
3076            entered: AtomicUsize::new(0),
3077        });
3078        let mem = Arc::new(CompactingMemory::new(
3079            InMemoryConversationMemory::new(),
3080            SlidingWindowMemory::last_messages(1),
3081            compactor.clone(),
3082        ));
3083
3084        mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
3085            .await
3086            .unwrap();
3087
3088        let mem_load = mem.clone();
3089        let stale = tokio::spawn(async move { mem_load.load("c").await });
3090        compactor.rendezvous.notified().await;
3091        assert_eq!(compactor.entered.load(Ordering::SeqCst), 1);
3092
3093        mem.clear("c").await.unwrap();
3094        mem.append(
3095            "c",
3096            vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
3097        )
3098        .await
3099        .unwrap();
3100
3101        let mem_load = mem.clone();
3102        let fresh = tokio::spawn(async move { mem_load.load("c").await });
3103        compactor.rendezvous.notified().await;
3104        assert_eq!(compactor.entered.load(Ordering::SeqCst), 2);
3105
3106        stale.abort();
3107        let _ = stale.await;
3108
3109        let mem_load = mem.clone();
3110        let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
3111        let concurrent_kept = tokio::select! {
3112            result = &mut concurrent => result.unwrap().unwrap(),
3113            _ = compactor.rendezvous.notified() => {
3114                panic!("stale guard must not clear the fresh in-flight reservation")
3115            }
3116        };
3117        assert_eq!(
3118            compactor.entered.load(Ordering::SeqCst),
3119            2,
3120            "stale guard must not clear the fresh in-flight reservation"
3121        );
3122
3123        compactor.release.notify_one();
3124        assert_eq!(fresh.await.unwrap().unwrap().len(), 2);
3125        assert_eq!(concurrent_kept.len(), 1);
3126        assert_eq!(compactor.entered.load(Ordering::SeqCst), 2);
3127    }
3128
3129    #[tokio::test]
3130    async fn template_compactor_caps_summary_with_multiline_header() {
3131        // A header containing embedded newlines must not break the
3132        // truncation boundary calculation. The first newline in the
3133        // assembled buffer marks the header/body split, regardless of
3134        // how the caller chose to format the header.
3135        let cap = 256;
3136        let compactor = TemplateCompactor::with_header("line one\nline two").with_max_bytes(cap);
3137        let mut evicted = Vec::new();
3138        for i in 0..50 {
3139            evicted.push(user(&format!("message number {i} with some filler")));
3140        }
3141        let summary = compactor.compact("c", &evicted, None).await.unwrap();
3142        let text = summary.as_str();
3143
3144        // The first line of the header is preserved as the header line.
3145        assert!(text.starts_with("line one\n"));
3146        // Truncation marker is present and the most recent line survives.
3147        assert!(text.contains("[\u{2026}truncated\u{2026}]"));
3148        assert!(text.contains("message number 49"));
3149        // Cap is honoured up to the header+marker overhead.
3150        let overhead = "line one\n".len() + "[\u{2026}truncated\u{2026}]\n".len();
3151        assert!(
3152            text.len() <= cap + overhead,
3153            "summary len {} exceeds cap {} plus overhead {}",
3154            text.len(),
3155            cap,
3156            overhead,
3157        );
3158    }
3159}