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