use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub mod utils;
pub mod skill_name;
pub use skill_name::{
is_valid_skill_name, normalize_mcp_server_segment, parse_skill_name,
synthesize_marketplace_name, synthesize_mcp_name, synthesize_user_name, ParsedSkillName,
SkillNameError, SkillNameKind,
};
pub mod version;
pub use version::{
is_compatible, ProtocolVersion, ProtocolVersionError, ProtocolVersionParseError,
};
pub const SMCP_NAMESPACE: &str = "/smcp";
pub const PROTOCOL_VERSION: &str = "0.2.0";
pub mod error_codes {
pub const BAD_REQUEST: i32 = 400;
pub const UNAUTHORIZED: i32 = 401;
pub const FORBIDDEN: i32 = 403;
pub const NOT_FOUND: i32 = 404;
pub const TIMEOUT: i32 = 408;
pub const INTERNAL_ERROR: i32 = 500;
pub const TOOL_NOT_FOUND: i32 = 4001;
pub const TOOL_DISABLED: i32 = 4002;
pub const TOOL_EXECUTION_FAILED: i32 = 4003;
pub const TOOL_TIMEOUT: i32 = 4004;
pub const TOOL_REQUIRES_CONFIRMATION: i32 = 4005;
pub const ROOM_FULL: i32 = 4101;
pub const ROOM_NOT_FOUND: i32 = 4102;
pub const NOT_IN_ROOM: i32 = 4103;
pub const CROSS_ROOM_ACCESS: i32 = 4104;
}
pub const WS_VERSION_HANDSHAKE_REJECTED_CLOSE_CODE: i32 = 4900;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum ErrorCode {
NotFound = 404,
ToolAuthorizationRequired = 4006,
ToolAuthorizationFailed = 4007,
ProtocolVersionMismatch = 4008,
McpServerNotFound = 4014,
McpCapabilityNotSupported = 4015,
SkillNameInvalid = 4016,
SkillResourceNotAccessible = 4017,
BlobNotAccessible = 4018,
}
impl ErrorCode {
pub const fn code(self) -> i32 {
self as i32
}
pub fn from_code(code: i32) -> Option<Self> {
match code {
404 => Some(Self::NotFound),
4006 => Some(Self::ToolAuthorizationRequired),
4007 => Some(Self::ToolAuthorizationFailed),
4008 => Some(Self::ProtocolVersionMismatch),
4014 => Some(Self::McpServerNotFound),
4015 => Some(Self::McpCapabilityNotSupported),
4016 => Some(Self::SkillNameInvalid),
4017 => Some(Self::SkillResourceNotAccessible),
4018 => Some(Self::BlobNotAccessible),
_ => None,
}
}
}
impl Serialize for ErrorCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_i32(self.code())
}
}
impl<'de> Deserialize<'de> for ErrorCode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let code = i32::deserialize(deserializer)?;
Self::from_code(code)
.ok_or_else(|| serde::de::Error::custom(format!("unknown A2C-SMCP error code: {code}")))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorPayload {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_supported: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_supported: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_server_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl ErrorPayload {
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
details: None,
server_version: None,
client_version: None,
min_supported: None,
max_supported: None,
mcp_server_name: None,
capability: None,
extra: serde_json::Map::new(),
}
}
pub fn from_error_code(code: ErrorCode, message: impl Into<String>) -> Self {
Self::new(i64::from(code.code()), message)
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn with_detail(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
let mut map = match self.details {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
map.insert(key.into(), value.into());
self.details = Some(serde_json::Value::Object(map));
self
}
pub fn with_mcp_server_name(mut self, name: impl Into<String>) -> Self {
self.mcp_server_name = Some(name.into());
self
}
pub fn with_capability(mut self, capability: impl Into<String>) -> Self {
self.capability = Some(capability.into());
self
}
pub fn version_mismatch(client: &ProtocolVersion, server: &ProtocolVersion) -> Self {
Self {
code: i64::from(ErrorCode::ProtocolVersionMismatch.code()),
message: "Protocol version mismatch".to_string(),
details: None,
server_version: Some(server.to_string()),
client_version: Some(client.to_string()),
min_supported: Some(format!("{}.{}.0", server.major, server.minor)),
max_supported: Some(format!("{}.{}.999", server.major, server.minor)),
mcp_server_name: None,
capability: None,
extra: serde_json::Map::new(),
}
}
}
pub fn is_protocol_error_payload(value: &serde_json::Value) -> bool {
value
.as_object()
.and_then(|obj| obj.get("code"))
.and_then(serde_json::Value::as_i64)
.and_then(|code| i32::try_from(code).ok())
.is_some_and(|code| ErrorCode::from_code(code).is_some())
}
pub fn build_computer_not_found_error(computer_name: &str) -> ErrorPayload {
ErrorPayload::new(
i64::from(ErrorCode::NotFound.code()),
format!("Computer with name '{computer_name}' not found"),
)
.with_detail("computer_name", computer_name)
}
pub mod events {
pub const CLIENT_GET_TOOLS: &str = "client:get_tools";
pub const CLIENT_GET_CONFIG: &str = "client:get_config";
pub const CLIENT_GET_DESKTOP: &str = "client:get_desktop";
pub const CLIENT_TOOL_CALL: &str = "client:tool_call";
pub const CLIENT_GET_RESOURCES: &str = "client:get_resources";
pub const CLIENT_GET_SKILLS: &str = "client:get_skills";
pub const CLIENT_GET_SKILL: &str = "client:get_skill";
pub const CLIENT_GET_BLOB: &str = "client:get_blob";
pub const SERVER_JOIN_OFFICE: &str = "server:join_office";
pub const SERVER_LEAVE_OFFICE: &str = "server:leave_office";
pub const SERVER_UPDATE_CONFIG: &str = "server:update_config";
pub const SERVER_UPDATE_TOOL_LIST: &str = "server:update_tool_list";
pub const SERVER_UPDATE_DESKTOP: &str = "server:update_desktop";
pub const SERVER_UPDATE_SKILLS: &str = "server:update_skills";
pub const SERVER_TOOL_CALL_CANCEL: &str = "server:tool_call_cancel";
pub const SERVER_LIST_ROOM: &str = "server:list_room";
pub const NOTIFY_TOOL_CALL_CANCEL: &str = "notify:tool_call_cancel";
pub const NOTIFY_ENTER_OFFICE: &str = "notify:enter_office";
pub const NOTIFY_LEAVE_OFFICE: &str = "notify:leave_office";
pub const NOTIFY_UPDATE_CONFIG: &str = "notify:update_config";
pub const NOTIFY_UPDATE_TOOL_LIST: &str = "notify:update_tool_list";
pub const NOTIFY_UPDATE_DESKTOP: &str = "notify:update_desktop";
pub const NOTIFY_UPDATE_SKILLS: &str = "notify:update_skills";
pub const NOTIFY_PREFIX: &str = "notify:";
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReqId(pub String);
impl ReqId {
pub fn new() -> Self {
Self(Uuid::new_v4().simple().to_string())
}
pub fn from_string(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for ReqId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Agent,
Computer,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::Agent => write!(f, "agent"),
Role::Computer => write!(f, "computer"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub name: String,
pub role: Role,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
pub tool_name: String,
pub params: serde_json::Value,
pub timeout: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetComputerConfigReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateComputerConfigReq {
pub computer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetComputerConfigRet {
#[serde(skip_serializing_if = "Option::is_none")]
pub inputs: Option<Vec<serde_json::Value>>,
pub servers: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallRet {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<serde_json::Value>>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_id: Option<ReqId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
pub mod tool_meta {
pub const A2C_CANCELLED_KEY: &str = "a2c_cancelled";
pub const A2C_CANCEL_REASON_KEY: &str = "a2c_cancel_reason";
pub const A2C_TIMEOUT_KEY: &str = "a2c_timeout";
pub const A2C_DEFAULT_CANCEL_REASON: &str = "agent_requested";
pub const A2C_BLOB_HANDLE_KEY: &str = "a2c_blob_handle";
pub const A2C_TOTAL_SIZE_KEY: &str = "a2c_total_size";
pub const A2C_SHA256_KEY: &str = "a2c_sha256";
pub const AUTH_ERROR_CODE_KEY: &str = "error_code";
pub const AUTH_MCP_SERVER_KEY: &str = "mcp_server";
pub const AUTH_HINT_KEY: &str = "auth_hint";
}
impl ToolCallRet {
fn meta_object_mut(&mut self) -> &mut serde_json::Map<String, serde_json::Value> {
if !matches!(self.meta, Some(serde_json::Value::Object(_))) {
self.meta = Some(serde_json::Value::Object(serde_json::Map::new()));
}
match self.meta.as_mut() {
Some(serde_json::Value::Object(map)) => map,
_ => unreachable!("meta 刚被规整为对象"),
}
}
pub fn mark_cancelled(&mut self, reason: Option<&str>) {
let reason = reason
.unwrap_or(tool_meta::A2C_DEFAULT_CANCEL_REASON)
.to_string();
let map = self.meta_object_mut();
map.insert(tool_meta::A2C_CANCELLED_KEY.to_string(), true.into());
map.insert(tool_meta::A2C_CANCEL_REASON_KEY.to_string(), reason.into());
}
pub fn mark_timeout(&mut self) {
self.meta_object_mut()
.insert(tool_meta::A2C_TIMEOUT_KEY.to_string(), true.into());
}
pub fn is_cancelled(&self) -> bool {
meta_bool(self.meta.as_ref(), tool_meta::A2C_CANCELLED_KEY)
}
pub fn cancel_reason(&self) -> Option<&str> {
self.meta
.as_ref()
.and_then(|m| m.get(tool_meta::A2C_CANCEL_REASON_KEY))
.and_then(serde_json::Value::as_str)
}
pub fn is_timeout(&self) -> bool {
meta_bool(self.meta.as_ref(), tool_meta::A2C_TIMEOUT_KEY)
}
}
fn meta_bool(meta: Option<&serde_json::Value>, key: &str) -> bool {
meta.and_then(|m| m.get(key))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobSideband {
pub blob_handle: BlobHandle,
pub total_size: Option<u64>,
pub sha256: Option<String>,
}
pub fn set_content_blob_sideband(
item: &mut serde_json::Value,
blob_handle: &str,
total_size: Option<u64>,
sha256: Option<&str>,
) {
let serde_json::Value::Object(obj) = item else {
return;
};
let meta = obj
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !meta.is_object() {
*meta = serde_json::Value::Object(serde_json::Map::new());
}
if let serde_json::Value::Object(meta) = meta {
meta.insert(
tool_meta::A2C_BLOB_HANDLE_KEY.to_string(),
blob_handle.into(),
);
if let Some(size) = total_size {
meta.insert(tool_meta::A2C_TOTAL_SIZE_KEY.to_string(), size.into());
}
if let Some(hash) = sha256 {
meta.insert(tool_meta::A2C_SHA256_KEY.to_string(), hash.into());
}
}
}
pub fn read_content_blob_sideband(item: &serde_json::Value) -> Option<BlobSideband> {
let meta = item.get("_meta")?;
let blob_handle = meta
.get(tool_meta::A2C_BLOB_HANDLE_KEY)
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())?;
Some(BlobSideband {
blob_handle: blob_handle.to_string(),
total_size: meta
.get(tool_meta::A2C_TOTAL_SIZE_KEY)
.and_then(serde_json::Value::as_u64),
sha256: meta
.get(tool_meta::A2C_SHA256_KEY)
.and_then(serde_json::Value::as_str)
.map(str::to_string),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetToolsReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SMCPTool {
pub name: String,
pub description: String,
pub params_schema: serde_json::Value,
pub return_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetToolsRet {
pub tools: Vec<SMCPTool>,
pub req_id: ReqId,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentCallData {
pub agent: String,
pub req_id: ReqId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnterOfficeReq {
pub role: Role,
pub name: String,
pub office_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaveOfficeReq {
pub office_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetDesktopReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub desktop_size: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window: Option<String>,
}
pub type Desktop = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetDesktopRet {
#[serde(skip_serializing_if = "Option::is_none")]
pub desktops: Option<Vec<Desktop>>,
pub req_id: ReqId,
}
pub const GET_RESOURCES_EVENT: &str = events::CLIENT_GET_RESOURCES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResourceAudience {
User,
Assistant,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ResourceAnnotations {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audience: Option<Vec<ResourceAudience>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct A2CResource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotations: Option<ResourceAnnotations>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetResourcesReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
pub mcp_server: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct GetResourcesRet {
#[serde(default)]
pub resources: Vec<A2CResource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub req_id: Option<ReqId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListRoomReq {
#[serde(flatten)]
pub base: AgentCallData,
pub office_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub sid: String,
pub name: String,
pub role: Role,
pub office_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub a2c_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListRoomRet {
pub sessions: Vec<SessionInfo>,
pub req_id: ReqId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnterOfficeNotification {
pub office_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub computer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaveOfficeNotification {
pub office_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub computer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateMCPConfigNotification {
pub computer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateToolListNotification {
pub computer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Notification {
ToolCallCancel,
EnterOffice(EnterOfficeNotification),
LeaveOffice(LeaveOfficeNotification),
UpdateMCPConfig(UpdateMCPConfigNotification),
UpdateToolList(UpdateToolListNotification),
UpdateDesktop,
}
pub const GET_SKILLS_EVENT: &str = events::CLIENT_GET_SKILLS;
pub const GET_SKILL_EVENT: &str = events::CLIENT_GET_SKILL;
pub const UPDATE_SKILLS_EVENT: &str = events::SERVER_UPDATE_SKILLS;
pub const UPDATE_SKILLS_NOTIFICATION: &str = events::NOTIFY_UPDATE_SKILLS;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct A2CSkillRef {
pub name: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
pub path: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetSkillsReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetSkillsRet {
#[serde(default)]
pub skills: Vec<A2CSkillRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub req_id: Option<ReqId>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetSkillReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rel_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillResource<'a> {
Inline(&'a str),
Blob(&'a str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SkillRetError {
#[error("GetSkillRet: body and blob_handle are mutually exclusive (both present)")]
BothPresent,
#[error("GetSkillRet: exactly one of body / blob_handle must be present (neither found)")]
NeitherPresent,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetSkillRet {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rel_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_handle: Option<BlobHandle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub req_id: Option<ReqId>,
}
impl GetSkillRet {
pub fn resource(&self) -> Result<SkillResource<'_>, SkillRetError> {
match (self.body.as_deref(), self.blob_handle.as_deref()) {
(Some(body), None) => Ok(SkillResource::Inline(body)),
(None, Some(handle)) => Ok(SkillResource::Blob(handle)),
(Some(_), Some(_)) => Err(SkillRetError::BothPresent),
(None, None) => Err(SkillRetError::NeitherPresent),
}
}
pub fn is_exclusive(&self) -> bool {
self.body.is_some() ^ self.blob_handle.is_some()
}
}
pub const GET_BLOB_EVENT: &str = events::CLIENT_GET_BLOB;
pub type BlobHandle = String;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetBlobReq {
#[serde(flatten)]
pub base: AgentCallData,
pub computer: String,
pub blob_handle: BlobHandle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chunk_offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chunk_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetBlobRet {
pub blob_handle: BlobHandle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
pub total_size: u64,
pub sha256: String,
pub chunk_offset: u64,
pub eof: bool,
pub blob: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub req_id: Option<ReqId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_req_id_helpers() {
let req_id = ReqId::new();
assert!(!req_id.as_str().is_empty());
let req_id2 = ReqId::from_string("abc".to_string());
assert_eq!(req_id2.as_str(), "abc");
let req_id3 = ReqId::default();
assert!(!req_id3.as_str().is_empty());
}
#[test]
fn test_role_serde_lowercase() {
let json = serde_json::to_string(&Role::Agent).unwrap();
assert_eq!(json, "\"agent\"");
let de: Role = serde_json::from_str("\"computer\"").unwrap();
assert!(matches!(de, Role::Computer));
}
#[test]
fn test_session_info_a2c_version_serde() {
let with_ver = SessionInfo {
sid: "sid-1".to_string(),
name: "agent-1".to_string(),
role: Role::Agent,
office_id: "office-1".to_string(),
a2c_version: Some("0.2.0".to_string()),
};
let json = serde_json::to_value(&with_ver).unwrap();
assert_eq!(json["a2c_version"], "0.2.0");
let without_ver = SessionInfo {
sid: "sid-2".to_string(),
name: "computer-1".to_string(),
role: Role::Computer,
office_id: "office-1".to_string(),
a2c_version: None,
};
let json_none = serde_json::to_value(&without_ver).unwrap();
assert!(
json_none.get("a2c_version").is_none(),
"None 时必须省略 a2c_version 键,实得: {json_none}"
);
let legacy = r#"{"sid":"sid-3","name":"c2","role":"computer","office_id":"office-1"}"#;
let de: SessionInfo = serde_json::from_str(legacy).unwrap();
assert!(de.a2c_version.is_none());
let back: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(back.a2c_version.as_deref(), Some("0.2.0"));
}
#[test]
fn test_notification_serde() {
let n = Notification::EnterOffice(EnterOfficeNotification {
office_id: "office1".to_string(),
computer: Some("c1".to_string()),
agent: None,
});
let json = serde_json::to_string(&n).unwrap();
let de: Notification = serde_json::from_str(&json).unwrap();
match de {
Notification::EnterOffice(p) => {
assert_eq!(p.office_id, "office1");
assert_eq!(p.computer.as_deref(), Some("c1"));
assert!(p.agent.is_none());
}
_ => panic!("unexpected notification"),
}
}
#[test]
fn test_tool_call_ret_mcp_format() {
let success_ret = ToolCallRet {
content: Some(vec![serde_json::json!({
"type": "text",
"text": "Operation completed successfully"
})]),
is_error: Some(false),
req_id: Some(ReqId::from_string("test123".to_string())),
meta: None,
};
let json = serde_json::to_string(&success_ret).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("content").is_some());
assert!(parsed.get("isError").is_some());
assert_eq!(parsed.get("isError").unwrap(), false);
assert_eq!(parsed.get("req_id").unwrap().as_str().unwrap(), "test123");
assert!(json.contains("isError"));
assert!(!json.contains("is_error"));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("success").is_none());
assert!(parsed.get("result").is_none());
assert!(parsed.get("error").is_none());
}
#[test]
fn test_tool_call_ret_error_format() {
let error_ret = ToolCallRet {
content: Some(vec![serde_json::json!({
"type": "text",
"text": "Tool execution failed"
})]),
is_error: Some(true),
req_id: None,
meta: None,
};
let json = serde_json::to_string(&error_ret).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("content").is_some());
assert_eq!(parsed.get("isError").unwrap(), true);
assert!(parsed.get("req_id").is_none());
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("success").is_none());
assert!(parsed.get("result").is_none());
assert!(parsed.get("error").is_none());
}
#[test]
fn test_tool_call_ret_minimal() {
let minimal_ret = ToolCallRet {
content: None,
is_error: None,
req_id: None,
meta: None,
};
let json = serde_json::to_string(&minimal_ret).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, serde_json::json!({}));
}
#[test]
fn test_tool_call_ret_roundtrip() {
let original = ToolCallRet {
content: Some(vec![serde_json::json!({
"type": "text",
"text": "Test result"
})]),
is_error: Some(false),
req_id: Some(ReqId::new()),
meta: None,
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: ToolCallRet = serde_json::from_str(&json).unwrap();
assert_eq!(original.content, deserialized.content);
assert_eq!(original.is_error, deserialized.is_error);
assert_eq!(original.req_id, deserialized.req_id);
}
#[test]
fn test_error_payload_flat_serialization() {
let payload = ErrorPayload::new(404, "Resource not found");
let v = serde_json::to_value(&payload).unwrap();
assert!(v.get("error").is_none(), "禁止嵌套 envelope"); assert_eq!(v.get("code").unwrap(), 404);
assert_eq!(v.get("message").unwrap(), "Resource not found");
assert!(v.get("details").is_none()); }
#[test]
fn test_error_payload_with_details_serialization() {
let payload = ErrorPayload::new(4014, "boom")
.with_detail("mcp_server_name", "srv-a")
.with_detail("hint", "retry");
let v = serde_json::to_value(&payload).unwrap();
assert_eq!(v["code"], 4014);
assert_eq!(v["details"]["mcp_server_name"], "srv-a");
assert_eq!(v["details"]["hint"], "retry");
}
#[test]
fn test_is_protocol_error_payload_flat_true() {
for code in [404, 4006, 4007, 4008, 4014, 4015, 4016, 4017, 4018] {
let v = serde_json::json!({ "code": code, "message": "x" });
assert!(
is_protocol_error_payload(&v),
"code {code} 应判定为协议错误负载"
);
}
let v = serde_json::to_value(build_computer_not_found_error("c1")).unwrap();
assert!(is_protocol_error_payload(&v));
}
#[test]
fn test_is_protocol_error_payload_false() {
let nested = serde_json::json!({ "error": { "code": 404, "message": "x" } });
assert!(!is_protocol_error_payload(&nested));
assert!(!is_protocol_error_payload(
&serde_json::json!({ "code": 400 })
));
assert!(!is_protocol_error_payload(
&serde_json::json!({ "code": 9999 })
));
assert!(!is_protocol_error_payload(
&serde_json::json!({ "message": "x" })
));
assert!(!is_protocol_error_payload(
&serde_json::json!({ "code": "404" })
));
assert!(!is_protocol_error_payload(&serde_json::json!([
"code", 404
])));
assert!(!is_protocol_error_payload(&serde_json::json!("nope")));
}
#[test]
fn test_build_computer_not_found_error() {
let payload = build_computer_not_found_error("my-computer");
assert_eq!(payload.code, 404);
assert_eq!(payload.code, i64::from(ErrorCode::NotFound.code()));
assert!(payload.message.contains("my-computer"));
assert_eq!(
payload
.details
.as_ref()
.unwrap()
.get("computer_name")
.unwrap(),
"my-computer"
);
}
#[test]
fn test_build_computer_not_found_error_python_byte_compat() {
let v = serde_json::to_value(build_computer_not_found_error("c1")).unwrap();
let expected = serde_json::json!({
"code": 404,
"message": "Computer with name 'c1' not found",
"details": { "computer_name": "c1" }
});
assert_eq!(v, expected);
}
#[test]
fn test_error_payload_roundtrip() {
let original = ErrorPayload::new(500, "Internal error").with_detail("trace_id", "abc123");
let json = serde_json::to_string(&original).unwrap();
let deserialized: ErrorPayload = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_error_payload_from_error_code() {
let payload = ErrorPayload::from_error_code(ErrorCode::McpServerNotFound, "boom");
assert_eq!(payload.code, 4014);
assert_eq!(payload.code, i64::from(ErrorCode::McpServerNotFound.code()));
assert_eq!(payload.message, "boom");
let v = serde_json::to_value(&payload).unwrap();
assert!(is_protocol_error_payload(&v));
assert!(v.get("mcp_server_name").is_none());
assert!(v.get("capability").is_none());
assert!(v.get("details").is_none());
}
#[test]
fn test_error_payload_4014_top_level_field() {
let v = serde_json::to_value(
ErrorPayload::from_error_code(ErrorCode::McpServerNotFound, "not found")
.with_mcp_server_name("filesystem"),
)
.unwrap();
assert_eq!(
v,
serde_json::json!({
"code": 4014,
"message": "not found",
"mcp_server_name": "filesystem"
})
);
}
#[test]
fn test_error_payload_4015_top_level_fields_python_shape() {
let v = serde_json::to_value(
ErrorPayload::from_error_code(ErrorCode::McpCapabilityNotSupported, "unsupported")
.with_mcp_server_name("docs-server")
.with_capability("resources"),
)
.unwrap();
assert_eq!(
v,
serde_json::json!({
"code": 4015,
"message": "unsupported",
"mcp_server_name": "docs-server",
"capability": "resources"
})
);
}
#[test]
fn test_error_payload_captures_unknown_top_level_fields() {
let wire = serde_json::json!({
"code": 4014,
"message": "x",
"mcp_server_name": "srv", "future_string": "keep-me", "future_object": { "nested": true },
"future_number": 7
});
let payload: ErrorPayload = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(payload.mcp_server_name.as_deref(), Some("srv"));
assert!(!payload.extra.contains_key("mcp_server_name"));
assert!(!payload.extra.contains_key("code"));
assert_eq!(payload.extra.get("future_string").unwrap(), "keep-me");
assert_eq!(payload.extra.get("future_number").unwrap(), 7);
let round = serde_json::to_value(&payload).unwrap();
assert_eq!(round, wire);
}
#[test]
fn test_protocol_version_constant() {
assert_eq!(PROTOCOL_VERSION, "0.2.0");
}
#[test]
fn test_error_code_values() {
assert_eq!(ErrorCode::NotFound.code(), 404);
assert_eq!(ErrorCode::ToolAuthorizationRequired.code(), 4006);
assert_eq!(ErrorCode::ToolAuthorizationFailed.code(), 4007);
assert_eq!(ErrorCode::ProtocolVersionMismatch.code(), 4008);
assert_eq!(ErrorCode::McpServerNotFound.code(), 4014);
assert_eq!(ErrorCode::McpCapabilityNotSupported.code(), 4015);
assert_eq!(ErrorCode::SkillNameInvalid.code(), 4016);
assert_eq!(ErrorCode::SkillResourceNotAccessible.code(), 4017);
assert_eq!(ErrorCode::BlobNotAccessible.code(), 4018);
}
#[test]
fn test_error_code_serializes_as_int() {
assert_eq!(
serde_json::to_string(&ErrorCode::ProtocolVersionMismatch).unwrap(),
"4008"
);
assert_eq!(serde_json::to_string(&ErrorCode::NotFound).unwrap(), "404");
let v = serde_json::json!({ "code": ErrorCode::BlobNotAccessible });
assert_eq!(v["code"], serde_json::json!(4018));
}
#[test]
fn test_error_code_deserializes_from_int() {
let c: ErrorCode = serde_json::from_str("4014").unwrap();
assert_eq!(c, ErrorCode::McpServerNotFound);
assert!(serde_json::from_str::<ErrorCode>("9999").is_err());
assert_eq!(ErrorCode::from_code(9999), None);
}
#[test]
fn test_error_code_int_roundtrip() {
for code in [
ErrorCode::NotFound,
ErrorCode::ToolAuthorizationRequired,
ErrorCode::ToolAuthorizationFailed,
ErrorCode::ProtocolVersionMismatch,
ErrorCode::McpServerNotFound,
ErrorCode::McpCapabilityNotSupported,
ErrorCode::SkillNameInvalid,
ErrorCode::SkillResourceNotAccessible,
ErrorCode::BlobNotAccessible,
] {
let json = serde_json::to_string(&code).unwrap();
let back: ErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, back);
assert_eq!(ErrorCode::from_code(code.code()), Some(code));
}
}
#[test]
fn test_ws_close_code_distinct_from_protocol_mismatch() {
assert_eq!(WS_VERSION_HANDSHAKE_REJECTED_CLOSE_CODE, 4900);
assert_eq!(ErrorCode::ProtocolVersionMismatch.code(), 4008);
assert_ne!(
WS_VERSION_HANDSHAKE_REJECTED_CLOSE_CODE,
ErrorCode::ProtocolVersionMismatch.code()
);
}
#[test]
fn test_tool_lookup_vs_execution_boundary() {
assert_eq!(error_codes::TOOL_NOT_FOUND, 4001);
assert_eq!(error_codes::TOOL_EXECUTION_FAILED, 4003);
assert_ne!(
error_codes::TOOL_NOT_FOUND,
error_codes::TOOL_EXECUTION_FAILED
);
}
#[test]
fn test_get_blob_event_constant() {
assert_eq!(GET_BLOB_EVENT, "client:get_blob");
assert_eq!(GET_BLOB_EVENT, events::CLIENT_GET_BLOB);
}
fn sample_blob_req(chunk_offset: Option<u64>, max_chunk_bytes: Option<u64>) -> GetBlobReq {
GetBlobReq {
base: AgentCallData {
agent: "agent-1".to_string(),
req_id: ReqId::from_string("r1".to_string()),
},
computer: "comp-1".to_string(),
blob_handle: "opaque-handle".to_string(),
chunk_offset,
max_chunk_bytes,
}
}
#[test]
fn test_get_blob_req_with_offset_serde() {
let req = sample_blob_req(Some(1024), Some(65536));
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["agent"], "agent-1");
assert_eq!(v["req_id"], "r1");
assert_eq!(v["computer"], "comp-1");
assert_eq!(v["blob_handle"], "opaque-handle");
assert_eq!(v["chunk_offset"], 1024);
assert_eq!(v["max_chunk_bytes"], 65536);
let back: GetBlobReq = serde_json::from_value(v).unwrap();
assert_eq!(back, req);
}
#[test]
fn test_get_blob_req_without_offset_omits_optional_keys() {
let req = sample_blob_req(None, None);
let v = serde_json::to_value(&req).unwrap();
assert!(
v.get("chunk_offset").is_none(),
"缺省 chunk_offset 不应序列化"
);
assert!(
v.get("max_chunk_bytes").is_none(),
"缺省 max_chunk_bytes 不应序列化"
);
let back: GetBlobReq = serde_json::from_value(v).unwrap();
assert_eq!(back, req);
assert!(back.chunk_offset.is_none());
}
#[test]
fn test_get_blob_ret_first_and_last_chunk_roundtrip() {
let first = GetBlobRet {
blob_handle: "h".to_string(),
mime_type: Some("application/octet-stream".to_string()),
total_size: 10,
sha256: "abc123".to_string(),
chunk_offset: 0,
eof: false,
blob: "aGVsbG8=".to_string(), req_id: Some(ReqId::from_string("r1".to_string())),
};
let back: GetBlobRet =
serde_json::from_str(&serde_json::to_string(&first).unwrap()).unwrap();
assert_eq!(back, first);
assert!(!back.eof);
let last = GetBlobRet {
blob_handle: "h".to_string(),
mime_type: None,
total_size: 10,
sha256: "abc123".to_string(),
chunk_offset: 5,
eof: true,
blob: "d29ybGQ=".to_string(),
req_id: None,
};
let v = serde_json::to_value(&last).unwrap();
assert!(v.get("mime_type").is_none());
assert!(v.get("req_id").is_none());
assert_eq!(v["eof"], true);
let back: GetBlobRet = serde_json::from_value(v).unwrap();
assert_eq!(back, last);
}
fn empty_ret() -> ToolCallRet {
ToolCallRet {
content: None,
is_error: Some(true),
req_id: None,
meta: None,
}
}
#[test]
fn test_mark_cancelled_default_and_custom_reason() {
let mut ret = empty_ret();
ret.mark_cancelled(None);
assert!(ret.is_cancelled());
assert_eq!(ret.cancel_reason(), Some("agent_requested"));
let v = serde_json::to_value(&ret).unwrap();
assert_eq!(v["meta"]["a2c_cancelled"], true);
assert_eq!(v["meta"]["a2c_cancel_reason"], "agent_requested");
let mut ret2 = empty_ret();
ret2.mark_cancelled(Some("operator_abort"));
assert_eq!(ret2.cancel_reason(), Some("operator_abort"));
}
#[test]
fn test_mark_timeout() {
let mut ret = empty_ret();
assert!(!ret.is_timeout());
ret.mark_timeout();
assert!(ret.is_timeout());
assert!(!ret.is_cancelled()); let v = serde_json::to_value(&ret).unwrap();
assert_eq!(v["meta"]["a2c_timeout"], true);
}
#[test]
fn test_content_blob_sideband_placement_and_roundtrip() {
let mut item = serde_json::json!({ "type": "text", "text": "" });
set_content_blob_sideband(&mut item, "blob-xyz", Some(2048), Some("deadbeef"));
assert_eq!(item["_meta"]["a2c_blob_handle"], "blob-xyz");
assert_eq!(item["_meta"]["a2c_total_size"], 2048);
assert_eq!(item["_meta"]["a2c_sha256"], "deadbeef");
let sb = read_content_blob_sideband(&item).expect("应读到旁路句柄");
assert_eq!(sb.blob_handle, "blob-xyz");
assert_eq!(sb.total_size, Some(2048));
assert_eq!(sb.sha256.as_deref(), Some("deadbeef"));
let plain = serde_json::json!({ "type": "text", "text": "hi" });
assert!(read_content_blob_sideband(&plain).is_none());
}
#[test]
fn test_result_meta_vs_child_meta_separation() {
let mut item = serde_json::json!({ "type": "text", "text": "" });
set_content_blob_sideband(&mut item, "h", None, None);
let mut ret = ToolCallRet {
content: Some(vec![item]),
is_error: Some(true),
req_id: None,
meta: None,
};
ret.mark_cancelled(None);
let v = serde_json::to_value(&ret).unwrap();
assert_eq!(v["meta"]["a2c_cancelled"], true);
assert!(v["meta"].get("a2c_blob_handle").is_none());
assert_eq!(v["content"][0]["_meta"]["a2c_blob_handle"], "h");
assert!(v["content"][0]["_meta"].get("a2c_cancelled").is_none());
}
#[test]
fn test_tool_call_ret_deserialize_external_meta() {
let cancelled_wire = r#"{"content":[{"type":"text","text":"cancelled"}],"isError":true,"meta":{"a2c_cancelled":true,"a2c_cancel_reason":"agent_requested"}}"#;
let ret: ToolCallRet = serde_json::from_str(cancelled_wire).unwrap();
assert!(ret.is_cancelled());
assert_eq!(ret.cancel_reason(), Some("agent_requested"));
assert!(!ret.is_timeout());
let timeout_wire = r#"{"isError":true,"meta":{"a2c_timeout":true}}"#;
let ret2: ToolCallRet = serde_json::from_str(timeout_wire).unwrap();
assert!(ret2.is_timeout());
assert!(!ret2.is_cancelled());
assert_eq!(ret2.cancel_reason(), None);
let plain: ToolCallRet = serde_json::from_str(r#"{"isError":false}"#).unwrap();
assert!(!plain.is_cancelled() && !plain.is_timeout());
}
#[test]
fn test_agent_call_data_cancel_carrier_shape() {
let cancel = AgentCallData {
agent: "agent-x".to_string(),
req_id: ReqId::from_string("rid_tool".to_string()),
};
let v = serde_json::to_value(&cancel).unwrap();
assert_eq!(v["agent"], "agent-x");
assert_eq!(v["req_id"], "rid_tool");
assert!(
v.get("computer").is_none(),
"取消载体 MUST NOT 含 computer 字段,实得: {v}"
);
assert_eq!(
v.as_object().map(|m| m.len()),
Some(2),
"AgentCallData 在线形态应恰为 {{agent, req_id}}"
);
assert_eq!(serde_json::from_value::<AgentCallData>(v).unwrap(), cancel);
}
#[test]
fn get_resources_event_const_exact() {
assert_eq!(GET_RESOURCES_EVENT, "client:get_resources");
assert_eq!(GET_RESOURCES_EVENT, events::CLIENT_GET_RESOURCES);
}
#[test]
fn get_resources_req_round_trip_with_and_without_cursor() {
let base = AgentCallData {
agent: "agent-x".to_string(),
req_id: ReqId::from_string("rid-1".to_string()),
};
let req = GetResourcesReq {
base: base.clone(),
computer: "comp-1".to_string(),
mcp_server: "srv-1".to_string(),
cursor: None,
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["agent"], "agent-x");
assert_eq!(v["req_id"], "rid-1");
assert_eq!(v["computer"], "comp-1");
assert_eq!(v["mcp_server"], "srv-1");
assert!(v.get("cursor").is_none());
assert_eq!(serde_json::from_value::<GetResourcesReq>(v).unwrap(), req);
let req2 = GetResourcesReq {
base,
computer: "comp-1".to_string(),
mcp_server: "srv-1".to_string(),
cursor: Some("page-2".to_string()),
};
let v2 = serde_json::to_value(&req2).unwrap();
assert_eq!(v2["cursor"], "page-2");
assert_eq!(serde_json::from_value::<GetResourcesReq>(v2).unwrap(), req2);
}
#[test]
fn get_resources_ret_empty_and_populated() {
let empty = GetResourcesRet::default();
let v = serde_json::to_value(&empty).unwrap();
assert_eq!(v["resources"], serde_json::json!([]));
assert!(v.get("next_cursor").is_none());
let ret = GetResourcesRet {
resources: vec![
A2CResource {
uri: Some("window://main".to_string()),
name: Some("main".to_string()),
annotations: Some(ResourceAnnotations {
audience: Some(vec![ResourceAudience::User, ResourceAudience::Assistant]),
priority: Some(0.0),
last_modified: Some("2026-06-01T00:00:00Z".to_string()),
}),
meta: Some(serde_json::json!({ "fullscreen": true })),
..Default::default()
},
A2CResource {
uri: Some("window://side".to_string()),
annotations: Some(ResourceAnnotations {
priority: Some(1.0),
..Default::default()
}),
..Default::default()
},
],
next_cursor: Some("next".to_string()),
req_id: Some(ReqId::from_string("rid-2".to_string())),
};
let v = serde_json::to_value(&ret).unwrap();
assert_eq!(v["resources"][0]["_meta"]["fullscreen"], true);
assert!(v["resources"][0].get("meta").is_none());
assert_eq!(v["resources"][0]["annotations"]["audience"][0], "user");
assert_eq!(v["resources"][0]["annotations"]["priority"], 0.0);
assert_eq!(v["resources"][1]["annotations"]["priority"], 1.0);
assert_eq!(v["next_cursor"], "next");
let back: GetResourcesRet = serde_json::from_value(v).unwrap();
assert_eq!(back, ret);
}
#[test]
fn skill_event_consts_exact() {
assert_eq!(GET_SKILLS_EVENT, "client:get_skills");
assert_eq!(GET_SKILL_EVENT, "client:get_skill");
assert_eq!(UPDATE_SKILLS_EVENT, "server:update_skills");
assert_eq!(UPDATE_SKILLS_NOTIFICATION, "notify:update_skills");
assert_eq!(GET_SKILLS_EVENT, events::CLIENT_GET_SKILLS);
assert_eq!(GET_SKILL_EVENT, events::CLIENT_GET_SKILL);
assert_eq!(UPDATE_SKILLS_EVENT, events::SERVER_UPDATE_SKILLS);
assert_eq!(UPDATE_SKILLS_NOTIFICATION, events::NOTIFY_UPDATE_SKILLS);
}
#[test]
fn skill_ref_three_sources_round_trip() {
let user = A2CSkillRef {
name: "my-helper".to_string(),
source: "user".to_string(),
uri: None,
path: "/home/u/.skills/my-helper".to_string(),
description: "a helper".to_string(),
license: None,
compatibility: None,
allowed_tools: None,
version: None,
skill_metadata: None,
};
let v = serde_json::to_value(&user).unwrap();
assert_eq!(v["name"], "my-helper");
assert_eq!(v["source"], "user");
assert!(v.get("uri").is_none());
assert!(v.get("version").is_none());
assert_eq!(serde_json::from_value::<A2CSkillRef>(v).unwrap(), user);
let marketplace = A2CSkillRef {
name: "acme-audit:audit".to_string(),
source: "marketplace:acme-skills".to_string(),
..user.clone()
};
assert_eq!(
serde_json::from_value::<A2CSkillRef>(serde_json::to_value(&marketplace).unwrap())
.unwrap(),
marketplace
);
let mcp = A2CSkillRef {
name: "mcp:tfrobot-tools:code-review".to_string(),
source: "mcp:tfrobot-tools".to_string(),
uri: Some("skill://tfrobot-tools/code-review".to_string()),
path: "/tmp/staging/code-review".to_string(),
description: "review".to_string(),
license: Some("MIT".to_string()),
compatibility: Some(">=0.2".to_string()),
allowed_tools: Some(vec!["read".to_string(), "grep".to_string()]),
version: Some("1.2.0".to_string()),
skill_metadata: Some(serde_json::json!({ "x": 1 })),
};
let v = serde_json::to_value(&mcp).unwrap();
assert_eq!(v["uri"], "skill://tfrobot-tools/code-review");
assert_eq!(v["allowed_tools"][1], "grep");
assert_eq!(v["skill_metadata"]["x"], 1);
assert_eq!(serde_json::from_value::<A2CSkillRef>(v).unwrap(), mcp);
}
#[test]
fn get_skills_req_ret_round_trip() {
let req = GetSkillsReq {
base: AgentCallData {
agent: "a".to_string(),
req_id: ReqId::from_string("r".to_string()),
},
computer: "c".to_string(),
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["agent"], "a");
assert_eq!(v["computer"], "c");
assert_eq!(serde_json::from_value::<GetSkillsReq>(v).unwrap(), req);
let ret = GetSkillsRet::default();
let v = serde_json::to_value(&ret).unwrap();
assert_eq!(v["skills"], serde_json::json!([]));
assert!(v.get("req_id").is_none());
assert_eq!(serde_json::from_value::<GetSkillsRet>(v).unwrap(), ret);
}
#[test]
fn get_skill_req_rel_path_optional() {
let base = AgentCallData {
agent: "a".to_string(),
req_id: ReqId::from_string("r".to_string()),
};
let req = GetSkillReq {
base: base.clone(),
computer: "c".to_string(),
name: "my-helper".to_string(),
rel_path: None,
};
let v = serde_json::to_value(&req).unwrap();
assert!(v.get("rel_path").is_none());
assert_eq!(serde_json::from_value::<GetSkillReq>(v).unwrap(), req);
let req2 = GetSkillReq {
base,
computer: "c".to_string(),
name: "my-helper".to_string(),
rel_path: Some("docs/usage.md".to_string()),
};
let v2 = serde_json::to_value(&req2).unwrap();
assert_eq!(v2["rel_path"], "docs/usage.md");
assert_eq!(serde_json::from_value::<GetSkillReq>(v2).unwrap(), req2);
}
#[test]
fn get_skill_ret_body_blob_handle_exclusive() {
let inline = GetSkillRet {
name: Some("my-helper".to_string()),
rel_path: Some("SKILL.md".to_string()),
mime_type: Some("text/markdown".to_string()),
total_size: Some(42),
sha256: Some("abc".to_string()),
body: Some("# Hello".to_string()),
..Default::default()
};
assert!(inline.is_exclusive());
assert_eq!(inline.resource().unwrap(), SkillResource::Inline("# Hello"));
let back: GetSkillRet =
serde_json::from_value(serde_json::to_value(&inline).unwrap()).unwrap();
assert_eq!(back, inline);
let blob = GetSkillRet {
name: Some("big".to_string()),
blob_handle: Some("opaque-handle".to_string()),
..Default::default()
};
assert!(blob.is_exclusive());
assert_eq!(
blob.resource().unwrap(),
SkillResource::Blob("opaque-handle")
);
let both = GetSkillRet {
body: Some("x".to_string()),
blob_handle: Some("h".to_string()),
..Default::default()
};
assert!(!both.is_exclusive());
assert_eq!(both.resource().unwrap_err(), SkillRetError::BothPresent);
let neither = GetSkillRet::default();
assert!(!neither.is_exclusive());
assert_eq!(
neither.resource().unwrap_err(),
SkillRetError::NeitherPresent
);
}
}