Skip to main content

cratefield_core/sse/
ui_message.rs

1//! The `Vercel` AI SDK v6 "UI message stream" (issue #860): the chunk
2//! vocabulary and wire encoding a `useChat` frontend consumes, written
3//! here so a harness route answers a browser chat the AI SDK parses
4//! without a TypeScript build step on this side.
5//!
6//! The wire is plain Server-Sent Events — one `data:` line of JSON per
7//! [`UiMessageChunk`], framed by [`SseEvent::encode`](crate::sse::SseEvent::encode),
8//! the stream closed by a literal `data: [DONE]` — and the response names
9//! its dialect with the [`UI_MESSAGE_STREAM_HEADER`] header set to
10//! [`UI_MESSAGE_STREAM_VERSION`]. Chunk fields are the SDK's camelCase
11//! `looseObject`s: a field the Rust side carries as `Option` is left off
12//! the object entirely when `None`, never sent as `null`.
13//!
14//! [`UiMessageEncoder`] turns what this crate already streams into those
15//! chunks while keeping the parts well-formed: a
16//! [`TextDelta`] stream or a
17//! [`ToolLoopEvent`] stream becomes a `start`, then
18//! one step's `text-*` / `reasoning-*` / `tool-input-*` parts, a
19//! `finish-step` per step and a closing `finish` — with part ids
20//! (`text-0`, `reasoning-0`, `text-1`, …) assigned deterministically, so
21//! the same run always encodes to the same bytes. The committed fixtures
22//! under `tests/fixtures/ui-message-stream/v1` are that determinism made
23//! checkable, on both sides of the language border.
24//!
25//! The stream adapters [`text_stream_chunks`] and [`tool_loop_chunks`]
26//! wrap the two source streams end to end, and [`ui_message_response`]
27//! answers a route with the encoded stream. Errors are masked by default
28//! — [`MASKED_ERROR_TEXT`] on the wire, the detail in the log — the same
29//! policy the problem+json answers take, for the same reason: provider
30//! and transport text can quote a user's prompt straight back. A caller
31//! that wants the detail on the wire pushes its own
32//! [`UiMessageChunk::Error`].
33
34use std::collections::HashMap;
35use std::pin::Pin;
36use std::sync::Arc;
37
38use futures_core::Stream;
39use futures_util::StreamExt;
40use http::{HeaderName, HeaderValue};
41use serde::{Serialize, Serializer};
42use serde_json::{Map, Value};
43
44use crate::ports::{Clock, FinishReason, TextDelta, TextModelError};
45use crate::sse::{SseEvent, SseResponse};
46use crate::stream::BoxStream;
47use crate::tool_loop::{ToolLoopError, ToolLoopEvent};
48
49/// The response header that tells an AI SDK client which chunk dialect
50/// this stream speaks.
51pub const UI_MESSAGE_STREAM_HEADER: &str = "x-vercel-ai-ui-message-stream";
52
53/// The only dialect this module writes.
54pub const UI_MESSAGE_STREAM_VERSION: &str = "v1";
55
56/// The end-of-stream marker [`ui_message_response`] appends after the
57/// last chunk, and the last line of every fixture file.
58const DONE_MARKER: &str = "[DONE]";
59
60/// The error text a stream adapter puts in an
61/// [`UiMessageChunk::Error`] when the real error is only logged. The
62/// sentence the AI SDK answers with by default: a provider's own words
63/// can quote a user's prompt back, so they go to the log, not the wire.
64/// A caller that wants the detail on the wire emits its own
65/// [`UiMessageChunk::Error`] instead of relying on the adapters.
66pub const MASKED_ERROR_TEXT: &str = "An error occurred.";
67
68/// Why a message ended, in the vocabulary the `finish` chunk carries.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum UiFinishReason {
71    /// The model finished its answer on its own.
72    Stop,
73    /// The answer was cut off at its token ceiling.
74    Length,
75    /// The model ended its turn by asking for tool calls.
76    ToolCalls,
77    /// The provider withheld or truncated content under its own policy.
78    ContentFilter,
79    /// The message died in an error.
80    Error,
81    /// A reason this vocabulary does not name.
82    Other,
83}
84
85impl UiFinishReason {
86    /// The word the wire carries.
87    #[must_use]
88    pub fn as_str(&self) -> &'static str {
89        match self {
90            Self::Stop => "stop",
91            Self::Length => "length",
92            Self::ToolCalls => "tool-calls",
93            Self::ContentFilter => "content-filter",
94            Self::Error => "error",
95            Self::Other => "other",
96        }
97    }
98}
99
100impl Serialize for UiFinishReason {
101    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
102        serializer.serialize_str(self.as_str())
103    }
104}
105
106impl From<&FinishReason> for UiFinishReason {
107    fn from(reason: &FinishReason) -> Self {
108        match reason {
109            FinishReason::Stop => Self::Stop,
110            FinishReason::Length => Self::Length,
111            FinishReason::ToolUse => Self::ToolCalls,
112            FinishReason::ContentFilter => Self::ContentFilter,
113            // `FinishReason` is non_exhaustive and the SDK's word for a
114            // reason its vocabulary does not name is `other`.
115            _ => Self::Other,
116        }
117    }
118}
119
120/// One chunk of a UI message stream — one `data:` line of JSON on the
121/// wire. The variant names are the SDK's chunk types, the fields its
122/// camelCase ones; an `Option` field is left out of the object when
123/// `None`. Serialisation goes through a serde-derived mirror, with
124/// [`Self::Data`] built by hand because its type tag is its own name.
125#[derive(Debug, Clone, PartialEq)]
126#[non_exhaustive]
127pub enum UiMessageChunk {
128    /// Opens the message.
129    Start {
130        /// The id the client uses for the message it is building.
131        message_id: Option<String>,
132        /// Metadata the caller wants to ride on the message.
133        message_metadata: Option<Value>,
134    },
135    /// Closes the message. Nothing goes on the wire after it.
136    Finish {
137        /// Why the message ended.
138        finish_reason: Option<UiFinishReason>,
139        /// Metadata the caller wants to ride on the message.
140        message_metadata: Option<Value>,
141    },
142    /// Opens a step — one model call's worth of parts.
143    StartStep,
144    /// Closes the step the previous `start-step` opened.
145    FinishStep,
146    /// Opens the text part `id`.
147    TextStart {
148        /// The part's id, `text-0`, `text-1`, … from the encoder.
149        id: String,
150    },
151    /// Grows the text part `id`.
152    TextDelta {
153        /// The part's id.
154        id: String,
155        /// The next run of text.
156        delta: String,
157    },
158    /// Closes the text part `id`.
159    TextEnd {
160        /// The part's id.
161        id: String,
162    },
163    /// Opens the reasoning part `id`.
164    ReasoningStart {
165        /// The part's id, `reasoning-0`, `reasoning-1`, … from the
166        /// encoder.
167        id: String,
168    },
169    /// Grows the reasoning part `id`.
170    ReasoningDelta {
171        /// The part's id.
172        id: String,
173        /// The next run of reasoning.
174        delta: String,
175    },
176    /// Closes the reasoning part `id`.
177    ReasoningEnd {
178        /// The part's id.
179        id: String,
180    },
181    /// Announces the tool call `tool_call_id`; its arguments follow as
182    /// `tool-input-delta`s.
183    ToolInputStart {
184        /// The provider's id for the call.
185        tool_call_id: String,
186        /// The tool being called.
187        tool_name: String,
188    },
189    /// Grows the arguments of the call `tool_call_id`.
190    ToolInputDelta {
191        /// The call's id.
192        tool_call_id: String,
193        /// The next piece of the arguments JSON.
194        input_text_delta: String,
195    },
196    /// The call `tool_call_id` is complete, arguments and all.
197    ToolInputAvailable {
198        /// The call's id.
199        tool_call_id: String,
200        /// The tool that was called.
201        tool_name: String,
202        /// The parsed arguments object.
203        input: Value,
204    },
205    /// The result of running the call `tool_call_id`.
206    ToolOutputAvailable {
207        /// The call's id.
208        tool_call_id: String,
209        /// The tool's output, as JSON.
210        output: Value,
211    },
212    /// The call `tool_call_id` failed; the model was shown `error_text`.
213    ToolOutputError {
214        /// The call's id.
215        tool_call_id: String,
216        /// The error the model was shown.
217        error_text: String,
218    },
219    /// A source the answer drew on, by URL.
220    SourceUrl {
221        /// The source's id.
222        source_id: String,
223        /// Where the source lives.
224        url: String,
225        /// A display name, where there is one.
226        title: Option<String>,
227    },
228    /// A caller-defined `data-<name>` chunk: the type tag on the wire is
229    /// the name joined to the prefix, which is why the enum serialises by
230    /// hand.
231    Data {
232        /// The name after the `data-` prefix.
233        name: String,
234        /// An id, for a client that updates one data part in place.
235        id: Option<String>,
236        /// The payload, any JSON.
237        data: Value,
238        /// Whether the client should keep this only for the live stream
239        /// and not persist it into the message.
240        transient: Option<bool>,
241    },
242    /// The message died in an error. Nothing goes on the wire after it.
243    Error {
244        /// What went wrong, already scrubbed of anything that should not
245        /// reach a client.
246        error_text: String,
247    },
248    /// The message was aborted by the caller.
249    Abort,
250    /// Metadata for the message, mid-stream.
251    MessageMetadata {
252        /// The metadata.
253        message_metadata: Value,
254    },
255}
256
257impl UiMessageChunk {
258    /// The chunk as the SSE event the wire carries: no `event:` name and
259    /// no `id:` — the SDK's parser reads `data:` lines only — and the
260    /// JSON on the single data line.
261    ///
262    /// # Panics
263    ///
264    /// Only if `serde_json` refuses to serialise the chunk's JSON — which
265    /// these objects cannot provoke (every key is a string, every value a
266    /// [`Value`]), so the `expect` documents an invariant rather than a
267    /// reachable failure.
268    #[must_use]
269    pub fn to_sse_event(&self) -> SseEvent {
270        SseEvent::new(serde_json::to_string(self).expect("a chunk serialises to JSON"))
271    }
272}
273
274/// The wire shape of the chunks whose tag is the variant's own name,
275/// derived: `serde` writes the kebab-case `type` tag first and the
276/// camelCase fields after it, leaving every `None` out — the exact object
277/// [`UiMessageChunk`] serialises to, borrowed so serialising copies
278/// nothing but the output bytes. [`UiMessageChunk::Data`] stays outside
279/// it: its tag is the caller's name, not a variant name.
280#[derive(Serialize)]
281#[serde(
282    tag = "type",
283    rename_all = "kebab-case",
284    rename_all_fields = "camelCase"
285)]
286enum Wire<'a> {
287    Start {
288        #[serde(skip_serializing_if = "Option::is_none")]
289        message_id: Option<&'a str>,
290        #[serde(skip_serializing_if = "Option::is_none")]
291        message_metadata: Option<&'a Value>,
292    },
293    Finish {
294        #[serde(skip_serializing_if = "Option::is_none")]
295        finish_reason: Option<&'a UiFinishReason>,
296        #[serde(skip_serializing_if = "Option::is_none")]
297        message_metadata: Option<&'a Value>,
298    },
299    StartStep,
300    FinishStep,
301    TextStart {
302        id: &'a str,
303    },
304    TextDelta {
305        id: &'a str,
306        delta: &'a str,
307    },
308    TextEnd {
309        id: &'a str,
310    },
311    ReasoningStart {
312        id: &'a str,
313    },
314    ReasoningDelta {
315        id: &'a str,
316        delta: &'a str,
317    },
318    ReasoningEnd {
319        id: &'a str,
320    },
321    ToolInputStart {
322        tool_call_id: &'a str,
323        tool_name: &'a str,
324    },
325    ToolInputDelta {
326        tool_call_id: &'a str,
327        input_text_delta: &'a str,
328    },
329    ToolInputAvailable {
330        tool_call_id: &'a str,
331        tool_name: &'a str,
332        input: &'a Value,
333    },
334    ToolOutputAvailable {
335        tool_call_id: &'a str,
336        output: &'a Value,
337    },
338    ToolOutputError {
339        tool_call_id: &'a str,
340        error_text: &'a str,
341    },
342    SourceUrl {
343        source_id: &'a str,
344        url: &'a str,
345        #[serde(skip_serializing_if = "Option::is_none")]
346        title: Option<&'a str>,
347    },
348    Error {
349        error_text: &'a str,
350    },
351    Abort,
352    MessageMetadata {
353        message_metadata: &'a Value,
354    },
355}
356
357impl<'a> From<&'a UiMessageChunk> for Wire<'a> {
358    fn from(chunk: &'a UiMessageChunk) -> Self {
359        match chunk {
360            UiMessageChunk::Start {
361                message_id,
362                message_metadata,
363            } => Self::Start {
364                message_id: message_id.as_deref(),
365                message_metadata: message_metadata.as_ref(),
366            },
367            UiMessageChunk::Finish {
368                finish_reason,
369                message_metadata,
370            } => Self::Finish {
371                finish_reason: finish_reason.as_ref(),
372                message_metadata: message_metadata.as_ref(),
373            },
374            UiMessageChunk::StartStep => Self::StartStep,
375            UiMessageChunk::FinishStep => Self::FinishStep,
376            UiMessageChunk::TextStart { id } => Self::TextStart { id },
377            UiMessageChunk::TextDelta { id, delta } => Self::TextDelta { id, delta },
378            UiMessageChunk::TextEnd { id } => Self::TextEnd { id },
379            UiMessageChunk::ReasoningStart { id } => Self::ReasoningStart { id },
380            UiMessageChunk::ReasoningDelta { id, delta } => Self::ReasoningDelta { id, delta },
381            UiMessageChunk::ReasoningEnd { id } => Self::ReasoningEnd { id },
382            UiMessageChunk::ToolInputStart {
383                tool_call_id,
384                tool_name,
385            } => Self::ToolInputStart {
386                tool_call_id,
387                tool_name,
388            },
389            UiMessageChunk::ToolInputDelta {
390                tool_call_id,
391                input_text_delta,
392            } => Self::ToolInputDelta {
393                tool_call_id,
394                input_text_delta,
395            },
396            UiMessageChunk::ToolInputAvailable {
397                tool_call_id,
398                tool_name,
399                input,
400            } => Self::ToolInputAvailable {
401                tool_call_id,
402                tool_name,
403                input,
404            },
405            UiMessageChunk::ToolOutputAvailable {
406                tool_call_id,
407                output,
408            } => Self::ToolOutputAvailable {
409                tool_call_id,
410                output,
411            },
412            UiMessageChunk::ToolOutputError {
413                tool_call_id,
414                error_text,
415            } => Self::ToolOutputError {
416                tool_call_id,
417                error_text,
418            },
419            UiMessageChunk::SourceUrl {
420                source_id,
421                url,
422                title,
423            } => Self::SourceUrl {
424                source_id,
425                url,
426                title: title.as_deref(),
427            },
428            UiMessageChunk::Error { error_text } => Self::Error { error_text },
429            UiMessageChunk::Abort => Self::Abort,
430            UiMessageChunk::MessageMetadata { message_metadata } => {
431                Self::MessageMetadata { message_metadata }
432            }
433            // Its tag is `data-<name>`, not a variant name: `Serialize`
434            // builds this chunk's object by hand instead.
435            UiMessageChunk::Data { .. } => unreachable!("Data never converts to a Wire chunk"),
436        }
437    }
438}
439
440impl Serialize for UiMessageChunk {
441    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
442        match self {
443            Self::Data {
444                name,
445                id,
446                data,
447                transient,
448            } => {
449                let mut object = Map::new();
450                object.insert("type".to_owned(), Value::String(format!("data-{name}")));
451                if let Some(id) = id {
452                    object.insert("id".to_owned(), Value::String(id.clone()));
453                }
454                object.insert("data".to_owned(), data.clone());
455                if let Some(transient) = transient {
456                    object.insert("transient".to_owned(), Value::Bool(*transient));
457                }
458                object.serialize(serializer)
459            }
460            other => Wire::from(other).serialize(serializer),
461        }
462    }
463}
464
465/// Folds what this crate streams into UI message chunks, keeping the
466/// parts well-formed as it goes (issue #860).
467///
468/// The encoder is the piece that turns a flat delta stream into the
469/// structured shape a chat frontend keeps in state: the message opens
470/// with `start` and `start-step` on its first output, whatever kind of
471/// output that is; text and reasoning open, grow and close as parts —
472/// `text-0`, `reasoning-0`, `text-1`, … ids assigned in order, so the
473/// same run always encodes to the same bytes — and opening one closes the
474/// other, so a step's parts never interleave. A tool call becomes
475/// `tool-input-start`, then `tool-input-delta`s, then
476/// `tool-input-available`; a step closes with `finish-step`; the message
477/// closes with `finish`.
478///
479/// Feed it one stream, through one of:
480///
481/// - [`text_delta`](Self::text_delta) — a plain
482///   [`TextDelta`] stream, whose `Finish` ends the
483///   message with the reason it names;
484/// - [`tool_loop_event`](Self::tool_loop_event) — a
485///   [`ToolLoopEvent`] stream, where a delta
486///   `Finish` inside the loop only closes the open parts and remembers
487///   its reason (the loop carries on), and
488///   [`ToolLoopEvent::Done`] ends the
489///   message;
490/// - [`error`](Self::error) — ends the message with an `error` chunk;
491/// - [`finish`](Self::finish) — ends a message whose source stopped
492///   without either, so the client's stream closes framed regardless.
493///
494/// After the first end, every method answers an empty `Vec`: nothing is
495/// emitted after the message is over.
496#[derive(Debug, Default, Clone)]
497pub struct UiMessageEncoder {
498    /// The `messageId` the opening `start` carries, when the caller set
499    /// one.
500    message_id: Option<String>,
501    /// Whether `start` and `start-step` have gone out: exactly once, on
502    /// the first output of the message.
503    started: bool,
504    /// Whether a step is open — a `start-step` without its
505    /// `finish-step` — which is what makes the first delta after a
506    /// finished step open a new one.
507    step_open: bool,
508    /// The next text and reasoning part ids.
509    next_text: usize,
510    next_reasoning: usize,
511    /// The open text and reasoning parts' ids, when one of each is open.
512    open_text: Option<String>,
513    open_reasoning: Option<String>,
514    /// The tool-call ids whose `tool-input-start` went out, by
515    /// [`TextDelta`] index: the address its argument
516    /// deltas need. An entry is removed when its call finishes. The tool
517    /// name is not remembered — the finish carries its own, and nothing
518    /// between a call's start and its finish names it again.
519    started_calls: HashMap<usize, String>,
520    /// The reason the message ends with: the last `Finish` seen, from a
521    /// plain stream's own or the loop's most recent step.
522    finish_reason: Option<UiFinishReason>,
523    /// Whether the message is over. Set by [`Self::finish`],
524    /// [`Self::error`] and a plain stream's `Finish`; nothing goes out
525    /// after it.
526    finished: bool,
527}
528
529impl UiMessageEncoder {
530    /// An encoder for a message with no id: the opening `start` carries
531    /// no `messageId`.
532    #[must_use]
533    pub fn new() -> Self {
534        Self::default()
535    }
536
537    /// An encoder whose opening `start` names the message `message_id` —
538    /// the id a client that persists the message will store it under.
539    #[must_use]
540    pub fn with_message_id(message_id: impl Into<String>) -> Self {
541        Self {
542            message_id: Some(message_id.into()),
543            ..Self::default()
544        }
545    }
546
547    /// Folds one delta of a plain [`TextDelta`] stream.
548    /// The stream's `Finish` ends the message: open parts close, the open
549    /// step gets its `finish-step`, and the `finish` chunk names the
550    /// reason.
551    pub fn text_delta(&mut self, delta: &TextDelta) -> Vec<UiMessageChunk> {
552        if self.finished {
553            return Vec::new();
554        }
555        let TextDelta::Finish { reason, .. } = delta else {
556            return self.delta_chunks(delta);
557        };
558        let mut out = Vec::new();
559        self.ensure_started(&mut out);
560        self.close_open_parts(&mut out);
561        self.finish_reason = Some(UiFinishReason::from(reason));
562        self.close_step(&mut out);
563        out.push(UiMessageChunk::Finish {
564            finish_reason: self.finish_reason.clone(),
565            message_metadata: None,
566        });
567        self.finished = true;
568        out
569    }
570
571    /// Folds one event of a streamed tool loop. A delta `Finish` inside
572    /// the loop only closes the open parts and remembers its reason —
573    /// the loop goes on to run tools or call the model again, and the
574    /// message ends at `Done` or an `error`, not here. A delta for a new
575    /// step — any content delta once the previous step's `finish-step`
576    /// went out — opens the step with a `start-step` first. A
577    /// [`ToolResult`]'s output rides as JSON where it parses, as one JSON
578    /// string where it does not, and as a `tool-output-error` where the
579    /// tool failed.
580    ///
581    /// [`ToolResult`]: crate::ToolResult
582    pub fn tool_loop_event(&mut self, event: &ToolLoopEvent) -> Vec<UiMessageChunk> {
583        if self.finished {
584            return Vec::new();
585        }
586        match event {
587            ToolLoopEvent::Delta { delta, .. } => match delta {
588                TextDelta::Finish { reason, .. } => {
589                    let mut out = Vec::new();
590                    self.ensure_started(&mut out);
591                    self.close_open_parts(&mut out);
592                    self.finish_reason = Some(UiFinishReason::from(reason));
593                    out
594                }
595                delta => self.delta_chunks(delta),
596            },
597            ToolLoopEvent::ToolResult { call, result, .. } => {
598                let mut out = Vec::new();
599                self.ensure_started(&mut out);
600                out.push(if result.is_error {
601                    UiMessageChunk::ToolOutputError {
602                        tool_call_id: call.id.clone(),
603                        error_text: result.content.clone(),
604                    }
605                } else {
606                    UiMessageChunk::ToolOutputAvailable {
607                        tool_call_id: call.id.clone(),
608                        output: output_value(&result.content),
609                    }
610                });
611                out
612            }
613            ToolLoopEvent::StepFinished { .. } => {
614                let mut out = Vec::new();
615                self.ensure_started(&mut out);
616                self.close_open_parts(&mut out);
617                self.close_step(&mut out);
618                out
619            }
620            ToolLoopEvent::Done(_) => self.finish(),
621        }
622    }
623
624    /// Ends the message with an `error` chunk. The text given goes on the
625    /// wire as it is handed over — pass [`MASKED_ERROR_TEXT`] when its
626    /// source is a provider or a transport, and log the detail instead,
627    /// the way [`text_stream_chunks`] and [`tool_loop_chunks`] do. A
628    /// caller that wants the detail on the wire puts it here itself.
629    pub fn error(&mut self, error_text: &str) -> Vec<UiMessageChunk> {
630        let mut out = Vec::new();
631        if self.finished {
632            return out;
633        }
634        self.ensure_started(&mut out);
635        self.close_open_parts(&mut out);
636        out.push(UiMessageChunk::Error {
637            error_text: error_text.to_owned(),
638        });
639        self.finished = true;
640        out
641    }
642
643    /// Ends the message the way a stream that stopped without a `Finish`
644    /// or an error still should: open parts close, an open step gets its
645    /// `finish-step`, and `finish` carries the last reason a `Finish`
646    /// recorded — no field at all when none did. This is also how
647    /// [`ToolLoopEvent::Done`] ends a loop.
648    pub fn finish(&mut self) -> Vec<UiMessageChunk> {
649        let mut out = Vec::new();
650        if self.finished {
651            return out;
652        }
653        self.ensure_started(&mut out);
654        self.close_open_parts(&mut out);
655        self.close_step(&mut out);
656        out.push(UiMessageChunk::Finish {
657            finish_reason: self.finish_reason.clone(),
658            message_metadata: None,
659        });
660        self.finished = true;
661        out
662    }
663
664    /// The chunks one non-terminal delta produces. Text and reasoning
665    /// alternate through parts — opening one closes the other; tool calls
666    /// run `start` → `delta`… → `available`; usage is nothing, because
667    /// the UI shows the stream, not the meter. The message and step open
668    /// only when a chunk is about to land: a delta that produces nothing
669    /// (usage, a dangling argument chunk) leaves the stream untouched.
670    fn delta_chunks(&mut self, delta: &TextDelta) -> Vec<UiMessageChunk> {
671        let mut out = Vec::new();
672        match delta {
673            TextDelta::Text(text) => {
674                self.open_step(&mut out);
675                self.close_open_reasoning(&mut out);
676                let id = self.open_text_part(&mut out);
677                out.push(UiMessageChunk::TextDelta {
678                    id,
679                    delta: text.clone(),
680                });
681            }
682            TextDelta::Reasoning(text) => {
683                self.open_step(&mut out);
684                self.close_open_text(&mut out);
685                let id = self.open_reasoning_part(&mut out);
686                out.push(UiMessageChunk::ReasoningDelta {
687                    id,
688                    delta: text.clone(),
689                });
690            }
691            TextDelta::ToolCallStarted { index, id, name } => {
692                self.open_step(&mut out);
693                self.close_open_parts(&mut out);
694                self.started_calls.insert(*index, id.clone());
695                out.push(UiMessageChunk::ToolInputStart {
696                    tool_call_id: id.clone(),
697                    tool_name: name.clone(),
698                });
699            }
700            TextDelta::ToolCallArguments { index, chunk } => {
701                // An argument chunk for a call whose start never went out
702                // has no part to grow: dropped, not invented.
703                let tool_call_id = self.started_calls.get(index).cloned();
704                if let Some(tool_call_id) = tool_call_id {
705                    self.open_step(&mut out);
706                    out.push(UiMessageChunk::ToolInputDelta {
707                        tool_call_id,
708                        input_text_delta: chunk.clone(),
709                    });
710                }
711            }
712            TextDelta::ToolCallFinished { index, call } => {
713                let remembered = self.started_calls.remove(index).is_some();
714                self.open_step(&mut out);
715                if !remembered {
716                    // The call's start never arrived (an adapter that
717                    // skips starts): announce it before the input lands,
718                    // so the client sees a whole call either way.
719                    self.close_open_parts(&mut out);
720                    out.push(UiMessageChunk::ToolInputStart {
721                        tool_call_id: call.id.clone(),
722                        tool_name: call.name.clone(),
723                    });
724                }
725                out.push(UiMessageChunk::ToolInputAvailable {
726                    tool_call_id: call.id.clone(),
727                    tool_name: call.name.clone(),
728                    input: call.arguments.clone(),
729                });
730            }
731            // Usage is the meter, not the message: the UI stream carries
732            // nothing for it. A `Finish` is terminal and ends differently
733            // per source kind; the callers handle it before this runs.
734            TextDelta::Usage { .. } | TextDelta::Finish { .. } => {}
735        }
736        out
737    }
738
739    /// Emits `start` then `start-step` on the first output of the
740    /// message, whatever kind of output it is: the client needs the
741    /// message and its first step before any part lands.
742    fn ensure_started(&mut self, out: &mut Vec<UiMessageChunk>) {
743        if self.started {
744            return;
745        }
746        self.started = true;
747        self.step_open = true;
748        out.push(UiMessageChunk::Start {
749            message_id: self.message_id.clone(),
750            message_metadata: None,
751        });
752        out.push(UiMessageChunk::StartStep);
753    }
754
755    /// The step content is about to land in: `start` and `start-step` on
756    /// the message's first output, `start-step` alone when a loop's next
757    /// step begins after the previous one's `finish-step` went out.
758    fn open_step(&mut self, out: &mut Vec<UiMessageChunk>) {
759        self.ensure_started(out);
760        if !self.step_open {
761            self.step_open = true;
762            out.push(UiMessageChunk::StartStep);
763        }
764    }
765
766    /// The open text part's id, opening one — and announcing it — if
767    /// there is none.
768    fn open_text_part(&mut self, out: &mut Vec<UiMessageChunk>) -> String {
769        if let Some(id) = &self.open_text {
770            return id.clone();
771        }
772        let id = format!("text-{}", self.next_text);
773        self.next_text += 1;
774        self.open_text = Some(id.clone());
775        out.push(UiMessageChunk::TextStart { id: id.clone() });
776        id
777    }
778
779    /// The open reasoning part's id, symmetric with
780    /// [`Self::open_text_part`].
781    fn open_reasoning_part(&mut self, out: &mut Vec<UiMessageChunk>) -> String {
782        if let Some(id) = &self.open_reasoning {
783            return id.clone();
784        }
785        let id = format!("reasoning-{}", self.next_reasoning);
786        self.next_reasoning += 1;
787        self.open_reasoning = Some(id.clone());
788        out.push(UiMessageChunk::ReasoningStart { id: id.clone() });
789        id
790    }
791
792    fn close_open_text(&mut self, out: &mut Vec<UiMessageChunk>) {
793        if let Some(id) = self.open_text.take() {
794            out.push(UiMessageChunk::TextEnd { id });
795        }
796    }
797
798    fn close_open_reasoning(&mut self, out: &mut Vec<UiMessageChunk>) {
799        if let Some(id) = self.open_reasoning.take() {
800            out.push(UiMessageChunk::ReasoningEnd { id });
801        }
802    }
803
804    /// Closes whichever parts are open, in the order they opened: a part
805    /// never ends after another kind began.
806    fn close_open_parts(&mut self, out: &mut Vec<UiMessageChunk>) {
807        self.close_open_text(out);
808        self.close_open_reasoning(out);
809    }
810
811    /// Closes the open step, where there is one. `start-step` and
812    /// `finish-step` travel in pairs, or the SDK sees a step that never
813    /// ended.
814    fn close_step(&mut self, out: &mut Vec<UiMessageChunk>) {
815        if self.step_open {
816            self.step_open = false;
817            out.push(UiMessageChunk::FinishStep);
818        }
819    }
820}
821
822/// A tool's output as the JSON the chunk carries: the content parsed
823/// where it *is* JSON, the content as one JSON string where it is not —
824/// an executor answers in text, and the wire wants a value either way.
825fn output_value(content: &str) -> Value {
826    serde_json::from_str(content).unwrap_or_else(|_| Value::String(content.to_owned()))
827}
828
829/// The `unfold` state behind [`text_stream_chunks`] and
830/// [`tool_loop_chunks`]: the source, boxed so the state stays `Unpin`;
831/// the encoder method folding each item; the chunks the last item
832/// produced that have not been yielded yet; and the `done` flag that
833/// stops the pulls once the source has ended or failed. One source item
834/// per consumer poll, and only when the encoder answered the last one
835/// with nothing — the adapter never reads ahead, so dropping the chunk
836/// stream drops the source, which is the cancellation the response body
837/// carries.
838struct Chunker<S, T> {
839    source: Pin<Box<S>>,
840    emit: fn(&mut UiMessageEncoder, &T) -> Vec<UiMessageChunk>,
841    encoder: UiMessageEncoder,
842    pending: std::vec::IntoIter<UiMessageChunk>,
843    done: bool,
844}
845
846/// Streams `source` through the encoder as UI message chunks — the shape
847/// [`ui_message_response`] takes. One source item per consumer poll; the
848/// source's error ends the message with [`MASKED_ERROR_TEXT`] on the
849/// wire (the detail is logged), and a source that stops without a
850/// `Finish` still gets its closing `finish`.
851fn chunk_stream<S, T, E>(
852    source: S,
853    emit: fn(&mut UiMessageEncoder, &T) -> Vec<UiMessageChunk>,
854) -> BoxStream<'static, UiMessageChunk>
855where
856    S: Stream<Item = Result<T, E>> + Send + 'static,
857    T: 'static,
858    E: std::fmt::Display + 'static,
859{
860    Box::pin(futures_util::stream::unfold(
861        Chunker {
862            source: Box::pin(source),
863            emit,
864            encoder: UiMessageEncoder::new(),
865            pending: Vec::new().into_iter(),
866            done: false,
867        },
868        |mut chunker| async move {
869            loop {
870                if let Some(chunk) = chunker.pending.next() {
871                    return Some((chunk, chunker));
872                }
873                if chunker.done {
874                    return None;
875                }
876                match chunker.source.next().await {
877                    Some(Ok(item)) => {
878                        chunker.pending = (chunker.emit)(&mut chunker.encoder, &item).into_iter();
879                    }
880                    Some(Err(error)) => {
881                        // The source's error is the end of the message,
882                        // and its text stays here in the log: provider
883                        // and transport text can quote a user's prompt
884                        // back, so the wire gets the fixed sentence.
885                        tracing::warn!(
886                            error = %error,
887                            "ui message stream failed; the wire carries the masked text"
888                        );
889                        chunker.pending = chunker.encoder.error(MASKED_ERROR_TEXT).into_iter();
890                        chunker.done = true;
891                    }
892                    None => {
893                        // The source stopped without a `Finish` or an
894                        // error; close the message anyway, so the
895                        // client's stream ends framed.
896                        chunker.pending = chunker.encoder.finish().into_iter();
897                        chunker.done = true;
898                    }
899                }
900            }
901        },
902    ))
903}
904
905/// Streams a [`TextDelta`] completion as UI message
906/// chunks (issue #860): the deltas a route already holds —
907/// [`stream_owned`](crate::stream_owned)'s output, say — as the chunks a
908/// chat frontend parses. The source's error masks to
909/// [`MASKED_ERROR_TEXT`]; a source that ends without a `Finish` still
910/// ends the message framed.
911pub fn text_stream_chunks<S>(deltas: S) -> BoxStream<'static, UiMessageChunk>
912where
913    S: Stream<Item = Result<TextDelta, TextModelError>> + Send + 'static,
914{
915    chunk_stream(deltas, UiMessageEncoder::text_delta)
916}
917
918/// Streams a [`ToolLoopEvent`] loop as UI message
919/// chunks: every step's parts as they arrive, each executed call's
920/// output, and a `finish` carrying the loop's last reason at `Done`.
921/// The source's error masks to [`MASKED_ERROR_TEXT`]; a source that ends
922/// without a `Done` still ends the message framed.
923pub fn tool_loop_chunks<S>(events: S) -> BoxStream<'static, UiMessageChunk>
924where
925    S: Stream<Item = Result<ToolLoopEvent, ToolLoopError>> + Send + 'static,
926{
927    chunk_stream(events, UiMessageEncoder::tool_loop_event)
928}
929
930/// Answers a route with a UI message stream (issue #860): every chunk
931/// encoded and written as it arrives, the `data: [DONE]` marker after
932/// the last, the [`UI_MESSAGE_STREAM_HEADER`] dialect header on top of
933/// the SSE headers, and the heartbeat, cancellation contract and
934/// `ResponseStream` handle of [`SseResponse`] underneath.
935#[must_use]
936pub fn ui_message_response<S>(chunks: S, clock: Arc<dyn Clock>) -> SseResponse
937where
938    S: Stream<Item = UiMessageChunk> + Send + 'static,
939{
940    let events = chunks
941        .map(|chunk| chunk.to_sse_event())
942        .chain(futures_util::stream::iter([SseEvent::new(DONE_MARKER)]));
943    SseResponse::new(events, clock).header(
944        HeaderName::from_static(UI_MESSAGE_STREAM_HEADER),
945        HeaderValue::from_static(UI_MESSAGE_STREAM_VERSION),
946    )
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use crate::ports::ToolCall;
953    use serde_json::{Value, json};
954
955    /// The chunk's wire JSON, through the real `Serialize` path.
956    fn wire(chunk: &UiMessageChunk) -> Value {
957        serde_json::to_value(chunk).expect("a chunk serialises")
958    }
959
960    /// The chunk type tags in order — the stream's shape.
961    fn kinds(chunks: &[UiMessageChunk]) -> Vec<String> {
962        chunks
963            .iter()
964            .map(|chunk| {
965                wire(chunk)["type"]
966                    .as_str()
967                    .map(str::to_owned)
968                    .expect("every chunk types")
969            })
970            .collect()
971    }
972
973    #[test]
974    fn finish_reasons_map_onto_the_wire_words() {
975        assert_eq!(UiFinishReason::from(&FinishReason::Stop).as_str(), "stop");
976        assert_eq!(
977            UiFinishReason::from(&FinishReason::Length).as_str(),
978            "length"
979        );
980        assert_eq!(
981            UiFinishReason::from(&FinishReason::ToolUse).as_str(),
982            "tool-calls"
983        );
984        assert_eq!(
985            UiFinishReason::from(&FinishReason::ContentFilter).as_str(),
986            "content-filter"
987        );
988        assert_eq!(
989            UiFinishReason::from(&FinishReason::Other("weird".to_owned())).as_str(),
990            "other",
991            "an unknown reason, and any future one, is `other`"
992        );
993    }
994
995    #[test]
996    fn text_and_reasoning_parts_take_turns() {
997        let mut encoder = UiMessageEncoder::new();
998        let mut chunks = encoder.text_delta(&TextDelta::Reasoning("hmm".to_owned()));
999        chunks.extend(encoder.text_delta(&TextDelta::Text("hi".to_owned())));
1000        chunks.extend(encoder.text_delta(&TextDelta::Reasoning("again".to_owned())));
1001        let kinds: Vec<String> = chunks
1002            .iter()
1003            .map(|chunk| {
1004                let object = wire(chunk);
1005                format!(
1006                    "{}:{}",
1007                    object["type"].as_str().expect("typed"),
1008                    object.get("id").and_then(Value::as_str).unwrap_or("-")
1009                )
1010            })
1011            .collect();
1012        assert_eq!(
1013            kinds,
1014            vec![
1015                "start:-",
1016                "start-step:-",
1017                "reasoning-start:reasoning-0",
1018                "reasoning-delta:reasoning-0",
1019                "reasoning-end:reasoning-0",
1020                "text-start:text-0",
1021                "text-delta:text-0",
1022                "text-end:text-0",
1023                "reasoning-start:reasoning-1",
1024                "reasoning-delta:reasoning-1",
1025            ],
1026            "opening one part closes the other; ids count per kind: {chunks:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn nothing_is_emitted_after_the_message_ends() {
1032        let mut encoder = UiMessageEncoder::new();
1033        encoder.text_delta(&TextDelta::Finish {
1034            reason: FinishReason::Stop,
1035            model: "fake".to_owned(),
1036        });
1037        assert!(
1038            encoder
1039                .text_delta(&TextDelta::Text("late".to_owned()))
1040                .is_empty()
1041        );
1042        assert!(encoder.error("late").is_empty());
1043        assert!(encoder.finish().is_empty());
1044
1045        // An error ends a message the same way: the message opens first
1046        // if it had not, then the error lands, then nothing more.
1047        let mut encoder = UiMessageEncoder::new();
1048        assert_eq!(
1049            kinds(&encoder.error("nope")),
1050            vec!["start", "start-step", "error"]
1051        );
1052        assert!(encoder.finish().is_empty());
1053        assert!(
1054            encoder
1055                .tool_loop_event(&ToolLoopEvent::Delta {
1056                    step: 0,
1057                    delta: TextDelta::Text("late".to_owned()),
1058                })
1059                .is_empty()
1060        );
1061    }
1062
1063    #[test]
1064    fn an_argument_chunk_for_a_call_that_never_started_is_dropped() {
1065        let mut encoder = UiMessageEncoder::new();
1066        let chunks = encoder.text_delta(&TextDelta::ToolCallArguments {
1067            index: 3,
1068            chunk: "{\"q\":".to_owned(),
1069        });
1070        assert!(chunks.is_empty(), "no start, no part to grow: {chunks:?}");
1071    }
1072
1073    #[test]
1074    fn a_finished_call_without_a_start_announces_itself_first() {
1075        let mut encoder = UiMessageEncoder::new();
1076        let chunks = encoder.text_delta(&TextDelta::ToolCallFinished {
1077            index: 0,
1078            call: ToolCall::new("call-9", "lookup", json!({"q": "x"})),
1079        });
1080        assert_eq!(
1081            kinds(&chunks),
1082            vec![
1083                "start",
1084                "start-step",
1085                "tool-input-start",
1086                "tool-input-available"
1087            ]
1088        );
1089        assert_eq!(
1090            wire(&chunks[2])["toolCallId"],
1091            json!("call-9"),
1092            "the announcement carries the finished call's own id"
1093        );
1094    }
1095
1096    #[test]
1097    fn a_tool_output_is_json_where_it_parses_and_a_string_where_it_does_not() {
1098        assert_eq!(output_value("{\"a\": 1}"), json!({"a": 1}));
1099        assert_eq!(output_value("ran lookup"), json!("ran lookup"));
1100    }
1101}