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, PromptLane};
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 {
105        /// The option's label, injected into context so the next turn knows
106        /// which choice was made.
107        user_text: String,
108    },
109    /// An abort option ⇒ cancel the run immediately.
110    Abort,
111    /// A directive option ⇒ inject the directive and re-run inference in-stage.
112    Directive {
113        /// The option's label, injected so the next turn knows what was chosen.
114        user_text: String,
115        /// The instruction attached to that option, injected alongside it.
116        directive: String,
117    },
118    /// An edit option ⇒ inject the user's edited text and re-present the point.
119    Edit {
120        /// The option's label.
121        user_text: String,
122        /// What the user actually wrote, which is authoritative over whatever
123        /// was presented for editing.
124        edited: String,
125    },
126    /// A point declaring `unattended = "ask"` expired or was cancelled with no
127    /// answer ⇒ stop the run rather than proceed past a checkpoint nobody made.
128    Unanswered,
129}
130
131/// One resolved interaction-point answer, reported on the lane.
132pub struct InteractionPointOutcome {
133    /// The agent the answer is for.
134    pub entity: Entity,
135    /// The routed decision.
136    pub decision: PointOutcome,
137}
138
139/// The sending side of the interaction-point lane + the handle/wake needed to
140/// drive the async ask task, as a world resource.
141#[derive(Resource)]
142pub struct InteractionPointStage {
143    /// Where resolved outcomes are reported.
144    pub outcomes: UnboundedSender<InteractionPointOutcome>,
145    /// Wakes the tick loop when an outcome lands.
146    pub wake: Arc<Notify>,
147    /// Runtime the ask task is spawned onto.
148    pub runtime: Handle,
149}
150
151/// The receiving side of the interaction-point lane, for the collect system.
152#[derive(Resource)]
153pub struct InteractionPointResults(pub UnboundedReceiver<InteractionPointOutcome>);
154
155// ─── Pure routing helpers (ported from the deleted imperative stage loop) ─────
156
157/// Normalize an option label for matching: fold Unicode dashes to ASCII `-` and
158/// collapse whitespace, so `"Revise - I'll…"` matches regardless of dash style.
159fn normalize_for_followup(s: &str) -> String {
160    s.chars()
161        .map(|c| match c {
162            '\u{2014}' | '\u{2013}' | '\u{2212}' | '\u{2015}' => '-',
163            _ => c,
164        })
165        .collect::<String>()
166        .split_whitespace()
167        .collect::<Vec<_>>()
168        .join(" ")
169}
170
171/// Whether `user_text` matches one of `candidates` (exact first, then normalized).
172fn option_matches(candidates: &[String], user_text: &str) -> bool {
173    if candidates.iter().any(|o| o == user_text) {
174        return true;
175    }
176    let normalized = normalize_for_followup(user_text);
177    candidates
178        .iter()
179        .any(|o| normalize_for_followup(o) == normalized)
180}
181
182/// Look up a directive by option label (exact first, then normalized).
183fn lookup_directive<'a>(
184    directives: &'a HashMap<String, String>,
185    user_text: &str,
186) -> Option<&'a str> {
187    if let Some(d) = directives.get(user_text) {
188        return Some(d.as_str());
189    }
190    let normalized = normalize_for_followup(user_text);
191    directives
192        .iter()
193        .find(|(k, _)| normalize_for_followup(k) == normalized)
194        .map(|(_, d)| d.as_str())
195}
196
197/// Build the interaction request for a point in its declared style, attaching
198/// `body` (the document the stage produced - e.g. the plan) so the client can
199/// show just this instance's document to review, rather than the full history.
200fn build_point_request(point: &InteractionPoint, id: String, body: &str) -> InteractionRequest {
201    let mut req = match point.style {
202        InteractionStyle::MultipleChoice => InteractionRequest::multiple_choice(
203            id,
204            &point.prompt,
205            point.options.clone(),
206            &point.name,
207        ),
208        InteractionStyle::Confirm => InteractionRequest::confirm(id, &point.prompt, &point.name),
209        InteractionStyle::FreeText => {
210            InteractionRequest::free_text(id, &point.prompt, &point.name, point.required)
211        }
212    };
213    if !body.trim().is_empty() {
214        req.body = Some(body.to_string());
215        req.body_format = leviath_core::interaction::BodyFormat::Markdown;
216    }
217    req
218}
219
220/// Resolve a response to the selected option label / free text: a choice index
221/// maps through `options`, otherwise the free-text value (empty if none).
222fn resolve_answer(resp: &InteractionResponse, options: &[String]) -> String {
223    if let Some(opt) = resp.choice_index.and_then(|i| options.get(i)) {
224        return opt.clone();
225    }
226    resp.value.clone().unwrap_or_default()
227}
228
229/// Whether `resp` carries no decision at all.
230///
231/// This is the neutral response the hub hands back when a request expires or is
232/// cancelled: `InteractionResponse::text(id, "")`. Every real answer sets
233/// exactly one of the three fields, including a `confirm` denial, which sets
234/// `approved`.
235fn is_unanswered(resp: &InteractionResponse) -> bool {
236    resp.approved.is_none()
237        && resp.choice_index.is_none()
238        && resp.value.as_deref().unwrap_or("").trim().is_empty()
239}
240
241/// Route a resolved answer to a [`PointOutcome`] (pure; the edit branch's second
242/// ask is done by the caller, which knows the edited text).
243fn route_answer(point: &InteractionPoint, user_text: String) -> Routed {
244    if option_matches(&point.abort_options, &user_text) {
245        Routed::Abort
246    } else if option_matches(&point.edit_options, &user_text) {
247        Routed::Edit { user_text }
248    } else if let Some(directive) = lookup_directive(&point.directives, &user_text) {
249        Routed::Directive {
250            user_text,
251            directive: directive.to_string(),
252        }
253    } else {
254        Routed::Approve { user_text }
255    }
256}
257
258/// Intermediate routing result before the edit branch's second ask.
259#[derive(Debug, PartialEq, Eq)]
260enum Routed {
261    Approve {
262        user_text: String,
263    },
264    Abort,
265    Directive {
266        user_text: String,
267        directive: String,
268    },
269    Edit {
270        user_text: String,
271    },
272}
273
274// ─── The async ask task ──────────────────────────────────────────────────────
275
276/// Ask an interaction point through the hub, resolve + route the answer (doing
277/// the edit branch's second "edit this text" ask when needed), and report the
278/// [`PointOutcome`] on the lane, waking the tick loop.
279/// The point being asked: whose run, which point, on what text, and how many
280/// times it has come round already.
281///
282/// Held apart from the lane it reports on for the same reason as
283/// the taint gate's own `GatedCall`: these five are what the person sees, and
284/// the lane is only where their answer goes.
285pub struct PointAsk {
286    /// The agent parked on this point.
287    pub entity: Entity,
288    /// That agent's run id, which the request id is namespaced by so two runs
289    /// at the same point never collide in the shared hub.
290    pub agent_id: String,
291    /// The point as the blueprint declared it.
292    pub point: InteractionPoint,
293    /// The text being asked about.
294    pub body: String,
295    /// Which round of this point the run is on.
296    pub round: usize,
297}
298
299async fn run_interaction_point(ask: PointAsk, lane: PromptLane<InteractionPointOutcome>) {
300    let PointAsk {
301        entity,
302        agent_id,
303        point,
304        body,
305        round,
306    } = ask;
307    let PromptLane {
308        hub,
309        outcomes,
310        wake,
311    } = lane;
312    // Request ids are prefixed with the run id so concurrent runs at the same
313    // point (same name/round) never collide in the shared hub.
314    let ask_id = format!("{agent_id}-point-{}-{round}", point.name);
315    let backend = hub.backend_for(agent_id);
316    let req = build_point_request(&point, ask_id.clone(), &body);
317    let resp = backend.ask(req).await;
318
319    // A point that declared it needs a person, and did not get one. Routing an
320    // empty answer normally lands in the final `else` of `route_answer`, which
321    // is `Approve` - so a `--yolo` run in CI waited out the interaction timeout
322    // and then approved the plan nobody read. A timeout is not a person.
323    //
324    // The default `auto_approve` policy never takes this arm, so a point that
325    // does not claim to need a person behaves exactly as before.
326    if point.unattended == UnattendedPolicy::Ask && is_unanswered(&resp) {
327        let _ = outcomes.send(InteractionPointOutcome {
328            entity,
329            decision: PointOutcome::Unanswered,
330        });
331        wake.notify_one();
332        return;
333    }
334
335    let user_text = resolve_answer(&resp, &point.options);
336
337    let decision = match route_answer(&point, user_text) {
338        Routed::Approve { user_text } => PointOutcome::Approve { user_text },
339        Routed::Abort => PointOutcome::Abort,
340        Routed::Directive {
341            user_text,
342            directive,
343        } => PointOutcome::Directive {
344            user_text,
345            directive,
346        },
347        Routed::Edit { user_text } => {
348            let edit_req = InteractionRequest::edit_text(
349                format!("{ask_id}-edit"),
350                "Edit the document - your changes replace it, then submit:",
351                &point.name,
352                body,
353            );
354            let edited = backend.ask(edit_req).await.value.unwrap_or_default();
355            PointOutcome::Edit { user_text, edited }
356        }
357    };
358
359    let _ = outcomes.send(InteractionPointOutcome { entity, decision });
360    wake.notify_one();
361}
362
363/// Re-arm an agent that was blocked at an interaction point when the daemon stopped,
364/// bringing it back in the *waiting* state with the same open request - rather than
365/// the default `Active` + `ReadyToInfer` restore, which would re-issue inference and
366/// drop the prompt.
367///
368/// Looks up the point from the agent's (already-restored) blueprint + stage cursor,
369/// restores the point cursor/round, flips the agent to `Waiting` (clearing the
370/// spawn-set `ReadyToInfer`, marking `AwaitingInteractionPoint`), and re-spawns the
371/// ask task so the request re-registers in the hub with the same id
372/// (`{agent_id}-point-{name}-{round}`). From there it is indistinguishable from a live
373/// dispatch: a client that had the prompt open still sees it, and answering it later
374/// routes normally through [`collect_interaction_point`].
375///
376/// A no-op (leaving the default restore in place) when the interaction-point lane
377/// isn't wired (a test world), or when the stage is no longer an interactive-points
378/// stage / the cursor is out of range (e.g. the blueprint changed under the run).
379pub fn restore_interaction_point(
380    world: &mut World,
381    agent: crate::world::AgentId,
382    state: InteractionPointState,
383) {
384    // An id from another world would name a different agent here, and this
385    // writes a pending prompt onto it.
386    let Some(entity) = agent.resolve_in(world) else {
387        return;
388    };
389    // The lane + hub must both be wired (they are in the daemon; absent in a test
390    // world) - otherwise there is nothing to await the re-opened request.
391    let Some(((outcomes, wake, runtime), hub)) = world
392        .get_resource::<InteractionPointStage>()
393        .map(|s| (s.outcomes.clone(), s.wake.clone(), s.runtime.clone()))
394        .zip(world.get_resource::<InteractionHub>().cloned())
395    else {
396        return;
397    };
398
399    // Resolve the point from the restored blueprint + stage cursor. A reloaded agent
400    // always carries these; a blueprint that changed out from under the run (stage no
401    // longer interactive, or fewer points) leaves the default restore in place rather
402    // than resuming a stale prompt.
403    let agent_id = world
404        .get::<AgentState>(entity)
405        .expect("a reloaded agent has AgentState")
406        .agent_id
407        .clone();
408    let point = {
409        let bp = world
410            .get::<AgentBlueprint>(entity)
411            .expect("a reloaded agent has a blueprint");
412        let cursor = world
413            .get::<StageCursor>(entity)
414            .expect("a reloaded agent has a stage cursor");
415        stage_points(bp, cursor)
416            .and_then(|p| p.get(state.cursor))
417            .cloned()
418    };
419    let Some(point) = point else {
420        tracing::warn!(
421            ?entity,
422            cursor = state.cursor,
423            "interaction-point restore skipped: stage not interactive or cursor out of range"
424        );
425        return;
426    };
427
428    // Re-arm the waiting state: restore the cursor/round, mark the agent awaiting the
429    // point, and clear the spawn-set `ReadyToInfer` so the inference lane won't fire.
430    {
431        let mut e = world.entity_mut(entity);
432        e.insert(InteractionPointCursor(state.cursor));
433        e.insert(InteractionPointRounds(state.round));
434        e.insert(AwaitingInteractionPoint);
435        e.remove::<ReadyToInfer>();
436        e.get_mut::<AgentState>()
437            .expect("a reloaded agent has AgentState")
438            .status = AgentStatus::Waiting;
439    }
440
441    // Re-open the request in the hub and await it, exactly as a live dispatch would.
442    runtime.spawn(run_interaction_point(
443        PointAsk {
444            entity,
445            agent_id,
446            point,
447            body: state.body,
448            round: state.round,
449        },
450        PromptLane {
451            hub,
452            outcomes,
453            wake,
454        },
455    ));
456}
457
458// ─── Systems ─────────────────────────────────────────────────────────────────
459
460/// Read the interaction points of an agent's current stage, or `None` if the
461/// stage isn't an interactive-points stage.
462fn stage_points<'a>(
463    bp: &'a AgentBlueprint,
464    cursor: &StageCursor,
465) -> Option<&'a [InteractionPoint]> {
466    match &bp.0.stages[cursor.index].mode {
467        StageMode::InteractivePoints { points } => Some(points),
468        _ => None,
469    }
470}
471
472/// What `gate_interaction_points` selects.
473///
474/// `&'static` is bevy's `WorldQuery` convention, not a claim about
475/// lifetimes: the borrow is bound when the query is fetched.
476type InteractionPointQuery = (
477    Entity,
478    &'static AgentBlueprint,
479    &'static StageCursor,
480    Option<&'static InteractionPointCursor>,
481);
482
483/// Gate: intercept a would-be transition for an interactive-points stage whose
484/// points aren't all satisfied yet, routing the agent to the interaction-point
485/// lane instead. Stages with no points, or whose point cursor is past the end
486/// (all approved), fall through to the normal transition.
487pub fn gate_interaction_points(
488    agents: Query<InteractionPointQuery, With<ResolveTransition>>,
489    mut commands: Commands,
490) {
491    crate::tick_scope::clear();
492    for (entity, bp, cursor, pc) in agents.iter() {
493        crate::tick_scope::enter(entity);
494        let Some(points) = stage_points(bp, cursor) else {
495            continue;
496        };
497        let idx = pc.map_or(0, |c| c.0);
498        if points.is_empty() || idx >= points.len() {
499            continue; // nothing to ask ⇒ let the transition proceed
500        }
501        commands
502            .entity(entity)
503            .remove::<ResolveTransition>()
504            .insert(ReadyForInteractionPoint);
505    }
506}
507
508/// What `dispatch_interaction_point` selects.
509///
510/// `&'static` is bevy's `WorldQuery` convention, not a claim about
511/// lifetimes: the borrow is bound when the query is fetched.
512type DispatchInteractionPointQuery = (
513    Entity,
514    &'static AgentState,
515    &'static AgentBlueprint,
516    &'static StageCursor,
517    &'static InferenceResult,
518    &'static mut ContextWindow,
519    Option<&'static InteractionPointCursor>,
520    Option<&'static InteractionPointRounds>,
521    Option<&'static PlanBodyOverride>,
522    Option<&'static crate::components::InteractionAutoApprove>,
523);
524
525/// Dispatch: for each `ReadyForInteractionPoint` agent, spawn the ask task for
526/// its current point and move it to `AwaitingInteractionPoint`. No hub (test
527/// world) ⇒ no-op; a non-interactive stage ⇒ fall back to the transition.
528pub fn dispatch_interaction_point(
529    mut agents: Query<DispatchInteractionPointQuery, With<ReadyForInteractionPoint>>,
530    hub: Option<Res<InteractionHub>>,
531    stage: Option<Res<InteractionPointStage>>,
532    mut commands: Commands,
533) {
534    crate::tick_scope::clear();
535    let (Some(hub), Some(stage)) = (hub, stage) else {
536        return; // no lane wired (test world)
537    };
538    for (entity, state, bp, cursor, infer, mut window, pc, rounds, plan_override, auto_approve) in
539        agents.iter_mut()
540    {
541        crate::tick_scope::enter(entity);
542        if state.status != AgentStatus::Active {
543            continue; // paused / cancelled - don't open a prompt
544        }
545        let idx = pc.map_or(0, |c| c.0);
546        let point = stage_points(bp, cursor).and_then(|p| p.get(idx)).cloned();
547        let Some(point) = point else {
548            // Stage changed out from under us ⇒ just proceed to the transition.
549            commands
550                .entity(entity)
551                .remove::<ReadyForInteractionPoint>()
552                .insert(ResolveTransition);
553            continue;
554        };
555        // The document to review: a direct edit (override) takes precedence over
556        // the last inference response, so a re-presented approval reflects it.
557        let user_revised = plan_override.is_some();
558        let body = plan_override
559            .map(|o| o.0.clone())
560            .unwrap_or_else(|| infer.response.clone());
561        // Make this the authoritative document in its pinned region (replacing
562        // any prior version), so revisions build on the current text - the
563        // user's edit included - rather than regenerating from the task. A
564        // user edit is marked so the model preserves it deliberately.
565        if let Some(region) = &point.document_region
566            && !body.trim().is_empty()
567        {
568            let content = if user_revised {
569                format!("[revised by user - keep these changes]\n{body}")
570            } else {
571                body.clone()
572            };
573            let tokens = leviath_core::estimate_tokens(&content);
574            window.replace_region(region, content, tokens);
575        }
576        // An unattended run (`--yolo`) approves the checkpoint instead of
577        // opening a prompt nobody will answer. The document was published to its
578        // region above, so what was approved is still on the record.
579        //
580        // Unless the point declares `unattended = "ask"`: some checkpoints exist
581        // precisely because a person has to look - a plan signed off before any
582        // code is written - and their author would rather the run wait than have
583        // it wave itself through. `[limits] interaction_timeout_secs` is what
584        // keeps that wait from lasting for ever.
585        if auto_approve.is_some() && point.unattended == UnattendedPolicy::AutoApprove {
586            tracing::info!(
587                agent = %state.agent_id,
588                point = %point.name,
589                "auto-approving interaction point (unattended run)"
590            );
591            let _ = stage.outcomes.send(InteractionPointOutcome {
592                entity,
593                decision: PointOutcome::Approve {
594                    user_text: String::new(),
595                },
596            });
597            stage.wake.notify_one();
598            commands
599                .entity(entity)
600                .remove::<ReadyForInteractionPoint>()
601                .remove::<PlanBodyOverride>()
602                .insert(AwaitingInteractionPoint);
603            continue;
604        }
605        stage.runtime.spawn(run_interaction_point(
606            PointAsk {
607                entity,
608                agent_id: state.agent_id.clone(),
609                point,
610                body,
611                round: rounds.map_or(0, |r| r.0),
612            },
613            PromptLane {
614                hub: hub.clone(),
615                outcomes: stage.outcomes.clone(),
616                wake: stage.wake.clone(),
617            },
618        ));
619        commands
620            .entity(entity)
621            .remove::<ReadyForInteractionPoint>()
622            .remove::<PlanBodyOverride>()
623            .insert(AwaitingInteractionPoint);
624    }
625}
626
627/// What `collect_interaction_point` selects.
628///
629/// `&'static` is bevy's `WorldQuery` convention, not a claim about
630/// lifetimes: the borrow is bound when the query is fetched.
631type CollectInteractionPointQuery = (
632    &'static mut AgentState,
633    &'static mut ContextWindow,
634    &'static AgentBlueprint,
635    &'static StageCursor,
636    Option<&'static InteractionPointCursor>,
637    Option<&'static InteractionPointRounds>,
638    Option<&'static mut StageIoBuffer>,
639);
640
641/// Collect: apply each resolved interaction-point outcome - approve advances
642/// (or transitions when all points are done), abort cancels, a directive injects
643/// the directive and re-infers in-stage, an edit injects the edited text and
644/// re-presents; both revision paths are bounded by [`MAX_REVISION_ROUNDS`].
645pub fn collect_interaction_point(
646    mut results: ResMut<InteractionPointResults>,
647    mut agents: Query<CollectInteractionPointQuery, With<AwaitingInteractionPoint>>,
648    mut commands: Commands,
649) {
650    crate::tick_scope::clear();
651    while let Ok(out) = results.0.try_recv() {
652        let Ok((mut state, mut window, bp, cursor, pc, rounds, io_buf)) =
653            agents.get_mut(out.entity)
654        else {
655            continue; // stale: agent cancelled/despawned since dispatch
656        };
657        crate::tick_scope::enter(out.entity);
658        // A run cancelled while its prompt was open is finished, and the arms
659        // below all set `Active`/`ResolveTransition` unconditionally - so without
660        // this, answering the orphaned prompt (from `lev respond`, the dashboard,
661        // or the neutral response a cancel itself delivers) walked the run
662        // straight back to `Active` and it carried on as if it had never been
663        // cancelled. Drop the outcome and let the reaper take the entity.
664        if crate::pipeline::is_terminal_status(&state.status) {
665            commands
666                .entity(out.entity)
667                .remove::<AwaitingInteractionPoint>();
668            continue;
669        }
670        let idx = pc.map_or(0, |c| c.0);
671        let round = rounds.map_or(0, |r| r.0);
672        let (name, npoints) = match stage_points(bp, cursor) {
673            Some(points) => (
674                points.get(idx).map(|p| p.name.clone()).unwrap_or_default(),
675                points.len(),
676            ),
677            None => (String::new(), 0),
678        };
679
680        let mut e = commands.entity(out.entity);
681        e.remove::<AwaitingInteractionPoint>();
682
683        // Mark all points satisfied so the gate lets the transition proceed
684        // (the cursor is reset when the next stage is entered).
685        let proceed = |e: &mut bevy_ecs::system::EntityCommands| {
686            e.insert(InteractionPointCursor(npoints))
687                .insert(ResolveTransition);
688        };
689
690        match out.decision {
691            PointOutcome::Abort => {
692                state.status = AgentStatus::Cancelled;
693            }
694            // Terminal, and `Error` rather than `Cancelled`: a poller waiting on
695            // an unattended run needs it to end, the reason has to reach
696            // `meta.json` and `lev ps`, and it must read differently from an
697            // operator's `lev cancel`. No `ResolveTransition` is inserted, so
698            // the run stops here rather than diverting to an `error_recovery`
699            // edge - there is nothing to recover from, only a person to wait for
700            // who did not come.
701            PointOutcome::Unanswered => {
702                state.status = AgentStatus::Error {
703                    message: format!(
704                        "checkpoint '{name}' went unanswered within the interaction timeout; \
705                         the run stopped rather than approving it unread"
706                    ),
707                };
708            }
709            PointOutcome::Approve { user_text } => {
710                state.status = AgentStatus::Active;
711                inject(&mut window, &name, "", &user_text);
712                // Say plainly that this was approved *after* being changed.
713                //
714                // A model that has already concluded something tends to keep the
715                // conclusion and apply the correction only to the document. That
716                // happened: an agent that had created a file during discovery
717                // read it back while planning, decided "already created - no
718                // further action is needed", was told to use a different
719                // filename, updated the plan to say so, and still ended the run
720                // without renaming anything. The plan changed; its reading of
721                // the world did not.
722                //
723                // `round` counts revision rounds on this point, so a non-zero
724                // value means the approved text is not what the model first
725                // proposed.
726                if round > 0 {
727                    inject(
728                        &mut window,
729                        &name,
730                        "",
731                        "The plan above was revised before you approved it. Work from \
732                         the approved text as written - any conclusion you reached \
733                         from the earlier version, including that something is \
734                         already done, may no longer hold and should be re-checked \
735                         against the plan rather than assumed.",
736                    );
737                }
738                let next = idx + 1;
739                if next >= npoints {
740                    proceed(&mut e); // all points satisfied ⇒ transition
741                } else {
742                    e.insert(InteractionPointCursor(next))
743                        .insert(InteractionPointRounds(0))
744                        .insert(ReadyForInteractionPoint);
745                }
746            }
747            PointOutcome::Directive {
748                user_text,
749                directive,
750            } => {
751                state.status = AgentStatus::Active;
752                inject(&mut window, &name, "", &user_text);
753                if round + 1 >= MAX_REVISION_ROUNDS {
754                    proceed(&mut e); // revision cap ⇒ proceed
755                } else {
756                    // Stay on this point; re-run inference in-stage on the directive.
757                    inject(&mut window, &name, "directive: ", &directive);
758                    e.insert(InteractionPointRounds(round + 1))
759                        .insert(ReadyToInfer);
760                }
761            }
762            PointOutcome::Edit { user_text, edited } => {
763                state.status = AgentStatus::Active;
764                inject(&mut window, &name, "", &user_text);
765                if round + 1 >= MAX_REVISION_ROUNDS {
766                    proceed(&mut e);
767                } else {
768                    if !edited.is_empty() {
769                        let note = format!(
770                            "edited the output directly. Adopt this exact text as the \
771                             authoritative version and re-present it:\n{edited}"
772                        );
773                        inject(&mut window, &name, "", &note);
774                        // Surface the adopted text in the stage output so observers
775                        // (e.g. the dashboard's output pane, which reads output.log)
776                        // reflect the revision rather than the pre-edit version.
777                        if let Some(mut buf) = io_buf {
778                            buf.output.push((
779                                cursor.index,
780                                format!("\n─── Updated (your edit) ───\n{edited}"),
781                            ));
782                        }
783                        // Present the edited text (not the pre-edit inference
784                        // response) as the re-presented point's review body.
785                        e.insert(PlanBodyOverride(edited));
786                    }
787                    // Re-present the same point with the edit applied (no re-infer).
788                    e.insert(InteractionPointRounds(round + 1))
789                        .insert(ReadyForInteractionPoint);
790                }
791            }
792        }
793    }
794}
795
796/// Inject a `User [name] <prefix><text>` line into the conversation region (no-op
797/// on empty text), so the agent sees the user's selection / directive / edit.
798fn inject(window: &mut ContextWindow, name: &str, prefix: &str, text: &str) {
799    if text.is_empty() {
800        return;
801    }
802    let content = format!("User [{name}] {prefix}{text}");
803    let tokens = leviath_core::estimate_tokens(&content);
804    let _ = window.add_to_region("conversation", content, tokens);
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use crate::components::AgentStatus;
811    use leviath_core::interaction::InteractionResponse;
812    use leviath_core::{Region, RegionKind};
813    use tokio::sync::mpsc::unbounded_channel;
814
815    // ── builders ──
816
817    fn point(name: &str, style: InteractionStyle, options: &[&str]) -> InteractionPoint {
818        InteractionPoint {
819            name: name.to_string(),
820            prompt: "Choose".to_string(),
821            required: true,
822            unattended: UnattendedPolicy::AutoApprove,
823            style,
824            options: options.iter().map(|s| s.to_string()).collect(),
825            directives: HashMap::new(),
826            abort_options: Vec::new(),
827            edit_options: Vec::new(),
828            document_region: None,
829        }
830    }
831
832    /// The plan_approval point: approve / revise (directive) / edit / abort.
833    fn plan_point() -> InteractionPoint {
834        let mut p = point(
835            "plan_approval",
836            InteractionStyle::MultipleChoice,
837            &["Approve", "Revise", "Add detail", "Abort"],
838        );
839        p.directives
840            .insert("Revise".to_string(), "revise the plan".to_string());
841        p.abort_options = vec!["Abort".to_string()];
842        p.edit_options = vec!["Add detail".to_string()];
843        p.document_region = Some("plan".to_string());
844        p
845    }
846
847    fn blueprint_with(points: Vec<InteractionPoint>) -> AgentBlueprint {
848        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
849        let mut stage = leviath_core::Stage::new(
850            "plan".to_string(),
851            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
852        );
853        stage.mode = StageMode::InteractivePoints { points };
854        let bp =
855            leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
856        AgentBlueprint(bp)
857    }
858
859    /// A single-stage blueprint whose stage is *not* an interactive-points stage.
860    fn noninteractive_bp() -> AgentBlueprint {
861        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
862        let stage = leviath_core::Stage::new(
863            "auto".to_string(),
864            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
865        );
866        AgentBlueprint(leviath_core::Blueprint::new(
867            "t".to_string(),
868            "d".to_string(),
869            vec![stage],
870            layout,
871        ))
872    }
873
874    fn agent_state(status: AgentStatus) -> AgentState {
875        AgentState {
876            agent_id: "run-1".to_string(),
877            current_stage: "plan".to_string(),
878            iteration: 1,
879            status,
880            spawned_children_ids: vec![],
881            pending_wait: None,
882            accepts_messages: true,
883        }
884    }
885
886    fn window() -> ContextWindow {
887        let mut w = ContextWindow::new(100_000);
888        w.add_region(Region::new(
889            "conversation".to_string(),
890            RegionKind::Clearable,
891            10_000,
892        ));
893        w
894    }
895
896    fn window_with_plan() -> ContextWindow {
897        let mut w = window();
898        w.add_region(Region::new("plan".to_string(), RegionKind::Pinned, 6_000));
899        w
900    }
901
902    fn infer(text: &str) -> InferenceResult {
903        InferenceResult {
904            response: text.to_string(),
905            tool_calls: vec![],
906            tokens_used: 0,
907            timestamp: 0,
908        }
909    }
910
911    // ── pure helpers ──
912
913    #[test]
914    fn normalize_folds_dashes_and_whitespace() {
915        assert_eq!(
916            normalize_for_followup("Revise \u{2014} now"),
917            "Revise - now"
918        );
919        assert_eq!(normalize_for_followup("a\u{2013}b"), "a-b");
920        assert_eq!(normalize_for_followup("  x   y  "), "x y");
921    }
922
923    #[test]
924    fn option_matches_exact_normalized_and_miss() {
925        let opts = vec!["Abort \u{2014} now".to_string()];
926        assert!(option_matches(&opts, "Abort \u{2014} now")); // exact
927        assert!(option_matches(&opts, "Abort - now")); // normalized
928        assert!(!option_matches(&opts, "Approve")); // miss
929    }
930
931    #[test]
932    fn lookup_directive_exact_normalized_and_none() {
933        let mut d = HashMap::new();
934        d.insert("Revise \u{2014} x".to_string(), "do it".to_string());
935        assert_eq!(lookup_directive(&d, "Revise \u{2014} x"), Some("do it"));
936        assert_eq!(lookup_directive(&d, "Revise - x"), Some("do it"));
937        assert_eq!(lookup_directive(&d, "Approve"), None);
938    }
939
940    #[test]
941    fn build_point_request_by_style() {
942        use leviath_core::interaction::InteractionKind;
943        let mc = build_point_request(
944            &point("p", InteractionStyle::MultipleChoice, &["a", "b"]),
945            "id".to_string(),
946            "## Plan\n1. do it",
947        );
948        assert_eq!(mc.kind, InteractionKind::MultipleChoice);
949        assert_eq!(mc.options.len(), 2);
950        // The document is attached as a markdown body to review.
951        assert_eq!(mc.body.as_deref(), Some("## Plan\n1. do it"));
952        assert_eq!(
953            mc.body_format,
954            leviath_core::interaction::BodyFormat::Markdown
955        );
956        let cf = build_point_request(
957            &point("p", InteractionStyle::Confirm, &[]),
958            "id".to_string(),
959            "",
960        );
961        assert_eq!(cf.kind, InteractionKind::Confirm);
962        // A blank body is not attached.
963        assert_eq!(cf.body, None);
964        let ft = build_point_request(
965            &point("p", InteractionStyle::FreeText, &[]),
966            "id".to_string(),
967            "   ",
968        );
969        assert_eq!(ft.kind, InteractionKind::FreeText);
970        assert_eq!(ft.body, None);
971    }
972
973    #[test]
974    fn resolve_answer_choice_index_fallback_and_value() {
975        let opts = vec!["A".to_string(), "B".to_string()];
976        let mut r = InteractionResponse::text("q", "");
977        r.choice_index = Some(1);
978        assert_eq!(resolve_answer(&r, &opts), "B"); // choice → option
979        r.choice_index = Some(9); // out of range → fall to value
980        r.value = Some("typed".to_string());
981        assert_eq!(resolve_answer(&r, &opts), "typed");
982        let empty = InteractionResponse::text("q", "");
983        assert_eq!(resolve_answer(&empty, &opts), ""); // no choice, empty value
984    }
985
986    #[test]
987    fn route_answer_covers_all_four() {
988        let p = plan_point();
989        assert_eq!(route_answer(&p, "Abort".to_string()), Routed::Abort);
990        assert_eq!(
991            route_answer(&p, "Add detail".to_string()),
992            Routed::Edit {
993                user_text: "Add detail".to_string()
994            }
995        );
996        assert_eq!(
997            route_answer(&p, "Revise".to_string()),
998            Routed::Directive {
999                user_text: "Revise".to_string(),
1000                directive: "revise the plan".to_string(),
1001            }
1002        );
1003        assert_eq!(
1004            route_answer(&p, "Approve".to_string()),
1005            Routed::Approve {
1006                user_text: "Approve".to_string()
1007            }
1008        );
1009    }
1010
1011    #[test]
1012    fn inject_skips_empty_and_appends_nonempty() {
1013        let mut w = window();
1014        inject(&mut w, "plan", "", "");
1015        assert_eq!(w.get_region("conversation").unwrap().current_tokens, 0);
1016        inject(&mut w, "plan", "directive: ", "do x");
1017        assert!(w.get_region("conversation").unwrap().current_tokens > 0);
1018    }
1019
1020    #[test]
1021    fn stage_points_some_for_interactive_none_otherwise() {
1022        let bp = blueprint_with(vec![plan_point()]);
1023        assert!(stage_points(&bp, &StageCursor { index: 0 }).is_some());
1024        // A non-interactive stage.
1025        let layout = leviath_core::layout::ContextLayout::new(vec![], 10_000);
1026        let stage = leviath_core::Stage::new(
1027            "auto".to_string(),
1028            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
1029        );
1030        let bp2 = AgentBlueprint(leviath_core::Blueprint::new(
1031            "t".to_string(),
1032            "d".to_string(),
1033            vec![stage],
1034            layout,
1035        ));
1036        assert!(stage_points(&bp2, &StageCursor { index: 0 }).is_none());
1037    }
1038
1039    // ── gate ──
1040
1041    fn run_gate(world: &mut World) {
1042        let mut s = Schedule::default();
1043        s.add_systems(gate_interaction_points);
1044        s.run(world);
1045    }
1046
1047    #[test]
1048    fn gate_intercepts_unsatisfied_interactive_stage() {
1049        let mut world = World::new();
1050        let e = world
1051            .spawn((
1052                blueprint_with(vec![plan_point()]),
1053                StageCursor { index: 0 },
1054                ResolveTransition,
1055            ))
1056            .id();
1057        run_gate(&mut world);
1058        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1059        assert!(world.get::<ResolveTransition>(e).is_none());
1060    }
1061
1062    #[test]
1063    fn gate_lets_satisfied_or_empty_or_noninteractive_proceed() {
1064        let mut world = World::new();
1065        // cursor past the (single) point ⇒ satisfied.
1066        let done = world
1067            .spawn((
1068                blueprint_with(vec![plan_point()]),
1069                StageCursor { index: 0 },
1070                InteractionPointCursor(1),
1071                ResolveTransition,
1072            ))
1073            .id();
1074        // empty points.
1075        let empty = world
1076            .spawn((
1077                blueprint_with(vec![]),
1078                StageCursor { index: 0 },
1079                ResolveTransition,
1080            ))
1081            .id();
1082        // non-interactive stage.
1083        let auto = world
1084            .spawn((
1085                noninteractive_bp(),
1086                StageCursor { index: 0 },
1087                ResolveTransition,
1088            ))
1089            .id();
1090        run_gate(&mut world);
1091        assert!(world.get::<ResolveTransition>(done).is_some());
1092        assert!(world.get::<ReadyForInteractionPoint>(done).is_none());
1093        assert!(world.get::<ResolveTransition>(empty).is_some());
1094        assert!(world.get::<ResolveTransition>(auto).is_some());
1095        assert!(world.get::<ReadyForInteractionPoint>(auto).is_none());
1096    }
1097
1098    // ── dispatch ──
1099
1100    #[tokio::test]
1101    async fn dispatch_noop_without_hub_or_stage() {
1102        let mut world = World::new();
1103        let e = world
1104            .spawn((
1105                agent_state(AgentStatus::Active),
1106                blueprint_with(vec![plan_point()]),
1107                StageCursor { index: 0 },
1108                infer("plan"),
1109                ReadyForInteractionPoint,
1110            ))
1111            .id();
1112        // No InteractionHub / InteractionPointStage resources ⇒ early return.
1113        let mut s = Schedule::default();
1114        s.add_systems(dispatch_interaction_point);
1115        s.run(&mut world);
1116        assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); // untouched
1117    }
1118
1119    fn dispatch_world() -> (World, InteractionHub) {
1120        let hub = InteractionHub::new();
1121        let (tx, _rx) = unbounded_channel();
1122        let mut world = World::new();
1123        world.insert_resource(hub.clone());
1124        world.insert_resource(InteractionPointStage {
1125            outcomes: tx,
1126            wake: Arc::new(Notify::new()),
1127            runtime: Handle::current(),
1128        });
1129        (world, hub)
1130    }
1131
1132    #[tokio::test]
1133    async fn dispatch_skips_non_active_agent() {
1134        let (mut world, _hub) = dispatch_world();
1135        let e = world
1136            .spawn((
1137                agent_state(AgentStatus::Waiting),
1138                blueprint_with(vec![plan_point()]),
1139                window_with_plan(),
1140                StageCursor { index: 0 },
1141                infer("plan"),
1142                ReadyForInteractionPoint,
1143            ))
1144            .id();
1145        let mut s = Schedule::default();
1146        s.add_systems(dispatch_interaction_point);
1147        s.run(&mut world);
1148        assert!(world.get::<ReadyForInteractionPoint>(e).is_some()); // not dispatched
1149    }
1150
1151    #[tokio::test]
1152    async fn dispatch_falls_through_when_point_missing() {
1153        let (mut world, _hub) = dispatch_world();
1154        // cursor past the single point ⇒ no point to ask ⇒ ResolveTransition.
1155        let e = world
1156            .spawn((
1157                agent_state(AgentStatus::Active),
1158                blueprint_with(vec![plan_point()]),
1159                window_with_plan(),
1160                StageCursor { index: 0 },
1161                InteractionPointCursor(5),
1162                infer("plan"),
1163                ReadyForInteractionPoint,
1164            ))
1165            .id();
1166        let mut s = Schedule::default();
1167        s.add_systems(dispatch_interaction_point);
1168        s.run(&mut world);
1169        assert!(world.get::<ResolveTransition>(e).is_some());
1170        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1171    }
1172
1173    #[tokio::test]
1174    async fn dispatch_spawns_ask_and_awaits() {
1175        let (mut world, hub) = dispatch_world();
1176        let e = world
1177            .spawn((
1178                agent_state(AgentStatus::Active),
1179                blueprint_with(vec![plan_point()]),
1180                window_with_plan(),
1181                StageCursor { index: 0 },
1182                infer("the plan"),
1183                ReadyForInteractionPoint,
1184            ))
1185            .id();
1186        let mut s = Schedule::default();
1187        s.add_systems(dispatch_interaction_point);
1188        s.run(&mut world);
1189        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1190        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1191        // The ask task registered a request in the hub, carrying the produced
1192        // document (the plan) as its review body.
1193        for _ in 0..8 {
1194            tokio::task::yield_now().await;
1195        }
1196        let pending = hub.pending();
1197        assert_eq!(pending.len(), 1);
1198        assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
1199        // The produced plan became the authoritative content of the pinned
1200        // `plan` region (no user-edit marker, since it came from inference).
1201        let plan = world
1202            .get::<ContextWindow>(e)
1203            .unwrap()
1204            .get_region("plan")
1205            .unwrap();
1206        assert_eq!(plan.content.len(), 1);
1207        assert_eq!(plan.content[0].content, "the plan");
1208    }
1209
1210    #[tokio::test]
1211    async fn dispatch_auto_approves_an_unattended_run_without_asking() {
1212        // `--yolo` means nobody is watching, so a stage-boundary checkpoint must
1213        // resolve itself rather than park the run on the hub forever (#107).
1214        let hub = InteractionHub::new();
1215        let (tx, mut rx) = unbounded_channel();
1216        let mut world = World::new();
1217        world.insert_resource(hub.clone());
1218        world.insert_resource(InteractionPointStage {
1219            outcomes: tx,
1220            wake: Arc::new(Notify::new()),
1221            runtime: Handle::current(),
1222        });
1223        let e = world
1224            .spawn((
1225                agent_state(AgentStatus::Active),
1226                blueprint_with(vec![plan_point()]),
1227                window_with_plan(),
1228                StageCursor { index: 0 },
1229                infer("the plan"),
1230                ReadyForInteractionPoint,
1231                crate::components::InteractionAutoApprove,
1232            ))
1233            .id();
1234        let mut s = Schedule::default();
1235        s.add_systems(dispatch_interaction_point);
1236        s.run(&mut world);
1237
1238        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1239        assert!(world.get::<ReadyForInteractionPoint>(e).is_none());
1240        // Approved straight onto the outcome lane; no prompt was ever opened.
1241        let outcome = rx.try_recv().expect("an outcome was published");
1242        assert_eq!(outcome.entity, e);
1243        assert!(matches!(
1244            outcome.decision,
1245            PointOutcome::Approve { ref user_text } if user_text.is_empty()
1246        ));
1247        for _ in 0..8 {
1248            tokio::task::yield_now().await;
1249        }
1250        assert!(hub.pending().is_empty(), "no human was asked");
1251        // The approved document still landed in its region, so what was waved
1252        // through is on the record.
1253        let plan = world
1254            .get::<ContextWindow>(e)
1255            .unwrap()
1256            .get_region("plan")
1257            .unwrap();
1258        assert_eq!(plan.content[0].content, "the plan");
1259    }
1260
1261    #[tokio::test]
1262    async fn dispatch_asks_an_unattended_run_when_the_point_opts_out() {
1263        // `unattended = "ask"` is the escape hatch for a checkpoint that exists
1264        // precisely because a person has to look - approving a plan unread is
1265        // worse than waiting for one. The prompt opens even under `--yolo`.
1266        let hub = InteractionHub::new();
1267        let (tx, mut rx) = unbounded_channel();
1268        let mut world = World::new();
1269        world.insert_resource(hub.clone());
1270        world.insert_resource(InteractionPointStage {
1271            outcomes: tx,
1272            wake: Arc::new(Notify::new()),
1273            runtime: Handle::current(),
1274        });
1275        let mut point = plan_point();
1276        point.unattended = UnattendedPolicy::Ask;
1277        let e = world
1278            .spawn((
1279                agent_state(AgentStatus::Active),
1280                blueprint_with(vec![point]),
1281                window_with_plan(),
1282                StageCursor { index: 0 },
1283                infer("the plan"),
1284                ReadyForInteractionPoint,
1285                crate::components::InteractionAutoApprove,
1286            ))
1287            .id();
1288        let mut s = Schedule::default();
1289        s.add_systems(dispatch_interaction_point);
1290        s.run(&mut world);
1291
1292        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1293        // Nothing was waved through: the run waits on a real prompt.
1294        assert!(rx.try_recv().is_err(), "no outcome was published");
1295        for _ in 0..8 {
1296            tokio::task::yield_now().await;
1297        }
1298        let pending = hub.pending();
1299        assert_eq!(pending.len(), 1, "a person is being asked");
1300        assert_eq!(pending[0].1.stage_name, "plan_approval");
1301    }
1302
1303    /// End to end through the real ask task: a held point whose prompt expires
1304    /// unanswered reports `Unanswered`, and one left on the default policy still
1305    /// approves. The second half is the regression guard - the whole point of
1306    /// keying this on `unattended` is that nothing else changes.
1307    #[tokio::test]
1308    async fn an_expired_prompt_stops_a_held_point_and_approves_an_auto_one() {
1309        for (policy, expected) in [
1310            (UnattendedPolicy::Ask, PointOutcome::Unanswered),
1311            (
1312                UnattendedPolicy::AutoApprove,
1313                PointOutcome::Approve {
1314                    user_text: String::new(),
1315                },
1316            ),
1317        ] {
1318            let hub = InteractionHub::new();
1319            let (tx, mut rx) = unbounded_channel();
1320            let mut point = plan_point();
1321            point.unattended = policy;
1322            let task = tokio::spawn(run_interaction_point(
1323                PointAsk {
1324                    entity: Entity::from_raw_u32(1).unwrap(),
1325                    agent_id: "run-1".to_string(),
1326                    point,
1327                    body: "the plan".to_string(),
1328                    round: 0,
1329                },
1330                PromptLane {
1331                    hub: hub.clone(),
1332                    outcomes: tx,
1333                    wake: Arc::new(Notify::new()),
1334                },
1335            ));
1336            // Expiring and cancelling hand back the same neutral response, and
1337            // cancelling does not need a clock. The id is the one
1338            // `run_interaction_point` mints, so cancelling succeeds exactly once
1339            // the prompt has registered.
1340            // Polling the runtime is enough to get there: `tokio::spawn` has
1341            // not run the task at all yet, and its first await point is inside
1342            // the hub, after the request has registered.
1343            for _ in 0..8 {
1344                tokio::task::yield_now().await;
1345            }
1346            assert!(
1347                hub.cancel("run-1-point-plan_approval-0"),
1348                "the point opened a prompt"
1349            );
1350            task.await.unwrap();
1351
1352            let out = rx.try_recv().expect("an outcome was published");
1353            assert_eq!(out.decision, expected, "{policy:?}");
1354        }
1355    }
1356
1357    #[tokio::test]
1358    async fn dispatch_without_document_region_skips_region_write() {
1359        // A point with no `document_region` still asks, but writes no region.
1360        let (mut world, _hub) = dispatch_world();
1361        let e = world
1362            .spawn((
1363                agent_state(AgentStatus::Active),
1364                blueprint_with(vec![point("p", InteractionStyle::Confirm, &[])]),
1365                window_with_plan(),
1366                StageCursor { index: 0 },
1367                infer("some output"),
1368                ReadyForInteractionPoint,
1369            ))
1370            .id();
1371        let mut s = Schedule::default();
1372        s.add_systems(dispatch_interaction_point);
1373        s.run(&mut world);
1374        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
1375        // The plan region is untouched (the point declared no document region).
1376        let plan = world
1377            .get::<ContextWindow>(e)
1378            .unwrap()
1379            .get_region("plan")
1380            .unwrap();
1381        assert!(plan.content.is_empty());
1382    }
1383
1384    #[tokio::test]
1385    async fn dispatch_with_empty_document_skips_region_write() {
1386        // An empty produced document is not written to the region.
1387        let (mut world, _hub) = dispatch_world();
1388        let e = world
1389            .spawn((
1390                agent_state(AgentStatus::Active),
1391                blueprint_with(vec![plan_point()]),
1392                window_with_plan(),
1393                StageCursor { index: 0 },
1394                infer("   "),
1395                ReadyForInteractionPoint,
1396            ))
1397            .id();
1398        let mut s = Schedule::default();
1399        s.add_systems(dispatch_interaction_point);
1400        s.run(&mut world);
1401        let plan = world
1402            .get::<ContextWindow>(e)
1403            .unwrap()
1404            .get_region("plan")
1405            .unwrap();
1406        assert!(plan.content.is_empty());
1407    }
1408
1409    #[tokio::test]
1410    async fn dispatch_prefers_the_plan_body_override() {
1411        let (mut world, hub) = dispatch_world();
1412        let e = world
1413            .spawn((
1414                agent_state(AgentStatus::Active),
1415                blueprint_with(vec![plan_point()]),
1416                window_with_plan(),
1417                StageCursor { index: 0 },
1418                infer("the stale pre-edit plan"),
1419                PlanBodyOverride("the edited plan".to_string()),
1420                ReadyForInteractionPoint,
1421            ))
1422            .id();
1423        let mut s = Schedule::default();
1424        s.add_systems(dispatch_interaction_point);
1425        s.run(&mut world);
1426        // The override is consumed once dispatched.
1427        assert!(world.get::<PlanBodyOverride>(e).is_none());
1428        // The edited text replaced the plan region, marked as user-revised so
1429        // the model preserves it on later revisions.
1430        let plan = world
1431            .get::<ContextWindow>(e)
1432            .unwrap()
1433            .get_region("plan")
1434            .unwrap();
1435        assert_eq!(plan.content.len(), 1);
1436        assert!(plan.content[0].content.contains("[revised by user"));
1437        assert!(plan.content[0].content.contains("the edited plan"));
1438        for _ in 0..8 {
1439            tokio::task::yield_now().await;
1440        }
1441        // The edited text, not the inference response, is the review body.
1442        assert_eq!(hub.pending()[0].1.body.as_deref(), Some("the edited plan"));
1443    }
1444
1445    // ── collect ──
1446
1447    fn collect_world() -> (
1448        World,
1449        tokio::sync::mpsc::UnboundedSender<InteractionPointOutcome>,
1450    ) {
1451        let (tx, rx) = unbounded_channel();
1452        let mut world = World::new();
1453        world.insert_resource(InteractionPointResults(rx));
1454        (world, tx)
1455    }
1456
1457    fn run_collect(world: &mut World) {
1458        let mut s = Schedule::default();
1459        s.add_systems(collect_interaction_point);
1460        s.run(world);
1461    }
1462
1463    fn spawn_awaiting(world: &mut World, points: Vec<InteractionPoint>) -> Entity {
1464        world
1465            .spawn((
1466                agent_state(AgentStatus::Waiting),
1467                window(),
1468                blueprint_with(points),
1469                StageCursor { index: 0 },
1470                AwaitingInteractionPoint,
1471            ))
1472            .id()
1473    }
1474
1475    #[test]
1476    fn collect_approve_single_point_proceeds() {
1477        let (mut world, tx) = collect_world();
1478        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1479        tx.send(InteractionPointOutcome {
1480            entity: e,
1481            decision: PointOutcome::Approve {
1482                user_text: "Approve".to_string(),
1483            },
1484        })
1485        .unwrap();
1486        run_collect(&mut world);
1487        assert!(world.get::<ResolveTransition>(e).is_some());
1488        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1489        assert_eq!(
1490            world.get::<AgentState>(e).unwrap().status,
1491            AgentStatus::Active
1492        );
1493        assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1494    }
1495
1496    #[test]
1497    fn collect_approve_advances_to_next_point() {
1498        let (mut world, tx) = collect_world();
1499        let e = spawn_awaiting(
1500            &mut world,
1501            vec![
1502                point("first", InteractionStyle::Confirm, &[]),
1503                point("second", InteractionStyle::Confirm, &[]),
1504            ],
1505        );
1506        tx.send(InteractionPointOutcome {
1507            entity: e,
1508            decision: PointOutcome::Approve {
1509                user_text: String::new(),
1510            },
1511        })
1512        .unwrap();
1513        run_collect(&mut world);
1514        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 1);
1515        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1516        assert!(world.get::<ResolveTransition>(e).is_none());
1517    }
1518
1519    /// The neutral response the hub hands back on a timeout or cancel carries
1520    /// no decision at all. Every real answer sets exactly one of the three
1521    /// fields, including a `confirm` denial, which sets `approved` - so a denial
1522    /// must not read as "nobody answered".
1523    #[test]
1524    fn is_unanswered_tells_a_timeout_from_every_real_answer() {
1525        let cases: &[(InteractionResponse, bool, &str)] = &[
1526            (InteractionResponse::text("id", ""), true, "expired"),
1527            (InteractionResponse::text("id", "   "), true, "whitespace"),
1528            (InteractionResponse::text("id", "Approve"), false, "text"),
1529            (InteractionResponse::choice("id", 0), false, "a choice"),
1530            (
1531                InteractionResponse::approval(
1532                    "id",
1533                    false,
1534                    leviath_core::interaction::ApprovalScope::Once,
1535                ),
1536                false,
1537                "a confirm denial",
1538            ),
1539        ];
1540        for (resp, expected, what) in cases {
1541            assert_eq!(is_unanswered(resp), *expected, "{what}");
1542        }
1543    }
1544
1545    /// The bug this closes: an empty answer routes through the final `else` of
1546    /// `route_answer`, which is `Approve`. A `--yolo` run in CI waited out the
1547    /// interaction timeout and then approved the plan nobody read, and went on
1548    /// to write code from it.
1549    #[test]
1550    fn an_unanswered_held_checkpoint_stops_the_run() {
1551        let (mut world, tx) = collect_world();
1552        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1553        tx.send(InteractionPointOutcome {
1554            entity: e,
1555            decision: PointOutcome::Unanswered,
1556        })
1557        .unwrap();
1558        run_collect(&mut world);
1559
1560        // Asserted whole rather than by substring, because the message is what
1561        // the operator reads out of `lev ps` and `meta.json`.
1562        assert_eq!(
1563            world.get::<AgentState>(e).unwrap().status,
1564            AgentStatus::Error {
1565                message: "checkpoint 'plan_approval' went unanswered within the interaction \
1566                          timeout; the run stopped rather than approving it unread"
1567                    .to_string(),
1568            }
1569        );
1570        // Terminal: no transition, so the run stops here rather than moving on
1571        // to the stage the checkpoint was guarding.
1572        assert!(world.get::<ResolveTransition>(e).is_none());
1573        assert!(world.get::<ReadyToInfer>(e).is_none());
1574        assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
1575    }
1576
1577    #[test]
1578    fn collect_abort_cancels() {
1579        let (mut world, tx) = collect_world();
1580        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1581        tx.send(InteractionPointOutcome {
1582            entity: e,
1583            decision: PointOutcome::Abort,
1584        })
1585        .unwrap();
1586        run_collect(&mut world);
1587        assert_eq!(
1588            world.get::<AgentState>(e).unwrap().status,
1589            AgentStatus::Cancelled
1590        );
1591        assert!(world.get::<ResolveTransition>(e).is_none());
1592    }
1593
1594    /// Answering the prompt of a run that was cancelled while it waited must not
1595    /// bring the run back. Every non-`Abort` arm sets `Active` unconditionally, so
1596    /// without the terminal guard an answer - including the neutral response a
1597    /// cancel itself delivers to release the blocked `ask` - walked a cancelled
1598    /// run straight back into the pipeline.
1599    #[test]
1600    fn collect_does_not_resurrect_a_cancelled_run() {
1601        for decision in [
1602            PointOutcome::Approve {
1603                user_text: "ok".to_string(),
1604            },
1605            PointOutcome::Directive {
1606                user_text: "go".to_string(),
1607                directive: "d".to_string(),
1608            },
1609            PointOutcome::Edit {
1610                user_text: "go".to_string(),
1611                edited: "body".to_string(),
1612            },
1613        ] {
1614            let (mut world, tx) = collect_world();
1615            let e = spawn_awaiting(&mut world, vec![plan_point()]);
1616            world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Cancelled;
1617
1618            tx.send(InteractionPointOutcome {
1619                entity: e,
1620                decision,
1621            })
1622            .unwrap();
1623            run_collect(&mut world);
1624
1625            assert_eq!(
1626                world.get::<AgentState>(e).unwrap().status,
1627                AgentStatus::Cancelled,
1628                "the run stays cancelled"
1629            );
1630            assert!(
1631                world.get::<AwaitingInteractionPoint>(e).is_none(),
1632                "the awaiting marker is still cleared, so nothing re-collects it"
1633            );
1634            assert!(
1635                world.get::<ResolveTransition>(e).is_none()
1636                    && world.get::<ReadyToInfer>(e).is_none()
1637                    && world.get::<ReadyForInteractionPoint>(e).is_none(),
1638                "and it is not queued for any further work"
1639            );
1640        }
1641    }
1642
1643    #[test]
1644    fn collect_directive_reinfers_then_caps() {
1645        let (mut world, tx) = collect_world();
1646        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1647        tx.send(InteractionPointOutcome {
1648            entity: e,
1649            decision: PointOutcome::Directive {
1650                user_text: "Revise".to_string(),
1651                directive: "do it".to_string(),
1652            },
1653        })
1654        .unwrap();
1655        run_collect(&mut world);
1656        assert!(world.get::<ReadyToInfer>(e).is_some());
1657        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1658        assert!(world.get::<ResolveTransition>(e).is_none());
1659
1660        // At the cap, a further directive proceeds instead of re-inferring.
1661        world
1662            .entity_mut(e)
1663            .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1664            .insert(AwaitingInteractionPoint);
1665        tx.send(InteractionPointOutcome {
1666            entity: e,
1667            decision: PointOutcome::Directive {
1668                user_text: String::new(),
1669                directive: "again".to_string(),
1670            },
1671        })
1672        .unwrap();
1673        run_collect(&mut world);
1674        assert!(world.get::<ResolveTransition>(e).is_some());
1675    }
1676
1677    #[test]
1678    fn collect_edit_surfaces_the_adopted_text_in_stage_output() {
1679        let (mut world, tx) = collect_world();
1680        let e = world
1681            .spawn((
1682                agent_state(AgentStatus::Waiting),
1683                window(),
1684                blueprint_with(vec![plan_point()]),
1685                StageCursor { index: 0 },
1686                AwaitingInteractionPoint,
1687                StageIoBuffer::default(),
1688            ))
1689            .id();
1690        tx.send(InteractionPointOutcome {
1691            entity: e,
1692            decision: PointOutcome::Edit {
1693                user_text: "Add detail".to_string(),
1694                edited: "the revised plan".to_string(),
1695            },
1696        })
1697        .unwrap();
1698        run_collect(&mut world);
1699        // The adopted text is buffered for stages/<idx>/output.log, tagged with
1700        // the current stage index, so observers reflect the revision.
1701        let buf = world.get::<StageIoBuffer>(e).unwrap();
1702        assert_eq!(buf.output.len(), 1);
1703        assert_eq!(buf.output[0].0, 0);
1704        assert!(buf.output[0].1.contains("the revised plan"));
1705        // The edited text is also queued as the re-presented point's review body.
1706        assert_eq!(
1707            world.get::<PlanBodyOverride>(e).unwrap().0,
1708            "the revised plan"
1709        );
1710    }
1711
1712    /// An approval that followed a revision has to say so. A model that had
1713    /// already concluded "this is done" kept the conclusion and applied the
1714    /// correction only to the document - the plan changed, its reading of the
1715    /// world did not. The note is only injected when there *was* a revision.
1716    #[test]
1717    fn collect_approve_after_a_revision_says_the_plan_changed() {
1718        let (mut world, tx) = collect_world();
1719
1720        let first_try = spawn_awaiting(&mut world, vec![plan_point()]);
1721        let revised = spawn_awaiting(&mut world, vec![plan_point()]);
1722        world.entity_mut(revised).insert(InteractionPointRounds(2));
1723
1724        for e in [first_try, revised] {
1725            tx.send(InteractionPointOutcome {
1726                entity: e,
1727                decision: PointOutcome::Approve {
1728                    user_text: "Approve".to_string(),
1729                },
1730            })
1731            .unwrap();
1732        }
1733        run_collect(&mut world);
1734
1735        let plain = world
1736            .get::<ContextWindow>(first_try)
1737            .unwrap()
1738            .current_tokens;
1739        let noted = world.get::<ContextWindow>(revised).unwrap().current_tokens;
1740        assert!(
1741            noted > plain,
1742            "a revised-then-approved plan carries the re-check note ({noted} vs {plain})"
1743        );
1744    }
1745
1746    #[test]
1747    fn collect_edit_represents_then_caps() {
1748        let (mut world, tx) = collect_world();
1749        let e = spawn_awaiting(&mut world, vec![plan_point()]);
1750        tx.send(InteractionPointOutcome {
1751            entity: e,
1752            decision: PointOutcome::Edit {
1753                user_text: "Add detail".to_string(),
1754                edited: "the edited plan".to_string(),
1755            },
1756        })
1757        .unwrap();
1758        run_collect(&mut world);
1759        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1760        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 1);
1761        // The edited text was injected.
1762        let after_first = world.get::<ContextWindow>(e).unwrap().current_tokens;
1763        assert!(after_first > 0);
1764
1765        // An empty edit re-presents too, but injects nothing new.
1766        world
1767            .entity_mut(e)
1768            .insert(InteractionPointRounds(0))
1769            .insert(AwaitingInteractionPoint);
1770        tx.send(InteractionPointOutcome {
1771            entity: e,
1772            decision: PointOutcome::Edit {
1773                user_text: String::new(),
1774                edited: String::new(),
1775            },
1776        })
1777        .unwrap();
1778        run_collect(&mut world);
1779        assert!(world.get::<ReadyForInteractionPoint>(e).is_some());
1780        assert_eq!(
1781            world.get::<ContextWindow>(e).unwrap().current_tokens,
1782            after_first
1783        );
1784
1785        // At the cap, an edit proceeds instead of re-presenting.
1786        world
1787            .entity_mut(e)
1788            .insert(InteractionPointRounds(MAX_REVISION_ROUNDS - 1))
1789            .insert(AwaitingInteractionPoint);
1790        tx.send(InteractionPointOutcome {
1791            entity: e,
1792            decision: PointOutcome::Edit {
1793                user_text: String::new(),
1794                edited: String::new(), // empty edit ⇒ no injection branch
1795            },
1796        })
1797        .unwrap();
1798        run_collect(&mut world);
1799        assert!(world.get::<ResolveTransition>(e).is_some());
1800    }
1801
1802    #[test]
1803    fn collect_on_noninteractive_stage_proceeds() {
1804        // An outcome for an agent whose stage isn't interactive (npoints = 0):
1805        // approve's next index immediately satisfies, so it proceeds.
1806        let (mut world, tx) = collect_world();
1807        let e = world
1808            .spawn((
1809                agent_state(AgentStatus::Waiting),
1810                window(),
1811                noninteractive_bp(),
1812                StageCursor { index: 0 },
1813                AwaitingInteractionPoint,
1814            ))
1815            .id();
1816        tx.send(InteractionPointOutcome {
1817            entity: e,
1818            decision: PointOutcome::Approve {
1819                user_text: String::new(),
1820            },
1821        })
1822        .unwrap();
1823        run_collect(&mut world);
1824        assert!(world.get::<ResolveTransition>(e).is_some());
1825    }
1826
1827    #[test]
1828    fn collect_drops_outcome_for_missing_agent() {
1829        let (mut world, tx) = collect_world();
1830        tx.send(InteractionPointOutcome {
1831            entity: Entity::from_raw_u32(999)
1832                .expect("a small literal index is always a valid entity id"),
1833            decision: PointOutcome::Abort,
1834        })
1835        .unwrap();
1836        run_collect(&mut world); // no panic
1837    }
1838
1839    // ── the async ask task ──
1840
1841    async fn drive_point(
1842        point: InteractionPoint,
1843        answer: impl FnOnce(&InteractionHub, String),
1844    ) -> PointOutcome {
1845        let hub = InteractionHub::new();
1846        let (tx, mut rx) = unbounded_channel();
1847        let task = {
1848            let hub = hub.clone();
1849            tokio::spawn(run_interaction_point(
1850                PointAsk {
1851                    entity: Entity::from_raw_u32(1)
1852                        .expect("a small literal index is always a valid entity id"),
1853                    agent_id: "run".to_string(),
1854                    point,
1855                    body: "body".to_string(),
1856                    round: 0,
1857                },
1858                PromptLane {
1859                    hub,
1860                    outcomes: tx,
1861                    wake: Arc::new(Notify::new()),
1862                },
1863            ))
1864        };
1865        for _ in 0..8 {
1866            tokio::task::yield_now().await;
1867        }
1868        let id = hub.pending()[0].1.id.clone();
1869        answer(&hub, id);
1870        task.await.unwrap();
1871        rx.recv().await.unwrap().decision
1872    }
1873
1874    #[tokio::test]
1875    async fn run_point_approve() {
1876        let out = drive_point(plan_point(), |hub, id| {
1877            let mut r = InteractionResponse::text(&id, "");
1878            r.choice_index = Some(0); // Approve
1879            hub.answer(r);
1880        })
1881        .await;
1882        assert_eq!(
1883            out,
1884            PointOutcome::Approve {
1885                user_text: "Approve".to_string()
1886            }
1887        );
1888    }
1889
1890    #[tokio::test]
1891    async fn run_point_abort_and_directive() {
1892        let abort = drive_point(plan_point(), |hub, id| {
1893            let mut r = InteractionResponse::text(&id, "");
1894            r.choice_index = Some(3); // Abort
1895            hub.answer(r);
1896        })
1897        .await;
1898        assert_eq!(abort, PointOutcome::Abort);
1899
1900        let directive = drive_point(plan_point(), |hub, id| {
1901            let mut r = InteractionResponse::text(&id, "");
1902            r.choice_index = Some(1); // Revise
1903            hub.answer(r);
1904        })
1905        .await;
1906        assert_eq!(
1907            directive,
1908            PointOutcome::Directive {
1909                user_text: "Revise".to_string(),
1910                directive: "revise the plan".to_string(),
1911            }
1912        );
1913    }
1914
1915    #[tokio::test]
1916    async fn run_point_edit_does_second_ask() {
1917        // Selecting the edit option triggers a second (edit_text) ask; answer both.
1918        let hub = InteractionHub::new();
1919        let (tx, mut rx) = unbounded_channel();
1920        let task = {
1921            let hub = hub.clone();
1922            tokio::spawn(run_interaction_point(
1923                PointAsk {
1924                    entity: Entity::from_raw_u32(1)
1925                        .expect("a small literal index is always a valid entity id"),
1926                    agent_id: "run".to_string(),
1927                    point: plan_point(),
1928                    body: "body".to_string(),
1929                    round: 0,
1930                },
1931                PromptLane {
1932                    hub,
1933                    outcomes: tx,
1934                    wake: Arc::new(Notify::new()),
1935                },
1936            ))
1937        };
1938        // Answer the point with the edit option.
1939        for _ in 0..8 {
1940            tokio::task::yield_now().await;
1941        }
1942        let id = hub.pending()[0].1.id.clone();
1943        let mut r = InteractionResponse::text(&id, "");
1944        r.choice_index = Some(2); // Add detail ⇒ edit
1945        hub.answer(r);
1946        // Then answer the edit request with the edited text.
1947        for _ in 0..8 {
1948            tokio::task::yield_now().await;
1949        }
1950        let edit_id = hub.pending()[0].1.id.clone();
1951        hub.answer(InteractionResponse::text(&edit_id, "edited body"));
1952        task.await.unwrap();
1953        assert_eq!(
1954            rx.recv().await.unwrap().decision,
1955            PointOutcome::Edit {
1956                user_text: "Add detail".to_string(),
1957                edited: "edited body".to_string(),
1958            }
1959        );
1960    }
1961
1962    // ── restore (restart persistence, issue #38) ──
1963
1964    #[test]
1965    fn interaction_point_state_round_trips() {
1966        let s = InteractionPointState {
1967            cursor: 2,
1968            round: 1,
1969            body: "# Plan\n1. do it".to_string(),
1970        };
1971        let json = serde_json::to_string(&s).unwrap();
1972        assert_eq!(
1973            serde_json::from_str::<InteractionPointState>(&json).unwrap(),
1974            s
1975        );
1976    }
1977
1978    /// A world with the interaction-point lane + hub wired, keeping the results
1979    /// receiver so a resumed point can be answered and collected end-to-end.
1980    fn resume_world() -> (
1981        World,
1982        InteractionHub,
1983        UnboundedReceiver<InteractionPointOutcome>,
1984    ) {
1985        let hub = InteractionHub::new();
1986        let (tx, rx) = unbounded_channel();
1987        let mut world = World::new();
1988        world.insert_resource(hub.clone());
1989        world.insert_resource(InteractionPointStage {
1990            outcomes: tx,
1991            wake: Arc::new(Notify::new()),
1992            runtime: Handle::current(),
1993        });
1994        (world, hub, rx)
1995    }
1996
1997    /// A freshly "restored" agent as `restore_agent` leaves it (Active +
1998    /// ReadyToInfer) before interaction-point restore runs.
1999    fn restored_agent(world: &mut World, bp: AgentBlueprint) -> Entity {
2000        world
2001            .spawn((
2002                agent_state(AgentStatus::Active),
2003                bp,
2004                window_with_plan(),
2005                StageCursor { index: 0 },
2006                ReadyToInfer,
2007            ))
2008            .id()
2009    }
2010
2011    #[tokio::test]
2012    async fn restore_rearms_waiting_and_reopens_the_prompt() {
2013        let (mut world, hub, _rx) = resume_world();
2014        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2015        let agent = crate::world::AgentId::in_world(&world, e);
2016        restore_interaction_point(
2017            &mut world,
2018            agent,
2019            InteractionPointState {
2020                cursor: 0,
2021                round: 2,
2022                body: "the plan".to_string(),
2023            },
2024        );
2025
2026        // Re-armed in the waiting state: the inference lane won't fire.
2027        assert_eq!(
2028            world.get::<AgentState>(e).unwrap().status,
2029            AgentStatus::Waiting
2030        );
2031        assert!(world.get::<AwaitingInteractionPoint>(e).is_some());
2032        assert!(world.get::<ReadyToInfer>(e).is_none());
2033        assert_eq!(world.get::<InteractionPointCursor>(e).unwrap().0, 0);
2034        assert_eq!(world.get::<InteractionPointRounds>(e).unwrap().0, 2);
2035
2036        // The ask task re-registered the *same* request id in the hub, with the body.
2037        for _ in 0..8 {
2038            tokio::task::yield_now().await;
2039        }
2040        let pending = hub.pending();
2041        assert_eq!(pending.len(), 1);
2042        assert_eq!(pending[0].0, "run-1");
2043        assert_eq!(pending[0].1.id, "run-1-point-plan_approval-2");
2044        assert_eq!(pending[0].1.body.as_deref(), Some("the plan"));
2045    }
2046
2047    #[tokio::test]
2048    async fn restore_then_answer_drives_the_transition() {
2049        let (mut world, hub, mut rx) = resume_world();
2050        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2051        let agent = crate::world::AgentId::in_world(&world, e);
2052        restore_interaction_point(
2053            &mut world,
2054            agent,
2055            InteractionPointState {
2056                cursor: 0,
2057                round: 0,
2058                body: "the plan".to_string(),
2059            },
2060        );
2061        for _ in 0..8 {
2062            tokio::task::yield_now().await;
2063        }
2064
2065        // Approve the re-opened prompt; the outcome lands on the lane.
2066        let id = hub.pending()[0].1.id.clone();
2067        let mut r = InteractionResponse::text(&id, "");
2068        r.choice_index = Some(0); // Approve
2069        assert!(hub.answer(r));
2070        let outcome = rx.recv().await.unwrap();
2071
2072        // Feed it to collect and confirm the stage proceeds.
2073        let (tx2, rx2) = unbounded_channel();
2074        tx2.send(outcome).unwrap();
2075        world.insert_resource(InteractionPointResults(rx2));
2076        let mut s = Schedule::default();
2077        s.add_systems(collect_interaction_point);
2078        s.run(&mut world);
2079
2080        assert!(world.get::<ResolveTransition>(e).is_some());
2081        assert_eq!(
2082            world.get::<AgentState>(e).unwrap().status,
2083            AgentStatus::Active
2084        );
2085    }
2086
2087    #[tokio::test]
2088    async fn restore_noop_on_noninteractive_stage() {
2089        let (mut world, hub, _rx) = resume_world();
2090        let e = restored_agent(&mut world, noninteractive_bp());
2091        let agent = crate::world::AgentId::in_world(&world, e);
2092        restore_interaction_point(
2093            &mut world,
2094            agent,
2095            InteractionPointState {
2096                cursor: 0,
2097                round: 0,
2098                body: "x".to_string(),
2099            },
2100        );
2101        // Left as the default restore: Active + ReadyToInfer, nothing re-opened.
2102        assert_eq!(
2103            world.get::<AgentState>(e).unwrap().status,
2104            AgentStatus::Active
2105        );
2106        assert!(world.get::<ReadyToInfer>(e).is_some());
2107        assert!(world.get::<AwaitingInteractionPoint>(e).is_none());
2108        for _ in 0..8 {
2109            tokio::task::yield_now().await;
2110        }
2111        assert!(hub.pending().is_empty());
2112    }
2113
2114    #[tokio::test]
2115    async fn restore_noop_without_lane_wired() {
2116        // No InteractionPointStage / hub resources (a test world) ⇒ no-op.
2117        let mut world = World::new();
2118        let e = restored_agent(&mut world, blueprint_with(vec![plan_point()]));
2119        let agent = crate::world::AgentId::in_world(&world, e);
2120        restore_interaction_point(
2121            &mut world,
2122            agent,
2123            InteractionPointState {
2124                cursor: 0,
2125                round: 0,
2126                body: "x".to_string(),
2127            },
2128        );
2129        assert_eq!(
2130            world.get::<AgentState>(e).unwrap().status,
2131            AgentStatus::Active
2132        );
2133        assert!(world.get::<ReadyToInfer>(e).is_some());
2134    }
2135}