use std::collections::HashMap;
use std::sync::Arc;
use bevy_ecs::prelude::*;
use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
use leviath_core::interaction::{InteractionRequest, InteractionResponse};
use serde::{Deserialize, Serialize};
use tokio::runtime::Handle;
use tokio::sync::Notify;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::components::{AgentState, AgentStatus, ContextWindow, InferenceResult};
use crate::dynamic_interaction::InteractionBackend;
use crate::interaction_hub::InteractionHub;
use crate::pipeline::{
AgentBlueprint, ReadyToInfer, ResolveTransition, StageCursor, StageIoBuffer,
};
pub const MAX_REVISION_ROUNDS: usize = 4;
#[derive(Component, Debug, Clone, Copy)]
pub struct ReadyForInteractionPoint;
#[derive(Component, Debug, Clone, Copy)]
pub struct AwaitingInteractionPoint;
#[derive(Component, Debug, Clone, Copy)]
pub struct InteractionPointCursor(pub usize);
#[derive(Component, Debug, Clone, Copy)]
pub struct InteractionPointRounds(pub usize);
#[derive(Component, Debug, Clone)]
pub struct PlanBodyOverride(pub String);
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct InteractionPointState {
pub cursor: usize,
pub round: usize,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PointOutcome {
Approve { user_text: String },
Abort,
Directive {
user_text: String,
directive: String,
},
Edit { user_text: String, edited: String },
}
pub struct InteractionPointOutcome {
pub entity: Entity,
pub decision: PointOutcome,
}
#[derive(Resource)]
pub struct InteractionPointStage {
pub outcomes: UnboundedSender<InteractionPointOutcome>,
pub wake: Arc<Notify>,
pub runtime: Handle,
}
#[derive(Resource)]
pub struct InteractionPointResults(pub UnboundedReceiver<InteractionPointOutcome>);
fn normalize_for_followup(s: &str) -> String {
s.chars()
.map(|c| match c {
'\u{2014}' | '\u{2013}' | '\u{2212}' | '\u{2015}' => '-',
_ => c,
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn option_matches(candidates: &[String], user_text: &str) -> bool {
if candidates.iter().any(|o| o == user_text) {
return true;
}
let normalized = normalize_for_followup(user_text);
candidates
.iter()
.any(|o| normalize_for_followup(o) == normalized)
}
fn lookup_directive<'a>(
directives: &'a HashMap<String, String>,
user_text: &str,
) -> Option<&'a str> {
if let Some(d) = directives.get(user_text) {
return Some(d.as_str());
}
let normalized = normalize_for_followup(user_text);
directives
.iter()
.find(|(k, _)| normalize_for_followup(k) == normalized)
.map(|(_, d)| d.as_str())
}
fn build_point_request(point: &InteractionPoint, id: String, body: &str) -> InteractionRequest {
let mut req = match point.style {
InteractionStyle::MultipleChoice => InteractionRequest::multiple_choice(
id,
&point.prompt,
point.options.clone(),
&point.name,
),
InteractionStyle::Confirm => InteractionRequest::confirm(id, &point.prompt, &point.name),
InteractionStyle::FreeText => {
InteractionRequest::free_text(id, &point.prompt, &point.name, point.required)
}
};
if !body.trim().is_empty() {
req.body = Some(body.to_string());
req.body_format = leviath_core::interaction::BodyFormat::Markdown;
}
req
}
fn resolve_answer(resp: &InteractionResponse, options: &[String]) -> String {
if let Some(opt) = resp.choice_index.and_then(|i| options.get(i)) {
return opt.clone();
}
resp.value.clone().unwrap_or_default()
}
fn route_answer(point: &InteractionPoint, user_text: String) -> Routed {
if option_matches(&point.abort_options, &user_text) {
Routed::Abort
} else if option_matches(&point.edit_options, &user_text) {
Routed::Edit { user_text }
} else if let Some(directive) = lookup_directive(&point.directives, &user_text) {
Routed::Directive {
user_text,
directive: directive.to_string(),
}
} else {
Routed::Approve { user_text }
}
}
#[derive(Debug, PartialEq, Eq)]
enum Routed {
Approve {
user_text: String,
},
Abort,
Directive {
user_text: String,
directive: String,
},
Edit {
user_text: String,
},
}
#[allow(clippy::too_many_arguments)]
async fn run_interaction_point(
entity: Entity,
hub: InteractionHub,
agent_id: String,
point: InteractionPoint,
body: String,
round: usize,
outcomes: UnboundedSender<InteractionPointOutcome>,
wake: Arc<Notify>,
) {
let ask_id = format!("{agent_id}-point-{}-{round}", point.name);
let backend = hub.backend_for(agent_id);
let req = build_point_request(&point, ask_id.clone(), &body);
let resp = backend.ask(req).await;
let user_text = resolve_answer(&resp, &point.options);
let decision = match route_answer(&point, user_text) {
Routed::Approve { user_text } => PointOutcome::Approve { user_text },
Routed::Abort => PointOutcome::Abort,
Routed::Directive {
user_text,
directive,
} => PointOutcome::Directive {
user_text,
directive,
},
Routed::Edit { user_text } => {
let edit_req = InteractionRequest::edit_text(
format!("{ask_id}-edit"),
"Edit the document - your changes replace it, then submit:",
&point.name,
body,
);
let edited = backend.ask(edit_req).await.value.unwrap_or_default();
PointOutcome::Edit { user_text, edited }
}
};
let _ = outcomes.send(InteractionPointOutcome { entity, decision });
wake.notify_one();
}
pub fn restore_interaction_point(world: &mut World, entity: Entity, state: InteractionPointState) {
let Some(((outcomes, wake, runtime), hub)) = world
.get_resource::<InteractionPointStage>()
.map(|s| (s.outcomes.clone(), s.wake.clone(), s.runtime.clone()))
.zip(world.get_resource::<InteractionHub>().cloned())
else {
return;
};
let agent_id = world
.get::<AgentState>(entity)
.expect("a reloaded agent has AgentState")
.agent_id
.clone();
let point = {
let bp = world
.get::<AgentBlueprint>(entity)
.expect("a reloaded agent has a blueprint");
let cursor = world
.get::<StageCursor>(entity)
.expect("a reloaded agent has a stage cursor");
stage_points(bp, cursor)
.and_then(|p| p.get(state.cursor))
.cloned()
};
let Some(point) = point else {
tracing::warn!(
?entity,
cursor = state.cursor,
"interaction-point restore skipped: stage not interactive or cursor out of range"
);
return;
};
{
let mut e = world.entity_mut(entity);
e.insert(InteractionPointCursor(state.cursor));
e.insert(InteractionPointRounds(state.round));
e.insert(AwaitingInteractionPoint);
e.remove::<ReadyToInfer>();
e.get_mut::<AgentState>()
.expect("a reloaded agent has AgentState")
.status = AgentStatus::Waiting;
}
runtime.spawn(run_interaction_point(
entity,
hub,
agent_id,
point,
state.body,
state.round,
outcomes,
wake,
));
}
fn stage_points<'a>(
bp: &'a AgentBlueprint,
cursor: &StageCursor,
) -> Option<&'a [InteractionPoint]> {
match &bp.0.stages[cursor.index].mode {
StageMode::InteractivePoints { points } => Some(points),
_ => None,
}
}
#[allow(clippy::type_complexity)]
pub fn gate_interaction_points(
agents: Query<
(
Entity,
&AgentBlueprint,
&StageCursor,
Option<&InteractionPointCursor>,
),
With<ResolveTransition>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, bp, cursor, pc) in agents.iter() {
crate::tick_scope::enter(entity);
let Some(points) = stage_points(bp, cursor) else {
continue;
};
let idx = pc.map_or(0, |c| c.0);
if points.is_empty() || idx >= points.len() {
continue; }
commands
.entity(entity)
.remove::<ResolveTransition>()
.insert(ReadyForInteractionPoint);
}
}
#[allow(clippy::type_complexity)]
pub fn dispatch_interaction_point(
mut agents: Query<
(
Entity,
&AgentState,
&AgentBlueprint,
&StageCursor,
&InferenceResult,
&mut ContextWindow,
Option<&InteractionPointCursor>,
Option<&InteractionPointRounds>,
Option<&PlanBodyOverride>,
Option<&crate::components::InteractionAutoApprove>,
),
With<ReadyForInteractionPoint>,
>,
hub: Option<Res<InteractionHub>>,
stage: Option<Res<InteractionPointStage>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let (Some(hub), Some(stage)) = (hub, stage) else {
return; };
for (entity, state, bp, cursor, infer, mut window, pc, rounds, plan_override, auto_approve) in
agents.iter_mut()
{
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue; }
let idx = pc.map_or(0, |c| c.0);
let point = stage_points(bp, cursor).and_then(|p| p.get(idx)).cloned();
let Some(point) = point else {
commands
.entity(entity)
.remove::<ReadyForInteractionPoint>()
.insert(ResolveTransition);
continue;
};
let user_revised = plan_override.is_some();
let body = plan_override
.map(|o| o.0.clone())
.unwrap_or_else(|| infer.response.clone());
if let Some(region) = &point.document_region
&& !body.trim().is_empty()
{
let content = if user_revised {
format!("[revised by user - keep these changes]\n{body}")
} else {
body.clone()
};
let tokens = leviath_core::estimate_tokens(&content);
window.replace_region(region, content, tokens);
}
if auto_approve.is_some() {
tracing::info!(
agent = %state.agent_id,
point = %point.name,
"auto-approving interaction point (unattended run)"
);
let _ = stage.outcomes.send(InteractionPointOutcome {
entity,
decision: PointOutcome::Approve {
user_text: String::new(),
},
});
stage.wake.notify_one();
commands
.entity(entity)
.remove::<ReadyForInteractionPoint>()
.remove::<PlanBodyOverride>()
.insert(AwaitingInteractionPoint);
continue;
}
stage.runtime.spawn(run_interaction_point(
entity,
hub.clone(),
state.agent_id.clone(),
point,
body,
rounds.map_or(0, |r| r.0),
stage.outcomes.clone(),
stage.wake.clone(),
));
commands
.entity(entity)
.remove::<ReadyForInteractionPoint>()
.remove::<PlanBodyOverride>()
.insert(AwaitingInteractionPoint);
}
}
#[allow(clippy::type_complexity)]
pub fn collect_interaction_point(
mut results: ResMut<InteractionPointResults>,
mut agents: Query<
(
&mut AgentState,
&mut ContextWindow,
&AgentBlueprint,
&StageCursor,
Option<&InteractionPointCursor>,
Option<&InteractionPointRounds>,
Option<&mut StageIoBuffer>,
),
With<AwaitingInteractionPoint>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
while let Ok(out) = results.0.try_recv() {
let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
agents.get_mut(out.entity)
else {
continue; };
crate::tick_scope::enter(out.entity);
if crate::pipeline::is_terminal_status(&state.status) {
commands
.entity(out.entity)
.remove::<AwaitingInteractionPoint>();
continue;
}
let idx = pc.map_or(0, |c| c.0);
let round = rounds.map_or(0, |r| r.0);
let (name, npoints) = match stage_points(bp, cursor) {
Some(points) => (
points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
points.len(),
),
None => (String::new(), 0),
};
let mut e = commands.entity(out.entity);
e.remove::<AwaitingInteractionPoint>();
let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
e.insert(InteractionPointCursor(npoints))
.insert(ResolveTransition);
};
match out.decision {
PointOutcome::Abort => {
state.status = AgentStatus::Cancelled;
}
PointOutcome::Approve { user_text } => {
state.status = AgentStatus::Active;
inject(&mut window, &name, "", &user_text);
if round > 0 {
inject(
&mut window,
&name,
"",
"The plan above was revised before you approved it. Work from \
the approved text as written - any conclusion you reached \
from the earlier version, including that something is \
already done, may no longer hold and should be re-checked \
against the plan rather than assumed.",
);
}
let next = idx + 1;
if next >= npoints {
proceed(&mut e); } else {
e.insert(InteractionPointCursor(next))
.insert(InteractionPointRounds(0))
.insert(ReadyForInteractionPoint);
}
}
PointOutcome::Directive {
user_text,
directive,
} => {
state.status = AgentStatus::Active;
inject(&mut window, &name, "", &user_text);
if round + 1 >= MAX_REVISION_ROUNDS {
proceed(&mut e); } else {
inject(&mut window, &name, "directive: ", &directive);
e.insert(InteractionPointRounds(round + 1))
.insert(ReadyToInfer);
}
}
PointOutcome::Edit { user_text, edited } => {
state.status = AgentStatus::Active;
inject(&mut window, &name, "", &user_text);
if round + 1 >= MAX_REVISION_ROUNDS {
proceed(&mut e);
} else {
if !edited.is_empty() {
let note = format!(
"edited the output directly. Adopt this exact text as the \
authoritative version and re-present it:\n{edited}"
);
inject(&mut window, &name, "", ¬e);
if let Some(mut buf) = io_buf {
buf.output.push((
cursor.index,
format!("\n─── Updated (your edit) ───\n{edited}"),
));
}
e.insert(PlanBodyOverride(edited));
}
e.insert(InteractionPointRounds(round + 1))
.insert(ReadyForInteractionPoint);
}
}
}
}
}
fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
if text.is_empty() {
return;
}
let content = format!("User [{name}] {prefix}{text}");
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region("conversation", content, tokens);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::AgentStatus;
use leviath_core::interaction::InteractionResponse;
use leviath_core::{Region, RegionKind};
use tokio::sync::mpsc::unbounded_channel;
fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
InteractionPoint {
name: name.to_string(),
prompt: "Choose".to_string(),
required: true,
style,
options: options.iter().map(|s| s.to_string()).collect(),
directives: HashMap::new(),
abort_options: Vec::new(),
edit_options: Vec::new(),
document_region: None,
}
}
fn plan_point() -> InteractionPoint {
let mut p = point(
"plan_approval",
InteractionStyle::MultipleChoice,
&["Approve", "Revise", "Add detail", "Abort"],
);
p.directives
.insert("Revise".to_string(), "revise the plan".to_string());
p.abort_options = vec!["Abort".to_string()];
p.edit_options = vec!["Add detail".to_string()];
p.document_region = Some("plan".to_string());
p
}
fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
let mut stage = leviath_core::Stage::new(
"plan".to_string(),
leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
);
stage.mode = StageMode::InteractivePoints { points };
let bp =
leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
AgentBlueprint(bp)
}
fn noninteractive_bp() -> AgentBlueprint {
let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
let stage = leviath_core::Stage::new(
"auto".to_string(),
leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
);
AgentBlueprint(leviath_core::Blueprint::new(
"t".to_string(),
"d".to_string(),
vec![stage],
layout,
))
}
fn agent_state(status: AgentStatus) -> AgentState {
AgentState {
agent_id: "run-1".to_string(),
current_stage: "plan".to_string(),
iteration: 1,
status,
spawned_children_ids: vec![],
pending_wait: None,
accepts_messages: true,
}
}
fn window() -> ContextWindow {
let mut w = ContextWindow::new(100_000);
w.add_region(Region::new(
"conversation".to_string(),
RegionKind::Clearable,
10_000,
));
w
}
fn window_with_plan() -> ContextWindow {
let mut w = window();
w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
w
}
fn infer(text: &str) -> InferenceResult {
InferenceResult {
response: text.to_string(),
tool_calls: vec![],
tokens_used: 0,
timestamp: 0,
}
}
#[test]
fn normalize_folds_dashes_and_whitespace() {
assert_eq!(
normalize_for_followup("Revise \u{2014} now"),
"Revise - now"
);
assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
assert_eq!(normalize_for_followup(" x y "), "x y");
}
#[test]
fn option_matches_exact_normalized_and_miss() {
let opts = vec!["Abort \u{2014} now".to_string()];
assert!(option_matches(&opts, "Abort \u{2014} now")); assert!(option_matches(&opts, "Abort - now")); assert!(!option_matches(&opts, "Approve")); }
#[test]
fn lookup_directive_exact_normalized_and_none() {
let mut d = HashMap::new();
d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
assert_eq!(lookup_directive(&d, "Approve"), None);
}
#[test]
fn build_point_request_by_style() {
use leviath_core::interaction::InteractionKind;
let mc = build_point_request(
&point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
"id".to_string(),
"## Plan\n1. do it",
);
assert_eq!(mc.kind, InteractionKind::MultipleChoice);
assert_eq!(mc.options.len(), 2);
assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
assert_eq!(
mc.body_format,
leviath_core::interaction::BodyFormat::Markdown
);
let cf = build_point_request(
&point("p", InteractionStyle::Confirm, &[]),
"id".to_string(),
"",
);
assert_eq!(cf.kind, InteractionKind::Confirm);
assert_eq!(cf.body, None);
let ft = build_point_request(
&point("p", InteractionStyle::FreeText, &[]),
"id".to_string(),
" ",
);
assert_eq!(ft.kind, InteractionKind::FreeText);
assert_eq!(ft.body, None);
}
#[test]
fn resolve_answer_choice_index_fallback_and_value() {
let opts = vec!["A".to_string(), "B".to_string()];
let mut r = InteractionResponse::text("q", "");
r.choice_index = Some(1);
assert_eq!(resolve_answer(&r, &opts), "B"); r.choice_index = Some(9); r.value = Some("typed".to_string());
assert_eq!(resolve_answer(&r, &opts), "typed");
let empty = InteractionResponse::text("q", "");
assert_eq!(resolve_answer(&empty, &opts), ""); }
#[test]
fn route_answer_covers_all_four() {
let p = plan_point();
assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
assert_eq!(
route_answer(&p, "Add detail".to_string()),
Routed::Edit {
user_text: "Add detail".to_string()
}
);
assert_eq!(
route_answer(&p, "Revise".to_string()),
Routed::Directive {
user_text: "Revise".to_string(),
directive: "revise the plan".to_string(),
}
);
assert_eq!(
route_answer(&p, "Approve".to_string()),
Routed::Approve {
user_text: "Approve".to_string()
}
);
}
#[test]
fn inject_skips_empty_and_appends_nonempty() {
let mut w = window();
inject(&mut w, "plan", "", "");
assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
inject(&mut w, "plan", "directive: ", "do x");
assert!(w.get_region("conversation").unwrap().current_tokens > 0);
}
#[test]
fn stage_points_some_for_interactive_none_otherwise() {
let bp = blueprint_with(vec![plan_point()]);
assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
let stage = leviath_core::Stage::new(
"auto".to_string(),
leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
);
let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
"t".to_string(),
"d".to_string(),
vec![stage],
layout,
));
assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
}
fn run_gate(world: &mut World) {
let mut s = Schedule::default();
s.add_systems(gate_interaction_points);
s.run(world);
}
#[test]
fn gate_intercepts_unsatisfied_interactive_stage() {
let mut world = World::new();
let e = world
.spawn((
blueprint_with(vec![plan_point()]),
StageCursor { index: 0 },
ResolveTransition,
))
.id();
run_gate(&mut world);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
assert!(world.get::<ResolveTransition>(e).is_none());
}
#[test]
fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
let mut world = World::new();
let done = world
.spawn((
blueprint_with(vec![plan_point()]),
StageCursor { index: 0 },
InteractionPointCursor(1),
ResolveTransition,
))
.id();
let empty = world
.spawn((
blueprint_with(vec![]),
StageCursor { index: 0 },
ResolveTransition,
))
.id();
let auto = world
.spawn((
noninteractive_bp(),
StageCursor { index: 0 },
ResolveTransition,
))
.id();
run_gate(&mut world);
assert!(world.get::<ResolveTransition>(done).is_some());
assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
assert!(world.get::<ResolveTransition>(empty).is_some());
assert!(world.get::<ResolveTransition>(auto).is_some());
assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
}
#[tokio::test]
async fn dispatch_noop_without_hub_or_stage() {
let mut world = World::new();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
StageCursor { index: 0 },
infer("plan"),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
fn dispatch_world() -> (World, InteractionHub) {
let hub = InteractionHub::new();
let (tx, _rx) = unbounded_channel();
let mut world = World::new();
world.insert_resource(hub.clone());
world.insert_resource(InteractionPointStage {
outcomes: tx,
wake: Arc::new(Notify::new()),
runtime: Handle::current(),
});
(world, hub)
}
#[tokio::test]
async fn dispatch_skips_non_active_agent() {
let (mut world, _hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Waiting),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
infer("plan"),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); }
#[tokio::test]
async fn dispatch_falls_through_when_point_missing() {
let (mut world, _hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
InteractionPointCursor(5),
infer("plan"),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
}
#[tokio::test]
async fn dispatch_spawns_ask_and_awaits() {
let (mut world, hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
infer("the plan"),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
for _ in 0..8 {
tokio::task::yield_now().await;
}
let pending = hub.pending();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
let plan = world
.get::<ContextWindow>(e)
.unwrap()
.get_region("plan")
.unwrap();
assert_eq!(plan.content.len(), 1);
assert_eq!(plan.content[0].content, "the plan");
}
#[tokio::test]
async fn dispatch_auto_approves_an_unattended_run_without_asking() {
let hub = InteractionHub::new();
let (tx, mut rx) = unbounded_channel();
let mut world = World::new();
world.insert_resource(hub.clone());
world.insert_resource(InteractionPointStage {
outcomes: tx,
wake: Arc::new(Notify::new()),
runtime: Handle::current(),
});
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
infer("the plan"),
ReadyForInteractionPoint,
crate::components::InteractionAutoApprove,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
let outcome = rx.try_recv().expect("an outcome was published");
assert_eq!(outcome.entity, e);
assert!(matches!(
outcome.decision,
PointOutcome::Approve { ref user_text } if user_text.is_empty()
));
for _ in 0..8 {
tokio::task::yield_now().await;
}
assert!(hub.pending().is_empty(), "no human was asked");
let plan = world
.get::<ContextWindow>(e)
.unwrap()
.get_region("plan")
.unwrap();
assert_eq!(plan.content[0].content, "the plan");
}
#[tokio::test]
async fn dispatch_without_document_region_skips_region_write() {
let (mut world, _hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
window_with_plan(),
StageCursor { index: 0 },
infer("some output"),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
let plan = world
.get::<ContextWindow>(e)
.unwrap()
.get_region("plan")
.unwrap();
assert!(plan.content.is_empty());
}
#[tokio::test]
async fn dispatch_with_empty_document_skips_region_write() {
let (mut world, _hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
infer(" "),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
let plan = world
.get::<ContextWindow>(e)
.unwrap()
.get_region("plan")
.unwrap();
assert!(plan.content.is_empty());
}
#[tokio::test]
async fn dispatch_prefers_the_plan_body_override() {
let (mut world, hub) = dispatch_world();
let e = world
.spawn((
agent_state(AgentStatus::Active),
blueprint_with(vec![plan_point()]),
window_with_plan(),
StageCursor { index: 0 },
infer("the stale pre-edit plan"),
PlanBodyOverride("the edited plan".to_string()),
ReadyForInteractionPoint,
))
.id();
let mut s = Schedule::default();
s.add_systems(dispatch_interaction_point);
s.run(&mut world);
assert!(world.get::<PlanBodyOverride>(e).is_none());
let plan = world
.get::<ContextWindow>(e)
.unwrap()
.get_region("plan")
.unwrap();
assert_eq!(plan.content.len(), 1);
assert!(plan.content[0].content.contains("[revised by user"));
assert!(plan.content[0].content.contains("the edited plan"));
for _ in 0..8 {
tokio::task::yield_now().await;
}
assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
}
fn collect_world() -> (
World,
tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
) {
let (tx, rx) = unbounded_channel();
let mut world = World::new();
world.insert_resource(InteractionPointResults(rx));
(world, tx)
}
fn run_collect(world: &mut World) {
let mut s = Schedule::default();
s.add_systems(collect_interaction_point);
s.run(world);
}
fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
world
.spawn((
agent_state(AgentStatus::Waiting),
window(),
blueprint_with(points),
StageCursor { index: 0 },
AwaitingInteractionPoint,
))
.id()
}
#[test]
fn collect_approve_single_point_proceeds() {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(&mut world, vec![plan_point()]);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Approve {
user_text: "Approve".to_string(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
}
#[test]
fn collect_approve_advances_to_next_point() {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(
&mut world,
vec![
point("first", InteractionStyle::Confirm, &[]),
point("second", InteractionStyle::Confirm, &[]),
],
);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Approve {
user_text: String::new(),
},
})
.unwrap();
run_collect(&mut world);
assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
assert!(world.get::<ResolveTransition>(e).is_none());
}
#[test]
fn collect_abort_cancels() {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(&mut world, vec![plan_point()]);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Abort,
})
.unwrap();
run_collect(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Cancelled
);
assert!(world.get::<ResolveTransition>(e).is_none());
}
#[test]
fn collect_does_not_resurrect_a_cancelled_run() {
for decision in [
PointOutcome::Approve {
user_text: "ok".to_string(),
},
PointOutcome::Directive {
user_text: "go".to_string(),
directive: "d".to_string(),
},
PointOutcome::Edit {
user_text: "go".to_string(),
edited: "body".to_string(),
},
] {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(&mut world, vec![plan_point()]);
world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
tx.send(InteractionPointOutcome {
entity: e,
decision,
})
.unwrap();
run_collect(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Cancelled,
"the run stays cancelled"
);
assert!(
world.get::<AwaitingInteractionPoint>(e).is_none(),
"the awaiting marker is still cleared, so nothing re-collects it"
);
assert!(
world.get::<ResolveTransition>(e).is_none()
&& world.get::<ReadyToInfer>(e).is_none()
&& world.get::<ReadyForInteractionPoint>(e).is_none(),
"and it is not queued for any further work"
);
}
}
#[test]
fn collect_directive_reinfers_then_caps() {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(&mut world, vec![plan_point()]);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Directive {
user_text: "Revise".to_string(),
directive: "do it".to_string(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ReadyToInfer>(e).is_some());
assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
assert!(world.get::<ResolveTransition>(e).is_none());
world
.entity_mut(e)
.insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
.insert(AwaitingInteractionPoint);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Directive {
user_text: String::new(),
directive: "again".to_string(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
}
#[test]
fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
let (mut world, tx) = collect_world();
let e = world
.spawn((
agent_state(AgentStatus::Waiting),
window(),
blueprint_with(vec![plan_point()]),
StageCursor { index: 0 },
AwaitingInteractionPoint,
StageIoBuffer::default(),
))
.id();
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Edit {
user_text: "Add detail".to_string(),
edited: "the revised plan".to_string(),
},
})
.unwrap();
run_collect(&mut world);
let buf = world.get::<StageIoBuffer>(e).unwrap();
assert_eq!(buf.output.len(), 1);
assert_eq!(buf.output[0].0, 0);
assert!(buf.output[0].1.contains("the revised plan"));
assert_eq!(
world.get::<PlanBodyOverride>(e).unwrap().0,
"the revised plan"
);
}
#[test]
fn collect_approve_after_a_revision_says_the_plan_changed() {
let (mut world, tx) = collect_world();
let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
let revised = spawn_awaiting(&mut world, vec![plan_point()]);
world.entity_mut(revised).insert(InteractionPointRounds(2));
for e in [first_try, revised] {
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Approve {
user_text: "Approve".to_string(),
},
})
.unwrap();
}
run_collect(&mut world);
let plain = world
.get::<ContextWindow>(first_try)
.unwrap()
.current_tokens;
let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
assert!(
noted > plain,
"a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
);
}
#[test]
fn collect_edit_represents_then_caps() {
let (mut world, tx) = collect_world();
let e = spawn_awaiting(&mut world, vec![plan_point()]);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Edit {
user_text: "Add detail".to_string(),
edited: "the edited plan".to_string(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
assert!(after_first > 0);
world
.entity_mut(e)
.insert(InteractionPointRounds(0))
.insert(AwaitingInteractionPoint);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Edit {
user_text: String::new(),
edited: String::new(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
assert_eq!(
world.get::<ContextWindow>(e).unwrap().current_tokens,
after_first
);
world
.entity_mut(e)
.insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
.insert(AwaitingInteractionPoint);
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Edit {
user_text: String::new(),
edited: String::new(), },
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
}
#[test]
fn collect_on_noninteractive_stage_proceeds() {
let (mut world, tx) = collect_world();
let e = world
.spawn((
agent_state(AgentStatus::Waiting),
window(),
noninteractive_bp(),
StageCursor { index: 0 },
AwaitingInteractionPoint,
))
.id();
tx.send(InteractionPointOutcome {
entity: e,
decision: PointOutcome::Approve {
user_text: String::new(),
},
})
.unwrap();
run_collect(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
}
#[test]
fn collect_drops_outcome_for_missing_agent() {
let (mut world, tx) = collect_world();
tx.send(InteractionPointOutcome {
entity: Entity::from_raw_u32(999)
.expect("a small literal index is always a valid entity id"),
decision: PointOutcome::Abort,
})
.unwrap();
run_collect(&mut world); }
async fn drive_point(
point: InteractionPoint,
answer: impl FnOnce(&InteractionHub, String),
) -> PointOutcome {
let hub = InteractionHub::new();
let (tx, mut rx) = unbounded_channel();
let task = {
let hub = hub.clone();
tokio::spawn(run_interaction_point(
Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
hub,
"run".to_string(),
point,
"body".to_string(),
0,
tx,
Arc::new(Notify::new()),
))
};
for _ in 0..8 {
tokio::task::yield_now().await;
}
let id = hub.pending()[0].1.id.clone();
answer(&hub, id);
task.await.unwrap();
rx.recv().await.unwrap().decision
}
#[tokio::test]
async fn run_point_approve() {
let out = drive_point(plan_point(), |hub, id| {
let mut r = InteractionResponse::text(&id, "");
r.choice_index = Some(0); hub.answer(r);
})
.await;
assert_eq!(
out,
PointOutcome::Approve {
user_text: "Approve".to_string()
}
);
}
#[tokio::test]
async fn run_point_abort_and_directive() {
let abort = drive_point(plan_point(), |hub, id| {
let mut r = InteractionResponse::text(&id, "");
r.choice_index = Some(3); hub.answer(r);
})
.await;
assert_eq!(abort, PointOutcome::Abort);
let directive = drive_point(plan_point(), |hub, id| {
let mut r = InteractionResponse::text(&id, "");
r.choice_index = Some(1); hub.answer(r);
})
.await;
assert_eq!(
directive,
PointOutcome::Directive {
user_text: "Revise".to_string(),
directive: "revise the plan".to_string(),
}
);
}
#[tokio::test]
async fn run_point_edit_does_second_ask() {
let hub = InteractionHub::new();
let (tx, mut rx) = unbounded_channel();
let task = {
let hub = hub.clone();
tokio::spawn(run_interaction_point(
Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
hub,
"run".to_string(),
plan_point(),
"body".to_string(),
0,
tx,
Arc::new(Notify::new()),
))
};
for _ in 0..8 {
tokio::task::yield_now().await;
}
let id = hub.pending()[0].1.id.clone();
let mut r = InteractionResponse::text(&id, "");
r.choice_index = Some(2); hub.answer(r);
for _ in 0..8 {
tokio::task::yield_now().await;
}
let edit_id = hub.pending()[0].1.id.clone();
hub.answer(InteractionResponse::text(&edit_id, "edited body"));
task.await.unwrap();
assert_eq!(
rx.recv().await.unwrap().decision,
PointOutcome::Edit {
user_text: "Add detail".to_string(),
edited: "edited body".to_string(),
}
);
}
#[test]
fn interaction_point_state_round_trips() {
let s = InteractionPointState {
cursor: 2,
round: 1,
body: "# Plan\n1. do it".to_string(),
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(
serde_json::from_str::<InteractionPointState>(&json).unwrap(),
s
);
}
fn resume_world() -> (
World,
InteractionHub,
UnboundedReceiver<InteractionPointOutcome>,
) {
let hub = InteractionHub::new();
let (tx, rx) = unbounded_channel();
let mut world = World::new();
world.insert_resource(hub.clone());
world.insert_resource(InteractionPointStage {
outcomes: tx,
wake: Arc::new(Notify::new()),
runtime: Handle::current(),
});
(world, hub, rx)
}
fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
world
.spawn((
agent_state(AgentStatus::Active),
bp,
window_with_plan(),
StageCursor { index: 0 },
ReadyToInfer,
))
.id()
}
#[tokio::test]
async fn restore_rearms_waiting_and_reopens_the_prompt() {
let (mut world, hub, _rx) = resume_world();
let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
restore_interaction_point(
&mut world,
e,
InteractionPointState {
cursor: 0,
round: 2,
body: "the plan".to_string(),
},
);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Waiting
);
assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
assert!(world.get::<ReadyToInfer>(e).is_none());
assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
for _ in 0..8 {
tokio::task::yield_now().await;
}
let pending = hub.pending();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].0, "run-1");
assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
}
#[tokio::test]
async fn restore_then_answer_drives_the_transition() {
let (mut world, hub, mut rx) = resume_world();
let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
restore_interaction_point(
&mut world,
e,
InteractionPointState {
cursor: 0,
round: 0,
body: "the plan".to_string(),
},
);
for _ in 0..8 {
tokio::task::yield_now().await;
}
let id = hub.pending()[0].1.id.clone();
let mut r = InteractionResponse::text(&id, "");
r.choice_index = Some(0); assert!(hub.answer(r));
let outcome = rx.recv().await.unwrap();
let (tx2, rx2) = unbounded_channel();
tx2.send(outcome).unwrap();
world.insert_resource(InteractionPointResults(rx2));
let mut s = Schedule::default();
s.add_systems(collect_interaction_point);
s.run(&mut world);
assert!(world.get::<ResolveTransition>(e).is_some());
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
}
#[tokio::test]
async fn restore_noop_on_noninteractive_stage() {
let (mut world, hub, _rx) = resume_world();
let e = restored_agent(&mut world, noninteractive_bp());
restore_interaction_point(
&mut world,
e,
InteractionPointState {
cursor: 0,
round: 0,
body: "x".to_string(),
},
);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(world.get::<ReadyToInfer>(e).is_some());
assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
for _ in 0..8 {
tokio::task::yield_now().await;
}
assert!(hub.pending().is_empty());
}
#[tokio::test]
async fn restore_noop_without_lane_wired() {
let mut world = World::new();
let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
restore_interaction_point(
&mut world,
e,
InteractionPointState {
cursor: 0,
round: 0,
body: "x".to_string(),
},
);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(world.get::<ReadyToInfer>(e).is_some());
}
}