Skip to content

Commit 740db05

Browse files
authored
feat(acp): add unstable elicitation support (#197)
1 parent d0bc8de commit 740db05

7 files changed

Lines changed: 307 additions & 0 deletions

File tree

src/agent-client-protocol/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- *(unstable)* Add JSON-RPC support for elicitation requests and notifications.
8+
59
## [0.13.1](https://github.com/agentclientprotocol/rust-sdk/compare/v0.13.0...v0.13.1) - 2026-06-01
610

711
### Added

src/agent-client-protocol/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ default = []
2222
unstable = [
2323
"unstable_auth_methods",
2424
"unstable_boolean_config",
25+
"unstable_elicitation",
2526
"unstable_mcp_over_acp",
2627
"unstable_message_id",
2728
"unstable_session_delete",
@@ -30,6 +31,7 @@ unstable = [
3031
]
3132
unstable_auth_methods = ["agent-client-protocol-schema/unstable_auth_methods"]
3233
unstable_boolean_config = ["agent-client-protocol-schema/unstable_boolean_config"]
34+
unstable_elicitation = ["agent-client-protocol-schema/unstable_elicitation"]
3335
unstable_mcp_over_acp = ["agent-client-protocol-schema/unstable_mcp_over_acp"]
3436
unstable_message_id = ["agent-client-protocol-schema/unstable_message_id"]
3537
unstable_session_delete = ["agent-client-protocol-schema/unstable_session_delete"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
#[cfg(feature = "unstable_elicitation")]
2+
use crate::schema::CompleteElicitationNotification;
13
use crate::schema::SessionNotification;
24

35
impl_jsonrpc_notification!(SessionNotification, "session/update");
6+
#[cfg(feature = "unstable_elicitation")]
7+
impl_jsonrpc_notification!(CompleteElicitationNotification, "elicitation/complete");

src/agent-client-protocol/src/schema/agent_to_client/requests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[cfg(feature = "unstable_elicitation")]
2+
use crate::schema::{CreateElicitationRequest, CreateElicitationResponse};
13
use crate::schema::{
24
CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest, KillTerminalResponse,
35
ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
@@ -42,3 +44,9 @@ impl_jsonrpc_request!(
4244
"terminal/wait_for_exit"
4345
);
4446
impl_jsonrpc_request!(KillTerminalRequest, KillTerminalResponse, "terminal/kill");
47+
#[cfg(feature = "unstable_elicitation")]
48+
impl_jsonrpc_request!(
49+
CreateElicitationRequest,
50+
CreateElicitationResponse,
51+
"elicitation/create"
52+
);

src/agent-client-protocol/src/schema/enum_impls.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ impl_jsonrpc_request_enum!(AgentRequest {
7272
ReleaseTerminalRequest => "terminal/release",
7373
WaitForTerminalExitRequest => "terminal/wait_for_exit",
7474
KillTerminalRequest => "terminal/kill",
75+
#[cfg(feature = "unstable_elicitation")]
76+
CreateElicitationRequest => "elicitation/create",
7577
#[cfg(feature = "unstable_mcp_over_acp")]
7678
ConnectMcpRequest => "mcp/connect",
7779
#[cfg(feature = "unstable_mcp_over_acp")]
@@ -90,6 +92,8 @@ impl_jsonrpc_response_enum!(ClientResponse {
9092
ReleaseTerminalResponse => "terminal/release",
9193
WaitForTerminalExitResponse => "terminal/wait_for_exit",
9294
KillTerminalResponse => "terminal/kill",
95+
#[cfg(feature = "unstable_elicitation")]
96+
CreateElicitationResponse => "elicitation/create",
9397
#[cfg(feature = "unstable_mcp_over_acp")]
9498
ConnectMcpResponse => "mcp/connect",
9599
#[cfg(feature = "unstable_mcp_over_acp")]
@@ -101,6 +105,8 @@ impl_jsonrpc_response_enum!(ClientResponse {
101105

102106
impl_jsonrpc_notification_enum!(AgentNotification {
103107
SessionNotification => "session/update",
108+
#[cfg(feature = "unstable_elicitation")]
109+
CompleteElicitationNotification => "elicitation/complete",
104110
#[cfg(feature = "unstable_mcp_over_acp")]
105111
MessageMcpNotification => "mcp/message",
106112
[ext] ExtNotification,

src/agent-client-protocol/src/schema/v2_impls.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,12 @@ impl_v2_jsonrpc_request!(
302302
v2::KillTerminalResponse,
303303
"terminal/kill"
304304
);
305+
#[cfg(feature = "unstable_elicitation")]
306+
impl_v2_jsonrpc_request!(
307+
v2::CreateElicitationRequest,
308+
v2::CreateElicitationResponse,
309+
"elicitation/create"
310+
);
305311
#[cfg(feature = "unstable_mcp_over_acp")]
306312
impl_v2_jsonrpc_request!(v2::ConnectMcpRequest, v2::ConnectMcpResponse, "mcp/connect");
307313
#[cfg(feature = "unstable_mcp_over_acp")]
@@ -312,6 +318,8 @@ impl_v2_jsonrpc_request!(
312318
);
313319

314320
impl_v2_jsonrpc_notification!(v2::SessionNotification, "session/update");
321+
#[cfg(feature = "unstable_elicitation")]
322+
impl_v2_jsonrpc_notification!(v2::CompleteElicitationNotification, "elicitation/complete");
315323

316324
impl_v2_jsonrpc_request_enum!(v2::ClientRequest {
317325
InitializeRequest => "initialize",
@@ -369,6 +377,8 @@ impl_v2_jsonrpc_request_enum!(v2::AgentRequest {
369377
ReleaseTerminalRequest => "terminal/release",
370378
WaitForTerminalExitRequest => "terminal/wait_for_exit",
371379
KillTerminalRequest => "terminal/kill",
380+
#[cfg(feature = "unstable_elicitation")]
381+
CreateElicitationRequest => "elicitation/create",
372382
#[cfg(feature = "unstable_mcp_over_acp")]
373383
ConnectMcpRequest => "mcp/connect",
374384
#[cfg(feature = "unstable_mcp_over_acp")]
@@ -387,6 +397,8 @@ impl_v2_jsonrpc_response_enum!(v2::ClientResponse {
387397
ReleaseTerminalResponse => "terminal/release",
388398
WaitForTerminalExitResponse => "terminal/wait_for_exit",
389399
KillTerminalResponse => "terminal/kill",
400+
#[cfg(feature = "unstable_elicitation")]
401+
CreateElicitationResponse => "elicitation/create",
390402
#[cfg(feature = "unstable_mcp_over_acp")]
391403
ConnectMcpResponse => "mcp/connect",
392404
#[cfg(feature = "unstable_mcp_over_acp")]
@@ -398,6 +410,8 @@ impl_v2_jsonrpc_response_enum!(v2::ClientResponse {
398410

399411
impl_v2_jsonrpc_notification_enum!(v2::AgentNotification {
400412
SessionNotification => "session/update",
413+
#[cfg(feature = "unstable_elicitation")]
414+
CompleteElicitationNotification => "elicitation/complete",
401415
#[cfg(feature = "unstable_mcp_over_acp")]
402416
MessageMcpNotification => "mcp/message",
403417
[ext] ExtNotification,
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#![cfg(feature = "unstable_elicitation")]
2+
3+
use agent_client_protocol::schema::{
4+
AgentNotification, AgentRequest, ClientCapabilities, ClientResponse,
5+
CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse,
6+
ElicitationAction, ElicitationCapabilities, ElicitationFormCapabilities, ElicitationFormMode,
7+
ElicitationSchema, ElicitationSessionScope, ElicitationUrlCapabilities, Error, ErrorCode,
8+
UrlElicitationRequiredData, UrlElicitationRequiredItem,
9+
};
10+
use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
11+
use serde::Serialize;
12+
use serde_json::{Value, json};
13+
14+
fn json_value(value: impl Serialize) -> Result<Value, Error> {
15+
serde_json::to_value(value).map_err(Error::into_internal_error)
16+
}
17+
18+
fn form_request() -> CreateElicitationRequest {
19+
CreateElicitationRequest::new(
20+
ElicitationFormMode::new(
21+
ElicitationSessionScope::new("sess_abc123"),
22+
ElicitationSchema::new().string("name", true),
23+
),
24+
"Please enter your name",
25+
)
26+
}
27+
28+
fn assert_request_response_pair<T: JsonRpcRequest<Response = CreateElicitationResponse>>() {}
29+
fn assert_notification<T: JsonRpcNotification>() {}
30+
31+
#[test]
32+
fn create_elicitation_request_has_jsonrpc_metadata() {
33+
let request = form_request();
34+
35+
assert_eq!(request.method(), "elicitation/create");
36+
assert!(CreateElicitationRequest::matches_method(
37+
"elicitation/create"
38+
));
39+
assert!(!CreateElicitationRequest::matches_method("session/prompt"));
40+
41+
let untyped = request.to_untyped_message().unwrap();
42+
assert_eq!(untyped.method, "elicitation/create");
43+
assert_eq!(untyped.params["mode"], "form");
44+
assert_eq!(untyped.params["sessionId"], "sess_abc123");
45+
46+
let parsed =
47+
CreateElicitationRequest::parse_message("elicitation/create", &untyped.params).unwrap();
48+
assert!(matches!(
49+
parsed.mode,
50+
agent_client_protocol::schema::ElicitationMode::Form(_)
51+
));
52+
53+
assert_request_response_pair::<CreateElicitationRequest>();
54+
}
55+
56+
#[test]
57+
fn elicitation_participates_in_agent_request_enum() {
58+
let request = AgentRequest::CreateElicitationRequest(form_request());
59+
60+
assert_eq!(request.method(), "elicitation/create");
61+
assert!(AgentRequest::matches_method("elicitation/create"));
62+
63+
let parsed =
64+
AgentRequest::parse_message("elicitation/create", &json_value(form_request()).unwrap())
65+
.unwrap();
66+
assert!(matches!(parsed, AgentRequest::CreateElicitationRequest(_)));
67+
}
68+
69+
#[test]
70+
fn create_elicitation_response_round_trips_json() {
71+
let value = CreateElicitationResponse::new(ElicitationAction::Decline)
72+
.into_json("elicitation/create")
73+
.unwrap();
74+
assert_eq!(value, json!({ "action": "decline" }));
75+
76+
let parsed = CreateElicitationResponse::from_value("elicitation/create", value).unwrap();
77+
assert!(matches!(parsed.action, ElicitationAction::Decline));
78+
79+
let enum_response =
80+
ClientResponse::from_value("elicitation/create", json!({ "action": "cancel" })).unwrap();
81+
assert!(matches!(
82+
enum_response,
83+
ClientResponse::CreateElicitationResponse(_)
84+
));
85+
}
86+
87+
#[test]
88+
fn complete_elicitation_notification_has_jsonrpc_metadata() {
89+
assert_notification::<CompleteElicitationNotification>();
90+
91+
let notification = CompleteElicitationNotification::new("elicit_1");
92+
assert_eq!(notification.method(), "elicitation/complete");
93+
assert!(CompleteElicitationNotification::matches_method(
94+
"elicitation/complete"
95+
));
96+
assert!(!CompleteElicitationNotification::matches_method(
97+
"session/update"
98+
));
99+
100+
let untyped = notification.to_untyped_message().unwrap();
101+
assert_eq!(untyped.method, "elicitation/complete");
102+
assert_eq!(untyped.params, json!({ "elicitationId": "elicit_1" }));
103+
104+
let parsed = AgentNotification::parse_message("elicitation/complete", &untyped.params).unwrap();
105+
assert!(matches!(
106+
parsed,
107+
AgentNotification::CompleteElicitationNotification(_)
108+
));
109+
}
110+
111+
#[test]
112+
fn client_capabilities_can_declare_elicitation_modes() {
113+
let capabilities = ClientCapabilities::new().elicitation(
114+
ElicitationCapabilities::new()
115+
.form(ElicitationFormCapabilities::new())
116+
.url(ElicitationUrlCapabilities::new()),
117+
);
118+
119+
let value = json_value(capabilities).unwrap();
120+
assert_eq!(value["elicitation"], json!({ "form": {}, "url": {} }));
121+
122+
let parsed: ClientCapabilities = serde_json::from_value(json!({ "elicitation": {} })).unwrap();
123+
assert!(parsed.elicitation.is_some());
124+
}
125+
126+
#[test]
127+
fn url_elicitation_required_error_helper_is_available() {
128+
let data = UrlElicitationRequiredData::new(vec![UrlElicitationRequiredItem::new(
129+
"elicit_1",
130+
"https://example.com/connect",
131+
"Connect your account",
132+
)]);
133+
let error = Error::url_elicitation_required().data(json_value(data).unwrap());
134+
135+
assert_eq!(error.code, ErrorCode::UrlElicitationRequired);
136+
assert_eq!(
137+
error.data.unwrap(),
138+
json!({
139+
"elicitations": [{
140+
"mode": "url",
141+
"elicitationId": "elicit_1",
142+
"url": "https://example.com/connect",
143+
"message": "Connect your account"
144+
}]
145+
})
146+
);
147+
}
148+
149+
#[cfg(feature = "unstable_protocol_v2")]
150+
#[test]
151+
fn protocol_v2_elicitation_variants_are_jsonrpc_mapped() -> Result<(), Error> {
152+
use agent_client_protocol::schema::v2;
153+
154+
let request = v2::CreateElicitationRequest::new(
155+
v2::ElicitationFormMode::new(
156+
v2::ElicitationSessionScope::new("sess_abc123"),
157+
v2::ElicitationSchema::new().string("name", true),
158+
),
159+
"Please enter your name",
160+
);
161+
162+
let parsed_request =
163+
v2::AgentRequest::parse_message("elicitation/create", &json_value(request.clone())?)?;
164+
assert!(matches!(
165+
parsed_request,
166+
v2::AgentRequest::CreateElicitationRequest(_)
167+
));
168+
169+
let parsed_response =
170+
v2::ClientResponse::from_value("elicitation/create", json!({ "action": "decline" }))?;
171+
assert!(matches!(
172+
parsed_response,
173+
v2::ClientResponse::CreateElicitationResponse(_)
174+
));
175+
176+
let notification = v2::CompleteElicitationNotification::new("elicit_1");
177+
let parsed_notification =
178+
v2::AgentNotification::parse_message("elicitation/complete", &json_value(notification)?)?;
179+
assert!(matches!(
180+
parsed_notification,
181+
v2::AgentNotification::CompleteElicitationNotification(_)
182+
));
183+
184+
Ok(())
185+
}
186+
187+
#[cfg(feature = "unstable_protocol_v2")]
188+
#[tokio::test(flavor = "current_thread")]
189+
async fn v2_agent_can_elicit_from_v1_client() -> Result<(), Error> {
190+
use agent_client_protocol::schema::{self, ProtocolVersion, v2};
191+
use agent_client_protocol::{Agent, Client};
192+
use std::collections::BTreeMap;
193+
194+
let agent = Agent
195+
.v2()
196+
.on_receive_request(
197+
async |initialize: v2::InitializeRequest, responder, _cx| {
198+
assert_eq!(initialize.protocol_version, ProtocolVersion::V2);
199+
responder.respond(v2::InitializeResponse::new(ProtocolVersion::V2))
200+
},
201+
agent_client_protocol::on_receive_request!(),
202+
)
203+
.on_receive_request(
204+
async |_prompt: v2::PromptRequest, responder, cx| {
205+
let request = v2::CreateElicitationRequest::new(
206+
v2::ElicitationFormMode::new(
207+
v2::ElicitationSessionScope::new("sess_abc123"),
208+
v2::ElicitationSchema::new().string("name", true),
209+
),
210+
"Please enter your name",
211+
);
212+
213+
cx.send_request(request)
214+
.on_receiving_result(async move |result| {
215+
let response = result?;
216+
let v2::ElicitationAction::Accept(action) = response.action else {
217+
return Err(Error::invalid_params().data("expected accept action"));
218+
};
219+
let content = action.content.ok_or_else(|| {
220+
Error::invalid_params().data("expected response content")
221+
})?;
222+
assert_eq!(
223+
content.get("name"),
224+
Some(&v2::ElicitationContentValue::String("Ada".into()))
225+
);
226+
responder.respond(v2::PromptResponse::new(v2::StopReason::EndTurn))
227+
})?;
228+
229+
Ok(())
230+
},
231+
agent_client_protocol::on_receive_request!(),
232+
);
233+
234+
Client
235+
.builder()
236+
.on_receive_request(
237+
async |request: CreateElicitationRequest, responder, _cx| {
238+
assert_eq!(request.method(), "elicitation/create");
239+
assert!(matches!(
240+
request.mode,
241+
schema::ElicitationMode::Form(schema::ElicitationFormMode { .. })
242+
));
243+
244+
let content = BTreeMap::from([("name".to_string(), "Ada".into())]);
245+
responder.respond(CreateElicitationResponse::new(ElicitationAction::Accept(
246+
schema::ElicitationAcceptAction::new().content(content),
247+
)))
248+
},
249+
agent_client_protocol::on_receive_request!(),
250+
)
251+
.connect_with(agent, async |cx| {
252+
let initialize = cx
253+
.send_request(schema::InitializeRequest::new(ProtocolVersion::V1))
254+
.block_task()
255+
.await?;
256+
assert_eq!(initialize.protocol_version, ProtocolVersion::V1);
257+
258+
let prompt = cx
259+
.send_request(schema::PromptRequest::new(
260+
"sess_abc123",
261+
vec!["continue".into()],
262+
))
263+
.block_task()
264+
.await?;
265+
assert_eq!(prompt.stop_reason, schema::StopReason::EndTurn);
266+
Ok(())
267+
})
268+
.await
269+
}

0 commit comments

Comments
 (0)