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