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};
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        if auto_approve.is_some() {
497            tracing::info!(
498                agent = %state.agent_id,
499                point = %point.name,
500                "auto-approving interaction point (unattended run)"
501            );
502            let _ = stage.outcomes.send(InteractionPointOutcome {
503                entity,
504                decision: PointOutcome::Approve {
505                    user_text: String::new(),
506                },
507            });
508            stage.wake.notify_one();
509            commands
510                .entity(entity)
511                .remove::<ReadyForInteractionPoint>()
512                .remove::<PlanBodyOverride>()
513                .insert(AwaitingInteractionPoint);
514            continue;
515        }
516        stage.runtime.spawn(run_interaction_point(
517            entity,
518            hub.clone(),
519            state.agent_id.clone(),
520            point,
521            body,
522            rounds.map_or(0, |r| r.0),
523            stage.outcomes.clone(),
524            stage.wake.clone(),
525        ));
526        commands
527            .entity(entity)
528            .remove::<ReadyForInteractionPoint>()
529            .remove::<PlanBodyOverride>()
530            .insert(AwaitingInteractionPoint);
531    }
532}
533
534/// Collect: apply each resolved interaction-point outcome - approve advances
535/// (or transitions when all points are done), abort cancels, a directive injects
536/// the directive and re-infers in-stage, an edit injects the edited text and
537/// re-presents; both revision paths are bounded by [`MAX_REVISION_ROUNDS`].
538#[allow(clippy::type_complexity)]
539pub fn collect_interaction_point(
540    mut results: ResMut<InteractionPointResults>,
541    mut agents: Query<
542        (
543            &mut AgentState,
544            &mut ContextWindow,
545            &AgentBlueprint,
546            &StageCursor,
547            Option<&InteractionPointCursor>,
548            Option<&InteractionPointRounds>,
549            Option<&mut StageIoBuffer>,
550        ),
551        With<AwaitingInteractionPoint>,
552    >,
553    mut commands: Commands,
554) {
555    crate::tick_scope::clear();
556    while let Ok(out) = results.0.try_recv() {
557        let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
558            agents.get_mut(out.entity)
559        else {
560            continue; // stale: agent cancelled/despawned since dispatch
561        };
562        crate::tick_scope::enter(out.entity);
563        // A run cancelled while its prompt was open is finished, and the arms
564        // below all set `Active`/`ResolveTransition` unconditionally - so without
565        // this, answering the orphaned prompt (from `lev respond`, the dashboard,
566        // or the neutral response a cancel itself delivers) walked the run
567        // straight back to `Active` and it carried on as if it had never been
568        // cancelled. Drop the outcome and let the reaper take the entity.
569        if crate::pipeline::is_terminal_status(&state.status) {
570            commands
571                .entity(out.entity)
572                .remove::<AwaitingInteractionPoint>();
573            continue;
574        }
575        let idx = pc.map_or(0, |c| c.0);
576        let round = rounds.map_or(0, |r| r.0);
577        let (name, npoints) = match stage_points(bp, cursor) {
578            Some(points) => (
579                points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
580                points.len(),
581            ),
582            None => (String::new(), 0),
583        };
584
585        let mut e = commands.entity(out.entity);
586        e.remove::<AwaitingInteractionPoint>();
587
588        // Mark all points satisfied so the gate lets the transition proceed
589        // (the cursor is reset when the next stage is entered).
590        let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
591            e.insert(InteractionPointCursor(npoints))
592                .insert(ResolveTransition);
593        };
594
595        match out.decision {
596            PointOutcome::Abort => {
597                state.status = AgentStatus::Cancelled;
598            }
599            PointOutcome::Approve { user_text } => {
600                state.status = AgentStatus::Active;
601                inject(&mut window, &name, "", &user_text);
602                // Say plainly that this was approved *after* being changed.
603                //
604                // A model that has already concluded something tends to keep the
605                // conclusion and apply the correction only to the document. That
606                // happened: an agent that had created a file during discovery
607                // read it back while planning, decided "already created - no
608                // further action is needed", was told to use a different
609                // filename, updated the plan to say so, and still ended the run
610                // without renaming anything. The plan changed; its reading of
611                // the world did not.
612                //
613                // `round` counts revision rounds on this point, so a non-zero
614                // value means the approved text is not what the model first
615                // proposed.
616                if round > 0 {
617                    inject(
618                        &mut window,
619                        &name,
620                        "",
621                        "The plan above was revised before you approved it. Work from \
622                         the approved text as written - any conclusion you reached \
623                         from the earlier version, including that something is \
624                         already done, may no longer hold and should be re-checked \
625                         against the plan rather than assumed.",
626                    );
627                }
628                let next = idx + 1;
629                if next >= npoints {
630                    proceed(&mut e); // all points satisfied ⇒ transition
631                } else {
632                    e.insert(InteractionPointCursor(next))
633                        .insert(InteractionPointRounds(0))
634                        .insert(ReadyForInteractionPoint);
635                }
636            }
637            PointOutcome::Directive {
638                user_text,
639                directive,
640            } => {
641                state.status = AgentStatus::Active;
642                inject(&mut window, &name, "", &user_text);
643                if round + 1 >= MAX_REVISION_ROUNDS {
644                    proceed(&mut e); // revision cap ⇒ proceed
645                } else {
646                    // Stay on this point; re-run inference in-stage on the directive.
647                    inject(&mut window, &name, "directive: ", &directive);
648                    e.insert(InteractionPointRounds(round + 1))
649                        .insert(ReadyToInfer);
650                }
651            }
652            PointOutcome::Edit { user_text, edited } => {
653                state.status = AgentStatus::Active;
654                inject(&mut window, &name, "", &user_text);
655                if round + 1 >= MAX_REVISION_ROUNDS {
656                    proceed(&mut e);
657                } else {
658                    if !edited.is_empty() {
659                        let note = format!(
660                            "edited the output directly. Adopt this exact text as the \
661                             authoritative version and re-present it:\n{edited}"
662                        );
663                        inject(&mut window, &name, "", &note);
664                        // Surface the adopted text in the stage output so observers
665                        // (e.g. the dashboard's output pane, which reads output.log)
666                        // reflect the revision rather than the pre-edit version.
667                        if let Some(mut buf) = io_buf {
668                            buf.output.push((
669                                cursor.index,
670                                format!("\n─── Updated (your edit) ───\n{edited}"),
671                            ));
672                        }
673                        // Present the edited text (not the pre-edit inference
674                        // response) as the re-presented point's review body.
675                        e.insert(PlanBodyOverride(edited));
676                    }
677                    // Re-present the same point with the edit applied (no re-infer).
678                    e.insert(InteractionPointRounds(round + 1))
679                        .insert(ReadyForInteractionPoint);
680                }
681            }
682        }
683    }
684}
685
686/// Inject a `User [name] <prefix><text>` line into the conversation region (no-op
687/// on empty text), so the agent sees the user's selection / directive / edit.
688fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
689    if text.is_empty() {
690        return;
691    }
692    let content = format!("User [{name}] {prefix}{text}");
693    let tokens = leviath_core::estimate_tokens(&content);
694    let _ = window.add_to_region("conversation", content, tokens);
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::components::AgentStatus;
701    use leviath_core::interaction::InteractionResponse;
702    use leviath_core::{Region, RegionKind};
703    use tokio::sync::mpsc::unbounded_channel;
704
705    // ── builders ──
706
707    fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
708        InteractionPoint {
709            name: name.to_string(),
710            prompt: "Choose".to_string(),
711            required: true,
712            style,
713            options: options.iter().map(|s| s.to_string()).collect(),
714            directives: HashMap::new(),
715            abort_options: Vec::new(),
716            edit_options: Vec::new(),
717            document_region: None,
718        }
719    }
720
721    /// The plan_approval point: approve / revise (directive) / edit / abort.
722    fn plan_point() -> InteractionPoint {
723        let mut p = point(
724            "plan_approval",
725            InteractionStyle::MultipleChoice,
726            &["Approve", "Revise", "Add detail", "Abort"],
727        );
728        p.directives
729            .insert("Revise".to_string(), "revise the plan".to_string());
730        p.abort_options = vec!["Abort".to_string()];
731        p.edit_options = vec!["Add detail".to_string()];
732        p.document_region = Some("plan".to_string());
733        p
734    }
735
736    fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
737        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
738        let mut stage = leviath_core::Stage::new(
739            "plan".to_string(),
740            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
741        );
742        stage.mode = StageMode::InteractivePoints { points };
743        let bp =
744            leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
745        AgentBlueprint(bp)
746    }
747
748    /// A single-stage blueprint whose stage is *not* an interactive-points stage.
749    fn noninteractive_bp() -> AgentBlueprint {
750        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
751        let stage = leviath_core::Stage::new(
752            "auto".to_string(),
753            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
754        );
755        AgentBlueprint(leviath_core::Blueprint::new(
756            "t".to_string(),
757            "d".to_string(),
758            vec![stage],
759            layout,
760        ))
761    }
762
763    fn agent_state(status: AgentStatus) -> AgentState {
764        AgentState {
765            agent_id: "run-1".to_string(),
766            current_stage: "plan".to_string(),
767            iteration: 1,
768            status,
769            spawned_children_ids: vec![],
770            pending_wait: None,
771            accepts_messages: true,
772        }
773    }
774
775    fn window() -> ContextWindow {
776        let mut w = ContextWindow::new(100_000);
777        w.add_region(Region::new(
778            "conversation".to_string(),
779            RegionKind::Clearable,
780            10_000,
781        ));
782        w
783    }
784
785    fn window_with_plan() -> ContextWindow {
786        let mut w = window();
787        w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
788        w
789    }
790
791    fn infer(text: &str) -> InferenceResult {
792        InferenceResult {
793            response: text.to_string(),
794            tool_calls: vec![],
795            tokens_used: 0,
796            timestamp: 0,
797        }
798    }
799
800    // ── pure helpers ──
801
802    #[test]
803    fn normalize_folds_dashes_and_whitespace() {
804        assert_eq!(
805            normalize_for_followup("Revise \u{2014} now"),
806            "Revise - now"
807        );
808        assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
809        assert_eq!(normalize_for_followup("  x   y  "), "x y");
810    }
811
812    #[test]
813    fn option_matches_exact_normalized_and_miss() {
814        let opts = vec!["Abort \u{2014} now".to_string()];
815        assert!(option_matches(&opts, "Abort \u{2014} now")); // exact
816        assert!(option_matches(&opts, "Abort - now")); // normalized
817        assert!(!option_matches(&opts, "Approve")); // miss
818    }
819
820    #[test]
821    fn lookup_directive_exact_normalized_and_none() {
822        let mut d = HashMap::new();
823        d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
824        assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
825        assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
826        assert_eq!(lookup_directive(&d, "Approve"), None);
827    }
828
829    #[test]
830    fn build_point_request_by_style() {
831        use leviath_core::interaction::InteractionKind;
832        let mc = build_point_request(
833            &point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
834            "id".to_string(),
835            "## Plan\n1. do it",
836        );
837        assert_eq!(mc.kind, InteractionKind::MultipleChoice);
838        assert_eq!(mc.options.len(), 2);
839        // The document is attached as a markdown body to review.
840        assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
841        assert_eq!(
842            mc.body_format,
843            leviath_core::interaction::BodyFormat::Markdown
844        );
845        let cf = build_point_request(
846            &point("p", InteractionStyle::Confirm, &[]),
847            "id".to_string(),
848            "",
849        );
850        assert_eq!(cf.kind, InteractionKind::Confirm);
851        // A blank body is not attached.
852        assert_eq!(cf.body, None);
853        let ft = build_point_request(
854            &point("p", InteractionStyle::FreeText, &[]),
855            "id".to_string(),
856            "   ",
857        );
858        assert_eq!(ft.kind, InteractionKind::FreeText);
859        assert_eq!(ft.body, None);
860    }
861
862    #[test]
863    fn resolve_answer_choice_index_fallback_and_value() {
864        let opts = vec!["A".to_string(), "B".to_string()];
865        let mut r = InteractionResponse::text("q", "");
866        r.choice_index = Some(1);
867        assert_eq!(resolve_answer(&r, &opts), "B"); // choice → option
868        r.choice_index = Some(9); // out of range → fall to value
869        r.value = Some("typed".to_string());
870        assert_eq!(resolve_answer(&r, &opts), "typed");
871        let empty = InteractionResponse::text("q", "");
872        assert_eq!(resolve_answer(&empty, &opts), ""); // no choice, empty value
873    }
874
875    #[test]
876    fn route_answer_covers_all_four() {
877        let p = plan_point();
878        assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
879        assert_eq!(
880            route_answer(&p, "Add detail".to_string()),
881            Routed::Edit {
882                user_text: "Add detail".to_string()
883            }
884        );
885        assert_eq!(
886            route_answer(&p, "Revise".to_string()),
887            Routed::Directive {
888                user_text: "Revise".to_string(),
889                directive: "revise the plan".to_string(),
890            }
891        );
892        assert_eq!(
893            route_answer(&p, "Approve".to_string()),
894            Routed::Approve {
895                user_text: "Approve".to_string()
896            }
897        );
898    }
899
900    #[test]
901    fn inject_skips_empty_and_appends_nonempty() {
902        let mut w = window();
903        inject(&mut w, "plan", "", "");
904        assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
905        inject(&mut w, "plan", "directive: ", "do x");
906        assert!(w.get_region("conversation").unwrap().current_tokens > 0);
907    }
908
909    #[test]
910    fn stage_points_some_for_interactive_none_otherwise() {
911        let bp = blueprint_with(vec![plan_point()]);
912        assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
913        // A non-interactive stage.
914        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
915        let stage = leviath_core::Stage::new(
916            "auto".to_string(),
917            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
918        );
919        let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
920            "t".to_string(),
921            "d".to_string(),
922            vec![stage],
923            layout,
924        ));
925        assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
926    }
927
928    // ── gate ──
929
930    fn run_gate(world: &mut World) {
931        let mut s = Schedule::default();
932        s.add_systems(gate_interaction_points);
933        s.run(world);
934    }
935
936    #[test]
937    fn gate_intercepts_unsatisfied_interactive_stage() {
938        let mut world = World::new();
939        let e = world
940            .spawn((
941                blueprint_with(vec![plan_point()]),
942                StageCursor { index: 0 },
943                ResolveTransition,
944            ))
945            .id();
946        run_gate(&mut world);
947        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
948        assert!(world.get::<ResolveTransition>(e).is_none());
949    }
950
951    #[test]
952    fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
953        let mut world = World::new();
954        // cursor past the (single) point ⇒ satisfied.
955        let done = world
956            .spawn((
957                blueprint_with(vec![plan_point()]),
958                StageCursor { index: 0 },
959                InteractionPointCursor(1),
960                ResolveTransition,
961            ))
962            .id();
963        // empty points.
964        let empty = world
965            .spawn((
966                blueprint_with(vec![]),
967                StageCursor { index: 0 },
968                ResolveTransition,
969            ))
970            .id();
971        // non-interactive stage.
972        let auto = world
973            .spawn((
974                noninteractive_bp(),
975                StageCursor { index: 0 },
976                ResolveTransition,
977            ))
978            .id();
979        run_gate(&mut world);
980        assert!(world.get::<ResolveTransition>(done).is_some());
981        assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
982        assert!(world.get::<ResolveTransition>(empty).is_some());
983        assert!(world.get::<ResolveTransition>(auto).is_some());
984        assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
985    }
986
987    // ── dispatch ──
988
989    #[tokio::test]
990    async fn dispatch_noop_without_hub_or_stage() {
991        let mut world = World::new();
992        let e = world
993            .spawn((
994                agent_state(AgentStatus::Active),
995                blueprint_with(vec![plan_point()]),
996                StageCursor { index: 0 },
997                infer("plan"),
998                ReadyForInteractionPoint,
999            ))
1000            .id();
1001        // No InteractionHub / InteractionPointStage resources ⇒ early return.
1002        let mut s = Schedule::default();
1003        s.add_systems(dispatch_interaction_point);
1004        s.run(&mut world);
1005        assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); // untouched
1006    }
1007
1008    fn dispatch_world() -> (World, InteractionHub) {
1009        let hub = InteractionHub::new();
1010        let (tx, _rx) = unbounded_channel();
1011        let mut world = World::new();
1012        world.insert_resource(hub.clone());
1013        world.insert_resource(InteractionPointStage {
1014            outcomes: tx,
1015            wake: Arc::new(Notify::new()),
1016            runtime: Handle::current(),
1017        });
1018        (world, hub)
1019    }
1020
1021    #[tokio::test]
1022    async fn dispatch_skips_non_active_agent() {
1023        let (mut world, _hub) = dispatch_world();
1024        let e = world
1025            .spawn((
1026                agent_state(AgentStatus::Waiting),
1027                blueprint_with(vec![plan_point()]),
1028                window_with_plan(),
1029                StageCursor { index: 0 },
1030                infer("plan"),
1031                ReadyForInteractionPoint,
1032            ))
1033            .id();
1034        let mut s = Schedule::default();
1035        s.add_systems(dispatch_interaction_point);
1036        s.run(&mut world);
1037        assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); // not dispatched
1038    }
1039
1040    #[tokio::test]
1041    async fn dispatch_falls_through_when_point_missing() {
1042        let (mut world, _hub) = dispatch_world();
1043        // cursor past the single point ⇒ no point to ask ⇒ ResolveTransition.
1044        let e = world
1045            .spawn((
1046                agent_state(AgentStatus::Active),
1047                blueprint_with(vec![plan_point()]),
1048                window_with_plan(),
1049                StageCursor { index: 0 },
1050                InteractionPointCursor(5),
1051                infer("plan"),
1052                ReadyForInteractionPoint,
1053            ))
1054            .id();
1055        let mut s = Schedule::default();
1056        s.add_systems(dispatch_interaction_point);
1057        s.run(&mut world);
1058        assert!(world.get::<ResolveTransition>(e).is_some());
1059        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1060    }
1061
1062    #[tokio::test]
1063    async fn dispatch_spawns_ask_and_awaits() {
1064        let (mut world, hub) = dispatch_world();
1065        let e = world
1066            .spawn((
1067                agent_state(AgentStatus::Active),
1068                blueprint_with(vec![plan_point()]),
1069                window_with_plan(),
1070                StageCursor { index: 0 },
1071                infer("the plan"),
1072                ReadyForInteractionPoint,
1073            ))
1074            .id();
1075        let mut s = Schedule::default();
1076        s.add_systems(dispatch_interaction_point);
1077        s.run(&mut world);
1078        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1079        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1080        // The ask task registered a request in the hub, carrying the produced
1081        // document (the plan) as its review body.
1082        for _ in 0..8 {
1083            tokio::task::yield_now().await;
1084        }
1085        let pending = hub.pending();
1086        assert_eq!(pending.len(), 1);
1087        assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1088        // The produced plan became the authoritative content of the pinned
1089        // `plan` region (no user-edit marker, since it came from inference).
1090        let plan = world
1091            .get::<ContextWindow>(e)
1092            .unwrap()
1093            .get_region("plan")
1094            .unwrap();
1095        assert_eq!(plan.content.len(), 1);
1096        assert_eq!(plan.content[0].content, "the plan");
1097    }
1098
1099    #[tokio::test]
1100    async fn dispatch_auto_approves_an_unattended_run_without_asking() {
1101        // `--yolo` means nobody is watching, so a stage-boundary checkpoint must
1102        // resolve itself rather than park the run on the hub forever (#107).
1103        let hub = InteractionHub::new();
1104        let (tx, mut rx) = unbounded_channel();
1105        let mut world = World::new();
1106        world.insert_resource(hub.clone());
1107        world.insert_resource(InteractionPointStage {
1108            outcomes: tx,
1109            wake: Arc::new(Notify::new()),
1110            runtime: Handle::current(),
1111        });
1112        let e = world
1113            .spawn((
1114                agent_state(AgentStatus::Active),
1115                blueprint_with(vec![plan_point()]),
1116                window_with_plan(),
1117                StageCursor { index: 0 },
1118                infer("the plan"),
1119                ReadyForInteractionPoint,
1120                crate::components::InteractionAutoApprove,
1121            ))
1122            .id();
1123        let mut s = Schedule::default();
1124        s.add_systems(dispatch_interaction_point);
1125        s.run(&mut world);
1126
1127        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1128        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1129        // Approved straight onto the outcome lane; no prompt was ever opened.
1130        let outcome = rx.try_recv().expect("an outcome was published");
1131        assert_eq!(outcome.entity, e);
1132        assert!(matches!(
1133            outcome.decision,
1134            PointOutcome::Approve { ref user_text } if user_text.is_empty()
1135        ));
1136        for _ in 0..8 {
1137            tokio::task::yield_now().await;
1138        }
1139        assert!(hub.pending().is_empty(), "no human was asked");
1140        // The approved document still landed in its region, so what was waved
1141        // through is on the record.
1142        let plan = world
1143            .get::<ContextWindow>(e)
1144            .unwrap()
1145            .get_region("plan")
1146            .unwrap();
1147        assert_eq!(plan.content[0].content, "the plan");
1148    }
1149
1150    #[tokio::test]
1151    async fn dispatch_without_document_region_skips_region_write() {
1152        // A point with no `document_region` still asks, but writes no region.
1153        let (mut world, _hub) = dispatch_world();
1154        let e = world
1155            .spawn((
1156                agent_state(AgentStatus::Active),
1157                blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
1158                window_with_plan(),
1159                StageCursor { index: 0 },
1160                infer("some output"),
1161                ReadyForInteractionPoint,
1162            ))
1163            .id();
1164        let mut s = Schedule::default();
1165        s.add_systems(dispatch_interaction_point);
1166        s.run(&mut world);
1167        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1168        // The plan region is untouched (the point declared no document region).
1169        let plan = world
1170            .get::<ContextWindow>(e)
1171            .unwrap()
1172            .get_region("plan")
1173            .unwrap();
1174        assert!(plan.content.is_empty());
1175    }
1176
1177    #[tokio::test]
1178    async fn dispatch_with_empty_document_skips_region_write() {
1179        // An empty produced document is not written to the region.
1180        let (mut world, _hub) = dispatch_world();
1181        let e = world
1182            .spawn((
1183                agent_state(AgentStatus::Active),
1184                blueprint_with(vec![plan_point()]),
1185                window_with_plan(),
1186                StageCursor { index: 0 },
1187                infer("   "),
1188                ReadyForInteractionPoint,
1189            ))
1190            .id();
1191        let mut s = Schedule::default();
1192        s.add_systems(dispatch_interaction_point);
1193        s.run(&mut world);
1194        let plan = world
1195            .get::<ContextWindow>(e)
1196            .unwrap()
1197            .get_region("plan")
1198            .unwrap();
1199        assert!(plan.content.is_empty());
1200    }
1201
1202    #[tokio::test]
1203    async fn dispatch_prefers_the_plan_body_override() {
1204        let (mut world, hub) = dispatch_world();
1205        let e = world
1206            .spawn((
1207                agent_state(AgentStatus::Active),
1208                blueprint_with(vec![plan_point()]),
1209                window_with_plan(),
1210                StageCursor { index: 0 },
1211                infer("the stale pre-edit plan"),
1212                PlanBodyOverride("the edited plan".to_string()),
1213                ReadyForInteractionPoint,
1214            ))
1215            .id();
1216        let mut s = Schedule::default();
1217        s.add_systems(dispatch_interaction_point);
1218        s.run(&mut world);
1219        // The override is consumed once dispatched.
1220        assert!(world.get::<PlanBodyOverride>(e).is_none());
1221        // The edited text replaced the plan region, marked as user-revised so
1222        // the model preserves it on later revisions.
1223        let plan = world
1224            .get::<ContextWindow>(e)
1225            .unwrap()
1226            .get_region("plan")
1227            .unwrap();
1228        assert_eq!(plan.content.len(), 1);
1229        assert!(plan.content[0].content.contains("[revised by user"));
1230        assert!(plan.content[0].content.contains("the edited plan"));
1231        for _ in 0..8 {
1232            tokio::task::yield_now().await;
1233        }
1234        // The edited text, not the inference response, is the review body.
1235        assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1236    }
1237
1238    // ── collect ──
1239
1240    fn collect_world() -> (
1241        World,
1242        tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
1243    ) {
1244        let (tx, rx) = unbounded_channel();
1245        let mut world = World::new();
1246        world.insert_resource(InteractionPointResults(rx));
1247        (world, tx)
1248    }
1249
1250    fn run_collect(world: &mut World) {
1251        let mut s = Schedule::default();
1252        s.add_systems(collect_interaction_point);
1253        s.run(world);
1254    }
1255
1256    fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
1257        world
1258            .spawn((
1259                agent_state(AgentStatus::Waiting),
1260                window(),
1261                blueprint_with(points),
1262                StageCursor { index: 0 },
1263                AwaitingInteractionPoint,
1264            ))
1265            .id()
1266    }
1267
1268    #[test]
1269    fn collect_approve_single_point_proceeds() {
1270        let (mut world, tx) = collect_world();
1271        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1272        tx.send(InteractionPointOutcome {
1273            entity: e,
1274            decision: PointOutcome::Approve {
1275                user_text: "Approve".to_string(),
1276            },
1277        })
1278        .unwrap();
1279        run_collect(&mut world);
1280        assert!(world.get::<ResolveTransition>(e).is_some());
1281        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1282        assert_eq!(
1283            world.get::<AgentState>(e).unwrap().status,
1284            AgentStatus::Active
1285        );
1286        assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1287    }
1288
1289    #[test]
1290    fn collect_approve_advances_to_next_point() {
1291        let (mut world, tx) = collect_world();
1292        let e = spawn_awaiting(
1293            &mut world,
1294            vec![
1295                point("first", InteractionStyle::Confirm, &[]),
1296                point("second", InteractionStyle::Confirm, &[]),
1297            ],
1298        );
1299        tx.send(InteractionPointOutcome {
1300            entity: e,
1301            decision: PointOutcome::Approve {
1302                user_text: String::new(),
1303            },
1304        })
1305        .unwrap();
1306        run_collect(&mut world);
1307        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1308        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1309        assert!(world.get::<ResolveTransition>(e).is_none());
1310    }
1311
1312    #[test]
1313    fn collect_abort_cancels() {
1314        let (mut world, tx) = collect_world();
1315        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1316        tx.send(InteractionPointOutcome {
1317            entity: e,
1318            decision: PointOutcome::Abort,
1319        })
1320        .unwrap();
1321        run_collect(&mut world);
1322        assert_eq!(
1323            world.get::<AgentState>(e).unwrap().status,
1324            AgentStatus::Cancelled
1325        );
1326        assert!(world.get::<ResolveTransition>(e).is_none());
1327    }
1328
1329    /// Answering the prompt of a run that was cancelled while it waited must not
1330    /// bring the run back. Every non-`Abort` arm sets `Active` unconditionally, so
1331    /// without the terminal guard an answer - including the neutral response a
1332    /// cancel itself delivers to release the blocked `ask` - walked a cancelled
1333    /// run straight back into the pipeline.
1334    #[test]
1335    fn collect_does_not_resurrect_a_cancelled_run() {
1336        for decision in [
1337            PointOutcome::Approve {
1338                user_text: "ok".to_string(),
1339            },
1340            PointOutcome::Directive {
1341                user_text: "go".to_string(),
1342                directive: "d".to_string(),
1343            },
1344            PointOutcome::Edit {
1345                user_text: "go".to_string(),
1346                edited: "body".to_string(),
1347            },
1348        ] {
1349            let (mut world, tx) = collect_world();
1350            let e = spawn_awaiting(&mut world, vec![plan_point()]);
1351            world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
1352
1353            tx.send(InteractionPointOutcome {
1354                entity: e,
1355                decision,
1356            })
1357            .unwrap();
1358            run_collect(&mut world);
1359
1360            assert_eq!(
1361                world.get::<AgentState>(e).unwrap().status,
1362                AgentStatus::Cancelled,
1363                "the run stays cancelled"
1364            );
1365            assert!(
1366                world.get::<AwaitingInteractionPoint>(e).is_none(),
1367                "the awaiting marker is still cleared, so nothing re-collects it"
1368            );
1369            assert!(
1370                world.get::<ResolveTransition>(e).is_none()
1371                    && world.get::<ReadyToInfer>(e).is_none()
1372                    && world.get::<ReadyForInteractionPoint>(e).is_none(),
1373                "and it is not queued for any further work"
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn collect_directive_reinfers_then_caps() {
1380        let (mut world, tx) = collect_world();
1381        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1382        tx.send(InteractionPointOutcome {
1383            entity: e,
1384            decision: PointOutcome::Directive {
1385                user_text: "Revise".to_string(),
1386                directive: "do it".to_string(),
1387            },
1388        })
1389        .unwrap();
1390        run_collect(&mut world);
1391        assert!(world.get::<ReadyToInfer>(e).is_some());
1392        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1393        assert!(world.get::<ResolveTransition>(e).is_none());
1394
1395        // At the cap, a further directive proceeds instead of re-inferring.
1396        world
1397            .entity_mut(e)
1398            .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1399            .insert(AwaitingInteractionPoint);
1400        tx.send(InteractionPointOutcome {
1401            entity: e,
1402            decision: PointOutcome::Directive {
1403                user_text: String::new(),
1404                directive: "again".to_string(),
1405            },
1406        })
1407        .unwrap();
1408        run_collect(&mut world);
1409        assert!(world.get::<ResolveTransition>(e).is_some());
1410    }
1411
1412    #[test]
1413    fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
1414        let (mut world, tx) = collect_world();
1415        let e = world
1416            .spawn((
1417                agent_state(AgentStatus::Waiting),
1418                window(),
1419                blueprint_with(vec![plan_point()]),
1420                StageCursor { index: 0 },
1421                AwaitingInteractionPoint,
1422                StageIoBuffer::default(),
1423            ))
1424            .id();
1425        tx.send(InteractionPointOutcome {
1426            entity: e,
1427            decision: PointOutcome::Edit {
1428                user_text: "Add detail".to_string(),
1429                edited: "the revised plan".to_string(),
1430            },
1431        })
1432        .unwrap();
1433        run_collect(&mut world);
1434        // The adopted text is buffered for stages/<idx>/output.log, tagged with
1435        // the current stage index, so observers reflect the revision.
1436        let buf = world.get::<StageIoBuffer>(e).unwrap();
1437        assert_eq!(buf.output.len(), 1);
1438        assert_eq!(buf.output[0].0, 0);
1439        assert!(buf.output[0].1.contains("the revised plan"));
1440        // The edited text is also queued as the re-presented point's review body.
1441        assert_eq!(
1442            world.get::<PlanBodyOverride>(e).unwrap().0,
1443            "the revised plan"
1444        );
1445    }
1446
1447    /// An approval that followed a revision has to say so. A model that had
1448    /// already concluded "this is done" kept the conclusion and applied the
1449    /// correction only to the document - the plan changed, its reading of the
1450    /// world did not. The note is only injected when there *was* a revision.
1451    #[test]
1452    fn collect_approve_after_a_revision_says_the_plan_changed() {
1453        let (mut world, tx) = collect_world();
1454
1455        let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
1456        let revised = spawn_awaiting(&mut world, vec![plan_point()]);
1457        world.entity_mut(revised).insert(InteractionPointRounds(2));
1458
1459        for e in [first_try, revised] {
1460            tx.send(InteractionPointOutcome {
1461                entity: e,
1462                decision: PointOutcome::Approve {
1463                    user_text: "Approve".to_string(),
1464                },
1465            })
1466            .unwrap();
1467        }
1468        run_collect(&mut world);
1469
1470        let plain = world
1471            .get::<ContextWindow>(first_try)
1472            .unwrap()
1473            .current_tokens;
1474        let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
1475        assert!(
1476            noted > plain,
1477            "a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
1478        );
1479    }
1480
1481    #[test]
1482    fn collect_edit_represents_then_caps() {
1483        let (mut world, tx) = collect_world();
1484        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1485        tx.send(InteractionPointOutcome {
1486            entity: e,
1487            decision: PointOutcome::Edit {
1488                user_text: "Add detail".to_string(),
1489                edited: "the edited plan".to_string(),
1490            },
1491        })
1492        .unwrap();
1493        run_collect(&mut world);
1494        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1495        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1496        // The edited text was injected.
1497        let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1498        assert!(after_first > 0);
1499
1500        // An empty edit re-presents too, but injects nothing new.
1501        world
1502            .entity_mut(e)
1503            .insert(InteractionPointRounds(0))
1504            .insert(AwaitingInteractionPoint);
1505        tx.send(InteractionPointOutcome {
1506            entity: e,
1507            decision: PointOutcome::Edit {
1508                user_text: String::new(),
1509                edited: String::new(),
1510            },
1511        })
1512        .unwrap();
1513        run_collect(&mut world);
1514        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1515        assert_eq!(
1516            world.get::<ContextWindow>(e).unwrap().current_tokens,
1517            after_first
1518        );
1519
1520        // At the cap, an edit proceeds instead of re-presenting.
1521        world
1522            .entity_mut(e)
1523            .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1524            .insert(AwaitingInteractionPoint);
1525        tx.send(InteractionPointOutcome {
1526            entity: e,
1527            decision: PointOutcome::Edit {
1528                user_text: String::new(),
1529                edited: String::new(), // empty edit ⇒ no injection branch
1530            },
1531        })
1532        .unwrap();
1533        run_collect(&mut world);
1534        assert!(world.get::<ResolveTransition>(e).is_some());
1535    }
1536
1537    #[test]
1538    fn collect_on_noninteractive_stage_proceeds() {
1539        // An outcome for an agent whose stage isn't interactive (npoints = 0):
1540        // approve's next index immediately satisfies, so it proceeds.
1541        let (mut world, tx) = collect_world();
1542        let e = world
1543            .spawn((
1544                agent_state(AgentStatus::Waiting),
1545                window(),
1546                noninteractive_bp(),
1547                StageCursor { index: 0 },
1548                AwaitingInteractionPoint,
1549            ))
1550            .id();
1551        tx.send(InteractionPointOutcome {
1552            entity: e,
1553            decision: PointOutcome::Approve {
1554                user_text: String::new(),
1555            },
1556        })
1557        .unwrap();
1558        run_collect(&mut world);
1559        assert!(world.get::<ResolveTransition>(e).is_some());
1560    }
1561
1562    #[test]
1563    fn collect_drops_outcome_for_missing_agent() {
1564        let (mut world, tx) = collect_world();
1565        tx.send(InteractionPointOutcome {
1566            entity: Entity::from_raw_u32(999)
1567                .expect("a small literal index is always a valid entity id"),
1568            decision: PointOutcome::Abort,
1569        })
1570        .unwrap();
1571        run_collect(&mut world); // no panic
1572    }
1573
1574    // ── the async ask task ──
1575
1576    async fn drive_point(
1577        point: InteractionPoint,
1578        answer: impl FnOnce(&InteractionHub, String),
1579    ) -> PointOutcome {
1580        let hub = InteractionHub::new();
1581        let (tx, mut rx) = unbounded_channel();
1582        let task = {
1583            let hub = hub.clone();
1584            tokio::spawn(run_interaction_point(
1585                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1586                hub,
1587                "run".to_string(),
1588                point,
1589                "body".to_string(),
1590                0,
1591                tx,
1592                Arc::new(Notify::new()),
1593            ))
1594        };
1595        for _ in 0..8 {
1596            tokio::task::yield_now().await;
1597        }
1598        let id = hub.pending()[0].1.id.clone();
1599        answer(&hub, id);
1600        task.await.unwrap();
1601        rx.recv().await.unwrap().decision
1602    }
1603
1604    #[tokio::test]
1605    async fn run_point_approve() {
1606        let out = drive_point(plan_point(), |hub, id| {
1607            let mut r = InteractionResponse::text(&id, "");
1608            r.choice_index = Some(0); // Approve
1609            hub.answer(r);
1610        })
1611        .await;
1612        assert_eq!(
1613            out,
1614            PointOutcome::Approve {
1615                user_text: "Approve".to_string()
1616            }
1617        );
1618    }
1619
1620    #[tokio::test]
1621    async fn run_point_abort_and_directive() {
1622        let abort = drive_point(plan_point(), |hub, id| {
1623            let mut r = InteractionResponse::text(&id, "");
1624            r.choice_index = Some(3); // Abort
1625            hub.answer(r);
1626        })
1627        .await;
1628        assert_eq!(abort, PointOutcome::Abort);
1629
1630        let directive = drive_point(plan_point(), |hub, id| {
1631            let mut r = InteractionResponse::text(&id, "");
1632            r.choice_index = Some(1); // Revise
1633            hub.answer(r);
1634        })
1635        .await;
1636        assert_eq!(
1637            directive,
1638            PointOutcome::Directive {
1639                user_text: "Revise".to_string(),
1640                directive: "revise the plan".to_string(),
1641            }
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn run_point_edit_does_second_ask() {
1647        // Selecting the edit option triggers a second (edit_text) ask; answer both.
1648        let hub = InteractionHub::new();
1649        let (tx, mut rx) = unbounded_channel();
1650        let task = {
1651            let hub = hub.clone();
1652            tokio::spawn(run_interaction_point(
1653                Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
1654                hub,
1655                "run".to_string(),
1656                plan_point(),
1657                "body".to_string(),
1658                0,
1659                tx,
1660                Arc::new(Notify::new()),
1661            ))
1662        };
1663        // Answer the point with the edit option.
1664        for _ in 0..8 {
1665            tokio::task::yield_now().await;
1666        }
1667        let id = hub.pending()[0].1.id.clone();
1668        let mut r = InteractionResponse::text(&id, "");
1669        r.choice_index = Some(2); // Add detail ⇒ edit
1670        hub.answer(r);
1671        // Then answer the edit request with the edited text.
1672        for _ in 0..8 {
1673            tokio::task::yield_now().await;
1674        }
1675        let edit_id = hub.pending()[0].1.id.clone();
1676        hub.answer(InteractionResponse::text(&edit_id, "edited body"));
1677        task.await.unwrap();
1678        assert_eq!(
1679            rx.recv().await.unwrap().decision,
1680            PointOutcome::Edit {
1681                user_text: "Add detail".to_string(),
1682                edited: "edited body".to_string(),
1683            }
1684        );
1685    }
1686
1687    // ── restore (restart persistence, issue #38) ──
1688
1689    #[test]
1690    fn interaction_point_state_round_trips() {
1691        let s = InteractionPointState {
1692            cursor: 2,
1693            round: 1,
1694            body: "# Plan\n1. do it".to_string(),
1695        };
1696        let json = serde_json::to_string(&s).unwrap();
1697        assert_eq!(
1698            serde_json::from_str::<InteractionPointState>(&json).unwrap(),
1699            s
1700        );
1701    }
1702
1703    /// A world with the interaction-point lane + hub wired, keeping the results
1704    /// receiver so a resumed point can be answered and collected end-to-end.
1705    fn resume_world() -> (
1706        World,
1707        InteractionHub,
1708        UnboundedReceiver<InteractionPointOutcome>,
1709    ) {
1710        let hub = InteractionHub::new();
1711        let (tx, rx) = unbounded_channel();
1712        let mut world = World::new();
1713        world.insert_resource(hub.clone());
1714        world.insert_resource(InteractionPointStage {
1715            outcomes: tx,
1716            wake: Arc::new(Notify::new()),
1717            runtime: Handle::current(),
1718        });
1719        (world, hub, rx)
1720    }
1721
1722    /// A freshly "restored" agent as `restore_agent` leaves it (Active +
1723    /// ReadyToInfer) before interaction-point restore runs.
1724    fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
1725        world
1726            .spawn((
1727                agent_state(AgentStatus::Active),
1728                bp,
1729                window_with_plan(),
1730                StageCursor { index: 0 },
1731                ReadyToInfer,
1732            ))
1733            .id()
1734    }
1735
1736    #[tokio::test]
1737    async fn restore_rearms_waiting_and_reopens_the_prompt() {
1738        let (mut world, hub, _rx) = resume_world();
1739        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1740        restore_interaction_point(
1741            &mut world,
1742            e,
1743            InteractionPointState {
1744                cursor: 0,
1745                round: 2,
1746                body: "the plan".to_string(),
1747            },
1748        );
1749
1750        // Re-armed in the waiting state: the inference lane won't fire.
1751        assert_eq!(
1752            world.get::<AgentState>(e).unwrap().status,
1753            AgentStatus::Waiting
1754        );
1755        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1756        assert!(world.get::<ReadyToInfer>(e).is_none());
1757        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
1758        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
1759
1760        // The ask task re-registered the *same* request id in the hub, with the body.
1761        for _ in 0..8 {
1762            tokio::task::yield_now().await;
1763        }
1764        let pending = hub.pending();
1765        assert_eq!(pending.len(), 1);
1766        assert_eq!(pending[0].0, "run-1");
1767        assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
1768        assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1769    }
1770
1771    #[tokio::test]
1772    async fn restore_then_answer_drives_the_transition() {
1773        let (mut world, hub, mut rx) = resume_world();
1774        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1775        restore_interaction_point(
1776            &mut world,
1777            e,
1778            InteractionPointState {
1779                cursor: 0,
1780                round: 0,
1781                body: "the plan".to_string(),
1782            },
1783        );
1784        for _ in 0..8 {
1785            tokio::task::yield_now().await;
1786        }
1787
1788        // Approve the re-opened prompt; the outcome lands on the lane.
1789        let id = hub.pending()[0].1.id.clone();
1790        let mut r = InteractionResponse::text(&id, "");
1791        r.choice_index = Some(0); // Approve
1792        assert!(hub.answer(r));
1793        let outcome = rx.recv().await.unwrap();
1794
1795        // Feed it to collect and confirm the stage proceeds.
1796        let (tx2, rx2) = unbounded_channel();
1797        tx2.send(outcome).unwrap();
1798        world.insert_resource(InteractionPointResults(rx2));
1799        let mut s = Schedule::default();
1800        s.add_systems(collect_interaction_point);
1801        s.run(&mut world);
1802
1803        assert!(world.get::<ResolveTransition>(e).is_some());
1804        assert_eq!(
1805            world.get::<AgentState>(e).unwrap().status,
1806            AgentStatus::Active
1807        );
1808    }
1809
1810    #[tokio::test]
1811    async fn restore_noop_on_noninteractive_stage() {
1812        let (mut world, hub, _rx) = resume_world();
1813        let e = restored_agent(&mut world, noninteractive_bp());
1814        restore_interaction_point(
1815            &mut world,
1816            e,
1817            InteractionPointState {
1818                cursor: 0,
1819                round: 0,
1820                body: "x".to_string(),
1821            },
1822        );
1823        // Left as the default restore: Active + ReadyToInfer, nothing re-opened.
1824        assert_eq!(
1825            world.get::<AgentState>(e).unwrap().status,
1826            AgentStatus::Active
1827        );
1828        assert!(world.get::<ReadyToInfer>(e).is_some());
1829        assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1830        for _ in 0..8 {
1831            tokio::task::yield_now().await;
1832        }
1833        assert!(hub.pending().is_empty());
1834    }
1835
1836    #[tokio::test]
1837    async fn restore_noop_without_lane_wired() {
1838        // No InteractionPointStage / hub resources (a test world) ⇒ no-op.
1839        let mut world = World::new();
1840        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
1841        restore_interaction_point(
1842            &mut world,
1843            e,
1844            InteractionPointState {
1845                cursor: 0,
1846                round: 0,
1847                body: "x".to_string(),
1848            },
1849        );
1850        assert_eq!(
1851            world.get::<AgentState>(e).unwrap().status,
1852            AgentStatus::Active
1853        );
1854        assert!(world.get::<ReadyToInfer>(e).is_some());
1855    }
1856}