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, PromptLane};
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 {
105 user_text: String,
108 },
109 Abort,
111 Directive {
113 user_text: String,
115 directive: String,
117 },
118 Edit {
120 user_text: String,
122 edited: String,
125 },
126 Unanswered,
129}
130
131pub struct InteractionPointOutcome {
133 pub entity: Entity,
135 pub decision: PointOutcome,
137}
138
139#[derive(Resource)]
142pub struct InteractionPointStage {
143 pub outcomes: UnboundedSender<InteractionPointOutcome>,
145 pub wake: Arc<Notify>,
147 pub runtime: Handle,
149}
150
151#[derive(Resource)]
153pub struct InteractionPointResults(pub UnboundedReceiver<InteractionPointOutcome>);
154
155fn normalize_for_followup(s: &str) -> String {
160 s.chars()
161 .map(|c| match c {
162 '\u{2014}' | '\u{2013}' | '\u{2212}' | '\u{2015}' => '-',
163 _ => c,
164 })
165 .collect::<String>()
166 .split_whitespace()
167 .collect::<Vec<_>>()
168 .join(" ")
169}
170
171fn option_matches(candidates: &[String], user_text: &str) -> bool {
173 if candidates.iter().any(|o| o == user_text) {
174 return true;
175 }
176 let normalized = normalize_for_followup(user_text);
177 candidates
178 .iter()
179 .any(|o| normalize_for_followup(o) == normalized)
180}
181
182fn lookup_directive<'a>(
184 directives: &'a HashMap<String, String>,
185 user_text: &str,
186) -> Option<&'a str> {
187 if let Some(d) = directives.get(user_text) {
188 return Some(d.as_str());
189 }
190 let normalized = normalize_for_followup(user_text);
191 directives
192 .iter()
193 .find(|(k, _)| normalize_for_followup(k) == normalized)
194 .map(|(_, d)| d.as_str())
195}
196
197fn build_point_request(point: &InteractionPoint, id: String, body: &str) -> InteractionRequest {
201 let mut req = match point.style {
202 InteractionStyle::MultipleChoice => InteractionRequest::multiple_choice(
203 id,
204 &point.prompt,
205 point.options.clone(),
206 &point.name,
207 ),
208 InteractionStyle::Confirm => InteractionRequest::confirm(id, &point.prompt, &point.name),
209 InteractionStyle::FreeText => {
210 InteractionRequest::free_text(id, &point.prompt, &point.name, point.required)
211 }
212 };
213 if !body.trim().is_empty() {
214 req.body = Some(body.to_string());
215 req.body_format = leviath_core::interaction::BodyFormat::Markdown;
216 }
217 req
218}
219
220fn resolve_answer(resp: &InteractionResponse, options: &[String]) -> String {
223 if let Some(opt) = resp.choice_index.and_then(|i| options.get(i)) {
224 return opt.clone();
225 }
226 resp.value.clone().unwrap_or_default()
227}
228
229fn is_unanswered(resp: &InteractionResponse) -> bool {
236 resp.approved.is_none()
237 && resp.choice_index.is_none()
238 && resp.value.as_deref().unwrap_or("").trim().is_empty()
239}
240
241fn route_answer(point: &InteractionPoint, user_text: String) -> Routed {
244 if option_matches(&point.abort_options, &user_text) {
245 Routed::Abort
246 } else if option_matches(&point.edit_options, &user_text) {
247 Routed::Edit { user_text }
248 } else if let Some(directive) = lookup_directive(&point.directives, &user_text) {
249 Routed::Directive {
250 user_text,
251 directive: directive.to_string(),
252 }
253 } else {
254 Routed::Approve { user_text }
255 }
256}
257
258#[derive(Debug, PartialEq, Eq)]
260enum Routed {
261 Approve {
262 user_text: String,
263 },
264 Abort,
265 Directive {
266 user_text: String,
267 directive: String,
268 },
269 Edit {
270 user_text: String,
271 },
272}
273
274pub struct PointAsk {
286 pub entity: Entity,
288 pub agent_id: String,
291 pub point: InteractionPoint,
293 pub body: String,
295 pub round: usize,
297}
298
299async fn run_interaction_point(ask: PointAsk, lane: PromptLane<InteractionPointOutcome>) {
300 let PointAsk {
301 entity,
302 agent_id,
303 point,
304 body,
305 round,
306 } = ask;
307 let PromptLane {
308 hub,
309 outcomes,
310 wake,
311 } = lane;
312 let ask_id = format!("{agent_id}-point-{}-{round}", point.name);
315 let backend = hub.backend_for(agent_id);
316 let req = build_point_request(&point, ask_id.clone(), &body);
317 let resp = backend.ask(req).await;
318
319 if point.unattended == UnattendedPolicy::Ask && is_unanswered(&resp) {
327 let _ = outcomes.send(InteractionPointOutcome {
328 entity,
329 decision: PointOutcome::Unanswered,
330 });
331 wake.notify_one();
332 return;
333 }
334
335 let user_text = resolve_answer(&resp, &point.options);
336
337 let decision = match route_answer(&point, user_text) {
338 Routed::Approve { user_text } => PointOutcome::Approve { user_text },
339 Routed::Abort => PointOutcome::Abort,
340 Routed::Directive {
341 user_text,
342 directive,
343 } => PointOutcome::Directive {
344 user_text,
345 directive,
346 },
347 Routed::Edit { user_text } => {
348 let edit_req = InteractionRequest::edit_text(
349 format!("{ask_id}-edit"),
350 "Edit the document - your changes replace it, then submit:",
351 &point.name,
352 body,
353 );
354 let edited = backend.ask(edit_req).await.value.unwrap_or_default();
355 PointOutcome::Edit { user_text, edited }
356 }
357 };
358
359 let _ = outcomes.send(InteractionPointOutcome { entity, decision });
360 wake.notify_one();
361}
362
363pub fn restore_interaction_point(
380 world: &mut World,
381 agent: crate::world::AgentId,
382 state: InteractionPointState,
383) {
384 let Some(entity) = agent.resolve_in(world) else {
387 return;
388 };
389 let Some(((outcomes, wake, runtime), hub)) = world
392 .get_resource::<InteractionPointStage>()
393 .map(|s| (s.outcomes.clone(), s.wake.clone(), s.runtime.clone()))
394 .zip(world.get_resource::<InteractionHub>().cloned())
395 else {
396 return;
397 };
398
399 let agent_id = world
404 .get::<AgentState>(entity)
405 .expect("a reloaded agent has AgentState")
406 .agent_id
407 .clone();
408 let point = {
409 let bp = world
410 .get::<AgentBlueprint>(entity)
411 .expect("a reloaded agent has a blueprint");
412 let cursor = world
413 .get::<StageCursor>(entity)
414 .expect("a reloaded agent has a stage cursor");
415 stage_points(bp, cursor)
416 .and_then(|p| p.get(state.cursor))
417 .cloned()
418 };
419 let Some(point) = point else {
420 tracing::warn!(
421 ?entity,
422 cursor = state.cursor,
423 "interaction-point restore skipped: stage not interactive or cursor out of range"
424 );
425 return;
426 };
427
428 {
431 let mut e = world.entity_mut(entity);
432 e.insert(InteractionPointCursor(state.cursor));
433 e.insert(InteractionPointRounds(state.round));
434 e.insert(AwaitingInteractionPoint);
435 e.remove::<ReadyToInfer>();
436 e.get_mut::<AgentState>()
437 .expect("a reloaded agent has AgentState")
438 .status = AgentStatus::Waiting;
439 }
440
441 runtime.spawn(run_interaction_point(
443 PointAsk {
444 entity,
445 agent_id,
446 point,
447 body: state.body,
448 round: state.round,
449 },
450 PromptLane {
451 hub,
452 outcomes,
453 wake,
454 },
455 ));
456}
457
458fn stage_points<'a>(
463 bp: &'a AgentBlueprint,
464 cursor: &StageCursor,
465) -> Option<&'a [InteractionPoint]> {
466 match &bp.0.stages[cursor.index].mode {
467 StageMode::InteractivePoints { points } => Some(points),
468 _ => None,
469 }
470}
471
472type InteractionPointQuery = (
477 Entity,
478 &'static AgentBlueprint,
479 &'static StageCursor,
480 Option<&'static InteractionPointCursor>,
481);
482
483pub fn gate_interaction_points(
488 agents: Query<InteractionPointQuery, With<ResolveTransition>>,
489 mut commands: Commands,
490) {
491 crate::tick_scope::clear();
492 for (entity, bp, cursor, pc) in agents.iter() {
493 crate::tick_scope::enter(entity);
494 let Some(points) = stage_points(bp, cursor) else {
495 continue;
496 };
497 let idx = pc.map_or(0, |c| c.0);
498 if points.is_empty() || idx >= points.len() {
499 continue; }
501 commands
502 .entity(entity)
503 .remove::<ResolveTransition>()
504 .insert(ReadyForInteractionPoint);
505 }
506}
507
508type DispatchInteractionPointQuery = (
513 Entity,
514 &'static AgentState,
515 &'static AgentBlueprint,
516 &'static StageCursor,
517 &'static InferenceResult,
518 &'static mut ContextWindow,
519 Option<&'static InteractionPointCursor>,
520 Option<&'static InteractionPointRounds>,
521 Option<&'static PlanBodyOverride>,
522 Option<&'static crate::components::InteractionAutoApprove>,
523);
524
525pub fn dispatch_interaction_point(
529 mut agents: Query<DispatchInteractionPointQuery, With<ReadyForInteractionPoint>>,
530 hub: Option<Res<InteractionHub>>,
531 stage: Option<Res<InteractionPointStage>>,
532 mut commands: Commands,
533) {
534 crate::tick_scope::clear();
535 let (Some(hub), Some(stage)) = (hub, stage) else {
536 return; };
538 for (entity, state, bp, cursor, infer, mut window, pc, rounds, plan_override, auto_approve) in
539 agents.iter_mut()
540 {
541 crate::tick_scope::enter(entity);
542 if state.status != AgentStatus::Active {
543 continue; }
545 let idx = pc.map_or(0, |c| c.0);
546 let point = stage_points(bp, cursor).and_then(|p| p.get(idx)).cloned();
547 let Some(point) = point else {
548 commands
550 .entity(entity)
551 .remove::<ReadyForInteractionPoint>()
552 .insert(ResolveTransition);
553 continue;
554 };
555 let user_revised = plan_override.is_some();
558 let body = plan_override
559 .map(|o| o.0.clone())
560 .unwrap_or_else(|| infer.response.clone());
561 if let Some(region) = &point.document_region
566 && !body.trim().is_empty()
567 {
568 let content = if user_revised {
569 format!("[revised by user - keep these changes]\n{body}")
570 } else {
571 body.clone()
572 };
573 let tokens = leviath_core::estimate_tokens(&content);
574 window.replace_region(region, content, tokens);
575 }
576 if auto_approve.is_some() && point.unattended == UnattendedPolicy::AutoApprove {
586 tracing::info!(
587 agent = %state.agent_id,
588 point = %point.name,
589 "auto-approving interaction point (unattended run)"
590 );
591 let _ = stage.outcomes.send(InteractionPointOutcome {
592 entity,
593 decision: PointOutcome::Approve {
594 user_text: String::new(),
595 },
596 });
597 stage.wake.notify_one();
598 commands
599 .entity(entity)
600 .remove::<ReadyForInteractionPoint>()
601 .remove::<PlanBodyOverride>()
602 .insert(AwaitingInteractionPoint);
603 continue;
604 }
605 stage.runtime.spawn(run_interaction_point(
606 PointAsk {
607 entity,
608 agent_id: state.agent_id.clone(),
609 point,
610 body,
611 round: rounds.map_or(0, |r| r.0),
612 },
613 PromptLane {
614 hub: hub.clone(),
615 outcomes: stage.outcomes.clone(),
616 wake: stage.wake.clone(),
617 },
618 ));
619 commands
620 .entity(entity)
621 .remove::<ReadyForInteractionPoint>()
622 .remove::<PlanBodyOverride>()
623 .insert(AwaitingInteractionPoint);
624 }
625}
626
627type CollectInteractionPointQuery = (
632 &'static mut AgentState,
633 &'static mut ContextWindow,
634 &'static AgentBlueprint,
635 &'static StageCursor,
636 Option<&'static InteractionPointCursor>,
637 Option<&'static InteractionPointRounds>,
638 Option<&'static mut StageIoBuffer>,
639);
640
641pub fn collect_interaction_point(
646 mut results: ResMut<InteractionPointResults>,
647 mut agents: Query<CollectInteractionPointQuery, With<AwaitingInteractionPoint>>,
648 mut commands: Commands,
649) {
650 crate::tick_scope::clear();
651 while let Ok(out) = results.0.try_recv() {
652 let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
653 agents.get_mut(out.entity)
654 else {
655 continue; };
657 crate::tick_scope::enter(out.entity);
658 if crate::pipeline::is_terminal_status(&state.status) {
665 commands
666 .entity(out.entity)
667 .remove::<AwaitingInteractionPoint>();
668 continue;
669 }
670 let idx = pc.map_or(0, |c| c.0);
671 let round = rounds.map_or(0, |r| r.0);
672 let (name, npoints) = match stage_points(bp, cursor) {
673 Some(points) => (
674 points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
675 points.len(),
676 ),
677 None => (String::new(), 0),
678 };
679
680 let mut e = commands.entity(out.entity);
681 e.remove::<AwaitingInteractionPoint>();
682
683 let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
686 e.insert(InteractionPointCursor(npoints))
687 .insert(ResolveTransition);
688 };
689
690 match out.decision {
691 PointOutcome::Abort => {
692 state.status = AgentStatus::Cancelled;
693 }
694 PointOutcome::Unanswered => {
702 state.status = AgentStatus::Error {
703 message: format!(
704 "checkpoint '{name}' went unanswered within the interaction timeout; \
705 the run stopped rather than approving it unread"
706 ),
707 };
708 }
709 PointOutcome::Approve { user_text } => {
710 state.status = AgentStatus::Active;
711 inject(&mut window, &name, "", &user_text);
712 if round > 0 {
727 inject(
728 &mut window,
729 &name,
730 "",
731 "The plan above was revised before you approved it. Work from \
732 the approved text as written - any conclusion you reached \
733 from the earlier version, including that something is \
734 already done, may no longer hold and should be re-checked \
735 against the plan rather than assumed.",
736 );
737 }
738 let next = idx + 1;
739 if next >= npoints {
740 proceed(&mut e); } else {
742 e.insert(InteractionPointCursor(next))
743 .insert(InteractionPointRounds(0))
744 .insert(ReadyForInteractionPoint);
745 }
746 }
747 PointOutcome::Directive {
748 user_text,
749 directive,
750 } => {
751 state.status = AgentStatus::Active;
752 inject(&mut window, &name, "", &user_text);
753 if round + 1 >= MAX_REVISION_ROUNDS {
754 proceed(&mut e); } else {
756 inject(&mut window, &name, "directive: ", &directive);
758 e.insert(InteractionPointRounds(round + 1))
759 .insert(ReadyToInfer);
760 }
761 }
762 PointOutcome::Edit { user_text, edited } => {
763 state.status = AgentStatus::Active;
764 inject(&mut window, &name, "", &user_text);
765 if round + 1 >= MAX_REVISION_ROUNDS {
766 proceed(&mut e);
767 } else {
768 if !edited.is_empty() {
769 let note = format!(
770 "edited the output directly. Adopt this exact text as the \
771 authoritative version and re-present it:\n{edited}"
772 );
773 inject(&mut window, &name, "", ¬e);
774 if let Some(mut buf) = io_buf {
778 buf.output.push((
779 cursor.index,
780 format!("\n─── Updated (your edit) ───\n{edited}"),
781 ));
782 }
783 e.insert(PlanBodyOverride(edited));
786 }
787 e.insert(InteractionPointRounds(round + 1))
789 .insert(ReadyForInteractionPoint);
790 }
791 }
792 }
793 }
794}
795
796fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
799 if text.is_empty() {
800 return;
801 }
802 let content = format!("User [{name}] {prefix}{text}");
803 let tokens = leviath_core::estimate_tokens(&content);
804 let _ = window.add_to_region("conversation", content, tokens);
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use crate::components::AgentStatus;
811 use leviath_core::interaction::InteractionResponse;
812 use leviath_core::{Region, RegionKind};
813 use tokio::sync::mpsc::unbounded_channel;
814
815 fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
818 InteractionPoint {
819 name: name.to_string(),
820 prompt: "Choose".to_string(),
821 required: true,
822 unattended: UnattendedPolicy::AutoApprove,
823 style,
824 options: options.iter().map(|s| s.to_string()).collect(),
825 directives: HashMap::new(),
826 abort_options: Vec::new(),
827 edit_options: Vec::new(),
828 document_region: None,
829 }
830 }
831
832 fn plan_point() -> InteractionPoint {
834 let mut p = point(
835 "plan_approval",
836 InteractionStyle::MultipleChoice,
837 &["Approve", "Revise", "Add detail", "Abort"],
838 );
839 p.directives
840 .insert("Revise".to_string(), "revise the plan".to_string());
841 p.abort_options = vec!["Abort".to_string()];
842 p.edit_options = vec!["Add detail".to_string()];
843 p.document_region = Some("plan".to_string());
844 p
845 }
846
847 fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
848 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
849 let mut stage = leviath_core::Stage::new(
850 "plan".to_string(),
851 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
852 );
853 stage.mode = StageMode::InteractivePoints { points };
854 let bp =
855 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
856 AgentBlueprint(bp)
857 }
858
859 fn noninteractive_bp() -> AgentBlueprint {
861 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
862 let stage = leviath_core::Stage::new(
863 "auto".to_string(),
864 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
865 );
866 AgentBlueprint(leviath_core::Blueprint::new(
867 "t".to_string(),
868 "d".to_string(),
869 vec![stage],
870 layout,
871 ))
872 }
873
874 fn agent_state(status: AgentStatus) -> AgentState {
875 AgentState {
876 agent_id: "run-1".to_string(),
877 current_stage: "plan".to_string(),
878 iteration: 1,
879 status,
880 spawned_children_ids: vec![],
881 pending_wait: None,
882 accepts_messages: true,
883 }
884 }
885
886 fn window() -> ContextWindow {
887 let mut w = ContextWindow::new(100_000);
888 w.add_region(Region::new(
889 "conversation".to_string(),
890 RegionKind::Clearable,
891 10_000,
892 ));
893 w
894 }
895
896 fn window_with_plan() -> ContextWindow {
897 let mut w = window();
898 w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
899 w
900 }
901
902 fn infer(text: &str) -> InferenceResult {
903 InferenceResult {
904 response: text.to_string(),
905 tool_calls: vec![],
906 tokens_used: 0,
907 timestamp: 0,
908 }
909 }
910
911 #[test]
914 fn normalize_folds_dashes_and_whitespace() {
915 assert_eq!(
916 normalize_for_followup("Revise \u{2014} now"),
917 "Revise - now"
918 );
919 assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
920 assert_eq!(normalize_for_followup(" x y "), "x y");
921 }
922
923 #[test]
924 fn option_matches_exact_normalized_and_miss() {
925 let opts = vec!["Abort \u{2014} now".to_string()];
926 assert!(option_matches(&opts, "Abort \u{2014} now")); assert!(option_matches(&opts, "Abort - now")); assert!(!option_matches(&opts, "Approve")); }
930
931 #[test]
932 fn lookup_directive_exact_normalized_and_none() {
933 let mut d = HashMap::new();
934 d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
935 assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
936 assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
937 assert_eq!(lookup_directive(&d, "Approve"), None);
938 }
939
940 #[test]
941 fn build_point_request_by_style() {
942 use leviath_core::interaction::InteractionKind;
943 let mc = build_point_request(
944 &point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
945 "id".to_string(),
946 "## Plan\n1. do it",
947 );
948 assert_eq!(mc.kind, InteractionKind::MultipleChoice);
949 assert_eq!(mc.options.len(), 2);
950 assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
952 assert_eq!(
953 mc.body_format,
954 leviath_core::interaction::BodyFormat::Markdown
955 );
956 let cf = build_point_request(
957 &point("p", InteractionStyle::Confirm, &[]),
958 "id".to_string(),
959 "",
960 );
961 assert_eq!(cf.kind, InteractionKind::Confirm);
962 assert_eq!(cf.body, None);
964 let ft = build_point_request(
965 &point("p", InteractionStyle::FreeText, &[]),
966 "id".to_string(),
967 " ",
968 );
969 assert_eq!(ft.kind, InteractionKind::FreeText);
970 assert_eq!(ft.body, None);
971 }
972
973 #[test]
974 fn resolve_answer_choice_index_fallback_and_value() {
975 let opts = vec!["A".to_string(), "B".to_string()];
976 let mut r = InteractionResponse::text("q", "");
977 r.choice_index = Some(1);
978 assert_eq!(resolve_answer(&r, &opts), "B"); r.choice_index = Some(9); r.value = Some("typed".to_string());
981 assert_eq!(resolve_answer(&r, &opts), "typed");
982 let empty = InteractionResponse::text("q", "");
983 assert_eq!(resolve_answer(&empty, &opts), ""); }
985
986 #[test]
987 fn route_answer_covers_all_four() {
988 let p = plan_point();
989 assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
990 assert_eq!(
991 route_answer(&p, "Add detail".to_string()),
992 Routed::Edit {
993 user_text: "Add detail".to_string()
994 }
995 );
996 assert_eq!(
997 route_answer(&p, "Revise".to_string()),
998 Routed::Directive {
999 user_text: "Revise".to_string(),
1000 directive: "revise the plan".to_string(),
1001 }
1002 );
1003 assert_eq!(
1004 route_answer(&p, "Approve".to_string()),
1005 Routed::Approve {
1006 user_text: "Approve".to_string()
1007 }
1008 );
1009 }
1010
1011 #[test]
1012 fn inject_skips_empty_and_appends_nonempty() {
1013 let mut w = window();
1014 inject(&mut w, "plan", "", "");
1015 assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
1016 inject(&mut w, "plan", "directive: ", "do x");
1017 assert!(w.get_region("conversation").unwrap().current_tokens > 0);
1018 }
1019
1020 #[test]
1021 fn stage_points_some_for_interactive_none_otherwise() {
1022 let bp = blueprint_with(vec![plan_point()]);
1023 assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
1024 let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
1026 let stage = leviath_core::Stage::new(
1027 "auto".to_string(),
1028 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
1029 );
1030 let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
1031 "t".to_string(),
1032 "d".to_string(),
1033 vec![stage],
1034 layout,
1035 ));
1036 assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
1037 }
1038
1039 fn run_gate(world: &mut World) {
1042 let mut s = Schedule::default();
1043 s.add_systems(gate_interaction_points);
1044 s.run(world);
1045 }
1046
1047 #[test]
1048 fn gate_intercepts_unsatisfied_interactive_stage() {
1049 let mut world = World::new();
1050 let e = world
1051 .spawn((
1052 blueprint_with(vec![plan_point()]),
1053 StageCursor { index: 0 },
1054 ResolveTransition,
1055 ))
1056 .id();
1057 run_gate(&mut world);
1058 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1059 assert!(world.get::<ResolveTransition>(e).is_none());
1060 }
1061
1062 #[test]
1063 fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
1064 let mut world = World::new();
1065 let done = world
1067 .spawn((
1068 blueprint_with(vec![plan_point()]),
1069 StageCursor { index: 0 },
1070 InteractionPointCursor(1),
1071 ResolveTransition,
1072 ))
1073 .id();
1074 let empty = world
1076 .spawn((
1077 blueprint_with(vec![]),
1078 StageCursor { index: 0 },
1079 ResolveTransition,
1080 ))
1081 .id();
1082 let auto = world
1084 .spawn((
1085 noninteractive_bp(),
1086 StageCursor { index: 0 },
1087 ResolveTransition,
1088 ))
1089 .id();
1090 run_gate(&mut world);
1091 assert!(world.get::<ResolveTransition>(done).is_some());
1092 assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
1093 assert!(world.get::<ResolveTransition>(empty).is_some());
1094 assert!(world.get::<ResolveTransition>(auto).is_some());
1095 assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
1096 }
1097
1098 #[tokio::test]
1101 async fn dispatch_noop_without_hub_or_stage() {
1102 let mut world = World::new();
1103 let e = world
1104 .spawn((
1105 agent_state(AgentStatus::Active),
1106 blueprint_with(vec![plan_point()]),
1107 StageCursor { index: 0 },
1108 infer("plan"),
1109 ReadyForInteractionPoint,
1110 ))
1111 .id();
1112 let mut s = Schedule::default();
1114 s.add_systems(dispatch_interaction_point);
1115 s.run(&mut world);
1116 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1118
1119 fn dispatch_world() -> (World, InteractionHub) {
1120 let hub = InteractionHub::new();
1121 let (tx, _rx) = unbounded_channel();
1122 let mut world = World::new();
1123 world.insert_resource(hub.clone());
1124 world.insert_resource(InteractionPointStage {
1125 outcomes: tx,
1126 wake: Arc::new(Notify::new()),
1127 runtime: Handle::current(),
1128 });
1129 (world, hub)
1130 }
1131
1132 #[tokio::test]
1133 async fn dispatch_skips_non_active_agent() {
1134 let (mut world, _hub) = dispatch_world();
1135 let e = world
1136 .spawn((
1137 agent_state(AgentStatus::Waiting),
1138 blueprint_with(vec![plan_point()]),
1139 window_with_plan(),
1140 StageCursor { index: 0 },
1141 infer("plan"),
1142 ReadyForInteractionPoint,
1143 ))
1144 .id();
1145 let mut s = Schedule::default();
1146 s.add_systems(dispatch_interaction_point);
1147 s.run(&mut world);
1148 assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
1150
1151 #[tokio::test]
1152 async fn dispatch_falls_through_when_point_missing() {
1153 let (mut world, _hub) = dispatch_world();
1154 let e = world
1156 .spawn((
1157 agent_state(AgentStatus::Active),
1158 blueprint_with(vec![plan_point()]),
1159 window_with_plan(),
1160 StageCursor { index: 0 },
1161 InteractionPointCursor(5),
1162 infer("plan"),
1163 ReadyForInteractionPoint,
1164 ))
1165 .id();
1166 let mut s = Schedule::default();
1167 s.add_systems(dispatch_interaction_point);
1168 s.run(&mut world);
1169 assert!(world.get::<ResolveTransition>(e).is_some());
1170 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1171 }
1172
1173 #[tokio::test]
1174 async fn dispatch_spawns_ask_and_awaits() {
1175 let (mut world, hub) = dispatch_world();
1176 let e = world
1177 .spawn((
1178 agent_state(AgentStatus::Active),
1179 blueprint_with(vec![plan_point()]),
1180 window_with_plan(),
1181 StageCursor { index: 0 },
1182 infer("the plan"),
1183 ReadyForInteractionPoint,
1184 ))
1185 .id();
1186 let mut s = Schedule::default();
1187 s.add_systems(dispatch_interaction_point);
1188 s.run(&mut world);
1189 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1190 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1191 for _ in 0..8 {
1194 tokio::task::yield_now().await;
1195 }
1196 let pending = hub.pending();
1197 assert_eq!(pending.len(), 1);
1198 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1199 let plan = world
1202 .get::<ContextWindow>(e)
1203 .unwrap()
1204 .get_region("plan")
1205 .unwrap();
1206 assert_eq!(plan.content.len(), 1);
1207 assert_eq!(plan.content[0].content, "the plan");
1208 }
1209
1210 #[tokio::test]
1211 async fn dispatch_auto_approves_an_unattended_run_without_asking() {
1212 let hub = InteractionHub::new();
1215 let (tx, mut rx) = unbounded_channel();
1216 let mut world = World::new();
1217 world.insert_resource(hub.clone());
1218 world.insert_resource(InteractionPointStage {
1219 outcomes: tx,
1220 wake: Arc::new(Notify::new()),
1221 runtime: Handle::current(),
1222 });
1223 let e = world
1224 .spawn((
1225 agent_state(AgentStatus::Active),
1226 blueprint_with(vec![plan_point()]),
1227 window_with_plan(),
1228 StageCursor { index: 0 },
1229 infer("the plan"),
1230 ReadyForInteractionPoint,
1231 crate::components::InteractionAutoApprove,
1232 ))
1233 .id();
1234 let mut s = Schedule::default();
1235 s.add_systems(dispatch_interaction_point);
1236 s.run(&mut world);
1237
1238 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1239 assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1240 let outcome = rx.try_recv().expect("an outcome was published");
1242 assert_eq!(outcome.entity, e);
1243 assert!(matches!(
1244 outcome.decision,
1245 PointOutcome::Approve { ref user_text } if user_text.is_empty()
1246 ));
1247 for _ in 0..8 {
1248 tokio::task::yield_now().await;
1249 }
1250 assert!(hub.pending().is_empty(), "no human was asked");
1251 let plan = world
1254 .get::<ContextWindow>(e)
1255 .unwrap()
1256 .get_region("plan")
1257 .unwrap();
1258 assert_eq!(plan.content[0].content, "the plan");
1259 }
1260
1261 #[tokio::test]
1262 async fn dispatch_asks_an_unattended_run_when_the_point_opts_out() {
1263 let hub = InteractionHub::new();
1267 let (tx, mut rx) = unbounded_channel();
1268 let mut world = World::new();
1269 world.insert_resource(hub.clone());
1270 world.insert_resource(InteractionPointStage {
1271 outcomes: tx,
1272 wake: Arc::new(Notify::new()),
1273 runtime: Handle::current(),
1274 });
1275 let mut point = plan_point();
1276 point.unattended = UnattendedPolicy::Ask;
1277 let e = world
1278 .spawn((
1279 agent_state(AgentStatus::Active),
1280 blueprint_with(vec![point]),
1281 window_with_plan(),
1282 StageCursor { index: 0 },
1283 infer("the plan"),
1284 ReadyForInteractionPoint,
1285 crate::components::InteractionAutoApprove,
1286 ))
1287 .id();
1288 let mut s = Schedule::default();
1289 s.add_systems(dispatch_interaction_point);
1290 s.run(&mut world);
1291
1292 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1293 assert!(rx.try_recv().is_err(), "no outcome was published");
1295 for _ in 0..8 {
1296 tokio::task::yield_now().await;
1297 }
1298 let pending = hub.pending();
1299 assert_eq!(pending.len(), 1, "a person is being asked");
1300 assert_eq!(pending[0].1.stage_name, "plan_approval");
1301 }
1302
1303 #[tokio::test]
1308 async fn an_expired_prompt_stops_a_held_point_and_approves_an_auto_one() {
1309 for (policy, expected) in [
1310 (UnattendedPolicy::Ask, PointOutcome::Unanswered),
1311 (
1312 UnattendedPolicy::AutoApprove,
1313 PointOutcome::Approve {
1314 user_text: String::new(),
1315 },
1316 ),
1317 ] {
1318 let hub = InteractionHub::new();
1319 let (tx, mut rx) = unbounded_channel();
1320 let mut point = plan_point();
1321 point.unattended = policy;
1322 let task = tokio::spawn(run_interaction_point(
1323 PointAsk {
1324 entity: Entity::from_raw_u32(1).unwrap(),
1325 agent_id: "run-1".to_string(),
1326 point,
1327 body: "the plan".to_string(),
1328 round: 0,
1329 },
1330 PromptLane {
1331 hub: hub.clone(),
1332 outcomes: tx,
1333 wake: Arc::new(Notify::new()),
1334 },
1335 ));
1336 for _ in 0..8 {
1344 tokio::task::yield_now().await;
1345 }
1346 assert!(
1347 hub.cancel("run-1-point-plan_approval-0"),
1348 "the point opened a prompt"
1349 );
1350 task.await.unwrap();
1351
1352 let out = rx.try_recv().expect("an outcome was published");
1353 assert_eq!(out.decision, expected, "{policy:?}");
1354 }
1355 }
1356
1357 #[tokio::test]
1358 async fn dispatch_without_document_region_skips_region_write() {
1359 let (mut world, _hub) = dispatch_world();
1361 let e = world
1362 .spawn((
1363 agent_state(AgentStatus::Active),
1364 blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
1365 window_with_plan(),
1366 StageCursor { index: 0 },
1367 infer("some output"),
1368 ReadyForInteractionPoint,
1369 ))
1370 .id();
1371 let mut s = Schedule::default();
1372 s.add_systems(dispatch_interaction_point);
1373 s.run(&mut world);
1374 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1375 let plan = world
1377 .get::<ContextWindow>(e)
1378 .unwrap()
1379 .get_region("plan")
1380 .unwrap();
1381 assert!(plan.content.is_empty());
1382 }
1383
1384 #[tokio::test]
1385 async fn dispatch_with_empty_document_skips_region_write() {
1386 let (mut world, _hub) = dispatch_world();
1388 let e = world
1389 .spawn((
1390 agent_state(AgentStatus::Active),
1391 blueprint_with(vec![plan_point()]),
1392 window_with_plan(),
1393 StageCursor { index: 0 },
1394 infer(" "),
1395 ReadyForInteractionPoint,
1396 ))
1397 .id();
1398 let mut s = Schedule::default();
1399 s.add_systems(dispatch_interaction_point);
1400 s.run(&mut world);
1401 let plan = world
1402 .get::<ContextWindow>(e)
1403 .unwrap()
1404 .get_region("plan")
1405 .unwrap();
1406 assert!(plan.content.is_empty());
1407 }
1408
1409 #[tokio::test]
1410 async fn dispatch_prefers_the_plan_body_override() {
1411 let (mut world, hub) = dispatch_world();
1412 let e = world
1413 .spawn((
1414 agent_state(AgentStatus::Active),
1415 blueprint_with(vec![plan_point()]),
1416 window_with_plan(),
1417 StageCursor { index: 0 },
1418 infer("the stale pre-edit plan"),
1419 PlanBodyOverride("the edited plan".to_string()),
1420 ReadyForInteractionPoint,
1421 ))
1422 .id();
1423 let mut s = Schedule::default();
1424 s.add_systems(dispatch_interaction_point);
1425 s.run(&mut world);
1426 assert!(world.get::<PlanBodyOverride>(e).is_none());
1428 let plan = world
1431 .get::<ContextWindow>(e)
1432 .unwrap()
1433 .get_region("plan")
1434 .unwrap();
1435 assert_eq!(plan.content.len(), 1);
1436 assert!(plan.content[0].content.contains("[revised by user"));
1437 assert!(plan.content[0].content.contains("the edited plan"));
1438 for _ in 0..8 {
1439 tokio::task::yield_now().await;
1440 }
1441 assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1443 }
1444
1445 fn collect_world() -> (
1448 World,
1449 tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
1450 ) {
1451 let (tx, rx) = unbounded_channel();
1452 let mut world = World::new();
1453 world.insert_resource(InteractionPointResults(rx));
1454 (world, tx)
1455 }
1456
1457 fn run_collect(world: &mut World) {
1458 let mut s = Schedule::default();
1459 s.add_systems(collect_interaction_point);
1460 s.run(world);
1461 }
1462
1463 fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
1464 world
1465 .spawn((
1466 agent_state(AgentStatus::Waiting),
1467 window(),
1468 blueprint_with(points),
1469 StageCursor { index: 0 },
1470 AwaitingInteractionPoint,
1471 ))
1472 .id()
1473 }
1474
1475 #[test]
1476 fn collect_approve_single_point_proceeds() {
1477 let (mut world, tx) = collect_world();
1478 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1479 tx.send(InteractionPointOutcome {
1480 entity: e,
1481 decision: PointOutcome::Approve {
1482 user_text: "Approve".to_string(),
1483 },
1484 })
1485 .unwrap();
1486 run_collect(&mut world);
1487 assert!(world.get::<ResolveTransition>(e).is_some());
1488 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1489 assert_eq!(
1490 world.get::<AgentState>(e).unwrap().status,
1491 AgentStatus::Active
1492 );
1493 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1494 }
1495
1496 #[test]
1497 fn collect_approve_advances_to_next_point() {
1498 let (mut world, tx) = collect_world();
1499 let e = spawn_awaiting(
1500 &mut world,
1501 vec![
1502 point("first", InteractionStyle::Confirm, &[]),
1503 point("second", InteractionStyle::Confirm, &[]),
1504 ],
1505 );
1506 tx.send(InteractionPointOutcome {
1507 entity: e,
1508 decision: PointOutcome::Approve {
1509 user_text: String::new(),
1510 },
1511 })
1512 .unwrap();
1513 run_collect(&mut world);
1514 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1515 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1516 assert!(world.get::<ResolveTransition>(e).is_none());
1517 }
1518
1519 #[test]
1524 fn is_unanswered_tells_a_timeout_from_every_real_answer() {
1525 let cases: &[(InteractionResponse, bool, &str)] = &[
1526 (InteractionResponse::text("id", ""), true, "expired"),
1527 (InteractionResponse::text("id", " "), true, "whitespace"),
1528 (InteractionResponse::text("id", "Approve"), false, "text"),
1529 (InteractionResponse::choice("id", 0), false, "a choice"),
1530 (
1531 InteractionResponse::approval(
1532 "id",
1533 false,
1534 leviath_core::interaction::ApprovalScope::Once,
1535 ),
1536 false,
1537 "a confirm denial",
1538 ),
1539 ];
1540 for (resp, expected, what) in cases {
1541 assert_eq!(is_unanswered(resp), *expected, "{what}");
1542 }
1543 }
1544
1545 #[test]
1550 fn an_unanswered_held_checkpoint_stops_the_run() {
1551 let (mut world, tx) = collect_world();
1552 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1553 tx.send(InteractionPointOutcome {
1554 entity: e,
1555 decision: PointOutcome::Unanswered,
1556 })
1557 .unwrap();
1558 run_collect(&mut world);
1559
1560 assert_eq!(
1563 world.get::<AgentState>(e).unwrap().status,
1564 AgentStatus::Error {
1565 message: "checkpoint 'plan_approval' went unanswered within the interaction \
1566 timeout; the run stopped rather than approving it unread"
1567 .to_string(),
1568 }
1569 );
1570 assert!(world.get::<ResolveTransition>(e).is_none());
1573 assert!(world.get::<ReadyToInfer>(e).is_none());
1574 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1575 }
1576
1577 #[test]
1578 fn collect_abort_cancels() {
1579 let (mut world, tx) = collect_world();
1580 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1581 tx.send(InteractionPointOutcome {
1582 entity: e,
1583 decision: PointOutcome::Abort,
1584 })
1585 .unwrap();
1586 run_collect(&mut world);
1587 assert_eq!(
1588 world.get::<AgentState>(e).unwrap().status,
1589 AgentStatus::Cancelled
1590 );
1591 assert!(world.get::<ResolveTransition>(e).is_none());
1592 }
1593
1594 #[test]
1600 fn collect_does_not_resurrect_a_cancelled_run() {
1601 for decision in [
1602 PointOutcome::Approve {
1603 user_text: "ok".to_string(),
1604 },
1605 PointOutcome::Directive {
1606 user_text: "go".to_string(),
1607 directive: "d".to_string(),
1608 },
1609 PointOutcome::Edit {
1610 user_text: "go".to_string(),
1611 edited: "body".to_string(),
1612 },
1613 ] {
1614 let (mut world, tx) = collect_world();
1615 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1616 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
1617
1618 tx.send(InteractionPointOutcome {
1619 entity: e,
1620 decision,
1621 })
1622 .unwrap();
1623 run_collect(&mut world);
1624
1625 assert_eq!(
1626 world.get::<AgentState>(e).unwrap().status,
1627 AgentStatus::Cancelled,
1628 "the run stays cancelled"
1629 );
1630 assert!(
1631 world.get::<AwaitingInteractionPoint>(e).is_none(),
1632 "the awaiting marker is still cleared, so nothing re-collects it"
1633 );
1634 assert!(
1635 world.get::<ResolveTransition>(e).is_none()
1636 && world.get::<ReadyToInfer>(e).is_none()
1637 && world.get::<ReadyForInteractionPoint>(e).is_none(),
1638 "and it is not queued for any further work"
1639 );
1640 }
1641 }
1642
1643 #[test]
1644 fn collect_directive_reinfers_then_caps() {
1645 let (mut world, tx) = collect_world();
1646 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1647 tx.send(InteractionPointOutcome {
1648 entity: e,
1649 decision: PointOutcome::Directive {
1650 user_text: "Revise".to_string(),
1651 directive: "do it".to_string(),
1652 },
1653 })
1654 .unwrap();
1655 run_collect(&mut world);
1656 assert!(world.get::<ReadyToInfer>(e).is_some());
1657 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1658 assert!(world.get::<ResolveTransition>(e).is_none());
1659
1660 world
1662 .entity_mut(e)
1663 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1664 .insert(AwaitingInteractionPoint);
1665 tx.send(InteractionPointOutcome {
1666 entity: e,
1667 decision: PointOutcome::Directive {
1668 user_text: String::new(),
1669 directive: "again".to_string(),
1670 },
1671 })
1672 .unwrap();
1673 run_collect(&mut world);
1674 assert!(world.get::<ResolveTransition>(e).is_some());
1675 }
1676
1677 #[test]
1678 fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
1679 let (mut world, tx) = collect_world();
1680 let e = world
1681 .spawn((
1682 agent_state(AgentStatus::Waiting),
1683 window(),
1684 blueprint_with(vec![plan_point()]),
1685 StageCursor { index: 0 },
1686 AwaitingInteractionPoint,
1687 StageIoBuffer::default(),
1688 ))
1689 .id();
1690 tx.send(InteractionPointOutcome {
1691 entity: e,
1692 decision: PointOutcome::Edit {
1693 user_text: "Add detail".to_string(),
1694 edited: "the revised plan".to_string(),
1695 },
1696 })
1697 .unwrap();
1698 run_collect(&mut world);
1699 let buf = world.get::<StageIoBuffer>(e).unwrap();
1702 assert_eq!(buf.output.len(), 1);
1703 assert_eq!(buf.output[0].0, 0);
1704 assert!(buf.output[0].1.contains("the revised plan"));
1705 assert_eq!(
1707 world.get::<PlanBodyOverride>(e).unwrap().0,
1708 "the revised plan"
1709 );
1710 }
1711
1712 #[test]
1717 fn collect_approve_after_a_revision_says_the_plan_changed() {
1718 let (mut world, tx) = collect_world();
1719
1720 let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
1721 let revised = spawn_awaiting(&mut world, vec![plan_point()]);
1722 world.entity_mut(revised).insert(InteractionPointRounds(2));
1723
1724 for e in [first_try, revised] {
1725 tx.send(InteractionPointOutcome {
1726 entity: e,
1727 decision: PointOutcome::Approve {
1728 user_text: "Approve".to_string(),
1729 },
1730 })
1731 .unwrap();
1732 }
1733 run_collect(&mut world);
1734
1735 let plain = world
1736 .get::<ContextWindow>(first_try)
1737 .unwrap()
1738 .current_tokens;
1739 let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
1740 assert!(
1741 noted > plain,
1742 "a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
1743 );
1744 }
1745
1746 #[test]
1747 fn collect_edit_represents_then_caps() {
1748 let (mut world, tx) = collect_world();
1749 let e = spawn_awaiting(&mut world, vec![plan_point()]);
1750 tx.send(InteractionPointOutcome {
1751 entity: e,
1752 decision: PointOutcome::Edit {
1753 user_text: "Add detail".to_string(),
1754 edited: "the edited plan".to_string(),
1755 },
1756 })
1757 .unwrap();
1758 run_collect(&mut world);
1759 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1760 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1761 let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1763 assert!(after_first > 0);
1764
1765 world
1767 .entity_mut(e)
1768 .insert(InteractionPointRounds(0))
1769 .insert(AwaitingInteractionPoint);
1770 tx.send(InteractionPointOutcome {
1771 entity: e,
1772 decision: PointOutcome::Edit {
1773 user_text: String::new(),
1774 edited: String::new(),
1775 },
1776 })
1777 .unwrap();
1778 run_collect(&mut world);
1779 assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1780 assert_eq!(
1781 world.get::<ContextWindow>(e).unwrap().current_tokens,
1782 after_first
1783 );
1784
1785 world
1787 .entity_mut(e)
1788 .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1789 .insert(AwaitingInteractionPoint);
1790 tx.send(InteractionPointOutcome {
1791 entity: e,
1792 decision: PointOutcome::Edit {
1793 user_text: String::new(),
1794 edited: String::new(), },
1796 })
1797 .unwrap();
1798 run_collect(&mut world);
1799 assert!(world.get::<ResolveTransition>(e).is_some());
1800 }
1801
1802 #[test]
1803 fn collect_on_noninteractive_stage_proceeds() {
1804 let (mut world, tx) = collect_world();
1807 let e = world
1808 .spawn((
1809 agent_state(AgentStatus::Waiting),
1810 window(),
1811 noninteractive_bp(),
1812 StageCursor { index: 0 },
1813 AwaitingInteractionPoint,
1814 ))
1815 .id();
1816 tx.send(InteractionPointOutcome {
1817 entity: e,
1818 decision: PointOutcome::Approve {
1819 user_text: String::new(),
1820 },
1821 })
1822 .unwrap();
1823 run_collect(&mut world);
1824 assert!(world.get::<ResolveTransition>(e).is_some());
1825 }
1826
1827 #[test]
1828 fn collect_drops_outcome_for_missing_agent() {
1829 let (mut world, tx) = collect_world();
1830 tx.send(InteractionPointOutcome {
1831 entity: Entity::from_raw_u32(999)
1832 .expect("a small literal index is always a valid entity id"),
1833 decision: PointOutcome::Abort,
1834 })
1835 .unwrap();
1836 run_collect(&mut world); }
1838
1839 async fn drive_point(
1842 point: InteractionPoint,
1843 answer: impl FnOnce(&InteractionHub, String),
1844 ) -> PointOutcome {
1845 let hub = InteractionHub::new();
1846 let (tx, mut rx) = unbounded_channel();
1847 let task = {
1848 let hub = hub.clone();
1849 tokio::spawn(run_interaction_point(
1850 PointAsk {
1851 entity: Entity::from_raw_u32(1)
1852 .expect("a small literal index is always a valid entity id"),
1853 agent_id: "run".to_string(),
1854 point,
1855 body: "body".to_string(),
1856 round: 0,
1857 },
1858 PromptLane {
1859 hub,
1860 outcomes: tx,
1861 wake: Arc::new(Notify::new()),
1862 },
1863 ))
1864 };
1865 for _ in 0..8 {
1866 tokio::task::yield_now().await;
1867 }
1868 let id = hub.pending()[0].1.id.clone();
1869 answer(&hub, id);
1870 task.await.unwrap();
1871 rx.recv().await.unwrap().decision
1872 }
1873
1874 #[tokio::test]
1875 async fn run_point_approve() {
1876 let out = drive_point(plan_point(), |hub, id| {
1877 let mut r = InteractionResponse::text(&id, "");
1878 r.choice_index = Some(0); hub.answer(r);
1880 })
1881 .await;
1882 assert_eq!(
1883 out,
1884 PointOutcome::Approve {
1885 user_text: "Approve".to_string()
1886 }
1887 );
1888 }
1889
1890 #[tokio::test]
1891 async fn run_point_abort_and_directive() {
1892 let abort = drive_point(plan_point(), |hub, id| {
1893 let mut r = InteractionResponse::text(&id, "");
1894 r.choice_index = Some(3); hub.answer(r);
1896 })
1897 .await;
1898 assert_eq!(abort, PointOutcome::Abort);
1899
1900 let directive = drive_point(plan_point(), |hub, id| {
1901 let mut r = InteractionResponse::text(&id, "");
1902 r.choice_index = Some(1); hub.answer(r);
1904 })
1905 .await;
1906 assert_eq!(
1907 directive,
1908 PointOutcome::Directive {
1909 user_text: "Revise".to_string(),
1910 directive: "revise the plan".to_string(),
1911 }
1912 );
1913 }
1914
1915 #[tokio::test]
1916 async fn run_point_edit_does_second_ask() {
1917 let hub = InteractionHub::new();
1919 let (tx, mut rx) = unbounded_channel();
1920 let task = {
1921 let hub = hub.clone();
1922 tokio::spawn(run_interaction_point(
1923 PointAsk {
1924 entity: Entity::from_raw_u32(1)
1925 .expect("a small literal index is always a valid entity id"),
1926 agent_id: "run".to_string(),
1927 point: plan_point(),
1928 body: "body".to_string(),
1929 round: 0,
1930 },
1931 PromptLane {
1932 hub,
1933 outcomes: tx,
1934 wake: Arc::new(Notify::new()),
1935 },
1936 ))
1937 };
1938 for _ in 0..8 {
1940 tokio::task::yield_now().await;
1941 }
1942 let id = hub.pending()[0].1.id.clone();
1943 let mut r = InteractionResponse::text(&id, "");
1944 r.choice_index = Some(2); hub.answer(r);
1946 for _ in 0..8 {
1948 tokio::task::yield_now().await;
1949 }
1950 let edit_id = hub.pending()[0].1.id.clone();
1951 hub.answer(InteractionResponse::text(&edit_id, "edited body"));
1952 task.await.unwrap();
1953 assert_eq!(
1954 rx.recv().await.unwrap().decision,
1955 PointOutcome::Edit {
1956 user_text: "Add detail".to_string(),
1957 edited: "edited body".to_string(),
1958 }
1959 );
1960 }
1961
1962 #[test]
1965 fn interaction_point_state_round_trips() {
1966 let s = InteractionPointState {
1967 cursor: 2,
1968 round: 1,
1969 body: "# Plan\n1. do it".to_string(),
1970 };
1971 let json = serde_json::to_string(&s).unwrap();
1972 assert_eq!(
1973 serde_json::from_str::<InteractionPointState>(&json).unwrap(),
1974 s
1975 );
1976 }
1977
1978 fn resume_world() -> (
1981 World,
1982 InteractionHub,
1983 UnboundedReceiver<InteractionPointOutcome>,
1984 ) {
1985 let hub = InteractionHub::new();
1986 let (tx, rx) = unbounded_channel();
1987 let mut world = World::new();
1988 world.insert_resource(hub.clone());
1989 world.insert_resource(InteractionPointStage {
1990 outcomes: tx,
1991 wake: Arc::new(Notify::new()),
1992 runtime: Handle::current(),
1993 });
1994 (world, hub, rx)
1995 }
1996
1997 fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
2000 world
2001 .spawn((
2002 agent_state(AgentStatus::Active),
2003 bp,
2004 window_with_plan(),
2005 StageCursor { index: 0 },
2006 ReadyToInfer,
2007 ))
2008 .id()
2009 }
2010
2011 #[tokio::test]
2012 async fn restore_rearms_waiting_and_reopens_the_prompt() {
2013 let (mut world, hub, _rx) = resume_world();
2014 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2015 let agent = crate::world::AgentId::in_world(&world, e);
2016 restore_interaction_point(
2017 &mut world,
2018 agent,
2019 InteractionPointState {
2020 cursor: 0,
2021 round: 2,
2022 body: "the plan".to_string(),
2023 },
2024 );
2025
2026 assert_eq!(
2028 world.get::<AgentState>(e).unwrap().status,
2029 AgentStatus::Waiting
2030 );
2031 assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
2032 assert!(world.get::<ReadyToInfer>(e).is_none());
2033 assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
2034 assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
2035
2036 for _ in 0..8 {
2038 tokio::task::yield_now().await;
2039 }
2040 let pending = hub.pending();
2041 assert_eq!(pending.len(), 1);
2042 assert_eq!(pending[0].0, "run-1");
2043 assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
2044 assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
2045 }
2046
2047 #[tokio::test]
2048 async fn restore_then_answer_drives_the_transition() {
2049 let (mut world, hub, mut rx) = resume_world();
2050 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2051 let agent = crate::world::AgentId::in_world(&world, e);
2052 restore_interaction_point(
2053 &mut world,
2054 agent,
2055 InteractionPointState {
2056 cursor: 0,
2057 round: 0,
2058 body: "the plan".to_string(),
2059 },
2060 );
2061 for _ in 0..8 {
2062 tokio::task::yield_now().await;
2063 }
2064
2065 let id = hub.pending()[0].1.id.clone();
2067 let mut r = InteractionResponse::text(&id, "");
2068 r.choice_index = Some(0); assert!(hub.answer(r));
2070 let outcome = rx.recv().await.unwrap();
2071
2072 let (tx2, rx2) = unbounded_channel();
2074 tx2.send(outcome).unwrap();
2075 world.insert_resource(InteractionPointResults(rx2));
2076 let mut s = Schedule::default();
2077 s.add_systems(collect_interaction_point);
2078 s.run(&mut world);
2079
2080 assert!(world.get::<ResolveTransition>(e).is_some());
2081 assert_eq!(
2082 world.get::<AgentState>(e).unwrap().status,
2083 AgentStatus::Active
2084 );
2085 }
2086
2087 #[tokio::test]
2088 async fn restore_noop_on_noninteractive_stage() {
2089 let (mut world, hub, _rx) = resume_world();
2090 let e = restored_agent(&mut world, noninteractive_bp());
2091 let agent = crate::world::AgentId::in_world(&world, e);
2092 restore_interaction_point(
2093 &mut world,
2094 agent,
2095 InteractionPointState {
2096 cursor: 0,
2097 round: 0,
2098 body: "x".to_string(),
2099 },
2100 );
2101 assert_eq!(
2103 world.get::<AgentState>(e).unwrap().status,
2104 AgentStatus::Active
2105 );
2106 assert!(world.get::<ReadyToInfer>(e).is_some());
2107 assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
2108 for _ in 0..8 {
2109 tokio::task::yield_now().await;
2110 }
2111 assert!(hub.pending().is_empty());
2112 }
2113
2114 #[tokio::test]
2115 async fn restore_noop_without_lane_wired() {
2116 let mut world = World::new();
2118 let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2119 let agent = crate::world::AgentId::in_world(&world, e);
2120 restore_interaction_point(
2121 &mut world,
2122 agent,
2123 InteractionPointState {
2124 cursor: 0,
2125 round: 0,
2126 body: "x".to_string(),
2127 },
2128 );
2129 assert_eq!(
2130 world.get::<AgentState>(e).unwrap().status,
2131 AgentStatus::Active
2132 );
2133 assert!(world.get::<ReadyToInfer>(e).is_some());
2134 }
2135}