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}