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