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