use serde::{Deserialize, Serialize};
pub const ERR_CAPABILITY_DENIED: u16 = 1001;
pub const ERR_RATE_LIMITED: u16 = 1002;
pub const ERR_BROKER_FAILED: u16 = 1003;
pub const ERR_UCAN_INVALID: u16 = 1010;
pub const ERR_UCAN_EXPIRED: u16 = 1011;
pub const ERR_DELEGATION_TOO_DEEP: u16 = 1012;
pub const ERR_AUDIENCE_MISMATCH: u16 = 1013;
pub const ERR_CURSOR_EXPIRED: u16 = 1020;
pub const ERR_CURSOR_INVALID: u16 = 1021;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "type")]
pub enum Request {
#[serde(rename = "ping")]
Ping,
#[serde(rename = "hello")]
Hello {
#[serde(default, skip_serializing_if = "Option::is_none")]
client_id: Option<String>,
#[serde(default)]
requested_capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ucan_tokens: Vec<String>,
},
#[serde(rename = "tool_list")]
ToolList,
#[serde(rename = "tool_schema")]
ToolSchema { tool_id: String },
#[serde(rename = "run_tool")]
RunTool {
tool_id: String,
args: serde_json::Value,
dry_run: bool,
},
#[serde(rename = "run_tool_continue")]
RunToolContinue { tool_id: String, cursor: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "type")]
pub enum Response {
#[serde(rename = "pong")]
Pong,
#[serde(rename = "hello_ack")]
HelloAck {
#[serde(default)]
granted_capabilities: Vec<String>,
#[serde(default)]
server_version: String,
#[serde(default)]
supported_tiers: Vec<String>,
},
#[serde(rename = "tool_list")]
ToolListResponse { tools: serde_json::Value },
#[serde(rename = "tool_schema")]
ToolSchemaResponse { schema: serde_json::Value },
#[serde(rename = "tool_result")]
ToolResultResponse {
tool_id: String,
result: serde_json::Value,
success: bool,
dry_run: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
next_cursor: Option<String>,
},
#[serde(rename = "error")]
Error {
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
retryable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<serde_json::Value>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ping_serializes_with_type_tag() {
let j = serde_json::to_string(&Request::Ping).unwrap();
assert_eq!(j, r#"{"type":"ping"}"#);
}
#[test]
fn run_tool_roundtrip() {
let r = Request::RunTool {
tool_id: "anos:fs.read".into(),
args: serde_json::json!({"path": "/tmp/x"}),
dry_run: false,
};
let j = serde_json::to_string(&r).unwrap();
let back: Request = serde_json::from_str(&j).unwrap();
match back {
Request::RunTool {
tool_id, dry_run, ..
} => {
assert_eq!(tool_id, "anos:fs.read");
assert!(!dry_run);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn tool_list_response_carries_array() {
let r = Response::ToolListResponse {
tools: serde_json::json!([{"id": "a"}, {"id": "b"}]),
};
let j = serde_json::to_string(&r).unwrap();
assert!(j.contains("\"type\":\"tool_list\""));
let back: Response = serde_json::from_str(&j).unwrap();
match back {
Response::ToolListResponse { tools } => {
assert_eq!(tools.as_array().unwrap().len(), 2);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn error_deserializes_with_optional_fields_missing() {
let j = r#"{"type":"error","message":"boom"}"#;
let back: Response = serde_json::from_str(j).unwrap();
match back {
Response::Error {
message,
code,
retryable,
details,
} => {
assert_eq!(message, "boom");
assert!(code.is_none());
assert!(retryable.is_none());
assert!(details.is_none());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn run_tool_continue_round_trips() {
let r = Request::RunToolContinue {
tool_id: "celia:fhir.list_observations".into(),
cursor: "abc123".into(),
};
let j = serde_json::to_string(&r).unwrap();
assert!(j.contains(r#""type":"run_tool_continue""#));
let back: Request = serde_json::from_str(&j).unwrap();
match back {
Request::RunToolContinue { tool_id, cursor } => {
assert_eq!(tool_id, "celia:fhir.list_observations");
assert_eq!(cursor, "abc123");
}
_ => panic!("wrong variant: {j}"),
}
}
#[test]
fn tool_result_response_without_next_cursor_omits_field_on_wire() {
let r = Response::ToolResultResponse {
tool_id: "x".into(),
result: serde_json::json!({}),
success: true,
dry_run: false,
next_cursor: None,
};
let j = serde_json::to_string(&r).unwrap();
assert!(
!j.contains("next_cursor"),
"next_cursor: None must be omitted on the wire (back-compat), got: {j}"
);
}
#[test]
fn tool_result_response_with_next_cursor_includes_field_on_wire() {
let r = Response::ToolResultResponse {
tool_id: "x".into(),
result: serde_json::json!({}),
success: true,
dry_run: false,
next_cursor: Some("abc".into()),
};
let j = serde_json::to_string(&r).unwrap();
assert!(
j.contains(r#""next_cursor":"abc""#),
"next_cursor: Some(_) must serialize, got: {j}"
);
}
#[test]
fn tool_result_response_back_compat_default_when_field_missing() {
let j =
r#"{"type":"tool_result","tool_id":"x","result":{},"success":true,"dry_run":false}"#;
let back: Response = serde_json::from_str(j).unwrap();
match back {
Response::ToolResultResponse { next_cursor, .. } => {
assert!(next_cursor.is_none(), "missing field must default to None");
}
_ => panic!("wrong variant"),
}
}
#[test]
fn err_cursor_codes_distinct_from_existing_families() {
assert_eq!(ERR_CURSOR_EXPIRED, 1020);
assert_eq!(ERR_CURSOR_INVALID, 1021);
assert_ne!(ERR_CURSOR_EXPIRED, ERR_CURSOR_INVALID);
assert_ne!(ERR_CURSOR_EXPIRED, ERR_AUDIENCE_MISMATCH);
assert_ne!(ERR_CURSOR_INVALID, ERR_RATE_LIMITED);
}
}