Skip to main content

leviath_core/
region.rs

1//! Memory region types and validation schemas.
2//!
3//! Regions are typed sections of an agent's context window with different lifecycle
4//! policies. This module defines the region kinds, content storage, and validation
5//! schemas that enforce content format requirements.
6
7use serde::{Deserialize, Serialize};
8
9/// The kind of content stored in a region entry.
10///
11/// Entries carry typed metadata instead of relying on text-prefix parsing
12/// (e.g., "Assistant: " / "User: ") to determine message roles. This
13/// eliminates the bug where tool results stored outside the conversation
14/// region all become "user" role messages.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(tag = "type")]
17pub enum EntryKind {
18    /// Plain text (system content, summaries, scratch).
19    #[default]
20    Text,
21    /// User message in conversation.
22    UserMessage,
23    /// Assistant response with optional tool calls.
24    AssistantTurn {
25        /// The calls the model asked for, empty when it only spoke. Kept with
26        /// the turn so a reloaded context replays the same request shape the
27        /// provider originally saw.
28        tool_calls: Vec<SerializedToolCall>,
29    },
30    /// Tool execution result, paired with a tool_call_id.
31    ToolResult {
32        /// The `AssistantTurn` call this answers. Providers reject a result
33        /// whose id does not match a call they were shown.
34        tool_call_id: String,
35        /// The tool that produced it, for display and telemetry.
36        tool_name: String,
37        /// Whether the tool refused or failed, so a reload does not present a
38        /// failure back to the model as a successful result.
39        is_error: bool,
40    },
41}
42
43/// A serialized tool call stored within an `AssistantTurn` entry.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct SerializedToolCall {
46    /// The provider-assigned call id, which the matching
47    /// [`EntryKind::ToolResult`] must quote back.
48    pub id: String,
49    /// The tool the model asked for, as it named it.
50    pub name: String,
51    /// The arguments as the model supplied them, unvalidated and untransformed.
52    pub arguments: serde_json::Value,
53    /// Opaque provider token that must be replayed with this call
54    /// (Gemini's `thought_signature`). Persisted so it survives a restart.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub thought_signature: Option<String>,
57}
58
59/// Eviction strategy for `SlidingWindow` regions.
60///
61/// Controls how entries are removed when the window exceeds its `max_items` limit.
62/// The choice of strategy affects prompt caching effectiveness: PerItem eviction
63/// shifts the message prefix every iteration (breaking cache), while Bulk and
64/// Compact keep the prefix stable between eviction events.
65#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(tag = "strategy", rename_all = "snake_case")]
67pub enum EvictionStrategy {
68    /// Evict one turn group at a time (current behavior). Default.
69    #[default]
70    PerItem,
71    /// Evict in bulk when items exceed max + overflow.
72    /// Between bulk evictions, the prefix stays stable for caching.
73    Bulk {
74        /// How many items over max_items before triggering a bulk eviction.
75        /// When triggered, evicts items back down to max_items.
76        overflow: usize,
77    },
78    /// Summarize oldest entries when threshold is hit (requires external LLM call).
79    /// The region stores a `pending_compaction` flag; the runtime checks this
80    /// and performs compaction externally.
81    Compact {
82        /// Number of oldest entries to compact into a summary when triggered.
83        compact_count: usize,
84    },
85}
86
87/// A typed memory region within an agent's context window.
88///
89/// Regions have different lifecycle policies controlling how they behave
90/// when the context window fills up. This is inspired by hardware memory
91/// architectures like SNES VRAM, where different memory regions serve
92/// distinct purposes with their own access patterns and constraints.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum RegionKind {
95    /// Never evicted or compacted. Architecture diagrams, constraints, identity.
96    ///
97    /// Like SNES OAM (Object Attribute Memory) - fixed format, always present.
98    /// Use for content that defines the agent's core identity, constraints,
99    /// and architectural understanding. This content persists for the entire
100    /// agent lifecycle.
101    Pinned,
102
103    /// Maintains the last N items, oldest rolls off. Conversation history.
104    ///
105    /// Like a ring buffer with configurable size. When the buffer is full,
106    /// the oldest item is removed to make room for new content. Use for
107    /// conversation history or any sequential data where recent items
108    /// are most relevant.
109    SlidingWindow {
110        /// Maximum number of items to retain in the window
111        max_items: usize,
112        /// Strategy used to evict entries when the window is full
113        eviction_strategy: EvictionStrategy,
114    },
115
116    /// First to be evicted when space is needed. Tool outputs, intermediate results.
117    ///
118    /// Cheapest to regenerate, lowest priority to keep. Use for content that
119    /// can be easily regenerated or has low value after immediate use, such as
120    /// tool execution results or temporary computations.
121    Temporary,
122
123    /// Compacts (summarizes) when threshold is hit, then cleared.
124    ///
125    /// When token count exceeds the threshold, the region's content is summarized
126    /// and moved to a paired CompactHistory region, then the original Compacting
127    /// region is completely cleared, giving fresh capacity.
128    Compacting {
129        /// Token count that triggers compaction
130        threshold_tokens: usize,
131    },
132
133    /// Wiped entirely in one shot when space is needed. All-or-nothing eviction.
134    ///
135    /// Unlike Temporary (which evicts oldest entries one at a time), Clearable
136    /// regions are dumped completely and immediately when eviction is needed.
137    /// Use for scratch space or temporary working data where partial results
138    /// are useless.
139    Clearable,
140
141    /// Receives summaries from paired Compacting regions, never evicted.
142    ///
143    /// When a Compacting region hits its threshold and summarizes, the summary
144    /// moves here. CompactHistory regions hold compressed knowledge indefinitely
145    /// and are never evicted. Can also support sliding window behavior (oldest
146    /// summaries drop off) and re-compaction (combine multiple summaries).
147    CompactHistory {
148        /// Name of the source Compacting region
149        source_region: String,
150    },
151
152    /// Key-value region where entries are indexed by string key.
153    /// Writing with an existing key replaces that entry (upsert semantics).
154    /// When over token budget, evicts least-recently-updated entries (LRU).
155    HashMap {
156        /// Optional maximum number of keys
157        max_entries: Option<usize>,
158    },
159
160    /// A task list whose entries carry state: open or done.
161    ///
162    /// Never evicted, like [`Self::Pinned`] - a checklist that quietly loses
163    /// items is worse than no checklist. What it adds over a pinned region is
164    /// that the state is *real*: "compute the fee table" and "~~compute the fee
165    /// table~~ done" are two different strings to every other region kind, so
166    /// nothing could count what was left and no gate could ask. Written through
167    /// the `todo_*` tools rather than free text, so the state cannot drift from
168    /// what the model believes it wrote.
169    Checklist,
170
171    /// Script-backed region: a user-authored Rhai script owns how the region
172    /// renders into the assembled context (`render`), may transform or reject
173    /// each incoming entry (`on_write`), and may choose what to drop under
174    /// budget pressure (`on_overflow`).
175    ///
176    /// `script` is the blueprint-dir-relative path to the `.rhai` file; path
177    /// resolution and compilation happen in the CLI spawner (this crate stays
178    /// filesystem-free), and the compiled script travels on the runtime's
179    /// context window keyed by this path. `persistent` regions behave like
180    /// [`Pinned`](Self::Pinned) for lifecycle - never evicted, immune to edge
181    /// `Clear` transforms, counted as fixed budget - while non-persistent
182    /// regions behave like [`Temporary`](Self::Temporary).
183    ///
184    /// Note: this kind is orthogonal to [`RegionSchema`]'s (unwired)
185    /// `custom_script` field, which is a content-*validation* concept.
186    Custom {
187        /// Blueprint-dir-relative path to the Rhai script backing this region
188        script: String,
189        /// Lifecycle: `true` = Pinned-like (protected, fixed budget),
190        /// `false` = Temporary-like (stage-specific, evictable)
191        persistent: bool,
192    },
193}
194
195impl PartialEq for RegionKind {
196    #[inline(never)]
197    fn eq(&self, other: &Self) -> bool {
198        match (self, other) {
199            (Self::Pinned, Self::Pinned)
200            | (Self::Temporary, Self::Temporary)
201            | (Self::Clearable, Self::Clearable) => true,
202            (
203                Self::SlidingWindow {
204                    max_items: a,
205                    eviction_strategy: sa,
206                },
207                Self::SlidingWindow {
208                    max_items: b,
209                    eviction_strategy: sb,
210                },
211            ) => a == b && sa == sb,
212            (
213                Self::Compacting {
214                    threshold_tokens: a,
215                },
216                Self::Compacting {
217                    threshold_tokens: b,
218                },
219            ) => a == b,
220            (
221                Self::CompactHistory { source_region: a },
222                Self::CompactHistory { source_region: b },
223            ) => a == b,
224            (Self::HashMap { max_entries: a }, Self::HashMap { max_entries: b }) => a == b,
225            (Self::Checklist, Self::Checklist) => true,
226            (
227                Self::Custom {
228                    script: a,
229                    persistent: pa,
230                },
231                Self::Custom {
232                    script: b,
233                    persistent: pb,
234                },
235            ) => a == b && pa == pb,
236            _ => false,
237        }
238    }
239}
240impl Eq for RegionKind {}
241
242/// One row of a [`RegionKind::Checklist`] region.
243///
244/// A projection of a [`RegionEntry`], not a second storage: the item's text is
245/// the entry's content and its state is the entry's metadata, so a checklist
246/// persists, carries across a stage swap and restores from a snapshot with no
247/// extra plumbing.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct ChecklistItem {
250    /// Stable identifier the `todo_*` tools address, assigned on add.
251    pub id: usize,
252    /// What the item says.
253    pub text: String,
254    /// Whether it has been ticked off.
255    pub done: bool,
256    /// Anything the agent recorded against it.
257    pub note: Option<String>,
258}
259
260/// The metadata key holding a checklist item's id.
261const ITEM_ID: &str = "checklist_id";
262/// The metadata key holding whether a checklist item is done.
263const ITEM_DONE: &str = "checklist_done";
264/// The metadata key holding a checklist item's note.
265const ITEM_NOTE: &str = "checklist_note";
266
267impl RegionEntry {
268    /// Read this entry as a checklist item, when it is one.
269    pub fn as_checklist_item(&self) -> Option<ChecklistItem> {
270        let meta = self.metadata.as_ref()?;
271        Some(ChecklistItem {
272            id: meta.get(ITEM_ID)?.as_u64()? as usize,
273            text: self.content.clone(),
274            done: meta
275                .get(ITEM_DONE)
276                .and_then(|v| v.as_bool())
277                .unwrap_or(false),
278            note: meta
279                .get(ITEM_NOTE)
280                .and_then(|v| v.as_str())
281                .map(str::to_string),
282        })
283    }
284}
285
286impl Region {
287    /// Every checklist item this region holds, in the order they were added.
288    pub fn checklist_items(&self) -> Vec<ChecklistItem> {
289        self.content
290            .iter()
291            .filter_map(RegionEntry::as_checklist_item)
292            .collect()
293    }
294
295    /// Items still open. The number a gate asks about.
296    pub fn open_checklist_items(&self) -> Vec<ChecklistItem> {
297        self.checklist_items()
298            .into_iter()
299            .filter(|i| !i.done)
300            .collect()
301    }
302
303    /// Append an item and return its id.
304    ///
305    /// Ids come from a counter over what is already there rather than the
306    /// entry count, so an id stays valid for the life of the region even if an
307    /// entry is dropped under budget pressure - a `todo_done(3)` that silently
308    /// ticked off a different item would be worse than one that failed.
309    pub fn add_checklist_item(
310        &mut self,
311        text: String,
312        tokens: usize,
313    ) -> crate::error::Result<usize> {
314        let id = self
315            .checklist_items()
316            .iter()
317            .map(|i| i.id)
318            .max()
319            .unwrap_or(0)
320            + 1;
321        self.add_entry_with_metadata(
322            text,
323            tokens,
324            serde_json::json!({ ITEM_ID: id, ITEM_DONE: false }),
325        )?;
326        Ok(id)
327    }
328
329    /// Tick an item off. `false` when no item carries that id.
330    pub fn complete_checklist_item(&mut self, id: usize) -> bool {
331        self.set_item_field(id, ITEM_DONE, serde_json::Value::Bool(true))
332    }
333
334    /// Record a note against an item. `false` when no item carries that id.
335    pub fn note_checklist_item(&mut self, id: usize, note: &str) -> bool {
336        self.set_item_field(id, ITEM_NOTE, serde_json::Value::String(note.to_string()))
337    }
338
339    /// Write one metadata field of the item carrying `id`.
340    fn set_item_field(&mut self, id: usize, key: &str, value: serde_json::Value) -> bool {
341        for entry in &mut self.content {
342            let is_target = entry
343                .metadata
344                .as_ref()
345                .and_then(|m| m.get(ITEM_ID))
346                .and_then(serde_json::Value::as_u64)
347                .is_some_and(|found| found as usize == id);
348            if is_target && let Some(serde_json::Value::Object(meta)) = entry.metadata.as_mut() {
349                meta.insert(key.to_string(), value);
350                return true;
351            }
352        }
353        false
354    }
355
356    /// The checklist as the model sees it: open items first, then done.
357    ///
358    /// Ordering is the point. This region's value is that it stays in front of
359    /// the model every turn as *instruction* rather than history, and what is
360    /// left to do belongs at the top of an instruction.
361    pub fn render_checklist(&self) -> String {
362        let items = self.checklist_items();
363        if items.is_empty() {
364            return String::new();
365        }
366        let (open, done): (Vec<_>, Vec<_>) = items.into_iter().partition(|i| !i.done);
367        let mut out = String::new();
368        for item in open.iter().chain(done.iter()) {
369            let box_ = match item.done {
370                true => "[x]",
371                false => "[ ]",
372            };
373            out.push_str(&format!("{box_} {} {}", item.id, item.text));
374            if let Some(note) = &item.note {
375                out.push_str(&format!("\n    note: {note}"));
376            }
377            out.push('\n');
378        }
379        format!(
380            "Checklist ({} open, {} done):\n{}",
381            open.len(),
382            done.len(),
383            out.trim_end()
384        )
385    }
386}
387
388impl RegionKind {
389    /// Return the cache hint appropriate for this region kind.
390    pub fn cache_hint(&self) -> crate::cache::CacheHint {
391        match self {
392            RegionKind::Pinned | RegionKind::CompactHistory { .. } => {
393                crate::cache::CacheHint::Always
394            }
395            RegionKind::Compacting { .. } => crate::cache::CacheHint::UntilChanged,
396            RegionKind::SlidingWindow { .. } => crate::cache::CacheHint::SlidingPrefix {
397                stable_fraction: 0.75,
398            },
399            RegionKind::HashMap { .. } => crate::cache::CacheHint::UntilChanged,
400            // Changes only when an item is added or ticked off, which is rarer
401            // than a tool result and far rarer than a turn.
402            RegionKind::Checklist => crate::cache::CacheHint::UntilChanged,
403            RegionKind::Temporary | RegionKind::Clearable => crate::cache::CacheHint::Never,
404            // A persistent custom region is Pinned-like: its rendered output is
405            // expected to be stable. Non-persistent custom content changes on
406            // writes, like Compacting/HashMap.
407            RegionKind::Custom { persistent, .. } => {
408                if *persistent {
409                    crate::cache::CacheHint::Always
410                } else {
411                    crate::cache::CacheHint::UntilChanged
412                }
413            }
414        }
415    }
416}
417
418/// A single region in the context window with its content and metadata.
419///
420/// Each region tracks its own token budget, current usage, and optional
421/// validation schema to enforce content format requirements.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct Region {
424    /// Unique name identifying this region
425    pub name: String,
426
427    /// Lifecycle policy for this region
428    pub kind: RegionKind,
429
430    /// Content entries stored in this region
431    pub content: Vec<RegionEntry>,
432
433    /// Maximum tokens allowed in this region
434    pub max_tokens: usize,
435
436    /// Current token count
437    pub current_tokens: usize,
438
439    /// Optional validation schema enforcing content format
440    pub schema: Option<RegionSchema>,
441
442    /// Taint tracking state. Present when taint tracking is enabled.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub taint: Option<crate::taint::RegionTaint>,
445
446    /// When true, the Compact eviction strategy has determined that oldest
447    /// entries should be summarized. The runtime checks this flag and
448    /// performs the compaction externally (requires an LLM call).
449    #[serde(default)]
450    pub needs_message_compaction: bool,
451
452    /// Whether an edge transform may hand this region to the summarizer.
453    ///
454    /// Carried from the region's declaration so the transform can consult it
455    /// without the layout: `transform = "compact"` summarizes by region *kind*,
456    /// and kind cannot tell a transcript from a table of results (#369).
457    #[serde(default = "crate::region::default_true")]
458    pub summarizable: bool,
459
460    /// What this region does when a write does not fit. See [`Admission`].
461    #[serde(default)]
462    pub admission: Admission,
463}
464
465/// What a region does when a write does not fit.
466///
467/// The default is what every region did before this existed: make room. That
468/// is the right behaviour for a transcript, where the oldest turn is the least
469/// useful thing present and losing it costs nothing anyone will notice.
470///
471/// It is the wrong behaviour for a region holding material the agent chose to
472/// keep. There, silently dropping the oldest entry is a decision about what
473/// matters, taken by whichever write happened to arrive when the region was
474/// full - and the agent never learns it happened. [`Admission::Reject`] hands
475/// that decision back: the write fails, the agent is told the region is full,
476/// and it releases what it is finished with before adding more.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum Admission {
480    /// Make room for the write - roll off the oldest entry, or let the
481    /// window-level cascade reclaim the region.
482    #[default]
483    Evict,
484    /// Refuse the write and say so. Nothing already in the region is lost to a
485    /// write the agent did not know would displace it.
486    Reject,
487}
488
489mod schema;
490
491pub use schema::{ContentFormat, RegionSchema, Validator};
492
493/// Serde default for a flag that is on unless a blueprint turns it off.
494pub(crate) fn default_true() -> bool {
495    true
496}
497
498impl Region {
499    /// Create a new region with the specified configuration.
500    pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
501        Self {
502            name,
503            kind,
504            content: Vec::new(),
505            max_tokens,
506            current_tokens: 0,
507            schema: None,
508            taint: None,
509            needs_message_compaction: false,
510            summarizable: true,
511            admission: Admission::default(),
512        }
513    }
514
515    /// Enable taint tracking for this region.
516    pub fn with_taint_tracking(mut self) -> Self {
517        self.taint = Some(crate::taint::RegionTaint::new());
518        self
519    }
520
521    /// Enable taint tracking on this region (mutable).
522    pub fn enable_taint_tracking(&mut self) {
523        if self.taint.is_none() {
524            self.taint = Some(crate::taint::RegionTaint::new());
525        }
526    }
527
528    /// Get the current taint level of this region, if taint tracking is enabled.
529    pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
530        self.taint.as_ref().map(|t| t.level())
531    }
532
533    /// Accept one entry: validate it, charge it against the budget, record it,
534    /// and let the sliding window evict if it now needs to.
535    ///
536    /// The single implementation behind the five `add_*_entry` methods, which
537    /// differ only in what they supply for `metadata`, `kind` and
538    /// `taint_level`. They were five copies of this body, which is five places
539    /// for the budget check or the taint update to drift out of step - and the
540    /// order matters: content is validated before it is charged for, and the
541    /// window is enforced only after the entry is in.
542    ///
543    /// Private, so the public surface is unchanged and every caller keeps the
544    /// named method that says which of the three it cares about.
545    fn push_entry(
546        &mut self,
547        content: String,
548        tokens: usize,
549        metadata: Option<serde_json::Value>,
550        kind: EntryKind,
551        taint_level: crate::taint::TaintLevel,
552        key: Option<&str>,
553    ) -> crate::error::Result<()> {
554        if let Some(schema) = &self.schema {
555            schema.validate(&content)?;
556        }
557
558        if self.current_tokens + tokens > self.max_tokens {
559            // Which failure this is depends on whether anything would have been
560            // dropped to fit. A region that never evicts reports being full,
561            // because "release something" is advice the agent can act on;
562            // reporting the budget would invite it to retry a smaller write
563            // into a region that is not going to take one.
564            if self.admission == Admission::Reject && !self.content.is_empty() {
565                return Err(crate::error::Error::RegionFull {
566                    region: self.name.clone(),
567                    used: self.current_tokens,
568                    max: self.max_tokens,
569                });
570            }
571            return Err(crate::error::Error::TokenBudgetExceeded {
572                used: self.current_tokens + tokens,
573                max: self.max_tokens,
574            });
575        }
576        // Checked before the push, not after: `enforce_sliding_window` runs on
577        // the way out and would already have dropped the oldest entry by the
578        // time anything could refuse.
579        if self.admission == Admission::Reject && self.would_roll_off() {
580            return Err(crate::error::Error::RegionFull {
581                region: self.name.clone(),
582                used: self.current_tokens,
583                max: self.max_tokens,
584            });
585        }
586
587        self.content.push(RegionEntry {
588            content,
589            tokens,
590            timestamp: chrono::Utc::now().timestamp(),
591            metadata,
592            kind,
593            key: key.map(str::to_string),
594        });
595        self.current_tokens += tokens;
596
597        // A region with taint tracking off ignores the level entirely, which is
598        // why the untainted callers can pass `Public` rather than needing a
599        // separate path.
600        if let Some(taint) = &mut self.taint {
601            taint.add_entry(taint_level);
602        }
603
604        self.enforce_sliding_window();
605
606        Ok(())
607    }
608
609    /// Whether one more entry would push a sliding window past its item cap.
610    ///
611    /// Only sliding windows roll off by count; every other kind is bounded by
612    /// tokens alone and is answered by the budget check above.
613    fn would_roll_off(&self) -> bool {
614        match &self.kind {
615            RegionKind::SlidingWindow { max_items, .. } => self.content.len() + 1 > *max_items,
616            _ => false,
617        }
618    }
619
620    /// Add an entry under `key`, so the agent can name it again to release it.
621    ///
622    /// Distinct from [`upsert_by_key`](Self::upsert_by_key), which replaces:
623    /// appending two sources under one key should keep both halves, the way an
624    /// unkeyed append keeps everything appended before it.
625    pub fn add_keyed_entry(
626        &mut self,
627        key: &str,
628        content: String,
629        tokens: usize,
630    ) -> crate::error::Result<()> {
631        self.push_entry(
632            content,
633            tokens,
634            None,
635            EntryKind::default(),
636            crate::taint::TaintLevel::Public,
637            Some(key),
638        )
639    }
640
641    /// Remove the entry at `index`, counting from the oldest. Returns whether
642    /// there was one.
643    ///
644    /// The companion to keys, for entries that never had one: an agent that has
645    /// just listed a region can name a position in what it read back.
646    pub fn remove_at(&mut self, index: usize) -> bool {
647        if index >= self.content.len() {
648            return false;
649        }
650        let entry = self.content.remove(index);
651        self.current_tokens = self.current_tokens.saturating_sub(entry.tokens);
652        true
653    }
654
655    /// Drop the `n` oldest entries, returning how many were actually removed.
656    ///
657    /// Fewer than `n` when the region holds fewer, which is not an error: the
658    /// agent asked for room and got as much as there was.
659    pub fn release_oldest(&mut self, n: usize) -> usize {
660        let count = n.min(self.content.len());
661        for _ in 0..count {
662            self.remove_oldest();
663        }
664        count
665    }
666
667    /// Add an entry with a taint level. Used when taint tracking is enabled.
668    pub fn add_tainted_entry(
669        &mut self,
670        content: String,
671        tokens: usize,
672        taint_level: crate::taint::TaintLevel,
673    ) -> crate::error::Result<()> {
674        self.push_entry(
675            content,
676            tokens,
677            None,
678            EntryKind::default(),
679            taint_level,
680            None,
681        )
682    }
683
684    /// Add a typed entry with a taint level.
685    ///
686    /// Combines [`add_typed_entry`](Self::add_typed_entry) (the entry carries a
687    /// typed [`EntryKind`] so eviction can group turns) with
688    /// [`add_tainted_entry`](Self::add_tainted_entry) (the entry contributes a
689    /// specific taint level rather than defaulting to `Public`). Used for tool
690    /// results when taint tracking is enabled, so a sensitive tool's output
691    /// both keeps its `ToolResult` kind and raises the region's taint level.
692    pub fn add_typed_tainted_entry(
693        &mut self,
694        content: String,
695        tokens: usize,
696        kind: EntryKind,
697        taint_level: crate::taint::TaintLevel,
698    ) -> crate::error::Result<()> {
699        self.push_entry(content, tokens, None, kind, taint_level, None)
700    }
701
702    /// Add a validation schema to this region.
703    pub fn with_schema(mut self, schema: RegionSchema) -> Self {
704        self.schema = Some(schema);
705        self
706    }
707
708    /// Add an entry to this region.
709    ///
710    /// Validates content against schema if present, checks token budget,
711    /// and adds the entry to the region.
712    pub fn add_entry(&mut self, content: String, tokens: usize) -> crate::error::Result<()> {
713        self.push_entry(
714            content,
715            tokens,
716            None,
717            EntryKind::default(),
718            crate::taint::TaintLevel::Public,
719            None,
720        )
721    }
722
723    /// Add an entry with metadata.
724    pub fn add_entry_with_metadata(
725        &mut self,
726        content: String,
727        tokens: usize,
728        metadata: serde_json::Value,
729    ) -> crate::error::Result<()> {
730        self.push_entry(
731            content,
732            tokens,
733            Some(metadata),
734            EntryKind::default(),
735            crate::taint::TaintLevel::Public,
736            None,
737        )
738    }
739
740    /// Add an entry with a specific [`EntryKind`] to this region.
741    ///
742    /// Like [`add_entry`](Self::add_entry), but the caller supplies the entry
743    /// kind so the entry carries typed metadata rather than relying on
744    /// text-prefix parsing.
745    pub fn add_typed_entry(
746        &mut self,
747        content: String,
748        tokens: usize,
749        kind: EntryKind,
750    ) -> crate::error::Result<()> {
751        self.push_entry(
752            content,
753            tokens,
754            None,
755            kind,
756            crate::taint::TaintLevel::Public,
757            None,
758        )
759    }
760
761    /// Carry an already-accepted entry into this region verbatim, preserving
762    /// its [`EntryKind`], metadata, key, and timestamp.
763    ///
764    /// Used when a stage-layout swap rebuilds a region and moves its surviving
765    /// content across: re-adding through [`add_entry`](Self::add_entry) would
766    /// stamp every carried entry [`EntryKind::Text`], destroying the typed
767    /// `tool_use`/`tool_result` pairing the assembler needs (the orphan
768    /// sanitizer would then strip the whole history). Skips schema validation
769    /// deliberately - the entry passed it when first accepted - but keeps the
770    /// budget check and sliding-window enforcement so the destination region's
771    /// limits still hold. Taint is not touched per entry: a carry copies the
772    /// region-level [`crate::taint::RegionTaint`] wholesale instead of
773    /// re-accumulating it.
774    pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
775        // Check token budget
776        if self.current_tokens + entry.tokens > self.max_tokens {
777            return Err(crate::error::Error::TokenBudgetExceeded {
778                used: self.current_tokens + entry.tokens,
779                max: self.max_tokens,
780            });
781        }
782
783        self.current_tokens += entry.tokens;
784        self.content.push(entry);
785
786        // Enforce SlidingWindow max_items limit
787        self.enforce_sliding_window();
788
789        Ok(())
790    }
791
792    /// Upsert an entry by key. If key exists, replace content and update timestamp/tokens.
793    /// If key doesn't exist, add new entry. Enforces max_tokens and max_entries via LRU eviction.
794    pub fn upsert_by_key(
795        &mut self,
796        key: &str,
797        content: String,
798        tokens: usize,
799    ) -> Result<(), String> {
800        // If key exists, update in place
801        if let Some(pos) = self
802            .content
803            .iter()
804            .position(|e| e.key.as_deref() == Some(key))
805        {
806            let old_tokens = self.content[pos].tokens;
807            self.current_tokens -= old_tokens;
808            self.content[pos].content = content;
809            self.content[pos].tokens = tokens;
810            self.content[pos].timestamp = chrono::Utc::now().timestamp();
811            self.current_tokens += tokens;
812            return Ok(());
813        }
814
815        // Enforce max_entries via LRU eviction
816        let max_entries = if let RegionKind::HashMap {
817            max_entries: Some(max),
818        } = &self.kind
819        {
820            Some(*max)
821        } else {
822            None
823        };
824        if let Some(max) = max_entries {
825            while self.content.len() >= max {
826                self.evict_lru_entry();
827            }
828        }
829
830        // Enforce max_tokens via LRU eviction
831        while self.current_tokens + tokens > self.max_tokens && !self.content.is_empty() {
832            self.evict_lru_entry();
833        }
834
835        if self.current_tokens + tokens > self.max_tokens {
836            return Err(format!(
837                "Entry ({} tokens) exceeds region budget ({} max)",
838                tokens, self.max_tokens
839            ));
840        }
841
842        self.content.push(RegionEntry {
843            content,
844            tokens,
845            timestamp: chrono::Utc::now().timestamp(),
846            metadata: None,
847            kind: EntryKind::default(),
848            key: Some(key.to_string()),
849        });
850        self.current_tokens += tokens;
851        Ok(())
852    }
853
854    /// Get entry by key.
855    pub fn get_by_key(&self, key: &str) -> Option<&RegionEntry> {
856        self.content.iter().find(|e| e.key.as_deref() == Some(key))
857    }
858
859    /// Remove entry by key.
860    pub fn remove_by_key(&mut self, key: &str) -> bool {
861        if let Some(pos) = self
862            .content
863            .iter()
864            .position(|e| e.key.as_deref() == Some(key))
865        {
866            let tokens = self.content[pos].tokens;
867            self.content.remove(pos);
868            self.current_tokens -= tokens;
869            if let Some(taint) = &mut self.taint {
870                taint.remove_at(pos);
871            }
872            true
873        } else {
874            false
875        }
876    }
877
878    /// List all keys in this region.
879    pub fn keys(&self) -> Vec<&str> {
880        self.content
881            .iter()
882            .filter_map(|e| e.key.as_deref())
883            .collect()
884    }
885
886    /// Evict the least-recently-updated entry (LRU) for HashMap regions.
887    fn evict_lru_entry(&mut self) {
888        if self.content.is_empty() {
889            return;
890        }
891        let oldest_idx = self
892            .content
893            .iter()
894            .enumerate()
895            .min_by_key(|(_, e)| e.timestamp)
896            .map(|(i, _)| i)
897            .unwrap_or(0);
898        let tokens = self.content[oldest_idx].tokens;
899        self.content.remove(oldest_idx);
900        self.current_tokens -= tokens;
901        if let Some(taint) = &mut self.taint {
902            taint.remove_at(oldest_idx);
903        }
904    }
905
906    /// Enforce the SlidingWindow max_items limit by removing oldest entries.
907    ///
908    /// Behaviour depends on the configured [`EvictionStrategy`]:
909    /// - **PerItem** – evict one turn group at a time (original behaviour).
910    /// - **Bulk** – only evict when `len > max_items + overflow`, then evict
911    ///   down to `max_items`. Between bulk evictions the prefix is stable,
912    ///   which preserves Anthropic prompt-cache keys.
913    /// - **Compact** – set `needs_message_compaction` when `len > max_items + compact_count`.
914    ///   If the runtime hasn't compacted and `len > max_items + compact_count * 2`,
915    ///   fall back to bulk eviction to prevent unbounded growth.
916    fn enforce_sliding_window(&mut self) {
917        if let RegionKind::SlidingWindow {
918            max_items,
919            eviction_strategy,
920        } = &self.kind
921        {
922            let max = *max_items;
923            match eviction_strategy.clone() {
924                EvictionStrategy::PerItem => {
925                    // `remove_oldest` only returns None when empty, which the
926                    // `len > max >= 0` guard already precludes; folding it into
927                    // the condition keeps the guard without a dead break arm.
928                    while self.content.len() > max && self.remove_oldest().is_some() {}
929                }
930                EvictionStrategy::Bulk { overflow } => {
931                    if self.content.len() > max + overflow {
932                        while self.content.len() > max && self.remove_oldest().is_some() {}
933                    }
934                }
935                EvictionStrategy::Compact { compact_count } => {
936                    if self.content.len() > max + compact_count * 2 {
937                        // Fallback: runtime hasn't compacted, bulk-evict to prevent
938                        // unbounded growth.
939                        while self.content.len() > max && self.remove_oldest().is_some() {}
940                        self.needs_message_compaction = false;
941                    } else if self.content.len() > max + compact_count {
942                        self.needs_message_compaction = true;
943                    }
944                }
945            }
946        }
947    }
948
949    /// Returns the number of entries in the turn group starting at `idx`.
950    ///
951    /// A turn group is:
952    /// - A single Text or UserMessage entry (group size = 1)
953    /// - An AssistantTurn followed by consecutive ToolResult entries
954    ///   (group size = 1 + number of following ToolResults)
955    /// - A lone ToolResult (shouldn't happen, but size = 1 for safety)
956    fn turn_group_size_at(&self, idx: usize) -> usize {
957        if idx >= self.content.len() {
958            return 0;
959        }
960        match &self.content[idx].kind {
961            EntryKind::AssistantTurn { .. } => {
962                let mut size = 1;
963                while idx + size < self.content.len() {
964                    if matches!(self.content[idx + size].kind, EntryKind::ToolResult { .. }) {
965                        size += 1;
966                    } else {
967                        break;
968                    }
969                }
970                size
971            }
972            _ => 1,
973        }
974    }
975
976    /// Clear all content from this region.
977    pub fn clear(&mut self) {
978        self.content.clear();
979        self.current_tokens = 0;
980        if let Some(taint) = &mut self.taint {
981            taint.clear();
982        }
983    }
984
985    /// Remove the oldest entry (for Temporary regions).
986    pub fn remove_oldest(&mut self) -> Option<RegionEntry> {
987        if self.content.is_empty() {
988            return None;
989        }
990        // Respect turn groups: an AssistantTurn with tool_calls must be
991        // evicted together with its following ToolResult entries to avoid
992        // orphaned tool_use/tool_result blocks that providers reject.
993        let group_size = self.turn_group_size_at(0);
994        let mut first = None;
995        let mut extra_tokens = 0usize;
996        // `group_size <= content.len()`, so the window never empties mid-group;
997        // the `!is_empty()` guard lives in the loop condition (no dead break arm).
998        let mut i = 0;
999        while i < group_size && !self.content.is_empty() {
1000            let entry_tokens = self.content[0].tokens;
1001            self.current_tokens -= entry_tokens;
1002            let removed = self.content.remove(0);
1003            if let Some(taint) = &mut self.taint {
1004                taint.remove_oldest();
1005            }
1006            if i == 0 {
1007                first = Some(removed);
1008            } else {
1009                extra_tokens += entry_tokens;
1010            }
1011            i += 1;
1012        }
1013        // Embed extra group tokens in the returned entry so callers that use
1014        // `entry.tokens` to adjust their own totals account for the full group.
1015        // `first` is `Some` whenever we removed anything (guaranteed by the
1016        // non-empty early return), so `map` always runs; `extra_tokens` is 0
1017        // for a single-entry group, making the add a no-op there.
1018        first.map(|mut entry| {
1019            entry.tokens += extra_tokens;
1020            entry
1021        })
1022    }
1023
1024    /// Remove all entries whose content starts with the given prefix.
1025    ///
1026    /// Used to clear tagged entries (e.g. stage instructions) before injecting
1027    /// replacements, so stale instructions don't accumulate across stage
1028    /// transitions.
1029    pub fn remove_entries_by_prefix(&mut self, prefix: &str) {
1030        let mut i = 0;
1031        while i < self.content.len() {
1032            if self.content[i].content.starts_with(prefix) {
1033                let tokens = self.content[i].tokens;
1034                self.content.remove(i);
1035                self.current_tokens -= tokens;
1036                if let Some(taint) = &mut self.taint {
1037                    taint.remove_at(i);
1038                }
1039            } else {
1040                i += 1;
1041            }
1042        }
1043    }
1044
1045    /// Get the number of entries in this region.
1046    pub fn entry_count(&self) -> usize {
1047        self.content.len()
1048    }
1049
1050    /// Check if region needs compaction (for Compacting regions).
1051    pub fn needs_compaction(&self) -> bool {
1052        if let RegionKind::Compacting { threshold_tokens } = self.kind {
1053            self.current_tokens > threshold_tokens
1054        } else {
1055            false
1056        }
1057    }
1058}
1059
1060/// A single entry within a region.
1061///
1062/// Each entry has content and metadata tracking its token usage.
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct RegionEntry {
1065    /// The actual content of this entry
1066    pub content: String,
1067
1068    /// Token count for this entry
1069    pub tokens: usize,
1070
1071    /// Timestamp when this entry was added
1072    pub timestamp: i64,
1073
1074    /// Optional metadata about this entry
1075    pub metadata: Option<serde_json::Value>,
1076
1077    /// The kind of content stored in this entry.
1078    /// Defaults to `EntryKind::Text` for backward compatibility with
1079    /// serialized data that predates the typed-entry system.
1080    #[serde(default)]
1081    pub kind: EntryKind,
1082
1083    /// Optional key for HashMap regions. When set, upsert semantics apply.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub key: Option<String>,
1086}
1087
1088/// Validation schema for a region's content.
1089///
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093
1094    // ─── Checklist items ────────────────────────────────────────────────────
1095
1096    fn checklist() -> Region {
1097        Region::new("todos".to_string(), RegionKind::Checklist, 10_000)
1098    }
1099
1100    /// Anything in the region that is not a well-formed item is not an item.
1101    ///
1102    /// A checklist region can still receive an ordinary write - a seed, a
1103    /// carried entry from an older run, a `context_append` - and counting one
1104    /// of those as an open item would hold a stage on work nobody recorded.
1105    #[test]
1106    fn a_malformed_entry_is_not_an_item() {
1107        let mut r = checklist();
1108        // No metadata at all.
1109        r.add_entry("a plain note".to_string(), 3).unwrap();
1110        // Metadata, but not an item's.
1111        r.add_entry_with_metadata(
1112            "something else".to_string(),
1113            3,
1114            serde_json::json!({ "unrelated": true }),
1115        )
1116        .unwrap();
1117        // An id of the wrong type.
1118        r.add_entry_with_metadata(
1119            "bad id".to_string(),
1120            3,
1121            serde_json::json!({ "checklist_id": "one" }),
1122        )
1123        .unwrap();
1124
1125        assert!(r.checklist_items().is_empty(), "none of those are items");
1126        assert!(r.open_checklist_items().is_empty());
1127        assert!(
1128            r.render_checklist().is_empty(),
1129            "and they do not render as a checklist"
1130        );
1131    }
1132
1133    /// A checklist is cached like a hashmap, not like a turn: it changes only
1134    /// when an item is added or ticked off.
1135    #[test]
1136    fn a_checklist_caches_until_it_changes() {
1137        assert_eq!(
1138            RegionKind::Checklist.cache_hint(),
1139            crate::cache::CacheHint::UntilChanged
1140        );
1141    }
1142
1143    #[test]
1144    fn a_note_appears_in_the_render() {
1145        let mut r = checklist();
1146        let id = r.add_checklist_item("blocked".to_string(), 2).unwrap();
1147        r.note_checklist_item(id, "waiting on the manual");
1148        let rendered = r.render_checklist();
1149        assert!(
1150            rendered.contains("note: waiting on the manual"),
1151            "{rendered}"
1152        );
1153    }
1154
1155    /// An item that will not fit is refused rather than silently dropped: a
1156    /// checklist that loses items is worse than no checklist.
1157    #[test]
1158    fn an_item_over_budget_is_refused() {
1159        let mut r = Region::new("todos".to_string(), RegionKind::Checklist, 4);
1160        assert!(r.add_checklist_item("x".to_string(), 99).is_err());
1161        assert!(r.checklist_items().is_empty());
1162    }
1163
1164    #[test]
1165    fn an_added_item_starts_open_and_gets_an_id() {
1166        let mut r = checklist();
1167        let first = r
1168            .add_checklist_item("compute the fee table".to_string(), 5)
1169            .unwrap();
1170        let second = r
1171            .add_checklist_item("check the manual".to_string(), 5)
1172            .unwrap();
1173        assert_eq!((first, second), (1, 2), "ids are stable and sequential");
1174        assert_eq!(r.open_checklist_items().len(), 2);
1175    }
1176
1177    #[test]
1178    fn completing_an_item_closes_it_and_nothing_else() {
1179        let mut r = checklist();
1180        let id = r.add_checklist_item("one".to_string(), 2).unwrap();
1181        r.add_checklist_item("two".to_string(), 2).unwrap();
1182
1183        assert!(r.complete_checklist_item(id));
1184        let open = r.open_checklist_items();
1185        assert_eq!(open.len(), 1);
1186        assert_eq!(open[0].text, "two");
1187        assert_eq!(
1188            r.checklist_items().len(),
1189            2,
1190            "done items are kept, not deleted"
1191        );
1192    }
1193
1194    #[test]
1195    fn an_unknown_id_reports_failure_rather_than_ticking_something_else() {
1196        // A `todo_done(3)` that silently closed a different item would be worse
1197        // than one that fails: the model would believe work was finished.
1198        let mut r = checklist();
1199        r.add_checklist_item("one".to_string(), 2).unwrap();
1200        assert!(!r.complete_checklist_item(99));
1201        assert!(!r.note_checklist_item(99, "x"));
1202        assert_eq!(r.open_checklist_items().len(), 1);
1203    }
1204
1205    #[test]
1206    fn a_note_records_without_closing() {
1207        let mut r = checklist();
1208        let id = r
1209            .add_checklist_item("blocked thing".to_string(), 2)
1210            .unwrap();
1211        assert!(r.note_checklist_item(id, "waiting on the manual"));
1212        let item = &r.checklist_items()[0];
1213        assert!(!item.done, "a note is not a completion");
1214        assert_eq!(item.note.as_deref(), Some("waiting on the manual"));
1215    }
1216
1217    /// Ordering is the point: this region is instruction, not history, so what
1218    /// is left to do belongs at the top of what the model reads every turn.
1219    #[test]
1220    fn the_render_puts_open_items_first() {
1221        let mut r = checklist();
1222        let done = r
1223            .add_checklist_item("already finished".to_string(), 2)
1224            .unwrap();
1225        r.add_checklist_item("still to do".to_string(), 2).unwrap();
1226        r.complete_checklist_item(done);
1227
1228        let rendered = r.render_checklist();
1229        let open_at = rendered.find("still to do").expect("open item rendered");
1230        let done_at = rendered
1231            .find("already finished")
1232            .expect("done item rendered");
1233        assert!(open_at < done_at, "open before done:\n{rendered}");
1234        assert!(rendered.contains("1 open, 1 done"), "{rendered}");
1235        assert!(
1236            rendered.contains("[x]") && rendered.contains("[ ]"),
1237            "{rendered}"
1238        );
1239    }
1240
1241    #[test]
1242    fn an_empty_checklist_renders_nothing() {
1243        // Rather than an empty heading taking up the window every turn.
1244        assert!(checklist().render_checklist().is_empty());
1245    }
1246
1247    /// Ids survive an entry being dropped, so a later `todo_done` cannot land on
1248    /// the wrong item.
1249    #[test]
1250    fn ids_do_not_get_reused_after_a_drop() {
1251        let mut r = checklist();
1252        r.add_checklist_item("one".to_string(), 2).unwrap();
1253        let second = r.add_checklist_item("two".to_string(), 2).unwrap();
1254        r.content.remove(0);
1255        let third = r.add_checklist_item("three".to_string(), 2).unwrap();
1256        assert!(third > second, "a reused id would tick off the wrong item");
1257    }
1258
1259    #[test]
1260    fn test_region_creation() {
1261        let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1262        assert_eq!(region.name, "test");
1263        assert_eq!(region.max_tokens, 1000);
1264        assert_eq!(region.current_tokens, 0);
1265    }
1266
1267    #[test]
1268    fn test_sliding_window_config() {
1269        let kind = RegionKind::SlidingWindow {
1270            max_items: 10,
1271            eviction_strategy: EvictionStrategy::PerItem,
1272        };
1273        let region = Region::new("history".to_string(), kind.clone(), 5000);
1274        assert_eq!(region.kind, kind);
1275    }
1276
1277    #[test]
1278    fn test_region_kind_equality() {
1279        assert_eq!(RegionKind::Clearable, RegionKind::Clearable);
1280        assert_eq!(
1281            RegionKind::Compacting {
1282                threshold_tokens: 500
1283            },
1284            RegionKind::Compacting {
1285                threshold_tokens: 500
1286            }
1287        );
1288        assert_eq!(
1289            RegionKind::CompactHistory {
1290                source_region: "conv".to_string()
1291            },
1292            RegionKind::CompactHistory {
1293                source_region: "conv".to_string()
1294            }
1295        );
1296        assert_ne!(RegionKind::Pinned, RegionKind::Temporary);
1297    }
1298
1299    #[test]
1300    fn custom_kind_equality_compares_script_and_persistent() {
1301        let a = RegionKind::Custom {
1302            script: "conv.rhai".to_string(),
1303            persistent: false,
1304        };
1305        assert_eq!(a, a.clone());
1306        assert_ne!(
1307            a,
1308            RegionKind::Custom {
1309                script: "other.rhai".to_string(),
1310                persistent: false,
1311            }
1312        );
1313        assert_ne!(
1314            a,
1315            RegionKind::Custom {
1316                script: "conv.rhai".to_string(),
1317                persistent: true,
1318            }
1319        );
1320        assert_ne!(a, RegionKind::Temporary);
1321    }
1322
1323    #[test]
1324    fn custom_kind_serde_round_trips() {
1325        let kind = RegionKind::Custom {
1326            script: "hooks/conv.rhai".to_string(),
1327            persistent: true,
1328        };
1329        let json = serde_json::to_string(&kind).unwrap();
1330        let back: RegionKind = serde_json::from_str(&json).unwrap();
1331        assert_eq!(kind, back);
1332        // Pre-existing serialized kinds still deserialize (additive variant).
1333        let old: RegionKind = serde_json::from_str("\"Pinned\"").unwrap();
1334        assert_eq!(old, RegionKind::Pinned);
1335    }
1336
1337    #[test]
1338    fn custom_kind_cache_hint_follows_persistent() {
1339        assert_eq!(
1340            RegionKind::Custom {
1341                script: "s.rhai".to_string(),
1342                persistent: true,
1343            }
1344            .cache_hint(),
1345            crate::cache::CacheHint::Always
1346        );
1347        assert_eq!(
1348            RegionKind::Custom {
1349                script: "s.rhai".to_string(),
1350                persistent: false,
1351            }
1352            .cache_hint(),
1353            crate::cache::CacheHint::UntilChanged
1354        );
1355    }
1356
1357    #[test]
1358    fn carry_entry_preserves_kind_metadata_key_and_timestamp() {
1359        let mut source = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1360        source
1361            .add_typed_entry(
1362                "result body".to_string(),
1363                10,
1364                EntryKind::ToolResult {
1365                    tool_call_id: "call_1".to_string(),
1366                    tool_name: "read_file".to_string(),
1367                    is_error: false,
1368                },
1369            )
1370            .unwrap();
1371        let mut entry = source.content[0].clone();
1372        entry.metadata = Some(serde_json::json!({"origin": "test"}));
1373        entry.key = Some("k".to_string());
1374        let stamped = entry.timestamp;
1375
1376        let mut dest = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1377        dest.carry_entry(entry).unwrap();
1378
1379        let carried = &dest.content[0];
1380        assert!(matches!(
1381            &carried.kind,
1382            EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "call_1"
1383        ));
1384        assert_eq!(
1385            carried.metadata,
1386            Some(serde_json::json!({"origin": "test"}))
1387        );
1388        assert_eq!(carried.key.as_deref(), Some("k"));
1389        assert_eq!(carried.timestamp, stamped);
1390        assert_eq!(dest.current_tokens, 10);
1391    }
1392
1393    #[test]
1394    fn carry_entry_rejects_over_budget() {
1395        let mut dest = Region::new("small".to_string(), RegionKind::Temporary, 5);
1396        let mut source = Region::new("src".to_string(), RegionKind::Temporary, 100);
1397        source.add_entry("filler".to_string(), 10).unwrap();
1398        let err = dest.carry_entry(source.content[0].clone()).unwrap_err();
1399        assert_eq!(err.to_string(), "Content exceeds token budget: 10 > 5");
1400        assert!(dest.content.is_empty());
1401        assert_eq!(dest.current_tokens, 0);
1402    }
1403
1404    #[test]
1405    fn carry_entry_enforces_sliding_window_max_items() {
1406        let mut source = Region::new("src".to_string(), RegionKind::Temporary, 10_000);
1407        for i in 0..4 {
1408            source.add_entry(format!("msg{i}"), 10).unwrap();
1409        }
1410        let mut dest = Region::new(
1411            "conv".to_string(),
1412            RegionKind::SlidingWindow {
1413                max_items: 3,
1414                eviction_strategy: EvictionStrategy::PerItem,
1415            },
1416            10_000,
1417        );
1418        for entry in &source.content {
1419            dest.carry_entry(entry.clone()).unwrap();
1420        }
1421        assert_eq!(dest.content.len(), 3);
1422        assert_eq!(dest.content[0].content, "msg1");
1423    }
1424
1425    #[test]
1426    fn test_sliding_window_enforces_max_items() {
1427        let mut region = Region::new(
1428            "conv".to_string(),
1429            RegionKind::SlidingWindow {
1430                max_items: 3,
1431                eviction_strategy: EvictionStrategy::PerItem,
1432            },
1433            50000,
1434        );
1435
1436        region.add_entry("msg1".to_string(), 10).unwrap();
1437        region.add_entry("msg2".to_string(), 20).unwrap();
1438        region.add_entry("msg3".to_string(), 30).unwrap();
1439        assert_eq!(region.entry_count(), 3);
1440        assert_eq!(region.current_tokens, 60);
1441
1442        // Adding a 4th entry should evict the oldest
1443        region.add_entry("msg4".to_string(), 40).unwrap();
1444        assert_eq!(region.entry_count(), 3);
1445        assert_eq!(region.content[0].content, "msg2");
1446        assert_eq!(region.content[2].content, "msg4");
1447        assert_eq!(region.current_tokens, 90); // 20 + 30 + 40
1448
1449        // Adding a 5th entry should evict again
1450        region.add_entry("msg5".to_string(), 50).unwrap();
1451        assert_eq!(region.entry_count(), 3);
1452        assert_eq!(region.content[0].content, "msg3");
1453        assert_eq!(region.current_tokens, 120); // 30 + 40 + 50
1454    }
1455
1456    #[test]
1457    fn test_sliding_window_enforces_max_items_with_metadata() {
1458        let mut region = Region::new(
1459            "conv".to_string(),
1460            RegionKind::SlidingWindow {
1461                max_items: 2,
1462                eviction_strategy: EvictionStrategy::PerItem,
1463            },
1464            50000,
1465        );
1466
1467        region
1468            .add_entry_with_metadata("a".to_string(), 10, serde_json::json!({"idx": 1}))
1469            .unwrap();
1470        region
1471            .add_entry_with_metadata("b".to_string(), 20, serde_json::json!({"idx": 2}))
1472            .unwrap();
1473        region
1474            .add_entry_with_metadata("c".to_string(), 30, serde_json::json!({"idx": 3}))
1475            .unwrap();
1476
1477        assert_eq!(region.entry_count(), 2);
1478        assert_eq!(region.content[0].content, "b");
1479        assert_eq!(region.content[1].content, "c");
1480        assert_eq!(region.current_tokens, 50);
1481    }
1482
1483    #[test]
1484    fn test_cache_hint_pinned() {
1485        let kind = RegionKind::Pinned;
1486        assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1487    }
1488
1489    #[test]
1490    fn test_cache_hint_compact_history() {
1491        let kind = RegionKind::CompactHistory {
1492            source_region: "conv".to_string(),
1493        };
1494        assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1495    }
1496
1497    #[test]
1498    fn test_cache_hint_compacting() {
1499        let kind = RegionKind::Compacting {
1500            threshold_tokens: 1000,
1501        };
1502        assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
1503    }
1504
1505    #[test]
1506    fn test_cache_hint_sliding_window() {
1507        let kind = RegionKind::SlidingWindow {
1508            max_items: 10,
1509            eviction_strategy: EvictionStrategy::PerItem,
1510        };
1511        assert_eq!(
1512            kind.cache_hint(),
1513            crate::cache::CacheHint::SlidingPrefix {
1514                stable_fraction: 0.75
1515            }
1516        );
1517    }
1518
1519    #[test]
1520    fn test_cache_hint_temporary() {
1521        assert_eq!(
1522            RegionKind::Temporary.cache_hint(),
1523            crate::cache::CacheHint::Never
1524        );
1525    }
1526
1527    #[test]
1528    fn test_cache_hint_clearable() {
1529        assert_eq!(
1530            RegionKind::Clearable.cache_hint(),
1531            crate::cache::CacheHint::Never
1532        );
1533    }
1534
1535    // ─── Region::with_schema / add_entry schema + budget checks ────────────
1536
1537    #[test]
1538    fn test_with_schema_attaches_schema() {
1539        let schema = RegionSchema::new(ContentFormat::Json);
1540        let region =
1541            Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1542        assert!(region.schema.is_some());
1543    }
1544
1545    #[test]
1546    fn test_add_entry_rejects_content_failing_schema() {
1547        let schema = RegionSchema::new(ContentFormat::Json);
1548        let mut region =
1549            Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1550        let result = region.add_entry("not json".to_string(), 10);
1551        assert!(result.is_err());
1552        assert_eq!(region.entry_count(), 0);
1553    }
1554
1555    #[test]
1556    fn test_add_entry_accepts_content_passing_schema() {
1557        let schema = RegionSchema::new(ContentFormat::Json);
1558        let mut region =
1559            Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1560        let result = region.add_entry("{\"a\":1}".to_string(), 10);
1561        assert!(result.is_ok());
1562        assert_eq!(region.entry_count(), 1);
1563    }
1564
1565    #[test]
1566    fn test_add_entry_rejects_over_budget() {
1567        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1568        let result = region.add_entry("too much".to_string(), 20);
1569        assert_eq!(
1570            result.unwrap_err().to_string(),
1571            "Content exceeds token budget: 20 > 10"
1572        );
1573        assert_eq!(region.entry_count(), 0);
1574    }
1575
1576    #[test]
1577    fn test_add_entry_with_metadata_rejects_content_failing_schema() {
1578        let schema = RegionSchema::new(ContentFormat::Json);
1579        let mut region =
1580            Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1581        let result =
1582            region.add_entry_with_metadata("not json".to_string(), 10, serde_json::json!({}));
1583        assert!(result.is_err());
1584    }
1585
1586    #[test]
1587    fn test_add_entry_with_metadata_rejects_over_budget() {
1588        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1589        let result =
1590            region.add_entry_with_metadata("too much".to_string(), 20, serde_json::json!({}));
1591        assert_eq!(
1592            result.unwrap_err().to_string(),
1593            "Content exceeds token budget: 20 > 10"
1594        );
1595    }
1596
1597    #[test]
1598    fn test_add_entry_with_metadata_stores_metadata() {
1599        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1600        region
1601            .add_entry_with_metadata("hello".to_string(), 5, serde_json::json!({"k": "v"}))
1602            .unwrap();
1603        assert_eq!(
1604            region.content[0].metadata,
1605            Some(serde_json::json!({"k": "v"}))
1606        );
1607    }
1608
1609    // ─── clear / remove_oldest / needs_compaction ──────────────────────────
1610
1611    #[test]
1612    fn test_clear_removes_all_content_and_resets_tokens() {
1613        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1614        region.add_entry("a".to_string(), 10).unwrap();
1615        region.add_entry("b".to_string(), 20).unwrap();
1616        assert_eq!(region.entry_count(), 2);
1617
1618        region.clear();
1619        assert_eq!(region.entry_count(), 0);
1620        assert_eq!(region.current_tokens, 0);
1621    }
1622
1623    #[test]
1624    fn test_remove_oldest_returns_and_removes_first_entry() {
1625        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1626        region.add_entry("first".to_string(), 10).unwrap();
1627        region.add_entry("second".to_string(), 20).unwrap();
1628
1629        let removed = region.remove_oldest().unwrap();
1630        assert_eq!(removed.content, "first");
1631        assert_eq!(region.entry_count(), 1);
1632        assert_eq!(region.current_tokens, 20);
1633    }
1634
1635    #[test]
1636    fn test_remove_oldest_returns_none_when_empty() {
1637        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1638        assert!(region.remove_oldest().is_none());
1639    }
1640
1641    #[test]
1642    fn test_needs_compaction_true_when_over_threshold() {
1643        let mut region = Region::new(
1644            "impl".to_string(),
1645            RegionKind::Compacting {
1646                threshold_tokens: 10,
1647            },
1648            1000,
1649        );
1650        region.add_entry("x".to_string(), 20).unwrap();
1651        assert!(region.needs_compaction());
1652    }
1653
1654    #[test]
1655    fn test_needs_compaction_false_when_under_threshold() {
1656        let mut region = Region::new(
1657            "impl".to_string(),
1658            RegionKind::Compacting {
1659                threshold_tokens: 100,
1660            },
1661            1000,
1662        );
1663        region.add_entry("x".to_string(), 20).unwrap();
1664        assert!(!region.needs_compaction());
1665    }
1666
1667    #[test]
1668    fn test_needs_compaction_false_for_non_compacting_kind() {
1669        let region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1670        assert!(!region.needs_compaction());
1671    }
1672
1673    // ─── RegionSchema::with_custom_script ──────────────────────────────────
1674
1675    #[test]
1676    fn test_region_schema_with_custom_script() {
1677        let schema = RegionSchema::new(ContentFormat::Custom {
1678            format_name: "special".to_string(),
1679        })
1680        .with_custom_script("validate_special()".to_string());
1681        assert_eq!(schema.custom_script.as_deref(), Some("validate_special()"));
1682    }
1683
1684    // ─── RegionSchema::validate - every ContentFormat branch ───────────────
1685
1686    #[test]
1687    fn test_validate_json_valid() {
1688        let schema = RegionSchema::new(ContentFormat::Json);
1689        assert!(schema.validate("{\"a\": 1}").is_ok());
1690    }
1691
1692    #[test]
1693    fn test_validate_json_invalid() {
1694        let schema = RegionSchema::new(ContentFormat::Json);
1695        let err = schema.validate("not json").unwrap_err();
1696        assert!(err.to_string().starts_with("Region validation failed:"));
1697    }
1698
1699    #[test]
1700    fn test_validate_mermaid_valid() {
1701        let schema = RegionSchema::new(ContentFormat::Mermaid);
1702        assert!(schema.validate("graph TD\nA-->B").is_ok());
1703    }
1704
1705    #[test]
1706    fn test_validate_mermaid_all_recognized_diagram_types() {
1707        let schema = RegionSchema::new(ContentFormat::Mermaid);
1708        for kind in [
1709            "graph",
1710            "sequenceDiagram",
1711            "classDiagram",
1712            "stateDiagram",
1713            "erDiagram",
1714            "journey",
1715            "gantt",
1716            "pie",
1717            "flowchart",
1718        ] {
1719            assert!(schema.validate(&format!("{} content", kind)).is_ok());
1720        }
1721    }
1722
1723    #[test]
1724    fn test_validate_mermaid_invalid() {
1725        let schema = RegionSchema::new(ContentFormat::Mermaid);
1726        let err = schema.validate("just some text").unwrap_err();
1727        assert!(err.to_string().starts_with("Region validation failed:"));
1728    }
1729
1730    #[test]
1731    fn test_validate_code_non_empty_is_ok() {
1732        let schema = RegionSchema::new(ContentFormat::Code {
1733            language: "rust".to_string(),
1734        });
1735        assert!(schema.validate("fn main() {}").is_ok());
1736    }
1737
1738    #[test]
1739    fn test_validate_code_empty_is_error() {
1740        let schema = RegionSchema::new(ContentFormat::Code {
1741            language: "rust".to_string(),
1742        });
1743        let err = schema.validate("   ").unwrap_err();
1744        assert!(err.to_string().starts_with("Region validation failed:"));
1745    }
1746
1747    #[test]
1748    fn test_validate_markdown_non_empty_is_ok() {
1749        let schema = RegionSchema::new(ContentFormat::Markdown);
1750        assert!(schema.validate("# Heading").is_ok());
1751    }
1752
1753    #[test]
1754    fn test_validate_markdown_empty_is_error() {
1755        let schema = RegionSchema::new(ContentFormat::Markdown);
1756        let err = schema.validate("").unwrap_err();
1757        assert!(err.to_string().starts_with("Region validation failed:"));
1758    }
1759
1760    #[test]
1761    fn test_validate_text_has_no_restrictions() {
1762        let schema = RegionSchema::new(ContentFormat::Text);
1763        assert!(schema.validate("").is_ok());
1764        assert!(schema.validate("anything at all").is_ok());
1765    }
1766
1767    #[test]
1768    fn test_validate_custom_has_no_restrictions_here() {
1769        let schema = RegionSchema::new(ContentFormat::Custom {
1770            format_name: "special".to_string(),
1771        });
1772        // Custom format validation is deferred to the scripting layer -
1773        // this schema's own validate() is a no-op for it.
1774        assert!(schema.validate("").is_ok());
1775        assert!(schema.validate("whatever").is_ok());
1776    }
1777
1778    // ─── RegionSchema Clone impl ────────────────────────────────────────────
1779
1780    #[test]
1781    fn test_region_schema_clone_preserves_fields() {
1782        let schema = RegionSchema::new(ContentFormat::Text).with_custom_script("s".to_string());
1783        let cloned = schema.clone();
1784        assert_eq!(cloned.custom_script.as_deref(), Some("s"));
1785        assert_eq!(cloned.format, ContentFormat::Text);
1786    }
1787
1788    // ─── Region taint tracking ──────────────────────────────────────────────
1789
1790    #[test]
1791    fn test_region_with_taint_tracking() {
1792        let region =
1793            Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1794        assert!(region.taint.is_some());
1795        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1796    }
1797
1798    #[test]
1799    fn test_region_without_taint_tracking() {
1800        let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1801        assert!(region.taint.is_none());
1802        assert_eq!(region.taint_level(), None);
1803    }
1804
1805    #[test]
1806    fn test_enable_taint_tracking() {
1807        let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1808        assert!(region.taint.is_none());
1809        region.enable_taint_tracking();
1810        assert!(region.taint.is_some());
1811        // Calling again is a no-op
1812        region.enable_taint_tracking();
1813        assert!(region.taint.is_some());
1814    }
1815
1816    #[test]
1817    fn test_add_tainted_entry() {
1818        let mut region =
1819            Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1820        region
1821            .add_tainted_entry(
1822                "secret data".to_string(),
1823                10,
1824                crate::taint::TaintLevel::Private,
1825            )
1826            .unwrap();
1827        assert_eq!(
1828            region.taint_level(),
1829            Some(crate::taint::TaintLevel::Private)
1830        );
1831        assert_eq!(region.entry_count(), 1);
1832    }
1833
1834    #[test]
1835    fn test_add_tainted_entry_validates_schema() {
1836        let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000)
1837            .with_taint_tracking()
1838            .with_schema(RegionSchema::new(ContentFormat::Json));
1839        let result = region.add_tainted_entry(
1840            "not json".to_string(),
1841            10,
1842            crate::taint::TaintLevel::Internal,
1843        );
1844        assert!(result.is_err());
1845        assert_eq!(region.entry_count(), 0);
1846    }
1847
1848    #[test]
1849    fn test_add_tainted_entry_checks_budget() {
1850        let mut region =
1851            Region::new("test".to_string(), RegionKind::Temporary, 10).with_taint_tracking();
1852        let result = region.add_tainted_entry(
1853            "too much".to_string(),
1854            20,
1855            crate::taint::TaintLevel::Internal,
1856        );
1857        assert!(result.is_err());
1858    }
1859
1860    #[test]
1861    fn test_add_entry_tracks_taint_as_public() {
1862        let mut region =
1863            Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1864        region.add_entry("public data".to_string(), 10).unwrap();
1865        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1866    }
1867
1868    #[test]
1869    fn test_taint_recovery_on_remove_oldest() {
1870        let mut region =
1871            Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1872        region
1873            .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1874            .unwrap();
1875        region
1876            .add_tainted_entry("public".to_string(), 10, crate::taint::TaintLevel::Public)
1877            .unwrap();
1878        assert_eq!(
1879            region.taint_level(),
1880            Some(crate::taint::TaintLevel::Private)
1881        );
1882
1883        region.remove_oldest(); // removes private entry
1884        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1885    }
1886
1887    #[test]
1888    fn test_taint_recovery_on_clear() {
1889        let mut region =
1890            Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1891        region
1892            .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1893            .unwrap();
1894        region.clear();
1895        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1896    }
1897
1898    #[test]
1899    fn test_taint_recovery_on_sliding_window_eviction() {
1900        let mut region = Region::new(
1901            "conv".to_string(),
1902            RegionKind::SlidingWindow {
1903                max_items: 2,
1904                eviction_strategy: EvictionStrategy::PerItem,
1905            },
1906            50000,
1907        )
1908        .with_taint_tracking();
1909
1910        region
1911            .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1912            .unwrap();
1913        region
1914            .add_tainted_entry("public1".to_string(), 10, crate::taint::TaintLevel::Public)
1915            .unwrap();
1916        assert_eq!(
1917            region.taint_level(),
1918            Some(crate::taint::TaintLevel::Private)
1919        );
1920
1921        // Third entry evicts the private one
1922        region
1923            .add_tainted_entry("public2".to_string(), 10, crate::taint::TaintLevel::Public)
1924            .unwrap();
1925        assert_eq!(region.entry_count(), 2);
1926        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1927    }
1928
1929    #[test]
1930    fn test_taint_field_not_serialized_when_none() {
1931        let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1932        let json = serde_json::to_string(&region).unwrap();
1933        assert!(!json.contains("taint"));
1934    }
1935
1936    #[test]
1937    fn test_taint_field_deserialized_as_none_when_missing() {
1938        let json = r#"{"name":"test","kind":"Temporary","content":[],"max_tokens":1000,"current_tokens":0,"schema":null}"#;
1939        let region: Region = serde_json::from_str(json).unwrap();
1940        assert!(region.taint.is_none());
1941    }
1942
1943    #[test]
1944    fn test_add_typed_tainted_entry() {
1945        let mut region = Region::new(
1946            "conversation".to_string(),
1947            RegionKind::SlidingWindow {
1948                max_items: 100,
1949                eviction_strategy: EvictionStrategy::PerItem,
1950            },
1951            1000,
1952        )
1953        .with_taint_tracking();
1954
1955        region
1956            .add_typed_tainted_entry(
1957                "secret data".to_string(),
1958                10,
1959                EntryKind::ToolResult {
1960                    tool_call_id: "tc_1".to_string(),
1961                    tool_name: "calendar".to_string(),
1962                    is_error: false,
1963                },
1964                crate::taint::TaintLevel::Private,
1965            )
1966            .unwrap();
1967
1968        assert_eq!(region.content.len(), 1);
1969        assert_eq!(
1970            region.content[0].kind,
1971            EntryKind::ToolResult {
1972                tool_call_id: "tc_1".to_string(),
1973                tool_name: "calendar".to_string(),
1974                is_error: false,
1975            }
1976        );
1977        assert_eq!(
1978            region.taint_level(),
1979            Some(crate::taint::TaintLevel::Private)
1980        );
1981    }
1982
1983    /// The replay token survives persistence, and archives written before the
1984    /// field existed still load (`#[serde(default)]`) - a restart must not
1985    /// strand a Gemini run on a missing signature or fail on an old run dir.
1986    #[test]
1987    fn serialized_tool_call_round_trips_thought_signature_and_reads_old_json() {
1988        let with = SerializedToolCall {
1989            id: "c1".into(),
1990            name: "shell".into(),
1991            arguments: serde_json::json!({"command": "ls"}),
1992            thought_signature: Some("sig".into()),
1993        };
1994        let json = serde_json::to_string(&with).unwrap();
1995        let back: SerializedToolCall = serde_json::from_str(&json).unwrap();
1996        assert_eq!(back.thought_signature.as_deref(), Some("sig"));
1997
1998        // Pre-field JSON (what every existing run dir contains).
1999        let old = r#"{"id":"c2","name":"shell","arguments":{}}"#;
2000        let back: SerializedToolCall = serde_json::from_str(old).unwrap();
2001        assert_eq!(back.thought_signature, None);
2002
2003        // And a `None` signature serializes to the old shape, so new writes
2004        // stay readable by anything parsing the documented format.
2005        let without = SerializedToolCall {
2006            id: "c3".into(),
2007            name: "shell".into(),
2008            arguments: serde_json::json!({}),
2009            thought_signature: None,
2010        };
2011        assert!(
2012            !serde_json::to_string(&without)
2013                .unwrap()
2014                .contains("thought_signature")
2015        );
2016    }
2017
2018    #[test]
2019    fn test_add_typed_tainted_entry_checks_budget() {
2020        let mut region = Region::new(
2021            "conversation".to_string(),
2022            RegionKind::SlidingWindow {
2023                max_items: 100,
2024                eviction_strategy: EvictionStrategy::PerItem,
2025            },
2026            5,
2027        )
2028        .with_taint_tracking();
2029
2030        let result = region.add_typed_tainted_entry(
2031            "too large".to_string(),
2032            100,
2033            EntryKind::ToolResult {
2034                tool_call_id: "tc_1".to_string(),
2035                tool_name: "tool".to_string(),
2036                is_error: false,
2037            },
2038            crate::taint::TaintLevel::Internal,
2039        );
2040        assert!(result.is_err());
2041    }
2042
2043    #[test]
2044    fn test_add_typed_tainted_entry_validates_schema() {
2045        let mut region = Region::new("test".to_string(), RegionKind::Pinned, 1000)
2046            .with_taint_tracking()
2047            .with_schema(RegionSchema::new(ContentFormat::Json));
2048
2049        // Non-JSON content should fail validation
2050        let result = region.add_typed_tainted_entry(
2051            "not json".to_string(),
2052            5,
2053            EntryKind::Text,
2054            crate::taint::TaintLevel::Public,
2055        );
2056        assert!(result.is_err());
2057    }
2058
2059    #[test]
2060    fn test_add_typed_tainted_entry_without_taint_tracking() {
2061        // When taint tracking is NOT enabled, add_typed_tainted_entry still works
2062        // but the taint level is not tracked
2063        let mut region = Region::new(
2064            "conversation".to_string(),
2065            RegionKind::SlidingWindow {
2066                max_items: 100,
2067                eviction_strategy: EvictionStrategy::PerItem,
2068            },
2069            1000,
2070        );
2071        // No .with_taint_tracking()
2072
2073        region
2074            .add_typed_tainted_entry(
2075                "data".to_string(),
2076                10,
2077                EntryKind::Text,
2078                crate::taint::TaintLevel::Private,
2079            )
2080            .unwrap();
2081
2082        assert_eq!(region.content.len(), 1);
2083        assert_eq!(region.taint_level(), None); // no tracking
2084    }
2085
2086    // ─── turn_group_size_at ────────────────────────────────────────────────
2087
2088    #[test]
2089    fn test_turn_group_size_at_assistant_with_tool_results() {
2090        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2091        region
2092            .add_typed_entry(
2093                "assistant response".to_string(),
2094                10,
2095                EntryKind::AssistantTurn {
2096                    tool_calls: vec![
2097                        SerializedToolCall {
2098                            id: "tc_1".to_string(),
2099                            name: "read_file".to_string(),
2100                            arguments: serde_json::json!({}),
2101                            thought_signature: None,
2102                        },
2103                        SerializedToolCall {
2104                            id: "tc_2".to_string(),
2105                            name: "write_file".to_string(),
2106                            arguments: serde_json::json!({}),
2107                            thought_signature: None,
2108                        },
2109                    ],
2110                },
2111            )
2112            .unwrap();
2113        region
2114            .add_typed_entry(
2115                "result 1".to_string(),
2116                5,
2117                EntryKind::ToolResult {
2118                    tool_call_id: "tc_1".to_string(),
2119                    tool_name: "read_file".to_string(),
2120                    is_error: false,
2121                },
2122            )
2123            .unwrap();
2124        region
2125            .add_typed_entry(
2126                "result 2".to_string(),
2127                5,
2128                EntryKind::ToolResult {
2129                    tool_call_id: "tc_2".to_string(),
2130                    tool_name: "write_file".to_string(),
2131                    is_error: false,
2132                },
2133            )
2134            .unwrap();
2135
2136        assert_eq!(region.turn_group_size_at(0), 3);
2137    }
2138
2139    #[test]
2140    fn test_turn_group_size_at_assistant_at_end() {
2141        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2142        region
2143            .add_typed_entry(
2144                "assistant with no tools".to_string(),
2145                10,
2146                EntryKind::AssistantTurn { tool_calls: vec![] },
2147            )
2148            .unwrap();
2149
2150        assert_eq!(region.turn_group_size_at(0), 1);
2151    }
2152
2153    #[test]
2154    fn test_turn_group_size_at_out_of_bounds() {
2155        let region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2156        assert_eq!(region.turn_group_size_at(0), 0);
2157        assert_eq!(region.turn_group_size_at(99), 0);
2158    }
2159
2160    #[test]
2161    fn test_turn_group_size_at_non_assistant_entries() {
2162        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2163        region
2164            .add_typed_entry("hello".to_string(), 5, EntryKind::Text)
2165            .unwrap();
2166        region
2167            .add_typed_entry("hi".to_string(), 5, EntryKind::UserMessage)
2168            .unwrap();
2169        region
2170            .add_typed_entry(
2171                "orphan result".to_string(),
2172                5,
2173                EntryKind::ToolResult {
2174                    tool_call_id: "tc_x".to_string(),
2175                    tool_name: "tool".to_string(),
2176                    is_error: false,
2177                },
2178            )
2179            .unwrap();
2180
2181        assert_eq!(region.turn_group_size_at(0), 1); // Text
2182        assert_eq!(region.turn_group_size_at(1), 1); // UserMessage
2183        assert_eq!(region.turn_group_size_at(2), 1); // ToolResult (orphan)
2184    }
2185
2186    // ─── remove_oldest with turn group eviction ────────────────────────────
2187
2188    #[test]
2189    fn test_remove_oldest_evicts_entire_turn_group() {
2190        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2191        // AssistantTurn with 2 tool calls
2192        region
2193            .add_typed_entry(
2194                "assistant".to_string(),
2195                100,
2196                EntryKind::AssistantTurn {
2197                    tool_calls: vec![
2198                        SerializedToolCall {
2199                            id: "tc_1".to_string(),
2200                            name: "read_file".to_string(),
2201                            arguments: serde_json::json!({}),
2202                            thought_signature: None,
2203                        },
2204                        SerializedToolCall {
2205                            id: "tc_2".to_string(),
2206                            name: "list_dir".to_string(),
2207                            arguments: serde_json::json!({}),
2208                            thought_signature: None,
2209                        },
2210                    ],
2211                },
2212            )
2213            .unwrap();
2214        region
2215            .add_typed_entry(
2216                "result 1".to_string(),
2217                30,
2218                EntryKind::ToolResult {
2219                    tool_call_id: "tc_1".to_string(),
2220                    tool_name: "read_file".to_string(),
2221                    is_error: false,
2222                },
2223            )
2224            .unwrap();
2225        region
2226            .add_typed_entry(
2227                "result 2".to_string(),
2228                20,
2229                EntryKind::ToolResult {
2230                    tool_call_id: "tc_2".to_string(),
2231                    tool_name: "list_dir".to_string(),
2232                    is_error: false,
2233                },
2234            )
2235            .unwrap();
2236        // A trailing user message that should survive
2237        region
2238            .add_typed_entry("user msg".to_string(), 10, EntryKind::UserMessage)
2239            .unwrap();
2240
2241        assert_eq!(region.entry_count(), 4);
2242        assert_eq!(region.current_tokens, 160);
2243
2244        let removed = region.remove_oldest().unwrap();
2245        // The returned entry is the AssistantTurn, with tokens adjusted to
2246        // include the extra tokens from the 2 ToolResult entries.
2247        assert_eq!(removed.content, "assistant");
2248        assert_eq!(removed.tokens, 100 + 30 + 20); // 150
2249        // Only the user message remains
2250        assert_eq!(region.entry_count(), 1);
2251        assert_eq!(region.content[0].content, "user msg");
2252        assert_eq!(region.current_tokens, 10);
2253    }
2254
2255    // ─── remove_oldest with taint tracking and turn group ──────────────────
2256
2257    #[test]
2258    fn test_remove_oldest_turn_group_calls_taint_remove_for_each_entry() {
2259        let mut region =
2260            Region::new("conv".to_string(), RegionKind::Temporary, 50000).with_taint_tracking();
2261
2262        // AssistantTurn (Private) + 1 ToolResult (Internal) + 1 trailing Public entry
2263        region
2264            .add_typed_tainted_entry(
2265                "assistant".to_string(),
2266                10,
2267                EntryKind::AssistantTurn {
2268                    tool_calls: vec![SerializedToolCall {
2269                        id: "tc_1".to_string(),
2270                        name: "tool".to_string(),
2271                        arguments: serde_json::json!({}),
2272                        thought_signature: None,
2273                    }],
2274                },
2275                crate::taint::TaintLevel::Private,
2276            )
2277            .unwrap();
2278        region
2279            .add_typed_tainted_entry(
2280                "result".to_string(),
2281                5,
2282                EntryKind::ToolResult {
2283                    tool_call_id: "tc_1".to_string(),
2284                    tool_name: "tool".to_string(),
2285                    is_error: false,
2286                },
2287                crate::taint::TaintLevel::Internal,
2288            )
2289            .unwrap();
2290        region
2291            .add_tainted_entry(
2292                "public stuff".to_string(),
2293                5,
2294                crate::taint::TaintLevel::Public,
2295            )
2296            .unwrap();
2297
2298        assert_eq!(
2299            region.taint_level(),
2300            Some(crate::taint::TaintLevel::Private)
2301        );
2302        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 3);
2303
2304        // Evict the turn group (AssistantTurn + ToolResult)
2305        let removed = region.remove_oldest().unwrap();
2306        assert_eq!(removed.content, "assistant");
2307        assert_eq!(region.entry_count(), 1);
2308        // Taint should have called remove_oldest twice (once per group member),
2309        // leaving only the Public entry's taint.
2310        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2311        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2312    }
2313
2314    // ─── enforce_sliding_window with turn group ────────────────────────────
2315
2316    #[test]
2317    fn test_sliding_window_evicts_entire_turn_group() {
2318        let mut region = Region::new(
2319            "conv".to_string(),
2320            RegionKind::SlidingWindow {
2321                max_items: 3,
2322                eviction_strategy: EvictionStrategy::PerItem,
2323            },
2324            50000,
2325        );
2326
2327        // Add an AssistantTurn + 2 ToolResults = 3 entries (fills the window)
2328        region
2329            .add_typed_entry(
2330                "assistant".to_string(),
2331                10,
2332                EntryKind::AssistantTurn {
2333                    tool_calls: vec![
2334                        SerializedToolCall {
2335                            id: "tc_1".to_string(),
2336                            name: "t1".to_string(),
2337                            arguments: serde_json::json!({}),
2338                            thought_signature: None,
2339                        },
2340                        SerializedToolCall {
2341                            id: "tc_2".to_string(),
2342                            name: "t2".to_string(),
2343                            arguments: serde_json::json!({}),
2344                            thought_signature: None,
2345                        },
2346                    ],
2347                },
2348            )
2349            .unwrap();
2350        region
2351            .add_typed_entry(
2352                "r1".to_string(),
2353                5,
2354                EntryKind::ToolResult {
2355                    tool_call_id: "tc_1".to_string(),
2356                    tool_name: "t1".to_string(),
2357                    is_error: false,
2358                },
2359            )
2360            .unwrap();
2361        region
2362            .add_typed_entry(
2363                "r2".to_string(),
2364                5,
2365                EntryKind::ToolResult {
2366                    tool_call_id: "tc_2".to_string(),
2367                    tool_name: "t2".to_string(),
2368                    is_error: false,
2369                },
2370            )
2371            .unwrap();
2372
2373        assert_eq!(region.entry_count(), 3);
2374
2375        // Adding a 4th entry should evict the entire turn group (3 entries)
2376        // because the group at index 0 is an AssistantTurn with 2 ToolResults.
2377        region
2378            .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2379            .unwrap();
2380
2381        // After eviction: only the new user message remains
2382        assert_eq!(region.entry_count(), 1);
2383        assert_eq!(region.content[0].content, "user msg");
2384        assert_eq!(region.current_tokens, 15);
2385    }
2386
2387    // ─── add_entry_with_metadata with taint tracking ───────────────────────
2388
2389    #[test]
2390    fn test_add_entry_with_metadata_tracks_taint_as_public() {
2391        let mut region =
2392            Region::new("data".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2393
2394        region
2395            .add_entry_with_metadata("content".to_string(), 10, serde_json::json!({"key": "val"}))
2396            .unwrap();
2397
2398        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2399        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2400        assert_eq!(
2401            region.taint.as_ref().unwrap().entry_taint(0),
2402            Some(crate::taint::TaintLevel::Public)
2403        );
2404    }
2405
2406    // ─── add_typed_entry with taint tracking ───────────────────────────────
2407
2408    #[test]
2409    fn test_add_typed_entry_tracks_taint_as_public() {
2410        let mut region =
2411            Region::new("conv".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2412
2413        region
2414            .add_typed_entry(
2415                "assistant response".to_string(),
2416                10,
2417                EntryKind::AssistantTurn { tool_calls: vec![] },
2418            )
2419            .unwrap();
2420
2421        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2422        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2423        assert_eq!(
2424            region.taint.as_ref().unwrap().entry_taint(0),
2425            Some(crate::taint::TaintLevel::Public)
2426        );
2427    }
2428
2429    // ─── EvictionStrategy tests ───────────────────────────────────────────
2430
2431    #[test]
2432    fn test_per_item_strategy_evicts_one_at_a_time() {
2433        let mut region = Region::new(
2434            "conv".to_string(),
2435            RegionKind::SlidingWindow {
2436                max_items: 3,
2437                eviction_strategy: EvictionStrategy::PerItem,
2438            },
2439            50000,
2440        );
2441        for i in 0..5 {
2442            region.add_entry(format!("msg{}", i), 10).unwrap();
2443        }
2444        assert_eq!(region.entry_count(), 3);
2445        assert_eq!(region.content[0].content, "msg2");
2446        assert_eq!(region.content[1].content, "msg3");
2447        assert_eq!(region.content[2].content, "msg4");
2448    }
2449
2450    #[test]
2451    fn test_bulk_eviction_triggers_on_overflow() {
2452        let mut region = Region::new(
2453            "conv".to_string(),
2454            RegionKind::SlidingWindow {
2455                max_items: 5,
2456                eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2457            },
2458            50000,
2459        );
2460        // Add 8 entries: 5 (max) + 3 (overflow) = 8, which does NOT trigger
2461        // because the check is > not >=.
2462        for i in 0..8 {
2463            region.add_entry(format!("msg{}", i), 10).unwrap();
2464        }
2465        assert_eq!(region.entry_count(), 8);
2466
2467        // Adding one more (9 total > 5+3=8) triggers bulk eviction → down to 5
2468        region.add_entry("msg8".to_string(), 10).unwrap();
2469        assert_eq!(region.entry_count(), 5);
2470        assert_eq!(region.content[0].content, "msg4");
2471    }
2472
2473    #[test]
2474    fn test_bulk_eviction_respects_turn_groups() {
2475        let mut region = Region::new(
2476            "conv".to_string(),
2477            RegionKind::SlidingWindow {
2478                max_items: 3,
2479                eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2480            },
2481            50000,
2482        );
2483        // Add AssistantTurn + ToolResult (turn group of 2)
2484        region
2485            .add_typed_entry(
2486                "assistant".to_string(),
2487                10,
2488                EntryKind::AssistantTurn {
2489                    tool_calls: vec![SerializedToolCall {
2490                        id: "tc1".to_string(),
2491                        name: "tool".to_string(),
2492                        arguments: serde_json::json!({}),
2493                        thought_signature: None,
2494                    }],
2495                },
2496            )
2497            .unwrap();
2498        region
2499            .add_typed_entry(
2500                "result".to_string(),
2501                5,
2502                EntryKind::ToolResult {
2503                    tool_call_id: "tc1".to_string(),
2504                    tool_name: "tool".to_string(),
2505                    is_error: false,
2506                },
2507            )
2508            .unwrap();
2509        // Add more entries to exceed overflow
2510        region.add_entry("msg2".to_string(), 10).unwrap();
2511        region.add_entry("msg3".to_string(), 10).unwrap();
2512        region.add_entry("msg4".to_string(), 10).unwrap();
2513        // 5 entries, under overflow (5 < 3+2=5 is not >), no eviction yet
2514        assert_eq!(region.entry_count(), 5);
2515
2516        // Adding 6th entry: 6 > 5 triggers bulk eviction
2517        region.add_entry("msg5".to_string(), 10).unwrap();
2518        // Turn group (assistant+result=2) evicted together, then msg2 evicted
2519        // to get down to max_items=3
2520        assert_eq!(region.entry_count(), 3);
2521        assert_eq!(region.content[0].content, "msg3");
2522    }
2523
2524    #[test]
2525    fn test_bulk_eviction_under_overflow_no_eviction() {
2526        let mut region = Region::new(
2527            "conv".to_string(),
2528            RegionKind::SlidingWindow {
2529                max_items: 5,
2530                eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2531            },
2532            50000,
2533        );
2534        // Add exactly max_items + overflow - 1 = 7 entries
2535        for i in 0..7 {
2536            region.add_entry(format!("msg{}", i), 10).unwrap();
2537        }
2538        // 7 <= 8 (5+3), so no eviction
2539        assert_eq!(region.entry_count(), 7);
2540    }
2541
2542    #[test]
2543    fn test_compact_sets_needs_message_compaction_flag() {
2544        let mut region = Region::new(
2545            "conv".to_string(),
2546            RegionKind::SlidingWindow {
2547                max_items: 5,
2548                eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2549            },
2550            50000,
2551        );
2552        assert!(!region.needs_message_compaction);
2553
2554        // Add 9 entries: > max_items(5) + compact_count(3) = 8
2555        for i in 0..9 {
2556            region.add_entry(format!("msg{}", i), 10).unwrap();
2557        }
2558        assert!(region.needs_message_compaction);
2559        // No entries were evicted - compaction flag is set for the runtime
2560        assert_eq!(region.entry_count(), 9);
2561    }
2562
2563    #[test]
2564    fn test_compact_fallback_to_bulk_eviction() {
2565        let mut region = Region::new(
2566            "conv".to_string(),
2567            RegionKind::SlidingWindow {
2568                max_items: 5,
2569                eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2570            },
2571            50000,
2572        );
2573        // Add enough entries to exceed 2x threshold:
2574        // > max_items(5) + compact_count(3) * 2 = 11
2575        for i in 0..12 {
2576            region.add_entry(format!("msg{}", i), 10).unwrap();
2577        }
2578        // Should have bulk-evicted down to max_items=5
2579        assert_eq!(region.entry_count(), 5);
2580        assert_eq!(region.content[0].content, "msg7");
2581        // Compaction flag should be cleared after fallback
2582        assert!(!region.needs_message_compaction);
2583    }
2584
2585    #[test]
2586    fn test_eviction_strategy_default_is_per_item() {
2587        assert_eq!(EvictionStrategy::default(), EvictionStrategy::PerItem);
2588    }
2589
2590    #[test]
2591    fn test_remove_entries_by_prefix() {
2592        let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2593        region
2594            .add_entry("[Stage instructions: Be terse.]".to_string(), 10)
2595            .unwrap();
2596        region
2597            .add_entry("Core identity block".to_string(), 20)
2598            .unwrap();
2599        region
2600            .add_entry("[Stage instructions: Be verbose.]".to_string(), 15)
2601            .unwrap();
2602
2603        assert_eq!(region.entry_count(), 3);
2604        region.remove_entries_by_prefix("[Stage instructions:");
2605        assert_eq!(region.entry_count(), 1);
2606        assert_eq!(region.content[0].content, "Core identity block");
2607        assert_eq!(region.current_tokens, 20);
2608    }
2609
2610    #[test]
2611    fn test_remove_entries_by_prefix_with_taint_tracking() {
2612        let mut region =
2613            Region::new("system".to_string(), RegionKind::Pinned, 50000).with_taint_tracking();
2614        region
2615            .add_tainted_entry(
2616                "[Stage instructions: Be terse.]".to_string(),
2617                10,
2618                crate::taint::TaintLevel::Private,
2619            )
2620            .unwrap();
2621        region
2622            .add_tainted_entry(
2623                "Core identity block".to_string(),
2624                20,
2625                crate::taint::TaintLevel::Public,
2626            )
2627            .unwrap();
2628        region
2629            .add_tainted_entry(
2630                "[Stage instructions: Be verbose.]".to_string(),
2631                15,
2632                crate::taint::TaintLevel::Internal,
2633            )
2634            .unwrap();
2635
2636        assert_eq!(region.entry_count(), 3);
2637        assert_eq!(
2638            region.taint_level(),
2639            Some(crate::taint::TaintLevel::Private)
2640        );
2641
2642        region.remove_entries_by_prefix("[Stage instructions:");
2643        assert_eq!(region.entry_count(), 1);
2644        assert_eq!(region.content[0].content, "Core identity block");
2645        assert_eq!(region.current_tokens, 20);
2646        // After removing Private and Internal entries, only Public remains
2647        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2648        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2649    }
2650
2651    #[test]
2652    fn test_compact_below_threshold_no_flag() {
2653        // When entries are <= max_items + compact_count, no flag should be set
2654        let mut region = Region::new(
2655            "conv".to_string(),
2656            RegionKind::SlidingWindow {
2657                max_items: 5,
2658                eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2659            },
2660            50000,
2661        );
2662        for i in 0..8 {
2663            region.add_entry(format!("msg{}", i), 10).unwrap();
2664        }
2665        // 8 == max_items(5) + compact_count(3), not >, so no flag
2666        assert!(!region.needs_message_compaction);
2667        assert_eq!(region.entry_count(), 8);
2668    }
2669
2670    #[test]
2671    fn test_bulk_eviction_with_taint_tracking() {
2672        let mut region = Region::new(
2673            "conv".to_string(),
2674            RegionKind::SlidingWindow {
2675                max_items: 3,
2676                eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2677            },
2678            50000,
2679        )
2680        .with_taint_tracking();
2681
2682        // Add 5 entries (3+2): at threshold, no eviction
2683        region
2684            .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2685            .unwrap();
2686        for i in 1..5 {
2687            region
2688                .add_tainted_entry(format!("pub{}", i), 10, crate::taint::TaintLevel::Public)
2689                .unwrap();
2690        }
2691        assert_eq!(region.entry_count(), 5);
2692
2693        // 6th entry triggers bulk eviction to max_items=3
2694        region
2695            .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2696            .unwrap();
2697        assert_eq!(region.entry_count(), 3);
2698        // Private entry was evicted, only public remain
2699        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2700    }
2701
2702    #[test]
2703    fn test_eviction_strategy_serde_roundtrip() {
2704        let bulk = EvictionStrategy::Bulk { overflow: 5 };
2705        let json = serde_json::to_string(&bulk).unwrap();
2706        let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2707        assert_eq!(parsed, bulk);
2708
2709        let compact = EvictionStrategy::Compact { compact_count: 10 };
2710        let json = serde_json::to_string(&compact).unwrap();
2711        let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2712        assert_eq!(parsed, compact);
2713
2714        let per_item = EvictionStrategy::PerItem;
2715        let json = serde_json::to_string(&per_item).unwrap();
2716        let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2717        assert_eq!(parsed, per_item);
2718    }
2719
2720    #[test]
2721    fn test_sliding_window_kind_equality_with_eviction_strategy() {
2722        assert_eq!(
2723            RegionKind::SlidingWindow {
2724                max_items: 10,
2725                eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2726            },
2727            RegionKind::SlidingWindow {
2728                max_items: 10,
2729                eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2730            }
2731        );
2732        assert_ne!(
2733            RegionKind::SlidingWindow {
2734                max_items: 10,
2735                eviction_strategy: EvictionStrategy::PerItem,
2736            },
2737            RegionKind::SlidingWindow {
2738                max_items: 10,
2739                eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2740            }
2741        );
2742    }
2743
2744    #[test]
2745    fn test_needs_message_compaction_default_false() {
2746        let region = Region::new("conv".to_string(), RegionKind::Temporary, 1000);
2747        assert!(!region.needs_message_compaction);
2748    }
2749
2750    // ─── add_typed_entry schema + budget edge cases ───────────────────────
2751
2752    #[test]
2753    fn test_add_typed_entry_validates_schema() {
2754        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000)
2755            .with_schema(RegionSchema::new(ContentFormat::Json));
2756        let result = region.add_typed_entry("not json".to_string(), 5, EntryKind::Text);
2757        assert!(result.is_err());
2758        assert_eq!(region.entry_count(), 0);
2759    }
2760
2761    #[test]
2762    fn test_add_typed_entry_checks_budget() {
2763        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
2764        let result = region.add_typed_entry("too big".to_string(), 20, EntryKind::UserMessage);
2765        assert!(result.is_err());
2766        assert_eq!(region.entry_count(), 0);
2767    }
2768
2769    #[test]
2770    fn test_add_tainted_entry_without_taint_tracking() {
2771        // When taint tracking is NOT enabled, the taint level is silently ignored.
2772        let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
2773        region
2774            .add_tainted_entry("data".to_string(), 10, crate::taint::TaintLevel::Private)
2775            .unwrap();
2776        assert_eq!(region.entry_count(), 1);
2777        assert_eq!(region.taint_level(), None);
2778    }
2779
2780    #[test]
2781    fn test_remove_entries_by_prefix_no_match() {
2782        let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2783        region.add_entry("Keep this".to_string(), 10).unwrap();
2784        region.add_entry("And this".to_string(), 20).unwrap();
2785        region.remove_entries_by_prefix("[Stage instructions:");
2786        assert_eq!(region.entry_count(), 2);
2787        assert_eq!(region.current_tokens, 30);
2788    }
2789
2790    // ─── HashMap region tests ──────────────────────────────────────────────
2791
2792    #[test]
2793    fn test_hashmap_region_upsert_and_get() {
2794        let mut region = Region::new(
2795            "files".to_string(),
2796            RegionKind::HashMap { max_entries: None },
2797            10000,
2798        );
2799        region
2800            .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2801            .unwrap();
2802        region
2803            .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2804            .unwrap();
2805
2806        assert_eq!(region.entry_count(), 2);
2807        assert_eq!(region.current_tokens, 18);
2808
2809        let entry = region.get_by_key("src/main.rs").unwrap();
2810        assert_eq!(entry.content, "fn main() {}");
2811        assert_eq!(entry.key.as_deref(), Some("src/main.rs"));
2812    }
2813
2814    #[test]
2815    fn test_hashmap_region_upsert_replaces_existing() {
2816        let mut region = Region::new(
2817            "files".to_string(),
2818            RegionKind::HashMap { max_entries: None },
2819            10000,
2820        );
2821        region
2822            .upsert_by_key("file.rs", "version 1".to_string(), 10)
2823            .unwrap();
2824        assert_eq!(region.current_tokens, 10);
2825
2826        region
2827            .upsert_by_key("file.rs", "version 2".to_string(), 15)
2828            .unwrap();
2829        assert_eq!(region.entry_count(), 1);
2830        assert_eq!(region.current_tokens, 15);
2831        assert_eq!(region.get_by_key("file.rs").unwrap().content, "version 2");
2832    }
2833
2834    #[test]
2835    fn test_hashmap_region_remove_by_key() {
2836        let mut region = Region::new(
2837            "files".to_string(),
2838            RegionKind::HashMap { max_entries: None },
2839            10000,
2840        );
2841        region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2842        region.upsert_by_key("b.rs", "bbb".to_string(), 20).unwrap();
2843
2844        assert!(region.remove_by_key("a.rs"));
2845        assert_eq!(region.entry_count(), 1);
2846        assert_eq!(region.current_tokens, 20);
2847        assert!(region.get_by_key("a.rs").is_none());
2848        assert!(!region.remove_by_key("nonexistent"));
2849    }
2850
2851    #[test]
2852    fn test_hashmap_region_keys() {
2853        let mut region = Region::new(
2854            "files".to_string(),
2855            RegionKind::HashMap { max_entries: None },
2856            10000,
2857        );
2858        region.upsert_by_key("x.rs", "x".to_string(), 5).unwrap();
2859        region.upsert_by_key("y.rs", "y".to_string(), 5).unwrap();
2860
2861        let keys = region.keys();
2862        assert_eq!(keys.len(), 2);
2863        assert!(keys.contains(&"x.rs"));
2864        assert!(keys.contains(&"y.rs"));
2865    }
2866
2867    #[test]
2868    fn test_hashmap_region_lru_eviction_on_max_tokens() {
2869        let mut region = Region::new(
2870            "files".to_string(),
2871            RegionKind::HashMap { max_entries: None },
2872            30, // tight budget
2873        );
2874        region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2875        // Make 'a' older by manually adjusting timestamp
2876        region.content[0].timestamp -= 100;
2877        region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2878        region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2879        assert_eq!(region.entry_count(), 3);
2880        assert_eq!(region.current_tokens, 30);
2881
2882        // Adding d.rs should evict a.rs (oldest timestamp)
2883        region.upsert_by_key("d.rs", "ddd".to_string(), 10).unwrap();
2884        assert_eq!(region.entry_count(), 3);
2885        assert!(region.get_by_key("a.rs").is_none());
2886        assert!(region.get_by_key("d.rs").is_some());
2887    }
2888
2889    #[test]
2890    fn test_hashmap_region_max_entries_eviction() {
2891        let mut region = Region::new(
2892            "files".to_string(),
2893            RegionKind::HashMap {
2894                max_entries: Some(2),
2895            },
2896            10000,
2897        );
2898        region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2899        region.content[0].timestamp -= 100; // make oldest
2900        region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2901        assert_eq!(region.entry_count(), 2);
2902
2903        // Adding c.rs should evict a.rs (oldest, max_entries=2)
2904        region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2905        assert_eq!(region.entry_count(), 2);
2906        assert!(region.get_by_key("a.rs").is_none());
2907        assert!(region.get_by_key("c.rs").is_some());
2908    }
2909
2910    #[test]
2911    fn test_hashmap_region_upsert_too_large_for_budget() {
2912        let mut region = Region::new(
2913            "files".to_string(),
2914            RegionKind::HashMap { max_entries: None },
2915            5, // very small
2916        );
2917        let result = region.upsert_by_key("big.rs", "huge content".to_string(), 100);
2918        assert!(result.is_err());
2919    }
2920
2921    #[test]
2922    fn test_hashmap_region_kind_equality() {
2923        assert_eq!(
2924            RegionKind::HashMap {
2925                max_entries: Some(10)
2926            },
2927            RegionKind::HashMap {
2928                max_entries: Some(10)
2929            }
2930        );
2931        assert_ne!(
2932            RegionKind::HashMap {
2933                max_entries: Some(10)
2934            },
2935            RegionKind::HashMap {
2936                max_entries: Some(20)
2937            }
2938        );
2939        assert_ne!(
2940            RegionKind::HashMap { max_entries: None },
2941            RegionKind::Pinned
2942        );
2943    }
2944
2945    #[test]
2946    fn test_hashmap_cache_hint() {
2947        let kind = RegionKind::HashMap { max_entries: None };
2948        assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
2949    }
2950
2951    #[test]
2952    fn test_region_entry_key_default_none() {
2953        let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
2954        region.add_entry("content".to_string(), 10).unwrap();
2955        assert!(region.content[0].key.is_none());
2956    }
2957
2958    #[test]
2959    fn test_region_entry_key_serde_skip_when_none() {
2960        let entry = RegionEntry {
2961            content: "test".to_string(),
2962            tokens: 5,
2963            timestamp: 0,
2964            metadata: None,
2965            kind: EntryKind::default(),
2966            key: None,
2967        };
2968        let json = serde_json::to_string(&entry).unwrap();
2969        assert!(!json.contains("key"));
2970    }
2971
2972    #[test]
2973    fn test_region_entry_key_serde_roundtrip() {
2974        let entry = RegionEntry {
2975            content: "test".to_string(),
2976            tokens: 5,
2977            timestamp: 0,
2978            metadata: None,
2979            kind: EntryKind::default(),
2980            key: Some("mykey".to_string()),
2981        };
2982        let json = serde_json::to_string(&entry).unwrap();
2983        assert!(json.contains("mykey"));
2984        let back: RegionEntry = serde_json::from_str(&json).unwrap();
2985        assert_eq!(back.key.as_deref(), Some("mykey"));
2986    }
2987
2988    // ─── Additional HashMap region tests ──────────────────────────────────
2989
2990    #[test]
2991    fn test_hashmap_region_creation_and_basic_properties() {
2992        let region = Region::new(
2993            "lookup".to_string(),
2994            RegionKind::HashMap {
2995                max_entries: Some(5),
2996            },
2997            2000,
2998        );
2999        assert_eq!(region.name, "lookup");
3000        assert_eq!(
3001            region.kind,
3002            RegionKind::HashMap {
3003                max_entries: Some(5)
3004            }
3005        );
3006        assert_eq!(region.max_tokens, 2000);
3007        assert_eq!(region.current_tokens, 0);
3008        assert_eq!(region.entry_count(), 0);
3009        assert!(region.content.is_empty());
3010    }
3011
3012    #[test]
3013    fn test_hashmap_upsert_insert_new_entry() {
3014        let mut region = Region::new(
3015            "store".to_string(),
3016            RegionKind::HashMap {
3017                max_entries: Some(5),
3018            },
3019            5000,
3020        );
3021        region
3022            .upsert_by_key("config.toml", "[package]\nname = \"foo\"".to_string(), 12)
3023            .unwrap();
3024
3025        assert_eq!(region.entry_count(), 1);
3026        assert_eq!(region.current_tokens, 12);
3027
3028        let entry = region.get_by_key("config.toml").unwrap();
3029        assert_eq!(entry.content, "[package]\nname = \"foo\"");
3030        assert_eq!(entry.tokens, 12);
3031        assert_eq!(entry.key.as_deref(), Some("config.toml"));
3032    }
3033
3034    #[test]
3035    fn test_hashmap_upsert_update_existing_entry() {
3036        let mut region = Region::new(
3037            "store".to_string(),
3038            RegionKind::HashMap { max_entries: None },
3039            5000,
3040        );
3041        region
3042            .upsert_by_key("readme.md", "# Old".to_string(), 20)
3043            .unwrap();
3044        assert_eq!(region.current_tokens, 20);
3045
3046        region
3047            .upsert_by_key("readme.md", "# New and improved".to_string(), 35)
3048            .unwrap();
3049        assert_eq!(region.entry_count(), 1);
3050        assert_eq!(region.current_tokens, 35);
3051
3052        let entry = region.get_by_key("readme.md").unwrap();
3053        assert_eq!(entry.content, "# New and improved");
3054        assert_eq!(entry.tokens, 35);
3055    }
3056
3057    #[test]
3058    fn test_hashmap_upsert_lru_eviction_on_max_tokens() {
3059        let mut region = Region::new(
3060            "files".to_string(),
3061            RegionKind::HashMap { max_entries: None },
3062            100, // small token budget
3063        );
3064
3065        // Insert entries that together fill the budget
3066        region
3067            .upsert_by_key("first.rs", "first content".to_string(), 40)
3068            .unwrap();
3069        region.content[0].timestamp -= 200; // oldest
3070
3071        region
3072            .upsert_by_key("second.rs", "second content".to_string(), 40)
3073            .unwrap();
3074        region.content[1].timestamp -= 100; // middle age
3075
3076        region
3077            .upsert_by_key("third.rs", "third content".to_string(), 20)
3078            .unwrap();
3079        // total = 100, at budget
3080
3081        // Inserting another entry that exceeds budget should evict oldest
3082        region
3083            .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
3084            .unwrap();
3085
3086        // first.rs (oldest timestamp) should have been evicted
3087        assert!(region.get_by_key("first.rs").is_none());
3088        assert!(region.get_by_key("fourth.rs").is_some());
3089        // total tokens should be within budget
3090        assert!(region.current_tokens <= 100);
3091    }
3092
3093    #[test]
3094    fn test_hashmap_upsert_max_entries_enforcement() {
3095        let mut region = Region::new(
3096            "cache".to_string(),
3097            RegionKind::HashMap {
3098                max_entries: Some(2),
3099            },
3100            50000,
3101        );
3102
3103        region
3104            .upsert_by_key("alpha", "aaa".to_string(), 10)
3105            .unwrap();
3106        region.content[0].timestamp -= 200; // make oldest
3107
3108        region.upsert_by_key("beta", "bbb".to_string(), 10).unwrap();
3109        region.content[1].timestamp -= 100;
3110
3111        region
3112            .upsert_by_key("gamma", "ccc".to_string(), 10)
3113            .unwrap();
3114
3115        // Only 2 entries should remain, oldest evicted
3116        assert_eq!(region.entry_count(), 2);
3117        assert!(region.get_by_key("alpha").is_none());
3118        assert!(region.get_by_key("beta").is_some());
3119        assert!(region.get_by_key("gamma").is_some());
3120    }
3121
3122    #[test]
3123    fn test_hashmap_get_by_key_found_and_not_found() {
3124        let mut region = Region::new(
3125            "data".to_string(),
3126            RegionKind::HashMap { max_entries: None },
3127            5000,
3128        );
3129        region
3130            .upsert_by_key("exists", "hello".to_string(), 5)
3131            .unwrap();
3132
3133        // Found
3134        let found = region.get_by_key("exists");
3135        assert!(found.is_some());
3136        assert_eq!(found.unwrap().content, "hello");
3137
3138        // Not found
3139        let missing = region.get_by_key("does_not_exist");
3140        assert!(missing.is_none());
3141    }
3142
3143    #[test]
3144    fn test_hashmap_remove_by_key_exists() {
3145        let mut region = Region::new(
3146            "data".to_string(),
3147            RegionKind::HashMap { max_entries: None },
3148            5000,
3149        );
3150        region
3151            .upsert_by_key("target", "remove me".to_string(), 25)
3152            .unwrap();
3153        assert_eq!(region.current_tokens, 25);
3154
3155        let removed = region.remove_by_key("target");
3156        assert!(removed);
3157        assert_eq!(region.entry_count(), 0);
3158        assert_eq!(region.current_tokens, 0);
3159        assert!(region.get_by_key("target").is_none());
3160    }
3161
3162    #[test]
3163    fn test_hashmap_remove_by_key_does_not_exist() {
3164        let mut region = Region::new(
3165            "data".to_string(),
3166            RegionKind::HashMap { max_entries: None },
3167            5000,
3168        );
3169        let removed = region.remove_by_key("ghost");
3170        assert!(!removed);
3171    }
3172
3173    #[test]
3174    fn test_hashmap_keys_empty_populated_after_removal() {
3175        let mut region = Region::new(
3176            "data".to_string(),
3177            RegionKind::HashMap { max_entries: None },
3178            5000,
3179        );
3180
3181        // Empty
3182        assert!(region.keys().is_empty());
3183
3184        // Populated
3185        region.upsert_by_key("one", "1".to_string(), 5).unwrap();
3186        region.upsert_by_key("two", "2".to_string(), 5).unwrap();
3187        region.upsert_by_key("three", "3".to_string(), 5).unwrap();
3188
3189        let keys = region.keys();
3190        assert_eq!(keys.len(), 3);
3191        assert!(keys.contains(&"one"));
3192        assert!(keys.contains(&"two"));
3193        assert!(keys.contains(&"three"));
3194
3195        // After removal
3196        region.remove_by_key("two");
3197        let keys = region.keys();
3198        assert_eq!(keys.len(), 2);
3199        assert!(keys.contains(&"one"));
3200        assert!(!keys.contains(&"two"));
3201        assert!(keys.contains(&"three"));
3202    }
3203
3204    #[test]
3205    fn test_region_entry_serialization_with_key_field() {
3206        // Entry with key
3207        let entry_with_key = RegionEntry {
3208            content: "some data".to_string(),
3209            tokens: 10,
3210            timestamp: 1234567890,
3211            metadata: None,
3212            kind: EntryKind::default(),
3213            key: Some("mykey".to_string()),
3214        };
3215        let json = serde_json::to_string(&entry_with_key).unwrap();
3216        let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3217        assert_eq!(deserialized.key.as_deref(), Some("mykey"));
3218        assert_eq!(deserialized.content, "some data");
3219        assert_eq!(deserialized.tokens, 10);
3220
3221        // Entry without key
3222        let entry_no_key = RegionEntry {
3223            content: "no key data".to_string(),
3224            tokens: 7,
3225            timestamp: 1234567890,
3226            metadata: None,
3227            kind: EntryKind::default(),
3228            key: None,
3229        };
3230        let json = serde_json::to_string(&entry_no_key).unwrap();
3231        assert!(!json.contains("\"key\""));
3232        let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3233        assert!(deserialized.key.is_none());
3234        assert_eq!(deserialized.content, "no key data");
3235    }
3236
3237    #[test]
3238    fn test_hashmap_partial_eq() {
3239        let a = RegionKind::HashMap {
3240            max_entries: Some(5),
3241        };
3242        let b = RegionKind::HashMap {
3243            max_entries: Some(5),
3244        };
3245        let c = RegionKind::HashMap {
3246            max_entries: Some(10),
3247        };
3248        let d = RegionKind::HashMap { max_entries: None };
3249
3250        assert_eq!(a, b);
3251        assert_ne!(a, c);
3252        assert_ne!(a, d);
3253        assert_ne!(c, d);
3254        assert_ne!(a, RegionKind::Pinned);
3255        assert_ne!(a, RegionKind::Temporary);
3256    }
3257
3258    #[test]
3259    fn test_hashmap_cache_hint_returns_until_changed() {
3260        let kind = RegionKind::HashMap { max_entries: None };
3261        assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3262
3263        let kind_with_max = RegionKind::HashMap {
3264            max_entries: Some(10),
3265        };
3266        assert_eq!(
3267            kind_with_max.cache_hint(),
3268            crate::cache::CacheHint::UntilChanged
3269        );
3270    }
3271
3272    // ─── taint-vector fixups on keyed removal / LRU eviction ───────────────
3273
3274    #[test]
3275    fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3276        // A taint-tracked region: remove_by_key must run its taint-vector
3277        // fixup branch (`taint.remove_at`) without panicking.
3278        let mut region = Region::new(
3279            "kv".to_string(),
3280            RegionKind::HashMap { max_entries: None },
3281            10_000,
3282        )
3283        .with_taint_tracking();
3284        region
3285            .upsert_by_key("k1", "value one".to_string(), 10)
3286            .unwrap();
3287        region
3288            .upsert_by_key("k2", "value two".to_string(), 10)
3289            .unwrap();
3290
3291        assert!(region.remove_by_key("k1"));
3292        assert!(!region.remove_by_key("missing"));
3293        assert_eq!(region.entry_count(), 1);
3294        assert_eq!(region.current_tokens, 10);
3295    }
3296
3297    #[test]
3298    fn test_evict_lru_entry_runs_taint_fixup() {
3299        // A taint-tracked HashMap region with a max_entries cap: inserting past
3300        // the cap triggers evict_lru_entry, which must run its taint-vector
3301        // fixup branch.
3302        let mut region = Region::new(
3303            "kv".to_string(),
3304            RegionKind::HashMap {
3305                max_entries: Some(1),
3306            },
3307            10_000,
3308        )
3309        .with_taint_tracking();
3310        region
3311            .upsert_by_key("first", "aaa".to_string(), 10)
3312            .unwrap();
3313        region
3314            .upsert_by_key("second", "bbb".to_string(), 10)
3315            .unwrap();
3316
3317        // Only the most-recently-inserted key survives after LRU eviction.
3318        assert_eq!(region.entry_count(), 1);
3319        assert!(region.get_by_key("second").is_some());
3320        assert!(region.get_by_key("first").is_none());
3321    }
3322
3323    #[test]
3324    fn test_evict_lru_entry_on_empty_region_is_noop() {
3325        // Directly exercise the early-return guard in `evict_lru_entry` when
3326        // there is nothing to evict - a defensive branch not reachable through
3327        // the public upsert path (which only evicts non-empty regions).
3328        let mut region = Region::new(
3329            "kv".to_string(),
3330            RegionKind::HashMap {
3331                max_entries: Some(4),
3332            },
3333            1000,
3334        );
3335        assert_eq!(region.entry_count(), 0);
3336        region.evict_lru_entry();
3337        assert_eq!(region.entry_count(), 0);
3338        assert_eq!(region.current_tokens, 0);
3339    }
3340
3341    /// Keys used to be a HashMap-only idea at the tool layer. The region API
3342    /// never cared, so an entry on any kind can carry one - which is what makes
3343    /// `context_delete` work on a sources region.
3344    #[test]
3345    fn a_keyed_entry_can_be_added_to_any_region_kind_and_found_again() {
3346        for kind in [
3347            RegionKind::Temporary,
3348            RegionKind::Clearable,
3349            RegionKind::Pinned,
3350        ] {
3351            let mut region = Region::new("r".to_string(), kind.clone(), 1000);
3352            region
3353                .add_keyed_entry("doc", "body".to_string(), 10)
3354                .unwrap();
3355            assert_eq!(
3356                region.get_by_key("doc").map(|e| e.content.as_str()),
3357                Some("body"),
3358                "{kind:?}"
3359            );
3360            assert!(region.remove_by_key("doc"), "{kind:?}");
3361            assert_eq!(region.current_tokens, 0, "{kind:?}");
3362        }
3363    }
3364
3365    /// Appending the same key twice keeps both, unlike `upsert_by_key`. Two
3366    /// halves of one source are still both wanted; an append that quietly
3367    /// replaced the first half would lose content the agent had gathered.
3368    #[test]
3369    fn appending_under_one_key_twice_keeps_both_entries() {
3370        let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3371        region
3372            .add_keyed_entry("doc", "first".to_string(), 5)
3373            .unwrap();
3374        region
3375            .add_keyed_entry("doc", "second".to_string(), 5)
3376            .unwrap();
3377        assert_eq!(region.content.len(), 2);
3378        assert_eq!(region.current_tokens, 10);
3379    }
3380
3381    /// A refused write leaves nothing behind - notably no half-added entry
3382    /// waiting to be given a key.
3383    #[test]
3384    fn a_refused_keyed_write_adds_nothing() {
3385        let mut region = Region::new("r".to_string(), RegionKind::Temporary, 10);
3386        assert!(
3387            region
3388                .add_keyed_entry("doc", "too big".to_string(), 99)
3389                .is_err()
3390        );
3391        assert!(region.content.is_empty());
3392        assert_eq!(region.current_tokens, 0);
3393    }
3394
3395    /// Releasing by position, including the out-of-range answer an agent gets
3396    /// when it names one that is not there.
3397    #[test]
3398    fn remove_at_releases_by_position_and_reports_a_miss() {
3399        let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3400        for text in ["a", "b", "c"] {
3401            region.add_entry(text.to_string(), 5).unwrap();
3402        }
3403        assert!(region.remove_at(1));
3404        assert_eq!(region.current_tokens, 10);
3405        let left: Vec<_> = region.content.iter().map(|e| e.content.as_str()).collect();
3406        assert_eq!(left, vec!["a", "c"]);
3407
3408        assert!(!region.remove_at(9), "nothing at that position");
3409        assert_eq!(region.content.len(), 2, "a miss changes nothing");
3410    }
3411
3412    /// Asking for more than the region holds is not an error: the agent wanted
3413    /// room and got as much as there was.
3414    #[test]
3415    fn release_oldest_takes_what_it_can_and_says_how_much() {
3416        let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3417        for text in ["a", "b", "c"] {
3418            region.add_entry(text.to_string(), 5).unwrap();
3419        }
3420        assert_eq!(region.release_oldest(2), 2);
3421        assert_eq!(
3422            region.content.first().map(|e| e.content.as_str()),
3423            Some("c"),
3424            "the oldest two went"
3425        );
3426        assert_eq!(region.release_oldest(10), 1, "only one was left");
3427        assert_eq!(region.release_oldest(3), 0, "and now none");
3428        assert_eq!(region.current_tokens, 0);
3429    }
3430
3431    /// The two refusals a `reject` region can give, and the distinction between
3432    /// them. An empty region reports the budget, because "release something"
3433    /// would be advice with nothing to act on - the write is simply too big.
3434    #[test]
3435    fn a_reject_region_distinguishes_being_full_from_an_oversized_write() {
3436        let mut region = Region::new("r".to_string(), RegionKind::Temporary, 100);
3437        region.admission = Admission::Reject;
3438
3439        // Asserted through the message rather than the variant, because the
3440        // message is what reaches the agent - and it carries the region, the
3441        // usage and the ceiling, so it pins the payload too.
3442        //
3443        // Empty: nothing to release, so this is a budget problem.
3444        let err = region
3445            .add_entry("huge".to_string(), 500)
3446            .unwrap_err()
3447            .to_string();
3448        assert!(err.contains("exceeds token budget"), "{err}");
3449
3450        region.add_entry("fits".to_string(), 90).unwrap();
3451        let err = region
3452            .add_entry("more".to_string(), 50)
3453            .unwrap_err()
3454            .to_string();
3455        assert!(err.contains("Region 'r' is full"), "{err}");
3456        assert!(err.contains("90/100 tokens"), "{err}");
3457        assert!(err.contains("release an entry"), "says what to do: {err}");
3458    }
3459
3460    /// The count-based half: a sliding window under `reject` refuses rather
3461    /// than rolling the oldest entry off. Checked before the push, because
3462    /// `enforce_sliding_window` runs on the way out and would already have
3463    /// dropped it.
3464    #[test]
3465    fn a_reject_sliding_window_refuses_rather_than_rolling_off() {
3466        let mut region = Region::new(
3467            "r".to_string(),
3468            RegionKind::SlidingWindow {
3469                max_items: 2,
3470                eviction_strategy: EvictionStrategy::PerItem,
3471            },
3472            1000,
3473        );
3474        region.admission = Admission::Reject;
3475        region.add_entry("one".to_string(), 5).unwrap();
3476        region.add_entry("two".to_string(), 5).unwrap();
3477
3478        let err = region
3479            .add_entry("three".to_string(), 5)
3480            .unwrap_err()
3481            .to_string();
3482        assert!(err.contains("is full"), "{err}");
3483        assert_eq!(region.content.len(), 2);
3484        assert_eq!(
3485            region.content.first().map(|e| e.content.as_str()),
3486            Some("one"),
3487            "the oldest survived"
3488        );
3489
3490        // The same window under the default still rolls off, which is what
3491        // every existing blueprint depends on.
3492        let mut evicting = Region::new(
3493            "r".to_string(),
3494            RegionKind::SlidingWindow {
3495                max_items: 2,
3496                eviction_strategy: EvictionStrategy::PerItem,
3497            },
3498            1000,
3499        );
3500        for text in ["one", "two", "three"] {
3501            evicting.add_entry(text.to_string(), 5).unwrap();
3502        }
3503        assert_eq!(evicting.content.len(), 2);
3504        assert_eq!(
3505            evicting.content.first().map(|e| e.content.as_str()),
3506            Some("two"),
3507            "the oldest rolled off as it always did"
3508        );
3509    }
3510}