Skip to main content

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