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