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        });
2008        assert_eq!(assembled.system_blocks[0].text, "<brain iter=7>");
2009    }
2010
2011    #[test]
2012    fn assemble_custom_region_renders_even_when_empty() {
2013        // Static scaffolding: the script emits structure with no entries.
2014        let src = r#"fn render(ctx) { `<empty count=${ctx.entries.len()}>` }"#;
2015        let window = custom_window(src, false);
2016        let assembled = window.assemble();
2017        assert_eq!(assembled.system_blocks.len(), 1);
2018        assert_eq!(assembled.system_blocks[0].text, "<empty count=0>");
2019    }
2020
2021    #[test]
2022    fn assemble_custom_conversation_takeover_renders_single_user_message() {
2023        // The 12-factor case: a custom region NAMED conversation holds the
2024        // typed history and renders it as one XML user message. No sliding
2025        // window exists; the request's only message is the script's.
2026        let src = r#"
2027            fn render(ctx) {
2028                let xml = "<context>";
2029                for entry in ctx.entries {
2030                    xml += `<event kind="${entry.kind}">${entry.content}</event>`;
2031                }
2032                xml += "</context>";
2033                #{ messages: [ #{ role: "user", content: xml } ] }
2034            }
2035        "#;
2036        let mut window = ContextWindow::new(10_000);
2037        window.add_region(Region::new(
2038            "conversation".to_string(),
2039            RegionKind::Custom {
2040                script: "conv.rhai".to_string(),
2041                persistent: false,
2042            },
2043            5_000,
2044        ));
2045        window.region_scripts.insert(
2046            "conv.rhai".to_string(),
2047            std::sync::Arc::new(leviath_scripting::region_hook::compile("conv.rhai", src).unwrap()),
2048        );
2049        window
2050            .add_typed_entry(
2051                "conversation",
2052                leviath_core::EntryKind::UserMessage,
2053                "do the task".to_string(),
2054                4,
2055            )
2056            .unwrap();
2057        window
2058            .add_typed_entry(
2059                "conversation",
2060                leviath_core::EntryKind::ToolResult {
2061                    tool_call_id: "c1".to_string(),
2062                    tool_name: "shell".to_string(),
2063                    is_error: false,
2064                },
2065                "output".to_string(),
2066                2,
2067            )
2068            .unwrap();
2069
2070        let assembled = window.assemble();
2071        assert!(assembled.system_blocks.is_empty());
2072        assert_eq!(assembled.messages.len(), 1);
2073        assert_eq!(assembled.messages[0].role, "user");
2074        assert_eq!(
2075            assembled.messages[0].content.as_text(),
2076            "<context><event kind=\"user_message\">do the task</event>\
2077             <event kind=\"tool_result\">output</event></context>"
2078        );
2079    }
2080
2081    #[test]
2082    fn assemble_custom_script_emitting_nothing_gets_begin_fallback() {
2083        // A script that emits no messages leaves the request message-less;
2084        // the shared finalization injects the "Begin." user message.
2085        let window = custom_window("fn render(ctx) { \"\" }", false);
2086        let assembled = window.assemble();
2087        assert!(assembled.system_blocks.is_empty());
2088        assert_eq!(assembled.messages.len(), 1);
2089        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
2090    }
2091
2092    #[test]
2093    fn assemble_custom_unpaired_tool_blocks_are_sanitized() {
2094        // A buggy script emits a tool_result with no matching tool_use; the
2095        // orphan sanitizer strips it instead of sending a provider-invalid
2096        // request.
2097        let src = r#"
2098            fn render(ctx) {
2099                #{ messages: [
2100                    #{ role: "user", content: "hello" },
2101                    #{ role: "user", tool_results: [
2102                        #{ tool_call_id: "ghost", content: "orphan" },
2103                    ] },
2104                ] }
2105            }
2106        "#;
2107        let window = custom_window(src, false);
2108        let assembled = window.assemble();
2109        assert_eq!(assembled.messages.len(), 1, "orphan tool_result stripped");
2110        assert_eq!(assembled.messages[0].content.as_text(), "hello");
2111    }
2112
2113    // ─── assemble() EntryKind::Text prefix parsing ────────────────────────
2114
2115    #[test]
2116    fn test_assemble_text_entry_with_assistant_prefix() {
2117        let mut window = ContextWindow::new(100_000);
2118        let region = Region::new(
2119            "conv".to_string(),
2120            RegionKind::SlidingWindow {
2121                max_items: 100,
2122                eviction_strategy: EvictionStrategy::PerItem,
2123            },
2124            50_000,
2125        );
2126        window.add_region(region);
2127
2128        window
2129            .add_typed_entry(
2130                "conv",
2131                leviath_core::EntryKind::Text,
2132                "Assistant: I can help with that.".to_string(),
2133                10,
2134            )
2135            .unwrap();
2136
2137        let assembled = window.assemble();
2138
2139        let assistant_msgs: Vec<_> = assembled
2140            .messages
2141            .iter()
2142            .filter(|m| m.role == "assistant")
2143            .collect();
2144        assert_eq!(assistant_msgs.len(), 1);
2145        assert_eq!(assistant_msgs[0].content.as_text(), "I can help with that.");
2146    }
2147
2148    #[test]
2149    fn test_assemble_text_entry_with_user_prefix() {
2150        let mut window = ContextWindow::new(100_000);
2151        let region = Region::new(
2152            "conv".to_string(),
2153            RegionKind::SlidingWindow {
2154                max_items: 100,
2155                eviction_strategy: EvictionStrategy::PerItem,
2156            },
2157            50_000,
2158        );
2159        window.add_region(region);
2160
2161        window
2162            .add_typed_entry(
2163                "conv",
2164                leviath_core::EntryKind::Text,
2165                "User: What is Rust?".to_string(),
2166                10,
2167            )
2168            .unwrap();
2169
2170        let assembled = window.assemble();
2171
2172        let user_msgs: Vec<_> = assembled
2173            .messages
2174            .iter()
2175            .filter(|m| m.role == "user")
2176            .collect();
2177        assert_eq!(user_msgs.len(), 1);
2178        assert_eq!(user_msgs[0].content.as_text(), "What is Rust?");
2179    }
2180
2181    #[test]
2182    fn test_assemble_text_entry_without_prefix_defaults_to_user() {
2183        let mut window = ContextWindow::new(100_000);
2184        let region = Region::new(
2185            "conv".to_string(),
2186            RegionKind::SlidingWindow {
2187                max_items: 100,
2188                eviction_strategy: EvictionStrategy::PerItem,
2189            },
2190            50_000,
2191        );
2192        window.add_region(region);
2193
2194        window
2195            .add_typed_entry(
2196                "conv",
2197                leviath_core::EntryKind::Text,
2198                "some plain text".to_string(),
2199                10,
2200            )
2201            .unwrap();
2202
2203        let assembled = window.assemble();
2204
2205        let user_msgs: Vec<_> = assembled
2206            .messages
2207            .iter()
2208            .filter(|m| m.role == "user")
2209            .collect();
2210        assert_eq!(user_msgs.len(), 1);
2211        assert_eq!(user_msgs[0].content.as_text(), "some plain text");
2212    }
2213
2214    // ─── assemble() AssistantTurn variants ────────────────────────────────
2215
2216    #[test]
2217    fn test_assemble_assistant_turn_with_text_and_tool_calls() {
2218        let mut window = ContextWindow::new(100_000);
2219        let region = Region::new(
2220            "conv".to_string(),
2221            RegionKind::SlidingWindow {
2222                max_items: 100,
2223                eviction_strategy: EvictionStrategy::PerItem,
2224            },
2225            50_000,
2226        );
2227        window.add_region(region);
2228
2229        // User message first
2230        window
2231            .add_typed_entry(
2232                "conv",
2233                leviath_core::EntryKind::UserMessage,
2234                "Read my file".to_string(),
2235                10,
2236            )
2237            .unwrap();
2238
2239        // Assistant with text + tool_calls
2240        window
2241            .add_typed_entry(
2242                "conv",
2243                leviath_core::EntryKind::AssistantTurn {
2244                    tool_calls: vec![leviath_core::SerializedToolCall {
2245                        id: "tc_a".to_string(),
2246                        name: "read_file".to_string(),
2247                        arguments: serde_json::json!({"path": "foo.rs"}),
2248                        thought_signature: None,
2249                    }],
2250                },
2251                "Sure, let me read it.".to_string(),
2252                20,
2253            )
2254            .unwrap();
2255
2256        // Matching tool result
2257        window
2258            .add_typed_entry(
2259                "conv",
2260                leviath_core::EntryKind::ToolResult {
2261                    tool_call_id: "tc_a".to_string(),
2262                    tool_name: "read_file".to_string(),
2263                    is_error: false,
2264                },
2265                "fn main() {}".to_string(),
2266                10,
2267            )
2268            .unwrap();
2269
2270        let assembled = window.assemble();
2271
2272        // Find the assistant message with blocks
2273        let assistant_msg = assembled
2274            .messages
2275            .iter()
2276            .find(|m| m.role == "assistant")
2277            .expect("should have assistant message");
2278
2279        // Assistant turn with text + a tool call assembles to a Text block
2280        // followed by the ToolUse block.
2281        assert_eq!(
2282            assistant_msg.content,
2283            leviath_providers::MessageContent::Blocks(vec![
2284                leviath_providers::ContentBlock::Text {
2285                    text: "Sure, let me read it.".to_string(),
2286                },
2287                leviath_providers::ContentBlock::ToolUse {
2288                    id: "tc_a".to_string(),
2289                    name: "read_file".to_string(),
2290                    input: serde_json::json!({"path": "foo.rs"}),
2291                    thought_signature: None,
2292                },
2293            ])
2294        );
2295    }
2296
2297    #[test]
2298    fn test_assemble_assistant_turn_no_text_only_tool_calls() {
2299        let mut window = ContextWindow::new(100_000);
2300        let region = Region::new(
2301            "conv".to_string(),
2302            RegionKind::SlidingWindow {
2303                max_items: 100,
2304                eviction_strategy: EvictionStrategy::PerItem,
2305            },
2306            50_000,
2307        );
2308        window.add_region(region);
2309
2310        // User message
2311        window
2312            .add_typed_entry(
2313                "conv",
2314                leviath_core::EntryKind::UserMessage,
2315                "Do it".to_string(),
2316                10,
2317            )
2318            .unwrap();
2319
2320        // Assistant with empty text + tool_calls
2321        window
2322            .add_typed_entry(
2323                "conv",
2324                leviath_core::EntryKind::AssistantTurn {
2325                    tool_calls: vec![leviath_core::SerializedToolCall {
2326                        id: "tc_b".to_string(),
2327                        name: "bash".to_string(),
2328                        arguments: serde_json::json!({"cmd": "ls"}),
2329                        thought_signature: None,
2330                    }],
2331                },
2332                "".to_string(),
2333                10,
2334            )
2335            .unwrap();
2336
2337        // Matching tool result
2338        window
2339            .add_typed_entry(
2340                "conv",
2341                leviath_core::EntryKind::ToolResult {
2342                    tool_call_id: "tc_b".to_string(),
2343                    tool_name: "bash".to_string(),
2344                    is_error: false,
2345                },
2346                "file1.rs\nfile2.rs".to_string(),
2347                10,
2348            )
2349            .unwrap();
2350
2351        let assembled = window.assemble();
2352
2353        let assistant_msg = assembled
2354            .messages
2355            .iter()
2356            .find(|m| m.role == "assistant")
2357            .expect("should have assistant message");
2358
2359        // Empty assistant text produces a single ToolUse block, no Text block.
2360        assert_eq!(
2361            assistant_msg.content,
2362            leviath_providers::MessageContent::Blocks(vec![
2363                leviath_providers::ContentBlock::ToolUse {
2364                    id: "tc_b".to_string(),
2365                    name: "bash".to_string(),
2366                    input: serde_json::json!({"cmd": "ls"}),
2367                    thought_signature: None,
2368                },
2369            ])
2370        );
2371    }
2372
2373    // ─── assemble() consecutive ToolResults flushed ───────────────────────
2374
2375    #[test]
2376    fn test_assemble_consecutive_tool_results_flushed_on_non_tool_result() {
2377        let mut window = ContextWindow::new(100_000);
2378        let region = Region::new(
2379            "conv".to_string(),
2380            RegionKind::SlidingWindow {
2381                max_items: 100,
2382                eviction_strategy: EvictionStrategy::PerItem,
2383            },
2384            50_000,
2385        );
2386        window.add_region(region);
2387
2388        // User message
2389        window
2390            .add_typed_entry(
2391                "conv",
2392                leviath_core::EntryKind::UserMessage,
2393                "Run two tools".to_string(),
2394                10,
2395            )
2396            .unwrap();
2397
2398        // Assistant with two tool calls
2399        window
2400            .add_typed_entry(
2401                "conv",
2402                leviath_core::EntryKind::AssistantTurn {
2403                    tool_calls: vec![
2404                        leviath_core::SerializedToolCall {
2405                            id: "tc_1".to_string(),
2406                            name: "read_file".to_string(),
2407                            arguments: serde_json::json!({"path": "a.rs"}),
2408                            thought_signature: None,
2409                        },
2410                        leviath_core::SerializedToolCall {
2411                            id: "tc_2".to_string(),
2412                            name: "read_file".to_string(),
2413                            arguments: serde_json::json!({"path": "b.rs"}),
2414                            thought_signature: None,
2415                        },
2416                    ],
2417                },
2418                "".to_string(),
2419                10,
2420            )
2421            .unwrap();
2422
2423        // Two consecutive ToolResults
2424        window
2425            .add_typed_entry(
2426                "conv",
2427                leviath_core::EntryKind::ToolResult {
2428                    tool_call_id: "tc_1".to_string(),
2429                    tool_name: "read_file".to_string(),
2430                    is_error: false,
2431                },
2432                "content of a.rs".to_string(),
2433                10,
2434            )
2435            .unwrap();
2436        window
2437            .add_typed_entry(
2438                "conv",
2439                leviath_core::EntryKind::ToolResult {
2440                    tool_call_id: "tc_2".to_string(),
2441                    tool_name: "read_file".to_string(),
2442                    is_error: false,
2443                },
2444                "content of b.rs".to_string(),
2445                10,
2446            )
2447            .unwrap();
2448
2449        // Then a UserMessage (should flush the pending tool results first)
2450        window
2451            .add_typed_entry(
2452                "conv",
2453                leviath_core::EntryKind::UserMessage,
2454                "Now fix the bug".to_string(),
2455                10,
2456            )
2457            .unwrap();
2458
2459        let assembled = window.assemble();
2460
2461        // Messages should be: user("Run two tools"), assistant(tool_uses),
2462        // user(tool_result x2), user("Now fix the bug")
2463        assert_eq!(assembled.messages.len(), 4);
2464
2465        // The third message should be a user message with two ToolResult blocks
2466        let tool_result_msg = &assembled.messages[2];
2467        assert_eq!(tool_result_msg.role, "user");
2468        // The two consecutive tool results merge into one user message with two
2469        // ToolResult blocks, in order.
2470        assert_eq!(
2471            tool_result_msg.content,
2472            leviath_providers::MessageContent::Blocks(vec![
2473                leviath_providers::ContentBlock::ToolResult {
2474                    tool_use_id: "tc_1".to_string(),
2475                    content: "content of a.rs".to_string(),
2476                    is_error: false,
2477                },
2478                leviath_providers::ContentBlock::ToolResult {
2479                    tool_use_id: "tc_2".to_string(),
2480                    content: "content of b.rs".to_string(),
2481                    is_error: false,
2482                },
2483            ])
2484        );
2485
2486        // The fourth message should be the user follow-up
2487        assert_eq!(assembled.messages[3].role, "user");
2488        assert_eq!(assembled.messages[3].content.as_text(), "Now fix the bug");
2489    }
2490
2491    // ─── assemble() "Begin." fallback ─────────────────────────────────────
2492
2493    #[test]
2494    fn test_assemble_injects_begin_when_no_user_messages() {
2495        let mut window = ContextWindow::new(100_000);
2496        // Only a Pinned region, no SlidingWindow with user messages
2497        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
2498        pinned
2499            .add_entry("You are a helpful assistant.".to_string(), 20)
2500            .unwrap();
2501        window.add_region(pinned);
2502
2503        let assembled = window.assemble();
2504
2505        // Should have injected a "Begin." fallback user message
2506        assert_eq!(assembled.messages.len(), 1);
2507        assert_eq!(assembled.messages[0].role, "user");
2508        assert_eq!(assembled.messages[0].content.as_text(), "Begin.");
2509    }
2510
2511    // ─── add_typed_entry / add_typed_tainted_to_region error paths ────────
2512
2513    #[test]
2514    fn test_add_typed_entry_to_nonexistent_region() {
2515        let mut window = ContextWindow::new(10000);
2516        let result = window.add_typed_entry(
2517            "nonexistent",
2518            leviath_core::EntryKind::UserMessage,
2519            "hello".to_string(),
2520            10,
2521        );
2522        assert!(result.is_err());
2523        let err_str = result.unwrap_err().to_string();
2524        assert!(
2525            err_str.contains("nonexistent"),
2526            "Error should mention the missing region name"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_add_typed_tainted_to_nonexistent_region() {
2532        let mut window = ContextWindow::new(10000);
2533        let result = window.add_typed_tainted_to_region(
2534            "ghost",
2535            leviath_core::EntryKind::UserMessage,
2536            "data".to_string(),
2537            10,
2538            leviath_core::TaintLevel::Public,
2539        );
2540        assert!(result.is_err());
2541        let err_str = result.unwrap_err().to_string();
2542        assert!(
2543            err_str.contains("ghost"),
2544            "Error should mention the missing region name"
2545        );
2546    }
2547
2548    #[test]
2549    fn test_assemble_tool_result_before_any_tool_use() {
2550        // Edge case: tool_result appears in context but no tool_use exists at all
2551        let mut window = ContextWindow::new(100_000);
2552        let region = Region::new(
2553            "conversation".to_string(),
2554            RegionKind::SlidingWindow {
2555                max_items: 100,
2556                eviction_strategy: EvictionStrategy::PerItem,
2557            },
2558            50_000,
2559        );
2560        window.add_region(region);
2561
2562        // A tool_result with no tool_use anywhere
2563        window
2564            .add_typed_entry(
2565                "conversation",
2566                leviath_core::EntryKind::ToolResult {
2567                    tool_call_id: "tc_nowhere".to_string(),
2568                    tool_name: "read_file".to_string(),
2569                    is_error: false,
2570                },
2571                "orphan result".to_string(),
2572                10,
2573            )
2574            .unwrap();
2575
2576        let assembled = with_tracing(|| window.assemble());
2577
2578        // The orphaned tool_result message is stripped to empty and dropped,
2579        // leaving no messages - so the "Begin." user fallback is synthesized.
2580        assert_eq!(assembled.messages.len(), 1);
2581        assert_eq!(assembled.messages[0].role, "user");
2582        assert_eq!(
2583            assembled.messages[0].content,
2584            leviath_providers::MessageContent::Text("Begin.".to_string())
2585        );
2586    }
2587
2588    // ─── Prompt caching tests ────────────────────────────────────────────
2589
2590    #[test]
2591    fn test_assemble_sets_cache_breakpoint_on_stable_prefix() {
2592        let mut window = ContextWindow::new(100_000);
2593        let region = Region::new(
2594            "conv".to_string(),
2595            RegionKind::SlidingWindow {
2596                max_items: 100,
2597                eviction_strategy: EvictionStrategy::PerItem,
2598            },
2599            50_000,
2600        );
2601        window.add_region(region);
2602
2603        // Add 10 alternating user/assistant messages
2604        for i in 0..10 {
2605            let kind = if i % 2 == 0 {
2606                leviath_core::EntryKind::UserMessage
2607            } else {
2608                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] }
2609            };
2610            window
2611                .add_typed_entry("conv", kind, format!("message {i}"), 10)
2612                .unwrap();
2613        }
2614
2615        let assembled = window.assemble();
2616        // 10 alternating messages end on an assistant turn, so assemble appends a
2617        // trailing "Continue." user nudge → 11 messages.
2618        assert_eq!(assembled.messages.len(), 11);
2619        assert_eq!(assembled.messages.last().unwrap().role, "user");
2620
2621        // The breakpoint is placed at the 4th-from-last of the pre-nudge run
2622        // (index 6 of the original 10); the nudge is appended after.
2623        let bp_idx = 6;
2624        for (i, msg) in assembled.messages.iter().enumerate() {
2625            if i == bp_idx {
2626                assert!(
2627                    msg.cache_breakpoint,
2628                    "Message at index {i} should have cache_breakpoint = true"
2629                );
2630            } else {
2631                assert!(
2632                    !msg.cache_breakpoint,
2633                    "Message at index {i} should have cache_breakpoint = false"
2634                );
2635            }
2636        }
2637    }
2638
2639    #[test]
2640    fn test_assemble_cache_breakpoint_small_conversation() {
2641        let mut window = ContextWindow::new(100_000);
2642        let region = Region::new(
2643            "conv".to_string(),
2644            RegionKind::SlidingWindow {
2645                max_items: 100,
2646                eviction_strategy: EvictionStrategy::PerItem,
2647            },
2648            50_000,
2649        );
2650        window.add_region(region);
2651
2652        // Add 3 messages (user, assistant, user)
2653        window
2654            .add_typed_entry(
2655                "conv",
2656                leviath_core::EntryKind::UserMessage,
2657                "Hello".to_string(),
2658                10,
2659            )
2660            .unwrap();
2661        window
2662            .add_typed_entry(
2663                "conv",
2664                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
2665                "Hi there".to_string(),
2666                10,
2667            )
2668            .unwrap();
2669        window
2670            .add_typed_entry(
2671                "conv",
2672                leviath_core::EntryKind::UserMessage,
2673                "How are you?".to_string(),
2674                10,
2675            )
2676            .unwrap();
2677
2678        let assembled = window.assemble();
2679        assert_eq!(assembled.messages.len(), 3);
2680
2681        // With < 5 messages but >= 2, first message gets the breakpoint
2682        assert!(
2683            assembled.messages[0].cache_breakpoint,
2684            "First message should have cache_breakpoint in small conversation"
2685        );
2686        assert!(!assembled.messages[1].cache_breakpoint);
2687        assert!(!assembled.messages[2].cache_breakpoint);
2688    }
2689
2690    #[test]
2691    fn test_assemble_cache_breakpoint_too_few_messages() {
2692        let mut window = ContextWindow::new(100_000);
2693        let region = Region::new(
2694            "conv".to_string(),
2695            RegionKind::SlidingWindow {
2696                max_items: 100,
2697                eviction_strategy: EvictionStrategy::PerItem,
2698            },
2699            50_000,
2700        );
2701        window.add_region(region);
2702
2703        // Add only 1 message
2704        window
2705            .add_typed_entry(
2706                "conv",
2707                leviath_core::EntryKind::UserMessage,
2708                "Solo message".to_string(),
2709                10,
2710            )
2711            .unwrap();
2712
2713        let assembled = window.assemble();
2714        assert_eq!(assembled.messages.len(), 1);
2715
2716        // With only 1 message, no breakpoints should be set
2717        assert!(
2718            !assembled.messages[0].cache_breakpoint,
2719            "Single message should not get a cache breakpoint"
2720        );
2721    }
2722
2723    #[test]
2724    fn test_assemble_system_blocks_sorted_by_cache_stability() {
2725        use leviath_core::CacheHint;
2726
2727        let mut window = ContextWindow::new(100_000);
2728
2729        // Add regions in "wrong" order: volatile first, stable last
2730        let mut clearable = Region::new("scratch".to_string(), RegionKind::Clearable, 10_000);
2731        clearable
2732            .add_entry("clearable data".to_string(), 20)
2733            .unwrap();
2734        window.add_region(clearable);
2735
2736        let mut temporary = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
2737        temporary
2738            .add_entry("temporary data".to_string(), 20)
2739            .unwrap();
2740        window.add_region(temporary);
2741
2742        let mut compacting = Region::new(
2743            "impl".to_string(),
2744            RegionKind::Compacting {
2745                threshold_tokens: 500,
2746            },
2747            10_000,
2748        );
2749        compacting
2750            .add_entry("compacting data".to_string(), 20)
2751            .unwrap();
2752        window.add_region(compacting);
2753
2754        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
2755        pinned
2756            .add_entry("pinned system prompt".to_string(), 20)
2757            .unwrap();
2758        window.add_region(pinned);
2759
2760        let assembled = window.assemble();
2761
2762        assert_eq!(assembled.system_blocks.len(), 4);
2763
2764        // Verify ordering: Always (Pinned) first, UntilChanged (Compacting) second,
2765        // Never (Temporary, Clearable) last
2766        assert_eq!(
2767            assembled.system_blocks[0].cache_hint,
2768            CacheHint::Always,
2769            "First system block should be Always (Pinned)"
2770        );
2771        assert_eq!(
2772            assembled.system_blocks[1].cache_hint,
2773            CacheHint::UntilChanged,
2774            "Second system block should be UntilChanged (Compacting)"
2775        );
2776        assert_eq!(
2777            assembled.system_blocks[2].cache_hint,
2778            CacheHint::Never,
2779            "Third system block should be Never"
2780        );
2781        assert_eq!(
2782            assembled.system_blocks[3].cache_hint,
2783            CacheHint::Never,
2784            "Fourth system block should be Never"
2785        );
2786    }
2787
2788    // ─── Coverage for ContextWindow typed+tainted methods ─────────────────
2789
2790    #[test]
2791    fn test_add_typed_tainted_to_region_success() {
2792        let mut window = ContextWindow::new(10000);
2793        let mut region = Region::new(
2794            "conv".to_string(),
2795            RegionKind::SlidingWindow {
2796                max_items: 50,
2797                eviction_strategy: EvictionStrategy::PerItem,
2798            },
2799            5000,
2800        );
2801        region.enable_taint_tracking();
2802        window.add_region(region);
2803
2804        window
2805            .add_typed_tainted_to_region(
2806                "conv",
2807                leviath_core::EntryKind::ToolResult {
2808                    tool_call_id: "tc_1".to_string(),
2809                    tool_name: "read_file".to_string(),
2810                    is_error: false,
2811                },
2812                "secret data".to_string(),
2813                100,
2814                leviath_core::TaintLevel::Private,
2815            )
2816            .unwrap();
2817
2818        assert_eq!(window.current_tokens, 100);
2819        assert_eq!(
2820            window.get_region("conv").and_then(|r| r.taint_level()),
2821            Some(leviath_core::TaintLevel::Private)
2822        );
2823    }
2824
2825    #[test]
2826    fn test_add_typed_tainted_to_region_not_found() {
2827        let mut window = ContextWindow::new(10000);
2828        let result = window.add_typed_tainted_to_region(
2829            "nonexistent",
2830            leviath_core::EntryKind::Text,
2831            "data".to_string(),
2832            10,
2833            leviath_core::TaintLevel::Public,
2834        );
2835        assert!(result.is_err());
2836    }
2837
2838    #[test]
2839    fn test_assemble_consecutive_tool_results_flushed_at_end() {
2840        // Tool results at the END of the region (not followed by a non-ToolResult)
2841        // should still be flushed into a user message.
2842        let mut window = ContextWindow::new(100_000);
2843        let region = Region::new(
2844            "conv".to_string(),
2845            RegionKind::SlidingWindow {
2846                max_items: 100,
2847                eviction_strategy: EvictionStrategy::PerItem,
2848            },
2849            50_000,
2850        );
2851        window.add_region(region);
2852
2853        // Add user message, then assistant with tool calls, then tool results at end
2854        window
2855            .add_typed_entry(
2856                "conv",
2857                leviath_core::EntryKind::UserMessage,
2858                "do something".to_string(),
2859                10,
2860            )
2861            .unwrap();
2862        window
2863            .add_typed_entry(
2864                "conv",
2865                leviath_core::EntryKind::AssistantTurn {
2866                    tool_calls: vec![leviath_core::SerializedToolCall {
2867                        id: "tc_1".to_string(),
2868                        name: "read_file".to_string(),
2869                        arguments: serde_json::json!({"path": "foo.rs"}),
2870                        thought_signature: None,
2871                    }],
2872                },
2873                "Let me read that".to_string(),
2874                10,
2875            )
2876            .unwrap();
2877        window
2878            .add_typed_entry(
2879                "conv",
2880                leviath_core::EntryKind::ToolResult {
2881                    tool_call_id: "tc_1".to_string(),
2882                    tool_name: "read_file".to_string(),
2883                    is_error: false,
2884                },
2885                "fn main() {}".to_string(),
2886                10,
2887            )
2888            .unwrap();
2889
2890        let assembled = window.assemble();
2891        // user msg + assistant (with tool_use blocks) + user (with tool_result blocks)
2892        assert_eq!(assembled.messages.len(), 3);
2893        assert_eq!(assembled.messages[2].role, "user");
2894        // The last message is a Blocks message carrying the single ToolResult.
2895        assert_eq!(
2896            assembled.messages[2].content,
2897            leviath_providers::MessageContent::Blocks(vec![
2898                leviath_providers::ContentBlock::ToolResult {
2899                    tool_use_id: "tc_1".to_string(),
2900                    content: "fn main() {}".to_string(),
2901                    is_error: false,
2902                },
2903            ])
2904        );
2905    }
2906
2907    #[test]
2908    fn test_assemble_compact_history_with_sliding_prefix_sorting() {
2909        // CompactHistory should sort before Compacting/Temporary in system blocks
2910        use leviath_core::CacheHint;
2911
2912        let mut window = ContextWindow::new(100_000);
2913
2914        let mut temp = Region::new("temp".to_string(), RegionKind::Temporary, 10_000);
2915        temp.add_entry("temp data".to_string(), 10).unwrap();
2916        window.add_region(temp);
2917
2918        let mut history = Region::new(
2919            "history".to_string(),
2920            RegionKind::CompactHistory {
2921                source_region: "impl".to_string(),
2922            },
2923            10_000,
2924        );
2925        history.add_entry("summary data".to_string(), 10).unwrap();
2926        window.add_region(history);
2927
2928        let assembled = window.assemble();
2929        assert_eq!(assembled.system_blocks.len(), 2);
2930        // CompactHistory (Always) should come before Temporary (Never)
2931        assert_eq!(assembled.system_blocks[0].cache_hint, CacheHint::Always);
2932        assert_eq!(assembled.system_blocks[1].cache_hint, CacheHint::Never);
2933    }
2934
2935    #[test]
2936    fn cache_hint_sort_priority_orders_by_stability() {
2937        use leviath_core::CacheHint;
2938        // Most stable first (lowest priority), volatile last.
2939        assert_eq!(cache_hint_sort_priority(CacheHint::Always), 0);
2940        assert_eq!(
2941            cache_hint_sort_priority(CacheHint::SlidingPrefix {
2942                stable_fraction: 0.75
2943            }),
2944            1
2945        );
2946        assert_eq!(cache_hint_sort_priority(CacheHint::UntilChanged), 2);
2947        assert_eq!(cache_hint_sort_priority(CacheHint::Never), 3);
2948        // The four priorities are strictly increasing by volatility.
2949        assert!(
2950            cache_hint_sort_priority(CacheHint::Always)
2951                < cache_hint_sort_priority(CacheHint::SlidingPrefix {
2952                    stable_fraction: 0.5
2953                })
2954        );
2955    }
2956
2957    #[test]
2958    fn test_assemble_empty_regions_skipped() {
2959        let mut window = ContextWindow::new(100_000);
2960        window.add_region(Region::new(
2961            "system".to_string(),
2962            RegionKind::Pinned,
2963            10_000,
2964        ));
2965        // Empty pinned region should be skipped
2966        let assembled = window.assemble();
2967        assert!(assembled.system_blocks.is_empty());
2968    }
2969
2970    #[test]
2971    fn test_assemble_hashmap_region_with_keys() {
2972        let mut window = ContextWindow::new(100_000);
2973        let mut region = Region::new(
2974            "files".to_string(),
2975            RegionKind::HashMap { max_entries: None },
2976            10_000,
2977        );
2978        region
2979            .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2980            .unwrap();
2981        region
2982            .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2983            .unwrap();
2984        window.add_region(region);
2985
2986        let assembled = window.assemble();
2987        assert_eq!(assembled.system_blocks.len(), 1);
2988        let block_text = &assembled.system_blocks[0].text;
2989        assert!(block_text.contains("[files]:"));
2990        assert!(block_text.contains("### [src/main.rs]"));
2991        assert!(block_text.contains("fn main() {}"));
2992        assert!(block_text.contains("### [src/lib.rs]"));
2993        assert!(block_text.contains("pub mod foo;"));
2994    }
2995
2996    #[test]
2997    fn test_assemble_hashmap_region_cache_hint() {
2998        let mut window = ContextWindow::new(100_000);
2999        let mut region = Region::new(
3000            "files".to_string(),
3001            RegionKind::HashMap { max_entries: None },
3002            10_000,
3003        );
3004        region
3005            .upsert_by_key("a.rs", "content".to_string(), 5)
3006            .unwrap();
3007        window.add_region(region);
3008
3009        let assembled = window.assemble();
3010        assert_eq!(assembled.system_blocks.len(), 1);
3011        assert_eq!(
3012            assembled.system_blocks[0].cache_hint,
3013            leviath_core::CacheHint::UntilChanged
3014        );
3015    }
3016
3017    // ─── HashMap region assembly tests ──────────────────────────────────
3018
3019    #[test]
3020    fn test_assemble_hashmap_single_keyed_entry() {
3021        let mut window = ContextWindow::new(100_000);
3022        let mut region = Region::new(
3023            "context".to_string(),
3024            RegionKind::HashMap { max_entries: None },
3025            10_000,
3026        );
3027        region
3028            .upsert_by_key("config.toml", "key = \"value\"".to_string(), 10)
3029            .unwrap();
3030        window.add_region(region);
3031
3032        let assembled = window.assemble();
3033
3034        assert_eq!(assembled.system_blocks.len(), 1);
3035        let block_text = &assembled.system_blocks[0].text;
3036        assert!(
3037            block_text.starts_with("[context]:"),
3038            "System block should start with [region_name]: prefix"
3039        );
3040        assert!(
3041            block_text.contains("### [config.toml]"),
3042            "Entry should have ### [key] header"
3043        );
3044        assert!(
3045            block_text.contains("key = \"value\""),
3046            "Entry content should be present"
3047        );
3048    }
3049
3050    #[test]
3051    fn test_assemble_hashmap_multiple_keyed_entries() {
3052        let mut window = ContextWindow::new(100_000);
3053        let mut region = Region::new(
3054            "tracked_files".to_string(),
3055            RegionKind::HashMap { max_entries: None },
3056            10_000,
3057        );
3058        region
3059            .upsert_by_key("alpha.rs", "fn alpha() {}".to_string(), 10)
3060            .unwrap();
3061        region
3062            .upsert_by_key("beta.rs", "fn beta() {}".to_string(), 10)
3063            .unwrap();
3064        region
3065            .upsert_by_key("gamma.rs", "fn gamma() {}".to_string(), 10)
3066            .unwrap();
3067        window.add_region(region);
3068
3069        let assembled = window.assemble();
3070
3071        assert_eq!(assembled.system_blocks.len(), 1);
3072        let block_text = &assembled.system_blocks[0].text;
3073        assert!(block_text.starts_with("[tracked_files]:"));
3074        assert!(block_text.contains("### [alpha.rs]"));
3075        assert!(block_text.contains("fn alpha() {}"));
3076        assert!(block_text.contains("### [beta.rs]"));
3077        assert!(block_text.contains("fn beta() {}"));
3078        assert!(block_text.contains("### [gamma.rs]"));
3079        assert!(block_text.contains("fn gamma() {}"));
3080    }
3081
3082    #[test]
3083    fn test_assemble_hashmap_empty_region_skipped() {
3084        let mut window = ContextWindow::new(100_000);
3085        let region = Region::new(
3086            "empty_map".to_string(),
3087            RegionKind::HashMap { max_entries: None },
3088            10_000,
3089        );
3090        // No entries added
3091        window.add_region(region);
3092
3093        let assembled = window.assemble();
3094
3095        assert!(
3096            assembled.system_blocks.is_empty(),
3097            "Empty HashMap region should not produce a system block"
3098        );
3099    }
3100
3101    #[test]
3102    fn test_assemble_mixed_pinned_hashmap_sliding_window() {
3103        use leviath_core::CacheHint;
3104
3105        let mut window = ContextWindow::new(100_000);
3106
3107        // Pinned region
3108        let mut pinned = Region::new("system".to_string(), RegionKind::Pinned, 10_000);
3109        pinned
3110            .add_entry("You are a helpful assistant.".to_string(), 20)
3111            .unwrap();
3112        window.add_region(pinned);
3113
3114        // HashMap region
3115        let mut hashmap = Region::new(
3116            "files".to_string(),
3117            RegionKind::HashMap { max_entries: None },
3118            10_000,
3119        );
3120        hashmap
3121            .upsert_by_key("main.rs", "fn main() {}".to_string(), 10)
3122            .unwrap();
3123        window.add_region(hashmap);
3124
3125        // SlidingWindow region with user messages
3126        let mut sliding = Region::new(
3127            "conv".to_string(),
3128            RegionKind::SlidingWindow {
3129                max_items: 100,
3130                eviction_strategy: EvictionStrategy::PerItem,
3131            },
3132            50_000,
3133        );
3134        sliding
3135            .add_typed_entry(
3136                "Hello there".to_string(),
3137                10,
3138                leviath_core::EntryKind::UserMessage,
3139            )
3140            .unwrap();
3141        window.add_region(sliding);
3142
3143        let assembled = window.assemble();
3144
3145        // Pinned and HashMap should produce system blocks (2 total)
3146        assert_eq!(assembled.system_blocks.len(), 2);
3147
3148        // System blocks sorted by cache hint: Pinned (Always) first, HashMap (UntilChanged) second
3149        assert_eq!(
3150            assembled.system_blocks[0].cache_hint,
3151            CacheHint::Always,
3152            "Pinned region should sort first (Always cache hint)"
3153        );
3154        assert!(
3155            assembled.system_blocks[0]
3156                .text
3157                .contains("You are a helpful assistant."),
3158            "First system block should be the pinned content"
3159        );
3160
3161        assert_eq!(
3162            assembled.system_blocks[1].cache_hint,
3163            CacheHint::UntilChanged,
3164            "HashMap region should sort second (UntilChanged cache hint)"
3165        );
3166        assert!(
3167            assembled.system_blocks[1].text.starts_with("[files]:"),
3168            "HashMap system block should have [region_name]: prefix"
3169        );
3170        assert!(
3171            assembled.system_blocks[1].text.contains("### [main.rs]"),
3172            "HashMap system block should contain ### [key] header"
3173        );
3174
3175        // SlidingWindow should produce messages, not system blocks
3176        assert!(
3177            assembled
3178                .messages
3179                .iter()
3180                .any(|m| m.role == "user" && m.content.as_text().contains("Hello there")),
3181            "SlidingWindow entries should appear as messages"
3182        );
3183    }
3184
3185    #[test]
3186    fn test_add_tainted_to_region_propagates_budget_error() {
3187        // Region is found, but the entry exceeds its token budget, so the
3188        // inner `add_tainted_entry` error must propagate through the `?`.
3189        let mut window = ContextWindow::new(10_000);
3190        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
3191        region.enable_taint_tracking();
3192        window.add_region(region);
3193
3194        let result = window.add_tainted_to_region(
3195            "conv",
3196            "far too many tokens".to_string(),
3197            100,
3198            leviath_core::TaintLevel::Private,
3199        );
3200        assert!(result.is_err());
3201    }
3202
3203    #[test]
3204    fn test_add_typed_tainted_to_region_propagates_budget_error() {
3205        // Region is found, but the entry exceeds its token budget, so the
3206        // inner `add_typed_tainted_entry` error must propagate through the `?`.
3207        let mut window = ContextWindow::new(10_000);
3208        let region = Region::new("conv".to_string(), RegionKind::Temporary, 10);
3209        window.add_region(region);
3210
3211        let result = window.add_typed_tainted_to_region(
3212            "conv",
3213            leviath_core::EntryKind::Text,
3214            "far too many tokens".to_string(),
3215            100,
3216            leviath_core::TaintLevel::Public,
3217        );
3218        assert!(result.is_err());
3219    }
3220
3221    #[test]
3222    fn test_assemble_hashmap_region_entry_without_key() {
3223        // A HashMap-region entry with no key falls back to its raw content
3224        // (rather than a "### [key]" header) when assembled.
3225        let mut window = ContextWindow::new(10_000);
3226        let region = Region::new(
3227            "kv".to_string(),
3228            RegionKind::HashMap { max_entries: None },
3229            5000,
3230        );
3231        window.add_region(region);
3232        // add_to_region stores the entry with key: None.
3233        window
3234            .add_to_region("kv", "keyless content".to_string(), 10)
3235            .unwrap();
3236
3237        let assembled = window.assemble();
3238        assert!(
3239            assembled
3240                .system_blocks
3241                .iter()
3242                .any(|b| b.text.contains("keyless content")),
3243            "keyless HashMap entry should appear verbatim in a system block"
3244        );
3245    }
3246
3247    // ─── Status and wait-reason labels (issue #184) ──────────────────────────
3248
3249    /// `label` is a wire contract: the `WorldEvent` stream and the REST
3250    /// WebSocket forward these words verbatim, so pinning them here is what
3251    /// stops a rename from silently breaking an API consumer.
3252    #[test]
3253    fn status_labels_are_fixed() {
3254        assert_eq!(AgentStatus::Idle.label(), "idle");
3255        assert_eq!(AgentStatus::Active.label(), "active");
3256        assert_eq!(AgentStatus::Waiting.label(), "waiting");
3257        assert_eq!(AgentStatus::Paused.label(), "paused");
3258        assert_eq!(AgentStatus::Complete.label(), "complete");
3259        assert_eq!(AgentStatus::Cancelled.label(), "cancelled");
3260        assert_eq!(
3261            AgentStatus::Error {
3262                message: "boom".to_string()
3263            }
3264            .label(),
3265            "error"
3266        );
3267    }
3268
3269    /// `Display` matches `label` except for an error, which carries its message
3270    /// - that is the difference between "a child failed" and knowing why.
3271    #[test]
3272    fn display_matches_label_except_for_an_error() {
3273        for status in [
3274            AgentStatus::Idle,
3275            AgentStatus::Active,
3276            AgentStatus::Waiting,
3277            AgentStatus::Paused,
3278            AgentStatus::Complete,
3279            AgentStatus::Cancelled,
3280        ] {
3281            assert_eq!(status.to_string(), status.label());
3282        }
3283        assert_eq!(
3284            AgentStatus::Error {
3285                message: "disk full".to_string()
3286            }
3287            .to_string(),
3288            "error: disk full"
3289        );
3290    }
3291
3292    #[test]
3293    fn wait_reasons_read_as_short_phrases() {
3294        assert_eq!(WaitReason::ToolApproval.to_string(), "tool approval");
3295        assert_eq!(WaitReason::UserPrompt.to_string(), "user prompt");
3296        assert_eq!(WaitReason::TaintGate.to_string(), "taint gate");
3297        assert_eq!(WaitReason::InteractionPoint.to_string(), "checkpoint");
3298        assert_eq!(
3299            WaitReason::FanOutWorkers { outstanding: 4 }.to_string(),
3300            "workers(4)"
3301        );
3302        assert_eq!(
3303            WaitReason::Children { outstanding: 1 }.to_string(),
3304            "children(1)"
3305        );
3306    }
3307
3308    /// The split the whole issue turns on: which of these an operator has to do
3309    /// something about.
3310    #[test]
3311    fn only_prompts_need_a_person() {
3312        for reason in [
3313            WaitReason::ToolApproval,
3314            WaitReason::UserPrompt,
3315            WaitReason::TaintGate,
3316            WaitReason::InteractionPoint,
3317        ] {
3318            assert!(reason.needs_a_person(), "{reason} is blocked on someone");
3319        }
3320        for reason in [
3321            WaitReason::FanOutWorkers { outstanding: 2 },
3322            WaitReason::Children { outstanding: 2 },
3323        ] {
3324            assert!(!reason.needs_a_person(), "{reason} resolves on its own");
3325        }
3326    }
3327}
3328
3329#[cfg(test)]
3330mod stage_hook_scripts_tests {
3331    use super::*;
3332
3333    fn scripts(path: &str) -> StageHookScripts {
3334        let compiled = leviath_scripting::stage_hook::compile(
3335            path,
3336            "fn on_stage_enter(ctx) { () } fn on_stage_exit(ctx) { () }",
3337            &[],
3338        )
3339        .expect("compiles");
3340        let mut m = std::collections::HashMap::new();
3341        m.insert(path.to_string(), std::sync::Arc::new(compiled));
3342        StageHookScripts(m)
3343    }
3344
3345    fn stage_declaring(enter: Option<&str>, exit: Option<&str>) -> leviath_core::Stage {
3346        let mut s = leviath_core::Stage::new(
3347            "main".to_string(),
3348            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
3349        );
3350        s.hooks.on_stage_enter = enter.map(str::to_string);
3351        s.hooks.on_stage_exit = exit.map(str::to_string);
3352        s
3353    }
3354
3355    #[test]
3356    fn each_hook_resolves_to_the_file_its_stage_named() {
3357        let s = scripts("h.rhai");
3358        let stage = stage_declaring(Some("h.rhai"), Some("h.rhai"));
3359        assert!(s.script_for(&stage, "on_stage_enter").is_some());
3360        assert!(s.script_for(&stage, "on_stage_exit").is_some());
3361    }
3362
3363    #[test]
3364    fn a_hook_the_stage_did_not_declare_resolves_to_nothing() {
3365        let s = scripts("h.rhai");
3366        let stage = stage_declaring(Some("h.rhai"), None);
3367        assert!(s.script_for(&stage, "on_stage_exit").is_none());
3368    }
3369
3370    /// A hook name this build does not implement resolves to nothing rather
3371    /// than panicking - the caller asks by string.
3372    #[test]
3373    fn an_unknown_hook_name_resolves_to_nothing() {
3374        let s = scripts("h.rhai");
3375        let stage = stage_declaring(Some("h.rhai"), None);
3376        assert!(s.script_for(&stage, "on_nothing").is_none());
3377    }
3378
3379    /// Declared but not on file: spawn already refused that, so a miss here
3380    /// means the stage simply has no such hook.
3381    #[test]
3382    fn a_declared_path_with_no_compiled_script_resolves_to_nothing() {
3383        let s = scripts("other.rhai");
3384        let stage = stage_declaring(Some("h.rhai"), None);
3385        assert!(s.script_for(&stage, "on_stage_enter").is_none());
3386    }
3387}