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