1use bevy_ecs::prelude::*;
11use leviath_core::RegionKind;
12use leviath_core::run_meta::{
13 ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus, StageRunStatus,
14};
15
16use crate::components::{AgentState, AgentStatus, ContextWindow};
17
18#[derive(Component, Clone)]
22pub struct RunMetadata {
23 pub run_id: String,
25 pub agent_name: String,
27 pub agent_path: String,
29 pub task: String,
31 pub model: Option<String>,
33 pub workdir: String,
35 pub num_stages: usize,
37 pub started_at: i64,
39 pub parent_run_id: Option<String>,
41 pub metadata: std::collections::HashMap<String, String>,
43 pub callback_url: Option<String>,
45 pub callback_secret: Option<String>,
47 pub title: Option<String>,
49 pub unattended: bool,
58 pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
64 pub output_request: Option<leviath_core::output::OutputSpec>,
68}
69
70#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
73pub struct TokenTotals {
74 pub prompt_tokens: usize,
76 pub completion_tokens: usize,
78 pub cached_tokens: usize,
80 pub cache_write_tokens: usize,
82 pub tool_calls: usize,
84}
85
86#[derive(Component, Clone, Default, Debug, PartialEq)]
92pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
93
94impl RunOutcomeFlags {
95 pub fn for_blueprint(bp: &leviath_core::Blueprint) -> Self {
107 Self(leviath_core::run_meta::RunFlags {
108 no_output_tools: !bp.stages.iter().any(stage_can_modify),
109 ..Default::default()
110 })
111 }
112}
113
114#[derive(Component, Clone, Debug, PartialEq)]
122pub struct FinalOutput(pub leviath_core::output::FinalOutput);
123
124fn stage_can_modify(stage: &leviath_core::Stage) -> bool {
139 stage.available_tools.iter().any(|t| {
140 let canonical = leviath_tools::canonical_tool_name(t);
141 leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
142 || stage
143 .transitions
144 .iter()
145 .flat_map(|edges| edges.values())
146 .filter_map(|edge| edge.gate.as_ref())
147 .any(|gate| {
148 gate.tools
149 .iter()
150 .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
151 })
152 })
153}
154
155impl TokenTotals {
156 pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
158 self.prompt_tokens += usage.prompt_tokens;
159 self.completion_tokens += usage.completion_tokens;
160 self.cached_tokens += usage.cached_tokens;
161 self.cache_write_tokens += usage.cache_write_tokens;
162 }
163}
164
165pub fn is_empty_output(status: &AgentStatus, flags: &leviath_core::run_meta::RunFlags) -> bool {
179 matches!(
180 run_status_from(status),
181 RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
182 ) && flags.modified_file_count == 0
183 && !flags.produced_output
184 && !flags.no_output_tools
185}
186
187pub fn run_status_from(status: &AgentStatus) -> RunStatus {
189 match status {
190 AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
191 AgentStatus::Paused => RunStatus::Paused,
192 AgentStatus::Waiting => RunStatus::WaitingInput,
193 AgentStatus::Complete => RunStatus::Complete,
194 AgentStatus::Error { .. } => RunStatus::Error,
195 AgentStatus::Cancelled => RunStatus::Cancelled,
196 }
197}
198
199pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
203 match status {
204 AgentStatus::Idle | AgentStatus::Active | AgentStatus::Paused => StageRunStatus::Active,
206 AgentStatus::Waiting => StageRunStatus::WaitingInput,
207 AgentStatus::Complete => StageRunStatus::Complete,
208 AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
209 }
210}
211
212fn region_kind_str(kind: &RegionKind) -> &'static str {
214 match kind {
215 RegionKind::Pinned => "pinned",
216 RegionKind::Temporary => "temporary",
217 RegionKind::Clearable => "clearable",
218 RegionKind::SlidingWindow { .. } => "sliding",
219 RegionKind::Compacting { .. } => "compacting",
220 RegionKind::CompactHistory { .. } => "history",
221 RegionKind::HashMap { .. } => "hashmap",
222 RegionKind::Checklist => "checklist",
223 RegionKind::Custom { .. } => "custom",
224 }
225}
226
227pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
230 let regions = window
231 .regions
232 .iter()
233 .map(|r| RegionSnapshot {
234 name: r.name.clone(),
235 kind: region_kind_str(&r.kind).to_string(),
236 current_tokens: r.current_tokens,
237 max_tokens: r.max_tokens,
238 entries: r
239 .content
240 .iter()
241 .enumerate()
242 .map(|(i, e)| RegionEntrySnapshot {
243 content: e.content.clone(),
244 tokens: e.tokens,
245 kind: e.kind.clone(),
246 metadata: e.metadata.clone(),
247 key: e.key.clone(),
248 taint: r
252 .taint
253 .as_ref()
254 .and_then(|t| t.entry_taint(i))
255 .unwrap_or_default(),
256 })
257 .collect(),
258 })
259 .collect();
260 ContextSnapshot {
261 stage_name: stage_name.to_string(),
262 total_tokens: window.current_tokens,
263 max_tokens: window.max_tokens,
264 regions,
265 }
266}
267
268pub struct RunMetaSources<'a> {
274 pub md: &'a RunMetadata,
276 pub state: &'a AgentState,
278 pub totals: &'a TokenTotals,
280 pub flags: &'a RunOutcomeFlags,
282 pub final_output: Option<&'a FinalOutput>,
284}
285
286pub struct RunPosition {
288 pub stage_index: usize,
290 pub now_secs: i64,
292 pub last_progress_at: Option<i64>,
294 pub depth: usize,
296 pub max_child_depth: usize,
298}
299
300pub fn build_run_meta(sources: RunMetaSources<'_>, at: RunPosition) -> RunMeta {
311 let RunMetaSources {
312 md,
313 state,
314 totals,
315 flags,
316 final_output,
317 } = sources;
318 let RunPosition {
319 stage_index,
320 now_secs,
321 last_progress_at,
322 depth,
323 max_child_depth,
324 } = at;
325 let status = run_status_from(&state.status);
326 let mut flags = flags.0.clone();
327 flags.produced_output = final_output.is_some();
330 flags.empty_output = is_empty_output(&state.status, &flags);
331 RunMeta {
332 run_id: md.run_id.clone(),
333 agent_name: md.agent_name.clone(),
334 agent_path: md.agent_path.clone(),
335 task: md.task.clone(),
336 model: md.model.clone(),
337 pid: 0, status,
339 current_stage: state.current_stage.clone(),
340 stage_index,
341 num_stages: md.num_stages,
342 iteration: state.iteration,
343 prompt_tokens: totals.prompt_tokens,
344 completion_tokens: totals.completion_tokens,
345 cached_tokens: totals.cached_tokens,
346 cache_write_tokens: totals.cache_write_tokens,
347 tool_calls: totals.tool_calls,
348 workdir: md.workdir.clone(),
349 started_at: md.started_at,
350 updated_at: now_secs,
351 last_progress_at,
352 error: match &state.status {
353 AgentStatus::Error { message } => Some(message.clone()),
354 _ => None,
355 },
356 title: md.title.clone(),
357 metadata: md.metadata.clone(),
358 callback_url: md.callback_url.clone(),
359 callback_secret: md.callback_secret.clone(),
360 parent_run_id: md.parent_run_id.clone(),
361 children: state.spawned_children_ids.clone(),
363 depth,
364 max_child_depth,
365 flags,
366 yolo: md.unattended,
367 read_paths: md.read_paths,
368 final_output: final_output.map(|o| o.0.descriptor()),
369 output_request: md.output_request.clone(),
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use leviath_core::Region;
377 use leviath_providers::TokenUsage;
378
379 fn state(status: AgentStatus) -> AgentState {
380 AgentState {
381 agent_id: "a".to_string(),
382 current_stage: "plan".to_string(),
383 iteration: 4,
384 status,
385 spawned_children_ids: vec![],
386 pending_wait: None,
387 accepts_messages: true,
388 }
389 }
390
391 fn metadata() -> RunMetadata {
392 RunMetadata {
393 run_id: "run-1".to_string(),
394 agent_name: "coder".to_string(),
395 agent_path: "/agents/coder".to_string(),
396 task: "do it".to_string(),
397 model: Some("anthropic/claude".to_string()),
398 workdir: "/work".to_string(),
399 num_stages: 3,
400 started_at: 1000,
401 parent_run_id: Some("parent".to_string()),
402 metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
403 callback_url: Some("http://cb".to_string()),
404 callback_secret: Some("sekret".to_string()),
405 title: Some("Do It".to_string()),
406 unattended: false,
407 read_paths: None,
408 output_request: None,
409 }
410 }
411
412 fn stage_with(tools: &[&str], gate_tools: Option<&[&str]>) -> leviath_core::Stage {
416 let mut stage = leviath_core::Stage::new(
417 "s".to_string(),
418 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
419 );
420 stage.available_tools = tools.iter().map(|t| (*t).to_string()).collect();
421 stage.transitions = gate_tools.map(|extra| {
422 let gate = (!extra.is_empty()).then(|| leviath_core::blueprint::TransitionGate {
423 require_modifications: true,
424 tools: extra.iter().map(|t| (*t).to_string()).collect(),
425 ..Default::default()
426 });
427 std::collections::HashMap::from([(
428 "next".to_string(),
429 leviath_core::blueprint::TransitionEdge {
430 target: "next".to_string(),
431 condition: leviath_core::blueprint::TransitionCondition::Always,
432 hint: None,
433 transform: leviath_core::blueprint::EdgeTransform::Direct,
434 gate,
435 stuck: None,
436 },
437 )])
438 });
439 stage
440 }
441
442 fn blueprint_of(stages: Vec<leviath_core::Stage>) -> leviath_core::Blueprint {
443 leviath_core::Blueprint::new(
444 "bp".to_string(),
445 "d".to_string(),
446 stages,
447 leviath_core::ContextLayout::new(vec![], 1000),
448 )
449 }
450
451 fn no_output_tools(stages: Vec<leviath_core::Stage>) -> bool {
452 RunOutcomeFlags::for_blueprint(&blueprint_of(stages))
453 .0
454 .no_output_tools
455 }
456
457 #[test]
458 fn for_blueprint_asks_whether_any_stage_could_have_written() {
459 assert!(no_output_tools(vec![]));
461 assert!(no_output_tools(vec![stage_with(
464 &["read_file", "spawn_agent", "context_write"],
465 None
466 )]));
467 assert!(no_output_tools(vec![stage_with(&["bash"], None)]));
471 assert!(!no_output_tools(vec![stage_with(&["write_file"], None)]));
473 assert!(!no_output_tools(vec![stage_with(&["edit_file"], None)]));
474 assert!(!no_output_tools(vec![
476 stage_with(&["read_file"], None),
477 stage_with(&["write_file"], None),
478 ]));
479 }
480
481 #[test]
482 fn for_blueprint_honors_a_gate_declaring_its_own_write_tool() {
483 assert!(!no_output_tools(vec![stage_with(
486 &["mcp__fs__put"],
487 Some(&["mcp__fs__put"])
488 )]));
489 assert!(no_output_tools(vec![stage_with(
491 &["read_file"],
492 Some(&["mcp__fs__put"])
493 )]));
494 assert!(no_output_tools(vec![stage_with(&["read_file"], Some(&[]))]));
496 assert!(no_output_tools(vec![stage_with(
498 &["mcp__fs__put"],
499 Some(&["mcp__other__put"])
500 )]));
501 }
502
503 #[test]
504 fn is_empty_output_needs_a_stopped_run_that_could_have_written() {
505 let nothing = leviath_core::run_meta::RunFlags::default();
506 assert!(!is_empty_output(&AgentStatus::Active, ¬hing));
508 assert!(!is_empty_output(&AgentStatus::Idle, ¬hing));
509 assert!(!is_empty_output(&AgentStatus::Paused, ¬hing));
510 assert!(!is_empty_output(&AgentStatus::Waiting, ¬hing));
511 for status in [
513 AgentStatus::Complete,
514 AgentStatus::Cancelled,
515 AgentStatus::Error {
516 message: "x".to_string(),
517 },
518 ] {
519 assert!(is_empty_output(&status, ¬hing));
520 }
521 let mut wrote = leviath_core::run_meta::RunFlags::default();
523 wrote.record_modification("src/a.rs");
524 assert!(!is_empty_output(&AgentStatus::Complete, &wrote));
525 let incapable = leviath_core::run_meta::RunFlags {
527 no_output_tools: true,
528 ..Default::default()
529 };
530 assert!(!is_empty_output(&AgentStatus::Complete, &incapable));
531 }
532
533 #[test]
534 fn status_mapping_covers_all_variants() {
535 assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
536 assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
537 assert_eq!(run_status_from(&AgentStatus::Paused), RunStatus::Paused);
538 assert_eq!(
539 run_status_from(&AgentStatus::Waiting),
540 RunStatus::WaitingInput
541 );
542 assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
543 assert_eq!(
544 run_status_from(&AgentStatus::Error {
545 message: "x".to_string()
546 }),
547 RunStatus::Error
548 );
549 assert_eq!(
550 run_status_from(&AgentStatus::Cancelled),
551 RunStatus::Cancelled
552 );
553 }
554
555 #[test]
556 fn stage_status_mapping_covers_all_variants() {
557 use leviath_core::run_meta::StageRunStatus;
558 assert_eq!(
559 stage_status_from(&AgentStatus::Idle),
560 StageRunStatus::Active
561 );
562 assert_eq!(
563 stage_status_from(&AgentStatus::Active),
564 StageRunStatus::Active
565 );
566 assert_eq!(
567 stage_status_from(&AgentStatus::Paused),
568 StageRunStatus::Active
569 );
570 assert_eq!(
571 stage_status_from(&AgentStatus::Waiting),
572 StageRunStatus::WaitingInput
573 );
574 assert_eq!(
575 stage_status_from(&AgentStatus::Complete),
576 StageRunStatus::Complete
577 );
578 assert_eq!(
579 stage_status_from(&AgentStatus::Error {
580 message: "x".to_string()
581 }),
582 StageRunStatus::Error
583 );
584 assert_eq!(
585 stage_status_from(&AgentStatus::Cancelled),
586 StageRunStatus::Error
587 );
588 }
589
590 #[test]
591 fn token_totals_accumulate() {
592 let mut t = TokenTotals::default();
593 t.add_usage(&TokenUsage {
594 prompt_tokens: 10,
595 completion_tokens: 5,
596 total_tokens: 15,
597 cached_tokens: 2,
598 cache_write_tokens: 1,
599 });
600 t.add_usage(&TokenUsage {
601 prompt_tokens: 3,
602 completion_tokens: 4,
603 total_tokens: 7,
604 cached_tokens: 0,
605 cache_write_tokens: 0,
606 });
607 t.tool_calls = 6;
608 assert_eq!(t.prompt_tokens, 13);
609 assert_eq!(t.completion_tokens, 9);
610 assert_eq!(t.cached_tokens, 2);
611 assert_eq!(t.cache_write_tokens, 1);
612 }
613
614 #[test]
615 fn build_run_meta_fills_dynamic_and_static_fields() {
616 let md = metadata();
617 let totals = TokenTotals {
618 prompt_tokens: 100,
619 completion_tokens: 50,
620 cached_tokens: 10,
621 cache_write_tokens: 5,
622 tool_calls: 7,
623 };
624 let mut st = state(AgentStatus::Active);
625 st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
626 let meta = build_run_meta(
627 RunMetaSources {
628 md: &md,
629 state: &st,
630 totals: &totals,
631 flags: &RunOutcomeFlags::default(),
632 final_output: None,
633 },
634 RunPosition {
635 stage_index: 1,
636 now_secs: 2000,
637 last_progress_at: Some(1900),
638 depth: 1,
639 max_child_depth: 4,
640 },
641 );
642
643 assert_eq!(meta.run_id, "run-1");
644 assert_eq!(meta.status, RunStatus::Running);
645 assert_eq!(meta.current_stage, "plan");
646 assert_eq!(meta.stage_index, 1);
647 assert_eq!(meta.iteration, 4);
648 assert_eq!(meta.prompt_tokens, 100);
649 assert_eq!(meta.tool_calls, 7);
650 assert_eq!(meta.updated_at, 2000);
651 assert_eq!(meta.last_progress_at, Some(1900));
654 assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
655 assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
656 assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
657 assert!(meta.error.is_none());
658 assert_eq!(
660 meta.children,
661 vec!["child-a".to_string(), "child-b".to_string()]
662 );
663 assert_eq!(meta.depth, 1);
664 assert_eq!(meta.max_child_depth, 4);
665 assert!(!meta.yolo);
667 }
668
669 #[test]
672 fn build_run_meta_records_an_unattended_run() {
673 let mut md = metadata();
674 md.unattended = true;
675 let meta = build_run_meta(
676 RunMetaSources {
677 md: &md,
678 state: &state(AgentStatus::Active),
679 totals: &TokenTotals::default(),
680 flags: &RunOutcomeFlags::default(),
681 final_output: None,
682 },
683 RunPosition {
684 stage_index: 1,
685 now_secs: 2000,
686 last_progress_at: None,
687 depth: 1,
688 max_child_depth: 4,
689 },
690 );
691 assert!(meta.yolo);
692 }
693
694 #[test]
695 fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
696 let mut flags = RunOutcomeFlags::default();
697 flags.0.gates_forced = 2;
698 let running = build_run_meta(
700 RunMetaSources {
701 md: &metadata(),
702 state: &state(AgentStatus::Active),
703 totals: &TokenTotals::default(),
704 flags: &flags,
705 final_output: None,
706 },
707 RunPosition {
708 stage_index: 0,
709 now_secs: 1000,
710 last_progress_at: None,
711 depth: 0,
712 max_child_depth: 0,
713 },
714 );
715 assert!(!running.flags.empty_output);
716 assert_eq!(running.flags.gates_forced, 2);
717
718 for status in [
720 AgentStatus::Complete,
721 AgentStatus::Cancelled,
722 AgentStatus::Error {
723 message: "x".to_string(),
724 },
725 ] {
726 let meta = build_run_meta(
727 RunMetaSources {
728 md: &metadata(),
729 state: &state(status),
730 totals: &TokenTotals::default(),
731 flags: &flags,
732 final_output: None,
733 },
734 RunPosition {
735 stage_index: 0,
736 now_secs: 1000,
737 last_progress_at: None,
738 depth: 0,
739 max_child_depth: 0,
740 },
741 );
742 assert!(meta.flags.empty_output);
743 }
744
745 let mut wrote = RunOutcomeFlags::default();
747 wrote.0.record_modification("src/a.rs");
748 let meta = build_run_meta(
749 RunMetaSources {
750 md: &metadata(),
751 state: &state(AgentStatus::Complete),
752 totals: &TokenTotals::default(),
753 flags: &wrote,
754 final_output: None,
755 },
756 RunPosition {
757 stage_index: 0,
758 now_secs: 1000,
759 last_progress_at: None,
760 depth: 0,
761 max_child_depth: 0,
762 },
763 );
764 assert!(!meta.flags.empty_output);
765 assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
766
767 let mut incapable = RunOutcomeFlags::default();
770 incapable.0.no_output_tools = true;
771 let meta = build_run_meta(
772 RunMetaSources {
773 md: &metadata(),
774 state: &state(AgentStatus::Complete),
775 totals: &TokenTotals::default(),
776 flags: &incapable,
777 final_output: None,
778 },
779 RunPosition {
780 stage_index: 0,
781 now_secs: 1000,
782 last_progress_at: None,
783 depth: 0,
784 max_child_depth: 0,
785 },
786 );
787 assert!(!meta.flags.empty_output);
788 assert!(meta.flags.no_output_tools);
789 }
790
791 #[test]
792 fn build_run_meta_carries_error_message() {
793 let meta = build_run_meta(
794 RunMetaSources {
795 md: &metadata(),
796 state: &state(AgentStatus::Error {
797 message: "boom".to_string(),
798 }),
799 totals: &TokenTotals::default(),
800 flags: &RunOutcomeFlags::default(),
801 final_output: None,
802 },
803 RunPosition {
804 stage_index: 2,
805 now_secs: 3000,
806 last_progress_at: None,
807 depth: 0,
808 max_child_depth: 0,
809 },
810 );
811 assert_eq!(meta.status, RunStatus::Error);
812 assert_eq!(meta.error.as_deref(), Some("boom"));
813 }
814
815 #[test]
821 fn build_run_meta_carries_a_submitted_output_and_clears_the_empty_verdict() {
822 let submitted = FinalOutput(leviath_core::output::FinalOutput::new(
823 "Renamed two helpers and updated their callers.",
824 Some("markdown".to_string()),
825 "summary".to_string(),
826 1234,
827 ));
828 let meta = build_run_meta(
829 RunMetaSources {
830 md: &metadata(),
831 state: &state(AgentStatus::Complete),
832 totals: &TokenTotals::default(),
833 flags: &RunOutcomeFlags::default(),
834 final_output: Some(&submitted),
835 },
836 RunPosition {
837 stage_index: 0,
838 now_secs: 1000,
839 last_progress_at: None,
840 depth: 0,
841 max_child_depth: 0,
842 },
843 );
844 let carried = meta.final_output.expect("the submission reached meta.json");
845 assert_eq!(
848 carried.bytes,
849 "Renamed two helpers and updated their callers.".len()
850 );
851 assert_eq!(carried.format.as_deref(), Some("markdown"));
852 assert_eq!(carried.stage, "summary");
853 assert!(meta.flags.produced_output);
854 assert!(!meta.flags.empty_output);
856 }
857
858 #[test]
861 fn a_run_that_submits_nothing_is_still_judged_empty() {
862 let meta = build_run_meta(
863 RunMetaSources {
864 md: &metadata(),
865 state: &state(AgentStatus::Complete),
866 totals: &TokenTotals::default(),
867 flags: &RunOutcomeFlags::default(),
868 final_output: None,
869 },
870 RunPosition {
871 stage_index: 0,
872 now_secs: 1000,
873 last_progress_at: None,
874 depth: 0,
875 max_child_depth: 0,
876 },
877 );
878 assert!(meta.final_output.is_none());
879 assert!(!meta.flags.produced_output);
880 assert!(meta.flags.empty_output);
881 }
882
883 #[test]
884 fn context_snapshot_captures_all_region_kinds() {
885 let mut w = ContextWindow::new(1000);
886 w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
887 w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
888 w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
889 w.add_region(Region::new(
890 "slide".to_string(),
891 RegionKind::SlidingWindow {
892 max_items: 5,
893 eviction_strategy: leviath_core::EvictionStrategy::PerItem,
894 },
895 100,
896 ));
897 w.add_region(Region::new(
898 "comp".to_string(),
899 RegionKind::Compacting {
900 threshold_tokens: 5,
901 },
902 100,
903 ));
904 w.add_region(Region::new(
905 "hist".to_string(),
906 RegionKind::CompactHistory {
907 source_region: "comp".to_string(),
908 },
909 100,
910 ));
911 w.add_region(Region::new(
912 "map".to_string(),
913 RegionKind::HashMap { max_entries: None },
914 100,
915 ));
916 w.add_region(Region::new(
917 "brain".to_string(),
918 RegionKind::Custom {
919 script: "b.rhai".to_string(),
920 persistent: false,
921 },
922 100,
923 ));
924 w.add_region(Region::new("todos".to_string(), RegionKind::Checklist, 100));
925 let _ = w.add_to_region("pin", "hello".to_string(), 3);
926 w.current_tokens = w.calculate_tokens();
927
928 let snap = build_context_snapshot(&w, "plan");
929
930 assert_eq!(snap.stage_name, "plan");
931 let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
932 assert_eq!(
933 kinds,
934 vec![
935 "pinned",
936 "temporary",
937 "clearable",
938 "sliding",
939 "compacting",
940 "history",
941 "hashmap",
942 "custom",
943 "checklist"
944 ]
945 );
946 let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
948 assert_eq!(pin.entries.len(), 1);
949 assert_eq!(pin.entries[0].content, "hello");
950 }
951}