Skip to main content

flux_flow/
engine.rs

1//! Flux's single turn engine. An authored Flux-Lang program owns the outer control loop while typed
2//! model stages use provider-native operation calls for intent, evidence, action proposals, repair,
3//! and presentation. Models never author executable Flux.
4//!
5//! The engine reuses [`crate::AgentSink`] so a surface (CLI/TUI) can drive it with the same sink.
6//! Every op still executes through `Executor::dispatch` — there is no new bypass surface.
7
8use std::sync::Arc;
9
10use futures::StreamExt;
11use tokio_util::sync::CancellationToken;
12
13use flux_core::{Chunk, ContentBlock, Error, Message, Result, Usage};
14use flux_events::EventStore;
15use flux_provider::{Effort, Provider, Request};
16use flux_runtime::{
17    scope_runtime_turn, Executor, RuntimeTurnContext, SpawnTaskSupervisor, TurnIdentity,
18    SPAWN_CLEANUP_GRACE,
19};
20
21use crate::ast::{DraftAst, Node, NodeId};
22use crate::composites::DynamicComposites;
23use crate::model::StageOptions;
24use crate::registry::OpRegistry;
25use crate::runtime::{
26    execute_flow_traced, execute_flow_with_composites, resume_flow_with_composites,
27};
28use crate::state::FlowStore;
29use crate::AgentSink;
30use flux_lang::runtime::FlowOutcome;
31
32/// Default bound for the authored decision/batch repeat in the shipped agent loop.
33pub const DEFAULT_AGENT_LOOP_ITERATIONS: usize = 50;
34
35/// Maximum configurable bound for an authored agent loop.
36///
37/// The shipped loop lowers its repeat into a durable top-level state machine by cloning the loop
38/// body once per iteration. Keeping this at twenty times the normal default permits unusually long
39/// turns without allowing configuration input to drive unbounded startup work.
40pub const MAX_AGENT_LOOP_ITERATIONS: usize = 1_000;
41
42/// A shipped agent-loop preset. Presets are ordinary Flux-Lang programs selected explicitly by an
43/// agent definition; the host does not probe the workspace for an implicit override.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum BuiltinAgentLoop {
46    /// Intent routing, native-schema exploration, explicit action-batch approval/execution, and
47    /// grounded presentation.
48    #[default]
49    Adaptive,
50}
51
52/// The outer control program for an agent turn.
53#[derive(Debug, Clone, PartialEq)]
54pub enum AgentLoopSpec {
55    /// One of Flux's shipped, versioned loop presets.
56    Builtin(BuiltinAgentLoop),
57    /// A caller-supplied, already parsed Flux-Lang program.
58    Flux(DraftAst),
59}
60
61impl Default for AgentLoopSpec {
62    fn default() -> Self {
63        Self::Builtin(BuiltinAgentLoop::Adaptive)
64    }
65}
66
67impl AgentLoopSpec {
68    /// Parse an explicit selector: `adaptive` chooses the shipped preset; every other value is
69    /// parsed as Flux-Lang source. File IO belongs to the calling surface and is intentionally not
70    /// hidden inside this constructor.
71    pub fn parse(selector_or_source: &str) -> Result<Self> {
72        if selector_or_source.trim().eq_ignore_ascii_case("adaptive") {
73            return Ok(Self::default());
74        }
75        flux_lang::parse::parse(selector_or_source)
76            .map(Self::Flux)
77            .map_err(|error| Error::Other(format!("invalid explicit agent loop: {error}")))
78    }
79}
80
81/// flux-flow's turn engine: a provider, the tool executor (safety envelope), the unified event store
82/// (conversation + run trace + turn telemetry), and flux-flow's own value/symbol/suspension store.
83pub struct FlowEngine {
84    /// Shared with the loop host so model stages and `maybe_compact` use one provider instance.
85    pub provider: Arc<dyn Provider>,
86    /// Shared with the loop host so all stage and action dispatches use one safety envelope.
87    pub executor: Arc<Executor>,
88    pub events: Arc<EventStore>,
89    /// Shared (`Arc`) with the loop host: inner runs bind symbols / trace into the same session store.
90    pub flow: Arc<FlowStore>,
91    /// The agent loop itself, written in Flux-Lang. The bootstrap runs this each turn.
92    pub agent_loop: DraftAst,
93    /// The installed authored-loop host; `set_turn` points it at the current session + sink.
94    pub loop_host: Arc<crate::loop_host::EngineLoopHost>,
95    /// Dynamic composite ops loaded from global/project stores or registered by this agent.
96    pub composites: Arc<DynamicComposites>,
97    pub model: String,
98    pub system_prompt: String,
99    pub max_tokens: u32,
100    pub max_iterations: usize,
101    /// Adaptive-thinking request policy for every model call made by this agent.
102    pub thinking: bool,
103    /// Provider-mapped reasoning effort for every model call made by this agent.
104    pub effort: Option<Effort>,
105    /// Skills explicitly enabled for this agent. Their bodies are injected into each turn's system
106    /// prompt; discovery metadata/triggers never activate a skill implicitly.
107    pub skills: Vec<flux_skill::Skill>,
108    /// When the persisted session exceeds this many (serialized) chars, older turns are summarized
109    /// into one synthetic message before the next request. `0` disables compaction.
110    pub compact_threshold_chars: usize,
111    /// Evidence-gated tool groups. Each turn the workspace is probed for signals (`detect_signals`)
112    /// and only ops whose group is surfaced are advertised in the op catalog. **Empty disables
113    /// gating** (every op advertised, as before surfacing existed).
114    pub groups: Vec<flux_evidence::ToolGroup>,
115    /// Workspace root, re-probed each turn for the surfacing signals above.
116    pub cwd: std::path::PathBuf,
117    /// Session-ambient signals injected by the host surface (D-115): facts the per-turn workspace
118    /// walk can't see — e.g. the CLI's "the endpoints store is non-empty", computed once from its
119    /// startup-loaded registry. Appended to every turn's detected signals for group surfacing;
120    /// sticky-monotonic surfacing makes session-static values sufficient. Empty by default.
121    ambient_signals: Vec<String>,
122    /// Monotonic union of every group that has surfaced on this ENGINE. `resolve_active_groups` is
123    /// stateless (it reflects only the current turn's signals), so a marker file appearing then
124    /// disappearing would rewrite segment A's op catalog and miss the provider prompt cache on the
125    /// whole `tools+A+phase+B` prefix (A-03). Accumulating here makes the advertised catalog grow
126    /// monotonically — once a group surfaces it stays — so the cached prefix only ever stabilizes.
127    /// Scope note: an engine shared across sessions (the a2a server) accumulates across them; that
128    /// widens advertisement only (never grants), and the signals derive from the same host/cwd anyway.
129    /// The approval/policy envelope still gates every op. Unused when `groups` is empty (gating off ⇒
130    /// all ops advertised, already stable).
131    /// Monotonic surfaced groups scoped by session. A shared server/A2A engine must preserve cache
132    /// stability within one conversation without leaking an integration catalog into another.
133    sticky_groups:
134        std::sync::Mutex<std::collections::HashMap<String, std::collections::HashSet<String>>>,
135    /// How many in-memory evidence observations have been flushed to the event store so far — the
136    /// per-turn watermark [`flush_observations`](Self::flush_observations) advances (C-14). The
137    /// executor's log is append-only and shared across this engine's turns, so a plain high-water
138    /// mark attributes each tail to the turn that just ended.
139    evidence_flushed: std::sync::atomic::AtomicUsize,
140    /// One active public turn per engine. Nested authored operations stay inside the already-held
141    /// lifecycle and call the runtime directly, so they never recursively acquire this gate.
142    turn_gate: tokio::sync::Mutex<()>,
143}
144
145enum TurnProgram<'a> {
146    Adaptive,
147    Authored(&'a DraftAst),
148    Resume {
149        flow_name: Option<String>,
150        body: Vec<Node>,
151        node: NodeId,
152    },
153}
154
155#[derive(Clone, Copy, PartialEq, Eq)]
156enum TurnProgramKind {
157    Adaptive,
158    Authored,
159    Resume,
160}
161
162impl TurnProgram<'_> {
163    fn kind(&self) -> TurnProgramKind {
164        match self {
165            Self::Adaptive => TurnProgramKind::Adaptive,
166            Self::Authored(_) => TurnProgramKind::Authored,
167            Self::Resume { .. } => TurnProgramKind::Resume,
168        }
169    }
170
171    fn label<'a>(&'a self, user_input: Option<&'a str>) -> &'a str {
172        match self {
173            Self::Authored(flow) => flow.name.as_deref().unwrap_or("(flow start)"),
174            Self::Adaptive | Self::Resume { .. } => user_input.unwrap_or_default(),
175        }
176    }
177}
178
179struct SuspensionWrite {
180    flow_name: Option<String>,
181    body: Vec<Node>,
182    node: NodeId,
183    source: String,
184}
185
186struct TurnTerminal {
187    outcome: &'static str,
188    steps: u32,
189    answer: String,
190    cancelled: bool,
191    consume_checkpoint: bool,
192    suspension: Option<SuspensionWrite>,
193}
194
195struct TurnLifecycle {
196    accounting: TurnAccounting,
197    channel: Arc<std::sync::Mutex<dyn AgentSink>>,
198    receiver: tokio::sync::mpsc::UnboundedReceiver<crate::loop_host::SinkEvent>,
199    runtime: RuntimeTurnContext,
200    spawn_supervisor: Arc<SpawnTaskSupervisor>,
201}
202
203struct TurnAccounting {
204    turn_id: i64,
205    iteration_base: usize,
206    subagent_base: usize,
207}
208
209impl FlowEngine {
210    /// Assemble an engine: wrap the store/provider in `Arc`, install the adaptive
211    /// [`EngineLoopHost`], and load the built-in `agent-loop.flux`. This is the one place the
212    /// executor⇄host cycle is tied — a plain struct literal cannot express it. Custom loops are
213    /// selected explicitly through [`Self::assemble_with_loop`].
214    #[allow(clippy::too_many_arguments)]
215    pub fn assemble(
216        provider: Arc<dyn Provider>,
217        executor: Executor,
218        events: Arc<EventStore>,
219        flow: FlowStore,
220        model: String,
221        system_prompt: String,
222        max_tokens: u32,
223        max_iterations: usize,
224        skills: Vec<flux_skill::Skill>,
225        compact_threshold_chars: usize,
226        groups: Vec<flux_evidence::ToolGroup>,
227        cwd: std::path::PathBuf,
228    ) -> Result<Self> {
229        Self::assemble_with_loop(
230            provider,
231            executor,
232            events,
233            flow,
234            model,
235            system_prompt,
236            max_tokens,
237            max_iterations,
238            skills,
239            compact_threshold_chars,
240            groups,
241            cwd,
242            AgentLoopSpec::default(),
243        )
244    }
245
246    /// Assemble an engine with an explicit authored outer loop.
247    #[allow(clippy::too_many_arguments)]
248    pub fn assemble_with_loop(
249        provider: Arc<dyn Provider>,
250        executor: Executor,
251        events: Arc<EventStore>,
252        flow: FlowStore,
253        model: String,
254        system_prompt: String,
255        max_tokens: u32,
256        max_iterations: usize,
257        skills: Vec<flux_skill::Skill>,
258        compact_threshold_chars: usize,
259        groups: Vec<flux_evidence::ToolGroup>,
260        cwd: std::path::PathBuf,
261        agent_loop: AgentLoopSpec,
262    ) -> Result<Self> {
263        let flow = Arc::new(flow);
264        let composites = Arc::new(DynamicComposites::load(executor.context().system.as_ref())?);
265        let opts = StageOptions {
266            max_tokens,
267            ..Default::default()
268        };
269        // A throwaway initial session/sink; `set_turn` points the host at the real ones each turn.
270        let init_sink: Arc<std::sync::Mutex<dyn AgentSink>> =
271            Arc::new(std::sync::Mutex::new(NullSink));
272        let (executor, loop_host) = crate::loop_host::EngineLoopHost::install(
273            executor,
274            provider.clone(),
275            model.clone(),
276            Some(system_prompt.clone()),
277            flow.clone(),
278            composites.clone(),
279            String::new(),
280            init_sink,
281            opts,
282        );
283        loop_host.set_groups(groups.clone());
284        // The loop machinery is engine control flow, not a user action. Proposed effects are still
285        // captured, approved as a batch, and dispatched individually through the executor.
286        executor.allow(&[
287            "detect_intent",
288            "explore",
289            "approve_batch",
290            "execute_batch",
291            "present_results",
292            "ai_segment",
293            "observe",
294            "evidence",
295            "metrics",
296        ]);
297        composites.validate_base(executor.registry())?;
298        let agent_loop = load_agent_loop_with_iterations(agent_loop, max_iterations)?;
299        validate_agent_loop(
300            &agent_loop,
301            executor.registry(),
302            composites.active_for_session(""),
303        )?;
304        Ok(FlowEngine {
305            provider,
306            executor,
307            events,
308            flow,
309            agent_loop,
310            loop_host,
311            composites,
312            model,
313            system_prompt,
314            max_tokens,
315            max_iterations,
316            thinking: false,
317            effort: None,
318            skills,
319            compact_threshold_chars,
320            groups,
321            cwd,
322            ambient_signals: Vec::new(),
323            sticky_groups: std::sync::Mutex::new(std::collections::HashMap::new()),
324            evidence_flushed: std::sync::atomic::AtomicUsize::new(0),
325            turn_gate: tokio::sync::Mutex::new(()),
326        })
327    }
328
329    /// Inject session-ambient group-surfacing signals (D-115): host-known facts the per-turn
330    /// workspace walk can't see (e.g. the CLI injects `endpoint` when its startup-loaded
331    /// endpoints store is non-empty). They join every turn's probed signals; surfacing is
332    /// sticky-monotonic, so values computed once at startup are enough.
333    pub fn with_ambient_signals(mut self, signals: Vec<String>) -> Self {
334        self.ambient_signals = signals;
335        self
336    }
337
338    /// Apply one reasoning policy to the full agent call graph: intent, exploration, repair,
339    /// presentation, authored model stages, and compaction calls.
340    pub fn with_reasoning(mut self, thinking: bool, effort: Option<Effort>) -> Self {
341        self.thinking = thinking;
342        self.effort = effort;
343        self.loop_host.set_reasoning(thinking, effort);
344        self
345    }
346
347    /// Atomically switch the live model and the session's durable model attribution. Persistence
348    /// happens first; if it fails, the in-memory engine remains unchanged.
349    pub fn switch_model_for_session(
350        &mut self,
351        session_id: &str,
352        provider: Arc<dyn Provider>,
353        model: String,
354    ) -> Result<()> {
355        self.events.set_model(session_id, &model)?;
356        self.loop_host.set_model(provider.clone(), model.clone());
357        self.provider = provider;
358        self.model = model;
359        Ok(())
360    }
361
362    fn begin_turn_lifecycle(
363        &self,
364        session_id: &str,
365        label: &str,
366        user_message: Option<&str>,
367        sink: &mut dyn AgentSink,
368        cancel: &CancellationToken,
369        identity: &TurnIdentity,
370    ) -> Result<TurnLifecycle> {
371        let skill_input = user_message.unwrap_or_default();
372        // The cache boundary precedes every execution flavor, including a persisted continuation.
373        self.executor.begin_cache_turn();
374        if let Some(message) = user_message {
375            self.events
376                .record_message(session_id, &Message::user_text(message))?;
377        }
378        let turn_id = self
379            .events
380            .begin_turn(
381                session_id,
382                label,
383                &flux_core::canonical_model_spec(Some(self.provider.name()), &self.model),
384            )
385            .unwrap_or(-1);
386        self.executor.observe(flux_evidence::Observation::new(
387            "turn.identity",
388            flux_evidence::Phase::Turn,
389            serde_json::json!({
390                "caller": identity.caller().principal.id.as_str(),
391                "source": identity.caller().source.as_str(),
392                "trust": identity.trust(),
393            }),
394        ));
395        let iteration_base = self.executor.evidence().by_kind("turn.iteration").count();
396        let subagent_base = self.executor.evidence().by_kind("subagent.usage").count();
397        let base_system = self.base_system_with_skills(skill_input, sink);
398        let advertised = self.surfaced_for_turn(session_id, skill_input, sink);
399        let (sender, receiver) =
400            tokio::sync::mpsc::unbounded_channel::<crate::loop_host::SinkEvent>();
401        let channel: Arc<std::sync::Mutex<dyn AgentSink>> = Arc::new(std::sync::Mutex::new(
402            crate::loop_host::ChannelSink::new(sender),
403        ));
404        let activity = self.loop_host.set_turn(
405            session_id.to_string(),
406            Some(base_system),
407            channel.clone(),
408            Some(advertised),
409            Some((self.events.clone(), turn_id)),
410        );
411        let spawn_supervisor = Arc::new(SpawnTaskSupervisor::with_cancel(cancel.child_token()));
412        let runtime = RuntimeTurnContext::new()
413            .with_cancel(cancel.clone())
414            .with_session(session_id)
415            .with_spawn_activity_sink(activity)
416            .with_spawn_supervisor(spawn_supervisor.clone())
417            .with_identity(identity.clone());
418        if crate::cassette::enabled() {
419            self.flow
420                .set_cassette(Some(Arc::new(crate::cassette::CassetteScope::Record(
421                    crate::cassette::RecordScope::new(self.events.clone(), session_id),
422                ))));
423        } else {
424            self.flow.set_cassette(None);
425        }
426        Ok(TurnLifecycle {
427            accounting: TurnAccounting {
428                turn_id,
429                iteration_base,
430                subagent_base,
431            },
432            channel,
433            receiver,
434            runtime,
435            spawn_supervisor,
436        })
437    }
438
439    async fn race_turn<F>(
440        lifecycle: TurnLifecycle,
441        sink: &mut dyn AgentSink,
442        cancel: &CancellationToken,
443        future: F,
444    ) -> (Option<Result<FlowOutcome>>, TurnAccounting)
445    where
446        F: std::future::Future<Output = Result<FlowOutcome>>,
447    {
448        let TurnLifecycle {
449            accounting,
450            channel: _,
451            mut receiver,
452            runtime,
453            spawn_supervisor,
454        } = lifecycle;
455        let reveal = show_loop();
456        let outcome = scope_runtime_turn(runtime, async {
457            tokio::pin!(future);
458            loop {
459                tokio::select! {
460                    biased;
461                    _ = cancel.cancelled() => break None,
462                    event = receiver.recv() => {
463                        if let Some(event) = event {
464                            drain_event(event, sink, reveal);
465                        }
466                    }
467                    result = &mut future => break Some(result),
468                }
469            }
470        })
471        .await;
472        // `task` runs its spawner in a turn-owned Tokio task. Dropping an operation branch detaches
473        // its JoinHandle, so every terminal path retains the turn until children have observed the
474        // supervisor token and finalized; abort is only the bounded backstop.
475        spawn_supervisor.shutdown(SPAWN_CLEANUP_GRACE).await;
476        while let Ok(event) = receiver.try_recv() {
477            drain_event(event, sink, reveal);
478        }
479        (outcome, accounting)
480    }
481
482    fn finish_turn_lifecycle(
483        &self,
484        session_id: &str,
485        sink: &mut dyn AgentSink,
486        accounting: &TurnAccounting,
487        mut terminal: TurnTerminal,
488    ) -> Result<()> {
489        let persistence = if let Some(suspension) = terminal.suspension.take() {
490            self.flow.save_suspension(
491                session_id,
492                suspension.flow_name.as_deref(),
493                &suspension.body,
494                suspension.node,
495                &suspension.source,
496            )
497        } else if terminal.consume_checkpoint {
498            self.flow.clear_suspension(session_id)
499        } else {
500            Ok(())
501        };
502        if let Err(error) = persistence {
503            terminal.outcome = "error";
504            terminal.cancelled = false;
505            terminal.answer = format!(
506                "The turn finished, but its continuation checkpoint could not be persisted — {error}"
507            );
508        }
509        sink.text_delta(&terminal.answer);
510        let usage =
511            self.record_resume_usage(session_id, accounting.turn_id, accounting.subagent_base);
512        let _ = self.events.end_turn(
513            session_id,
514            accounting.turn_id,
515            terminal.outcome,
516            terminal.steps,
517            &terminal.answer,
518            usage.clone(),
519        );
520        self.finish_turn(
521            session_id,
522            accounting.turn_id,
523            sink,
524            &terminal.answer,
525            terminal.cancelled,
526            usage,
527        )
528    }
529
530    /// Run one user turn to completion, uninterruptible.
531    pub async fn run_turn(
532        &self,
533        session_id: &str,
534        user_input: &str,
535        sink: &mut dyn AgentSink,
536    ) -> Result<()> {
537        self.run_turn_cancellable(session_id, user_input, sink, &CancellationToken::new())
538            .await
539    }
540
541    /// Run one user turn, abortable via `cancel`: the token races the whole agent-loop future in the
542    /// `select!` below, so a Ctrl-C mid-op returns at once and drops the in-flight op (aborting its IO)
543    /// rather than waiting for it to finish. Every termination path persists exactly one assistant
544    /// message, so the session stays a valid `user → assistant` alternation.
545    pub async fn run_turn_cancellable(
546        &self,
547        session_id: &str,
548        user_input: &str,
549        sink: &mut dyn AgentSink,
550        cancel: &CancellationToken,
551    ) -> Result<()> {
552        let _turn = self.turn_gate.lock().await;
553        let identity = self.executor.identity().snapshot();
554        self.run_turn_locked(session_id, user_input, sink, cancel, identity)
555            .await
556    }
557
558    /// Run one user turn under an explicit immutable caller identity. The identity is installed
559    /// only after this engine acquires its single-active-turn gate and remains lexical to the turn.
560    pub async fn run_turn_as(
561        &self,
562        session_id: &str,
563        user_input: &str,
564        sink: &mut dyn AgentSink,
565        identity: TurnIdentity,
566    ) -> Result<()> {
567        self.run_turn_cancellable_as(
568            session_id,
569            user_input,
570            sink,
571            &CancellationToken::new(),
572            identity,
573        )
574        .await
575    }
576
577    /// Cancellable counterpart to [`Self::run_turn_as`].
578    pub async fn run_turn_cancellable_as(
579        &self,
580        session_id: &str,
581        user_input: &str,
582        sink: &mut dyn AgentSink,
583        cancel: &CancellationToken,
584        identity: TurnIdentity,
585    ) -> Result<()> {
586        let _turn = self.turn_gate.lock().await;
587        self.run_turn_locked(session_id, user_input, sink, cancel, identity)
588            .await
589    }
590
591    /// Run one user turn after the engine-level single-active-turn gate has been acquired.
592    async fn run_turn_locked(
593        &self,
594        session_id: &str,
595        user_input: &str,
596        sink: &mut dyn AgentSink,
597        cancel: &CancellationToken,
598        identity: TurnIdentity,
599    ) -> Result<()> {
600        let program = match self.flow.load_suspension(session_id)? {
601            Some((flow_name, body, node, _source)) => TurnProgram::Resume {
602                flow_name,
603                body,
604                node,
605            },
606            None => TurnProgram::Adaptive,
607        };
608        self.run_turn_lifecycle(
609            session_id,
610            Some(user_input),
611            program,
612            sink,
613            cancel,
614            identity,
615        )
616        .await
617    }
618
619    async fn run_turn_lifecycle(
620        &self,
621        session_id: &str,
622        user_input: Option<&str>,
623        program: TurnProgram<'_>,
624        sink: &mut dyn AgentSink,
625        cancel: &CancellationToken,
626        identity: TurnIdentity,
627    ) -> Result<()> {
628        let lifecycle = self.begin_turn_lifecycle(
629            session_id,
630            program.label(user_input),
631            user_input,
632            sink,
633            cancel,
634            &identity,
635        )?;
636        let future = self.execute_turn_program(
637            session_id,
638            user_input.unwrap_or_default(),
639            &program,
640            lifecycle.channel.clone(),
641            cancel,
642        );
643        let (outcome, accounting) = Self::race_turn(lifecycle, sink, cancel, future).await;
644        let terminal = self.turn_terminal(&program, outcome, accounting.iteration_base);
645        self.finish_turn_lifecycle(session_id, sink, &accounting, terminal)
646    }
647
648    async fn execute_turn_program(
649        &self,
650        session_id: &str,
651        user_input: &str,
652        program: &TurnProgram<'_>,
653        channel: Arc<std::sync::Mutex<dyn AgentSink>>,
654        cancel: &CancellationToken,
655    ) -> Result<FlowOutcome> {
656        let mut output = crate::loop_host::SharedSink::new(channel);
657        match program {
658            TurnProgram::Adaptive => {
659                let (compaction, usage) = self
660                    .compaction_attempt(session_id, &mut output, cancel)
661                    .await;
662                if let Some(usage) = usage {
663                    self.loop_host
664                        .record_external_call(self.provider.name(), &self.model, usage);
665                }
666                if let Err(error) = compaction {
667                    return Err(Error::Other(format!("compaction: {}", model_error(&error))));
668                }
669                execute_flow_traced(
670                    &self.flow,
671                    &self.executor,
672                    session_id,
673                    &self.agent_loop,
674                    &mut output,
675                    trace_loop(),
676                )
677                .await
678                .map_err(|error| Error::Other(error.to_string()))
679            }
680            TurnProgram::Authored(flow) => {
681                self.composites
682                    .ensure_session_loaded(&self.flow, session_id)?;
683                let composites = self.composites.active_for_session(session_id);
684                execute_flow_with_composites(
685                    &self.flow,
686                    &self.executor,
687                    session_id,
688                    flow,
689                    &composites,
690                    &mut output,
691                )
692                .await
693                .map_err(|error| Error::Other(error.to_string()))
694            }
695            TurnProgram::Resume {
696                flow_name,
697                body,
698                node,
699            } => {
700                self.composites
701                    .ensure_session_loaded(&self.flow, session_id)?;
702                let composites = self.composites.active_for_session(session_id);
703                resume_flow_with_composites(
704                    &self.flow,
705                    &self.executor,
706                    session_id,
707                    flow_name.as_deref(),
708                    body,
709                    *node,
710                    flux_lang::ast::Value::String(user_input.to_string()),
711                    &composites,
712                    &mut output,
713                )
714                .await
715                .map_err(|error| Error::Other(error.to_string()))
716            }
717        }
718    }
719
720    fn turn_terminal(
721        &self,
722        program: &TurnProgram<'_>,
723        outcome: Option<Result<FlowOutcome>>,
724        iteration_base: usize,
725    ) -> TurnTerminal {
726        let kind = program.kind();
727        let Some(outcome) = outcome else {
728            return TurnTerminal {
729                outcome: "cancelled",
730                steps: 0,
731                answer: "(turn cancelled)".into(),
732                cancelled: true,
733                consume_checkpoint: false,
734                suspension: None,
735            };
736        };
737        let outcome = match outcome {
738            Ok(outcome) => outcome,
739            Err(error) => {
740                let error = error.to_string();
741                let answer = match kind {
742                    TurnProgramKind::Adaptive if error.starts_with("compaction: ") => format!(
743                        "I couldn't compact the conversation before continuing — {}",
744                        error.trim_start_matches("compaction: ")
745                    ),
746                    TurnProgramKind::Adaptive => {
747                        format!("I couldn't complete the turn — {error}")
748                    }
749                    TurnProgramKind::Authored => {
750                        format!("The flow failed to start — {error}")
751                    }
752                    TurnProgramKind::Resume => {
753                        format!("The resumed flow failed — {error}")
754                    }
755                };
756                return TurnTerminal {
757                    outcome: "error",
758                    steps: 0,
759                    answer,
760                    cancelled: false,
761                    consume_checkpoint: false,
762                    suspension: None,
763                };
764            }
765        };
766
767        if let Some(suspension) = &outcome.suspension {
768            let (flow_name, body) = match program {
769                TurnProgram::Adaptive => {
770                    (self.agent_loop.name.clone(), self.agent_loop.body.clone())
771                }
772                TurnProgram::Authored(flow) => (flow.name.clone(), flow.body.clone()),
773                TurnProgram::Resume {
774                    flow_name, body, ..
775                } => (flow_name.clone(), body.clone()),
776            };
777            let steps = if kind == TurnProgramKind::Adaptive {
778                self.executor
779                    .evidence()
780                    .by_kind("turn.iteration")
781                    .count()
782                    .saturating_sub(iteration_base) as u32
783            } else {
784                outcome.steps as u32
785            };
786            return TurnTerminal {
787                outcome: "suspended",
788                steps,
789                answer: suspension_prompt(&outcome),
790                cancelled: false,
791                consume_checkpoint: false,
792                suspension: Some(SuspensionWrite {
793                    flow_name,
794                    body,
795                    node: suspension.node,
796                    source: suspension.source.clone(),
797                }),
798            };
799        }
800
801        match kind {
802            TurnProgramKind::Adaptive => {
803                let answer = outcome.result.trim().to_string();
804                let (answer, status) = if answer.is_empty() {
805                    (
806                        format!(
807                            "Reached the maximum of {} adaptive iterations for this turn; stopping.",
808                            self.max_iterations
809                        ),
810                        "max_iter",
811                    )
812                } else {
813                    (answer, "ok")
814                };
815                TurnTerminal {
816                    outcome: status,
817                    steps: self
818                        .executor
819                        .evidence()
820                        .by_kind("turn.iteration")
821                        .count()
822                        .saturating_sub(iteration_base) as u32,
823                    answer,
824                    cancelled: false,
825                    consume_checkpoint: false,
826                    suspension: None,
827                }
828            }
829            TurnProgramKind::Authored => TurnTerminal {
830                outcome: "completed",
831                steps: outcome.steps as u32,
832                answer: if outcome.result.trim().is_empty() {
833                    format!("Flow completed ({} step(s)).", outcome.steps)
834                } else {
835                    outcome.result.trim().to_string()
836                },
837                cancelled: false,
838                consume_checkpoint: false,
839                suspension: None,
840            },
841            TurnProgramKind::Resume => TurnTerminal {
842                outcome: "resumed",
843                steps: outcome.steps as u32,
844                answer: if outcome.result.trim().is_empty() {
845                    format!("Resumed and completed ({} step(s)).", outcome.steps)
846                } else {
847                    outcome.result.trim().to_string()
848                },
849                cancelled: false,
850                consume_checkpoint: true,
851                suspension: None,
852            },
853        }
854    }
855
856    fn surfaced_for_turn(
857        &self,
858        session_id: &str,
859        user_input: &str,
860        sink: &mut dyn AgentSink,
861    ) -> std::collections::HashSet<String> {
862        let (advertised, surfaced) = surfaced_op_names(
863            self.executor.registry(),
864            &self.groups,
865            &self.cwd,
866            &self.sticky_groups,
867            session_id,
868            &self.ambient_signals,
869            user_input,
870        );
871        if let Some(surfaced) = surfaced.as_ref() {
872            self.record_active_groups(surfaced, sink);
873        }
874        advertised
875    }
876
877    /// Record (audit + surface) which evidence-gated groups are active this turn — and which
878    /// workspace signals justified them (C-14 provenance) — so the user can see what the workspace
879    /// surfaced and why. Mirrors the skill-activation observation pattern.
880    fn record_active_groups(&self, surfaced: &SurfacedGroups, sink: &mut dyn AgentSink) {
881        let mut names: Vec<&str> = surfaced.active.iter().map(String::as_str).collect();
882        names.sort_unstable();
883        let mut signals: Vec<&str> = surfaced.signals.iter().map(String::as_str).collect();
884        signals.sort_unstable();
885        let obs = flux_evidence::Observation::new(
886            "groups.active",
887            flux_evidence::Phase::Turn,
888            serde_json::json!({ "groups": names, "signals": signals }),
889        );
890        self.executor.observe(obs.clone());
891        sink.observation(&obs);
892    }
893
894    /// The agent identity + project context + explicitly enabled skills — the common base for every
895    /// model-backed stage.
896    fn base_system_with_skills(&self, _user_input: &str, sink: &mut dyn AgentSink) -> String {
897        let mut base_system = self.system_prompt.clone();
898        for skill in &self.skills {
899            base_system.push_str(&format!(
900                "\n\n<skill name=\"{}\">\n{}\n</skill>",
901                skill.name, skill.body
902            ));
903            let obs = flux_evidence::Observation::new(
904                "skill.activated",
905                flux_evidence::Phase::Turn,
906                serde_json::json!({ "skill": skill.name }),
907            );
908            self.executor.observe(obs.clone());
909            sink.observation(&obs);
910        }
911        base_system
912    }
913
914    /// Persist the single assistant message for this turn (keeping the `user → assistant` session
915    /// shape) and end the turn. `cancelled` records the audit observation. `usage` is this turn's
916    /// token tally (all model-stage calls summed), surfaced to the sink for the turn-end annotation.
917    /// Both termination paths (completion + cancel) come through here, which is what makes the
918    /// evidence flush below turn-complete.
919    fn finish_turn(
920        &self,
921        session_id: &str,
922        turn_id: i64,
923        sink: &mut dyn AgentSink,
924        answer: &str,
925        cancelled: bool,
926        usage: Option<Usage>,
927    ) -> Result<()> {
928        if cancelled {
929            let obs = flux_evidence::Observation::new(
930                "turn.cancelled",
931                flux_evidence::Phase::Turn,
932                serde_json::json!({}),
933            );
934            self.executor.observe(obs.clone());
935            sink.observation(&obs);
936        }
937        // Durable evidence (C-14): flush everything the in-memory log gained since the last flush
938        // to the event store, batched per turn (a crash mid-turn loses at most that turn's batch —
939        // turn-granular audit is the goal, not crash forensics). The first flush starts at
940        // watermark 0, so startup observations land too.
941        self.flush_observations(session_id, turn_id);
942        self.composites.clear_turn(session_id);
943        self.flow.set_cassette(None);
944        self.events.record_message(
945            session_id,
946            &Message::assistant(vec![ContentBlock::Text {
947                text: answer.to_string(),
948            }]),
949        )?;
950        sink.turn_end(usage);
951        Ok(())
952    }
953
954    /// Persist the in-memory evidence log's tail (`[watermark..]`) as `EventKind::Observation`
955    /// events on the session stream, then advance the watermark. Non-fatal — audit writes never
956    /// fail a turn. The in-memory log stays the live `/evidence` read model; this is its durable
957    /// mirror (`projection::observations`).
958    ///
959    /// C-22: the tail is scrubbed through the SAME [`Redactor`](flux_secret::Redactor) the executor
960    /// uses on tool results (seeded from `resolve_secrets` per C-13) BEFORE it reaches the store —
961    /// the `tool_call` observation carries raw per-token permission subjects (a `Bearer`/secret in a
962    /// `bash` arg), built and pushed before dispatch redacts the model-facing result. Redacting once
963    /// here, at the single flush seam, covers every observation kind without touching the live
964    /// in-memory log (which is process-local and already gated behind `/evidence`).
965    fn flush_observations(&self, session_id: &str, turn_id: i64) {
966        let redactor = &self.executor.context().redactor;
967        let log = self.executor.evidence();
968        let all = log.all();
969        let start = self
970            .evidence_flushed
971            .load(std::sync::atomic::Ordering::SeqCst)
972            .min(all.len());
973        // C-24: advance the watermark only past observations whose write returned `Ok` — a transient
974        // `record_observation` failure (WAL `BUSY`, disk-full) leaves the unwritten tail behind the
975        // watermark to be retried next flush, instead of being lost forever behind an
976        // unconditionally-advanced mark.
977        let written = flush_tail(&all[start..], |obs| {
978            let redacted = redact_observation(redactor, obs);
979            self.events
980                .record_observation(session_id, turn_id, &redacted)
981        });
982        self.evidence_flushed
983            .store(start + written, std::sync::atomic::Ordering::SeqCst);
984    }
985
986    /// This turn's token tally, as an `Option` for the sink: `None` when nothing was billed (e.g. an
987    /// offline `-m mock` turn) so a surface needn't render a misleading all-zero annotation. Includes
988    /// any sub-agents this turn spawned (`subagent_calls`, from [`Self::subagent_calls_since`]) — a
989    /// `task` call's tokens are real spend the parent turn incurred, so its total must reflect them
990    /// (C-06 sub-agent rollup).
991    fn turn_usage(&self, subagent_calls: &[(String, Usage)]) -> Option<Usage> {
992        let mut usage = self.loop_host.turn_usage();
993        for (_, call) in subagent_calls {
994            usage.sum_independent(call);
995        }
996        (usage.total() > 0).then_some(usage)
997    }
998
999    /// Every `subagent.usage` observation recorded since `base` (this turn's sub-agents, per the
1000    /// snapshot-then-diff scoping [`run_turn_cancellable`] already uses for `turn.iteration`), parsed
1001    /// into `(model, usage)` pairs. A malformed/missing field is skipped rather than panicking — this
1002    /// reads a cross-crate string-keyed contract (`flux-orchestrate`'s `TaskTool`), not a typed one.
1003    fn subagent_calls_since(&self, base: usize) -> Vec<(String, Usage)> {
1004        self.executor
1005            .evidence()
1006            .by_kind("subagent.usage")
1007            .skip(base)
1008            .filter_map(|o| {
1009                let model = o.data.get("model")?.as_str()?.to_string();
1010                let usage: Usage = serde_json::from_value(o.data.get("usage")?.clone()).ok()?;
1011                Some((model, usage))
1012            })
1013            .collect()
1014    }
1015
1016    /// Append one `EventKind::CallUsage` per model-stage call this turn made (`self.loop_host.turn_calls`)
1017    /// PLUS one per sub-agent this turn spawned (`subagent_calls`), each stamped with the model that
1018    /// produced it — the active stage model at the time, or the sub-agent's own resolved model —
1019    /// so a mid-turn `/model` switch AND a `task` delegation both attribute tokens/cost correctly
1020    /// (C-06). Called just before `end_turn` on every termination path (cancelled or completed) so the
1021    /// per-call attribution records and the turn-total `TurnEnded.usage` land together. Non-fatal like
1022    /// every other telemetry write here: a DB hiccup must never fail the turn — `record_call_usage`
1023    /// itself already no-ops on a failed (`-1`) `turn_id`.
1024    fn record_call_usage_events(
1025        &self,
1026        session_id: &str,
1027        turn_id: i64,
1028        subagent_calls: &[(String, Usage)],
1029    ) {
1030        // Zero-usage calls (a `mock`/free provider, or one that genuinely reported nothing) are
1031        // skipped — mirrors `TurnEnded.usage` staying `None` for a token-less turn, so a log doesn't
1032        // fill with placeholder zero entries for every offline/no-cost call.
1033        for (model, usage) in self.loop_host.turn_calls() {
1034            if usage.total() == 0 {
1035                continue;
1036            }
1037            let _ = self
1038                .events
1039                .record_call_usage(session_id, turn_id, &model, usage);
1040        }
1041        for (model, usage) in subagent_calls {
1042            if usage.total() == 0 {
1043                continue;
1044            }
1045            let _ = self
1046                .events
1047                .record_call_usage(session_id, turn_id, model, usage.clone());
1048        }
1049    }
1050
1051    /// Start an authored flow as the session's conversation driver (D-131). Executes the flow
1052    /// **fresh** to its first top-level `await`, persists the suspension so every later `run_turn`
1053    /// routes through the shared suspension-first turn runner, and surfaces the
1054    /// flow's own **authored prompt** (its last emitted view) as the assistant turn — no adaptive stage is
1055    /// invoked for this deterministic skeleton. A flow that completes without any `await` surfaces
1056    /// its result as a single completed turn.
1057    ///
1058    /// Turn 1 is flow-authored, not user-authored: the flow speaks first, so no user message is
1059    /// recorded (the session log opens with the authored prompt). Runs over the shared
1060    /// `Arc<Executor>`, so the authorization → approval → guarded-IO envelope applies exactly as on
1061    /// the adaptive path — a `RiskApprover` gates a flow-driven op identically to a captured one.
1062    pub async fn start_flow_turn(
1063        &self,
1064        session_id: &str,
1065        flow: &DraftAst,
1066        sink: &mut dyn AgentSink,
1067    ) -> Result<()> {
1068        self.start_flow_turn_cancellable(session_id, flow, sink, &CancellationToken::new())
1069            .await
1070    }
1071
1072    /// Start an authored flow under the engine's single-active-turn gate and cancellation context.
1073    pub async fn start_flow_turn_cancellable(
1074        &self,
1075        session_id: &str,
1076        flow: &DraftAst,
1077        sink: &mut dyn AgentSink,
1078        cancel: &CancellationToken,
1079    ) -> Result<()> {
1080        let _turn = self.turn_gate.lock().await;
1081        let identity = self.executor.identity().snapshot();
1082        self.start_flow_turn_locked(session_id, flow, sink, cancel, identity)
1083            .await
1084    }
1085
1086    /// Start an authored flow under an explicit immutable caller identity.
1087    pub async fn start_flow_turn_as(
1088        &self,
1089        session_id: &str,
1090        flow: &DraftAst,
1091        sink: &mut dyn AgentSink,
1092        identity: TurnIdentity,
1093    ) -> Result<()> {
1094        self.start_flow_turn_cancellable_as(
1095            session_id,
1096            flow,
1097            sink,
1098            &CancellationToken::new(),
1099            identity,
1100        )
1101        .await
1102    }
1103
1104    /// Cancellable counterpart to [`Self::start_flow_turn_as`].
1105    pub async fn start_flow_turn_cancellable_as(
1106        &self,
1107        session_id: &str,
1108        flow: &DraftAst,
1109        sink: &mut dyn AgentSink,
1110        cancel: &CancellationToken,
1111        identity: TurnIdentity,
1112    ) -> Result<()> {
1113        let _turn = self.turn_gate.lock().await;
1114        self.start_flow_turn_locked(session_id, flow, sink, cancel, identity)
1115            .await
1116    }
1117
1118    async fn start_flow_turn_locked(
1119        &self,
1120        session_id: &str,
1121        flow: &DraftAst,
1122        sink: &mut dyn AgentSink,
1123        cancel: &CancellationToken,
1124        identity: TurnIdentity,
1125    ) -> Result<()> {
1126        self.run_turn_lifecycle(
1127            session_id,
1128            None,
1129            TurnProgram::Authored(flow),
1130            sink,
1131            cancel,
1132            identity,
1133        )
1134        .await
1135    }
1136
1137    /// Record a turn's spend and return the turn total (C-26). The shared lifecycle arms (and
1138    /// resets) the loop host before every adaptive, authored, or resumed turn, so this folds BOTH
1139    /// the loop host's model-stage calls and the `task` sub-agent
1140    /// spend recorded since `subagent_base`, emitting one `CallUsage` per call and returning the
1141    /// turn aggregate. `None` when nothing billed, mirroring [`Self::turn_usage`].
1142    fn record_resume_usage(
1143        &self,
1144        session_id: &str,
1145        turn_id: i64,
1146        subagent_base: usize,
1147    ) -> Option<Usage> {
1148        let subagent_calls = self.subagent_calls_since(subagent_base);
1149        self.record_call_usage_events(session_id, turn_id, &subagent_calls);
1150        self.turn_usage(&subagent_calls)
1151    }
1152
1153    /// If the session has grown past `compact_threshold_chars`, summarize everything but the most
1154    /// recent messages into one synthetic message and rewrite the session log. A no-op when
1155    /// compaction is disabled or the session is small. Snaps the boundary back so a `tool_result` is
1156    /// never orphaned from the `tool_use` that compaction would summarize away.
1157    pub async fn maybe_compact(
1158        &self,
1159        session_id: &str,
1160        sink: &mut dyn AgentSink,
1161        cancel: &CancellationToken,
1162    ) -> Result<()> {
1163        self.compaction_attempt(session_id, sink, cancel).await.0
1164    }
1165
1166    /// The compaction result plus usage for its optional provider call. Usage is outside the
1167    /// `Result`: a stream can report tokens before failing, and those
1168    /// tokens remain billable. `None` means no summary request was needed; `Some(default())` means a
1169    /// request was attempted but the provider reported no usage.
1170    async fn compaction_attempt(
1171        &self,
1172        session_id: &str,
1173        sink: &mut dyn AgentSink,
1174        cancel: &CancellationToken,
1175    ) -> (Result<()>, Option<Usage>) {
1176        if self.compact_threshold_chars == 0 {
1177            return (Ok(()), None);
1178        }
1179        let messages = match self.events.conversation(session_id) {
1180            Ok(messages) => messages,
1181            Err(error) => return (Err(error), None),
1182        };
1183        if messages.len() < 4 {
1184            return (Ok(()), None);
1185        }
1186        let total: usize = messages
1187            .iter()
1188            .map(|m| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0))
1189            .sum();
1190        if total <= self.compact_threshold_chars {
1191            return (Ok(()), None);
1192        }
1193
1194        let keep = 2.min(messages.len());
1195        let mut split = messages.len() - keep;
1196        while split > 0 && has_tool_result(&messages[split]) {
1197            split -= 1;
1198        }
1199        if split == 0 {
1200            return (Ok(()), None); // can't summarize without splitting a tool_use/tool_result pair
1201        }
1202        let (old, recent) = messages.split_at(split);
1203
1204        let mut transcript = String::new();
1205        for m in old {
1206            let t = m.text();
1207            if !t.trim().is_empty() {
1208                transcript.push_str(t.trim());
1209                transcript.push('\n');
1210            }
1211        }
1212        let prompt = format!(
1213            "Summarize the earlier conversation into a compact set of durable facts, decisions, and \
1214             open threads. Preserve file paths, names, and numbers. Be terse.\n\n{transcript}"
1215        );
1216        let req = Request::new(self.model.clone(), prompt)
1217            .with_max_tokens(1024)
1218            .with_thinking(self.thinking);
1219        let req = match self.effort {
1220            Some(effort) => req.with_effort(effort),
1221            None => req,
1222        };
1223        let mut usage = Usage::default();
1224        let mut stream = match self.provider.stream(req).await {
1225            Ok(stream) => stream,
1226            Err(error) => return (Err(error), Some(usage)),
1227        };
1228        let mut summary = String::new();
1229        loop {
1230            tokio::select! {
1231                biased;
1232                _ = cancel.cancelled() => return (Ok(()), Some(usage)),
1233                chunk = stream.next() => {
1234                    let Some(chunk) = chunk else { break };
1235                    match chunk {
1236                        Ok(Chunk::TextDelta(text)) => summary.push_str(&text),
1237                        // Provider usage chunks are cumulative within one call: last wins.
1238                        Ok(Chunk::Usage(call_usage)) => usage = call_usage,
1239                        Ok(_) => {}
1240                        Err(error) => return (Err(error), Some(usage)),
1241                    }
1242                }
1243            }
1244        }
1245        if summary.trim().is_empty() {
1246            return (Ok(()), Some(usage));
1247        }
1248
1249        let mut new_msgs = vec![Message::user_text(format!(
1250            "[summary of earlier conversation]\n{}",
1251            summary.trim()
1252        ))];
1253        new_msgs.extend(recent.iter().cloned());
1254        let to = new_msgs.len();
1255        if let Err(error) = self.events.record_compaction(session_id, &new_msgs) {
1256            return (Err(error), Some(usage));
1257        }
1258
1259        let obs = flux_evidence::Observation::new(
1260            "context.compacted",
1261            flux_evidence::Phase::Turn,
1262            serde_json::json!({
1263                "from_messages": messages.len(),
1264                "to_messages": to,
1265                "approx_chars_before": total,
1266            }),
1267        );
1268        self.executor.observe(obs.clone());
1269        sink.observation(&obs);
1270        (Ok(()), Some(usage))
1271    }
1272}
1273
1274/// Persist a `tail` of observations via `record`, returning how many were durably written (C-24).
1275/// The caller advances the flush watermark by exactly this count, so a failed write leaves its
1276/// observation (and everything after it) behind the watermark for the next flush to retry.
1277fn flush_tail(
1278    tail: &[flux_evidence::Observation],
1279    mut record: impl FnMut(&flux_evidence::Observation) -> Result<()>,
1280) -> usize {
1281    let mut written = 0;
1282    for obs in tail {
1283        // Stop at the FIRST failed write: everything from here stays behind the watermark and is
1284        // retried next flush. Advancing past a failed write is exactly the lost-observation bug.
1285        if record(obs).is_err() {
1286            break;
1287        }
1288        written += 1;
1289    }
1290    written
1291}
1292
1293/// Return a redacted copy of `obs` — its `data` scrubbed of any registered/credential-shaped
1294/// secret (C-22). Only the JSON's string leaves are rewritten; keys and structure are preserved so
1295/// the persisted observation still folds through `projection::observations` unchanged in shape.
1296pub(crate) fn redact_observation(
1297    redactor: &flux_secret::Redactor,
1298    obs: &flux_evidence::Observation,
1299) -> flux_evidence::Observation {
1300    let mut out = obs.clone();
1301    redact_json_strings(redactor, &mut out.data);
1302    out
1303}
1304
1305/// Recursively rewrite every string leaf of `value` through the redactor (in place).
1306fn redact_json_strings(redactor: &flux_secret::Redactor, value: &mut serde_json::Value) {
1307    match value {
1308        serde_json::Value::String(s) => *s = redactor.redact(s),
1309        serde_json::Value::Array(items) => {
1310            for item in items {
1311                redact_json_strings(redactor, item);
1312            }
1313        }
1314        serde_json::Value::Object(map) => {
1315            for v in map.values_mut() {
1316                redact_json_strings(redactor, v);
1317            }
1318        }
1319        _ => {}
1320    }
1321}
1322
1323/// True if a message carries a tool_result block (a `user` message answering tool calls).
1324fn has_tool_result(msg: &Message) -> bool {
1325    msg.content
1326        .iter()
1327        .any(|b| matches!(b, ContentBlock::ToolResult { .. }))
1328}
1329
1330/// Where the active agent loop came from — what [`agent_loop_source`] resolved.
1331#[derive(Debug, Clone, PartialEq, Eq)]
1332pub enum LoopSource {
1333    /// The compiled-in default (`assets/agent-loop.flux`).
1334    Builtin,
1335}
1336
1337/// The advertised op-name set for a turn, plus the active group set when gating is on (`None` when
1338/// the manifest is empty = gating disabled). The ONE computation both catalog paths share — the
1339/// engine's preview registries and the loop host's model stages — so they can't drift.
1340///
1341/// Gating disabled still excludes the never-surfaced adaptive machinery (`reflect` group). With a
1342/// manifest, `advertised_op_names` already excludes it.
1343pub(crate) fn surfaced_op_names(
1344    reg: &flux_runtime::ToolRegistry,
1345    groups: &[flux_evidence::ToolGroup],
1346    cwd: &std::path::Path,
1347    sticky: &std::sync::Mutex<std::collections::HashMap<String, std::collections::HashSet<String>>>,
1348    session_id: &str,
1349    ambient: &[String],
1350    user_input: &str,
1351) -> (std::collections::HashSet<String>, Option<SurfacedGroups>) {
1352    if groups.is_empty() {
1353        let advertised = reg
1354            .specs()
1355            .iter()
1356            .filter(|s| s.group.as_deref() != Some(flux_runtime::REFLECT_GROUP))
1357            .map(|s| s.name.clone())
1358            .collect();
1359        return (advertised, None);
1360    }
1361    let mut signals = flux_runtime::detect_signals(cwd);
1362    // Session-ambient signals (D-115): host-known facts the workspace walk can't see — e.g. the
1363    // CLI's "the endpoints store is non-empty", computed once from its startup-loaded registry.
1364    // They join the probed signals and gate groups identically.
1365    signals.extend(
1366        ambient
1367            .iter()
1368            .map(|s| flux_evidence::Observation::signal(s)),
1369    );
1370    signals.extend(flux_evidence::turn_intent_observations(groups, user_input));
1371    let active = flux_evidence::resolve_active_groups(groups, &signals);
1372    // Monotonic surfacing (A-03 cache stability): fold this turn's active groups into the session's
1373    // session-local sticky union and advertise from the ACCUMULATED set. `resolve_active_groups` is
1374    // stateless, so a
1375    // marker file appearing then disappearing would otherwise rewrite segment A's op catalog and miss
1376    // the cached `tools+A+phase+B` prefix; accumulating means the catalog only ever grows and the
1377    // prefix restabilizes. Advertising is not granting — the approval/policy envelope still gates ops.
1378    let accumulated = {
1379        let mut sessions = sticky.lock().unwrap_or_else(|e| e.into_inner());
1380        let session = sessions.entry(session_id.to_string()).or_default();
1381        session.extend(active);
1382        session.clone()
1383    };
1384    let advertised = flux_runtime::advertised_op_names(&reg.specs(), groups, &accumulated);
1385    // Keep the signal NAMES alongside the resolved groups — the `groups.active` observation
1386    // records both, so the audit trail says not just which groups surfaced but WHY (C-14).
1387    let signal_names = signals
1388        .iter()
1389        .filter_map(|o| o.data.get("signal").and_then(serde_json::Value::as_str))
1390        .map(str::to_string)
1391        .collect();
1392    (
1393        advertised,
1394        Some(SurfacedGroups {
1395            // The CUMULATIVE set, so the `groups.active` audit matches what's actually advertised
1396            // this turn; `signals` above still reflects what fired THIS turn (the provenance).
1397            active: accumulated,
1398            signals: signal_names,
1399        }),
1400    )
1401}
1402
1403/// The resolved evidence-gated groups for a turn plus the workspace signals that justified them —
1404/// the signal→group provenance the `groups.active` observation records (C-14).
1405pub(crate) struct SurfacedGroups {
1406    pub active: std::collections::HashSet<String>,
1407    pub signals: Vec<String>,
1408}
1409
1410/// The compiled-in default agent loop, as readable Flux-Lang text, surfaced by `flux loop show`.
1411pub fn builtin_agent_loop() -> &'static str {
1412    include_str!("../assets/agent-loop.flux")
1413}
1414
1415/// Return the built-in loop source. Kept as the `flux loop show` inspection seam; workspace files no
1416/// longer override an agent implicitly.
1417pub fn agent_loop_source(cwd: &std::path::Path) -> (LoopSource, String) {
1418    let _ = cwd;
1419    (LoopSource::Builtin, builtin_agent_loop().to_string())
1420}
1421
1422/// Parse the built-in adaptive agent loop. Custom loops enter through [`AgentLoopSpec::Flux`], not a
1423/// magic filesystem probe.
1424pub fn load_agent_loop(cwd: &std::path::Path) -> Result<DraftAst> {
1425    let (_, src) = agent_loop_source(cwd);
1426    // The loop is written in readable Flux-Lang text (it round-trips through `format`/`parse`), so parse
1427    // it through the language surface rather than the JSON wire form.
1428    flux_lang::parse::parse(&src)
1429        .map_err(|e| flux_core::Error::Other(format!("agent-loop.flux: invalid flow: {e}")))
1430}
1431
1432/// Load the selected agent loop and replace its execute-pass `repeat … until
1433/// $done` budget with the engine setting. Flux-Lang repeat bounds are literals by design, so this is
1434/// configuration of the parsed program, not a second Rust turn loop. Custom loops without that
1435/// conventional execute pass remain byte-for-byte as authored.
1436fn load_agent_loop_with_iterations(spec: AgentLoopSpec, max_iterations: usize) -> Result<DraftAst> {
1437    if max_iterations == 0 {
1438        return Err(flux_core::Error::Other(
1439            "max_iterations must be greater than zero".into(),
1440        ));
1441    }
1442    if max_iterations > MAX_AGENT_LOOP_ITERATIONS {
1443        return Err(flux_core::Error::Other(format!(
1444            "max_iterations {max_iterations} exceeds the maximum of \
1445             {MAX_AGENT_LOOP_ITERATIONS} (the built-in agent loop expands once per iteration)"
1446        )));
1447    }
1448    // Bounded to `MAX_AGENT_LOOP_ITERATIONS` above, so narrowing to Flux-Lang's `u32` repeat bound
1449    // is always lossless.
1450    let max = max_iterations as u32;
1451    let builtin = matches!(spec, AgentLoopSpec::Builtin(BuiltinAgentLoop::Adaptive));
1452    let mut ast = match spec {
1453        AgentLoopSpec::Builtin(BuiltinAgentLoop::Adaptive) => {
1454            flux_lang::parse::parse(builtin_agent_loop()).map_err(|error| {
1455                flux_core::Error::Other(format!("built-in adaptive loop is invalid: {error}"))
1456            })?
1457        }
1458        AgentLoopSpec::Flux(ast) => ast,
1459    };
1460    for node in &mut ast.body {
1461        if let crate::ast::Node::Repeat {
1462            max: repeat_max,
1463            until: Some(until),
1464            ..
1465        } = node
1466        {
1467            if matches!(until.as_ref(), crate::ast::Node::Var { name } if name.0 == "done") {
1468                *repeat_max = max;
1469            }
1470        }
1471    }
1472    if builtin {
1473        ast = lift_builtin_repeat_awaits(ast)?;
1474    }
1475    Ok(ast)
1476}
1477
1478/// Compile the built-in loop's bounded `repeat` with a direct-child `await` into an equivalent
1479/// finite top-level state machine. Flux-Lang's durable cursor is a top-level index; lifting the
1480/// bounded copies keeps that cursor stable across process restart without adding another parking
1481/// mechanism or teaching the runtime to serialize an async interpreter stack.
1482fn lift_builtin_repeat_awaits(mut ast: DraftAst) -> Result<DraftAst> {
1483    let mut lowered = Vec::new();
1484    let mut repeat_index = 0usize;
1485    for node in std::mem::take(&mut ast.body) {
1486        let crate::ast::Node::Repeat {
1487            max,
1488            until,
1489            body,
1490            collect,
1491        } = node
1492        else {
1493            lowered.push(node);
1494            continue;
1495        };
1496        if !body
1497            .iter()
1498            .any(|node| matches!(node, crate::ast::Node::Await { .. }))
1499        {
1500            lowered.push(crate::ast::Node::Repeat {
1501                max,
1502                until,
1503                body,
1504                collect,
1505            });
1506            continue;
1507        }
1508        if collect.is_some() {
1509            return Err(Error::Other(
1510                "the built-in repeatable-decision loop cannot collect iteration values".into(),
1511            ));
1512        }
1513        let until = until.ok_or_else(|| {
1514            Error::Other("the built-in repeatable-decision loop must have an `until` guard".into())
1515        })?;
1516        if !matches!(
1517            until.as_ref(),
1518            crate::ast::Node::Var { .. } | crate::ast::Node::Lit { .. }
1519        ) {
1520            return Err(Error::Other(
1521                "the built-in repeatable-decision loop requires a literal or variable `until` guard"
1522                    .into(),
1523            ));
1524        }
1525        let active = crate::ast::SymbolName(format!("__adaptive_repeat_{repeat_index}_active"));
1526        lowered.push(crate::ast::Node::Bind {
1527            name: active.clone(),
1528            value: Box::new(crate::ast::Node::Lit {
1529                value: serde_json::Value::Bool(true),
1530            }),
1531            ty: None,
1532            effect: None,
1533        });
1534        for _ in 0..max {
1535            for statement in &body {
1536                match statement {
1537                    crate::ast::Node::Await {
1538                        binding,
1539                        source,
1540                        as_type,
1541                        condition,
1542                    } => {
1543                        let condition = match condition.as_deref() {
1544                            None => crate::ast::Node::Var {
1545                                name: active.clone(),
1546                            },
1547                            Some(
1548                                condition @ (crate::ast::Node::Var { .. }
1549                                | crate::ast::Node::Lit { .. }),
1550                            ) => crate::ast::Node::Expr {
1551                                formula: "active && requested".into(),
1552                                vars: [
1553                                    (
1554                                        "active".into(),
1555                                        Box::new(crate::ast::Node::Var {
1556                                            name: active.clone(),
1557                                        }),
1558                                    ),
1559                                    ("requested".into(), Box::new(condition.clone())),
1560                                ]
1561                                .into_iter()
1562                                .collect(),
1563                            },
1564                            Some(_) => {
1565                                return Err(Error::Other(
1566                                    "the built-in repeatable decision requires a literal or variable await condition"
1567                                        .into(),
1568                                ));
1569                            }
1570                        };
1571                        lowered.push(crate::ast::Node::Await {
1572                            binding: binding.clone(),
1573                            source: source.clone(),
1574                            as_type: as_type.clone(),
1575                            condition: Some(Box::new(condition)),
1576                        });
1577                    }
1578                    statement => lowered.push(crate::ast::Node::When {
1579                        cond: Box::new(crate::ast::Node::Var {
1580                            name: active.clone(),
1581                        }),
1582                        then: vec![statement.clone()],
1583                        otherwise: Vec::new(),
1584                    }),
1585                }
1586            }
1587            lowered.push(crate::ast::Node::Bind {
1588                name: active.clone(),
1589                value: Box::new(crate::ast::Node::Expr {
1590                    formula: "active && !stop".into(),
1591                    vars: [
1592                        (
1593                            "active".into(),
1594                            Box::new(crate::ast::Node::Var {
1595                                name: active.clone(),
1596                            }),
1597                        ),
1598                        ("stop".into(), Box::new(until.as_ref().clone())),
1599                    ]
1600                    .into_iter()
1601                    .collect(),
1602                }),
1603                ty: None,
1604                effect: None,
1605            });
1606        }
1607        repeat_index += 1;
1608    }
1609    ast.body = lowered;
1610    Ok(ast)
1611}
1612
1613fn validate_agent_loop(
1614    ast: &DraftAst,
1615    tools: &flux_runtime::ToolRegistry,
1616    composites: Vec<flux_lang::program::CompositeOpDecl>,
1617) -> Result<()> {
1618    let catalog = OpRegistry::new(tools).with_owned_composites(composites);
1619    flux_lang::analyze::analyze_flow(ast, &catalog, &std::collections::HashSet::new()).map_err(
1620        |diagnostics| {
1621            Error::Other(format!(
1622                "agent loop failed validation: {}",
1623                diagnostics
1624                    .into_iter()
1625                    .map(|diagnostic| diagnostic.message)
1626                    .collect::<Vec<_>>()
1627                    .join("; ")
1628            ))
1629        },
1630    )
1631}
1632
1633/// The loop-machinery ops a turn dispatches to *drive* the loop (not to do the user's work). Their
1634/// tool-call/result events are filtered out of the user-facing sink so the surface shows the actual
1635/// gathered and executed operations, not the plumbing.
1636const MACHINERY_OPS: &[&str] = &[
1637    "detect_intent",
1638    "explore",
1639    "approve_batch",
1640    "execute_batch",
1641    "present_results",
1642    "ai_segment",
1643    "observe",
1644    "evidence",
1645    "metrics",
1646    "grade",
1647];
1648
1649/// Whether an op is internal agent-loop machinery rather than user-requested work. Surfaces use
1650/// this when reconstructing durable traces so their historical view matches the live sink filter.
1651pub fn is_loop_machinery_op(name: &str) -> bool {
1652    MACHINERY_OPS.contains(&name)
1653}
1654
1655/// Whether the loop-machinery ops are revealed on the surface — the CLI `--show-loop`, exported as
1656/// `FLUX_SHOW_LOOP` so the engine reads it without new plumbing. When set, the user watches the loop
1657/// move through its typed stages instead of only seeing the gathered and executed operations.
1658pub fn show_loop() -> bool {
1659    flux_system::env_truthy("FLUX_SHOW_LOOP")
1660}
1661
1662/// Whether the OUTER agent loop's structure is traced (A-39) — the CLI `--trace-loop`, exported as
1663/// `FLUX_TRACE_LOOP` so the engine reads it without new plumbing. When set, [`execute_flow_traced`]
1664/// emits one live `loop.round`/`loop.node` observation per outer-loop round/structural node
1665/// executed. Scoped to the outer loop only; authored `flow run` and resume paths never see it.
1666pub fn trace_loop() -> bool {
1667    flux_system::env_truthy("FLUX_TRACE_LOOP")
1668}
1669
1670/// Drain one captured sink event onto the real sink. By default the loop-machinery tool calls/results
1671/// are dropped (the surface shows real work, not plumbing); `reveal` keeps them so `--show-loop` can
1672/// stream the loop's own iterations.
1673fn drain_event(ev: crate::loop_host::SinkEvent, sink: &mut dyn AgentSink, reveal: bool) {
1674    use crate::loop_host::SinkEvent;
1675    let machinery = match &ev {
1676        SinkEvent::ToolCall(name, _)
1677        | SinkEvent::ToolTiming(name, _)
1678        | SinkEvent::ToolResult(name, _) => is_loop_machinery_op(name),
1679        _ => false,
1680    };
1681    if reveal || !machinery {
1682        ev.apply(sink);
1683    }
1684}
1685
1686/// A sink that discards everything — the engine's initial loop-host sink, replaced by `set_turn` before
1687/// the first real turn.
1688struct NullSink;
1689impl AgentSink for NullSink {}
1690
1691/// Render a model-stage failure for the user. A provider API error carries the raw JSON response body;
1692/// unwrap it to `error.message` so a credit/billing/auth/rate-limit failure reads as a plain sentence
1693/// instead of a JSON dump. Every other error uses its own `Display`.
1694pub fn model_error(e: &flux_core::Error) -> String {
1695    match e {
1696        flux_core::Error::Api { status, message } => {
1697            let detail = serde_json::from_str::<serde_json::Value>(message)
1698                .ok()
1699                .and_then(|v| {
1700                    v.get("error")
1701                        .and_then(|err| err.get("message"))
1702                        .and_then(|m| m.as_str())
1703                        .map(str::to_string)
1704                })
1705                .filter(|s| !s.trim().is_empty())
1706                .unwrap_or_else(|| message.clone());
1707            format!("the model provider returned an error (HTTP {status}): {detail}")
1708        }
1709        // A-33: a decode-class error that escaped a stage's own bounded retry loop — the same friendly framing as
1710        // the API-error case above, instead of the raw "provider stream decode error: …" Display.
1711        flux_core::Error::StreamDecode(detail) => format!(
1712            "the model provider's response broke mid-stream and could not be decoded: {detail}"
1713        ),
1714        _ => e.to_string(),
1715    }
1716}
1717
1718/// The text to surface when a flow-driven session suspends on a top-level `await` (D-131): the
1719/// flow's own last-emitted view (`outcome.result`) — its **authored prompt** — falling back to the
1720/// generic hint only when the author emitted nothing before the `await`. Shared by the fresh
1721/// [`FlowEngine::start_flow_turn`] and the resume path so both surface the same authored text.
1722fn suspension_prompt(outcome: &FlowOutcome) -> String {
1723    let prompt = outcome.result.trim();
1724    if prompt.is_empty() {
1725        "(awaiting your input — reply to continue the flow)".to_string()
1726    } else {
1727        prompt.to_string()
1728    }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734
1735    use std::collections::VecDeque;
1736    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1737    use std::sync::Mutex;
1738
1739    use async_trait::async_trait;
1740    use flux_core::{StopReason, ToolResultContent};
1741    use flux_provider::ChunkStream;
1742    use flux_runtime::{
1743        AllowApprover, PermissionManager, Tool, ToolContext, ToolRegistry, ToolResult,
1744    };
1745    use flux_spec::{Effect, Idempotency, Risk, ToolSpec};
1746    use flux_system::{System, Workspace};
1747    use serde_json::{json, Value};
1748
1749    static TEST_ROOT: AtomicU64 = AtomicU64::new(0);
1750
1751    struct ScriptedProvider {
1752        responses: Mutex<VecDeque<Vec<Chunk>>>,
1753        requests: Arc<Mutex<Vec<Request>>>,
1754    }
1755
1756    #[async_trait]
1757    impl Provider for ScriptedProvider {
1758        fn name(&self) -> &str {
1759            "scripted"
1760        }
1761
1762        async fn stream(&self, request: Request) -> Result<ChunkStream> {
1763            self.requests.lock().unwrap().push(request);
1764            let chunks = self
1765                .responses
1766                .lock()
1767                .unwrap()
1768                .pop_front()
1769                .unwrap_or_default();
1770            Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1771        }
1772    }
1773
1774    struct PendingProvider;
1775
1776    #[async_trait]
1777    impl Provider for PendingProvider {
1778        fn name(&self) -> &str {
1779            "pending"
1780        }
1781
1782        async fn stream(&self, _request: Request) -> Result<ChunkStream> {
1783            Ok(Box::pin(futures::stream::pending()))
1784        }
1785    }
1786
1787    struct EchoTool;
1788
1789    #[async_trait]
1790    impl Tool for EchoTool {
1791        fn spec(&self) -> ToolSpec {
1792            ToolSpec::read_only(
1793                "echo",
1794                "Return the supplied text.",
1795                json!({
1796                    "type": "object",
1797                    "properties": {"text": {"type": "string"}},
1798                    "required": ["text"],
1799                    "additionalProperties": false
1800                }),
1801            )
1802        }
1803
1804        async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<ToolResult> {
1805            Ok(ToolResult::ok(
1806                input
1807                    .get("text")
1808                    .and_then(Value::as_str)
1809                    .unwrap_or_default(),
1810            ))
1811        }
1812    }
1813
1814    struct CountingWriteTool(Arc<AtomicU64>);
1815
1816    #[async_trait]
1817    impl Tool for CountingWriteTool {
1818        fn spec(&self) -> ToolSpec {
1819            let mut spec = ToolSpec::read_only(
1820                "change",
1821                "Change the fixture exactly once.",
1822                json!({
1823                    "type": "object",
1824                    "properties": {
1825                        "value": {"type": "string"},
1826                        "fail": {"type": "boolean"}
1827                    },
1828                    "required": ["value"],
1829                    "additionalProperties": false
1830                }),
1831            )
1832            .with_effects(vec![Effect::Write, Effect::Filesystem])
1833            .with_access(vec![flux_spec::AccessKind::Filesystem])
1834            .with_risk(Risk::Medium);
1835            spec.idempotency = Idempotency::NonIdempotent;
1836            spec
1837        }
1838
1839        fn permission_subjects(&self, _input: &Value) -> Vec<String> {
1840            vec!["fixture".into()]
1841        }
1842
1843        async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<ToolResult> {
1844            self.0.fetch_add(1, Ordering::SeqCst);
1845            if input.get("fail").and_then(Value::as_bool) == Some(true) {
1846                return Err(Error::Other("fixture change failed".into()));
1847            }
1848            Ok(ToolResult::ok("fixture changed"))
1849        }
1850    }
1851
1852    struct FileProbeTool(Arc<AtomicUsize>);
1853
1854    #[async_trait]
1855    impl Tool for FileProbeTool {
1856        fn spec(&self) -> ToolSpec {
1857            ToolSpec::read_only(
1858                "file_probe",
1859                "Read the lifecycle cache fixture.",
1860                json!({"type": "object", "additionalProperties": false}),
1861            )
1862            .with_access(vec![flux_spec::AccessKind::Filesystem])
1863        }
1864
1865        fn permission_subjects(&self, _input: &Value) -> Vec<String> {
1866            vec!["value.txt".into()]
1867        }
1868
1869        async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1870            self.0.fetch_add(1, Ordering::SeqCst);
1871            Ok(ToolResult::ok(ctx.system.read_file("value.txt").await?))
1872        }
1873    }
1874
1875    struct NestedAuthoredTool;
1876
1877    #[async_trait]
1878    impl Tool for NestedAuthoredTool {
1879        fn spec(&self) -> ToolSpec {
1880            ToolSpec::read_only(
1881                "nested_authored",
1882                "Run one authored flow through the active loop host.",
1883                json!({"type": "object", "additionalProperties": false}),
1884            )
1885        }
1886
1887        async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1888            let host = ctx
1889                .loop_host
1890                .as_ref()
1891                .ok_or_else(|| Error::Other("test loop host is missing".into()))?;
1892            let ast = DraftAst {
1893                body: vec![Node::Return {
1894                    value: Box::new(Node::Lit {
1895                        value: json!("nested result"),
1896                    }),
1897                }],
1898                ..Default::default()
1899            };
1900            let outcome = host.run_authored_flow(serde_json::to_value(ast)?).await?;
1901            Ok(ToolResult::ok(
1902                outcome
1903                    .get("result")
1904                    .and_then(Value::as_str)
1905                    .unwrap_or_default(),
1906            ))
1907        }
1908    }
1909
1910    struct TurnBarrier {
1911        entered: AtomicUsize,
1912        active: AtomicUsize,
1913        max_active: AtomicUsize,
1914        notify: tokio::sync::Notify,
1915        release: tokio::sync::Semaphore,
1916    }
1917
1918    impl Default for TurnBarrier {
1919        fn default() -> Self {
1920            Self {
1921                entered: AtomicUsize::new(0),
1922                active: AtomicUsize::new(0),
1923                max_active: AtomicUsize::new(0),
1924                notify: tokio::sync::Notify::new(),
1925                release: tokio::sync::Semaphore::new(0),
1926            }
1927        }
1928    }
1929
1930    impl TurnBarrier {
1931        async fn wait_for_entered(&self, expected: usize) {
1932            loop {
1933                let notified = self.notify.notified();
1934                if self.entered.load(Ordering::SeqCst) >= expected {
1935                    return;
1936                }
1937                notified.await;
1938            }
1939        }
1940
1941        fn release(&self, permits: usize) {
1942            self.release.add_permits(permits);
1943        }
1944    }
1945
1946    struct ActiveCall<'a>(&'a AtomicUsize);
1947
1948    impl Drop for ActiveCall<'_> {
1949        fn drop(&mut self) {
1950            self.0.fetch_sub(1, Ordering::SeqCst);
1951        }
1952    }
1953
1954    struct BlockingTurnTool(Arc<TurnBarrier>);
1955
1956    #[async_trait]
1957    impl Tool for BlockingTurnTool {
1958        fn spec(&self) -> ToolSpec {
1959            ToolSpec::read_only(
1960                "blocking_turn",
1961                "Hold a turn open until the test releases it.",
1962                json!({"type": "object", "additionalProperties": false}),
1963            )
1964        }
1965
1966        async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1967            let session_id = ctx.session_id().unwrap_or_default();
1968            let active = self.0.active.fetch_add(1, Ordering::SeqCst) + 1;
1969            self.0.max_active.fetch_max(active, Ordering::SeqCst);
1970            let _active = ActiveCall(&self.0.active);
1971            let call_number = self.0.entered.fetch_add(1, Ordering::SeqCst) + 1;
1972            ctx.evidence
1973                .lock()
1974                .unwrap()
1975                .record(flux_evidence::Observation::new(
1976                    "test.turn_context",
1977                    flux_evidence::Phase::Turn,
1978                    json!({"session_id": session_id}),
1979                ));
1980            ctx.evidence
1981                .lock()
1982                .unwrap()
1983                .record(flux_evidence::Observation::new(
1984                    "subagent.usage",
1985                    flux_evidence::Phase::Turn,
1986                    json!({
1987                        "model": format!("child-{session_id}"),
1988                        "usage": Usage {
1989                            input_tokens: call_number as u64,
1990                            output_tokens: (call_number * 10) as u64,
1991                            ..Usage::default()
1992                        }
1993                    }),
1994                ));
1995            self.0.notify.notify_waiters();
1996            let permit = self
1997                .0
1998                .release
1999                .acquire()
2000                .await
2001                .map_err(|error| Error::Other(error.to_string()))?;
2002            permit.forget();
2003            Ok(ToolResult::ok(session_id))
2004        }
2005    }
2006
2007    struct IdentityBlockingTool(Arc<TurnBarrier>);
2008
2009    #[async_trait]
2010    impl Tool for IdentityBlockingTool {
2011        fn spec(&self) -> ToolSpec {
2012            ToolSpec::read_only(
2013                "identity_blocking",
2014                "Hold a turn open and report its immutable caller identity.",
2015                json!({"type": "object", "additionalProperties": false}),
2016            )
2017        }
2018
2019        async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
2020            let session_id = ctx.session_id().unwrap_or_default();
2021            let caller = ctx
2022                .turn_identity()
2023                .ok_or_else(|| Error::Other("test turn identity is missing".into()))?
2024                .caller()
2025                .principal
2026                .id
2027                .clone();
2028            let active = self.0.active.fetch_add(1, Ordering::SeqCst) + 1;
2029            self.0.max_active.fetch_max(active, Ordering::SeqCst);
2030            let _active = ActiveCall(&self.0.active);
2031            self.0.entered.fetch_add(1, Ordering::SeqCst);
2032            ctx.evidence
2033                .lock()
2034                .unwrap()
2035                .record(flux_evidence::Observation::new(
2036                    "test.identity_context",
2037                    flux_evidence::Phase::Turn,
2038                    json!({"session_id": session_id.as_str(), "caller": caller.as_str()}),
2039                ));
2040            self.0.notify.notify_waiters();
2041            let permit = self
2042                .0
2043                .release
2044                .acquire()
2045                .await
2046                .map_err(|error| Error::Other(error.to_string()))?;
2047            permit.forget();
2048            Ok(ToolResult::ok(format!("{session_id}:{caller}")))
2049        }
2050    }
2051
2052    #[derive(Default)]
2053    struct CollectSink {
2054        text: String,
2055        tools: Vec<String>,
2056        observations: Vec<flux_evidence::Observation>,
2057        usages: Vec<Option<Usage>>,
2058        ended: usize,
2059    }
2060
2061    impl AgentSink for CollectSink {
2062        fn text_delta(&mut self, text: &str) {
2063            self.text.push_str(text);
2064        }
2065
2066        fn tool_call(&mut self, name: &str, _input: &Value) {
2067            self.tools.push(name.to_string());
2068        }
2069
2070        fn observation(&mut self, observation: &flux_evidence::Observation) {
2071            self.observations.push(observation.clone());
2072        }
2073
2074        fn turn_end(&mut self, usage: Option<Usage>) {
2075            self.usages.push(usage);
2076            self.ended += 1;
2077        }
2078    }
2079
2080    fn native_call(id: &str, name: &str, input: Value) -> Vec<Chunk> {
2081        vec![
2082            Chunk::Block(ContentBlock::ToolUse {
2083                id: id.into(),
2084                name: name.into(),
2085                input,
2086            }),
2087            Chunk::Done {
2088                stop_reason: Some(StopReason::ToolUse),
2089            },
2090        ]
2091    }
2092
2093    fn native_calls(calls: Vec<(&str, &str, Value)>) -> Vec<Chunk> {
2094        let mut chunks = calls
2095            .into_iter()
2096            .map(|(id, name, input)| {
2097                Chunk::Block(ContentBlock::ToolUse {
2098                    id: id.into(),
2099                    name: name.into(),
2100                    input,
2101                })
2102            })
2103            .collect::<Vec<_>>();
2104        chunks.push(Chunk::Done {
2105            stop_reason: Some(StopReason::ToolUse),
2106        });
2107        chunks
2108    }
2109
2110    fn prose(text: &str) -> Vec<Chunk> {
2111        vec![
2112            Chunk::TextDelta(text.into()),
2113            Chunk::Done {
2114                stop_reason: Some(StopReason::EndTurn),
2115            },
2116        ]
2117    }
2118
2119    fn assemble_test_engine(
2120        provider: Arc<dyn Provider>,
2121        loop_spec: AgentLoopSpec,
2122    ) -> (Result<FlowEngine>, Arc<EventStore>) {
2123        let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2124        let root = std::env::temp_dir().join(format!(
2125            "flux-adaptive-engine-{}-{sequence}",
2126            std::process::id()
2127        ));
2128        std::fs::create_dir_all(&root).unwrap();
2129        let mut registry = ToolRegistry::new();
2130        registry.register(Arc::new(EchoTool));
2131        flux_tools::register_reflect(&mut registry);
2132        flux_tools::register_evidence(&mut registry);
2133        let executor = Executor::new(
2134            registry,
2135            PermissionManager::from_rules(&["echo".into()], &[]),
2136            Arc::new(AllowApprover),
2137            ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2138        );
2139        let events = Arc::new(EventStore::in_memory().unwrap());
2140        let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2141        let engine = FlowEngine::assemble_with_loop(
2142            provider,
2143            executor,
2144            events.clone(),
2145            flow,
2146            "test-model".into(),
2147            "Use only observed evidence.".into(),
2148            2_048,
2149            5,
2150            Vec::new(),
2151            0,
2152            Vec::new(),
2153            root.clone(),
2154            loop_spec,
2155        );
2156        (engine, events)
2157    }
2158
2159    fn scripted_engine(
2160        responses: Vec<Vec<Chunk>>,
2161        loop_spec: AgentLoopSpec,
2162    ) -> (FlowEngine, Arc<EventStore>, Arc<Mutex<Vec<Request>>>) {
2163        let requests = Arc::new(Mutex::new(Vec::new()));
2164        let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
2165            responses: Mutex::new(responses.into()),
2166            requests: requests.clone(),
2167        });
2168        let (engine, events) = assemble_test_engine(provider, loop_spec);
2169        (engine.unwrap(), events, requests)
2170    }
2171
2172    type ScriptedWriteEngine = (
2173        FlowEngine,
2174        Arc<EventStore>,
2175        Arc<Mutex<Vec<Request>>>,
2176        Arc<AtomicU64>,
2177    );
2178
2179    fn scripted_write_engine(responses: Vec<Vec<Chunk>>) -> ScriptedWriteEngine {
2180        let requests = Arc::new(Mutex::new(Vec::new()));
2181        let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
2182            responses: Mutex::new(responses.into()),
2183            requests: requests.clone(),
2184        });
2185        let writes = Arc::new(AtomicU64::new(0));
2186        let mut registry = ToolRegistry::new();
2187        registry.register(Arc::new(CountingWriteTool(writes.clone())));
2188        flux_tools::register_reflect(&mut registry);
2189        flux_tools::register_evidence(&mut registry);
2190        let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2191        let root = std::env::temp_dir().join(format!(
2192            "flux-adaptive-write-{}-{sequence}",
2193            std::process::id()
2194        ));
2195        std::fs::create_dir_all(&root).unwrap();
2196        let executor = Executor::new(
2197            registry,
2198            PermissionManager::from_rules(&["change".into()], &[]),
2199            Arc::new(AllowApprover),
2200            ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2201        );
2202        let events = Arc::new(EventStore::in_memory().unwrap());
2203        let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2204        let engine = FlowEngine::assemble_with_loop(
2205            provider,
2206            executor,
2207            events.clone(),
2208            flow,
2209            "test-model".into(),
2210            "Use only observed evidence.".into(),
2211            2_048,
2212            8,
2213            Vec::new(),
2214            0,
2215            Vec::new(),
2216            root,
2217            AgentLoopSpec::default(),
2218        )
2219        .unwrap();
2220        (engine, events, requests, writes)
2221    }
2222
2223    fn tool_engine(
2224        tool: Arc<dyn Tool>,
2225        agent_loop: DraftAst,
2226    ) -> (FlowEngine, Arc<EventStore>, std::path::PathBuf) {
2227        let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2228        let root = std::env::temp_dir().join(format!(
2229            "flux-turn-lifecycle-{}-{sequence}",
2230            std::process::id()
2231        ));
2232        std::fs::create_dir_all(&root).unwrap();
2233        let name = tool.spec().name;
2234        let mut registry = ToolRegistry::new();
2235        registry.register(tool);
2236        let executor = Executor::new(
2237            registry,
2238            PermissionManager::from_rules(std::slice::from_ref(&name), &[]),
2239            Arc::new(AllowApprover),
2240            ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2241        );
2242        let events = Arc::new(EventStore::in_memory().unwrap());
2243        let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2244        let engine = FlowEngine::assemble_with_loop(
2245            Arc::new(flux_provider::NullProvider),
2246            executor,
2247            events.clone(),
2248            flow,
2249            "test-model".into(),
2250            "Test turn isolation.".into(),
2251            2_048,
2252            5,
2253            Vec::new(),
2254            0,
2255            Vec::new(),
2256            root.clone(),
2257            AgentLoopSpec::Flux(agent_loop),
2258        )
2259        .unwrap();
2260        (engine, events, root)
2261    }
2262
2263    fn call_node(op: &str) -> flux_lang::ast::Node {
2264        flux_lang::ast::Node::Call {
2265            op: op.into(),
2266            args: Vec::new(),
2267        }
2268    }
2269
2270    fn blocking_agent_loop() -> DraftAst {
2271        DraftAst {
2272            body: vec![flux_lang::ast::Node::Return {
2273                value: Box::new(call_node("blocking_turn")),
2274            }],
2275            ..Default::default()
2276        }
2277    }
2278
2279    fn identity_blocking_agent_loop() -> DraftAst {
2280        DraftAst {
2281            body: vec![flux_lang::ast::Node::Return {
2282                value: Box::new(call_node("identity_blocking")),
2283            }],
2284            ..Default::default()
2285        }
2286    }
2287
2288    fn idle_agent_loop() -> DraftAst {
2289        DraftAst {
2290            body: vec![flux_lang::ast::Node::Return {
2291                value: Box::new(flux_lang::ast::Node::Lit {
2292                    value: json!("idle"),
2293                }),
2294            }],
2295            ..Default::default()
2296        }
2297    }
2298
2299    fn nested_authored_agent_loop() -> DraftAst {
2300        DraftAst {
2301            body: vec![Node::Return {
2302                value: Box::new(call_node("nested_authored")),
2303            }],
2304            ..Default::default()
2305        }
2306    }
2307
2308    fn resumable_read_flow() -> DraftAst {
2309        use flux_lang::ast::{Node, SymbolName};
2310        DraftAst {
2311            name: Some("cache_resume".into()),
2312            body: vec![
2313                Node::Bind {
2314                    name: SymbolName("before".into()),
2315                    value: Box::new(call_node("file_probe")),
2316                    ty: None,
2317                    effect: None,
2318                },
2319                Node::Await {
2320                    binding: Some(SymbolName("reply".into())),
2321                    source: "user_input".into(),
2322                    as_type: None,
2323                    condition: None,
2324                },
2325                Node::Bind {
2326                    name: SymbolName("after".into()),
2327                    value: Box::new(call_node("file_probe")),
2328                    ty: None,
2329                    effect: None,
2330                },
2331                Node::Return {
2332                    value: Box::new(Node::Var {
2333                        name: SymbolName("after".into()),
2334                    }),
2335                },
2336            ],
2337            ..Default::default()
2338        }
2339    }
2340
2341    fn blocking_resume_flow() -> DraftAst {
2342        use flux_lang::ast::{Node, SymbolName};
2343        DraftAst {
2344            name: Some("cancel_resume".into()),
2345            body: vec![
2346                Node::Await {
2347                    binding: Some(SymbolName("reply".into())),
2348                    source: "user_input".into(),
2349                    as_type: None,
2350                    condition: None,
2351                },
2352                Node::Return {
2353                    value: Box::new(call_node("blocking_turn")),
2354                },
2355            ],
2356            ..Default::default()
2357        }
2358    }
2359
2360    #[test]
2361    fn builtin_loop_is_authored_flux_without_the_retired_compiler_ops() {
2362        let source = builtin_agent_loop();
2363        let ast = flux_lang::parse::parse(source).expect("built-in loop parses");
2364        let formatted = flux_lang::format::format(&ast);
2365        flux_lang::parse::parse(&formatted).expect("built-in loop round-trips");
2366
2367        for retired in ["emit_plan", "run_plan", "staged_plan"] {
2368            assert!(!source.contains(retired), "found retired op `{retired}`");
2369        }
2370        for stage in [
2371            "detect_intent",
2372            "explore",
2373            "approve_batch",
2374            "execute_batch",
2375            "present_results",
2376        ] {
2377            assert!(source.contains(stage), "missing adaptive stage `{stage}`");
2378        }
2379        assert!(
2380            source.contains(&format!("repeat {DEFAULT_AGENT_LOOP_ITERATIONS}")),
2381            "the inspectable built-in source must show the default outer-loop bound \
2382             ({DEFAULT_AGENT_LOOP_ITERATIONS}); regenerate assets/agent-loop.flux if this fails"
2383        );
2384    }
2385
2386    #[test]
2387    fn outer_loop_iteration_bounds_reject_zero_and_values_above_the_practical_cap() {
2388        let zero = load_agent_loop_with_iterations(AgentLoopSpec::default(), 0)
2389            .unwrap_err()
2390            .to_string();
2391        assert!(zero.contains("must be greater than zero"), "{zero}");
2392
2393        load_agent_loop_with_iterations(AgentLoopSpec::default(), MAX_AGENT_LOOP_ITERATIONS)
2394            .expect("the practical maximum remains usable");
2395
2396        let above_max = load_agent_loop_with_iterations(
2397            AgentLoopSpec::default(),
2398            MAX_AGENT_LOOP_ITERATIONS + 1,
2399        );
2400        match above_max {
2401            Err(error) => {
2402                let error = error.to_string();
2403                assert!(
2404                    error.contains(&format!("maximum of {MAX_AGENT_LOOP_ITERATIONS}")),
2405                    "{error}"
2406                );
2407            }
2408            Ok(_) => panic!("a bound above the practical maximum must fail before AST expansion"),
2409        }
2410    }
2411
2412    #[tokio::test]
2413    async fn ai_segment_honors_an_authored_50_round_budget() {
2414        let mut responses = (0..49)
2415            .map(|round| {
2416                native_call(
2417                    &format!("echo-{round}"),
2418                    "echo",
2419                    json!({"text": format!("evidence-{round}")}),
2420                )
2421            })
2422            .collect::<Vec<_>>();
2423        responses.push(prose("All 49 observations were gathered."));
2424        let loop_spec = AgentLoopSpec::Flux(
2425            flux_lang::parse::parse(
2426                r#"flow segment_test -> string
2427  $segment = ai_segment({ goal: "Gather every observation", tools: ["echo"], max_rounds: 50 })
2428  return $segment.result
2429"#,
2430            )
2431            .expect("test loop parses"),
2432        );
2433        let (engine, events, requests) = scripted_engine(responses, loop_spec);
2434        let session = events.create_session("scripted/test-model").unwrap();
2435        let mut sink = CollectSink::default();
2436
2437        engine
2438            .run_turn(&session, "Run the segment", &mut sink)
2439            .await
2440            .unwrap();
2441
2442        assert_eq!(sink.text, "All 49 observations were gathered.");
2443        assert_eq!(requests.lock().unwrap().len(), 50);
2444    }
2445
2446    #[tokio::test]
2447    async fn default_turn_routes_intent_then_uses_exact_native_tool_schema() {
2448        let (engine, events, requests) = scripted_engine(
2449            vec![
2450                native_call(
2451                    "intent-1",
2452                    "declare_intent",
2453                    json!({
2454                        "intent": "echo a grounded value",
2455                        "capability_families": ["core"]
2456                    }),
2457                ),
2458                native_call("echo-1", "echo", json!({"text": "evidence"})),
2459                prose("The observed value is evidence."),
2460            ],
2461            AgentLoopSpec::default(),
2462        );
2463        let session = events.create_session("scripted/test-model").unwrap();
2464        let mut sink = CollectSink::default();
2465
2466        engine
2467            .run_turn(&session, "Echo a grounded value", &mut sink)
2468            .await
2469            .unwrap();
2470
2471        assert_eq!(sink.text, "The observed value is evidence.");
2472        assert_eq!(sink.tools, vec!["echo"]);
2473        assert_eq!(sink.ended, 1);
2474        let requests = requests.lock().unwrap();
2475        assert_eq!(requests.len(), 3);
2476        assert_eq!(
2477            requests[0]
2478                .tools
2479                .iter()
2480                .map(|tool| tool.name.as_str())
2481                .collect::<Vec<_>>(),
2482            vec!["declare_intent"]
2483        );
2484        assert_eq!(
2485            requests[1]
2486                .tools
2487                .iter()
2488                .find(|tool| tool.name == "echo")
2489                .expect("selected native op")
2490                .input_schema,
2491            json!({
2492                "type": "object",
2493                "properties": {"text": {"type": "string"}},
2494                "required": ["text"],
2495                "additionalProperties": false
2496            })
2497        );
2498        assert!(requests
2499            .iter()
2500            .flat_map(|request| &request.tools)
2501            .all(|tool| tool.name != "emit_plan"));
2502        let feedback = requests[2]
2503            .messages
2504            .iter()
2505            .flat_map(|message| &message.content)
2506            .filter_map(|block| match block {
2507                ContentBlock::ToolResult { content, .. } => Some(content),
2508                _ => None,
2509            })
2510            .flatten()
2511            .filter_map(|content| match content {
2512                ToolResultContent::Text { text } => Some(text.as_str()),
2513                ToolResultContent::Image { .. } => None,
2514            })
2515            .collect::<Vec<_>>()
2516            .join("\n");
2517        assert!(feedback.contains("evidence"));
2518        assert_eq!(events.conversation(&session).unwrap().len(), 2);
2519    }
2520
2521    #[tokio::test]
2522    async fn authored_decision_suspends_and_resumes_the_same_native_ledger() {
2523        let (engine, events, requests) = scripted_engine(
2524            vec![
2525                native_call(
2526                    "intent-1",
2527                    "declare_intent",
2528                    json!({
2529                        "intent": "choose a fixture",
2530                        "capability_families": []
2531                    }),
2532                ),
2533                native_call(
2534                    "decision-1",
2535                    "request_decision",
2536                    json!({
2537                        "prompt": "Which fixture should I use?",
2538                        "options": ["alpha", "beta"]
2539                    }),
2540                ),
2541                prose("I will use alpha."),
2542            ],
2543            AgentLoopSpec::default(),
2544        );
2545        let session = events.create_session("scripted/test-model").unwrap();
2546        let mut sink = CollectSink::default();
2547
2548        engine
2549            .run_turn(&session, "Choose a fixture", &mut sink)
2550            .await
2551            .unwrap();
2552        assert!(sink.text.contains("Which fixture should I use?"));
2553        assert!(sink.text.contains("1. alpha"));
2554
2555        engine.run_turn(&session, "alpha", &mut sink).await.unwrap();
2556        assert!(sink.text.ends_with("I will use alpha."));
2557        assert_eq!(sink.ended, 2);
2558        assert_eq!(events.conversation(&session).unwrap().len(), 4);
2559        let requests = requests.lock().unwrap();
2560        assert_eq!(requests.len(), 3, "resume must not re-run intent detection");
2561        let resumed_context = requests[2]
2562            .messages
2563            .iter()
2564            .flat_map(|message| &message.content)
2565            .filter_map(|block| match block {
2566                ContentBlock::ToolResult { content, .. } => Some(content),
2567                _ => None,
2568            })
2569            .flatten()
2570            .filter_map(|content| match content {
2571                ToolResultContent::Text { text } => Some(text.as_str()),
2572                ToolResultContent::Image { .. } => None,
2573            })
2574            .collect::<Vec<_>>()
2575            .join("\n");
2576        assert!(resumed_context.contains("alpha"));
2577    }
2578
2579    #[tokio::test]
2580    async fn every_adaptive_decision_suspends_and_resumes_the_same_native_ledger() {
2581        let (engine, events, requests) = scripted_engine(
2582            vec![
2583                native_call(
2584                    "intent-1",
2585                    "declare_intent",
2586                    json!({
2587                        "intent": "choose two fixtures",
2588                        "capability_families": []
2589                    }),
2590                ),
2591                native_call(
2592                    "decision-1",
2593                    "request_decision",
2594                    json!({
2595                        "prompt": "Which primary fixture should I use?",
2596                        "options": ["alpha", "beta"]
2597                    }),
2598                ),
2599                native_call(
2600                    "decision-2",
2601                    "request_decision",
2602                    json!({
2603                        "prompt": "Which fallback fixture should I use?",
2604                        "options": ["gamma", "delta"]
2605                    }),
2606                ),
2607                prose("I will use alpha with gamma as the fallback."),
2608            ],
2609            AgentLoopSpec::default(),
2610        );
2611        let session = events.create_session("scripted/test-model").unwrap();
2612        let mut sink = CollectSink::default();
2613
2614        engine
2615            .run_turn(&session, "Choose two fixtures", &mut sink)
2616            .await
2617            .unwrap();
2618        assert!(sink.text.contains("Which primary fixture should I use?"));
2619
2620        engine.run_turn(&session, "alpha", &mut sink).await.unwrap();
2621        assert!(sink.text.contains("Which fallback fixture should I use?"));
2622
2623        engine.run_turn(&session, "gamma", &mut sink).await.unwrap();
2624        assert!(sink
2625            .text
2626            .ends_with("I will use alpha with gamma as the fallback."));
2627        assert_eq!(sink.ended, 3);
2628        assert_eq!(events.conversation(&session).unwrap().len(), 6);
2629        assert_eq!(
2630            requests.lock().unwrap().len(),
2631            4,
2632            "each resume must continue the native ledger without rerunning intent"
2633        );
2634    }
2635
2636    #[tokio::test]
2637    async fn decision_after_execution_resumes_without_replaying_the_completed_action() {
2638        let responses = vec![
2639            native_call(
2640                "intent-1",
2641                "declare_intent",
2642                json!({
2643                    "intent": "change a fixture and choose the follow-up",
2644                    "capability_families": ["workspace.write"]
2645                }),
2646            ),
2647            native_call("change-1", "change", json!({"value": "updated"})),
2648            native_call(
2649                "finalize-1",
2650                "finalize_plan",
2651                json!({"instructions": "Report the completed change after resolving the follow-up."}),
2652            ),
2653            native_call(
2654                "decision-after-execution",
2655                "request_decision",
2656                json!({
2657                    "prompt": "Which follow-up should I report?",
2658                    "options": ["summary", "details"]
2659                }),
2660            ),
2661            prose("The fixture changed once; here is the summary."),
2662        ];
2663        let (engine, events, requests, writes) = scripted_write_engine(responses);
2664        let session = events.create_session("scripted/test-model").unwrap();
2665        let mut sink = CollectSink::default();
2666
2667        engine
2668            .run_turn(&session, "Change the fixture", &mut sink)
2669            .await
2670            .unwrap();
2671        assert_eq!(
2672            writes.load(Ordering::SeqCst),
2673            1,
2674            "sink text={:?}, tools={:?}, conversation={:?}, requests={}",
2675            sink.text,
2676            sink.tools,
2677            events.conversation(&session).unwrap(),
2678            requests.lock().unwrap().len(),
2679        );
2680        assert!(sink.text.contains("Which follow-up should I report?"));
2681        assert!(
2682            sink.text.contains("1. summary"),
2683            "sink text={:?}",
2684            sink.text
2685        );
2686        let approved = sink
2687            .observations
2688            .iter()
2689            .find(|observation| observation.kind == "approval.approved")
2690            .expect("approval outcome is observable");
2691        assert!(approved.data["wait_us"].is_number());
2692        let executed = sink
2693            .observations
2694            .iter()
2695            .find(|observation| observation.kind == "action_batch.executed")
2696            .expect("batch execution is observable");
2697        assert!(executed.data["duration_us"].is_number());
2698        assert!(sink
2699            .observations
2700            .iter()
2701            .any(|observation| observation.kind == "model.call"
2702                && observation.data["stage"] == "explore"));
2703
2704        engine
2705            .run_turn(&session, "summary", &mut sink)
2706            .await
2707            .unwrap();
2708        assert_eq!(
2709            writes.load(Ordering::SeqCst),
2710            1,
2711            "resuming a post-execution decision must not replay the consumed batch"
2712        );
2713        assert!(sink
2714            .text
2715            .ends_with("The fixture changed once; here is the summary."));
2716        assert_eq!(
2717            requests.lock().unwrap().len(),
2718            5,
2719            "resume must continue after the execution report without rerunning intent or action capture"
2720        );
2721    }
2722
2723    #[tokio::test]
2724    async fn partial_failure_skips_later_actions_and_is_not_replayed_after_decision_resume() {
2725        let responses = vec![
2726            native_call(
2727                "intent-1",
2728                "declare_intent",
2729                json!({
2730                    "intent": "attempt two fixture changes and choose the report",
2731                    "capability_families": ["workspace.write"]
2732                }),
2733            ),
2734            native_calls(vec![
2735                (
2736                    "change-fails",
2737                    "change",
2738                    json!({"value": "first", "fail": true}),
2739                ),
2740                ("change-skipped", "change", json!({"value": "second"})),
2741            ]),
2742            native_call(
2743                "finalize-1",
2744                "finalize_plan",
2745                json!({"instructions": "Report the partial failure after resolving presentation."}),
2746            ),
2747            native_call(
2748                "decision-after-failure",
2749                "request_decision",
2750                json!({
2751                    "prompt": "How should I present the partial failure?",
2752                    "options": ["briefly", "with details"]
2753                }),
2754            ),
2755            prose("The first change failed, so the second was skipped."),
2756        ];
2757        let (engine, events, requests, writes) = scripted_write_engine(responses);
2758        let session = events.create_session("scripted/test-model").unwrap();
2759        let mut sink = CollectSink::default();
2760
2761        engine
2762            .run_turn(&session, "Attempt both fixture changes", &mut sink)
2763            .await
2764            .unwrap();
2765        assert_eq!(
2766            writes.load(Ordering::SeqCst),
2767            1,
2768            "the second action must be skipped after the first action fails"
2769        );
2770        assert!(sink.text.contains("1. briefly"));
2771
2772        engine
2773            .run_turn(&session, "briefly", &mut sink)
2774            .await
2775            .unwrap();
2776        assert_eq!(
2777            writes.load(Ordering::SeqCst),
2778            1,
2779            "resuming after a partial failure must not replay either the failed or skipped action"
2780        );
2781        assert!(sink
2782            .text
2783            .ends_with("The first change failed, so the second was skipped."));
2784        assert_eq!(requests.lock().unwrap().len(), 5);
2785    }
2786
2787    #[test]
2788    fn surfaced_groups_do_not_leak_between_sessions_on_a_shared_engine() {
2789        let mut registry = ToolRegistry::new();
2790        registry.register(Arc::new(EchoTool));
2791        let groups = vec![flux_evidence::ToolGroup {
2792            name: "plugin.slack".into(),
2793            description: "Company chat operations.".into(),
2794            tools: vec!["echo".into()],
2795            surface_when: vec![flux_evidence::SignalMatch {
2796                kind: flux_evidence::KIND_TURN_INTENT.into(),
2797                signal: Some("slack".into()),
2798            }],
2799        }];
2800        let root = std::env::temp_dir();
2801        let sticky = std::sync::Mutex::new(std::collections::HashMap::new());
2802
2803        let (first, _) = surfaced_op_names(
2804            &registry,
2805            &groups,
2806            &root,
2807            &sticky,
2808            "session-a",
2809            &[],
2810            "use slack",
2811        );
2812        assert!(first.contains("echo"));
2813
2814        let (second, _) = surfaced_op_names(
2815            &registry,
2816            &groups,
2817            &root,
2818            &sticky,
2819            "session-b",
2820            &[],
2821            "say hello",
2822        );
2823        assert!(
2824            !second.contains("echo"),
2825            "a different session must not inherit another session's surfaced integration"
2826        );
2827    }
2828
2829    #[tokio::test]
2830    async fn explicit_authored_loop_runs_without_a_model_call() {
2831        let loop_spec = AgentLoopSpec::parse("flow custom -> string\n  return \"custom loop\"")
2832            .expect("custom loop parses");
2833        let (engine, events, requests) = scripted_engine(Vec::new(), loop_spec);
2834        let session = events.create_session("scripted/test-model").unwrap();
2835        let mut sink = CollectSink::default();
2836
2837        engine.run_turn(&session, "hello", &mut sink).await.unwrap();
2838
2839        assert_eq!(sink.text, "custom loop");
2840        assert!(requests.lock().unwrap().is_empty());
2841        assert_eq!(events.conversation(&session).unwrap().len(), 2);
2842    }
2843
2844    #[tokio::test]
2845    async fn resumed_turn_starts_a_fresh_deterministic_read_cache_generation() {
2846        let calls = Arc::new(AtomicUsize::new(0));
2847        let (engine, events, root) =
2848            tool_engine(Arc::new(FileProbeTool(calls.clone())), idle_agent_loop());
2849        std::fs::write(root.join("value.txt"), "before").unwrap();
2850        let session = events.create_session("test-model").unwrap();
2851        let flow = resumable_read_flow();
2852        let mut started = CollectSink::default();
2853
2854        engine
2855            .start_flow_turn(&session, &flow, &mut started)
2856            .await
2857            .unwrap();
2858        assert!(engine.flow.has_suspension(&session).unwrap());
2859        assert_eq!(calls.load(Ordering::SeqCst), 1);
2860
2861        std::fs::write(root.join("value.txt"), "after").unwrap();
2862        let mut resumed = CollectSink::default();
2863        engine
2864            .run_turn(&session, "continue", &mut resumed)
2865            .await
2866            .unwrap();
2867
2868        assert_eq!(resumed.text, "after");
2869        assert_eq!(
2870            calls.load(Ordering::SeqCst),
2871            2,
2872            "the post-await read must execute in the resumed turn, not reuse the prior turn cache"
2873        );
2874    }
2875
2876    #[tokio::test]
2877    async fn pre_cancelled_resume_is_terminal_and_keeps_its_checkpoint() {
2878        let barrier = Arc::new(TurnBarrier::default());
2879        let (engine, events, _root) = tool_engine(
2880            Arc::new(BlockingTurnTool(barrier.clone())),
2881            idle_agent_loop(),
2882        );
2883        let session = events.create_session("test-model").unwrap();
2884        let mut started = CollectSink::default();
2885        engine
2886            .start_flow_turn(&session, &blocking_resume_flow(), &mut started)
2887            .await
2888            .unwrap();
2889
2890        let cancel = CancellationToken::new();
2891        cancel.cancel();
2892        let mut resumed = CollectSink::default();
2893        tokio::time::timeout(
2894            std::time::Duration::from_millis(250),
2895            engine.run_turn_cancellable(&session, "answer", &mut resumed, &cancel),
2896        )
2897        .await
2898        .expect("a pre-cancelled resume must return promptly")
2899        .unwrap();
2900
2901        assert_eq!(barrier.entered.load(Ordering::SeqCst), 0);
2902        assert!(engine.flow.has_suspension(&session).unwrap());
2903        assert_eq!(resumed.text, "(turn cancelled)");
2904        assert_eq!(resumed.ended, 1);
2905        let conversation = events.conversation(&session).unwrap();
2906        assert_eq!(conversation.len(), 3);
2907        assert_eq!(conversation[1].text(), "answer");
2908        assert_eq!(conversation[2].text(), "(turn cancelled)");
2909        assert_eq!(
2910            events.turns(&session).unwrap().last().unwrap().outcome,
2911            "cancelled"
2912        );
2913    }
2914
2915    #[tokio::test]
2916    async fn cancelling_an_in_flight_resume_keeps_its_checkpoint() {
2917        let barrier = Arc::new(TurnBarrier::default());
2918        let (engine, events, _root) = tool_engine(
2919            Arc::new(BlockingTurnTool(barrier.clone())),
2920            idle_agent_loop(),
2921        );
2922        let session = events.create_session("test-model").unwrap();
2923        let mut started = CollectSink::default();
2924        engine
2925            .start_flow_turn(&session, &blocking_resume_flow(), &mut started)
2926            .await
2927            .unwrap();
2928
2929        let cancel = CancellationToken::new();
2930        let mut resumed = CollectSink::default();
2931        let run = engine.run_turn_cancellable(&session, "answer", &mut resumed, &cancel);
2932        let controller = async {
2933            barrier.wait_for_entered(1).await;
2934            cancel.cancel();
2935        };
2936        let result = tokio::time::timeout(std::time::Duration::from_secs(1), async {
2937            let (result, ()) = tokio::join!(run, controller);
2938            result
2939        })
2940        .await
2941        .expect("an in-flight resumed operation must stop on cancellation");
2942        result.unwrap();
2943
2944        assert_eq!(barrier.active.load(Ordering::SeqCst), 0);
2945        assert!(engine.flow.has_suspension(&session).unwrap());
2946        assert_eq!(resumed.text, "(turn cancelled)");
2947        assert_eq!(resumed.ended, 1);
2948        let conversation = events.conversation(&session).unwrap();
2949        assert_eq!(conversation.len(), 3);
2950        assert_eq!(conversation[1].text(), "answer");
2951        assert_eq!(conversation[2].text(), "(turn cancelled)");
2952        assert_eq!(
2953            events.turns(&session).unwrap().last().unwrap().outcome,
2954            "cancelled"
2955        );
2956    }
2957
2958    fn persisted_turn_contexts(events: &EventStore, session_id: &str) -> Vec<String> {
2959        events
2960            .observations(session_id)
2961            .unwrap()
2962            .into_iter()
2963            .filter(|observation| observation.kind == "test.turn_context")
2964            .filter_map(|observation| observation.data["session_id"].as_str().map(str::to_string))
2965            .collect()
2966    }
2967
2968    fn persisted_callers(events: &EventStore, session_id: &str, kind: &str) -> Vec<String> {
2969        events
2970            .observations(session_id)
2971            .unwrap()
2972            .into_iter()
2973            .filter(|observation| observation.kind == kind)
2974            .filter_map(|observation| observation.data["caller"].as_str().map(str::to_string))
2975            .collect()
2976    }
2977
2978    #[tokio::test]
2979    async fn one_raw_engine_serializes_turns_without_cross_wiring_sinks_or_audit() {
2980        let barrier = Arc::new(TurnBarrier::default());
2981        let (mut engine, events, _root) = tool_engine(
2982            Arc::new(BlockingTurnTool(barrier.clone())),
2983            blocking_agent_loop(),
2984        );
2985        engine.groups = vec![
2986            flux_evidence::ToolGroup {
2987                name: "turn.first".into(),
2988                description: "First-turn-only test catalog.".into(),
2989                tools: vec!["blocking_turn".into()],
2990                surface_when: vec![flux_evidence::SignalMatch {
2991                    kind: flux_evidence::KIND_TURN_INTENT.into(),
2992                    signal: Some("first".into()),
2993                }],
2994            },
2995            flux_evidence::ToolGroup {
2996                name: "turn.second".into(),
2997                description: "Second-turn-only test catalog.".into(),
2998                tools: vec!["second_only".into()],
2999                surface_when: vec![flux_evidence::SignalMatch {
3000                    kind: flux_evidence::KIND_TURN_INTENT.into(),
3001                    signal: Some("second".into()),
3002                }],
3003            },
3004        ];
3005        let engine = Arc::new(engine);
3006        let authority_before = engine.executor.approval_context();
3007        let first_session = events.create_session("test-model").unwrap();
3008        let second_session = events.create_session("test-model").unwrap();
3009
3010        let first = tokio::spawn({
3011            let engine = engine.clone();
3012            let session = first_session.clone();
3013            async move {
3014                let mut sink = CollectSink::default();
3015                let result = engine.run_turn(&session, "first", &mut sink).await;
3016                (result, sink)
3017            }
3018        });
3019        barrier.wait_for_entered(1).await;
3020        let second = tokio::spawn({
3021            let engine = engine.clone();
3022            let session = second_session.clone();
3023            async move {
3024                let mut sink = CollectSink::default();
3025                let result = engine.run_turn(&session, "second", &mut sink).await;
3026                (result, sink)
3027            }
3028        });
3029
3030        assert!(
3031            tokio::time::timeout(
3032                std::time::Duration::from_millis(100),
3033                barrier.wait_for_entered(2)
3034            )
3035            .await
3036            .is_err(),
3037            "the second call must wait at the engine boundary"
3038        );
3039        barrier.release(1);
3040        let (first_result, first_sink) = first.await.unwrap();
3041        first_result.unwrap();
3042        barrier.wait_for_entered(2).await;
3043        barrier.release(1);
3044        let (second_result, second_sink) = second.await.unwrap();
3045        second_result.unwrap();
3046
3047        assert_eq!(barrier.max_active.load(Ordering::SeqCst), 1);
3048        assert_eq!(first_sink.text, first_session);
3049        assert_eq!(second_sink.text, second_session);
3050        assert_eq!(first_sink.tools, ["blocking_turn"]);
3051        assert_eq!(second_sink.tools, ["blocking_turn"]);
3052        assert_eq!(first_sink.usages.len(), 1);
3053        assert_eq!(second_sink.usages.len(), 1);
3054        assert_eq!(first_sink.usages[0].as_ref().unwrap().input_tokens, 1);
3055        assert_eq!(first_sink.usages[0].as_ref().unwrap().output_tokens, 10);
3056        assert_eq!(second_sink.usages[0].as_ref().unwrap().input_tokens, 2);
3057        assert_eq!(second_sink.usages[0].as_ref().unwrap().output_tokens, 20);
3058        assert_eq!(first_sink.ended, 1);
3059        assert_eq!(second_sink.ended, 1);
3060        let active_groups = |sink: &CollectSink| {
3061            sink.observations
3062                .iter()
3063                .find(|observation| observation.kind == "groups.active")
3064                .and_then(|observation| observation.data["groups"].as_array())
3065                .cloned()
3066                .unwrap_or_default()
3067        };
3068        assert_eq!(active_groups(&first_sink), vec![json!("turn.first")]);
3069        assert_eq!(active_groups(&second_sink), vec![json!("turn.second")]);
3070        let (first_advertised, _) = surfaced_op_names(
3071            engine.executor.registry(),
3072            &engine.groups,
3073            &engine.cwd,
3074            &engine.sticky_groups,
3075            &first_session,
3076            &[],
3077            "",
3078        );
3079        let (second_advertised, _) = surfaced_op_names(
3080            engine.executor.registry(),
3081            &engine.groups,
3082            &engine.cwd,
3083            &engine.sticky_groups,
3084            &second_session,
3085            &[],
3086            "",
3087        );
3088        assert!(first_advertised.contains("blocking_turn"));
3089        assert!(!second_advertised.contains("blocking_turn"));
3090        assert_eq!(engine.executor.approval_context(), authority_before);
3091        let authority: Value = serde_json::from_str(&authority_before).unwrap();
3092        assert_eq!(authority["caller"]["principal"]["id"], "local");
3093        assert_eq!(
3094            events
3095                .conversation(&first_session)
3096                .unwrap()
3097                .iter()
3098                .map(Message::text)
3099                .collect::<Vec<_>>(),
3100            vec!["first", first_session.as_str()]
3101        );
3102        assert_eq!(
3103            events
3104                .conversation(&second_session)
3105                .unwrap()
3106                .iter()
3107                .map(Message::text)
3108                .collect::<Vec<_>>(),
3109            vec!["second", second_session.as_str()]
3110        );
3111        assert_eq!(
3112            persisted_turn_contexts(&events, &first_session),
3113            vec![first_session]
3114        );
3115        assert_eq!(
3116            persisted_turn_contexts(&events, &second_session),
3117            vec![second_session]
3118        );
3119    }
3120
3121    #[tokio::test]
3122    async fn concurrent_raw_turns_keep_distinct_lexical_identities_in_sink_dispatch_and_audit() {
3123        let barrier = Arc::new(TurnBarrier::default());
3124        let (engine, events, _root) = tool_engine(
3125            Arc::new(IdentityBlockingTool(barrier.clone())),
3126            identity_blocking_agent_loop(),
3127        );
3128        let identity = |id: &str| {
3129            let (mut caller, trust) = engine.executor.identity().get();
3130            caller.principal.id = id.to_string();
3131            caller.principal.name = id.to_string();
3132            caller.source = "raw-engine-test".into();
3133            TurnIdentity::new(caller, trust)
3134        };
3135        let alice = identity("alice");
3136        let bob = identity("bob");
3137        let engine = Arc::new(engine);
3138        let first_session = events.create_session("test-model").unwrap();
3139        let second_session = events.create_session("test-model").unwrap();
3140
3141        let first = tokio::spawn({
3142            let engine = engine.clone();
3143            let session = first_session.clone();
3144            async move {
3145                let mut sink = CollectSink::default();
3146                let result = engine
3147                    .run_turn_as(&session, "first", &mut sink, alice)
3148                    .await;
3149                (result, sink)
3150            }
3151        });
3152        barrier.wait_for_entered(1).await;
3153        let second = tokio::spawn({
3154            let engine = engine.clone();
3155            let session = second_session.clone();
3156            async move {
3157                let mut sink = CollectSink::default();
3158                let result = engine.run_turn_as(&session, "second", &mut sink, bob).await;
3159                (result, sink)
3160            }
3161        });
3162
3163        assert!(
3164            tokio::time::timeout(
3165                std::time::Duration::from_millis(100),
3166                barrier.wait_for_entered(2),
3167            )
3168            .await
3169            .is_err(),
3170            "bob's turn must wait without retargeting alice's active context"
3171        );
3172        barrier.release(1);
3173        let (first_result, first_sink) = first.await.unwrap();
3174        first_result.unwrap();
3175        barrier.wait_for_entered(2).await;
3176        barrier.release(1);
3177        let (second_result, second_sink) = second.await.unwrap();
3178        second_result.unwrap();
3179
3180        assert_eq!(barrier.max_active.load(Ordering::SeqCst), 1);
3181        assert_eq!(first_sink.text, format!("{first_session}:alice"));
3182        assert_eq!(second_sink.text, format!("{second_session}:bob"));
3183        assert_eq!(first_sink.tools, ["identity_blocking"]);
3184        assert_eq!(second_sink.tools, ["identity_blocking"]);
3185        assert_eq!(
3186            persisted_callers(&events, &first_session, "turn.identity"),
3187            ["alice"]
3188        );
3189        assert_eq!(
3190            persisted_callers(&events, &second_session, "turn.identity"),
3191            ["bob"]
3192        );
3193        assert_eq!(
3194            persisted_callers(&events, &first_session, "tool_call"),
3195            ["alice"]
3196        );
3197        assert_eq!(
3198            persisted_callers(&events, &second_session, "tool_call"),
3199            ["bob"]
3200        );
3201        assert_eq!(
3202            persisted_callers(&events, &first_session, "test.identity_context"),
3203            ["alice"]
3204        );
3205        assert_eq!(
3206            persisted_callers(&events, &second_session, "test.identity_context"),
3207            ["bob"]
3208        );
3209        assert_eq!(
3210            engine.executor.identity().get().0.principal.id,
3211            "local",
3212            "explicit turns must not mutate the executor's assembly-time identity"
3213        );
3214    }
3215
3216    #[tokio::test]
3217    async fn nested_authored_flow_reuses_the_active_lifecycle_without_relocking() {
3218        let (engine, events, _root) =
3219            tool_engine(Arc::new(NestedAuthoredTool), nested_authored_agent_loop());
3220        let session = events.create_session("test-model").unwrap();
3221        let mut sink = CollectSink::default();
3222
3223        tokio::time::timeout(
3224            std::time::Duration::from_secs(1),
3225            engine.run_turn(&session, "run nested", &mut sink),
3226        )
3227        .await
3228        .expect("a nested authored flow must not recursively acquire the engine turn gate")
3229        .unwrap();
3230
3231        assert_eq!(sink.text, "nested result");
3232        assert_eq!(sink.ended, 1);
3233    }
3234
3235    #[tokio::test]
3236    async fn distinct_raw_engines_can_overlap() {
3237        let barrier = Arc::new(TurnBarrier::default());
3238        let (first_engine, first_events, _root) = tool_engine(
3239            Arc::new(BlockingTurnTool(barrier.clone())),
3240            blocking_agent_loop(),
3241        );
3242        let (second_engine, second_events, _root) = tool_engine(
3243            Arc::new(BlockingTurnTool(barrier.clone())),
3244            blocking_agent_loop(),
3245        );
3246        let first_engine = Arc::new(first_engine);
3247        let second_engine = Arc::new(second_engine);
3248        let first_session = first_events.create_session("test-model").unwrap();
3249        let second_session = second_events.create_session("test-model").unwrap();
3250
3251        let first = tokio::spawn({
3252            let engine = first_engine.clone();
3253            let session = first_session.clone();
3254            async move {
3255                let mut sink = CollectSink::default();
3256                let result = engine.run_turn(&session, "first", &mut sink).await;
3257                (result, sink)
3258            }
3259        });
3260        let second = tokio::spawn({
3261            let engine = second_engine.clone();
3262            let session = second_session.clone();
3263            async move {
3264                let mut sink = CollectSink::default();
3265                let result = engine.run_turn(&session, "second", &mut sink).await;
3266                (result, sink)
3267            }
3268        });
3269
3270        tokio::time::timeout(
3271            std::time::Duration::from_secs(1),
3272            barrier.wait_for_entered(2),
3273        )
3274        .await
3275        .expect("independent engines must reach their operations concurrently");
3276        assert_eq!(barrier.max_active.load(Ordering::SeqCst), 2);
3277        barrier.release(2);
3278        let (first_result, first_sink) = first.await.unwrap();
3279        let (second_result, second_sink) = second.await.unwrap();
3280        first_result.unwrap();
3281        second_result.unwrap();
3282        assert_eq!(first_sink.text, first_session);
3283        assert_eq!(second_sink.text, second_session);
3284    }
3285
3286    #[test]
3287    fn explicit_authored_loop_is_validated_against_the_live_catalog() {
3288        let loop_spec = AgentLoopSpec::parse(
3289            "flow invalid -> string\n  $value = missing_operation()\n  return $value",
3290        )
3291        .expect("source parses before semantic validation");
3292        let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
3293            responses: Mutex::new(VecDeque::new()),
3294            requests: Arc::new(Mutex::new(Vec::new())),
3295        });
3296        let (engine, _) = assemble_test_engine(provider, loop_spec);
3297        let error = engine.err().expect("unknown op must fail assembly");
3298        assert!(error.to_string().contains("missing_operation"));
3299    }
3300
3301    #[tokio::test]
3302    async fn cancellation_keeps_a_valid_user_assistant_session_shape() {
3303        let provider: Arc<dyn Provider> = Arc::new(PendingProvider);
3304        let (engine, events) = assemble_test_engine(provider, AgentLoopSpec::default());
3305        let engine = engine.unwrap();
3306        let session = events.create_session("pending/test-model").unwrap();
3307        let mut sink = CollectSink::default();
3308        let cancel = CancellationToken::new();
3309        cancel.cancel();
3310
3311        engine
3312            .run_turn_cancellable(&session, "wait forever", &mut sink, &cancel)
3313            .await
3314            .unwrap();
3315
3316        let conversation = events.conversation(&session).unwrap();
3317        assert_eq!(conversation.len(), 2);
3318        assert_eq!(conversation[0].text(), "wait forever");
3319        assert_eq!(conversation[1].text(), "(turn cancelled)");
3320        assert_eq!(sink.ended, 1);
3321    }
3322}