Skip to main content

turnframe_runtime/
stream.rs

1//! Safe streaming (spec §18.5).
2//!
3//! Streaming is where a careful runtime is most likely to lie, because the
4//! natural thing to send first is the sentence and the sentence is the part
5//! that is not true yet. So publication is gated by the turn's own phase:
6//!
7//! * **Nothing that states an outcome leaves before commit.** A
8//!   [`ResponseBlock::Receipt`], and every model-authored block, is refused by
9//!   [`PublicationGate`] until the turn reaches
10//!   [`TurnPhase::Committed`]. There is no configuration that relaxes it.
11//! * **Model-authored text goes out only once the plan is safe to publish.**
12//!   Which, for this runtime, is the same moment: composition runs after the
13//!   commit bundle has landed.
14//! * **Cards and receipts are atomic typed events.** They travel as whole
15//!   [`TurnEvent::Block`] values, never as a stream of characters a client
16//!   would have to reassemble into a button.
17//! * **A click-only turn returns immediately.** [`TurnStream::immediate`]
18//!   yields a finished turn with no model call behind it (§9).
19//!
20//! # Prose arrives whole
21//!
22//! A reply is reviewed before it is shown, so model-authored text is published as
23//! one finished [`TurnEvent::Block`], under the same gate as every other block.
24//!
25//! # Progress is not an outcome
26//!
27//! While the turn is interpreting and committing, the only thing on the wire is
28//! [`TurnEvent::Phase`]. It is what a client hangs an activity indicator on, and
29//! it says nothing about what will happen — which is exactly the point: a phase
30//! marker cannot be misread as a receipt.
31
32use std::collections::VecDeque;
33use std::fmt;
34use std::pin::Pin;
35use std::sync::{Arc, Mutex};
36use std::task::{Context, Poll};
37
38use futures::Stream;
39use tokio::sync::mpsc;
40use turnframe_core::replay::TurnPhase;
41use turnframe_core::response::{AssistantTurn, ResponseBlock};
42
43/// One thing a client learns while a turn is being handled.
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub enum TurnEvent {
47    /// The turn moved to a new phase. Progress, never an outcome.
48    Phase(TurnPhase),
49    /// One decision of the turn's understanding, as it was made. What was understood,
50    /// never what happened: it goes out before the commit, like a phase marker.
51    Step(Box<turnframe_understand::Step>),
52    /// A step said in the turn's language, when `NarrationConfig::steps` asks for it.
53    StepSaid {
54        /// The step.
55        step: Box<turnframe_understand::Step>,
56        /// The progress line a model wrote for it.
57        text: String,
58    },
59    /// One finished block, published atomically.
60    Block(Box<ResponseBlock>),
61    /// The turn is done; this is exactly what was persisted.
62    Completed(Box<AssistantTurn>),
63    /// The turn failed, with a stable code and no free text.
64    Failed {
65        /// Stable code from the error family.
66        code: String,
67    },
68}
69
70impl TurnEvent {
71    /// The block this event carries, if any.
72    #[must_use]
73    pub fn block(&self) -> Option<&ResponseBlock> {
74        match self {
75            Self::Block(block) => Some(block),
76            _ => None,
77        }
78    }
79
80    /// Returns `true` for the two events that end a stream.
81    #[must_use]
82    pub const fn is_terminal(&self) -> bool {
83        matches!(self, Self::Completed(_) | Self::Failed { .. })
84    }
85}
86
87/// Returns `true` once a turn's effects are settled, so a block that states an
88/// outcome may be published (spec §18.5).
89///
90/// [`TurnPhase::Executing`] is deliberately not one of them: effects *may*
91/// exist there, which is precisely the state in which nothing may be claimed.
92#[must_use]
93pub const fn publishes_outcomes(phase: TurnPhase) -> bool {
94    matches!(
95        phase,
96        TurnPhase::Committed | TurnPhase::Composed | TurnPhase::Delivered
97    )
98}
99
100/// Decides what may go on the wire at the phase the turn has reached.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct PublicationGate {
103    phase: TurnPhase,
104}
105
106impl PublicationGate {
107    /// A gate at the start of a turn.
108    #[must_use]
109    pub const fn new() -> Self {
110        Self {
111            phase: TurnPhase::Received,
112        }
113    }
114
115    /// A gate at an arbitrary phase.
116    #[must_use]
117    pub const fn at(phase: TurnPhase) -> Self {
118        Self { phase }
119    }
120
121    /// The phase the gate is at.
122    #[must_use]
123    pub const fn phase(&self) -> TurnPhase {
124        self.phase
125    }
126
127    /// Moves the gate to `phase`.
128    pub const fn advance(&mut self, phase: TurnPhase) {
129        self.phase = phase;
130    }
131
132    /// Whether `block` may be published now.
133    ///
134    /// Receipts and model-authored blocks wait for commit. Notices and cards do
135    /// not: a notice is a server statement about what is *not* happening, and a
136    /// card is a question, so neither can be mistaken for a success.
137    #[must_use]
138    pub fn admits(&self, block: &ResponseBlock) -> bool {
139        match block {
140            ResponseBlock::Notice(_) | ResponseBlock::Interaction(_) => true,
141            ResponseBlock::Receipt(_)
142            | ResponseBlock::Answer(_)
143            | ResponseBlock::Transition(_)
144            | ResponseBlock::Artifact(_) => publishes_outcomes(self.phase),
145            // A block kind added after this version is treated as if it could
146            // state an outcome, which is the only safe reading.
147            _ => publishes_outcomes(self.phase),
148        }
149    }
150}
151
152impl Default for PublicationGate {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158/// Receives the events of a turn as it runs.
159pub trait TurnSink: Send + Sync {
160    /// Delivers one event. Implementations must not block.
161    fn emit(&self, event: TurnEvent);
162}
163
164/// A sink that drops everything, for a caller that does not stream.
165#[derive(Debug, Clone, Copy, Default)]
166pub struct NullSink;
167
168impl TurnSink for NullSink {
169    fn emit(&self, _event: TurnEvent) {}
170}
171
172/// A sink that records every event, for tests and for a caller that wants the
173/// whole trace at the end.
174#[derive(Debug, Default)]
175pub struct RecordingSink {
176    events: Mutex<Vec<TurnEvent>>,
177}
178
179impl RecordingSink {
180    /// An empty recorder.
181    #[must_use]
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// Everything emitted so far, in order.
187    #[must_use]
188    pub fn events(&self) -> Vec<TurnEvent> {
189        self.events
190            .lock()
191            .map(|events| events.clone())
192            .unwrap_or_default()
193    }
194
195    /// The blocks emitted so far, in order.
196    #[must_use]
197    pub fn blocks(&self) -> Vec<ResponseBlock> {
198        self.events()
199            .into_iter()
200            .filter_map(|event| match event {
201                TurnEvent::Block(block) => Some(*block),
202                _ => None,
203            })
204            .collect()
205    }
206}
207
208impl TurnSink for RecordingSink {
209    fn emit(&self, event: TurnEvent) {
210        if let Ok(mut events) = self.events.lock() {
211            events.push(event);
212        }
213    }
214}
215
216/// A sink that forwards to a [`TurnStream`].
217#[derive(Debug, Clone)]
218pub struct ChannelSink {
219    sender: mpsc::UnboundedSender<TurnEvent>,
220}
221
222impl TurnSink for ChannelSink {
223    fn emit(&self, event: TurnEvent) {
224        // A closed receiver means the client went away; the turn carries on and
225        // still persists its answer.
226        let _ = self.sender.send(event);
227    }
228}
229
230/// Publishes a turn's events through a sink, under the [`PublicationGate`].
231///
232/// The gate is the reason this type exists rather than a bare sink: the
233/// orchestrator advances the phase as it goes, and a block offered too early is
234/// dropped instead of published, whatever the caller intended.
235pub struct TurnPublisher {
236    sink: Arc<dyn TurnSink>,
237    gate: Mutex<PublicationGate>,
238    live: bool,
239}
240
241impl turnframe_understand::StepSink for TurnPublisher {
242    fn step(&self, step: turnframe_understand::Step) {
243        self.publish_step(step);
244    }
245}
246
247impl fmt::Debug for TurnPublisher {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        f.debug_struct("TurnPublisher")
250            .field("phase", &self.phase())
251            .finish_non_exhaustive()
252    }
253}
254
255impl TurnPublisher {
256    /// A publisher writing to `sink`.
257    #[must_use]
258    pub fn new(sink: Arc<dyn TurnSink>) -> Self {
259        Self {
260            sink,
261            gate: Mutex::new(PublicationGate::new()),
262            live: true,
263        }
264    }
265
266    /// A publisher that drops everything.
267    #[must_use]
268    pub fn null() -> Self {
269        Self {
270            sink: Arc::new(NullSink),
271            gate: Mutex::new(PublicationGate::new()),
272            live: false,
273        }
274    }
275
276    /// Whether anything is listening: `false` for [`Self::null`].
277    ///
278    /// [`compose`](crate::compose) asks before it goes looking for a
279    /// streaming-capable provider, because a turn nobody is watching has no
280    /// reason to prefer one.
281    #[must_use]
282    pub const fn is_live(&self) -> bool {
283        self.live
284    }
285
286    /// The phase the gate is at.
287    #[must_use]
288    pub fn phase(&self) -> TurnPhase {
289        self.gate
290            .lock()
291            .map_or(TurnPhase::Received, |gate| gate.phase())
292    }
293
294    /// Announces a phase and moves the gate to it.
295    pub fn phase_reached(&self, phase: TurnPhase) {
296        if let Ok(mut gate) = self.gate.lock() {
297            gate.advance(phase);
298        }
299        self.sink.emit(TurnEvent::Phase(phase));
300    }
301
302    /// Publishes one step of the understanding. Steps are not outcomes, so the gate
303    /// never holds them back.
304    pub fn publish_step(&self, step: turnframe_understand::Step) {
305        self.sink.emit(TurnEvent::Step(Box::new(step)));
306    }
307
308    /// Publishes a step as a model said it.
309    pub fn step_said(&self, step: turnframe_understand::Step, text: String) {
310        self.sink.emit(TurnEvent::StepSaid {
311            step: Box::new(step),
312            text,
313        });
314    }
315
316    /// Publishes one block if the gate admits it, and returns whether it went
317    /// out.
318    pub fn block(&self, block: &ResponseBlock) -> bool {
319        let admitted = self.gate.lock().is_ok_and(|gate| gate.admits(block));
320        if admitted {
321            self.sink.emit(TurnEvent::Block(Box::new(block.clone())));
322        }
323        admitted
324    }
325
326    /// Publishes every block of a composed turn, in order.
327    pub fn blocks(&self, turn: &AssistantTurn) {
328        for block in &turn.blocks {
329            self.block(block);
330        }
331    }
332
333    /// Ends the stream with the turn that was persisted.
334    pub fn completed(&self, turn: &AssistantTurn) {
335        self.phase_reached(TurnPhase::Delivered);
336        self.sink.emit(TurnEvent::Completed(Box::new(turn.clone())));
337    }
338
339    /// Ends the stream with a stable failure code.
340    pub fn failed(&self, code: impl Into<String>) {
341        if let Ok(mut gate) = self.gate.lock() {
342            gate.advance(TurnPhase::Failed);
343        }
344        self.sink.emit(TurnEvent::Failed { code: code.into() });
345    }
346}
347
348/// The events of one turn, as a [`Stream`].
349///
350/// Two shapes come out of the same type: a live stream fed by a
351/// [`ChannelSink`], and a finished one built by [`TurnStream::immediate`] for a
352/// turn that needed no model call at all.
353pub struct TurnStream {
354    buffered: VecDeque<TurnEvent>,
355    receiver: Option<mpsc::UnboundedReceiver<TurnEvent>>,
356}
357
358impl fmt::Debug for TurnStream {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        f.debug_struct("TurnStream")
361            .field("buffered", &self.buffered.len())
362            .field("live", &self.receiver.is_some())
363            .finish()
364    }
365}
366
367impl TurnStream {
368    /// A live stream and the sink that feeds it.
369    #[must_use]
370    pub fn channel() -> (Self, ChannelSink) {
371        let (sender, receiver) = mpsc::unbounded_channel();
372        (
373            Self {
374                buffered: VecDeque::new(),
375                receiver: Some(receiver),
376            },
377            ChannelSink { sender },
378        )
379    }
380
381    /// A finished stream over a turn that is already composed.
382    ///
383    /// This is the click-only path of spec §18.5: the answer exists before the
384    /// client asked for a stream, so every block is published at once and the
385    /// stream ends.
386    #[must_use]
387    pub fn immediate(turn: AssistantTurn) -> Self {
388        let mut buffered = VecDeque::with_capacity(turn.blocks.len() + 2);
389        buffered.push_back(TurnEvent::Phase(TurnPhase::Delivered));
390        for block in &turn.blocks {
391            buffered.push_back(TurnEvent::Block(Box::new(block.clone())));
392        }
393        buffered.push_back(TurnEvent::Completed(Box::new(turn)));
394        Self {
395            buffered,
396            receiver: None,
397        }
398    }
399
400    /// A finished stream carrying only a failure.
401    #[must_use]
402    pub fn failed(code: impl Into<String>) -> Self {
403        let mut buffered = VecDeque::with_capacity(1);
404        buffered.push_back(TurnEvent::Failed { code: code.into() });
405        Self {
406            buffered,
407            receiver: None,
408        }
409    }
410
411    /// Drains the stream into a vector, for a caller that wants the trace
412    /// rather than the incremental delivery.
413    pub async fn collect_events(mut self) -> Vec<TurnEvent> {
414        let mut events: Vec<TurnEvent> = self.buffered.drain(..).collect();
415        if let Some(receiver) = self.receiver.as_mut() {
416            while let Some(event) = receiver.recv().await {
417                events.push(event);
418            }
419        }
420        events
421    }
422}
423
424impl Stream for TurnStream {
425    type Item = TurnEvent;
426
427    fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
428        let this = self.get_mut();
429        if let Some(event) = this.buffered.pop_front() {
430            return Poll::Ready(Some(event));
431        }
432        match this.receiver.as_mut() {
433            Some(receiver) => receiver.poll_recv(context),
434            None => Poll::Ready(None),
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use turnframe_core::event::{OperationalReceipt, ReceiptSeverity};
442    use turnframe_core::ids::{BlockId, ConversationId, EventId, ReceiptId, TurnId};
443    use turnframe_core::locale::LocalizedText;
444    use turnframe_core::response::{GeneratedTransition, ReceiptBlock, ServerNotice};
445
446    use super::*;
447
448    fn receipt_block() -> ResponseBlock {
449        ResponseBlock::Receipt(ReceiptBlock {
450            block_id: BlockId::from("receipt:1"),
451            receipt: OperationalReceipt {
452                receipt_id: ReceiptId::nil(),
453                event_ids: vec![EventId::nil()],
454                severity: ReceiptSeverity::Success,
455                title: LocalizedText::new("Sent"),
456                body: LocalizedText::new("It went out."),
457                status_code: "trip.rebooking_sent".to_owned(),
458                artifact_refs: Vec::new(),
459            },
460        })
461    }
462
463    fn transition_block() -> ResponseBlock {
464        ResponseBlock::Transition(GeneratedTransition {
465            block_id: BlockId::from("transition:0"),
466            text: "Right away.".to_owned(),
467            facts_used: Vec::new(),
468        })
469    }
470
471    fn notice_block() -> ResponseBlock {
472        ResponseBlock::Notice(ServerNotice {
473            block_id: BlockId::from("notice:x"),
474            code: "x".to_owned(),
475            severity: turnframe_core::response::NoticeSeverity::Info,
476            text: LocalizedText::new("nothing was submitted"),
477        })
478    }
479
480    #[test]
481    fn no_outcome_leaves_before_commit() {
482        for phase in [
483            TurnPhase::Received,
484            TurnPhase::Interpreted,
485            TurnPhase::Reduced,
486            TurnPhase::Executing,
487        ] {
488            let gate = PublicationGate::at(phase);
489            assert!(!gate.admits(&receipt_block()), "{phase:?}");
490            assert!(!gate.admits(&transition_block()), "{phase:?}");
491            assert!(gate.admits(&notice_block()), "{phase:?}");
492        }
493    }
494
495    #[test]
496    fn everything_is_publishable_once_the_turn_committed() {
497        let gate = PublicationGate::at(TurnPhase::Committed);
498        assert!(gate.admits(&receipt_block()));
499        assert!(gate.admits(&transition_block()));
500        assert!(gate.admits(&notice_block()));
501    }
502
503    #[test]
504    fn the_publisher_drops_a_block_offered_too_early() {
505        let sink = Arc::new(RecordingSink::new());
506        let publisher = TurnPublisher::new(sink.clone());
507        publisher.phase_reached(TurnPhase::Executing);
508        assert!(!publisher.block(&receipt_block()));
509        assert!(sink.blocks().is_empty());
510        publisher.phase_reached(TurnPhase::Committed);
511        assert!(publisher.block(&receipt_block()));
512        assert_eq!(sink.blocks().len(), 1);
513    }
514
515    #[tokio::test]
516    async fn a_click_only_turn_streams_without_waiting() {
517        let turn = AssistantTurn {
518            turn_id: TurnId::nil(),
519            conversation_id: ConversationId::nil(),
520            blocks: vec![notice_block()],
521            subjects: Vec::new(),
522            expectations: Vec::new(),
523            replay_token: turnframe_core::response::ReplayToken::from("t"),
524            done: Vec::new(),
525        };
526        let events = TurnStream::immediate(turn).collect_events().await;
527        assert_eq!(events.len(), 3);
528        assert!(events.last().is_some_and(TurnEvent::is_terminal));
529    }
530}