Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 297 additions & 6 deletions agent-client-protocol-schema/src/v2/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
}

impl UpdateSessionNotification {
/// Builds [`SessionNotification`] with the required notification fields set; optional fields start unset or empty.

Check warning on line 69 in agent-client-protocol-schema/src/v2/client.rs

View workflow job for this annotation

GitHub Actions / Build

unresolved link to `SessionNotification`
#[must_use]
pub fn new(session_id: impl Into<SessionId>, update: SessionUpdate) -> Self {
Self {
Expand Down Expand Up @@ -1259,7 +1259,7 @@

// Permission

/// Request for user permission to execute a tool call.
/// Request for user permission to proceed with an operation.
///
/// Sent when the agent needs authorization before performing a sensitive operation.
///
Expand All @@ -1273,8 +1273,27 @@
pub struct RequestPermissionRequest {
/// The session ID for this request.
pub session_id: SessionId,
/// Details about the tool call requiring permission.
pub tool_call: ToolCallUpdate,
/// Human-readable title for the permission prompt.
///
/// This title is specific to the permission prompt and does not update any
/// subject's displayed title.
pub title: String,
/// Optional human-readable explanation of why permission is needed.
///
/// This text is specific to the permission prompt and does not update any
/// subject's displayed content. Omitted or `null` both mean no separate
/// permission description was provided.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub description: Option<String>,
/// Optional structured context about the operation requiring permission.
///
/// Omitted or `null` both mean no structured subject was provided.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub subject: Option<RequestPermissionSubject>,
/// Available permission options for the user to choose from.
#[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
Expand All @@ -1296,17 +1315,33 @@
#[must_use]
pub fn new(
session_id: impl Into<SessionId>,
tool_call: ToolCallUpdate,
title: impl Into<String>,
options: Vec<PermissionOption>,
) -> Self {
Self {
session_id: session_id.into(),
tool_call,
title: title.into(),
description: None,
subject: None,
options,
meta: None,
}
}

/// Sets or clears the optional `description` field.
#[must_use]
pub fn description(mut self, description: impl IntoOption<String>) -> Self {
self.description = description.into_option();
self
}

/// Sets or clears the optional `subject` field.
#[must_use]
pub fn subject(mut self, subject: impl IntoOption<RequestPermissionSubject>) -> Self {
self.subject = subject.into_option();
self
}

/// The _meta property is reserved by ACP to allow clients and agents to attach additional
/// metadata to their interactions. Implementations MUST NOT make assumptions about values at
/// these keys.
Expand All @@ -1319,6 +1354,119 @@
}
}

/// The operation requiring permission.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
#[schemars(extend("discriminator" = {"propertyName": "type"}))]
#[non_exhaustive]
pub enum RequestPermissionSubject {
/// Permission is requested before executing a tool call.
ToolCall(Box<ToolCallPermissionSubject>),
/// Custom or future permission subject.
///
/// Values beginning with `_` are reserved for implementation-specific
/// extensions. Unknown values that do not begin with `_` are reserved for
/// future ACP variants.
///
/// Clients that do not understand this subject type should preserve the raw
/// payload when storing, replaying, proxying, or forwarding permission
/// requests, and otherwise display a generic permission prompt or decline it
/// according to policy.
#[serde(untagged)]
Other(OtherRequestPermissionSubject),
}

impl From<ToolCallPermissionSubject> for RequestPermissionSubject {
fn from(subject: ToolCallPermissionSubject) -> Self {
Self::ToolCall(Box::new(subject))
}
}

impl From<ToolCallUpdate> for RequestPermissionSubject {
fn from(tool_call: ToolCallUpdate) -> Self {
ToolCallPermissionSubject::new(tool_call).into()
}
}

/// Permission request details for a tool call.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ToolCallPermissionSubject {
/// Details about the tool call requiring permission.
pub tool_call: ToolCallUpdate,
}

impl ToolCallPermissionSubject {
/// Builds [`ToolCallPermissionSubject`] with the required fields set.
#[must_use]
pub fn new(tool_call: ToolCallUpdate) -> Self {
Self { tool_call }
}
}

/// Custom or future permission subject payload.
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)]
#[schemars(inline)]
#[schemars(transform = other_request_permission_subject_schema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OtherRequestPermissionSubject {
/// Custom or future permission subject type.
///
/// Values beginning with `_` are reserved for implementation-specific
/// extensions. Unknown values that do not begin with `_` are reserved for
/// future ACP variants.
#[serde(rename = "type")]
pub type_: String,
/// Additional fields from the unknown permission subject payload.
#[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>,
}

impl OtherRequestPermissionSubject {
/// Builds [`OtherRequestPermissionSubject`] from an unknown discriminator and preserves the remaining extension fields.
#[must_use]
pub fn new(type_: impl Into<String>, mut fields: BTreeMap<String, serde_json::Value>) -> Self {
fields.remove("type");
Self {
type_: type_.into(),
fields,
}
}
}

