1use bevy_ecs::prelude::*;
20use leviath_core::region::RegionEntry;
21use leviath_core::run_meta::{ContextSnapshot, RunMeta, RunStatus};
22
23use crate::components::{AgentState, AgentStatus, ContextWindow};
24use crate::persistence::TokenTotals;
25use crate::pipeline::{StageCursor, StageInferences, StageSetups};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30pub enum RestorePriority {
31 Blocked,
35 Active,
39}
40
41pub fn classify_restore(status: &RunStatus, parked_on_fanout: bool) -> Option<RestorePriority> {
50 match status {
51 RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled => None,
52 _ if parked_on_fanout => Some(RestorePriority::Blocked),
53 RunStatus::Starting | RunStatus::Running => Some(RestorePriority::Active),
54 RunStatus::WaitingInput | RunStatus::CompleteInteractive | RunStatus::Paused => {
55 Some(RestorePriority::Blocked)
56 }
57 }
58}
59
60pub fn triage_restores(candidates: Vec<(RunMeta, bool)>) -> Vec<RunMeta> {
72 let mut ranked: Vec<(RestorePriority, RunMeta)> = candidates
73 .into_iter()
74 .filter_map(|(meta, parked)| {
75 classify_restore(&meta.status, parked).map(|prio| (prio, meta))
76 })
77 .collect();
78 ranked.sort_by(|(a_prio, a), (b_prio, b)| {
81 b_prio
82 .cmp(a_prio)
83 .then_with(|| b.updated_at.cmp(&a.updated_at))
84 });
85 ranked.into_iter().map(|(_, meta)| meta).collect()
86}
87
88pub fn restore_agent(
99 world: &mut World,
100 entity: Entity,
101 snapshot: &ContextSnapshot,
102 stage_index: usize,
103 iteration: usize,
104 totals: TokenTotals,
105) {
106 {
108 let mut window = world
109 .get_mut::<ContextWindow>(entity)
110 .expect("a spawned agent has a context window");
111 for snap_region in &snapshot.regions {
112 if let Some(region) = window
113 .regions
114 .iter_mut()
115 .find(|r| r.name == snap_region.name)
116 {
117 region.content = snap_region
118 .entries
119 .iter()
120 .map(|e| RegionEntry {
121 content: e.content.clone(),
122 tokens: e.tokens,
123 timestamp: 0,
124 metadata: e.metadata.clone(),
125 kind: e.kind.clone(),
126 key: e.key.clone(),
127 })
128 .collect();
129 if region.taint.is_some() {
137 region.taint = Some(leviath_core::taint::RegionTaint::from_entry_taints(
138 snap_region.entries.iter().map(|e| e.taint).collect(),
139 ));
140 }
141 region.current_tokens = region.content.iter().map(|e| e.tokens).sum();
142 }
143 }
144 window.current_tokens = window.calculate_tokens();
145 }
146
147 if let Some(inf) = world
150 .get::<StageInferences>(entity)
151 .expect("a spawned agent has stage inferences")
152 .0
153 .get(stage_index)
154 .cloned()
155 {
156 let setup = &world
157 .get::<StageSetups>(entity)
158 .expect("a spawned agent has stage setups")
159 .0[stage_index];
160 let cfg = setup.inference_config.clone();
161 let routing = setup.routing.clone();
162 world.entity_mut(entity).insert((inf, cfg));
163 match routing {
167 Some(routing) => {
168 world
169 .entity_mut(entity)
170 .insert(crate::components::ToolResultRoutingComponent { routing });
171 }
172 None => {
173 world
174 .entity_mut(entity)
175 .remove::<crate::components::ToolResultRoutingComponent>();
176 }
177 }
178 world
179 .get_mut::<StageCursor>(entity)
180 .expect("a spawned agent has a stage cursor")
181 .index = stage_index;
182 }
183
184 {
186 let mut state = world
187 .get_mut::<AgentState>(entity)
188 .expect("a spawned agent has state");
189 state.current_stage = snapshot.stage_name.clone();
190 state.iteration = iteration;
191 state.status = AgentStatus::Active;
192 }
193 world.entity_mut(entity).insert(totals);
194}
195
196pub fn restore_stage_ledger(
217 world: &mut World,
218 entity: Entity,
219 records: &[leviath_core::run_meta::StageRecord],
220) {
221 let Some(mut ledger) = world.get_mut::<crate::pipeline::StageLedger>(entity) else {
222 return;
223 };
224 for rec in ledger.0.iter_mut() {
225 if let Some(saved) = records.iter().find(|saved| saved.name == rec.name) {
226 let index = rec.index;
227 *rec = saved.clone();
228 rec.index = index;
229 }
230 }
231}
232
233pub const INTERRUPTED_TOOL_RESULT: &str = "[error] interrupted: the daemon restarted while this tool call was executing and its \
237 result was lost. Verify whether it took effect before re-running side-effecting work.";
238
239fn interrupted_result(tool_name: &str, children: &[String]) -> String {
244 if leviath_tools::is_subagent_tool(tool_name) && !children.is_empty() {
245 format!(
246 "{INTERRUPTED_TOOL_RESULT} This run already has child agent runs: {}; check them \
247 with check_agent before spawning again.",
248 children.join(", ")
249 )
250 } else {
251 INTERRUPTED_TOOL_RESULT.to_string()
252 }
253}
254
255pub fn restore_pending_batch(
274 world: &mut World,
275 entity: Entity,
276 batch: &leviath_core::run_archive::PendingToolBatch,
277 children: &[String],
278) {
279 let calls: Vec<crate::components::ToolCall> = batch
280 .calls
281 .iter()
282 .map(|c| crate::components::ToolCall {
283 tool_id: c.id.clone(),
284 name: c.name.clone(),
285 arguments: serde_json::from_str(&c.arguments)
289 .unwrap_or_else(|_| serde_json::Value::String(c.arguments.clone())),
290 thought_signature: c.thought_signature.clone(),
291 })
292 .collect();
293 let merged: Vec<(String, String)> = batch
294 .calls
295 .iter()
296 .map(|c| {
297 let result = c
298 .result
299 .clone()
300 .unwrap_or_else(|| interrupted_result(&c.name, children));
301 (c.id.clone(), result)
302 })
303 .collect();
304 let routing = world
305 .get::<crate::components::ToolResultRoutingComponent>(entity)
306 .map(|c| c.routing.clone());
307 let sensitivities = world
308 .get::<crate::pipeline::ToolSensitivities>(entity)
309 .map(|s| s.0.clone());
310 let mut window = world
311 .get_mut::<ContextWindow>(entity)
312 .expect("a spawned agent has a context window");
313 crate::pipeline::apply_tool_results(
314 &mut window,
315 &batch.response,
316 &calls,
317 &merged,
318 routing.as_ref(),
319 sensitivities.as_ref(),
320 );
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::components::InferenceConfig;
327 use crate::pipeline::{ReadyToInfer, StageInference, StageSetup};
328 use leviath_core::region::EntryKind;
329 use leviath_core::run_meta::{RegionEntrySnapshot, RegionSnapshot};
330 use leviath_core::{Region, RegionKind};
331
332 fn setup(temp: Option<f32>) -> StageSetup {
333 StageSetup {
334 inference_config: InferenceConfig {
335 temperature: temp,
336 max_output_tokens: None,
337 extra_params: Default::default(),
338 batch_tool_hint: false,
339 shell_hint: false,
340 request_timeout_secs: None,
341 },
342 routing: None,
343 accepts_messages: true,
344 context_layout: None,
345 system_prompt: None,
346 output: None,
347 }
348 }
349
350 fn si(model: &str) -> StageInference {
351 StageInference {
352 provider_name: "p".to_string(),
353 model: model.to_string(),
354 tools: vec![],
355 tool_filter: None,
356 fallbacks: Vec::new(),
357 output: None,
358 }
359 }
360
361 fn agent_world() -> (World, Entity) {
364 let mut world = World::new();
365 let mut window = ContextWindow::new(10_000);
366 window.add_region(Region::new(
367 "conversation".to_string(),
368 RegionKind::Clearable,
369 10_000,
370 ));
371 let _ = window.add_to_region("conversation", "fresh task seed".to_string(), 3);
372 let entity = world
373 .spawn((
374 window,
375 StageCursor { index: 0 },
376 AgentState {
377 agent_id: "a".to_string(),
378 current_stage: "s0".to_string(),
379 iteration: 0,
380 status: AgentStatus::Active,
381 spawned_children_ids: vec![],
382 pending_wait: None,
383 accepts_messages: true,
384 },
385 StageInferences(vec![si("m0"), si("m1")]),
386 StageSetups(vec![setup(None), setup(Some(0.5))]),
387 si("m0"),
388 setup(None).inference_config,
389 TokenTotals::default(),
390 ReadyToInfer,
391 ))
392 .id();
393 (world, entity)
394 }
395
396 fn snapshot() -> ContextSnapshot {
397 ContextSnapshot {
398 stage_name: "s1".to_string(),
399 total_tokens: 8,
400 max_tokens: 10_000,
401 regions: vec![
402 RegionSnapshot {
403 name: "conversation".to_string(),
404 kind: "clearable".to_string(),
405 current_tokens: 8,
406 max_tokens: 10_000,
407 entries: vec![
408 RegionEntrySnapshot {
409 content: "prior user turn".to_string(),
410 tokens: 5,
411 kind: EntryKind::UserMessage,
412 metadata: None,
413 key: None,
414 taint: Default::default(),
415 },
416 RegionEntrySnapshot {
417 content: "prior assistant".to_string(),
418 tokens: 3,
419 kind: EntryKind::AssistantTurn { tool_calls: vec![] },
420 metadata: None,
421 key: None,
422 taint: Default::default(),
423 },
424 ],
425 },
426 RegionSnapshot {
428 name: "ghost".to_string(),
429 kind: "pinned".to_string(),
430 current_tokens: 1,
431 max_tokens: 10,
432 entries: vec![RegionEntrySnapshot {
433 content: "orphan".to_string(),
434 tokens: 1,
435 kind: EntryKind::Text,
436 metadata: None,
437 key: None,
438 taint: Default::default(),
439 }],
440 },
441 ],
442 }
443 }
444
445 #[test]
451 fn restore_rebuilds_region_taint_from_the_persisted_entries() {
452 use leviath_core::taint::TaintLevel;
453
454 let mut snap = snapshot();
455 snap.regions[0].entries[0].taint = TaintLevel::Private;
456 snap.regions[0].entries[1].taint = TaintLevel::Public;
457
458 let (mut world, entity) = agent_world();
460 restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
461 assert!(
462 world
463 .get::<ContextWindow>(entity)
464 .unwrap()
465 .get_region("conversation")
466 .unwrap()
467 .taint
468 .is_none()
469 );
470
471 let (mut world, entity) = agent_world();
473 world
474 .get_mut::<ContextWindow>(entity)
475 .unwrap()
476 .get_region_mut("conversation")
477 .unwrap()
478 .enable_taint_tracking();
479 restore_agent(&mut world, entity, &snap, 1, 7, TokenTotals::default());
480
481 let window = world.get::<ContextWindow>(entity).unwrap();
482 let region = window.get_region("conversation").unwrap();
483 assert_eq!(region.taint_level(), Some(TaintLevel::Private));
484 let taint = region.taint.as_ref().unwrap();
485 assert_eq!(taint.entry_taint(0), Some(TaintLevel::Private));
486 assert_eq!(taint.entry_taint(1), Some(TaintLevel::Public));
487 }
488
489 #[test]
494 fn restore_stage_ledger_overlays_the_persisted_records_by_name() {
495 use crate::pipeline::StageLedger;
496 use leviath_core::run_meta::{StageRecord, StageRunStatus};
497
498 let (mut world, entity) = agent_world();
499 restore_stage_ledger(&mut world, entity, &[StageRecord::new("s0".to_string(), 0)]);
501 assert!(world.get::<StageLedger>(entity).is_none());
502
503 world.entity_mut(entity).insert(StageLedger(vec![
504 StageRecord::new("s0".to_string(), 0),
505 StageRecord::new("s1".to_string(), 1),
506 ]));
507 let mut saved = StageRecord::new("s1".to_string(), 4);
510 saved.status = StageRunStatus::Complete;
511 saved.entered = true;
512 saved.prompt_tokens = 900;
513 saved.completion_tokens = 30;
514 saved.cached_tokens = 12;
515 saved.cache_write_tokens = 4;
516 saved.first_call_prompt_tokens = Some(300);
517 saved.runaway_warned = true;
518 saved.region_tokens.insert("conversation".to_string(), 120);
519 saved.started_at = Some(5);
520 saved.ended_at = Some(9);
521 restore_stage_ledger(
522 &mut world,
523 entity,
524 &[saved, StageRecord::new("removed".to_string(), 9)],
525 );
526
527 let ledger = world.get::<StageLedger>(entity).unwrap();
528 assert_eq!(
529 ledger.0.len(),
530 2,
531 "a record for a stage the blueprint no longer has is dropped, not appended"
532 );
533 assert_eq!(ledger.0[0].prompt_tokens, 0);
535 assert_eq!(ledger.0[0].status, StageRunStatus::Pending);
536 assert!(!ledger.0[0].entered);
537 assert_eq!(ledger.0[1].index, 1);
540 assert_eq!(ledger.0[1].name, "s1");
541 assert_eq!(ledger.0[1].prompt_tokens, 900);
542 assert_eq!(ledger.0[1].completion_tokens, 30);
543 assert_eq!(ledger.0[1].cached_tokens, 12);
544 assert_eq!(ledger.0[1].cache_write_tokens, 4);
545 assert_eq!(ledger.0[1].first_call_prompt_tokens, Some(300));
546 assert!(ledger.0[1].runaway_warned);
547 assert_eq!(ledger.0[1].region_tokens.get("conversation"), Some(&120));
548 assert_eq!(ledger.0[1].started_at, Some(5));
549 assert_eq!(ledger.0[1].ended_at, Some(9));
550 assert_eq!(ledger.0[1].status, StageRunStatus::Complete);
551 assert!(ledger.0[1].entered);
552 }
553
554 #[test]
555 fn restore_overlays_context_and_jumps_to_stage() {
556 let (mut world, entity) = agent_world();
557 restore_agent(
558 &mut world,
559 entity,
560 &snapshot(),
561 1,
562 7,
563 TokenTotals {
564 prompt_tokens: 100,
565 ..Default::default()
566 },
567 );
568
569 let window = world.get::<ContextWindow>(entity).unwrap();
571 let region = window.get_region("conversation").unwrap();
572 assert_eq!(region.content.len(), 2);
573 assert_eq!(region.content[0].content, "prior user turn");
574 assert_eq!(region.content[0].kind, EntryKind::UserMessage);
575 assert_eq!(region.current_tokens, 8);
576
577 assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 1);
579 let state = world.get::<AgentState>(entity).unwrap();
580 assert_eq!(state.current_stage, "s1");
581 assert_eq!(state.iteration, 7);
582 assert_eq!(state.status, AgentStatus::Active);
583 assert_eq!(
584 world.get::<InferenceConfig>(entity).unwrap().temperature,
585 Some(0.5)
586 );
587 assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m1");
588 assert_eq!(world.get::<TokenTotals>(entity).unwrap().prompt_tokens, 100);
589 assert!(world.get::<ReadyToInfer>(entity).is_some());
591 }
592
593 fn pending_call(
596 id: &str,
597 name: &str,
598 result: Option<&str>,
599 ) -> leviath_core::run_archive::ToolCallRecord {
600 leviath_core::run_archive::ToolCallRecord {
601 id: id.to_string(),
602 name: name.to_string(),
603 arguments: r#"{"path":"x.txt"}"#.to_string(),
604 result: result.map(str::to_string),
605 thought_signature: None,
606 }
607 }
608
609 fn pending_batch(
610 calls: Vec<leviath_core::run_archive::ToolCallRecord>,
611 ) -> leviath_core::run_archive::PendingToolBatch {
612 leviath_core::run_archive::PendingToolBatch {
613 stage_index: 1,
614 iteration: 7,
615 response: "writing then checking".to_string(),
616 calls,
617 }
618 }
619
620 fn conv_entries(world: &World, entity: Entity) -> Vec<RegionEntry> {
622 world
623 .get::<ContextWindow>(entity)
624 .unwrap()
625 .get_region("conversation")
626 .unwrap()
627 .content
628 .clone()
629 }
630
631 #[test]
632 fn pending_batch_replays_real_results_and_synthesizes_interrupted_ones() {
633 let (mut world, entity) = agent_world();
634 restore_agent(
635 &mut world,
636 entity,
637 &snapshot(),
638 1,
639 7,
640 TokenTotals::default(),
641 );
642 restore_pending_batch(
643 &mut world,
644 entity,
645 &pending_batch(vec![
646 pending_call("c1", "write_file", Some("Wrote 42 bytes to x.txt")),
647 pending_call("c2", "shell", None),
648 ]),
649 &[],
650 );
651
652 let entries = conv_entries(&world, entity);
653 let turn = entries
656 .iter()
657 .find_map(|e| match &e.kind {
658 EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
659 Some(tool_calls.clone())
660 }
661 _ => None,
662 })
663 .expect("assistant turn appended");
664 assert_eq!(turn.len(), 2);
665 assert_eq!(turn[0].id, "c1");
666 assert_eq!(
667 turn[0].arguments,
668 serde_json::json!({"path": "x.txt"}),
669 "journaled arguments parsed back to JSON"
670 );
671 let result_of = |id: &str| {
672 entries
673 .iter()
674 .find(|e| {
675 matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == id)
676 })
677 .map(|e| e.content.clone())
678 .expect("a result per call")
679 };
680 assert_eq!(result_of("c1"), "Wrote 42 bytes to x.txt");
681 assert!(result_of("c2").contains("interrupted"));
682 assert!(result_of("c2").contains("Verify whether it took effect"));
683 }
684
685 #[test]
686 fn pending_batch_survives_request_assembly_unstripped() {
687 let (mut world, entity) = agent_world();
692 world
693 .get_mut::<ContextWindow>(entity)
694 .unwrap()
695 .get_region_mut("conversation")
696 .unwrap()
697 .kind = RegionKind::SlidingWindow {
698 max_items: 100,
699 eviction_strategy: Default::default(),
700 };
701 restore_agent(
702 &mut world,
703 entity,
704 &snapshot(),
705 1,
706 7,
707 TokenTotals::default(),
708 );
709 restore_pending_batch(
710 &mut world,
711 entity,
712 &pending_batch(vec![pending_call("c1", "shell", None)]),
713 &[],
714 );
715
716 let assembled = world.get::<ContextWindow>(entity).unwrap().assemble();
717 let mut tool_uses = 0;
718 let mut tool_results = 0;
719 for msg in &assembled.messages {
720 if let leviath_providers::MessageContent::Blocks(blocks) = &msg.content {
721 for block in blocks {
722 match block {
723 leviath_providers::ContentBlock::ToolUse { id, .. } => {
724 assert_eq!(id, "c1");
725 tool_uses += 1;
726 }
727 leviath_providers::ContentBlock::ToolResult { tool_use_id, .. } => {
728 assert_eq!(tool_use_id, "c1");
729 tool_results += 1;
730 }
731 _ => {}
732 }
733 }
734 }
735 }
736 assert_eq!((tool_uses, tool_results), (1, 1), "nothing stripped");
737 }
738
739 #[test]
740 fn pending_batch_routes_results_through_the_restored_stage_routing() {
741 let (mut world, entity) = agent_world();
745 world
746 .get_mut::<ContextWindow>(entity)
747 .unwrap()
748 .add_region(Region::new(
749 "knowledge".to_string(),
750 RegionKind::Pinned,
751 10_000,
752 ));
753 world
754 .get_mut::<StageSetups>(entity)
755 .unwrap()
756 .0
757 .get_mut(1)
758 .unwrap()
759 .routing = Some(leviath_core::ToolResultRouting {
760 default_region: "knowledge".to_string(),
761 ..Default::default()
762 });
763 restore_agent(
764 &mut world,
765 entity,
766 &snapshot(),
767 1,
768 7,
769 TokenTotals::default(),
770 );
771 restore_pending_batch(
772 &mut world,
773 entity,
774 &pending_batch(vec![pending_call("c1", "read_file", Some("the file body"))]),
775 &[],
776 );
777
778 let window = world.get::<ContextWindow>(entity).unwrap();
779 let knowledge = window.get_region("knowledge").unwrap();
780 assert!(
781 knowledge
782 .content
783 .iter()
784 .any(|e| e.content.contains("the file body")),
785 "full text routed to the knowledge region"
786 );
787 assert!(
788 conv_entries(&world, entity).iter().any(
789 |e| matches!(&e.kind, EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "c1")
790 ),
791 "conversation keeps the paired pointer result"
792 );
793 }
794
795 #[test]
796 fn pending_batch_taints_results_per_tool_sensitivity() {
797 use leviath_core::taint::TaintLevel;
798 let (mut world, entity) = agent_world();
799 world
800 .get_mut::<ContextWindow>(entity)
801 .unwrap()
802 .get_region_mut("conversation")
803 .unwrap()
804 .enable_taint_tracking();
805 world
806 .entity_mut(entity)
807 .insert(crate::pipeline::ToolSensitivities(
808 [("read_file".to_string(), TaintLevel::Private)]
809 .into_iter()
810 .collect(),
811 ));
812 restore_agent(
813 &mut world,
814 entity,
815 &snapshot(),
816 1,
817 7,
818 TokenTotals::default(),
819 );
820 restore_pending_batch(
821 &mut world,
822 entity,
823 &pending_batch(vec![pending_call("c1", "read_file", Some("secret body"))]),
824 &[],
825 );
826
827 let window = world.get::<ContextWindow>(entity).unwrap();
828 assert_eq!(
829 window.get_region("conversation").unwrap().taint_level(),
830 Some(TaintLevel::Private),
831 "replayed result tainted like a live one"
832 );
833 }
834
835 #[test]
836 fn unparseable_journaled_arguments_survive_as_a_raw_string() {
837 let (mut world, entity) = agent_world();
838 restore_agent(
839 &mut world,
840 entity,
841 &snapshot(),
842 1,
843 7,
844 TokenTotals::default(),
845 );
846 let mut call = pending_call("c1", "shell", None);
847 call.arguments = "not json {".to_string();
848 restore_pending_batch(&mut world, entity, &pending_batch(vec![call]), &[]);
849
850 let entries = conv_entries(&world, entity);
851 let turn = entries
852 .iter()
853 .find_map(|e| match &e.kind {
854 EntryKind::AssistantTurn { tool_calls } if !tool_calls.is_empty() => {
855 Some(tool_calls.clone())
856 }
857 _ => None,
858 })
859 .expect("turn still lands");
860 assert_eq!(
861 turn[0].arguments,
862 serde_json::Value::String("not json {".to_string())
863 );
864 }
865
866 #[test]
867 fn interrupted_subagent_calls_point_at_known_children() {
868 let kids = vec!["run-kid-1".to_string(), "run-kid-2".to_string()];
872 let enriched = interrupted_result("spawn_agent", &kids);
873 assert!(enriched.contains("run-kid-1, run-kid-2"));
874 assert!(enriched.contains("check_agent"));
875 assert_eq!(interrupted_result("shell", &kids), INTERRUPTED_TOOL_RESULT);
876 assert_eq!(
877 interrupted_result("spawn_agent", &[]),
878 INTERRUPTED_TOOL_RESULT
879 );
880
881 let (mut world, entity) = agent_world();
883 restore_agent(
884 &mut world,
885 entity,
886 &snapshot(),
887 1,
888 7,
889 TokenTotals::default(),
890 );
891 restore_pending_batch(
892 &mut world,
893 entity,
894 &pending_batch(vec![pending_call("c1", "spawn_agent", None)]),
895 &kids,
896 );
897 assert!(
898 conv_entries(&world, entity)
899 .iter()
900 .any(|e| e.content.contains("already has child agent runs")),
901 "the synthesized sub-agent note lands in the window"
902 );
903 }
904
905 #[test]
906 fn restore_swaps_in_the_stage_routing_and_clears_stale() {
907 use crate::components::ToolResultRoutingComponent;
908
909 let (mut world, entity) = agent_world();
911 let routed = leviath_core::ToolResultRouting {
912 default_region: "knowledge".to_string(),
913 ..Default::default()
914 };
915 world
916 .get_mut::<StageSetups>(entity)
917 .unwrap()
918 .0
919 .get_mut(1)
920 .unwrap()
921 .routing = Some(routed);
922 restore_agent(
923 &mut world,
924 entity,
925 &snapshot(),
926 1,
927 7,
928 TokenTotals::default(),
929 );
930 assert_eq!(
931 world
932 .get::<ToolResultRoutingComponent>(entity)
933 .expect("stage 1's routing swapped in")
934 .routing
935 .default_region,
936 "knowledge"
937 );
938
939 let (mut world, entity) = agent_world();
942 world.entity_mut(entity).insert(ToolResultRoutingComponent {
943 routing: leviath_core::ToolResultRouting::default(),
944 });
945 restore_agent(
946 &mut world,
947 entity,
948 &snapshot(),
949 1,
950 7,
951 TokenTotals::default(),
952 );
953 assert!(world.get::<ToolResultRoutingComponent>(entity).is_none());
954 }
955
956 fn meta_with(run_id: &str, status: RunStatus, updated_at: i64) -> RunMeta {
957 let mut m = RunMeta::new(
958 run_id.to_string(),
959 "a".to_string(),
960 "/p".to_string(),
961 "t".to_string(),
962 None,
963 "/w".to_string(),
964 1,
965 );
966 m.status = status;
967 m.updated_at = updated_at;
968 m
969 }
970
971 #[test]
972 fn classify_restore_skips_terminal_and_ranks_the_rest() {
973 assert_eq!(classify_restore(&RunStatus::Complete, false), None);
975 assert_eq!(classify_restore(&RunStatus::Error, false), None);
976 assert_eq!(classify_restore(&RunStatus::Cancelled, false), None);
977 assert_eq!(
979 classify_restore(&RunStatus::Running, false),
980 Some(RestorePriority::Active)
981 );
982 assert_eq!(
983 classify_restore(&RunStatus::Starting, false),
984 Some(RestorePriority::Active)
985 );
986 assert_eq!(
988 classify_restore(&RunStatus::WaitingInput, false),
989 Some(RestorePriority::Blocked)
990 );
991 assert_eq!(
992 classify_restore(&RunStatus::Paused, false),
993 Some(RestorePriority::Blocked)
994 );
995 assert_eq!(
996 classify_restore(&RunStatus::CompleteInteractive, false),
997 Some(RestorePriority::Blocked)
998 );
999 assert_eq!(
1001 classify_restore(&RunStatus::Running, true),
1002 Some(RestorePriority::Blocked)
1003 );
1004 assert_eq!(classify_restore(&RunStatus::Complete, true), None);
1006 }
1007
1008 #[test]
1009 fn triage_orders_actionable_first_then_by_recency_and_drops_terminal() {
1010 let candidates = vec![
1011 (
1012 meta_with("blocked-old", RunStatus::WaitingInput, 100),
1013 false,
1014 ),
1015 (meta_with("active-old", RunStatus::Running, 200), false),
1016 (meta_with("terminal", RunStatus::Complete, 999), false),
1017 (meta_with("active-new", RunStatus::Starting, 300), false),
1018 (meta_with("parked", RunStatus::Running, 999), true), (
1020 meta_with("blocked-new", RunStatus::WaitingInput, 400),
1021 false,
1022 ),
1023 ];
1024 let order: Vec<String> = triage_restores(candidates)
1025 .into_iter()
1026 .map(|m| m.run_id)
1027 .collect();
1028 assert_eq!(
1031 order,
1032 vec![
1033 "active-new".to_string(), "active-old".to_string(), "parked".to_string(), "blocked-new".to_string(), "blocked-old".to_string(), ]
1039 );
1040 }
1041
1042 #[test]
1043 fn restore_with_out_of_range_stage_keeps_spawn_config() {
1044 let (mut world, entity) = agent_world();
1045 let mut snap = snapshot();
1046 snap.stage_name = "s0".to_string();
1047 restore_agent(&mut world, entity, &snap, 9, 2, TokenTotals::default());
1049
1050 assert_eq!(world.get::<StageCursor>(entity).unwrap().index, 0);
1052 assert_eq!(world.get::<StageInference>(entity).unwrap().model, "m0");
1053 assert_eq!(world.get::<AgentState>(entity).unwrap().iteration, 2);
1055 assert_eq!(
1056 world
1057 .get::<ContextWindow>(entity)
1058 .unwrap()
1059 .get_region("conversation")
1060 .unwrap()
1061 .content
1062 .len(),
1063 2
1064 );
1065 }
1066}