#![allow(clippy::doc_markdown)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "schema-generation")]
use schemars::JsonSchema;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct WidgetCSP {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connect_domains: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resource_domains: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect_domains: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_domains: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_uri_domains: Option<Vec<String>>,
}
impl WidgetCSP {
pub fn new() -> Self {
Self::default()
}
pub fn connect(mut self, domain: impl Into<String>) -> Self {
self.connect_domains.push(domain.into());
self
}
pub fn resources(mut self, domain: impl Into<String>) -> Self {
self.resource_domains.push(domain.into());
self
}
pub fn redirect(mut self, domain: impl Into<String>) -> Self {
self.redirect_domains
.get_or_insert_with(Vec::new)
.push(domain.into());
self
}
pub fn frame(mut self, domain: impl Into<String>) -> Self {
self.frame_domains
.get_or_insert_with(Vec::new)
.push(domain.into());
self
}
pub fn base_uri(mut self, domain: impl Into<String>) -> Self {
self.base_uri_domains
.get_or_insert_with(Vec::new)
.push(domain.into());
self
}
pub fn to_spec_map(&self) -> serde_json::Map<String, serde_json::Value> {
let mut csp_obj = serde_json::Map::with_capacity(4);
if !self.connect_domains.is_empty() {
csp_obj.insert(
"connectDomains".into(),
serde_json::json!(self.connect_domains),
);
}
if !self.resource_domains.is_empty() {
csp_obj.insert(
"resourceDomains".into(),
serde_json::json!(self.resource_domains),
);
}
if let Some(frames) = &self.frame_domains {
if !frames.is_empty() {
csp_obj.insert("frameDomains".into(), serde_json::json!(frames));
}
}
if let Some(base_uris) = &self.base_uri_domains {
if !base_uris.is_empty() {
csp_obj.insert("baseUriDomains".into(), serde_json::json!(base_uris));
}
}
csp_obj
}
pub fn is_empty(&self) -> bool {
self.connect_domains.is_empty()
&& self.resource_domains.is_empty()
&& self.redirect_domains.as_ref().is_none_or(Vec::is_empty)
&& self.frame_domains.as_ref().is_none_or(Vec::is_empty)
&& self.base_uri_domains.as_ref().is_none_or(Vec::is_empty)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct WidgetMeta {
#[serde(skip)]
pub resource_uri: Option<String>,
#[serde(
rename = "openai/widgetPrefersBorder",
skip_serializing_if = "Option::is_none"
)]
pub prefers_border: Option<bool>,
#[serde(
rename = "openai/widgetDomain",
skip_serializing_if = "Option::is_none"
)]
pub domain: Option<String>,
#[serde(rename = "openai/widgetCSP", skip_serializing_if = "Option::is_none")]
pub csp: Option<WidgetCSP>,
#[serde(
rename = "openai/widgetDescription",
skip_serializing_if = "Option::is_none"
)]
pub description: Option<String>,
}
impl WidgetMeta {
pub fn new() -> Self {
Self::default()
}
pub fn resource_uri(mut self, uri: impl Into<String>) -> Self {
self.resource_uri = Some(uri.into());
self
}
pub fn prefers_border(mut self, prefers: bool) -> Self {
self.prefers_border = Some(prefers);
self
}
pub fn domain(mut self, domain: impl Into<String>) -> Self {
self.domain = Some(domain.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn csp(mut self, csp: WidgetCSP) -> Self {
self.csp = Some(csp);
self
}
pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
let mut map = match serde_json::to_value(self).ok() {
Some(serde_json::Value::Object(m)) => m,
_ => serde_json::Map::new(),
};
let mut ui_obj = serde_json::Map::new();
if let Some(uri) = &self.resource_uri {
crate::types::ui::emit_resource_uri_keys(&mut ui_obj, uri);
}
if let Some(prefers) = self.prefers_border {
ui_obj.insert(
"prefersBorder".to_string(),
serde_json::Value::Bool(prefers),
);
}
if let Some(domain) = &self.domain {
ui_obj.insert(
"domain".to_string(),
serde_json::Value::String(domain.clone()),
);
}
if let Some(csp) = &self.csp {
let csp_obj = csp.to_spec_map();
if !csp_obj.is_empty() {
ui_obj.insert("csp".to_string(), serde_json::Value::Object(csp_obj));
}
}
if !ui_obj.is_empty() {
map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
}
if let Some(uri) = &self.resource_uri {
crate::types::ui::insert_legacy_resource_uri_key(&mut map, uri);
}
map
}
pub fn is_empty(&self) -> bool {
self.resource_uri.is_none()
&& self.prefers_border.is_none()
&& self.domain.is_none()
&& self.csp.is_none()
&& self.description.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum ToolVisibility {
#[default]
Public,
Private,
ModelOnly,
}
impl ToolVisibility {
pub fn to_visibility_array(&self) -> &'static [&'static str] {
match self {
Self::Public => &["model", "app"],
Self::Private => &["app"],
Self::ModelOnly => &["model"],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct ChatGptToolMeta {
#[serde(
rename = "openai/outputTemplate",
skip_serializing_if = "Option::is_none"
)]
pub output_template: Option<String>,
#[serde(
rename = "openai/toolInvocation/invoking",
skip_serializing_if = "Option::is_none"
)]
pub invoking: Option<String>,
#[serde(
rename = "openai/toolInvocation/invoked",
skip_serializing_if = "Option::is_none"
)]
pub invoked: Option<String>,
#[serde(
rename = "openai/widgetAccessible",
skip_serializing_if = "Option::is_none"
)]
pub widget_accessible: Option<bool>,
#[serde(rename = "openai/visibility", skip_serializing_if = "Option::is_none")]
pub visibility: Option<ToolVisibility>,
#[serde(rename = "openai/fileParams", skip_serializing_if = "Option::is_none")]
pub file_params: Option<Vec<String>>,
}
impl ChatGptToolMeta {
pub fn new() -> Self {
Self::default()
}
pub fn output_template(mut self, uri: impl Into<String>) -> Self {
self.output_template = Some(uri.into());
self
}
pub fn invoking(mut self, msg: impl Into<String>) -> Self {
self.invoking = Some(msg.into());
self
}
pub fn invoked(mut self, msg: impl Into<String>) -> Self {
self.invoked = Some(msg.into());
self
}
pub fn widget_accessible(mut self, accessible: bool) -> Self {
self.widget_accessible = Some(accessible);
self
}
pub fn visibility(mut self, visibility: ToolVisibility) -> Self {
self.visibility = Some(visibility);
self
}
pub fn file_params(mut self, params: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.file_params = Some(params.into_iter().map(Into::into).collect());
self
}
pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::to_value(self)
.ok()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
if let Some(vis) = &self.visibility {
let mut ui_obj = serde_json::Map::with_capacity(1);
ui_obj.insert(
"visibility".to_string(),
serde_json::json!(vis.to_visibility_array()),
);
map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
}
map
}
pub fn is_empty(&self) -> bool {
self.output_template.is_none()
&& self.invoking.is_none()
&& self.invoked.is_none()
&& self.widget_accessible.is_none()
&& self.visibility.is_none()
&& self.file_params.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct WidgetResponseMeta {
#[serde(rename = "openai/closeWidget", skip_serializing_if = "Option::is_none")]
pub close_widget: Option<bool>,
#[serde(
rename = "openai/widgetSessionId",
skip_serializing_if = "Option::is_none"
)]
pub widget_session_id: Option<String>,
}
impl WidgetResponseMeta {
pub fn new() -> Self {
Self::default()
}
pub fn close_widget(mut self, close: bool) -> Self {
self.close_widget = Some(close);
self
}
pub fn widget_session_id(mut self, id: impl Into<String>) -> Self {
self.widget_session_id = Some(id.into());
self
}
pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
serde_json::to_value(self)
.ok()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.close_widget.is_none() && self.widget_session_id.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum NotifyLevel {
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UIAction {
#[serde(rename_all = "camelCase")]
Tool {
name: String,
arguments: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
message_id: Option<String>,
},
Prompt {
text: String,
},
Intent {
action: String,
data: serde_json::Value,
},
Notify {
level: NotifyLevel,
message: String,
},
Link {
url: String,
},
SetState {
state: serde_json::Value,
},
SendMessage {
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtendedUIMimeType {
HtmlMcp,
HtmlSkybridge,
HtmlMcpApp,
HtmlPlain,
UriList,
RemoteDom,
RemoteDomReact,
}
impl ExtendedUIMimeType {
pub fn as_str(&self) -> &'static str {
match self {
Self::HtmlMcp => "text/html+mcp",
Self::HtmlSkybridge => "text/html+skybridge",
Self::HtmlMcpApp => "text/html;profile=mcp-app",
Self::HtmlPlain => "text/html",
Self::UriList => "text/uri-list",
Self::RemoteDom => "application/vnd.mcp-ui.remote-dom+javascript",
Self::RemoteDomReact => "application/vnd.mcp-ui.remote-dom+javascript; framework=react",
}
}
pub fn is_chatgpt(&self) -> bool {
matches!(self, Self::HtmlSkybridge | Self::HtmlMcpApp)
}
pub fn is_mcp_apps(&self) -> bool {
matches!(self, Self::HtmlMcp | Self::HtmlMcpApp)
}
pub fn is_mcp_ui(&self) -> bool {
matches!(
self,
Self::HtmlPlain | Self::UriList | Self::RemoteDom | Self::RemoteDomReact
)
}
}
impl std::fmt::Display for ExtendedUIMimeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ExtendedUIMimeType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"text/html+mcp" => Ok(Self::HtmlMcp),
"text/html+skybridge" => Ok(Self::HtmlSkybridge),
"text/html;profile=mcp-app" => Ok(Self::HtmlMcpApp),
"text/html" => Ok(Self::HtmlPlain),
"text/uri-list" => Ok(Self::UriList),
"application/vnd.mcp-ui.remote-dom+javascript" => Ok(Self::RemoteDom),
s if s.starts_with("application/vnd.mcp-ui.remote-dom+javascript") => {
if s.contains("framework=react") {
Ok(Self::RemoteDomReact)
} else {
Ok(Self::RemoteDom)
}
},
_ => Err(format!("Unknown UI MIME type: {}", s)),
}
}
}
impl From<crate::types::ui::UIMimeType> for ExtendedUIMimeType {
fn from(value: crate::types::ui::UIMimeType) -> Self {
match value {
crate::types::ui::UIMimeType::HtmlMcp => Self::HtmlMcp,
crate::types::ui::UIMimeType::HtmlSkybridge => Self::HtmlSkybridge,
crate::types::ui::UIMimeType::HtmlMcpApp => Self::HtmlMcpApp,
}
}
}
impl TryFrom<ExtendedUIMimeType> for crate::types::ui::UIMimeType {
type Error = String;
fn try_from(value: ExtendedUIMimeType) -> Result<Self, Self::Error> {
match value {
ExtendedUIMimeType::HtmlMcp => Ok(Self::HtmlMcp),
ExtendedUIMimeType::HtmlSkybridge => Ok(Self::HtmlSkybridge),
ExtendedUIMimeType::HtmlMcpApp => Ok(Self::HtmlMcpApp),
other => Err(format!(
"Cannot convert {} to UIMimeType (extended-only variant)",
other
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UIContent {
Html {
html: String,
},
Url {
url: String,
},
#[cfg(feature = "mcp-apps")]
RemoteDom {
script: String,
framework: RemoteDomFramework,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum RemoteDomFramework {
#[default]
WebComponents,
React,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct UIDimensions {
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_height: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
pub struct UIMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dimensions: Option<UIDimensions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<WidgetCSP>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HostType {
ChatGpt,
Claude,
Nanobot,
McpJam,
Generic,
}
impl HostType {
pub fn preferred_mime_type(&self) -> ExtendedUIMimeType {
match self {
Self::ChatGpt => ExtendedUIMimeType::HtmlSkybridge,
Self::Claude | Self::Generic => ExtendedUIMimeType::HtmlMcpApp,
Self::Nanobot | Self::McpJam => ExtendedUIMimeType::HtmlPlain,
}
}
pub fn supports_mime_type(&self, mime_type: ExtendedUIMimeType) -> bool {
match self {
Self::ChatGpt => matches!(
mime_type,
ExtendedUIMimeType::HtmlSkybridge | ExtendedUIMimeType::HtmlMcpApp
),
Self::Claude | Self::Generic => matches!(
mime_type,
ExtendedUIMimeType::HtmlMcp | ExtendedUIMimeType::HtmlMcpApp
),
Self::Nanobot | Self::McpJam => mime_type.is_mcp_ui(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_widget_csp_builder() {
let csp = WidgetCSP::new()
.connect("https://api.example.com")
.connect("https://api2.example.com")
.resources("https://cdn.example.com")
.redirect("https://checkout.example.com")
.frame("https://embed.example.com");
assert_eq!(csp.connect_domains.len(), 2);
assert_eq!(csp.resource_domains.len(), 1);
assert_eq!(csp.redirect_domains.as_ref().unwrap().len(), 1);
assert_eq!(csp.frame_domains.as_ref().unwrap().len(), 1);
}
#[test]
fn test_widget_csp_serialization() {
let csp = WidgetCSP::new()
.connect("https://api.example.com")
.resources("https://cdn.example.com");
let json = serde_json::to_value(&csp).unwrap();
assert_eq!(json["connect_domains"][0], "https://api.example.com");
assert_eq!(json["resource_domains"][0], "https://cdn.example.com");
assert!(json.get("redirect_domains").is_none());
assert!(json.get("frame_domains").is_none());
}
#[test]
fn test_widget_meta_builder() {
let meta = WidgetMeta::new()
.prefers_border(true)
.domain("https://chatgpt.com")
.description("Test widget")
.csp(WidgetCSP::new().connect("https://api.example.com"));
assert_eq!(meta.prefers_border, Some(true));
assert_eq!(meta.domain, Some("https://chatgpt.com".to_string()));
assert_eq!(meta.description, Some("Test widget".to_string()));
assert!(meta.csp.is_some());
}
#[test]
fn test_widget_meta_serialization() {
let meta = WidgetMeta::new().prefers_border(true).description("Test");
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["openai/widgetPrefersBorder"], true);
assert_eq!(json["openai/widgetDescription"], "Test");
}
#[test]
fn test_chatgpt_tool_meta_builder() {
let meta = ChatGptToolMeta::new()
.output_template("ui://widget/test.html")
.invoking("Loading...")
.invoked("Done!")
.widget_accessible(true)
.visibility(ToolVisibility::Public)
.file_params(vec!["imageFile", "documentFile"]);
assert_eq!(
meta.output_template,
Some("ui://widget/test.html".to_string())
);
assert_eq!(meta.invoking, Some("Loading...".to_string()));
assert_eq!(meta.invoked, Some("Done!".to_string()));
assert_eq!(meta.widget_accessible, Some(true));
assert_eq!(meta.visibility, Some(ToolVisibility::Public));
assert_eq!(meta.file_params.as_ref().unwrap().len(), 2);
}
#[test]
fn test_chatgpt_tool_meta_serialization() {
let meta = ChatGptToolMeta::new()
.output_template("ui://widget/test.html")
.invoking("Loading...")
.widget_accessible(true)
.visibility(ToolVisibility::Private);
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["openai/outputTemplate"], "ui://widget/test.html");
assert_eq!(json["openai/toolInvocation/invoking"], "Loading...");
assert_eq!(json["openai/widgetAccessible"], true);
assert_eq!(json["openai/visibility"], "private");
}
#[test]
fn test_tool_visibility_serialization() {
assert_eq!(
serde_json::to_value(ToolVisibility::Public).unwrap(),
"public"
);
assert_eq!(
serde_json::to_value(ToolVisibility::Private).unwrap(),
"private"
);
}
#[test]
fn test_ui_action_tool() {
let action = UIAction::Tool {
name: "test_tool".to_string(),
arguments: json!({ "param": "value" }),
message_id: Some("123".to_string()),
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["type"], "tool");
assert_eq!(json["name"], "test_tool");
assert_eq!(json["arguments"]["param"], "value");
assert_eq!(json["messageId"], "123");
}
#[test]
fn test_ui_action_set_state() {
let action = UIAction::SetState {
state: json!({ "selected": "item1" }),
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["type"], "setState");
assert_eq!(json["state"]["selected"], "item1");
}
#[test]
fn test_extended_ui_mime_type() {
assert_eq!(ExtendedUIMimeType::HtmlMcp.as_str(), "text/html+mcp");
assert_eq!(
ExtendedUIMimeType::HtmlSkybridge.as_str(),
"text/html+skybridge"
);
assert_eq!(
ExtendedUIMimeType::HtmlMcpApp.as_str(),
"text/html;profile=mcp-app"
);
assert_eq!(ExtendedUIMimeType::HtmlPlain.as_str(), "text/html");
assert!(ExtendedUIMimeType::HtmlSkybridge.is_chatgpt());
assert!(ExtendedUIMimeType::HtmlMcpApp.is_chatgpt());
assert!(ExtendedUIMimeType::HtmlMcp.is_mcp_apps());
assert!(ExtendedUIMimeType::HtmlMcpApp.is_mcp_apps());
assert!(ExtendedUIMimeType::HtmlPlain.is_mcp_ui());
}
#[test]
fn test_extended_ui_mime_type_from_str() {
assert_eq!(
"text/html+mcp".parse::<ExtendedUIMimeType>().unwrap(),
ExtendedUIMimeType::HtmlMcp
);
assert_eq!(
"text/html+skybridge".parse::<ExtendedUIMimeType>().unwrap(),
ExtendedUIMimeType::HtmlSkybridge
);
assert_eq!(
"text/html;profile=mcp-app"
.parse::<ExtendedUIMimeType>()
.unwrap(),
ExtendedUIMimeType::HtmlMcpApp
);
assert_eq!(
"text/html".parse::<ExtendedUIMimeType>().unwrap(),
ExtendedUIMimeType::HtmlPlain
);
assert!("invalid".parse::<ExtendedUIMimeType>().is_err());
}
#[test]
fn test_host_type_mime_type() {
assert_eq!(
HostType::ChatGpt.preferred_mime_type(),
ExtendedUIMimeType::HtmlSkybridge
);
assert_eq!(
HostType::Claude.preferred_mime_type(),
ExtendedUIMimeType::HtmlMcpApp
);
assert_eq!(
HostType::Nanobot.preferred_mime_type(),
ExtendedUIMimeType::HtmlPlain
);
}
#[test]
fn test_to_meta_map() {
let widget_meta = WidgetMeta::new().prefers_border(true).description("Test");
let map = widget_meta.to_meta_map();
assert_eq!(
map.get("openai/widgetPrefersBorder"),
Some(&serde_json::Value::Bool(true))
);
let tool_meta = ChatGptToolMeta::new()
.output_template("ui://test")
.widget_accessible(true);
let map = tool_meta.to_meta_map();
assert_eq!(
map.get("openai/outputTemplate"),
Some(&serde_json::Value::String("ui://test".to_string()))
);
}
#[test]
fn test_widget_meta_dual_emit_prefers_border() {
let meta = WidgetMeta::new().prefers_border(true);
let map = meta.to_meta_map();
assert_eq!(
map.get("openai/widgetPrefersBorder"),
Some(&serde_json::Value::Bool(true))
);
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["prefersBorder"], true);
}
#[test]
fn test_widget_meta_dual_emit_with_domain() {
let meta = WidgetMeta::new().prefers_border(true).domain("x.com");
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["prefersBorder"], true);
assert_eq!(ui_obj["domain"], "x.com");
assert_eq!(
map.get("openai/widgetDomain"),
Some(&serde_json::Value::String("x.com".to_string()))
);
}
#[test]
fn test_widget_meta_empty_no_ui_key() {
let meta = WidgetMeta::new();
let map = meta.to_meta_map();
assert!(
map.get("ui").is_none(),
"empty WidgetMeta should not have 'ui' key"
);
assert!(map.is_empty(), "empty WidgetMeta should produce empty map");
}
#[test]
fn test_from_ui_mime_type() {
use crate::types::ui::UIMimeType;
let html_mcp: ExtendedUIMimeType = UIMimeType::HtmlMcp.into();
assert_eq!(html_mcp, ExtendedUIMimeType::HtmlMcp);
assert_eq!(html_mcp.as_str(), UIMimeType::HtmlMcp.as_str());
let html_skybridge: ExtendedUIMimeType = UIMimeType::HtmlSkybridge.into();
assert_eq!(html_skybridge, ExtendedUIMimeType::HtmlSkybridge);
assert_eq!(html_skybridge.as_str(), UIMimeType::HtmlSkybridge.as_str());
let html_mcp_app: ExtendedUIMimeType = UIMimeType::HtmlMcpApp.into();
assert_eq!(html_mcp_app, ExtendedUIMimeType::HtmlMcpApp);
assert_eq!(html_mcp_app.as_str(), UIMimeType::HtmlMcpApp.as_str());
}
#[test]
fn test_try_from_extended_shared() {
use crate::types::ui::UIMimeType;
use std::convert::TryFrom;
let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcp);
assert_eq!(result, Ok(UIMimeType::HtmlMcp));
assert_eq!(
result.unwrap().as_str(),
ExtendedUIMimeType::HtmlMcp.as_str()
);
let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlSkybridge);
assert_eq!(result, Ok(UIMimeType::HtmlSkybridge));
assert_eq!(
result.unwrap().as_str(),
ExtendedUIMimeType::HtmlSkybridge.as_str()
);
let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcpApp);
assert_eq!(result, Ok(UIMimeType::HtmlMcpApp));
assert_eq!(
result.unwrap().as_str(),
ExtendedUIMimeType::HtmlMcpApp.as_str()
);
}
#[test]
fn test_try_from_extended_fails() {
use crate::types::ui::UIMimeType;
use std::convert::TryFrom;
let extended_only = [
ExtendedUIMimeType::HtmlPlain,
ExtendedUIMimeType::UriList,
ExtendedUIMimeType::RemoteDom,
ExtendedUIMimeType::RemoteDomReact,
];
for ext in &extended_only {
let result = UIMimeType::try_from(*ext);
assert!(result.is_err(), "Expected Err for {:?}", ext);
let err = result.unwrap_err();
assert!(
err.contains("extended-only variant"),
"Error for {:?} should contain 'extended-only variant', got: {}",
ext,
err
);
}
}
#[test]
fn test_widget_meta_dual_emit_domain() {
let meta = WidgetMeta::new().domain("x.com");
let map = meta.to_meta_map();
assert_eq!(
map.get("openai/widgetDomain"),
Some(&serde_json::Value::String("x.com".to_string()))
);
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["domain"], "x.com");
}
#[test]
fn test_widget_meta_dual_emit_csp() {
let csp = WidgetCSP::new()
.connect("https://api.example.com")
.resources("https://cdn.example.com")
.frame("https://embed.example.com")
.base_uri("https://base.example.com")
.redirect("https://redirect.example.com");
let meta = WidgetMeta::new().csp(csp);
let map = meta.to_meta_map();
assert!(map.get("openai/widgetCSP").is_some());
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
let nested_csp = ui_obj.get("csp").expect("must have nested csp");
assert_eq!(nested_csp["connectDomains"][0], "https://api.example.com");
assert_eq!(nested_csp["resourceDomains"][0], "https://cdn.example.com");
assert_eq!(nested_csp["frameDomains"][0], "https://embed.example.com");
assert_eq!(nested_csp["baseUriDomains"][0], "https://base.example.com");
assert!(
nested_csp.get("redirect_domains").is_none()
&& nested_csp.get("redirectDomains").is_none(),
"redirect_domains should not be in nested ui.csp (ChatGPT-specific)"
);
}
#[test]
fn test_widget_meta_dual_emit_all_fields() {
let meta = WidgetMeta::new()
.prefers_border(true)
.domain("x.com")
.csp(WidgetCSP::new().connect("https://api.example.com"));
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["prefersBorder"], true);
assert_eq!(ui_obj["domain"], "x.com");
assert!(ui_obj.get("csp").is_some());
}
#[test]
fn test_chatgpt_tool_meta_dual_emit_visibility_public() {
let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Public);
let map = meta.to_meta_map();
assert_eq!(map.get("openai/visibility"), Some(&json!("public")));
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["visibility"], json!(["model", "app"]));
}
#[test]
fn test_chatgpt_tool_meta_dual_emit_visibility_private() {
let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Private);
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["visibility"], json!(["app"]));
}
#[test]
fn test_chatgpt_tool_meta_dual_emit_visibility_model_only() {
let meta = ChatGptToolMeta::new().visibility(ToolVisibility::ModelOnly);
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["visibility"], json!(["model"]));
}
#[test]
fn test_widget_csp_base_uri_builder() {
let csp = WidgetCSP::new().base_uri("https://example.com");
assert_eq!(
csp.base_uri_domains,
Some(vec!["https://example.com".to_string()])
);
}
#[test]
fn test_widget_csp_base_uri_serialization() {
let csp = WidgetCSP::new()
.connect("https://api.example.com")
.base_uri("https://base.example.com");
let json = serde_json::to_value(&csp).unwrap();
assert_eq!(json["base_uri_domains"][0], "https://base.example.com");
}
#[test]
fn test_widget_csp_is_empty_with_base_uri() {
let csp = WidgetCSP::new().base_uri("https://example.com");
assert!(
!csp.is_empty(),
"CSP with base_uri_domains should not be empty"
);
}
#[test]
fn test_tool_visibility_model_only_serialization() {
assert_eq!(
serde_json::to_value(ToolVisibility::ModelOnly).unwrap(),
"modelonly"
);
}
#[test]
fn test_tool_visibility_has_three_variants() {
let variants = vec![
(ToolVisibility::Public, "public"),
(ToolVisibility::Private, "private"),
(ToolVisibility::ModelOnly, "modelonly"),
];
for (variant, expected) in variants {
assert_eq!(
serde_json::to_value(variant).unwrap(),
expected,
"ToolVisibility::{:?} should serialize to {:?}",
variant,
expected
);
}
}
#[test]
fn test_tool_visibility_to_visibility_array() {
assert_eq!(
ToolVisibility::Public.to_visibility_array(),
&["model", "app"]
);
assert_eq!(ToolVisibility::Private.to_visibility_array(), &["app"]);
assert_eq!(ToolVisibility::ModelOnly.to_visibility_array(), &["model"]);
}
#[test]
fn test_widget_meta_resource_uri_emits_standard_key_only() {
let meta = WidgetMeta::new().resource_uri("ui://chess/board.html");
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
assert_eq!(
map.get("ui/resourceUri"),
Some(&serde_json::Value::String(
"ui://chess/board.html".to_string()
)),
"must emit legacy flat ui/resourceUri key for Claude Desktop/ChatGPT"
);
assert!(
map.get("openai/outputTemplate").is_none(),
"must NOT emit openai/outputTemplate in standard-only mode"
);
}
#[test]
fn test_widget_meta_resource_uri_with_widget_fields() {
let meta = WidgetMeta::new()
.resource_uri("ui://chess/board.html")
.prefers_border(true)
.domain("chess.com");
let map = meta.to_meta_map();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
assert_eq!(ui_obj["prefersBorder"], true);
assert_eq!(ui_obj["domain"], "chess.com");
assert_eq!(
map.get("ui/resourceUri").and_then(|v| v.as_str()),
Some("ui://chess/board.html"),
"must emit legacy flat key for Claude Desktop"
);
assert!(
map.get("openai/outputTemplate").is_none(),
"must NOT emit openai/outputTemplate"
);
assert!(map.get("openai/widgetPrefersBorder").is_some());
assert!(map.get("openai/widgetDomain").is_some());
}
#[test]
fn test_widget_meta_resource_uri_not_in_serde_output() {
let meta = WidgetMeta::new().resource_uri("ui://x");
let map = meta.to_meta_map();
assert!(
map.get("resource_uri").is_none(),
"resource_uri should be removed from serde output"
);
}
#[test]
fn test_widget_meta_is_empty_with_resource_uri() {
let meta = WidgetMeta::new().resource_uri("ui://x");
assert!(!meta.is_empty());
}
#[test]
fn test_mime_type_round_trip() {
use crate::types::ui::UIMimeType;
use std::convert::TryFrom;
let variants = [
UIMimeType::HtmlMcp,
UIMimeType::HtmlSkybridge,
UIMimeType::HtmlMcpApp,
];
for original in &variants {
let extended: ExtendedUIMimeType = (*original).into();
let round_tripped = UIMimeType::try_from(extended)
.unwrap_or_else(|e| panic!("Round-trip failed for {:?}: {}", original, e));
assert_eq!(
*original, round_tripped,
"Round-trip failed: {:?} != {:?}",
original, round_tripped
);
}
}
}