impl<'de> Deserialize<'de> for OtherRequestPermissionSubject {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
let type_ = fields
.remove("type")
.ok_or_else(|| serde::de::Error::missing_field("type"))?;
let serde_json::Value::String(type_) = type_ else {
return Err(serde::de::Error::custom("`type` must be a string"));
};

if is_known_request_permission_subject_type(&type_) {
return Err(serde::de::Error::custom(format!(
"known request permission subject `{type_}` did not match its schema"
)));
}

Ok(Self { type_, fields })
}
}

fn is_known_request_permission_subject_type(type_: &str) -> bool {
matches!(type_, "tool_call")
}

fn other_request_permission_subject_schema(schema: &mut Schema) {
super::schema_util::reject_known_string_discriminators(schema, "type", &["tool_call"]);
}

/// An option presented to the user when requesting permission.
#[serde_as]
#[skip_serializing_none]
Expand Down Expand Up @@ -1924,7 +2072,7 @@
#[schemars(inline)]
#[non_exhaustive]
pub enum AgentRequest {
/// Requests permission from the user for a tool call operation.
/// Requests permission from the user for an operation.
///
/// Called by the agent when it needs user authorization before executing
/// a potentially sensitive operation. The client should present the options
Expand Down Expand Up @@ -2641,6 +2789,149 @@
assert!(matches!(roundtripped, AvailableCommandInput::Text(_)));
}

#[test]
fn request_permission_subject_tool_call_uses_type_discriminator() {
use serde_json::json;

let subject = RequestPermissionSubject::from(ToolCallUpdate::new("call_001"));

let json = serde_json::to_value(&subject).unwrap();
assert_eq!(
json,
json!({
"type": "tool_call",
"toolCall": {
"toolCallId": "call_001"
}
})
);

let roundtripped: RequestPermissionSubject = serde_json::from_value(json).unwrap();
assert!(matches!(
roundtripped,
RequestPermissionSubject::ToolCall(_)
));
}

#[test]
fn request_permission_subject_preserves_unknown_variant() {
use serde_json::json;

let subject: RequestPermissionSubject = serde_json::from_value(json!({
"type": "_review",
"reason": "needs-review",
"retryAfterSeconds": 30
}))
.unwrap();

let RequestPermissionSubject::Other(unknown) = subject else {
panic!("expected unknown permission subject");
};

assert_eq!(unknown.type_, "_review");
assert_eq!(unknown.fields.get("reason"), Some(&json!("needs-review")));
assert_eq!(unknown.fields.get("retryAfterSeconds"), Some(&json!(30)));
assert_eq!(
serde_json::to_value(RequestPermissionSubject::Other(unknown)).unwrap(),
json!({
"type": "_review",
"reason": "needs-review",
"retryAfterSeconds": 30
})
);
}

#[test]
fn request_permission_subject_unknown_does_not_hide_malformed_known_variant() {
use serde_json::json;

assert!(
serde_json::from_value::<RequestPermissionSubject>(json!({
"type": "tool_call"
}))
.is_err()
);
assert!(
serde_json::from_value::<RequestPermissionSubject>(json!({
"type": 1
}))
.is_err()
);
}

#[test]
fn request_permission_title_and_description_are_separate_from_tool_call_content() {
use serde_json::json;

let request =
RequestPermissionRequest::new("sess_abc123def456", "Approve file edit?", Vec::new())
.description("Allow this tool to edit src/main.rs?")
.subject(RequestPermissionSubject::from(ToolCallUpdate::new(
"call_001",
)));

assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"sessionId": "sess_abc123def456",
"title": "Approve file edit?",
"description": "Allow this tool to edit src/main.rs?",
"subject": {
"type": "tool_call",
"toolCall": {
"toolCallId": "call_001"
}
},
"options": []
})
);
}

#[test]
fn request_permission_requires_title_and_allows_missing_subject() {
use serde_json::json;

let request = RequestPermissionRequest::new(
"sess_abc123def456",
"Approve elevated permissions?",
Vec::new(),
);

assert_eq!(
serde_json::to_value(request).unwrap(),
json!({
"sessionId": "sess_abc123def456",
"title": "Approve elevated permissions?",
"options": []
})
);

let missing_subject: RequestPermissionRequest = serde_json::from_value(json!({
"sessionId": "sess_abc123def456",
"title": "Approve elevated permissions?",
"options": []
}))
.unwrap();
assert!(missing_subject.subject.is_none());

let null_subject: RequestPermissionRequest = serde_json::from_value(json!({
"sessionId": "sess_abc123def456",
"title": "Approve elevated permissions?",
"subject": null,
"options": []
}))
.unwrap();
assert!(null_subject.subject.is_none());

assert!(
serde_json::from_value::<RequestPermissionRequest>(json!({
"sessionId": "sess_abc123def456",
"options": []
}))
.is_err()
);
}

#[test]
fn request_permission_outcome_preserves_unknown_variant() {
use serde_json::json;
Expand Down
Loading