1use std::collections::HashMap;
29use std::sync::Arc;
30
31use bevy_ecs::prelude::*;
32use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode, UnattendedPolicy};
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() && point.unattended == UnattendedPolicy::AutoApprove {
503 tracing::info!(
504 agent = %state.agent_id,
505 point = %point.name,
506 "auto-approving interaction point (unattended run)"
507 );
508 let _ = stage.outcomes.send(InteractionPointOutcome {
509 entity,
510 decision: PointOutcome::Approve {
511 user_text: String::new(),
512 },
513 });
514 stage.wake.notify_one();
515 commands
516 .entity(entity)
517 .remove::<ReadyForInteractionPoint>()
518 .remove::<PlanBodyOverride>()
519 .insert(AwaitingInteractionPoint);
520 continue;
521 }
522 stage.runtime.spawn(run_interaction_point(
523 entity,
524 hub.clone(),
525 state.agent_id.clone(),
526 point,
527 body,
528 rounds.map_or(0, |r| r.0),
529 stage.outcomes.clone(),
530 stage.wake.clone(),
531 ));
532 commands
533 .entity(entity)
534 .remove::<ReadyForInteractionPoint>()
535 .remove::<PlanBodyOverride>()
536 .insert(AwaitingInteractionPoint);
537 }
538}
539
540#[allow(clippy::type_complexity)]
545pub fn collect_interaction_point(
546 mut results: ResMut<InteractionPointResults>,
547 mut agents: Query<
548 (
549 &mut AgentState,
550 &mut ContextWindow,
551 &AgentBlueprint,
552 &StageCursor,
553 Option<&InteractionPointCursor>,
554 Option<&InteractionPointRounds>,
555 Option<&mut StageIoBuffer>,
556 ),
557 With<AwaitingInteractionPoint>,
558 >,
559 mut commands: Commands,
560) {
561 crate::tick_scope::clear();
562 while let Ok(out) = results.0.try_recv() {
563 let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
564 agents.get_mut(out.entity)
565 else {
566 continue; };
568 crate::tick_scope::enter(out.entity);
569 if crate::pipeline::is_terminal_status(&state.status) {
576 commands
577 .entity(out.entity)
578 .remove::<AwaitingInteractionPoint>();
579 continue;
580 }
581 let idx = pc.map_or(0, |c| c.0);
582 let round = rounds.map_or(0, |r| r.0);
583 let (name, npoints) = match stage_points(bp, cursor) {
584 Some(points) => (
585 points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
586 points.len(),
587 ),
588 None => (String::new(), 0),
589 };
590
591 let mut e = commands.entity(out.entity);
592 e.remove::<AwaitingInteractionPoint>();
593
594 let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
597 e.insert(InteractionPointCursor(npoints))
598 .insert(ResolveTransition);
599 };
600
601 match out.decision {
602 PointOutcome::Abort => {
603 state.status = AgentStatus::Cancelled;
604 }
605 PointOutcome::Approve { user_text } => {
606 state.status = AgentStatus::Active;
607 inject(&mut window, &name, "", &user_text);
608 if round > 0 {
623 inject(
624 &mut window,
625 &name,
626 "",
627 "The plan above was revised before you approved it. Work from \
628 the approved text as written - any conclusion you reached \
629 from the earlier version, including that something is \
630 already done, may no longer hold and should be re-checked \
631 against the plan rather than assumed.",
632 );
633 }
634 let next = idx + 1;
635 if next >= npoints {
636 proceed(&mut e); } else {
638 e.insert(InteractionPointCursor(next))
639 .insert(InteractionPointRounds(0))
640 .insert(ReadyForInteractionPoint);
641 }
642 }
643 PointOutcome::Directive {
644 user_text,
645 directive,
646 } => {
647 state.status = AgentStatus::Active;
648 inject(&mut window, &name, "", &user_text);
649 if round + 1 >= MAX_REVISION_ROUNDS {
650 proceed(&mut e); } else {
652 inject(&mut window, &name, "directive: ", &directive);
654 e.insert(InteractionPointRounds(round + 1))
655 .insert(ReadyToInfer);
656 }
657 }
658 PointOutcome::Edit { user_text, edited } => {
659 state.status = AgentStatus::Active;
660 inject(&mut window, &name, "", &user_text);
661 if round + 1 >= MAX_REVISION_ROUNDS {
662 proceed(&mut e);
663 } else {
664 if !edited.is_empty() {
665 let note = format!(
666 "edited the output directly. Adopt this exact text as the \
667 authoritative version and re-present it:\n{edited}"
668 );
669 inject(&mut window, &name, "", ¬e);
670 if let Some(mut buf) = io_buf {
674 buf.output.push((
675 cursor.index,
676 format!("\n─── Updated (your edit) ───\n{edited}"),
677 ));
678 }
679 e.insert(PlanBodyOverride(edited));
682 }
683 e.insert(InteractionPointRounds(round + 1))
685 .insert(ReadyForInteractionPoint);
686 }
687 }
688 }
689 }
690}
691
692fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
695 if text.is_empty() {
696 return;
697 }
698 let content = format!("User [{name}] {prefix}{text}");
699 let tokens = leviath_core::estimate_tokens(&content);
700 let _ = window.add_to_region("conversation", content, tokens);
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::components::AgentStatus;
707 use leviath_core::interaction::InteractionResponse;
708 use leviath_core::{Region, RegionKind};
709 use tokio::sync::mpsc::unbounded_channel;
710
711 fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
714 InteractionPoint {
715 name: name.to_string(),
716 prompt: "Choose".to_string(),
717 required: true,
718 unattended: UnattendedPolicy::AutoApprove,
719 style,
720 options: options.iter().map(|s| s.to_string()).collect(),
721 directives: HashMap::new(),
722 abort_options: Vec::new(),
723 edit_options: Vec::new(),
724 document_region: None,
725 }
726 }
727
728 fn plan_point() -> InteractionPoint {
730 let mut p = point(
731 "plan_approval",
732 InteractionStyle::MultipleChoice,
733 &["Approve", "Revise", "Add detail", "Abort"],
734 );
735 p.directives
736 .insert("Revise".to_string(), "revise the plan".to_string());
737 p.abort_options = vec!["Abort".to_string()];
738 p.edit_options = vec!["Add detail".to_string()];
739 p.document_region = Some("plan".to_string());
740 p
741 }
742
743 fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
744 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
745 let mut stage = leviath_core::Stage::new(
746 "plan".to_string(),
747 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
748 );
749 stage.mode = StageMode::InteractivePoints { points };
750 let bp =
751 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
752 AgentBlueprint(bp)
753 }
754
755 fn noninteractive_bp() -> AgentBlueprint {
757 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
758 let stage = leviath_core::Stage::new(
759 "auto".to_string(),
760 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
761 );
762 AgentBlueprint(leviath_core::Blueprint::new(
763 "t".to_string(),
764 "d".to_string(),
765 vec![stage],
766 layout,
767 ))
768 }
769
770 fn agent_state(status: AgentStatus) -> AgentState {
771 AgentState {
772 agent_id: "run-1".to_string(),
773 current_stage: "plan".to_string(),
774 iteration: 1,
775 status,
776 spawned_children_ids: vec![],
777 pending_wait: None,
778 accepts_messages: true,
779 }
780 }
781
782 fn window() -> ContextWindow {
783 let mut w = ContextWindow::new(100_000);
784 w.add_region(Region::new(
785 "conversation".to_string(),
786 RegionKind::Clearable,
787 10_000,
788 ));
789 w
790 }
791
792 fn window_with_plan() -> ContextWindow {
793 let mut w = window();
794 w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
795 w
796 }
797
798 fn infer(text: &str) -> InferenceResult {
799 InferenceResult {
800 response: text.to_string(),
801 tool_calls: vec![],
802 tokens_used: 0,
803 timestamp: 0,
804 }
805 }
806
807 #[test]
810 fn normalize_folds_dashes_and_whitespace() {
811 assert_eq!(
812 normalize_for_followup("Revise \u{2014} now"),
813 "Revise - now"
814 );
815 assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
816 assert_eq!(normalize_for_followup(" x y "), "x y");
817 }
818
819 #[test]
820 fn option_matches_exact_normalized_and_miss() {
821 let opts = vec!["Abort \u{2014} now".to_string()];
822 assert!(option_matches(&opts, "Abort \u{2014} now")); assert!(option_matches(&opts, "Abort - now")); assert!(!option_matches(&opts, "Approve")); }
826
827 #[test]
828 fn lookup_directive_exact_normalized_and_none() {
829 let mut d = HashMap::new();
830 d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
831 assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
832 assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
833 assert_eq!(lookup_directive(&d, "Approve"), None);
834 }
835
836 #[test]
837 fn build_point_request_by_style() {
838 use leviath_core::interaction::InteractionKind;
839 let mc = build_point_request(
840 &point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
841 "id".to_string(),
842 "## Plan\n1. do it",
843 );
844 assert_eq!(mc.kind, InteractionKind::MultipleChoice);
845 assert_eq!(mc.options.len(), 2);
846 assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
848 assert_eq!(
849 mc.body_format,
850 leviath_core::interaction::BodyFormat::Markdown
851 );
852 let cf = build_point_request(
853 &point("p", InteractionStyle::Confirm, &[]),
854 "id".to_string(),
855 "",
856 );
857 assert_eq!(cf.kind, InteractionKind::Confirm);
858 assert_eq!(cf.body, None);
860 let ft = build_point_request(
861 &point("p", InteractionStyle::FreeText, &[]),
862 "id".to_string(),
863 " ",
864 );
865 assert_eq!(ft.kind, InteractionKind::FreeText);
866 assert_eq!(ft.body, None);
867 }
868
869 #[test]
870 fn resolve_answer_choice_index_fallback_and_value() {
871 let opts = vec!["A".to_string(), "B".to_string()];
872 let mut r = InteractionResponse::text("q", "");
873 r.choice_index = Some(1);
874 assert_eq!(resolve_answer(&r, &opts), "B"); r.choice_index = Some(9); r.value = Some("typed".to_string());
877 assert_eq!(resolve_answer(&r, &opts), "typed");
878 let empty = InteractionResponse::text("q", "");
879 assert_eq!(resolve_answer(&empty, &opts), ""); }
881
882 #[test]
883 fn route_answer_covers_all_four() {
884 let p = plan_point();
885 assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
886 assert_eq!(
887 route_answer(&p, "Add detail".to_string()),
888 Routed::Edit {
889 user_text: "Add detail".to_string()
890 }
891 );
892 assert_eq!(
893 route_answer(&p, "Revise".to_string()),
894 Routed::Directive {
895 user_text: "Revise".to_string(),
896 directive: "revise the plan".to_string(),
897 }
898 );
899 assert_eq!(
900 route_answer(&p, "Approve".to_string()),
901 Routed::Approve {
902 user_text: "Approve".to_string()
903 }
904 );
905 }
906
907 #[test]
908 fn inject_skips_empty_and_appends_nonempty() {
909 let mut w = window();
910 inject(&mut w, "plan", "", "");
911 assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
912 inject(&mut w, "plan", "directive: ", "do x");
913 assert!(w.get_region("conversation").unwrap().current_tokens > 0);
914 }
915
916 #[test]
917 fn stage_points_some_for_interactive_none_otherwise() {
918 let bp = blueprint_with(vec![plan_point()]);
919 assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
920 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
922 let stage = leviath_core::Stage::new(
923 "auto".to_string(),
924 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
925 );
926 let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
927 "t".to_string(),
928 "d".to_string(),
929 vec![stage],
930 layout,
931 ));
932 assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
933 }
934
935 fn run_gate(world: &mut World) {
938 let mut s = Schedule::default();
939 s.add_systems(gate_interaction_points);
940 s.run(world);
941 }
942
943 #[test]
944 fn gate_intercepts_unsatisfied_interactive_stage() {
945 let mut world = World::new();
946 let e = world
947 .spawn((
948 blueprint_with(vec![plan_point()]),
949 StageCursor { index: 0 },
950 ResolveTransition,
951 ))
952 .id();
953 run_gate(&mut world);
954 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
955 assert!(world.get::<ResolveTransition>(e).is_none());
956 }
957
958 #[test]
959 fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
960 let mut world = World::new();
961 let done = world
963 .spawn((
964 blueprint_with(vec![plan_point()]),
965 StageCursor { index: 0 },
966 InteractionPointCursor(1),
967 ResolveTransition,
968 ))
969 .id();
970 let empty = world
972 .spawn((
973 blueprint_with(vec![]),
974 StageCursor { index: 0 },
975 ResolveTransition,
976 ))
977 .id();
978 let auto = world
980 .spawn((
981 noninteractive_bp(),
982 StageCursor { index: 0 },
983 ResolveTransition,
984 ))
985 .id();
986 run_gate(&mut world);
987 assert!(world.get::<ResolveTransition>(done).is_some());
988 assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
989 assert!(world.get::<ResolveTransition>(empty).is_some());
990 assert!(world.get::<ResolveTransition>(auto).is_some());
991 assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
992 }
993
994 #[tokio::test]
997 async fn dispatch_noop_without_hub_or_stage() {
998 let mut world = World::new();
999 let e = world
1000 .spawn((
1001 agent_state(AgentStatus::Active),
1002 blueprint_with(vec![plan_point()]),
1003 StageCursor { index: 0 },
1004 infer("plan"),
1005 ReadyForInteractionPoint,
1006 ))
1007 .id();
1008 let mut s = Schedule::default();
1010 s.add_systems(dispatch_interaction_point);
1011 s.run(&mut world);
1012 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1014
1015 fn dispatch_world() -> (World, InteractionHub) {
1016 let hub = InteractionHub::new();
1017 let (tx, _rx) = unbounded_channel();
1018 let mut world = World::new();
1019 world.insert_resource(hub.clone());
1020 world.insert_resource(InteractionPointStage {
1021 outcomes: tx,
1022 wake: Arc::new(Notify::new()),
1023 runtime: Handle::current(),
1024 });
1025 (world, hub)
1026 }
1027
1028 #[tokio::test]
1029 async fn dispatch_skips_non_active_agent() {
1030 let (mut world, _hub) = dispatch_world();
1031 let e = world
1032 .spawn((
1033 agent_state(AgentStatus::Waiting),
1034 blueprint_with(vec![plan_point()]),
1035 window_with_plan(),
1036 StageCursor { index: 0 },
1037 infer("plan"),
1038 ReadyForInteractionPoint,
1039 ))
1040 .id();
1041 let mut s = Schedule::default();
1042 s.add_systems(dispatch_interaction_point);
1043 s.run(&mut world);
1044 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1046
1047 #[tokio::test]
1048 async fn dispatch_falls_through_when_point_missing() {
1049 let (mut world, _hub) = dispatch_world();
1050 let e = world
1052 .spawn((
1053 agent_state(AgentStatus::Active),
1054 blueprint_with(vec![plan_point()]),
1055 window_with_plan(),
1056 StageCursor { index: 0 },
1057 InteractionPointCursor(5),
1058 infer("plan"),
1059 ReadyForInteractionPoint,
1060 ))
1061 .id();
1062 let mut s = Schedule::default();
1063 s.add_systems(dispatch_interaction_point);
1064 s.run(&mut world);
1065 assert!(world.get::<ResolveTransition>(e).is_some());
1066 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1067 }
1068
1069 #[tokio::test]
1070 async fn dispatch_spawns_ask_and_awaits() {
1071 let (mut world, hub) = dispatch_world();
1072 let e = world
1073 .spawn((
1074 agent_state(AgentStatus::Active),
1075 blueprint_with(vec![plan_point()]),
1076 window_with_plan(),
1077 StageCursor { index: 0 },
1078 infer("the plan"),
1079 ReadyForInteractionPoint,
1080 ))
1081 .id();
1082 let mut s = Schedule::default();
1083 s.add_systems(dispatch_interaction_point);
1084 s.run(&mut world);
1085 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1086 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1087 for _ in 0..8 {
1090 tokio::task::yield_now().await;
1091 }
1092 let pending = hub.pending();
1093 assert_eq!(pending.len(), 1);
1094 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1095 let plan = world
1098 .get::<ContextWindow>(e)
1099 .unwrap()
1100 .get_region("plan")
1101 .unwrap();
1102 assert_eq!(plan.content.len(), 1);
1103 assert_eq!(plan.content[0].content, "the plan");
1104 }
1105
1106 #[tokio::test]
1107 async fn dispatch_auto_approves_an_unattended_run_without_asking() {
1108 let hub = InteractionHub::new();
1111 let (tx, mut rx) = unbounded_channel();
1112 let mut world = World::new();
1113 world.insert_resource(hub.clone());
1114 world.insert_resource(InteractionPointStage {
1115 outcomes: tx,
1116 wake: Arc::new(Notify::new()),
1117 runtime: Handle::current(),
1118 });
1119 let e = world
1120 .spawn((
1121 agent_state(AgentStatus::Active),
1122 blueprint_with(vec![plan_point()]),
1123 window_with_plan(),
1124 StageCursor { index: 0 },
1125 infer("the plan"),
1126 ReadyForInteractionPoint,
1127 crate::components::InteractionAutoApprove,
1128 ))
1129 .id();
1130 let mut s = Schedule::default();
1131 s.add_systems(dispatch_interaction_point);
1132 s.run(&mut world);
1133
1134 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1135 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1136 let outcome = rx.try_recv().expect("an outcome was published");
1138 assert_eq!(outcome.entity, e);
1139 assert!(matches!(
1140 outcome.decision,
1141 PointOutcome::Approve { ref user_text } if user_text.is_empty()
1142 ));
1143 for _ in 0..8 {
1144 tokio::task::yield_now().await;
1145 }
1146 assert!(hub.pending().is_empty(), "no human was asked");
1147 let plan = world
1150 .get::<ContextWindow>(e)
1151 .unwrap()
1152 .get_region("plan")
1153 .unwrap();
1154 assert_eq!(plan.content[0].content, "the plan");
1155 }
1156
1157 #[tokio::test]
1158 async fn dispatch_asks_an_unattended_run_when_the_point_opts_out() {
1159 let hub = InteractionHub::new();
1163 let (tx, mut rx) = unbounded_channel();
1164 let mut world = World::new();
1165 world.insert_resource(hub.clone());
1166 world.insert_resource(InteractionPointStage {
1167 outcomes: tx,
1168 wake: Arc::new(Notify::new()),
1169 runtime: Handle::current(),
1170 });
1171 let mut point = plan_point();
1172 point.unattended = UnattendedPolicy::Ask;
1173 let e = world
1174 .spawn((
1175 agent_state(AgentStatus::Active),
1176 blueprint_with(vec![point]),
1177 window_with_plan(),
1178 StageCursor { index: 0 },
1179 infer("the plan"),
1180 ReadyForInteractionPoint,
1181 crate::components::InteractionAutoApprove,
1182 ))
1183 .id();
1184 let mut s = Schedule::default();
1185 s.add_systems(dispatch_interaction_point);
1186 s.run(&mut world);
1187
1188 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1189 assert!(rx.try_recv().is_err(), "no outcome was published");
1191 for _ in 0..8 {
1192 tokio::task::yield_now().await;
1193 }
1194 let pending = hub.pending();
1195 assert_eq!(pending.len(), 1, "a person is being asked");
1196 assert_eq!(pending[0].1.stage_name, "plan_approval");
1197 }
1198
1199 #[tokio::test]
1200 async fn dispatch_without_document_region_skips_region_write() {
1201 let (mut world, _hub) = dispatch_world();
1203 let e = world
1204 .spawn((
1205 agent_state(AgentStatus::Active),
1206 blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
1207 window_with_plan(),
1208 StageCursor { index: 0 },
1209 infer("some output"),
1210 ReadyForInteractionPoint,
1211 ))
1212 .id();
1213 let mut s = Schedule::default();
1214 s.add_systems(dispatch_interaction_point);
1215 s.run(&mut world);
1216 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1217 let plan = world
1219 .get::<ContextWindow>(e)
1220 .unwrap()
1221 .get_region("plan")
1222 .unwrap();
1223 assert!(plan.content.is_empty());
1224 }
1225
1226 #[tokio::test]
1227 async fn dispatch_with_empty_document_skips_region_write() {
1228 let (mut world, _hub) = dispatch_world();
1230 let e = world
1231 .spawn((
1232 agent_state(AgentStatus::Active),
1233 blueprint_with(vec![plan_point()]),
1234 window_with_plan(),
1235 StageCursor { index: 0 },
1236 infer(" "),
1237 ReadyForInteractionPoint,
1238 ))
1239 .id();
1240 let mut s = Schedule::default();
1241 s.add_systems(dispatch_interaction_point);
1242 s.run(&mut world);
1243 let plan = world
1244 .get::<ContextWindow>(e)
1245 .unwrap()
1246 .get_region("plan")
1247 .unwrap();
1248 assert!(plan.content.is_empty());
1249 }
1250
1251 #[tokio::test]
1252 async fn dispatch_prefers_the_plan_body_override() {
1253 let (mut world, hub) = dispatch_world();
1254 let e = world
1255 .spawn((
1256 agent_state(AgentStatus::Active),
1257 blueprint_with(vec![plan_point()]),
1258 window_with_plan(),
1259 StageCursor { index: 0 },
1260 infer("the stale pre-edit plan"),
1261 PlanBodyOverride("the edited plan".to_string()),
1262 ReadyForInteractionPoint,
1263 ))
1264 .id();
1265 let mut s = Schedule::default();
1266 s.add_systems(dispatch_interaction_point);
1267 s.run(&mut world);
1268 assert!(world.get::<PlanBodyOverride>(e).is_none());
1270 let plan = world
1273 .get::<ContextWindow>(e)
1274 .unwrap()
1275 .get_region("plan")
1276 .unwrap();
1277 assert_eq!(plan.content.len(), 1);
1278 assert!(plan.content[0].content.contains("[revised by user"));
1279 assert!(plan.content[0].content.contains("the edited plan"));
1280 for _ in 0..8 {
1281 tokio::task::yield_now().await;
1282 }
1283 assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1285 }
1286
1287 fn collect_world() -> (
1290 World,
1291 tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
1292 ) {
1293 let (tx, rx) = unbounded_channel();
1294 let mut world = World::new();
1295 world.insert_resource(InteractionPointResults(rx));
1296 (world, tx)
1297 }
1298
1299 fn run_collect(world: &mut World) {
1300 let mut s = Schedule::default();
1301 s.add_systems(collect_interaction_point);
1302 s.run(world);
1303 }
1304
1305 fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
1306 world
1307 .spawn((
1308 agent_state(AgentStatus::Waiting),
1309 window(),
1310 blueprint_with(points),
1311 StageCursor { index: 0 },
1312 AwaitingInteractionPoint,
1313 ))
1314 .id()
1315 }
1316
1317 #[test]
1318 fn collect_approve_single_point_proceeds() {
1319 let (mut world, tx) = collect_world();
1320 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1321 tx.send(InteractionPointOutcome {
1322 entity: e,
1323 decision: PointOutcome::Approve {
1324 user_text: "Approve".to_string(),
1325 },
1326 })
1327 .unwrap();
1328 run_collect(&mut world);
1329 assert!(world.get::<ResolveTransition>(e).is_some());
1330 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1331 assert_eq!(
1332 world.get::<AgentState>(e).unwrap().status,
1333 AgentStatus::Active
1334 );
1335 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1336 }
1337
1338 #[test]
1339 fn collect_approve_advances_to_next_point() {
1340 let (mut world, tx) = collect_world();
1341 let e = spawn_awaiting(
1342 &mut world,
1343 vec![
1344 point("first", InteractionStyle::Confirm, &[]),
1345 point("second", InteractionStyle::Confirm, &[]),
1346 ],
1347 );
1348 tx.send(InteractionPointOutcome {
1349 entity: e,
1350 decision: PointOutcome::Approve {
1351 user_text: String::new(),
1352 },
1353 })
1354 .unwrap();
1355 run_collect(&mut world);
1356 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1357 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1358 assert!(world.get::<ResolveTransition>(e).is_none());
1359 }
1360
1361 #[test]
1362 fn collect_abort_cancels() {
1363 let (mut world, tx) = collect_world();
1364 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1365 tx.send(InteractionPointOutcome {
1366 entity: e,
1367 decision: PointOutcome::Abort,
1368 })
1369 .unwrap();
1370 run_collect(&mut world);
1371 assert_eq!(
1372 world.get::<AgentState>(e).unwrap().status,
1373 AgentStatus::Cancelled
1374 );
1375 assert!(world.get::<ResolveTransition>(e).is_none());
1376 }
1377
1378 #[test]
1384 fn collect_does_not_resurrect_a_cancelled_run() {
1385 for decision in [
1386 PointOutcome::Approve {
1387 user_text: "ok".to_string(),
1388 },
1389 PointOutcome::Directive {
1390 user_text: "go".to_string(),
1391 directive: "d".to_string(),
1392 },
1393 PointOutcome::Edit {
1394 user_text: "go".to_string(),
1395 edited: "body".to_string(),
1396 },
1397 ] {
1398 let (mut world, tx) = collect_world();
1399 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1400 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
1401
1402 tx.send(InteractionPointOutcome {
1403 entity: e,
1404 decision,
1405 })
1406 .unwrap();
1407 run_collect(&mut world);
1408
1409 assert_eq!(
1410 world.get::<AgentState>(e).unwrap().status,
1411 AgentStatus::Cancelled,
1412 "the run stays cancelled"
1413 );
1414 assert!(
1415 world.get::<AwaitingInteractionPoint>(e).is_none(),
1416 "the awaiting marker is still cleared, so nothing re-collects it"
1417 );
1418 assert!(
1419 world.get::<ResolveTransition>(e).is_none()
1420 && world.get::<ReadyToInfer>(e).is_none()
1421 && world.get::<ReadyForInteractionPoint>(e).is_none(),
1422 "and it is not queued for any further work"
1423 );
1424 }
1425 }
1426
1427 #[test]
1428 fn collect_directive_reinfers_then_caps() {
1429 let (mut world, tx) = collect_world();
1430 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1431 tx.send(InteractionPointOutcome {
1432 entity: e,
1433 decision: PointOutcome::Directive {
1434 user_text: "Revise".to_string(),
1435 directive: "do it".to_string(),
1436 },
1437 })
1438 .unwrap();
1439 run_collect(&mut world);
1440 assert!(world.get::<ReadyToInfer>(e).is_some());
1441 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1442 assert!(world.get::<ResolveTransition>(e).is_none());
1443
1444 world
1446 .entity_mut(e)
1447 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1448 .insert(AwaitingInteractionPoint);
1449 tx.send(InteractionPointOutcome {
1450 entity: e,
1451 decision: PointOutcome::Directive {
1452 user_text: String::new(),
1453 directive: "again".to_string(),
1454 },
1455 })
1456 .unwrap();
1457 run_collect(&mut world);
1458 assert!(world.get::<ResolveTransition>(e).is_some());
1459 }
1460
1461 #[test]
1462 fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
1463 let (mut world, tx) = collect_world();
1464 let e = world
1465 .spawn((
1466 agent_state(AgentStatus::Waiting),
1467 window(),
1468 blueprint_with(vec![plan_point()]),
1469 StageCursor { index: 0 },
1470 AwaitingInteractionPoint,
1471 StageIoBuffer::default(),
1472 ))
1473 .id();
1474 tx.send(InteractionPointOutcome {
1475 entity: e,
1476 decision: PointOutcome::Edit {
1477 user_text: "Add detail".to_string(),
1478 edited: "the revised plan".to_string(),
1479 },
1480 })
1481 .unwrap();
1482 run_collect(&mut world);
1483 let buf = world.get::<StageIoBuffer>(e).unwrap();
1486 assert_eq!(buf.output.len(), 1);
1487 assert_eq!(buf.output[0].0, 0);
1488 assert!(buf.output[0].1.contains("the revised plan"));
1489 assert_eq!(
1491 world.get::<PlanBodyOverride>(e).unwrap().0,
1492 "the revised plan"
1493 );
1494 }
1495
1496 #[test]
1501 fn collect_approve_after_a_revision_says_the_plan_changed() {
1502 let (mut world, tx) = collect_world();
1503
1504 let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
1505 let revised = spawn_awaiting(&mut world, vec![plan_point()]);
1506 world.entity_mut(revised).insert(InteractionPointRounds(2));
1507
1508 for e in [first_try, revised] {
1509 tx.send(InteractionPointOutcome {
1510 entity: e,
1511 decision: PointOutcome::Approve {
1512 user_text: "Approve".to_string(),
1513 },
1514 })
1515 .unwrap();
1516 }
1517 run_collect(&mut world);
1518
1519 let plain = world
1520 .get::<ContextWindow>(first_try)
1521 .unwrap()
1522 .current_tokens;
1523 let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
1524 assert!(
1525 noted > plain,
1526 "a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
1527 );
1528 }
1529
1530 #[test]
1531 fn collect_edit_represents_then_caps() {
1532 let (mut world, tx) = collect_world();
1533 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1534 tx.send(InteractionPointOutcome {
1535 entity: e,
1536 decision: PointOutcome::Edit {
1537 user_text: "Add detail".to_string(),
1538 edited: "the edited plan".to_string(),
1539 },
1540 })
1541 .unwrap();
1542 run_collect(&mut world);
1543 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1544 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1545 let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1547 assert!(after_first > 0);
1548
1549 world
1551 .entity_mut(e)
1552 .insert(InteractionPointRounds(0))
1553 .insert(AwaitingInteractionPoint);
1554 tx.send(InteractionPointOutcome {
1555 entity: e,
1556 decision: PointOutcome::Edit {
1557 user_text: String::new(),
1558 edited: String::new(),
1559 },
1560 })
1561 .unwrap();
1562 run_collect(&mut world);
1563 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1564 assert_eq!(
1565 world.get::<ContextWindow>(e).unwrap().current_tokens,
1566 after_first
1567 );
1568
1569 world
1571 .entity_mut(e)
1572 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1573 .insert(AwaitingInteractionPoint);
1574 tx.send(InteractionPointOutcome {
1575 entity: e,
1576 decision: PointOutcome::Edit {
1577 user_text: String::new(),
1578 edited: String::new(), },
1580 })
1581 .unwrap();
1582 run_collect(&mut world);
1583 assert!(world.get::<ResolveTransition>(e).is_some());
1584 }
1585
1586 #[test]
1587 fn collect_on_noninteractive_stage_proceeds() {
1588 let (mut world, tx) = collect_world();
1591 let e = world
1592 .spawn((
1593 agent_state(AgentStatus::Waiting),
1594 window(),
1595 noninteractive_bp(),
1596 StageCursor { index: 0 },
1597 AwaitingInteractionPoint,
1598 ))
1599 .id();
1600 tx.send(InteractionPointOutcome {
1601 entity: e,
1602 decision: PointOutcome::Approve {
1603 user_text: String::new(),
1604 },
1605 })
1606 .unwrap();
1607 run_collect(&mut world);
1608 assert!(world.get::<ResolveTransition>(e).is_some());
1609 }
1610
1611 #[test]
1612 fn collect_drops_outcome_for_missing_agent() {
1613 let (mut world, tx) = collect_world();
1614 tx.send(InteractionPointOutcome {
1615 entity: Entity::from_raw_u32(999)
1616 .expect("a small literal index is always a valid entity id"),
1617 decision: PointOutcome::Abort,
1618 })
1619 .unwrap();
1620 run_collect(&mut world); }
1622
1623 async fn drive_point(
1626 point: InteractionPoint,
1627 answer: impl FnOnce(&InteractionHub, String),
1628 ) -> PointOutcome {
1629 let hub = InteractionHub::new();
1630 let (tx, mut rx) = unbounded_channel();
1631 let task = {
1632 let hub = hub.clone();
1633 tokio::spawn(run_interaction_point(
1634 Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1635 hub,
1636 "run".to_string(),
1637 point,
1638 "body".to_string(),
1639 0,
1640 tx,
1641 Arc::new(Notify::new()),
1642 ))
1643 };
1644 for _ in 0..8 {
1645 tokio::task::yield_now().await;
1646 }
1647 let id = hub.pending()[0].1.id.clone();
1648 answer(&hub, id);
1649 task.await.unwrap();
1650 rx.recv().await.unwrap().decision
1651 }
1652
1653 #[tokio::test]
1654 async fn run_point_approve() {
1655 let out = drive_point(plan_point(), |hub, id| {
1656 let mut r = InteractionResponse::text(&id, "");
1657 r.choice_index = Some(0); hub.answer(r);
1659 })
1660 .await;
1661 assert_eq!(
1662 out,
1663 PointOutcome::Approve {
1664 user_text: "Approve".to_string()
1665 }
1666 );
1667 }
1668
1669 #[tokio::test]
1670 async fn run_point_abort_and_directive() {
1671 let abort = drive_point(plan_point(), |hub, id| {
1672 let mut r = InteractionResponse::text(&id, "");
1673 r.choice_index = Some(3); hub.answer(r);
1675 })
1676 .await;
1677 assert_eq!(abort, PointOutcome::Abort);
1678
1679 let directive = drive_point(plan_point(), |hub, id| {
1680 let mut r = InteractionResponse::text(&id, "");
1681 r.choice_index = Some(1); hub.answer(r);
1683 })
1684 .await;
1685 assert_eq!(
1686 directive,
1687 PointOutcome::Directive {
1688 user_text: "Revise".to_string(),
1689 directive: "revise the plan".to_string(),
1690 }
1691 );
1692 }
1693
1694 #[tokio::test]
1695 async fn run_point_edit_does_second_ask() {
1696 let hub = InteractionHub::new();
1698 let (tx, mut rx) = unbounded_channel();
1699 let task = {
1700 let hub = hub.clone();
1701 tokio::spawn(run_interaction_point(
1702 Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1703 hub,
1704 "run".to_string(),
1705 plan_point(),
1706 "body".to_string(),
1707 0,
1708 tx,
1709 Arc::new(Notify::new()),
1710 ))
1711 };
1712 for _ in 0..8 {
1714 tokio::task::yield_now().await;
1715 }
1716 let id = hub.pending()[0].1.id.clone();
1717 let mut r = InteractionResponse::text(&id, "");
1718 r.choice_index = Some(2); hub.answer(r);
1720 for _ in 0..8 {
1722 tokio::task::yield_now().await;
1723 }
1724 let edit_id = hub.pending()[0].1.id.clone();
1725 hub.answer(InteractionResponse::text(&edit_id, "edited body"));
1726 task.await.unwrap();
1727 assert_eq!(
1728 rx.recv().await.unwrap().decision,
1729 PointOutcome::Edit {
1730 user_text: "Add detail".to_string(),
1731 edited: "edited body".to_string(),
1732 }
1733 );
1734 }
1735
1736 #[test]
1739 fn interaction_point_state_round_trips() {
1740 let s = InteractionPointState {
1741 cursor: 2,
1742 round: 1,
1743 body: "# Plan\n1. do it".to_string(),
1744 };
1745 let json = serde_json::to_string(&s).unwrap();
1746 assert_eq!(
1747 serde_json::from_str::<InteractionPointState>(&json).unwrap(),
1748 s
1749 );
1750 }
1751
1752 fn resume_world() -> (
1755 World,
1756 InteractionHub,
1757 UnboundedReceiver<InteractionPointOutcome>,
1758 ) {
1759 let hub = InteractionHub::new();
1760 let (tx, rx) = unbounded_channel();
1761 let mut world = World::new();
1762 world.insert_resource(hub.clone());
1763 world.insert_resource(InteractionPointStage {
1764 outcomes: tx,
1765 wake: Arc::new(Notify::new()),
1766 runtime: Handle::current(),
1767 });
1768 (world, hub, rx)
1769 }
1770
1771 fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
1774 world
1775 .spawn((
1776 agent_state(AgentStatus::Active),
1777 bp,
1778 window_with_plan(),
1779 StageCursor { index: 0 },
1780 ReadyToInfer,
1781 ))
1782 .id()
1783 }
1784
1785 #[tokio::test]
1786 async fn restore_rearms_waiting_and_reopens_the_prompt() {
1787 let (mut world, hub, _rx) = resume_world();
1788 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1789 restore_interaction_point(
1790 &mut world,
1791 e,
1792 InteractionPointState {
1793 cursor: 0,
1794 round: 2,
1795 body: "the plan".to_string(),
1796 },
1797 );
1798
1799 assert_eq!(
1801 world.get::<AgentState>(e).unwrap().status,
1802 AgentStatus::Waiting
1803 );
1804 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1805 assert!(world.get::<ReadyToInfer>(e).is_none());
1806 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
1807 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
1808
1809 for _ in 0..8 {
1811 tokio::task::yield_now().await;
1812 }
1813 let pending = hub.pending();
1814 assert_eq!(pending.len(), 1);
1815 assert_eq!(pending[0].0, "run-1");
1816 assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
1817 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1818 }
1819
1820 #[tokio::test]
1821 async fn restore_then_answer_drives_the_transition() {
1822 let (mut world, hub, mut rx) = resume_world();
1823 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1824 restore_interaction_point(
1825 &mut world,
1826 e,
1827 InteractionPointState {
1828 cursor: 0,
1829 round: 0,
1830 body: "the plan".to_string(),
1831 },
1832 );
1833 for _ in 0..8 {
1834 tokio::task::yield_now().await;
1835 }
1836
1837 let id = hub.pending()[0].1.id.clone();
1839 let mut r = InteractionResponse::text(&id, "");
1840 r.choice_index = Some(0); assert!(hub.answer(r));
1842 let outcome = rx.recv().await.unwrap();
1843
1844 let (tx2, rx2) = unbounded_channel();
1846 tx2.send(outcome).unwrap();
1847 world.insert_resource(InteractionPointResults(rx2));
1848 let mut s = Schedule::default();
1849 s.add_systems(collect_interaction_point);
1850 s.run(&mut world);
1851
1852 assert!(world.get::<ResolveTransition>(e).is_some());
1853 assert_eq!(
1854 world.get::<AgentState>(e).unwrap().status,
1855 AgentStatus::Active
1856 );
1857 }
1858
1859 #[tokio::test]
1860 async fn restore_noop_on_noninteractive_stage() {
1861 let (mut world, hub, _rx) = resume_world();
1862 let e = restored_agent(&mut world, noninteractive_bp());
1863 restore_interaction_point(
1864 &mut world,
1865 e,
1866 InteractionPointState {
1867 cursor: 0,
1868 round: 0,
1869 body: "x".to_string(),
1870 },
1871 );
1872 assert_eq!(
1874 world.get::<AgentState>(e).unwrap().status,
1875 AgentStatus::Active
1876 );
1877 assert!(world.get::<ReadyToInfer>(e).is_some());
1878 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1879 for _ in 0..8 {
1880 tokio::task::yield_now().await;
1881 }
1882 assert!(hub.pending().is_empty());
1883 }
1884
1885 #[tokio::test]
1886 async fn restore_noop_without_lane_wired() {
1887 let mut world = World::new();
1889 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1890 restore_interaction_point(
1891 &mut world,
1892 e,
1893 InteractionPointState {
1894 cursor: 0,
1895 round: 0,
1896 body: "x".to_string(),
1897 },
1898 );
1899 assert_eq!(
1900 world.get::<AgentState>(e).unwrap().status,
1901 AgentStatus::Active
1902 );
1903 assert!(world.get::<ReadyToInfer>(e).is_some());
1904 }
1905}