Skip to main content

ag_ui/event/
text.rs

1//! Streaming assistant text: `TEXT_MESSAGE_*`.
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::event::BaseEvent;
6use crate::ids::{MessageId, SubagentRunId};
7
8/// Reads an omitted *or* explicitly null `role` as [`TextMessageRole`]'s
9/// default.
10///
11/// The field is optional on the wire, and a producer that models an optional
12/// field as *nullable* writes the absent case as `null` rather than by leaving
13/// the key out — the same case [`ToolCallStartEvent::parent_message_id`]
14/// documents. Without this, `"role": null` fails to deserialize and takes the
15/// whole event, and so usually the whole run, with it.
16///
17/// [`ToolCallStartEvent::parent_message_id`]: crate::event::ToolCallStartEvent::parent_message_id
18fn null_role_is_the_default<'de, D>(deserializer: D) -> Result<TextMessageRole, D::Error>
19where
20    D: Deserializer<'de>,
21{
22    Ok(Option::<TextMessageRole>::deserialize(deserializer)?.unwrap_or_default())
23}
24
25/// The roles a streamed text message may carry.
26///
27/// Every role except `tool` — a tool result is not streamed as text, it arrives
28/// whole in `TOOL_CALL_RESULT`.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
32#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
33pub enum TextMessageRole {
34    /// Out-of-band developer instructions.
35    Developer,
36    /// System prompt.
37    System,
38    /// Model output. The default when a producer omits the field.
39    #[default]
40    Assistant,
41    /// End-user input.
42    User,
43}
44
45impl TextMessageRole {
46    /// The role string as it appears on the wire.
47    pub const fn as_str(&self) -> &'static str {
48        match self {
49            Self::Developer => "developer",
50            Self::System => "system",
51            Self::Assistant => "assistant",
52            Self::User => "user",
53        }
54    }
55}
56
57/// Opens a text message. Every following `TEXT_MESSAGE_CONTENT` with the same
58/// `message_id` appends to it, until `TEXT_MESSAGE_END`.
59#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
62#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
63pub struct TextMessageStartEvent {
64    /// Timestamp and raw provider event.
65    #[serde(flatten)]
66    pub base: BaseEvent,
67    /// Id of the message being opened.
68    pub message_id: MessageId,
69    /// Who is speaking. Defaults to `assistant` when omitted, and a JSON `null`
70    /// reads as omitted.
71    #[serde(default, deserialize_with = "null_role_is_the_default")]
72    pub role: TextMessageRole,
73    /// Display name for the author.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub name: Option<String>,
76    /// The subagent that produced this event; absent means the parent agent.
77    /// A JSON `null` is rejected — see [`crate::event::subagent`].
78    #[serde(
79        default,
80        deserialize_with = "crate::serde_util::reject_null",
81        skip_serializing_if = "Option::is_none"
82    )]
83    pub subagent_run_id: Option<SubagentRunId>,
84}
85
86impl TextMessageStartEvent {
87    /// Opens a message with the given id and role.
88    pub fn new(message_id: impl Into<MessageId>, role: TextMessageRole) -> Self {
89        Self {
90            base: BaseEvent::default(),
91            message_id: message_id.into(),
92            role,
93            name: None,
94            subagent_run_id: None,
95        }
96    }
97}
98
99/// Appends a chunk of text to an open message.
100#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
104pub struct TextMessageContentEvent {
105    /// Timestamp and raw provider event.
106    #[serde(flatten)]
107    pub base: BaseEvent,
108    /// The message being appended to.
109    pub message_id: MessageId,
110    /// The text to append.
111    pub delta: String,
112    /// The subagent that produced this event; absent means the parent agent.
113    /// A JSON `null` is rejected — see [`crate::event::subagent`].
114    #[serde(
115        default,
116        deserialize_with = "crate::serde_util::reject_null",
117        skip_serializing_if = "Option::is_none"
118    )]
119    pub subagent_run_id: Option<SubagentRunId>,
120}
121
122impl TextMessageContentEvent {
123    /// Appends `delta` to the message.
124    pub fn new(message_id: impl Into<MessageId>, delta: impl Into<String>) -> Self {
125        Self {
126            base: BaseEvent::default(),
127            message_id: message_id.into(),
128            delta: delta.into(),
129            subagent_run_id: None,
130        }
131    }
132}
133
134/// Closes a text message.
135#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
139pub struct TextMessageEndEvent {
140    /// Timestamp and raw provider event.
141    #[serde(flatten)]
142    pub base: BaseEvent,
143    /// The message being closed.
144    pub message_id: MessageId,
145    /// The subagent that produced this event; absent means the parent agent.
146    /// A JSON `null` is rejected — see [`crate::event::subagent`].
147    #[serde(
148        default,
149        deserialize_with = "crate::serde_util::reject_null",
150        skip_serializing_if = "Option::is_none"
151    )]
152    pub subagent_run_id: Option<SubagentRunId>,
153}
154
155impl TextMessageEndEvent {
156    /// Closes the message.
157    pub fn new(message_id: impl Into<MessageId>) -> Self {
158        Self {
159            base: BaseEvent::default(),
160            message_id: message_id.into(),
161            subagent_run_id: None,
162        }
163    }
164}
165
166/// A self-contained text update: start, content and end folded into one event.
167///
168/// Producers that cannot bracket a message use this; consumers expand a run of
169/// chunks sharing a `message_id` into the equivalent start/content/end triple.
170#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
173#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
174pub struct TextMessageChunkEvent {
175    /// Timestamp and raw provider event.
176    #[serde(flatten)]
177    pub base: BaseEvent,
178    /// The message this chunk belongs to.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub message_id: Option<MessageId>,
181    /// Who is speaking.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub role: Option<TextMessageRole>,
184    /// The text to append.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub delta: Option<String>,
187    /// Display name for the author.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub name: Option<String>,
190    /// The subagent that produced this event; absent means the parent agent.
191    /// A JSON `null` is rejected — see [`crate::event::subagent`]. Under
192    /// concurrency a chunk that omits its `message_id` is resolved within the
193    /// sending subagent's own stream, so attribute every chunk when several
194    /// subagents stream at once.
195    #[serde(
196        default,
197        deserialize_with = "crate::serde_util::reject_null",
198        skip_serializing_if = "Option::is_none"
199    )]
200    pub subagent_run_id: Option<SubagentRunId>,
201}
202
203impl TextMessageChunkEvent {
204    /// Builds a chunk carrying a message id and a text delta.
205    pub fn new(message_id: Option<MessageId>, delta: Option<String>) -> Self {
206        Self {
207            base: BaseEvent::default(),
208            message_id,
209            role: None,
210            delta,
211            name: None,
212            subagent_run_id: None,
213        }
214    }
215}