1use std::collections::HashMap;
29use std::sync::Arc;
30
31use bevy_ecs::prelude::*;
32use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
33use leviath_core::interaction::{InteractionRequest, InteractionResponse};
34use serde::{Deserialize, Serialize};
35use tokio::runtime::Handle;
36use tokio::sync::Notify;
37use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
38
39use crate::components::{AgentState, AgentStatus, ContextWindow, InferenceResult};
40use crate::dynamic_interaction::InteractionBackend;
41use crate::interaction_hub::InteractionHub;
42use crate::pipeline::{
43 AgentBlueprint, ReadyToInfer, ResolveTransition, StageCursor, StageIoBuffer,
44};
45
46pub const MAX_REVISION_ROUNDS: usize = 4;
49
50#[derive(Component, Debug, Clone, Copy)]
55pub struct ReadyForInteractionPoint;
56
57#[derive(Component, Debug, Clone, Copy)]
60pub struct AwaitingInteractionPoint;
61
62#[derive(Component, Debug, Clone, Copy)]
65pub struct InteractionPointCursor(pub usize);
66
67#[derive(Component, Debug, Clone, Copy)]
70pub struct InteractionPointRounds(pub usize);
71
72#[derive(Component, Debug, Clone)]
77pub struct PlanBodyOverride(pub String);
78
79#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
88pub struct InteractionPointState {
89 pub cursor: usize,
91 pub round: usize,
93 pub body: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PointOutcome {
103 Approve { user_text: String },
105 Abort,
107 Directive {
109 user_text: String,
110 directive: String,
111 },
112 Edit { user_text: String, edited: String },
114}
115
116pub struct InteractionPointOutcome {
118 pub entity: Entity,
120 pub decision: PointOutcome,
122}
123
124#[derive(Resource)]
127pub struct InteractionPointStage {
128 pub outcomes: UnboundedSender<InteractionPointOutcome>,
130 pub wake: Arc<Notify>,
132 pub runtime: Handle,
134}
135
136#[derive(Resource)]
138pub struct InteractionPointResults(pub UnboundedReceiver<InteractionPointOutcome>);
139
140fn normalize_for_followup(s: &str) -> String {
145 s.chars()
146 .map(|c| match c {
147 '\u{2014}' | '\u{2013}' | '\u{2212}' | '\u{2015}' => '-',
148 _ => c,
149 })
150 .collect::<String>()
151 .split_whitespace()
152 .collect::<Vec<_>>()
153 .join(" ")
154}
155
156fn option_matches(candidates: &[String], user_text: &str) -> bool {
158 if candidates.iter().any(|o| o == user_text) {
159 return true;
160 }
161 let normalized = normalize_for_followup(user_text);
162 candidates
163 .iter()
164 .any(|o| normalize_for_followup(o) == normalized)
165}
166
167fn lookup_directive<'a>(
169 directives: &'a HashMap<String, String>,
170 user_text: &str,
171) -> Option<&'a str> {
172 if let Some(d) = directives.get(user_text) {
173 return Some(d.as_str());
174 }
175 let normalized = normalize_for_followup(user_text);
176 directives
177 .iter()
178 .find(|(k, _)| normalize_for_followup(k) == normalized)
179 .map(|(_, d)| d.as_str())
180}
181
182fn build_point_request(point: &InteractionPoint, id: String, body: &str) -> InteractionRequest {
186 let mut req = match point.style {
187 InteractionStyle::MultipleChoice => InteractionRequest::multiple_choice(
188 id,
189 &point.prompt,
190 point.options.clone(),
191 &point.name,
192 ),
193 InteractionStyle::Confirm => InteractionRequest::confirm(id, &point.prompt, &point.name),
194 InteractionStyle::FreeText => {
195 InteractionRequest::free_text(id, &point.prompt, &point.name, point.required)
196 }
197 };
198 if !body.trim().is_empty() {
199 req.body = Some(body.to_string());
200 req.body_format = leviath_core::interaction::BodyFormat::Markdown;
201 }
202 req
203}
204
205fn resolve_answer(resp: &InteractionResponse, options: &[String]) -> String {
208 if let Some(opt) = resp.choice_index.and_then(|i| options.get(i)) {
209 return opt.clone();
210 }
211 resp.value.clone().unwrap_or_default()
212}
213
214fn route_answer(point: &InteractionPoint, user_text: String) -> Routed {
217 if option_matches(&point.abort_options, &user_text) {
218 Routed::Abort
219 } else if option_matches(&point.edit_options, &user_text) {
220 Routed::Edit { user_text }
221 } else if let Some(directive) = lookup_directive(&point.directives, &user_text) {
222 Routed::Directive {
223 user_text,
224 directive: directive.to_string(),
225 }
226 } else {
227 Routed::Approve { user_text }
228 }
229}
230
231#[derive(Debug, PartialEq, Eq)]
233enum Routed {
234 Approve {
235 user_text: String,
236 },
237 Abort,
238 Directive {
239 user_text: String,
240 directive: String,
241 },
242 Edit {
243 user_text: String,
244 },
245}
246
247#[allow(clippy::too_many_arguments)]
253async fn run_interaction_point(
254 entity: Entity,
255 hub: InteractionHub,
256 agent_id: String,
257 point: InteractionPoint,
258 body: String,
259 round: usize,
260 outcomes: UnboundedSender<InteractionPointOutcome>,
261 wake: Arc<Notify>,
262) {
263 let ask_id = format!("{agent_id}-point-{}-{round}", point.name);
266 let backend = hub.backend_for(agent_id);
267 let req = build_point_request(&point, ask_id.clone(), &body);
268 let resp = backend.ask(req).await;
269 let user_text = resolve_answer(&resp, &point.options);
270
271 let decision = match route_answer(&point, user_text) {
272 Routed::Approve { user_text } => PointOutcome::Approve { user_text },
273 Routed::Abort => PointOutcome::Abort,
274 Routed::Directive {
275 user_text,
276 directive,
277 } => PointOutcome::Directive {
278 user_text,
279 directive,
280 },
281 Routed::Edit { user_text } => {
282 let edit_req = InteractionRequest::edit_text(
283 format!("{ask_id}-edit"),
284 "Edit the document - your changes replace it, then submit:",
285 &point.name,
286 body,
287 );
288 let edited = backend.ask(edit_req).await.value.unwrap_or_default();
289 PointOutcome::Edit { user_text, edited }
290 }
291 };
292
293 let _ = outcomes.send(InteractionPointOutcome { entity, decision });
294 wake.notify_one();
295}
296
297pub fn restore_interaction_point(world: &mut World, entity: Entity, state: InteractionPointState) {
314 let Some(((outcomes, wake, runtime), hub)) = world
317 .get_resource::<InteractionPointStage>()
318 .map(|s| (s.outcomes.clone(), s.wake.clone(), s.runtime.clone()))
319 .zip(world.get_resource::<InteractionHub>().cloned())
320 else {
321 return;
322 };
323
324 let agent_id = world
329 .get::<AgentState>(entity)
330 .expect("a reloaded agent has AgentState")
331 .agent_id
332 .clone();
333 let point = {
334 let bp = world
335 .get::<AgentBlueprint>(entity)
336 .expect("a reloaded agent has a blueprint");
337 let cursor = world
338 .get::<StageCursor>(entity)
339 .expect("a reloaded agent has a stage cursor");
340 stage_points(bp, cursor)
341 .and_then(|p| p.get(state.cursor))
342 .cloned()
343 };
344 let Some(point) = point else {
345 tracing::warn!(
346 ?entity,
347 cursor = state.cursor,
348 "interaction-point restore skipped: stage not interactive or cursor out of range"
349 );
350 return;
351 };
352
353 {
356 let mut e = world.entity_mut(entity);
357 e.insert(InteractionPointCursor(state.cursor));
358 e.insert(InteractionPointRounds(state.round));
359 e.insert(AwaitingInteractionPoint);
360 e.remove::<ReadyToInfer>();
361 e.get_mut::<AgentState>()
362 .expect("a reloaded agent has AgentState")
363 .status = AgentStatus::Waiting;
364 }
365
366 runtime.spawn(run_interaction_point(
368 entity,
369 hub,
370 agent_id,
371 point,
372 state.body,
373 state.round,
374 outcomes,
375 wake,
376 ));
377}
378
379fn stage_points<'a>(
384 bp: &'a AgentBlueprint,
385 cursor: &StageCursor,
386) -> Option<&'a [InteractionPoint]> {
387 match &bp.0.stages[cursor.index].mode {
388 StageMode::InteractivePoints { points } => Some(points),
389 _ => None,
390 }
391}
392
393#[allow(clippy::type_complexity)]
398pub fn gate_interaction_points(
399 agents: Query<
400 (
401 Entity,
402 &AgentBlueprint,
403 &StageCursor,
404 Option<&InteractionPointCursor>,
405 ),
406 With<ResolveTransition>,
407 >,
408 mut commands: Commands,
409) {
410 crate::tick_scope::clear();
411 for (entity, bp, cursor, pc) in agents.iter() {
412 crate::tick_scope::enter(entity);
413 let Some(points) = stage_points(bp, cursor) else {
414 continue;
415 };
416 let idx = pc.map_or(0, |c| c.0);
417 if points.is_empty() || idx >= points.len() {
418 continue; }
420 commands
421 .entity(entity)
422 .remove::<ResolveTransition>()
423 .insert(ReadyForInteractionPoint);
424 }
425}
426
427#[allow(clippy::type_complexity)]
431pub fn dispatch_interaction_point(
432 mut agents: Query<
433 (
434 Entity,
435 &AgentState,
436 &AgentBlueprint,
437 &StageCursor,
438 &InferenceResult,
439 &mut ContextWindow,
440 Option<&InteractionPointCursor>,
441 Option<&InteractionPointRounds>,
442 Option<&PlanBodyOverride>,
443 Option<&crate::components::InteractionAutoApprove>,
444 ),
445 With<ReadyForInteractionPoint>,
446 >,
447 hub: Option<Res<InteractionHub>>,
448 stage: Option<Res<InteractionPointStage>>,
449 mut commands: Commands,
450) {
451 crate::tick_scope::clear();
452 let (Some(hub), Some(stage)) = (hub, stage) else {
453 return; };
455 for (entity, state, bp, cursor, infer, mut window, pc, rounds, plan_override, auto_approve) in
456 agents.iter_mut()
457 {
458 crate::tick_scope::enter(entity);
459 if state.status != AgentStatus::Active {
460 continue; }
462 let idx = pc.map_or(0, |c| c.0);
463 let point = stage_points(bp, cursor).and_then(|p| p.get(idx)).cloned();
464 let Some(point) = point else {
465 commands
467 .entity(entity)
468 .remove::<ReadyForInteractionPoint>()
469 .insert(ResolveTransition);
470 continue;
471 };
472 let user_revised = plan_override.is_some();
475 let body = plan_override
476 .map(|o| o.0.clone())
477 .unwrap_or_else(|| infer.response.clone());
478 if let Some(region) = &point.document_region
483 && !body.trim().is_empty()
484 {
485 let content = if user_revised {
486 format!("[revised by user - keep these changes]\n{body}")
487 } else {
488 body.clone()
489 };
490 let tokens = leviath_core::estimate_tokens(&content);
491 window.replace_region(region, content, tokens);
492 }
493 if auto_approve.is_some() {
497 tracing::info!(
498 agent = %state.agent_id,
499 point = %point.name,
500 "auto-approving interaction point (unattended run)"
501 );
502 let _ = stage.outcomes.send(InteractionPointOutcome {
503 entity,
504 decision: PointOutcome::Approve {
505 user_text: String::new(),
506 },
507 });
508 stage.wake.notify_one();
509 commands
510 .entity(entity)
511 .remove::<ReadyForInteractionPoint>()
512 .remove::<PlanBodyOverride>()
513 .insert(AwaitingInteractionPoint);
514 continue;
515 }
516 stage.runtime.spawn(run_interaction_point(
517 entity,
518 hub.clone(),
519 state.agent_id.clone(),
520 point,
521 body,
522 rounds.map_or(0, |r| r.0),
523 stage.outcomes.clone(),
524 stage.wake.clone(),
525 ));
526 commands
527 .entity(entity)
528 .remove::<ReadyForInteractionPoint>()
529 .remove::<PlanBodyOverride>()
530 .insert(AwaitingInteractionPoint);
531 }
532}
533
534#[allow(clippy::type_complexity)]
539pub fn collect_interaction_point(
540 mut results: ResMut<InteractionPointResults>,
541 mut agents: Query<
542 (
543 &mut AgentState,
544 &mut ContextWindow,
545 &AgentBlueprint,
546 &StageCursor,
547 Option<&InteractionPointCursor>,
548 Option<&InteractionPointRounds>,
549 Option<&mut StageIoBuffer>,
550 ),
551 With<AwaitingInteractionPoint>,
552 >,
553 mut commands: Commands,
554) {
555 crate::tick_scope::clear();
556 while let Ok(out) = results.0.try_recv() {
557 let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
558 agents.get_mut(out.entity)
559 else {
560 continue; };
562 crate::tick_scope::enter(out.entity);
563 if crate::pipeline::is_terminal_status(&state.status) {
570 commands
571 .entity(out.entity)
572 .remove::<AwaitingInteractionPoint>();
573 continue;
574 }
575 let idx = pc.map_or(0, |c| c.0);
576 let round = rounds.map_or(0, |r| r.0);
577 let (name, npoints) = match stage_points(bp, cursor) {
578 Some(points) => (
579 points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
580 points.len(),
581 ),
582 None => (String::new(), 0),
583 };
584
585 let mut e = commands.entity(out.entity);
586 e.remove::<AwaitingInteractionPoint>();
587
588 let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
591 e.insert(InteractionPointCursor(npoints))
592 .insert(ResolveTransition);
593 };
594
595 match out.decision {
596 PointOutcome::Abort => {
597 state.status = AgentStatus::Cancelled;
598 }
599 PointOutcome::Approve { user_text } => {
600 state.status = AgentStatus::Active;
601 inject(&mut window, &name, "", &user_text);
602 if round > 0 {
617 inject(
618 &mut window,
619 &name,
620 "",
621 "The plan above was revised before you approved it. Work from \
622 the approved text as written - any conclusion you reached \
623 from the earlier version, including that something is \
624 already done, may no longer hold and should be re-checked \
625 against the plan rather than assumed.",
626 );
627 }
628 let next = idx + 1;
629 if next >= npoints {
630 proceed(&mut e); } else {
632 e.insert(InteractionPointCursor(next))
633 .insert(InteractionPointRounds(0))
634 .insert(ReadyForInteractionPoint);
635 }
636 }
637 PointOutcome::Directive {
638 user_text,
639 directive,
640 } => {
641 state.status = AgentStatus::Active;
642 inject(&mut window, &name, "", &user_text);
643 if round + 1 >= MAX_REVISION_ROUNDS {
644 proceed(&mut e); } else {
646 inject(&mut window, &name, "directive: ", &directive);
648 e.insert(InteractionPointRounds(round + 1))
649 .insert(ReadyToInfer);
650 }
651 }
652 PointOutcome::Edit { user_text, edited } => {
653 state.status = AgentStatus::Active;
654 inject(&mut window, &name, "", &user_text);
655 if round + 1 >= MAX_REVISION_ROUNDS {
656 proceed(&mut e);
657 } else {
658 if !edited.is_empty() {
659 let note = format!(
660 "edited the output directly. Adopt this exact text as the \
661 authoritative version and re-present it:\n{edited}"
662 );
663 inject(&mut window, &name, "", ¬e);
664 if let Some(mut buf) = io_buf {
668 buf.output.push((
669 cursor.index,
670 format!("\n─── Updated (your edit) ───\n{edited}"),
671 ));
672 }
673 e.insert(PlanBodyOverride(edited));
676 }
677 e.insert(InteractionPointRounds(round + 1))
679 .insert(ReadyForInteractionPoint);
680 }
681 }
682 }
683 }
684}
685
686fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
689 if text.is_empty() {
690 return;
691 }
692 let content = format!("User [{name}] {prefix}{text}");
693 let tokens = leviath_core::estimate_tokens(&content);
694 let _ = window.add_to_region("conversation", content, tokens);
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::components::AgentStatus;
701 use leviath_core::interaction::InteractionResponse;
702 use leviath_core::{Region, RegionKind};
703 use tokio::sync::mpsc::unbounded_channel;
704
705 fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
708 InteractionPoint {
709 name: name.to_string(),
710 prompt: "Choose".to_string(),
711 required: true,
712 style,
713 options: options.iter().map(|s| s.to_string()).collect(),
714 directives: HashMap::new(),
715 abort_options: Vec::new(),
716 edit_options: Vec::new(),
717 document_region: None,
718 }
719 }
720
721 fn plan_point() -> InteractionPoint {
723 let mut p = point(
724 "plan_approval",
725 InteractionStyle::MultipleChoice,
726 &["Approve", "Revise", "Add detail", "Abort"],
727 );
728 p.directives
729 .insert("Revise".to_string(), "revise the plan".to_string());
730 p.abort_options = vec!["Abort".to_string()];
731 p.edit_options = vec!["Add detail".to_string()];
732 p.document_region = Some("plan".to_string());
733 p
734 }
735
736 fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
737 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
738 let mut stage = leviath_core::Stage::new(
739 "plan".to_string(),
740 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
741 );
742 stage.mode = StageMode::InteractivePoints { points };
743 let bp =
744 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
745 AgentBlueprint(bp)
746 }
747
748 fn noninteractive_bp() -> AgentBlueprint {
750 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
751 let stage = leviath_core::Stage::new(
752 "auto".to_string(),
753 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
754 );
755 AgentBlueprint(leviath_core::Blueprint::new(
756 "t".to_string(),
757 "d".to_string(),
758 vec![stage],
759 layout,
760 ))
761 }
762
763 fn agent_state(status: AgentStatus) -> AgentState {
764 AgentState {
765 agent_id: "run-1".to_string(),
766 current_stage: "plan".to_string(),
767 iteration: 1,
768 status,
769 spawned_children_ids: vec![],
770 pending_wait: None,
771 accepts_messages: true,
772 }
773 }
774
775 fn window() -> ContextWindow {
776 let mut w = ContextWindow::new(100_000);
777 w.add_region(Region::new(
778 "conversation".to_string(),
779 RegionKind::Clearable,
780 10_000,
781 ));
782 w
783 }
784
785 fn window_with_plan() -> ContextWindow {
786 let mut w = window();
787 w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
788 w
789 }
790
791 fn infer(text: &str) -> InferenceResult {
792 InferenceResult {
793 response: text.to_string(),
794 tool_calls: vec![],
795 tokens_used: 0,
796 timestamp: 0,
797 }
798 }
799
800 #[test]
803 fn normalize_folds_dashes_and_whitespace() {
804 assert_eq!(
805 normalize_for_followup("Revise \u{2014} now"),
806 "Revise - now"
807 );
808 assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
809 assert_eq!(normalize_for_followup(" x y "), "x y");
810 }
811
812 #[test]
813 fn option_matches_exact_normalized_and_miss() {
814 let opts = vec!["Abort \u{2014} now".to_string()];
815 assert!(option_matches(&opts, "Abort \u{2014} now")); assert!(option_matches(&opts, "Abort - now")); assert!(!option_matches(&opts, "Approve")); }
819
820 #[test]
821 fn lookup_directive_exact_normalized_and_none() {
822 let mut d = HashMap::new();
823 d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
824 assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
825 assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
826 assert_eq!(lookup_directive(&d, "Approve"), None);
827 }
828
829 #[test]
830 fn build_point_request_by_style() {
831 use leviath_core::interaction::InteractionKind;
832 let mc = build_point_request(
833 &point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
834 "id".to_string(),
835 "## Plan\n1. do it",
836 );
837 assert_eq!(mc.kind, InteractionKind::MultipleChoice);
838 assert_eq!(mc.options.len(), 2);
839 assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
841 assert_eq!(
842 mc.body_format,
843 leviath_core::interaction::BodyFormat::Markdown
844 );
845 let cf = build_point_request(
846 &point("p", InteractionStyle::Confirm, &[]),
847 "id".to_string(),
848 "",
849 );
850 assert_eq!(cf.kind, InteractionKind::Confirm);
851 assert_eq!(cf.body, None);
853 let ft = build_point_request(
854 &point("p", InteractionStyle::FreeText, &[]),
855 "id".to_string(),
856 " ",
857 );
858 assert_eq!(ft.kind, InteractionKind::FreeText);
859 assert_eq!(ft.body, None);
860 }
861
862 #[test]
863 fn resolve_answer_choice_index_fallback_and_value() {
864 let opts = vec!["A".to_string(), "B".to_string()];
865 let mut r = InteractionResponse::text("q", "");
866 r.choice_index = Some(1);
867 assert_eq!(resolve_answer(&r, &opts), "B"); r.choice_index = Some(9); r.value = Some("typed".to_string());
870 assert_eq!(resolve_answer(&r, &opts), "typed");
871 let empty = InteractionResponse::text("q", "");
872 assert_eq!(resolve_answer(&empty, &opts), ""); }
874
875 #[test]
876 fn route_answer_covers_all_four() {
877 let p = plan_point();
878 assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
879 assert_eq!(
880 route_answer(&p, "Add detail".to_string()),
881 Routed::Edit {
882 user_text: "Add detail".to_string()
883 }
884 );
885 assert_eq!(
886 route_answer(&p, "Revise".to_string()),
887 Routed::Directive {
888 user_text: "Revise".to_string(),
889 directive: "revise the plan".to_string(),
890 }
891 );
892 assert_eq!(
893 route_answer(&p, "Approve".to_string()),
894 Routed::Approve {
895 user_text: "Approve".to_string()
896 }
897 );
898 }
899
900 #[test]
901 fn inject_skips_empty_and_appends_nonempty() {
902 let mut w = window();
903 inject(&mut w, "plan", "", "");
904 assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
905 inject(&mut w, "plan", "directive: ", "do x");
906 assert!(w.get_region("conversation").unwrap().current_tokens > 0);
907 }
908
909 #[test]
910 fn stage_points_some_for_interactive_none_otherwise() {
911 let bp = blueprint_with(vec![plan_point()]);
912 assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
913 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
915 let stage = leviath_core::Stage::new(
916 "auto".to_string(),
917 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
918 );
919 let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
920 "t".to_string(),
921 "d".to_string(),
922 vec![stage],
923 layout,
924 ));
925 assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
926 }
927
928 fn run_gate(world: &mut World) {
931 let mut s = Schedule::default();
932 s.add_systems(gate_interaction_points);
933 s.run(world);
934 }
935
936 #[test]
937 fn gate_intercepts_unsatisfied_interactive_stage() {
938 let mut world = World::new();
939 let e = world
940 .spawn((
941 blueprint_with(vec![plan_point()]),
942 StageCursor { index: 0 },
943 ResolveTransition,
944 ))
945 .id();
946 run_gate(&mut world);
947 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
948 assert!(world.get::<ResolveTransition>(e).is_none());
949 }
950
951 #[test]
952 fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
953 let mut world = World::new();
954 let done = world
956 .spawn((
957 blueprint_with(vec![plan_point()]),
958 StageCursor { index: 0 },
959 InteractionPointCursor(1),
960 ResolveTransition,
961 ))
962 .id();
963 let empty = world
965 .spawn((
966 blueprint_with(vec![]),
967 StageCursor { index: 0 },
968 ResolveTransition,
969 ))
970 .id();
971 let auto = world
973 .spawn((
974 noninteractive_bp(),
975 StageCursor { index: 0 },
976 ResolveTransition,
977 ))
978 .id();
979 run_gate(&mut world);
980 assert!(world.get::<ResolveTransition>(done).is_some());
981 assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
982 assert!(world.get::<ResolveTransition>(empty).is_some());
983 assert!(world.get::<ResolveTransition>(auto).is_some());
984 assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
985 }
986
987 #[tokio::test]
990 async fn dispatch_noop_without_hub_or_stage() {
991 let mut world = World::new();
992 let e = world
993 .spawn((
994 agent_state(AgentStatus::Active),
995 blueprint_with(vec![plan_point()]),
996 StageCursor { index: 0 },
997 infer("plan"),
998 ReadyForInteractionPoint,
999 ))
1000 .id();
1001 let mut s = Schedule::default();
1003 s.add_systems(dispatch_interaction_point);
1004 s.run(&mut world);
1005 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1007
1008 fn dispatch_world() -> (World, InteractionHub) {
1009 let hub = InteractionHub::new();
1010 let (tx, _rx) = unbounded_channel();
1011 let mut world = World::new();
1012 world.insert_resource(hub.clone());
1013 world.insert_resource(InteractionPointStage {
1014 outcomes: tx,
1015 wake: Arc::new(Notify::new()),
1016 runtime: Handle::current(),
1017 });
1018 (world, hub)
1019 }
1020
1021 #[tokio::test]
1022 async fn dispatch_skips_non_active_agent() {
1023 let (mut world, _hub) = dispatch_world();
1024 let e = world
1025 .spawn((
1026 agent_state(AgentStatus::Waiting),
1027 blueprint_with(vec![plan_point()]),
1028 window_with_plan(),
1029 StageCursor { index: 0 },
1030 infer("plan"),
1031 ReadyForInteractionPoint,
1032 ))
1033 .id();
1034 let mut s = Schedule::default();
1035 s.add_systems(dispatch_interaction_point);
1036 s.run(&mut world);
1037 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1039
1040 #[tokio::test]
1041 async fn dispatch_falls_through_when_point_missing() {
1042 let (mut world, _hub) = dispatch_world();
1043 let e = world
1045 .spawn((
1046 agent_state(AgentStatus::Active),
1047 blueprint_with(vec![plan_point()]),
1048 window_with_plan(),
1049 StageCursor { index: 0 },
1050 InteractionPointCursor(5),
1051 infer("plan"),
1052 ReadyForInteractionPoint,
1053 ))
1054 .id();
1055 let mut s = Schedule::default();
1056 s.add_systems(dispatch_interaction_point);
1057 s.run(&mut world);
1058 assert!(world.get::<ResolveTransition>(e).is_some());
1059 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1060 }
1061
1062 #[tokio::test]
1063 async fn dispatch_spawns_ask_and_awaits() {
1064 let (mut world, hub) = dispatch_world();
1065 let e = world
1066 .spawn((
1067 agent_state(AgentStatus::Active),
1068 blueprint_with(vec![plan_point()]),
1069 window_with_plan(),
1070 StageCursor { index: 0 },
1071 infer("the plan"),
1072 ReadyForInteractionPoint,
1073 ))
1074 .id();
1075 let mut s = Schedule::default();
1076 s.add_systems(dispatch_interaction_point);
1077 s.run(&mut world);
1078 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1079 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1080 for _ in 0..8 {
1083 tokio::task::yield_now().await;
1084 }
1085 let pending = hub.pending();
1086 assert_eq!(pending.len(), 1);
1087 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1088 let plan = world
1091 .get::<ContextWindow>(e)
1092 .unwrap()
1093 .get_region("plan")
1094 .unwrap();
1095 assert_eq!(plan.content.len(), 1);
1096 assert_eq!(plan.content[0].content, "the plan");
1097 }
1098
1099 #[tokio::test]
1100 async fn dispatch_auto_approves_an_unattended_run_without_asking() {
1101 let hub = InteractionHub::new();
1104 let (tx, mut rx) = unbounded_channel();
1105 let mut world = World::new();
1106 world.insert_resource(hub.clone());
1107 world.insert_resource(InteractionPointStage {
1108 outcomes: tx,
1109 wake: Arc::new(Notify::new()),
1110 runtime: Handle::current(),
1111 });
1112 let e = world
1113 .spawn((
1114 agent_state(AgentStatus::Active),
1115 blueprint_with(vec![plan_point()]),
1116 window_with_plan(),
1117 StageCursor { index: 0 },
1118 infer("the plan"),
1119 ReadyForInteractionPoint,
1120 crate::components::InteractionAutoApprove,
1121 ))
1122 .id();
1123 let mut s = Schedule::default();
1124 s.add_systems(dispatch_interaction_point);
1125 s.run(&mut world);
1126
1127 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1128 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1129 let outcome = rx.try_recv().expect("an outcome was published");
1131 assert_eq!(outcome.entity, e);
1132 assert!(matches!(
1133 outcome.decision,
1134 PointOutcome::Approve { ref user_text } if user_text.is_empty()
1135 ));
1136 for _ in 0..8 {
1137 tokio::task::yield_now().await;
1138 }
1139 assert!(hub.pending().is_empty(), "no human was asked");
1140 let plan = world
1143 .get::<ContextWindow>(e)
1144 .unwrap()
1145 .get_region("plan")
1146 .unwrap();
1147 assert_eq!(plan.content[0].content, "the plan");
1148 }
1149
1150 #[tokio::test]
1151 async fn dispatch_without_document_region_skips_region_write() {
1152 let (mut world, _hub) = dispatch_world();
1154 let e = world
1155 .spawn((
1156 agent_state(AgentStatus::Active),
1157 blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
1158 window_with_plan(),
1159 StageCursor { index: 0 },
1160 infer("some output"),
1161 ReadyForInteractionPoint,
1162 ))
1163 .id();
1164 let mut s = Schedule::default();
1165 s.add_systems(dispatch_interaction_point);
1166 s.run(&mut world);
1167 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1168 let plan = world
1170 .get::<ContextWindow>(e)
1171 .unwrap()
1172 .get_region("plan")
1173 .unwrap();
1174 assert!(plan.content.is_empty());
1175 }
1176
1177 #[tokio::test]
1178 async fn dispatch_with_empty_document_skips_region_write() {
1179 let (mut world, _hub) = dispatch_world();
1181 let e = world
1182 .spawn((
1183 agent_state(AgentStatus::Active),
1184 blueprint_with(vec![plan_point()]),
1185 window_with_plan(),
1186 StageCursor { index: 0 },
1187 infer(" "),
1188 ReadyForInteractionPoint,
1189 ))
1190 .id();
1191 let mut s = Schedule::default();
1192 s.add_systems(dispatch_interaction_point);
1193 s.run(&mut world);
1194 let plan = world
1195 .get::<ContextWindow>(e)
1196 .unwrap()
1197 .get_region("plan")
1198 .unwrap();
1199 assert!(plan.content.is_empty());
1200 }
1201
1202 #[tokio::test]
1203 async fn dispatch_prefers_the_plan_body_override() {
1204 let (mut world, hub) = dispatch_world();
1205 let e = world
1206 .spawn((
1207 agent_state(AgentStatus::Active),
1208 blueprint_with(vec![plan_point()]),
1209 window_with_plan(),
1210 StageCursor { index: 0 },
1211 infer("the stale pre-edit plan"),
1212 PlanBodyOverride("the edited plan".to_string()),
1213 ReadyForInteractionPoint,
1214 ))
1215 .id();
1216 let mut s = Schedule::default();
1217 s.add_systems(dispatch_interaction_point);
1218 s.run(&mut world);
1219 assert!(world.get::<PlanBodyOverride>(e).is_none());
1221 let plan = world
1224 .get::<ContextWindow>(e)
1225 .unwrap()
1226 .get_region("plan")
1227 .unwrap();
1228 assert_eq!(plan.content.len(), 1);
1229 assert!(plan.content[0].content.contains("[revised by user"));
1230 assert!(plan.content[0].content.contains("the edited plan"));
1231 for _ in 0..8 {
1232 tokio::task::yield_now().await;
1233 }
1234 assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1236 }
1237
1238 fn collect_world() -> (
1241 World,
1242 tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
1243 ) {
1244 let (tx, rx) = unbounded_channel();
1245 let mut world = World::new();
1246 world.insert_resource(InteractionPointResults(rx));
1247 (world, tx)
1248 }
1249
1250 fn run_collect(world: &mut World) {
1251 let mut s = Schedule::default();
1252 s.add_systems(collect_interaction_point);
1253 s.run(world);
1254 }
1255
1256 fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
1257 world
1258 .spawn((
1259 agent_state(AgentStatus::Waiting),
1260 window(),
1261 blueprint_with(points),
1262 StageCursor { index: 0 },
1263 AwaitingInteractionPoint,
1264 ))
1265 .id()
1266 }
1267
1268 #[test]
1269 fn collect_approve_single_point_proceeds() {
1270 let (mut world, tx) = collect_world();
1271 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1272 tx.send(InteractionPointOutcome {
1273 entity: e,
1274 decision: PointOutcome::Approve {
1275 user_text: "Approve".to_string(),
1276 },
1277 })
1278 .unwrap();
1279 run_collect(&mut world);
1280 assert!(world.get::<ResolveTransition>(e).is_some());
1281 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1282 assert_eq!(
1283 world.get::<AgentState>(e).unwrap().status,
1284 AgentStatus::Active
1285 );
1286 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1287 }
1288
1289 #[test]
1290 fn collect_approve_advances_to_next_point() {
1291 let (mut world, tx) = collect_world();
1292 let e = spawn_awaiting(
1293 &mut world,
1294 vec![
1295 point("first", InteractionStyle::Confirm, &[]),
1296 point("second", InteractionStyle::Confirm, &[]),
1297 ],
1298 );
1299 tx.send(InteractionPointOutcome {
1300 entity: e,
1301 decision: PointOutcome::Approve {
1302 user_text: String::new(),
1303 },
1304 })
1305 .unwrap();
1306 run_collect(&mut world);
1307 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1308 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1309 assert!(world.get::<ResolveTransition>(e).is_none());
1310 }
1311
1312 #[test]
1313 fn collect_abort_cancels() {
1314 let (mut world, tx) = collect_world();
1315 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1316 tx.send(InteractionPointOutcome {
1317 entity: e,
1318 decision: PointOutcome::Abort,
1319 })
1320 .unwrap();
1321 run_collect(&mut world);
1322 assert_eq!(
1323 world.get::<AgentState>(e).unwrap().status,
1324 AgentStatus::Cancelled
1325 );
1326 assert!(world.get::<ResolveTransition>(e).is_none());
1327 }
1328
1329 #[test]
1335 fn collect_does_not_resurrect_a_cancelled_run() {
1336 for decision in [
1337 PointOutcome::Approve {
1338 user_text: "ok".to_string(),
1339 },
1340 PointOutcome::Directive {
1341 user_text: "go".to_string(),
1342 directive: "d".to_string(),
1343 },
1344 PointOutcome::Edit {
1345 user_text: "go".to_string(),
1346 edited: "body".to_string(),
1347 },
1348 ] {
1349 let (mut world, tx) = collect_world();
1350 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1351 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
1352
1353 tx.send(InteractionPointOutcome {
1354 entity: e,
1355 decision,
1356 })
1357 .unwrap();
1358 run_collect(&mut world);
1359
1360 assert_eq!(
1361 world.get::<AgentState>(e).unwrap().status,
1362 AgentStatus::Cancelled,
1363 "the run stays cancelled"
1364 );
1365 assert!(
1366 world.get::<AwaitingInteractionPoint>(e).is_none(),
1367 "the awaiting marker is still cleared, so nothing re-collects it"
1368 );
1369 assert!(
1370 world.get::<ResolveTransition>(e).is_none()
1371 && world.get::<ReadyToInfer>(e).is_none()
1372 && world.get::<ReadyForInteractionPoint>(e).is_none(),
1373 "and it is not queued for any further work"
1374 );
1375 }
1376 }
1377
1378 #[test]
1379 fn collect_directive_reinfers_then_caps() {
1380 let (mut world, tx) = collect_world();
1381 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1382 tx.send(InteractionPointOutcome {
1383 entity: e,
1384 decision: PointOutcome::Directive {
1385 user_text: "Revise".to_string(),
1386 directive: "do it".to_string(),
1387 },
1388 })
1389 .unwrap();
1390 run_collect(&mut world);
1391 assert!(world.get::<ReadyToInfer>(e).is_some());
1392 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1393 assert!(world.get::<ResolveTransition>(e).is_none());
1394
1395 world
1397 .entity_mut(e)
1398 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1399 .insert(AwaitingInteractionPoint);
1400 tx.send(InteractionPointOutcome {
1401 entity: e,
1402 decision: PointOutcome::Directive {
1403 user_text: String::new(),
1404 directive: "again".to_string(),
1405 },
1406 })
1407 .unwrap();
1408 run_collect(&mut world);
1409 assert!(world.get::<ResolveTransition>(e).is_some());
1410 }
1411
1412 #[test]
1413 fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
1414 let (mut world, tx) = collect_world();
1415 let e = world
1416 .spawn((
1417 agent_state(AgentStatus::Waiting),
1418 window(),
1419 blueprint_with(vec![plan_point()]),
1420 StageCursor { index: 0 },
1421 AwaitingInteractionPoint,
1422 StageIoBuffer::default(),
1423 ))
1424 .id();
1425 tx.send(InteractionPointOutcome {
1426 entity: e,
1427 decision: PointOutcome::Edit {
1428 user_text: "Add detail".to_string(),
1429 edited: "the revised plan".to_string(),
1430 },
1431 })
1432 .unwrap();
1433 run_collect(&mut world);
1434 let buf = world.get::<StageIoBuffer>(e).unwrap();
1437 assert_eq!(buf.output.len(), 1);
1438 assert_eq!(buf.output[0].0, 0);
1439 assert!(buf.output[0].1.contains("the revised plan"));
1440 assert_eq!(
1442 world.get::<PlanBodyOverride>(e).unwrap().0,
1443 "the revised plan"
1444 );
1445 }
1446
1447 #[test]
1452 fn collect_approve_after_a_revision_says_the_plan_changed() {
1453 let (mut world, tx) = collect_world();
1454
1455 let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
1456 let revised = spawn_awaiting(&mut world, vec![plan_point()]);
1457 world.entity_mut(revised).insert(InteractionPointRounds(2));
1458
1459 for e in [first_try, revised] {
1460 tx.send(InteractionPointOutcome {
1461 entity: e,
1462 decision: PointOutcome::Approve {
1463 user_text: "Approve".to_string(),
1464 },
1465 })
1466 .unwrap();
1467 }
1468 run_collect(&mut world);
1469
1470 let plain = world
1471 .get::<ContextWindow>(first_try)
1472 .unwrap()
1473 .current_tokens;
1474 let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
1475 assert!(
1476 noted > plain,
1477 "a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
1478 );
1479 }
1480
1481 #[test]
1482 fn collect_edit_represents_then_caps() {
1483 let (mut world, tx) = collect_world();
1484 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1485 tx.send(InteractionPointOutcome {
1486 entity: e,
1487 decision: PointOutcome::Edit {
1488 user_text: "Add detail".to_string(),
1489 edited: "the edited plan".to_string(),
1490 },
1491 })
1492 .unwrap();
1493 run_collect(&mut world);
1494 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1495 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1496 let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1498 assert!(after_first > 0);
1499
1500 world
1502 .entity_mut(e)
1503 .insert(InteractionPointRounds(0))
1504 .insert(AwaitingInteractionPoint);
1505 tx.send(InteractionPointOutcome {
1506 entity: e,
1507 decision: PointOutcome::Edit {
1508 user_text: String::new(),
1509 edited: String::new(),
1510 },
1511 })
1512 .unwrap();
1513 run_collect(&mut world);
1514 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1515 assert_eq!(
1516 world.get::<ContextWindow>(e).unwrap().current_tokens,
1517 after_first
1518 );
1519
1520 world
1522 .entity_mut(e)
1523 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1524 .insert(AwaitingInteractionPoint);
1525 tx.send(InteractionPointOutcome {
1526 entity: e,
1527 decision: PointOutcome::Edit {
1528 user_text: String::new(),
1529 edited: String::new(), },
1531 })
1532 .unwrap();
1533 run_collect(&mut world);
1534 assert!(world.get::<ResolveTransition>(e).is_some());
1535 }
1536
1537 #[test]
1538 fn collect_on_noninteractive_stage_proceeds() {
1539 let (mut world, tx) = collect_world();
1542 let e = world
1543 .spawn((
1544 agent_state(AgentStatus::Waiting),
1545 window(),
1546 noninteractive_bp(),
1547 StageCursor { index: 0 },
1548 AwaitingInteractionPoint,
1549 ))
1550 .id();
1551 tx.send(InteractionPointOutcome {
1552 entity: e,
1553 decision: PointOutcome::Approve {
1554 user_text: String::new(),
1555 },
1556 })
1557 .unwrap();
1558 run_collect(&mut world);
1559 assert!(world.get::<ResolveTransition>(e).is_some());
1560 }
1561
1562 #[test]
1563 fn collect_drops_outcome_for_missing_agent() {
1564 let (mut world, tx) = collect_world();
1565 tx.send(InteractionPointOutcome {
1566 entity: Entity::from_raw_u32(999)
1567 .expect("a small literal index is always a valid entity id"),
1568 decision: PointOutcome::Abort,
1569 })
1570 .unwrap();
1571 run_collect(&mut world); }
1573
1574 async fn drive_point(
1577 point: InteractionPoint,
1578 answer: impl FnOnce(&InteractionHub, String),
1579 ) -> PointOutcome {
1580 let hub = InteractionHub::new();
1581 let (tx, mut rx) = unbounded_channel();
1582 let task = {
1583 let hub = hub.clone();
1584 tokio::spawn(run_interaction_point(
1585 Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1586 hub,
1587 "run".to_string(),
1588 point,
1589 "body".to_string(),
1590 0,
1591 tx,
1592 Arc::new(Notify::new()),
1593 ))
1594 };
1595 for _ in 0..8 {
1596 tokio::task::yield_now().await;
1597 }
1598 let id = hub.pending()[0].1.id.clone();
1599 answer(&hub, id);
1600 task.await.unwrap();
1601 rx.recv().await.unwrap().decision
1602 }
1603
1604 #[tokio::test]
1605 async fn run_point_approve() {
1606 let out = drive_point(plan_point(), |hub, id| {
1607 let mut r = InteractionResponse::text(&id, "");
1608 r.choice_index = Some(0); hub.answer(r);
1610 })
1611 .await;
1612 assert_eq!(
1613 out,
1614 PointOutcome::Approve {
1615 user_text: "Approve".to_string()
1616 }
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn run_point_abort_and_directive() {
1622 let abort = drive_point(plan_point(), |hub, id| {
1623 let mut r = InteractionResponse::text(&id, "");
1624 r.choice_index = Some(3); hub.answer(r);
1626 })
1627 .await;
1628 assert_eq!(abort, PointOutcome::Abort);
1629
1630 let directive = drive_point(plan_point(), |hub, id| {
1631 let mut r = InteractionResponse::text(&id, "");
1632 r.choice_index = Some(1); hub.answer(r);
1634 })
1635 .await;
1636 assert_eq!(
1637 directive,
1638 PointOutcome::Directive {
1639 user_text: "Revise".to_string(),
1640 directive: "revise the plan".to_string(),
1641 }
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn run_point_edit_does_second_ask() {
1647 let hub = InteractionHub::new();
1649 let (tx, mut rx) = unbounded_channel();
1650 let task = {
1651 let hub = hub.clone();
1652 tokio::spawn(run_interaction_point(
1653 Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1654 hub,
1655 "run".to_string(),
1656 plan_point(),
1657 "body".to_string(),
1658 0,
1659 tx,
1660 Arc::new(Notify::new()),
1661 ))
1662 };
1663 for _ in 0..8 {
1665 tokio::task::yield_now().await;
1666 }
1667 let id = hub.pending()[0].1.id.clone();
1668 let mut r = InteractionResponse::text(&id, "");
1669 r.choice_index = Some(2); hub.answer(r);
1671 for _ in 0..8 {
1673 tokio::task::yield_now().await;
1674 }
1675 let edit_id = hub.pending()[0].1.id.clone();
1676 hub.answer(InteractionResponse::text(&edit_id, "edited body"));
1677 task.await.unwrap();
1678 assert_eq!(
1679 rx.recv().await.unwrap().decision,
1680 PointOutcome::Edit {
1681 user_text: "Add detail".to_string(),
1682 edited: "edited body".to_string(),
1683 }
1684 );
1685 }
1686
1687 #[test]
1690 fn interaction_point_state_round_trips() {
1691 let s = InteractionPointState {
1692 cursor: 2,
1693 round: 1,
1694 body: "# Plan\n1. do it".to_string(),
1695 };
1696 let json = serde_json::to_string(&s).unwrap();
1697 assert_eq!(
1698 serde_json::from_str::<InteractionPointState>(&json).unwrap(),
1699 s
1700 );
1701 }
1702
1703 fn resume_world() -> (
1706 World,
1707 InteractionHub,
1708 UnboundedReceiver<InteractionPointOutcome>,
1709 ) {
1710 let hub = InteractionHub::new();
1711 let (tx, rx) = unbounded_channel();
1712 let mut world = World::new();
1713 world.insert_resource(hub.clone());
1714 world.insert_resource(InteractionPointStage {
1715 outcomes: tx,
1716 wake: Arc::new(Notify::new()),
1717 runtime: Handle::current(),
1718 });
1719 (world, hub, rx)
1720 }
1721
1722 fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
1725 world
1726 .spawn((
1727 agent_state(AgentStatus::Active),
1728 bp,
1729 window_with_plan(),
1730 StageCursor { index: 0 },
1731 ReadyToInfer,
1732 ))
1733 .id()
1734 }
1735
1736 #[tokio::test]
1737 async fn restore_rearms_waiting_and_reopens_the_prompt() {
1738 let (mut world, hub, _rx) = resume_world();
1739 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1740 restore_interaction_point(
1741 &mut world,
1742 e,
1743 InteractionPointState {
1744 cursor: 0,
1745 round: 2,
1746 body: "the plan".to_string(),
1747 },
1748 );
1749
1750 assert_eq!(
1752 world.get::<AgentState>(e).unwrap().status,
1753 AgentStatus::Waiting
1754 );
1755 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1756 assert!(world.get::<ReadyToInfer>(e).is_none());
1757 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
1758 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
1759
1760 for _ in 0..8 {
1762 tokio::task::yield_now().await;
1763 }
1764 let pending = hub.pending();
1765 assert_eq!(pending.len(), 1);
1766 assert_eq!(pending[0].0, "run-1");
1767 assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
1768 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1769 }
1770
1771 #[tokio::test]
1772 async fn restore_then_answer_drives_the_transition() {
1773 let (mut world, hub, mut rx) = resume_world();
1774 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1775 restore_interaction_point(
1776 &mut world,
1777 e,
1778 InteractionPointState {
1779 cursor: 0,
1780 round: 0,
1781 body: "the plan".to_string(),
1782 },
1783 );
1784 for _ in 0..8 {
1785 tokio::task::yield_now().await;
1786 }
1787
1788 let id = hub.pending()[0].1.id.clone();
1790 let mut r = InteractionResponse::text(&id, "");
1791 r.choice_index = Some(0); assert!(hub.answer(r));
1793 let outcome = rx.recv().await.unwrap();
1794
1795 let (tx2, rx2) = unbounded_channel();
1797 tx2.send(outcome).unwrap();
1798 world.insert_resource(InteractionPointResults(rx2));
1799 let mut s = Schedule::default();
1800 s.add_systems(collect_interaction_point);
1801 s.run(&mut world);
1802
1803 assert!(world.get::<ResolveTransition>(e).is_some());
1804 assert_eq!(
1805 world.get::<AgentState>(e).unwrap().status,
1806 AgentStatus::Active
1807 );
1808 }
1809
1810 #[tokio::test]
1811 async fn restore_noop_on_noninteractive_stage() {
1812 let (mut world, hub, _rx) = resume_world();
1813 let e = restored_agent(&mut world, noninteractive_bp());
1814 restore_interaction_point(
1815 &mut world,
1816 e,
1817 InteractionPointState {
1818 cursor: 0,
1819 round: 0,
1820 body: "x".to_string(),
1821 },
1822 );
1823 assert_eq!(
1825 world.get::<AgentState>(e).unwrap().status,
1826 AgentStatus::Active
1827 );
1828 assert!(world.get::<ReadyToInfer>(e).is_some());
1829 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1830 for _ in 0..8 {
1831 tokio::task::yield_now().await;
1832 }
1833 assert!(hub.pending().is_empty());
1834 }
1835
1836 #[tokio::test]
1837 async fn restore_noop_without_lane_wired() {
1838 let mut world = World::new();
1840 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1841 restore_interaction_point(
1842 &mut world,
1843 e,
1844 InteractionPointState {
1845 cursor: 0,
1846 round: 0,
1847 body: "x".to_string(),
1848 },
1849 );
1850 assert_eq!(
1851 world.get::<AgentState>(e).unwrap().status,
1852 AgentStatus::Active
1853 );
1854 assert!(world.get::<ReadyToInfer>(e).is_some());
1855 }
1856}