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