Skip to main content

leviath_runtime/
interaction_points.rs

1//! Declarative stage-boundary interaction points (`StageMode::InteractivePoints`).
2//!
3//! Unlike the model-driven `ask_user_*` / `edit_document` tools (which fire only
4//! if the model chooses to call them - see [`crate::dynamic_interaction`]), an
5//! interaction point is declared statically in the blueprint and fired by the
6//! framework at the stage boundary, *always*, before the stage may transition.
7//! The canonical example is `plan_approval`: after the plan stage produces a
8//! plan, the user is shown a choice - approve / revise / edit / abort - and the
9//! answer deterministically routes what happens next.
10//!
11//! This is a first-class ECS lane, mirroring the transition-choice lane:
12//! - [`gate_interaction_points`] intercepts a would-be transition
13//!   ([`ResolveTransition`]) for an interactive-points stage and instead marks the
14//!   agent [`ReadyForInteractionPoint`].
15//! - [`dispatch_interaction_point`] spawns an async task that asks through the
16//!   shared [`InteractionHub`] (so the dashboard surfaces the prompt via
17//!   [`reflect_interaction_status`](crate::pipeline::reflect_interaction_status)),
18//!   resolves the answer, and reports a [`PointOutcome`] on the lane.
19//! - [`collect_interaction_point`] applies the outcome: approve ⇒ proceed to the
20//!   transition, abort ⇒ cancel the run, a directive ⇒ inject it and re-run
21//!   inference in-stage, an edit ⇒ inject the edited text and re-present the
22//!   point. Directive/edit loops are bounded by [`MAX_REVISION_ROUNDS`].
23//!
24//! The routing is deterministic (code); only the input capture is a user
25//! interaction - faithfully porting the deleted imperative
26//! `run_interactive_points_stage`.
27
28use 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
46/// Maximum directive/edit revision rounds at one interaction point before the
47/// stage proceeds regardless, so a revise/edit loop can never run forever.
48pub const MAX_REVISION_ROUNDS: usize = 4;
49
50// ─── Components ──────────────────────────────────────────────────────────────
51
52/// The agent's current stage is done and has an unsatisfied interaction point;
53/// the dispatch system should ask it. (Set by the gate or by an edit re-present.)
54#[derive(Component, Debug, Clone, Copy)]
55pub struct ReadyForInteractionPoint;
56
57/// An interaction point is in flight (its request is open in the hub); the
58/// collect system applies the answer when the lane reports it.
59#[derive(Component, Debug, Clone, Copy)]
60pub struct AwaitingInteractionPoint;
61
62/// Which interaction point (index into the stage's `points`) the agent is on.
63/// Absent ⇒ 0. Advanced on approve; reset when a new stage is entered.
64#[derive(Component, Debug, Clone, Copy)]
65pub struct InteractionPointCursor(pub usize);
66
67/// How many directive/edit revision rounds have been taken at the current point.
68/// Absent ⇒ 0. Reset on approve (advancing points) and on entering a new stage.
69#[derive(Component, Debug, Clone, Copy)]
70pub struct InteractionPointRounds(pub usize);
71
72/// The authoritative document to present as the point's `body` on the next
73/// dispatch, overriding the last inference response. Set when the user edits the
74/// document directly (so the re-presented approval shows the *edited* text, not
75/// the pre-edit version) and consumed on the next dispatch.
76#[derive(Component, Debug, Clone)]
77pub struct PlanBodyOverride(pub String);
78
79// ─── Restart persistence ─────────────────────────────────────────────────────
80
81/// Serializable snapshot of an agent parked at a stage-boundary interaction point,
82/// persisted to `<run_dir>/interactions.json` so a daemon restart can re-present the
83/// exact same prompt instead of dropping it and re-issuing inference.
84/// Mirrors the fan-out sidecar (`fanout.json`). Everything needed to resume is small:
85/// the reviewed document lives here (and in a persisted context region), and the
86/// request id is derived from the agent id + point name + round.
87#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
88pub struct InteractionPointState {
89    /// Which point (index into the stage's `points`) was open.
90    pub cursor: usize,
91    /// How many directive/edit revision rounds had been taken at that point.
92    pub round: usize,
93    /// The document that was under review (the point's `body`), re-presented as-is.
94    pub body: String,
95}
96
97// ─── Lane plumbing ───────────────────────────────────────────────────────────
98
99/// What the user's answer resolved to, routed deterministically from the option
100/// label. Carries the text the collect system must inject into context.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PointOutcome {
103    /// A plain option (no directive/abort/edit) ⇒ complete the point.
104    Approve { user_text: String },
105    /// An abort option ⇒ cancel the run immediately.
106    Abort,
107    /// A directive option ⇒ inject the directive and re-run inference in-stage.
108    Directive {
109        user_text: String,
110        directive: String,
111    },
112    /// An edit option ⇒ inject the user's edited text and re-present the point.
113    Edit { user_text: String, edited: String },
114}
115
116/// One resolved interaction-point answer, reported on the lane.
117pub struct InteractionPointOutcome {
118    /// The agent the answer is for.
119    pub entity: Entity,
120    /// The routed decision.
121    pub decision: PointOutcome,
122}
123
124/// The sending side of the interaction-point lane + the handle/wake needed to
125/// drive the async ask task, as a world resource.
126#[derive(Resource)]
127pub struct InteractionPointStage {
128    /// Where resolved outcomes are reported.
129    pub outcomes: UnboundedSender<InteractionPointOutcome>,
130    /// Wakes the tick loop when an outcome lands.
131    pub wake: Arc<Notify>,
132    /// Runtime the ask task is spawned onto.
133    pub runtime: Handle,
134}
135
136/// The receiving side of the interaction-point lane, for the collect system.
137#[derive(Resource)]
138pub struct InteractionPointResults(pub UnboundedReceiver<InteractionPointOutcome>);
139
140// ─── Pure routing helpers (ported from the deleted imperative stage loop) ─────
141
142/// Normalize an option label for matching: fold Unicode dashes to ASCII `-` and
143/// collapse whitespace, so `"Revise - I'll…"` matches regardless of dash style.
144fn 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
156/// Whether `user_text` matches one of `candidates` (exact first, then normalized).
157fn 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
167/// Look up a directive by option label (exact first, then normalized).
168fn 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
182/// Build the interaction request for a point in its declared style, attaching
183/// `body` (the document the stage produced - e.g. the plan) so the client can
184/// show just this instance's document to review, rather than the full history.
185fn 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
205/// Resolve a response to the selected option label / free text: a choice index
206/// maps through `options`, otherwise the free-text value (empty if none).
207fn 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
214/// Route a resolved answer to a [`PointOutcome`] (pure; the edit branch's second
215/// ask is done by the caller, which knows the edited text).
216fn 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/// Intermediate routing result before the edit branch's second ask.
232#[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// ─── The async ask task ──────────────────────────────────────────────────────
248
249/// Ask an interaction point through the hub, resolve + route the answer (doing
250/// the edit branch's second "edit this text" ask when needed), and report the
251/// [`PointOutcome`] on the lane, waking the tick loop.
252#[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    // Request ids are prefixed with the run id so concurrent runs at the same
264    // point (same name/round) never collide in the shared hub.
265    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
297/// Re-arm an agent that was blocked at an interaction point when the daemon stopped,
298/// bringing it back in the *waiting* state with the same open request - rather than
299/// the default `Active` + `ReadyToInfer` restore, which would re-issue inference and
300/// drop the prompt.
301///
302/// Looks up the point from the agent's (already-restored) blueprint + stage cursor,
303/// restores the point cursor/round, flips the agent to `Waiting` (clearing the
304/// spawn-set `ReadyToInfer`, marking `AwaitingInteractionPoint`), and re-spawns the
305/// ask task so the request re-registers in the hub with the same id
306/// (`{agent_id}-point-{name}-{round}`). From there it is indistinguishable from a live
307/// dispatch: a client that had the prompt open still sees it, and answering it later
308/// routes normally through [`collect_interaction_point`].
309///
310/// A no-op (leaving the default restore in place) when the interaction-point lane
311/// isn't wired (a test world), or when the stage is no longer an interactive-points
312/// stage / the cursor is out of range (e.g. the blueprint changed under the run).
313pub fn restore_interaction_point(world: &mut World, entity: Entity, state: InteractionPointState) {
314    // The lane + hub must both be wired (they are in the daemon; absent in a test
315    // world) - otherwise there is nothing to await the re-opened request.
316    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    // Resolve the point from the restored blueprint + stage cursor. A reloaded agent
325    // always carries these; a blueprint that changed out from under the run (stage no
326    // longer interactive, or fewer points) leaves the default restore in place rather
327    // than resuming a stale prompt.
328    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    // Re-arm the waiting state: restore the cursor/round, mark the agent awaiting the
354    // point, and clear the spawn-set `ReadyToInfer` so the inference lane won't fire.
355    {
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    // Re-open the request in the hub and await it, exactly as a live dispatch would.
367    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
379// ─── Systems ─────────────────────────────────────────────────────────────────
380
381/// Read the interaction points of an agent's current stage, or `None` if the
382/// stage isn't an interactive-points stage.
383fn 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/// Gate: intercept a would-be transition for an interactive-points stage whose
394/// points aren't all satisfied yet, routing the agent to the interaction-point
395/// lane instead. Stages with no points, or whose point cursor is past the end
396/// (all approved), fall through to the normal transition.
397#[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; // nothing to ask ⇒ let the transition proceed
419        }
420        commands
421            .entity(entity)
422            .remove::<ResolveTransition>()
423            .insert(ReadyForInteractionPoint);
424    }
425}
426
427/// Dispatch: for each `ReadyForInteractionPoint` agent, spawn the ask task for
428/// its current point and move it to `AwaitingInteractionPoint`. No hub (test
429/// world) ⇒ no-op; a non-interactive stage ⇒ fall back to the transition.
430#[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; // no lane wired (test world)
454    };
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; // paused / cancelled - don't open a prompt
461        }
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            // Stage changed out from under us ⇒ just proceed to the transition.
466            commands
467                .entity(entity)
468                .remove::<ReadyForInteractionPoint>()
469                .insert(ResolveTransition);
470            continue;
471        };
472        // The document to review: a direct edit (override) takes precedence over
473        // the last inference response, so a re-presented approval reflects it.
474        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        // Make this the authoritative document in its pinned region (replacing
479        // any prior version), so revisions build on the current text - the
480        // user's edit included - rather than regenerating from the task. A
481        // user edit is marked so the model preserves it deliberately.
482        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        // An unattended run (`--yolo`) approves the checkpoint instead of
494        // opening a prompt nobody will answer. The document was published to its
495        // region above, so what was approved is still on the record.
496        //
497        // Unless the point declares `unattended = "ask"`: some checkpoints exist
498        // precisely because a person has to look - a plan signed off before any
499        // code is written - and their author would rather the run wait than have
500        // it wave itself through. `[limits] interaction_timeout_secs` is what
501        // keeps that wait from lasting for ever.
502        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/// Collect: apply each resolved interaction-point outcome - approve advances
541/// (or transitions when all points are done), abort cancels, a directive injects
542/// the directive and re-infers in-stage, an edit injects the edited text and
543/// re-presents; both revision paths are bounded by [`MAX_REVISION_ROUNDS`].
544#[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; // stale: agent cancelled/despawned since dispatch
567        };
568        crate::tick_scope::enter(out.entity);
569        // A run cancelled while its prompt was open is finished, and the arms
570        // below all set `Active`/`ResolveTransition` unconditionally - so without
571        // this, answering the orphaned prompt (from `lev respond`, the dashboard,
572        // or the neutral response a cancel itself delivers) walked the run
573        // straight back to `Active` and it carried on as if it had never been
574        // cancelled. Drop the outcome and let the reaper take the entity.
575        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        // Mark all points satisfied so the gate lets the transition proceed
595        // (the cursor is reset when the next stage is entered).
596        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                // Say plainly that this was approved *after* being changed.
609                //
610                // A model that has already concluded something tends to keep the
611                // conclusion and apply the correction only to the document. That
612                // happened: an agent that had created a file during discovery
613                // read it back while planning, decided "already created - no
614                // further action is needed", was told to use a different
615                // filename, updated the plan to say so, and still ended the run
616                // without renaming anything. The plan changed; its reading of
617                // the world did not.
618                //
619                // `round` counts revision rounds on this point, so a non-zero
620                // value means the approved text is not what the model first
621                // proposed.
622                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); // all points satisfied ⇒ transition
637                } 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); // revision cap ⇒ proceed
651                } else {
652                    // Stay on this point; re-run inference in-stage on the directive.
653                    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, "", &note);
670                        // Surface the adopted text in the stage output so observers
671                        // (e.g. the dashboard's output pane, which reads output.log)
672                        // reflect the revision rather than the pre-edit version.
673                        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                        // Present the edited text (not the pre-edit inference
680                        // response) as the re-presented point's review body.
681                        e.insert(PlanBodyOverride(edited));
682                    }
683                    // Re-present the same point with the edit applied (no re-infer).
684                    e.insert(InteractionPointRounds(round + 1))
685                        .insert(ReadyForInteractionPoint);
686                }
687            }
688        }
689    }
690}
691
692/// Inject a `User [name] <prefix><text>` line into the conversation region (no-op
693/// on empty text), so the agent sees the user's selection / directive / edit.
694fn 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    // ── builders ──
712
713    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    /// The plan_approval point: approve / revise (directive) / edit / abort.
729    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    /// A single-stage blueprint whose stage is *not* an interactive-points stage.
756    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    // ── pure helpers ──
808
809    #[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")); // exact
823        assert!(option_matches(&opts, "Abort - now")); // normalized
824        assert!(!option_matches(&opts, "Approve")); // miss
825    }
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        // The document is attached as a markdown body to review.
847        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        // A blank body is not attached.
859        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"); // choice → option
875        r.choice_index = Some(9); // out of range → fall to value
876        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), ""); // no choice, empty value
880    }
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        // A non-interactive stage.
921        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    // ── gate ──
936
937    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        // cursor past the (single) point ⇒ satisfied.
962        let done = world
963            .spawn((
964                blueprint_with(vec![plan_point()]),
965                StageCursor { index: 0 },
966                InteractionPointCursor(1),
967                ResolveTransition,
968            ))
969            .id();
970        // empty points.
971        let empty = world
972            .spawn((
973                blueprint_with(vec![]),
974                StageCursor { index: 0 },
975                ResolveTransition,
976            ))
977            .id();
978        // non-interactive stage.
979        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    // ── dispatch ──
995
996    #[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        // No InteractionHub / InteractionPointStage resources ⇒ early return.
1009        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()); // untouched
1013    }
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()); // not dispatched
1045    }
1046
1047    #[tokio::test]
1048    async fn dispatch_falls_through_when_point_missing() {
1049        let (mut world, _hub) = dispatch_world();
1050        // cursor past the single point ⇒ no point to ask ⇒ ResolveTransition.
1051        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        // The ask task registered a request in the hub, carrying the produced
1088        // document (the plan) as its review body.
1089        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        // The produced plan became the authoritative content of the pinned
1096        // `plan` region (no user-edit marker, since it came from inference).
1097        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        // `--yolo` means nobody is watching, so a stage-boundary checkpoint must
1109        // resolve itself rather than park the run on the hub forever (#107).
1110        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        // Approved straight onto the outcome lane; no prompt was ever opened.
1137        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        // The approved document still landed in its region, so what was waved
1148        // through is on the record.
1149        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        // `unattended = "ask"` is the escape hatch for a checkpoint that exists
1160        // precisely because a person has to look - approving a plan unread is
1161        // worse than waiting for one. The prompt opens even under `--yolo`.
1162        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        // Nothing was waved through: the run waits on a real prompt.
1190        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        // A point with no `document_region` still asks, but writes no region.
1202        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        // The plan region is untouched (the point declared no document region).
1218        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        // An empty produced document is not written to the region.
1229        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        // The override is consumed once dispatched.
1269        assert!(world.get::<PlanBodyOverride>(e).is_none());
1270        // The edited text replaced the plan region, marked as user-revised so
1271        // the model preserves it on later revisions.
1272        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        // The edited text, not the inference response, is the review body.
1284        assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1285    }
1286
1287    // ── collect ──
1288
1289    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    /// Answering the prompt of a run that was cancelled while it waited must not
1379    /// bring the run back. Every non-`Abort` arm sets `Active` unconditionally, so
1380    /// without the terminal guard an answer - including the neutral response a
1381    /// cancel itself delivers to release the blocked `ask` - walked a cancelled
1382    /// run straight back into the pipeline.
1383    #[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        // At the cap, a further directive proceeds instead of re-inferring.
1445        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        // The adopted text is buffered for stages/<idx>/output.log, tagged with
1484        // the current stage index, so observers reflect the revision.
1485        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        // The edited text is also queued as the re-presented point's review body.
1490        assert_eq!(
1491            world.get::<PlanBodyOverride>(e).unwrap().0,
1492            "the revised plan"
1493        );
1494    }
1495
1496    /// An approval that followed a revision has to say so. A model that had
1497    /// already concluded "this is done" kept the conclusion and applied the
1498    /// correction only to the document - the plan changed, its reading of the
1499    /// world did not. The note is only injected when there *was* a revision.
1500    #[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        // The edited text was injected.
1546        let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1547        assert!(after_first > 0);
1548
1549        // An empty edit re-presents too, but injects nothing new.
1550        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        // At the cap, an edit proceeds instead of re-presenting.
1570        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(), // empty edit ⇒ no injection branch
1579            },
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        // An outcome for an agent whose stage isn't interactive (npoints = 0):
1589        // approve's next index immediately satisfies, so it proceeds.
1590        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); // no panic
1621    }
1622
1623    // ── the async ask task ──
1624
1625    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); // Approve
1658            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); // Abort
1674            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); // Revise
1682            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        // Selecting the edit option triggers a second (edit_text) ask; answer both.
1697        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        // Answer the point with the edit option.
1713        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); // Add detail ⇒ edit
1719        hub.answer(r);
1720        // Then answer the edit request with the edited text.
1721        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    // ── restore (restart persistence, issue #38) ──
1737
1738    #[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    /// A world with the interaction-point lane + hub wired, keeping the results
1753    /// receiver so a resumed point can be answered and collected end-to-end.
1754    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    /// A freshly "restored" agent as `restore_agent` leaves it (Active +
1772    /// ReadyToInfer) before interaction-point restore runs.
1773    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        // Re-armed in the waiting state: the inference lane won't fire.
1800        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        // The ask task re-registered the *same* request id in the hub, with the body.
1810        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        // Approve the re-opened prompt; the outcome lands on the lane.
1838        let id = hub.pending()[0].1.id.clone();
1839        let mut r = InteractionResponse::text(&id, "");
1840        r.choice_index = Some(0); // Approve
1841        assert!(hub.answer(r));
1842        let outcome = rx.recv().await.unwrap();
1843
1844        // Feed it to collect and confirm the stage proceeds.
1845        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        // Left as the default restore: Active + ReadyToInfer, nothing re-opened.
1873        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        // No InteractionPointStage / hub resources (a test world) ⇒ no-op.
1888        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}