use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use futures_core::Stream;
use futures_util::StreamExt;
use http::{HeaderName, HeaderValue};
use serde::{Serialize, Serializer};
use serde_json::{Map, Value};
use crate::ports::{Clock, FinishReason, TextDelta, TextModelError};
use crate::sse::{SseEvent, SseResponse};
use crate::stream::BoxStream;
use crate::tool_loop::{ToolLoopError, ToolLoopEvent};
pub const UI_MESSAGE_STREAM_HEADER: &str = "x-vercel-ai-ui-message-stream";
pub const UI_MESSAGE_STREAM_VERSION: &str = "v1";
const DONE_MARKER: &str = "[DONE]";
pub const MASKED_ERROR_TEXT: &str = "An error occurred.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiFinishReason {
Stop,
Length,
ToolCalls,
ContentFilter,
Error,
Other,
}
impl UiFinishReason {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Stop => "stop",
Self::Length => "length",
Self::ToolCalls => "tool-calls",
Self::ContentFilter => "content-filter",
Self::Error => "error",
Self::Other => "other",
}
}
}
impl Serialize for UiFinishReason {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl From<&FinishReason> for UiFinishReason {
fn from(reason: &FinishReason) -> Self {
match reason {
FinishReason::Stop => Self::Stop,
FinishReason::Length => Self::Length,
FinishReason::ToolUse => Self::ToolCalls,
FinishReason::ContentFilter => Self::ContentFilter,
_ => Self::Other,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum UiMessageChunk {
Start {
message_id: Option<String>,
message_metadata: Option<Value>,
},
Finish {
finish_reason: Option<UiFinishReason>,
message_metadata: Option<Value>,
},
StartStep,
FinishStep,
TextStart {
id: String,
},
TextDelta {
id: String,
delta: String,
},
TextEnd {
id: String,
},
ReasoningStart {
id: String,
},
ReasoningDelta {
id: String,
delta: String,
},
ReasoningEnd {
id: String,
},
ToolInputStart {
tool_call_id: String,
tool_name: String,
},
ToolInputDelta {
tool_call_id: String,
input_text_delta: String,
},
ToolInputAvailable {
tool_call_id: String,
tool_name: String,
input: Value,
},
ToolOutputAvailable {
tool_call_id: String,
output: Value,
},
ToolOutputError {
tool_call_id: String,
error_text: String,
},
SourceUrl {
source_id: String,
url: String,
title: Option<String>,
},
Data {
name: String,
id: Option<String>,
data: Value,
transient: Option<bool>,
},
Error {
error_text: String,
},
Abort,
MessageMetadata {
message_metadata: Value,
},
}
impl UiMessageChunk {
#[must_use]
pub fn to_sse_event(&self) -> SseEvent {
SseEvent::new(serde_json::to_string(self).expect("a chunk serialises to JSON"))
}
}
#[derive(Serialize)]
#[serde(
tag = "type",
rename_all = "kebab-case",
rename_all_fields = "camelCase"
)]
enum Wire<'a> {
Start {
#[serde(skip_serializing_if = "Option::is_none")]
message_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
message_metadata: Option<&'a Value>,
},
Finish {
#[serde(skip_serializing_if = "Option::is_none")]
finish_reason: Option<&'a UiFinishReason>,
#[serde(skip_serializing_if = "Option::is_none")]
message_metadata: Option<&'a Value>,
},
StartStep,
FinishStep,
TextStart {
id: &'a str,
},
TextDelta {
id: &'a str,
delta: &'a str,
},
TextEnd {
id: &'a str,
},
ReasoningStart {
id: &'a str,
},
ReasoningDelta {
id: &'a str,
delta: &'a str,
},
ReasoningEnd {
id: &'a str,
},
ToolInputStart {
tool_call_id: &'a str,
tool_name: &'a str,
},
ToolInputDelta {
tool_call_id: &'a str,
input_text_delta: &'a str,
},
ToolInputAvailable {
tool_call_id: &'a str,
tool_name: &'a str,
input: &'a Value,
},
ToolOutputAvailable {
tool_call_id: &'a str,
output: &'a Value,
},
ToolOutputError {
tool_call_id: &'a str,
error_text: &'a str,
},
SourceUrl {
source_id: &'a str,
url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<&'a str>,
},
Error {
error_text: &'a str,
},
Abort,
MessageMetadata {
message_metadata: &'a Value,
},
}
impl<'a> From<&'a UiMessageChunk> for Wire<'a> {
fn from(chunk: &'a UiMessageChunk) -> Self {
match chunk {
UiMessageChunk::Start {
message_id,
message_metadata,
} => Self::Start {
message_id: message_id.as_deref(),
message_metadata: message_metadata.as_ref(),
},
UiMessageChunk::Finish {
finish_reason,
message_metadata,
} => Self::Finish {
finish_reason: finish_reason.as_ref(),
message_metadata: message_metadata.as_ref(),
},
UiMessageChunk::StartStep => Self::StartStep,
UiMessageChunk::FinishStep => Self::FinishStep,
UiMessageChunk::TextStart { id } => Self::TextStart { id },
UiMessageChunk::TextDelta { id, delta } => Self::TextDelta { id, delta },
UiMessageChunk::TextEnd { id } => Self::TextEnd { id },
UiMessageChunk::ReasoningStart { id } => Self::ReasoningStart { id },
UiMessageChunk::ReasoningDelta { id, delta } => Self::ReasoningDelta { id, delta },
UiMessageChunk::ReasoningEnd { id } => Self::ReasoningEnd { id },
UiMessageChunk::ToolInputStart {
tool_call_id,
tool_name,
} => Self::ToolInputStart {
tool_call_id,
tool_name,
},
UiMessageChunk::ToolInputDelta {
tool_call_id,
input_text_delta,
} => Self::ToolInputDelta {
tool_call_id,
input_text_delta,
},
UiMessageChunk::ToolInputAvailable {
tool_call_id,
tool_name,
input,
} => Self::ToolInputAvailable {
tool_call_id,
tool_name,
input,
},
UiMessageChunk::ToolOutputAvailable {
tool_call_id,
output,
} => Self::ToolOutputAvailable {
tool_call_id,
output,
},
UiMessageChunk::ToolOutputError {
tool_call_id,
error_text,
} => Self::ToolOutputError {
tool_call_id,
error_text,
},
UiMessageChunk::SourceUrl {
source_id,
url,
title,
} => Self::SourceUrl {
source_id,
url,
title: title.as_deref(),
},
UiMessageChunk::Error { error_text } => Self::Error { error_text },
UiMessageChunk::Abort => Self::Abort,
UiMessageChunk::MessageMetadata { message_metadata } => {
Self::MessageMetadata { message_metadata }
}
UiMessageChunk::Data { .. } => unreachable!("Data never converts to a Wire chunk"),
}
}
}
impl Serialize for UiMessageChunk {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Data {
name,
id,
data,
transient,
} => {
let mut object = Map::new();
object.insert("type".to_owned(), Value::String(format!("data-{name}")));
if let Some(id) = id {
object.insert("id".to_owned(), Value::String(id.clone()));
}
object.insert("data".to_owned(), data.clone());
if let Some(transient) = transient {
object.insert("transient".to_owned(), Value::Bool(*transient));
}
object.serialize(serializer)
}
other => Wire::from(other).serialize(serializer),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct UiMessageEncoder {
message_id: Option<String>,
started: bool,
step_open: bool,
next_text: usize,
next_reasoning: usize,
open_text: Option<String>,
open_reasoning: Option<String>,
started_calls: HashMap<usize, String>,
finish_reason: Option<UiFinishReason>,
finished: bool,
}
impl UiMessageEncoder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_message_id(message_id: impl Into<String>) -> Self {
Self {
message_id: Some(message_id.into()),
..Self::default()
}
}
pub fn text_delta(&mut self, delta: &TextDelta) -> Vec<UiMessageChunk> {
if self.finished {
return Vec::new();
}
let TextDelta::Finish { reason, .. } = delta else {
return self.delta_chunks(delta);
};
let mut out = Vec::new();
self.ensure_started(&mut out);
self.close_open_parts(&mut out);
self.finish_reason = Some(UiFinishReason::from(reason));
self.close_step(&mut out);
out.push(UiMessageChunk::Finish {
finish_reason: self.finish_reason.clone(),
message_metadata: None,
});
self.finished = true;
out
}
pub fn tool_loop_event(&mut self, event: &ToolLoopEvent) -> Vec<UiMessageChunk> {
if self.finished {
return Vec::new();
}
match event {
ToolLoopEvent::Delta { delta, .. } => match delta {
TextDelta::Finish { reason, .. } => {
let mut out = Vec::new();
self.ensure_started(&mut out);
self.close_open_parts(&mut out);
self.finish_reason = Some(UiFinishReason::from(reason));
out
}
delta => self.delta_chunks(delta),
},
ToolLoopEvent::ToolResult { call, result, .. } => {
let mut out = Vec::new();
self.ensure_started(&mut out);
out.push(if result.is_error {
UiMessageChunk::ToolOutputError {
tool_call_id: call.id.clone(),
error_text: result.content.clone(),
}
} else {
UiMessageChunk::ToolOutputAvailable {
tool_call_id: call.id.clone(),
output: output_value(&result.content),
}
});
out
}
ToolLoopEvent::StepFinished { .. } => {
let mut out = Vec::new();
self.ensure_started(&mut out);
self.close_open_parts(&mut out);
self.close_step(&mut out);
out
}
ToolLoopEvent::Done(_) => self.finish(),
}
}
pub fn error(&mut self, error_text: &str) -> Vec<UiMessageChunk> {
let mut out = Vec::new();
if self.finished {
return out;
}
self.ensure_started(&mut out);
self.close_open_parts(&mut out);
out.push(UiMessageChunk::Error {
error_text: error_text.to_owned(),
});
self.finished = true;
out
}
pub fn finish(&mut self) -> Vec<UiMessageChunk> {
let mut out = Vec::new();
if self.finished {
return out;
}
self.ensure_started(&mut out);
self.close_open_parts(&mut out);
self.close_step(&mut out);
out.push(UiMessageChunk::Finish {
finish_reason: self.finish_reason.clone(),
message_metadata: None,
});
self.finished = true;
out
}
fn delta_chunks(&mut self, delta: &TextDelta) -> Vec<UiMessageChunk> {
let mut out = Vec::new();
match delta {
TextDelta::Text(text) => {
self.open_step(&mut out);
self.close_open_reasoning(&mut out);
let id = self.open_text_part(&mut out);
out.push(UiMessageChunk::TextDelta {
id,
delta: text.clone(),
});
}
TextDelta::Reasoning(text) => {
self.open_step(&mut out);
self.close_open_text(&mut out);
let id = self.open_reasoning_part(&mut out);
out.push(UiMessageChunk::ReasoningDelta {
id,
delta: text.clone(),
});
}
TextDelta::ToolCallStarted { index, id, name } => {
self.open_step(&mut out);
self.close_open_parts(&mut out);
self.started_calls.insert(*index, id.clone());
out.push(UiMessageChunk::ToolInputStart {
tool_call_id: id.clone(),
tool_name: name.clone(),
});
}
TextDelta::ToolCallArguments { index, chunk } => {
let tool_call_id = self.started_calls.get(index).cloned();
if let Some(tool_call_id) = tool_call_id {
self.open_step(&mut out);
out.push(UiMessageChunk::ToolInputDelta {
tool_call_id,
input_text_delta: chunk.clone(),
});
}
}
TextDelta::ToolCallFinished { index, call } => {
let remembered = self.started_calls.remove(index).is_some();
self.open_step(&mut out);
if !remembered {
self.close_open_parts(&mut out);
out.push(UiMessageChunk::ToolInputStart {
tool_call_id: call.id.clone(),
tool_name: call.name.clone(),
});
}
out.push(UiMessageChunk::ToolInputAvailable {
tool_call_id: call.id.clone(),
tool_name: call.name.clone(),
input: call.arguments.clone(),
});
}
TextDelta::Usage { .. } | TextDelta::Finish { .. } => {}
}
out
}
fn ensure_started(&mut self, out: &mut Vec<UiMessageChunk>) {
if self.started {
return;
}
self.started = true;
self.step_open = true;
out.push(UiMessageChunk::Start {
message_id: self.message_id.clone(),
message_metadata: None,
});
out.push(UiMessageChunk::StartStep);
}
fn open_step(&mut self, out: &mut Vec<UiMessageChunk>) {
self.ensure_started(out);
if !self.step_open {
self.step_open = true;
out.push(UiMessageChunk::StartStep);
}
}
fn open_text_part(&mut self, out: &mut Vec<UiMessageChunk>) -> String {
if let Some(id) = &self.open_text {
return id.clone();
}
let id = format!("text-{}", self.next_text);
self.next_text += 1;
self.open_text = Some(id.clone());
out.push(UiMessageChunk::TextStart { id: id.clone() });
id
}
fn open_reasoning_part(&mut self, out: &mut Vec<UiMessageChunk>) -> String {
if let Some(id) = &self.open_reasoning {
return id.clone();
}
let id = format!("reasoning-{}", self.next_reasoning);
self.next_reasoning += 1;
self.open_reasoning = Some(id.clone());
out.push(UiMessageChunk::ReasoningStart { id: id.clone() });
id
}
fn close_open_text(&mut self, out: &mut Vec<UiMessageChunk>) {
if let Some(id) = self.open_text.take() {
out.push(UiMessageChunk::TextEnd { id });
}
}
fn close_open_reasoning(&mut self, out: &mut Vec<UiMessageChunk>) {
if let Some(id) = self.open_reasoning.take() {
out.push(UiMessageChunk::ReasoningEnd { id });
}
}
fn close_open_parts(&mut self, out: &mut Vec<UiMessageChunk>) {
self.close_open_text(out);
self.close_open_reasoning(out);
}
fn close_step(&mut self, out: &mut Vec<UiMessageChunk>) {
if self.step_open {
self.step_open = false;
out.push(UiMessageChunk::FinishStep);
}
}
}
fn output_value(content: &str) -> Value {
serde_json::from_str(content).unwrap_or_else(|_| Value::String(content.to_owned()))
}
struct Chunker<S, T> {
source: Pin<Box<S>>,
emit: fn(&mut UiMessageEncoder, &T) -> Vec<UiMessageChunk>,
encoder: UiMessageEncoder,
pending: std::vec::IntoIter<UiMessageChunk>,
done: bool,
}
fn chunk_stream<S, T, E>(
source: S,
emit: fn(&mut UiMessageEncoder, &T) -> Vec<UiMessageChunk>,
) -> BoxStream<'static, UiMessageChunk>
where
S: Stream<Item = Result<T, E>> + Send + 'static,
T: 'static,
E: std::fmt::Display + 'static,
{
Box::pin(futures_util::stream::unfold(
Chunker {
source: Box::pin(source),
emit,
encoder: UiMessageEncoder::new(),
pending: Vec::new().into_iter(),
done: false,
},
|mut chunker| async move {
loop {
if let Some(chunk) = chunker.pending.next() {
return Some((chunk, chunker));
}
if chunker.done {
return None;
}
match chunker.source.next().await {
Some(Ok(item)) => {
chunker.pending = (chunker.emit)(&mut chunker.encoder, &item).into_iter();
}
Some(Err(error)) => {
tracing::warn!(
error = %error,
"ui message stream failed; the wire carries the masked text"
);
chunker.pending = chunker.encoder.error(MASKED_ERROR_TEXT).into_iter();
chunker.done = true;
}
None => {
chunker.pending = chunker.encoder.finish().into_iter();
chunker.done = true;
}
}
}
},
))
}
pub fn text_stream_chunks<S>(deltas: S) -> BoxStream<'static, UiMessageChunk>
where
S: Stream<Item = Result<TextDelta, TextModelError>> + Send + 'static,
{
chunk_stream(deltas, UiMessageEncoder::text_delta)
}
pub fn tool_loop_chunks<S>(events: S) -> BoxStream<'static, UiMessageChunk>
where
S: Stream<Item = Result<ToolLoopEvent, ToolLoopError>> + Send + 'static,
{
chunk_stream(events, UiMessageEncoder::tool_loop_event)
}
#[must_use]
pub fn ui_message_response<S>(chunks: S, clock: Arc<dyn Clock>) -> SseResponse
where
S: Stream<Item = UiMessageChunk> + Send + 'static,
{
let events = chunks
.map(|chunk| chunk.to_sse_event())
.chain(futures_util::stream::iter([SseEvent::new(DONE_MARKER)]));
SseResponse::new(events, clock).header(
HeaderName::from_static(UI_MESSAGE_STREAM_HEADER),
HeaderValue::from_static(UI_MESSAGE_STREAM_VERSION),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ports::ToolCall;
use serde_json::{Value, json};
fn wire(chunk: &UiMessageChunk) -> Value {
serde_json::to_value(chunk).expect("a chunk serialises")
}
fn kinds(chunks: &[UiMessageChunk]) -> Vec<String> {
chunks
.iter()
.map(|chunk| {
wire(chunk)["type"]
.as_str()
.map(str::to_owned)
.expect("every chunk types")
})
.collect()
}
#[test]
fn finish_reasons_map_onto_the_wire_words() {
assert_eq!(UiFinishReason::from(&FinishReason::Stop).as_str(), "stop");
assert_eq!(
UiFinishReason::from(&FinishReason::Length).as_str(),
"length"
);
assert_eq!(
UiFinishReason::from(&FinishReason::ToolUse).as_str(),
"tool-calls"
);
assert_eq!(
UiFinishReason::from(&FinishReason::ContentFilter).as_str(),
"content-filter"
);
assert_eq!(
UiFinishReason::from(&FinishReason::Other("weird".to_owned())).as_str(),
"other",
"an unknown reason, and any future one, is `other`"
);
}
#[test]
fn text_and_reasoning_parts_take_turns() {
let mut encoder = UiMessageEncoder::new();
let mut chunks = encoder.text_delta(&TextDelta::Reasoning("hmm".to_owned()));
chunks.extend(encoder.text_delta(&TextDelta::Text("hi".to_owned())));
chunks.extend(encoder.text_delta(&TextDelta::Reasoning("again".to_owned())));
let kinds: Vec<String> = chunks
.iter()
.map(|chunk| {
let object = wire(chunk);
format!(
"{}:{}",
object["type"].as_str().expect("typed"),
object.get("id").and_then(Value::as_str).unwrap_or("-")
)
})
.collect();
assert_eq!(
kinds,
vec![
"start:-",
"start-step:-",
"reasoning-start:reasoning-0",
"reasoning-delta:reasoning-0",
"reasoning-end:reasoning-0",
"text-start:text-0",
"text-delta:text-0",
"text-end:text-0",
"reasoning-start:reasoning-1",
"reasoning-delta:reasoning-1",
],
"opening one part closes the other; ids count per kind: {chunks:?}"
);
}
#[test]
fn nothing_is_emitted_after_the_message_ends() {
let mut encoder = UiMessageEncoder::new();
encoder.text_delta(&TextDelta::Finish {
reason: FinishReason::Stop,
model: "fake".to_owned(),
});
assert!(
encoder
.text_delta(&TextDelta::Text("late".to_owned()))
.is_empty()
);
assert!(encoder.error("late").is_empty());
assert!(encoder.finish().is_empty());
let mut encoder = UiMessageEncoder::new();
assert_eq!(
kinds(&encoder.error("nope")),
vec!["start", "start-step", "error"]
);
assert!(encoder.finish().is_empty());
assert!(
encoder
.tool_loop_event(&ToolLoopEvent::Delta {
step: 0,
delta: TextDelta::Text("late".to_owned()),
})
.is_empty()
);
}
#[test]
fn an_argument_chunk_for_a_call_that_never_started_is_dropped() {
let mut encoder = UiMessageEncoder::new();
let chunks = encoder.text_delta(&TextDelta::ToolCallArguments {
index: 3,
chunk: "{\"q\":".to_owned(),
});
assert!(chunks.is_empty(), "no start, no part to grow: {chunks:?}");
}
#[test]
fn a_finished_call_without_a_start_announces_itself_first() {
let mut encoder = UiMessageEncoder::new();
let chunks = encoder.text_delta(&TextDelta::ToolCallFinished {
index: 0,
call: ToolCall::new("call-9", "lookup", json!({"q": "x"})),
});
assert_eq!(
kinds(&chunks),
vec![
"start",
"start-step",
"tool-input-start",
"tool-input-available"
]
);
assert_eq!(
wire(&chunks[2])["toolCallId"],
json!("call-9"),
"the announcement carries the finished call's own id"
);
}
#[test]
fn a_tool_output_is_json_where_it_parses_and_a_string_where_it_does_not() {
assert_eq!(output_value("{\"a\": 1}"), json!({"a": 1}));
assert_eq!(output_value("ran lookup"), json!("ran lookup"));
}
}