Skip to main content

agent_engine/runtime/
relay.rs

1//! CP-11 fix-2 (A): bounded caller-facing stream boundary.
2//!
3//! `Runtime::run_stream_with_messages` produces [`StreamEvent`]s from an
4//! internal turn task. Delivery to the CALLER is routed through this relay,
5//! which enforces a fixed retention policy so a slow or absent consumer can
6//! never make the runtime retain unbounded provider bytes:
7//!
8//! - the caller-facing channel is BOUNDED ([`RELAY_CHANNEL_CAPACITY`]
9//!   events, each delta payload chunked to [`RELAY_FORWARD_CHUNK_BYTES`]);
10//! - high-volume preview deltas (`LlmEvent::Text` / `Thinking` /
11//!   `ToolUseDelta` / `ToolResultDelta`) are COALESCED once retention
12//!   crosses [`RELAY_COALESCE_THRESHOLD_BYTES`] and DROPPED oldest-first
13//!   (exact byte accounting) once retention would exceed
14//!   [`RELAY_DELTA_RETAINED_BUDGET_BYTES`];
15//! - semantic terminal/control/tool-call events (tool_use, tool_result,
16//!   session history, errors, Done, agent events) are NEVER dropped or
17//!   reordered — their volume is structurally bounded by the turn budget
18//!   (calls × `max_tool_output`, rounds, one history per turn);
19//! - a dropped caller stream cancels the turn token so provider tasks are
20//!   released instead of streaming into the void.
21//!
22//! The internal producer hop stays an unbounded sender for API stability,
23//! but the relay drains it EAGERLY (it never awaits caller capacity while
24//! input is available), so retention there is scheduling-transient, not
25//! caller-dependent; all caller-dependent retention is governed by the
26//! budget above.
27
28use std::collections::VecDeque;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use tokio::sync::mpsc;
32use tokio_util::sync::CancellationToken;
33
34use crate::runtime::types::{LlmEvent, StreamEvent};
35
36/// Bounded caller-facing channel capacity, in events.
37pub const RELAY_CHANNEL_CAPACITY: usize = 64;
38/// Maximum delta payload bytes retained inside the relay while the caller
39/// is not consuming. Beyond this, oldest preview bytes are dropped.
40pub const RELAY_DELTA_RETAINED_BUDGET_BYTES: usize = 256 * 1024;
41/// Retained-delta level above which adjacent same-class deltas coalesce.
42/// Below it, event granularity is preserved for prompt consumers.
43pub const RELAY_COALESCE_THRESHOLD_BYTES: usize = 64 * 1024;
44/// Maximum delta payload bytes per forwarded event (large coalesced runs
45/// are re-chunked), bounding bytes parked in the caller channel itself.
46pub const RELAY_FORWARD_CHUNK_BYTES: usize = 16 * 1024;
47
48static PRODUCED_DELTA_BYTES: AtomicU64 = AtomicU64::new(0);
49static FORWARDED_DELTA_BYTES: AtomicU64 = AtomicU64::new(0);
50static DROPPED_DELTA_BYTES: AtomicU64 = AtomicU64::new(0);
51static RETAINED_DELTA_BYTES: AtomicU64 = AtomicU64::new(0);
52static PEAK_RETAINED_DELTA_BYTES: AtomicU64 = AtomicU64::new(0);
53static ACTIVE_RELAYS: AtomicU64 = AtomicU64::new(0);
54
55/// Point-in-time view of the global relay accounting. At quiescence,
56/// produced bytes equal forwarded plus dropped plus retained bytes;
57/// `retained` returns to zero when every relay has terminated.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct RelaySnapshot {
60    pub produced_delta_bytes: u64,
61    pub forwarded_delta_bytes: u64,
62    pub dropped_delta_bytes: u64,
63    pub retained_delta_bytes: u64,
64    pub peak_retained_delta_bytes: u64,
65    pub active_relays: u64,
66}
67
68pub fn stream_relay_snapshot() -> RelaySnapshot {
69    RelaySnapshot {
70        produced_delta_bytes: PRODUCED_DELTA_BYTES.load(Ordering::SeqCst),
71        forwarded_delta_bytes: FORWARDED_DELTA_BYTES.load(Ordering::SeqCst),
72        dropped_delta_bytes: DROPPED_DELTA_BYTES.load(Ordering::SeqCst),
73        retained_delta_bytes: RETAINED_DELTA_BYTES.load(Ordering::SeqCst),
74        peak_retained_delta_bytes: PEAK_RETAINED_DELTA_BYTES.load(Ordering::SeqCst),
75        active_relays: ACTIVE_RELAYS.load(Ordering::SeqCst),
76    }
77}
78
79/// Droppable preview payload length, or `None` for semantic events.
80fn delta_len(event: &StreamEvent) -> Option<usize> {
81    match event {
82        StreamEvent::Llm(LlmEvent::Text(s)) | StreamEvent::Llm(LlmEvent::Thinking(s)) => {
83            Some(s.len())
84        }
85        StreamEvent::Llm(LlmEvent::ToolUseDelta { delta, .. })
86        | StreamEvent::Llm(LlmEvent::ToolResultDelta { delta, .. }) => Some(delta.len()),
87        _ => None,
88    }
89}
90
91/// Merge `incoming` into `tail` when both are the SAME preview class (and
92/// same call id for tool deltas). Returns false when not coalescible.
93fn try_coalesce(tail: &mut StreamEvent, incoming: &StreamEvent) -> bool {
94    match (tail, incoming) {
95        (StreamEvent::Llm(LlmEvent::Text(a)), StreamEvent::Llm(LlmEvent::Text(b))) => {
96            a.push_str(b);
97            true
98        }
99        (StreamEvent::Llm(LlmEvent::Thinking(a)), StreamEvent::Llm(LlmEvent::Thinking(b))) => {
100            a.push_str(b);
101            true
102        }
103        (
104            StreamEvent::Llm(LlmEvent::ToolUseDelta {
105                tool_id: ia,
106                delta: da,
107            }),
108            StreamEvent::Llm(LlmEvent::ToolUseDelta {
109                tool_id: ib,
110                delta: db,
111            }),
112        ) if ia == ib => {
113            da.push_str(db);
114            true
115        }
116        (
117            StreamEvent::Llm(LlmEvent::ToolResultDelta {
118                tool_id: ia,
119                delta: da,
120            }),
121            StreamEvent::Llm(LlmEvent::ToolResultDelta {
122                tool_id: ib,
123                delta: db,
124            }),
125        ) if ia == ib => {
126            da.push_str(db);
127            true
128        }
129        _ => false,
130    }
131}
132
133/// Largest char boundary ≤ `n` (never 0 unless the string is empty or the
134/// first char is longer than `n`, in which case the smallest boundary > 0
135/// is used so progress is always made).
136fn split_boundary(s: &str, n: usize) -> usize {
137    let mut i = n.min(s.len());
138    while i > 0 && !s.is_char_boundary(i) {
139        i -= 1;
140    }
141    if i == 0 && !s.is_empty() {
142        i = 1;
143        while i < s.len() && !s.is_char_boundary(i) {
144            i += 1;
145        }
146    }
147    i
148}
149
150/// Trim AT LEAST `want` bytes from the front of `s` (char-boundary safe);
151/// returns the exact number of bytes removed.
152fn trim_front(s: &mut String, want: usize) -> usize {
153    let mut cut = want.min(s.len());
154    while cut < s.len() && !s.is_char_boundary(cut) {
155        cut += 1;
156    }
157    s.drain(..cut);
158    cut
159}
160
161fn payload_mut(event: &mut StreamEvent) -> Option<&mut String> {
162    match event {
163        StreamEvent::Llm(LlmEvent::Text(s)) | StreamEvent::Llm(LlmEvent::Thinking(s)) => Some(s),
164        StreamEvent::Llm(LlmEvent::ToolUseDelta { delta, .. })
165        | StreamEvent::Llm(LlmEvent::ToolResultDelta { delta, .. }) => Some(delta),
166        _ => None,
167    }
168}
169
170/// Rebuild the same delta variant carrying `chunk`.
171fn with_payload(event: &StreamEvent, chunk: String) -> StreamEvent {
172    match event {
173        StreamEvent::Llm(LlmEvent::Text(_)) => StreamEvent::Llm(LlmEvent::Text(chunk)),
174        StreamEvent::Llm(LlmEvent::Thinking(_)) => StreamEvent::Llm(LlmEvent::Thinking(chunk)),
175        StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, .. }) => {
176            StreamEvent::Llm(LlmEvent::ToolUseDelta {
177                tool_id: tool_id.clone(),
178                delta: chunk,
179            })
180        }
181        StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id, .. }) => {
182            StreamEvent::Llm(LlmEvent::ToolResultDelta {
183                tool_id: tool_id.clone(),
184                delta: chunk,
185            })
186        }
187        _ => unreachable!("with_payload is only called for delta events"),
188    }
189}
190
191struct RelayState {
192    pending: VecDeque<StreamEvent>,
193    /// Delta payload bytes currently held in `pending` (mirrors the global
194    /// RETAINED gauge contribution of THIS relay).
195    retained: usize,
196}
197
198impl RelayState {
199    fn new() -> Self {
200        ACTIVE_RELAYS.fetch_add(1, Ordering::SeqCst);
201        Self {
202            pending: VecDeque::new(),
203            retained: 0,
204        }
205    }
206
207    fn ingest(&mut self, event: StreamEvent) {
208        if let Some(bytes) = delta_len(&event) {
209            PRODUCED_DELTA_BYTES.fetch_add(bytes as u64, Ordering::SeqCst);
210            // Make room FIRST: the retained gauge must never exceed the
211            // fixed budget, even between two statements.
212            self.make_room_for(bytes);
213            let mut event = event;
214            if bytes > RELAY_DELTA_RETAINED_BUDGET_BYTES {
215                // A single oversized delta: keep only its newest bytes.
216                let payload = payload_mut(&mut event).expect("delta payload");
217                let cut = trim_front(payload, bytes - RELAY_DELTA_RETAINED_BUDGET_BYTES);
218                DROPPED_DELTA_BYTES.fetch_add(cut as u64, Ordering::SeqCst);
219            }
220            let kept = delta_len(&event).expect("delta payload");
221            self.add_retained(kept);
222            let coalesced = self.retained > RELAY_COALESCE_THRESHOLD_BYTES
223                && self
224                    .pending
225                    .back_mut()
226                    .is_some_and(|tail| try_coalesce(tail, &event));
227            if !coalesced {
228                self.pending.push_back(event);
229            }
230        } else {
231            self.pending.push_back(event);
232        }
233    }
234
235    fn add_retained(&mut self, bytes: usize) {
236        self.retained += bytes;
237        let now = RETAINED_DELTA_BYTES.fetch_add(bytes as u64, Ordering::SeqCst) + bytes as u64;
238        PEAK_RETAINED_DELTA_BYTES.fetch_max(now, Ordering::SeqCst);
239    }
240
241    fn sub_retained(&mut self, bytes: usize) {
242        self.retained -= bytes;
243        RETAINED_DELTA_BYTES.fetch_sub(bytes as u64, Ordering::SeqCst);
244    }
245
246    /// Drop OLDEST preview bytes (whole events, then a front-trim of the
247    /// oldest survivor) until `incoming` more bytes fit the fixed budget.
248    /// Semantic events are never touched.
249    fn make_room_for(&mut self, incoming: usize) {
250        let need = incoming.min(RELAY_DELTA_RETAINED_BUDGET_BYTES);
251        while self.retained + need > RELAY_DELTA_RETAINED_BUDGET_BYTES {
252            let Some(index) = self
253                .pending
254                .iter()
255                .position(|event| delta_len(event).is_some())
256            else {
257                break;
258            };
259            let over = self.retained + need - RELAY_DELTA_RETAINED_BUDGET_BYTES;
260            let len = delta_len(&self.pending[index]).expect("droppable by position");
261            if len <= over {
262                self.pending.remove(index);
263                self.sub_retained(len);
264                DROPPED_DELTA_BYTES.fetch_add(len as u64, Ordering::SeqCst);
265            } else {
266                let payload = payload_mut(&mut self.pending[index]).expect("delta payload");
267                let cut = trim_front(payload, over);
268                self.sub_retained(cut);
269                DROPPED_DELTA_BYTES.fetch_add(cut as u64, Ordering::SeqCst);
270            }
271        }
272    }
273
274    /// Next event to hand to the caller: semantic events go through whole;
275    /// delta payloads larger than the forward chunk are split, with the
276    /// remainder kept at the FRONT so order and content are preserved.
277    fn next_out(&mut self) -> Option<StreamEvent> {
278        let mut event = self.pending.pop_front()?;
279        match delta_len(&event) {
280            None => Some(event),
281            Some(len) if len <= RELAY_FORWARD_CHUNK_BYTES => {
282                self.sub_retained(len);
283                FORWARDED_DELTA_BYTES.fetch_add(len as u64, Ordering::SeqCst);
284                Some(event)
285            }
286            Some(_) => {
287                let payload = payload_mut(&mut event).expect("delta payload");
288                let cut = split_boundary(payload, RELAY_FORWARD_CHUNK_BYTES);
289                let remainder = payload.split_off(cut);
290                let chunk = std::mem::take(payload);
291                if !remainder.is_empty() {
292                    let rest = with_payload(&event, remainder);
293                    self.pending.push_front(rest);
294                }
295                self.sub_retained(chunk.len());
296                FORWARDED_DELTA_BYTES.fetch_add(chunk.len() as u64, Ordering::SeqCst);
297                Some(with_payload(&event, chunk))
298            }
299        }
300    }
301}
302
303impl Drop for RelayState {
304    fn drop(&mut self) {
305        // Whatever preview bytes were still parked when the relay ended
306        // (caller gone / cancellation) are accounted as dropped so the
307        // conservation identity holds and the gauge returns to zero.
308        if self.retained > 0 {
309            DROPPED_DELTA_BYTES.fetch_add(self.retained as u64, Ordering::SeqCst);
310            RETAINED_DELTA_BYTES.fetch_sub(self.retained as u64, Ordering::SeqCst);
311            self.retained = 0;
312        }
313        ACTIVE_RELAYS.fetch_sub(1, Ordering::SeqCst);
314    }
315}
316
317/// Spawn the bounded relay between the runtime's internal event producer and
318/// the caller-facing stream. When the caller drops the returned receiver,
319/// `cancel` is cancelled so the producing turn (provider requests, tool
320/// tasks) is released.
321pub(crate) fn spawn_bounded_stream_relay(
322    mut rx_in: mpsc::UnboundedReceiver<StreamEvent>,
323    cancel: CancellationToken,
324) -> mpsc::Receiver<StreamEvent> {
325    let (tx_out, rx_out) = mpsc::channel(RELAY_CHANNEL_CAPACITY);
326    tokio::spawn(async move {
327        let mut state = RelayState::new();
328        let mut input_open = true;
329        // Per-iteration drain burst: large enough to keep the inner hop
330        // empty, small enough to interleave forwarding fairly.
331        const DRAIN_BURST: usize = 128;
332        loop {
333            // 1. Eagerly drain whatever the producer already queued — this
334            //    never waits on the caller, so inner-hop retention is
335            //    scheduling-transient and policy-bounded here.
336            let mut drained = 0;
337            while input_open && drained < DRAIN_BURST {
338                match rx_in.try_recv() {
339                    Ok(event) => {
340                        state.ingest(event);
341                        drained += 1;
342                    }
343                    Err(mpsc::error::TryRecvError::Empty) => break,
344                    Err(mpsc::error::TryRecvError::Disconnected) => input_open = false,
345                }
346            }
347            // 2. Forward as much as the caller's bounded capacity allows,
348            //    without blocking.
349            while !state.pending.is_empty() {
350                match tx_out.try_reserve() {
351                    Ok(permit) => {
352                        if let Some(event) = state.next_out() {
353                            permit.send(event);
354                        }
355                    }
356                    Err(mpsc::error::TrySendError::Full(())) => break,
357                    Err(mpsc::error::TrySendError::Closed(())) => {
358                        // Caller went away: release the producing turn.
359                        cancel.cancel();
360                        return;
361                    }
362                }
363            }
364            // 3. Wait for whichever side can make progress next.
365            if !input_open && state.pending.is_empty() {
366                return; // producer finished and everything was delivered
367            }
368            if input_open {
369                if state.pending.is_empty() {
370                    tokio::select! {
371                        maybe = rx_in.recv() => match maybe {
372                            Some(event) => state.ingest(event),
373                            None => input_open = false,
374                        },
375                        // Idle producer + departed caller: release the turn
376                        // instead of waiting for the next event.
377                        _ = tx_out.closed() => {
378                            cancel.cancel();
379                            return;
380                        }
381                    }
382                } else {
383                    tokio::select! {
384                        maybe = rx_in.recv() => match maybe {
385                            Some(event) => state.ingest(event),
386                            None => input_open = false,
387                        },
388                        permit = tx_out.reserve() => match permit {
389                            Ok(permit) => {
390                                if let Some(event) = state.next_out() {
391                                    permit.send(event);
392                                }
393                            }
394                            Err(_) => {
395                                cancel.cancel();
396                                return;
397                            }
398                        },
399                    }
400                }
401            } else {
402                // Input closed: flush the remaining backlog at the caller's
403                // pace (semantic terminal events must arrive).
404                if let Some(event) = state.next_out() {
405                    if tx_out.send(event).await.is_err() {
406                        cancel.cancel();
407                        return;
408                    }
409                }
410            }
411        }
412    });
413    rx_out
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use serial_test::serial;
420    use std::time::{Duration, Instant};
421
422    const CHUNK: usize = 4096;
423
424    fn text(bytes: usize) -> StreamEvent {
425        StreamEvent::Llm(LlmEvent::Text("x".repeat(bytes)))
426    }
427
428    async fn wait_until(deadline: Duration, mut check: impl FnMut() -> bool) -> bool {
429        let end = Instant::now() + deadline;
430        while Instant::now() < end {
431            if check() {
432                return true;
433            }
434            tokio::time::sleep(Duration::from_millis(10)).await;
435        }
436        check()
437    }
438
439    /// A hostile flood with a STALLED consumer must keep relay retention
440    /// within the fixed budget, account every dropped byte, and conserve
441    /// produced == forwarded + dropped once the relay terminates.
442    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
443    #[serial(stream_relay)]
444    async fn flood_with_stalled_consumer_keeps_retention_within_fixed_budget() {
445        let base = stream_relay_snapshot();
446        let (tx, rx_in) = mpsc::unbounded_channel();
447        let cancel = CancellationToken::new();
448        let mut rx_out = spawn_bounded_stream_relay(rx_in, cancel);
449
450        const EVENTS: usize = 10_000; // ~40 MiB of preview deltas
451        for _ in 0..EVENTS {
452            tx.send(text(CHUNK)).unwrap();
453        }
454        let produced_target = (EVENTS * CHUNK) as u64;
455        assert!(
456            wait_until(Duration::from_secs(20), || {
457                stream_relay_snapshot().produced_delta_bytes - base.produced_delta_bytes
458                    == produced_target
459            })
460            .await,
461            "relay must ingest the full flood without caller progress"
462        );
463        let during = stream_relay_snapshot();
464        assert!(
465            during.retained_delta_bytes - base.retained_delta_bytes
466                <= RELAY_DELTA_RETAINED_BUDGET_BYTES as u64,
467            "stalled-consumer retention must stay within the fixed budget: {}",
468            during.retained_delta_bytes - base.retained_delta_bytes
469        );
470        assert!(
471            during.dropped_delta_bytes > base.dropped_delta_bytes,
472            "a flood beyond the budget must record dropped preview bytes"
473        );
474
475        // Consumer wakes up after producer hangup: bounded total delivery.
476        drop(tx);
477        let mut delivered = 0usize;
478        while let Some(event) = rx_out.recv().await {
479            delivered += delta_len(&event).unwrap_or(0);
480        }
481        assert!(
482            delivered
483                <= RELAY_DELTA_RETAINED_BUDGET_BYTES
484                    + RELAY_CHANNEL_CAPACITY * RELAY_FORWARD_CHUNK_BYTES
485                    + CHUNK,
486            "late delivery must be bounded by budget + channel capacity, got {delivered}"
487        );
488        assert!(delivered > 0, "the newest preview bytes must survive");
489        assert!(
490            wait_until(Duration::from_secs(5), || {
491                stream_relay_snapshot().active_relays == base.active_relays
492            })
493            .await,
494            "relay task must terminate"
495        );
496        let end = stream_relay_snapshot();
497        assert_eq!(
498            end.produced_delta_bytes - base.produced_delta_bytes,
499            (end.forwarded_delta_bytes - base.forwarded_delta_bytes)
500                + (end.dropped_delta_bytes - base.dropped_delta_bytes),
501            "conservation: every produced preview byte is forwarded or dropped"
502        );
503        assert_eq!(end.retained_delta_bytes, base.retained_delta_bytes);
504    }
505
506    /// Semantic control/terminal/tool-call events are NEVER dropped by the
507    /// flood policy and arrive in order.
508    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
509    #[serial(stream_relay)]
510    async fn semantic_events_survive_flood_in_order() {
511        let (tx, rx_in) = mpsc::unbounded_channel();
512        let cancel = CancellationToken::new();
513        let mut rx_out = spawn_bounded_stream_relay(rx_in, cancel);
514
515        let semantic = [
516            StreamEvent::Llm(LlmEvent::ToolUseStart {
517                tool_name: "bash".into(),
518                tool_id: "toolu_s1".into(),
519            }),
520            StreamEvent::Llm(LlmEvent::ToolUse {
521                tool_name: "bash".into(),
522                tool_id: "toolu_s1".into(),
523                input: serde_json::json!({"command": "true"}),
524            }),
525            StreamEvent::Llm(LlmEvent::ToolResult {
526                tool_id: "toolu_s1".into(),
527                result: "ok".into(),
528            }),
529            StreamEvent::Session(crate::runtime::types::SessionEvent::Done),
530        ];
531        for event in &semantic {
532            for _ in 0..2_000 {
533                tx.send(text(CHUNK)).unwrap(); // ~8 MiB flood before each
534            }
535            tx.send(event.clone()).unwrap();
536        }
537        drop(tx);
538
539        let mut seen = Vec::new();
540        while let Some(event) = rx_out.recv().await {
541            match &event {
542                StreamEvent::Llm(LlmEvent::ToolUseStart { tool_id, .. }) => {
543                    seen.push(format!("start:{tool_id}"))
544                }
545                StreamEvent::Llm(LlmEvent::ToolUse { tool_id, .. }) => {
546                    seen.push(format!("use:{tool_id}"))
547                }
548                StreamEvent::Llm(LlmEvent::ToolResult { tool_id, .. }) => {
549                    seen.push(format!("result:{tool_id}"))
550                }
551                StreamEvent::Session(crate::runtime::types::SessionEvent::Done) => {
552                    seen.push("done".into())
553                }
554                _ => {}
555            }
556        }
557        assert_eq!(
558            seen,
559            vec!["start:toolu_s1", "use:toolu_s1", "result:toolu_s1", "done"],
560            "every semantic event must survive the flood, in order"
561        );
562    }
563
564    /// Dropping the caller stream cancels the turn token (releasing the
565    /// producing provider tasks) and terminates the relay.
566    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
567    #[serial(stream_relay)]
568    async fn caller_drop_cancels_turn_token_and_ends_relay() {
569        let base = stream_relay_snapshot();
570        let (tx, rx_in) = mpsc::unbounded_channel();
571        let cancel = CancellationToken::new();
572        let rx_out = spawn_bounded_stream_relay(rx_in, cancel.clone());
573        drop(rx_out);
574
575        assert!(
576            wait_until(Duration::from_secs(5), || cancel.is_cancelled()).await,
577            "caller drop must cancel the turn token"
578        );
579        assert!(
580            wait_until(Duration::from_secs(5), || {
581                stream_relay_snapshot().active_relays == base.active_relays
582            })
583            .await,
584            "relay must terminate after the caller departs"
585        );
586        drop(tx);
587    }
588
589    /// A promptly-consuming caller keeps full delta granularity: no
590    /// coalescing below the threshold, exact payloads, exact order.
591    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
592    #[serial(stream_relay)]
593    async fn prompt_consumer_preserves_delta_granularity() {
594        let (tx, rx_in) = mpsc::unbounded_channel();
595        let cancel = CancellationToken::new();
596        let mut rx_out = spawn_bounded_stream_relay(rx_in, cancel);
597
598        for part in ["alpha ", "beta ", "gamma ", "delta ", "epsilon"] {
599            tx.send(StreamEvent::Llm(LlmEvent::Text(part.into())))
600                .unwrap();
601        }
602        drop(tx);
603        let mut parts = Vec::new();
604        while let Some(event) = rx_out.recv().await {
605            match event {
606                StreamEvent::Llm(LlmEvent::Text(s)) => parts.push(s),
607                other => panic!("unexpected event {other:?}"),
608            }
609        }
610        assert_eq!(
611            parts.concat(),
612            "alpha beta gamma delta epsilon",
613            "prompt consumers must observe complete text"
614        );
615        assert!(
616            parts.len() >= 2,
617            "small deltas below the coalesce threshold keep granularity"
618        );
619    }
620}