Skip to main content

leviath_runtime/
components.rs

1//! ECS components for agent state and execution.
2
3use bevy_ecs::prelude::*;
4use leviath_core::Region;
5use serde::{Deserialize, Serialize};
6
7/// Agent execution state component.
8///
9/// Tracks the current state of an agent's execution, including which stage
10/// it's in and iteration counts.
11#[derive(Component, Debug, Clone)]
12pub struct AgentState {
13    /// Unique identifier for this agent instance
14    pub agent_id: String,
15
16    /// Current execution stage
17    pub current_stage: String,
18
19    /// Number of iterations in current stage
20    pub iteration: usize,
21
22    /// Agent status
23    pub status: AgentStatus,
24
25    /// IDs of child agents spawned by this agent
26    pub spawned_children_ids: Vec<String>,
27
28    /// If set, this agent is blocked waiting for the named child to complete
29    pub pending_wait: Option<String>,
30
31    /// Whether the current stage accepts mid-run user messages.
32    /// When false, messages stay in the inbox until a stage that accepts them.
33    pub accepts_messages: bool,
34}
35
36/// Reference to a parent agent, making this agent a sub-agent.
37#[derive(Component, Debug, Clone)]
38pub struct ParentRef {
39    /// Entity of the parent agent
40    pub parent_entity: Entity,
41
42    /// Agent ID of the parent
43    pub parent_agent_id: String,
44
45    /// Depth in the agent tree (root = 0)
46    pub depth: usize,
47}
48
49/// Tracks child agents spawned by this agent.
50#[derive(Component, Debug, Clone)]
51pub struct SubAgentChildren {
52    /// Child agent entities
53    pub children: Vec<Entity>,
54
55    /// Maximum allowed sub-agent tree depth
56    pub max_child_depth: usize,
57}
58
59/// Marker: this agent is blocked on an open user interaction (a tool-approval
60/// prompt, an `ask_user_*` question, or a plan-approval review).
61///
62/// Inserted by [`reflect_interaction_status`](crate::pipeline::reflect_interaction_status)
63/// when the shared [`InteractionHub`](crate::interaction_hub::InteractionHub)
64/// reports a pending request for the agent, and removed when that request
65/// clears. It records that the agent's `Waiting` status is interaction-driven,
66/// so the reflection is distinct from fan-out waiting
67/// ([`FanOutWaiting`](crate::fanout::FanOutWaiting)).
68#[derive(Component, Debug, Clone)]
69pub struct AwaitingInteraction;
70
71/// Marker: auto-approve this agent's taint-gate blocks instead of raising a
72/// gate prompt.
73///
74/// Set when an agent is launched with `--yolo` (approve everything, run
75/// unattended). The taint gate raises a `MultipleChoice` interaction that the
76/// tool-policy `--yolo` wildcard does not cover, so without this a headless run -
77/// e.g. one driven over the Agent Client Protocol, where no human can answer -
78/// would block forever on a gate no one resolves. When present,
79/// [`dispatch_tools`](crate::pipeline::dispatch_tools) still evaluates the gate
80/// (so an over-cleared call is recorded in the audit trail as
81/// [`YoloAutoApprove`](leviath_core::taint::GateDecisionSource::YoloAutoApprove))
82/// but auto-approves the call instead of raising a prompt - enforcement is
83/// waived, accountability is kept.
84#[derive(Component, Debug, Clone, Copy, Default)]
85pub struct GateAutoApprove;
86
87/// `--yolo`'s counterpart for blueprint-declared interaction points: approve
88/// them without opening a prompt.
89///
90/// A stage-boundary checkpoint (`plan_approval` and friends) blocks on the
91/// interaction hub exactly like a tool approval does, so an unattended run
92/// would park at the first one forever - the same dead end a blocking tool
93/// approval poses for a headless run, reached a different way. When present,
94/// [`dispatch_interaction_point`](crate::interaction_points::dispatch_interaction_point)
95/// still publishes the document to its region (so the decision is inspectable
96/// afterwards) but resolves the point as approved.
97#[derive(Component, Debug, Clone, Copy, Default)]
98pub struct InteractionAutoApprove;
99
100/// Status of an agent.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub enum AgentStatus {
103    /// Agent is idle, ready for tasks
104    Idle,
105
106    /// Agent is actively working on a task
107    Active,
108
109    /// Agent is waiting for input or external event
110    Waiting,
111
112    /// Agent has completed its task
113    Complete,
114
115    /// Agent encountered an error
116    Error { message: String },
117
118    /// Agent was cancelled by the user or system
119    Cancelled,
120}
121
122/// Result of an eviction attempt, including tokens freed and regions needing LLM compaction.
123#[derive(Debug, Clone)]
124pub struct EvictionResult {
125    /// Number of tokens freed by eviction phases 1-2 (Clearable + Temporary).
126    pub tokens_freed: usize,
127    /// Region names that need LLM-based compaction (phase 3).
128    pub needs_compaction: Vec<String>,
129}
130
131/// Per-stage inference configuration overrides.
132///
133/// Set on the agent entity before each stage to override default inference
134/// parameters like temperature and max output tokens. When absent, defaults
135/// are used (temperature 0.7, max output 4096).
136#[derive(Component, Debug, Clone, Default)]
137pub struct InferenceConfig {
138    /// Temperature override. If None, uses 0.7 (or 0.0 if model doesn't support it).
139    pub temperature: Option<f32>,
140    /// Max output tokens override. If None, caps at model's max_output_tokens capability.
141    pub max_output_tokens: Option<usize>,
142    /// Extra provider parameters from `[stages.<name>.model.parameters]` beyond
143    /// `temperature`/`max_output_tokens` (e.g. `top_p`, `stop`, `seed`,
144    /// `frequency_penalty`). Passed through to the provider request so models can
145    /// be tuned from the manifest. Empty when none are set.
146    pub extra_params: serde_json::Map<String, serde_json::Value>,
147    /// Whether to prepend the batch-tool-calls hint to this stage's system
148    /// prompt. Resolved from the global config → agent → stage cascade at spawn
149    /// (see [`leviath_core::taint::resolve_batch_tool_hint`]); `false` by default
150    /// so an unset config is a no-op.
151    pub batch_tool_hint: bool,
152    /// Per-stage cap on the wall-clock time (in seconds) one inference for this
153    /// stage may run (the whole call including retries). Sourced from
154    /// `[stages.<name>.model] request_timeout_secs`. When `Some`, it overrides the
155    /// default inference job timeout at dispatch; when `None`, the default applies.
156    pub request_timeout_secs: Option<u64>,
157}
158
159/// Per-entity tool result routing configuration.
160///
161/// When present on an entity, tool results are routed to the specified region(s)
162/// instead of the default "conversation" region.
163#[derive(Component, Debug, Clone)]
164pub struct ToolResultRoutingComponent {
165    /// The routing configuration.
166    pub routing: leviath_core::ToolResultRouting,
167}
168
169/// Result of assembling a context window into system blocks and conversation messages.
170///
171/// Produced by [`ContextWindow::assemble()`]. System-bound regions (Pinned,
172/// CompactHistory, etc.) become `system_blocks`; the messages region
173/// (SlidingWindow) becomes typed `messages`.
174#[derive(Debug, Clone)]
175pub struct AssembledContext {
176    /// System prompt blocks (from Pinned, CompactHistory, etc. regions).
177    pub system_blocks: Vec<leviath_providers::SystemBlock>,
178    /// Conversation messages with proper role typing.
179    pub messages: Vec<leviath_providers::Message>,
180}
181
182/// Sort priority for a system block's cache hint.
183///
184/// Anthropic caches system content by prefix matching, so the most stable
185/// blocks must sort first to form the cacheable prefix. Lower value = earlier.
186fn cache_hint_sort_priority(hint: leviath_core::CacheHint) -> u8 {
187    use leviath_core::CacheHint;
188    match hint {
189        CacheHint::Always => 0,               // Pinned, CompactHistory - most stable
190        CacheHint::SlidingPrefix { .. } => 1, // Partially stable
191        CacheHint::UntilChanged => 2,         // Compacting - changes on compaction
192        CacheHint::Never => 3,                // Temporary, Clearable - changes every iteration
193    }
194}
195
196/// Context window component storing the agent's memory regions.
197#[derive(Component, Debug, Clone)]
198pub struct ContextWindow {
199    /// All regions in this context window
200    pub regions: Vec<Region>,
201
202    /// Current total token usage
203    pub current_tokens: usize,
204
205    /// Maximum token budget
206    pub max_tokens: usize,
207
208    /// Compiled custom-region scripts, keyed by the script path each
209    /// `RegionKind::Custom` carries. Populated once at spawn by the CLI
210    /// (which resolves blueprint-dir-relative paths and compile-checks the
211    /// files); a stage-layout swap rebuilds `regions` but leaves this table
212    /// untouched, so per-stage custom regions keep working. Empty when no
213    /// custom regions exist - every hook lookup then misses and the region
214    /// renders its fallback shape.
215    pub region_scripts: std::collections::HashMap<
216        String,
217        std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
218    >,
219}
220
221impl ContextWindow {
222    /// Create a new context window with the specified budget.
223    pub fn new(max_tokens: usize) -> Self {
224        Self {
225            regions: Vec::new(),
226            current_tokens: 0,
227            max_tokens,
228            region_scripts: std::collections::HashMap::new(),
229        }
230    }
231
232    /// The compiled script backing `region_name`, when it is a custom region
233    /// whose script path has an entry in [`Self::region_scripts`].
234    fn custom_script_for(
235        &self,
236        region_name: &str,
237    ) -> Option<std::sync::Arc<leviath_scripting::region_hook::RegionScript>> {
238        let region = self.get_region(region_name)?;
239        let leviath_core::RegionKind::Custom { script, .. } = &region.kind else {
240            return None;
241        };
242        self.region_scripts.get(script).cloned()
243    }
244
245    /// Run a custom region's `on_write` hook (when defined) for an incoming
246    /// entry. `None` means the script dropped the entry - the write reports
247    /// success without storing anything. Non-custom regions, missing scripts,
248    /// and hook failures all accept the entry unchanged.
249    ///
250    /// Deliberately NOT invoked by the layout-swap carry or restore overlay:
251    /// those re-add entries the hook already accepted once.
252    fn on_write_outcome(
253        &self,
254        region_name: &str,
255        content: String,
256        tokens: usize,
257        kind: &leviath_core::EntryKind,
258    ) -> Option<(String, usize)> {
259        let Some(script) = self.custom_script_for(region_name) else {
260            return Some((content, tokens));
261        };
262        if !script.has_on_write() {
263            return Some((content, tokens));
264        }
265        // The region exists - custom_script_for resolved through it.
266        let region = self
267            .get_region(region_name)
268            .expect("custom_script_for resolved through this region");
269        match crate::custom_region::apply_on_write(&script, region, content, tokens, kind) {
270            crate::custom_region::OnWriteOutcome::Accept(content, tokens) => {
271                Some((content, tokens))
272            }
273            crate::custom_region::OnWriteOutcome::Drop => None,
274        }
275    }
276
277    /// Retry hook for a custom-region write that hit `TokenBudgetExceeded`:
278    /// let the script's `on_overflow` free room, then report whether a single
279    /// retry is worthwhile. Non-custom regions and hook failures leave the
280    /// original error standing (the callers' existing truncation ladders
281    /// apply).
282    fn try_custom_overflow(&mut self, region_name: &str, incoming_tokens: usize) -> bool {
283        let Some(script) = self.custom_script_for(region_name) else {
284            return false;
285        };
286        if !script.has_on_overflow() {
287            return false;
288        }
289        let region = self
290            .get_region_mut(region_name)
291            .expect("custom_script_for resolved through this region");
292        let needed = (region.current_tokens + incoming_tokens).saturating_sub(region.max_tokens);
293        let freed = crate::custom_region::apply_overflow(&script, region, needed);
294        self.current_tokens = self.calculate_tokens();
295        freed >= needed && needed > 0
296    }
297
298    /// Get a region by name.
299    pub fn get_region(&self, name: &str) -> Option<&Region> {
300        self.regions.iter().find(|r| r.name == name)
301    }
302
303    /// Get a mutable reference to a region by name.
304    pub fn get_region_mut(&mut self, name: &str) -> Option<&mut Region> {
305        self.regions.iter_mut().find(|r| r.name == name)
306    }
307
308    /// Add a region to this context window.
309    pub fn add_region(&mut self, region: Region) {
310        self.regions.push(region);
311        self.current_tokens = self.calculate_tokens();
312    }
313
314    /// Add content to a specific region.
315    pub fn add_to_region(
316        &mut self,
317        region_name: &str,
318        content: String,
319        tokens: usize,
320    ) -> leviath_core::Result<()> {
321        let Some((content, tokens)) =
322            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
323        else {
324            return Ok(()); // the region's script dropped the entry
325        };
326        self.write_to_region(region_name, tokens, &mut |region, tokens| {
327            region.add_entry(content.clone(), tokens)
328        })
329    }
330
331    /// Replace a region's entire content with a single entry (clear, then add).
332    /// Returns `false` (no-op) if the region does not exist. Used to keep an
333    /// authoritative document region (e.g. the plan) holding only its current
334    /// version, so revisions build on it instead of accumulating stale copies.
335    pub fn replace_region(&mut self, region_name: &str, content: String, tokens: usize) -> bool {
336        // The replacement passes through on_write like any incoming entry - a
337        // custom region's script sees (and may transform or refuse) it.
338        let Some((content, tokens)) =
339            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
340        else {
341            // Dropped by the script: the region keeps its current content.
342            return self.get_region(region_name).is_some();
343        };
344        if let Some(region) = self.get_region_mut(region_name) {
345            region.clear();
346            let _ = region.add_entry(content, tokens);
347            self.current_tokens = self.calculate_tokens();
348            true
349        } else {
350            false
351        }
352    }
353
354    /// Add a typed entry to a specific region.
355    ///
356    /// Like [`add_to_region`](Self::add_to_region) but the entry carries an
357    /// `EntryKind` so message roles are determined by type, not text-prefix
358    /// parsing.
359    pub fn add_typed_entry(
360        &mut self,
361        region_name: &str,
362        kind: leviath_core::EntryKind,
363        content: String,
364        tokens: usize,
365    ) -> leviath_core::Result<()> {
366        let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
367        else {
368            return Ok(());
369        };
370        self.write_to_region(region_name, tokens, &mut |region, tokens| {
371            region.add_typed_entry(content.clone(), tokens, kind.clone())
372        })
373    }
374
375    /// Shared tail of every region write: run the insert, give a custom
376    /// region's `on_overflow` one shot at freeing room when the budget
377    /// rejects it, and recount the window. A `&mut dyn FnMut` (not generic)
378    /// keeps one instantiation for the coverage gate.
379    fn write_to_region(
380        &mut self,
381        region_name: &str,
382        tokens: usize,
383        insert: &mut dyn FnMut(&mut Region, usize) -> leviath_core::Result<()>,
384    ) -> leviath_core::Result<()> {
385        if self.get_region(region_name).is_none() {
386            return Err(leviath_core::Error::RegionNotFound(region_name.to_string()));
387        }
388        let first = {
389            let region = self.get_region_mut(region_name).expect("checked above");
390            insert(region, tokens)
391        };
392        match first {
393            Ok(()) => {
394                self.current_tokens = self.calculate_tokens();
395                Ok(())
396            }
397            Err(leviath_core::Error::TokenBudgetExceeded { .. })
398                if self.try_custom_overflow(region_name, tokens) =>
399            {
400                let region = self.get_region_mut(region_name).expect("checked above");
401                let retried = insert(region, tokens);
402                self.current_tokens = self.calculate_tokens();
403                retried
404            }
405            Err(e) => Err(e),
406        }
407    }
408
409    /// Calculate current token usage across all regions.
410    pub fn calculate_tokens(&self) -> usize {
411        self.regions.iter().map(|r| r.current_tokens).sum()
412    }
413
414    /// Check if the context window needs eviction.
415    pub fn needs_eviction(&self, threshold: f32) -> bool {
416        let usage_ratio = self.current_tokens as f32 / self.max_tokens as f32;
417        usage_ratio >= threshold
418    }
419
420    /// Execute eviction cascade to free up space.
421    ///
422    /// Returns an `EvictionResult` with tokens freed and any regions that need
423    /// LLM-based compaction. The caller is responsible for performing compaction
424    /// on the listed regions (since it requires async LLM access).
425    pub fn try_evict(&mut self, target_free_tokens: usize) -> leviath_core::Result<EvictionResult> {
426        use leviath_core::RegionKind;
427
428        let initial_tokens = self.current_tokens;
429
430        // Check if we have any evictable regions
431        let has_evictable = self.regions.iter().any(|r| {
432            matches!(
433                r.kind,
434                RegionKind::Clearable
435                    | RegionKind::Temporary
436                    | RegionKind::Custom {
437                        persistent: false,
438                        ..
439                    }
440            )
441        });
442
443        if !has_evictable {
444            tracing::warn!(
445                "Context window has no Clearable or Temporary regions. \
446                 This may be intentional, but usually indicates a configuration error."
447            );
448        }
449
450        // Phase 1: Clear Clearable regions (all-or-nothing)
451        for region in &mut self.regions {
452            if matches!(region.kind, RegionKind::Clearable) && !region.content.is_empty() {
453                let freed = region.current_tokens;
454                region.clear();
455                self.current_tokens -= freed;
456                tracing::debug!(
457                    region = %region.name,
458                    tokens_freed = freed,
459                    "Cleared Clearable region (all-or-nothing)"
460                );
461
462                if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
463                    return Ok(EvictionResult {
464                        tokens_freed: initial_tokens - self.current_tokens,
465                        needs_compaction: Vec::new(),
466                    });
467                }
468            }
469        }
470
471        // Phase 1.5: Give each non-persistent custom region's on_overflow
472        // hook first say over what IT loses, before the indiscriminate
473        // oldest-first cascade below. A script that keeps errors and drops
474        // successes only works if it runs before oldest-first does. Hook
475        // absent/failing/insufficient → phase 2 makes the guaranteed
476        // progress.
477        let mut custom_freed = 0usize;
478        for i in 0..self.regions.len() {
479            let needed = target_free_tokens
480                .saturating_sub(self.max_tokens.saturating_sub(self.current_tokens));
481            if needed == 0 {
482                break;
483            }
484            let region = &self.regions[i];
485            if !matches!(
486                region.kind,
487                RegionKind::Custom {
488                    persistent: false,
489                    ..
490                }
491            ) || region.content.is_empty()
492            {
493                continue;
494            }
495            let Some(script) = self.custom_script_for(&region.name.clone()) else {
496                continue;
497            };
498            if !script.has_on_overflow() {
499                continue;
500            }
501            let freed = crate::custom_region::apply_overflow(&script, &mut self.regions[i], needed);
502            self.current_tokens = self.current_tokens.saturating_sub(freed);
503            custom_freed += freed;
504            if freed > 0 {
505                tracing::debug!(
506                    region = %self.regions[i].name,
507                    tokens_freed = freed,
508                    "custom region's on_overflow chose its own evictions"
509                );
510            }
511        }
512        // Return early ONLY when a script's own drops satisfied the target -
513        // otherwise phase 2 would immediately evict one more entry (it checks
514        // the target *after* each eviction), overriding the script's
515        // retention choice. Windows with no custom drops (custom_freed == 0)
516        // fall through with phase 2's pre-existing behavior, byte-identical.
517        if custom_freed > 0
518            && self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens
519        {
520            return Ok(EvictionResult {
521                tokens_freed: initial_tokens - self.current_tokens,
522                needs_compaction: Vec::new(),
523            });
524        }
525
526        // Phase 2: Evict from Temporary regions (oldest first, one at a time).
527        // Non-persistent Custom regions join this phase: their script's
528        // on_overflow hook (when present) has already had its say in phase
529        // 1.5; oldest-first is the guaranteed-progress fallback.
530        loop {
531            let mut evicted_any = false;
532
533            for region in &mut self.regions {
534                if matches!(
535                    region.kind,
536                    RegionKind::Temporary
537                        | RegionKind::Custom {
538                            persistent: false,
539                            ..
540                        }
541                ) && let Some(entry) = region.remove_oldest()
542                {
543                    let freed = entry.tokens;
544                    self.current_tokens -= freed;
545                    evicted_any = true;
546
547                    tracing::debug!(
548                        region = %region.name,
549                        tokens_freed = freed,
550                        "Evicted temporary region entry (oldest first)"
551                    );
552
553                    if self.max_tokens.saturating_sub(self.current_tokens) >= target_free_tokens {
554                        return Ok(EvictionResult {
555                            tokens_freed: initial_tokens - self.current_tokens,
556                            needs_compaction: Vec::new(),
557                        });
558                    }
559                }
560            }
561
562            if !evicted_any {
563                break;
564            }
565        }
566
567        // Phase 3: If still need space, identify Compacting regions that need compaction
568        let mut needs_compaction = Vec::new();
569        if self.max_tokens.saturating_sub(self.current_tokens) < target_free_tokens {
570            for region in &self.regions {
571                if region.needs_compaction() {
572                    needs_compaction.push(region.name.clone());
573                }
574            }
575        }
576
577        // Phase 4: SlidingWindow regions are NEVER reduced
578        // Phase 5: Pinned and CompactHistory regions are NEVER touched
579
580        // Check for pinned regions over budget
581        let pinned_tokens: usize = self
582            .regions
583            .iter()
584            .filter(|r| {
585                matches!(
586                    r.kind,
587                    RegionKind::Pinned
588                        | RegionKind::CompactHistory { .. }
589                        | RegionKind::Custom {
590                            persistent: true,
591                            ..
592                        }
593                )
594            })
595            .map(|r| r.current_tokens)
596            .sum();
597
598        if pinned_tokens > self.max_tokens {
599            return Err(leviath_core::Error::PinnedRegionsOverBudget {
600                pinned_tokens,
601                total_budget: self.max_tokens,
602            });
603        }
604
605        Ok(EvictionResult {
606            tokens_freed: initial_tokens - self.current_tokens,
607            needs_compaction,
608        })
609    }
610
611    /// Result of assembling the context window into system blocks + messages.
612    ///
613    /// System-bound regions become `system_blocks`; the messages region
614    /// becomes `messages` with proper typed entries (no text-prefix parsing).
615    ///
616    /// Thin wrapper over [`assemble_with_meta`](Self::assemble_with_meta) with
617    /// no stage metadata - custom-region scripts see empty stage fields.
618    pub fn assemble(&self) -> AssembledContext {
619        self.assemble_with_meta(&crate::custom_region::AssembleMeta::default())
620    }
621
622    /// [`assemble`](Self::assemble) with stage metadata for custom-region
623    /// `render(ctx)` hooks (stage name, per-stage iteration count, model).
624    /// The inference path (`build_request`) threads real values; other
625    /// callers use the default.
626    pub fn assemble_with_meta(
627        &self,
628        meta: &crate::custom_region::AssembleMeta,
629    ) -> AssembledContext {
630        use leviath_core::{CacheHint, EntryKind};
631
632        let mut system_blocks = Vec::new();
633        let mut messages: Vec<leviath_providers::Message> = Vec::new();
634
635        for region in &self.regions {
636            // Custom regions render even when empty - a script may emit
637            // static scaffolding. Every other kind skips an empty region.
638            let is_custom = matches!(region.kind, leviath_core::RegionKind::Custom { .. });
639            if region.content.is_empty() && !is_custom {
640                continue;
641            }
642
643            match &region.kind {
644                // System-level content → system blocks
645                leviath_core::RegionKind::Pinned => {
646                    let text = region
647                        .content
648                        .iter()
649                        .map(|e| e.content.as_str())
650                        .collect::<Vec<_>>()
651                        .join("\n\n");
652                    system_blocks.push(leviath_providers::SystemBlock {
653                        text,
654                        cache_hint: CacheHint::Always,
655                    });
656                }
657                leviath_core::RegionKind::CompactHistory { .. } => {
658                    let text = region
659                        .content
660                        .iter()
661                        .map(|e| e.content.as_str())
662                        .collect::<Vec<_>>()
663                        .join("\n\n");
664                    system_blocks.push(leviath_providers::SystemBlock {
665                        text,
666                        cache_hint: CacheHint::Always,
667                    });
668                }
669
670                // Messages region → Vec<Message> with proper typed entries.
671                // Consecutive ToolResult entries are merged into a single user
672                // message with multiple tool_result content blocks (required by
673                // Anthropic: one assistant tool_use msg → one user tool_result msg).
674                leviath_core::RegionKind::SlidingWindow { .. } => {
675                    let mut pending_tool_results: Vec<leviath_providers::ContentBlock> = Vec::new();
676
677                    for entry in &region.content {
678                        // Flush any pending tool results when we hit a non-ToolResult entry
679                        if !matches!(entry.kind, EntryKind::ToolResult { .. })
680                            && !pending_tool_results.is_empty()
681                        {
682                            messages.push(leviath_providers::Message {
683                                role: "user".to_string(),
684                                content: leviath_providers::MessageContent::Blocks(std::mem::take(
685                                    &mut pending_tool_results,
686                                )),
687                                cache_breakpoint: false,
688                            });
689                        }
690
691                        match &entry.kind {
692                            EntryKind::UserMessage => {
693                                messages.push(leviath_providers::Message {
694                                    role: "user".to_string(),
695                                    content: entry.content.clone().into(),
696                                    cache_breakpoint: false,
697                                });
698                            }
699                            EntryKind::AssistantTurn { tool_calls } => {
700                                if tool_calls.is_empty() {
701                                    messages.push(leviath_providers::Message {
702                                        role: "assistant".to_string(),
703                                        content: entry.content.clone().into(),
704                                        cache_breakpoint: false,
705                                    });
706                                } else {
707                                    let mut blocks = Vec::new();
708                                    if !entry.content.is_empty() {
709                                        blocks.push(leviath_providers::ContentBlock::Text {
710                                            text: entry.content.clone(),
711                                        });
712                                    }
713                                    for tc in tool_calls {
714                                        blocks.push(leviath_providers::ContentBlock::ToolUse {
715                                            id: tc.id.clone(),
716                                            name: tc.name.clone(),
717                                            input: tc.arguments.clone(),
718                                            thought_signature: tc.thought_signature.clone(),
719                                        });
720                                    }
721                                    messages.push(leviath_providers::Message {
722                                        role: "assistant".to_string(),
723                                        content: leviath_providers::MessageContent::Blocks(blocks),
724                                        cache_breakpoint: false,
725                                    });
726                                }
727                            }
728                            EntryKind::ToolResult {
729                                tool_call_id,
730                                is_error,
731                                ..
732                            } => {
733                                // Accumulate - will be flushed on next non-ToolResult or end
734                                pending_tool_results.push(
735                                    leviath_providers::ContentBlock::ToolResult {
736                                        tool_use_id: tool_call_id.clone(),
737                                        content: entry.content.clone(),
738                                        is_error: *is_error,
739                                    },
740                                );
741                            }
742                            EntryKind::Text => {
743                                let trimmed = entry.content.trim();
744                                if let Some(rest) = trimmed.strip_prefix("Assistant: ") {
745                                    messages.push(leviath_providers::Message {
746                                        role: "assistant".to_string(),
747                                        content: rest.to_string().into(),
748                                        cache_breakpoint: false,
749                                    });
750                                } else if let Some(rest) = trimmed.strip_prefix("User: ") {
751                                    messages.push(leviath_providers::Message {
752                                        role: "user".to_string(),
753                                        content: rest.to_string().into(),
754                                        cache_breakpoint: false,
755                                    });
756                                } else {
757                                    messages.push(leviath_providers::Message {
758                                        role: "user".to_string(),
759                                        content: entry.content.clone().into(),
760                                        cache_breakpoint: false,
761                                    });
762                                }
763                            }
764                        }
765                    }
766
767                    // Flush any remaining tool results at the end of the region
768                    if !pending_tool_results.is_empty() {
769                        messages.push(leviath_providers::Message {
770                            role: "user".to_string(),
771                            content: leviath_providers::MessageContent::Blocks(std::mem::take(
772                                &mut pending_tool_results,
773                            )),
774                            cache_breakpoint: false,
775                        });
776                    }
777                }
778
779                // Compacting / Temporary / Clearable → system blocks
780                leviath_core::RegionKind::Compacting { .. } => {
781                    let text = region
782                        .content
783                        .iter()
784                        .map(|e| e.content.as_str())
785                        .collect::<Vec<_>>()
786                        .join("\n\n");
787                    system_blocks.push(leviath_providers::SystemBlock {
788                        text: format!("[{}]:\n{}", region.name, text),
789                        cache_hint: CacheHint::UntilChanged,
790                    });
791                }
792                leviath_core::RegionKind::Temporary => {
793                    let text = region
794                        .content
795                        .iter()
796                        .map(|e| e.content.as_str())
797                        .collect::<Vec<_>>()
798                        .join("\n\n");
799                    system_blocks.push(leviath_providers::SystemBlock {
800                        text: format!("[{}]:\n{}", region.name, text),
801                        cache_hint: CacheHint::Never,
802                    });
803                }
804                leviath_core::RegionKind::Clearable => {
805                    let text = region
806                        .content
807                        .iter()
808                        .map(|e| e.content.as_str())
809                        .collect::<Vec<_>>()
810                        .join("\n\n");
811                    system_blocks.push(leviath_providers::SystemBlock {
812                        text: format!("[{}]:\n{}", region.name, text),
813                        cache_hint: CacheHint::Never,
814                    });
815                }
816
817                // Custom (script-backed) regions render through their Rhai
818                // hook; a missing script or any hook failure falls back to
819                // the Temporary-style block inside `render_custom_region`,
820                // so a custom region is never silently dropped.
821                leviath_core::RegionKind::Custom { script, persistent } => {
822                    crate::custom_region::render_custom_region(
823                        region,
824                        self.region_scripts.get(script),
825                        *persistent,
826                        meta,
827                        self.current_tokens,
828                        self.max_tokens,
829                        &mut system_blocks,
830                        &mut messages,
831                    );
832                }
833
834                // HashMap regions → system blocks with key headers
835                leviath_core::RegionKind::HashMap { .. } => {
836                    let text = region
837                        .content
838                        .iter()
839                        .map(|e| {
840                            if let Some(key) = &e.key {
841                                format!("### [{}]\n{}", key, e.content)
842                            } else {
843                                e.content.clone()
844                            }
845                        })
846                        .collect::<Vec<_>>()
847                        .join("\n\n");
848                    system_blocks.push(leviath_providers::SystemBlock {
849                        text: format!("[{}]:\n{}", region.name, text),
850                        cache_hint: CacheHint::UntilChanged,
851                    });
852                }
853            }
854        }
855
856        // ── Sort system blocks for optimal prefix caching ────────────────
857        //
858        // Anthropic caches system content based on prefix matching.
859        // Stable blocks (Pinned, CompactHistory) should come first so
860        // they form the cacheable prefix, with volatile blocks
861        // (Compacting, Temporary, Clearable) after.
862        system_blocks.sort_by_key(|block| cache_hint_sort_priority(block.cache_hint));
863
864        // ── Sanitize orphaned tool_use / tool_result blocks ──────────────
865        //
866        // Collect all tool_use IDs from assistant messages and all tool_result
867        // IDs from user messages. Strip any that don't have a matching pair.
868        let mut tool_use_ids = std::collections::HashSet::new();
869        let mut tool_result_ids = std::collections::HashSet::new();
870
871        for msg in &messages {
872            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
873                for block in blocks {
874                    match block {
875                        leviath_providers::ContentBlock::ToolUse { id, .. } => {
876                            tool_use_ids.insert(id.clone());
877                        }
878                        leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
879                            tool_result_ids.insert(tool_use_id.clone());
880                        }
881                        _ => {}
882                    }
883                }
884            }
885        }
886
887        let orphaned_tool_uses: std::collections::HashSet<_> =
888            tool_use_ids.difference(&tool_result_ids).cloned().collect();
889        let orphaned_tool_results: std::collections::HashSet<_> =
890            tool_result_ids.difference(&tool_use_ids).cloned().collect();
891
892        if !orphaned_tool_uses.is_empty() || !orphaned_tool_results.is_empty() {
893            tracing::warn!(
894                orphaned_tool_uses = orphaned_tool_uses.len(),
895                orphaned_tool_results = orphaned_tool_results.len(),
896                "Stripping orphaned tool_use/tool_result blocks from assembled context"
897            );
898
899            messages = messages
900                .into_iter()
901                .filter_map(|msg| {
902                    if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
903                        let filtered: Vec<_> = blocks
904                            .iter()
905                            .filter(|block| match block {
906                                leviath_providers::ContentBlock::ToolUse { id, .. } => {
907                                    !orphaned_tool_uses.contains(id)
908                                }
909                                leviath_providers::ContentBlock::ToolResult {
910                                    tool_use_id, ..
911                                } => !orphaned_tool_results.contains(tool_use_id),
912                                _ => true,
913                            })
914                            .cloned()
915                            .collect();
916
917                        if filtered.is_empty() {
918                            // No content left - drop this message entirely
919                            None
920                        } else {
921                            Some(leviath_providers::Message {
922                                role: msg.role.clone(),
923                                content: leviath_providers::MessageContent::Blocks(filtered),
924                                cache_breakpoint: msg.cache_breakpoint,
925                            })
926                        }
927                    } else {
928                        Some(msg)
929                    }
930                })
931                .collect();
932        }
933
934        // ── Set cache breakpoints on stable message prefix ──────────────
935        //
936        // In an iterative inference loop, only the last few messages change
937        // each iteration (new assistant turn + tool results). Everything
938        // before is stable across iterations and benefits from Anthropic's
939        // prompt caching. We place a cache breakpoint near the end of the
940        // stable prefix to maximize cache hits.
941        //
942        // Anthropic allows up to 4 breakpoints. We use 1 on messages
943        // (system blocks already have cache_control via CacheHint).
944        // Place it on the 4th-from-last message to give a buffer for the
945        // new messages added each iteration (typically 2-3).
946        if messages.len() >= 5 {
947            let bp_idx = messages.len() - 4;
948            messages[bp_idx].cache_breakpoint = true;
949        } else if messages.len() >= 2 {
950            // Small conversation - cache at least the first message
951            messages[0].cache_breakpoint = true;
952        }
953
954        // Ensure there's at least one user message
955        if !messages.iter().any(|m| m.role == "user") {
956            messages.push(leviath_providers::Message {
957                role: "user".to_string(),
958                content: "Begin.".into(),
959                cache_breakpoint: false,
960            });
961        }
962
963        // The conversation must END with a user message: providers reject a
964        // request that ends on an assistant turn as an (unsupported) prefill
965        // ("This model does not support assistant message prefill"). After a
966        // stage transition that carries the conversation, the last message is
967        // the previous stage's final assistant turn - hand the turn back to the
968        // model with a minimal nudge so it acts on the new stage's instructions.
969        if messages.last().map(|m| m.role.as_str()) == Some("assistant") {
970            messages.push(leviath_providers::Message {
971                role: "user".to_string(),
972                content: "Continue.".into(),
973                cache_breakpoint: false,
974            });
975        }
976
977        AssembledContext {
978            system_blocks,
979            messages,
980        }
981    }
982
983    /// Enable taint tracking on all regions in this context window.
984    pub fn enable_taint_tracking(&mut self) {
985        for region in &mut self.regions {
986            region.enable_taint_tracking();
987        }
988    }
989
990    /// Add tainted content to a specific region.
991    pub fn add_tainted_to_region(
992        &mut self,
993        region_name: &str,
994        content: String,
995        tokens: usize,
996        taint_level: leviath_core::TaintLevel,
997    ) -> leviath_core::Result<()> {
998        let Some((content, tokens)) =
999            self.on_write_outcome(region_name, content, tokens, &leviath_core::EntryKind::Text)
1000        else {
1001            return Ok(());
1002        };
1003        self.write_to_region(region_name, tokens, &mut |region, tokens| {
1004            region.add_tainted_entry(content.clone(), tokens, taint_level)
1005        })
1006    }
1007
1008    /// Add a typed entry to a region with a specific taint level.
1009    ///
1010    /// The typed+tainted counterpart of [`add_typed_entry`](Self::add_typed_entry)
1011    /// and [`add_tainted_to_region`](Self::add_tainted_to_region): the entry keeps
1012    /// its `EntryKind` (so turn-group eviction stays intact) while contributing
1013    /// the given taint level (so the taint gate sees sensitive tool output).
1014    pub fn add_typed_tainted_to_region(
1015        &mut self,
1016        region_name: &str,
1017        kind: leviath_core::EntryKind,
1018        content: String,
1019        tokens: usize,
1020        taint_level: leviath_core::TaintLevel,
1021    ) -> leviath_core::Result<()> {
1022        let Some((content, tokens)) = self.on_write_outcome(region_name, content, tokens, &kind)
1023        else {
1024            return Ok(());
1025        };
1026        self.write_to_region(region_name, tokens, &mut |region, tokens| {
1027            region.add_typed_tainted_entry(content.clone(), tokens, kind.clone(), taint_level)
1028        })
1029    }
1030
1031    /// Get the overall taint level (max across all regions).
1032    /// Returns None if no region has taint tracking enabled.
1033    pub fn overall_taint(&self) -> Option<leviath_core::TaintLevel> {
1034        let mut max_taint = None;
1035        for region in &self.regions {
1036            if let Some(level) = region.taint_level() {
1037                max_taint = Some(match max_taint {
1038                    Some(current) => level.max(current),
1039                    None => level,
1040                });
1041            }
1042        }
1043        max_taint
1044    }
1045
1046    /// Get a summary of taint levels across all regions (for dashboard/audit).
1047    pub fn taint_summary(&self) -> Vec<(String, leviath_core::TaintLevel)> {
1048        self.regions
1049            .iter()
1050            .filter_map(|r| r.taint_level().map(|t| (r.name.clone(), t)))
1051            .collect()
1052    }
1053}
1054
1055/// Inference result component.
1056///
1057/// Stores the result of an LLM inference call, including the response
1058/// and any tool calls that need to be executed.
1059#[derive(Component, Debug, Clone)]
1060pub struct InferenceResult {
1061    /// The model's response text
1062    pub response: String,
1063
1064    /// Tool calls requested by the model
1065    pub tool_calls: Vec<ToolCall>,
1066
1067    /// Tokens used in this inference
1068    pub tokens_used: usize,
1069
1070    /// Timestamp of this inference
1071    pub timestamp: i64,
1072}
1073
1074/// A tool call requested by the model.
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct ToolCall {
1077    /// Tool identifier
1078    pub tool_id: String,
1079
1080    /// Tool name
1081    pub name: String,
1082
1083    /// Tool arguments
1084    pub arguments: serde_json::Value,
1085    /// Opaque provider token echoed back with this call on the next request
1086    /// (Gemini's `thought_signature`); `None` when the provider has none.
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub thought_signature: Option<String>,
1089}
1090
1091/// A message that can be sent to a running agent.
1092#[derive(Debug, Clone)]
1093pub struct AgentMessage {
1094    /// Target agent ID
1095    pub agent_id: String,
1096    /// Message content
1097    pub content: String,
1098    /// Which region to add the message to (default: "conversation")
1099    pub target_region: Option<String>,
1100    /// Priority (higher = processed sooner)
1101    pub priority: i32,
1102}
1103
1104/// Inbox component for receiving messages sent to a running agent.
1105#[derive(Component, Debug, Clone)]
1106pub struct MessageInbox {
1107    /// Pending messages waiting to be processed
1108    pub messages: Vec<AgentMessage>,
1109}
1110
1111impl MessageInbox {
1112    /// Create a new empty inbox.
1113    pub fn new() -> Self {
1114        Self {
1115            messages: Vec::new(),
1116        }
1117    }
1118
1119    /// Add a message to the inbox.
1120    pub fn push(&mut self, msg: AgentMessage) {
1121        self.messages.push(msg);
1122        // Sort by priority descending so highest priority is first
1123        self.messages.sort_by_key(|m| std::cmp::Reverse(m.priority));
1124    }
1125
1126    /// Drain all messages from the inbox.
1127    pub fn drain_all(&mut self) -> Vec<AgentMessage> {
1128        std::mem::take(&mut self.messages)
1129    }
1130}
1131
1132impl Default for MessageInbox {
1133    fn default() -> Self {
1134        Self::new()
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use crate::test_support::with_tracing;
1142    use leviath_core::{EvictionStrategy, Region, RegionKind};
1143
1144    #[test]
1145    fn test_context_window_creation() {
1146        let window = ContextWindow::new(10000);
1147        assert_eq!(window.max_tokens, 10000);
1148        assert_eq!(window.current_tokens, 0);
1149    }
1150
1151    #[test]
1152    fn test_needs_eviction() {
1153        let mut window = ContextWindow::new(10000);
1154        window.current_tokens = 9500;
1155        assert!(window.needs_eviction(0.9));
1156
1157        window.current_tokens = 5000;
1158        assert!(!window.needs_eviction(0.9));
1159    }
1160
1161    #[test]
1162    fn test_add_region() {
1163        let mut window = ContextWindow::new(10000);
1164        let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1165        window.add_region(region);
1166        assert_eq!(window.regions.len(), 1);
1167    }
1168
1169    #[test]
1170    fn replace_region_overwrites_existing_and_reports_missing() {
1171        let mut window = ContextWindow::new(10000);
1172        let mut region = Region::new("plan".to_string(), RegionKind::Pinned, 6000);
1173        region.add_entry("old plan".to_string(), 3).unwrap();
1174        window.add_region(region);
1175
1176        // Replacing an existing region overwrites its content wholesale.
1177        assert!(window.replace_region("plan", "new plan".to_string(), 3));
1178        let plan = window.get_region("plan").unwrap();
1179        assert_eq!(plan.content.len(), 1);
1180        assert_eq!(plan.content[0].content, "new plan");
1181
1182        // A missing region is a no-op that reports false.
1183        assert!(!window.replace_region("nope", "x".to_string(), 1));
1184    }
1185
1186    #[test]
1187    fn test_clearable_eviction() {
1188        let mut window = ContextWindow::new(10000);
1189        let mut region = Region::new("scratch".to_string(), RegionKind::Clearable, 5000);
1190        region
1191            .add_entry("test content 1".to_string(), 1000)
1192            .unwrap();
1193        region
1194            .add_entry("test content 2".to_string(), 1000)
1195            .unwrap();
1196        window.add_region(region);
1197
1198        assert_eq!(window.current_tokens, 2000);
1199
1200        // Evict should clear the entire Clearable region
1201        let result = with_tracing(|| window.try_evict(1000)).unwrap();
1202        assert_eq!(result.tokens_freed, 2000);
1203        assert!(result.needs_compaction.is_empty());
1204        assert_eq!(window.current_tokens, 0);
1205    }
1206
1207    #[test]
1208    fn test_temporary_eviction_oldest_first() {
1209        let mut window = ContextWindow::new(10000);
1210        let mut region = Region::new("temp".to_string(), RegionKind::Temporary, 5000);
1211        region.add_entry("old content".to_string(), 1000).unwrap();
1212        region
1213            .add_entry("middle content".to_string(), 1000)
1214            .unwrap();
1215        region.add_entry("new content".to_string(), 1000).unwrap();
1216        window.add_region(region);
1217
1218        assert_eq!(window.current_tokens, 3000);
1219
1220        // Evict should remove oldest first
1221        let result = with_tracing(|| window.try_evict(500)).unwrap();
1222        assert!(result.tokens_freed >= 1000); // Should free at least one entry
1223        assert!(result.needs_compaction.is_empty());
1224
1225        // Check that oldest was removed
1226        let region = window.get_region("temp").unwrap();
1227        assert_eq!(region.content.len(), 2);
1228        assert_eq!(region.content[0].content, "middle content");
1229    }
1230
1231    fn assert_sliding_window_unreduced(initial_count: usize, after_count: usize) {
1232        assert_eq!(
1233            initial_count, after_count,
1234            "SlidingWindow should never be reduced during eviction"
1235        );
1236    }
1237
1238    #[test]
1239    fn test_sliding_window_never_reduced() {
1240        let mut window = ContextWindow::new(10000);
1241        let mut region = Region::new(
1242            "conversation".to_string(),
1243            RegionKind::SlidingWindow {
1244                max_items: 5,
1245                eviction_strategy: EvictionStrategy::PerItem,
1246            },
1247            5000,
1248        );
1249        region.add_entry("msg 1".to_string(), 1000).unwrap();
1250        region.add_entry("msg 2".to_string(), 1000).unwrap();
1251        region.add_entry("msg 3".to_string(), 1000).unwrap();
1252        window.add_region(region);
1253
1254        let initial_count = window.get_region("conversation").unwrap().content.len();
1255
1256        // Try to evict - should not touch SlidingWindow
1257        window.try_evict(1000).ok();
1258
1259        let after_count = window.get_region("conversation").unwrap().content.len();
1260        assert_sliding_window_unreduced(initial_count, after_count);
1261    }
1262
1263    #[test]
1264    #[should_panic(expected = "SlidingWindow should never be reduced during eviction")]
1265    fn test_sliding_window_never_reduced_panics_on_mismatch() {
1266        assert_sliding_window_unreduced(3, 2);
1267    }
1268
1269    fn assert_pinned_unevicted(initial_tokens: usize, after_tokens: usize) {
1270        assert_eq!(
1271            initial_tokens, after_tokens,
1272            "Pinned region should never be evicted"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_pinned_never_touched() {
1278        let mut window = ContextWindow::new(10000);
1279        let mut region = Region::new("architecture".to_string(), RegionKind::Pinned, 3000);
1280        region
1281            .add_entry("architecture diagram".to_string(), 2000)
1282            .unwrap();
1283        window.add_region(region);
1284
1285        let initial_tokens = window.get_region("architecture").unwrap().current_tokens;
1286
1287        // Try to evict - should not touch Pinned
1288        window.try_evict(1000).ok();
1289
1290        let after_tokens = window.get_region("architecture").unwrap().current_tokens;
1291        assert_pinned_unevicted(initial_tokens, after_tokens);
1292    }
1293
1294    #[test]
1295    #[should_panic(expected = "Pinned region should never be evicted")]
1296    fn test_pinned_never_touched_panics_on_mismatch() {
1297        assert_pinned_unevicted(2000, 1000);
1298    }
1299
1300    #[test]
1301    fn test_eviction_cascade_order() {
1302        let mut window = ContextWindow::new(10000);
1303
1304        // Add Clearable region
1305        let mut clearable = Region::new("scratch".to_string(), RegionKind::Clearable, 2000);
1306        clearable
1307            .add_entry("scratch data".to_string(), 1000)
1308            .unwrap();
1309        window.add_region(clearable);
1310
1311        // Add Temporary region
1312        let mut temporary = Region::new("temp".to_string(), RegionKind::Temporary, 3000);
1313        temporary
1314            .add_entry("temp data 1".to_string(), 1000)
1315            .unwrap();
1316        temporary
1317            .add_entry("temp data 2".to_string(), 1000)
1318            .unwrap();
1319        window.add_region(temporary);
1320
1321        assert_eq!(window.current_tokens, 3000);
1322
1323        // Evict with small target - should clear Clearable first
1324        window.try_evict(500).unwrap();
1325
1326        // Clearable should be empty
1327        assert_eq!(window.get_region("scratch").unwrap().current_tokens, 0);
1328
1329        // Temporary should still have content
1330        assert!(window.get_region("temp").unwrap().current_tokens > 0);
1331    }
1332
1333    #[test]
1334    fn test_message_inbox() {
1335        let mut inbox = MessageInbox::new();
1336        assert!(inbox.messages.is_empty());
1337
1338        inbox.push(AgentMessage {
1339            agent_id: "agent-1".to_string(),
1340            content: "hello".to_string(),
1341            target_region: None,
1342            priority: 0,
1343        });
1344        assert_eq!(inbox.messages.len(), 1);
1345
1346        let drained = inbox.drain_all();
1347        assert_eq!(drained.len(), 1);
1348        assert!(inbox.messages.is_empty());
1349    }
1350
1351    #[test]
1352    fn test_message_inbox_priority_ordering() {
1353        let mut inbox = MessageInbox::new();
1354
1355        inbox.push(AgentMessage {
1356            agent_id: "a".to_string(),
1357            content: "low".to_string(),
1358            target_region: None,
1359            priority: 1,
1360        });
1361        inbox.push(AgentMessage {
1362            agent_id: "a".to_string(),
1363            content: "high".to_string(),
1364            target_region: None,
1365            priority: 10,
1366        });
1367        inbox.push(AgentMessage {
1368            agent_id: "a".to_string(),
1369            content: "medium".to_string(),
1370            target_region: None,
1371            priority: 5,
1372        });
1373
1374        let msgs = inbox.drain_all();
1375        assert_eq!(msgs[0].content, "high");
1376        assert_eq!(msgs[1].content, "medium");
1377        assert_eq!(msgs[2].content, "low");
1378    }
1379
1380    #[test]
1381    fn test_eviction_result_identifies_compaction_regions() {
1382        // Small window so compacting region fills most of it
1383        let mut window = ContextWindow::new(1000);
1384        // Add a compacting region that's over threshold
1385        let mut compacting = Region::new(
1386            "impl".to_string(),
1387            RegionKind::Compacting {
1388                threshold_tokens: 500,
1389            },
1390            900,
1391        );
1392        compacting
1393            .add_entry("lots of content".to_string(), 600)
1394            .unwrap();
1395        window.add_region(compacting);
1396
1397        assert_eq!(window.current_tokens, 600);
1398
1399        // Request 500 free tokens - only 400 free, can't free clearable/temporary, so compacting should be identified
1400        let result = window.try_evict(500).unwrap();
1401        assert_eq!(result.tokens_freed, 0);
1402        assert_eq!(result.needs_compaction, vec!["impl".to_string()]);
1403    }
1404
1405    #[test]
1406    fn test_try_evict_returns_needs_compaction_when_full() {
1407        let mut window = ContextWindow::new(1200);
1408
1409        // Fill with compacting region content above threshold
1410        let mut compacting = Region::new(
1411            "analysis".to_string(),
1412            RegionKind::Compacting {
1413                threshold_tokens: 800,
1414            },
1415            1100,
1416        );
1417        compacting.add_entry("data 1".to_string(), 500).unwrap();
1418        compacting.add_entry("data 2".to_string(), 500).unwrap();
1419        window.add_region(compacting);
1420
1421        // 200 free tokens, request 500 → needs compaction
1422        let result = window.try_evict(500).unwrap();
1423        assert_eq!(result.tokens_freed, 0);
1424        assert!(result.needs_compaction.contains(&"analysis".to_string()));
1425    }
1426
1427    #[test]
1428    fn test_try_evict_errors_when_pinned_regions_exceed_budget() {
1429        // Pinned/CompactHistory regions are never evicted - if their combined
1430        // token usage alone exceeds max_tokens, try_evict must report this as
1431        // a configuration error instead of silently doing nothing useful.
1432        let mut window = ContextWindow::new(1000);
1433        let mut pinned = Region::new("architecture".to_string(), RegionKind::Pinned, 2000);
1434        pinned
1435            .add_entry("huge pinned doc".to_string(), 1500)
1436            .unwrap();
1437        window.add_region(pinned);
1438
1439        let result = window.try_evict(100);
1440        assert!(result.is_err());
1441        let err_str = result.unwrap_err().to_string();
1442        assert!(err_str.contains("Pinned regions"));
1443    }
1444
1445    #[test]
1446    fn test_clearable_eviction_continues_past_insufficient_first_region() {
1447        // Phase 1 clears Clearable regions one at a time and returns early as
1448        // soon as enough space has been freed. If clearing the *first*
1449        // Clearable region alone isn't enough, the loop must fall through and
1450        // keep clearing subsequent Clearable regions rather than stopping.
1451        let mut window = ContextWindow::new(2000);
1452
1453        let mut region_a = Region::new("a".to_string(), RegionKind::Clearable, 1000);
1454        region_a.add_entry("small".to_string(), 500).unwrap();
1455        window.add_region(region_a);
1456
1457        let mut region_b = Region::new("b".to_string(), RegionKind::Clearable, 1000);
1458        region_b.add_entry("large".to_string(), 1000).unwrap();
1459        window.add_region(region_b);
1460
1461        assert_eq!(window.current_tokens, 1500);
1462
1463        // After clearing only "a" (frees 500), 2000 - 1000 = 1000 free tokens,
1464        // which is still below the 1400 target, so the loop must continue on
1465        // to clear "b" as well before it can satisfy the request.
1466        let result = with_tracing(|| window.try_evict(1400)).unwrap();
1467        assert_eq!(result.tokens_freed, 1500);
1468        assert_eq!(window.current_tokens, 0);
1469        assert_eq!(window.get_region("a").unwrap().current_tokens, 0);
1470        assert_eq!(window.get_region("b").unwrap().current_tokens, 0);
1471    }
1472
1473    #[test]
1474    fn test_agent_status_cancelled() {
1475        assert_eq!(AgentStatus::Cancelled, AgentStatus::Cancelled);
1476    }
1477
1478    #[test]
1479    fn test_parent_ref_component() {
1480        let parent_ref = super::ParentRef {
1481            parent_entity: Entity::from_raw_u32(42)
1482                .expect("a small literal index is always a valid entity id"),
1483            parent_agent_id: "coder-01".to_string(),
1484            depth: 1,
1485        };
1486        assert_eq!(parent_ref.parent_agent_id, "coder-01");
1487        assert_eq!(parent_ref.depth, 1);
1488    }
1489
1490    #[test]
1491    fn test_children_component() {
1492        let children = super::SubAgentChildren {
1493            children: vec![
1494                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1495                Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id"),
1496            ],
1497            max_child_depth: 3,
1498        };
1499        assert_eq!(children.children.len(), 2);
1500        assert_eq!(children.max_child_depth, 3);
1501    }
1502
1503    #[test]
1504    fn test_agent_state_with_children_fields() {
1505        let state = AgentState {
1506            agent_id: "test-01".to_string(),
1507            current_stage: "analyze".to_string(),
1508            iteration: 0,
1509            status: AgentStatus::Active,
1510            spawned_children_ids: vec!["child-01".to_string(), "child-02".to_string()],
1511            pending_wait: Some("child-01".to_string()),
1512            accepts_messages: true,
1513        };
1514        assert_eq!(state.spawned_children_ids.len(), 2);
1515        assert_eq!(state.pending_wait, Some("child-01".to_string()));
1516    }
1517
1518    // ── Additional coverage tests ──────────────────────────────────────────
1519
1520    #[test]
1521    fn test_context_window_get_region() {
1522        let mut window = ContextWindow::new(10000);
1523        let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1524        window.add_region(region);
1525
1526        assert!(window.get_region("test").is_some());
1527        assert!(window.get_region("nonexistent").is_none());
1528    }
1529
1530    #[test]
1531    fn test_context_window_get_region_mut() {
1532        let mut window = ContextWindow::new(10000);
1533        let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1534        window.add_region(region);
1535
1536        let region = window.get_region_mut("test").unwrap();
1537        region.add_entry("new content".to_string(), 50).unwrap();
1538        assert_eq!(region.content.len(), 1);
1539
1540        assert!(window.get_region_mut("nonexistent").is_none());
1541    }
1542
1543    #[test]
1544    fn test_context_window_add_to_region_success() {
1545        let mut window = ContextWindow::new(10000);
1546        let region = Region::new("conv".to_string(), RegionKind::Temporary, 5000);
1547        window.add_region(region);
1548
1549        let result = window.add_to_region("conv", "Hello".to_string(), 10);
1550        assert!(result.is_ok());
1551        assert_eq!(window.current_tokens, 10);
1552    }
1553
1554    #[test]
1555    fn test_context_window_add_to_region_not_found() {
1556        let mut window = ContextWindow::new(10000);
1557        let result = window.add_to_region("nonexistent", "Hello".to_string(), 10);
1558        assert!(result.is_err());
1559    }
1560
1561    #[test]
1562    fn test_context_window_calculate_tokens() {
1563        let mut window = ContextWindow::new(10000);
1564        let mut r1 = Region::new("a".to_string(), RegionKind::Pinned, 5000);
1565        r1.add_entry("x".to_string(), 100).unwrap();
1566        let mut r2 = Region::new("b".to_string(), RegionKind::Temporary, 5000);
1567        r2.add_entry("y".to_string(), 200).unwrap();
1568        window.add_region(r1);
1569        window.add_region(r2);
1570
1571        assert_eq!(window.calculate_tokens(), 300);
1572    }
1573
1574    #[test]
1575    fn test_context_window_needs_eviction_boundary() {
1576        let mut window = ContextWindow::new(100);
1577        // Exactly 90% → should trigger at 0.9 threshold
1578        window.current_tokens = 90;
1579        assert!(window.needs_eviction(0.9));
1580
1581        // Just below 90%
1582        window.current_tokens = 89;
1583        assert!(!window.needs_eviction(0.9));
1584    }
1585
1586    #[test]
1587    fn test_eviction_result_default_fields() {
1588        let result = EvictionResult {
1589            tokens_freed: 0,
1590            needs_compaction: Vec::new(),
1591        };
1592        assert_eq!(result.tokens_freed, 0);
1593        assert!(result.needs_compaction.is_empty());
1594    }
1595
1596    #[test]
1597    fn test_message_inbox_default() {
1598        let inbox = MessageInbox::default();
1599        assert!(inbox.messages.is_empty());
1600    }
1601
1602    #[test]
1603    fn test_message_inbox_drain_all_empties() {
1604        let mut inbox = MessageInbox::new();
1605        inbox.push(AgentMessage {
1606            agent_id: "a".to_string(),
1607            content: "msg".to_string(),
1608            target_region: None,
1609            priority: 0,
1610        });
1611        let _ = inbox.drain_all();
1612        assert!(inbox.messages.is_empty());
1613        // Drain again should return empty vec
1614        let result = inbox.drain_all();
1615        assert!(result.is_empty());
1616    }
1617
1618    #[test]
1619    fn test_agent_message_clone() {
1620        let msg = AgentMessage {
1621            agent_id: "agent-1".to_string(),
1622            content: "hello".to_string(),
1623            target_region: Some("conv".to_string()),
1624            priority: 5,
1625        };
1626        let cloned = msg.clone();
1627        assert_eq!(cloned.agent_id, "agent-1");
1628        assert_eq!(cloned.content, "hello");
1629        assert_eq!(cloned.target_region, Some("conv".to_string()));
1630        assert_eq!(cloned.priority, 5);
1631    }
1632
1633    #[test]
1634    fn test_agent_status_serialization() {
1635        let status = AgentStatus::Active;
1636        let json = serde_json::to_string(&status).unwrap();
1637        assert!(json.contains("Active"));
1638
1639        let error_status = AgentStatus::Error {
1640            message: "boom".to_string(),
1641        };
1642        let json = serde_json::to_string(&error_status).unwrap();
1643        assert!(json.contains("boom"));
1644    }
1645
1646    #[test]
1647    fn test_tool_call_serialization() {
1648        let tc = ToolCall {
1649            tool_id: "tool-1".to_string(),
1650            name: "search".to_string(),
1651            arguments: serde_json::json!({"query": "rust"}),
1652            thought_signature: None,
1653        };
1654        let json = serde_json::to_string(&tc).unwrap();
1655        assert!(json.contains("search"));
1656        assert!(json.contains("rust"));
1657    }
1658
1659    #[test]
1660    fn test_eviction_with_only_pinned_region_frees_nothing() {
1661        // When the only region is Pinned (within budget), eviction frees nothing.
1662        let mut window = ContextWindow::new(10000);
1663        let mut pinned = Region::new("pinned".to_string(), RegionKind::Pinned, 5000);
1664        pinned
1665            .add_entry("important data".to_string(), 2000)
1666            .unwrap();
1667        window.add_region(pinned);
1668
1669        let result = with_tracing(|| window.try_evict(500)).unwrap();
1670        assert_eq!(result.tokens_freed, 0);
1671        assert!(result.needs_compaction.is_empty());
1672    }
1673
1674    #[test]
1675    fn test_inference_result_fields() {
1676        let ir = InferenceResult {
1677            response: "Hello".to_string(),
1678            tool_calls: vec![ToolCall {
1679                tool_id: "t1".to_string(),
1680                name: "search".to_string(),
1681                arguments: serde_json::json!({}),
1682                thought_signature: None,
1683            }],
1684            tokens_used: 100,
1685            timestamp: 99999,
1686        };
1687        assert_eq!(ir.response, "Hello");
1688        assert_eq!(ir.tool_calls.len(), 1);
1689        assert_eq!(ir.tokens_used, 100);
1690    }
1691
1692    #[test]
1693    fn test_sub_agent_children_clone() {
1694        let children = SubAgentChildren {
1695            children: vec![
1696                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1697            ],
1698            max_child_depth: 2,
1699        };
1700        let cloned = children.clone();
1701        assert_eq!(cloned.children.len(), 1);
1702        assert_eq!(cloned.max_child_depth, 2);
1703    }
1704
1705    // ─── try_evict: FALSE path after each single-entry removal ────────────
1706    // Covers 235:25 (false path of the early-return check) and 242:13 (break).
1707    //
1708    // Setup: max=1000, current=950, target=200.
1709    // Two Temporary entries of 50 tokens each.
1710    //
1711    // Pass 1: remove entry1 (50 tokens) → current=900, available=100 < 200
1712    //   → condition FALSE → line 235 covered → outer loop continues
1713    // Pass 2: remove entry2 (50 tokens) → current=850, available=150 < 200
1714    //   → condition FALSE → line 235 covered again
1715    // Pass 3: no more entries → evicted_any=false → break → line 242 covered
1716
1717    #[test]
1718    fn try_evict_continues_loop_when_each_entry_removal_is_insufficient() {
1719        let mut window = ContextWindow::new(1000);
1720        let mut temp = Region::new("cache".to_string(), RegionKind::Temporary, 800);
1721        temp.add_entry("entry1".to_string(), 50).unwrap();
1722        temp.add_entry("entry2".to_string(), 50).unwrap();
1723        window.add_region(temp);
1724        window.current_tokens = 950; // 95% full
1725
1726        // Target=200: removing 50 at a time is insufficient each pass
1727        let result = window.try_evict(200).unwrap();
1728        assert_eq!(result.tokens_freed, 100); // freed 50+50, but not enough for target
1729    }
1730
1731    // ─── Context window taint tracking ──────────────────────────────────────
1732
1733    #[test]
1734    fn test_enable_taint_tracking_on_context_window() {
1735        let mut window = ContextWindow::new(10000);
1736        window.add_region(Region::new(
1737            "conv".to_string(),
1738            RegionKind::SlidingWindow {
1739                max_items: 10,
1740                eviction_strategy: EvictionStrategy::PerItem,
1741            },
1742            5000,
1743        ));
1744        window.add_region(Region::new(
1745            "tools".to_string(),
1746            RegionKind::Temporary,
1747            3000,
1748        ));
1749
1750        assert!(window.overall_taint().is_none());
1751        window.enable_taint_tracking();
1752        assert_eq!(
1753            window.overall_taint(),
1754            Some(leviath_core::TaintLevel::Public)
1755        );
1756    }
1757
1758    #[test]
1759    fn test_add_tainted_to_region() {
1760        let mut window = ContextWindow::new(10000);
1761        let region =
1762            Region::new("tools".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
1763        window.add_region(region);
1764
1765        window
1766            .add_tainted_to_region(
1767                "tools",
1768                "secret data".to_string(),
1769                10,
1770                leviath_core::TaintLevel::Private,
1771            )
1772            .unwrap();
1773
1774        assert_eq!(
1775            window.get_region("tools").and_then(|r| r.taint_level()),
1776            Some(leviath_core::TaintLevel::Private)
1777        );
1778        assert_eq!(
1779            window.overall_taint(),
1780            Some(leviath_core::TaintLevel::Private)
1781        );
1782    }
1783
1784    #[test]
1785    fn test_add_tainted_to_nonexistent_region() {
1786        let mut window = ContextWindow::new(10000);
1787        let result = window.add_tainted_to_region(
1788            "nope",
1789            "data".to_string(),
1790            10,
1791            leviath_core::TaintLevel::Public,
1792        );
1793        assert!(result.is_err());
1794    }
1795
1796    #[test]
1797    fn test_overall_taint_is_max_across_regions() {
1798        let mut window = ContextWindow::new(10000);
1799        let r1 = Region::new("a".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
1800        let r2 = Region::new("b".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
1801        window.add_region(r1);
1802        window.add_region(r2);
1803
1804        window
1805            .add_tainted_to_region("a", "x".to_string(), 5, leviath_core::TaintLevel::Internal)
1806            .unwrap();
1807        window
1808            .add_tainted_to_region("b", "y".to_string(), 5, leviath_core::TaintLevel::Public)
1809            .unwrap();
1810
1811        assert_eq!(
1812            window.overall_taint(),
1813            Some(leviath_core::TaintLevel::Internal)
1814        );
1815    }
1816
1817    #[test]
1818    fn test_taint_summary() {
1819        let mut window = ContextWindow::new(10000);
1820        let r1 = Region::new("conv".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
1821        let r2 =
1822            Region::new("tools".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
1823        window.add_region(r1);
1824        window.add_region(r2);
1825
1826        window
1827            .add_tainted_to_region(
1828                "conv",
1829                "x".to_string(),
1830                5,
1831                leviath_core::TaintLevel::Private,
1832            )
1833            .unwrap();
1834
1835        let summary = window.taint_summary();
1836        assert_eq!(summary.len(), 2);
1837        assert!(
1838            summary
1839                .iter()
1840                .any(|(name, level)| name == "conv" && *level == leviath_core::TaintLevel::Private)
1841        );
1842        assert!(
1843            summary
1844                .iter()
1845                .any(|(name, level)| name == "tools" && *level == leviath_core::TaintLevel::Public)
1846        );
1847    }
1848
1849    #[test]
1850    fn test_taint_recovery_through_eviction() {
1851        with_tracing(|| {});
1852        let mut window = ContextWindow::new(100);
1853        let r = Region::new("temp".to_string(), RegionKind::Temporary, 100).with_taint_tracking();
1854        window.add_region(r);
1855
1856        window
1857            .add_tainted_to_region(
1858                "temp",
1859                "private".to_string(),
1860                30,
1861                leviath_core::TaintLevel::Private,
1862            )
1863            .unwrap();
1864        window
1865            .add_tainted_to_region(
1866                "temp",
1867                "public".to_string(),
1868                30,
1869                leviath_core::TaintLevel::Public,
1870            )
1871            .unwrap();
1872
1873        assert_eq!(
1874            window.get_region("temp").and_then(|r| r.taint_level()),
1875            Some(leviath_core::TaintLevel::Private)
1876        );
1877
1878        // Eviction should trigger and remove oldest (private) entry
1879        window.current_tokens = 96; // Push over 0.95 threshold
1880        let result = window.try_evict(10).unwrap();
1881        assert!(result.tokens_freed > 0);
1882
1883        // After evicting the private entry, taint should recover
1884        assert_eq!(
1885            window.get_region("temp").and_then(|r| r.taint_level()),
1886            Some(leviath_core::TaintLevel::Public)
1887        );
1888    }
1889
1890    // ─── Tool-use/tool-result pairing sanitization tests ────────────────
1891
1892    #[test]
1893    fn test_assemble_appends_user_nudge_when_conversation_ends_with_assistant() {
1894        // After a stage transition the carried conversation ends with the prior
1895        // stage's assistant turn; assemble must append a trailing user message so
1896        // the request doesn't end on an assistant turn (rejected as prefill).
1897        let mut window = ContextWindow::new(100_000);
1898        window.add_region(Region::new(
1899            "conversation".to_string(),
1900            RegionKind::SlidingWindow {
1901                max_items: 100,
1902                eviction_strategy: EvictionStrategy::PerItem,
1903            },
1904            50_000,
1905        ));
1906        window
1907            .add_typed_entry(
1908                "conversation",
1909                leviath_core::EntryKind::UserMessage,
1910                "do the task".to_string(),
1911                10,
1912            )
1913            .unwrap();
1914        window
1915            .add_typed_entry(
1916                "conversation",
1917                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
1918                "All done with stage one.".to_string(),
1919                10,
1920            )
1921            .unwrap();
1922
1923        let assembled = window.assemble();
1924        assert_eq!(
1925            assembled.messages.last().map(|m| m.role.as_str()),
1926            Some("user"),
1927            "the assembled conversation must end with a user message"
1928        );
1929    }
1930
1931    #[test]
1932    fn test_assemble_strips_orphaned_tool_use() {
1933        let mut window = ContextWindow::new(100_000);
1934        let region = Region::new(
1935            "conversation".to_string(),
1936            RegionKind::SlidingWindow {
1937                max_items: 100,
1938                eviction_strategy: EvictionStrategy::PerItem,
1939            },
1940            50_000,
1941        );
1942        window.add_region(region);
1943
1944        // Add an assistant turn with a tool_use but no matching tool_result
1945        window
1946            .add_typed_entry(
1947                "conversation",
1948                leviath_core::EntryKind::AssistantTurn {
1949                    tool_calls: vec![leviath_core::SerializedToolCall {
1950                        id: "tc_orphan".to_string(),
1951                        name: "read_file".to_string(),
1952                        arguments: serde_json::json!({"path": "foo.rs"}),
1953                        thought_signature: None,
1954                    }],
1955                },
1956                "Let me read that file.".to_string(),
1957                50,
1958            )
1959            .unwrap();
1960
1961        let assembled = with_tracing(|| window.assemble());
1962
1963        // The orphaned tool_use should be stripped; text should remain
1964        for msg in &assembled.messages {
1965            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
1966                for block in blocks {
1967                    assert!(
1968                        !matches!(block, leviath_providers::ContentBlock::ToolUse { .. }),
1969                        "Orphaned tool_use should have been stripped"
1970                    );
1971                }
1972            }
1973        }
1974        // The assistant text should still be present
1975        assert!(
1976            assembled
1977                .messages
1978                .iter()
1979                .any(|m| m.role == "assistant" && m.content.as_text().contains("read that file"))
1980        );
1981    }
1982
1983    #[test]
1984    fn test_assemble_strips_orphaned_tool_result() {
1985        let mut window = ContextWindow::new(100_000);
1986        let region = Region::new(
1987            "conversation".to_string(),
1988            RegionKind::SlidingWindow {
1989                max_items: 100,
1990                eviction_strategy: EvictionStrategy::PerItem,
1991            },
1992            50_000,
1993        );
1994        window.add_region(region);
1995
1996        // Add a user message first
1997        window
1998            .add_typed_entry(
1999                "conversation",
2000                leviath_core::EntryKind::UserMessage,
2001                "Hello".to_string(),
2002                10,
2003            )
2004            .unwrap();
2005
2006        // Add a tool_result with no preceding tool_use
2007        window
2008            .add_typed_entry(
2009                "conversation",
2010                leviath_core::EntryKind::ToolResult {
2011                    tool_call_id: "tc_missing".to_string(),
2012                    tool_name: "read_file".to_string(),
2013                    is_error: false,
2014                },
2015                "file contents here".to_string(),
2016                20,
2017            )
2018            .unwrap();
2019
2020        let assembled = with_tracing(|| window.assemble());
2021
2022        // The orphaned tool_result message is stripped to empty and dropped;
2023        // only the plain user message survives (as Text, carrying no blocks).
2024        assert_eq!(assembled.messages.len(), 1);
2025        assert_eq!(assembled.messages[0].role, "user");
2026        assert_eq!(
2027            assembled.messages[0].content,
2028            leviath_providers::MessageContent::Text("Hello".to_string())
2029        );
2030    }
2031
2032    #[test]
2033    fn test_assemble_paired_tool_use_result_passes_through() {
2034        let mut window = ContextWindow::new(100_000);
2035        let region = Region::new(
2036            "conversation".to_string(),
2037            RegionKind::SlidingWindow {
2038                max_items: 100,
2039                eviction_strategy: EvictionStrategy::PerItem,
2040            },
2041            50_000,
2042        );
2043        window.add_region(region);
2044
2045        // User message
2046        window
2047            .add_typed_entry(
2048                "conversation",
2049                leviath_core::EntryKind::UserMessage,
2050                "Fix the bug".to_string(),
2051                10,
2052            )
2053            .unwrap();
2054
2055        // Assistant with tool_use
2056        window
2057            .add_typed_entry(
2058                "conversation",
2059                leviath_core::EntryKind::AssistantTurn {
2060                    tool_calls: vec![leviath_core::SerializedToolCall {
2061                        id: "tc_1".to_string(),
2062                        name: "read_file".to_string(),
2063                        arguments: serde_json::json!({"path": "main.rs"}),
2064                        thought_signature: None,
2065                    }],
2066                },
2067                "".to_string(),
2068                10,
2069            )
2070            .unwrap();
2071
2072        // Matching tool_result
2073        window
2074            .add_typed_entry(
2075                "conversation",
2076                leviath_core::EntryKind::ToolResult {
2077                    tool_call_id: "tc_1".to_string(),
2078                    tool_name: "read_file".to_string(),
2079                    is_error: false,
2080                },
2081                "fn main() {}".to_string(),
2082                10,
2083            )
2084            .unwrap();
2085
2086        let assembled = window.assemble();
2087
2088        // Both tool_use and tool_result should be present
2089        let has_tool_use = assembled.messages.iter().any(|m| {
2090            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
2091                blocks
2092                    .iter()
2093                    .any(|b| matches!(b, leviath_providers::ContentBlock::ToolUse { id, .. } if id == "tc_1"))
2094            } else {
2095                false
2096            }
2097        });
2098        let has_tool_result = assembled.messages.iter().any(|m| {
2099            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
2100                blocks
2101                    .iter()
2102                    .any(|b| matches!(b, leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tc_1"))
2103            } else {
2104                false
2105            }
2106        });
2107        assert!(has_tool_use, "Paired tool_use should remain");
2108        assert!(has_tool_result, "Paired tool_result should remain");
2109    }
2110
2111    #[test]
2112    fn test_assemble_removes_empty_assistant_after_stripping() {
2113        let mut window = ContextWindow::new(100_000);
2114        let region = Region::new(
2115            "conversation".to_string(),
2116            RegionKind::SlidingWindow {
2117                max_items: 100,
2118                eviction_strategy: EvictionStrategy::PerItem,
2119            },
2120            50_000,
2121        );
2122        window.add_region(region);
2123
2124        // User message
2125        window
2126            .add_typed_entry(
2127                "conversation",
2128                leviath_core::EntryKind::UserMessage,
2129                "Do something".to_string(),
2130                10,
2131            )
2132            .unwrap();
2133
2134        // Assistant with ONLY a tool_use (no text), and no matching result
2135        window
2136            .add_typed_entry(
2137                "conversation",
2138                leviath_core::EntryKind::AssistantTurn {
2139                    tool_calls: vec![leviath_core::SerializedToolCall {
2140                        id: "tc_gone".to_string(),
2141                        name: "bash".to_string(),
2142                        arguments: serde_json::json!({"command": "ls"}),
2143                        thought_signature: None,
2144                    }],
2145                },
2146                "".to_string(),
2147                10,
2148            )
2149            .unwrap();
2150
2151        let assembled = with_tracing(|| window.assemble());
2152
2153        // The assistant message should be entirely removed (empty after stripping)
2154        let assistant_msgs: Vec<_> = assembled
2155            .messages
2156            .iter()
2157            .filter(|m| m.role == "assistant")
2158            .collect();
2159        assert!(
2160            assistant_msgs.is_empty(),
2161            "Assistant message with only orphaned tool_use should be removed entirely"
2162        );
2163    }
2164
2165    #[test]
2166    fn test_assemble_strips_multiple_orphaned_tool_uses_in_one_message() {
2167        let mut window = ContextWindow::new(100_000);
2168        let region = Region::new(
2169            "conversation".to_string(),
2170            RegionKind::SlidingWindow {
2171                max_items: 100,
2172                eviction_strategy: EvictionStrategy::PerItem,
2173            },
2174            50_000,
2175        );
2176        window.add_region(region);
2177
2178        // User message first
2179        window
2180            .add_typed_entry(
2181                "conversation",
2182                leviath_core::EntryKind::UserMessage,
2183                "Do two things".to_string(),
2184                10,
2185            )
2186            .unwrap();
2187
2188        // Assistant with TWO orphaned tool_uses (no matching results for either)
2189        window
2190            .add_typed_entry(
2191                "conversation",
2192                leviath_core::EntryKind::AssistantTurn {
2193                    tool_calls: vec![
2194                        leviath_core::SerializedToolCall {
2195                            id: "tc_orphan_1".to_string(),
2196                            name: "read_file".to_string(),
2197                            arguments: serde_json::json!({"path": "a.rs"}),
2198                            thought_signature: None,
2199                        },
2200                        leviath_core::SerializedToolCall {
2201                            id: "tc_orphan_2".to_string(),
2202                            name: "bash".to_string(),
2203                            arguments: serde_json::json!({"cmd": "ls"}),
2204                            thought_signature: None,
2205                        },
2206                    ],
2207                },
2208                "Let me do both.".to_string(),
2209                50,
2210            )
2211            .unwrap();
2212
2213        let assembled = with_tracing(|| window.assemble());
2214
2215        // Both orphaned tool_uses should be stripped
2216        for msg in &assembled.messages {
2217            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
2218                for block in blocks {
2219                    assert!(
2220                        !matches!(block, leviath_providers::ContentBlock::ToolUse { .. }),
2221                        "All orphaned tool_uses should have been stripped"
2222                    );
2223                }
2224            }
2225        }
2226        // The assistant text should still be present
2227        assert!(
2228            assembled
2229                .messages
2230                .iter()
2231                .any(|m| m.role == "assistant" && m.content.as_text().contains("do both"))
2232        );
2233    }
2234
2235    #[test]
2236    fn test_assemble_mixed_valid_and_orphaned_in_same_message() {
2237        let mut window = ContextWindow::new(100_000);
2238        let region = Region::new(
2239            "conversation".to_string(),
2240            RegionKind::SlidingWindow {
2241                max_items: 100,
2242                eviction_strategy: EvictionStrategy::PerItem,
2243            },
2244            50_000,
2245        );
2246        window.add_region(region);
2247
2248        // User message
2249        window
2250            .add_typed_entry(
2251                "conversation",
2252                leviath_core::EntryKind::UserMessage,
2253                "Do stuff".to_string(),
2254                10,
2255            )
2256            .unwrap();
2257
2258        // Assistant with one valid tool_use (tc_valid) and one orphaned (tc_orphan)
2259        window
2260            .add_typed_entry(
2261                "conversation",
2262                leviath_core::EntryKind::AssistantTurn {
2263                    tool_calls: vec![
2264                        leviath_core::SerializedToolCall {
2265                            id: "tc_valid".to_string(),
2266                            name: "read_file".to_string(),
2267                            arguments: serde_json::json!({"path": "main.rs"}),
2268                            thought_signature: None,
2269                        },
2270                        leviath_core::SerializedToolCall {
2271                            id: "tc_orphan".to_string(),
2272                            name: "bash".to_string(),
2273                            arguments: serde_json::json!({"cmd": "ls"}),
2274                            thought_signature: None,
2275                        },
2276                    ],
2277                },
2278                "".to_string(),
2279                10,
2280            )
2281            .unwrap();
2282
2283        // Only provide tool_result for tc_valid
2284        window
2285            .add_typed_entry(
2286                "conversation",
2287                leviath_core::EntryKind::ToolResult {
2288                    tool_call_id: "tc_valid".to_string(),
2289                    tool_name: "read_file".to_string(),
2290                    is_error: false,
2291                },
2292                "fn main() {}".to_string(),
2293                10,
2294            )
2295            .unwrap();
2296
2297        let assembled = with_tracing(|| window.assemble());
2298
2299        // Collect the tool_use ids that survived assembly.
2300        let tool_use_ids: Vec<&str> = assembled
2301            .messages
2302            .iter()
2303            .filter_map(|m| match &m.content {
2304                leviath_providers::MessageContent::Blocks(blocks) => Some(blocks),
2305                _ => None,
2306            })
2307            .flatten()
2308            .filter_map(|b| match b {
2309                leviath_providers::ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
2310                _ => None,
2311            })
2312            .collect();
2313        // tc_valid's tool_use remains; the orphaned tc_orphan is stripped.
2314        assert!(
2315            tool_use_ids.contains(&"tc_valid"),
2316            "Valid tool_use should remain"
2317        );
2318        assert!(
2319            !tool_use_ids.contains(&"tc_orphan"),
2320            "Orphaned tool_use should be stripped"
2321        );
2322
2323        // tc_valid tool_result should remain
2324        let has_result = assembled.messages.iter().any(|m| {
2325            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
2326                blocks.iter().any(|b| {
2327                    matches!(b, leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tc_valid")
2328                })
2329            } else {
2330                false
2331            }
2332        });
2333        assert!(has_result, "Valid tool_result should remain");
2334    }
2335
2336    // ─── assemble() region kind coverage ──────────────────────────────────
2337
2338    #[test]
2339    fn test_assemble_compact_history_region_produces_system_block_always() {
2340        let mut window = ContextWindow::new(100_000);
2341        let mut region = Region::new(
2342            "history".to_string(),
2343            RegionKind::CompactHistory {
2344                source_region: "conv".to_string(),
2345            },
2346            10_000,
2347        );
2348        region
2349            .add_entry("summary of earlier conversation".to_string(), 50)
2350            .unwrap();
2351        window.add_region(region);
2352
2353        let assembled = window.assemble();
2354
2355        assert_eq!(assembled.system_blocks.len(), 1);
2356        assert_eq!(
2357            assembled.system_blocks[0].text,
2358            "summary of earlier conversation"
2359        );
2360        assert_eq!(
2361            assembled.system_blocks[0].cache_hint,
2362            leviath_core::CacheHint::Always
2363        );
2364    }
2365
2366    #[test]
2367    fn test_assemble_compacting_region_produces_system_block_until_changed() {
2368        let mut window = ContextWindow::new(100_000);
2369        let mut region = Region::new(
2370            "impl".to_string(),
2371            RegionKind::Compacting {
2372                threshold_tokens: 500,
2373            },
2374            10_000,
2375        );
2376        region
2377            .add_entry("implementation details".to_string(), 50)
2378            .unwrap();
2379        window.add_region(region);
2380
2381        let assembled = window.assemble();
2382
2383        assert_eq!(assembled.system_blocks.len(), 1);
2384        assert_eq!(
2385            assembled.system_blocks[0].text,
2386            "[impl]:\nimplementation details"
2387        );
2388        assert_eq!(
2389            assembled.system_blocks[0].cache_hint,
2390            leviath_core::CacheHint::UntilChanged
2391        );
2392    }
2393
2394    #[test]
2395    fn test_assemble_temporary_region_produces_system_block_never() {
2396        let mut window = ContextWindow::new(100_000);
2397        let mut region = Region::new("scratch".to_string(), RegionKind::Temporary, 10_000);
2398        region.add_entry("temp data".to_string(), 20).unwrap();
2399        window.add_region(region);
2400
2401        let assembled = window.assemble();
2402
2403        assert_eq!(assembled.system_blocks.len(), 1);
2404        assert_eq!(assembled.system_blocks[0].text, "[scratch]:\ntemp data");
2405        assert_eq!(
2406            assembled.system_blocks[0].cache_hint,
2407            leviath_core::CacheHint::Never
2408        );
2409    }
2410
2411    #[test]
2412    fn test_assemble_clearable_region_produces_system_block_never() {
2413        let mut window = ContextWindow::new(100_000);
2414        let mut region = Region::new("cache".to_string(), RegionKind::Clearable, 10_000);
2415        region.add_entry("clearable data".to_string(), 20).unwrap();
2416        window.add_region(region);
2417
2418        let assembled = window.assemble();
2419
2420        assert_eq!(assembled.system_blocks.len(), 1);
2421        assert_eq!(assembled.system_blocks[0].text, "[cache]:\nclearable data");
2422        assert_eq!(
2423            assembled.system_blocks[0].cache_hint,
2424            leviath_core::CacheHint::Never
2425        );
2426    }
2427
2428    fn custom_kind(script: &str, persistent: bool) -> RegionKind {
2429        RegionKind::Custom {
2430            script: script.to_string(),
2431            persistent,
2432        }
2433    }
2434
2435    #[test]
2436    fn test_assemble_custom_region_falls_back_to_temporary_style_block() {
2437        // Plain `assemble()` has no compiled script available, so a custom
2438        // region renders as the hook-less fallback: a Temporary-style block -
2439        // never silently dropped.
2440        let mut window = ContextWindow::new(100_000);
2441        let mut region = Region::new("brain".to_string(), custom_kind("b.rhai", false), 10_000);
2442        region.add_entry("thought one".to_string(), 10).unwrap();
2443        region.add_entry("thought two".to_string(), 10).unwrap();
2444        window.add_region(region);
2445
2446        let assembled = window.assemble();
2447
2448        assert_eq!(assembled.system_blocks.len(), 1);
2449        assert_eq!(
2450            assembled.system_blocks[0].text,
2451            "[brain]:\nthought one\n\nthought two"
2452        );
2453        assert_eq!(
2454            assembled.system_blocks[0].cache_hint,
2455            leviath_core::CacheHint::Never
2456        );
2457    }
2458
2459    #[test]
2460    fn try_evict_evicts_non_persistent_custom_regions_oldest_first() {
2461        let mut window = ContextWindow::new(100);
2462        let mut region = Region::new("brain".to_string(), custom_kind("b.rhai", false), 100);
2463        region.add_entry("old".to_string(), 40).unwrap();
2464        region.add_entry("new".to_string(), 40).unwrap();
2465        window.add_region(region);
2466        window.current_tokens = 80;
2467
2468        let result = with_tracing(|| window.try_evict(30).unwrap());
2469        assert!(result.tokens_freed >= 40);
2470        let brain = window.get_region("brain").unwrap();
2471        assert_eq!(brain.content.len(), 1);
2472        assert_eq!(brain.content[0].content, "new");
2473    }
2474
2475    #[test]
2476    fn try_evict_never_touches_persistent_custom_and_counts_it_as_pinned() {
2477        // Persistent custom content survives eviction, and when it alone
2478        // exceeds the whole window budget the pinned over-budget guard fires.
2479        let mut window = ContextWindow::new(50);
2480        let mut vault = Region::new("vault".to_string(), custom_kind("v.rhai", true), 100);
2481        vault.add_entry("precious".to_string(), 60).unwrap();
2482        window.add_region(vault);
2483        window.current_tokens = 60;
2484
2485        let err = with_tracing(|| window.try_evict(10).unwrap_err());
2486        assert_eq!(
2487            err.to_string(),
2488            "Pinned regions (60) exceed total budget (50)"
2489        );
2490        assert_eq!(window.get_region("vault").unwrap().content.len(), 1);
2491    }
2492
2493    /// A window with one custom region (`brain`, budget 100) backed by `src`,
2494    /// compiled and installed in the script table under "s.rhai".
2495    fn custom_window(src: &str, persistent: bool) -> ContextWindow {
2496        let mut window = ContextWindow::new(10_000);
2497        window.add_region(Region::new(
2498            "brain".to_string(),
2499            RegionKind::Custom {
2500                script: "s.rhai".to_string(),
2501                persistent,
2502            },
2503            100,
2504        ));
2505        window.region_scripts.insert(
2506            "s.rhai".to_string(),
2507            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
2508        );
2509        window
2510    }
2511
2512    #[test]
2513    fn custom_region_on_write_fires_across_all_write_methods() {
2514        let src = r#"
2515            fn render(ctx) { "" }
2516            fn on_write(ctx) { `${ctx.entry.kind}:${ctx.entry.content}` }
2517        "#;
2518        let mut window = custom_window(src, false);
2519
2520        window.add_to_region("brain", "a".to_string(), 1).unwrap();
2521        window
2522            .add_typed_entry(
2523                "brain",
2524                leviath_core::EntryKind::UserMessage,
2525                "b".to_string(),
2526                1,
2527            )
2528            .unwrap();
2529        window
2530            .add_tainted_to_region(
2531                "brain",
2532                "c".to_string(),
2533                1,
2534                leviath_core::TaintLevel::Public,
2535            )
2536            .unwrap();
2537        window
2538            .add_typed_tainted_to_region(
2539                "brain",
2540                leviath_core::EntryKind::UserMessage,
2541                "d".to_string(),
2542                1,
2543                leviath_core::TaintLevel::Public,
2544            )
2545            .unwrap();
2546
2547        let contents: Vec<_> = window
2548            .get_region("brain")
2549            .unwrap()
2550            .content
2551            .iter()
2552            .map(|e| e.content.as_str())
2553            .collect();
2554        assert_eq!(
2555            contents,
2556            vec!["text:a", "user_message:b", "text:c", "user_message:d"],
2557            "every write method passes through on_write with the entry kind visible"
2558        );
2559        // Token counts were re-estimated for the replacements.
2560        assert_eq!(window.current_tokens, window.calculate_tokens());
2561
2562        assert!(window.replace_region("brain", "e".to_string(), 1));
2563        let region = window.get_region("brain").unwrap();
2564        assert_eq!(region.content.len(), 1);
2565        assert_eq!(region.content[0].content, "text:e");
2566    }
2567
2568    #[test]
2569    fn custom_region_on_write_drop_reports_success_without_storing() {
2570        let src = r#"
2571            fn render(ctx) { "" }
2572            fn on_write(ctx) { false }
2573        "#;
2574        let mut window = custom_window(src, false);
2575        window
2576            .add_to_region("brain", "spam".to_string(), 1)
2577            .unwrap();
2578        assert!(window.get_region("brain").unwrap().content.is_empty());
2579
2580        // A dropped replacement leaves existing content in place.
2581        assert!(window.replace_region("brain", "more spam".to_string(), 1));
2582        assert!(window.get_region("brain").unwrap().content.is_empty());
2583    }
2584
2585    #[test]
2586    fn custom_region_on_write_drop_covers_typed_and_tainted_methods() {
2587        // Every write method's drop arm, not just add_to_region's.
2588        let src = r#"
2589            fn render(ctx) { "" }
2590            fn on_write(ctx) { false }
2591        "#;
2592        let mut window = custom_window(src, false);
2593        window
2594            .add_typed_entry(
2595                "brain",
2596                leviath_core::EntryKind::UserMessage,
2597                "a".to_string(),
2598                1,
2599            )
2600            .unwrap();
2601        window
2602            .add_tainted_to_region(
2603                "brain",
2604                "b".to_string(),
2605                1,
2606                leviath_core::TaintLevel::Public,
2607            )
2608            .unwrap();
2609        window
2610            .add_typed_tainted_to_region(
2611                "brain",
2612                leviath_core::EntryKind::UserMessage,
2613                "c".to_string(),
2614                1,
2615                leviath_core::TaintLevel::Public,
2616            )
2617            .unwrap();
2618        assert!(window.get_region("brain").unwrap().content.is_empty());
2619    }
2620
2621    #[test]
2622    fn try_evict_skips_custom_region_whose_script_has_no_on_overflow() {
2623        // Phase 1.5 leaves the choice to phase 2 (oldest-first) when the
2624        // script defines no on_overflow.
2625        let mut window = ContextWindow::new(100);
2626        window.add_region(Region::new(
2627            "brain".to_string(),
2628            RegionKind::Custom {
2629                script: "s.rhai".to_string(),
2630                persistent: false,
2631            },
2632            100,
2633        ));
2634        window.region_scripts.insert(
2635            "s.rhai".to_string(),
2636            std::sync::Arc::new(
2637                leviath_scripting::region_hook::compile("s.rhai", "fn render(ctx) { \"\" }")
2638                    .unwrap(),
2639            ),
2640        );
2641        window
2642            .add_to_region("brain", "old".to_string(), 40)
2643            .unwrap();
2644        window
2645            .add_to_region("brain", "new".to_string(), 40)
2646            .unwrap();
2647
2648        let result = with_tracing(|| window.try_evict(30).unwrap());
2649        assert!(result.tokens_freed >= 40);
2650        let brain = window.get_region("brain").unwrap();
2651        assert_eq!(brain.content.len(), 1);
2652        assert_eq!(brain.content[0].content, "new", "oldest-first fallback ran");
2653    }
2654
2655    #[test]
2656    fn try_evict_falls_to_oldest_first_when_script_frees_nothing() {
2657        // on_overflow returns [] under pressure: phase 1.5 frees 0 and phase 2
2658        // makes the progress.
2659        let src = r#"
2660            fn render(ctx) { "" }
2661            fn on_overflow(ctx) { [] }
2662        "#;
2663        let mut window = ContextWindow::new(100);
2664        window.add_region(Region::new(
2665            "brain".to_string(),
2666            RegionKind::Custom {
2667                script: "s.rhai".to_string(),
2668                persistent: false,
2669            },
2670            100,
2671        ));
2672        window.region_scripts.insert(
2673            "s.rhai".to_string(),
2674            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
2675        );
2676        window
2677            .add_to_region("brain", "old".to_string(), 40)
2678            .unwrap();
2679        window
2680            .add_to_region("brain", "new".to_string(), 40)
2681            .unwrap();
2682
2683        let result = with_tracing(|| window.try_evict(30).unwrap());
2684        assert!(result.tokens_freed >= 40);
2685        assert_eq!(window.get_region("brain").unwrap().content.len(), 1);
2686    }
2687
2688    #[test]
2689    fn non_custom_regions_bypass_the_on_write_seam() {
2690        // A script table entry exists, but the region is plain Temporary - the
2691        // hook must not fire for it.
2692        let mut window = custom_window(
2693            "fn render(ctx) { \"\" }\nfn on_write(ctx) { \"MANGLED\" }",
2694            false,
2695        );
2696        window.add_region(Region::new("plain".to_string(), RegionKind::Temporary, 100));
2697        window
2698            .add_to_region("plain", "untouched".to_string(), 2)
2699            .unwrap();
2700        assert_eq!(
2701            window.get_region("plain").unwrap().content[0].content,
2702            "untouched"
2703        );
2704    }
2705
2706    #[test]
2707    fn write_to_missing_region_still_errors() {
2708        let mut window = custom_window("fn render(ctx) { \"\" }", false);
2709        let err = window
2710            .add_to_region("ghost", "x".to_string(), 1)
2711            .unwrap_err();
2712        assert!(err.to_string().contains("ghost"), "{err}");
2713    }
2714
2715    #[test]
2716    fn custom_region_add_time_overflow_retries_after_script_drops() {
2717        // Region budget 100: fill with 90, then add 20 - over budget. The
2718        // script drops entry 0 (90 tokens), freeing room; the retry succeeds.
2719        let src = r#"
2720            fn render(ctx) { "" }
2721            fn on_overflow(ctx) { [0] }
2722        "#;
2723        let mut window = custom_window(src, false);
2724        window
2725            .add_to_region("brain", "big".to_string(), 90)
2726            .unwrap();
2727        window
2728            .add_to_region("brain", "next".to_string(), 20)
2729            .unwrap();
2730
2731        let region = window.get_region("brain").unwrap();
2732        assert_eq!(region.content.len(), 1);
2733        assert_eq!(region.content[0].content, "next");
2734        assert_eq!(window.current_tokens, 20);
2735    }
2736
2737    #[test]
2738    fn custom_region_add_time_overflow_propagates_when_still_too_big() {
2739        // The script frees nothing, so the retry path never runs and the
2740        // original budget error propagates to the caller's ladders.
2741        let src = r#"
2742            fn render(ctx) { "" }
2743            fn on_overflow(ctx) { [] }
2744        "#;
2745        let mut window = custom_window(src, false);
2746        window
2747            .add_to_region("brain", "big".to_string(), 90)
2748            .unwrap();
2749        let err =
2750            with_tracing(|| window.add_to_region("brain", "too much".to_string(), 50)).unwrap_err();
2751        assert_eq!(err.to_string(), "Content exceeds token budget: 140 > 100");
2752        assert_eq!(window.get_region("brain").unwrap().content.len(), 1);
2753    }
2754
2755    #[test]
2756    fn custom_region_without_on_overflow_gets_no_retry() {
2757        let mut window = custom_window("fn render(ctx) { \"\" }", false);
2758        window
2759            .add_to_region("brain", "big".to_string(), 90)
2760            .unwrap();
2761        let err = window
2762            .add_to_region("brain", "too much".to_string(), 50)
2763            .unwrap_err();
2764        assert_eq!(err.to_string(), "Content exceeds token budget: 140 > 100");
2765    }
2766
2767    #[test]
2768    fn try_evict_lets_custom_script_choose_what_to_drop() {
2769        // The script keeps errors, drops successes - the retention choice the
2770        // oldest-first cascade could never make. Window is small so eviction
2771        // has real pressure.
2772        let src = r#"
2773            fn render(ctx) { "" }
2774            fn on_overflow(ctx) {
2775                let drops = [];
2776                for (entry, i) in ctx.entries {
2777                    if !entry.content.contains("ERROR") { drops.push(i); }
2778                }
2779                drops
2780            }
2781        "#;
2782        let mut window = ContextWindow::new(100);
2783        window.add_region(Region::new(
2784            "brain".to_string(),
2785            RegionKind::Custom {
2786                script: "s.rhai".to_string(),
2787                persistent: false,
2788            },
2789            100,
2790        ));
2791        window.region_scripts.insert(
2792            "s.rhai".to_string(),
2793            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
2794        );
2795        window
2796            .add_to_region("brain", "ok one".to_string(), 30)
2797            .unwrap();
2798        window
2799            .add_to_region("brain", "ERROR two".to_string(), 30)
2800            .unwrap();
2801        window
2802            .add_to_region("brain", "ok three".to_string(), 30)
2803            .unwrap();
2804
2805        let result = with_tracing(|| window.try_evict(40)).unwrap();
2806        assert!(result.tokens_freed >= 40);
2807        let contents: Vec<_> = window
2808            .get_region("brain")
2809            .unwrap()
2810            .content
2811            .iter()
2812            .map(|e| e.content.as_str())
2813            .collect();
2814        assert_eq!(
2815            contents,
2816            vec!["ERROR two"],
2817            "script retention choice honored"
2818        );
2819    }
2820
2821    // ─── assemble(): custom regions ──────────────────────────────────────
2822
2823    #[test]
2824    fn assemble_custom_region_renders_through_script() {
2825        let src = r#"fn render(ctx) { `<brain iter=${ctx.stage_iterations}>` }"#;
2826        let mut window = custom_window(src, false);
2827        window
2828            .add_to_region("brain", "note".to_string(), 2)
2829            .unwrap();
2830
2831        // Default meta via plain assemble().
2832        let assembled = window.assemble();
2833        assert_eq!(assembled.system_blocks.len(), 1);
2834        assert_eq!(assembled.system_blocks[0].text, "<brain iter=0>");
2835        assert_eq!(
2836            assembled.system_blocks[0].cache_hint,
2837            leviath_core::CacheHint::UntilChanged
2838        );
2839
2840        // Real meta via assemble_with_meta.
2841        let assembled = window.assemble_with_meta(&crate::custom_region::AssembleMeta {
2842            stage_name: "plan".to_string(),
2843            stage_iterations: 7,
2844            model: "m".to_string(),
2845        });
2846        assert_eq!(assembled.system_blocks[0].text, "<brain iter=7>");
2847    }
2848
2849    #[test]
2850    fn assemble_custom_region_renders_even_when_empty() {
2851        // Static scaffolding: the script emits structure with no entries.
2852        let src = r#"fn render(ctx) { `<empty count=${ctx.entries.len()}>` }"#;
2853        let window = custom_window(src, false);
2854        let assembled = window.assemble();
2855        assert_eq!(assembled.system_blocks.len(), 1);
2856        assert_eq!(assembled.system_blocks[0].text, "<empty count=0>");
2857    }
2858
2859    #[test]
2860    fn assemble_custom_conversation_takeover_renders_single_user_message() {
2861        // The 12-factor case: a custom region NAMED conversation holds the
2862        // typed history and renders it as one XML user message. No sliding
2863        // window exists; the request's only message is the script's.
2864        let src = r#"
2865            fn render(ctx) {
2866                let xml = "<context>";
2867                for entry in ctx.entries {
2868                    xml += `<event kind="${entry.kind}">${entry.content}</event>`;
2869                }
2870                xml += "</context>";
2871                #{ messages: [ #{ role: "user", content: xml } ] }
2872            }
2873        "#;
2874        let mut window = ContextWindow::new(10_000);
2875        window.add_region(Region::new(
2876            "conversation".to_string(),
2877            RegionKind::Custom {
2878                script: "conv.rhai".to_string(),
2879                persistent: false,
2880            },
2881            5_000,
2882        ));
2883        window.region_scripts.insert(
2884            "conv.rhai".to_string(),
2885            std::sync::Arc::new(leviath_scripting::region_hook::compile("conv.rhai", src).unwrap()),
2886        );
2887        window
2888            .add_typed_entry(
2889                "conversation",
2890                leviath_core::EntryKind::UserMessage,
2891                "do the task".to_string(),
2892                4,
2893            )
2894            .unwrap();
2895        window
2896            .add_typed_entry(
2897                "conversation",
2898                leviath_core::EntryKind::ToolResult {
2899                    tool_call_id: "c1".to_string(),
2900                    tool_name: "shell".to_string(),
2901                    is_error: false,
2902                },
2903                "output".to_string(),
2904                2,
2905            )
2906            .unwrap();
2907
2908        let assembled = window.assemble();
2909        assert!(assembled.system_blocks.is_empty());
2910        assert_eq!(assembled.messages.len(), 1);
2911        assert_eq!(assembled.messages[0].role, "user");
2912        assert_eq!(
2913            assembled.messages[0].content.as_text(),
2914            "<context><event kind=\"user_message\">do the task</event>\
2915             <event kind=\"tool_result\">output</event></context>"
2916        );
2917    }
2918
2919    #[test]
2920    fn assemble_custom_script_emitting_nothing_gets_begin_fallback() {
2921        // A script that emits no messages leaves the request message-less;
2922        // the shared finalization injects the "Begin." user message.
2923        let window = custom_window("fn render(ctx) { \"\" }", false);
2924        let assembled = window.assemble();
2925        assert!(assembled.system_blocks.is_empty());
2926        assert_eq!(assembled.messages.len(), 1);
2927        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
2928    }
2929
2930    #[test]
2931    fn assemble_custom_unpaired_tool_blocks_are_sanitized() {
2932        // A buggy script emits a tool_result with no matching tool_use; the
2933        // orphan sanitizer strips it instead of sending a provider-invalid
2934        // request.
2935        let src = r#"
2936            fn render(ctx) {
2937                #{ messages: [
2938                    #{ role: "user", content: "hello" },
2939                    #{ role: "user", tool_results: [
2940                        #{ tool_call_id: "ghost", content: "orphan" },
2941                    ] },
2942                ] }
2943            }
2944        "#;
2945        let window = custom_window(src, false);
2946        let assembled = window.assemble();
2947        assert_eq!(assembled.messages.len(), 1, "orphan tool_result stripped");
2948        assert_eq!(assembled.messages[0].content.as_text(), "hello");
2949    }
2950
2951    // ─── assemble() EntryKind::Text prefix parsing ────────────────────────
2952
2953    #[test]
2954    fn test_assemble_text_entry_with_assistant_prefix() {
2955        let mut window = ContextWindow::new(100_000);
2956        let region = Region::new(
2957            "conv".to_string(),
2958            RegionKind::SlidingWindow {
2959                max_items: 100,
2960                eviction_strategy: EvictionStrategy::PerItem,
2961            },
2962            50_000,
2963        );
2964        window.add_region(region);
2965
2966        window
2967            .add_typed_entry(
2968                "conv",
2969                leviath_core::EntryKind::Text,
2970                "Assistant: I can help with that.".to_string(),
2971                10,
2972            )
2973            .unwrap();
2974
2975        let assembled = window.assemble();
2976
2977        let assistant_msgs: Vec<_> = assembled
2978            .messages
2979            .iter()
2980            .filter(|m| m.role == "assistant")
2981            .collect();
2982        assert_eq!(assistant_msgs.len(), 1);
2983        assert_eq!(assistant_msgs[0].content.as_text(), "I can help with that.");
2984    }
2985
2986    #[test]
2987    fn test_assemble_text_entry_with_user_prefix() {
2988        let mut window = ContextWindow::new(100_000);
2989        let region = Region::new(
2990            "conv".to_string(),
2991            RegionKind::SlidingWindow {
2992                max_items: 100,
2993                eviction_strategy: EvictionStrategy::PerItem,
2994            },
2995            50_000,
2996        );
2997        window.add_region(region);
2998
2999        window
3000            .add_typed_entry(
3001                "conv",
3002                leviath_core::EntryKind::Text,
3003                "User: What is Rust?".to_string(),
3004                10,
3005            )
3006            .unwrap();
3007
3008        let assembled = window.assemble();
3009
3010        let user_msgs: Vec<_> = assembled
3011            .messages
3012            .iter()
3013            .filter(|m| m.role == "user")
3014            .collect();
3015        assert_eq!(user_msgs.len(), 1);
3016        assert_eq!(user_msgs[0].content.as_text(), "What is Rust?");
3017    }
3018
3019    #[test]
3020    fn test_assemble_text_entry_without_prefix_defaults_to_user() {
3021        let mut window = ContextWindow::new(100_000);
3022        let region = Region::new(
3023            "conv".to_string(),
3024            RegionKind::SlidingWindow {
3025                max_items: 100,
3026                eviction_strategy: EvictionStrategy::PerItem,
3027            },
3028            50_000,
3029        );
3030        window.add_region(region);
3031
3032        window
3033            .add_typed_entry(
3034                "conv",
3035                leviath_core::EntryKind::Text,
3036                "some plain text".to_string(),
3037                10,
3038            )
3039            .unwrap();
3040
3041        let assembled = window.assemble();
3042
3043        let user_msgs: Vec<_> = assembled
3044            .messages
3045            .iter()
3046            .filter(|m| m.role == "user")
3047            .collect();
3048        assert_eq!(user_msgs.len(), 1);
3049        assert_eq!(user_msgs[0].content.as_text(), "some plain text");
3050    }
3051
3052    // ─── assemble() AssistantTurn variants ────────────────────────────────
3053
3054    #[test]
3055    fn test_assemble_assistant_turn_with_text_and_tool_calls() {
3056        let mut window = ContextWindow::new(100_000);
3057        let region = Region::new(
3058            "conv".to_string(),
3059            RegionKind::SlidingWindow {
3060                max_items: 100,
3061                eviction_strategy: EvictionStrategy::PerItem,
3062            },
3063            50_000,
3064        );
3065        window.add_region(region);
3066
3067        // User message first
3068        window
3069            .add_typed_entry(
3070                "conv",
3071                leviath_core::EntryKind::UserMessage,
3072                "Read my file".to_string(),
3073                10,
3074            )
3075            .unwrap();
3076
3077        // Assistant with text + tool_calls
3078        window
3079            .add_typed_entry(
3080                "conv",
3081                leviath_core::EntryKind::AssistantTurn {
3082                    tool_calls: vec![leviath_core::SerializedToolCall {
3083                        id: "tc_a".to_string(),
3084                        name: "read_file".to_string(),
3085                        arguments: serde_json::json!({"path": "foo.rs"}),
3086                        thought_signature: None,
3087                    }],
3088                },
3089                "Sure, let me read it.".to_string(),
3090                20,
3091            )
3092            .unwrap();
3093
3094        // Matching tool result
3095        window
3096            .add_typed_entry(
3097                "conv",
3098                leviath_core::EntryKind::ToolResult {
3099                    tool_call_id: "tc_a".to_string(),
3100                    tool_name: "read_file".to_string(),
3101                    is_error: false,
3102                },
3103                "fn main() {}".to_string(),
3104                10,
3105            )
3106            .unwrap();
3107
3108        let assembled = window.assemble();
3109
3110        // Find the assistant message with blocks
3111        let assistant_msg = assembled
3112            .messages
3113            .iter()
3114            .find(|m| m.role == "assistant")
3115            .expect("should have assistant message");
3116
3117        // Assistant turn with text + a tool call assembles to a Text block
3118        // followed by the ToolUse block.
3119        assert_eq!(
3120            assistant_msg.content,
3121            leviath_providers::MessageContent::Blocks(vec![
3122                leviath_providers::ContentBlock::Text {
3123                    text: "Sure, let me read it.".to_string(),
3124                },
3125                leviath_providers::ContentBlock::ToolUse {
3126                    id: "tc_a".to_string(),
3127                    name: "read_file".to_string(),
3128                    input: serde_json::json!({"path": "foo.rs"}),
3129                    thought_signature: None,
3130                },
3131            ])
3132        );
3133    }
3134
3135    #[test]
3136    fn test_assemble_assistant_turn_no_text_only_tool_calls() {
3137        let mut window = ContextWindow::new(100_000);
3138        let region = Region::new(
3139            "conv".to_string(),
3140            RegionKind::SlidingWindow {
3141                max_items: 100,
3142                eviction_strategy: EvictionStrategy::PerItem,
3143            },
3144            50_000,
3145        );
3146        window.add_region(region);
3147
3148        // User message
3149        window
3150            .add_typed_entry(
3151                "conv",
3152                leviath_core::EntryKind::UserMessage,
3153                "Do it".to_string(),
3154                10,
3155            )
3156            .unwrap();
3157
3158        // Assistant with empty text + tool_calls
3159        window
3160            .add_typed_entry(
3161                "conv",
3162                leviath_core::EntryKind::AssistantTurn {
3163                    tool_calls: vec![leviath_core::SerializedToolCall {
3164                        id: "tc_b".to_string(),
3165                        name: "bash".to_string(),
3166                        arguments: serde_json::json!({"cmd": "ls"}),
3167                        thought_signature: None,
3168                    }],
3169                },
3170                "".to_string(),
3171                10,
3172            )
3173            .unwrap();
3174
3175        // Matching tool result
3176        window
3177            .add_typed_entry(
3178                "conv",
3179                leviath_core::EntryKind::ToolResult {
3180                    tool_call_id: "tc_b".to_string(),
3181                    tool_name: "bash".to_string(),
3182                    is_error: false,
3183                },
3184                "file1.rs\nfile2.rs".to_string(),
3185                10,
3186            )
3187            .unwrap();
3188
3189        let assembled = window.assemble();
3190
3191        let assistant_msg = assembled
3192            .messages
3193            .iter()
3194            .find(|m| m.role == "assistant")
3195            .expect("should have assistant message");
3196
3197        // Empty assistant text produces a single ToolUse block, no Text block.
3198        assert_eq!(
3199            assistant_msg.content,
3200            leviath_providers::MessageContent::Blocks(vec![
3201                leviath_providers::ContentBlock::ToolUse {
3202                    id: "tc_b".to_string(),
3203                    name: "bash".to_string(),
3204                    input: serde_json::json!({"cmd": "ls"}),
3205                    thought_signature: None,
3206                },
3207            ])
3208        );
3209    }
3210
3211    // ─── assemble() consecutive ToolResults flushed ───────────────────────
3212
3213    #[test]
3214    fn test_assemble_consecutive_tool_results_flushed_on_non_tool_result() {
3215        let mut window = ContextWindow::new(100_000);
3216        let region = Region::new(
3217            "conv".to_string(),
3218            RegionKind::SlidingWindow {
3219                max_items: 100,
3220                eviction_strategy: EvictionStrategy::PerItem,
3221            },
3222            50_000,
3223        );
3224        window.add_region(region);
3225
3226        // User message
3227        window
3228            .add_typed_entry(
3229                "conv",
3230                leviath_core::EntryKind::UserMessage,
3231                "Run two tools".to_string(),
3232                10,
3233            )
3234            .unwrap();
3235
3236        // Assistant with two tool calls
3237        window
3238            .add_typed_entry(
3239                "conv",
3240                leviath_core::EntryKind::AssistantTurn {
3241                    tool_calls: vec![
3242                        leviath_core::SerializedToolCall {
3243                            id: "tc_1".to_string(),
3244                            name: "read_file".to_string(),
3245                            arguments: serde_json::json!({"path": "a.rs"}),
3246                            thought_signature: None,
3247                        },
3248                        leviath_core::SerializedToolCall {
3249                            id: "tc_2".to_string(),
3250                            name: "read_file".to_string(),
3251                            arguments: serde_json::json!({"path": "b.rs"}),
3252                            thought_signature: None,
3253                        },
3254                    ],
3255                },
3256                "".to_string(),
3257                10,
3258            )
3259            .unwrap();
3260
3261        // Two consecutive ToolResults
3262        window
3263            .add_typed_entry(
3264                "conv",
3265                leviath_core::EntryKind::ToolResult {
3266                    tool_call_id: "tc_1".to_string(),
3267                    tool_name: "read_file".to_string(),
3268                    is_error: false,
3269                },
3270                "content of a.rs".to_string(),
3271                10,
3272            )
3273            .unwrap();
3274        window
3275            .add_typed_entry(
3276                "conv",
3277                leviath_core::EntryKind::ToolResult {
3278                    tool_call_id: "tc_2".to_string(),
3279                    tool_name: "read_file".to_string(),
3280                    is_error: false,
3281                },
3282                "content of b.rs".to_string(),
3283                10,
3284            )
3285            .unwrap();
3286
3287        // Then a UserMessage (should flush the pending tool results first)
3288        window
3289            .add_typed_entry(
3290                "conv",
3291                leviath_core::EntryKind::UserMessage,
3292                "Now fix the bug".to_string(),
3293                10,
3294            )
3295            .unwrap();
3296
3297        let assembled = window.assemble();
3298
3299        // Messages should be: user("Run two tools"), assistant(tool_uses),
3300        // user(tool_result x2), user("Now fix the bug")
3301        assert_eq!(assembled.messages.len(), 4);
3302
3303        // The third message should be a user message with two ToolResult blocks
3304        let tool_result_msg = &assembled.messages[2];
3305        assert_eq!(tool_result_msg.role, "user");
3306        // The two consecutive tool results merge into one user message with two
3307        // ToolResult blocks, in order.
3308        assert_eq!(
3309            tool_result_msg.content,
3310            leviath_providers::MessageContent::Blocks(vec![
3311                leviath_providers::ContentBlock::ToolResult {
3312                    tool_use_id: "tc_1".to_string(),
3313                    content: "content of a.rs".to_string(),
3314                    is_error: false,
3315                },
3316                leviath_providers::ContentBlock::ToolResult {
3317                    tool_use_id: "tc_2".to_string(),
3318                    content: "content of b.rs".to_string(),
3319                    is_error: false,
3320                },
3321            ])
3322        );
3323
3324        // The fourth message should be the user follow-up
3325        assert_eq!(assembled.messages[3].role, "user");
3326        assert_eq!(assembled.messages[3].content.as_text(), "Now fix the bug");
3327    }
3328
3329    // ─── assemble() "Begin." fallback ─────────────────────────────────────
3330
3331    #[test]
3332    fn test_assemble_injects_begin_when_no_user_messages() {
3333        let mut window = ContextWindow::new(100_000);
3334        // Only a Pinned region, no SlidingWindow with user messages
3335        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
3336        pinned
3337            .add_entry("You are a helpful assistant.".to_string(), 20)
3338            .unwrap();
3339        window.add_region(pinned);
3340
3341        let assembled = window.assemble();
3342
3343        // Should have injected a "Begin." fallback user message
3344        assert_eq!(assembled.messages.len(), 1);
3345        assert_eq!(assembled.messages[0].role, "user");
3346        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
3347    }
3348
3349    // ─── add_typed_entry / add_typed_tainted_to_region error paths ────────
3350
3351    #[test]
3352    fn test_add_typed_entry_to_nonexistent_region() {
3353        let mut window = ContextWindow::new(10000);
3354        let result = window.add_typed_entry(
3355            "nonexistent",
3356            leviath_core::EntryKind::UserMessage,
3357            "hello".to_string(),
3358            10,
3359        );
3360        assert!(result.is_err());
3361        let err_str = result.unwrap_err().to_string();
3362        assert!(
3363            err_str.contains("nonexistent"),
3364            "Error should mention the missing region name"
3365        );
3366    }
3367
3368    #[test]
3369    fn test_add_typed_tainted_to_nonexistent_region() {
3370        let mut window = ContextWindow::new(10000);
3371        let result = window.add_typed_tainted_to_region(
3372            "ghost",
3373            leviath_core::EntryKind::UserMessage,
3374            "data".to_string(),
3375            10,
3376            leviath_core::TaintLevel::Public,
3377        );
3378        assert!(result.is_err());
3379        let err_str = result.unwrap_err().to_string();
3380        assert!(
3381            err_str.contains("ghost"),
3382            "Error should mention the missing region name"
3383        );
3384    }
3385
3386    #[test]
3387    fn test_assemble_tool_result_before_any_tool_use() {
3388        // Edge case: tool_result appears in context but no tool_use exists at all
3389        let mut window = ContextWindow::new(100_000);
3390        let region = Region::new(
3391            "conversation".to_string(),
3392            RegionKind::SlidingWindow {
3393                max_items: 100,
3394                eviction_strategy: EvictionStrategy::PerItem,
3395            },
3396            50_000,
3397        );
3398        window.add_region(region);
3399
3400        // A tool_result with no tool_use anywhere
3401        window
3402            .add_typed_entry(
3403                "conversation",
3404                leviath_core::EntryKind::ToolResult {
3405                    tool_call_id: "tc_nowhere".to_string(),
3406                    tool_name: "read_file".to_string(),
3407                    is_error: false,
3408                },
3409                "orphan result".to_string(),
3410                10,
3411            )
3412            .unwrap();
3413
3414        let assembled = with_tracing(|| window.assemble());
3415
3416        // The orphaned tool_result message is stripped to empty and dropped,
3417        // leaving no messages - so the "Begin." user fallback is synthesized.
3418        assert_eq!(assembled.messages.len(), 1);
3419        assert_eq!(assembled.messages[0].role, "user");
3420        assert_eq!(
3421            assembled.messages[0].content,
3422            leviath_providers::MessageContent::Text("Begin.".to_string())
3423        );
3424    }
3425
3426    // ─── Prompt caching tests ────────────────────────────────────────────
3427
3428    #[test]
3429    fn test_assemble_sets_cache_breakpoint_on_stable_prefix() {
3430        let mut window = ContextWindow::new(100_000);
3431        let region = Region::new(
3432            "conv".to_string(),
3433            RegionKind::SlidingWindow {
3434                max_items: 100,
3435                eviction_strategy: EvictionStrategy::PerItem,
3436            },
3437            50_000,
3438        );
3439        window.add_region(region);
3440
3441        // Add 10 alternating user/assistant messages
3442        for i in 0..10 {
3443            let kind = if i % 2 == 0 {
3444                leviath_core::EntryKind::UserMessage
3445            } else {
3446                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] }
3447            };
3448            window
3449                .add_typed_entry("conv", kind, format!("message {i}"), 10)
3450                .unwrap();
3451        }
3452
3453        let assembled = window.assemble();
3454        // 10 alternating messages end on an assistant turn, so assemble appends a
3455        // trailing "Continue." user nudge → 11 messages.
3456        assert_eq!(assembled.messages.len(), 11);
3457        assert_eq!(assembled.messages.last().unwrap().role, "user");
3458
3459        // The breakpoint is placed at the 4th-from-last of the pre-nudge run
3460        // (index 6 of the original 10); the nudge is appended after.
3461        let bp_idx = 6;
3462        for (i, msg) in assembled.messages.iter().enumerate() {
3463            if i == bp_idx {
3464                assert!(
3465                    msg.cache_breakpoint,
3466                    "Message at index {i} should have cache_breakpoint = true"
3467                );
3468            } else {
3469                assert!(
3470                    !msg.cache_breakpoint,
3471                    "Message at index {i} should have cache_breakpoint = false"
3472                );
3473            }
3474        }
3475    }
3476
3477    #[test]
3478    fn test_assemble_cache_breakpoint_small_conversation() {
3479        let mut window = ContextWindow::new(100_000);
3480        let region = Region::new(
3481            "conv".to_string(),
3482            RegionKind::SlidingWindow {
3483                max_items: 100,
3484                eviction_strategy: EvictionStrategy::PerItem,
3485            },
3486            50_000,
3487        );
3488        window.add_region(region);
3489
3490        // Add 3 messages (user, assistant, user)
3491        window
3492            .add_typed_entry(
3493                "conv",
3494                leviath_core::EntryKind::UserMessage,
3495                "Hello".to_string(),
3496                10,
3497            )
3498            .unwrap();
3499        window
3500            .add_typed_entry(
3501                "conv",
3502                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
3503                "Hi there".to_string(),
3504                10,
3505            )
3506            .unwrap();
3507        window
3508            .add_typed_entry(
3509                "conv",
3510                leviath_core::EntryKind::UserMessage,
3511                "How are you?".to_string(),
3512                10,
3513            )
3514            .unwrap();
3515
3516        let assembled = window.assemble();
3517        assert_eq!(assembled.messages.len(), 3);
3518
3519        // With < 5 messages but >= 2, first message gets the breakpoint
3520        assert!(
3521            assembled.messages[0].cache_breakpoint,
3522            "First message should have cache_breakpoint in small conversation"
3523        );
3524        assert!(!assembled.messages[1].cache_breakpoint);
3525        assert!(!assembled.messages[2].cache_breakpoint);
3526    }
3527
3528    #[test]
3529    fn test_assemble_cache_breakpoint_too_few_messages() {
3530        let mut window = ContextWindow::new(100_000);
3531        let region = Region::new(
3532            "conv".to_string(),
3533            RegionKind::SlidingWindow {
3534                max_items: 100,
3535                eviction_strategy: EvictionStrategy::PerItem,
3536            },
3537            50_000,
3538        );
3539        window.add_region(region);
3540
3541        // Add only 1 message
3542        window
3543            .add_typed_entry(
3544                "conv",
3545                leviath_core::EntryKind::UserMessage,
3546                "Solo message".to_string(),
3547                10,
3548            )
3549            .unwrap();
3550
3551        let assembled = window.assemble();
3552        assert_eq!(assembled.messages.len(), 1);
3553
3554        // With only 1 message, no breakpoints should be set
3555        assert!(
3556            !assembled.messages[0].cache_breakpoint,
3557            "Single message should not get a cache breakpoint"
3558        );
3559    }
3560
3561    #[test]
3562    fn test_assemble_system_blocks_sorted_by_cache_stability() {
3563        use leviath_core::CacheHint;
3564
3565        let mut window = ContextWindow::new(100_000);
3566
3567        // Add regions in "wrong" order: volatile first, stable last
3568        let mut clearable = Region::new("scratch".to_string(), RegionKind::Clearable, 10_000);
3569        clearable
3570            .add_entry("clearable data".to_string(), 20)
3571            .unwrap();
3572        window.add_region(clearable);
3573
3574        let mut temporary = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
3575        temporary
3576            .add_entry("temporary data".to_string(), 20)
3577            .unwrap();
3578        window.add_region(temporary);
3579
3580        let mut compacting = Region::new(
3581            "impl".to_string(),
3582            RegionKind::Compacting {
3583                threshold_tokens: 500,
3584            },
3585            10_000,
3586        );
3587        compacting
3588            .add_entry("compacting data".to_string(), 20)
3589            .unwrap();
3590        window.add_region(compacting);
3591
3592        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
3593        pinned
3594            .add_entry("pinned system prompt".to_string(), 20)
3595            .unwrap();
3596        window.add_region(pinned);
3597
3598        let assembled = window.assemble();
3599
3600        assert_eq!(assembled.system_blocks.len(), 4);
3601
3602        // Verify ordering: Always (Pinned) first, UntilChanged (Compacting) second,
3603        // Never (Temporary, Clearable) last
3604        assert_eq!(
3605            assembled.system_blocks[0].cache_hint,
3606            CacheHint::Always,
3607            "First system block should be Always (Pinned)"
3608        );
3609        assert_eq!(
3610            assembled.system_blocks[1].cache_hint,
3611            CacheHint::UntilChanged,
3612            "Second system block should be UntilChanged (Compacting)"
3613        );
3614        assert_eq!(
3615            assembled.system_blocks[2].cache_hint,
3616            CacheHint::Never,
3617            "Third system block should be Never"
3618        );
3619        assert_eq!(
3620            assembled.system_blocks[3].cache_hint,
3621            CacheHint::Never,
3622            "Fourth system block should be Never"
3623        );
3624    }
3625
3626    // ─── Coverage for ContextWindow typed+tainted methods ─────────────────
3627
3628    #[test]
3629    fn test_add_typed_tainted_to_region_success() {
3630        let mut window = ContextWindow::new(10000);
3631        let mut region = Region::new(
3632            "conv".to_string(),
3633            RegionKind::SlidingWindow {
3634                max_items: 50,
3635                eviction_strategy: EvictionStrategy::PerItem,
3636            },
3637            5000,
3638        );
3639        region.enable_taint_tracking();
3640        window.add_region(region);
3641
3642        window
3643            .add_typed_tainted_to_region(
3644                "conv",
3645                leviath_core::EntryKind::ToolResult {
3646                    tool_call_id: "tc_1".to_string(),
3647                    tool_name: "read_file".to_string(),
3648                    is_error: false,
3649                },
3650                "secret data".to_string(),
3651                100,
3652                leviath_core::TaintLevel::Private,
3653            )
3654            .unwrap();
3655
3656        assert_eq!(window.current_tokens, 100);
3657        assert_eq!(
3658            window.get_region("conv").and_then(|r| r.taint_level()),
3659            Some(leviath_core::TaintLevel::Private)
3660        );
3661    }
3662
3663    #[test]
3664    fn test_add_typed_tainted_to_region_not_found() {
3665        let mut window = ContextWindow::new(10000);
3666        let result = window.add_typed_tainted_to_region(
3667            "nonexistent",
3668            leviath_core::EntryKind::Text,
3669            "data".to_string(),
3670            10,
3671            leviath_core::TaintLevel::Public,
3672        );
3673        assert!(result.is_err());
3674    }
3675
3676    #[test]
3677    fn test_assemble_consecutive_tool_results_flushed_at_end() {
3678        // Tool results at the END of the region (not followed by a non-ToolResult)
3679        // should still be flushed into a user message.
3680        let mut window = ContextWindow::new(100_000);
3681        let region = Region::new(
3682            "conv".to_string(),
3683            RegionKind::SlidingWindow {
3684                max_items: 100,
3685                eviction_strategy: EvictionStrategy::PerItem,
3686            },
3687            50_000,
3688        );
3689        window.add_region(region);
3690
3691        // Add user message, then assistant with tool calls, then tool results at end
3692        window
3693            .add_typed_entry(
3694                "conv",
3695                leviath_core::EntryKind::UserMessage,
3696                "do something".to_string(),
3697                10,
3698            )
3699            .unwrap();
3700        window
3701            .add_typed_entry(
3702                "conv",
3703                leviath_core::EntryKind::AssistantTurn {
3704                    tool_calls: vec![leviath_core::SerializedToolCall {
3705                        id: "tc_1".to_string(),
3706                        name: "read_file".to_string(),
3707                        arguments: serde_json::json!({"path": "foo.rs"}),
3708                        thought_signature: None,
3709                    }],
3710                },
3711                "Let me read that".to_string(),
3712                10,
3713            )
3714            .unwrap();
3715        window
3716            .add_typed_entry(
3717                "conv",
3718                leviath_core::EntryKind::ToolResult {
3719                    tool_call_id: "tc_1".to_string(),
3720                    tool_name: "read_file".to_string(),
3721                    is_error: false,
3722                },
3723                "fn main() {}".to_string(),
3724                10,
3725            )
3726            .unwrap();
3727
3728        let assembled = window.assemble();
3729        // user msg + assistant (with tool_use blocks) + user (with tool_result blocks)
3730        assert_eq!(assembled.messages.len(), 3);
3731        assert_eq!(assembled.messages[2].role, "user");
3732        // The last message is a Blocks message carrying the single ToolResult.
3733        assert_eq!(
3734            assembled.messages[2].content,
3735            leviath_providers::MessageContent::Blocks(vec![
3736                leviath_providers::ContentBlock::ToolResult {
3737                    tool_use_id: "tc_1".to_string(),
3738                    content: "fn main() {}".to_string(),
3739                    is_error: false,
3740                },
3741            ])
3742        );
3743    }
3744
3745    #[test]
3746    fn test_assemble_compact_history_with_sliding_prefix_sorting() {
3747        // CompactHistory should sort before Compacting/Temporary in system blocks
3748        use leviath_core::CacheHint;
3749
3750        let mut window = ContextWindow::new(100_000);
3751
3752        let mut temp = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
3753        temp.add_entry("temp data".to_string(), 10).unwrap();
3754        window.add_region(temp);
3755
3756        let mut history = Region::new(
3757            "history".to_string(),
3758            RegionKind::CompactHistory {
3759                source_region: "impl".to_string(),
3760            },
3761            10_000,
3762        );
3763        history.add_entry("summary data".to_string(), 10).unwrap();
3764        window.add_region(history);
3765
3766        let assembled = window.assemble();
3767        assert_eq!(assembled.system_blocks.len(), 2);
3768        // CompactHistory (Always) should come before Temporary (Never)
3769        assert_eq!(assembled.system_blocks[0].cache_hint, CacheHint::Always);
3770        assert_eq!(assembled.system_blocks[1].cache_hint, CacheHint::Never);
3771    }
3772
3773    #[test]
3774    fn cache_hint_sort_priority_orders_by_stability() {
3775        use leviath_core::CacheHint;
3776        // Most stable first (lowest priority), volatile last.
3777        assert_eq!(cache_hint_sort_priority(CacheHint::Always), 0);
3778        assert_eq!(
3779            cache_hint_sort_priority(CacheHint::SlidingPrefix {
3780                stable_fraction: 0.75
3781            }),
3782            1
3783        );
3784        assert_eq!(cache_hint_sort_priority(CacheHint::UntilChanged), 2);
3785        assert_eq!(cache_hint_sort_priority(CacheHint::Never), 3);
3786        // The four priorities are strictly increasing by volatility.
3787        assert!(
3788            cache_hint_sort_priority(CacheHint::Always)
3789                < cache_hint_sort_priority(CacheHint::SlidingPrefix {
3790                    stable_fraction: 0.5
3791                })
3792        );
3793    }
3794
3795    #[test]
3796    fn test_assemble_empty_regions_skipped() {
3797        let mut window = ContextWindow::new(100_000);
3798        window.add_region(Region::new(
3799            "system".to_string(),
3800            RegionKind::Pinned,
3801            10_000,
3802        ));
3803        // Empty pinned region should be skipped
3804        let assembled = window.assemble();
3805        assert!(assembled.system_blocks.is_empty());
3806    }
3807
3808    #[test]
3809    fn test_assemble_hashmap_region_with_keys() {
3810        let mut window = ContextWindow::new(100_000);
3811        let mut region = Region::new(
3812            "files".to_string(),
3813            RegionKind::HashMap { max_entries: None },
3814            10_000,
3815        );
3816        region
3817            .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
3818            .unwrap();
3819        region
3820            .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
3821            .unwrap();
3822        window.add_region(region);
3823
3824        let assembled = window.assemble();
3825        assert_eq!(assembled.system_blocks.len(), 1);
3826        let block_text = &assembled.system_blocks[0].text;
3827        assert!(block_text.contains("[files]:"));
3828        assert!(block_text.contains("### [src/main.rs]"));
3829        assert!(block_text.contains("fn main() {}"));
3830        assert!(block_text.contains("### [src/lib.rs]"));
3831        assert!(block_text.contains("pub mod foo;"));
3832    }
3833
3834    #[test]
3835    fn test_assemble_hashmap_region_cache_hint() {
3836        let mut window = ContextWindow::new(100_000);
3837        let mut region = Region::new(
3838            "files".to_string(),
3839            RegionKind::HashMap { max_entries: None },
3840            10_000,
3841        );
3842        region
3843            .upsert_by_key("a.rs", "content".to_string(), 5)
3844            .unwrap();
3845        window.add_region(region);
3846
3847        let assembled = window.assemble();
3848        assert_eq!(assembled.system_blocks.len(), 1);
3849        assert_eq!(
3850            assembled.system_blocks[0].cache_hint,
3851            leviath_core::CacheHint::UntilChanged
3852        );
3853    }
3854
3855    // ─── HashMap region assembly tests ──────────────────────────────────
3856
3857    #[test]
3858    fn test_assemble_hashmap_single_keyed_entry() {
3859        let mut window = ContextWindow::new(100_000);
3860        let mut region = Region::new(
3861            "context".to_string(),
3862            RegionKind::HashMap { max_entries: None },
3863            10_000,
3864        );
3865        region
3866            .upsert_by_key("config.toml", "key = \"value\"".to_string(), 10)
3867            .unwrap();
3868        window.add_region(region);
3869
3870        let assembled = window.assemble();
3871
3872        assert_eq!(assembled.system_blocks.len(), 1);
3873        let block_text = &assembled.system_blocks[0].text;
3874        assert!(
3875            block_text.starts_with("[context]:"),
3876            "System block should start with [region_name]: prefix"
3877        );
3878        assert!(
3879            block_text.contains("### [config.toml]"),
3880            "Entry should have ### [key] header"
3881        );
3882        assert!(
3883            block_text.contains("key = \"value\""),
3884            "Entry content should be present"
3885        );
3886    }
3887
3888    #[test]
3889    fn test_assemble_hashmap_multiple_keyed_entries() {
3890        let mut window = ContextWindow::new(100_000);
3891        let mut region = Region::new(
3892            "tracked_files".to_string(),
3893            RegionKind::HashMap { max_entries: None },
3894            10_000,
3895        );
3896        region
3897            .upsert_by_key("alpha.rs", "fn alpha() {}".to_string(), 10)
3898            .unwrap();
3899        region
3900            .upsert_by_key("beta.rs", "fn beta() {}".to_string(), 10)
3901            .unwrap();
3902        region
3903            .upsert_by_key("gamma.rs", "fn gamma() {}".to_string(), 10)
3904            .unwrap();
3905        window.add_region(region);
3906
3907        let assembled = window.assemble();
3908
3909        assert_eq!(assembled.system_blocks.len(), 1);
3910        let block_text = &assembled.system_blocks[0].text;
3911        assert!(block_text.starts_with("[tracked_files]:"));
3912        assert!(block_text.contains("### [alpha.rs]"));
3913        assert!(block_text.contains("fn alpha() {}"));
3914        assert!(block_text.contains("### [beta.rs]"));
3915        assert!(block_text.contains("fn beta() {}"));
3916        assert!(block_text.contains("### [gamma.rs]"));
3917        assert!(block_text.contains("fn gamma() {}"));
3918    }
3919
3920    #[test]
3921    fn test_assemble_hashmap_empty_region_skipped() {
3922        let mut window = ContextWindow::new(100_000);
3923        let region = Region::new(
3924            "empty_map".to_string(),
3925            RegionKind::HashMap { max_entries: None },
3926            10_000,
3927        );
3928        // No entries added
3929        window.add_region(region);
3930
3931        let assembled = window.assemble();
3932
3933        assert!(
3934            assembled.system_blocks.is_empty(),
3935            "Empty HashMap region should not produce a system block"
3936        );
3937    }
3938
3939    #[test]
3940    fn test_assemble_mixed_pinned_hashmap_sliding_window() {
3941        use leviath_core::CacheHint;
3942
3943        let mut window = ContextWindow::new(100_000);
3944
3945        // Pinned region
3946        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
3947        pinned
3948            .add_entry("You are a helpful assistant.".to_string(), 20)
3949            .unwrap();
3950        window.add_region(pinned);
3951
3952        // HashMap region
3953        let mut hashmap = Region::new(
3954            "files".to_string(),
3955            RegionKind::HashMap { max_entries: None },
3956            10_000,
3957        );
3958        hashmap
3959            .upsert_by_key("main.rs", "fn main() {}".to_string(), 10)
3960            .unwrap();
3961        window.add_region(hashmap);
3962
3963        // SlidingWindow region with user messages
3964        let mut sliding = Region::new(
3965            "conv".to_string(),
3966            RegionKind::SlidingWindow {
3967                max_items: 100,
3968                eviction_strategy: EvictionStrategy::PerItem,
3969            },
3970            50_000,
3971        );
3972        sliding
3973            .add_typed_entry(
3974                "Hello there".to_string(),
3975                10,
3976                leviath_core::EntryKind::UserMessage,
3977            )
3978            .unwrap();
3979        window.add_region(sliding);
3980
3981        let assembled = window.assemble();
3982
3983        // Pinned and HashMap should produce system blocks (2 total)
3984        assert_eq!(assembled.system_blocks.len(), 2);
3985
3986        // System blocks sorted by cache hint: Pinned (Always) first, HashMap (UntilChanged) second
3987        assert_eq!(
3988            assembled.system_blocks[0].cache_hint,
3989            CacheHint::Always,
3990            "Pinned region should sort first (Always cache hint)"
3991        );
3992        assert!(
3993            assembled.system_blocks[0]
3994                .text
3995                .contains("You are a helpful assistant."),
3996            "First system block should be the pinned content"
3997        );
3998
3999        assert_eq!(
4000            assembled.system_blocks[1].cache_hint,
4001            CacheHint::UntilChanged,
4002            "HashMap region should sort second (UntilChanged cache hint)"
4003        );
4004        assert!(
4005            assembled.system_blocks[1].text.starts_with("[files]:"),
4006            "HashMap system block should have [region_name]: prefix"
4007        );
4008        assert!(
4009            assembled.system_blocks[1].text.contains("### [main.rs]"),
4010            "HashMap system block should contain ### [key] header"
4011        );
4012
4013        // SlidingWindow should produce messages, not system blocks
4014        assert!(
4015            assembled
4016                .messages
4017                .iter()
4018                .any(|m| m.role == "user" && m.content.as_text().contains("Hello there")),
4019            "SlidingWindow entries should appear as messages"
4020        );
4021    }
4022
4023    #[test]
4024    fn test_add_tainted_to_region_propagates_budget_error() {
4025        // Region is found, but the entry exceeds its token budget, so the
4026        // inner `add_tainted_entry` error must propagate through the `?`.
4027        let mut window = ContextWindow::new(10_000);
4028        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
4029        region.enable_taint_tracking();
4030        window.add_region(region);
4031
4032        let result = window.add_tainted_to_region(
4033            "conv",
4034            "far too many tokens".to_string(),
4035            100,
4036            leviath_core::TaintLevel::Private,
4037        );
4038        assert!(result.is_err());
4039    }
4040
4041    #[test]
4042    fn test_add_typed_tainted_to_region_propagates_budget_error() {
4043        // Region is found, but the entry exceeds its token budget, so the
4044        // inner `add_typed_tainted_entry` error must propagate through the `?`.
4045        let mut window = ContextWindow::new(10_000);
4046        let region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
4047        window.add_region(region);
4048
4049        let result = window.add_typed_tainted_to_region(
4050            "conv",
4051            leviath_core::EntryKind::Text,
4052            "far too many tokens".to_string(),
4053            100,
4054            leviath_core::TaintLevel::Public,
4055        );
4056        assert!(result.is_err());
4057    }
4058
4059    #[test]
4060    fn test_assemble_hashmap_region_entry_without_key() {
4061        // A HashMap-region entry with no key falls back to its raw content
4062        // (rather than a "### [key]" header) when assembled.
4063        let mut window = ContextWindow::new(10_000);
4064        let region = Region::new(
4065            "kv".to_string(),
4066            RegionKind::HashMap { max_entries: None },
4067            5000,
4068        );
4069        window.add_region(region);
4070        // add_to_region stores the entry with key: None.
4071        window
4072            .add_to_region("kv", "keyless content".to_string(), 10)
4073            .unwrap();
4074
4075        let assembled = window.assemble();
4076        assert!(
4077            assembled
4078                .system_blocks
4079                .iter()
4080                .any(|b| b.text.contains("keyless content")),
4081            "keyless HashMap entry should appear verbatim in a system block"
4082        );
4083    }
4084}