1#![allow(clippy::doc_markdown)]
3
4use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50
51#[cfg(feature = "schema-generation")]
52use schemars::JsonSchema;
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
75#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
76pub struct WidgetCSP {
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub connect_domains: Vec<String>,
82
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub resource_domains: Vec<String>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub redirect_domains: Option<Vec<String>>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub frame_domains: Option<Vec<String>>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub base_uri_domains: Option<Vec<String>>,
110}
111
112impl WidgetCSP {
113 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn connect(mut self, domain: impl Into<String>) -> Self {
130 self.connect_domains.push(domain.into());
131 self
132 }
133
134 pub fn resources(mut self, domain: impl Into<String>) -> Self {
146 self.resource_domains.push(domain.into());
147 self
148 }
149
150 pub fn redirect(mut self, domain: impl Into<String>) -> Self {
152 self.redirect_domains
153 .get_or_insert_with(Vec::new)
154 .push(domain.into());
155 self
156 }
157
158 pub fn frame(mut self, domain: impl Into<String>) -> Self {
163 self.frame_domains
164 .get_or_insert_with(Vec::new)
165 .push(domain.into());
166 self
167 }
168
169 pub fn base_uri(mut self, domain: impl Into<String>) -> Self {
173 self.base_uri_domains
174 .get_or_insert_with(Vec::new)
175 .push(domain.into());
176 self
177 }
178
179 pub fn to_spec_map(&self) -> serde_json::Map<String, serde_json::Value> {
184 let mut csp_obj = serde_json::Map::with_capacity(4);
185 if !self.connect_domains.is_empty() {
186 csp_obj.insert(
187 "connectDomains".into(),
188 serde_json::json!(self.connect_domains),
189 );
190 }
191 if !self.resource_domains.is_empty() {
192 csp_obj.insert(
193 "resourceDomains".into(),
194 serde_json::json!(self.resource_domains),
195 );
196 }
197 if let Some(frames) = &self.frame_domains {
198 if !frames.is_empty() {
199 csp_obj.insert("frameDomains".into(), serde_json::json!(frames));
200 }
201 }
202 if let Some(base_uris) = &self.base_uri_domains {
203 if !base_uris.is_empty() {
204 csp_obj.insert("baseUriDomains".into(), serde_json::json!(base_uris));
205 }
206 }
207 csp_obj
208 }
209
210 pub fn is_empty(&self) -> bool {
212 self.connect_domains.is_empty()
213 && self.resource_domains.is_empty()
214 && self.redirect_domains.as_ref().is_none_or(Vec::is_empty)
215 && self.frame_domains.as_ref().is_none_or(Vec::is_empty)
216 && self.base_uri_domains.as_ref().is_none_or(Vec::is_empty)
217 }
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
247#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
248pub struct WidgetMeta {
249 #[serde(skip)]
256 pub resource_uri: Option<String>,
257
258 #[serde(
260 rename = "openai/widgetPrefersBorder",
261 skip_serializing_if = "Option::is_none"
262 )]
263 pub prefers_border: Option<bool>,
264
265 #[serde(
271 rename = "openai/widgetDomain",
272 skip_serializing_if = "Option::is_none"
273 )]
274 pub domain: Option<String>,
275
276 #[serde(rename = "openai/widgetCSP", skip_serializing_if = "Option::is_none")]
278 pub csp: Option<WidgetCSP>,
279
280 #[serde(
284 rename = "openai/widgetDescription",
285 skip_serializing_if = "Option::is_none"
286 )]
287 pub description: Option<String>,
288}
289
290impl WidgetMeta {
291 pub fn new() -> Self {
293 Self::default()
294 }
295
296 pub fn resource_uri(mut self, uri: impl Into<String>) -> Self {
301 self.resource_uri = Some(uri.into());
302 self
303 }
304
305 pub fn prefers_border(mut self, prefers: bool) -> Self {
307 self.prefers_border = Some(prefers);
308 self
309 }
310
311 pub fn domain(mut self, domain: impl Into<String>) -> Self {
313 self.domain = Some(domain.into());
314 self
315 }
316
317 pub fn description(mut self, desc: impl Into<String>) -> Self {
319 self.description = Some(desc.into());
320 self
321 }
322
323 pub fn csp(mut self, csp: WidgetCSP) -> Self {
325 self.csp = Some(csp);
326 self
327 }
328
329 pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
338 let mut map = match serde_json::to_value(self).ok() {
339 Some(serde_json::Value::Object(m)) => m,
340 _ => serde_json::Map::new(),
341 };
342 let mut ui_obj = serde_json::Map::new();
344 if let Some(uri) = &self.resource_uri {
345 crate::types::ui::emit_resource_uri_keys(&mut ui_obj, uri);
346 }
347 if let Some(prefers) = self.prefers_border {
348 ui_obj.insert(
349 "prefersBorder".to_string(),
350 serde_json::Value::Bool(prefers),
351 );
352 }
353 if let Some(domain) = &self.domain {
354 ui_obj.insert(
355 "domain".to_string(),
356 serde_json::Value::String(domain.clone()),
357 );
358 }
359 if let Some(csp) = &self.csp {
360 let csp_obj = csp.to_spec_map();
361 if !csp_obj.is_empty() {
362 ui_obj.insert("csp".to_string(), serde_json::Value::Object(csp_obj));
363 }
364 }
365 if !ui_obj.is_empty() {
366 map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
367 }
368 if let Some(uri) = &self.resource_uri {
370 crate::types::ui::insert_legacy_resource_uri_key(&mut map, uri);
371 }
372 map
373 }
374
375 pub fn is_empty(&self) -> bool {
377 self.resource_uri.is_none()
378 && self.prefers_border.is_none()
379 && self.domain.is_none()
380 && self.csp.is_none()
381 && self.description.is_none()
382 }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
394#[serde(rename_all = "lowercase")]
395pub enum ToolVisibility {
396 #[default]
400 Public,
401
402 Private,
408
409 ModelOnly,
413}
414
415impl ToolVisibility {
416 pub fn to_visibility_array(&self) -> &'static [&'static str] {
424 match self {
425 Self::Public => &["model", "app"],
426 Self::Private => &["app"],
427 Self::ModelOnly => &["model"],
428 }
429 }
430}
431
432#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
454#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
455pub struct ChatGptToolMeta {
456 #[serde(
460 rename = "openai/outputTemplate",
461 skip_serializing_if = "Option::is_none"
462 )]
463 pub output_template: Option<String>,
464
465 #[serde(
469 rename = "openai/toolInvocation/invoking",
470 skip_serializing_if = "Option::is_none"
471 )]
472 pub invoking: Option<String>,
473
474 #[serde(
478 rename = "openai/toolInvocation/invoked",
479 skip_serializing_if = "Option::is_none"
480 )]
481 pub invoked: Option<String>,
482
483 #[serde(
487 rename = "openai/widgetAccessible",
488 skip_serializing_if = "Option::is_none"
489 )]
490 pub widget_accessible: Option<bool>,
491
492 #[serde(rename = "openai/visibility", skip_serializing_if = "Option::is_none")]
494 pub visibility: Option<ToolVisibility>,
495
496 #[serde(rename = "openai/fileParams", skip_serializing_if = "Option::is_none")]
500 pub file_params: Option<Vec<String>>,
501}
502
503impl ChatGptToolMeta {
504 pub fn new() -> Self {
506 Self::default()
507 }
508
509 pub fn output_template(mut self, uri: impl Into<String>) -> Self {
511 self.output_template = Some(uri.into());
512 self
513 }
514
515 pub fn invoking(mut self, msg: impl Into<String>) -> Self {
517 self.invoking = Some(msg.into());
518 self
519 }
520
521 pub fn invoked(mut self, msg: impl Into<String>) -> Self {
523 self.invoked = Some(msg.into());
524 self
525 }
526
527 pub fn widget_accessible(mut self, accessible: bool) -> Self {
529 self.widget_accessible = Some(accessible);
530 self
531 }
532
533 pub fn visibility(mut self, visibility: ToolVisibility) -> Self {
535 self.visibility = Some(visibility);
536 self
537 }
538
539 pub fn file_params(mut self, params: impl IntoIterator<Item = impl Into<String>>) -> Self {
541 self.file_params = Some(params.into_iter().map(Into::into).collect());
542 self
543 }
544
545 pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
550 let mut map = serde_json::to_value(self)
551 .ok()
552 .and_then(|v| v.as_object().cloned())
553 .unwrap_or_default();
554 if let Some(vis) = &self.visibility {
556 let mut ui_obj = serde_json::Map::with_capacity(1);
557 ui_obj.insert(
558 "visibility".to_string(),
559 serde_json::json!(vis.to_visibility_array()),
560 );
561 map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
562 }
563 map
564 }
565
566 pub fn is_empty(&self) -> bool {
568 self.output_template.is_none()
569 && self.invoking.is_none()
570 && self.invoked.is_none()
571 && self.widget_accessible.is_none()
572 && self.visibility.is_none()
573 && self.file_params.is_none()
574 }
575}
576
577#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
597#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
598pub struct WidgetResponseMeta {
599 #[serde(rename = "openai/closeWidget", skip_serializing_if = "Option::is_none")]
604 pub close_widget: Option<bool>,
605
606 #[serde(
611 rename = "openai/widgetSessionId",
612 skip_serializing_if = "Option::is_none"
613 )]
614 pub widget_session_id: Option<String>,
615}
616
617impl WidgetResponseMeta {
618 pub fn new() -> Self {
620 Self::default()
621 }
622
623 pub fn close_widget(mut self, close: bool) -> Self {
625 self.close_widget = Some(close);
626 self
627 }
628
629 pub fn widget_session_id(mut self, id: impl Into<String>) -> Self {
631 self.widget_session_id = Some(id.into());
632 self
633 }
634
635 pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
637 serde_json::to_value(self)
638 .ok()
639 .and_then(|v| v.as_object().cloned())
640 .unwrap_or_default()
641 }
642
643 pub fn is_empty(&self) -> bool {
645 self.close_widget.is_none() && self.widget_session_id.is_none()
646 }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
655#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
656#[serde(rename_all = "lowercase")]
657pub enum NotifyLevel {
658 Info,
660 Success,
662 Warning,
664 Error,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
694#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
695#[serde(tag = "type", rename_all = "camelCase")]
696pub enum UIAction {
697 #[serde(rename_all = "camelCase")]
701 Tool {
702 name: String,
704 arguments: serde_json::Value,
706 #[serde(skip_serializing_if = "Option::is_none")]
708 message_id: Option<String>,
709 },
710
711 Prompt {
715 text: String,
717 },
718
719 Intent {
723 action: String,
725 data: serde_json::Value,
727 },
728
729 Notify {
733 level: NotifyLevel,
735 message: String,
737 },
738
739 Link {
743 url: String,
745 },
746
747 SetState {
751 state: serde_json::Value,
753 },
754
755 SendMessage {
759 message: String,
761 },
762}
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
772pub enum ExtendedUIMimeType {
773 HtmlMcp,
777
778 HtmlSkybridge,
783
784 HtmlMcpApp,
789
790 HtmlPlain,
792
793 UriList,
797
798 RemoteDom,
802
803 RemoteDomReact,
805}
806
807impl ExtendedUIMimeType {
808 pub fn as_str(&self) -> &'static str {
810 match self {
811 Self::HtmlMcp => "text/html+mcp",
812 Self::HtmlSkybridge => "text/html+skybridge",
813 Self::HtmlMcpApp => "text/html;profile=mcp-app",
814 Self::HtmlPlain => "text/html",
815 Self::UriList => "text/uri-list",
816 Self::RemoteDom => "application/vnd.mcp-ui.remote-dom+javascript",
817 Self::RemoteDomReact => "application/vnd.mcp-ui.remote-dom+javascript; framework=react",
818 }
819 }
820
821 pub fn is_chatgpt(&self) -> bool {
823 matches!(self, Self::HtmlSkybridge | Self::HtmlMcpApp)
824 }
825
826 pub fn is_mcp_apps(&self) -> bool {
828 matches!(self, Self::HtmlMcp | Self::HtmlMcpApp)
829 }
830
831 pub fn is_mcp_ui(&self) -> bool {
833 matches!(
834 self,
835 Self::HtmlPlain | Self::UriList | Self::RemoteDom | Self::RemoteDomReact
836 )
837 }
838}
839
840impl std::fmt::Display for ExtendedUIMimeType {
841 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
842 write!(f, "{}", self.as_str())
843 }
844}
845
846impl std::str::FromStr for ExtendedUIMimeType {
847 type Err = String;
848
849 fn from_str(s: &str) -> Result<Self, Self::Err> {
850 match s {
851 "text/html+mcp" => Ok(Self::HtmlMcp),
852 "text/html+skybridge" => Ok(Self::HtmlSkybridge),
853 "text/html;profile=mcp-app" => Ok(Self::HtmlMcpApp),
854 "text/html" => Ok(Self::HtmlPlain),
855 "text/uri-list" => Ok(Self::UriList),
856 "application/vnd.mcp-ui.remote-dom+javascript" => Ok(Self::RemoteDom),
857 s if s.starts_with("application/vnd.mcp-ui.remote-dom+javascript") => {
858 if s.contains("framework=react") {
859 Ok(Self::RemoteDomReact)
860 } else {
861 Ok(Self::RemoteDom)
862 }
863 },
864 _ => Err(format!("Unknown UI MIME type: {}", s)),
865 }
866 }
867}
868
869impl From<crate::types::ui::UIMimeType> for ExtendedUIMimeType {
879 fn from(value: crate::types::ui::UIMimeType) -> Self {
880 match value {
881 crate::types::ui::UIMimeType::HtmlMcp => Self::HtmlMcp,
882 crate::types::ui::UIMimeType::HtmlSkybridge => Self::HtmlSkybridge,
883 crate::types::ui::UIMimeType::HtmlMcpApp => Self::HtmlMcpApp,
884 }
885 }
886}
887
888impl TryFrom<ExtendedUIMimeType> for crate::types::ui::UIMimeType {
895 type Error = String;
896
897 fn try_from(value: ExtendedUIMimeType) -> Result<Self, Self::Error> {
898 match value {
899 ExtendedUIMimeType::HtmlMcp => Ok(Self::HtmlMcp),
900 ExtendedUIMimeType::HtmlSkybridge => Ok(Self::HtmlSkybridge),
901 ExtendedUIMimeType::HtmlMcpApp => Ok(Self::HtmlMcpApp),
902 other => Err(format!(
903 "Cannot convert {} to UIMimeType (extended-only variant)",
904 other
905 )),
906 }
907 }
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
918#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
919#[serde(tag = "type", rename_all = "camelCase")]
920pub enum UIContent {
921 Html {
923 html: String,
925 },
926
927 Url {
929 url: String,
931 },
932
933 #[cfg(feature = "mcp-apps")]
935 RemoteDom {
936 script: String,
938 framework: RemoteDomFramework,
940 },
941}
942
943#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
945#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
946#[serde(rename_all = "lowercase")]
947pub enum RemoteDomFramework {
948 #[default]
950 WebComponents,
951 React,
953}
954
955#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
961#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
962pub struct UIDimensions {
963 #[serde(skip_serializing_if = "Option::is_none")]
965 pub width: Option<u32>,
966 #[serde(skip_serializing_if = "Option::is_none")]
968 pub height: Option<u32>,
969 #[serde(skip_serializing_if = "Option::is_none")]
971 pub min_width: Option<u32>,
972 #[serde(skip_serializing_if = "Option::is_none")]
974 pub min_height: Option<u32>,
975 #[serde(skip_serializing_if = "Option::is_none")]
977 pub max_width: Option<u32>,
978 #[serde(skip_serializing_if = "Option::is_none")]
980 pub max_height: Option<u32>,
981}
982
983#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
985#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
986pub struct UIMetadata {
987 #[serde(skip_serializing_if = "Option::is_none")]
989 pub description: Option<String>,
990
991 #[serde(skip_serializing_if = "Option::is_none")]
993 pub dimensions: Option<UIDimensions>,
994
995 #[serde(skip_serializing_if = "Option::is_none")]
997 pub initial_data: Option<serde_json::Value>,
998
999 #[serde(skip_serializing_if = "Option::is_none")]
1001 pub csp: Option<WidgetCSP>,
1002
1003 #[serde(flatten)]
1005 pub extra: HashMap<String, serde_json::Value>,
1006}
1007
1008#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1014pub enum HostType {
1015 ChatGpt,
1017 Claude,
1019 Nanobot,
1021 McpJam,
1023 Generic,
1025}
1026
1027impl HostType {
1028 pub fn preferred_mime_type(&self) -> ExtendedUIMimeType {
1030 match self {
1031 Self::ChatGpt => ExtendedUIMimeType::HtmlSkybridge,
1032 Self::Claude | Self::Generic => ExtendedUIMimeType::HtmlMcpApp,
1034 Self::Nanobot | Self::McpJam => ExtendedUIMimeType::HtmlPlain,
1035 }
1036 }
1037
1038 pub fn supports_mime_type(&self, mime_type: ExtendedUIMimeType) -> bool {
1040 match self {
1041 Self::ChatGpt => matches!(
1042 mime_type,
1043 ExtendedUIMimeType::HtmlSkybridge | ExtendedUIMimeType::HtmlMcpApp
1044 ),
1045 Self::Claude | Self::Generic => matches!(
1047 mime_type,
1048 ExtendedUIMimeType::HtmlMcp | ExtendedUIMimeType::HtmlMcpApp
1049 ),
1050 Self::Nanobot | Self::McpJam => mime_type.is_mcp_ui(),
1051 }
1052 }
1053}
1054
1055#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use serde_json::json;
1063
1064 #[test]
1065 fn test_widget_csp_builder() {
1066 let csp = WidgetCSP::new()
1067 .connect("https://api.example.com")
1068 .connect("https://api2.example.com")
1069 .resources("https://cdn.example.com")
1070 .redirect("https://checkout.example.com")
1071 .frame("https://embed.example.com");
1072
1073 assert_eq!(csp.connect_domains.len(), 2);
1074 assert_eq!(csp.resource_domains.len(), 1);
1075 assert_eq!(csp.redirect_domains.as_ref().unwrap().len(), 1);
1076 assert_eq!(csp.frame_domains.as_ref().unwrap().len(), 1);
1077 }
1078
1079 #[test]
1080 fn test_widget_csp_serialization() {
1081 let csp = WidgetCSP::new()
1082 .connect("https://api.example.com")
1083 .resources("https://cdn.example.com");
1084
1085 let json = serde_json::to_value(&csp).unwrap();
1086 assert_eq!(json["connect_domains"][0], "https://api.example.com");
1087 assert_eq!(json["resource_domains"][0], "https://cdn.example.com");
1088 assert!(json.get("redirect_domains").is_none());
1089 assert!(json.get("frame_domains").is_none());
1090 }
1091
1092 #[test]
1093 fn test_widget_meta_builder() {
1094 let meta = WidgetMeta::new()
1095 .prefers_border(true)
1096 .domain("https://chatgpt.com")
1097 .description("Test widget")
1098 .csp(WidgetCSP::new().connect("https://api.example.com"));
1099
1100 assert_eq!(meta.prefers_border, Some(true));
1101 assert_eq!(meta.domain, Some("https://chatgpt.com".to_string()));
1102 assert_eq!(meta.description, Some("Test widget".to_string()));
1103 assert!(meta.csp.is_some());
1104 }
1105
1106 #[test]
1107 fn test_widget_meta_serialization() {
1108 let meta = WidgetMeta::new().prefers_border(true).description("Test");
1109
1110 let json = serde_json::to_value(&meta).unwrap();
1111 assert_eq!(json["openai/widgetPrefersBorder"], true);
1112 assert_eq!(json["openai/widgetDescription"], "Test");
1113 }
1114
1115 #[test]
1116 fn test_chatgpt_tool_meta_builder() {
1117 let meta = ChatGptToolMeta::new()
1118 .output_template("ui://widget/test.html")
1119 .invoking("Loading...")
1120 .invoked("Done!")
1121 .widget_accessible(true)
1122 .visibility(ToolVisibility::Public)
1123 .file_params(vec!["imageFile", "documentFile"]);
1124
1125 assert_eq!(
1126 meta.output_template,
1127 Some("ui://widget/test.html".to_string())
1128 );
1129 assert_eq!(meta.invoking, Some("Loading...".to_string()));
1130 assert_eq!(meta.invoked, Some("Done!".to_string()));
1131 assert_eq!(meta.widget_accessible, Some(true));
1132 assert_eq!(meta.visibility, Some(ToolVisibility::Public));
1133 assert_eq!(meta.file_params.as_ref().unwrap().len(), 2);
1134 }
1135
1136 #[test]
1137 fn test_chatgpt_tool_meta_serialization() {
1138 let meta = ChatGptToolMeta::new()
1139 .output_template("ui://widget/test.html")
1140 .invoking("Loading...")
1141 .widget_accessible(true)
1142 .visibility(ToolVisibility::Private);
1143
1144 let json = serde_json::to_value(&meta).unwrap();
1145 assert_eq!(json["openai/outputTemplate"], "ui://widget/test.html");
1146 assert_eq!(json["openai/toolInvocation/invoking"], "Loading...");
1147 assert_eq!(json["openai/widgetAccessible"], true);
1148 assert_eq!(json["openai/visibility"], "private");
1149 }
1150
1151 #[test]
1152 fn test_tool_visibility_serialization() {
1153 assert_eq!(
1154 serde_json::to_value(ToolVisibility::Public).unwrap(),
1155 "public"
1156 );
1157 assert_eq!(
1158 serde_json::to_value(ToolVisibility::Private).unwrap(),
1159 "private"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_ui_action_tool() {
1165 let action = UIAction::Tool {
1166 name: "test_tool".to_string(),
1167 arguments: json!({ "param": "value" }),
1168 message_id: Some("123".to_string()),
1169 };
1170
1171 let json = serde_json::to_value(&action).unwrap();
1172 assert_eq!(json["type"], "tool");
1173 assert_eq!(json["name"], "test_tool");
1174 assert_eq!(json["arguments"]["param"], "value");
1175 assert_eq!(json["messageId"], "123");
1176 }
1177
1178 #[test]
1179 fn test_ui_action_set_state() {
1180 let action = UIAction::SetState {
1181 state: json!({ "selected": "item1" }),
1182 };
1183
1184 let json = serde_json::to_value(&action).unwrap();
1185 assert_eq!(json["type"], "setState");
1186 assert_eq!(json["state"]["selected"], "item1");
1187 }
1188
1189 #[test]
1190 fn test_extended_ui_mime_type() {
1191 assert_eq!(ExtendedUIMimeType::HtmlMcp.as_str(), "text/html+mcp");
1192 assert_eq!(
1193 ExtendedUIMimeType::HtmlSkybridge.as_str(),
1194 "text/html+skybridge"
1195 );
1196 assert_eq!(
1197 ExtendedUIMimeType::HtmlMcpApp.as_str(),
1198 "text/html;profile=mcp-app"
1199 );
1200 assert_eq!(ExtendedUIMimeType::HtmlPlain.as_str(), "text/html");
1201
1202 assert!(ExtendedUIMimeType::HtmlSkybridge.is_chatgpt());
1203 assert!(ExtendedUIMimeType::HtmlMcpApp.is_chatgpt());
1204 assert!(ExtendedUIMimeType::HtmlMcp.is_mcp_apps());
1205 assert!(ExtendedUIMimeType::HtmlMcpApp.is_mcp_apps());
1206 assert!(ExtendedUIMimeType::HtmlPlain.is_mcp_ui());
1207 }
1208
1209 #[test]
1210 fn test_extended_ui_mime_type_from_str() {
1211 assert_eq!(
1212 "text/html+mcp".parse::<ExtendedUIMimeType>().unwrap(),
1213 ExtendedUIMimeType::HtmlMcp
1214 );
1215 assert_eq!(
1216 "text/html+skybridge".parse::<ExtendedUIMimeType>().unwrap(),
1217 ExtendedUIMimeType::HtmlSkybridge
1218 );
1219 assert_eq!(
1220 "text/html;profile=mcp-app"
1221 .parse::<ExtendedUIMimeType>()
1222 .unwrap(),
1223 ExtendedUIMimeType::HtmlMcpApp
1224 );
1225 assert_eq!(
1226 "text/html".parse::<ExtendedUIMimeType>().unwrap(),
1227 ExtendedUIMimeType::HtmlPlain
1228 );
1229 assert!("invalid".parse::<ExtendedUIMimeType>().is_err());
1230 }
1231
1232 #[test]
1233 fn test_host_type_mime_type() {
1234 assert_eq!(
1235 HostType::ChatGpt.preferred_mime_type(),
1236 ExtendedUIMimeType::HtmlSkybridge
1237 );
1238 assert_eq!(
1239 HostType::Claude.preferred_mime_type(),
1240 ExtendedUIMimeType::HtmlMcpApp
1241 );
1242 assert_eq!(
1243 HostType::Nanobot.preferred_mime_type(),
1244 ExtendedUIMimeType::HtmlPlain
1245 );
1246 }
1247
1248 #[test]
1249 fn test_to_meta_map() {
1250 let widget_meta = WidgetMeta::new().prefers_border(true).description("Test");
1251
1252 let map = widget_meta.to_meta_map();
1253 assert_eq!(
1254 map.get("openai/widgetPrefersBorder"),
1255 Some(&serde_json::Value::Bool(true))
1256 );
1257
1258 let tool_meta = ChatGptToolMeta::new()
1259 .output_template("ui://test")
1260 .widget_accessible(true);
1261
1262 let map = tool_meta.to_meta_map();
1263 assert_eq!(
1264 map.get("openai/outputTemplate"),
1265 Some(&serde_json::Value::String("ui://test".to_string()))
1266 );
1267 }
1268
1269 #[test]
1270 fn test_widget_meta_dual_emit_prefers_border() {
1271 let meta = WidgetMeta::new().prefers_border(true);
1272 let map = meta.to_meta_map();
1273
1274 assert_eq!(
1276 map.get("openai/widgetPrefersBorder"),
1277 Some(&serde_json::Value::Bool(true))
1278 );
1279
1280 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1282 assert_eq!(ui_obj["prefersBorder"], true);
1283 }
1284
1285 #[test]
1286 fn test_widget_meta_dual_emit_with_domain() {
1287 let meta = WidgetMeta::new().prefers_border(true).domain("x.com");
1288 let map = meta.to_meta_map();
1289
1290 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1292 assert_eq!(ui_obj["prefersBorder"], true);
1293 assert_eq!(ui_obj["domain"], "x.com");
1295 assert_eq!(
1296 map.get("openai/widgetDomain"),
1297 Some(&serde_json::Value::String("x.com".to_string()))
1298 );
1299 }
1300
1301 #[test]
1302 fn test_widget_meta_empty_no_ui_key() {
1303 let meta = WidgetMeta::new();
1304 let map = meta.to_meta_map();
1305
1306 assert!(
1308 map.get("ui").is_none(),
1309 "empty WidgetMeta should not have 'ui' key"
1310 );
1311 assert!(map.is_empty(), "empty WidgetMeta should produce empty map");
1312 }
1313
1314 #[test]
1315 fn test_from_ui_mime_type() {
1316 use crate::types::ui::UIMimeType;
1317
1318 let html_mcp: ExtendedUIMimeType = UIMimeType::HtmlMcp.into();
1320 assert_eq!(html_mcp, ExtendedUIMimeType::HtmlMcp);
1321 assert_eq!(html_mcp.as_str(), UIMimeType::HtmlMcp.as_str());
1322
1323 let html_skybridge: ExtendedUIMimeType = UIMimeType::HtmlSkybridge.into();
1324 assert_eq!(html_skybridge, ExtendedUIMimeType::HtmlSkybridge);
1325 assert_eq!(html_skybridge.as_str(), UIMimeType::HtmlSkybridge.as_str());
1326
1327 let html_mcp_app: ExtendedUIMimeType = UIMimeType::HtmlMcpApp.into();
1328 assert_eq!(html_mcp_app, ExtendedUIMimeType::HtmlMcpApp);
1329 assert_eq!(html_mcp_app.as_str(), UIMimeType::HtmlMcpApp.as_str());
1330 }
1331
1332 #[test]
1333 fn test_try_from_extended_shared() {
1334 use crate::types::ui::UIMimeType;
1335 use std::convert::TryFrom;
1336
1337 let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcp);
1339 assert_eq!(result, Ok(UIMimeType::HtmlMcp));
1340 assert_eq!(
1341 result.unwrap().as_str(),
1342 ExtendedUIMimeType::HtmlMcp.as_str()
1343 );
1344
1345 let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlSkybridge);
1346 assert_eq!(result, Ok(UIMimeType::HtmlSkybridge));
1347 assert_eq!(
1348 result.unwrap().as_str(),
1349 ExtendedUIMimeType::HtmlSkybridge.as_str()
1350 );
1351
1352 let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcpApp);
1353 assert_eq!(result, Ok(UIMimeType::HtmlMcpApp));
1354 assert_eq!(
1355 result.unwrap().as_str(),
1356 ExtendedUIMimeType::HtmlMcpApp.as_str()
1357 );
1358 }
1359
1360 #[test]
1361 fn test_try_from_extended_fails() {
1362 use crate::types::ui::UIMimeType;
1363 use std::convert::TryFrom;
1364
1365 let extended_only = [
1367 ExtendedUIMimeType::HtmlPlain,
1368 ExtendedUIMimeType::UriList,
1369 ExtendedUIMimeType::RemoteDom,
1370 ExtendedUIMimeType::RemoteDomReact,
1371 ];
1372
1373 for ext in &extended_only {
1374 let result = UIMimeType::try_from(*ext);
1375 assert!(result.is_err(), "Expected Err for {:?}", ext);
1376 let err = result.unwrap_err();
1377 assert!(
1378 err.contains("extended-only variant"),
1379 "Error for {:?} should contain 'extended-only variant', got: {}",
1380 ext,
1381 err
1382 );
1383 }
1384 }
1385
1386 #[test]
1387 fn test_widget_meta_dual_emit_domain() {
1388 let meta = WidgetMeta::new().domain("x.com");
1389 let map = meta.to_meta_map();
1390
1391 assert_eq!(
1393 map.get("openai/widgetDomain"),
1394 Some(&serde_json::Value::String("x.com".to_string()))
1395 );
1396
1397 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1399 assert_eq!(ui_obj["domain"], "x.com");
1400 }
1401
1402 #[test]
1403 fn test_widget_meta_dual_emit_csp() {
1404 let csp = WidgetCSP::new()
1405 .connect("https://api.example.com")
1406 .resources("https://cdn.example.com")
1407 .frame("https://embed.example.com")
1408 .base_uri("https://base.example.com")
1409 .redirect("https://redirect.example.com");
1410
1411 let meta = WidgetMeta::new().csp(csp);
1412 let map = meta.to_meta_map();
1413
1414 assert!(map.get("openai/widgetCSP").is_some());
1416
1417 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1419 let nested_csp = ui_obj.get("csp").expect("must have nested csp");
1420 assert_eq!(nested_csp["connectDomains"][0], "https://api.example.com");
1421 assert_eq!(nested_csp["resourceDomains"][0], "https://cdn.example.com");
1422 assert_eq!(nested_csp["frameDomains"][0], "https://embed.example.com");
1423 assert_eq!(nested_csp["baseUriDomains"][0], "https://base.example.com");
1424
1425 assert!(
1427 nested_csp.get("redirect_domains").is_none()
1428 && nested_csp.get("redirectDomains").is_none(),
1429 "redirect_domains should not be in nested ui.csp (ChatGPT-specific)"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_widget_meta_dual_emit_all_fields() {
1435 let meta = WidgetMeta::new()
1436 .prefers_border(true)
1437 .domain("x.com")
1438 .csp(WidgetCSP::new().connect("https://api.example.com"));
1439
1440 let map = meta.to_meta_map();
1441 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1442
1443 assert_eq!(ui_obj["prefersBorder"], true);
1445 assert_eq!(ui_obj["domain"], "x.com");
1446 assert!(ui_obj.get("csp").is_some());
1447 }
1448
1449 #[test]
1450 fn test_chatgpt_tool_meta_dual_emit_visibility_public() {
1451 let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Public);
1452 let map = meta.to_meta_map();
1453
1454 assert_eq!(map.get("openai/visibility"), Some(&json!("public")));
1456
1457 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1459 assert_eq!(ui_obj["visibility"], json!(["model", "app"]));
1460 }
1461
1462 #[test]
1463 fn test_chatgpt_tool_meta_dual_emit_visibility_private() {
1464 let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Private);
1465 let map = meta.to_meta_map();
1466
1467 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1468 assert_eq!(ui_obj["visibility"], json!(["app"]));
1469 }
1470
1471 #[test]
1472 fn test_chatgpt_tool_meta_dual_emit_visibility_model_only() {
1473 let meta = ChatGptToolMeta::new().visibility(ToolVisibility::ModelOnly);
1474 let map = meta.to_meta_map();
1475
1476 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1477 assert_eq!(ui_obj["visibility"], json!(["model"]));
1478 }
1479
1480 #[test]
1481 fn test_widget_csp_base_uri_builder() {
1482 let csp = WidgetCSP::new().base_uri("https://example.com");
1483 assert_eq!(
1484 csp.base_uri_domains,
1485 Some(vec!["https://example.com".to_string()])
1486 );
1487 }
1488
1489 #[test]
1490 fn test_widget_csp_base_uri_serialization() {
1491 let csp = WidgetCSP::new()
1492 .connect("https://api.example.com")
1493 .base_uri("https://base.example.com");
1494
1495 let json = serde_json::to_value(&csp).unwrap();
1496 assert_eq!(json["base_uri_domains"][0], "https://base.example.com");
1497 }
1498
1499 #[test]
1500 fn test_widget_csp_is_empty_with_base_uri() {
1501 let csp = WidgetCSP::new().base_uri("https://example.com");
1502 assert!(
1503 !csp.is_empty(),
1504 "CSP with base_uri_domains should not be empty"
1505 );
1506 }
1507
1508 #[test]
1509 fn test_tool_visibility_model_only_serialization() {
1510 assert_eq!(
1511 serde_json::to_value(ToolVisibility::ModelOnly).unwrap(),
1512 "modelonly"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_tool_visibility_has_three_variants() {
1518 let variants = vec![
1520 (ToolVisibility::Public, "public"),
1521 (ToolVisibility::Private, "private"),
1522 (ToolVisibility::ModelOnly, "modelonly"),
1523 ];
1524 for (variant, expected) in variants {
1525 assert_eq!(
1526 serde_json::to_value(variant).unwrap(),
1527 expected,
1528 "ToolVisibility::{:?} should serialize to {:?}",
1529 variant,
1530 expected
1531 );
1532 }
1533 }
1534
1535 #[test]
1536 fn test_tool_visibility_to_visibility_array() {
1537 assert_eq!(
1538 ToolVisibility::Public.to_visibility_array(),
1539 &["model", "app"]
1540 );
1541 assert_eq!(ToolVisibility::Private.to_visibility_array(), &["app"]);
1542 assert_eq!(ToolVisibility::ModelOnly.to_visibility_array(), &["model"]);
1543 }
1544
1545 #[test]
1546 fn test_widget_meta_resource_uri_emits_standard_key_only() {
1547 let meta = WidgetMeta::new().resource_uri("ui://chess/board.html");
1548 let map = meta.to_meta_map();
1549
1550 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1552 assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
1553
1554 assert_eq!(
1556 map.get("ui/resourceUri"),
1557 Some(&serde_json::Value::String(
1558 "ui://chess/board.html".to_string()
1559 )),
1560 "must emit legacy flat ui/resourceUri key for Claude Desktop/ChatGPT"
1561 );
1562
1563 assert!(
1565 map.get("openai/outputTemplate").is_none(),
1566 "must NOT emit openai/outputTemplate in standard-only mode"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_widget_meta_resource_uri_with_widget_fields() {
1572 let meta = WidgetMeta::new()
1573 .resource_uri("ui://chess/board.html")
1574 .prefers_border(true)
1575 .domain("chess.com");
1576 let map = meta.to_meta_map();
1577
1578 let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1579 assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
1581 assert_eq!(ui_obj["prefersBorder"], true);
1582 assert_eq!(ui_obj["domain"], "chess.com");
1583
1584 assert_eq!(
1586 map.get("ui/resourceUri").and_then(|v| v.as_str()),
1587 Some("ui://chess/board.html"),
1588 "must emit legacy flat key for Claude Desktop"
1589 );
1590 assert!(
1591 map.get("openai/outputTemplate").is_none(),
1592 "must NOT emit openai/outputTemplate"
1593 );
1594 assert!(map.get("openai/widgetPrefersBorder").is_some());
1596 assert!(map.get("openai/widgetDomain").is_some());
1597 }
1598
1599 #[test]
1600 fn test_widget_meta_resource_uri_not_in_serde_output() {
1601 let meta = WidgetMeta::new().resource_uri("ui://x");
1603 let map = meta.to_meta_map();
1604 assert!(
1605 map.get("resource_uri").is_none(),
1606 "resource_uri should be removed from serde output"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_widget_meta_is_empty_with_resource_uri() {
1612 let meta = WidgetMeta::new().resource_uri("ui://x");
1613 assert!(!meta.is_empty());
1614 }
1615
1616 #[test]
1617 fn test_mime_type_round_trip() {
1618 use crate::types::ui::UIMimeType;
1619 use std::convert::TryFrom;
1620
1621 let variants = [
1623 UIMimeType::HtmlMcp,
1624 UIMimeType::HtmlSkybridge,
1625 UIMimeType::HtmlMcpApp,
1626 ];
1627
1628 for original in &variants {
1629 let extended: ExtendedUIMimeType = (*original).into();
1630 let round_tripped = UIMimeType::try_from(extended)
1631 .unwrap_or_else(|e| panic!("Round-trip failed for {:?}: {}", original, e));
1632 assert_eq!(
1633 *original, round_tripped,
1634 "Round-trip failed: {:?} != {:?}",
1635 original, round_tripped
1636 );
1637 }
1638 }
1639}