diff --git a/src/agent-client-protocol/CHANGELOG.md b/src/agent-client-protocol/CHANGELOG.md index 7b02423..c270618 100644 --- a/src/agent-client-protocol/CHANGELOG.md +++ b/src/agent-client-protocol/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- *(unstable)* Add JSON-RPC support for elicitation requests and notifications. + ## [0.13.1](https://github.com/agentclientprotocol/rust-sdk/compare/v0.13.0...v0.13.1) - 2026-06-01 ### Added diff --git a/src/agent-client-protocol/Cargo.toml b/src/agent-client-protocol/Cargo.toml index 0461592..7e995bb 100644 --- a/src/agent-client-protocol/Cargo.toml +++ b/src/agent-client-protocol/Cargo.toml @@ -22,6 +22,7 @@ default = [] unstable = [ "unstable_auth_methods", "unstable_boolean_config", + "unstable_elicitation", "unstable_mcp_over_acp", "unstable_message_id", "unstable_session_delete", @@ -30,6 +31,7 @@ unstable = [ ] unstable_auth_methods = ["agent-client-protocol-schema/unstable_auth_methods"] unstable_boolean_config = ["agent-client-protocol-schema/unstable_boolean_config"] +unstable_elicitation = ["agent-client-protocol-schema/unstable_elicitation"] unstable_mcp_over_acp = ["agent-client-protocol-schema/unstable_mcp_over_acp"] unstable_message_id = ["agent-client-protocol-schema/unstable_message_id"] unstable_session_delete = ["agent-client-protocol-schema/unstable_session_delete"] diff --git a/src/agent-client-protocol/src/schema/agent_to_client/notifications.rs b/src/agent-client-protocol/src/schema/agent_to_client/notifications.rs index a321593..22c8c5f 100644 --- a/src/agent-client-protocol/src/schema/agent_to_client/notifications.rs +++ b/src/agent-client-protocol/src/schema/agent_to_client/notifications.rs @@ -1,3 +1,7 @@ +#[cfg(feature = "unstable_elicitation")] +use crate::schema::CompleteElicitationNotification; use crate::schema::SessionNotification; impl_jsonrpc_notification!(SessionNotification, "session/update"); +#[cfg(feature = "unstable_elicitation")] +impl_jsonrpc_notification!(CompleteElicitationNotification, "elicitation/complete"); diff --git a/src/agent-client-protocol/src/schema/agent_to_client/requests.rs b/src/agent-client-protocol/src/schema/agent_to_client/requests.rs index b258aed..d3bbb5a 100644 --- a/src/agent-client-protocol/src/schema/agent_to_client/requests.rs +++ b/src/agent-client-protocol/src/schema/agent_to_client/requests.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "unstable_elicitation")] +use crate::schema::{CreateElicitationRequest, CreateElicitationResponse}; use crate::schema::{ CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest, KillTerminalResponse, ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest, ReleaseTerminalResponse, @@ -42,3 +44,9 @@ impl_jsonrpc_request!( "terminal/wait_for_exit" ); impl_jsonrpc_request!(KillTerminalRequest, KillTerminalResponse, "terminal/kill"); +#[cfg(feature = "unstable_elicitation")] +impl_jsonrpc_request!( + CreateElicitationRequest, + CreateElicitationResponse, + "elicitation/create" +); diff --git a/src/agent-client-protocol/src/schema/enum_impls.rs b/src/agent-client-protocol/src/schema/enum_impls.rs index d605c7e..37d1c3f 100644 --- a/src/agent-client-protocol/src/schema/enum_impls.rs +++ b/src/agent-client-protocol/src/schema/enum_impls.rs @@ -72,6 +72,8 @@ impl_jsonrpc_request_enum!(AgentRequest { ReleaseTerminalRequest => "terminal/release", WaitForTerminalExitRequest => "terminal/wait_for_exit", KillTerminalRequest => "terminal/kill", + #[cfg(feature = "unstable_elicitation")] + CreateElicitationRequest => "elicitation/create", #[cfg(feature = "unstable_mcp_over_acp")] ConnectMcpRequest => "mcp/connect", #[cfg(feature = "unstable_mcp_over_acp")] @@ -90,6 +92,8 @@ impl_jsonrpc_response_enum!(ClientResponse { ReleaseTerminalResponse => "terminal/release", WaitForTerminalExitResponse => "terminal/wait_for_exit", KillTerminalResponse => "terminal/kill", + #[cfg(feature = "unstable_elicitation")] + CreateElicitationResponse => "elicitation/create", #[cfg(feature = "unstable_mcp_over_acp")] ConnectMcpResponse => "mcp/connect", #[cfg(feature = "unstable_mcp_over_acp")] @@ -101,6 +105,8 @@ impl_jsonrpc_response_enum!(ClientResponse { impl_jsonrpc_notification_enum!(AgentNotification { SessionNotification => "session/update", + #[cfg(feature = "unstable_elicitation")] + CompleteElicitationNotification => "elicitation/complete", #[cfg(feature = "unstable_mcp_over_acp")] MessageMcpNotification => "mcp/message", [ext] ExtNotification, diff --git a/src/agent-client-protocol/src/schema/v2_impls.rs b/src/agent-client-protocol/src/schema/v2_impls.rs index c944c79..03384a7 100644 --- a/src/agent-client-protocol/src/schema/v2_impls.rs +++ b/src/agent-client-protocol/src/schema/v2_impls.rs @@ -302,6 +302,12 @@ impl_v2_jsonrpc_request!( v2::KillTerminalResponse, "terminal/kill" ); +#[cfg(feature = "unstable_elicitation")] +impl_v2_jsonrpc_request!( + v2::CreateElicitationRequest, + v2::CreateElicitationResponse, + "elicitation/create" +); #[cfg(feature = "unstable_mcp_over_acp")] impl_v2_jsonrpc_request!(v2::ConnectMcpRequest, v2::ConnectMcpResponse, "mcp/connect"); #[cfg(feature = "unstable_mcp_over_acp")] @@ -312,6 +318,8 @@ impl_v2_jsonrpc_request!( ); impl_v2_jsonrpc_notification!(v2::SessionNotification, "session/update"); +#[cfg(feature = "unstable_elicitation")] +impl_v2_jsonrpc_notification!(v2::CompleteElicitationNotification, "elicitation/complete"); impl_v2_jsonrpc_request_enum!(v2::ClientRequest { InitializeRequest => "initialize", @@ -369,6 +377,8 @@ impl_v2_jsonrpc_request_enum!(v2::AgentRequest { ReleaseTerminalRequest => "terminal/release", WaitForTerminalExitRequest => "terminal/wait_for_exit", KillTerminalRequest => "terminal/kill", + #[cfg(feature = "unstable_elicitation")] + CreateElicitationRequest => "elicitation/create", #[cfg(feature = "unstable_mcp_over_acp")] ConnectMcpRequest => "mcp/connect", #[cfg(feature = "unstable_mcp_over_acp")] @@ -387,6 +397,8 @@ impl_v2_jsonrpc_response_enum!(v2::ClientResponse { ReleaseTerminalResponse => "terminal/release", WaitForTerminalExitResponse => "terminal/wait_for_exit", KillTerminalResponse => "terminal/kill", + #[cfg(feature = "unstable_elicitation")] + CreateElicitationResponse => "elicitation/create", #[cfg(feature = "unstable_mcp_over_acp")] ConnectMcpResponse => "mcp/connect", #[cfg(feature = "unstable_mcp_over_acp")] @@ -398,6 +410,8 @@ impl_v2_jsonrpc_response_enum!(v2::ClientResponse { impl_v2_jsonrpc_notification_enum!(v2::AgentNotification { SessionNotification => "session/update", + #[cfg(feature = "unstable_elicitation")] + CompleteElicitationNotification => "elicitation/complete", #[cfg(feature = "unstable_mcp_over_acp")] MessageMcpNotification => "mcp/message", [ext] ExtNotification, diff --git a/src/agent-client-protocol/tests/schema_elicitation.rs b/src/agent-client-protocol/tests/schema_elicitation.rs new file mode 100644 index 0000000..49e1c33 --- /dev/null +++ b/src/agent-client-protocol/tests/schema_elicitation.rs @@ -0,0 +1,269 @@ +#![cfg(feature = "unstable_elicitation")] + +use agent_client_protocol::schema::{ + AgentNotification, AgentRequest, ClientCapabilities, ClientResponse, + CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, + ElicitationAction, ElicitationCapabilities, ElicitationFormCapabilities, ElicitationFormMode, + ElicitationSchema, ElicitationSessionScope, ElicitationUrlCapabilities, Error, ErrorCode, + UrlElicitationRequiredData, UrlElicitationRequiredItem, +}; +use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; +use serde::Serialize; +use serde_json::{Value, json}; + +fn json_value(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(Error::into_internal_error) +} + +fn form_request() -> CreateElicitationRequest { + CreateElicitationRequest::new( + ElicitationFormMode::new( + ElicitationSessionScope::new("sess_abc123"), + ElicitationSchema::new().string("name", true), + ), + "Please enter your name", + ) +} + +fn assert_request_response_pair>() {} +fn assert_notification() {} + +#[test] +fn create_elicitation_request_has_jsonrpc_metadata() { + let request = form_request(); + + assert_eq!(request.method(), "elicitation/create"); + assert!(CreateElicitationRequest::matches_method( + "elicitation/create" + )); + assert!(!CreateElicitationRequest::matches_method("session/prompt")); + + let untyped = request.to_untyped_message().unwrap(); + assert_eq!(untyped.method, "elicitation/create"); + assert_eq!(untyped.params["mode"], "form"); + assert_eq!(untyped.params["sessionId"], "sess_abc123"); + + let parsed = + CreateElicitationRequest::parse_message("elicitation/create", &untyped.params).unwrap(); + assert!(matches!( + parsed.mode, + agent_client_protocol::schema::ElicitationMode::Form(_) + )); + + assert_request_response_pair::(); +} + +#[test] +fn elicitation_participates_in_agent_request_enum() { + let request = AgentRequest::CreateElicitationRequest(form_request()); + + assert_eq!(request.method(), "elicitation/create"); + assert!(AgentRequest::matches_method("elicitation/create")); + + let parsed = + AgentRequest::parse_message("elicitation/create", &json_value(form_request()).unwrap()) + .unwrap(); + assert!(matches!(parsed, AgentRequest::CreateElicitationRequest(_))); +} + +#[test] +fn create_elicitation_response_round_trips_json() { + let value = CreateElicitationResponse::new(ElicitationAction::Decline) + .into_json("elicitation/create") + .unwrap(); + assert_eq!(value, json!({ "action": "decline" })); + + let parsed = CreateElicitationResponse::from_value("elicitation/create", value).unwrap(); + assert!(matches!(parsed.action, ElicitationAction::Decline)); + + let enum_response = + ClientResponse::from_value("elicitation/create", json!({ "action": "cancel" })).unwrap(); + assert!(matches!( + enum_response, + ClientResponse::CreateElicitationResponse(_) + )); +} + +#[test] +fn complete_elicitation_notification_has_jsonrpc_metadata() { + assert_notification::(); + + let notification = CompleteElicitationNotification::new("elicit_1"); + assert_eq!(notification.method(), "elicitation/complete"); + assert!(CompleteElicitationNotification::matches_method( + "elicitation/complete" + )); + assert!(!CompleteElicitationNotification::matches_method( + "session/update" + )); + + let untyped = notification.to_untyped_message().unwrap(); + assert_eq!(untyped.method, "elicitation/complete"); + assert_eq!(untyped.params, json!({ "elicitationId": "elicit_1" })); + + let parsed = AgentNotification::parse_message("elicitation/complete", &untyped.params).unwrap(); + assert!(matches!( + parsed, + AgentNotification::CompleteElicitationNotification(_) + )); +} + +#[test] +fn client_capabilities_can_declare_elicitation_modes() { + let capabilities = ClientCapabilities::new().elicitation( + ElicitationCapabilities::new() + .form(ElicitationFormCapabilities::new()) + .url(ElicitationUrlCapabilities::new()), + ); + + let value = json_value(capabilities).unwrap(); + assert_eq!(value["elicitation"], json!({ "form": {}, "url": {} })); + + let parsed: ClientCapabilities = serde_json::from_value(json!({ "elicitation": {} })).unwrap(); + assert!(parsed.elicitation.is_some()); +} + +#[test] +fn url_elicitation_required_error_helper_is_available() { + let data = UrlElicitationRequiredData::new(vec![UrlElicitationRequiredItem::new( + "elicit_1", + "https://example.com/connect", + "Connect your account", + )]); + let error = Error::url_elicitation_required().data(json_value(data).unwrap()); + + assert_eq!(error.code, ErrorCode::UrlElicitationRequired); + assert_eq!( + error.data.unwrap(), + json!({ + "elicitations": [{ + "mode": "url", + "elicitationId": "elicit_1", + "url": "https://example.com/connect", + "message": "Connect your account" + }] + }) + ); +} + +#[cfg(feature = "unstable_protocol_v2")] +#[test] +fn protocol_v2_elicitation_variants_are_jsonrpc_mapped() -> Result<(), Error> { + use agent_client_protocol::schema::v2; + + let request = v2::CreateElicitationRequest::new( + v2::ElicitationFormMode::new( + v2::ElicitationSessionScope::new("sess_abc123"), + v2::ElicitationSchema::new().string("name", true), + ), + "Please enter your name", + ); + + let parsed_request = + v2::AgentRequest::parse_message("elicitation/create", &json_value(request.clone())?)?; + assert!(matches!( + parsed_request, + v2::AgentRequest::CreateElicitationRequest(_) + )); + + let parsed_response = + v2::ClientResponse::from_value("elicitation/create", json!({ "action": "decline" }))?; + assert!(matches!( + parsed_response, + v2::ClientResponse::CreateElicitationResponse(_) + )); + + let notification = v2::CompleteElicitationNotification::new("elicit_1"); + let parsed_notification = + v2::AgentNotification::parse_message("elicitation/complete", &json_value(notification)?)?; + assert!(matches!( + parsed_notification, + v2::AgentNotification::CompleteElicitationNotification(_) + )); + + Ok(()) +} + +#[cfg(feature = "unstable_protocol_v2")] +#[tokio::test(flavor = "current_thread")] +async fn v2_agent_can_elicit_from_v1_client() -> Result<(), Error> { + use agent_client_protocol::schema::{self, ProtocolVersion, v2}; + use agent_client_protocol::{Agent, Client}; + use std::collections::BTreeMap; + + let agent = Agent + .v2() + .on_receive_request( + async |initialize: v2::InitializeRequest, responder, _cx| { + assert_eq!(initialize.protocol_version, ProtocolVersion::V2); + responder.respond(v2::InitializeResponse::new(ProtocolVersion::V2)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async |_prompt: v2::PromptRequest, responder, cx| { + let request = v2::CreateElicitationRequest::new( + v2::ElicitationFormMode::new( + v2::ElicitationSessionScope::new("sess_abc123"), + v2::ElicitationSchema::new().string("name", true), + ), + "Please enter your name", + ); + + cx.send_request(request) + .on_receiving_result(async move |result| { + let response = result?; + let v2::ElicitationAction::Accept(action) = response.action else { + return Err(Error::invalid_params().data("expected accept action")); + }; + let content = action.content.ok_or_else(|| { + Error::invalid_params().data("expected response content") + })?; + assert_eq!( + content.get("name"), + Some(&v2::ElicitationContentValue::String("Ada".into())) + ); + responder.respond(v2::PromptResponse::new(v2::StopReason::EndTurn)) + })?; + + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ); + + Client + .builder() + .on_receive_request( + async |request: CreateElicitationRequest, responder, _cx| { + assert_eq!(request.method(), "elicitation/create"); + assert!(matches!( + request.mode, + schema::ElicitationMode::Form(schema::ElicitationFormMode { .. }) + )); + + let content = BTreeMap::from([("name".to_string(), "Ada".into())]); + responder.respond(CreateElicitationResponse::new(ElicitationAction::Accept( + schema::ElicitationAcceptAction::new().content(content), + ))) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(agent, async |cx| { + let initialize = cx + .send_request(schema::InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; + assert_eq!(initialize.protocol_version, ProtocolVersion::V1); + + let prompt = cx + .send_request(schema::PromptRequest::new( + "sess_abc123", + vec!["continue".into()], + )) + .block_task() + .await?; + assert_eq!(prompt.stop_reason, schema::StopReason::EndTurn); + Ok(()) + }) + .await +}