1use 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#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub enum TurnEvent {
47 Phase(TurnPhase),
49 Step(Box<turnframe_understand::Step>),
52 StepSaid {
54 step: Box<turnframe_understand::Step>,
56 text: String,
58 },
59 Block(Box<ResponseBlock>),
61 Completed(Box<AssistantTurn>),
63 Failed {
65 code: String,
67 },
68}
69
70impl TurnEvent {
71 #[must_use]
73 pub fn block(&self) -> Option<&ResponseBlock> {
74 match self {
75 Self::Block(block) => Some(block),
76 _ => None,
77 }
78 }
79
80 #[must_use]
82 pub const fn is_terminal(&self) -> bool {
83 matches!(self, Self::Completed(_) | Self::Failed { .. })
84 }
85}
86
87#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct PublicationGate {
103 phase: TurnPhase,
104}
105
106impl PublicationGate {
107 #[must_use]
109 pub const fn new() -> Self {
110 Self {
111 phase: TurnPhase::Received,
112 }
113 }
114
115 #[must_use]
117 pub const fn at(phase: TurnPhase) -> Self {
118 Self { phase }
119 }
120
121 #[must_use]
123 pub const fn phase(&self) -> TurnPhase {
124 self.phase
125 }
126
127 pub const fn advance(&mut self, phase: TurnPhase) {
129 self.phase = phase;
130 }
131
132 #[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 _ => publishes_outcomes(self.phase),
148 }
149 }
150}
151
152impl Default for PublicationGate {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158pub trait TurnSink: Send + Sync {
160 fn emit(&self, event: TurnEvent);
162}
163
164#[derive(Debug, Clone, Copy, Default)]
166pub struct NullSink;
167
168impl TurnSink for NullSink {
169 fn emit(&self, _event: TurnEvent) {}
170}
171
172#[derive(Debug, Default)]
175pub struct RecordingSink {
176 events: Mutex<Vec<TurnEvent>>,
177}
178
179impl RecordingSink {
180 #[must_use]
182 pub fn new() -> Self {
183 Self::default()
184 }
185
186 #[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 #[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#[derive(Debug, Clone)]
218pub struct ChannelSink {
219 sender: mpsc::UnboundedSender<TurnEvent>,
220}
221
222impl TurnSink for ChannelSink {
223 fn emit(&self, event: TurnEvent) {
224 let _ = self.sender.send(event);
227 }
228}
229
230pub 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 #[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 #[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 #[must_use]
282 pub const fn is_live(&self) -> bool {
283 self.live
284 }
285
286 #[must_use]
288 pub fn phase(&self) -> TurnPhase {
289 self.gate
290 .lock()
291 .map_or(TurnPhase::Received, |gate| gate.phase())
292 }
293
294 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 pub fn publish_step(&self, step: turnframe_understand::Step) {
305 self.sink.emit(TurnEvent::Step(Box::new(step)));
306 }
307
308 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 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 pub fn blocks(&self, turn: &AssistantTurn) {
328 for block in &turn.blocks {
329 self.block(block);
330 }
331 }
332
333 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 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
348pub 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 #[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 #[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 #[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 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(¬ice_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(¬ice_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}