1use serde::de::Error as DeError;
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(tag = "type", rename_all = "kebab-case")]
11pub enum UIStreamEvent {
12 #[serde(rename = "start")]
19 MessageStart {
20 #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
22 message_id: Option<String>,
23 #[serde(rename = "messageMetadata", skip_serializing_if = "Option::is_none")]
25 message_metadata: Option<Value>,
26 },
27
28 TextStart {
33 id: String,
35 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
37 provider_metadata: Option<Value>,
38 },
39
40 TextDelta {
42 id: String,
44 delta: String,
46 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
48 provider_metadata: Option<Value>,
49 },
50
51 TextEnd {
53 id: String,
55 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
57 provider_metadata: Option<Value>,
58 },
59
60 ReasoningStart {
65 id: String,
67 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
69 provider_metadata: Option<Value>,
70 },
71
72 ReasoningDelta {
74 id: String,
76 delta: String,
78 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
80 provider_metadata: Option<Value>,
81 },
82
83 ReasoningEnd {
85 id: String,
87 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
89 provider_metadata: Option<Value>,
90 },
91
92 ToolInputStart {
97 #[serde(rename = "toolCallId")]
99 tool_call_id: String,
100 #[serde(rename = "toolName")]
102 tool_name: String,
103 #[serde(rename = "providerExecuted", skip_serializing_if = "Option::is_none")]
105 provider_executed: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 dynamic: Option<bool>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 title: Option<String>,
112 },
113
114 ToolInputDelta {
116 #[serde(rename = "toolCallId")]
118 tool_call_id: String,
119 #[serde(rename = "inputTextDelta")]
121 input_text_delta: String,
122 },
123
124 ToolInputAvailable {
126 #[serde(rename = "toolCallId")]
128 tool_call_id: String,
129 #[serde(rename = "toolName")]
131 tool_name: String,
132 input: Value,
134 #[serde(rename = "providerExecuted", skip_serializing_if = "Option::is_none")]
136 provider_executed: Option<bool>,
137 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
139 provider_metadata: Option<Value>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 dynamic: Option<bool>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 title: Option<String>,
146 },
147
148 ToolInputError {
153 #[serde(rename = "toolCallId")]
155 tool_call_id: String,
156 #[serde(rename = "toolName")]
158 tool_name: String,
159 input: Value,
161 #[serde(rename = "providerExecuted", skip_serializing_if = "Option::is_none")]
163 provider_executed: Option<bool>,
164 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
166 provider_metadata: Option<Value>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 dynamic: Option<bool>,
170 #[serde(rename = "errorText")]
172 error_text: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 title: Option<String>,
176 },
177
178 ToolApprovalRequest {
180 #[serde(rename = "approvalId")]
182 approval_id: String,
183 #[serde(rename = "toolCallId")]
185 tool_call_id: String,
186 },
187
188 ToolOutputAvailable {
193 #[serde(rename = "toolCallId")]
195 tool_call_id: String,
196 output: Value,
198 #[serde(rename = "providerExecuted", skip_serializing_if = "Option::is_none")]
200 provider_executed: Option<bool>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 dynamic: Option<bool>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 preliminary: Option<bool>,
207 },
208
209 ToolOutputDenied {
211 #[serde(rename = "toolCallId")]
213 tool_call_id: String,
214 },
215
216 ToolOutputError {
218 #[serde(rename = "toolCallId")]
220 tool_call_id: String,
221 #[serde(rename = "errorText")]
223 error_text: String,
224 #[serde(rename = "providerExecuted", skip_serializing_if = "Option::is_none")]
226 provider_executed: Option<bool>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 dynamic: Option<bool>,
230 },
231
232 StartStep,
237
238 FinishStep,
240
241 SourceUrl {
246 #[serde(rename = "sourceId")]
248 source_id: String,
249 url: String,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 title: Option<String>,
254 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
256 provider_metadata: Option<Value>,
257 },
258
259 SourceDocument {
261 #[serde(rename = "sourceId")]
263 source_id: String,
264 #[serde(rename = "mediaType")]
266 media_type: String,
267 title: String,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 filename: Option<String>,
272 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
274 provider_metadata: Option<Value>,
275 },
276
277 File {
279 url: String,
281 #[serde(rename = "mediaType")]
283 media_type: String,
284 #[serde(rename = "providerMetadata", skip_serializing_if = "Option::is_none")]
286 provider_metadata: Option<Value>,
287 },
288
289 Finish {
294 #[serde(rename = "finishReason", skip_serializing_if = "Option::is_none")]
296 finish_reason: Option<String>,
297 #[serde(rename = "messageMetadata", skip_serializing_if = "Option::is_none")]
299 message_metadata: Option<Value>,
300 },
301
302 Abort {
304 #[serde(skip_serializing_if = "Option::is_none")]
306 reason: Option<String>,
307 },
308
309 MessageMetadata {
311 #[serde(rename = "messageMetadata")]
313 message_metadata: Value,
314 },
315
316 Error {
318 #[serde(rename = "errorText")]
320 error_text: String,
321 },
322
323 #[serde(untagged)]
328 Data {
329 #[serde(rename = "type", deserialize_with = "deserialize_data_event_type")]
331 data_type: String,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 id: Option<String>,
335 data: Value,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 transient: Option<bool>,
340 },
341}
342
343impl UIStreamEvent {
344 pub fn message_start(message_id: impl Into<String>) -> Self {
350 Self::MessageStart {
351 message_id: Some(message_id.into()),
352 message_metadata: None,
353 }
354 }
355
356 pub fn start() -> Self {
358 Self::MessageStart {
359 message_id: None,
360 message_metadata: None,
361 }
362 }
363
364 pub fn text_start(id: impl Into<String>) -> Self {
366 Self::TextStart {
367 id: id.into(),
368 provider_metadata: None,
369 }
370 }
371
372 pub fn text_delta(id: impl Into<String>, delta: impl Into<String>) -> Self {
374 Self::TextDelta {
375 id: id.into(),
376 delta: delta.into(),
377 provider_metadata: None,
378 }
379 }
380
381 pub fn text_end(id: impl Into<String>) -> Self {
383 Self::TextEnd {
384 id: id.into(),
385 provider_metadata: None,
386 }
387 }
388
389 pub fn reasoning_start(id: impl Into<String>) -> Self {
391 Self::ReasoningStart {
392 id: id.into(),
393 provider_metadata: None,
394 }
395 }
396
397 pub fn reasoning_delta(id: impl Into<String>, delta: impl Into<String>) -> Self {
399 Self::ReasoningDelta {
400 id: id.into(),
401 delta: delta.into(),
402 provider_metadata: None,
403 }
404 }
405
406 pub fn reasoning_end(id: impl Into<String>) -> Self {
408 Self::ReasoningEnd {
409 id: id.into(),
410 provider_metadata: None,
411 }
412 }
413
414 pub fn tool_input_start(tool_call_id: impl Into<String>, tool_name: impl Into<String>) -> Self {
416 Self::ToolInputStart {
417 tool_call_id: tool_call_id.into(),
418 tool_name: tool_name.into(),
419 provider_executed: None,
420 dynamic: None,
421 title: None,
422 }
423 }
424
425 pub fn tool_input_delta(tool_call_id: impl Into<String>, delta: impl Into<String>) -> Self {
427 Self::ToolInputDelta {
428 tool_call_id: tool_call_id.into(),
429 input_text_delta: delta.into(),
430 }
431 }
432
433 pub fn tool_input_available(
435 tool_call_id: impl Into<String>,
436 tool_name: impl Into<String>,
437 input: Value,
438 ) -> Self {
439 Self::ToolInputAvailable {
440 tool_call_id: tool_call_id.into(),
441 tool_name: tool_name.into(),
442 input,
443 provider_executed: None,
444 provider_metadata: None,
445 dynamic: None,
446 title: None,
447 }
448 }
449
450 pub fn tool_input_error(
452 tool_call_id: impl Into<String>,
453 tool_name: impl Into<String>,
454 input: Value,
455 error_text: impl Into<String>,
456 ) -> Self {
457 Self::ToolInputError {
458 tool_call_id: tool_call_id.into(),
459 tool_name: tool_name.into(),
460 input,
461 provider_executed: None,
462 provider_metadata: None,
463 dynamic: None,
464 error_text: error_text.into(),
465 title: None,
466 }
467 }
468
469 pub fn tool_approval_request(
471 approval_id: impl Into<String>,
472 tool_call_id: impl Into<String>,
473 ) -> Self {
474 Self::ToolApprovalRequest {
475 approval_id: approval_id.into(),
476 tool_call_id: tool_call_id.into(),
477 }
478 }
479
480 pub fn tool_output_available(tool_call_id: impl Into<String>, output: Value) -> Self {
482 Self::ToolOutputAvailable {
483 tool_call_id: tool_call_id.into(),
484 output,
485 provider_executed: None,
486 dynamic: None,
487 preliminary: None,
488 }
489 }
490
491 pub fn tool_output_denied(tool_call_id: impl Into<String>) -> Self {
493 Self::ToolOutputDenied {
494 tool_call_id: tool_call_id.into(),
495 }
496 }
497
498 pub fn tool_output_error(
500 tool_call_id: impl Into<String>,
501 error_text: impl Into<String>,
502 ) -> Self {
503 Self::ToolOutputError {
504 tool_call_id: tool_call_id.into(),
505 error_text: error_text.into(),
506 provider_executed: None,
507 dynamic: None,
508 }
509 }
510
511 pub fn start_step() -> Self {
513 Self::StartStep
514 }
515
516 pub fn finish_step() -> Self {
518 Self::FinishStep
519 }
520
521 pub fn source_url(
523 source_id: impl Into<String>,
524 url: impl Into<String>,
525 title: Option<String>,
526 ) -> Self {
527 Self::SourceUrl {
528 source_id: source_id.into(),
529 url: url.into(),
530 title,
531 provider_metadata: None,
532 }
533 }
534
535 pub fn source_document(
537 source_id: impl Into<String>,
538 media_type: impl Into<String>,
539 title: impl Into<String>,
540 filename: Option<String>,
541 ) -> Self {
542 Self::SourceDocument {
543 source_id: source_id.into(),
544 media_type: media_type.into(),
545 title: title.into(),
546 filename,
547 provider_metadata: None,
548 }
549 }
550
551 pub fn file(url: impl Into<String>, media_type: impl Into<String>) -> Self {
553 Self::File {
554 url: url.into(),
555 media_type: media_type.into(),
556 provider_metadata: None,
557 }
558 }
559
560 pub fn finish() -> Self {
562 Self::Finish {
563 finish_reason: None,
564 message_metadata: None,
565 }
566 }
567
568 pub fn finish_with_reason(reason: impl Into<String>) -> Self {
570 Self::Finish {
571 finish_reason: Some(reason.into()),
572 message_metadata: None,
573 }
574 }
575
576 pub fn abort(reason: impl Into<String>) -> Self {
578 Self::Abort {
579 reason: Some(reason.into()),
580 }
581 }
582
583 pub fn message_metadata(message_metadata: Value) -> Self {
585 Self::MessageMetadata { message_metadata }
586 }
587
588 pub fn error(error_text: impl Into<String>) -> Self {
590 Self::Error {
591 error_text: error_text.into(),
592 }
593 }
594
595 pub fn data(name: impl Into<String>, data: Value) -> Self {
597 Self::data_with_options(name, data, None, None)
598 }
599
600 pub fn data_with_options(
602 name: impl Into<String>,
603 data: Value,
604 id: Option<String>,
605 transient: Option<bool>,
606 ) -> Self {
607 let name = name.into();
608 let data_type = if name.starts_with("data-") {
609 name
610 } else {
611 format!("data-{name}")
612 };
613 Self::Data {
614 data_type,
615 id,
616 data,
617 transient,
618 }
619 }
620}
621
622fn deserialize_data_event_type<'de, D>(deserializer: D) -> Result<String, D::Error>
623where
624 D: Deserializer<'de>,
625{
626 let value = String::deserialize(deserializer)?;
627 if value.starts_with("data-") {
628 Ok(value)
629 } else {
630 Err(D::Error::custom(format!(
631 "invalid data event type '{value}', expected 'data-*'"
632 )))
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use serde_json::json;
640
641 #[test]
642 fn data_event_with_options_serializes_id_and_transient() {
643 let event = UIStreamEvent::data_with_options(
644 "reasoning-encrypted",
645 json!({ "encryptedValue": "opaque" }),
646 Some("reasoning_msg_1".to_string()),
647 Some(true),
648 );
649 let value = serde_json::to_value(event).expect("serialize data event");
650
651 assert_eq!(value["type"], "data-reasoning-encrypted");
652 assert_eq!(value["id"], "reasoning_msg_1");
653 assert_eq!(value["transient"], true);
654 }
655
656 #[test]
657 fn data_event_rejects_non_prefixed_type() {
658 let err = serde_json::from_value::<UIStreamEvent>(json!({
659 "type": "reasoning-encrypted",
660 "data": { "encryptedValue": "opaque" }
661 }))
662 .expect_err("invalid data type must be rejected");
663
664 assert!(!err.to_string().is_empty());
665 }
666
667 #[test]
668 fn tool_input_error_roundtrip() {
669 let event = UIStreamEvent::tool_input_error(
670 "call_1",
671 "search",
672 json!({ "q": "rust" }),
673 "invalid args",
674 );
675 let raw = serde_json::to_string(&event).expect("serialize tool-input-error");
676 let restored: UIStreamEvent =
677 serde_json::from_str(&raw).expect("deserialize tool-input-error");
678
679 assert!(matches!(
680 restored,
681 UIStreamEvent::ToolInputError {
682 tool_call_id,
683 tool_name,
684 error_text,
685 ..
686 } if tool_call_id == "call_1" && tool_name == "search" && error_text == "invalid args"
687 ));
688 }
689
690 #[test]
691 fn message_metadata_roundtrip() {
692 let event = UIStreamEvent::message_metadata(json!({ "step": 2 }));
693 let raw = serde_json::to_string(&event).expect("serialize message metadata");
694 let restored: UIStreamEvent =
695 serde_json::from_str(&raw).expect("deserialize message metadata");
696
697 assert!(matches!(
698 restored,
699 UIStreamEvent::MessageMetadata { message_metadata } if message_metadata["step"] == 2
700 ));
701 }
702
703 #[test]
704 fn text_delta_roundtrip_preserves_provider_metadata() {
705 let event = UIStreamEvent::TextDelta {
706 id: "txt_1".to_string(),
707 delta: "hello".to_string(),
708 provider_metadata: Some(json!({ "model": "x" })),
709 };
710 let raw = serde_json::to_string(&event).expect("serialize text delta");
711 let restored: UIStreamEvent = serde_json::from_str(&raw).expect("deserialize text delta");
712
713 assert!(matches!(
714 restored,
715 UIStreamEvent::TextDelta {
716 id,
717 delta,
718 provider_metadata: Some(provider_metadata),
719 } if id == "txt_1" && delta == "hello" && provider_metadata["model"] == "x"
720 ));
721 }
722}