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