Skip to main content

leviath_runtime/components/
mod.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/// The output validators this agent's blueprint names, compiled, keyed by the
88/// path written in the blueprint.
89///
90/// Compiled once at spawn (a broken script is a spawn error, not a surprise at
91/// the end of a long run) and looked up when a submission arrives. Absent when
92/// the blueprint names none, which is nearly every agent.
93#[derive(Component, Clone, Default)]
94pub struct OutputValidators(
95    pub  std::collections::HashMap<
96        String,
97        std::sync::Arc<leviath_scripting::output_validator::OutputValidator>,
98    >,
99);
100
101/// `--yolo`'s counterpart for blueprint-declared interaction points: approve
102/// them without opening a prompt.
103///
104/// A stage-boundary checkpoint (`plan_approval` and friends) blocks on the
105/// interaction hub exactly like a tool approval does, so an unattended run
106/// would park at the first one forever - the same dead end a blocking tool
107/// approval poses for a headless run, reached a different way. When present,
108/// [`dispatch_interaction_point`](crate::interaction_points::dispatch_interaction_point)
109/// still publishes the document to its region (so the decision is inspectable
110/// afterwards) but resolves the point as approved.
111#[derive(Component, Debug, Clone, Copy, Default)]
112pub struct InteractionAutoApprove;
113
114/// Status of an agent.
115///
116/// `Hash` so the driver's quiescence check can fold an agent's status into its
117/// per-tick digest (see `PipelineWorld::agent_digest`).
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
119pub enum AgentStatus {
120    /// Agent is idle, ready for tasks
121    Idle,
122
123    /// Agent is actively working on a task
124    Active,
125
126    /// Agent is waiting for input or external event
127    Waiting,
128
129    /// Agent was paused by the user. The async-starting systems skip it exactly
130    /// like `Idle`; the variant is distinct so the pause persists visibly
131    /// (`meta.json`, `lev ps`, dashboard) and so resume can be gated on it.
132    Paused,
133
134    /// Agent has completed its task
135    Complete,
136
137    /// Agent encountered an error
138    Error {
139        /// What went wrong, as shown to the user and written to the run record.
140        message: String,
141    },
142
143    /// Agent was cancelled by the user or system
144    Cancelled,
145}
146
147impl AgentStatus {
148    /// The short, stable lowercase word for this status.
149    ///
150    /// One table, because three used to drift independently: `lev ps`, the
151    /// [`WorldEvent`](crate::host::WorldEvent) stream (and through it the REST
152    /// WebSocket), and the `check_agent` tool result the model reads. The
153    /// strings are part of the daemon's wire contract, so they are fixed here
154    /// rather than derived from the variant names.
155    pub fn label(&self) -> &'static str {
156        match self {
157            Self::Idle => "idle",
158            Self::Active => "active",
159            Self::Waiting => "waiting",
160            Self::Paused => "paused",
161            Self::Complete => "complete",
162            Self::Error { .. } => "error",
163            Self::Cancelled => "cancelled",
164        }
165    }
166}
167
168impl std::fmt::Display for AgentStatus {
169    /// [`AgentStatus::label`], except that an error carries its message. Use
170    /// this where a human (or the model) reads the status; use `label` where a
171    /// fixed vocabulary is expected.
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::Error { message } => write!(f, "error: {message}"),
175            other => f.write_str(other.label()),
176        }
177    }
178}
179
180/// Why an agent's status is [`AgentStatus::Waiting`].
181///
182/// Lives in `leviath-core` because it is written to `meta.json` as well as
183/// reported live over the control socket, and re-exported here so every
184/// existing `components::WaitReason` path keeps working.
185pub use leviath_core::run_meta::WaitReason;
186
187// The context window, which was two thirds of this file. Glob re-exported so
188// every existing `components::ContextWindow` path keeps working.
189mod context_window;
190pub use context_window::*;
191
192/// Compiled stage-hook scripts for an agent, keyed by the script path the
193/// blueprint wrote (issue #260).
194///
195/// Populated once at spawn by the CLI, which resolves blueprint-dir-relative
196/// paths and compile-checks the files - the same lifecycle
197/// [`ContextWindow::region_scripts`] has, and for the same reason: a broken
198/// script must fail the spawn, not the run.
199///
200/// The component is absent entirely on an agent whose blueprint declares no
201/// hooks, so the hook systems' queries skip it and nothing about the scripting
202/// engine is touched.
203#[derive(Component, Debug, Clone, Default)]
204pub struct StageHookScripts(
205    pub std::collections::HashMap<String, std::sync::Arc<leviath_scripting::stage_hook::HookScript>>,
206);
207
208impl StageHookScripts {
209    /// The compiled script backing `hook` for this stage, when the stage
210    /// declares one and it is on file.
211    ///
212    /// Returns `None` rather than erroring on a miss: spawn already refused a
213    /// blueprint whose script was unreadable or did not define what it was
214    /// named for, so a miss here means the stage simply has no such hook.
215    pub fn script_for(
216        &self,
217        stage: &leviath_core::Stage,
218        hook: &str,
219    ) -> Option<std::sync::Arc<leviath_scripting::stage_hook::HookScript>> {
220        let path = match hook {
221            "on_stage_enter" => stage.hooks.on_stage_enter.as_deref(),
222            "on_stage_exit" => stage.hooks.on_stage_exit.as_deref(),
223            "before_inference" => stage.hooks.before_inference.as_deref(),
224            "after_inference" => stage.hooks.after_inference.as_deref(),
225            "on_tool_call" => stage.hooks.on_tool_call.as_deref(),
226            "on_completion" => stage.hooks.on_completion.as_deref(),
227            "on_error" => stage.hooks.on_error.as_deref(),
228            _ => None,
229        }?;
230        self.0.get(path).cloned()
231    }
232}
233
234/// Inference result component.
235///
236/// Stores the result of an LLM inference call, including the response
237/// and any tool calls that need to be executed.
238#[derive(Component, Debug, Clone)]
239pub struct InferenceResult {
240    /// The model's response text
241    pub response: String,
242
243    /// Tool calls requested by the model
244    pub tool_calls: Vec<ToolCall>,
245
246    /// Tokens used in this inference
247    pub tokens_used: usize,
248
249    /// Timestamp of this inference
250    pub timestamp: i64,
251}
252
253/// A tool call requested by the model.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ToolCall {
256    /// Tool identifier
257    pub tool_id: String,
258
259    /// Tool name
260    pub name: String,
261
262    /// Tool arguments
263    pub arguments: serde_json::Value,
264    /// Opaque provider token echoed back with this call on the next request
265    /// (Gemini's `thought_signature`); `None` when the provider has none.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub thought_signature: Option<String>,
268}
269
270/// A message that can be sent to a running agent.
271#[derive(Debug, Clone)]
272pub struct AgentMessage {
273    /// Target agent ID
274    pub agent_id: String,
275    /// Message content
276    pub content: String,
277    /// Which region to add the message to (default: "conversation")
278    pub target_region: Option<String>,
279}
280
281/// Inbox component for receiving messages sent to a running agent.
282#[derive(Component, Debug, Clone)]
283pub struct MessageInbox {
284    /// Pending messages waiting to be processed
285    pub messages: Vec<AgentMessage>,
286}
287
288impl MessageInbox {
289    /// Create a new empty inbox.
290    pub fn new() -> Self {
291        Self {
292            messages: Vec::new(),
293        }
294    }
295
296    /// Add a message to the inbox. Messages deliver in the order they
297    /// arrived, and deliberately carry no priority: nothing that sends one
298    /// has a reason to reorder, and a priority field nobody sets is a field
299    /// every reader of the inbox has to rule out first.
300    pub fn push(&mut self, msg: AgentMessage) {
301        self.messages.push(msg);
302    }
303
304    /// Drain all messages from the inbox.
305    pub fn drain_all(&mut self) -> Vec<AgentMessage> {
306        std::mem::take(&mut self.messages)
307    }
308}
309
310impl Default for MessageInbox {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::test_support::with_tracing;
320    use leviath_core::{EvictionStrategy, Region, RegionKind};
321
322    #[test]
323    fn test_context_window_creation() {
324        let window = ContextWindow::new(10000);
325        assert_eq!(window.max_tokens, 10000);
326        assert_eq!(window.current_tokens, 0);
327    }
328
329    #[test]
330    fn test_needs_eviction() {
331        let mut window = ContextWindow::new(10000);
332        window.current_tokens = 9500;
333        assert!(window.needs_eviction(0.9));
334
335        window.current_tokens = 5000;
336        assert!(!window.needs_eviction(0.9));
337    }
338
339    #[test]
340    fn test_add_region() {
341        let mut window = ContextWindow::new(10000);
342        let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
343        window.add_region(region);
344        assert_eq!(window.regions.len(), 1);
345    }
346
347    #[test]
348    fn replace_region_overwrites_existing_and_reports_missing() {
349        let mut window = ContextWindow::new(10000);
350        let mut region = Region::new("plan".to_string(), RegionKind::Pinned, 6000);
351        region.add_entry("old plan".to_string(), 3).unwrap();
352        window.add_region(region);
353
354        // Replacing an existing region overwrites its content wholesale.
355        assert!(window.replace_region("plan", "new plan".to_string(), 3));
356        let plan = window.get_region("plan").unwrap();
357        assert_eq!(plan.content.len(), 1);
358        assert_eq!(plan.content[0].content, "new plan");
359
360        // A missing region is a no-op that reports false.
361        assert!(!window.replace_region("nope", "x".to_string(), 1));
362    }
363
364    #[test]
365    fn test_clearable_eviction() {
366        let mut window = ContextWindow::new(10000);
367        let mut region = Region::new("scratch".to_string(), RegionKind::Clearable, 5000);
368        region
369            .add_entry("test content 1".to_string(), 1000)
370            .unwrap();
371        region
372            .add_entry("test content 2".to_string(), 1000)
373            .unwrap();
374        window.add_region(region);
375
376        assert_eq!(window.current_tokens, 2000);
377
378        // Evict should clear the entire Clearable region
379        let result = with_tracing(|| window.try_evict(1000)).unwrap();
380        assert_eq!(result.tokens_freed, 2000);
381        assert!(result.needs_compaction.is_empty());
382        assert_eq!(window.current_tokens, 0);
383    }
384
385    #[test]
386    fn test_temporary_eviction_oldest_first() {
387        let mut window = ContextWindow::new(10000);
388        let mut region = Region::new("temp".to_string(), RegionKind::Temporary, 5000);
389        region.add_entry("old content".to_string(), 1000).unwrap();
390        region
391            .add_entry("middle content".to_string(), 1000)
392            .unwrap();
393        region.add_entry("new content".to_string(), 1000).unwrap();
394        window.add_region(region);
395
396        assert_eq!(window.current_tokens, 3000);
397
398        // Evict should remove oldest first
399        let result = with_tracing(|| window.try_evict(500)).unwrap();
400        assert!(result.tokens_freed >= 1000); // Should free at least one entry
401        assert!(result.needs_compaction.is_empty());
402
403        // Check that oldest was removed
404        let region = window.get_region("temp").unwrap();
405        assert_eq!(region.content.len(), 2);
406        assert_eq!(region.content[0].content, "middle content");
407    }
408
409    fn assert_sliding_window_unreduced(initial_count: usize, after_count: usize) {
410        assert_eq!(
411            initial_count, after_count,
412            "SlidingWindow should never be reduced during eviction"
413        );
414    }
415
416    #[test]
417    fn test_sliding_window_never_reduced() {
418        let mut window = ContextWindow::new(10000);
419        let mut region = Region::new(
420            "conversation".to_string(),
421            RegionKind::SlidingWindow {
422                max_items: 5,
423                eviction_strategy: EvictionStrategy::PerItem,
424            },
425            5000,
426        );
427        region.add_entry("msg 1".to_string(), 1000).unwrap();
428        region.add_entry("msg 2".to_string(), 1000).unwrap();
429        region.add_entry("msg 3".to_string(), 1000).unwrap();
430        window.add_region(region);
431
432        let initial_count = window.get_region("conversation").unwrap().content.len();
433
434        // Try to evict - should not touch SlidingWindow
435        window.try_evict(1000).ok();
436
437        let after_count = window.get_region("conversation").unwrap().content.len();
438        assert_sliding_window_unreduced(initial_count, after_count);
439    }
440
441    #[test]
442    #[should_panic(expected = "SlidingWindow should never be reduced during eviction")]
443    fn test_sliding_window_never_reduced_panics_on_mismatch() {
444        assert_sliding_window_unreduced(3, 2);
445    }
446
447    fn assert_pinned_unevicted(initial_tokens: usize, after_tokens: usize) {
448        assert_eq!(
449            initial_tokens, after_tokens,
450            "Pinned region should never be evicted"
451        );
452    }
453
454    #[test]
455    fn test_pinned_never_touched() {
456        let mut window = ContextWindow::new(10000);
457        let mut region = Region::new("architecture".to_string(), RegionKind::Pinned, 3000);
458        region
459            .add_entry("architecture diagram".to_string(), 2000)
460            .unwrap();
461        window.add_region(region);
462
463        let initial_tokens = window.get_region("architecture").unwrap().current_tokens;
464
465        // Try to evict - should not touch Pinned
466        window.try_evict(1000).ok();
467
468        let after_tokens = window.get_region("architecture").unwrap().current_tokens;
469        assert_pinned_unevicted(initial_tokens, after_tokens);
470    }
471
472    #[test]
473    #[should_panic(expected = "Pinned region should never be evicted")]
474    fn test_pinned_never_touched_panics_on_mismatch() {
475        assert_pinned_unevicted(2000, 1000);
476    }
477
478    #[test]
479    fn test_eviction_cascade_order() {
480        let mut window = ContextWindow::new(10000);
481
482        // Add Clearable region
483        let mut clearable = Region::new("scratch".to_string(), RegionKind::Clearable, 2000);
484        clearable
485            .add_entry("scratch data".to_string(), 1000)
486            .unwrap();
487        window.add_region(clearable);
488
489        // Add Temporary region
490        let mut temporary = Region::new("temp".to_string(), RegionKind::Temporary, 3000);
491        temporary
492            .add_entry("temp data 1".to_string(), 1000)
493            .unwrap();
494        temporary
495            .add_entry("temp data 2".to_string(), 1000)
496            .unwrap();
497        window.add_region(temporary);
498
499        assert_eq!(window.current_tokens, 3000);
500
501        // Evict with small target - should clear Clearable first
502        window.try_evict(500).unwrap();
503
504        // Clearable should be empty
505        assert_eq!(window.get_region("scratch").unwrap().current_tokens, 0);
506
507        // Temporary should still have content
508        assert!(window.get_region("temp").unwrap().current_tokens > 0);
509    }
510
511    #[test]
512    fn test_message_inbox() {
513        let mut inbox = MessageInbox::new();
514        assert!(inbox.messages.is_empty());
515
516        inbox.push(AgentMessage {
517            agent_id: "agent-1".to_string(),
518            content: "hello".to_string(),
519            target_region: None,
520        });
521        assert_eq!(inbox.messages.len(), 1);
522
523        let drained = inbox.drain_all();
524        assert_eq!(drained.len(), 1);
525        assert!(inbox.messages.is_empty());
526    }
527
528    #[test]
529    fn message_inbox_preserves_fifo_order() {
530        let mut inbox = MessageInbox::new();
531        for content in ["first", "second", "third"] {
532            inbox.push(AgentMessage {
533                agent_id: "a".to_string(),
534                content: content.to_string(),
535                target_region: None,
536            });
537        }
538
539        let msgs = inbox.drain_all();
540        assert_eq!(msgs[0].content, "first");
541        assert_eq!(msgs[1].content, "second");
542        assert_eq!(msgs[2].content, "third");
543    }
544
545    #[test]
546    fn test_eviction_result_identifies_compaction_regions() {
547        // Small window so compacting region fills most of it
548        let mut window = ContextWindow::new(1000);
549        // Add a compacting region that's over threshold
550        let mut compacting = Region::new(
551            "impl".to_string(),
552            RegionKind::Compacting {
553                threshold_tokens: 500,
554            },
555            900,
556        );
557        compacting
558            .add_entry("lots of content".to_string(), 600)
559            .unwrap();
560        window.add_region(compacting);
561
562        assert_eq!(window.current_tokens, 600);
563
564        // Request 500 free tokens - only 400 free, can't free clearable/temporary, so compacting should be identified
565        let result = window.try_evict(500).unwrap();
566        assert_eq!(result.tokens_freed, 0);
567        assert_eq!(result.needs_compaction, vec!["impl".to_string()]);
568    }
569
570    #[test]
571    fn test_try_evict_returns_needs_compaction_when_full() {
572        let mut window = ContextWindow::new(1200);
573
574        // Fill with compacting region content above threshold
575        let mut compacting = Region::new(
576            "analysis".to_string(),
577            RegionKind::Compacting {
578                threshold_tokens: 800,
579            },
580            1100,
581        );
582        compacting.add_entry("data 1".to_string(), 500).unwrap();
583        compacting.add_entry("data 2".to_string(), 500).unwrap();
584        window.add_region(compacting);
585
586        // 200 free tokens, request 500 → needs compaction
587        let result = window.try_evict(500).unwrap();
588        assert_eq!(result.tokens_freed, 0);
589        assert!(result.needs_compaction.contains(&"analysis".to_string()));
590    }
591
592    #[test]
593    fn test_try_evict_errors_when_pinned_regions_exceed_budget() {
594        // Pinned/CompactHistory regions are never evicted - if their combined
595        // token usage alone exceeds max_tokens, try_evict must report this as
596        // a configuration error instead of silently doing nothing useful.
597        let mut window = ContextWindow::new(1000);
598        let mut pinned = Region::new("architecture".to_string(), RegionKind::Pinned, 2000);
599        pinned
600            .add_entry("huge pinned doc".to_string(), 1500)
601            .unwrap();
602        window.add_region(pinned);
603
604        let result = window.try_evict(100);
605        assert!(result.is_err());
606        let err_str = result.unwrap_err().to_string();
607        assert!(err_str.contains("Pinned regions"));
608    }
609
610    #[test]
611    fn test_clearable_eviction_continues_past_insufficient_first_region() {
612        // Phase 1 clears Clearable regions one at a time and returns early as
613        // soon as enough space has been freed. If clearing the *first*
614        // Clearable region alone isn't enough, the loop must fall through and
615        // keep clearing subsequent Clearable regions rather than stopping.
616        let mut window = ContextWindow::new(2000);
617
618        let mut region_a = Region::new("a".to_string(), RegionKind::Clearable, 1000);
619        region_a.add_entry("small".to_string(), 500).unwrap();
620        window.add_region(region_a);
621
622        let mut region_b = Region::new("b".to_string(), RegionKind::Clearable, 1000);
623        region_b.add_entry("large".to_string(), 1000).unwrap();
624        window.add_region(region_b);
625
626        assert_eq!(window.current_tokens, 1500);
627
628        // After clearing only "a" (frees 500), 2000 - 1000 = 1000 free tokens,
629        // which is still below the 1400 target, so the loop must continue on
630        // to clear "b" as well before it can satisfy the request.
631        let result = with_tracing(|| window.try_evict(1400)).unwrap();
632        assert_eq!(result.tokens_freed, 1500);
633        assert_eq!(window.current_tokens, 0);
634        assert_eq!(window.get_region("a").unwrap().current_tokens, 0);
635        assert_eq!(window.get_region("b").unwrap().current_tokens, 0);
636    }
637
638    #[test]
639    fn test_agent_status_cancelled() {
640        assert_eq!(AgentStatus::Cancelled, AgentStatus::Cancelled);
641    }
642
643    #[test]
644    fn test_parent_ref_component() {
645        let parent_ref = super::ParentRef {
646            parent_entity: Entity::from_raw_u32(42)
647                .expect("a small literal index is always a valid entity id"),
648            parent_agent_id: "coder-01".to_string(),
649            depth: 1,
650        };
651        assert_eq!(parent_ref.parent_agent_id, "coder-01");
652        assert_eq!(parent_ref.depth, 1);
653    }
654
655    #[test]
656    fn test_children_component() {
657        let children = super::SubAgentChildren {
658            children: vec![
659                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
660                Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id"),
661            ],
662            max_child_depth: 3,
663        };
664        assert_eq!(children.children.len(), 2);
665        assert_eq!(children.max_child_depth, 3);
666    }
667
668    #[test]
669    fn test_agent_state_with_children_fields() {
670        let state = AgentState {
671            agent_id: "test-01".to_string(),
672            current_stage: "analyze".to_string(),
673            iteration: 0,
674            status: AgentStatus::Active,
675            spawned_children_ids: vec!["child-01".to_string(), "child-02".to_string()],
676            pending_wait: Some("child-01".to_string()),
677            accepts_messages: true,
678        };
679        assert_eq!(state.spawned_children_ids.len(), 2);
680        assert_eq!(state.pending_wait, Some("child-01".to_string()));
681    }
682
683    // ── Additional coverage tests ──────────────────────────────────────────
684
685    #[test]
686    fn test_context_window_get_region() {
687        let mut window = ContextWindow::new(10000);
688        let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
689        window.add_region(region);
690
691        assert!(window.get_region("test").is_some());
692        assert!(window.get_region("nonexistent").is_none());
693    }
694
695    #[test]
696    fn test_context_window_get_region_mut() {
697        let mut window = ContextWindow::new(10000);
698        let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
699        window.add_region(region);
700
701        let region = window.get_region_mut("test").unwrap();
702        region.add_entry("new content".to_string(), 50).unwrap();
703        assert_eq!(region.content.len(), 1);
704
705        assert!(window.get_region_mut("nonexistent").is_none());
706    }
707
708    #[test]
709    fn test_context_window_add_to_region_success() {
710        let mut window = ContextWindow::new(10000);
711        let region = Region::new("conv".to_string(), RegionKind::Temporary, 5000);
712        window.add_region(region);
713
714        let result = window.add_to_region("conv", "Hello".to_string(), 10);
715        assert!(result.is_ok());
716        assert_eq!(window.current_tokens, 10);
717    }
718
719    #[test]
720    fn test_context_window_add_to_region_not_found() {
721        let mut window = ContextWindow::new(10000);
722        let result = window.add_to_region("nonexistent", "Hello".to_string(), 10);
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn test_context_window_calculate_tokens() {
728        let mut window = ContextWindow::new(10000);
729        let mut r1 = Region::new("a".to_string(), RegionKind::Pinned, 5000);
730        r1.add_entry("x".to_string(), 100).unwrap();
731        let mut r2 = Region::new("b".to_string(), RegionKind::Temporary, 5000);
732        r2.add_entry("y".to_string(), 200).unwrap();
733        window.add_region(r1);
734        window.add_region(r2);
735
736        assert_eq!(window.calculate_tokens(), 300);
737    }
738
739    #[test]
740    fn test_context_window_needs_eviction_boundary() {
741        let mut window = ContextWindow::new(100);
742        // Exactly 90% → should trigger at 0.9 threshold
743        window.current_tokens = 90;
744        assert!(window.needs_eviction(0.9));
745
746        // Just below 90%
747        window.current_tokens = 89;
748        assert!(!window.needs_eviction(0.9));
749    }
750
751    #[test]
752    fn test_eviction_result_default_fields() {
753        let result = EvictionResult {
754            tokens_freed: 0,
755            needs_compaction: Vec::new(),
756        };
757        assert_eq!(result.tokens_freed, 0);
758        assert!(result.needs_compaction.is_empty());
759    }
760
761    #[test]
762    fn test_message_inbox_default() {
763        let inbox = MessageInbox::default();
764        assert!(inbox.messages.is_empty());
765    }
766
767    #[test]
768    fn test_message_inbox_drain_all_empties() {
769        let mut inbox = MessageInbox::new();
770        inbox.push(AgentMessage {
771            agent_id: "a".to_string(),
772            content: "msg".to_string(),
773            target_region: None,
774        });
775        let _ = inbox.drain_all();
776        assert!(inbox.messages.is_empty());
777        // Drain again should return empty vec
778        let result = inbox.drain_all();
779        assert!(result.is_empty());
780    }
781
782    #[test]
783    fn test_agent_message_clone() {
784        let msg = AgentMessage {
785            agent_id: "agent-1".to_string(),
786            content: "hello".to_string(),
787            target_region: Some("conv".to_string()),
788        };
789        let cloned = msg.clone();
790        assert_eq!(cloned.agent_id, "agent-1");
791        assert_eq!(cloned.content, "hello");
792        assert_eq!(cloned.target_region, Some("conv".to_string()));
793    }
794
795    #[test]
796    fn test_agent_status_serialization() {
797        let status = AgentStatus::Active;
798        let json = serde_json::to_string(&status).unwrap();
799        assert!(json.contains("Active"));
800
801        let error_status = AgentStatus::Error {
802            message: "boom".to_string(),
803        };
804        let json = serde_json::to_string(&error_status).unwrap();
805        assert!(json.contains("boom"));
806    }
807
808    #[test]
809    fn test_tool_call_serialization() {
810        let tc = ToolCall {
811            tool_id: "tool-1".to_string(),
812            name: "search".to_string(),
813            arguments: serde_json::json!({"query": "rust"}),
814            thought_signature: None,
815        };
816        let json = serde_json::to_string(&tc).unwrap();
817        assert!(json.contains("search"));
818        assert!(json.contains("rust"));
819    }
820
821    #[test]
822    fn test_eviction_with_only_pinned_region_frees_nothing() {
823        // When the only region is Pinned (within budget), eviction frees nothing.
824        let mut window = ContextWindow::new(10000);
825        let mut pinned = Region::new("pinned".to_string(), RegionKind::Pinned, 5000);
826        pinned
827            .add_entry("important data".to_string(), 2000)
828            .unwrap();
829        window.add_region(pinned);
830
831        let result = with_tracing(|| window.try_evict(500)).unwrap();
832        assert_eq!(result.tokens_freed, 0);
833        assert!(result.needs_compaction.is_empty());
834    }
835
836    #[test]
837    fn test_inference_result_fields() {
838        let ir = InferenceResult {
839            response: "Hello".to_string(),
840            tool_calls: vec![ToolCall {
841                tool_id: "t1".to_string(),
842                name: "search".to_string(),
843                arguments: serde_json::json!({}),
844                thought_signature: None,
845            }],
846            tokens_used: 100,
847            timestamp: 99999,
848        };
849        assert_eq!(ir.response, "Hello");
850        assert_eq!(ir.tool_calls.len(), 1);
851        assert_eq!(ir.tokens_used, 100);
852    }
853
854    #[test]
855    fn test_sub_agent_children_clone() {
856        let children = SubAgentChildren {
857            children: vec![
858                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
859            ],
860            max_child_depth: 2,
861        };
862        let cloned = children.clone();
863        assert_eq!(cloned.children.len(), 1);
864        assert_eq!(cloned.max_child_depth, 2);
865    }
866
867    // ─── try_evict: FALSE path after each single-entry removal ────────────
868    // Covers 235:25 (false path of the early-return check) and 242:13 (break).
869    //
870    // Setup: max=1000, current=950, target=200.
871    // Two Temporary entries of 50 tokens each.
872    //
873    // Pass 1: remove entry1 (50 tokens) → current=900, available=100 < 200
874    //   → condition FALSE → line 235 covered → outer loop continues
875    // Pass 2: remove entry2 (50 tokens) → current=850, available=150 < 200
876    //   → condition FALSE → line 235 covered again
877    // Pass 3: no more entries → evicted_any=false → break → line 242 covered
878
879    #[test]
880    fn try_evict_continues_loop_when_each_entry_removal_is_insufficient() {
881        let mut window = ContextWindow::new(1000);
882        let mut temp = Region::new("cache".to_string(), RegionKind::Temporary, 800);
883        temp.add_entry("entry1".to_string(), 50).unwrap();
884        temp.add_entry("entry2".to_string(), 50).unwrap();
885        window.add_region(temp);
886        window.current_tokens = 950; // 95% full
887
888        // Target=200: removing 50 at a time is insufficient each pass
889        let result = window.try_evict(200).unwrap();
890        assert_eq!(result.tokens_freed, 100); // freed 50+50, but not enough for target
891    }
892
893    // ─── Context window taint tracking ──────────────────────────────────────
894
895    #[test]
896    fn test_enable_taint_tracking_on_context_window() {
897        let mut window = ContextWindow::new(10000);
898        window.add_region(Region::new(
899            "conv".to_string(),
900            RegionKind::SlidingWindow {
901                max_items: 10,
902                eviction_strategy: EvictionStrategy::PerItem,
903            },
904            5000,
905        ));
906        window.add_region(Region::new(
907            "tools".to_string(),
908            RegionKind::Temporary,
909            3000,
910        ));
911
912        assert!(window.overall_taint().is_none());
913        window.enable_taint_tracking();
914        assert_eq!(
915            window.overall_taint(),
916            Some(leviath_core::TaintLevel::Public)
917        );
918    }
919
920    #[test]
921    fn test_add_tainted_to_region() {
922        let mut window = ContextWindow::new(10000);
923        let region =
924            Region::new("tools".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
925        window.add_region(region);
926
927        window
928            .add_tainted_to_region(
929                "tools",
930                "secret data".to_string(),
931                10,
932                leviath_core::TaintLevel::Private,
933            )
934            .unwrap();
935
936        assert_eq!(
937            window.get_region("tools").and_then(|r| r.taint_level()),
938            Some(leviath_core::TaintLevel::Private)
939        );
940        assert_eq!(
941            window.overall_taint(),
942            Some(leviath_core::TaintLevel::Private)
943        );
944    }
945
946    #[test]
947    fn test_add_tainted_to_nonexistent_region() {
948        let mut window = ContextWindow::new(10000);
949        let result = window.add_tainted_to_region(
950            "nope",
951            "data".to_string(),
952            10,
953            leviath_core::TaintLevel::Public,
954        );
955        assert!(result.is_err());
956    }
957
958    #[test]
959    fn test_overall_taint_is_max_across_regions() {
960        let mut window = ContextWindow::new(10000);
961        let r1 = Region::new("a".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
962        let r2 = Region::new("b".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
963        window.add_region(r1);
964        window.add_region(r2);
965
966        window
967            .add_tainted_to_region("a", "x".to_string(), 5, leviath_core::TaintLevel::Internal)
968            .unwrap();
969        window
970            .add_tainted_to_region("b", "y".to_string(), 5, leviath_core::TaintLevel::Public)
971            .unwrap();
972
973        assert_eq!(
974            window.overall_taint(),
975            Some(leviath_core::TaintLevel::Internal)
976        );
977    }
978
979    #[test]
980    fn test_taint_summary() {
981        let mut window = ContextWindow::new(10000);
982        let r1 = Region::new("conv".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
983        let r2 =
984            Region::new("tools".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
985        window.add_region(r1);
986        window.add_region(r2);
987
988        window
989            .add_tainted_to_region(
990                "conv",
991                "x".to_string(),
992                5,
993                leviath_core::TaintLevel::Private,
994            )
995            .unwrap();
996
997        let summary = window.taint_summary();
998        assert_eq!(summary.len(), 2);
999        assert!(
1000            summary
1001                .iter()
1002                .any(|(name, level)| name == "conv" && *level == leviath_core::TaintLevel::Private)
1003        );
1004        assert!(
1005            summary
1006                .iter()
1007                .any(|(name, level)| name == "tools" && *level == leviath_core::TaintLevel::Public)
1008        );
1009    }
1010
1011    #[test]
1012    fn test_taint_recovery_through_eviction() {
1013        with_tracing(|| {});
1014        let mut window = ContextWindow::new(100);
1015        let r = Region::new("temp".to_string(), RegionKind::Temporary, 100).with_taint_tracking();
1016        window.add_region(r);
1017
1018        window
1019            .add_tainted_to_region(
1020                "temp",
1021                "private".to_string(),
1022                30,
1023                leviath_core::TaintLevel::Private,
1024            )
1025            .unwrap();
1026        window
1027            .add_tainted_to_region(
1028                "temp",
1029                "public".to_string(),
1030                30,
1031                leviath_core::TaintLevel::Public,
1032            )
1033            .unwrap();
1034
1035        assert_eq!(
1036            window.get_region("temp").and_then(|r| r.taint_level()),
1037            Some(leviath_core::TaintLevel::Private)
1038        );
1039
1040        // Eviction should trigger and remove oldest (private) entry
1041        window.current_tokens = 96; // Push over 0.95 threshold
1042        let result = window.try_evict(10).unwrap();
1043        assert!(result.tokens_freed > 0);
1044
1045        // After evicting the private entry, taint should recover
1046        assert_eq!(
1047            window.get_region("temp").and_then(|r| r.taint_level()),
1048            Some(leviath_core::TaintLevel::Public)
1049        );
1050    }
1051
1052    // ─── Tool-use/tool-result pairing sanitization tests ────────────────
1053
1054    #[test]
1055    fn test_assemble_appends_user_nudge_when_conversation_ends_with_assistant() {
1056        // After a stage transition the carried conversation ends with the prior
1057        // stage's assistant turn; assemble must append a trailing user message so
1058        // the request doesn't end on an assistant turn (rejected as prefill).
1059        let mut window = ContextWindow::new(100_000);
1060        window.add_region(Region::new(
1061            "conversation".to_string(),
1062            RegionKind::SlidingWindow {
1063                max_items: 100,
1064                eviction_strategy: EvictionStrategy::PerItem,
1065            },
1066            50_000,
1067        ));
1068        window
1069            .add_typed_entry(
1070                "conversation",
1071                leviath_core::EntryKind::UserMessage,
1072                "do the task".to_string(),
1073                10,
1074            )
1075            .unwrap();
1076        window
1077            .add_typed_entry(
1078                "conversation",
1079                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
1080                "All done with stage one.".to_string(),
1081                10,
1082            )
1083            .unwrap();
1084
1085        let assembled = window.assemble();
1086        assert_eq!(
1087            assembled.messages.last().map(|m| m.role.as_str()),
1088            Some("user"),
1089            "the assembled conversation must end with a user message"
1090        );
1091    }
1092
1093    #[test]
1094    fn test_assemble_strips_orphaned_tool_use() {
1095        let mut window = ContextWindow::new(100_000);
1096        let region = Region::new(
1097            "conversation".to_string(),
1098            RegionKind::SlidingWindow {
1099                max_items: 100,
1100                eviction_strategy: EvictionStrategy::PerItem,
1101            },
1102            50_000,
1103        );
1104        window.add_region(region);
1105
1106        // Add an assistant turn with a tool_use but no matching tool_result
1107        window
1108            .add_typed_entry(
1109                "conversation",
1110                leviath_core::EntryKind::AssistantTurn {
1111                    tool_calls: vec![leviath_core::SerializedToolCall {
1112                        id: "tc_orphan".to_string(),
1113                        name: "read_file".to_string(),
1114                        arguments: serde_json::json!({"path": "foo.rs"}),
1115                        thought_signature: None,
1116                    }],
1117                },
1118                "Let me read that file.".to_string(),
1119                50,
1120            )
1121            .unwrap();
1122
1123        let assembled = with_tracing(|| window.assemble());
1124
1125        // The orphaned tool_use should be stripped; text should remain
1126        for msg in &assembled.messages {
1127            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
1128                for block in blocks {
1129                    assert!(
1130                        !matches!(block, leviath_providers::ContentBlock::ToolUse { .. }),
1131                        "Orphaned tool_use should have been stripped"
1132                    );
1133                }
1134            }
1135        }
1136        // The assistant text should still be present
1137        assert!(
1138            assembled
1139                .messages
1140                .iter()
1141                .any(|m| m.role == "assistant" && m.content.as_text().contains("read that file"))
1142        );
1143    }
1144
1145    #[test]
1146    fn test_assemble_strips_orphaned_tool_result() {
1147        let mut window = ContextWindow::new(100_000);
1148        let region = Region::new(
1149            "conversation".to_string(),
1150            RegionKind::SlidingWindow {
1151                max_items: 100,
1152                eviction_strategy: EvictionStrategy::PerItem,
1153            },
1154            50_000,
1155        );
1156        window.add_region(region);
1157
1158        // Add a user message first
1159        window
1160            .add_typed_entry(
1161                "conversation",
1162                leviath_core::EntryKind::UserMessage,
1163                "Hello".to_string(),
1164                10,
1165            )
1166            .unwrap();
1167
1168        // Add a tool_result with no preceding tool_use
1169        window
1170            .add_typed_entry(
1171                "conversation",
1172                leviath_core::EntryKind::ToolResult {
1173                    tool_call_id: "tc_missing".to_string(),
1174                    tool_name: "read_file".to_string(),
1175                    is_error: false,
1176                },
1177                "file contents here".to_string(),
1178                20,
1179            )
1180            .unwrap();
1181
1182        let assembled = with_tracing(|| window.assemble());
1183
1184        // The orphaned tool_result message is stripped to empty and dropped;
1185        // only the plain user message survives (as Text, carrying no blocks).
1186        assert_eq!(assembled.messages.len(), 1);
1187        assert_eq!(assembled.messages[0].role, "user");
1188        assert_eq!(
1189            assembled.messages[0].content,
1190            leviath_providers::MessageContent::Text("Hello".to_string())
1191        );
1192    }
1193
1194    #[test]
1195    fn test_assemble_paired_tool_use_result_passes_through() {
1196        let mut window = ContextWindow::new(100_000);
1197        let region = Region::new(
1198            "conversation".to_string(),
1199            RegionKind::SlidingWindow {
1200                max_items: 100,
1201                eviction_strategy: EvictionStrategy::PerItem,
1202            },
1203            50_000,
1204        );
1205        window.add_region(region);
1206
1207        // User message
1208        window
1209            .add_typed_entry(
1210                "conversation",
1211                leviath_core::EntryKind::UserMessage,
1212                "Fix the bug".to_string(),
1213                10,
1214            )
1215            .unwrap();
1216
1217        // Assistant with tool_use
1218        window
1219            .add_typed_entry(
1220                "conversation",
1221                leviath_core::EntryKind::AssistantTurn {
1222                    tool_calls: vec![leviath_core::SerializedToolCall {
1223                        id: "tc_1".to_string(),
1224                        name: "read_file".to_string(),
1225                        arguments: serde_json::json!({"path": "main.rs"}),
1226                        thought_signature: None,
1227                    }],
1228                },
1229                "".to_string(),
1230                10,
1231            )
1232            .unwrap();
1233
1234        // Matching tool_result
1235        window
1236            .add_typed_entry(
1237                "conversation",
1238                leviath_core::EntryKind::ToolResult {
1239                    tool_call_id: "tc_1".to_string(),
1240                    tool_name: "read_file".to_string(),
1241                    is_error: false,
1242                },
1243                "fn main() {}".to_string(),
1244                10,
1245            )
1246            .unwrap();
1247
1248        let assembled = window.assemble();
1249
1250        // Both tool_use and tool_result should be present
1251        let has_tool_use = assembled.messages.iter().any(|m| {
1252            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
1253                blocks
1254                    .iter()
1255                    .any(|b| matches!(b, leviath_providers::ContentBlock::ToolUse { id, .. } if id == "tc_1"))
1256            } else {
1257                false
1258            }
1259        });
1260        let has_tool_result = assembled.messages.iter().any(|m| {
1261            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
1262                blocks
1263                    .iter()
1264                    .any(|b| matches!(b, leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tc_1"))
1265            } else {
1266                false
1267            }
1268        });
1269        assert!(has_tool_use, "Paired tool_use should remain");
1270        assert!(has_tool_result, "Paired tool_result should remain");
1271    }
1272
1273    #[test]
1274    fn test_assemble_removes_empty_assistant_after_stripping() {
1275        let mut window = ContextWindow::new(100_000);
1276        let region = Region::new(
1277            "conversation".to_string(),
1278            RegionKind::SlidingWindow {
1279                max_items: 100,
1280                eviction_strategy: EvictionStrategy::PerItem,
1281            },
1282            50_000,
1283        );
1284        window.add_region(region);
1285
1286        // User message
1287        window
1288            .add_typed_entry(
1289                "conversation",
1290                leviath_core::EntryKind::UserMessage,
1291                "Do something".to_string(),
1292                10,
1293            )
1294            .unwrap();
1295
1296        // Assistant with ONLY a tool_use (no text), and no matching result
1297        window
1298            .add_typed_entry(
1299                "conversation",
1300                leviath_core::EntryKind::AssistantTurn {
1301                    tool_calls: vec![leviath_core::SerializedToolCall {
1302                        id: "tc_gone".to_string(),
1303                        name: "bash".to_string(),
1304                        arguments: serde_json::json!({"command": "ls"}),
1305                        thought_signature: None,
1306                    }],
1307                },
1308                "".to_string(),
1309                10,
1310            )
1311            .unwrap();
1312
1313        let assembled = with_tracing(|| window.assemble());
1314
1315        // The assistant message should be entirely removed (empty after stripping)
1316        let assistant_msgs: Vec<_> = assembled
1317            .messages
1318            .iter()
1319            .filter(|m| m.role == "assistant")
1320            .collect();
1321        assert!(
1322            assistant_msgs.is_empty(),
1323            "Assistant message with only orphaned tool_use should be removed entirely"
1324        );
1325    }
1326
1327    #[test]
1328    fn test_assemble_strips_multiple_orphaned_tool_uses_in_one_message() {
1329        let mut window = ContextWindow::new(100_000);
1330        let region = Region::new(
1331            "conversation".to_string(),
1332            RegionKind::SlidingWindow {
1333                max_items: 100,
1334                eviction_strategy: EvictionStrategy::PerItem,
1335            },
1336            50_000,
1337        );
1338        window.add_region(region);
1339
1340        // User message first
1341        window
1342            .add_typed_entry(
1343                "conversation",
1344                leviath_core::EntryKind::UserMessage,
1345                "Do two things".to_string(),
1346                10,
1347            )
1348            .unwrap();
1349
1350        // Assistant with TWO orphaned tool_uses (no matching results for either)
1351        window
1352            .add_typed_entry(
1353                "conversation",
1354                leviath_core::EntryKind::AssistantTurn {
1355                    tool_calls: vec![
1356                        leviath_core::SerializedToolCall {
1357                            id: "tc_orphan_1".to_string(),
1358                            name: "read_file".to_string(),
1359                            arguments: serde_json::json!({"path": "a.rs"}),
1360                            thought_signature: None,
1361                        },
1362                        leviath_core::SerializedToolCall {
1363                            id: "tc_orphan_2".to_string(),
1364                            name: "bash".to_string(),
1365                            arguments: serde_json::json!({"cmd": "ls"}),
1366                            thought_signature: None,
1367                        },
1368                    ],
1369                },
1370                "Let me do both.".to_string(),
1371                50,
1372            )
1373            .unwrap();
1374
1375        let assembled = with_tracing(|| window.assemble());
1376
1377        // Both orphaned tool_uses should be stripped
1378        for msg in &assembled.messages {
1379            if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
1380                for block in blocks {
1381                    assert!(
1382                        !matches!(block, leviath_providers::ContentBlock::ToolUse { .. }),
1383                        "All orphaned tool_uses should have been stripped"
1384                    );
1385                }
1386            }
1387        }
1388        // The assistant text should still be present
1389        assert!(
1390            assembled
1391                .messages
1392                .iter()
1393                .any(|m| m.role == "assistant" && m.content.as_text().contains("do both"))
1394        );
1395    }
1396
1397    #[test]
1398    fn test_assemble_mixed_valid_and_orphaned_in_same_message() {
1399        let mut window = ContextWindow::new(100_000);
1400        let region = Region::new(
1401            "conversation".to_string(),
1402            RegionKind::SlidingWindow {
1403                max_items: 100,
1404                eviction_strategy: EvictionStrategy::PerItem,
1405            },
1406            50_000,
1407        );
1408        window.add_region(region);
1409
1410        // User message
1411        window
1412            .add_typed_entry(
1413                "conversation",
1414                leviath_core::EntryKind::UserMessage,
1415                "Do stuff".to_string(),
1416                10,
1417            )
1418            .unwrap();
1419
1420        // Assistant with one valid tool_use (tc_valid) and one orphaned (tc_orphan)
1421        window
1422            .add_typed_entry(
1423                "conversation",
1424                leviath_core::EntryKind::AssistantTurn {
1425                    tool_calls: vec![
1426                        leviath_core::SerializedToolCall {
1427                            id: "tc_valid".to_string(),
1428                            name: "read_file".to_string(),
1429                            arguments: serde_json::json!({"path": "main.rs"}),
1430                            thought_signature: None,
1431                        },
1432                        leviath_core::SerializedToolCall {
1433                            id: "tc_orphan".to_string(),
1434                            name: "bash".to_string(),
1435                            arguments: serde_json::json!({"cmd": "ls"}),
1436                            thought_signature: None,
1437                        },
1438                    ],
1439                },
1440                "".to_string(),
1441                10,
1442            )
1443            .unwrap();
1444
1445        // Only provide tool_result for tc_valid
1446        window
1447            .add_typed_entry(
1448                "conversation",
1449                leviath_core::EntryKind::ToolResult {
1450                    tool_call_id: "tc_valid".to_string(),
1451                    tool_name: "read_file".to_string(),
1452                    is_error: false,
1453                },
1454                "fn main() {}".to_string(),
1455                10,
1456            )
1457            .unwrap();
1458
1459        let assembled = with_tracing(|| window.assemble());
1460
1461        // Collect the tool_use ids that survived assembly.
1462        let tool_use_ids: Vec<&str> = assembled
1463            .messages
1464            .iter()
1465            .filter_map(|m| match &m.content {
1466                leviath_providers::MessageContent::Blocks(blocks) => Some(blocks),
1467                _ => None,
1468            })
1469            .flatten()
1470            .filter_map(|b| match b {
1471                leviath_providers::ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
1472                _ => None,
1473            })
1474            .collect();
1475        // tc_valid's tool_use remains; the orphaned tc_orphan is stripped.
1476        assert!(
1477            tool_use_ids.contains(&"tc_valid"),
1478            "Valid tool_use should remain"
1479        );
1480        assert!(
1481            !tool_use_ids.contains(&"tc_orphan"),
1482            "Orphaned tool_use should be stripped"
1483        );
1484
1485        // tc_valid tool_result should remain
1486        let has_result = assembled.messages.iter().any(|m| {
1487            if let leviath_providers::MessageContent::Blocks(blocks) = &m.content {
1488                blocks.iter().any(|b| {
1489                    matches!(b, leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tc_valid")
1490                })
1491            } else {
1492                false
1493            }
1494        });
1495        assert!(has_result, "Valid tool_result should remain");
1496    }
1497
1498    // ─── assemble() region kind coverage ──────────────────────────────────
1499
1500    #[test]
1501    fn test_assemble_compact_history_region_produces_system_block_always() {
1502        let mut window = ContextWindow::new(100_000);
1503        let mut region = Region::new(
1504            "history".to_string(),
1505            RegionKind::CompactHistory {
1506                source_region: "conv".to_string(),
1507            },
1508            10_000,
1509        );
1510        region
1511            .add_entry("summary of earlier conversation".to_string(), 50)
1512            .unwrap();
1513        window.add_region(region);
1514
1515        let assembled = window.assemble();
1516
1517        assert_eq!(assembled.system_blocks.len(), 1);
1518        assert_eq!(
1519            assembled.system_blocks[0].text,
1520            "summary of earlier conversation"
1521        );
1522        assert_eq!(
1523            assembled.system_blocks[0].cache_hint,
1524            leviath_core::CacheHint::Always
1525        );
1526    }
1527
1528    #[test]
1529    fn test_assemble_compacting_region_produces_system_block_until_changed() {
1530        let mut window = ContextWindow::new(100_000);
1531        let mut region = Region::new(
1532            "impl".to_string(),
1533            RegionKind::Compacting {
1534                threshold_tokens: 500,
1535            },
1536            10_000,
1537        );
1538        region
1539            .add_entry("implementation details".to_string(), 50)
1540            .unwrap();
1541        window.add_region(region);
1542
1543        let assembled = window.assemble();
1544
1545        assert_eq!(assembled.system_blocks.len(), 1);
1546        assert_eq!(
1547            assembled.system_blocks[0].text,
1548            "[impl]:\nimplementation details"
1549        );
1550        assert_eq!(
1551            assembled.system_blocks[0].cache_hint,
1552            leviath_core::CacheHint::UntilChanged
1553        );
1554    }
1555
1556    #[test]
1557    fn test_assemble_temporary_region_produces_system_block_never() {
1558        let mut window = ContextWindow::new(100_000);
1559        let mut region = Region::new("scratch".to_string(), RegionKind::Temporary, 10_000);
1560        region.add_entry("temp data".to_string(), 20).unwrap();
1561        window.add_region(region);
1562
1563        let assembled = window.assemble();
1564
1565        assert_eq!(assembled.system_blocks.len(), 1);
1566        assert_eq!(assembled.system_blocks[0].text, "[scratch]:\ntemp data");
1567        assert_eq!(
1568            assembled.system_blocks[0].cache_hint,
1569            leviath_core::CacheHint::Never
1570        );
1571    }
1572
1573    #[test]
1574    fn test_assemble_clearable_region_produces_system_block_never() {
1575        let mut window = ContextWindow::new(100_000);
1576        let mut region = Region::new("cache".to_string(), RegionKind::Clearable, 10_000);
1577        region.add_entry("clearable data".to_string(), 20).unwrap();
1578        window.add_region(region);
1579
1580        let assembled = window.assemble();
1581
1582        assert_eq!(assembled.system_blocks.len(), 1);
1583        assert_eq!(assembled.system_blocks[0].text, "[cache]:\nclearable data");
1584        assert_eq!(
1585            assembled.system_blocks[0].cache_hint,
1586            leviath_core::CacheHint::Never
1587        );
1588    }
1589
1590    fn custom_kind(script: &str, persistent: bool) -> RegionKind {
1591        RegionKind::Custom {
1592            script: script.to_string(),
1593            persistent,
1594        }
1595    }
1596
1597    #[test]
1598    fn test_assemble_custom_region_falls_back_to_temporary_style_block() {
1599        // Plain `assemble()` has no compiled script available, so a custom
1600        // region renders as the hook-less fallback: a Temporary-style block -
1601        // never silently dropped.
1602        let mut window = ContextWindow::new(100_000);
1603        let mut region = Region::new("brain".to_string(), custom_kind("b.rhai", false), 10_000);
1604        region.add_entry("thought one".to_string(), 10).unwrap();
1605        region.add_entry("thought two".to_string(), 10).unwrap();
1606        window.add_region(region);
1607
1608        let assembled = window.assemble();
1609
1610        assert_eq!(assembled.system_blocks.len(), 1);
1611        assert_eq!(
1612            assembled.system_blocks[0].text,
1613            "[brain]:\nthought one\n\nthought two"
1614        );
1615        assert_eq!(
1616            assembled.system_blocks[0].cache_hint,
1617            leviath_core::CacheHint::Never
1618        );
1619    }
1620
1621    #[test]
1622    fn try_evict_evicts_non_persistent_custom_regions_oldest_first() {
1623        let mut window = ContextWindow::new(100);
1624        let mut region = Region::new("brain".to_string(), custom_kind("b.rhai", false), 100);
1625        region.add_entry("old".to_string(), 40).unwrap();
1626        region.add_entry("new".to_string(), 40).unwrap();
1627        window.add_region(region);
1628        window.current_tokens = 80;
1629
1630        let result = with_tracing(|| window.try_evict(30).unwrap());
1631        assert!(result.tokens_freed >= 40);
1632        let brain = window.get_region("brain").unwrap();
1633        assert_eq!(brain.content.len(), 1);
1634        assert_eq!(brain.content[0].content, "new");
1635    }
1636
1637    #[test]
1638    fn try_evict_never_touches_persistent_custom_and_counts_it_as_pinned() {
1639        // Persistent custom content survives eviction, and when it alone
1640        // exceeds the whole window budget the pinned over-budget guard fires.
1641        let mut window = ContextWindow::new(50);
1642        let mut vault = Region::new("vault".to_string(), custom_kind("v.rhai", true), 100);
1643        vault.add_entry("precious".to_string(), 60).unwrap();
1644        window.add_region(vault);
1645        window.current_tokens = 60;
1646
1647        let err = with_tracing(|| window.try_evict(10).unwrap_err());
1648        assert_eq!(
1649            err.to_string(),
1650            "Pinned regions (60) exceed total budget (50)"
1651        );
1652        assert_eq!(window.get_region("vault").unwrap().content.len(), 1);
1653    }
1654
1655    /// A window with one custom region (`brain`, budget 100) backed by `src`,
1656    /// compiled and installed in the script table under "s.rhai".
1657    fn custom_window(src: &str, persistent: bool) -> ContextWindow {
1658        let mut window = ContextWindow::new(10_000);
1659        window.add_region(Region::new(
1660            "brain".to_string(),
1661            RegionKind::Custom {
1662                script: "s.rhai".to_string(),
1663                persistent,
1664            },
1665            100,
1666        ));
1667        window.region_scripts.insert(
1668            "s.rhai".to_string(),
1669            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
1670        );
1671        window
1672    }
1673
1674    #[test]
1675    fn custom_region_on_write_fires_across_all_write_methods() {
1676        let src = r#"
1677            fn render(ctx) { "" }
1678            fn on_write(ctx) { `${ctx.entry.kind}:${ctx.entry.content}` }
1679        "#;
1680        let mut window = custom_window(src, false);
1681
1682        window.add_to_region("brain", "a".to_string(), 1).unwrap();
1683        window
1684            .add_typed_entry(
1685                "brain",
1686                leviath_core::EntryKind::UserMessage,
1687                "b".to_string(),
1688                1,
1689            )
1690            .unwrap();
1691        window
1692            .add_tainted_to_region(
1693                "brain",
1694                "c".to_string(),
1695                1,
1696                leviath_core::TaintLevel::Public,
1697            )
1698            .unwrap();
1699        window
1700            .add_typed_tainted_to_region(
1701                "brain",
1702                leviath_core::EntryKind::UserMessage,
1703                "d".to_string(),
1704                1,
1705                leviath_core::TaintLevel::Public,
1706            )
1707            .unwrap();
1708
1709        let contents: Vec<_> = window
1710            .get_region("brain")
1711            .unwrap()
1712            .content
1713            .iter()
1714            .map(|e| e.content.as_str())
1715            .collect();
1716        assert_eq!(
1717            contents,
1718            vec!["text:a", "user_message:b", "text:c", "user_message:d"],
1719            "every write method passes through on_write with the entry kind visible"
1720        );
1721        // Token counts were re-estimated for the replacements.
1722        assert_eq!(window.current_tokens, window.calculate_tokens());
1723
1724        assert!(window.replace_region("brain", "e".to_string(), 1));
1725        let region = window.get_region("brain").unwrap();
1726        assert_eq!(region.content.len(), 1);
1727        assert_eq!(region.content[0].content, "text:e");
1728    }
1729
1730    #[test]
1731    fn custom_region_on_write_drop_reports_success_without_storing() {
1732        let src = r#"
1733            fn render(ctx) { "" }
1734            fn on_write(ctx) { false }
1735        "#;
1736        let mut window = custom_window(src, false);
1737        window
1738            .add_to_region("brain", "spam".to_string(), 1)
1739            .unwrap();
1740        assert!(window.get_region("brain").unwrap().content.is_empty());
1741
1742        // A dropped replacement leaves existing content in place.
1743        assert!(window.replace_region("brain", "more spam".to_string(), 1));
1744        assert!(window.get_region("brain").unwrap().content.is_empty());
1745    }
1746
1747    #[test]
1748    fn custom_region_on_write_drop_covers_typed_and_tainted_methods() {
1749        // Every write method's drop arm, not just add_to_region's.
1750        let src = r#"
1751            fn render(ctx) { "" }
1752            fn on_write(ctx) { false }
1753        "#;
1754        let mut window = custom_window(src, false);
1755        window
1756            .add_typed_entry(
1757                "brain",
1758                leviath_core::EntryKind::UserMessage,
1759                "a".to_string(),
1760                1,
1761            )
1762            .unwrap();
1763        window
1764            .add_tainted_to_region(
1765                "brain",
1766                "b".to_string(),
1767                1,
1768                leviath_core::TaintLevel::Public,
1769            )
1770            .unwrap();
1771        window
1772            .add_typed_tainted_to_region(
1773                "brain",
1774                leviath_core::EntryKind::UserMessage,
1775                "c".to_string(),
1776                1,
1777                leviath_core::TaintLevel::Public,
1778            )
1779            .unwrap();
1780        assert!(window.get_region("brain").unwrap().content.is_empty());
1781    }
1782
1783    #[test]
1784    fn try_evict_skips_custom_region_whose_script_has_no_on_overflow() {
1785        // Phase 1.5 leaves the choice to phase 2 (oldest-first) when the
1786        // script defines no on_overflow.
1787        let mut window = ContextWindow::new(100);
1788        window.add_region(Region::new(
1789            "brain".to_string(),
1790            RegionKind::Custom {
1791                script: "s.rhai".to_string(),
1792                persistent: false,
1793            },
1794            100,
1795        ));
1796        window.region_scripts.insert(
1797            "s.rhai".to_string(),
1798            std::sync::Arc::new(
1799                leviath_scripting::region_hook::compile("s.rhai", "fn render(ctx) { \"\" }")
1800                    .unwrap(),
1801            ),
1802        );
1803        window
1804            .add_to_region("brain", "old".to_string(), 40)
1805            .unwrap();
1806        window
1807            .add_to_region("brain", "new".to_string(), 40)
1808            .unwrap();
1809
1810        let result = with_tracing(|| window.try_evict(30).unwrap());
1811        assert!(result.tokens_freed >= 40);
1812        let brain = window.get_region("brain").unwrap();
1813        assert_eq!(brain.content.len(), 1);
1814        assert_eq!(brain.content[0].content, "new", "oldest-first fallback ran");
1815    }
1816
1817    #[test]
1818    fn try_evict_falls_to_oldest_first_when_script_frees_nothing() {
1819        // on_overflow returns [] under pressure: phase 1.5 frees 0 and phase 2
1820        // makes the progress.
1821        let src = r#"
1822            fn render(ctx) { "" }
1823            fn on_overflow(ctx) { [] }
1824        "#;
1825        let mut window = ContextWindow::new(100);
1826        window.add_region(Region::new(
1827            "brain".to_string(),
1828            RegionKind::Custom {
1829                script: "s.rhai".to_string(),
1830                persistent: false,
1831            },
1832            100,
1833        ));
1834        window.region_scripts.insert(
1835            "s.rhai".to_string(),
1836            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
1837        );
1838        window
1839            .add_to_region("brain", "old".to_string(), 40)
1840            .unwrap();
1841        window
1842            .add_to_region("brain", "new".to_string(), 40)
1843            .unwrap();
1844
1845        let result = with_tracing(|| window.try_evict(30).unwrap());
1846        assert!(result.tokens_freed >= 40);
1847        assert_eq!(window.get_region("brain").unwrap().content.len(), 1);
1848    }
1849
1850    #[test]
1851    fn non_custom_regions_bypass_the_on_write_seam() {
1852        // A script table entry exists, but the region is plain Temporary - the
1853        // hook must not fire for it.
1854        let mut window = custom_window(
1855            "fn render(ctx) { \"\" }\nfn on_write(ctx) { \"MANGLED\" }",
1856            false,
1857        );
1858        window.add_region(Region::new("plain".to_string(), RegionKind::Temporary, 100));
1859        window
1860            .add_to_region("plain", "untouched".to_string(), 2)
1861            .unwrap();
1862        assert_eq!(
1863            window.get_region("plain").unwrap().content[0].content,
1864            "untouched"
1865        );
1866    }
1867
1868    #[test]
1869    fn write_to_missing_region_still_errors() {
1870        let mut window = custom_window("fn render(ctx) { \"\" }", false);
1871        let err = window
1872            .add_to_region("ghost", "x".to_string(), 1)
1873            .unwrap_err();
1874        assert!(err.to_string().contains("ghost"), "{err}");
1875    }
1876
1877    #[test]
1878    fn custom_region_add_time_overflow_retries_after_script_drops() {
1879        // Region budget 100: fill with 90, then add 20 - over budget. The
1880        // script drops entry 0 (90 tokens), freeing room; the retry succeeds.
1881        let src = r#"
1882            fn render(ctx) { "" }
1883            fn on_overflow(ctx) { [0] }
1884        "#;
1885        let mut window = custom_window(src, false);
1886        window
1887            .add_to_region("brain", "big".to_string(), 90)
1888            .unwrap();
1889        window
1890            .add_to_region("brain", "next".to_string(), 20)
1891            .unwrap();
1892
1893        let region = window.get_region("brain").unwrap();
1894        assert_eq!(region.content.len(), 1);
1895        assert_eq!(region.content[0].content, "next");
1896        assert_eq!(window.current_tokens, 20);
1897    }
1898
1899    #[test]
1900    fn custom_region_add_time_overflow_propagates_when_still_too_big() {
1901        // The script frees nothing, so the retry path never runs and the
1902        // original budget error propagates to the caller's ladders.
1903        let src = r#"
1904            fn render(ctx) { "" }
1905            fn on_overflow(ctx) { [] }
1906        "#;
1907        let mut window = custom_window(src, false);
1908        window
1909            .add_to_region("brain", "big".to_string(), 90)
1910            .unwrap();
1911        let err =
1912            with_tracing(|| window.add_to_region("brain", "too much".to_string(), 50)).unwrap_err();
1913        assert_eq!(err.to_string(), "Content exceeds token budget: 140 > 100");
1914        assert_eq!(window.get_region("brain").unwrap().content.len(), 1);
1915    }
1916
1917    #[test]
1918    fn custom_region_without_on_overflow_gets_no_retry() {
1919        let mut window = custom_window("fn render(ctx) { \"\" }", false);
1920        window
1921            .add_to_region("brain", "big".to_string(), 90)
1922            .unwrap();
1923        let err = window
1924            .add_to_region("brain", "too much".to_string(), 50)
1925            .unwrap_err();
1926        assert_eq!(err.to_string(), "Content exceeds token budget: 140 > 100");
1927    }
1928
1929    #[test]
1930    fn try_evict_lets_custom_script_choose_what_to_drop() {
1931        // The script keeps errors, drops successes - the retention choice the
1932        // oldest-first cascade could never make. Window is small so eviction
1933        // has real pressure.
1934        let src = r#"
1935            fn render(ctx) { "" }
1936            fn on_overflow(ctx) {
1937                let drops = [];
1938                for (entry, i) in ctx.entries {
1939                    if !entry.content.contains("ERROR") { drops.push(i); }
1940                }
1941                drops
1942            }
1943        "#;
1944        let mut window = ContextWindow::new(100);
1945        window.add_region(Region::new(
1946            "brain".to_string(),
1947            RegionKind::Custom {
1948                script: "s.rhai".to_string(),
1949                persistent: false,
1950            },
1951            100,
1952        ));
1953        window.region_scripts.insert(
1954            "s.rhai".to_string(),
1955            std::sync::Arc::new(leviath_scripting::region_hook::compile("s.rhai", src).unwrap()),
1956        );
1957        window
1958            .add_to_region("brain", "ok one".to_string(), 30)
1959            .unwrap();
1960        window
1961            .add_to_region("brain", "ERROR two".to_string(), 30)
1962            .unwrap();
1963        window
1964            .add_to_region("brain", "ok three".to_string(), 30)
1965            .unwrap();
1966
1967        let result = with_tracing(|| window.try_evict(40)).unwrap();
1968        assert!(result.tokens_freed >= 40);
1969        let contents: Vec<_> = window
1970            .get_region("brain")
1971            .unwrap()
1972            .content
1973            .iter()
1974            .map(|e| e.content.as_str())
1975            .collect();
1976        assert_eq!(
1977            contents,
1978            vec!["ERROR two"],
1979            "script retention choice honored"
1980        );
1981    }
1982
1983    // ─── assemble(): custom regions ──────────────────────────────────────
1984
1985    #[test]
1986    fn assemble_custom_region_renders_through_script() {
1987        let src = r#"fn render(ctx) { `<brain iter=${ctx.stage_iterations}>` }"#;
1988        let mut window = custom_window(src, false);
1989        window
1990            .add_to_region("brain", "note".to_string(), 2)
1991            .unwrap();
1992
1993        // Default meta via plain assemble().
1994        let assembled = window.assemble();
1995        assert_eq!(assembled.system_blocks.len(), 1);
1996        assert_eq!(assembled.system_blocks[0].text, "<brain iter=0>");
1997        assert_eq!(
1998            assembled.system_blocks[0].cache_hint,
1999            leviath_core::CacheHint::UntilChanged
2000        );
2001
2002        // Real meta via assemble_with_meta.
2003        let assembled = window.assemble_with_meta(&crate::custom_region::AssembleMeta {
2004            stage_name: "plan".to_string(),
2005            stage_iterations: 7,
2006            model: "m".to_string(),
2007            previous_system_hash: None,
2008        });
2009        assert_eq!(assembled.system_blocks[0].text, "<brain iter=7>");
2010    }
2011
2012    #[test]
2013    fn assemble_custom_region_renders_even_when_empty() {
2014        // Static scaffolding: the script emits structure with no entries.
2015        let src = r#"fn render(ctx) { `<empty count=${ctx.entries.len()}>` }"#;
2016        let window = custom_window(src, false);
2017        let assembled = window.assemble();
2018        assert_eq!(assembled.system_blocks.len(), 1);
2019        assert_eq!(assembled.system_blocks[0].text, "<empty count=0>");
2020    }
2021
2022    #[test]
2023    fn assemble_custom_conversation_takeover_renders_single_user_message() {
2024        // The 12-factor case: a custom region NAMED conversation holds the
2025        // typed history and renders it as one XML user message. No sliding
2026        // window exists; the request's only message is the script's.
2027        let src = r#"
2028            fn render(ctx) {
2029                let xml = "<context>";
2030                for entry in ctx.entries {
2031                    xml += `<event kind="${entry.kind}">${entry.content}</event>`;
2032                }
2033                xml += "</context>";
2034                #{ messages: [ #{ role: "user", content: xml } ] }
2035            }
2036        "#;
2037        let mut window = ContextWindow::new(10_000);
2038        window.add_region(Region::new(
2039            "conversation".to_string(),
2040            RegionKind::Custom {
2041                script: "conv.rhai".to_string(),
2042                persistent: false,
2043            },
2044            5_000,
2045        ));
2046        window.region_scripts.insert(
2047            "conv.rhai".to_string(),
2048            std::sync::Arc::new(leviath_scripting::region_hook::compile("conv.rhai", src).unwrap()),
2049        );
2050        window
2051            .add_typed_entry(
2052                "conversation",
2053                leviath_core::EntryKind::UserMessage,
2054                "do the task".to_string(),
2055                4,
2056            )
2057            .unwrap();
2058        window
2059            .add_typed_entry(
2060                "conversation",
2061                leviath_core::EntryKind::ToolResult {
2062                    tool_call_id: "c1".to_string(),
2063                    tool_name: "shell".to_string(),
2064                    is_error: false,
2065                },
2066                "output".to_string(),
2067                2,
2068            )
2069            .unwrap();
2070
2071        let assembled = window.assemble();
2072        assert!(assembled.system_blocks.is_empty());
2073        assert_eq!(assembled.messages.len(), 1);
2074        assert_eq!(assembled.messages[0].role, "user");
2075        assert_eq!(
2076            assembled.messages[0].content.as_text(),
2077            "<context><event kind=\"user_message\">do the task</event>\
2078             <event kind=\"tool_result\">output</event></context>"
2079        );
2080    }
2081
2082    #[test]
2083    fn assemble_custom_script_emitting_nothing_gets_begin_fallback() {
2084        // A script that emits no messages leaves the request message-less;
2085        // the shared finalization injects the "Begin." user message.
2086        let window = custom_window("fn render(ctx) { \"\" }", false);
2087        let assembled = window.assemble();
2088        assert!(assembled.system_blocks.is_empty());
2089        assert_eq!(assembled.messages.len(), 1);
2090        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
2091    }
2092
2093    #[test]
2094    fn assemble_custom_unpaired_tool_blocks_are_sanitized() {
2095        // A buggy script emits a tool_result with no matching tool_use; the
2096        // orphan sanitizer strips it instead of sending a provider-invalid
2097        // request.
2098        let src = r#"
2099            fn render(ctx) {
2100                #{ messages: [
2101                    #{ role: "user", content: "hello" },
2102                    #{ role: "user", tool_results: [
2103                        #{ tool_call_id: "ghost", content: "orphan" },
2104                    ] },
2105                ] }
2106            }
2107        "#;
2108        let window = custom_window(src, false);
2109        let assembled = window.assemble();
2110        assert_eq!(assembled.messages.len(), 1, "orphan tool_result stripped");
2111        assert_eq!(assembled.messages[0].content.as_text(), "hello");
2112    }
2113
2114    // ─── assemble() EntryKind::Text prefix parsing ────────────────────────
2115
2116    #[test]
2117    fn test_assemble_text_entry_with_assistant_prefix() {
2118        let mut window = ContextWindow::new(100_000);
2119        let region = Region::new(
2120            "conv".to_string(),
2121            RegionKind::SlidingWindow {
2122                max_items: 100,
2123                eviction_strategy: EvictionStrategy::PerItem,
2124            },
2125            50_000,
2126        );
2127        window.add_region(region);
2128
2129        window
2130            .add_typed_entry(
2131                "conv",
2132                leviath_core::EntryKind::Text,
2133                "Assistant: I can help with that.".to_string(),
2134                10,
2135            )
2136            .unwrap();
2137
2138        let assembled = window.assemble();
2139
2140        let assistant_msgs: Vec<_> = assembled
2141            .messages
2142            .iter()
2143            .filter(|m| m.role == "assistant")
2144            .collect();
2145        assert_eq!(assistant_msgs.len(), 1);
2146        assert_eq!(assistant_msgs[0].content.as_text(), "I can help with that.");
2147    }
2148
2149    #[test]
2150    fn test_assemble_text_entry_with_user_prefix() {
2151        let mut window = ContextWindow::new(100_000);
2152        let region = Region::new(
2153            "conv".to_string(),
2154            RegionKind::SlidingWindow {
2155                max_items: 100,
2156                eviction_strategy: EvictionStrategy::PerItem,
2157            },
2158            50_000,
2159        );
2160        window.add_region(region);
2161
2162        window
2163            .add_typed_entry(
2164                "conv",
2165                leviath_core::EntryKind::Text,
2166                "User: What is Rust?".to_string(),
2167                10,
2168            )
2169            .unwrap();
2170
2171        let assembled = window.assemble();
2172
2173        let user_msgs: Vec<_> = assembled
2174            .messages
2175            .iter()
2176            .filter(|m| m.role == "user")
2177            .collect();
2178        assert_eq!(user_msgs.len(), 1);
2179        assert_eq!(user_msgs[0].content.as_text(), "What is Rust?");
2180    }
2181
2182    #[test]
2183    fn test_assemble_text_entry_without_prefix_defaults_to_user() {
2184        let mut window = ContextWindow::new(100_000);
2185        let region = Region::new(
2186            "conv".to_string(),
2187            RegionKind::SlidingWindow {
2188                max_items: 100,
2189                eviction_strategy: EvictionStrategy::PerItem,
2190            },
2191            50_000,
2192        );
2193        window.add_region(region);
2194
2195        window
2196            .add_typed_entry(
2197                "conv",
2198                leviath_core::EntryKind::Text,
2199                "some plain text".to_string(),
2200                10,
2201            )
2202            .unwrap();
2203
2204        let assembled = window.assemble();
2205
2206        let user_msgs: Vec<_> = assembled
2207            .messages
2208            .iter()
2209            .filter(|m| m.role == "user")
2210            .collect();
2211        assert_eq!(user_msgs.len(), 1);
2212        assert_eq!(user_msgs[0].content.as_text(), "some plain text");
2213    }
2214
2215    // ─── assemble() AssistantTurn variants ────────────────────────────────
2216
2217    #[test]
2218    fn test_assemble_assistant_turn_with_text_and_tool_calls() {
2219        let mut window = ContextWindow::new(100_000);
2220        let region = Region::new(
2221            "conv".to_string(),
2222            RegionKind::SlidingWindow {
2223                max_items: 100,
2224                eviction_strategy: EvictionStrategy::PerItem,
2225            },
2226            50_000,
2227        );
2228        window.add_region(region);
2229
2230        // User message first
2231        window
2232            .add_typed_entry(
2233                "conv",
2234                leviath_core::EntryKind::UserMessage,
2235                "Read my file".to_string(),
2236                10,
2237            )
2238            .unwrap();
2239
2240        // Assistant with text + tool_calls
2241        window
2242            .add_typed_entry(
2243                "conv",
2244                leviath_core::EntryKind::AssistantTurn {
2245                    tool_calls: vec![leviath_core::SerializedToolCall {
2246                        id: "tc_a".to_string(),
2247                        name: "read_file".to_string(),
2248                        arguments: serde_json::json!({"path": "foo.rs"}),
2249                        thought_signature: None,
2250                    }],
2251                },
2252                "Sure, let me read it.".to_string(),
2253                20,
2254            )
2255            .unwrap();
2256
2257        // Matching tool result
2258        window
2259            .add_typed_entry(
2260                "conv",
2261                leviath_core::EntryKind::ToolResult {
2262                    tool_call_id: "tc_a".to_string(),
2263                    tool_name: "read_file".to_string(),
2264                    is_error: false,
2265                },
2266                "fn main() {}".to_string(),
2267                10,
2268            )
2269            .unwrap();
2270
2271        let assembled = window.assemble();
2272
2273        // Find the assistant message with blocks
2274        let assistant_msg = assembled
2275            .messages
2276            .iter()
2277            .find(|m| m.role == "assistant")
2278            .expect("should have assistant message");
2279
2280        // Assistant turn with text + a tool call assembles to a Text block
2281        // followed by the ToolUse block.
2282        assert_eq!(
2283            assistant_msg.content,
2284            leviath_providers::MessageContent::Blocks(vec![
2285                leviath_providers::ContentBlock::Text {
2286                    text: "Sure, let me read it.".to_string(),
2287                },
2288                leviath_providers::ContentBlock::ToolUse {
2289                    id: "tc_a".to_string(),
2290                    name: "read_file".to_string(),
2291                    input: serde_json::json!({"path": "foo.rs"}),
2292                    thought_signature: None,
2293                },
2294            ])
2295        );
2296    }
2297
2298    #[test]
2299    fn test_assemble_assistant_turn_no_text_only_tool_calls() {
2300        let mut window = ContextWindow::new(100_000);
2301        let region = Region::new(
2302            "conv".to_string(),
2303            RegionKind::SlidingWindow {
2304                max_items: 100,
2305                eviction_strategy: EvictionStrategy::PerItem,
2306            },
2307            50_000,
2308        );
2309        window.add_region(region);
2310
2311        // User message
2312        window
2313            .add_typed_entry(
2314                "conv",
2315                leviath_core::EntryKind::UserMessage,
2316                "Do it".to_string(),
2317                10,
2318            )
2319            .unwrap();
2320
2321        // Assistant with empty text + tool_calls
2322        window
2323            .add_typed_entry(
2324                "conv",
2325                leviath_core::EntryKind::AssistantTurn {
2326                    tool_calls: vec![leviath_core::SerializedToolCall {
2327                        id: "tc_b".to_string(),
2328                        name: "bash".to_string(),
2329                        arguments: serde_json::json!({"cmd": "ls"}),
2330                        thought_signature: None,
2331                    }],
2332                },
2333                "".to_string(),
2334                10,
2335            )
2336            .unwrap();
2337
2338        // Matching tool result
2339        window
2340            .add_typed_entry(
2341                "conv",
2342                leviath_core::EntryKind::ToolResult {
2343                    tool_call_id: "tc_b".to_string(),
2344                    tool_name: "bash".to_string(),
2345                    is_error: false,
2346                },
2347                "file1.rs\nfile2.rs".to_string(),
2348                10,
2349            )
2350            .unwrap();
2351
2352        let assembled = window.assemble();
2353
2354        let assistant_msg = assembled
2355            .messages
2356            .iter()
2357            .find(|m| m.role == "assistant")
2358            .expect("should have assistant message");
2359
2360        // Empty assistant text produces a single ToolUse block, no Text block.
2361        assert_eq!(
2362            assistant_msg.content,
2363            leviath_providers::MessageContent::Blocks(vec![
2364                leviath_providers::ContentBlock::ToolUse {
2365                    id: "tc_b".to_string(),
2366                    name: "bash".to_string(),
2367                    input: serde_json::json!({"cmd": "ls"}),
2368                    thought_signature: None,
2369                },
2370            ])
2371        );
2372    }
2373
2374    // ─── assemble() consecutive ToolResults flushed ───────────────────────
2375
2376    #[test]
2377    fn test_assemble_consecutive_tool_results_flushed_on_non_tool_result() {
2378        let mut window = ContextWindow::new(100_000);
2379        let region = Region::new(
2380            "conv".to_string(),
2381            RegionKind::SlidingWindow {
2382                max_items: 100,
2383                eviction_strategy: EvictionStrategy::PerItem,
2384            },
2385            50_000,
2386        );
2387        window.add_region(region);
2388
2389        // User message
2390        window
2391            .add_typed_entry(
2392                "conv",
2393                leviath_core::EntryKind::UserMessage,
2394                "Run two tools".to_string(),
2395                10,
2396            )
2397            .unwrap();
2398
2399        // Assistant with two tool calls
2400        window
2401            .add_typed_entry(
2402                "conv",
2403                leviath_core::EntryKind::AssistantTurn {
2404                    tool_calls: vec![
2405                        leviath_core::SerializedToolCall {
2406                            id: "tc_1".to_string(),
2407                            name: "read_file".to_string(),
2408                            arguments: serde_json::json!({"path": "a.rs"}),
2409                            thought_signature: None,
2410                        },
2411                        leviath_core::SerializedToolCall {
2412                            id: "tc_2".to_string(),
2413                            name: "read_file".to_string(),
2414                            arguments: serde_json::json!({"path": "b.rs"}),
2415                            thought_signature: None,
2416                        },
2417                    ],
2418                },
2419                "".to_string(),
2420                10,
2421            )
2422            .unwrap();
2423
2424        // Two consecutive ToolResults
2425        window
2426            .add_typed_entry(
2427                "conv",
2428                leviath_core::EntryKind::ToolResult {
2429                    tool_call_id: "tc_1".to_string(),
2430                    tool_name: "read_file".to_string(),
2431                    is_error: false,
2432                },
2433                "content of a.rs".to_string(),
2434                10,
2435            )
2436            .unwrap();
2437        window
2438            .add_typed_entry(
2439                "conv",
2440                leviath_core::EntryKind::ToolResult {
2441                    tool_call_id: "tc_2".to_string(),
2442                    tool_name: "read_file".to_string(),
2443                    is_error: false,
2444                },
2445                "content of b.rs".to_string(),
2446                10,
2447            )
2448            .unwrap();
2449
2450        // Then a UserMessage (should flush the pending tool results first)
2451        window
2452            .add_typed_entry(
2453                "conv",
2454                leviath_core::EntryKind::UserMessage,
2455                "Now fix the bug".to_string(),
2456                10,
2457            )
2458            .unwrap();
2459
2460        let assembled = window.assemble();
2461
2462        // Messages should be: user("Run two tools"), assistant(tool_uses),
2463        // user(tool_result x2), user("Now fix the bug")
2464        assert_eq!(assembled.messages.len(), 4);
2465
2466        // The third message should be a user message with two ToolResult blocks
2467        let tool_result_msg = &assembled.messages[2];
2468        assert_eq!(tool_result_msg.role, "user");
2469        // The two consecutive tool results merge into one user message with two
2470        // ToolResult blocks, in order.
2471        assert_eq!(
2472            tool_result_msg.content,
2473            leviath_providers::MessageContent::Blocks(vec![
2474                leviath_providers::ContentBlock::ToolResult {
2475                    tool_use_id: "tc_1".to_string(),
2476                    content: "content of a.rs".to_string(),
2477                    is_error: false,
2478                },
2479                leviath_providers::ContentBlock::ToolResult {
2480                    tool_use_id: "tc_2".to_string(),
2481                    content: "content of b.rs".to_string(),
2482                    is_error: false,
2483                },
2484            ])
2485        );
2486
2487        // The fourth message should be the user follow-up
2488        assert_eq!(assembled.messages[3].role, "user");
2489        assert_eq!(assembled.messages[3].content.as_text(), "Now fix the bug");
2490    }
2491
2492    // ─── assemble() "Begin." fallback ─────────────────────────────────────
2493
2494    #[test]
2495    fn test_assemble_injects_begin_when_no_user_messages() {
2496        let mut window = ContextWindow::new(100_000);
2497        // Only a Pinned region, no SlidingWindow with user messages
2498        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
2499        pinned
2500            .add_entry("You are a helpful assistant.".to_string(), 20)
2501            .unwrap();
2502        window.add_region(pinned);
2503
2504        let assembled = window.assemble();
2505
2506        // Should have injected a "Begin." fallback user message
2507        assert_eq!(assembled.messages.len(), 1);
2508        assert_eq!(assembled.messages[0].role, "user");
2509        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
2510    }
2511
2512    // ─── add_typed_entry / add_typed_tainted_to_region error paths ────────
2513
2514    #[test]
2515    fn test_add_typed_entry_to_nonexistent_region() {
2516        let mut window = ContextWindow::new(10000);
2517        let result = window.add_typed_entry(
2518            "nonexistent",
2519            leviath_core::EntryKind::UserMessage,
2520            "hello".to_string(),
2521            10,
2522        );
2523        assert!(result.is_err());
2524        let err_str = result.unwrap_err().to_string();
2525        assert!(
2526            err_str.contains("nonexistent"),
2527            "Error should mention the missing region name"
2528        );
2529    }
2530
2531    #[test]
2532    fn test_add_typed_tainted_to_nonexistent_region() {
2533        let mut window = ContextWindow::new(10000);
2534        let result = window.add_typed_tainted_to_region(
2535            "ghost",
2536            leviath_core::EntryKind::UserMessage,
2537            "data".to_string(),
2538            10,
2539            leviath_core::TaintLevel::Public,
2540        );
2541        assert!(result.is_err());
2542        let err_str = result.unwrap_err().to_string();
2543        assert!(
2544            err_str.contains("ghost"),
2545            "Error should mention the missing region name"
2546        );
2547    }
2548
2549    #[test]
2550    fn test_assemble_tool_result_before_any_tool_use() {
2551        // Edge case: tool_result appears in context but no tool_use exists at all
2552        let mut window = ContextWindow::new(100_000);
2553        let region = Region::new(
2554            "conversation".to_string(),
2555            RegionKind::SlidingWindow {
2556                max_items: 100,
2557                eviction_strategy: EvictionStrategy::PerItem,
2558            },
2559            50_000,
2560        );
2561        window.add_region(region);
2562
2563        // A tool_result with no tool_use anywhere
2564        window
2565            .add_typed_entry(
2566                "conversation",
2567                leviath_core::EntryKind::ToolResult {
2568                    tool_call_id: "tc_nowhere".to_string(),
2569                    tool_name: "read_file".to_string(),
2570                    is_error: false,
2571                },
2572                "orphan result".to_string(),
2573                10,
2574            )
2575            .unwrap();
2576
2577        let assembled = with_tracing(|| window.assemble());
2578
2579        // The orphaned tool_result message is stripped to empty and dropped,
2580        // leaving no messages - so the "Begin." user fallback is synthesized.
2581        assert_eq!(assembled.messages.len(), 1);
2582        assert_eq!(assembled.messages[0].role, "user");
2583        assert_eq!(
2584            assembled.messages[0].content,
2585            leviath_providers::MessageContent::Text("Begin.".to_string())
2586        );
2587    }
2588
2589    // ─── Prompt caching tests ────────────────────────────────────────────
2590
2591    #[test]
2592    fn test_assemble_sets_cache_breakpoint_on_stable_prefix() {
2593        let mut window = ContextWindow::new(100_000);
2594        let region = Region::new(
2595            "conv".to_string(),
2596            RegionKind::SlidingWindow {
2597                max_items: 100,
2598                eviction_strategy: EvictionStrategy::PerItem,
2599            },
2600            50_000,
2601        );
2602        window.add_region(region);
2603
2604        // Add 10 alternating user/assistant messages
2605        for i in 0..10 {
2606            let kind = if i % 2 == 0 {
2607                leviath_core::EntryKind::UserMessage
2608            } else {
2609                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] }
2610            };
2611            window
2612                .add_typed_entry("conv", kind, format!("message {i}"), 10)
2613                .unwrap();
2614        }
2615
2616        let assembled = window.assemble();
2617        // 10 alternating messages end on an assistant turn, so assemble appends a
2618        // trailing "Continue." user nudge → 11 messages.
2619        assert_eq!(assembled.messages.len(), 11);
2620        assert_eq!(assembled.messages.last().unwrap().role, "user");
2621
2622        // The breakpoint is placed at the 4th-from-last of the pre-nudge run
2623        // (index 6 of the original 10); the nudge is appended after.
2624        let bp_idx = 6;
2625        for (i, msg) in assembled.messages.iter().enumerate() {
2626            if i == bp_idx {
2627                assert!(
2628                    msg.cache_breakpoint,
2629                    "Message at index {i} should have cache_breakpoint = true"
2630                );
2631            } else {
2632                assert!(
2633                    !msg.cache_breakpoint,
2634                    "Message at index {i} should have cache_breakpoint = false"
2635                );
2636            }
2637        }
2638    }
2639
2640    #[test]
2641    fn test_assemble_cache_breakpoint_small_conversation() {
2642        let mut window = ContextWindow::new(100_000);
2643        let region = Region::new(
2644            "conv".to_string(),
2645            RegionKind::SlidingWindow {
2646                max_items: 100,
2647                eviction_strategy: EvictionStrategy::PerItem,
2648            },
2649            50_000,
2650        );
2651        window.add_region(region);
2652
2653        // Add 3 messages (user, assistant, user)
2654        window
2655            .add_typed_entry(
2656                "conv",
2657                leviath_core::EntryKind::UserMessage,
2658                "Hello".to_string(),
2659                10,
2660            )
2661            .unwrap();
2662        window
2663            .add_typed_entry(
2664                "conv",
2665                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
2666                "Hi there".to_string(),
2667                10,
2668            )
2669            .unwrap();
2670        window
2671            .add_typed_entry(
2672                "conv",
2673                leviath_core::EntryKind::UserMessage,
2674                "How are you?".to_string(),
2675                10,
2676            )
2677            .unwrap();
2678
2679        let assembled = window.assemble();
2680        assert_eq!(assembled.messages.len(), 3);
2681
2682        // With < 5 messages but >= 2, first message gets the breakpoint
2683        assert!(
2684            assembled.messages[0].cache_breakpoint,
2685            "First message should have cache_breakpoint in small conversation"
2686        );
2687        assert!(!assembled.messages[1].cache_breakpoint);
2688        assert!(!assembled.messages[2].cache_breakpoint);
2689    }
2690
2691    #[test]
2692    fn test_assemble_cache_breakpoint_too_few_messages() {
2693        let mut window = ContextWindow::new(100_000);
2694        let region = Region::new(
2695            "conv".to_string(),
2696            RegionKind::SlidingWindow {
2697                max_items: 100,
2698                eviction_strategy: EvictionStrategy::PerItem,
2699            },
2700            50_000,
2701        );
2702        window.add_region(region);
2703
2704        // Add only 1 message
2705        window
2706            .add_typed_entry(
2707                "conv",
2708                leviath_core::EntryKind::UserMessage,
2709                "Solo message".to_string(),
2710                10,
2711            )
2712            .unwrap();
2713
2714        let assembled = window.assemble();
2715        assert_eq!(assembled.messages.len(), 1);
2716
2717        // With only 1 message, no breakpoints should be set
2718        assert!(
2719            !assembled.messages[0].cache_breakpoint,
2720            "Single message should not get a cache breakpoint"
2721        );
2722    }
2723
2724    #[test]
2725    fn test_assemble_system_blocks_sorted_by_cache_stability() {
2726        use leviath_core::CacheHint;
2727
2728        let mut window = ContextWindow::new(100_000);
2729
2730        // Add regions in "wrong" order: volatile first, stable last
2731        let mut clearable = Region::new("scratch".to_string(), RegionKind::Clearable, 10_000);
2732        clearable
2733            .add_entry("clearable data".to_string(), 20)
2734            .unwrap();
2735        window.add_region(clearable);
2736
2737        let mut temporary = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
2738        temporary
2739            .add_entry("temporary data".to_string(), 20)
2740            .unwrap();
2741        window.add_region(temporary);
2742
2743        let mut compacting = Region::new(
2744            "impl".to_string(),
2745            RegionKind::Compacting {
2746                threshold_tokens: 500,
2747            },
2748            10_000,
2749        );
2750        compacting
2751            .add_entry("compacting data".to_string(), 20)
2752            .unwrap();
2753        window.add_region(compacting);
2754
2755        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
2756        pinned
2757            .add_entry("pinned system prompt".to_string(), 20)
2758            .unwrap();
2759        window.add_region(pinned);
2760
2761        let assembled = window.assemble();
2762
2763        assert_eq!(assembled.system_blocks.len(), 4);
2764
2765        // Verify ordering: Always (Pinned) first, UntilChanged (Compacting) second,
2766        // Never (Temporary, Clearable) last
2767        assert_eq!(
2768            assembled.system_blocks[0].cache_hint,
2769            CacheHint::Always,
2770            "First system block should be Always (Pinned)"
2771        );
2772        assert_eq!(
2773            assembled.system_blocks[1].cache_hint,
2774            CacheHint::UntilChanged,
2775            "Second system block should be UntilChanged (Compacting)"
2776        );
2777        assert_eq!(
2778            assembled.system_blocks[2].cache_hint,
2779            CacheHint::Never,
2780            "Third system block should be Never"
2781        );
2782        assert_eq!(
2783            assembled.system_blocks[3].cache_hint,
2784            CacheHint::Never,
2785            "Fourth system block should be Never"
2786        );
2787    }
2788
2789    // ─── Coverage for ContextWindow typed+tainted methods ─────────────────
2790
2791    #[test]
2792    fn test_add_typed_tainted_to_region_success() {
2793        let mut window = ContextWindow::new(10000);
2794        let mut region = Region::new(
2795            "conv".to_string(),
2796            RegionKind::SlidingWindow {
2797                max_items: 50,
2798                eviction_strategy: EvictionStrategy::PerItem,
2799            },
2800            5000,
2801        );
2802        region.enable_taint_tracking();
2803        window.add_region(region);
2804
2805        window
2806            .add_typed_tainted_to_region(
2807                "conv",
2808                leviath_core::EntryKind::ToolResult {
2809                    tool_call_id: "tc_1".to_string(),
2810                    tool_name: "read_file".to_string(),
2811                    is_error: false,
2812                },
2813                "secret data".to_string(),
2814                100,
2815                leviath_core::TaintLevel::Private,
2816            )
2817            .unwrap();
2818
2819        assert_eq!(window.current_tokens, 100);
2820        assert_eq!(
2821            window.get_region("conv").and_then(|r| r.taint_level()),
2822            Some(leviath_core::TaintLevel::Private)
2823        );
2824    }
2825
2826    #[test]
2827    fn test_add_typed_tainted_to_region_not_found() {
2828        let mut window = ContextWindow::new(10000);
2829        let result = window.add_typed_tainted_to_region(
2830            "nonexistent",
2831            leviath_core::EntryKind::Text,
2832            "data".to_string(),
2833            10,
2834            leviath_core::TaintLevel::Public,
2835        );
2836        assert!(result.is_err());
2837    }
2838
2839    #[test]
2840    fn test_assemble_consecutive_tool_results_flushed_at_end() {
2841        // Tool results at the END of the region (not followed by a non-ToolResult)
2842        // should still be flushed into a user message.
2843        let mut window = ContextWindow::new(100_000);
2844        let region = Region::new(
2845            "conv".to_string(),
2846            RegionKind::SlidingWindow {
2847                max_items: 100,
2848                eviction_strategy: EvictionStrategy::PerItem,
2849            },
2850            50_000,
2851        );
2852        window.add_region(region);
2853
2854        // Add user message, then assistant with tool calls, then tool results at end
2855        window
2856            .add_typed_entry(
2857                "conv",
2858                leviath_core::EntryKind::UserMessage,
2859                "do something".to_string(),
2860                10,
2861            )
2862            .unwrap();
2863        window
2864            .add_typed_entry(
2865                "conv",
2866                leviath_core::EntryKind::AssistantTurn {
2867                    tool_calls: vec![leviath_core::SerializedToolCall {
2868                        id: "tc_1".to_string(),
2869                        name: "read_file".to_string(),
2870                        arguments: serde_json::json!({"path": "foo.rs"}),
2871                        thought_signature: None,
2872                    }],
2873                },
2874                "Let me read that".to_string(),
2875                10,
2876            )
2877            .unwrap();
2878        window
2879            .add_typed_entry(
2880                "conv",
2881                leviath_core::EntryKind::ToolResult {
2882                    tool_call_id: "tc_1".to_string(),
2883                    tool_name: "read_file".to_string(),
2884                    is_error: false,
2885                },
2886                "fn main() {}".to_string(),
2887                10,
2888            )
2889            .unwrap();
2890
2891        let assembled = window.assemble();
2892        // user msg + assistant (with tool_use blocks) + user (with tool_result blocks)
2893        assert_eq!(assembled.messages.len(), 3);
2894        assert_eq!(assembled.messages[2].role, "user");
2895        // The last message is a Blocks message carrying the single ToolResult.
2896        assert_eq!(
2897            assembled.messages[2].content,
2898            leviath_providers::MessageContent::Blocks(vec![
2899                leviath_providers::ContentBlock::ToolResult {
2900                    tool_use_id: "tc_1".to_string(),
2901                    content: "fn main() {}".to_string(),
2902                    is_error: false,
2903                },
2904            ])
2905        );
2906    }
2907
2908    #[test]
2909    fn test_assemble_compact_history_with_sliding_prefix_sorting() {
2910        // CompactHistory should sort before Compacting/Temporary in system blocks
2911        use leviath_core::CacheHint;
2912
2913        let mut window = ContextWindow::new(100_000);
2914
2915        let mut temp = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
2916        temp.add_entry("temp data".to_string(), 10).unwrap();
2917        window.add_region(temp);
2918
2919        let mut history = Region::new(
2920            "history".to_string(),
2921            RegionKind::CompactHistory {
2922                source_region: "impl".to_string(),
2923            },
2924            10_000,
2925        );
2926        history.add_entry("summary data".to_string(), 10).unwrap();
2927        window.add_region(history);
2928
2929        let assembled = window.assemble();
2930        assert_eq!(assembled.system_blocks.len(), 2);
2931        // CompactHistory (Always) should come before Temporary (Never)
2932        assert_eq!(assembled.system_blocks[0].cache_hint, CacheHint::Always);
2933        assert_eq!(assembled.system_blocks[1].cache_hint, CacheHint::Never);
2934    }
2935
2936    #[test]
2937    fn cache_hint_sort_priority_orders_by_stability() {
2938        use leviath_core::CacheHint;
2939        // Most stable first (lowest priority), volatile last.
2940        assert_eq!(cache_hint_sort_priority(CacheHint::Always), 0);
2941        assert_eq!(
2942            cache_hint_sort_priority(CacheHint::SlidingPrefix {
2943                stable_fraction: 0.75
2944            }),
2945            1
2946        );
2947        assert_eq!(cache_hint_sort_priority(CacheHint::UntilChanged), 2);
2948        assert_eq!(cache_hint_sort_priority(CacheHint::Never), 3);
2949        // The four priorities are strictly increasing by volatility.
2950        assert!(
2951            cache_hint_sort_priority(CacheHint::Always)
2952                < cache_hint_sort_priority(CacheHint::SlidingPrefix {
2953                    stable_fraction: 0.5
2954                })
2955        );
2956    }
2957
2958    #[test]
2959    fn test_assemble_empty_regions_skipped() {
2960        let mut window = ContextWindow::new(100_000);
2961        window.add_region(Region::new(
2962            "system".to_string(),
2963            RegionKind::Pinned,
2964            10_000,
2965        ));
2966        // Empty pinned region should be skipped
2967        let assembled = window.assemble();
2968        assert!(assembled.system_blocks.is_empty());
2969    }
2970
2971    #[test]
2972    fn test_assemble_hashmap_region_with_keys() {
2973        let mut window = ContextWindow::new(100_000);
2974        let mut region = Region::new(
2975            "files".to_string(),
2976            RegionKind::HashMap { max_entries: None },
2977            10_000,
2978        );
2979        region
2980            .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2981            .unwrap();
2982        region
2983            .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2984            .unwrap();
2985        window.add_region(region);
2986
2987        let assembled = window.assemble();
2988        assert_eq!(assembled.system_blocks.len(), 1);
2989        let block_text = &assembled.system_blocks[0].text;
2990        assert!(block_text.contains("[files]:"));
2991        assert!(block_text.contains("### [src/main.rs]"));
2992        assert!(block_text.contains("fn main() {}"));
2993        assert!(block_text.contains("### [src/lib.rs]"));
2994        assert!(block_text.contains("pub mod foo;"));
2995    }
2996
2997    #[test]
2998    fn test_assemble_hashmap_region_cache_hint() {
2999        let mut window = ContextWindow::new(100_000);
3000        let mut region = Region::new(
3001            "files".to_string(),
3002            RegionKind::HashMap { max_entries: None },
3003            10_000,
3004        );
3005        region
3006            .upsert_by_key("a.rs", "content".to_string(), 5)
3007            .unwrap();
3008        window.add_region(region);
3009
3010        let assembled = window.assemble();
3011        assert_eq!(assembled.system_blocks.len(), 1);
3012        assert_eq!(
3013            assembled.system_blocks[0].cache_hint,
3014            leviath_core::CacheHint::UntilChanged
3015        );
3016    }
3017
3018    // ─── HashMap region assembly tests ──────────────────────────────────
3019
3020    #[test]
3021    fn test_assemble_hashmap_single_keyed_entry() {
3022        let mut window = ContextWindow::new(100_000);
3023        let mut region = Region::new(
3024            "context".to_string(),
3025            RegionKind::HashMap { max_entries: None },
3026            10_000,
3027        );
3028        region
3029            .upsert_by_key("config.toml", "key = \"value\"".to_string(), 10)
3030            .unwrap();
3031        window.add_region(region);
3032
3033        let assembled = window.assemble();
3034
3035        assert_eq!(assembled.system_blocks.len(), 1);
3036        let block_text = &assembled.system_blocks[0].text;
3037        assert!(
3038            block_text.starts_with("[context]:"),
3039            "System block should start with [region_name]: prefix"
3040        );
3041        assert!(
3042            block_text.contains("### [config.toml]"),
3043            "Entry should have ### [key] header"
3044        );
3045        assert!(
3046            block_text.contains("key = \"value\""),
3047            "Entry content should be present"
3048        );
3049    }
3050
3051    #[test]
3052    fn test_assemble_hashmap_multiple_keyed_entries() {
3053        let mut window = ContextWindow::new(100_000);
3054        let mut region = Region::new(
3055            "tracked_files".to_string(),
3056            RegionKind::HashMap { max_entries: None },
3057            10_000,
3058        );
3059        region
3060            .upsert_by_key("alpha.rs", "fn alpha() {}".to_string(), 10)
3061            .unwrap();
3062        region
3063            .upsert_by_key("beta.rs", "fn beta() {}".to_string(), 10)
3064            .unwrap();
3065        region
3066            .upsert_by_key("gamma.rs", "fn gamma() {}".to_string(), 10)
3067            .unwrap();
3068        window.add_region(region);
3069
3070        let assembled = window.assemble();
3071
3072        assert_eq!(assembled.system_blocks.len(), 1);
3073        let block_text = &assembled.system_blocks[0].text;
3074        assert!(block_text.starts_with("[tracked_files]:"));
3075        assert!(block_text.contains("### [alpha.rs]"));
3076        assert!(block_text.contains("fn alpha() {}"));
3077        assert!(block_text.contains("### [beta.rs]"));
3078        assert!(block_text.contains("fn beta() {}"));
3079        assert!(block_text.contains("### [gamma.rs]"));
3080        assert!(block_text.contains("fn gamma() {}"));
3081    }
3082
3083    #[test]
3084    fn test_assemble_hashmap_empty_region_skipped() {
3085        let mut window = ContextWindow::new(100_000);
3086        let region = Region::new(
3087            "empty_map".to_string(),
3088            RegionKind::HashMap { max_entries: None },
3089            10_000,
3090        );
3091        // No entries added
3092        window.add_region(region);
3093
3094        let assembled = window.assemble();
3095
3096        assert!(
3097            assembled.system_blocks.is_empty(),
3098            "Empty HashMap region should not produce a system block"
3099        );
3100    }
3101
3102    #[test]
3103    fn test_assemble_mixed_pinned_hashmap_sliding_window() {
3104        use leviath_core::CacheHint;
3105
3106        let mut window = ContextWindow::new(100_000);
3107
3108        // Pinned region
3109        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
3110        pinned
3111            .add_entry("You are a helpful assistant.".to_string(), 20)
3112            .unwrap();
3113        window.add_region(pinned);
3114
3115        // HashMap region
3116        let mut hashmap = Region::new(
3117            "files".to_string(),
3118            RegionKind::HashMap { max_entries: None },
3119            10_000,
3120        );
3121        hashmap
3122            .upsert_by_key("main.rs", "fn main() {}".to_string(), 10)
3123            .unwrap();
3124        window.add_region(hashmap);
3125
3126        // SlidingWindow region with user messages
3127        let mut sliding = Region::new(
3128            "conv".to_string(),
3129            RegionKind::SlidingWindow {
3130                max_items: 100,
3131                eviction_strategy: EvictionStrategy::PerItem,
3132            },
3133            50_000,
3134        );
3135        sliding
3136            .add_typed_entry(
3137                "Hello there".to_string(),
3138                10,
3139                leviath_core::EntryKind::UserMessage,
3140            )
3141            .unwrap();
3142        window.add_region(sliding);
3143
3144        let assembled = window.assemble();
3145
3146        // Pinned and HashMap should produce system blocks (2 total)
3147        assert_eq!(assembled.system_blocks.len(), 2);
3148
3149        // System blocks sorted by cache hint: Pinned (Always) first, HashMap (UntilChanged) second
3150        assert_eq!(
3151            assembled.system_blocks[0].cache_hint,
3152            CacheHint::Always,
3153            "Pinned region should sort first (Always cache hint)"
3154        );
3155        assert!(
3156            assembled.system_blocks[0]
3157                .text
3158                .contains("You are a helpful assistant."),
3159            "First system block should be the pinned content"
3160        );
3161
3162        assert_eq!(
3163            assembled.system_blocks[1].cache_hint,
3164            CacheHint::UntilChanged,
3165            "HashMap region should sort second (UntilChanged cache hint)"
3166        );
3167        assert!(
3168            assembled.system_blocks[1].text.starts_with("[files]:"),
3169            "HashMap system block should have [region_name]: prefix"
3170        );
3171        assert!(
3172            assembled.system_blocks[1].text.contains("### [main.rs]"),
3173            "HashMap system block should contain ### [key] header"
3174        );
3175
3176        // SlidingWindow should produce messages, not system blocks
3177        assert!(
3178            assembled
3179                .messages
3180                .iter()
3181                .any(|m| m.role == "user" && m.content.as_text().contains("Hello there")),
3182            "SlidingWindow entries should appear as messages"
3183        );
3184    }
3185
3186    #[test]
3187    fn test_add_tainted_to_region_propagates_budget_error() {
3188        // Region is found, but the entry exceeds its token budget, so the
3189        // inner `add_tainted_entry` error must propagate through the `?`.
3190        let mut window = ContextWindow::new(10_000);
3191        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
3192        region.enable_taint_tracking();
3193        window.add_region(region);
3194
3195        let result = window.add_tainted_to_region(
3196            "conv",
3197            "far too many tokens".to_string(),
3198            100,
3199            leviath_core::TaintLevel::Private,
3200        );
3201        assert!(result.is_err());
3202    }
3203
3204    #[test]
3205    fn test_add_typed_tainted_to_region_propagates_budget_error() {
3206        // Region is found, but the entry exceeds its token budget, so the
3207        // inner `add_typed_tainted_entry` error must propagate through the `?`.
3208        let mut window = ContextWindow::new(10_000);
3209        let region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
3210        window.add_region(region);
3211
3212        let result = window.add_typed_tainted_to_region(
3213            "conv",
3214            leviath_core::EntryKind::Text,
3215            "far too many tokens".to_string(),
3216            100,
3217            leviath_core::TaintLevel::Public,
3218        );
3219        assert!(result.is_err());
3220    }
3221
3222    #[test]
3223    fn test_assemble_hashmap_region_entry_without_key() {
3224        // A HashMap-region entry with no key falls back to its raw content
3225        // (rather than a "### [key]" header) when assembled.
3226        let mut window = ContextWindow::new(10_000);
3227        let region = Region::new(
3228            "kv".to_string(),
3229            RegionKind::HashMap { max_entries: None },
3230            5000,
3231        );
3232        window.add_region(region);
3233        // add_to_region stores the entry with key: None.
3234        window
3235            .add_to_region("kv", "keyless content".to_string(), 10)
3236            .unwrap();
3237
3238        let assembled = window.assemble();
3239        assert!(
3240            assembled
3241                .system_blocks
3242                .iter()
3243                .any(|b| b.text.contains("keyless content")),
3244            "keyless HashMap entry should appear verbatim in a system block"
3245        );
3246    }
3247
3248    // ─── Status and wait-reason labels (issue #184) ──────────────────────────
3249
3250    /// `label` is a wire contract: the `WorldEvent` stream and the REST
3251    /// WebSocket forward these words verbatim, so pinning them here is what
3252    /// stops a rename from silently breaking an API consumer.
3253    #[test]
3254    fn status_labels_are_fixed() {
3255        assert_eq!(AgentStatus::Idle.label(), "idle");
3256        assert_eq!(AgentStatus::Active.label(), "active");
3257        assert_eq!(AgentStatus::Waiting.label(), "waiting");
3258        assert_eq!(AgentStatus::Paused.label(), "paused");
3259        assert_eq!(AgentStatus::Complete.label(), "complete");
3260        assert_eq!(AgentStatus::Cancelled.label(), "cancelled");
3261        assert_eq!(
3262            AgentStatus::Error {
3263                message: "boom".to_string()
3264            }
3265            .label(),
3266            "error"
3267        );
3268    }
3269
3270    /// `Display` matches `label` except for an error, which carries its message
3271    /// - that is the difference between "a child failed" and knowing why.
3272    #[test]
3273    fn display_matches_label_except_for_an_error() {
3274        for status in [
3275            AgentStatus::Idle,
3276            AgentStatus::Active,
3277            AgentStatus::Waiting,
3278            AgentStatus::Paused,
3279            AgentStatus::Complete,
3280            AgentStatus::Cancelled,
3281        ] {
3282            assert_eq!(status.to_string(), status.label());
3283        }
3284        assert_eq!(
3285            AgentStatus::Error {
3286                message: "disk full".to_string()
3287            }
3288            .to_string(),
3289            "error: disk full"
3290        );
3291    }
3292
3293    #[test]
3294    fn wait_reasons_read_as_short_phrases() {
3295        assert_eq!(WaitReason::ToolApproval.to_string(), "tool approval");
3296        assert_eq!(WaitReason::UserPrompt.to_string(), "user prompt");
3297        assert_eq!(WaitReason::TaintGate.to_string(), "taint gate");
3298        assert_eq!(WaitReason::InteractionPoint.to_string(), "checkpoint");
3299        assert_eq!(
3300            WaitReason::FanOutWorkers { outstanding: 4 }.to_string(),
3301            "workers(4)"
3302        );
3303        assert_eq!(
3304            WaitReason::Children { outstanding: 1 }.to_string(),
3305            "children(1)"
3306        );
3307    }
3308
3309    /// The split the whole issue turns on: which of these an operator has to do
3310    /// something about.
3311    #[test]
3312    fn only_prompts_need_a_person() {
3313        for reason in [
3314            WaitReason::ToolApproval,
3315            WaitReason::UserPrompt,
3316            WaitReason::TaintGate,
3317            WaitReason::InteractionPoint,
3318        ] {
3319            assert!(reason.needs_a_person(), "{reason} is blocked on someone");
3320        }
3321        for reason in [
3322            WaitReason::FanOutWorkers { outstanding: 2 },
3323            WaitReason::Children { outstanding: 2 },
3324        ] {
3325            assert!(!reason.needs_a_person(), "{reason} resolves on its own");
3326        }
3327    }
3328}
3329
3330#[cfg(test)]
3331mod stage_hook_scripts_tests {
3332    use super::*;
3333
3334    fn scripts(path: &str) -> StageHookScripts {
3335        let compiled = leviath_scripting::stage_hook::compile(
3336            path,
3337            "fn on_stage_enter(ctx) { () } fn on_stage_exit(ctx) { () }",
3338            &[],
3339        )
3340        .expect("compiles");
3341        let mut m = std::collections::HashMap::new();
3342        m.insert(path.to_string(), std::sync::Arc::new(compiled));
3343        StageHookScripts(m)
3344    }
3345
3346    fn stage_declaring(enter: Option<&str>, exit: Option<&str>) -> leviath_core::Stage {
3347        let mut s = leviath_core::Stage::new(
3348            "main".to_string(),
3349            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
3350        );
3351        s.hooks.on_stage_enter = enter.map(str::to_string);
3352        s.hooks.on_stage_exit = exit.map(str::to_string);
3353        s
3354    }
3355
3356    #[test]
3357    fn each_hook_resolves_to_the_file_its_stage_named() {
3358        let s = scripts("h.rhai");
3359        let stage = stage_declaring(Some("h.rhai"), Some("h.rhai"));
3360        assert!(s.script_for(&stage, "on_stage_enter").is_some());
3361        assert!(s.script_for(&stage, "on_stage_exit").is_some());
3362    }
3363
3364    #[test]
3365    fn a_hook_the_stage_did_not_declare_resolves_to_nothing() {
3366        let s = scripts("h.rhai");
3367        let stage = stage_declaring(Some("h.rhai"), None);
3368        assert!(s.script_for(&stage, "on_stage_exit").is_none());
3369    }
3370
3371    /// A hook name this build does not implement resolves to nothing rather
3372    /// than panicking - the caller asks by string.
3373    #[test]
3374    fn an_unknown_hook_name_resolves_to_nothing() {
3375        let s = scripts("h.rhai");
3376        let stage = stage_declaring(Some("h.rhai"), None);
3377        assert!(s.script_for(&stage, "on_nothing").is_none());
3378    }
3379
3380    /// Declared but not on file: spawn already refused that, so a miss here
3381    /// means the stage simply has no such hook.
3382    #[test]
3383    fn a_declared_path_with_no_compiled_script_resolves_to_nothing() {
3384        let s = scripts("other.rhai");
3385        let stage = stage_declaring(Some("h.rhai"), None);
3386        assert!(s.script_for(&stage, "on_stage_enter").is_none());
3387    }
3388}