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